Compare commits

..

10 Commits

Author SHA1 Message Date
Elio Struyf
1c0ce6a6f2 Merge pull request #1007 from estruyf/revert-960-copilot/fix-958
Revert "Fix variable frontmatter error - handle both string and object formats for preview image fields"
2026-03-14 11:16:55 +01:00
Elio Struyf
bdb2179e3e Revert "Fix variable frontmatter error - handle both string and object formats for preview image fields" 2026-03-14 11:16:37 +01:00
Elio Struyf
bd8cd1f1d6 Merge pull request #960 from estruyf/copilot/fix-958
Fix variable frontmatter error - handle both string and object formats for preview image fields
2026-03-14 11:16:27 +01:00
Elio Struyf
3b65bb3cd7 Remove BEJS Community sponsorship section from README.md 2026-01-26 14:30:51 +01:00
Elio Struyf
829c5c6e64 Remove run.events sponsorship section from README.md 2026-01-26 14:30:33 +01:00
Elio Struyf
e6ef7555e3 Update README.md to clarify focus on sustainable revenue for open source projects 2026-01-26 14:23:09 +01:00
Elio Struyf
2af6c57a49 Update README.md with 2026 Open Source Priorities and content improvements 2026-01-26 14:20:48 +01:00
copilot-swe-agent[bot]
a387d5eb89 Fix ContentType validation to handle non-string/non-array field values safely
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2025-06-20 12:48:08 +00:00
copilot-swe-agent[bot]
f1ae60f280 Fix variable frontmatter error - handle both string and object formats for preview image fields
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2025-06-20 12:46:44 +00:00
copilot-swe-agent[bot]
0e2aea626f Initial plan for issue 2025-06-20 12:36:28 +00:00
64 changed files with 513 additions and 2218 deletions

View File

@@ -1,30 +1,5 @@
# Change Log
## [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 *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
- [#969](https://github.com/estruyf/vscode-front-matter/issues/969): Fix typo on welcome screen
## [10.9.0] - 2025-07-01 - [Release notes](https://beta.frontmatter.codes/updates/v10.9.0)
### 🎨 Enhancements

View File

@@ -117,7 +117,7 @@ In version v2 we released the re-designed sidebar panel with improved SEO suppor
You can get the extension via:
- The VS Code marketplace: [VS Code Marketplace - Front Matter](https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-front-matter).
- The extension CLI: `code --install-extension eliostruyf.vscode-front-matter`
- The extension CLI: `ext install eliostruyf.vscode-front-matter`
- Or by clicking on the following link: <a href="" title="open extension in VS Code" data-vscode="vscode:extension/eliostruyf.vscode-front-matter">open extension in VS Code</a>
> **Info**: The docs can be found on [frontmatter.codes](https://frontmatter.codes).
@@ -129,7 +129,7 @@ If you have the courage to test out the beta features, we made available a beta
- Uninstall the main Front Matter version
- Install the beta version
- VS Code marketplace: [VS Code Marketplace - Front Matter BETA](https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-front-matter-beta).
- The extension CLI: `code --install-extension eliostruyf.vscode-front-matter-beta`
- The extension CLI: `ext install eliostruyf.vscode-front-matter-beta`
- Or by clicking on the following link: <a href="" title="open extension in VS Code" data-vscode="vscode:extension/eliostruyf.vscode-front-matter-beta">open extension in VS Code</a>
> **Info**: The BETA docs can be found on [beta.frontmatter.codes](https://beta.frontmatter.codes).

157
README.md
View File

@@ -4,7 +4,29 @@
</a>
</h1>
<h2 align="center">Front Matter a CMS running straight in Visual Studio Code</h2>
<h2 align="center">Front Matter - A Headless CMS for Visual Studio Code</h2>
> **📢 2026 Open Source Priorities Update**
>
> I love working with and creating open source products, but after careful
> evaluation and working with a coach, I've decided to focus my efforts on
> creating a better revenue stream. As open-source isn't providing me a
> sustainable income, I need to focus my time and effort more strategically on
> how to make my work sustainable.
>
> **Front Matter CMS will continue to be maintained** as I use it daily.
> However, major changes will only happen if there's a personal reason, a
> company commitment, or significant community support. Feature requests may
> take longer to be addressed.
>
> I'm shifting focus to open source projects that I can learn from or that have
> different outcomes, like **Demo Time**, which I use when presenting at
> conferences. If you or your company would like to sponsor my work on Front
> Matter CMS or other projects, I'd love to discuss how we can collaborate to
> make it even better!
>
> This is not about Front Matter CMS going away, but rather about managing
> expectations around feature development timelines.
<p align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-front-matter" title="Check it out on the Visual Studio Marketplace">
@@ -28,11 +50,17 @@
## ❓ What is Front Matter?
Front Matter is a CMS that runs within Visual Studio Code. It gives you the power and control of a full-blown CMS while also providing you the flexibility and speed of the static site generator of your choice. Jump right into editing and creating content with Front Matter and be able to preview it straight in VS Code.
Front Matter is a CMS that runs within Visual Studio Code. It gives you the
power and control of a full-blown CMS while also providing you the flexibility
and speed of the static site generator of your choice. Jump right into editing
and creating content with Front Matter and be able to preview it straight in VS
Code.
The extension supports various static-site generators and frameworks like Hugo, Jekyll, Hexo, NextJs, Gatsby, and more.
The extension supports various static-site generators and frameworks like Hugo,
Jekyll, Hexo, NextJs, Gatsby, and more.
A couple of our extension highlights that hopefully get you interested in giving Front Matter a try:
A couple of our extension highlights that hopefully get you interested in giving
Front Matter a try:
- Content, data, and media management
- Search, filter, sort, etc. all your content
@@ -41,30 +69,40 @@ A couple of our extension highlights that hopefully get you interested in giving
- Preview your site/content straight in Visual Studio Code
- SEO checks for title, description, and keywords
- Extensibility
- As we know, we cannot support all use cases. We provide a way to extend the functionality of the extension to your needs
- As we know, we cannot support all use cases. We provide a way to extend the
functionality of the extension to your needs
- and many more features ...
> Missing something? Let us know by opening an issue on the [GitHub repository](https://github.com/estruyf/vscode-front-matter/issues/new/choose)
> Missing something? Let us know by opening an issue on the
> [GitHub repository](https://github.com/estruyf/vscode-front-matter/issues/new/choose)
<p align="center">
<img src="https://frontmatter.codes/assets/marketplace/v6.0.0/content-preview.png" alt="Site preview" style="display: inline-block" />
</p>
> If you see something missing in your article creation flow, please feel free to reach out.
> If you see something missing in your article creation flow, please feel free
> to reach out.
**Version 10**
In version 10, we introduced the new i18n/multilingual support for your content. You can now manage your content in multiple languages, more information can be found in the [multilingual](https://frontmatter.codes/docs/content-creation/multilingual) section of our documentation.
In version 10, we introduced the new i18n/multilingual support for your content.
You can now manage your content in multiple languages, more information can be
found in the
[multilingual](https://frontmatter.codes/docs/content-creation/multilingual)
section of our documentation.
![Multilingual support](https://beta.frontmatter.codes/releases/v10.0.0/multilingual-content.png)
**Version 9**
The extension is now available in multiple languages: English, German, and Japanese. Want to add your language? Check out the [localization the extension](https://frontmatter.codes/docs/contributing#translating-the-extension).
The extension is now available in multiple languages: English, German, and
Japanese. Want to add your language? Check out the
[localization the extension](https://frontmatter.codes/docs/contributing#translating-the-extension).
**Version 8**
The taxonomy dashboard got introduced on which you can manage your tags, categories, and custom taxonomy.
The taxonomy dashboard got introduced on which you can manage your tags,
categories, and custom taxonomy.
![Taxonomy dashboard](https://frontmatter.codes/assets/marketplace/v8.1.0/taxonomy-dashboard.png)
@@ -76,17 +114,24 @@ Snippets support for Front Matter has been added!
**Version 6**
In this version, we introduced the new data files/folders dashboard. You can find more information about the release in our [v6.0.0 release notes](https://frontmatter.codes/updates/v6.0.0).
In this version, we introduced the new data files/folders dashboard. You can
find more information about the release in our
[v6.0.0 release notes](https://frontmatter.codes/updates/v6.0.0).
<p align="center">
<img src="https://frontmatter.codes/assets/marketplace/v6.0.0/data-dashboard.png" alt="Data dashboard" style="display: inline-block" />
</p>
> Data files/folders are pieces of content that do not belong to any markdown content, but live on their own. Most of the time, these data files are used to store additional information about your project/blog/website that will be used to render the content.
> Data files/folders are pieces of content that do not belong to any markdown
> content, but live on their own. Most of the time, these data files are used to
> store additional information about your project/blog/website that will be used
> to render the content.
**Version 5**
The new media dashboard redesign got introduced + support for setting metadata on media files [v5.0.0 release notes](https://frontmatter.codes/updates/v5.0.0).
The new media dashboard redesign got introduced + support for setting metadata
on media files
[v5.0.0 release notes](https://frontmatter.codes/updates/v5.0.0).
<p align="center">
<img src="https://frontmatter.codes/assets/marketplace/v5.9.0/media-dashboard.png" alt="Data dashboard" style="display: inline-block" />
@@ -94,15 +139,21 @@ The new media dashboard redesign got introduced + support for setting metadata o
**Version 4**
Support for Team level settings, content-types, and image support. Get to know more at: [v4.0.0 release notes](https://frontmatter.codes/updates/v4_0_0).
Support for Team level settings, content-types, and image support. Get to know
more at: [v4.0.0 release notes](https://frontmatter.codes/updates/v4_0_0).
**Version 3**
In version v3 we introduced the welcome and dashboard webview. The welcome view allows to get you started using the extension, and the dashboard allows you to manage all your markdown pages in one place. This makes it easy to search, filter, sort, and more.
In version v3 we introduced the welcome and dashboard webview. The welcome view
allows to get you started using the extension, and the dashboard allows you to
manage all your markdown pages in one place. This makes it easy to search,
filter, sort, and more.
**Version 2**
In version v2 we released the re-designed sidebar panel with improved SEO support. This extension makes it the only extension to manage your Markdown pages for your static sites in Visual Studio Code.
In version v2 we released the re-designed sidebar panel with improved SEO
support. This extension makes it the only extension to manage your Markdown
pages for your static sites in Visual Studio Code.
<p align="center" style="margin-top: 2rem;">
<a href="https://www.producthunt.com/posts/front-matter?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-front-matter" target="_blank">
@@ -114,33 +165,47 @@ In version v2 we released the re-designed sidebar panel with improved SEO suppor
You can get the extension via:
- The VS Code marketplace: [VS Code Marketplace - Front Matter](https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-front-matter).
- The extension CLI: `code --install-extension eliostruyf.vscode-front-matter`
- Or by clicking on the following link: <a href="" title="open extension in VS Code" data-vscode="vscode:extension/eliostruyf.vscode-front-matter">open extension in VS Code</a>
- The VS Code marketplace:
[VS Code Marketplace - Front Matter](https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-front-matter).
- The extension CLI: `ext install eliostruyf.vscode-front-matter`
- Or by clicking on the following link: <a href="" title="open extension in VS
Code" data-vscode="vscode:extension/eliostruyf.vscode-front-matter">open
extension in VS Code</a>
> **Info**: The docs can be found on [frontmatter.codes](https://frontmatter.codes).
> **Info**: The docs can be found on
> [frontmatter.codes](https://frontmatter.codes).
### 🧪 Beta version
If you have the courage to test out the beta features, we made available a beta version as well. You can install this via:
If you have the courage to test out the beta features, we made available a beta
version as well. You can install this via:
- Uninstall the main Front Matter version
- Install the beta version
- VS Code marketplace: [VS Code Marketplace - Front Matter BETA](https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-front-matter-beta).
- The extension CLI: `code --install-extension eliostruyf.vscode-front-matter-beta`
- Or by clicking on the following link: <a href="" title="open extension in VS Code" data-vscode="vscode:extension/eliostruyf.vscode-front-matter-beta">open extension in VS Code</a>
- VS Code marketplace:
[VS Code Marketplace - Front Matter BETA](https://marketplace.visualstudio.com/items?itemName=eliostruyf.vscode-front-matter-beta).
- The extension CLI: `ext install eliostruyf.vscode-front-matter-beta`
- Or by clicking on the following link: <a href="" title="open extension in VS
Code"
data-vscode="vscode:extension/eliostruyf.vscode-front-matter-beta">open
extension in VS Code</a>
> **Info**: The BETA docs can be found on [beta.frontmatter.codes](https://beta.frontmatter.codes).
> **Info**: The BETA docs can be found on
> [beta.frontmatter.codes](https://beta.frontmatter.codes).
## 📖 Documentation
All documentation can be found on [frontmatter.codes](https://frontmatter.codes).
All documentation can be found on
[frontmatter.codes](https://frontmatter.codes).
Documentation repository: [GitHub - Front Matter DOCs](https://github.com/FrontMatter/web-documentation-nextjs)
Documentation repository:
[GitHub - Front Matter DOCs](https://github.com/FrontMatter/web-documentation-nextjs)
## 💪 Contributing
Pull requests are welcome. Please open an issue first to discuss what you would like to change, or which problem you would like to fix. This makes it easier for us to follow-up and plan for future releases.
Pull requests are welcome. Please open an issue first to discuss what you would
like to change, or which problem you would like to fix. This makes it easier for
us to follow-up and plan for future releases.
You can always help us improve the extension in varous ways like:
@@ -153,7 +218,8 @@ You can always help us improve the extension in varous ways like:
- Tutorials
- etc.
Eager to start contributing? Great 🤩, you can contribute to the following projects:
Eager to start contributing? Great 🤩, you can contribute to the following
projects:
- [Extension](https://github.com/estruyf/vscode-front-matter)
- [Documentation](https://github.com/FrontMatter/web-documentation-nextjs)
@@ -161,13 +227,16 @@ Eager to start contributing? Great 🤩, you can contribute to the following pro
## 👀 Show the work you are using Front Matter
Are you using Front Matter and are you interested in showing for which websites you use it? You can show your work by opening a [showcase issue](https://github.com/estruyf/vscode-front-matter/issues/new?assignees=&labels=&template=showcase.md&title=Showcase%3A+).
Are you using Front Matter and are you interested in showing for which websites
you use it? You can show your work by opening a
[showcase issue](https://github.com/estruyf/vscode-front-matter/issues/new?assignees=&labels=&template=showcase.md&title=Showcase%3A+).
You can open showcase issues for the following things:
- Show the website for which you use Front Matter;
- Share an article/video/webcast/... that explains how you use Front Matter;
- Got something else to share? Open an issue and we can see where it fits on our website.
- Got something else to share? Open an issue and we can see where it fits on our
website.
## 👉 Contributors 🤘
@@ -185,33 +254,23 @@ You can open showcase issues for the following things:
<br />
<p align="center" title="Support by run.events">
<a href="https://run.events/?utm_source=frontmatter&utm_campaign=oss">
<img src="https://frontmatter.codes/assets/sponsors/runevents-purple.webp" alt="run.events - Event Management Platform" height="50px" />
</a>
</p>
<br />
<p align="center" title="Powered by Netlify">
<a href="https://www.netlify.com?utm_source=vscode-frontmatter&utm_campaign=oss">
<img src="https://frontmatter.codes/assets/sponsors/netlify-dark.png" alt="Deploys by Netlify" height="51px" />
</a>
</p>
<br />
<p align="center">
<a href="http://bejs.io/" title="Supported by the BEJS Community">
<img src="https://frontmatter.codes/assets/sponsors/bejs-community.png" alt="Supported by the BEJS Community" height="50px"/>
</a>
</p>
## 📊 Telemetry
The Front Matter CMS extension only uses telemetry on application crashes. The extension respects the `telemetry.enableTelemetry` setting which you can learn more about in the [Visual Studio Code FAQ](https://aka.ms/vscode-remote/telemetry).
The Front Matter CMS extension only uses telemetry on application crashes. The
extension respects the `telemetry.enableTelemetry` setting which you can learn
more about in the
[Visual Studio Code FAQ](https://aka.ms/vscode-remote/telemetry).
For crash reports in the webviews, we make use of Sentry to help us understand what went wrong. This data is only used to fix issues and improve the extension. You can find more information about the Sentry implementation in the following files:
For crash reports in the webviews, we make use of Sentry to help us understand
what went wrong. This data is only used to fix issues and improve the extension.
You can find more information about the Sentry implementation in the following
files:
- [Sentry config](https://github.com/estruyf/vscode-front-matter/blob/63e296d62f11be73ac86d9e823084247952a7ddc/src/utils/sentryInit.ts)

View File

@@ -109,7 +109,6 @@
"dashboard.header.tabs.taxonomies": "Taxonomien",
"dashboard.header.viewSwitch.toGrid": "Zur Rasteransicht wechseln",
"dashboard.header.viewSwitch.toList": "Zur Listenansicht wechseln",
"dashboard.header.viewSwitch.toStructure": "Zur Strukturansicht wechseln",
"dashboard.layout.sponsor.support.msg": "Unterstützen Sie Front Matter",
"dashboard.layout.sponsor.review.label": "Bewerten",
"dashboard.layout.sponsor.review.msg": "Bewerten Sie Front Matter",

View File

@@ -109,7 +109,6 @@
"dashboard.header.tabs.taxonomies": "Taxonomies",
"dashboard.header.viewSwitch.toGrid": "Afficher en grille",
"dashboard.header.viewSwitch.toList": "Afficher en liste",
"dashboard.header.viewSwitch.toStructure": "Afficher en structure",
"dashboard.layout.sponsor.support.msg": "Soutenir Front Matter",
"dashboard.layout.sponsor.review.label": "Donnez votre avis",
"dashboard.layout.sponsor.review.msg": "Donnez votre avis sur Front Matter",

View File

@@ -214,7 +214,6 @@
"dashboard.header.viewSwitch.toGrid": "グリッド表示",
"dashboard.header.viewSwitch.toList": "リスト表示",
"dashboard.header.viewSwitch.toStructure": "構造表示",
"dashboard.layout.sponsor.support.msg": "Front Matterをサポートする",
"dashboard.layout.sponsor.review.label": "評価する",

View File

@@ -222,7 +222,6 @@
"dashboard.header.viewSwitch.toGrid": "Change to grid",
"dashboard.header.viewSwitch.toList": "Change to list",
"dashboard.header.viewSwitch.toStructure": "Change to structure",
"dashboard.layout.sponsor.support.msg": "Support Front Matter",
"dashboard.layout.sponsor.review.label": "Review",
@@ -333,7 +332,7 @@
"dashboard.steps.stepsToGetStarted.tags.name": "Import all tags and categories (optional)",
"dashboard.steps.stepsToGetStarted.tags.description": "Now that Front Matter knows all the content folders. Would you like to import all tags and categories from the available content?",
"dashboard.steps.stepsToGetStarted.git.name": "Do you want to enable Git synchronization?",
"dashboard.steps.stepsToGetStarted.git.description": "Enable Git synchronization to easily sync your changes with your repository.",
"dashboard.steps.stepsToGetStarted.git.description": "Enable Git synchronization to eaily sync your changes with your repository.",
"dashboard.steps.stepsToGetStarted.showDashboard.name": "Show the dashboard",
"dashboard.steps.stepsToGetStarted.showDashboard.description": "Once all actions are completed, the dashboard can be loaded.",
"dashboard.steps.stepsToGetStarted.template.name": "Use a configuration template",
@@ -505,7 +504,6 @@
"panel.seoKeywords.density": "Keyword density",
"panel.seoKeywordInfo.validInfo.label": "Heading(s)",
"panel.seoKeywordInfo.validInfo.content": "Content",
"panel.seoKeywordInfo.validInfo.firstParagraph": "First paragraph",
"panel.seoKeywordInfo.density.tooltip": "Recommended frequency: 0.75% - 1.5%",
"panel.seoKeywords.title": "Keywords",

View File

@@ -222,7 +222,6 @@
"dashboard.header.viewSwitch.toGrid": "切换到网格视图",
"dashboard.header.viewSwitch.toList": "切换到列表视图",
"dashboard.header.viewSwitch.toStructure": "切换到结构视图",
"dashboard.layout.sponsor.support.msg": "支持 Front Matter",
"dashboard.layout.sponsor.review.label": "评价",

1
package-lock.json generated
View File

@@ -18041,7 +18041,6 @@
"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,6 +139,12 @@
}
}
},
"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%",
@@ -2104,12 +2110,6 @@
"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,6 +2391,15 @@
"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%",
@@ -2557,6 +2566,11 @@
"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": [
@@ -2766,6 +2780,11 @@
"group": "navigation@-1",
"when": "view == frontMatter.explorer"
},
{
"command": "frontMatter.chatbot",
"group": "navigation@0",
"when": "view == frontMatter.explorer"
},
{
"command": "frontMatter.mode.switch",
"group": "navigation@1",
@@ -2795,7 +2814,10 @@
},
"languages": [
{
"id": "frontmatter.project.output"
"id": "frontmatter.project.output",
"mimetypes": [
"text/x-code-output"
]
}
],
"grammars": [

View File

@@ -52,6 +52,7 @@
"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,6 +53,7 @@
"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,6 +55,7 @@
"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)",
@@ -276,7 +277,6 @@
"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,6 +55,7 @@
"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)",

126
src/commands/Chatbot.ts Normal file
View File

@@ -0,0 +1,126 @@
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,7 +54,6 @@ export class Preview {
return;
}
const integratedBrowserCommand = await this.getIntegratedBrowserCommand();
const browserLiteCommand = await this.getBrowserLiteCommand();
const editor = window.activeTextEditor;
@@ -70,12 +69,6 @@ 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);
@@ -375,17 +368,6 @@ 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,39 +4,22 @@ import {
EXTENSION_NAME,
NOTIFICATION_TYPE,
SETTING_SEO_DESCRIPTION_LENGTH,
SETTING_SEO_TITLE_LENGTH,
SETTING_VALIDATION_ENABLED
SETTING_SEO_TITLE_LENGTH
} from './../constants';
import * as vscode from 'vscode';
import {
ArticleHelper,
Notifications,
SeoHelper,
Settings,
FrontMatterValidator,
ValidationError
} from '../helpers';
import { ArticleHelper, Notifications, SeoHelper, Settings } 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
*
@@ -87,12 +70,6 @@ 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);
}
}
}
@@ -196,245 +173,6 @@ 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,6 +1,7 @@
export * from './Article';
export * from './Backers';
export * from './Cache';
export * from './Chatbot';
export * from './Content';
export * from './Dashboard';
export * from './Diagnostics';

View File

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

View File

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

View File

@@ -1,68 +0,0 @@
import { XCircleIcon } from '@heroicons/react/24/solid';
import * as React from 'react';
export interface INumberFieldProps {
name: string;
value?: string;
placeholder?: string;
description?: string;
icon?: JSX.Element;
disabled?: boolean;
autoFocus?: boolean;
onChange?: (value: string) => void;
onReset?: () => void;
}
export const NumberField: React.FunctionComponent<INumberFieldProps> = ({
name,
value,
placeholder,
description,
icon,
autoFocus,
disabled,
onChange,
onReset
}: React.PropsWithChildren<INumberFieldProps>) => {
return (
<>
<div className="relative flex justify-center">
{
icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
{icon}
</div>
)
}
<input
type="number"
name={name}
className={`block w-full py-2 ${icon ? "pl-10" : "pl-2"} pr-2 sm:text-sm appearance-none disabled:opacity-50 rounded bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] placeholder-[var(--vscode-input-placeholderForeground)] border-[var(--frontmatter-border)] focus:border-[var(--vscode-focusBorder)] focus:outline-0`}
style={{
boxShadow: "none"
}}
placeholder={placeholder || ""}
value={value}
autoFocus={!!autoFocus}
onChange={(e) => onChange && onChange(e.target.value)}
disabled={!!disabled}
/>
{(value && onReset) && (
<button onClick={onReset} className="absolute inset-y-0 right-0 pr-3 flex items-center text-[var(--vscode-input-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]">
<XCircleIcon className={`h-5 w-5`} aria-hidden="true" />
</button>
)}
</div>
{
description && (
<p className="text-xs text-[var(--vscode--settings-headerForeground)] opacity-75 mt-2 mx-2">
{description}
</p>
)
}
</>
);
};

View File

@@ -1,12 +1,5 @@
import { messageHandler } from '@estruyf/vscode/dist/client';
import {
EyeIcon,
GlobeEuropeAfricaIcon,
TrashIcon,
LanguageIcon,
EllipsisHorizontalIcon,
ArrowRightCircleIcon
} from '@heroicons/react/24/outline';
import { EyeIcon, GlobeEuropeAfricaIcon, TrashIcon, LanguageIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline';
import * as React from 'react';
import { CustomScript, I18nConfig } from '../../../models';
import { DashboardMessage } from '../../DashboardMessage';
@@ -65,11 +58,6 @@ 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);
@@ -134,11 +122,6 @@ 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,7 +14,6 @@ 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';
@@ -29,14 +28,12 @@ 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);
}, []);
@@ -49,29 +46,13 @@ export const Contents: React.FunctionComponent<IContentsProps> = ({
setSelectedItemAction(undefined);
}, [page]);
const onMoveConfirm = useCallback((destinationFolder: string) => {
if (page) {
Messenger.send(DashboardMessage.moveFile, {
filePath: page.fmFilePath,
destinationFolder
});
}
setShowMoveDialog(false);
setSelectedItemAction(undefined);
}, [page]);
useEffect(() => {
if (selectedItemAction && selectedItemAction.path) {
const pageItem = pageItems.find((p) => p.fmFilePath === selectedItemAction.path);
if (selectedItemAction && selectedItemAction.path && selectedItemAction.action === 'delete') {
const page = pageItems.find((p) => p.fmFilePath === selectedItemAction.path);
if (pageItem) {
setPage(pageItem);
if (selectedItemAction.action === 'delete') {
setShowDeletionAlert(true);
} else if (selectedItemAction.action === 'move') {
setShowMoveDialog(true);
}
if (page) {
setPage(page);
setShowDeletionAlert(true);
}
setSelectedItemAction(undefined);
@@ -104,15 +85,6 @@ 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

@@ -17,8 +17,6 @@ export const List: React.FunctionComponent<IListProps> = ({
className = `grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-5 gap-4`;
} else if (view === DashboardViewType.List) {
className = `-mx-4`;
} else if (view === DashboardViewType.Structure) {
className = `structure-view`;
}
return (

View File

@@ -9,7 +9,6 @@ import { GroupOption } from '../../constants/GroupOption';
import { GroupingSelector, PageAtom, PagedItems, ViewSelector } from '../../state';
import { Item } from './Item';
import { List } from './List';
import { StructureView } from './StructureView';
import usePagination from '../../hooks/usePagination';
import { LocalizationKey, localize } from '../../../localization';
import { PinnedItemsAtom } from '../../state/atom/PinnedItems';
@@ -146,21 +145,16 @@ export const Overview: React.FunctionComponent<IOverviewProps> = ({
/>
{settings && settings?.contentFolders?.length > 0 ? (
<p className={`text-xl font-medium`}>{localize(LocalizationKey.dashboardContentsOverviewNoMarkdown)}</p>
) : (
<p className={`text-lg font-medium`}>{localize(LocalizationKey.dashboardContentsOverviewNoFolders)}</p>
)}
</div>
</div>
);
}
// Handle Structure view first - it overrides all other display modes
if (view === DashboardViewType.Structure) {
return <StructureView pages={pages} />;
}
if (grouping !== GroupOption.none) {
return (
<>
@@ -202,7 +196,7 @@ export const Overview: React.FunctionComponent<IOverviewProps> = ({
<h1 className='text-xl flex space-x-2 items-center mb-4'>
<PinIcon className={`-rotate-45`} />
<span>{localize(LocalizationKey.dashboardContentsOverviewPinned)}</span>
</h1>
<List>
{pinnedPages.map((page, idx) => (

View File

@@ -1,79 +0,0 @@
import { useRecoilValue } from 'recoil';
import { MarkdownIcon } from '../../../panelWebView/components/Icons/MarkdownIcon';
import { Page } from '../../models/Page';
import { SettingsSelector } from '../../state';
import { DateField } from '../Common/DateField';
import { ContentActions } from './ContentActions';
import { useMemo } from 'react';
import { Status } from './Status';
import * as React from 'react';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import useCard from '../../hooks/useCard';
import { ItemSelection } from '../Common/ItemSelection';
import { openFile } from '../../utils';
import useSelectedItems from '../../hooks/useSelectedItems';
import { cn } from '../../../utils/cn';
export interface IStructureItemProps extends Page { }
export const StructureItem: React.FunctionComponent<IStructureItemProps> = ({
...pageData
}: React.PropsWithChildren<IStructureItemProps>) => {
const { selectedFiles } = useSelectedItems();
const settings = useRecoilValue(SettingsSelector);
const draftField = useMemo(() => settings?.draftField, [settings]);
const { escapedTitle } = useCard(pageData, settings?.dashboardState?.contents?.cardFields);
const isSelected = useMemo(() => selectedFiles.includes(pageData.fmFilePath), [selectedFiles, pageData.fmFilePath]);
const onOpenFile = React.useCallback(() => {
openFile(pageData.fmFilePath);
}, [pageData.fmFilePath]);
return (
<div className="relative">
<div
className={cn(
`flex items-center space-x-3 py-1 px-2 rounded cursor-pointer hover:bg-[var(--vscode-list-hoverBackground)] text-[var(--vscode-editor-foreground)]`,
isSelected && `bg-[var(--vscode-list-activeSelectionBackground)]`
)}
>
<ItemSelection filePath={pageData.fmFilePath} show />
<MarkdownIcon className="w-4 h-4 text-[var(--vscode-symbolIcon-fileForeground)] flex-shrink-0" />
<button
title={escapedTitle ? l10n.t(LocalizationKey.commonOpenWithValue, escapedTitle) : l10n.t(LocalizationKey.commonOpen)}
onClick={onOpenFile}
className="flex-1 text-left truncate font-medium"
>
{escapedTitle}
</button>
<div className="flex items-center space-x-2 flex-shrink-0">
{pageData.date && (
<DateField
value={pageData.date}
format={pageData.fmDateFormat}
className="text-xs text-[var(--vscode-descriptionForeground)]"
/>
)}
{draftField && draftField.name && typeof pageData[draftField.name] !== "undefined" && (
<Status draft={pageData[draftField.name]} published={pageData.fmPublished} />
)}
<ContentActions
path={pageData.fmFilePath}
relPath={pageData.fmRelFileWsPath}
contentType={pageData.fmContentType}
scripts={settings?.scripts}
onOpen={onOpenFile}
listView
/>
</div>
</div>
</div>
);
};

View File

@@ -1,321 +0,0 @@
import { Disclosure } from '@headlessui/react';
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[];
}
interface FolderNode {
name: string;
path: string;
children: FolderNode[];
pages: Page[];
}
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: '',
path: '',
children: [],
pages: []
};
const folderMap = new Map<string, FolderNode>();
folderMap.set('', root);
// 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 '';
}
const fmFolder = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
// 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: use fmFolder title if we can't determine the path
return fmFolder;
};
// First pass: create all folder nodes (ensure nodes exist even if a page lacks fmFilePath)
for (const page of pages) {
if (!page.fmFolder) {
continue;
}
const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalizedPath) {
continue;
}
const parts = normalizedPath.split('/').filter(part => part.length > 0);
let currentPath = '';
let currentNode = root;
for (const part of parts) {
const fullPath = currentPath ? `${currentPath}/${part}` : part;
if (!folderMap.has(fullPath)) {
const newNode: FolderNode = {
name: part,
path: fullPath,
children: [],
pages: []
};
folderMap.set(fullPath, newNode);
currentNode.children.push(newNode);
}
const nextNode = folderMap.get(fullPath);
if (nextNode) {
currentNode = nextNode;
}
currentPath = fullPath;
}
}
// Second pass: assign pages to their exact folder node (including subfolders)
for (const page of pages) {
if (!page.fmFolder) {
root.pages.push(page);
continue;
}
const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const folderNode = normalizedPath ? folderMap.get(normalizedPath) : folderMap.get(page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''));
if (folderNode) {
folderNode.pages.push(page);
} else {
// If folder not found, add to root as fallback
root.pages.push(page);
}
}
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;
if (!hasContent) {
return null;
}
const isRoot = depth === 0;
const paddingLeft = depth * 20;
if (isRoot) {
// For root node, render children and pages directly
return (
<div className='space-y-4'>
{/* Root level folders */}
{node.children.map(child => renderFolderNode(child, depth + 1))}
{/* Root level pages */}
{node.pages.length > 0 && (
<div>
<h3 className="text-lg font-medium mb-3 text-[var(--vscode-editor-foreground)]">
Root Files
</h3>
<ul className="space-y-2">
{node.pages.map((page, idx) => (
<li key={`${page.slug}-${idx}`}>
<StructureItem {...page} />
</li>
))}
</ul>
</div>
)}
</div>
);
}
return (
<div key={node.path} className="mb-4">
<Disclosure defaultOpen={depth <= 1}>
{({ open }) => (
<>
<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 */}
{node.children.map(child => renderFolderNode(child, depth + 1))}
{/* Pages in this folder */}
{node.pages.length > 0 && (
<ul className="space-y-1 mb-3">
{node.pages.map((page, idx) => (
<li key={`${page.slug}-${idx}`} style={{ paddingLeft: `${paddingLeft + 20}px` }}>
<StructureItem {...page} />
</li>
))}
</ul>
)}
</Disclosure.Panel>
</>
)}
</Disclosure>
</div>
);
};
return (
<div className="structure-view">
{/* 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

@@ -2,11 +2,10 @@ import * as React from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import usePagination from '../../hooks/usePagination';
import { MediaTotalSelector, PageAtom, SettingsAtom, ViewSelector } from '../../state';
import { MediaTotalSelector, PageAtom, SettingsAtom } from '../../state';
import { PaginationButton } from './PaginationButton';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import { DashboardViewType } from '../../models';
export interface IPaginationProps {
totalPages?: number;
@@ -18,7 +17,6 @@ export const Pagination: React.FunctionComponent<IPaginationProps> = ({
const [page, setPage] = useRecoilState(PageAtom);
const totalMedia = useRecoilValue(MediaTotalSelector);
const settings = useRecoilValue(SettingsAtom);
const view = useRecoilValue(ViewSelector);
const { pageSetNr, totalPagesNr } = usePagination(
settings?.dashboardState.contents.pagination,
totalPages,
@@ -35,17 +33,17 @@ export const Pagination: React.FunctionComponent<IPaginationProps> = ({
if (i >= 0 && i <= totalPagesNr) {
buttons.push(
<button
key={i}
disabled={i === page}
onClick={() => {
setPage(i);
}}
className={`max-h-8 rounded ${page === i
? `px-2 bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]`
: `text-[var(--vscode-editor-foreground)] hover:text-[var(--vscode-list-activeSelectionForeground)]`}`}
>
{i + 1}
</button>
key={i}
disabled={i === page}
onClick={() => {
setPage(i);
}}
className={`max-h-8 rounded ${page === i
? `px-2 bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]`
: `text-[var(--vscode-editor-foreground)] hover:text-[var(--vscode-list-activeSelectionForeground)]`}`}
>
{i + 1}
</button>
);
}
}
@@ -60,10 +58,6 @@ export const Pagination: React.FunctionComponent<IPaginationProps> = ({
setPage(0);
}, []);
if (view === DashboardViewType.Structure) {
return null;
}
return (
<div className="flex justify-between items-center sm:justify-end space-x-2 text-sm">
<PaginationButton

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { ViewAtom, SettingsSelector } from '../../state';
import { Bars4Icon, Squares2X2Icon, FolderIcon } from '@heroicons/react/24/solid';
import { Bars4Icon, Squares2X2Icon } from '@heroicons/react/24/solid';
import { Messenger } from '@estruyf/vscode/dist/client';
import { DashboardMessage } from '../../DashboardMessage';
import { DashboardViewType } from '../../models';
@@ -16,7 +16,9 @@ export const ViewSwitch: React.FunctionComponent<IViewSwitchProps> = (
const [view, setView] = useRecoilState(ViewAtom);
const settings = useRecoilValue(SettingsSelector);
const handleViewChange = (newView: DashboardViewType) => {
const toggleView = () => {
const newView =
view === DashboardViewType.Grid ? DashboardViewType.List : DashboardViewType.Grid;
setView(newView);
Messenger.send(DashboardMessage.setPageViewType, newView);
};
@@ -34,7 +36,7 @@ export const ViewSwitch: React.FunctionComponent<IViewSwitchProps> = (
}`}
title={l10n.t(LocalizationKey.dashboardHeaderViewSwitchToGrid)}
type={`button`}
onClick={() => handleViewChange(DashboardViewType.Grid)}
onClick={toggleView}
>
<Squares2X2Icon className={`w-4 h-4`} />
<span className={`sr-only`}>
@@ -42,29 +44,17 @@ export const ViewSwitch: React.FunctionComponent<IViewSwitchProps> = (
</span>
</button>
<button
className={`flex items-center px-2 py-1 ${view === DashboardViewType.List ? `bg-[var(--frontmatter-button-background)] text-[var(--vscode-button-foreground)]` : 'text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]'
className={`flex items-center px-2 py-1 rounded-r-sm ${view === DashboardViewType.List ? `bg-[var(--frontmatter-button-background)] text-[var(--vscode-button-foreground)]` : 'text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]'
}`}
title={l10n.t(LocalizationKey.dashboardHeaderViewSwitchToList)}
type={`button`}
onClick={() => handleViewChange(DashboardViewType.List)}
onClick={toggleView}
>
<Bars4Icon className={`w-4 h-4`} />
<span className={`sr-only`}>
{l10n.t(LocalizationKey.dashboardHeaderViewSwitchToList)}
</span>
</button>
<button
className={`flex items-center px-2 py-1 rounded-r-sm ${view === DashboardViewType.Structure ? `bg-[var(--frontmatter-button-background)] text-[var(--vscode-button-foreground)]` : 'text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]'
}`}
title={l10n.t(LocalizationKey.dashboardHeaderViewSwitchToStructure)}
type={`button`}
onClick={() => handleViewChange(DashboardViewType.Structure)}
>
<FolderIcon className={`w-4 h-4`} />
<span className={`sr-only`}>
{l10n.t(LocalizationKey.dashboardHeaderViewSwitchToStructure)}
</span>
</button>
</div>
);
};

View File

@@ -1,175 +0,0 @@
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,6 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { Choice, SnippetField, SnippetInfoField } from '../../../models';
import { useEffect } from 'react';
import { TextField } from '../Common/TextField';
import { NumberField } from '../Common/NumberField';
export interface ISnippetInputFieldProps {
field: SnippetField;
@@ -79,17 +78,6 @@ export const SnippetInputField: React.FunctionComponent<ISnippetInputFieldProps>
);
}
if (field.type === 'number') {
return (
<NumberField
name={field.name}
value={field.value as string || ''}
description={field.description}
onChange={(e) => onValueChange(field, e)}
/>
);
}
return (
<TextField
name={field.name}

View File

@@ -21,7 +21,9 @@ import { DEFAULT_DASHBOARD_FEATURE_FLAGS } from '../../../constants/DefaultFeatu
export interface ISnippetsProps { }
export const Snippets: React.FunctionComponent<ISnippetsProps> = () => {
export const Snippets: React.FunctionComponent<ISnippetsProps> = (
_: React.PropsWithChildren<ISnippetsProps>
) => {
const settings = useRecoilValue(SettingsSelector);
const viewData = useRecoilValue(ViewDataSelector);
const mode = useRecoilValue(ModeAtom);

View File

@@ -1,5 +1,4 @@
export enum DashboardViewType {
Grid = 1,
List,
Structure
List
}

View File

@@ -1,4 +1,4 @@
import { ContentFolder, I18nConfig } from '../../models';
import { I18nConfig } from '../../models';
export interface Page {
// Properties for caching
@@ -20,16 +20,15 @@ export interface Page {
fmCategories: string[];
fmContentType: string;
fmDateFormat: string | undefined;
fmPageFolder: ContentFolder | undefined;
// i18n fields
fmDefaultLocale?: boolean;
fmLocale?: I18nConfig;
fmTranslations?: {
fmTranslations?: {
[locale: string]: {
locale: I18nConfig;
path: string;
};
}
};
title: string;

View File

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

View File

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

View File

@@ -22,7 +22,6 @@ 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

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

View File

@@ -8,7 +8,6 @@ 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,6 +30,7 @@ import {
Article,
Settings,
StatusListener,
Chatbot,
Taxonomy
} from './commands';
import { join } from 'path';
@@ -193,12 +194,17 @@ export async function activate(context: vscode.ExtensionContext) {
subscriptions.push(
vscode.commands.registerCommand(COMMAND_NAME.docs, () => {
vscode.commands.executeCommand(
`workbench.action.browser.open`,
`simpleBrowser.show`,
`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,17 +149,12 @@ 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> {
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}`);
const file = await workspace.fs.readFile(Uri.file(parseWinPath(filePath)));
if (!file) {
return undefined;
}
return new TextDecoder().decode(file);
}
/**
@@ -812,8 +807,7 @@ export class ArticleHelper {
const elms: Parent[] | Link[] = this.getAllElms(mdTree);
const headings = elms.filter((node) => node.type === 'heading');
const paragraphNodes = elms.filter((node) => node.type === 'paragraph');
const paragraphs = paragraphNodes.length;
const paragraphs = elms.filter((node) => node.type === 'paragraph').length;
const images = elms.filter((node) => node.type === 'image').length;
const links: string[] = elms
.filter((node) => node.type === 'link')
@@ -842,21 +836,6 @@ export class ArticleHelper {
}
}
// Extract first paragraph text for SEO keyword checking
let firstParagraph = '';
if (paragraphNodes.length > 0) {
const firstParagraphNode = paragraphNodes[0];
const extractTextFromNode = (node: any): string => {
if (node.type === 'text') {
return node.value || '';
} else if (node.children && Array.isArray(node.children)) {
return node.children.map(extractTextFromNode).join('');
}
return '';
};
firstParagraph = extractTextFromNode(firstParagraphNode);
}
const wordCount = this.wordCount(0, mdTree);
return {
@@ -867,8 +846,7 @@ export class ArticleHelper {
internalLinks,
externalLinks: externalLinks.length,
wordCount,
content: article.content,
firstParagraph
content: article.content
};
}

View File

@@ -34,6 +34,7 @@ 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';
@@ -407,7 +408,7 @@ export class ContentType {
* @param parents
* @returns
*/
public static getFieldValue(data: any, parents: string[]): any {
public static getFieldValue(data: any, parents: string[]): string | string[] {
let fieldValue = [];
let crntPageData = data;
@@ -574,8 +575,7 @@ export class ContentType {
fieldValue === null ||
fieldValue === undefined ||
fieldValue === '' ||
(Array.isArray(fieldValue) && fieldValue.length === 0) ||
(typeof fieldValue === 'string' && fieldValue.length === 0) ||
fieldValue.length === 0 ||
fieldValue === DefaultFieldValues.faultyCustomPlaceholder
) {
emptyFields.push(fields);
@@ -956,25 +956,8 @@ export class ContentType {
let templatePath = contentType.template;
let templateData: ParsedFrontMatter | null | undefined = null;
if (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}`
);
}
templatePath = Folders.getAbsFilePath(templatePath);
templateData = await ArticleHelper.getFrontMatterByPath(templatePath);
}
const newFilePath: string | undefined = await ArticleHelper.createContent(

View File

@@ -1,370 +0,0 @@
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

@@ -1,216 +0,0 @@
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, 'frontmatter.project.output');
Logger.channel = window.createOutputChannel(displayName);
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 = MediaLibrary.parsePath(id);
const fileId = this.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 = MediaLibrary.parsePath(id);
const fileId = this.parsePath(id);
this.db?.push(fileId, metadata, true);
}
public async rename(oldId: string, newId: string): Promise<void> {
const fileId = MediaLibrary.parsePath(oldId);
const newFileId = MediaLibrary.parsePath(newId);
const fileId = this.parsePath(oldId);
const newFileId = this.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 = MediaLibrary.parsePath(path);
const fileId = this.parsePath(path);
await this.db?.delete(fileId);
}
@@ -183,12 +183,9 @@ export class MediaLibrary {
}
}
public static parsePath(path: string) {
public parsePath(path: string) {
const wsFolder = Folders.getWorkspaceFolder();
let absPath = parseWinPath(path).replace(
parseWinPath(wsFolder?.fsPath || ''),
WORKSPACE_PLACEHOLDER
);
let absPath = path.replace(parseWinPath(wsFolder?.fsPath || ''), WORKSPACE_PLACEHOLDER);
absPath = isWindows() ? absPath.split('\\').join('/') : absPath;
return absPath.toLowerCase();
}

View File

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

View File

@@ -1,8 +1,11 @@
import { QuickPickItem, QuickPickItemKind, window } from 'vscode';
import { authentication, 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';
@@ -37,28 +40,56 @@ 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 (isCopilotInstalled) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPlaceholder),
ignoreFocusOut: true
});
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 (title) {
try {
aiTitles = await Copilot.suggestTitles(title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleCopilotInputFailed)
);
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;
}
}
} 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;
}
}
}
}

View File

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

View File

@@ -75,30 +75,25 @@ 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 = MediaLibrary.parsePath(msg.folder);
const mediaFiles = await mediaLib.getAllByPath(parsedPath);
const mediaLib = MediaLibrary.getInstance();
const parsedPath = mediaLib.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);
}
await workspace.fs.delete(Uri.file(msg.folder), { recursive: true, useTrash: false });
await MediaListener.sendMediaFiles(0, folderPath);
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);
});
}
public static async updateMediaFolder(msg: {
@@ -110,48 +105,41 @@ export class MediaListener extends BaseListener {
return;
}
window.withProgress(
{
location: ProgressLocation.Notification,
title: localize(
LocalizationKey.listenersDashboardMediaListenersUpdateMediaFolderProgressTitle
),
cancellable: false
},
async () => {
const folderName = parse(msg.folder).base;
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
});
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);
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 = 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);
});
}
/**
@@ -217,7 +205,7 @@ export class MediaListener extends BaseListener {
for (const file of filesEndingWith) {
const absPath = FilesHelper.relToAbsPath(file);
if (!(await existsAsync(absPath))) {
const parsedPath = MediaLibrary.parsePath(absPath);
const parsedPath = mediaLib.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, join } from 'path';
import { basename } from 'path';
import { commands, FileSystemWatcher, RelativePattern, TextDocument, Uri, workspace } from 'vscode';
import { Dashboard } from '../../commands/Dashboard';
import { Folders } from '../../commands/Folders';
@@ -12,24 +12,13 @@ import {
import { DashboardCommand } from '../../dashboardWebView/DashboardCommand';
import { DashboardMessage } from '../../dashboardWebView/DashboardMessage';
import { Page } from '../../dashboardWebView/models';
import { ContentFolder } from '../../models/ContentFolder';
import {
ArticleHelper,
Extension,
Logger,
parseWinPath,
Settings,
ContentType,
Notifications
} from '../../helpers';
import { ArticleHelper, Extension, Logger, parseWinPath, Settings } 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 } = {};
@@ -56,9 +45,6 @@ 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;
@@ -71,9 +57,6 @@ export class PagesListener extends BaseListener {
case DashboardMessage.rename:
ArticleHelper.rename(msg.payload);
break;
case DashboardMessage.moveFile:
await this.moveFile(msg.payload);
break;
}
}
@@ -323,246 +306,6 @@ 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

@@ -727,10 +727,6 @@ export enum LocalizationKey {
* Change to list
*/
dashboardHeaderViewSwitchToList = 'dashboard.header.viewSwitch.toList',
/**
* Change to structure
*/
dashboardHeaderViewSwitchToStructure = 'dashboard.header.viewSwitch.toStructure',
/**
* Support Front Matter
*/
@@ -1112,7 +1108,7 @@ export enum LocalizationKey {
*/
dashboardStepsStepsToGetStartedGitName = 'dashboard.steps.stepsToGetStarted.git.name',
/**
* Enable Git synchronization to easily sync your changes with your repository.
* Enable Git synchronization to eaily sync your changes with your repository.
*/
dashboardStepsStepsToGetStartedGitDescription = 'dashboard.steps.stepsToGetStarted.git.description',
/**
@@ -1636,10 +1632,6 @@ export enum LocalizationKey {
* Content
*/
panelSeoKeywordInfoValidInfoContent = 'panel.seoKeywordInfo.validInfo.content',
/**
* First paragraph
*/
panelSeoKeywordInfoValidInfoFirstParagraph = 'panel.seoKeywordInfo.validInfo.firstParagraph',
/**
* Recommended frequency: 0.75% - 1.5%
*/

View File

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

View File

@@ -11,7 +11,6 @@ export interface IArticleDetailsProps {
internalLinks: number;
externalLinks: number;
images: number;
firstParagraph?: string;
};
}

View File

@@ -40,13 +40,11 @@ export const DateTimeField: React.FunctionComponent<IDateTimeFieldProps> = ({
const onDateChange = React.useCallback((date: Date) => {
setDateValue(date);
if (format) {
// Always use DateHelper.formatInTimezone when a format is provided
onChange(DateHelper.formatInTimezone(date, format, timezone) || "");
} else {
// Only fallback to ISO string if no format is provided
onChange(date.toISOString());
}
}, [format, timezone, onChange]);
}, [format, onChange]);
const showRequiredState = useMemo(() => {
return required && !dateValue;

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 { XMarkIcon } from '@heroicons/react/24/outline';
import { SparklesIcon } from '@heroicons/react/24/outline';
import { LocalizationKey, localize } from '../../../localization';
import useDropdownStyle from '../../hooks/useDropdownStyle';
import { CopilotIcon } from '../Icons';
@@ -311,6 +311,21 @@ 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"
@@ -327,7 +342,7 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
)}
</>
);
}, [settings?.copilotEnabled, label, type]);
}, [settings?.aiEnabled, settings?.copilotEnabled, label, type]);
const sortedSelectedTags = useMemo(() => {
const safeSelected = selected || [];

View File

@@ -1,4 +1,4 @@
import { PencilIcon } from '@heroicons/react/24/outline';
import { PencilIcon, SparklesIcon } from '@heroicons/react/24/outline';
import * as React from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useRecoilState } from 'recoil';
@@ -132,6 +132,18 @@ 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"
@@ -145,7 +157,7 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
)}
</>
);
}, [settings?.copilotEnabled, settings?.seo, name, actions, loading]);
}, [settings?.aiEnabled, settings?.copilotEnabled, settings?.seo, name, actions, loading]);
useEffect(() => {
if (showRequiredState) {

View File

@@ -16,7 +16,6 @@ export interface ISeoKeywordInfoProps {
content: string;
wordCount?: number;
headings?: string[];
firstParagraph?: string;
}
const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({
@@ -27,8 +26,7 @@ const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({
slug,
content,
wordCount,
headings,
firstParagraph
headings
}: React.PropsWithChildren<ISeoKeywordInfoProps>) => {
const density = () => {
@@ -92,10 +90,9 @@ const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({
(slug.toLowerCase().includes(keyword.toLowerCase()) ||
slug.toLowerCase().includes(keyword.replace(/ /g, '-').toLowerCase())),
content: !!content && content.toLowerCase().includes(keyword.toLowerCase()),
heading: checkHeadings(),
firstParagraph: !!firstParagraph && firstParagraph.toLowerCase().includes(keyword.toLowerCase())
heading: checkHeadings()
};
}, [title, description, slug, content, headings, wordCount, firstParagraph]);
}, [title, description, slug, content, headings, wordCount]);
const tooltipContent = React.useMemo(() => {
return (
@@ -105,8 +102,7 @@ const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({
<span className='inline-flex items-center gap-1'><ValidInfo isValid={checks.description} /> {localize(LocalizationKey.commonDescription)}</span><br />
<span className='inline-flex items-center gap-1'><ValidInfo isValid={checks.slug} /> {localize(LocalizationKey.commonSlug)}</span><br />
<span className='inline-flex items-center gap-1'><ValidInfo isValid={checks.content} /> {localize(LocalizationKey.panelSeoKeywordInfoValidInfoContent)}</span><br />
<span className='inline-flex items-center gap-1'><ValidInfo isValid={!!checks.heading} /> {localize(LocalizationKey.panelSeoKeywordInfoValidInfoLabel)}</span><br />
<span className='inline-flex items-center gap-1'><ValidInfo isValid={checks.firstParagraph} /> {localize(LocalizationKey.panelSeoKeywordInfoValidInfoFirstParagraph)}</span>
<span className='inline-flex items-center gap-1'><ValidInfo isValid={!!checks.heading} /> {localize(LocalizationKey.panelSeoKeywordInfoValidInfoLabel)}</span>
</>
)
}, [checks]);

View File

@@ -14,7 +14,6 @@ export interface ISeoKeywordsProps {
content: string;
headings?: string[];
wordCount?: number;
firstParagraph?: string;
}
const SeoKeywords: React.FunctionComponent<ISeoKeywordsProps> = ({

View File

@@ -96,7 +96,6 @@ const SeoStatus: React.FunctionComponent<ISeoStatusProps> = ({
headings={metadata?.articleDetails?.headingsText}
wordCount={metadata?.articleDetails?.wordCount}
content={metadata?.articleDetails?.content}
firstParagraph={metadata?.articleDetails?.firstParagraph}
/>
<FieldBoundary fieldName={`Keywords`}>

View File

@@ -33,8 +33,7 @@ export class Copilot {
}
const copilotExt = extensions.getExtension(`GitHub.copilot`);
const copilotChatExt = extensions.getExtension(`GitHub.copilot-chat`);
return !!copilotExt || !!copilotChatExt;
return !!copilotExt;
}
public static async suggestTitles(title: string): Promise<string[] | undefined> {
@@ -270,7 +269,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-4.1'
family: Settings.get<string>(SETTING_COPILOT_FAMILY) || 'gpt-4o-mini'
});
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], modifiedField.dateFormat)?.getTime()
? DateHelper.tryParse(article?.data[modifiedField.name])?.getTime()
: undefined;
const staticFolder = Folders.getStaticFolderRelativePath();
@@ -253,8 +253,7 @@ export class PagesParser {
Article.generateSlug(escapedTitle, article, contentType.slugTemplate)
?.slugWithPrefixAndSuffix,
date: article?.data[dateField] || '',
draft: article?.data.draft,
fmPageFolder: undefined
draft: article?.data.draft
};
let previewFieldParents = ContentType.findPreviewField(contentType.fields);
@@ -334,52 +333,38 @@ export class PagesParser {
// Revalidate as the array could have been empty
if (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;
// 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;
} else {
// Skip processing if the value is neither a string nor an object with src
imageValue = undefined;
}
let staticPath = join(wsFolder.fsPath, staticFolder || '', 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);
if (staticFolder === STATIC_FOLDER_PLACEHOLDER.hexo.placeholder) {
const crntFilePath = parseWinPath(filePath);
const pathWithoutExtension = crntFilePath.replace(extname(crntFilePath), '');
staticPath = join(pathWithoutExtension, fieldValue);
}
if (staticFolder === STATIC_FOLDER_PLACEHOLDER.hexo.placeholder) {
const crntFilePath = parseWinPath(filePath);
const pathWithoutExtension = crntFilePath.replace(extname(crntFilePath), '');
staticPath = join(pathWithoutExtension, 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();
}
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 || '';
}
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) || 'UTC';
const timezone = Settings.get<string>(SETTING_GLOBAL_TIMEZONE);
return DateHelper.formatInTimezone(date, dateFormat, timezone) || '';
};