Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
beef6f36d8 Implement first paragraph keyword check for SEO validation
Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
2025-09-08 20:07:41 +00:00
copilot-swe-agent[bot]
d11dbc9d76 Initial plan 2025-09-08 19:52:20 +00:00
18 changed files with 274 additions and 42 deletions

View File

@@ -504,6 +504,7 @@
"panel.seoKeywords.density": "Keyword density",
"panel.seoKeywordInfo.validInfo.label": "Heading(s)",
"panel.seoKeywordInfo.validInfo.content": "Content",
"panel.seoKeywordInfo.validInfo.firstParagraph": "First paragraph",
"panel.seoKeywordInfo.density.tooltip": "Recommended frequency: 0.75% - 1.5%",
"panel.seoKeywords.title": "Keywords",

View File

@@ -139,10 +139,11 @@
}
}
},
"frontMatter.ai.enabled": {
"frontMatter.sponsors.ai.enabled": {
"type": "boolean",
"default": true,
"markdownDescription": "%setting.frontMatter.ai.enabled.markdownDescription%"
"default": false,
"markdownDescription": "%setting.frontMatter.sponsors.ai.enabled.markdownDescription%",
"scope": "Sponsors"
},
"frontMatter.extensibility.scripts": {
"type": "array",

View File

@@ -55,7 +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.ai.enabled.markdownDescription": "Specify if you want to enable AI suggestions (requires GitHub Copilot extension). [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.ai.enabled) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.ai.enabled%22%5D)",
"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)",

View File

@@ -117,10 +117,14 @@ export const SETTING_SNIPPETS_WRAPPER = 'snippets.wrapper.enabled';
export const SETTING_WEBSITE_URL = 'website.host';
export const SETTING_COPILOT_FAMILY = 'copilot.family';
export const SETTING_AI_ENABLED = 'ai.enabled';
export const SETTING_LOGGING = 'logging';
/**
* Sponsors only settings
*/
export const SETTING_SPONSORS_AI_ENABLED = 'sponsors.ai.enabled';
/**
* Project override support
*/

View File

@@ -807,7 +807,8 @@ export class ArticleHelper {
const elms: Parent[] | Link[] = this.getAllElms(mdTree);
const headings = elms.filter((node) => node.type === 'heading');
const paragraphs = elms.filter((node) => node.type === 'paragraph').length;
const paragraphNodes = elms.filter((node) => node.type === 'paragraph');
const paragraphs = paragraphNodes.length;
const images = elms.filter((node) => node.type === 'image').length;
const links: string[] = elms
.filter((node) => node.type === 'link')
@@ -836,6 +837,21 @@ 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 {
@@ -846,7 +862,8 @@ export class ArticleHelper {
internalLinks,
externalLinks: externalLinks.length,
wordCount,
content: article.content
content: article.content,
firstParagraph
};
}

View File

@@ -1,7 +1,7 @@
import {
SETTING_GLOBAL_TIMEZONE,
SETTING_PANEL_ACTIONS_DISABLED,
SETTING_AI_ENABLED,
SETTING_SPONSORS_AI_ENABLED,
SETTING_WEBSITE_URL
} from './../constants/settings';
import { workspace } from 'vscode';
@@ -52,7 +52,7 @@ export class PanelSettings {
try {
return {
aiEnabled: Settings.get<boolean>(SETTING_AI_ENABLED) !== false,
aiEnabled: Settings.get<boolean>(SETTING_SPONSORS_AI_ENABLED) || false,
copilotEnabled: await Copilot.isInstalled(),
git: await GitListener.getSettings(),
seo: {

View File

@@ -1,10 +1,11 @@
import { authentication, QuickPickItem, QuickPickItemKind, window } from 'vscode';
import { Folders } from '../commands/Folders';
import { SETTING_AI_ENABLED } from '../constants';
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';
@@ -39,30 +40,56 @@ export class Questions {
* @returns
*/
public static async ContentTitle(showWarning = true): Promise<string | undefined> {
const aiEnabled = Settings.get<boolean>(SETTING_AI_ENABLED);
const aiEnabled = Settings.get<boolean>(SETTING_SPONSORS_AI_ENABLED);
let title: string | undefined = '';
const isCopilotInstalled = await Copilot.isInstalled();
let aiTitles: string[] | undefined;
// Only show AI suggestions if both the setting is enabled and Copilot is installed
if (aiEnabled !== false && isCopilotInstalled) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPlaceholder),
ignoreFocusOut: true
});
if (aiEnabled || isCopilotInstalled) {
if (isCopilotInstalled) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPlaceholder),
ignoreFocusOut: true
});
if (title) {
try {
aiTitles = await Copilot.suggestTitles(title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleCopilotInputFailed)
);
title = undefined;
if (title) {
try {
aiTitles = await Copilot.suggestTitles(title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleCopilotInputFailed)
);
title = undefined;
}
}
} else {
const githubAuth = await authentication.getSession('github', ['read:user'], {
silent: true
});
if (githubAuth && githubAuth.account.label) {
title = await window.showInputBox({
title: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputTitle),
prompt: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPrompt),
placeHolder: l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputPlaceholder),
ignoreFocusOut: true
});
if (title) {
try {
aiTitles = await SponsorAi.getTitles(githubAuth.accessToken, title);
} catch (e) {
Logger.error((e as Error).message);
Notifications.error(
l10n.t(LocalizationKey.helpersQuestionsContentTitleAiInputFailed)
);
title = undefined;
}
}
}
}

View File

@@ -5,7 +5,7 @@ import { Folders } from '../../commands/Folders';
import { Command } from '../../panelWebView/Command';
import { CommandToCode } from '../../panelWebView/CommandToCode';
import { BaseListener } from './BaseListener';
import { Uri, commands, window } from 'vscode';
import { Uri, authentication, commands, window } from 'vscode';
import {
ArticleHelper,
Extension,
@@ -40,6 +40,7 @@ import {
import { encodeEmoji, fieldWhenClause, getTitleField } from '../../utils';
import { PanelProvider } from '../../panelWebView/PanelProvider';
import { MessageHandlerData } from '@estruyf/vscode';
import { SponsorAi } from '../../services/SponsorAI';
import { Terminal } from '../../services';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../localization';
@@ -101,6 +102,9 @@ export class DataListener extends BaseListener {
case CommandToCode.getDataEntries:
this.getDataFileEntries(msg.command, msg.requestId || '', msg.payload);
break;
case CommandToCode.aiSuggestDescription:
this.aiSuggestTaxonomy(msg.command, msg.requestId);
break;
case CommandToCode.copilotSuggestDescription:
this.copilotSuggestDescription(msg.command, msg.requestId);
break;
@@ -175,6 +179,68 @@ export class DataListener extends BaseListener {
}
}
/**
* Suggests taxonomy using AI.
* @param command - The command string.
* @param requestId - The optional request ID.
*/
private static async aiSuggestTaxonomy(command: string, requestId?: string) {
if (!command || !requestId) {
return;
}
const extPath = Extension.getInstance().extensionPath;
const panel = PanelProvider.getInstance(extPath);
const editor = window.activeTextEditor;
if (!editor) {
panel.getWebview()?.postMessage({
command,
requestId,
error: l10n.t(LocalizationKey.listenersPanelDataListenerAiSuggestTaxonomyNoEditorError)
} as MessageHandlerData<string>);
return;
}
const article = ArticleHelper.getFrontMatter(editor);
if (!article || !article.data) {
panel.getWebview()?.postMessage({
command,
requestId,
error: l10n.t(LocalizationKey.listenersPanelDataListenerAiSuggestTaxonomyNoDataError)
} as MessageHandlerData<string>);
return;
}
const githubAuth = await authentication.getSession('github', ['read:user'], { silent: true });
if (!githubAuth || !githubAuth.accessToken) {
return;
}
const titleField = getTitleField();
const suggestion = await SponsorAi.getDescription(
githubAuth.accessToken,
article.data[titleField] || '',
article.content || ''
);
if (!suggestion) {
panel.getWebview()?.postMessage({
command,
requestId,
error: l10n.t(LocalizationKey.listenersPanelDataListenerAiSuggestTaxonomyNoDataError)
} as MessageHandlerData<string>);
return;
}
panel.getWebview()?.postMessage({
command,
requestId,
payload: suggestion || []
} as MessageHandlerData<string>);
}
/**
* Retrieve the information about the registered folders and its files
*/

View File

@@ -1,10 +1,11 @@
import { CommandToCode } from '../../panelWebView/CommandToCode';
import { TagType } from '../../panelWebView/TagType';
import { BaseListener } from './BaseListener';
import { window } from 'vscode';
import { authentication, window } from 'vscode';
import { ArticleHelper, Extension, Settings, TaxonomyHelper } from '../../helpers';
import { BlockFieldData, CustomTaxonomyData, PostMessageData, TaxonomyType } from '../../models';
import { DataListener } from '.';
import { SponsorAi } from '../../services/SponsorAI';
import { PanelProvider } from '../../panelWebView/PanelProvider';
import { MessageHandlerData } from '@estruyf/vscode';
import * as l10n from '@vscode/l10n';
@@ -59,6 +60,9 @@ export class TaxonomyListener extends BaseListener {
case CommandToCode.addToCustomTaxonomy:
this.addCustomTaxonomy(msg.payload);
break;
case CommandToCode.aiSuggestTaxonomy:
this.aiSuggestTaxonomy(msg.command, msg.requestId, msg.payload);
break;
case CommandToCode.copilotSuggestTaxonomy:
this.copilotSuggestTaxonomy(msg.command, msg.requestId, msg.payload);
break;
@@ -116,6 +120,73 @@ export class TaxonomyListener extends BaseListener {
}
}
/**
* Suggests taxonomy based on the provided command, request ID, and tag type.
*
* @param command - The command to execute.
* @param requestId - The ID of the request.
* @param type - The type of tag.
* @returns A Promise that resolves to void.
*/
private static async aiSuggestTaxonomy(command: string, requestId?: string, type?: TagType) {
if (!command || !requestId || !type) {
return;
}
const extPath = Extension.getInstance().extensionPath;
const panel = PanelProvider.getInstance(extPath);
const editor = window.activeTextEditor;
if (!editor) {
panel.getWebview()?.postMessage({
command,
requestId,
error: l10n.t(LocalizationKey.listenersPanelTaxonomyListenerAiSuggestTaxonomyNoDataError)
} as MessageHandlerData<string>);
return;
}
const article = ArticleHelper.getFrontMatter(editor);
if (!article || !article.data) {
panel.getWebview()?.postMessage({
command,
requestId,
error: l10n.t(LocalizationKey.listenersPanelTaxonomyListenerAiSuggestTaxonomyNoEditorError)
} as MessageHandlerData<string>);
return;
}
const githubAuth = await authentication.getSession('github', ['read:user'], { silent: true });
if (!githubAuth || !githubAuth.accessToken) {
return;
}
const titleField = getTitleField();
const descriptionField = getDescriptionField();
const suggestions = await SponsorAi.getTaxonomySuggestions(
githubAuth.accessToken,
article.data[titleField] || '',
article.data[descriptionField] || '',
type
);
if (!suggestions) {
panel.getWebview()?.postMessage({
command,
requestId,
error: l10n.t(LocalizationKey.listenersPanelTaxonomyListenerAiSuggestTaxonomyNoDataError)
} as MessageHandlerData<string>);
return;
}
panel.getWebview()?.postMessage({
command,
requestId,
payload: suggestions || []
} as MessageHandlerData<string[]>);
}
/**
* Update the tags in the current document
* @param tagType

View File

@@ -1632,6 +1632,10 @@ export enum LocalizationKey {
* Content
*/
panelSeoKeywordInfoValidInfoContent = 'panel.seoKeywordInfo.validInfo.content',
/**
* First paragraph
*/
panelSeoKeywordInfoValidInfoFirstParagraph = 'panel.seoKeywordInfo.validInfo.firstParagraph',
/**
* Recommended frequency: 0.75% - 1.5%
*/

View File

@@ -40,6 +40,8 @@ export enum CommandToCode {
getDataEntries = 'get-data-entries',
generateSlug = 'generate-slug',
stopServer = 'stop-server',
aiSuggestTaxonomy = 'ai-suggest-taxonomy',
aiSuggestDescription = 'ai-suggest-description',
copilotSuggestTitle = 'copilot-suggest-title',
copilotSuggestDescription = 'copilot-suggest-description',
copilotSuggestTaxonomy = 'copilot-suggest-taxonomy',

View File

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

View File

@@ -260,11 +260,13 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
}
const suggestTaxonomy = useCallback(
(type: TagType) => {
(aiType: 'ai' | 'copilot', type: TagType) => {
setLoading(localize(LocalizationKey.panelTagPickerAiGenerating));
const command =
aiType === 'ai' ? CommandToCode.aiSuggestTaxonomy : CommandToCode.copilotSuggestTaxonomy;
messageHandler
.request<string[]>(CommandToCode.copilotSuggestTaxonomy, type)
.request<string[]>(command, type)
.then((values) => {
setLoading(undefined);
updateTaxonomy(values)
@@ -309,7 +311,22 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
return (
<>
{settings?.aiEnabled && settings?.copilotEnabled && (
{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"
title={localize(
@@ -317,7 +334,7 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
label?.toLowerCase() || type.toLowerCase()
)}
type="button"
onClick={() => suggestTaxonomy(type)}
onClick={() => suggestTaxonomy('copilot', type)}
disabled={!!loading}
>
<CopilotIcon />

View File

@@ -105,11 +105,13 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
});
};
const suggestDescription = () => {
const suggestDescription = (type: 'ai' | 'copilot') => {
setLoading(localize(LocalizationKey.panelFieldsTextFieldAiGenerate));
messageHandler
.request<string>(CommandToCode.copilotSuggestDescription)
.request<string>(
type === 'copilot' ? CommandToCode.copilotSuggestDescription : CommandToCode.aiSuggestDescription
)
.then((suggestion) => {
setLoading(undefined);
@@ -130,12 +132,24 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
return (
<>
{settings?.aiEnabled && settings?.copilotEnabled && (
{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"
title={localize(LocalizationKey.panelFieldsTextFieldCopilotMessage, label?.toLowerCase())}
type="button"
onClick={() => settings.seo.descriptionField === name ? suggestDescription() : suggestTitle()}
onClick={() => settings.seo.descriptionField === name ? suggestDescription('copilot') : suggestTitle()}
disabled={!!loading}
>
<CopilotIcon />

View File

@@ -16,6 +16,7 @@ export interface ISeoKeywordInfoProps {
content: string;
wordCount?: number;
headings?: string[];
firstParagraph?: string;
}
const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({
@@ -26,7 +27,8 @@ const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({
slug,
content,
wordCount,
headings
headings,
firstParagraph
}: React.PropsWithChildren<ISeoKeywordInfoProps>) => {
const density = () => {
@@ -90,9 +92,10 @@ 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()
heading: checkHeadings(),
firstParagraph: !!firstParagraph && firstParagraph.toLowerCase().includes(keyword.toLowerCase())
};
}, [title, description, slug, content, headings, wordCount]);
}, [title, description, slug, content, headings, wordCount, firstParagraph]);
const tooltipContent = React.useMemo(() => {
return (
@@ -102,7 +105,8 @@ 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>
<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>
</>
)
}, [checks]);

View File

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

View File

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

View File

@@ -2,4 +2,5 @@ export * from './Credentials';
export * from './ModeSwitch';
export * from './PagesParser';
export * from './PinnedItems';
export * from './SponsorAI';
export * from './Terminal';