mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-08-10 10:53:07 +02:00
feat: add content health checks and readability analysis
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,5 +6,6 @@ export const DefaultFields = {
|
||||
Slug: `slug`,
|
||||
Type: `type`,
|
||||
ContentType: `fmContentType`,
|
||||
Keywords: `keywords`
|
||||
Keywords: `keywords`,
|
||||
ContentHealth: `contentHealth`
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -10,5 +10,6 @@ export enum Command {
|
||||
sendMediaUrl = 'sendMediaUrl',
|
||||
updatePlaceholder = 'updatePlaceholder',
|
||||
dataFileEntries = 'dataFileEntries',
|
||||
serverStarted = 'server-started'
|
||||
serverStarted = 'server-started',
|
||||
contentHealth = 'contentHealth'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 — {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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user