feat: add content health checks and readability analysis

This commit is contained in:
Elio Struyf
2026-06-05 17:01:23 +02:00
parent cb78ee8623
commit 80a433a3b5
23 changed files with 1334 additions and 12 deletions
+1
View File
@@ -5,6 +5,7 @@
### 🎨 Enhancements
- Support `fieldGroup` as a single value on `fields` fields
- Added the new content health feature to the Front Matter panel with readability scoring, link checks, and freshness warnings (`frontMatter.contentHealth.enabled`, `frontMatter.contentHealth.checkExternalLinks`, `frontMatter.contentHealth.freshnessThreshold`, `frontMatter.contentHealth.minReadability`)
- [#1030](https://github.com/estruyf/vscode-front-matter/pull/1030): Add `frontMatter.file.slugSeparator` setting
- [#1033](https://github.com/estruyf/vscode-front-matter/issues/1033): Support freeform tags and categories in the front matter validation
- [#1036](https://github.com/estruyf/vscode-front-matter/issues/1036): Default filter, sorting, and grouping configuration for the `contents` dashboard
+34
View File
@@ -524,6 +524,31 @@
"panel.seoStatus.required": "{0} or {1} is required.",
"panel.slugAction.title": "Optimize slug",
"panel.contentHealth.readability.copilot.optimizeSentence": "Optimize average sentence length with Copilot",
"panel.contentHealth.readability.copilot.optimizeWord": "Optimize average word complexity with Copilot",
"panel.contentHealth.readability.hint.sentence": "Split long sentences to boost the score.",
"panel.contentHealth.readability.hint.word": "Replace multi-syllable words with simpler alternatives.",
"panel.contentHealth.title": "Content Health",
"panel.contentHealth.noIssues": "No content health issues found.",
"panel.contentHealth.readability.label": "Readability",
"panel.contentHealth.readability.level.veryEasy": "Very Easy",
"panel.contentHealth.readability.level.easy": "Easy",
"panel.contentHealth.readability.level.standard": "Standard",
"panel.contentHealth.readability.level.difficult": "Difficult",
"panel.contentHealth.readability.level.veryDifficult": "Very Difficult",
"panel.contentHealth.readability.belowThreshold": "(below threshold - {0})",
"panel.contentHealth.readability.avgSentence": "Avg sentence",
"panel.contentHealth.readability.avgWord": "Avg word",
"panel.contentHealth.readability.words": "{0} words",
"panel.contentHealth.readability.syllables": "{0} syllables",
"panel.contentHealth.readability.target": "(target <= {0})",
"panel.contentHealth.freshness.label": "Freshness",
"panel.contentHealth.freshness.daysOld": "{0} days old - consider refreshing",
"panel.contentHealth.brokenExternalLinks": "Broken external links",
"panel.contentHealth.link.status.valid": "Link is valid",
"panel.contentHealth.link.status.broken": "Broken link",
"panel.contentHealth.selectInDocument": "Select in document: {0}",
"panel.contentHealth.link.notFound": "(not found)",
"panel.smartRenameAction.title": "Smart rename",
@@ -812,6 +837,15 @@
"listeners.panel.dataListener.createDataFile.inputTitle": "What is the name of the data file?",
"listeners.panel.dataListener.createDataFile.error": "No data file id or path defined.",
"listeners.panel.dataListener.createDataFile.noFileName": "No filename provided.",
"listeners.panel.dataListener.copilotOptimizeReadability.noActiveArticle": "No active article found.",
"listeners.panel.dataListener.copilotOptimizeReadability.noArticleContent": "No article content found.",
"listeners.panel.dataListener.copilotOptimizeReadability.prompt.intro": "You are helping me improve readability for this markdown article.",
"listeners.panel.dataListener.copilotOptimizeReadability.prompt.objective.sentence": "Optimize the article to reduce average words per sentence by splitting overly long sentences.",
"listeners.panel.dataListener.copilotOptimizeReadability.prompt.objective.word": "Optimize the article to reduce average syllables per word by replacing complex words with simpler alternatives.",
"listeners.panel.dataListener.copilotOptimizeReadability.prompt.preserve": "Preserve meaning, markdown structure, links, code blocks, and front matter unchanged.",
"listeners.panel.dataListener.copilotOptimizeReadability.prompt.return": "Return revised markdown body suggestions and explain key edits.",
"listeners.panel.dataListener.copilotOptimizeReadability.prompt.file": "File: {0}",
"listeners.panel.dataListener.copilotOptimizeReadability.prompt.content": "Content:",
"listeners.panel.taxonomyListener.aiSuggestTaxonomy.noEditor.error": "No active editor",
"listeners.panel.taxonomyListener.aiSuggestTaxonomy.noData.error": "No article data",
+24
View File
@@ -2138,6 +2138,30 @@
"markdownDescription": "%setting.frontMatter.validation.enabled.markdownDescription%",
"scope": "Validation"
},
"frontMatter.contentHealth.enabled": {
"type": "boolean",
"default": true,
"markdownDescription": "Enable the Content Health panel that shows readability scores, broken links, and freshness warnings.",
"scope": "ContentHealth"
},
"frontMatter.contentHealth.checkExternalLinks": {
"type": "boolean",
"default": false,
"markdownDescription": "When enabled, Front Matter will also perform HTTP HEAD requests to validate external links (may slow down the panel on articles with many external links).",
"scope": "ContentHealth"
},
"frontMatter.contentHealth.freshnessThreshold": {
"type": "number",
"default": 180,
"markdownDescription": "Number of days after which content is considered stale and a freshness warning is shown (0 disables the warning).",
"scope": "ContentHealth"
},
"frontMatter.contentHealth.minReadability": {
"type": "number",
"default": 0,
"markdownDescription": "Minimum Flesch Reading Ease score (0100) before a readability warning is shown (0 disables the threshold).",
"scope": "ContentHealth"
},
"frontMatter.website.host": {
"type": "string",
"markdownDescription": "%setting.frontMatter.website.host.markdownDescription%"
+16
View File
@@ -517,6 +517,22 @@ export class Folders {
PagesListener.startWatchers();
}
/**
* Find folders based on a glob pattern
* @param filePath
* @returns
*/
public static getRelativePath(filePath: string): string {
const wsFolder = Folders.getWorkspaceFolder();
if (wsFolder) {
const relativePath = parseWinPath(
relative(parseWinPath(wsFolder.fsPath), parseWinPath(filePath))
);
return relativePath;
}
return filePath;
}
/**
* Retrieve the absolute file path
* @param filePath
+2 -1
View File
@@ -6,5 +6,6 @@ export const DefaultFields = {
Slug: `slug`,
Type: `type`,
ContentType: `fmContentType`,
Keywords: `keywords`
Keywords: `keywords`,
ContentHealth: `contentHealth`
};
+2 -1
View File
@@ -7,7 +7,8 @@ export const FEATURE_FLAG = {
contentType: 'panel.contentType',
gitActions: 'panel.gitActions',
recentlyModified: 'panel.recentlyModified',
otherActions: 'panel.otherActions'
otherActions: 'panel.otherActions',
contentHealth: 'panel.contentHealth'
},
dashboard: {
snippets: {
+5
View File
@@ -125,6 +125,11 @@ export const SETTING_LOGGING = 'logging';
export const SETTING_VALIDATION_ENABLED = 'validation.enabled';
export const SETTING_CONTENT_HEALTH_ENABLED = 'contentHealth.enabled';
export const SETTING_CONTENT_HEALTH_CHECK_EXTERNAL_LINKS = 'contentHealth.checkExternalLinks';
export const SETTING_CONTENT_HEALTH_FRESHNESS_THRESHOLD = 'contentHealth.freshnessThreshold';
export const SETTING_CONTENT_HEALTH_MIN_READABILITY = 'contentHealth.minReadability';
/**
* Project override support
*/
+9 -5
View File
@@ -946,17 +946,19 @@ export class ArticleHelper {
.filter((node) => node.type === 'link')
.map((node) => (node as Link).url);
const internalLinks = links.filter(
const internalLinkUrls = links.filter(
(link) =>
!link.startsWith('http') ||
(baseUrl && link.toLowerCase().includes((baseUrl || '').toLowerCase()))
).length;
let externalLinks = links.filter((link) => link.startsWith('http'));
);
const internalLinks = internalLinkUrls.length;
let externalLinksList = links.filter((link) => link.startsWith('http'));
if (baseUrl) {
externalLinks = externalLinks.filter(
externalLinksList = externalLinksList.filter(
(link) => !link.toLowerCase().includes(baseUrl.toLowerCase())
);
}
const externalLinkUrls = externalLinksList;
const headers = [];
for (const header of headings) {
@@ -992,7 +994,9 @@ export class ArticleHelper {
paragraphs,
images,
internalLinks,
externalLinks: externalLinks.length,
internalLinkUrls,
externalLinks: externalLinkUrls.length,
externalLinkUrls,
wordCount,
content: article.content,
firstParagraph
+378
View File
@@ -0,0 +1,378 @@
import { join, dirname, extname, basename } from 'path';
import { Uri, workspace } from 'vscode';
import { ContentFolder } from '../models';
import { parseWinPath } from './parseWinPath';
import { PagesParser } from '../services/PagesParser';
import { Page } from '../dashboardWebView/models/Page';
export interface LinkValidationResult {
url: string;
exists: boolean;
/** Resolved absolute file path — only set for internal links that map to a file */
filePath?: string;
internal: boolean;
}
interface ExternalCacheEntry {
ok: boolean;
checkedAt: number;
}
const EXTERNAL_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
export class LinkValidator {
private static externalCache = new Map<string, ExternalCacheEntry>();
/**
* Validate internal link URLs against the workspace file system.
* Returns results with `filePath` set for links that resolve to a file
* so the UI can offer a click-to-open action.
*/
public static async validateInternalLinks(
urls: string[],
currentFilePath: string,
folders: ContentFolder[]
): Promise<LinkValidationResult[]> {
const results: LinkValidationResult[] = [];
const pages = await LinkValidator.getInternalPages();
const slugIndex = LinkValidator.buildSlugIndex(pages, folders);
for (const url of urls) {
const cleanUrl = LinkValidator.normalizeInternalUrl(url);
if (!cleanUrl) {
continue;
}
const filePath = await LinkValidator.resolveToFilePath(
cleanUrl,
currentFilePath,
folders,
slugIndex
);
if (filePath) {
results.push({ url, exists: true, filePath, internal: true });
} else {
results.push({ url, exists: false, internal: true });
}
}
return results;
}
/**
* Validate external HTTP(S) URLs via HEAD requests (with caching).
*/
public static async validateExternalLinks(urls: string[]): Promise<LinkValidationResult[]> {
const results: LinkValidationResult[] = [];
for (const url of urls) {
const cached = LinkValidator.externalCache.get(url);
if (cached && Date.now() - cached.checkedAt < EXTERNAL_CACHE_TTL_MS) {
results.push({ url, exists: cached.ok, internal: false });
continue;
}
const ok = await LinkValidator.headRequest(url);
LinkValidator.externalCache.set(url, { ok, checkedAt: Date.now() });
results.push({ url, exists: ok, internal: false });
}
return results;
}
/**
* Ensure a resolved path stays within the given root to prevent path traversal.
*/
private static isWithinRoot(resolvedPath: string, root: string): boolean {
const normalizedResolved = parseWinPath(resolvedPath);
const normalizedRoot = parseWinPath(root).replace(/\/?$/, '/');
return normalizedResolved.startsWith(normalizedRoot);
}
/**
* Resolve a link URL to an absolute file path.
*
* Resolution order:
* 1. Relative paths (./foo, ../foo) resolve against current file's directory,
* clamped to the workspace root.
* 2. Absolute paths (/foo/bar) try to match against a content folder's previewPath,
* then fall back to the workspace root.
*
* Paths that resolve outside the workspace are rejected to prevent traversal.
*/
private static async resolveToFilePath(
url: string,
currentFilePath: string,
folders: ContentFolder[],
slugIndex: Map<string, string>
): Promise<string | undefined> {
const wsFolder = workspace.workspaceFolders?.[0]?.uri.fsPath;
const normalizedUrl = parseWinPath(url);
const isRelative =
normalizedUrl.startsWith('./') ||
normalizedUrl.startsWith('../') ||
!normalizedUrl.startsWith('/');
// Slug-like links ("post-name", "/blog/post-name") are first resolved
// against indexed internal pages, then we fall back to file probing.
if (!normalizedUrl.startsWith('./') && !normalizedUrl.startsWith('../')) {
const resolvedFromSlug = LinkValidator.resolveBySlug(normalizedUrl, folders, slugIndex);
if (resolvedFromSlug) {
return resolvedFromSlug;
}
}
if (isRelative) {
const base = dirname(currentFilePath);
const resolved = join(base, normalizedUrl);
// Reject paths that escape the workspace
if (wsFolder && !LinkValidator.isWithinRoot(resolved, wsFolder)) {
return undefined;
}
return await LinkValidator.findContentFile(resolved);
}
// Absolute path — try content folders first (previewPath → filesystem path)
const sortedFolders = [...folders].sort(
(a, b) => (b.previewPath?.length ?? 0) - (a.previewPath?.length ?? 0)
);
for (const folder of sortedFolders) {
const preview = folder.previewPath ? parseWinPath(folder.previewPath) : '';
if (!preview) {
continue;
}
if (normalizedUrl.startsWith(preview)) {
// Strip only alphanumeric path segments — reject traversal sequences
const relativePart = normalizedUrl
.slice(preview.length)
.replace(/^\//, '')
.replace(/\.\.\//g, ''); // strip any traversal attempts
const folderPath = parseWinPath(folder.path);
const candidate = join(folderPath, relativePart);
// Confirm it stays inside the folder
if (!LinkValidator.isWithinRoot(candidate, folderPath)) {
continue;
}
const resolved = await LinkValidator.findContentFile(candidate);
if (resolved) {
return resolved;
}
}
}
// Fall back: try resolving against workspace root
if (wsFolder) {
const sanitizedUrl = normalizedUrl.replace(/\.\.\//g, '');
const candidate = join(wsFolder, sanitizedUrl);
if (!LinkValidator.isWithinRoot(candidate, wsFolder)) {
return undefined;
}
const resolved = await LinkValidator.findContentFile(candidate);
if (resolved) {
return resolved;
}
}
return undefined;
}
private static normalizeInternalUrl(url: string): string | undefined {
let cleanUrl = url.split('#')[0].split('?')[0].trim();
if (!cleanUrl) {
return undefined;
}
// Internal links may still come in absolute form when they match baseUrl.
if (/^https?:\/\//i.test(cleanUrl)) {
try {
const parsed = new URL(cleanUrl);
cleanUrl = parsed.pathname || '/';
} catch {
// Keep the original string if URL parsing fails.
}
}
return parseWinPath(cleanUrl);
}
private static async getInternalPages(): Promise<Page[]> {
if (PagesParser.allPages?.length > 0) {
return PagesParser.allPages;
}
return new Promise((resolve) => {
try {
PagesParser.getPages((pages) => resolve(pages || []));
} catch {
resolve([]);
}
});
}
private static buildSlugIndex(pages: Page[], folders: ContentFolder[]): Map<string, string> {
const index = new Map<string, string>();
const previews = folders
.map((f) => LinkValidator.toComparablePath(f.previewPath || ''))
.filter((p) => !!p);
for (const page of pages) {
const filePath = page.fmFilePath;
if (!filePath) {
continue;
}
const slug = LinkValidator.toComparablePath(page.slug || '');
const fileName = page.fmFileName || basename(filePath);
const fileNameWithoutExt = fileName
? fileName.slice(0, fileName.length - extname(fileName).length)
: '';
const candidates = new Set<string>();
candidates.add(slug);
// Fallback: allow matching links by filename when no slug match exists.
if (fileNameWithoutExt) {
candidates.add(LinkValidator.toComparablePath(fileNameWithoutExt));
}
if (slug.endsWith('/index')) {
candidates.add(slug.slice(0, -6));
}
for (const preview of previews) {
if (!preview) {
continue;
}
if (slug) {
candidates.add(LinkValidator.toComparablePath(`${preview}/${slug}`));
if (fileNameWithoutExt) {
candidates.add(LinkValidator.toComparablePath(`${preview}/${fileNameWithoutExt}`));
}
if (slug.startsWith(`${preview}/`)) {
candidates.add(LinkValidator.toComparablePath(slug.slice(preview.length + 1)));
}
} else {
candidates.add(preview);
}
}
for (const candidate of candidates) {
if (!index.has(candidate)) {
index.set(candidate, filePath);
}
}
}
return index;
}
private static resolveBySlug(
url: string,
folders: ContentFolder[],
slugIndex: Map<string, string>
): string | undefined {
const urlPath = LinkValidator.toComparablePath(url);
const candidates = new Set<string>([urlPath]);
if (urlPath.endsWith('/index')) {
candidates.add(urlPath.slice(0, -6));
}
// Some frameworks prepend route groups (for example, /session/<slug>)
// while front matter stores only the terminal slug. Add trailing path
// slices so the resolver can still match the page.
const pathParts = urlPath.split('/').filter(Boolean);
if (pathParts.length > 1) {
for (let i = 1; i < pathParts.length; i++) {
candidates.add(pathParts.slice(i).join('/'));
}
}
const previews = folders
.map((f) => LinkValidator.toComparablePath(f.previewPath || ''))
.filter((p) => !!p);
for (const preview of previews) {
if (!preview) {
continue;
}
if (urlPath.startsWith(`${preview}/`)) {
candidates.add(LinkValidator.toComparablePath(urlPath.slice(preview.length + 1)));
}
if (urlPath === preview) {
candidates.add('');
}
}
for (const candidate of candidates) {
const filePath = slugIndex.get(candidate);
if (filePath) {
return filePath;
}
}
return undefined;
}
private static toComparablePath(path: string): string {
const normalized = parseWinPath(path || '')
.trim()
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.replace(/\/+/g, '/');
if (!normalized || normalized === 'index') {
return '';
}
return normalized;
}
/**
* Given a base path (without extension), probe common content file variants.
*/
private static async findContentFile(basePath: string): Promise<string | undefined> {
// If the path already has a known extension, check it directly
const ext = extname(basePath);
if (ext) {
return (await LinkValidator.fileExists(basePath)) ? basePath : undefined;
}
const candidates = [
`${basePath}.md`,
`${basePath}.mdx`,
`${basePath}/index.md`,
`${basePath}/index.mdx`
];
for (const candidate of candidates) {
if (await LinkValidator.fileExists(candidate)) {
return candidate;
}
}
return undefined;
}
private static async fileExists(filePath: string): Promise<boolean> {
try {
await workspace.fs.stat(Uri.file(filePath));
return true;
} catch {
return false;
}
}
private static async headRequest(url: string): Promise<boolean> {
try {
const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) });
return res.ok;
} catch {
return false;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
export type ReadabilityLabel = 'veryEasy' | 'easy' | 'standard' | 'difficult' | 'veryDifficult';
export interface ReadabilityResult {
score: number;
label: ReadabilityLabel;
/** Average number of words per sentence (target ≤ 20) */
avgWordsPerSentence: number;
/** Average number of syllables per word (target ≤ 1.5) */
avgSyllablesPerWord: number;
}
export class ReadabilityHelper {
/**
* Strip markdown syntax to get plain text for analysis.
*/
private static stripMarkdown(text: string): string {
return text
.replace(/!\[.*?\]\(.*?\)/g, '') // images
.replace(/\[([^\]]+)\]\(.*?\)/g, '$1') // links → text only
.replace(/#{1,6}\s/g, '') // headings
.replace(/`{1,3}[^`]*`{1,3}/g, '') // inline + fenced code
.replace(/```[\s\S]*?```/g, '') // fenced blocks
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1') // bold/italic
.replace(/>\s/g, '') // blockquotes
.replace(/[-*+]\s/g, '') // list bullets
.replace(/\d+\.\s/g, '') // numbered lists
.replace(/\r?\n/g, ' ') // line breaks → space
.replace(/\s{2,}/g, ' ') // collapse whitespace
.trim();
}
private static countSyllables(word: string): number {
word = word.toLowerCase().replace(/[^a-z]/g, '');
if (word.length <= 2) {
return 1;
}
// Remove silent trailing 'e'
word = word.replace(/e$/, '');
const vowelGroups = word.match(/[aeiouy]+/g);
return Math.max(1, vowelGroups ? vowelGroups.length : 1);
}
private static countSentences(text: string): number {
const sentences = text.match(/[^.!?]+[.!?]+/g);
return Math.max(1, sentences ? sentences.length : 1);
}
private static labelFromScore(score: number): ReadabilityLabel {
if (score >= 80) {
return 'veryEasy';
}
if (score >= 60) {
return 'easy';
}
if (score >= 30) {
return 'standard';
}
if (score >= 10) {
return 'difficult';
}
return 'veryDifficult';
}
/**
* Calculates the Flesch Reading Ease score for the given markdown text.
* Score ranges: 80 Very Easy, 6079 Easy, 3059 Standard, 1029 Difficult, <10 Very Difficult
*/
public static analyze(markdownText: string): ReadabilityResult {
const plain = ReadabilityHelper.stripMarkdown(markdownText);
const words = plain.split(/\s+/).filter((w) => w.length > 0);
const wordCount = words.length;
if (wordCount < 5) {
return {
score: 0,
label: 'veryDifficult',
avgWordsPerSentence: 0,
avgSyllablesPerWord: 0
};
}
const sentenceCount = ReadabilityHelper.countSentences(plain);
const syllableCount = words.reduce((sum, w) => sum + ReadabilityHelper.countSyllables(w), 0);
const avgWordsPerSentence = Math.round((wordCount / sentenceCount) * 10) / 10;
const avgSyllablesPerWord = Math.round((syllableCount / wordCount) * 100) / 100;
const score =
206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (syllableCount / wordCount);
const clamped = Math.max(0, Math.min(100, Math.round(score)));
return {
score: clamped,
label: ReadabilityHelper.labelFromScore(clamped),
avgWordsPerSentence,
avgSyllablesPerWord
};
}
}
+179 -1
View File
@@ -23,11 +23,16 @@ import {
DefaultFields,
FEATURE_FLAG,
SETTING_COMMA_SEPARATED_FIELDS,
SETTING_CONTENT_HEALTH_CHECK_EXTERNAL_LINKS,
SETTING_CONTENT_HEALTH_ENABLED,
SETTING_CONTENT_HEALTH_FRESHNESS_THRESHOLD,
SETTING_CONTENT_HEALTH_MIN_READABILITY,
SETTING_DATE_FORMAT,
SETTING_GLOBAL_ACTIVE_MODE,
SETTING_GLOBAL_MODES,
SETTING_TAXONOMY_CONTENT_TYPES,
SETTING_COPILOT_ENABLED
SETTING_COPILOT_ENABLED,
SETTING_EXPERIMENTAL
} from '../../constants';
import { Article, Preview } from '../../commands';
import { FrontMatterParser, ParsedFrontMatter } from '../../parsers';
@@ -47,6 +52,10 @@ import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../localization';
import { parse } from 'path';
import { Copilot } from '../../services/Copilot';
import { LinkValidator } from '../../helpers/LinkValidator';
import { ReadabilityHelper } from '../../helpers/ReadabilityHelper';
import { DateHelper } from '../../helpers/DateHelper';
import { getRelPath } from '../../dashboardWebView/utils';
const FILE_LIMIT = 10;
@@ -112,6 +121,100 @@ export class DataListener extends BaseListener {
case CommandToCode.copilotSuggestTitle:
this.copilotSuggestTitle(msg.command, msg.requestId, msg.payload);
break;
case CommandToCode.copilotOptimizeAvgSentence:
this.copilotOptimizeReadability(msg.command, msg.requestId, 'sentence');
break;
case CommandToCode.copilotOptimizeAvgWord:
this.copilotOptimizeReadability(msg.command, msg.requestId, 'word');
break;
}
}
private static async copilotOptimizeReadability(
command: string,
requestId?: string,
type: 'sentence' | 'word' = 'sentence'
) {
if (!command || !requestId) {
return;
}
const copilotEnabled = Settings.get<boolean>(SETTING_COPILOT_ENABLED) !== false;
const isCopilotInstalled = await Copilot.isInstalled();
if (!copilotEnabled || !isCopilotInstalled) {
this.sendRequestError(
command,
requestId,
l10n.t(LocalizationKey.servicesCopilotGetChatResponseError)
);
return;
}
const articlePath = ArticleHelper.getActiveFile();
if (!articlePath) {
this.sendRequestError(
command,
requestId,
l10n.t(LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityNoActiveArticle)
);
return;
}
const article = await ArticleHelper.getFrontMatterByPath(articlePath);
if (!article || !article.content) {
this.sendRequestError(
command,
requestId,
l10n.t(LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityNoArticleContent)
);
return;
}
const objective =
type === 'sentence'
? l10n.t(
LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityPromptObjectiveSentence
)
: l10n.t(
LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityPromptObjectiveWord
);
const prompt = [
l10n.t(LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityPromptIntro),
objective,
l10n.t(LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityPromptPreserve),
l10n.t(LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityPromptReturn),
l10n.t(
LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityPromptFile,
articlePath
),
l10n.t(LocalizationKey.listenersPanelDataListenerCopilotOptimizeReadabilityPromptContent)
].join('\n\n');
try {
await commands.executeCommand('workbench.action.chat.open');
await commands.executeCommand('workbench.action.chat.newChat');
try {
await commands.executeCommand('workbench.action.chat.openagent', {
query: prompt,
attachFiles: [Uri.file(parseWinPath(articlePath))]
});
} catch {
await commands.executeCommand('workbench.action.chat.open', {
query: prompt,
attachFiles: [Uri.file(parseWinPath(articlePath))]
});
}
this.sendRequest(command, requestId, true);
} catch {
this.sendRequestError(
command,
requestId,
l10n.t(LocalizationKey.servicesCopilotGetChatResponseError)
);
}
}
@@ -436,6 +539,81 @@ export class DataListener extends BaseListener {
this.sendMsg(Command.metadata, updatedMetadata);
DataListener.lastMetadataUpdate = updatedMetadata;
// Run content health checks in background — does not block the panel render
if (filePath && articleDetails && typeof articleDetails === 'object') {
void DataListener.runHealthChecks(
filePath,
articleDetails as {
internalLinkUrls: string[];
externalLinkUrls: string[];
content: string;
},
updatedMetadata
);
}
}
private static async runHealthChecks(
filePath: string,
articleDetails: { internalLinkUrls: string[]; externalLinkUrls: string[]; content: string },
metadata: Record<string, unknown>
): Promise<void> {
const healthEnabled = Settings.get<boolean>(SETTING_CONTENT_HEALTH_ENABLED) !== false;
if (!healthEnabled) {
return;
}
try {
const folders = await Folders.getCachedOrFresh();
const experimental = Settings.get<boolean>(SETTING_EXPERIMENTAL) !== false;
const internalLinks = experimental
? await LinkValidator.validateInternalLinks(
articleDetails.internalLinkUrls || [],
filePath,
folders
)
: [];
const checkExternal =
Settings.get<boolean>(SETTING_CONTENT_HEALTH_CHECK_EXTERNAL_LINKS) === true;
const externalResults = checkExternal
? await LinkValidator.validateExternalLinks(articleDetails.externalLinkUrls || [])
: [];
const brokenExternalLinks = externalResults.filter((r) => !r.exists);
const readability = ReadabilityHelper.analyze(articleDetails.content || '');
const threshold = Settings.get<number>(SETTING_CONTENT_HEALTH_FRESHNESS_THRESHOLD) ?? 180;
let freshnessWarning: { daysSince: number; threshold: number } | null = null;
if (threshold > 0) {
const dateValue = (metadata?.date || metadata?.publishDate) as string | undefined;
if (dateValue) {
const dateFormat = Settings.get<string>(SETTING_DATE_FORMAT);
const parsed = DateHelper.tryParse(dateValue, dateFormat || undefined);
if (parsed && DateHelper.isValid(parsed)) {
const daysSince = Math.floor((Date.now() - parsed.getTime()) / (1000 * 60 * 60 * 24));
if (daysSince > threshold) {
freshnessWarning = { daysSince, threshold };
}
}
}
}
const minReadability = Settings.get<number>(SETTING_CONTENT_HEALTH_MIN_READABILITY) ?? 0;
DataListener.sendMsg(Command.contentHealth, {
internalLinks,
brokenExternalLinks,
readability,
freshnessWarning,
minReadability
});
} catch (e) {
Logger.error(`DataListener::runHealthChecks: ${(e as Error).message}`);
}
}
/**
+41 -1
View File
@@ -1,6 +1,6 @@
import { CommandToCode } from '../../panelWebView/CommandToCode';
import { BaseListener } from './BaseListener';
import { commands, env as vscodeEnv } from 'vscode';
import { Range, Selection, TextEditorRevealType, commands, env as vscodeEnv, window } from 'vscode';
import * as os from 'os';
import { exec } from 'child_process';
import { Folders } from '../../commands/Folders';
@@ -38,6 +38,46 @@ export class ExtensionListener extends BaseListener {
case CommandToCode.openDashboard:
commands.executeCommand(COMMAND_NAME.dashboard);
break;
case CommandToCode.selectInDocument:
ExtensionListener.selectLinkInDocument(msg.payload);
break;
}
}
/**
* Find the given link URL in the active editor and select it.
* Searches for markdown link syntax `[text](url)` as well as bare `url` occurrences.
*/
private static selectLinkInDocument(url: string) {
const editor = window.activeTextEditor;
if (!editor || !url) {
return;
}
const text = editor.document.getText();
// Primary: match full markdown link [text](url)
const markdownPattern = new RegExp(
`\\[[^\\]]*\\]\\(${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`,
'g'
);
const mdMatch = markdownPattern.exec(text);
if (mdMatch) {
const start = editor.document.positionAt(mdMatch.index);
const end = editor.document.positionAt(mdMatch.index + mdMatch[0].length);
editor.selection = new Selection(start, end);
editor.revealRange(new Range(start, end), TextEditorRevealType.InCenter);
return;
}
// Fallback: select just the URL wherever it appears
const idx = text.indexOf(url);
if (idx !== -1) {
const start = editor.document.positionAt(idx);
const end = editor.document.positionAt(idx + url.length);
editor.selection = new Selection(start, end);
editor.revealRange(new Range(start, end), TextEditorRevealType.InCenter);
}
}
+136
View File
@@ -1700,6 +1700,106 @@ export enum LocalizationKey {
* Optimize slug
*/
panelSlugActionTitle = 'panel.slugAction.title',
/**
* Optimize average sentence length with Copilot
*/
panelContentHealthReadabilityCopilotOptimizeSentence = 'panel.contentHealth.readability.copilot.optimizeSentence',
/**
* Optimize average word complexity with Copilot
*/
panelContentHealthReadabilityCopilotOptimizeWord = 'panel.contentHealth.readability.copilot.optimizeWord',
/**
* Split long sentences to boost the score.
*/
panelContentHealthReadabilityHintSentence = 'panel.contentHealth.readability.hint.sentence',
/**
* Replace multi-syllable words with simpler alternatives.
*/
panelContentHealthReadabilityHintWord = 'panel.contentHealth.readability.hint.word',
/**
* Content Health
*/
panelContentHealthTitle = 'panel.contentHealth.title',
/**
* No content health issues found.
*/
panelContentHealthNoIssues = 'panel.contentHealth.noIssues',
/**
* Readability
*/
panelContentHealthReadabilityLabel = 'panel.contentHealth.readability.label',
/**
* Very Easy
*/
panelContentHealthReadabilityLevelVeryEasy = 'panel.contentHealth.readability.level.veryEasy',
/**
* Easy
*/
panelContentHealthReadabilityLevelEasy = 'panel.contentHealth.readability.level.easy',
/**
* Standard
*/
panelContentHealthReadabilityLevelStandard = 'panel.contentHealth.readability.level.standard',
/**
* Difficult
*/
panelContentHealthReadabilityLevelDifficult = 'panel.contentHealth.readability.level.difficult',
/**
* Very Difficult
*/
panelContentHealthReadabilityLevelVeryDifficult = 'panel.contentHealth.readability.level.veryDifficult',
/**
* (below threshold)
*/
panelContentHealthReadabilityBelowThreshold = 'panel.contentHealth.readability.belowThreshold',
/**
* Avg sentence
*/
panelContentHealthReadabilityAvgSentence = 'panel.contentHealth.readability.avgSentence',
/**
* Avg word
*/
panelContentHealthReadabilityAvgWord = 'panel.contentHealth.readability.avgWord',
/**
* {0} words
*/
panelContentHealthReadabilityWords = 'panel.contentHealth.readability.words',
/**
* {0} syllables
*/
panelContentHealthReadabilitySyllables = 'panel.contentHealth.readability.syllables',
/**
* (target <= {0})
*/
panelContentHealthReadabilityTarget = 'panel.contentHealth.readability.target',
/**
* Freshness
*/
panelContentHealthFreshnessLabel = 'panel.contentHealth.freshness.label',
/**
* {0} days old - consider refreshing
*/
panelContentHealthFreshnessDaysOld = 'panel.contentHealth.freshness.daysOld',
/**
* Broken external links
*/
panelContentHealthBrokenExternalLinks = 'panel.contentHealth.brokenExternalLinks',
/**
* Link is valid
*/
panelContentHealthLinkStatusValid = 'panel.contentHealth.link.status.valid',
/**
* Broken link
*/
panelContentHealthLinkStatusBroken = 'panel.contentHealth.link.status.broken',
/**
* Select in document: {0}
*/
panelContentHealthSelectInDocument = 'panel.contentHealth.selectInDocument',
/**
* (not found)
*/
panelContentHealthLinkNotFound = 'panel.contentHealth.link.notFound',
/**
* Smart rename
*/
@@ -2692,6 +2792,42 @@ export enum LocalizationKey {
* No filename provided.
*/
listenersPanelDataListenerCreateDataFileNoFileName = 'listeners.panel.dataListener.createDataFile.noFileName',
/**
* No active article found.
*/
listenersPanelDataListenerCopilotOptimizeReadabilityNoActiveArticle = 'listeners.panel.dataListener.copilotOptimizeReadability.noActiveArticle',
/**
* No article content found.
*/
listenersPanelDataListenerCopilotOptimizeReadabilityNoArticleContent = 'listeners.panel.dataListener.copilotOptimizeReadability.noArticleContent',
/**
* You are helping me improve readability for this markdown article.
*/
listenersPanelDataListenerCopilotOptimizeReadabilityPromptIntro = 'listeners.panel.dataListener.copilotOptimizeReadability.prompt.intro',
/**
* Optimize the article to reduce average words per sentence by splitting overly long sentences.
*/
listenersPanelDataListenerCopilotOptimizeReadabilityPromptObjectiveSentence = 'listeners.panel.dataListener.copilotOptimizeReadability.prompt.objective.sentence',
/**
* Optimize the article to reduce average syllables per word by replacing complex words with simpler alternatives.
*/
listenersPanelDataListenerCopilotOptimizeReadabilityPromptObjectiveWord = 'listeners.panel.dataListener.copilotOptimizeReadability.prompt.objective.word',
/**
* Preserve meaning, markdown structure, links, code blocks, and front matter unchanged.
*/
listenersPanelDataListenerCopilotOptimizeReadabilityPromptPreserve = 'listeners.panel.dataListener.copilotOptimizeReadability.prompt.preserve',
/**
* Return revised markdown body suggestions and explain key edits.
*/
listenersPanelDataListenerCopilotOptimizeReadabilityPromptReturn = 'listeners.panel.dataListener.copilotOptimizeReadability.prompt.return',
/**
* File: {0}
*/
listenersPanelDataListenerCopilotOptimizeReadabilityPromptFile = 'listeners.panel.dataListener.copilotOptimizeReadability.prompt.file',
/**
* Content:
*/
listenersPanelDataListenerCopilotOptimizeReadabilityPromptContent = 'listeners.panel.dataListener.copilotOptimizeReadability.prompt.content',
/**
* No active editor
*/
+2 -1
View File
@@ -10,5 +10,6 @@ export enum Command {
sendMediaUrl = 'sendMediaUrl',
updatePlaceholder = 'updatePlaceholder',
dataFileEntries = 'dataFileEntries',
serverStarted = 'server-started'
serverStarted = 'server-started',
contentHealth = 'contentHealth'
}
+4 -1
View File
@@ -45,9 +45,12 @@ export enum CommandToCode {
copilotSuggestTitle = 'copilot-suggest-title',
copilotSuggestDescription = 'copilot-suggest-description',
copilotSuggestTaxonomy = 'copilot-suggest-taxonomy',
copilotOptimizeAvgSentence = 'copilot-optimize-avg-sentence',
copilotOptimizeAvgWord = 'copilot-optimize-avg-word',
searchByType = 'search-by-type',
processMediaData = 'process-media-data',
isServerStarted = 'is-server-started',
runFieldAction = 'run-field-action',
smartRename = 'smart-rename'
smartRename = 'smart-rename',
selectInDocument = 'select-in-document'
}
+9
View File
@@ -3,6 +3,7 @@ import { Actions } from './components/Actions';
import { GlobalSettings } from './components/GlobalSettings';
import { OtherActions } from './components/OtherActions';
import { SeoStatus } from './components/SeoStatus';
import { ContentHealth } from './components/ContentHealth/ContentHealth';
import { Spinner } from './components/Spinner';
import { FolderAndFiles } from './components/FolderAndFiles';
import { Metadata } from './components/Metadata';
@@ -128,6 +129,14 @@ export const ViewPanel: React.FunctionComponent<IViewPanelProps> = () => {
<GlobalSettings settings={settings} isBase={!metadata} />
</FeatureFlag>
{
!loading && metadata?.contentHealth && (
<FeatureFlag features={mode?.features || DEFAULT_PANEL_FEATURE_FLAGS} flag={FEATURE_FLAG.panel.contentHealth}>
<ContentHealth contentHealth={metadata.contentHealth} />
</FeatureFlag>
)
}
{
!loading && metadata && (
<FeatureFlag features={mode?.features || DEFAULT_PANEL_FEATURE_FLAGS} flag={FEATURE_FLAG.panel.metadata}>
@@ -0,0 +1,53 @@
import { Messenger } from '@estruyf/vscode/dist/client';
import * as React from 'react';
import { CommandToCode } from '../../CommandToCode';
import { LocalizationKey, localize } from '../../../localization';
import type { LinkValidationResult } from '../../../helpers/LinkValidator';
interface Props {
brokenExternalLinks: LinkValidationResult[];
}
const truncate = (url: string, max = 50): string =>
url.length > max ? `${url.slice(0, max)}` : url;
const BrokenExternalLinks: React.FunctionComponent<Props> = ({ brokenExternalLinks }) => {
if (!brokenExternalLinks || brokenExternalLinks.length === 0) {
return null;
}
const handleSelect = (url: string) => {
Messenger.send(CommandToCode.selectInDocument, url);
};
return (
<div className='mt-2'>
<p className='text-sm font-semibold text-[var(--vscode-descriptionForeground)] uppercase mb-1'>
{localize(LocalizationKey.panelContentHealthBrokenExternalLinks)}
</p>
<ul className='space-y-1'>
{brokenExternalLinks.map((link) => (
<li
key={link.url}
className='flex items-center gap-1 text-sm text-[var(--vscode-problemsErrorIcon-foreground)]'
>
<span
className='shrink-0 w-2 h-2 rounded-full bg-[var(--vscode-problemsErrorIcon-foreground)]'
title={localize(LocalizationKey.panelContentHealthLinkStatusBroken)}
/>
<button
className='text-[var(--vscode-problemsErrorIcon-foreground)] hover:underline cursor-pointer bg-transparent border-0 p-0 text-sm text-left truncate max-w-full hover:bg-transparent active:outline-0 focus:outline-0'
title={localize(LocalizationKey.panelContentHealthSelectInDocument, link.url)}
onClick={() => handleSelect(link.url)}
>
{truncate(link.url)}
</button>
</li>
))}
</ul>
</div>
);
};
BrokenExternalLinks.displayName = 'BrokenExternalLinks';
export { BrokenExternalLinks };
@@ -0,0 +1,70 @@
import * as React from 'react';
import { Collapsible } from '../Collapsible';
import { VSCodeTable, VSCodeTableBody } from '../VSCode/VSCodeTable';
import { ReadabilityScore } from './ReadabilityScore';
import { FreshnessWarning } from './FreshnessWarning';
import { InternalLinks } from './InternalLinks';
import { BrokenExternalLinks } from './BrokenExternalLinks';
import { LocalizationKey, localize } from '../../../localization';
import type { LinkValidationResult } from '../../../helpers/LinkValidator';
import type { ReadabilityResult } from '../../../helpers/ReadabilityHelper';
interface ContentHealthData {
internalLinks?: LinkValidationResult[];
brokenExternalLinks?: LinkValidationResult[];
readability?: ReadabilityResult;
freshnessWarning?: { daysSince: number; threshold: number } | null;
minReadability?: number;
}
interface Props {
contentHealth: ContentHealthData;
}
const ContentHealth: React.FunctionComponent<Props> = ({ contentHealth }) => {
if (!contentHealth) {
return null;
}
const { internalLinks, brokenExternalLinks, readability, freshnessWarning, minReadability = 0 } = contentHealth;
const hasBrokenInternalLinks = (internalLinks || []).some((l) => !l.exists);
const hasBrokenExternal = (brokenExternalLinks || []).length > 0;
const hasIssues = hasBrokenInternalLinks || hasBrokenExternal ||
(freshnessWarning !== null && freshnessWarning !== undefined) ||
(readability && minReadability > 0 && readability.score < minReadability);
return (
<Collapsible id='contentHealth' title={localize(LocalizationKey.panelContentHealthTitle)}>
<div className='space-y-2'>
<VSCodeTable>
<VSCodeTableBody>
{readability && (
<ReadabilityScore readability={readability} minScore={minReadability} />
)}
{freshnessWarning && (
<FreshnessWarning freshnessWarning={freshnessWarning} />
)}
</VSCodeTableBody>
</VSCodeTable>
{internalLinks && internalLinks.length > 0 && (
<InternalLinks internalLinks={internalLinks} />
)}
{brokenExternalLinks && brokenExternalLinks.length > 0 && (
<BrokenExternalLinks brokenExternalLinks={brokenExternalLinks} />
)}
{!hasIssues && (
<p className='text-xs text-[var(--vscode-testing-iconPassed)]'>
{localize(LocalizationKey.panelContentHealthNoIssues)}
</p>
)}
</div>
</Collapsible>
);
};
ContentHealth.displayName = 'ContentHealth';
export { ContentHealth };
@@ -0,0 +1,26 @@
import * as React from 'react';
import { VSCodeTableCell, VSCodeTableRow } from '../VSCode/VSCodeTable';
import { LocalizationKey, localize } from '../../../localization';
interface Props {
freshnessWarning: { daysSince: number; threshold: number };
}
const FreshnessWarning: React.FunctionComponent<Props> = ({ freshnessWarning }) => {
return (
<VSCodeTableRow>
<VSCodeTableCell>{localize(LocalizationKey.panelContentHealthFreshnessLabel)}</VSCodeTableCell>
<VSCodeTableCell>
<span className='text-[var(--vscode-problemsWarningIcon-foreground)]'>
{localize(
LocalizationKey.panelContentHealthFreshnessDaysOld,
freshnessWarning.daysSince
)}
</span>
</VSCodeTableCell>
</VSCodeTableRow>
);
};
FreshnessWarning.displayName = 'FreshnessWarning';
export { FreshnessWarning };
@@ -0,0 +1,74 @@
import { Messenger } from '@estruyf/vscode/dist/client';
import * as React from 'react';
import { CommandToCode } from '../../CommandToCode';
import { LocalizationKey, localize } from '../../../localization';
import type { LinkValidationResult } from '../../../helpers/LinkValidator';
interface Props {
internalLinks: LinkValidationResult[];
}
const truncate = (url: string, max = 40): string =>
url.length > max ? `${url.slice(0, max)}` : url;
const InternalLinks: React.FunctionComponent<Props> = ({ internalLinks }) => {
if (!internalLinks || internalLinks.length === 0) {
return null;
}
const handleSelect = (url: string) => {
Messenger.send(CommandToCode.selectInDocument, url);
};
const invalidLinks = React.useMemo(() => internalLinks.filter(link => !link.exists), [internalLinks]);
if (!invalidLinks || invalidLinks.length === 0) {
return null;
}
return (
<div className='mt-2'>
<p className='text-sm font-semibold text-[var(--vscode-descriptionForeground)] uppercase mb-1'>
{localize(LocalizationKey.panelArticleDetailsInternalLinks)}
</p>
<ul className='space-y-1'>
{invalidLinks.map((link) => (
<li
key={link.url}
className={`flex items-center gap-1 text-sm ${link.exists
? 'text-[var(--vscode-foreground)]'
: 'text-[var(--vscode-problemsErrorIcon-foreground)]'
}`}
>
<span
className={`shrink-0 w-2 h-2 rounded-full ${link.exists
? 'bg-[var(--vscode-testing-iconPassed)]'
: 'bg-[var(--vscode-problemsErrorIcon-foreground)]'
}`}
title={
link.exists
? localize(LocalizationKey.panelContentHealthLinkStatusValid)
: localize(LocalizationKey.panelContentHealthLinkStatusBroken)
}
/>
<button
className='text-[var(--vscode-problemsErrorIcon-foreground)] hover:underline cursor-pointer bg-transparent border-0 p-0 text-sm text-left truncate max-w-full hover:bg-transparent active:outline-0 focus:outline-0'
title={localize(LocalizationKey.panelContentHealthSelectInDocument, link.url)}
onClick={() => handleSelect(link.url)}
>
{truncate(link.url)}
{!link.exists && (
<span className='ml-1 opacity-70'>
{localize(LocalizationKey.panelContentHealthLinkNotFound)}
</span>
)}
</button>
</li>
))}
</ul>
</div>
);
};
InternalLinks.displayName = 'InternalLinks';
export { InternalLinks };
@@ -0,0 +1,165 @@
import * as React from 'react';
import { messageHandler } from '@estruyf/vscode/dist/client';
import { VSCodeTableCell, VSCodeTableRow } from '../VSCode/VSCodeTable';
import { CommandToCode } from '../../CommandToCode';
import { useRecoilValue } from 'recoil';
import { PanelSettingsAtom } from '../../state';
import { CopilotIcon } from '../Icons';
import { LocalizationKey, localize } from '../../../localization';
import type { ReadabilityLabel, ReadabilityResult } from '../../../helpers/ReadabilityHelper';
// Targets from Flesch formula: shorter sentences and simpler words both raise the score
const TARGET_WORDS_PER_SENTENCE = 20;
const TARGET_SYLLABLES_PER_WORD = 1.5;
interface Props {
readability: ReadabilityResult;
minScore: number;
}
const scoreColor = (score: number): string => {
if (score >= 60) { return 'text-[var(--vscode-testing-iconPassed)]'; }
if (score >= 30) { return 'text-[var(--vscode-problemsWarningIcon-foreground)]'; }
return 'text-[var(--vscode-problemsErrorIcon-foreground)]';
};
const localizedReadabilityLabel = (label: ReadabilityLabel): string => {
switch (label) {
case 'veryEasy':
return localize(LocalizationKey.panelContentHealthReadabilityLevelVeryEasy);
case 'easy':
return localize(LocalizationKey.panelContentHealthReadabilityLevelEasy);
case 'standard':
return localize(LocalizationKey.panelContentHealthReadabilityLevelStandard);
case 'difficult':
return localize(LocalizationKey.panelContentHealthReadabilityLevelDifficult);
default:
return localize(LocalizationKey.panelContentHealthReadabilityLevelVeryDifficult);
}
};
const ReadabilityScore: React.FunctionComponent<Props> = ({ readability, minScore }) => {
const settings = useRecoilValue(PanelSettingsAtom);
const isLow = minScore > 0 && readability.score < minScore;
const sentencesTooLong = readability.avgWordsPerSentence > TARGET_WORDS_PER_SENTENCE;
const wordsTooComplex = readability.avgSyllablesPerWord > TARGET_SYLLABLES_PER_WORD;
const optimizeReadability = (type: 'sentence' | 'word') => {
const command =
type === 'sentence'
? CommandToCode.copilotOptimizeAvgSentence
: CommandToCode.copilotOptimizeAvgWord;
messageHandler
.request<boolean>(command)
.catch(() => {
// Errors are surfaced via the extension message pipeline.
})
};
return (
<>
<VSCodeTableRow>
<VSCodeTableCell>{localize(LocalizationKey.panelContentHealthReadabilityLabel)}</VSCodeTableCell>
<VSCodeTableCell>
<span className={`font-semibold ${scoreColor(readability.score)}`}>
{readability.score}
</span>
<span className='text-[var(--vscode-descriptionForeground)] ml-1'>
/ 100 &mdash; {localizedReadabilityLabel(readability.label)}
{isLow && (
<span className='ml-1 text-[var(--vscode-problemsWarningIcon-foreground)]'>
{localize(LocalizationKey.panelContentHealthReadabilityBelowThreshold, minScore)}
</span>
)}
</span>
</VSCodeTableCell>
</VSCodeTableRow>
<VSCodeTableRow>
<VSCodeTableCell>{localize(LocalizationKey.panelContentHealthReadabilityAvgSentence)}</VSCodeTableCell>
<VSCodeTableCell>
<div className='flex items-center justify-between gap-1'>
<div>
<span className={sentencesTooLong ? 'text-[var(--vscode-problemsWarningIcon-foreground)]' : 'text-[var(--vscode-testing-iconPassed)]'}>
{localize(
LocalizationKey.panelContentHealthReadabilityWords,
readability.avgWordsPerSentence
)}
</span>
<span className='text-[var(--vscode-descriptionForeground)] ml-1'>
{localize(
LocalizationKey.panelContentHealthReadabilityTarget,
TARGET_WORDS_PER_SENTENCE
)}
</span>
</div>
{settings?.copilotEnabled && sentencesTooLong && (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
title={localize(LocalizationKey.panelContentHealthReadabilityCopilotOptimizeSentence)}
type='button'
onClick={() => optimizeReadability('sentence')}
>
<CopilotIcon />
</button>
)}
</div>
{sentencesTooLong && (
<p className='text-xs text-[var(--vscode-descriptionForeground)] mt-0.5'>
{localize(LocalizationKey.panelContentHealthReadabilityHintSentence)}
</p>
)}
</VSCodeTableCell>
</VSCodeTableRow>
<VSCodeTableRow>
<VSCodeTableCell>{localize(LocalizationKey.panelContentHealthReadabilityAvgWord)}</VSCodeTableCell>
<VSCodeTableCell>
<div className='flex items-center justify-between gap-1'>
<div>
<span className={wordsTooComplex ? 'text-[var(--vscode-problemsWarningIcon-foreground)]' : 'text-[var(--vscode-testing-iconPassed)]'}>
{localize(
LocalizationKey.panelContentHealthReadabilitySyllables,
readability.avgSyllablesPerWord
)}
</span>
<span className='text-[var(--vscode-descriptionForeground)] ml-1'>
{localize(
LocalizationKey.panelContentHealthReadabilityTarget,
TARGET_SYLLABLES_PER_WORD
)}
</span>
</div>
{settings?.copilotEnabled && wordsTooComplex && (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
title={localize(LocalizationKey.panelContentHealthReadabilityCopilotOptimizeWord)}
type='button'
onClick={() => optimizeReadability('word')}
>
<CopilotIcon />
</button>
)}
</div>
{wordsTooComplex && (
<p className='text-xs text-[var(--vscode-descriptionForeground)] mt-0.5'>
{localize(LocalizationKey.panelContentHealthReadabilityHintWord)}
</p>
)}
</VSCodeTableCell>
</VSCodeTableRow>
</>
);
};
ReadabilityScore.displayName = 'ReadabilityScore';
export { ReadabilityScore };
@@ -15,7 +15,7 @@ export interface IContentTypeValidatorProps {
metadata: IMetadata;
}
const fieldsToIgnore = [`filePath`, `articleDetails`, DefaultFields.Slug, DefaultFields.Keywords, DefaultFields.Type, DefaultFields.ContentType];
const fieldsToIgnore = [`filePath`, `articleDetails`, DefaultFields.Slug, DefaultFields.Keywords, DefaultFields.Type, DefaultFields.ContentType, DefaultFields.ContentHealth];
export const ContentTypeValidator: React.FunctionComponent<IContentTypeValidatorProps> = ({
fields,
+3
View File
@@ -35,6 +35,9 @@ export default function useMessages() {
setMetadata(message.payload);
setLoading(false);
break;
case Command.contentHealth:
setMetadata((prev: any) => prev ? { ...prev, contentHealth: message.payload } : prev);
break;
case Command.settings:
setSettings(message.payload);
setLoading(false);