diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f7278c5..7a182f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 8e297335..dff4e9cc 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -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", diff --git a/package.json b/package.json index e4e57b91..5e573cc4 100644 --- a/package.json +++ b/package.json @@ -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 (0–100) before a readability warning is shown (0 disables the threshold).", + "scope": "ContentHealth" + }, "frontMatter.website.host": { "type": "string", "markdownDescription": "%setting.frontMatter.website.host.markdownDescription%" diff --git a/src/commands/Folders.ts b/src/commands/Folders.ts index 8e6be394..81563ab8 100644 --- a/src/commands/Folders.ts +++ b/src/commands/Folders.ts @@ -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 diff --git a/src/constants/DefaultFields.ts b/src/constants/DefaultFields.ts index a0168682..995de543 100644 --- a/src/constants/DefaultFields.ts +++ b/src/constants/DefaultFields.ts @@ -6,5 +6,6 @@ export const DefaultFields = { Slug: `slug`, Type: `type`, ContentType: `fmContentType`, - Keywords: `keywords` + Keywords: `keywords`, + ContentHealth: `contentHealth` }; diff --git a/src/constants/Features.ts b/src/constants/Features.ts index 4aeb5f9d..8c212bd6 100644 --- a/src/constants/Features.ts +++ b/src/constants/Features.ts @@ -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: { diff --git a/src/constants/settings.ts b/src/constants/settings.ts index 96e1dd8a..7d10952b 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -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 */ diff --git a/src/helpers/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index 9761bc16..5eae96c2 100644 --- a/src/helpers/ArticleHelper.ts +++ b/src/helpers/ArticleHelper.ts @@ -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 diff --git a/src/helpers/LinkValidator.ts b/src/helpers/LinkValidator.ts new file mode 100644 index 00000000..b220d212 --- /dev/null +++ b/src/helpers/LinkValidator.ts @@ -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(); + + /** + * 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 { + 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 { + 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 + ): Promise { + 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 { + 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 { + const index = new Map(); + 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(); + 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 | undefined { + const urlPath = LinkValidator.toComparablePath(url); + const candidates = new Set([urlPath]); + + if (urlPath.endsWith('/index')) { + candidates.add(urlPath.slice(0, -6)); + } + + // Some frameworks prepend route groups (for example, /session/) + // 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 { + // 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 { + try { + await workspace.fs.stat(Uri.file(filePath)); + return true; + } catch { + return false; + } + } + + private static async headRequest(url: string): Promise { + try { + const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) }); + return res.ok; + } catch { + return false; + } + } +} diff --git a/src/helpers/ReadabilityHelper.ts b/src/helpers/ReadabilityHelper.ts new file mode 100644 index 00000000..c8aa744b --- /dev/null +++ b/src/helpers/ReadabilityHelper.ts @@ -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, 60–79 Easy, 30–59 Standard, 10–29 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 + }; + } +} diff --git a/src/listeners/panel/DataListener.ts b/src/listeners/panel/DataListener.ts index 80c285dd..ec0acdfb 100644 --- a/src/listeners/panel/DataListener.ts +++ b/src/listeners/panel/DataListener.ts @@ -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(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 + ): Promise { + const healthEnabled = Settings.get(SETTING_CONTENT_HEALTH_ENABLED) !== false; + if (!healthEnabled) { + return; + } + + try { + const folders = await Folders.getCachedOrFresh(); + + const experimental = Settings.get(SETTING_EXPERIMENTAL) !== false; + const internalLinks = experimental + ? await LinkValidator.validateInternalLinks( + articleDetails.internalLinkUrls || [], + filePath, + folders + ) + : []; + + const checkExternal = + Settings.get(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(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(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(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}`); + } } /** diff --git a/src/listeners/panel/ExtensionListener.ts b/src/listeners/panel/ExtensionListener.ts index bdfdecae..425eb447 100644 --- a/src/listeners/panel/ExtensionListener.ts +++ b/src/listeners/panel/ExtensionListener.ts @@ -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); } } diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index fb53ef77..913272cf 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -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 */ diff --git a/src/panelWebView/Command.ts b/src/panelWebView/Command.ts index 4840847b..ec17225d 100644 --- a/src/panelWebView/Command.ts +++ b/src/panelWebView/Command.ts @@ -10,5 +10,6 @@ export enum Command { sendMediaUrl = 'sendMediaUrl', updatePlaceholder = 'updatePlaceholder', dataFileEntries = 'dataFileEntries', - serverStarted = 'server-started' + serverStarted = 'server-started', + contentHealth = 'contentHealth' } diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index b5307bdb..a2670f7f 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -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' } diff --git a/src/panelWebView/ViewPanel.tsx b/src/panelWebView/ViewPanel.tsx index ba7ed5bd..cf8d0994 100644 --- a/src/panelWebView/ViewPanel.tsx +++ b/src/panelWebView/ViewPanel.tsx @@ -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 = () => { + { + !loading && metadata?.contentHealth && ( + + + + ) + } + { !loading && metadata && ( diff --git a/src/panelWebView/components/ContentHealth/BrokenExternalLinks.tsx b/src/panelWebView/components/ContentHealth/BrokenExternalLinks.tsx new file mode 100644 index 00000000..ef2f7e82 --- /dev/null +++ b/src/panelWebView/components/ContentHealth/BrokenExternalLinks.tsx @@ -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 = ({ brokenExternalLinks }) => { + if (!brokenExternalLinks || brokenExternalLinks.length === 0) { + return null; + } + + const handleSelect = (url: string) => { + Messenger.send(CommandToCode.selectInDocument, url); + }; + + return ( +
+

+ {localize(LocalizationKey.panelContentHealthBrokenExternalLinks)} +

+
    + {brokenExternalLinks.map((link) => ( +
  • + + +
  • + ))} +
+
+ ); +}; + +BrokenExternalLinks.displayName = 'BrokenExternalLinks'; +export { BrokenExternalLinks }; diff --git a/src/panelWebView/components/ContentHealth/ContentHealth.tsx b/src/panelWebView/components/ContentHealth/ContentHealth.tsx new file mode 100644 index 00000000..cd6f6e61 --- /dev/null +++ b/src/panelWebView/components/ContentHealth/ContentHealth.tsx @@ -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 = ({ 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 ( + +
+ + + {readability && ( + + )} + {freshnessWarning && ( + + )} + + + + {internalLinks && internalLinks.length > 0 && ( + + )} + + {brokenExternalLinks && brokenExternalLinks.length > 0 && ( + + )} + + {!hasIssues && ( +

+ {localize(LocalizationKey.panelContentHealthNoIssues)} +

+ )} +
+
+ ); +}; + +ContentHealth.displayName = 'ContentHealth'; +export { ContentHealth }; diff --git a/src/panelWebView/components/ContentHealth/FreshnessWarning.tsx b/src/panelWebView/components/ContentHealth/FreshnessWarning.tsx new file mode 100644 index 00000000..f313abc9 --- /dev/null +++ b/src/panelWebView/components/ContentHealth/FreshnessWarning.tsx @@ -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 = ({ freshnessWarning }) => { + return ( + + {localize(LocalizationKey.panelContentHealthFreshnessLabel)} + + + {localize( + LocalizationKey.panelContentHealthFreshnessDaysOld, + freshnessWarning.daysSince + )} + + + + ); +}; + +FreshnessWarning.displayName = 'FreshnessWarning'; +export { FreshnessWarning }; diff --git a/src/panelWebView/components/ContentHealth/InternalLinks.tsx b/src/panelWebView/components/ContentHealth/InternalLinks.tsx new file mode 100644 index 00000000..e1da6eeb --- /dev/null +++ b/src/panelWebView/components/ContentHealth/InternalLinks.tsx @@ -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 = ({ 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 ( +
+

+ {localize(LocalizationKey.panelArticleDetailsInternalLinks)} +

+
    + {invalidLinks.map((link) => ( +
  • + + +
  • + ))} +
+
+ ); +}; + +InternalLinks.displayName = 'InternalLinks'; +export { InternalLinks }; diff --git a/src/panelWebView/components/ContentHealth/ReadabilityScore.tsx b/src/panelWebView/components/ContentHealth/ReadabilityScore.tsx new file mode 100644 index 00000000..4c760d33 --- /dev/null +++ b/src/panelWebView/components/ContentHealth/ReadabilityScore.tsx @@ -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 = ({ 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(command) + .catch(() => { + // Errors are surfaced via the extension message pipeline. + }) + }; + + return ( + <> + + {localize(LocalizationKey.panelContentHealthReadabilityLabel)} + + + {readability.score} + + + / 100 — {localizedReadabilityLabel(readability.label)} + {isLow && ( + + {localize(LocalizationKey.panelContentHealthReadabilityBelowThreshold, minScore)} + + )} + + + + + + {localize(LocalizationKey.panelContentHealthReadabilityAvgSentence)} + +
+
+ + {localize( + LocalizationKey.panelContentHealthReadabilityWords, + readability.avgWordsPerSentence + )} + + + {localize( + LocalizationKey.panelContentHealthReadabilityTarget, + TARGET_WORDS_PER_SENTENCE + )} + +
+ + {settings?.copilotEnabled && sentencesTooLong && ( + + )} +
+ + + + {sentencesTooLong && ( +

+ {localize(LocalizationKey.panelContentHealthReadabilityHintSentence)} +

+ )} +
+
+ + + {localize(LocalizationKey.panelContentHealthReadabilityAvgWord)} + +
+
+ + {localize( + LocalizationKey.panelContentHealthReadabilitySyllables, + readability.avgSyllablesPerWord + )} + + + + {localize( + LocalizationKey.panelContentHealthReadabilityTarget, + TARGET_SYLLABLES_PER_WORD + )} + +
+ + {settings?.copilotEnabled && wordsTooComplex && ( + + )} +
+ + {wordsTooComplex && ( +

+ {localize(LocalizationKey.panelContentHealthReadabilityHintWord)} +

+ )} +
+
+ + ); +}; + +ReadabilityScore.displayName = 'ReadabilityScore'; +export { ReadabilityScore }; diff --git a/src/panelWebView/components/ContentType/ContentTypeValidator.tsx b/src/panelWebView/components/ContentType/ContentTypeValidator.tsx index deef83c0..da76e450 100644 --- a/src/panelWebView/components/ContentType/ContentTypeValidator.tsx +++ b/src/panelWebView/components/ContentType/ContentTypeValidator.tsx @@ -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 = ({ fields, diff --git a/src/panelWebView/hooks/useMessages.tsx b/src/panelWebView/hooks/useMessages.tsx index 099693e3..f550ee10 100644 --- a/src/panelWebView/hooks/useMessages.tsx +++ b/src/panelWebView/hooks/useMessages.tsx @@ -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);