Compare commits

...

27 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
7415ea786e fix: pass dateFormat to DateHelper.tryParse when parsing modified date field
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2026-03-14 10:33:03 +00:00
copilot-swe-agent[bot]
2ca2993786 Initial plan 2026-03-14 10:30:18 +00:00
Elio Struyf
fb95439452 fix: improve error handling for file reading and template loading in ArticleHelper and ContentType #950 2026-03-14 11:26:53 +01:00
Elio Struyf
b6ac0ea1e6 refactor: improve handling of field values and remove unused variables in PagesParser #958 2026-03-14 11:22:32 +01:00
Elio Struyf
09f97b9c8c Merge branch 'copilot/enhance-schema-validation-yaml' into beta 2026-03-14 11:13:50 +01:00
Elio Struyf
70c17d5de3 Enhance schema validation for YAML front matter and improve error range detection 2026-03-14 11:12:59 +01:00
Elio Struyf
1f52b02bf7 Refactor code formatting and improve schema validation logic in StatusListener 2026-03-14 11:07:47 +01:00
Elio Struyf
7d2ecc53af Add front matter validation setting and update schema validation logic 2026-03-14 11:07:36 +01:00
Elio Struyf
66d21cc255 fix: update changelog to correctly reflect output channel colorizer schema fix 2026-03-14 10:53:37 +01:00
Elio Struyf
13a80b33e3 fix: update output channel creation to include specific ID for frontmatter project #1006 2026-03-14 10:50:32 +01:00
Elio Struyf
7847464899 refactor: improve StructureView layout and enhance folder selection logic in PagesListener 2026-03-06 17:32:46 +01:00
Elio Struyf
7db95ca091 refactor: remove chatbot functionality and related localization keys 2026-03-06 16:56:27 +01:00
Elio Struyf
5c0076b9b2 refactor: clean up whitespace and formatting in multiple components 2026-03-06 16:51:47 +01:00
Elio Struyf
7ea0fbad05 feat: add move file functionality and create content in folder dialog 2026-03-06 16:51:41 +01:00
Elio Struyf
2e8472dd75 Merge branch 'beta' of github.com:estruyf/vscode-front-matter into beta 2026-03-06 16:51:14 +01:00
Elio Struyf
0842133db4 Enhancement: Support the new integrated browser instead of the lite browser
Fixes #1005
2026-03-06 16:50:02 +01:00
Elio Struyf
e25cb9796a Remove AI sponsor feature and related settings; update changelog and localization files #983 2026-03-06 16:35:46 +01:00
Elio Struyf
648541d9a5 Merge pull request #992 from estruyf/copilot/fix-timezone-issue
Fix timezone setting ignored when formatting dates
2026-03-06 16:27:14 +01:00
Elio Struyf
57e0e2e7b7 Refactor MediaLibrary and MediaListener to use static parsePath method for improved consistency 2026-03-06 16:19:42 +01:00
copilot-swe-agent[bot]
5de91cf683 Add validation for tags, categories, and custom taxonomy against known values
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2025-12-04 11:02:40 +00:00
copilot-swe-agent[bot]
2b7fd1d1e7 Improve field location detection and add comprehensive documentation
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2025-12-03 15:12:58 +00:00
copilot-swe-agent[bot]
c179364f2b Address code review feedback and improve validation
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2025-12-03 15:07:43 +00:00
copilot-swe-agent[bot]
4bee998d9b Add schema generation and validation infrastructure
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2025-12-03 15:02:49 +00:00
copilot-swe-agent[bot]
17f390545a Fix timezone issue by providing default 'UTC' value
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2025-12-03 15:02:31 +00:00
copilot-swe-agent[bot]
de569d37d5 Initial plan 2025-12-03 14:54:14 +00:00
copilot-swe-agent[bot]
d59969cbe1 Initial plan 2025-12-03 14:53:46 +00:00
Elio Struyf
f5b636d960 Enhance Copilot extension detection and update model family to gpt-4.1 2025-12-01 09:38:49 +01:00
41 changed files with 1724 additions and 414 deletions

View File

@@ -2,11 +2,24 @@
## [10.10.0] - 2025-xx-xx
- Removed the chatbot command and all related code and references
- [#983](https://github.com/estruyf/vscode-front-matter/issues/983): Removal of the `frontMatter.sponsors.ai.enabled` features
### 🎨 Enhancements
- [#937](https://github.com/estruyf/vscode-front-matter/issues/937): Dashboard "Structure" view for documentation sites
- [#937](https://github.com/estruyf/vscode-front-matter/issues/937): Dashboard "Structure" view for documentation sites *WIP*
- [#965](https://github.com/estruyf/vscode-front-matter/issues/965): Added SEO support for the keyword in the first paragraph
- [#973](https://github.com/estruyf/vscode-front-matter/issues/973): Support for number fields in the snippets
- [#990](https://github.com/estruyf/vscode-front-matter/issues/990): Schema and validation for front matter in markdown files. It can be turned off by the `frontMatter.validation.enabled` setting.
- [#1005](https://github.com/estruyf/vscode-front-matter/issues/1005): Support the integrated VSCode browser for the preview command
### 🐞 Fixes
- [#950](https://github.com/estruyf/vscode-front-matter/issues/950): Fix for template is not applied to new content type when created
- [#958](https://github.com/estruyf/vscode-front-matter/issues/958): Fix variable frontmatter leads to error
- [#984](https://github.com/estruyf/vscode-front-matter/issues/984): Fix in `frontMatter.global.timezone` is invalid
- [#1004](https://github.com/estruyf/vscode-front-matter/issues/1004): Fix for `mediaDB.json` containing full paths on Windows instead of relative paths
- [#1006](https://github.com/estruyf/vscode-front-matter/issues/1006): Fix output channel colorizer schema to only apply to the Front Matter output channel
### 🐞 Fixes

1
package-lock.json generated
View File

@@ -18041,6 +18041,7 @@
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
"integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@discoveryjs/json-ext": "0.5.7",
"acorn": "^8.0.4",

View File

@@ -139,12 +139,6 @@
}
}
},
"frontMatter.sponsors.ai.enabled": {
"type": "boolean",
"default": false,
"markdownDescription": "%setting.frontMatter.sponsors.ai.enabled.markdownDescription%",
"scope": "Sponsors"
},
"frontMatter.extensibility.scripts": {
"type": "array",
"markdownDescription": "%setting.frontMatter.extensibility.scripts.markdownDescription%",
@@ -2110,6 +2104,12 @@
"markdownDescription": "%setting.frontMatter.templates.prefix.markdownDescription%",
"scope": "Templates"
},
"frontMatter.validation.enabled": {
"type": "boolean",
"default": true,
"markdownDescription": "%setting.frontMatter.validation.enabled.markdownDescription%",
"scope": "Validation"
},
"frontMatter.website.host": {
"type": "string",
"markdownDescription": "%setting.frontMatter.website.host.markdownDescription%"
@@ -2391,15 +2391,6 @@
"category": "Front Matter",
"icon": "$(book)"
},
{
"command": "frontMatter.chatbot",
"title": "%command.frontMatter.chatbot%",
"category": "Front Matter",
"icon": {
"light": "assets/icons/chatbot-light.svg",
"dark": "assets/icons/chatbot-dark.svg"
}
},
{
"command": "frontMatter.promoteSettings",
"title": "%command.frontMatter.promoteSettings%",
@@ -2566,11 +2557,6 @@
"command": "frontMatter.dashboard.close",
"group": "navigation@-98",
"when": "frontMatter:enabled == true && frontMatter:dashboard:open == true"
},
{
"command": "frontMatter.chatbot",
"group": "navigation@-97",
"when": "resourceFilename == 'frontmatter.json'"
}
],
"explorer/context": [
@@ -2780,11 +2766,6 @@
"group": "navigation@-1",
"when": "view == frontMatter.explorer"
},
{
"command": "frontMatter.chatbot",
"group": "navigation@0",
"when": "view == frontMatter.explorer"
},
{
"command": "frontMatter.mode.switch",
"group": "navigation@1",
@@ -2814,10 +2795,7 @@
},
"languages": [
{
"id": "frontmatter.project.output",
"mimetypes": [
"text/x-code-output"
]
"id": "frontmatter.project.output"
}
],
"grammars": [

View File

@@ -52,7 +52,6 @@
"setting.frontMatter.projects.markdownDescription": "Geben Sie die Liste der Projekte an, die in Front Matter CMS geladen werden sollen. [Dokumentation prüfen](https://frontmatter.codes/docs/settings/overview#frontmatter.projects)",
"setting.frontMatter.projects.items.properties.name.markdownDescription": "Geben Sie den Namen des Projekts an.",
"setting.frontMatter.projects.items.properties.default.markdownDescription": "Geben Sie an, ob dieses Projekt das Standardprojekt zum Laden ist.",
"setting.frontMatter.sponsors.ai.enabled.markdownDescription": "Geben Sie an, ob Sie KI-Vorschläge aktivieren möchten. [Dokumentation prüfen](https://frontmatter.codes/docs/settings/overview#frontmatter.sponsors.ai.enabled)",
"setting.frontMatter.extensibility.scripts.markdownDescription": "Geben Sie die Liste der Skripte an, die in Front Matter CMS geladen werden sollen. [Dokumentation prüfen](https://frontmatter.codes/docs/settings/overview#frontmatter.extensibility.scripts)",
"setting.frontMatter.experimental.markdownDescription": "Geben Sie an, ob Sie experimentelle Funktionen aktivieren möchten. [Dokumentation prüfen](https://frontmatter.codes/docs/settings/overview#frontmatter.experimental)",
"setting.frontMatter.extends.markdownDescription": "Geben Sie die Liste der Pfade/URLs an, um die Front Matter CMS-Konfiguration zu erweitern. [Dokumentation prüfen](https://frontmatter.codes/docs/settings/overview#frontmatter.extends)",

View File

@@ -53,7 +53,6 @@
"setting.frontMatter.projects.markdownDescription": "Front Matter CMSを利用するプロジェクトを設定します。[ドキュメントを確認](https://frontmatter.codes/docs/settings/overview#frontmatter.projects)",
"setting.frontMatter.projects.items.properties.name.markdownDescription": "プロジェクトの名前を指定します。",
"setting.frontMatter.projects.items.properties.default.markdownDescription": "このプロジェクトを読み込む既定のプロジェクトにするかどうかを指定します。",
"setting.frontMatter.sponsors.ai.enabled.markdownDescription": "AIによる提案を利用します。[ドキュメントを確認](https://frontmatter.codes/docs/settings/overview#frontmatter.sponsors.ai.enabled)",
"setting.frontMatter.extensibility.scripts.markdownDescription": "Front Matter CMSで読み込むスクリプトのリストを指定します。[ドキュメントを確認](https://frontmatter.codes/docs/settings/overview#frontmatter.extensibility.scripts)",
"setting.frontMatter.experimental.markdownDescription": "実験的な機能をオンにします。[ドキュメントを確認](https://frontmatter.codes/docs/settings/overview#frontmatter.experimental)",
"setting.frontMatter.extends.markdownDescription": "Front Matter CMSの構成を拡張するパス/URLのリストを設定します。[ドキュメントを確認](https://frontmatter.codes/docs/settings/overview#frontmatter.extends)",

View File

@@ -55,7 +55,6 @@
"setting.frontMatter.projects.markdownDescription": "Specify the list of projects to load in the Front Matter CMS. [Local](https://file%2B.vscode-resource.vscode-cdn.net/Users/eliostruyf/nodejs/frontmatter-test-projects/astro-blog/test.html) - [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.projects) - [View in VS Code](vscode://simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.projects%22%5D)",
"setting.frontMatter.projects.items.properties.name.markdownDescription": "Specify the name of the project.",
"setting.frontMatter.projects.items.properties.default.markdownDescription": "Specify if this project is the default project to load.",
"setting.frontMatter.sponsors.ai.enabled.markdownDescription": "Specify if you want to enable AI suggestions. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.sponsors.ai.enabled) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.sponsors.ai.enabled%22%5D)",
"setting.frontMatter.extensibility.scripts.markdownDescription": "Specify the list of scripts to load in the Front Matter CMS. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.extensibility.scripts) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.extensibility.scripts%22%5D)",
"setting.frontMatter.experimental.markdownDescription": "Specify if you want to enable the experimental features. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.experimental) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.experimental%22%5D)",
"setting.frontMatter.extends.markdownDescription": "Specify the list of paths/URLs to extend the Front Matter CMS config. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.extends) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.extends%22%5D)",
@@ -277,6 +276,7 @@
"setting.frontMatter.taxonomy.tags.markdownDescription": "Specifies the tags which can be used in the Front Matter. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.taxonomy.tags) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.taxonomy.tags%22%5D)",
"setting.frontMatter.telemetry.disable.markdownDescription": "Specify if you want to disable the telemetry. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.telemetry.disable) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.telemetry.disable%22%5D)",
"setting.frontMatter.templates.enabled.markdownDescription": "Specify if you want to use templates. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.templates.enabled) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.templates.enabled%22%5D)",
"setting.frontMatter.validation.enabled.markdownDescription": "Specify if you want to enable front matter validation. When enabled, the extension will validate your front matter against the content type schema. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.validation.enabled) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.validation.enabled%22%5D)",
"setting.frontMatter.templates.folder.markdownDescription": "Specify the folder to use for your article templates. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.templates.folder) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.templates.folder%22%5D)",
"setting.frontMatter.templates.prefix.markdownDescription": "Specify the prefix you want to add for your new article filenames. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.templates.prefix) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.templates.prefix%22%5D)",
"setting.frontMatter.dashboard.mediaSnippet.deprecationMessage": "This setting is deprecated and will be removed in the next major version. Please define your media snippet in the `frontMatter.content.snippet` setting.",

View File

@@ -55,7 +55,6 @@
"setting.frontMatter.projects.markdownDescription": "指定要在 Front Matter CMS 中加载的项目列表。[本地](https://file%2B.vscode-resource.vscode-cdn.net/Users/eliostruyf/nodejs/frontmatter-test-projects/astro-blog/test.html) - [文档](https://frontmatter.codes/docs/settings/overview#frontmatter.projects) - [在 VS Code 中查看](vscode://simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.projects%22%5D)",
"setting.frontMatter.projects.items.properties.name.markdownDescription": "指定项目名称。",
"setting.frontMatter.projects.items.properties.default.markdownDescription": "指定此项目是否为默认加载项目。",
"setting.frontMatter.sponsors.ai.enabled.markdownDescription": "指定是否启用 AI 建议。[文档](https://frontmatter.codes/docs/settings/overview#frontmatter.sponsors.ai.enabled) - [在 VS Code 中查看](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.sponsors.ai.enabled%22%5D)",
"setting.frontMatter.extensibility.scripts.markdownDescription": "指定要在 Front Matter CMS 中加载的脚本列表。[文档](https://frontmatter.codes/docs/settings/overview#frontmatter.extensibility.scripts) - [在 VS Code 中查看](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.extensibility.scripts%22%5D)",
"setting.frontMatter.experimental.markdownDescription": "指定是否启用实验性功能。[文档](https://frontmatter.codes/docs/settings/overview#frontmatter.experimental) - [在 VS Code 中查看](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.experimental%22%5D)",
"setting.frontMatter.extends.markdownDescription": "指定扩展 Front Matter CMS 配置的路径/URL 列表。[文档](https://frontmatter.codes/docs/settings/overview#frontmatter.extends) - [在 VS Code 中查看](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.extends%22%5D)",

View File

@@ -1,126 +0,0 @@
import { PreviewCommands, GeneralCommands } from './../constants';
import { join } from 'path';
import { commands, Uri, ViewColumn, window } from 'vscode';
import { Extension } from '../helpers';
import { WebviewHelper } from '@estruyf/vscode';
import { getLocalizationFile } from '../utils/getLocalizationFile';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../localization';
import { getWebviewJsFiles } from '../utils';
export class Chatbot {
/**
* Open the Chatbot in the editor
*/
public static async open(extensionPath: string) {
// Create the preview webview
const webView = window.createWebviewPanel(
'frontMatterChatbot',
`Front Matter AI - ${l10n.t(LocalizationKey.commandsChatbotTitle)}`,
{
viewColumn: ViewColumn.Beside,
preserveFocus: true
},
{
enableScripts: true
}
);
webView.iconPath = {
dark: Uri.file(join(extensionPath, 'assets/icons/frontmatter-short-dark.svg')),
light: Uri.file(join(extensionPath, 'assets/icons/frontmatter-short-light.svg'))
};
const cspSource = webView.webview.cspSource;
const fetchLocalization = async (requestId: string) => {
if (!requestId) {
return;
}
const fileContents = await getLocalizationFile();
webView.webview.postMessage({
command: GeneralCommands.toVSCode.getLocalization,
requestId,
payload: fileContents
});
};
webView.webview.onDidReceiveMessage(async (message) => {
const { command, requestId, payload, data } = message;
switch (command) {
case PreviewCommands.toVSCode.open:
if (payload || data) {
commands.executeCommand('vscode.open', payload || data);
}
break;
case GeneralCommands.toVSCode.getLocalization:
fetchLocalization(requestId);
return;
}
});
const webviewFile = 'dashboard.main.js';
const localPort = `9000`;
const localServerUrl = `localhost:${localPort}`;
const nonce = WebviewHelper.getNonce();
const ext = Extension.getInstance();
const isProd = ext.isProductionMode;
const version = ext.getVersion();
const isBeta = ext.isBetaVersion();
const csp = [
`default-src 'none';`,
`img-src ${cspSource} http: https:;`,
`script-src ${
isProd ? `'nonce-${nonce}'` : `http://${localServerUrl} http://0.0.0.0:${localPort}`
} 'unsafe-eval'`,
`style-src ${cspSource} 'self' 'unsafe-inline' http: https:`,
`connect-src https://* ${
isProd
? ``
: `ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`
}`
];
let scriptUris = [];
if (isProd) {
scriptUris = await getWebviewJsFiles('dashboard', webView.webview);
} else {
scriptUris.push(`http://${localServerUrl}/${webviewFile}`);
}
// By default, the chatbot is seen as experimental
const experimental = true;
webView.webview.html = `
<!DOCTYPE html>
<html lang="en" style="width:100%;height:100%;margin:0;padding:0;">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="${csp.join('; ')}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Front Matter Docs Chatbot</title>
</head>
<body style="width:100%;height:100%;margin:0;padding:0;overflow:hidden">
<div id="app" data-type="chatbot" data-isProd="${isProd}" data-environment="${
isBeta ? 'BETA' : 'main'
}" data-version="${version.usedVersion}" ${
experimental ? `data-experimental="${experimental}"` : ''
} style="width:100%;height:100%;margin:0;padding:0;"></div>
${scriptUris
.map((uri) => `<script ${isProd ? `nonce="${nonce}"` : ''} src="${uri}"></script>`)
.join('\n')}
<img style="display:none" src="https://api.visitorbadge.io/api/combined?user=estruyf&repo=frontmatter-usage&countColor=%23263759&slug=${`chatbot-${version.installedVersion}`}" alt="Daily usage" />
</body>
</html>
`;
}
}

View File

@@ -54,6 +54,7 @@ export class Preview {
return;
}
const integratedBrowserCommand = await this.getIntegratedBrowserCommand();
const browserLiteCommand = await this.getBrowserLiteCommand();
const editor = window.activeTextEditor;
@@ -69,6 +70,12 @@ export class Preview {
const slug = await this.getContentSlug(article, editor?.document.uri.fsPath);
const localhostUrl = await this.getLocalServerUrl();
if (integratedBrowserCommand) {
const pageUrl = joinUrl(localhostUrl.toString(), slug || '');
commands.executeCommand(integratedBrowserCommand, pageUrl);
return;
}
if (browserLiteCommand) {
const pageUrl = joinUrl(localhostUrl.toString(), slug || '');
commands.executeCommand(browserLiteCommand, pageUrl);
@@ -368,6 +375,17 @@ export class Preview {
return undefined;
}
/**
* Check if Browser Lite is installed
*/
private static async getIntegratedBrowserCommand() {
const allCommands = await commands.getCommands(true);
if (allCommands.includes(`workbench.action.browser.open`)) {
return `workbench.action.browser.open`;
}
return undefined;
}
/**
* Retrieve the localhost url
* @returns

View File

@@ -4,22 +4,39 @@ import {
EXTENSION_NAME,
NOTIFICATION_TYPE,
SETTING_SEO_DESCRIPTION_LENGTH,
SETTING_SEO_TITLE_LENGTH
SETTING_SEO_TITLE_LENGTH,
SETTING_VALIDATION_ENABLED
} from './../constants';
import * as vscode from 'vscode';
import { ArticleHelper, Notifications, SeoHelper, Settings } from '../helpers';
import {
ArticleHelper,
Notifications,
SeoHelper,
Settings,
FrontMatterValidator,
ValidationError
} from '../helpers';
import { PanelProvider } from '../panelWebView/PanelProvider';
import { ContentType } from '../helpers/ContentType';
import { DataListener } from '../listeners/panel';
import { commands } from 'vscode';
import { Field } from '../models';
import { FrontMatterParser } from '../parsers';
import { Preview } from './Preview';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../localization';
import { i18n } from './i18n';
import { getDescriptionField, getTitleField } from '../utils';
import * as yaml from 'yaml';
export class StatusListener {
private static _validator: FrontMatterValidator | undefined;
private static get validator(): FrontMatterValidator {
if (!StatusListener._validator) {
StatusListener._validator = new FrontMatterValidator();
}
return StatusListener._validator;
}
/**
* Update the text of the status bar
*
@@ -70,6 +87,12 @@ export class StatusListener {
// Check the required fields
if (editor) {
StatusListener.verifyRequiredFields(editor, article, collection);
// Schema validation
const validationEnabled = Settings.get<boolean>(SETTING_VALIDATION_ENABLED, true);
if (validationEnabled) {
await StatusListener.verifySchemaValidation(editor, article, collection);
}
}
}
@@ -173,6 +196,245 @@ export class StatusListener {
}
}
/**
* Verify schema validation
* @param editor Text editor
* @param article Parsed front matter
* @param collection Diagnostic collection
*/
private static async verifySchemaValidation(
editor: vscode.TextEditor,
article: ParsedFrontMatter,
collection: vscode.DiagnosticCollection
) {
try {
const contentType = await ArticleHelper.getContentType(article);
if (!contentType || !contentType.fields || contentType.fields.length === 0) {
return;
}
// Validate against schema
const errors = await StatusListener.validator.validate(article.data, contentType);
if (errors.length === 0) {
return;
}
const text = editor.document.getText();
const schemaDiagnostics: vscode.Diagnostic[] = [];
for (const error of errors) {
const range = StatusListener.findSchemaErrorRange(editor.document, text, error);
if (range) {
const diagnostic: vscode.Diagnostic = {
code: '',
message: error.message,
range,
severity: vscode.DiagnosticSeverity.Warning,
source: EXTENSION_NAME
};
schemaDiagnostics.push(diagnostic);
}
}
if (schemaDiagnostics.length > 0) {
if (collection.has(editor.document.uri)) {
const otherDiag = collection.get(editor.document.uri) || [];
collection.set(editor.document.uri, [...otherDiag, ...schemaDiagnostics]);
} else {
collection.set(editor.document.uri, [...schemaDiagnostics]);
}
}
} catch (error) {
// Silently fail validation errors to not disrupt the user experience
// Logger can be used here if needed for debugging
}
}
private static findSchemaErrorRange(
document: vscode.TextDocument,
text: string,
error: ValidationError
): vscode.Range | undefined {
const language = FrontMatterParser.getLanguageFromContent(text);
if (language === 'yaml') {
const yamlRange = StatusListener.findYamlSchemaErrorRange(document, text, error);
if (yamlRange) {
return yamlRange;
}
}
return StatusListener.findTextSchemaErrorRange(document, text, error);
}
private static findYamlSchemaErrorRange(
document: vscode.TextDocument,
text: string,
error: ValidationError
): vscode.Range | undefined {
const frontMatter = StatusListener.getYamlFrontMatter(text);
if (!frontMatter) {
return undefined;
}
const path = StatusListener.getValidationPath(error);
if (path.length === 0) {
return undefined;
}
const doc = yaml.parseDocument(frontMatter.content);
const node = doc.getIn(path, true) as { range?: [number, number, number] } | null;
if (!node?.range || node.range.length < 2) {
return undefined;
}
const normalizedRange = StatusListener.normalizeYamlNodeRange(frontMatter.content, node.range);
if (!normalizedRange) {
return undefined;
}
return new vscode.Range(
document.positionAt(frontMatter.startOffset + normalizedRange.start),
document.positionAt(frontMatter.startOffset + normalizedRange.end)
);
}
private static findTextSchemaErrorRange(
document: vscode.TextDocument,
text: string,
error: ValidationError
): vscode.Range | undefined {
const path = StatusListener.getValidationPath(error);
const fieldName = path.length > 0 ? String(path[path.length - 1]) : '';
const arrayIndex =
typeof path[path.length - 1] === 'number' ? (path[path.length - 1] as number) : undefined;
const searchFieldName =
arrayIndex !== undefined ? String(path[path.length - 2] || '') : fieldName;
if (!searchFieldName || searchFieldName === 'root') {
return undefined;
}
const frontMatterMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontMatterEnd = frontMatterMatch ? frontMatterMatch[0].length : text.length;
const searchText = text.substring(0, frontMatterEnd);
const fieldIdx = searchText.indexOf(`${searchFieldName}:`);
if (fieldIdx === -1) {
return undefined;
}
let posStart = document.positionAt(fieldIdx);
let posEnd = document.positionAt(fieldIdx + searchFieldName.length);
if (arrayIndex !== undefined) {
const afterField = text.indexOf('\n', fieldIdx) + 1;
let remaining = arrayIndex;
let searchFrom = afterField;
while (searchFrom < frontMatterEnd) {
const lineEnd = text.indexOf('\n', searchFrom);
const line = text.substring(searchFrom, lineEnd === -1 ? frontMatterEnd : lineEnd);
if (/^\s*-\s/.test(line)) {
if (remaining === 0) {
const valueOffset = line.indexOf('- ') + 2;
const rawItemValue = line.substring(valueOffset).trim();
const isQuoted =
rawItemValue.length > 1 &&
((rawItemValue.startsWith('"') && rawItemValue.endsWith('"')) ||
(rawItemValue.startsWith("'") && rawItemValue.endsWith("'")));
const itemValue = isQuoted ? rawItemValue.slice(1, -1) : rawItemValue;
const valueStartOffset = searchFrom + valueOffset + (isQuoted ? 1 : 0);
posStart = document.positionAt(valueStartOffset);
posEnd = document.positionAt(valueStartOffset + itemValue.length);
break;
}
remaining--;
} else if (line.trim() && !/^\s/.test(line)) {
break;
}
searchFrom = (lineEnd === -1 ? frontMatterEnd : lineEnd) + 1;
}
}
return new vscode.Range(posStart, posEnd);
}
private static getValidationPath(error: ValidationError): Array<string | number> {
const path =
error.field && error.field !== 'root'
? error.field
.split('.')
.filter(Boolean)
.map((segment) => (/^\d+$/.test(segment) ? parseInt(segment, 10) : segment))
: [];
if (error.keyword === 'required' && typeof error.params?.missingProperty === 'string') {
return [...path, error.params.missingProperty];
}
if (
error.keyword === 'additionalProperties' &&
typeof error.params?.additionalProperty === 'string'
) {
return [...path, error.params.additionalProperty];
}
return path;
}
private static getYamlFrontMatter(
text: string
): { content: string; startOffset: number } | undefined {
const openMatch = text.match(/^---\r?\n/);
if (!openMatch) {
return undefined;
}
const startOffset = openMatch[0].length;
const closeMatch = /\r?\n---/.exec(text.slice(startOffset));
const endOffset = closeMatch ? startOffset + closeMatch.index : text.length;
return {
content: text.slice(startOffset, endOffset),
startOffset
};
}
private static normalizeYamlNodeRange(
source: string,
range: [number, number, number]
): { start: number; end: number } | undefined {
let start = range[0];
let end = range[1];
if (start >= end) {
return undefined;
}
let value = source.slice(start, end);
const leadingWhitespace = value.match(/^\s*/)?.[0].length || 0;
const trailingWhitespace = value.match(/\s*$/)?.[0].length || 0;
start += leadingWhitespace;
end -= trailingWhitespace;
value = source.slice(start, end);
if (
value.length > 1 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
start += 1;
end -= 1;
}
return start < end ? { start, end } : undefined;
}
/**
* Find the line of the field
* @param text

View File

@@ -1,7 +1,6 @@
export * from './Article';
export * from './Backers';
export * from './Cache';
export * from './Chatbot';
export * from './Content';
export * from './Dashboard';
export * from './Diagnostics';

View File

@@ -28,7 +28,6 @@ export const COMMAND_NAME = {
collapseSections: getCommandName('collapseSections'),
preview: getCommandName('preview'),
docs: getCommandName('docs'),
chatbot: getCommandName('chatbot'),
dashboard: getCommandName('dashboard'),
dashboardMedia: getCommandName('dashboard.media'),
dashboardSnippets: getCommandName('dashboard.snippets'),

View File

@@ -120,10 +120,7 @@ export const SETTING_COPILOT_FAMILY = 'copilot.family';
export const SETTING_LOGGING = 'logging';
/**
* Sponsors only settings
*/
export const SETTING_SPONSORS_AI_ENABLED = 'sponsors.ai.enabled';
export const SETTING_VALIDATION_ENABLED = 'validation.enabled';
/**
* Project override support

View File

@@ -23,6 +23,7 @@ export enum DashboardMessage {
createContent = 'createContent',
createByContentType = 'createByContentType',
createByTemplate = 'createByTemplate',
createContentInFolder = 'createContentInFolder',
refreshPages = 'refreshPages',
searchPages = 'searchPages',
openFile = 'openFile',
@@ -31,6 +32,7 @@ export enum DashboardMessage {
pinItem = 'pinItem',
unpinItem = 'unpinItem',
rename = 'rename',
moveFile = 'moveFile',
// Media Dashboard
getMedia = 'getMedia',

View File

@@ -1,5 +1,12 @@
import { messageHandler } from '@estruyf/vscode/dist/client';
import { EyeIcon, GlobeEuropeAfricaIcon, TrashIcon, LanguageIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline';
import {
EyeIcon,
GlobeEuropeAfricaIcon,
TrashIcon,
LanguageIcon,
EllipsisHorizontalIcon,
ArrowRightCircleIcon
} from '@heroicons/react/24/outline';
import * as React from 'react';
import { CustomScript, I18nConfig } from '../../../models';
import { DashboardMessage } from '../../DashboardMessage';
@@ -58,6 +65,11 @@ export const ContentActions: React.FunctionComponent<IContentActionsProps> = ({
setSelectedItemAction({ path, action: 'delete' });
}, [path]);
const onMove = React.useCallback((e: React.MouseEvent<HTMLButtonElement | HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
setSelectedItemAction({ path, action: 'move' });
}, [path]);
const onRename = React.useCallback((e: React.MouseEvent<HTMLButtonElement | HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
messageHandler.send(DashboardMessage.rename, path);
@@ -122,6 +134,11 @@ export const ContentActions: React.FunctionComponent<IContentActionsProps> = ({
<span>{l10n.t(LocalizationKey.dashboardContentsContentActionsMenuItemView)}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onMove}>
<ArrowRightCircleIcon className={`mr-2 h-4 w-4`} aria-hidden={true} />
<span>Move to folder</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onRename}>
<RenameIcon className={`mr-2 h-4 w-4`} aria-hidden={true} />
<span>{l10n.t(LocalizationKey.commonRename)}</span>

View File

@@ -14,6 +14,7 @@ import { GeneralCommands } from '../../../constants';
import { PageLayout } from '../Layout/PageLayout';
import { FilesProvider } from '../../providers/FilesProvider';
import { Alert } from '../Modals/Alert';
import { MoveFileDialog } from '../Modals/MoveFileDialog';
import { LocalizationKey } from '../../../localization';
import { deletePage } from '../../utils';
@@ -28,12 +29,14 @@ export const Contents: React.FunctionComponent<IContentsProps> = ({
const settings = useRecoilValue(SettingsSelector);
const { pageItems } = usePages(pages);
const [showDeletionAlert, setShowDeletionAlert] = React.useState(false);
const [showMoveDialog, setShowMoveDialog] = React.useState(false);
const [page, setPage] = useState<Page | undefined>(undefined);
const [selectedItemAction, setSelectedItemAction] = useRecoilState(SelectedItemActionAtom);
const pageFolders = [...new Set(pageItems.map((page) => page.fmFolder))];
const onDismiss = useCallback(() => {
setShowMoveDialog(false);
setShowDeletionAlert(false);
setSelectedItemAction(undefined);
}, []);
@@ -46,13 +49,29 @@ export const Contents: React.FunctionComponent<IContentsProps> = ({
setSelectedItemAction(undefined);
}, [page]);
useEffect(() => {
if (selectedItemAction && selectedItemAction.path && selectedItemAction.action === 'delete') {
const page = pageItems.find((p) => p.fmFilePath === selectedItemAction.path);
const onMoveConfirm = useCallback((destinationFolder: string) => {
if (page) {
Messenger.send(DashboardMessage.moveFile, {
filePath: page.fmFilePath,
destinationFolder
});
}
setShowMoveDialog(false);
setSelectedItemAction(undefined);
}, [page]);
if (page) {
setPage(page);
setShowDeletionAlert(true);
useEffect(() => {
if (selectedItemAction && selectedItemAction.path) {
const pageItem = pageItems.find((p) => p.fmFilePath === selectedItemAction.path);
if (pageItem) {
setPage(pageItem);
if (selectedItemAction.action === 'delete') {
setShowDeletionAlert(true);
} else if (selectedItemAction.action === 'move') {
setShowMoveDialog(true);
}
}
setSelectedItemAction(undefined);
@@ -85,6 +104,15 @@ export const Contents: React.FunctionComponent<IContentsProps> = ({
<img className='hidden' src="https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Ffrontmatter.codes%2Fmetrics%2Fdashboards&slug=content" alt="Content metrics" />
{showMoveDialog && page && (
<MoveFileDialog
page={page}
availableFolders={pageFolders}
dismiss={onDismiss}
trigger={onMoveConfirm}
/>
)}
{showDeletionAlert && page && (
<Alert
title={l10n.t(LocalizationKey.dashboardContentsContentActionsAlertTitle, page.title)}

View File

@@ -1,10 +1,16 @@
import { Disclosure } from '@headlessui/react';
import { ChevronRightIcon, FolderIcon } from '@heroicons/react/24/solid';
import { ChevronRightIcon, FolderIcon, PlusIcon, HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/solid';
import * as React from 'react';
import { useMemo } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Page } from '../../models';
import { StructureItem } from './StructureItem';
import { parseWinPath } from '../../../helpers/parseWinPath';
import { SelectedStructureFolderAtom, SettingsSelector } from '../../state';
import { Messenger } from '@estruyf/vscode/dist/client';
import { DashboardMessage } from '../../DashboardMessage';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
export interface IStructureViewProps {
pages: Page[];
@@ -20,6 +26,9 @@ interface FolderNode {
export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
pages
}: React.PropsWithChildren<IStructureViewProps>) => {
const [selectedFolder, setSelectedFolder] = useRecoilState(SelectedStructureFolderAtom);
const settings = useRecoilValue(SettingsSelector);
const folderTree = useMemo(() => {
const root: FolderNode = {
name: '',
@@ -31,9 +40,8 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
const folderMap = new Map<string, FolderNode>();
folderMap.set('', root);
// Helper to compute the normalized folder path for a page.
// It ensures the page's folder starts with the `fmFolder` segment and
// preserves any subpaths after that segment (so subfolders are created).
// Helper to compute the normalized workspace-relative folder path for a page.
// This returns the actual folder path relative to the workspace, not just titles.
const computeNormalizedFolderPath = (page: Page): string => {
if (!page.fmFolder) {
return '';
@@ -41,31 +49,16 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
const fmFolder = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
// If we have a file path, use its directory (exclude the filename) to compute
// the relative path. This avoids treating filenames as folder segments.
const filePath = page.fmFilePath ? parseWinPath(page.fmFilePath).replace(/^\/+|\/+$/g, '') : '';
const fileDir = filePath && filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')).replace(/^\/+|\/+$/g, '') : '';
if (fileDir) {
// If the content folder is known, and the file directory starts with it,
// replace that root with the fmFolder (preserving subfolders after it).
if (page.fmPageFolder?.path) {
const contentFolderPath = parseWinPath(page.fmPageFolder.path).replace(/^\/+|\/+$/g, '');
if (fileDir.startsWith(contentFolderPath)) {
const rel = fileDir.substring(contentFolderPath.length).replace(/^\/+|\/+$/g, '');
return rel ? `${fmFolder}/${rel}` : fmFolder;
}
}
// Otherwise try to find fmFolder as a directory segment in the fileDir
const segments = fileDir.split('/').filter(Boolean);
const fmIndex = segments.indexOf(fmFolder);
if (fmIndex >= 0) {
return segments.slice(fmIndex).join('/');
// Use fmRelFilePath which is already workspace-relative
if (page.fmRelFilePath) {
const relPath = parseWinPath(page.fmRelFilePath).replace(/^\/+|\/+$/g, '');
const relDir = relPath.includes('/') ? relPath.substring(0, relPath.lastIndexOf('/')).replace(/^\/+|\/+$/g, '') : '';
if (relDir) {
return relDir;
}
}
// Fallback: just use the fmFolder name
// Fallback: use fmFolder title if we can't determine the path
return fmFolder;
};
@@ -127,6 +120,57 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
return root;
}, [pages]);
// Filter the folder tree based on the selected folder
const displayedNode = useMemo(() => {
if (!selectedFolder) {
return folderTree;
}
// Find the selected folder node in the tree
const findNode = (node: FolderNode, path: string): FolderNode | null => {
if (node.path === path) {
return node;
}
for (const child of node.children) {
const found = findNode(child, path);
if (found) {
return found;
}
}
return null;
};
const foundNode = findNode(folderTree, selectedFolder);
return foundNode || folderTree;
}, [folderTree, selectedFolder]);
const handleFolderClick = (folderPath: string) => {
setSelectedFolder(folderPath);
};
const handleBackClick = () => {
if (!selectedFolder) {
return;
}
// Navigate to parent folder
const parts = selectedFolder.split('/');
if (parts.length > 1) {
const parentPath = parts.slice(0, -1).join('/');
setSelectedFolder(parentPath);
} else {
setSelectedFolder(null);
}
};
const handleHomeClick = () => {
setSelectedFolder(null);
};
const handleCreateContent = () => {
Messenger.send(DashboardMessage.createContentInFolder, { folderPath: selectedFolder });
};
const renderFolderNode = (node: FolderNode, depth = 0): React.ReactNode => {
const hasContent = node.pages.length > 0 || node.children.length > 0;
@@ -168,24 +212,30 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
<Disclosure defaultOpen={depth <= 1}>
{({ open }) => (
<>
<Disclosure.Button
className="flex items-center w-full text-left"
style={{ paddingLeft: `${paddingLeft}px` }}
>
<ChevronRightIcon
className={`w-4 h-4 mr-2 transform transition-transform ${open ? 'rotate-90' : ''
}`}
/>
<FolderIcon className="w-4 h-4 mr-2 text-[var(--vscode-symbolIcon-folderForeground)]" />
<span className="font-medium text-[var(--vscode-editor-foreground)]">
{node.name}
{node.pages.length > 0 && (
<span className="ml-2 text-sm text-[var(--vscode-descriptionForeground)]">
({node.pages.length} {node.pages.length === 1 ? 'file' : 'files'})
</span>
)}
</span>
</Disclosure.Button>
<div className="flex items-center w-full gap-1" style={{ paddingLeft: `${paddingLeft}px` }}>
<Disclosure.Button className="flex items-center text-left hover:bg-[var(--vscode-list-hoverBackground)] rounded px-2 py-1 transition-colors">
<ChevronRightIcon
className={`w-4 h-4 transform transition-transform ${open ? 'rotate-90' : ''}`}
/>
</Disclosure.Button>
<button
onClick={() => handleFolderClick(node.path)}
className="flex items-center flex-1 px-2 py-1 hover:bg-[var(--vscode-list-hoverBackground)] rounded transition-colors"
title={l10n.t(LocalizationKey.commonOpen)}
>
<FolderIcon className="w-4 h-4 mr-2 flex-shrink-0 text-[var(--vscode-symbolIcon-folderForeground)]" />
<span className="flex items-center font-medium text-[var(--vscode-editor-foreground)] flex-1">
<span className="mr-2">{node.name}</span>
{node.pages.length > 0 && (
<span className="text-sm text-[var(--vscode-descriptionForeground)]">
({node.pages.length} {node.pages.length === 1 ? 'file' : 'files'})
</span>
)}
</span>
<ChevronRightIcon className="w-4 h-4 flex-shrink-0 text-[var(--vscode-descriptionForeground)]" />
</button>
</div>
<Disclosure.Panel className="mt-2">
{/* Child folders */}
@@ -211,7 +261,61 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
return (
<div className="structure-view">
{renderFolderNode(folderTree)}
{/* Toolbar */}
<div className="mb-4 pb-3 border-b border-[var(--frontmatter-border)]">
{/* Breadcrumb navigation */}
{selectedFolder && (
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<button
onClick={handleHomeClick}
className="p-1 hover:bg-[var(--vscode-list-hoverBackground)] rounded"
title="Home"
>
<HomeIcon className="w-4 h-4 text-[var(--vscode-descriptionForeground)]" />
</button>
<button
onClick={handleBackClick}
className="flex items-center space-x-1 px-2 py-1 hover:bg-[var(--vscode-list-hoverBackground)] rounded text-sm"
title={l10n.t(LocalizationKey.commonBack) || 'Back'}
>
<ArrowLeftIcon className="w-3 h-3" />
<span>{l10n.t(LocalizationKey.commonBack) || 'Back'}</span>
</button>
<span className="text-sm text-[var(--vscode-descriptionForeground)]">
/ {selectedFolder.split('/').join(' / ')}
</span>
</div>
</div>
)}
{/* Create content button */}
<div className="flex items-center space-x-2">
<button
onClick={handleCreateContent}
disabled={!settings?.initialized}
className="inline-flex items-center px-3 py-1 border border-transparent text-xs leading-4 font-medium focus:outline-none rounded text-[var(--vscode-button-foreground)] bg-[var(--frontmatter-button-background)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:opacity-50"
title={selectedFolder
? l10n.t(LocalizationKey.dashboardHeaderHeaderCreateContent) + ` in ${selectedFolder}`
: l10n.t(LocalizationKey.dashboardHeaderHeaderCreateContent)}
>
<PlusIcon className="w-4 h-4 mr-1" />
<span>
{selectedFolder
? `${l10n.t(LocalizationKey.dashboardHeaderHeaderCreateContent)} here`
: l10n.t(LocalizationKey.dashboardHeaderHeaderCreateContent)}
</span>
</button>
{selectedFolder && (
<span className="text-xs text-[var(--vscode-descriptionForeground)]">
in {selectedFolder}
</span>
)}
</div>
</div>
{/* Folder tree */}
{renderFolderNode(displayedNode)}
</div>
);
};

View File

@@ -0,0 +1,175 @@
import * as React from 'react';
import { useState, useMemo, useEffect } from 'react';
import { FolderIcon, ChevronRightIcon } from '@heroicons/react/24/solid';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import { parseWinPath } from '../../../helpers/parseWinPath';
import { Page } from '../../models';
export interface IMoveFileDialogProps {
page: Page;
availableFolders: string[];
dismiss: () => void;
trigger: (destinationFolder: string) => void;
}
interface FolderNode {
name: string;
path: string;
children: FolderNode[];
level: number;
}
export const MoveFileDialog: React.FunctionComponent<IMoveFileDialogProps> = ({
page,
availableFolders,
dismiss,
trigger
}: React.PropsWithChildren<IMoveFileDialogProps>) => {
const [selectedFolder, setSelectedFolder] = useState<string>('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Build folder tree structure
const folderTree = useMemo(() => {
const root: FolderNode[] = [];
const folderMap = new Map<string, FolderNode>();
for (const folderPath of availableFolders) {
const normalized = parseWinPath(folderPath).replace(/^\/+|\/+$/g, '');
const parts = normalized.split('/').filter(Boolean);
let currentPath = '';
let currentLevel: FolderNode[] = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const fullPath = currentPath ? `${currentPath}/${part}` : part;
if (!folderMap.has(fullPath)) {
const newNode: FolderNode = {
name: part,
path: fullPath,
children: [],
level: i
};
folderMap.set(fullPath, newNode);
currentLevel.push(newNode);
}
const node = folderMap.get(fullPath);
if (node) {
currentLevel = node.children;
}
currentPath = fullPath;
}
}
return root;
}, [availableFolders]);
const toggleFolder = (folderPath: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folderPath)) {
newExpanded.delete(folderPath);
} else {
newExpanded.add(folderPath);
}
setExpandedFolders(newExpanded);
};
const renderFolderNode = (node: FolderNode): React.ReactNode => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedFolder === node.path;
const hasChildren = node.children.length > 0;
const paddingLeft = node.level * 20;
return (
<div key={node.path}>
<div
className={`flex items-center py-1 px-2 cursor-pointer hover:bg-[var(--vscode-list-hoverBackground)] rounded ${isSelected ? 'bg-[var(--vscode-list-activeSelectionBackground)]' : ''
}`}
style={{ paddingLeft: `${paddingLeft}px` }}
onClick={() => setSelectedFolder(node.path)}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
toggleFolder(node.path);
}}
className="mr-1"
>
<ChevronRightIcon
className={`w-3 h-3 transform transition-transform ${isExpanded ? 'rotate-90' : ''
}`}
/>
</button>
)}
{!hasChildren && <span className="w-3 mr-1"></span>}
<FolderIcon className="w-4 h-4 mr-2 text-[var(--vscode-symbolIcon-folderForeground)]" />
<span className="text-sm text-[var(--vscode-editor-foreground)]">{node.name}</span>
</div>
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => renderFolderNode(child))}
</div>
)}
</div>
);
};
const handleMove = () => {
if (selectedFolder) {
trigger(selectedFolder);
}
};
// Auto-expand folders by default (first level)
useEffect(() => {
const firstLevelFolders = folderTree.map(node => node.path);
setExpandedFolders(new Set(firstLevelFolders));
}, [folderTree]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div className="bg-[var(--vscode-editor-background)] border border-[var(--frontmatter-border)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-[var(--frontmatter-border)]">
<h2 className="text-xl font-bold text-[var(--vscode-editor-foreground)]">
Move File
</h2>
<p className="mt-2 text-sm text-[var(--vscode-descriptionForeground)]">
Move <span className="font-medium">{page.title}</span> to a different folder
</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-1">
{folderTree.length > 0 ? (
folderTree.map((node) => renderFolderNode(node))
) : (
<p className="text-sm text-[var(--vscode-descriptionForeground)]">
No folders available
</p>
)}
</div>
</div>
<div className="p-6 border-t border-[var(--frontmatter-border)] flex justify-end space-x-2">
<button
onClick={dismiss}
className="px-4 py-2 text-sm font-medium rounded text-[var(--vscode-button-foreground)] bg-[var(--vscode-button-secondaryBackground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]"
>
{l10n.t(LocalizationKey.commonCancel)}
</button>
<button
onClick={handleMove}
disabled={!selectedFolder}
className="px-4 py-2 text-sm font-medium rounded text-[var(--vscode-button-foreground)] bg-[var(--frontmatter-button-background)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:opacity-50 disabled:cursor-not-allowed"
>
Move
</button>
</div>
</div>
</div>
);
};

View File

@@ -3,7 +3,7 @@ import { atom } from 'recoil';
export const SelectedItemActionAtom = atom<
| {
path: string;
action: 'view' | 'edit' | 'delete';
action: 'view' | 'edit' | 'delete' | 'move';
}
| undefined
>({

View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const SelectedStructureFolderAtom = atom<string | null>({
key: 'SelectedStructureFolderAtom',
default: null
});

View File

@@ -22,6 +22,7 @@ export * from './SearchAtom';
export * from './SearchReadyAtom';
export * from './SelectedItemActionAtom';
export * from './SelectedMediaFolderAtom';
export * from './SelectedStructureFolderAtom';
export * from './SettingsAtom';
export * from './SortingAtom';
export * from './TabAtom';

View File

@@ -0,0 +1,9 @@
import { selector } from 'recoil';
import { SelectedStructureFolderAtom } from '..';
export const SelectedStructureFolderSelector = selector({
key: 'SelectedStructureFolderSelector',
get: ({ get }) => {
return get(SelectedStructureFolderAtom);
}
});

View File

@@ -8,6 +8,7 @@ export * from './MediaTotalSelector';
export * from './PageSelector';
export * from './SearchSelector';
export * from './SelectedMediaFolderSelector';
export * from './SelectedStructureFolderSelector';
export * from './SettingsSelector';
export * from './SortingSelector';
export * from './TabSelector';

View File

@@ -30,7 +30,6 @@ import {
Article,
Settings,
StatusListener,
Chatbot,
Taxonomy
} from './commands';
import { join } from 'path';
@@ -194,17 +193,12 @@ export async function activate(context: vscode.ExtensionContext) {
subscriptions.push(
vscode.commands.registerCommand(COMMAND_NAME.docs, () => {
vscode.commands.executeCommand(
`simpleBrowser.show`,
`workbench.action.browser.open`,
`https://${extension.isBetaVersion() ? `beta.` : ``}frontmatter.codes/docs`
);
})
);
// Chat to the bot
subscriptions.push(
vscode.commands.registerCommand(COMMAND_NAME.chatbot, () => Chatbot.open(extensionPath))
);
// Create the editor experience for bulk scripts
subscriptions.push(
vscode.workspace.registerTextDocumentContentProvider(

View File

@@ -149,12 +149,17 @@ export class ArticleHelper {
* @returns A promise that resolves to the contents of the file, or undefined if the file does not exist.
*/
public static async getContents(filePath: string): Promise<string | undefined> {
const file = await workspace.fs.readFile(Uri.file(parseWinPath(filePath)));
if (!file) {
try {
const file = await workspace.fs.readFile(Uri.file(parseWinPath(filePath)));
if (!file) {
return undefined;
}
return new TextDecoder().decode(file);
} catch (error) {
Logger.error(`ArticleHelper.getContents: Failed to read file ${filePath}: ${error}`);
return undefined;
}
return new TextDecoder().decode(file);
}
/**

View File

@@ -34,7 +34,6 @@ import { Folders } from '../commands/Folders';
import { Questions } from './Questions';
import { Notifications } from './Notifications';
import { DEFAULT_CONTENT_TYPE_NAME } from '../constants/ContentType';
import { Telemetry } from './Telemetry';
import { basename } from 'path';
import { ParsedFrontMatter } from '../parsers';
import { encodeEmoji, existsAsync, fieldWhenClause, getTitleField, writeFileAsync } from '../utils';
@@ -408,7 +407,7 @@ export class ContentType {
* @param parents
* @returns
*/
public static getFieldValue(data: any, parents: string[]): string | string[] {
public static getFieldValue(data: any, parents: string[]): any {
let fieldValue = [];
let crntPageData = data;
@@ -575,7 +574,8 @@ export class ContentType {
fieldValue === null ||
fieldValue === undefined ||
fieldValue === '' ||
fieldValue.length === 0 ||
(Array.isArray(fieldValue) && fieldValue.length === 0) ||
(typeof fieldValue === 'string' && fieldValue.length === 0) ||
fieldValue === DefaultFieldValues.faultyCustomPlaceholder
) {
emptyFields.push(fields);
@@ -956,8 +956,25 @@ export class ContentType {
let templatePath = contentType.template;
let templateData: ParsedFrontMatter | null | undefined = null;
if (templatePath) {
templatePath = Folders.getAbsFilePath(templatePath);
templateData = await ArticleHelper.getFrontMatterByPath(templatePath);
try {
templatePath = Folders.getAbsFilePath(templatePath);
templateData = await ArticleHelper.getFrontMatterByPath(templatePath);
if (!templateData) {
Logger.warning(
`ContentType.create: Template file not found or could not be parsed: ${templatePath}`
);
Notifications.warning(
l10n.t(LocalizationKey.commonError) + ` Template not found: ${templatePath}`
);
}
} catch (error) {
Logger.error(
`ContentType.create: Error loading template from ${templatePath}: ${error}`
);
Notifications.error(
l10n.t(LocalizationKey.commonError) + ` Template loading failed: ${templatePath}`
);
}
}
const newFilePath: string | undefined = await ArticleHelper.createContent(

View File

@@ -0,0 +1,370 @@
import { ContentType, Field, FieldType, CustomTaxonomy } from '../models';
import { Settings } from '../helpers/SettingsHelper';
import { SETTING_TAXONOMY_FIELD_GROUPS, SETTING_TAXONOMY_CUSTOM } from '../constants';
import { TaxonomyHelper } from './TaxonomyHelper';
import { TaxonomyType } from '../models/TaxonomyType';
/**
* JSON Schema type definition
*/
export interface JSONSchema {
$schema?: string;
type?: string | string[];
properties?: { [key: string]: JSONSchema };
required?: string[];
items?: JSONSchema;
enum?: any[];
format?: string;
anyOf?: JSONSchema[];
oneOf?: JSONSchema[];
allOf?: JSONSchema[];
description?: string;
default?: any;
minimum?: number;
maximum?: number;
}
/**
* Generates JSON Schema from Front Matter Content Type definitions
*
* This utility converts Front Matter content type definitions into JSON Schema format
* which can then be used for validation. It handles all field types supported by
* Front Matter CMS including nested fields, blocks, and field groups.
*
* Field Type Mappings:
* - string, slug, image, file, customField → string
* - number → number (with optional min/max)
* - boolean, draft → boolean
* - datetime → string with date-time format
* - choice → string with enum (or array if multiple)
* - tags, categories, taxonomy, list → array of strings
* - fields → nested object with properties
* - block → array of objects with oneOf for field groups
* - json → any valid JSON type
* - dataFile, contentRelationship → string or array
*
* Features:
* - Required field validation
* - Type validation
* - Enum/choice validation
* - Number range validation (min/max)
* - Nested object support
* - Block field support with multiple field group options
*
* Usage:
* ```typescript
* const schema = ContentTypeSchemaGenerator.generateSchema(contentType);
* // Use schema for validation with AJV or other JSON Schema validators
* ```
*/
export class ContentTypeSchemaGenerator {
/**
* Generate JSON Schema from a content type
* @param contentType The content type to generate schema from
* @returns JSON Schema object
*/
public static async generateSchema(contentType: ContentType): Promise<JSONSchema> {
const schema: JSONSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {},
required: []
};
if (!contentType.fields || contentType.fields.length === 0) {
return schema;
}
// Process each field in the content type
for (const field of contentType.fields) {
const fieldSchema = await this.generateFieldSchema(field);
if (fieldSchema && schema.properties) {
schema.properties[field.name] = fieldSchema;
// Add to required array if field is required
if (field.required && schema.required) {
schema.required.push(field.name);
}
}
}
// Remove required array if empty
if (schema.required && schema.required.length === 0) {
delete schema.required;
}
return schema;
}
/**
* Generate JSON Schema for a single field
* @param field The field to generate schema from
* @returns JSON Schema object for the field
*/
private static async generateFieldSchema(field: Field): Promise<JSONSchema | null> {
// Skip divider and heading fields as they are UI-only
if (field.type === 'divider' || field.type === 'heading') {
return null;
}
const schema: JSONSchema = {};
// Add description if available
if (field.description) {
schema.description = field.description;
}
// Add default value if specified
if (field.default !== undefined && field.default !== null) {
schema.default = field.default;
}
// Map field type to JSON Schema type
switch (field.type) {
case 'string':
case 'slug':
case 'image':
case 'file':
case 'customField':
schema.type = 'string';
break;
case 'number':
schema.type = 'number';
if (field.numberOptions) {
if (field.numberOptions.min !== undefined) {
schema.minimum = field.numberOptions.min;
}
if (field.numberOptions.max !== undefined) {
schema.maximum = field.numberOptions.max;
}
}
break;
case 'boolean':
case 'draft':
schema.type = 'boolean';
break;
case 'datetime':
schema.type = 'string';
schema.format = 'date-time';
break;
case 'choice':
if (field.multiple) {
schema.type = 'array';
schema.items = {
type: 'string'
};
if (field.choices && field.choices.length > 0) {
schema.items.enum = this.extractChoiceValues(field.choices);
}
} else {
schema.type = 'string';
if (field.choices && field.choices.length > 0) {
schema.enum = this.extractChoiceValues(field.choices);
}
}
break;
case 'tags': {
schema.type = 'array';
schema.items = {
type: 'string'
};
// Get available tags and add as enum for validation
const availableTags = await TaxonomyHelper.get(TaxonomyType.Tag);
if (availableTags && availableTags.length > 0) {
schema.items.enum = availableTags;
}
break;
}
case 'categories': {
schema.type = 'array';
schema.items = {
type: 'string'
};
// Get available categories and add as enum for validation
const availableCategories = await TaxonomyHelper.get(TaxonomyType.Category);
if (availableCategories && availableCategories.length > 0) {
schema.items.enum = availableCategories;
}
break;
}
case 'taxonomy': {
schema.type = 'array';
schema.items = {
type: 'string'
};
// Get custom taxonomy options if taxonomyId is specified
if (field.taxonomyId) {
const customTaxonomies = Settings.get<CustomTaxonomy[]>(SETTING_TAXONOMY_CUSTOM);
if (customTaxonomies && customTaxonomies.length > 0) {
const taxonomy = customTaxonomies.find((t) => t.id === field.taxonomyId);
if (taxonomy && taxonomy.options && taxonomy.options.length > 0) {
schema.items.enum = taxonomy.options;
}
}
}
break;
}
case 'list':
schema.type = 'array';
schema.items = {
type: 'string'
};
break;
case 'fields':
schema.type = 'object';
schema.properties = {};
schema.required = [];
if (field.fields && field.fields.length > 0) {
for (const subField of field.fields) {
const subFieldSchema = await this.generateFieldSchema(subField);
if (subFieldSchema && schema.properties) {
schema.properties[subField.name] = subFieldSchema;
if (subField.required && schema.required) {
schema.required.push(subField.name);
}
}
}
}
// Remove required array if empty
if (schema.required && schema.required.length === 0) {
delete schema.required;
}
break;
case 'block': {
// Block fields can contain different field groups
schema.type = 'array';
schema.items = {
type: 'object'
};
// Try to get the field group schemas
const blockSchemas = await this.getBlockFieldGroupSchemas(field);
if (blockSchemas.length > 0) {
schema.items = {
oneOf: blockSchemas
};
}
break;
}
case 'json':
// JSON fields can be any valid JSON
schema.type = ['object', 'array', 'string', 'number', 'boolean', 'null'];
break;
case 'dataFile':
// Data file references are typically strings (IDs or keys)
schema.type = 'string';
break;
case 'contentRelationship':
// Content relationships can be a string (slug/path) or array of strings
if (field.multiple) {
schema.type = 'array';
schema.items = {
type: 'string'
};
} else {
schema.type = 'string';
}
break;
case 'fieldCollection':
// Field collections reference field groups, handle similarly to blocks
schema.type = 'array';
schema.items = {
type: 'object'
};
break;
default:
// Unknown field type, default to string
schema.type = 'string';
break;
}
return schema;
}
/**
* Extract choice values from field choices
* @param choices Array of choice strings or objects
* @returns Array of choice values
*/
private static extractChoiceValues(choices: (string | { id?: string | null; title: string })[]): string[] {
return choices.map((choice) => {
if (typeof choice === 'string') {
return choice;
} else {
return choice.id || choice.title;
}
});
}
/**
* Get schemas for block field groups
* @param field The block field
* @returns Array of JSON Schemas for each field group
*/
private static async getBlockFieldGroupSchemas(field: Field): Promise<JSONSchema[]> {
const schemas: JSONSchema[] = [];
if (!field.fieldGroup) {
return schemas;
}
const fieldGroupIds = Array.isArray(field.fieldGroup) ? field.fieldGroup : [field.fieldGroup];
const fieldGroups = Settings.get(SETTING_TAXONOMY_FIELD_GROUPS) as { id: string; fields: Field[] }[] | undefined;
if (!fieldGroups || fieldGroups.length === 0) {
return schemas;
}
for (const groupId of fieldGroupIds) {
const fieldGroup = fieldGroups.find((fg) => fg.id === groupId);
if (fieldGroup && fieldGroup.fields) {
const groupSchema: JSONSchema = {
type: 'object',
properties: {},
required: []
};
for (const groupField of fieldGroup.fields) {
const fieldSchema = await this.generateFieldSchema(groupField);
if (fieldSchema && groupSchema.properties) {
groupSchema.properties[groupField.name] = fieldSchema;
if (groupField.required && groupSchema.required) {
groupSchema.required.push(groupField.name);
}
}
}
// Remove required array if empty
if (groupSchema.required && groupSchema.required.length === 0) {
delete groupSchema.required;
}
schemas.push(groupSchema);
}
}
return schemas;
}
}

View File

@@ -0,0 +1,216 @@
import Ajv, { ErrorObject } from 'ajv';
import { ContentType } from '../models';
import { ContentTypeSchemaGenerator, JSONSchema } from './ContentTypeSchemaGenerator';
/**
* Validation error with location information
*/
export interface ValidationError {
field: string;
message: string;
keyword?: string;
params?: Record<string, any>;
}
/**
* Validates front matter data against content type schemas
*
* This validator uses JSON Schema validation (via AJV) to ensure that front matter
* in markdown files conforms to the structure defined in content types.
*
* Features:
* - Automatic schema generation from content type definitions
* - Type validation (string, number, boolean, datetime, arrays, etc.)
* - Required field validation
* - Enum/choice validation
* - Number range validation (min/max)
* - Nested object validation
*
* Usage:
* ```typescript
* const validator = new FrontMatterValidator();
* const errors = validator.validate(frontMatterData, contentType);
* if (errors.length > 0) {
* // Handle validation errors
* }
* ```
*/
export class FrontMatterValidator {
private ajv: Ajv;
private schemaCache: Map<string, JSONSchema>;
constructor() {
this.ajv = new Ajv({
allErrors: true,
verbose: true,
strict: false,
allowUnionTypes: true
});
this.schemaCache = new Map();
}
/**
* Validate front matter data against a content type
* @param data The front matter data to validate
* @param contentType The content type to validate against
* @returns Array of validation errors (empty if valid)
*/
public async validate(data: any, contentType: ContentType): Promise<ValidationError[]> {
if (!contentType || !contentType.fields || contentType.fields.length === 0) {
return [];
}
// Get or generate schema
const schema = await this.getSchema(contentType);
if (!schema) {
return [];
}
// Compile and validate
const validate = this.ajv.compile(schema);
const valid = validate(data);
if (valid) {
return [];
}
// Convert AJV errors to our format
return this.convertAjvErrors(validate.errors || []);
}
/**
* Get or generate schema for a content type
* @param contentType The content type
* @returns JSON Schema
*/
private async getSchema(contentType: ContentType): Promise<JSONSchema | null> {
// Check cache first
const cacheKey = contentType.name;
if (this.schemaCache.has(cacheKey)) {
return this.schemaCache.get(cacheKey) || null;
}
// Generate new schema
const schema = await ContentTypeSchemaGenerator.generateSchema(contentType);
this.schemaCache.set(cacheKey, schema);
return schema;
}
/**
* Clear the schema cache
*/
public clearCache(): void {
this.schemaCache.clear();
}
/**
* Convert AJV errors to validation errors
* @param ajvErrors AJV error objects
* @returns Array of validation errors
*/
private convertAjvErrors(ajvErrors: ErrorObject[]): ValidationError[] {
const errors: ValidationError[] = [];
for (const error of ajvErrors) {
const field = this.extractFieldName(error.instancePath);
const message = this.formatErrorMessage(error, field);
errors.push({
field,
message,
keyword: error.keyword,
params: error.params
});
}
return errors;
}
/**
* Extract field name from instance path
* @param instancePath The JSON pointer path
* @returns Field name
*/
private extractFieldName(instancePath: string): string {
if (!instancePath || instancePath === '') {
return 'root';
}
// Remove leading slash and convert to dot notation
return instancePath
.replace(/^\//, '')
.replace(/\//g, '.')
.replace(/~1/g, '/')
.replace(/~0/g, '~');
}
/**
* Format error message for display
* @param error AJV error object
* @param field Field name
* @returns Formatted error message
*/
private formatErrorMessage(error: ErrorObject, field: string): string {
const displayField = field === 'root' ? 'The document' : `Field '${field}'`;
switch (error.keyword) {
case 'required': {
const missingProperty = error.params?.missingProperty;
return `Missing required field '${missingProperty}'`;
}
case 'type': {
const expectedType = error.params?.type;
return `${displayField} must be of type ${expectedType}`;
}
case 'enum': {
const allowedValues = error.params?.allowedValues;
if (allowedValues && Array.isArray(allowedValues)) {
return `${displayField} must be one of: ${allowedValues.join(', ')}`;
}
return `${displayField} has an invalid value`;
}
case 'format': {
const format = error.params?.format;
return `${displayField} must be in ${format} format`;
}
case 'minimum': {
const minimum = error.params?.limit;
return `${displayField} must be greater than or equal to ${minimum}`;
}
case 'maximum': {
const maximum = error.params?.limit;
return `${displayField} must be less than or equal to ${maximum}`;
}
case 'minItems': {
const minItems = error.params?.limit;
return `${displayField} must have at least ${minItems} items`;
}
case 'maxItems': {
const maxItems = error.params?.limit;
return `${displayField} must have at most ${maxItems} items`;
}
case 'additionalProperties': {
const additionalProperty = error.params?.additionalProperty;
return `Unexpected field '${additionalProperty}' is not allowed`;
}
case 'oneOf':
return `${displayField} must match exactly one of the allowed schemas`;
case 'anyOf':
return `${displayField} must match at least one of the allowed schemas`;
default:
return error.message || `${displayField} is invalid`;
}
}
}

View File

@@ -12,7 +12,7 @@ export class Logger {
private constructor() {
const displayName = Extension.getInstance().displayName;
Logger.channel = window.createOutputChannel(displayName);
Logger.channel = window.createOutputChannel(displayName, 'frontmatter.project.output');
commands.registerCommand(COMMAND_NAME.showOutputChannel, () => {
Logger.channel?.show();
});

View File

@@ -113,7 +113,7 @@ export class MediaLibrary {
public async get(id: string): Promise<MediaRecord | undefined> {
try {
const fileId = this.parsePath(id);
const fileId = MediaLibrary.parsePath(id);
if (await this.db?.exists(fileId)) {
return await this.db?.getData(fileId);
}
@@ -142,13 +142,13 @@ export class MediaLibrary {
}
public set(id: string, metadata: any): void {
const fileId = this.parsePath(id);
const fileId = MediaLibrary.parsePath(id);
this.db?.push(fileId, metadata, true);
}
public async rename(oldId: string, newId: string): Promise<void> {
const fileId = this.parsePath(oldId);
const newFileId = this.parsePath(newId);
const fileId = MediaLibrary.parsePath(oldId);
const newFileId = MediaLibrary.parsePath(newId);
const data = await this.get(fileId);
if (data) {
this.db?.delete(fileId);
@@ -157,7 +157,7 @@ export class MediaLibrary {
}
public async remove(path: string): Promise<void> {
const fileId = this.parsePath(path);
const fileId = MediaLibrary.parsePath(path);
await this.db?.delete(fileId);
}
@@ -183,9 +183,12 @@ export class MediaLibrary {
}
}
public parsePath(path: string) {
public static parsePath(path: string) {
const wsFolder = Folders.getWorkspaceFolder();
let absPath = path.replace(parseWinPath(wsFolder?.fsPath || ''), WORKSPACE_PLACEHOLDER);
let absPath = parseWinPath(path).replace(
parseWinPath(wsFolder?.fsPath || ''),
WORKSPACE_PLACEHOLDER
);
absPath = isWindows() ? absPath.split('\\').join('/') : absPath;
return absPath.toLowerCase();
}

View File

@@ -1,7 +1,6 @@
import {
SETTING_GLOBAL_TIMEZONE,
SETTING_PANEL_ACTIONS_DISABLED,
SETTING_SPONSORS_AI_ENABLED,
SETTING_WEBSITE_URL
} from './../constants/settings';
import { workspace } from 'vscode';
@@ -52,7 +51,6 @@ export class PanelSettings {
try {
return {
aiEnabled: Settings.get<boolean>(SETTING_SPONSORS_AI_ENABLED) || false,
copilotEnabled: await Copilot.isInstalled(),
git: await GitListener.getSettings(),
seo: {
@@ -70,7 +68,7 @@ export class PanelSettings {
},
date: {
format: Settings.get<string>(SETTING_DATE_FORMAT) || '',
timezone: Settings.get<string>(SETTING_GLOBAL_TIMEZONE) || ''
timezone: Settings.get<string>(SETTING_GLOBAL_TIMEZONE) || 'UTC'
},
tags: (await TaxonomyHelper.get(TaxonomyType.Tag)) || [],
categories: (await TaxonomyHelper.get(TaxonomyType.Category)) || [],

View File

@@ -1,11 +1,8 @@
import { authentication, QuickPickItem, QuickPickItemKind, window } from 'vscode';
import { QuickPickItem, QuickPickItemKind, window } from 'vscode';
import { Folders } from '../commands/Folders';
import { SETTING_SPONSORS_AI_ENABLED } from '../constants';
import { ContentType } from './ContentType';
import { Notifications } from './Notifications';
import { Settings } from './SettingsHelper';
import { Logger } from './Logger';
import { SponsorAi } from '../services/SponsorAI';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../localization';
import { ContentFolder } from '../models';
@@ -40,56 +37,28 @@ export class Questions {
* @returns
*/
public static async ContentTitle(showWarning = true): Promise<string | undefined> {
const aiEnabled = Settings.get<boolean>(SETTING_SPONSORS_AI_ENABLED);
let title: string | undefined = '';
const isCopilotInstalled = await Copilot.isInstalled();
let aiTitles: string[] | undefined;
if (aiEnabled || isCopilotInstalled) {
if (isCopilotInstalled) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPlaceholder),
ignoreFocusOut: true
});
if (isCopilotInstalled) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPlaceholder),
ignoreFocusOut: true
});
if (title) {
try {
aiTitles = await Copilot.suggestTitles(title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleCopilotInputFailed)
);
title = undefined;
}
}
} else {
const githubAuth = await authentication.getSession('github', ['read:user'], {
silent: true
});
if (githubAuth && githubAuth.account.label) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPlaceholder),
ignoreFocusOut: true
});
if (title) {
try {
aiTitles = await SponsorAi.getTitles(githubAuth.accessToken, title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputFailed)
);
title = undefined;
}
}
if (title) {
try {
aiTitles = await Copilot.suggestTitles(title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleCopilotInputFailed)
);
title = undefined;
}
}

View File

@@ -38,3 +38,5 @@ export * from './processFmPlaceholders';
export * from './processI18nPlaceholders';
export * from './processPathPlaceholders';
export * from './processTimePlaceholders';
export * from './ContentTypeSchemaGenerator';
export * from './FrontMatterValidator';

View File

@@ -75,25 +75,30 @@ export class MediaListener extends BaseListener {
return;
}
window.withProgress({
location: ProgressLocation.Notification,
title: localize(LocalizationKey.listenersDashboardMediaListenersDeleteMediaFolderProgressTitle),
cancellable: false
}, async () => {
const folderPath = parse(msg.folder).dir;
window.withProgress(
{
location: ProgressLocation.Notification,
title: localize(
LocalizationKey.listenersDashboardMediaListenersDeleteMediaFolderProgressTitle
),
cancellable: false
},
async () => {
const folderPath = parse(msg.folder).dir;
const mediaLib = MediaLibrary.getInstance();
const parsedPath = mediaLib.parsePath(msg.folder);
const mediaFiles = await mediaLib.getAllByPath(parsedPath);
const mediaLib = MediaLibrary.getInstance();
const parsedPath = MediaLibrary.parsePath(msg.folder);
const mediaFiles = await mediaLib.getAllByPath(parsedPath);
for (const fileName of Object.keys(mediaFiles)) {
const filePath = join(msg.folder, fileName);
await mediaLib.remove(filePath);
for (const fileName of Object.keys(mediaFiles)) {
const filePath = join(msg.folder, fileName);
await mediaLib.remove(filePath);
}
await workspace.fs.delete(Uri.file(msg.folder), { recursive: true, useTrash: false });
await MediaListener.sendMediaFiles(0, folderPath);
}
await workspace.fs.delete(Uri.file(msg.folder), { recursive: true, useTrash: false });
await MediaListener.sendMediaFiles(0, folderPath);
});
);
}
public static async updateMediaFolder(msg: {
@@ -105,41 +110,48 @@ export class MediaListener extends BaseListener {
return;
}
window.withProgress({
location: ProgressLocation.Notification,
title: localize(LocalizationKey.listenersDashboardMediaListenersUpdateMediaFolderProgressTitle),
cancellable: false
}, async () => {
const folderName = parse(msg.folder).base;
const newFolderName = await window.showInputBox({
prompt: 'Enter new folder name',
value: folderName
});
window.withProgress(
{
location: ProgressLocation.Notification,
title: localize(
LocalizationKey.listenersDashboardMediaListenersUpdateMediaFolderProgressTitle
),
cancellable: false
},
async () => {
const folderName = parse(msg.folder).base;
if (!newFolderName || newFolderName === folderName) {
return;
const newFolderName = await window.showInputBox({
prompt: 'Enter new folder name',
value: folderName
});
if (!newFolderName || newFolderName === folderName) {
return;
}
const newFolderPath = join(parse(msg.folder).dir, newFolderName);
// Get all media files from the folder
const mediaLib = MediaLibrary.getInstance();
const parsedPath = MediaLibrary.parsePath(msg.folder);
const mediaFiles = await mediaLib.getAllByPath(parsedPath);
// Update the folder
await workspace.fs.rename(Uri.file(msg.folder), Uri.file(newFolderPath), {
overwrite: false
});
// Update the media files
for (const fileName of Object.keys(mediaFiles)) {
const newFilePath = join(newFolderPath, fileName);
const oldFilePath = join(msg.folder, fileName);
await mediaLib.rename(oldFilePath, newFilePath);
}
await this.sendMediaFiles(0, parse(msg.folder).dir);
}
const newFolderPath = join(parse(msg.folder).dir, newFolderName);
// Get all media files from the folder
const mediaLib = MediaLibrary.getInstance();
const parsedPath = mediaLib.parsePath(msg.folder);
const mediaFiles = await mediaLib.getAllByPath(parsedPath);
// Update the folder
await workspace.fs.rename(Uri.file(msg.folder), Uri.file(newFolderPath), { overwrite: false });
// Update the media files
for (const fileName of Object.keys(mediaFiles)) {
const newFilePath = join(newFolderPath, fileName);
const oldFilePath = join(msg.folder, fileName);
await mediaLib.rename(oldFilePath, newFilePath);
}
await this.sendMediaFiles(0, parse(msg.folder).dir);
});
);
}
/**
@@ -205,7 +217,7 @@ export class MediaListener extends BaseListener {
for (const file of filesEndingWith) {
const absPath = FilesHelper.relToAbsPath(file);
if (!(await existsAsync(absPath))) {
const parsedPath = mediaLib.parsePath(absPath);
const parsedPath = MediaLibrary.parsePath(absPath);
const metadata = await mediaLib.get(parsedPath);
if (metadata) {
unmappedFiles.push({

View File

@@ -1,5 +1,5 @@
import { PostMessageData } from './../../models/PostMessageData';
import { basename } from 'path';
import { basename, join } from 'path';
import { commands, FileSystemWatcher, RelativePattern, TextDocument, Uri, workspace } from 'vscode';
import { Dashboard } from '../../commands/Dashboard';
import { Folders } from '../../commands/Folders';
@@ -12,13 +12,24 @@ import {
import { DashboardCommand } from '../../dashboardWebView/DashboardCommand';
import { DashboardMessage } from '../../dashboardWebView/DashboardMessage';
import { Page } from '../../dashboardWebView/models';
import { ArticleHelper, Extension, Logger, parseWinPath, Settings } from '../../helpers';
import { ContentFolder } from '../../models/ContentFolder';
import {
ArticleHelper,
Extension,
Logger,
parseWinPath,
Settings,
ContentType,
Notifications
} from '../../helpers';
import { BaseListener } from './BaseListener';
import { DataListener } from '../panel';
import Fuse from 'fuse.js';
import { PagesParser } from '../../services/PagesParser';
import { unlinkAsync, rmdirAsync } from '../../utils';
import { LoadingType } from '../../models';
import { Questions } from '../../helpers/Questions';
import { Template } from '../../commands/Template';
export class PagesListener extends BaseListener {
private static watchers: { [path: string]: FileSystemWatcher } = {};
@@ -45,6 +56,9 @@ export class PagesListener extends BaseListener {
case DashboardMessage.createByTemplate:
await commands.executeCommand(COMMAND_NAME.createByTemplate);
break;
case DashboardMessage.createContentInFolder:
await this.createContentInFolder(msg.payload);
break;
case DashboardMessage.refreshPages:
this.getPagesData(true);
break;
@@ -57,6 +71,9 @@ export class PagesListener extends BaseListener {
case DashboardMessage.rename:
ArticleHelper.rename(msg.payload);
break;
case DashboardMessage.moveFile:
await this.moveFile(msg.payload);
break;
}
}
@@ -306,6 +323,246 @@ export class PagesListener extends BaseListener {
this.sendMsg(DashboardCommand.searchPages, pageResults);
}
/**
* Move a file to a different folder
* @param payload
*/
private static async moveFile(payload: { filePath: string; destinationFolder: string }) {
if (!payload || !payload.filePath || !payload.destinationFolder) {
return;
}
const { filePath, destinationFolder } = payload;
try {
const wsFolder = Folders.getWorkspaceFolder();
if (!wsFolder) {
Logger.error('Workspace folder not found');
return;
}
// Get all content folders
const folders = await Folders.get();
if (!folders || folders.length === 0) {
Logger.error('No content folders found');
return;
}
// Find the destination folder
let targetFolderPath = '';
for (const folder of folders) {
const absoluteFolderPath = Folders.getFolderPath(Uri.file(folder.path));
const relativeFolderPath = parseWinPath(absoluteFolderPath)
.replace(parseWinPath(wsFolder.fsPath), '')
.replace(/^\/+|\/+$/g, '');
if (
destinationFolder === relativeFolderPath ||
destinationFolder.startsWith(relativeFolderPath + '/')
) {
targetFolderPath = absoluteFolderPath;
// Add subfolder if any
if (destinationFolder !== relativeFolderPath) {
const subPath = destinationFolder
.substring(relativeFolderPath.length)
.replace(/^\/+|\/+$/g, '');
targetFolderPath = join(targetFolderPath, subPath);
}
break;
}
}
if (!targetFolderPath) {
Logger.error('Target folder not found');
return;
}
// Get the file name
const fileName = basename(filePath);
const newFilePath = join(targetFolderPath, fileName);
// Check if target already exists
try {
await workspace.fs.stat(Uri.file(newFilePath));
Logger.error(`File already exists at destination: ${newFilePath}`);
return;
} catch {
// File doesn't exist, which is good
}
// Check if it's a page bundle
const article = await ArticleHelper.getFrontMatterByPath(filePath);
if (article) {
const contentType = await ArticleHelper.getContentType(article);
if (contentType.pageBundle) {
// Move the entire folder
const sourceFolder = parseWinPath(filePath).substring(
0,
parseWinPath(filePath).lastIndexOf('/')
);
const folderName = basename(sourceFolder);
const newFolderPath = join(targetFolderPath, folderName);
// Move the folder
await workspace.fs.rename(Uri.file(sourceFolder), Uri.file(newFolderPath), {
overwrite: false
});
Logger.info(`Moved page bundle from ${sourceFolder} to ${newFolderPath}`);
} else {
// Move just the file
await workspace.fs.rename(Uri.file(filePath), Uri.file(newFilePath), {
overwrite: false
});
Logger.info(`Moved file from ${filePath} to ${newFilePath}`);
}
} else {
// Move just the file
await workspace.fs.rename(Uri.file(filePath), Uri.file(newFilePath), {
overwrite: false
});
Logger.info(`Moved file from ${filePath} to ${newFilePath}`);
}
// Refresh the pages data
this.getPagesData(true);
} catch (error) {
Logger.error(`Error moving file: ${(error as Error).message}`);
}
}
/**
* Create content in a specific folder
* @param payload
*/
private static async createContentInFolder(payload: { folderPath: string | null }) {
if (!payload) {
// Fall back to regular content creation
await commands.executeCommand(COMMAND_NAME.createContent);
return;
}
const { folderPath } = payload;
// Get all content folders (including those with disableCreation)
const allFolders = await Folders.get();
if (!allFolders || allFolders.length === 0) {
await commands.executeCommand(COMMAND_NAME.createContent);
return;
}
let targetFolder = null;
let subPath = '';
if (folderPath) {
// The folderPath is a relative path like "content/posts" or "blog/en"
// We need to find the matching content folder and determine the subfolder
Logger.info(`[createContentInFolder] folderPath: ${folderPath}`);
let bestMatch: { folder: ContentFolder; subPath: string; matchLength: number } | null = null;
for (const folder of allFolders) {
const wsFolder = Folders.getWorkspaceFolder();
if (!wsFolder) {
continue;
}
const absoluteFolderPath = Folders.getFolderPath(Uri.file(folder.path));
const relativeFolderPath = parseWinPath(absoluteFolderPath)
.replace(parseWinPath(wsFolder.fsPath), '')
.replace(/^\/+|\/+$/g, '');
Logger.info(
`[createContentInFolder] Checking folder: ${folder.title}, relativePath: ${relativeFolderPath}`
);
// Check if the folderPath matches or starts with this content folder
if (folderPath === relativeFolderPath || folderPath.startsWith(relativeFolderPath + '/')) {
const currentSubPath =
folderPath !== relativeFolderPath
? folderPath.substring(relativeFolderPath.length).replace(/^\/+|\/+$/g, '')
: '';
// Keep track of the best (longest/most specific) match
if (!bestMatch || relativeFolderPath.length > bestMatch.matchLength) {
bestMatch = {
folder,
subPath: currentSubPath,
matchLength: relativeFolderPath.length
};
}
}
}
if (bestMatch) {
targetFolder = bestMatch.folder;
subPath = bestMatch.subPath;
Logger.info(
`[createContentInFolder] Best match: ${targetFolder.title}, subPath: ${subPath}`
);
// Check if content creation is disabled for this folder
if (targetFolder.disableCreation) {
Notifications.error(`Content creation is disabled for folder: ${targetFolder.title}`);
return;
}
}
}
if (!targetFolder) {
// If no folder matches, let the user select one (filter out disabled folders)
const availableFolders = allFolders.filter((f) => !f.disableCreation);
if (availableFolders.length === 0) {
await commands.executeCommand(COMMAND_NAME.createContent);
return;
}
const selectedFolder = await Questions.SelectContentFolder();
if (!selectedFolder) {
return;
}
targetFolder = allFolders.find((f) => f.path === selectedFolder.path);
}
if (!targetFolder) {
return;
}
// Get the folder path
let absoluteFolderPath = Folders.getFolderPath(Uri.file(targetFolder.path));
// Add the subfolder if any
if (subPath) {
absoluteFolderPath = join(absoluteFolderPath, subPath);
}
// Check if templates are enabled
const templatesEnabled = Settings.get('dashboardState.contents.templatesEnabled');
if (templatesEnabled) {
// Use the template creation flow
await Template.create(absoluteFolderPath);
} else {
// Use the content type creation flow
const selectedContentType = await Questions.SelectContentType(
targetFolder.contentTypes || []
);
if (!selectedContentType) {
return;
}
const contentTypes = ContentType.getAll();
const contentType = contentTypes?.find((ct) => ct.name === selectedContentType);
if (contentType) {
ContentType['create'](contentType, absoluteFolderPath);
}
}
}
/**
* Get fresh page data
*/

View File

@@ -28,7 +28,6 @@ export interface PanelSettings {
dataTypes: DataType[] | undefined;
fieldGroups: FieldGroup[] | undefined;
commaSeparatedFields: string[];
aiEnabled: boolean;
copilotEnabled: boolean;
contentFolders: ContentFolder[];
websiteUrl: string;

View File

@@ -12,7 +12,7 @@ import { FieldMessage } from '../Fields/FieldMessage';
import { FieldTitle } from '../Fields/FieldTitle';
import { useRecoilValue } from 'recoil';
import { PanelSettingsAtom } from '../../state';
import { SparklesIcon } from '@heroicons/react/24/outline';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { LocalizationKey, localize } from '../../../localization';
import useDropdownStyle from '../../hooks/useDropdownStyle';
import { CopilotIcon } from '../Icons';
@@ -311,21 +311,6 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
return (
<>
{settings?.aiEnabled && (
<button
className="metadata_field__title__action"
title={localize(
LocalizationKey.panelTagPickerAiSuggest,
label?.toLowerCase() || type.toLowerCase()
)}
type="button"
onClick={() => suggestTaxonomy('ai', type)}
disabled={!!loading}
>
<SparklesIcon />
</button>
)}
{settings?.copilotEnabled && (
<button
className="metadata_field__title__action"
@@ -342,7 +327,7 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
)}
</>
);
}, [settings?.aiEnabled, settings?.copilotEnabled, label, type]);
}, [settings?.copilotEnabled, label, type]);
const sortedSelectedTags = useMemo(() => {
const safeSelected = selected || [];

View File

@@ -1,4 +1,4 @@
import { PencilIcon, SparklesIcon } from '@heroicons/react/24/outline';
import { PencilIcon } from '@heroicons/react/24/outline';
import * as React from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useRecoilState } from 'recoil';
@@ -132,18 +132,6 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
return (
<>
{settings?.aiEnabled && settings.seo.descriptionField === name && (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
title={localize(LocalizationKey.panelFieldsTextFieldAiMessage, label?.toLowerCase())}
type="button"
onClick={() => suggestDescription('ai')}
disabled={!!loading}
>
<SparklesIcon />
</button>
)}
{settings?.copilotEnabled && (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
@@ -157,7 +145,7 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
)}
</>
);
}, [settings?.aiEnabled, settings?.copilotEnabled, settings?.seo, name, actions, loading]);
}, [settings?.copilotEnabled, settings?.seo, name, actions, loading]);
useEffect(() => {
if (showRequiredState) {

View File

@@ -33,7 +33,8 @@ export class Copilot {
}
const copilotExt = extensions.getExtension(`GitHub.copilot`);
return !!copilotExt;
const copilotChatExt = extensions.getExtension(`GitHub.copilot-chat`);
return !!copilotExt || !!copilotChatExt;
}
public static async suggestTitles(title: string): Promise<string[] | undefined> {
@@ -269,7 +270,7 @@ Example: SEO, website optimization, digital marketing.`
// console.log(models);
const [model] = await lm.selectChatModels({
vendor: 'copilot',
family: Settings.get<string>(SETTING_COPILOT_FAMILY) || 'gpt-4o-mini'
family: Settings.get<string>(SETTING_COPILOT_FAMILY) || 'gpt-4.1'
});
if ((!model || !model.sendRequest) && retry <= 5) {

View File

@@ -201,7 +201,7 @@ export class PagesParser {
const modifiedField = await ArticleHelper.getModifiedDateField(article);
const modifiedFieldValue =
modifiedField?.name && article?.data[modifiedField.name]
? DateHelper.tryParse(article?.data[modifiedField.name])?.getTime()
? DateHelper.tryParse(article?.data[modifiedField.name], modifiedField.dateFormat)?.getTime()
: undefined;
const staticFolder = Folders.getStaticFolderRelativePath();
@@ -219,7 +219,6 @@ export class PagesParser {
const isDefaultLanguage = await i18n.isDefaultLanguage(filePath);
const locale = await i18n.getLocale(filePath);
const translations = await i18n.getTranslations(filePath);
const pageFolder = await Folders.getPageFolderByFilePath(filePath);
const page: Page = {
...article.data,
@@ -242,7 +241,6 @@ export class PagesParser {
fmContentType: contentType.name || DEFAULT_CONTENT_TYPE_NAME,
fmBody: article?.content || '',
fmDateFormat: dateFormat,
fmPageFolder: pageFolder,
// i18n properties
fmDefaultLocale: isDefaultLanguage,
fmLocale: locale,
@@ -255,7 +253,8 @@ export class PagesParser {
Article.generateSlug(escapedTitle, article, contentType.slugTemplate)
?.slugWithPrefixAndSuffix,
date: article?.data[dateField] || '',
draft: article?.data.draft
draft: article?.data.draft,
fmPageFolder: undefined
};
let previewFieldParents = ContentType.findPreviewField(contentType.fields);
@@ -335,38 +334,52 @@ export class PagesParser {
// Revalidate as the array could have been empty
if (fieldValue) {
// Check if the value already starts with https - if that is the case, it is an external image
if (fieldValue.startsWith('http')) {
page.fmPreviewImage = fieldValue;
// Handle both string and object formats for the field value
let imageValue: string | undefined;
if (typeof fieldValue === 'string') {
imageValue = fieldValue;
} else if (typeof fieldValue === 'object' && fieldValue.src) {
// Handle object format like { src: "filename.jpg", title: "title" }
imageValue = fieldValue.src;
} else {
let staticPath = join(wsFolder.fsPath, staticFolder || '', fieldValue);
// Skip processing if the value is neither a string nor an object with src
imageValue = undefined;
}
if (staticFolder === STATIC_FOLDER_PLACEHOLDER.hexo.placeholder) {
const crntFilePath = parseWinPath(filePath);
const pathWithoutExtension = crntFilePath.replace(extname(crntFilePath), '');
staticPath = join(pathWithoutExtension, fieldValue);
}
if (imageValue) {
// Check if the value already starts with https - if that is the case, it is an external image
if (imageValue.startsWith('http')) {
page.fmPreviewImage = imageValue;
} else {
let staticPath = join(wsFolder.fsPath, staticFolder || '', imageValue);
const contentFolderPath = join(dirname(filePath), fieldValue);
let previewUri = null;
if (await existsAsync(staticPath)) {
previewUri = Uri.file(staticPath);
} else if (await existsAsync(contentFolderPath)) {
previewUri = Uri.file(contentFolderPath);
}
if (previewUri) {
let previewPath = '';
const Webview = Dashboard.getWebview();
if (Webview) {
previewPath = Webview.asWebviewUri(previewUri).toString();
} else {
previewPath = PagesParser.getWebviewUri(previewUri).toString();
if (staticFolder === STATIC_FOLDER_PLACEHOLDER.hexo.placeholder) {
const crntFilePath = parseWinPath(filePath);
const pathWithoutExtension = crntFilePath.replace(extname(crntFilePath), '');
staticPath = join(pathWithoutExtension, imageValue);
}
page['fmPreviewImage'] = previewPath || '';
const contentFolderPath = join(dirname(filePath), imageValue);
let previewUri = null;
if (await existsAsync(staticPath)) {
previewUri = Uri.file(staticPath);
} else if (await existsAsync(contentFolderPath)) {
previewUri = Uri.file(contentFolderPath);
}
if (previewUri) {
let previewPath = '';
const Webview = Dashboard.getWebview();
if (Webview) {
previewPath = Webview.asWebviewUri(previewUri).toString();
} else {
previewPath = PagesParser.getWebviewUri(previewUri).toString();
}
page['fmPreviewImage'] = previewPath || '';
}
}
}
}

View File

@@ -2,6 +2,6 @@ import { SETTING_GLOBAL_TIMEZONE } from '../constants';
import { DateHelper, Settings } from '../helpers';
export const formatInTimezone = (date: Date, dateFormat: string) => {
const timezone = Settings.get<string>(SETTING_GLOBAL_TIMEZONE);
const timezone = Settings.get<string>(SETTING_GLOBAL_TIMEZONE) || 'UTC';
return DateHelper.formatInTimezone(date, dateFormat, timezone) || '';
};