mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-03-28 17:42:40 +01:00
Compare commits
10 Commits
copilot/fi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c0ce6a6f2 | ||
|
|
bdb2179e3e | ||
|
|
bd8cd1f1d6 | ||
|
|
3b65bb3cd7 | ||
|
|
829c5c6e64 | ||
|
|
e6ef7555e3 | ||
|
|
2af6c57a49 | ||
|
|
a387d5eb89 | ||
|
|
f1ae60f280 | ||
|
|
0e2aea626f |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
157
README.md
@@ -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.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "評価する",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
1
package-lock.json
generated
@@ -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",
|
||||
|
||||
36
package.json
36
package.json
@@ -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": [
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
126
src/commands/Chatbot.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export enum DashboardViewType {
|
||||
Grid = 1,
|
||||
List,
|
||||
Structure
|
||||
List
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { atom } from 'recoil';
|
||||
export const SelectedItemActionAtom = atom<
|
||||
| {
|
||||
path: string;
|
||||
action: 'view' | 'edit' | 'delete' | 'move';
|
||||
action: 'view' | 'edit' | 'delete';
|
||||
}
|
||||
| undefined
|
||||
>({
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const SelectedStructureFolderAtom = atom<string | null>({
|
||||
key: 'SelectedStructureFolderAtom',
|
||||
default: null
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { selector } from 'recoil';
|
||||
import { SelectedStructureFolderAtom } from '..';
|
||||
|
||||
export const SelectedStructureFolderSelector = selector({
|
||||
key: 'SelectedStructureFolderSelector',
|
||||
get: ({ get }) => {
|
||||
return get(SelectedStructureFolderAtom);
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)) || [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,5 +38,3 @@ export * from './processFmPlaceholders';
|
||||
export * from './processI18nPlaceholders';
|
||||
export * from './processPathPlaceholders';
|
||||
export * from './processTimePlaceholders';
|
||||
export * from './ContentTypeSchemaGenerator';
|
||||
export * from './FrontMatterValidator';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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%
|
||||
*/
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface PanelSettings {
|
||||
dataTypes: DataType[] | undefined;
|
||||
fieldGroups: FieldGroup[] | undefined;
|
||||
commaSeparatedFields: string[];
|
||||
aiEnabled: boolean;
|
||||
copilotEnabled: boolean;
|
||||
contentFolders: ContentFolder[];
|
||||
websiteUrl: string;
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface IArticleDetailsProps {
|
||||
internalLinks: number;
|
||||
externalLinks: number;
|
||||
images: number;
|
||||
firstParagraph?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface ISeoKeywordsProps {
|
||||
content: string;
|
||||
headings?: string[];
|
||||
wordCount?: number;
|
||||
firstParagraph?: string;
|
||||
}
|
||||
|
||||
const SeoKeywords: React.FunctionComponent<ISeoKeywordsProps> = ({
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) || '';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user