From 2c7209164fc375a70774597f594756da9d592eac Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:36:33 +0200 Subject: [PATCH 01/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 391 ++++++++++++++++++++++++++++++ .github/workflows/readability.yml | 16 ++ 2 files changed, 407 insertions(+) create mode 100644 .github/scripts/readability.mjs create mode 100644 .github/workflows/readability.yml diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs new file mode 100644 index 000000000..ebfd0a26a --- /dev/null +++ b/.github/scripts/readability.mjs @@ -0,0 +1,391 @@ +#!/usr/bin/env node +/** + * Readability checker for MDX documentation. + * + * Validates readability separately for: + * - Primary content (always visible to the reader) + * - Collapsed content (
, ) per section + * + * Thresholds mirror vale.ini audience segments: + * Business docs → FK ≤ 8, FRE ≥ 70 + * Technical docs → FK ≤ 14, FRE ≥ 30 + * + * Additional neurodiverse-friendly checks (both sections): + * - Average sentence length ≤ 20 words + * + * Each failure includes: + * - GitHub annotation with file path and line number + * - A preview of the most problematic sentence + * - A concrete suggestion for how to fix it + * + * Usage: node readability.mjs [target-dir] + * Defaults to 'versioned_docs'. + */ + +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative, extname, basename } from 'path'; +import process from 'process'; + +// ── Thresholds ─────────────────────────────────────────────────────────────── + +const TECH_FILE_PATTERNS = [ + /versioned_docs\/.*\/framework\//, + /versioned_docs\/.*\/dashboard\/installation\//, + /versioned_docs\/.*\/(technical|architecture-diagram)\.mdx?$/, + /versioned_docs\/.*\/support\/(migrate|release-notes)/, +]; + +const THRESHOLDS = { + business: { fkMax: 8, freMin: 70 }, + tech: { fkMax: 14, freMin: 30 }, +}; + +function getThresholds(filePath) { + const p = filePath.replace(/\\/g, '/'); + return TECH_FILE_PATTERNS.some(pattern => pattern.test(p)) + ? THRESHOLDS.tech + : THRESHOLDS.business; +} + +// ── Section extraction ─────────────────────────────────────────────────────── + +/** + * Splits MDX content into primary (always visible) and collapsed sections. + * content inside
is treated as primary — it's always shown. + * + * Returns: + * primary: { text: string, startLine: number } + * collapsed: Array<{ text: string, startLine: number }> + */ +function extractSections(content) { + const lines = content.split('\n'); + const primaryLines = []; + const collapsedSections = []; + + let inFrontmatter = false; + let frontmatterDone = false; + let inCodeBlock = false; + let collapsibleDepth = 0; + let currentCollapsed = null; + let currentCollapsedStartLine = 1; + let inSummary = false; + let primaryStartLine = 1; + let firstPrimaryLine = true; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; + + // Strip frontmatter block + if (!frontmatterDone) { + if (line.trim() === '---') { + if (!inFrontmatter) { inFrontmatter = true; continue; } + else { frontmatterDone = true; continue; } + } + if (inFrontmatter) continue; + } + + // Track fenced code blocks — skip their content entirely + if (/^```/.test(line.trim())) { + inCodeBlock = !inCodeBlock; + continue; + } + if (inCodeBlock) continue; + + const opensCollapsible = /<(details|Collapsible)[\s>]/.test(line); + const closesCollapsible = /<\/(details|Collapsible)>/.test(line); + + if (currentCollapsed === null) { + if (opensCollapsible) { + currentCollapsed = []; + currentCollapsedStartLine = lineNumber; + collapsibleDepth = 1; + // A opening on the same line as
is visible + if (/]+>/g, ' ').trim()); + if (/<\/summary>/.test(line)) inSummary = false; + } + continue; + } + if (firstPrimaryLine && line.trim()) { + primaryStartLine = lineNumber; + firstPrimaryLine = false; + } + primaryLines.push(line); + } else { + // Inside a collapsible block + if (opensCollapsible) collapsibleDepth++; + + if (closesCollapsible) { + collapsibleDepth--; + if (collapsibleDepth === 0) { + collapsedSections.push({ text: currentCollapsed.join('\n'), startLine: currentCollapsedStartLine }); + currentCollapsed = null; + continue; + } + } + + // at depth 1 is always visible — redirect to primary + if (/]+>/g, ' ').trim()); + if (/<\/summary>/.test(line)) inSummary = false; + continue; + } + + currentCollapsed.push(line); + } + } + + // Unclosed collapsible block (malformed MDX) — treat remainder as primary + if (currentCollapsed !== null) primaryLines.push(...currentCollapsed); + + return { + primary: { text: primaryLines.join('\n'), startLine: primaryStartLine }, + collapsed: collapsedSections, + }; +} + +// ── Text cleaning ──────────────────────────────────────────────────────────── + +function toPlainText(raw) { + return raw + .replace(/\{\/\*[\s\S]*?\*\/\}/g, '') // JSX comments {/* ... */} + .replace(/import\s[^;]+;/g, '') // import statements + .replace(/:::[\w-]+[^\n]*/g, '') // admonition type markers + .replace(/`{3}[^\n]*\n[\s\S]*?`{3}/g, '') // fenced code blocks + .replace(/`[^`\n]+`/g, 'code') // inline code → neutral word + .replace(/<[^>]+>/g, ' ') // HTML/JSX tags + .replace(/\{[^}]+\}/g, ' ') // JSX expressions + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → label text only + .replace(/^#{1,6}\s+/gm, '') // heading markers + .replace(/\*{1,2}([^*\n]+)\*{1,2}/g, '$1') // bold/italic markers + .replace(/^[-*+]\s+/gm, '') // unordered list markers + .replace(/^\d+\.\s+/gm, '') // ordered list markers + .replace(/\s+/g, ' ') + .trim(); +} + +// ── Readability metrics ────────────────────────────────────────────────────── + +function countSyllables(word) { + word = word.toLowerCase().replace(/[^a-z]/g, ''); + if (!word) return 0; + if (word.length <= 3) return 1; + word = word.replace(/(?:[^laeiouy]es|[^laeiouy]e)$/, ''); + word = word.replace(/^y/, ''); + const groups = word.match(/[aeiouy]{1,2}/g); + return Math.max(1, groups ? groups.length : 1); +} + +function getSentences(text) { + return text + .split(/(?<=[.!?])\s+/) + .map(s => s.trim()) + .filter(s => s.split(/\s+/).length > 2); +} + +function wordCount(sentence) { + return (sentence.match(/\b[a-zA-Z'-]{2,}\b/g) ?? []).length; +} + +/** Returns the longest sentence truncated to maxLen characters. */ +function longestSentencePreview(sentences, maxLen = 120) { + if (!sentences.length) return null; + const longest = sentences.slice().sort((a, b) => wordCount(b) - wordCount(a))[0]; + return longest.length > maxLen ? longest.slice(0, maxLen - 1) + '…' : longest; +} + +/** + * Calculates Flesch-Kincaid grade level, Flesch Reading Ease, + * and average words per sentence. + * Returns null when there is not enough text to produce a meaningful score. + */ +function analyzeText(text) { + const sentences = getSentences(text); + const words = text.match(/\b[a-zA-Z'-]{2,}\b/g) ?? []; + + if (sentences.length < 2 || words.length < 15) return null; + + const syllables = words.reduce((n, w) => n + countSyllables(w), 0); + const avgWords = words.length / sentences.length; + const avgSyllables = syllables / words.length; + + return { + fk: Math.round((0.39 * avgWords + 11.8 * avgSyllables - 15.59) * 10) / 10, + fre: Math.round((206.835 - 1.015 * avgWords - 84.6 * avgSyllables) * 10) / 10, + wordCount: words.length, + sentenceCount: sentences.length, + avgWords: Math.round(avgWords * 10) / 10, + sentences, + }; +} + +// ── Reporting ──────────────────────────────────────────────────────────────── + +const SUGGESTIONS = { + fk: 'Split long sentences at conjunctions (and, but, which, that), or replace multi-syllable words with shorter alternatives.', + fre: 'Use shorter, more common words. Aim for 1–2 syllable words in most sentences.', + len: 'Look for "which", "that", "and", "but", "because" as natural split points to break this into two sentences.', +}; + +function annotate(filePath, startLine, label, checkName, stats, preview) { + const messages = { + fk: `[${label}] Flesch-Kincaid grade ${stats.fk} exceeds target of ≤${stats.fkMax} ` + + `(${stats.wordCount} words, ${stats.sentenceCount} sentences, avg ${stats.avgWords} words/sentence).`, + fre: `[${label}] Flesch Reading Ease ${stats.fre} is below target of ≥${stats.freMin}.`, + len: `[${label}] Average sentence length is ${stats.avgWords} words (target: ≤20).`, + }; + + const parts = [ + messages[checkName], + preview ? `Longest sentence: "${preview}"` : null, + `Suggestion: ${SUGGESTIONS[checkName]}`, + ].filter(Boolean).join(' | '); + + console.log(`::warning file=${filePath},line=${startLine}::${parts}`); +} + +// ── Issue tracking (for summary) ───────────────────────────────────────────── + +const issueLog = []; + +function recordIssue(filePath, label, checkName, startLine) { + issueLog.push({ filePath, label, checkName, startLine }); +} + +// ── Section checker ────────────────────────────────────────────────────────── + +function checkSection(label, text, filePath, startLine, thresholds) { + const plain = toPlainText(text); + const stats = analyzeText(plain); + if (!stats) return true; + + const { fk, fre, avgWords, sentences } = stats; + const preview = longestSentencePreview(sentences); + const enriched = { ...stats, fkMax: thresholds.fkMax, freMin: thresholds.freMin }; + let passed = true; + + if (fk > thresholds.fkMax) { + annotate(filePath, startLine, label, 'fk', enriched, preview); + recordIssue(filePath, label, 'fk', startLine); + passed = false; + } + + if (fre < thresholds.freMin) { + annotate(filePath, startLine, label, 'fre', enriched, preview); + recordIssue(filePath, label, 'fre', startLine); + passed = false; + } + + if (avgWords > 20) { + annotate(filePath, startLine, label, 'len', enriched, preview); + recordIssue(filePath, label, 'len', startLine); + passed = false; + } + + return passed; +} + +// ── File discovery ─────────────────────────────────────────────────────────── + +const EXCLUDED_DIRS = new Set(['deprecated']); +const EXCLUDED_FILES = /import.flow-via-fa\.mdx?$/; + +function findMdxFiles(dir) { + const results = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (!EXCLUDED_DIRS.has(entry)) results.push(...findMdxFiles(full)); + } else if (['.md', '.mdx'].includes(extname(entry))) { + if (!EXCLUDED_FILES.test(basename(entry))) results.push(full); + } + } + return results; +} + +// ── Summary report ─────────────────────────────────────────────────────────── + +const CHECK_LABELS = { + fk: 'Flesch-Kincaid grade too high', + fre: 'Flesch Reading Ease too low ', + len: 'Avg sentence length > 20 words', +}; + +function printSummary(totalFiles, allPassed) { + const LINE = '─'.repeat(62); + const filesWithIssues = [...new Set(issueLog.map(i => i.filePath))]; + const countByType = { fk: 0, fre: 0, len: 0 }; + for (const issue of issueLog) countByType[issue.checkName]++; + + const issuesByFile = {}; + for (const issue of issueLog) { + (issuesByFile[issue.filePath] ??= []).push(issue); + } + + console.log('\n::group::Readability Check — Summary'); + console.log(LINE); + console.log(` Files checked : ${totalFiles}`); + console.log(` Files with issues : ${filesWithIssues.length}`); + console.log(` Total warnings : ${issueLog.length}`); + console.log(LINE); + console.log(' By check type:'); + for (const [key, label] of Object.entries(CHECK_LABELS)) { + const n = countByType[key]; + if (n > 0) console.log(` ${label} : ${n}`); + } + + if (filesWithIssues.length > 0) { + console.log(LINE); + console.log(' Files needing attention (sorted by issue count):'); + const sorted = filesWithIssues + .map(f => ({ f, n: issuesByFile[f].length })) + .sort((a, b) => b.n - a.n); + + for (const { f, n } of sorted) { + const sections = [...new Set(issuesByFile[f].map(i => i.label))].join(', '); + const flag = n >= 3 ? '✗✗' : '✗ '; + console.log(` ${flag} ${f}`); + console.log(` ${n} warning${n > 1 ? 's' : ''} in: ${sections}`); + } + } + + console.log(LINE); + console.log(allPassed ? ' ✓ All checks passed.' : ' ✗ Readability check failed. See warnings above.'); + console.log(LINE); + console.log('::endgroup::'); +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +const targetDir = process.argv[2] ?? 'versioned_docs'; +const files = findMdxFiles(targetDir); +let allPassed = true; + +for (const file of files) { + const content = readFileSync(file, 'utf8'); + const { primary, collapsed } = extractSections(content); + const thresholds = getThresholds(file); + const relPath = relative(process.cwd(), file).replace(/\\/g, '/'); + + if (!checkSection('Primary content', primary.text, relPath, primary.startLine, thresholds)) { + allPassed = false; + } + + for (let i = 0; i < collapsed.length; i++) { + const { text, startLine } = collapsed[i]; + if (!checkSection(`Collapsed section ${i + 1}`, text, relPath, startLine, thresholds)) { + allPassed = false; + } + } +} + +printSummary(files.length, allPassed); + +if (!allPassed) { + console.log('::error::Readability check failed. See warnings above for details.'); + process.exit(1); +} diff --git a/.github/workflows/readability.yml b/.github/workflows/readability.yml new file mode 100644 index 000000000..47db5aa87 --- /dev/null +++ b/.github/workflows/readability.yml @@ -0,0 +1,16 @@ +name: Readability check +on: [pull_request] + +jobs: + readability: + name: Readability + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Check readability + run: node .github/scripts/readability.mjs versioned_docs From 798bc9644c869359c982480755ccd18f10ac375c Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:52:55 +0200 Subject: [PATCH 02/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 70 ++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index ebfd0a26a..e1f069ef8 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -54,8 +54,8 @@ function getThresholds(filePath) { * content inside
is treated as primary — it's always shown. * * Returns: - * primary: { text: string, startLine: number } - * collapsed: Array<{ text: string, startLine: number }> + * primary: { text: string, startLine: number, startCol: number } + * collapsed: Array<{ text: string, startLine: number, startCol: number }> */ function extractSections(content) { const lines = content.split('\n'); @@ -68,8 +68,10 @@ function extractSections(content) { let collapsibleDepth = 0; let currentCollapsed = null; let currentCollapsedStartLine = 1; + let currentCollapsedStartCol = 1; let inSummary = false; let primaryStartLine = 1; + let primaryStartCol = 1; let firstPrimaryLine = true; for (let i = 0; i < lines.length; i++) { @@ -92,13 +94,15 @@ function extractSections(content) { } if (inCodeBlock) continue; - const opensCollapsible = /<(details|Collapsible)[\s>]/.test(line); + const collapsibleMatch = line.match(/<(details|Collapsible)[\s>]/); + const opensCollapsible = collapsibleMatch !== null; const closesCollapsible = /<\/(details|Collapsible)>/.test(line); if (currentCollapsed === null) { if (opensCollapsible) { currentCollapsed = []; currentCollapsedStartLine = lineNumber; + currentCollapsedStartCol = line.indexOf(collapsibleMatch[0]) + 1; collapsibleDepth = 1; // A opening on the same line as
is visible if (/]+>/g, ' ') // HTML/JSX tags - .replace(/\{[^}]+\}/g, ' ') // JSX expressions - .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images - .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → label text only - .replace(/^#{1,6}\s+/gm, '') // heading markers - .replace(/\*{1,2}([^*\n]+)\*{1,2}/g, '$1') // bold/italic markers - .replace(/^[-*+]\s+/gm, '') // unordered list markers - .replace(/^\d+\.\s+/gm, '') // ordered list markers + .replace(/^\uFEFF/, '') // BOM character + .replace(/\{\/\*[\s\S]*?\*\/\}/g, '') // JSX comments {/* ... */} + .replace(/import\s[^;]+;/g, '') // import statements + .replace(/:::[\w-]*[^\n]*/g, '') // admonition markers (opening :::note and closing :::) + .replace(/`{3}[^\n]*\n[\s\S]*?`{3}/g, '') // fenced code blocks + .replace(/`[^`\n]+`/g, 'code') // inline code → neutral word + .replace(/\{\{[^}]*\}\}/g, ' ') // double-brace JSX expressions {{ }} + .replace(/\{[^}]+\}/g, ' ') // single-brace JSX expressions { } + .replace(/<[A-Z][A-Za-z]*[^>]*\/>/g, ' ') // self-closing JSX components + .replace(/<[A-Z][A-Za-z]*[^>]*>[\s\S]*?<\/[A-Z][A-Za-z]*>/g, ' ') // JSX component pairs ... + .replace(/<[^>]+>/g, ' ') // remaining HTML tags + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // inline links → label text only + .replace(/\[[^\]]+\]:\s*\S+[^\n]*/gm, '') // reference-style link definitions + .replace(/https?:\/\/\S+/g, '') // bare URLs + .replace(/^\|[-:\s|]+\|$/gm, '') // table separator rows + .replace(/\|/g, ' ') // table pipe characters + .replace(/^[-_*]{3,}\s*$/gm, '') // thematic breaks (--- ___ ***) + .replace(/^#{1,6}\s+/gm, '') // heading markers + .replace(/\*{1,2}([^*\n]*)\*{1,2}/g, '$1') // bold/italic markers (balanced) + .replace(/\*/g, '') // remaining unbalanced asterisks + .replace(/^[-*+]\s+(.+?)\.?\s*$/gm, '$1. ') // unordered list items → each becomes a sentence + .replace(/^\d+\.\s+(.+?)\.?\s*$/gm, '$1. ') // ordered list items → each becomes a sentence .replace(/\s+/g, ' ') + .replace(/[`\[\]]/g, '') // remaining bare backticks and brackets .trim(); } @@ -231,7 +247,7 @@ const SUGGESTIONS = { len: 'Look for "which", "that", "and", "but", "because" as natural split points to break this into two sentences.', }; -function annotate(filePath, startLine, label, checkName, stats, preview) { +function annotate(filePath, startLine, startCol, label, checkName, stats, preview) { const messages = { fk: `[${label}] Flesch-Kincaid grade ${stats.fk} exceeds target of ≤${stats.fkMax} ` + `(${stats.wordCount} words, ${stats.sentenceCount} sentences, avg ${stats.avgWords} words/sentence).`, @@ -245,7 +261,7 @@ function annotate(filePath, startLine, label, checkName, stats, preview) { `Suggestion: ${SUGGESTIONS[checkName]}`, ].filter(Boolean).join(' | '); - console.log(`::warning file=${filePath},line=${startLine}::${parts}`); + console.log(`::warning file=${filePath},line=${startLine},col=${startCol}::${parts}`); } // ── Issue tracking (for summary) ───────────────────────────────────────────── @@ -258,7 +274,7 @@ function recordIssue(filePath, label, checkName, startLine) { // ── Section checker ────────────────────────────────────────────────────────── -function checkSection(label, text, filePath, startLine, thresholds) { +function checkSection(label, text, filePath, startLine, startCol, thresholds) { const plain = toPlainText(text); const stats = analyzeText(plain); if (!stats) return true; @@ -269,19 +285,19 @@ function checkSection(label, text, filePath, startLine, thresholds) { let passed = true; if (fk > thresholds.fkMax) { - annotate(filePath, startLine, label, 'fk', enriched, preview); + annotate(filePath, startLine, startCol, label, 'fk', enriched, preview); recordIssue(filePath, label, 'fk', startLine); passed = false; } if (fre < thresholds.freMin) { - annotate(filePath, startLine, label, 'fre', enriched, preview); + annotate(filePath, startLine, startCol, label, 'fre', enriched, preview); recordIssue(filePath, label, 'fre', startLine); passed = false; } if (avgWords > 20) { - annotate(filePath, startLine, label, 'len', enriched, preview); + annotate(filePath, startLine, startCol, label, 'len', enriched, preview); recordIssue(filePath, label, 'len', startLine); passed = false; } @@ -371,13 +387,13 @@ for (const file of files) { const thresholds = getThresholds(file); const relPath = relative(process.cwd(), file).replace(/\\/g, '/'); - if (!checkSection('Primary content', primary.text, relPath, primary.startLine, thresholds)) { + if (!checkSection('Primary content', primary.text, relPath, primary.startLine, primary.startCol, thresholds)) { allPassed = false; } for (let i = 0; i < collapsed.length; i++) { - const { text, startLine } = collapsed[i]; - if (!checkSection(`Collapsed section ${i + 1}`, text, relPath, startLine, thresholds)) { + const { text, startLine, startCol } = collapsed[i]; + if (!checkSection(`Collapsed section ${i + 1}`, text, relPath, startLine, startCol, thresholds)) { allPassed = false; } } From c6269ef2b57deec52f00e853d6485c8278d962e9 Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:59:41 +0200 Subject: [PATCH 03/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 147 ++++++++++++++++++++++++-------- 1 file changed, 112 insertions(+), 35 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index e1f069ef8..91963ab45 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -26,6 +26,25 @@ import { readFileSync, readdirSync, statSync } from 'fs'; import { join, relative, extname, basename } from 'path'; import process from 'process'; +// ── Environment ────────────────────────────────────────────────────────────── + +const IS_GH_ACTIONS = !!process.env.GITHUB_ACTIONS; + +// ANSI codes — all no-ops in CI so annotations stay plain text +const c = IS_GH_ACTIONS ? Object.fromEntries( + ['reset','bold','dim','italic','cyan','yellow','green','gray','red'].map(k => [k, '']) +) : { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + italic: '\x1b[3m', + cyan: '\x1b[36m', + yellow: '\x1b[33m', + green: '\x1b[32m', + gray: '\x1b[90m', + red: '\x1b[31m', +}; + // ── Thresholds ─────────────────────────────────────────────────────────────── const TECH_FILE_PATTERNS = [ @@ -247,21 +266,58 @@ const SUGGESTIONS = { len: 'Look for "which", "that", "and", "but", "because" as natural split points to break this into two sentences.', }; -function annotate(filePath, startLine, startCol, label, checkName, stats, preview) { +function createWarning(filePath, startLine, startCol, label, checkName, stats, preview) { const messages = { - fk: `[${label}] Flesch-Kincaid grade ${stats.fk} exceeds target of ≤${stats.fkMax} ` + - `(${stats.wordCount} words, ${stats.sentenceCount} sentences, avg ${stats.avgWords} words/sentence).`, - fre: `[${label}] Flesch Reading Ease ${stats.fre} is below target of ≥${stats.freMin}.`, - len: `[${label}] Average sentence length is ${stats.avgWords} words (target: ≤20).`, + fk: `Flesch-Kincaid grade ${stats.fk} exceeds target of ≤${stats.fkMax} ` + + `(${stats.wordCount} words, ${stats.sentenceCount} sentences, avg ${stats.avgWords} words/sentence)`, + fre: `Flesch Reading Ease ${stats.fre} is below target of ≥${stats.freMin}`, + len: `Average sentence length is ${stats.avgWords} words (target: ≤20)`, }; + return { filePath, startLine, startCol, label, checkName, message: messages[checkName], preview, suggestion: SUGGESTIONS[checkName] }; +} + +// ── Output rendering ───────────────────────────────────────────────────────── +function renderCI(w) { const parts = [ - messages[checkName], - preview ? `Longest sentence: "${preview}"` : null, - `Suggestion: ${SUGGESTIONS[checkName]}`, + `[${w.label}] ${w.message}.`, + w.preview ? `Longest sentence: "${w.preview}"` : null, + `Suggestion: ${w.suggestion}`, ].filter(Boolean).join(' | '); + return `::warning file=${w.filePath},line=${w.startLine},col=${w.startCol}::${parts}`; +} + +const COL_WIDTH = 68; - console.log(`::warning file=${filePath},line=${startLine},col=${startCol}::${parts}`); +function printFileWarningsCI(relPath, warnings) { + console.log(`\n::group::${relPath}`); + warnings.forEach(w => console.log(renderCI(w))); + console.log('::endgroup::'); +} + +function printFileWarningsLocal(relPath, warnings) { + const shortPath = relPath.replace(/^versioned_docs\/[^/]+\//, ''); + const titlePad = Math.max(2, COL_WIDTH - shortPath.length - 5); + console.log(`\n${c.bold}${c.cyan}┌─ ${shortPath} ${'─'.repeat(titlePad)}${c.reset}`); + + for (const w of warnings) { + const location = `${c.gray}line ${w.startLine}, col ${w.startCol}${c.reset}`; + console.log(`${c.cyan}│${c.reset}`); + console.log(`${c.cyan}│${c.reset} ${c.yellow}⚠${c.reset} ${c.bold}${w.label}${c.reset} ${c.gray}·${c.reset} ${location}`); + console.log(`${c.cyan}│${c.reset} ${c.yellow}${w.message}${c.reset}`); + if (w.preview) { + console.log(`${c.cyan}│${c.reset} ${c.dim}${c.italic}"${w.preview}"${c.reset}`); + } + console.log(`${c.cyan}│${c.reset} ${c.green}→ ${w.suggestion}${c.reset}`); + } + + console.log(`${c.cyan}│${c.reset}`); + console.log(`${c.bold}${c.cyan}└${'─'.repeat(COL_WIDTH)}${c.reset}`); +} + +function printFileWarnings(relPath, warnings) { + if (IS_GH_ACTIONS) printFileWarningsCI(relPath, warnings); + else printFileWarningsLocal(relPath, warnings); } // ── Issue tracking (for summary) ───────────────────────────────────────────── @@ -274,7 +330,7 @@ function recordIssue(filePath, label, checkName, startLine) { // ── Section checker ────────────────────────────────────────────────────────── -function checkSection(label, text, filePath, startLine, startCol, thresholds) { +function checkSection(label, text, filePath, startLine, startCol, thresholds, fileWarnings) { const plain = toPlainText(text); const stats = analyzeText(plain); if (!stats) return true; @@ -285,19 +341,19 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds) { let passed = true; if (fk > thresholds.fkMax) { - annotate(filePath, startLine, startCol, label, 'fk', enriched, preview); + fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'fk', enriched, preview)); recordIssue(filePath, label, 'fk', startLine); passed = false; } if (fre < thresholds.freMin) { - annotate(filePath, startLine, startCol, label, 'fre', enriched, preview); + fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'fre', enriched, preview)); recordIssue(filePath, label, 'fre', startLine); passed = false; } if (avgWords > 20) { - annotate(filePath, startLine, startCol, label, 'len', enriched, preview); + fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'len', enriched, preview)); recordIssue(filePath, label, 'len', startLine); passed = false; } @@ -332,7 +388,7 @@ const CHECK_LABELS = { }; function printSummary(totalFiles, allPassed) { - const LINE = '─'.repeat(62); + const LINE = '─'.repeat(COL_WIDTH); const filesWithIssues = [...new Set(issueLog.map(i => i.filePath))]; const countByType = { fk: 0, fre: 0, len: 0 }; for (const issue of issueLog) countByType[issue.checkName]++; @@ -342,37 +398,56 @@ function printSummary(totalFiles, allPassed) { (issuesByFile[issue.filePath] ??= []).push(issue); } - console.log('\n::group::Readability Check — Summary'); - console.log(LINE); - console.log(` Files checked : ${totalFiles}`); - console.log(` Files with issues : ${filesWithIssues.length}`); - console.log(` Total warnings : ${issueLog.length}`); - console.log(LINE); + const header = `${c.bold}Readability Check — Summary${c.reset}`; + + if (IS_GH_ACTIONS) console.log('\n::group::Readability Check — Summary'); + else console.log(`\n${c.bold}${c.cyan}${LINE}${c.reset}`); + + console.log(IS_GH_ACTIONS ? LINE : ` ${header}`); + if (!IS_GH_ACTIONS) console.log(`${c.bold}${c.cyan}${LINE}${c.reset}`); + + console.log(` Files checked : ${c.bold}${totalFiles}${c.reset}`); + console.log(` Files with issues : ${filesWithIssues.length > 0 ? c.yellow : c.green}${c.bold}${filesWithIssues.length}${c.reset}`); + console.log(` Total warnings : ${issueLog.length > 0 ? c.yellow : c.green}${c.bold}${issueLog.length}${c.reset}`); + console.log(IS_GH_ACTIONS ? LINE : `${c.bold}${c.cyan}${LINE}${c.reset}`); console.log(' By check type:'); + + const CHECK_LABELS = { + fk: 'Flesch-Kincaid grade too high ', + fre: 'Flesch Reading Ease too low ', + len: 'Avg sentence length > 20 words', + }; for (const [key, label] of Object.entries(CHECK_LABELS)) { const n = countByType[key]; - if (n > 0) console.log(` ${label} : ${n}`); + if (n > 0) console.log(` ${c.yellow}${label}${c.reset} : ${c.bold}${n}${c.reset}`); } if (filesWithIssues.length > 0) { - console.log(LINE); + console.log(IS_GH_ACTIONS ? LINE : `${c.bold}${c.cyan}${LINE}${c.reset}`); console.log(' Files needing attention (sorted by issue count):'); + const sorted = filesWithIssues .map(f => ({ f, n: issuesByFile[f].length })) .sort((a, b) => b.n - a.n); for (const { f, n } of sorted) { + const shortPath = f.replace(/^versioned_docs\/[^/]+\//, ''); const sections = [...new Set(issuesByFile[f].map(i => i.label))].join(', '); - const flag = n >= 3 ? '✗✗' : '✗ '; - console.log(` ${flag} ${f}`); - console.log(` ${n} warning${n > 1 ? 's' : ''} in: ${sections}`); + const flag = n >= 3 ? `${c.red}✗✗${c.reset}` : `${c.yellow}✗ ${c.reset}`; + console.log(` ${flag} ${c.bold}${shortPath}${c.reset}`); + console.log(` ${c.gray}${n} warning${n > 1 ? 's' : ''} in: ${sections}${c.reset}`); } } - console.log(LINE); - console.log(allPassed ? ' ✓ All checks passed.' : ' ✗ Readability check failed. See warnings above.'); - console.log(LINE); - console.log('::endgroup::'); + console.log(IS_GH_ACTIONS ? LINE : `${c.bold}${c.cyan}${LINE}${c.reset}`); + if (allPassed) { + console.log(` ${c.green}${c.bold}✓ All checks passed.${c.reset}`); + } else { + console.log(` ${c.red}${c.bold}✗ Readability check failed. See warnings above.${c.reset}`); + } + console.log(IS_GH_ACTIONS ? LINE : `${c.bold}${c.cyan}${LINE}${c.reset}`); + + if (IS_GH_ACTIONS) console.log('::endgroup::'); } // ── Main ───────────────────────────────────────────────────────────────────── @@ -386,16 +461,18 @@ for (const file of files) { const { primary, collapsed } = extractSections(content); const thresholds = getThresholds(file); const relPath = relative(process.cwd(), file).replace(/\\/g, '/'); + const fileWarnings = []; - if (!checkSection('Primary content', primary.text, relPath, primary.startLine, primary.startCol, thresholds)) { - allPassed = false; - } + checkSection('Primary content', primary.text, relPath, primary.startLine, primary.startCol, thresholds, fileWarnings); for (let i = 0; i < collapsed.length; i++) { const { text, startLine, startCol } = collapsed[i]; - if (!checkSection(`Collapsed section ${i + 1}`, text, relPath, startLine, startCol, thresholds)) { - allPassed = false; - } + checkSection(`Collapsed section ${i + 1}`, text, relPath, startLine, startCol, thresholds, fileWarnings); + } + + if (fileWarnings.length > 0) { + allPassed = false; + printFileWarnings(relPath, fileWarnings); } } From 81df8acecefb3b588b7706b8ec393695c8edb98b Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:32:25 +0200 Subject: [PATCH 04/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 172 ++++++++++++++++++++++++++------ 1 file changed, 143 insertions(+), 29 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index 91963ab45..e6d1c8f8b 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -55,8 +55,8 @@ const TECH_FILE_PATTERNS = [ ]; const THRESHOLDS = { - business: { fkMax: 8, freMin: 70 }, - tech: { fkMax: 14, freMin: 30 }, + business: { fkMax: 8, freMin: 70, clMax: 9, lixMax: 35 }, + tech: { fkMax: 14, freMin: 30, clMax: 16, lixMax: 55 }, }; function getThresholds(filePath) { @@ -180,7 +180,7 @@ function toPlainText(raw) { .replace(/import\s[^;]+;/g, '') // import statements .replace(/:::[\w-]*[^\n]*/g, '') // admonition markers (opening :::note and closing :::) .replace(/`{3}[^\n]*\n[\s\S]*?`{3}/g, '') // fenced code blocks - .replace(/`[^`\n]+`/g, 'code') // inline code → neutral word + .replace(/`[^`\n]+`/g, ' ') // inline code → remove (don't score code tokens) .replace(/\{\{[^}]*\}\}/g, ' ') // double-brace JSX expressions {{ }} .replace(/\{[^}]+\}/g, ' ') // single-brace JSX expressions { } .replace(/<[A-Z][A-Za-z]*[^>]*\/>/g, ' ') // self-closing JSX components @@ -193,7 +193,7 @@ function toPlainText(raw) { .replace(/^\|[-:\s|]+\|$/gm, '') // table separator rows .replace(/\|/g, ' ') // table pipe characters .replace(/^[-_*]{3,}\s*$/gm, '') // thematic breaks (--- ___ ***) - .replace(/^#{1,6}\s+/gm, '') // heading markers + .replace(/^#{1,6}\s+(.+)$/gm, '$1. ') // headings → sentence (prevents merging with next paragraph) .replace(/\*{1,2}([^*\n]*)\*{1,2}/g, '$1') // bold/italic markers (balanced) .replace(/\*/g, '') // remaining unbalanced asterisks .replace(/^[-*+]\s+(.+?)\.?\s*$/gm, '$1. ') // unordered list items → each becomes a sentence @@ -244,36 +244,101 @@ function analyzeText(text) { if (sentences.length < 2 || words.length < 15) return null; - const syllables = words.reduce((n, w) => n + countSyllables(w), 0); + const syllables = words.reduce((n, w) => n + countSyllables(w), 0); + const chars = words.reduce((n, w) => n + w.replace(/[^a-zA-Z]/g, '').length, 0); + const longWords = words.filter(w => w.length >= 7).length; const avgWords = words.length / sentences.length; const avgSyllables = syllables / words.length; + const L = (chars / words.length) * 100; // avg letters per 100 words + const S = (sentences.length / words.length) * 100; // avg sentences per 100 words + + const sentenceWordCounts = sentences.map(s => wordCount(s)); + const maxSentenceWords = Math.max(...sentenceWordCounts); + const longestSentence = sentences[sentenceWordCounts.indexOf(maxSentenceWords)] ?? ''; return { - fk: Math.round((0.39 * avgWords + 11.8 * avgSyllables - 15.59) * 10) / 10, - fre: Math.round((206.835 - 1.015 * avgWords - 84.6 * avgSyllables) * 10) / 10, - wordCount: words.length, - sentenceCount: sentences.length, - avgWords: Math.round(avgWords * 10) / 10, + fk: Math.round((0.39 * avgWords + 11.8 * avgSyllables - 15.59) * 10) / 10, + fre: Math.round((206.835 - 1.015 * avgWords - 84.6 * avgSyllables) * 10) / 10, + cl: Math.round((0.0588 * L - 0.296 * S - 15.8) * 10) / 10, + lix: Math.round((avgWords + (longWords * 100 / words.length)) * 10) / 10, + wordCount: words.length, + sentenceCount: sentences.length, + avgWords: Math.round(avgWords * 10) / 10, + longWordCount: longWords, + maxSentenceWords, + longestSentence, sentences, }; } -// ── Reporting ──────────────────────────────────────────────────────────────── +// ── Suggestion builders ────────────────────────────────────────────────────── + +const SPLIT_CONJUNCTIONS = [', which', ', that', ', and', ', but', ', because', ', however', ', although', ', while', ', whereas']; +const SPLIT_CONJUNCTIONS_SOFT = [' because ', ' however ', ' although ', ' while ', ' which ', ' whereas ']; + +function buildLenSuggestion(longest) { + for (const conj of SPLIT_CONJUNCTIONS) { + const idx = longest.toLowerCase().indexOf(conj); + if (idx > 15 && idx < longest.length - 10) { + const before = longest.slice(0, idx).trim().replace(/,\s*$/, ''); + const after = longest.slice(idx + conj.length).trim(); + const cap = after.charAt(0).toUpperCase() + after.slice(1); + const b = before.length > 55 ? '…' + before.slice(-52) : before; + const a = cap.length > 55 ? cap.slice(0, 52) + '…' : cap; + return [`Split at "${conj.trim()}":`, `Before: "${b}."`, `After: "${a}."`].join('\n '); + } + } + for (const conj of SPLIT_CONJUNCTIONS_SOFT) { + const idx = longest.toLowerCase().indexOf(conj); + if (idx > 20 && idx < longest.length - 15) { + const before = longest.slice(0, idx).trim().replace(/,\s*$/, ''); + const after = longest.slice(idx + conj.length).trim(); + const cap = after.charAt(0).toUpperCase() + after.slice(1); + const b = before.length > 55 ? '…' + before.slice(-52) : before; + const a = cap.length > 55 ? cap.slice(0, 52) + '…' : cap; + return [`Split at "${conj.trim()}":`, `Before: "${b}."`, `After: "${a}."`].join('\n '); + } + } + const colonCount = (longest.match(/:/g) ?? []).length; + const codeCount = (longest.match(/\bcode\b/g) ?? []).length; + if (colonCount >= 3 || codeCount >= 3) { + return 'This looks like multiple items in one sentence. Use a bullet list or table so each item is its own line.'; + } + return 'Look for "which", "that", "and", "but", "because" as natural split points to break this into two sentences.'; +} -const SUGGESTIONS = { - fk: 'Split long sentences at conjunctions (and, but, which, that), or replace multi-syllable words with shorter alternatives.', - fre: 'Use shorter, more common words. Aim for 1–2 syllable words in most sentences.', - len: 'Look for "which", "that", "and", "but", "because" as natural split points to break this into two sentences.', +const STATIC_SUGGESTIONS = { + fk: 'Rewrite with shorter sentences and simpler word choices. Aim for words your audience uses in everyday conversation.', + fre: 'Simplify by using shorter sentences and more common words. Avoid unnecessary multi-syllable vocabulary.', + cl: 'Reduce average word length. Where two words mean the same thing, prefer the shorter one.', + lix: 'Too many long words (7+ characters). Where possible, replace them with shorter alternatives.', + para:'Split at a natural topic boundary. Each paragraph should cover one idea. Aim for 3–5 sentences.', }; + +function buildSuggestion(checkName, sentences) { + if (checkName === 'len' || checkName === 'max') { + const longest = sentences.slice().sort((a, b) => wordCount(b) - wordCount(a))[0] ?? ''; + return buildLenSuggestion(longest); + } + return STATIC_SUGGESTIONS[checkName] ?? ''; +} + +// ── Warning factory ────────────────────────────────────────────────────────── + function createWarning(filePath, startLine, startCol, label, checkName, stats, preview) { const messages = { fk: `Flesch-Kincaid grade ${stats.fk} exceeds target of ≤${stats.fkMax} ` + `(${stats.wordCount} words, ${stats.sentenceCount} sentences, avg ${stats.avgWords} words/sentence)`, fre: `Flesch Reading Ease ${stats.fre} is below target of ≥${stats.freMin}`, - len: `Average sentence length is ${stats.avgWords} words (target: ≤20)`, + cl: `Coleman-Liau index ${stats.cl} exceeds target of ≤${stats.clMax} (character density too high)`, + lix: `LIX score ${stats.lix} exceeds target of ≤${stats.lixMax} (${stats.longWordCount} long words of ${stats.wordCount} total)`, + len: `Average sentence length is ${stats.avgWords} words (target: ≤25)`, + max: `Longest sentence is ${stats.maxSentenceWords} words (target: ≤40) — breaks reading flow`, + para:`Paragraph has ${stats.sentenceCount} sentences (target: ≤5) — may overwhelm working memory`, }; - return { filePath, startLine, startCol, label, checkName, message: messages[checkName], preview, suggestion: SUGGESTIONS[checkName] }; + const suggestion = buildSuggestion(checkName, stats.sentences); + return { filePath, startLine, startCol, label, checkName, message: messages[checkName], preview, suggestion }; } // ── Output rendering ───────────────────────────────────────────────────────── @@ -282,7 +347,7 @@ function renderCI(w) { const parts = [ `[${w.label}] ${w.message}.`, w.preview ? `Longest sentence: "${w.preview}"` : null, - `Suggestion: ${w.suggestion}`, + `Suggestion: ${w.suggestion.replace(/\n\s*/g, ' // ')}`, ].filter(Boolean).join(' | '); return `::warning file=${w.filePath},line=${w.startLine},col=${w.startCol}::${parts}`; } @@ -308,7 +373,11 @@ function printFileWarningsLocal(relPath, warnings) { if (w.preview) { console.log(`${c.cyan}│${c.reset} ${c.dim}${c.italic}"${w.preview}"${c.reset}`); } - console.log(`${c.cyan}│${c.reset} ${c.green}→ ${w.suggestion}${c.reset}`); + const suggLines = w.suggestion.split('\n'); + console.log(`${c.cyan}│${c.reset} ${c.green}→ ${suggLines[0]}${c.reset}`); + for (const line of suggLines.slice(1)) { + console.log(`${c.cyan}│${c.reset} ${c.green}${line}${c.reset}`); + } } console.log(`${c.cyan}│${c.reset}`); @@ -335,9 +404,9 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi const stats = analyzeText(plain); if (!stats) return true; - const { fk, fre, avgWords, sentences } = stats; + const { fk, fre, cl, lix, avgWords, maxSentenceWords, longestSentence, sentences } = stats; const preview = longestSentencePreview(sentences); - const enriched = { ...stats, fkMax: thresholds.fkMax, freMin: thresholds.freMin }; + const enriched = { ...stats, fkMax: thresholds.fkMax, freMin: thresholds.freMin, clMax: thresholds.clMax, lixMax: thresholds.lixMax }; let passed = true; if (fk > thresholds.fkMax) { @@ -352,12 +421,49 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi passed = false; } - if (avgWords > 20) { + if (cl > thresholds.clMax) { + fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'cl', enriched, preview)); + recordIssue(filePath, label, 'cl', startLine); + passed = false; + } + + if (lix > thresholds.lixMax) { + fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'lix', enriched, preview)); + recordIssue(filePath, label, 'lix', startLine); + passed = false; + } + + if (avgWords > 25) { fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'len', enriched, preview)); recordIssue(filePath, label, 'len', startLine); passed = false; } + if (maxSentenceWords > 40) { + const maxPreview = longestSentence.length > 120 ? longestSentence.slice(0, 119) + '…' : longestSentence; + fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'max', enriched, maxPreview)); + recordIssue(filePath, label, 'max', startLine); + passed = false; + } + + // Paragraph density — uses raw text to preserve paragraph boundaries + let lineOffset = 0; + for (const para of text.split(/\n{2,}/)) { + const paraSentences = getSentences(toPlainText(para)); + if (paraSentences.length > 5) { + const paraPreview = paraSentences.slice(0, 2).join(' '); + const truncated = paraPreview.length > 120 ? paraPreview.slice(0, 119) + '…' : paraPreview; + fileWarnings.push(createWarning( + filePath, startLine + lineOffset, 1, label, 'para', + { ...enriched, sentenceCount: paraSentences.length, sentences: paraSentences }, + truncated, + )); + recordIssue(filePath, label, 'para', startLine + lineOffset); + passed = false; + } + lineOffset += (para.match(/\n/g) ?? []).length + 2; + } + return passed; } @@ -382,15 +488,19 @@ function findMdxFiles(dir) { // ── Summary report ─────────────────────────────────────────────────────────── const CHECK_LABELS = { - fk: 'Flesch-Kincaid grade too high', - fre: 'Flesch Reading Ease too low ', - len: 'Avg sentence length > 20 words', + fk: 'Flesch-Kincaid grade too high ', + fre: 'Flesch Reading Ease too low ', + cl: 'Coleman-Liau index too high ', + lix: 'LIX score too high (long words)', + len: 'Avg sentence length > 25 words ', + max: 'Single sentence > 40 words ', + para:'Paragraph density > 5 sentences', }; function printSummary(totalFiles, allPassed) { const LINE = '─'.repeat(COL_WIDTH); const filesWithIssues = [...new Set(issueLog.map(i => i.filePath))]; - const countByType = { fk: 0, fre: 0, len: 0 }; + const countByType = { fk: 0, fre: 0, cl: 0, lix: 0, len: 0, max: 0, para: 0 }; for (const issue of issueLog) countByType[issue.checkName]++; const issuesByFile = {}; @@ -413,9 +523,13 @@ function printSummary(totalFiles, allPassed) { console.log(' By check type:'); const CHECK_LABELS = { - fk: 'Flesch-Kincaid grade too high ', - fre: 'Flesch Reading Ease too low ', - len: 'Avg sentence length > 20 words', + fk: 'Flesch-Kincaid grade too high ', + fre: 'Flesch Reading Ease too low ', + cl: 'Coleman-Liau index too high ', + lix: 'LIX score too high (long words)', + len: 'Avg sentence length > 25 words ', + max: 'Single sentence > 40 words ', + para:'Paragraph density > 5 sentences', }; for (const [key, label] of Object.entries(CHECK_LABELS)) { const n = countByType[key]; From 3d0a7d2b73d6fac302b81a3270acd4ffbfaf322a Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:39:03 +0200 Subject: [PATCH 05/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index e6d1c8f8b..228c53678 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -55,8 +55,8 @@ const TECH_FILE_PATTERNS = [ ]; const THRESHOLDS = { - business: { fkMax: 8, freMin: 70, clMax: 9, lixMax: 35 }, - tech: { fkMax: 14, freMin: 30, clMax: 16, lixMax: 55 }, + business: { fkMax: 8, freMin: 70, clMax: 9, lixMax: 35, lenMax: 20, maxLen: 35, paraMax: 4 }, + tech: { fkMax: 14, freMin: 30, clMax: 16, lixMax: 55, lenMax: 25, maxLen: 40, paraMax: 6 }, }; function getThresholds(filePath) { @@ -333,9 +333,9 @@ function createWarning(filePath, startLine, startCol, label, checkName, stats, p fre: `Flesch Reading Ease ${stats.fre} is below target of ≥${stats.freMin}`, cl: `Coleman-Liau index ${stats.cl} exceeds target of ≤${stats.clMax} (character density too high)`, lix: `LIX score ${stats.lix} exceeds target of ≤${stats.lixMax} (${stats.longWordCount} long words of ${stats.wordCount} total)`, - len: `Average sentence length is ${stats.avgWords} words (target: ≤25)`, - max: `Longest sentence is ${stats.maxSentenceWords} words (target: ≤40) — breaks reading flow`, - para:`Paragraph has ${stats.sentenceCount} sentences (target: ≤5) — may overwhelm working memory`, + len: `Average sentence length is ${stats.avgWords} words (target: ≤${stats.lenMax})`, + max: `Longest sentence is ${stats.maxSentenceWords} words (target: ≤${stats.maxLen}) — breaks reading flow`, + para:`Paragraph has ${stats.sentenceCount} sentences (target: ≤${stats.paraMax}) — may overwhelm working memory`, }; const suggestion = buildSuggestion(checkName, stats.sentences); return { filePath, startLine, startCol, label, checkName, message: messages[checkName], preview, suggestion }; @@ -406,7 +406,7 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi const { fk, fre, cl, lix, avgWords, maxSentenceWords, longestSentence, sentences } = stats; const preview = longestSentencePreview(sentences); - const enriched = { ...stats, fkMax: thresholds.fkMax, freMin: thresholds.freMin, clMax: thresholds.clMax, lixMax: thresholds.lixMax }; + const enriched = { ...stats, ...thresholds }; let passed = true; if (fk > thresholds.fkMax) { @@ -433,13 +433,13 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi passed = false; } - if (avgWords > 25) { + if (avgWords > thresholds.lenMax) { fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'len', enriched, preview)); recordIssue(filePath, label, 'len', startLine); passed = false; } - if (maxSentenceWords > 40) { + if (maxSentenceWords > thresholds.maxLen) { const maxPreview = longestSentence.length > 120 ? longestSentence.slice(0, 119) + '…' : longestSentence; fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'max', enriched, maxPreview)); recordIssue(filePath, label, 'max', startLine); @@ -450,7 +450,7 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi let lineOffset = 0; for (const para of text.split(/\n{2,}/)) { const paraSentences = getSentences(toPlainText(para)); - if (paraSentences.length > 5) { + if (paraSentences.length > thresholds.paraMax) { const paraPreview = paraSentences.slice(0, 2).join(' '); const truncated = paraPreview.length > 120 ? paraPreview.slice(0, 119) + '…' : paraPreview; fileWarnings.push(createWarning( From 70f6aec883874537422c0543db59d3ae1988ef10 Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:45:47 +0200 Subject: [PATCH 06/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 56 +++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index 228c53678..de82cc844 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -341,25 +341,60 @@ function createWarning(filePath, startLine, startCol, label, checkName, stats, p return { filePath, startLine, startCol, label, checkName, message: messages[checkName], preview, suggestion }; } +// ── Check explanations (CI "why" context) ─────────────────────────────────── + +const CHECK_WHY = { + fk: 'Flesch-Kincaid grade level estimates the years of education needed to read this text comfortably. ' + + 'A lower grade means more readers can understand it without effort.', + fre: 'Flesch Reading Ease scores text from 0 (very hard) to 100 (very easy). ' + + 'Business docs should score ≥70; technical docs ≥30. Below the target, readers have to work harder to follow the text.', + cl: 'Coleman-Liau measures character density — the average length of words. ' + + 'Longer words increase cognitive load even when sentences are short. Prefer shorter words where meaning is the same.', + lix: 'LIX measures the proportion of long words (7+ characters). ' + + 'A high ratio signals dense vocabulary that slows readers down, particularly non-native speakers and neurodiverse readers.', + len: 'Long average sentence length forces readers to hold more information in working memory before reaching the end of a thought. ' + + 'This is especially taxing for readers with ADHD or working memory differences.', + max: 'A single very long sentence disrupts reading flow even when the rest of the text is concise. ' + + 'Readers must hold the entire sentence in memory to understand its structure and meaning.', + para:'Dense paragraphs without visual breaks overwhelm working memory. ' + + 'Paragraph breaks act as cognitive rest points — particularly important for neurodiverse readers who benefit from chunked information.', +}; + // ── Output rendering ───────────────────────────────────────────────────────── -function renderCI(w) { - const parts = [ - `[${w.label}] ${w.message}.`, - w.preview ? `Longest sentence: "${w.preview}"` : null, - `Suggestion: ${w.suggestion.replace(/\n\s*/g, ' // ')}`, - ].filter(Boolean).join(' | '); - return `::warning file=${w.filePath},line=${w.startLine},col=${w.startCol}::${parts}`; -} +const SEP = ' ' + '─'.repeat(66); -const COL_WIDTH = 68; +function renderCIAnnotation(w) { + // Concise single-line annotation for the PR diff view + const fix = w.suggestion.replace(/\n\s*/g, ' // '); + return `::warning file=${w.filePath},line=${w.startLine},col=${w.startCol}::` + + `[${w.label}] ${w.message}. Fix: ${fix}`; +} function printFileWarningsCI(relPath, warnings) { console.log(`\n::group::${relPath}`); - warnings.forEach(w => console.log(renderCI(w))); + for (const w of warnings) { + console.log(''); + console.log(` ⚠ ${w.label} · line ${w.startLine}, col ${w.startCol}`); + console.log(SEP); + console.log(` Why ${CHECK_WHY[w.checkName]}`); + console.log(` Score ${w.message}`); + if (w.preview) { + console.log(` Quote "${w.preview}"`); + } + const suggLines = w.suggestion.split('\n'); + console.log(` Fix ${suggLines[0]}`); + for (const line of suggLines.slice(1)) { + console.log(` ${line}`); + } + console.log(''); + console.log(renderCIAnnotation(w)); + } console.log('::endgroup::'); } +const COL_WIDTH = 68; + function printFileWarningsLocal(relPath, warnings) { const shortPath = relPath.replace(/^versioned_docs\/[^/]+\//, ''); const titlePad = Math.max(2, COL_WIDTH - shortPath.length - 5); @@ -369,6 +404,7 @@ function printFileWarningsLocal(relPath, warnings) { const location = `${c.gray}line ${w.startLine}, col ${w.startCol}${c.reset}`; console.log(`${c.cyan}│${c.reset}`); console.log(`${c.cyan}│${c.reset} ${c.yellow}⚠${c.reset} ${c.bold}${w.label}${c.reset} ${c.gray}·${c.reset} ${location}`); + console.log(`${c.cyan}│${c.reset} ${c.dim}${CHECK_WHY[w.checkName]}${c.reset}`); console.log(`${c.cyan}│${c.reset} ${c.yellow}${w.message}${c.reset}`); if (w.preview) { console.log(`${c.cyan}│${c.reset} ${c.dim}${c.italic}"${w.preview}"${c.reset}`); From c71a1978994db7b1f8321c9569567c414718b2c6 Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:00:11 +0200 Subject: [PATCH 07/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 75 ++++++++++++++++++++++++++++++- .github/workflows/readability.yml | 1 + 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index de82cc844..a263723ce 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -22,7 +22,7 @@ * Defaults to 'versioned_docs'. */ -import { readFileSync, readdirSync, statSync } from 'fs'; +import { readFileSync, readdirSync, statSync, appendFileSync } from 'fs'; import { join, relative, extname, basename } from 'path'; import process from 'process'; @@ -600,11 +600,82 @@ function printSummary(totalFiles, allPassed) { if (IS_GH_ACTIONS) console.log('::endgroup::'); } +// ── Job summary (GitHub Actions step summary) ──────────────────────────────── + +function writeJobSummary(totalFiles, allPassed, warningsByFile) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) return; + + const totalWarnings = Object.values(warningsByFile).reduce((n, ws) => n + ws.length, 0); + const filesWithIssues = Object.keys(warningsByFile).length; + const countByType = { fk: 0, fre: 0, cl: 0, lix: 0, len: 0, max: 0, para: 0 }; + for (const ws of Object.values(warningsByFile)) { + for (const w of ws) countByType[w.checkName] = (countByType[w.checkName] ?? 0) + 1; + } + + const status = allPassed ? '✅ All checks passed' : '❌ Readability issues found'; + const lines = []; + + lines.push(`## 📖 Readability Check — ${status}`); + lines.push(''); + lines.push('| | |'); + lines.push('|---|---|'); + lines.push(`| Files checked | ${totalFiles} |`); + lines.push(`| Files with issues | ${filesWithIssues} |`); + lines.push(`| Total warnings | ${totalWarnings} |`); + lines.push(''); + + if (totalWarnings > 0) { + lines.push('### By check type'); + lines.push(''); + lines.push('| Check | Count |'); + lines.push('|---|---|'); + const checkLabels = { + fk: 'Flesch-Kincaid grade too high', + fre: 'Flesch Reading Ease too low', + cl: 'Coleman-Liau index too high', + lix: 'LIX score too high (long words)', + len: 'Avg sentence length exceeded', + max: 'Single sentence too long', + para:'Paragraph density too high', + }; + for (const [key, label] of Object.entries(checkLabels)) { + if (countByType[key] > 0) lines.push(`| ${label} | ${countByType[key]} |`); + } + lines.push(''); + lines.push('---'); + lines.push(''); + + for (const [filePath, warnings] of Object.entries(warningsByFile)) { + const shortPath = filePath.replace(/^versioned_docs\/[^/]+\//, ''); + lines.push(`### ⚠ \`${shortPath}\` — ${warnings.length} warning${warnings.length > 1 ? 's' : ''}`); + lines.push(''); + for (const w of warnings) { + const title = `${w.label} · line ${w.startLine} — ${w.message}`; + lines.push(`
${title}`); + lines.push(''); + lines.push(`**Why:** ${CHECK_WHY[w.checkName]}`); + lines.push(''); + if (w.preview) lines.push(`**Quote:** *"${w.preview}"*`); + lines.push(''); + const fix = w.suggestion.replace(/\n\s*/g, '
'); + lines.push(`**Fix:** ${fix}`); + lines.push(''); + lines.push('
'); + lines.push(''); + } + } + } + + appendFileSync(summaryPath, lines.join('\n') + '\n'); +} + // ── Main ───────────────────────────────────────────────────────────────────── const targetDir = process.argv[2] ?? 'versioned_docs'; const files = findMdxFiles(targetDir); let allPassed = true; +const warningsByFile = {}; for (const file of files) { const content = readFileSync(file, 'utf8'); @@ -622,11 +693,13 @@ for (const file of files) { if (fileWarnings.length > 0) { allPassed = false; + warningsByFile[relPath] = fileWarnings; printFileWarnings(relPath, fileWarnings); } } printSummary(files.length, allPassed); +writeJobSummary(files.length, allPassed, warningsByFile); if (!allPassed) { console.log('::error::Readability check failed. See warnings above for details.'); diff --git a/.github/workflows/readability.yml b/.github/workflows/readability.yml index 47db5aa87..562ed00d5 100644 --- a/.github/workflows/readability.yml +++ b/.github/workflows/readability.yml @@ -7,6 +7,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + pull-requests: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 From 24bc889b0289dd204ffcae7cc74b27d375e7ff51 Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:13:50 +0200 Subject: [PATCH 08/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 103 +++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index a263723ce..19a773540 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -1,10 +1,12 @@ #!/usr/bin/env node /** - * Readability checker for MDX documentation. + * Readability checker for MDX documentation and dynamic data files. * * Validates readability separately for: * - Primary content (always visible to the reader) * - Collapsed content (
, ) per section + * - Dynamic data files: FAQ (faq.v6.json), Glossary (glossary.v6.json), + * and Bicep parameters (framework/dashboard .v6.bicep.parameters.json) * * Thresholds mirror vale.ini audience segments: * Business docs → FK ≤ 8, FRE ≥ 70 @@ -503,6 +505,97 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi return passed; } +// ── Dynamic data file checking ─────────────────────────────────────────────── + +/** + * Returns the 1-based line number of the first occurrence of `searchText` + * within `rawText`. Searches by the first 60 characters of the text to avoid + * long-string mismatches. Falls back to 1 if not found. + */ +function findLineNumber(rawText, searchText) { + if (!searchText) return 1; + const needle = searchText.slice(0, 60); + const idx = rawText.indexOf(needle); + if (idx === -1) return 1; + return rawText.slice(0, idx).split('\n').length; +} + +const DATA_FILES = [ + { + path: 'src/data/faq.v6.json', + extractItems: (data) => data.map(item => ({ + label: `FAQ: "${item.question?.length > 60 ? item.question.slice(0, 59) + '…' : item.question}"`, + // Combine question + answer so the full reader-facing unit is scored + text: [item.question, item.answer].filter(Boolean).join('\n\n'), + audience: item.userType === 'technical' ? 'tech' : 'business', + searchText: item.answer ?? item.question, + })), + }, + { + path: 'src/data/glossary.v6.json', + extractItems: (data) => data.map(item => ({ + label: `Glossary: "${item.term}"`, + text: item.definition ?? '', + audience: item.userType === 'technical' ? 'tech' : 'business', + searchText: item.definition, + })), + }, + { + path: 'src/data/framework.v6.bicep.parameters.json', + extractItems: (data) => data.map(item => ({ + label: `Parameter: ${item.name}`, + text: item.description ?? '', + audience: 'tech', + searchText: item.description, + })), + }, + { + path: 'src/data/dashboard.v6.bicep.parameters.json', + extractItems: (data) => data.map(item => ({ + label: `Parameter: ${item.name}`, + text: item.description ?? '', + audience: 'tech', + searchText: item.description, + })), + }, +]; + +/** + * Runs readability checks on all dynamic data files (FAQ, glossary, Bicep + * parameters). Accumulates warnings into `warningsByFile` and returns whether + * all checks passed (no warnings found). + */ +function processDataFiles(warningsByFile) { + let allPassed = true; + + for (const { path: filePath, extractItems } of DATA_FILES) { + let raw, data; + try { + raw = readFileSync(filePath, 'utf8'); + data = JSON.parse(raw); + } catch { + continue; // file absent or malformed — skip silently + } + + const fileWarnings = []; + + for (const { label, text, audience, searchText } of extractItems(data)) { + if (!text || text.trim().length < 20) continue; + const thresholds = THRESHOLDS[audience] ?? THRESHOLDS.tech; + const lineNum = findLineNumber(raw, searchText); + checkSection(label, text, filePath, lineNum, 1, thresholds, fileWarnings); + } + + if (fileWarnings.length > 0) { + allPassed = false; + warningsByFile[filePath] = fileWarnings; + printFileWarnings(filePath, fileWarnings); + } + } + + return allPassed; +} + // ── File discovery ─────────────────────────────────────────────────────────── const EXCLUDED_DIRS = new Set(['deprecated']); @@ -698,8 +791,12 @@ for (const file of files) { } } -printSummary(files.length, allPassed); -writeJobSummary(files.length, allPassed, warningsByFile); +const dataFilesPassed = processDataFiles(warningsByFile); +if (!dataFilesPassed) allPassed = false; + +const totalChecked = files.length + DATA_FILES.length; +printSummary(totalChecked, allPassed); +writeJobSummary(totalChecked, allPassed, warningsByFile); if (!allPassed) { console.log('::error::Readability check failed. See warnings above for details.'); From 0e167e33b178586197228f0bedec07114aa88f43 Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:53:11 +0200 Subject: [PATCH 09/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 93 +++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index 19a773540..6a652b21c 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -20,8 +20,14 @@ * - A preview of the most problematic sentence * - A concrete suggestion for how to fix it * - * Usage: node readability.mjs [target-dir] - * Defaults to 'versioned_docs'. + * Usage: + * node readability.mjs [target] + * + * target can be: + * - A directory (default: versioned_docs) → scans all files + * - A single .mdx or .md file → checks only that file + * - A single .json data file → checks only that file's entries + * (faq.v6.json, glossary.v6.json, *.bicep.parameters.json) */ import { readFileSync, readdirSync, statSync, appendFileSync } from 'fs'; @@ -57,7 +63,7 @@ const TECH_FILE_PATTERNS = [ ]; const THRESHOLDS = { - business: { fkMax: 8, freMin: 70, clMax: 9, lixMax: 35, lenMax: 20, maxLen: 35, paraMax: 4 }, + business: { fkMax: 8, freMin: 70, clMax: 12, lixMax: 42, lenMax: 20, maxLen: 35, paraMax: 4 }, tech: { fkMax: 14, freMin: 30, clMax: 16, lixMax: 55, lenMax: 25, maxLen: 40, paraMax: 6 }, }; @@ -195,7 +201,7 @@ function toPlainText(raw) { .replace(/^\|[-:\s|]+\|$/gm, '') // table separator rows .replace(/\|/g, ' ') // table pipe characters .replace(/^[-_*]{3,}\s*$/gm, '') // thematic breaks (--- ___ ***) - .replace(/^#{1,6}\s+(.+)$/gm, '$1. ') // headings → sentence (prevents merging with next paragraph) + .replace(/^#{1,6}\s+.+$/gm, '') // headings → removed (navigation labels, not prose) .replace(/\*{1,2}([^*\n]*)\*{1,2}/g, '$1') // bold/italic markers (balanced) .replace(/\*/g, '') // remaining unbalanced asterisks .replace(/^[-*+]\s+(.+?)\.?\s*$/gm, '$1. ') // unordered list items → each becomes a sentence @@ -221,7 +227,8 @@ function getSentences(text) { return text .split(/(?<=[.!?])\s+/) .map(s => s.trim()) - .filter(s => s.split(/\s+/).length > 2); + // Require 3+ real words — filters out JSX artifacts like `, } />` or `= true,` + .filter(s => (s.match(/\b[a-zA-Z'-]{2,}\b/g) ?? []).length > 2); } function wordCount(sentence) { @@ -564,14 +571,16 @@ const DATA_FILES = [ * Runs readability checks on all dynamic data files (FAQ, glossary, Bicep * parameters). Accumulates warnings into `warningsByFile` and returns whether * all checks passed (no warnings found). + * + * Pass a subset of `DATA_FILES` to check a single data file in single-file mode. */ -function processDataFiles(warningsByFile) { +function processDataFiles(warningsByFile, dataFilesConfig = DATA_FILES) { let allPassed = true; - for (const { path: filePath, extractItems } of DATA_FILES) { + for (const { path: filePath, extractItems } of dataFilesConfig) { let raw, data; try { - raw = readFileSync(filePath, 'utf8'); + raw = readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); data = JSON.parse(raw); } catch { continue; // file absent or malformed — skip silently @@ -765,13 +774,53 @@ function writeJobSummary(totalFiles, allPassed, warningsByFile) { // ── Main ───────────────────────────────────────────────────────────────────── -const targetDir = process.argv[2] ?? 'versioned_docs'; -const files = findMdxFiles(targetDir); +const arg = process.argv[2] ?? 'versioned_docs'; +const argStat = statSync(arg, { throwIfNoEntry: false }); + +if (!argStat) { + console.error(`${c.red}Error: '${arg}' is not a valid file or directory.${c.reset}`); + console.error(''); + console.error('Usage:'); + console.error(' node readability.mjs [target]'); + console.error(''); + console.error(' target can be:'); + console.error(' - A directory (default: versioned_docs) → scans all files'); + console.error(' - A single .mdx or .md file → checks only that file'); + console.error(' - A single .json data file → checks only that file\'s entries'); + process.exit(1); +} + +const isSingleFile = argStat.isFile(); +let files = []; +let dataFilesConfig = DATA_FILES; + +if (isSingleFile) { + const ext = extname(arg); + if (['.md', '.mdx'].includes(ext)) { + files = [arg]; + dataFilesConfig = []; + } else if (ext === '.json') { + files = []; + const normArg = arg.replace(/\\/g, '/'); + dataFilesConfig = DATA_FILES.filter(df => normArg.endsWith(df.path) || df.path.endsWith(normArg)); + if (dataFilesConfig.length === 0) { + console.error(`${c.red}Error: '${arg}' is not a recognised data file.${c.reset}`); + console.error('Supported data files: ' + DATA_FILES.map(df => df.path).join(', ')); + process.exit(1); + } + } else { + console.error(`${c.red}Error: '${arg}' is not a .md, .mdx, or .json file.${c.reset}`); + process.exit(1); + } +} else { + files = findMdxFiles(arg); +} + let allPassed = true; const warningsByFile = {}; for (const file of files) { - const content = readFileSync(file, 'utf8'); + const content = readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); const { primary, collapsed } = extractSections(content); const thresholds = getThresholds(file); const relPath = relative(process.cwd(), file).replace(/\\/g, '/'); @@ -791,14 +840,26 @@ for (const file of files) { } } -const dataFilesPassed = processDataFiles(warningsByFile); +const dataFilesPassed = processDataFiles(warningsByFile, dataFilesConfig); if (!dataFilesPassed) allPassed = false; -const totalChecked = files.length + DATA_FILES.length; -printSummary(totalChecked, allPassed); -writeJobSummary(totalChecked, allPassed, warningsByFile); +if (isSingleFile) { + const totalWarnings = Object.values(warningsByFile).reduce((n, ws) => n + ws.length, 0); + if (allPassed) { + console.log(`\n${c.green}✅ No readability issues found.${c.reset}`); + } else { + const noun = totalWarnings === 1 ? 'warning' : 'warnings'; + console.log(`\n${c.yellow}✗ ${totalWarnings} ${noun} found — see above for details.${c.reset}`); + } +} else { + const totalChecked = files.length + dataFilesConfig.length; + printSummary(totalChecked, allPassed); + writeJobSummary(totalChecked, allPassed, warningsByFile); +} if (!allPassed) { - console.log('::error::Readability check failed. See warnings above for details.'); + if (!isSingleFile) { + console.log('::error::Readability check failed. See warnings above for details.'); + } process.exit(1); } From 0d95778352a5773629af3d0944b0ecc2d02b59e3 Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:20:42 +0200 Subject: [PATCH 10/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 57 +++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index 6a652b21c..2c2a6cb5a 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -181,16 +181,41 @@ function extractSections(content) { // ── Text cleaning ──────────────────────────────────────────────────────────── +/** + * Strips all brace-delimited JSX expressions {…} with full nesting support. + * Each top-level {…} becomes a single space regardless of nesting depth. + * + * Must run BEFORE JSX tag removal: attributes like `icon={}` contain + * a `>` inside the braces which confuses `[^>]*` tag-matching regexes. + * Removing brace content first leaves clean attribute-free tags that the + * JSX self-closing and pair regexes can then reliably match. + */ +function removeBraceExpressions(text) { + let result = ''; + let depth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '{') { + depth++; + } else if (ch === '}') { + if (depth > 0) depth--; + else result += ch; // stray `}` not opened — pass through unchanged + } else if (depth === 0) { + result += ch; + } + } + return result; +} + function toPlainText(raw) { - return raw + return removeBraceExpressions(raw .replace(/^\uFEFF/, '') // BOM character - .replace(/\{\/\*[\s\S]*?\*\/\}/g, '') // JSX comments {/* ... */} .replace(/import\s[^;]+;/g, '') // import statements .replace(/:::[\w-]*[^\n]*/g, '') // admonition markers (opening :::note and closing :::) .replace(/`{3}[^\n]*\n[\s\S]*?`{3}/g, '') // fenced code blocks + .replace(//gi, '') // HTML tables (structured data, not prose) .replace(/`[^`\n]+`/g, ' ') // inline code → remove (don't score code tokens) - .replace(/\{\{[^}]*\}\}/g, ' ') // double-brace JSX expressions {{ }} - .replace(/\{[^}]+\}/g, ' ') // single-brace JSX expressions { } + ) // remove all {…} JSX expressions (balanced-brace, any depth) .replace(/<[A-Z][A-Za-z]*[^>]*\/>/g, ' ') // self-closing JSX components .replace(/<[A-Z][A-Za-z]*[^>]*>[\s\S]*?<\/[A-Z][A-Za-z]*>/g, ' ') // JSX component pairs ... .replace(/<[^>]+>/g, ' ') // remaining HTML tags @@ -198,8 +223,7 @@ function toPlainText(raw) { .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // inline links → label text only .replace(/\[[^\]]+\]:\s*\S+[^\n]*/gm, '') // reference-style link definitions .replace(/https?:\/\/\S+/g, '') // bare URLs - .replace(/^\|[-:\s|]+\|$/gm, '') // table separator rows - .replace(/\|/g, ' ') // table pipe characters + .replace(/^\|.+$/gm, '') // table rows (data + separator) — structured data, not prose .replace(/^[-_*]{3,}\s*$/gm, '') // thematic breaks (--- ___ ***) .replace(/^#{1,6}\s+.+$/gm, '') // headings → removed (navigation labels, not prose) .replace(/\*{1,2}([^*\n]*)\*{1,2}/g, '$1') // bold/italic markers (balanced) @@ -253,6 +277,11 @@ function analyzeText(text) { if (sentences.length < 2 || words.length < 15) return null; + // Below 50 words, corpus-wide metrics (FK, FRE, CL, LIX) are unreliable — + // a single multi-syllable product name shifts the score by a full grade level. + // Sentence-length and paragraph density checks still run (they work per-sentence). + const tooSmallForCorpusMetrics = words.length < 50; + const syllables = words.reduce((n, w) => n + countSyllables(w), 0); const chars = words.reduce((n, w) => n + w.replace(/[^a-zA-Z]/g, '').length, 0); const longWords = words.filter(w => w.length >= 7).length; @@ -266,10 +295,10 @@ function analyzeText(text) { const longestSentence = sentences[sentenceWordCounts.indexOf(maxSentenceWords)] ?? ''; return { - fk: Math.round((0.39 * avgWords + 11.8 * avgSyllables - 15.59) * 10) / 10, - fre: Math.round((206.835 - 1.015 * avgWords - 84.6 * avgSyllables) * 10) / 10, - cl: Math.round((0.0588 * L - 0.296 * S - 15.8) * 10) / 10, - lix: Math.round((avgWords + (longWords * 100 / words.length)) * 10) / 10, + fk: tooSmallForCorpusMetrics ? null : Math.round((0.39 * avgWords + 11.8 * avgSyllables - 15.59) * 10) / 10, + fre: tooSmallForCorpusMetrics ? null : Math.round((206.835 - 1.015 * avgWords - 84.6 * avgSyllables) * 10) / 10, + cl: tooSmallForCorpusMetrics ? null : Math.round((0.0588 * L - 0.296 * S - 15.8) * 10) / 10, + lix: tooSmallForCorpusMetrics ? null : Math.round((avgWords + (longWords * 100 / words.length)) * 10) / 10, wordCount: words.length, sentenceCount: sentences.length, avgWords: Math.round(avgWords * 10) / 10, @@ -454,25 +483,25 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi const enriched = { ...stats, ...thresholds }; let passed = true; - if (fk > thresholds.fkMax) { + if (fk !== null && fk > thresholds.fkMax) { fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'fk', enriched, preview)); recordIssue(filePath, label, 'fk', startLine); passed = false; } - if (fre < thresholds.freMin) { + if (fre !== null && fre < thresholds.freMin) { fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'fre', enriched, preview)); recordIssue(filePath, label, 'fre', startLine); passed = false; } - if (cl > thresholds.clMax) { + if (cl !== null && cl > thresholds.clMax) { fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'cl', enriched, preview)); recordIssue(filePath, label, 'cl', startLine); passed = false; } - if (lix > thresholds.lixMax) { + if (lix !== null && lix > thresholds.lixMax) { fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'lix', enriched, preview)); recordIssue(filePath, label, 'lix', startLine); passed = false; From 5035ee1d5e3b3633fe0ed9b035f03d117a86b098 Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:39:05 +0200 Subject: [PATCH 11/21] chore(gh): add readability scores during content changes --- .github/scripts/readability.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index 2c2a6cb5a..e82015282 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -58,6 +58,7 @@ const c = IS_GH_ACTIONS ? Object.fromEntries( const TECH_FILE_PATTERNS = [ /versioned_docs\/.*\/framework\//, /versioned_docs\/.*\/dashboard\/installation\//, + /versioned_docs\/.*\/dashboard\/flows\/04_import-flow-traces\//, /versioned_docs\/.*\/(technical|architecture-diagram)\.mdx?$/, /versioned_docs\/.*\/support\/(migrate|release-notes)/, ]; @@ -223,13 +224,13 @@ function toPlainText(raw) { .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // inline links → label text only .replace(/\[[^\]]+\]:\s*\S+[^\n]*/gm, '') // reference-style link definitions .replace(/https?:\/\/\S+/g, '') // bare URLs - .replace(/^\|.+$/gm, '') // table rows (data + separator) — structured data, not prose + .replace(/^\s*\|.+$/gm, '') // table rows (data + separator) — structured data, not prose .replace(/^[-_*]{3,}\s*$/gm, '') // thematic breaks (--- ___ ***) .replace(/^#{1,6}\s+.+$/gm, '') // headings → removed (navigation labels, not prose) .replace(/\*{1,2}([^*\n]*)\*{1,2}/g, '$1') // bold/italic markers (balanced) - .replace(/\*/g, '') // remaining unbalanced asterisks - .replace(/^[-*+]\s+(.+?)\.?\s*$/gm, '$1. ') // unordered list items → each becomes a sentence - .replace(/^\d+\.\s+(.+?)\.?\s*$/gm, '$1. ') // ordered list items → each becomes a sentence + .replace(/^\s*[-*+]\s+(.+?)\.?\s*$/gm, '$1. ') // unordered list items (any indent, before * cleanup) → each becomes a sentence + .replace(/^\s*\d+\.\s+(.+?)\.?\s*$/gm, '$1. ') // ordered list items (any indent) → each becomes a sentence + .replace(/\*/g, '') // remaining unbalanced asterisks (after list processing) .replace(/\s+/g, ' ') .replace(/[`\[\]]/g, '') // remaining bare backticks and brackets .trim(); From 48ebf85ce3e40053ee9a4a92562a21a50294d2cd Mon Sep 17 00:00:00 2001 From: Stijn Moreels <9039753+stijnmoreels@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:16:20 +0200 Subject: [PATCH 12/21] chore(readability): add dale-chall word list --- .github/scripts/dale-chall-word-list.txt | 2942 ++++++++++++++++++++++ .github/scripts/readability.mjs | 528 +++- .github/scripts/readability.test.mjs | 263 ++ .github/workflows/readability.yml | 2 + 4 files changed, 3623 insertions(+), 112 deletions(-) create mode 100644 .github/scripts/dale-chall-word-list.txt create mode 100644 .github/scripts/readability.test.mjs diff --git a/.github/scripts/dale-chall-word-list.txt b/.github/scripts/dale-chall-word-list.txt new file mode 100644 index 000000000..25b1bdd4a --- /dev/null +++ b/.github/scripts/dale-chall-word-list.txt @@ -0,0 +1,2942 @@ +a +able +aboard +about +above +absent +accept +accident +account +ache +aching +acorn +acre +across +act +acts +add +address +admire +adventure +afar +afraid +after +afternoon +afterward +afterwards +again +against +age +aged +ago +agree +ah +ahead +aid +aim +air +airfield +airplane +airport +airship +airy +alarm +alike +alive +all +alley +alligator +allow +almost +alone +along +aloud +already +also +always +am +america +american +among +amount +an +and +angel +anger +angry +animal +another +answer +ant +any +anybody +anyhow +anyone +anything +anyway +anywhere +apart +apartment +ape +apiece +appear +apple +april +apron +are +aren't +arise +arithmetic +arm +armful +army +arose +around +arrange +arrive +arrived +arrow +art +artist +as +ash +ashes +aside +ask +asleep +at +ate +attack +attend +attention +august +aunt +author +auto +automobile +autumn +avenue +awake +awaken +away +awful +awfully +awhile +ax +axe +baa +babe +babies +back +background +backward +backwards +bacon +bad +badge +badly +bag +bake +baker +bakery +baking +ball +balloon +banana +band +bandage +bang +banjo +bank +banker +bar +barber +bare +barefoot +barely +bark +barn +barrel +base +baseball +basement +basket +bat +batch +bath +bathe +bathing +bathroom +bathtub +battle +battleship +bay +be +beach +bead +beam +bean +bear +beard +beast +beat +beating +beautiful +beautify +beauty +became +because +become +becoming +bed +bedbug +bedroom +bedspread +bedtime +bee +beech +beef +beefsteak +beehive +been +beer +beet +before +beg +began +beggar +begged +begin +beginning +begun +behave +behind +being +believe +bell +belong +below +belt +bench +bend +beneath +bent +berries +berry +beside +besides +best +bet +better +between +bib +bible +bicycle +bid +big +bigger +bill +billboard +bin +bind +bird +birth +birthday +biscuit +bit +bite +biting +bitter +black +blackberry +blackbird +blackboard +blackness +blacksmith +blame +blank +blanket +blast +blaze +bleed +bless +blessing +blew +blind +blindfold +blinds +block +blood +bloom +blossom +blot +blow +blue +blueberry +bluebird +blush +board +boast +boat +bob +bobwhite +bodies +body +boil +boiler +bold +bone +bonnet +boo +book +bookcase +bookkeeper +boom +boot +born +borrow +boss +both +bother +bottle +bottom +bought +bounce +bow +bow-wow +bowl +box +boxcar +boxer +boxes +boy +boyhood +bracelet +brain +brake +bran +branch +brass +brave +bread +break +breakfast +breast +breath +breathe +breeze +brick +bride +bridge +bright +brightness +bring +broad +broadcast +broke +broken +brook +broom +brother +brought +brown +brush +bubble +bucket +buckle +bud +buffalo +bug +buggy +build +building +built +bulb +bull +bullet +bum +bumblebee +bump +bun +bunch +bundle +bunny +burn +burst +bury +bus +bush +bushel +business +busy +but +butcher +butt +butter +buttercup +butterfly +buttermilk +butterscotch +button +buttonhole +buy +buzz +by +bye +cab +cabbage +cabin +cabinet +cackle +cage +cake +calendar +calf +call +caller +calling +came +camel +camp +campfire +can +can't +canal +canary +candle +candlestick +candy +cane +cannon +cannot +canoe +canyon +cap +cape +capital +captain +car +card +cardboard +care +careful +careless +carelessness +carload +carpenter +carpet +carriage +carrot +carry +cart +carve +case +cash +cashier +castle +cat +catbird +catch +catcher +caterpillar +catfish +catsup +cattle +caught +cause +cave +ceiling +cell +cellar +cent +center +cereal +certain +certainly +chain +chair +chalk +champion +chance +change +chap +charge +charm +chart +chase +chatter +cheap +cheat +check +checkers +cheek +cheer +cheese +cherry +chest +chew +chick +chicken +chief +child +childhood +children +chill +chilly +chimney +chin +china +chip +chipmunk +chocolate +choice +choose +chop +chorus +chose +chosen +christen +christmas +church +churn +cigarette +circle +circus +citizen +city +clang +clap +class +classmate +classroom +claw +clay +clean +cleaner +clear +clerk +clever +click +cliff +climb +clip +cloak +clock +close +closet +cloth +clothes +clothing +cloud +cloudy +clover +clown +club +cluck +clump +coach +coal +coast +coat +cob +cobbler +cocoa +coconut +cocoon +cod +codfish +coffee +coffeepot +coin +cold +collar +college +color +colored +colt +column +comb +come +comfort +comic +coming +company +compare +conductor +cone +connect +coo +cook +cooked +cookie +cookies +cooking +cool +cooler +coop +copper +copy +cord +cork +corn +corner +correct +cost +cot +cottage +cotton +couch +cough +could +couldn't +count +counter +country +county +course +court +cousin +cover +cow +coward +cowardly +cowboy +cozy +crab +crack +cracker +cradle +cramps +cranberry +crank +cranky +crash +crawl +crazy +cream +creamy +creek +creep +crept +cried +cries +croak +crook +crooked +crop +cross +cross-eyed +crossing +crow +crowd +crowded +crown +cruel +crumb +crumble +crush +crust +cry +cub +cuff +cup +cupboard +cupful +cure +curl +curly +curtain +curve +cushion +custard +customer +cut +cute +cutting +dab +dad +daddy +daily +dairy +daisy +dam +damage +dame +damp +dance +dancer +dancing +dandy +danger +dangerous +dare +dark +darkness +darling +darn +dart +dash +date +daughter +dawn +day +daybreak +daytime +dead +deaf +deal +dear +death +december +decide +deck +deed +deep +deer +defeat +defend +defense +delight +den +dentist +depend +deposit +describe +desert +deserve +desire +desk +destroy +devil +dew +diamond +did +didn't +die +died +dies +difference +different +dig +dim +dime +dine +ding-dong +dinner +dip +direct +direction +dirt +dirty +discover +dish +dislike +dismiss +ditch +dive +diver +divide +do +dock +doctor +does +doesn't +dog +doll +dollar +dolly +don't +done +donkey +door +doorbell +doorknob +doorstep +dope +dot +double +dough +dove +down +downstairs +downtown +dozen +drag +drain +drank +draw +drawer +drawing +dream +dress +dresser +dressmaker +drew +dried +drift +drill +drink +drip +drive +driven +driver +drop +drove +drown +drowsy +drub +drum +drunk +dry +duck +due +dug +dull +dumb +dump +during +dust +dusty +duty +dwarf +dwell +dwelt +dying +each +eager +eagle +ear +early +earn +earth +east +eastern +easy +eat +eaten +edge +egg +eh +eight +eighteen +eighth +eighty +either +elbow +elder +eldest +electric +electricity +elephant +eleven +elf +elm +else +elsewhere +empty +end +ending +enemy +engine +engineer +english +enjoy +enough +enter +envelope +equal +erase +eraser +errand +escape +eve +even +evening +ever +every +everybody +everyday +everyone +everything +everywhere +evil +exact +except +exchange +excited +exciting +excuse +exit +expect +explain +extra +eye +eyebrow +fable +face +facing +fact +factory +fail +faint +fair +fairy +faith +fake +fall +false +family +fan +fancy +far +far-off +faraway +fare +farm +farmer +farming +farther +fashion +fast +fasten +fat +father +fault +favor +favorite +fear +feast +feather +february +fed +feed +feel +feet +fell +fellow +felt +fence +fever +few +fib +fiddle +field +fife +fifteen +fifth +fifty +fig +fight +figure +file +fill +film +finally +find +fine +finger +finish +fire +firearm +firecracker +fireplace +fireworks +firing +first +fish +fisherman +fist +fit +fits +five +fix +flag +flake +flame +flap +flash +flashlight +flat +flea +flesh +flew +flies +flight +flip +flip-flop +float +flock +flood +floor +flop +flour +flow +flower +flowery +flutter +fly +foam +fog +foggy +fold +folks +follow +following +fond +food +fool +foolish +foot +football +footprint +for +forehead +forest +forget +forgive +forgot +forgotten +fork +form +fort +forth +fortune +forty +forward +fought +found +fountain +four +fourteen +fourth +fox +frame +free +freedom +freeze +freight +french +fresh +fret +friday +fried +friend +friendly +friendship +frighten +frog +from +front +frost +frown +froze +fruit +fry +fudge +fuel +full +fully +fun +funny +fur +furniture +further +fuzzy +gain +gallon +gallop +game +gang +garage +garbage +garden +gas +gasoline +gate +gather +gave +gay +gear +geese +general +gentle +gentleman +gentlemen +geography +get +getting +giant +gift +gingerbread +girl +give +given +giving +glad +gladly +glance +glass +glasses +gleam +glide +glory +glove +glow +glue +go +goal +goat +gobble +god +godmother +goes +going +gold +golden +goldfish +golf +gone +good +good-by +good-bye +good-looking +goodbye +goodness +goods +goody +goose +gooseberry +got +govern +government +gown +grab +gracious +grade +grain +grand +grandchild +grandchildren +granddaughter +grandfather +grandma +grandmother +grandpa +grandson +grandstand +grape +grapefruit +grapes +grass +grasshopper +grateful +grave +gravel +graveyard +gravy +gray +graze +grease +great +green +greet +grew +grind +groan +grocery +ground +group +grove +grow +guard +guess +guest +guide +gulf +gum +gun +gunpowder +guy +ha +habit +had +hadn't +hail +hair +haircut +hairpin +half +hall +halt +ham +hammer +hand +handful +handkerchief +handle +handwriting +hang +happen +happily +happiness +happy +harbor +hard +hardly +hardship +hardware +hare +hark +harm +harness +harp +harvest +has +hasn't +haste +hasten +hasty +hat +hatch +hatchet +hate +haul +have +haven't +having +hawk +hay +hayfield +haystack +he +he'd +he'll +he's +head +headache +heal +health +healthy +heap +hear +heard +hearing +heart +heat +heater +heaven +heavy +heel +height +held +hell +hello +helmet +help +helper +helpful +hem +hen +henhouse +her +herd +here +here's +hero +hers +herself +hey +hickory +hid +hidden +hide +high +highway +hill +hillside +hilltop +hilly +him +himself +hind +hint +hip +hire +his +hiss +history +hit +hitch +hive +ho +hoe +hog +hold +holder +hole +holiday +hollow +holy +home +homely +homesick +honest +honey +honeybee +honeymoon +honk +honor +hood +hoof +hook +hoop +hop +hope +hopeful +hopeless +horn +horse +horseback +horseshoe +hose +hospital +host +hot +hotel +hound +hour +house +housetop +housewife +housework +how +however +howl +hug +huge +hum +humble +hump +hundred +hung +hunger +hungry +hunk +hunt +hunter +hurrah +hurried +hurry +hurt +husband +hush +hut +hymn +i +i'd +i'll +i'm +i've +ice +icy +idea +ideal +if +ill +important +impossible +improve +in +inch +inches +income +indeed +indian +indoors +ink +inn +insect +inside +instant +instead +insult +intend +interested +interesting +into +invite +iron +is +island +isn't +it +it's +its +itself +ivory +ivy +jacket +jacks +jail +jam +january +jar +jaw +jay +jelly +jellyfish +jerk +jig +job +jockey +join +joke +joking +jolly +journey +joy +joyful +joyous +judge +jug +juice +juicy +july +jump +june +junior +junk +just +keen +keep +kept +kettle +key +kick +kid +kill +killed +kind +kindly +kindness +king +kingdom +kiss +kitchen +kite +kitten +kitty +knee +kneel +knew +knife +knit +knives +knob +knock +knot +know +known +lace +lad +ladder +ladies +lady +laid +lake +lamb +lame +lamp +land +lane +language +lantern +lap +lard +large +lash +lass +last +late +laugh +laundry +law +lawn +lawyer +lay +lazy +lead +leader +leaf +leak +lean +leap +learn +learned +least +leather +leave +leaving +led +left +leg +lemon +lemonade +lend +length +less +lesson +let +let's +letter +letting +lettuce +level +liberty +library +lice +lick +lid +lie +life +lift +light +lightness +lightning +like +likely +liking +lily +limb +lime +limp +line +linen +lion +lip +list +listen +lit +little +live +lively +liver +lives +living +lizard +load +loaf +loan +loaves +lock +locomotive +log +lone +lonely +lonesome +long +look +lookout +loop +loose +lord +lose +loser +loss +lost +lot +loud +love +lovely +lover +low +luck +lucky +lumber +lump +lunch +lying +ma +machine +machinery +mad +made +magazine +magic +maid +mail +mailbox +mailman +major +make +making +male +mama +mamma +man +manager +mane +manger +many +map +maple +marble +march +mare +mark +market +marriage +married +marry +mask +mast +master +mat +match +matter +mattress +may +maybe +mayor +maypole +me +meadow +meal +mean +means +meant +measure +meat +medicine +meet +meeting +melt +member +men +mend +meow +merry +mess +message +met +metal +mew +mice +middle +midnight +might +mighty +mile +miler +milk +milkman +mill +million +mind +mine +miner +mint +minute +mirror +mischief +miss +misspell +mistake +misty +mitt +mitten +mix +moment +monday +money +monkey +month +moo +moon +moonlight +moose +mop +more +morning +morrow +moss +most +mostly +mother +motor +mount +mountain +mouse +mouth +move +movie +movies +moving +mow +mr. +mrs. +much +mud +muddy +mug +mule +multiply +murder +music +must +my +myself +nail +name +nap +napkin +narrow +nasty +naughty +navy +near +nearby +nearly +neat +neck +necktie +need +needle +needn't +negro +neighbor +neighborhood +neither +nerve +nest +net +never +nevermore +new +news +newspaper +next +nibble +nice +nickel +night +nightgown +nine +nineteen +ninety +no +nobody +nod +noise +noisy +none +noon +nor +north +northern +nose +not +note +nothing +notice +november +now +nowhere +number +nurse +nut +o'clock +oak +oar +oatmeal +oats +obey +ocean +october +odd +of +off +offer +office +officer +often +oh +oil +old +old-fashioned +on +once +one +onion +only +onward +open +or +orange +orchard +order +ore +organ +other +otherwise +ouch +ought +our +ours +ourselves +out +outdoors +outfit +outlaw +outline +outside +outward +oven +over +overalls +overcoat +overeat +overhead +overhear +overnight +overturn +owe +owing +owl +own +owner +ox +pa +pace +pack +package +pad +page +paid +pail +pain +painful +paint +painter +painting +pair +pal +palace +pale +pan +pancake +pane +pansy +pants +papa +paper +parade +pardon +parent +park +part +partly +partner +party +pass +passenger +past +paste +pasture +pat +patch +path +patter +pave +pavement +paw +pay +payment +pea +peace +peaceful +peach +peaches +peak +peanut +pear +pearl +peas +peck +peek +peel +peep +peg +pen +pencil +penny +people +pepper +peppermint +perfume +perhaps +person +pet +phone +piano +pick +pickle +picnic +picture +pie +piece +pig +pigeon +piggy +pile +pill +pillow +pin +pine +pineapple +pink +pint +pipe +pistol +pit +pitch +pitcher +pity +place +plain +plan +plane +plant +plate +platform +platter +play +player +playground +playhouse +playmate +plaything +pleasant +please +pleasure +plenty +plow +plug +plum +pocket +pocketbook +poem +point +poison +poke +pole +police +policeman +polish +polite +pond +ponies +pony +pool +poor +pop +popcorn +popped +porch +pork +possible +post +postage +postman +pot +potato +potatoes +pound +pour +powder +power +powerful +praise +pray +prayer +prepare +present +pretty +price +prick +prince +princess +print +prison +prize +promise +proper +protect +proud +prove +prune +public +puddle +puff +pull +pump +pumpkin +punch +punish +pup +pupil +puppy +pure +purple +purse +push +puss +pussy +pussycat +put +putting +puzzle +quack +quart +quarter +queen +queer +question +quick +quickly +quiet +quilt +quit +quite +rabbit +race +rack +radio +radish +rag +rail +railroad +railway +rain +rainbow +rainy +raise +raisin +rake +ram +ran +ranch +rang +rap +rapidly +rat +rate +rather +rattle +raw +ray +reach +read +reader +reading +ready +real +really +reap +rear +reason +rebuild +receive +recess +record +red +redbird +redbreast +refuse +reindeer +rejoice +remain +remember +remind +remove +rent +repair +repay +repeat +report +rest +return +review +reward +rib +ribbon +rice +rich +rid +riddle +ride +rider +riding +right +rim +ring +rip +ripe +rise +rising +river +road +roadside +roar +roast +rob +robber +robe +robin +rock +rocket +rocky +rode +roll +roller +roof +room +rooster +root +rope +rose +rosebud +rot +rotten +rough +round +route +row +rowboat +royal +rub +rubbed +rubber +rubbish +rug +rule +ruler +rumble +run +rung +runner +running +rush +rust +rusty +rye +sack +sad +saddle +sadness +safe +safety +said +sail +sailboat +sailor +saint +salad +sale +salt +same +sand +sandwich +sandy +sang +sank +sap +sash +sat +satin +satisfactory +saturday +sausage +savage +save +savings +saw +say +scab +scales +scare +scarf +school +schoolboy +schoolhouse +schoolmaster +schoolroom +scorch +score +scrap +scrape +scratch +scream +screen +screw +scrub +sea +seal +seam +search +season +seat +second +secret +see +seed +seeing +seek +seem +seen +seesaw +select +self +selfish +sell +send +sense +sent +sentence +separate +september +servant +serve +service +set +setting +settle +settlement +seven +seventeen +seventh +seventy +several +sew +shade +shadow +shady +shake +shaker +shaking +shall +shame +shan't +shape +share +sharp +shave +she +she'd +she'll +she's +shear +shears +shed +sheep +sheet +shelf +shell +shepherd +shine +shining +shiny +ship +shirt +shock +shoe +shoemaker +shone +shook +shoot +shop +shopping +shore +short +shot +should +shoulder +shouldn't +shout +shovel +show +shower +shut +shy +sick +sickness +side +sidewalk +sideways +sigh +sight +sign +silence +silent +silk +sill +silly +silver +simple +sin +since +sing +singer +single +sink +sip +sir +sis +sissy +sister +sit +sitting +six +sixteen +sixth +sixty +size +skate +skater +ski +skin +skip +skirt +sky +slam +slap +slate +slave +sled +sleep +sleepy +sleeve +sleigh +slept +slice +slid +slide +sling +slip +slipped +slipper +slippery +slit +slow +slowly +sly +smack +small +smart +smell +smile +smoke +smooth +snail +snake +snap +snapping +sneeze +snow +snowball +snowflake +snowy +snuff +snug +so +soak +soap +sob +socks +sod +soda +sofa +soft +soil +sold +soldier +sole +some +somebody +somehow +someone +something +sometime +sometimes +somewhere +son +song +soon +sore +sorrow +sorry +sort +soul +sound +soup +sour +south +southern +space +spade +spank +sparrow +speak +speaker +spear +speech +speed +spell +spelling +spend +spent +spider +spike +spill +spin +spinach +spirit +spit +splash +spoil +spoke +spook +spoon +sport +spot +spread +spring +springtime +sprinkle +square +squash +squeak +squeeze +squirrel +stable +stack +stage +stair +stall +stamp +stand +star +stare +start +starve +state +states +station +stay +steak +steal +steam +steamboat +steamer +steel +steep +steeple +steer +stem +step +stepping +stick +sticky +stiff +still +stillness +sting +stir +stitch +stock +stocking +stole +stone +stood +stool +stoop +stop +stopped +stopping +store +stories +stork +storm +stormy +story +stove +straight +strange +stranger +strap +straw +strawberry +stream +street +stretch +string +strip +stripes +strong +stuck +study +stuff +stump +stung +subject +such +suck +sudden +suffer +sugar +suit +sum +summer +sun +sunday +sunflower +sung +sunk +sunlight +sunny +sunrise +sunset +sunshine +supper +suppose +sure +surely +surface +surprise +swallow +swam +swamp +swan +swat +swear +sweat +sweater +sweep +sweet +sweetheart +sweetness +swell +swept +swift +swim +swimming +swing +switch +sword +swore +table +tablecloth +tablespoon +tablet +tack +tag +tail +tailor +take +taken +taking +tale +talk +talker +tall +tame +tan +tank +tap +tape +tar +tardy +task +taste +taught +tax +tea +teach +teacher +team +tear +tease +teaspoon +teeth +telephone +tell +temper +ten +tennis +tent +term +terrible +test +than +thank +thankful +thanks +thanksgiving +that +that's +the +theater +thee +their +them +then +there +these +they +they'd +they'll +they're +they've +thick +thief +thimble +thin +thing +think +third +thirsty +thirteen +thirty +this +thorn +those +though +thought +thousand +thread +three +threw +throat +throne +through +throw +thrown +thumb +thunder +thursday +thy +tick +ticket +tickle +tie +tiger +tight +till +time +tin +tinkle +tiny +tip +tiptoe +tire +tired +title +to +toad +toadstool +toast +tobacco +today +toe +together +toilet +told +tomato +tomorrow +ton +tone +tongue +tonight +too +took +tool +toot +tooth +toothbrush +toothpick +top +tore +torn +toss +touch +tow +toward +towards +towel +tower +town +toy +trace +track +trade +train +tramp +trap +tray +treasure +treat +tree +trick +tricycle +tried +trim +trip +trolley +trouble +truck +true +truly +trunk +trust +truth +try +tub +tuesday +tug +tulip +tumble +tune +tunnel +turkey +turn +turtle +twelve +twenty +twice +twig +twin +two +ugly +umbrella +uncle +under +understand +underwear +undress +unfair +unfinished +unfold +unfriendly +unhappy +unhurt +uniform +united +unkind +unknown +unless +unpleasant +until +unwilling +up +upon +upper +upset +upside +upstairs +uptown +upward +us +use +used +useful +valentine +valley +valuable +value +vase +vegetable +velvet +very +vessel +victory +view +village +vine +violet +visit +visitor +voice +vote +wag +wagon +waist +wait +wake +waken +walk +wall +walnut +want +war +warm +warn +was +wash +washer +washtub +wasn't +waste +watch +watchman +water +watermelon +waterproof +wave +wax +way +wayside +we +we'd +we'll +we're +we've +weak +weaken +weakness +wealth +weapon +wear +weary +weather +weave +web +wedding +wednesday +wee +weed +week +weep +weigh +welcome +well +went +were +west +western +wet +whale +what +what's +wheat +wheel +when +whenever +where +which +while +whip +whipped +whirl +whiskey +whisky +whisper +whistle +white +who +who'd +who'll +who's +whole +whom +whose +why +wicked +wide +wife +wiggle +wild +wildcat +will +willing +willow +win +wind +windmill +window +windy +wine +wing +wink +winner +winter +wipe +wire +wise +wish +wit +witch +with +without +woke +wolf +woman +women +won +won't +wonder +wonderful +wood +wooden +woodpecker +woods +wool +woolen +word +wore +work +worker +workman +world +worm +worn +worry +worse +worst +worth +would +wouldn't +wound +wove +wrap +wrapped +wreck +wren +wring +write +writing +written +wrong +wrote +wrung +yard +yarn +year +yell +yellow +yes +yesterday +yet +yolk +yonder +you +you'd +you'll +you're +you've +young +youngster +your +yours +yourself +yourselves +youth diff --git a/.github/scripts/readability.mjs b/.github/scripts/readability.mjs index e82015282..e2b9980a2 100644 --- a/.github/scripts/readability.mjs +++ b/.github/scripts/readability.mjs @@ -4,13 +4,13 @@ * * Validates readability separately for: * - Primary content (always visible to the reader) - * - Collapsed content (
, ) per section + * - Collapsed content (
, , non-first ) per section * - Dynamic data files: FAQ (faq.v6.json), Glossary (glossary.v6.json), * and Bicep parameters (framework/dashboard .v6.bicep.parameters.json) * * Thresholds mirror vale.ini audience segments: - * Business docs → FK ≤ 8, FRE ≥ 70 - * Technical docs → FK ≤ 14, FRE ≥ 30 + * Business docs → FK ≤ 8, FRE ≥ 70, Dale-Chall ≤ 9.5 + * Technical docs → FK ≤ 14, FRE ≥ 30, Dale-Chall not enforced (see THRESHOLDS comment) * * Additional neurodiverse-friendly checks (both sections): * - Average sentence length ≤ 20 words @@ -32,6 +32,7 @@ import { readFileSync, readdirSync, statSync, appendFileSync } from 'fs'; import { join, relative, extname, basename } from 'path'; +import { pathToFileURL } from 'url'; import process from 'process'; // ── Environment ────────────────────────────────────────────────────────────── @@ -64,8 +65,15 @@ const TECH_FILE_PATTERNS = [ ]; const THRESHOLDS = { - business: { fkMax: 8, freMin: 70, clMax: 12, lixMax: 42, lenMax: 20, maxLen: 35, paraMax: 4 }, - tech: { fkMax: 14, freMin: 30, clMax: 16, lixMax: 55, lenMax: 25, maxLen: 40, paraMax: 6 }, + // dcMax (Dale-Chall) is only enforced for business docs: the formula scores + // any word outside a ~3,000-word list familiar to a 4th-grade reader as + // "difficult", which unavoidably flags nearly all integration/technical + // vocabulary (XPath, SQL, dependency, backend, Transco, …) regardless of + // how simply a sentence is written. For a technical audience that already + // knows this jargon, that's not a real readability problem — so tech docs + // skip this check (dcMax: null) rather than produce corpus-wide noise. + business: { fkMax: 8, freMin: 70, clMax: 12, lixMax: 42, dcMax: 9.5, lenMax: 20, maxLen: 35, paraMax: 4, listMax: 8 }, + tech: { fkMax: 14, freMin: 30, clMax: 16, lixMax: 55, dcMax: null, lenMax: 25, maxLen: 40, paraMax: 6, listMax: 10 }, }; function getThresholds(filePath) { @@ -77,6 +85,22 @@ function getThresholds(filePath) { // ── Section extraction ─────────────────────────────────────────────────────── +/** + * Converts a `...` line to plain text for primary-content + * scoring. By HTML/ARIA semantics, `` is the clickable disclosure + * label for a `
`/`` block — a short title, not a + * sentence of running prose (confirmed across the corpus: every `` + * usage is a few-word label, e.g. "Add an Entra ID user", never multi-sentence + * text). So — like a markdown `#` heading — it's excluded entirely from + * prose analysis. Without this, short jargon-heavy labels get scored + * alongside real sentences and skew Flesch Reading Ease/FK/CL/LIX even + * though the actual paragraphs are simple and short. + * Returns null (nothing to score). + */ +function summaryLineToPrimaryText(line) { + return null; +} + /** * Splits MDX content into primary (always visible) and collapsed sections. * content inside
is treated as primary — it's always shown. @@ -101,6 +125,11 @@ function extractSections(content) { let primaryStartLine = 1; let primaryStartCol = 1; let firstPrimaryLine = true; + // entries (src/components/ReleaseNotes) collapse by + // default except the very first one in the file — it mirrors the + // `isLatest = index === 0` UX in ReleaseNotes/index.tsx, where only the + // newest version is expanded on load and every older one starts collapsed. + let releaseVersionCount = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -122,9 +151,19 @@ function extractSections(content) { } if (inCodeBlock) continue; - const collapsibleMatch = line.match(/<(details|Collapsible)[\s>]/); - const opensCollapsible = collapsibleMatch !== null; - const closesCollapsible = /<\/(details|Collapsible)>/.test(line); + let collapsibleMatch = line.match(/<(details|Collapsible)[\s>]/); + let opensCollapsible = collapsibleMatch !== null; + let closesCollapsible = /<\/(details|Collapsible)>/.test(line); + + const releaseVersionOpenMatch = currentCollapsed === null ? line.match(/]/) : null; + if (releaseVersionOpenMatch) { + releaseVersionCount++; + if (releaseVersionCount > 1) { + collapsibleMatch = releaseVersionOpenMatch; + opensCollapsible = true; + } + } + if (currentCollapsed !== null && /<\/ReleaseVersion>/.test(line)) closesCollapsible = true; if (currentCollapsed === null) { if (opensCollapsible) { @@ -135,7 +174,8 @@ function extractSections(content) { // A opening on the same line as
is visible if (/]+>/g, ' ').trim()); + const summaryText = summaryLineToPrimaryText(line); + if (summaryText) primaryLines.push(summaryText); if (/<\/summary>/.test(line)) inSummary = false; } continue; @@ -162,7 +202,8 @@ function extractSections(content) { // at depth 1 is always visible — redirect to primary if (/]+>/g, ' ').trim()); + const summaryText = summaryLineToPrimaryText(line); + if (summaryText) primaryLines.push(summaryText); if (/<\/summary>/.test(line)) inSummary = false; continue; } @@ -217,8 +258,9 @@ function toPlainText(raw) { .replace(//gi, '') // HTML tables (structured data, not prose) .replace(/`[^`\n]+`/g, ' ') // inline code → remove (don't score code tokens) ) // remove all {…} JSX expressions (balanced-brace, any depth) - .replace(/<[A-Z][A-Za-z]*[^>]*\/>/g, ' ') // self-closing JSX components - .replace(/<[A-Z][A-Za-z]*[^>]*>[\s\S]*?<\/[A-Z][A-Za-z]*>/g, ' ') // JSX component pairs ... + .replace(/<[A-Z][A-Za-z]*[^>]*\slabel\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*\/>/g, ' $1$2 ') // self-closing components with a `label` prop (InputControls: , , etc.) render that label as real visible text — keep it instead of discarding the whole tag. Requires a space before "label" so "aria-label" (not visible text) isn't matched too. + .replace(/<[A-Z][A-Za-z]*[^>]*\/>/g, ' ') // self-closing JSX components (no label — nothing visible to preserve) + .replace(/<([A-Z][A-Za-z]*)[^>]*>([\s\S]*?)<\/\1>/g, ' $2 ') // JSX component pairs ... — strip tags, keep inner text .replace(/<[^>]+>/g, ' ') // remaining HTML tags .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // inline links → label text only @@ -227,6 +269,7 @@ function toPlainText(raw) { .replace(/^\s*\|.+$/gm, '') // table rows (data + separator) — structured data, not prose .replace(/^[-_*]{3,}\s*$/gm, '') // thematic breaks (--- ___ ***) .replace(/^#{1,6}\s+.+$/gm, '') // headings → removed (navigation labels, not prose) + .replace(/^(\s*(?:[-*+]|\d+\.)\s+)\*\*([^*\n]+):\*\*(\s*)(?=\S)/gm, '$1$2. ') // list item "**Label:**" prefix (e.g. release notes "- **Feature name:** description") acts like a mini-heading — split into its own sentence so it doesn't fuse with the description that follows .replace(/\*{1,2}([^*\n]*)\*{1,2}/g, '$1') // bold/italic markers (balanced) .replace(/^\s*[-*+]\s+(.+?)\.?\s*$/gm, '$1. ') // unordered list items (any indent, before * cleanup) → each becomes a sentence .replace(/^\s*\d+\.\s+(.+?)\.?\s*$/gm, '$1. ') // ordered list items (any indent) → each becomes a sentence @@ -238,16 +281,80 @@ function toPlainText(raw) { // ── Readability metrics ────────────────────────────────────────────────────── +// Brand/product proper nouns that appear constantly across this docs site. +// Readers already recognize these instantly — they aren't sounded out +// syllable-by-syllable the way genuinely unfamiliar vocabulary is — so +// counting their literal phonetic syllables overstates reading difficulty +// and unfairly drags down Flesch Reading Ease / FK grade on short, simple +// sentences (e.g. "Invictus manages Local users." scores as hard due to +// "Invictus" alone, even though no reader actually struggles with it). +const FAMILIAR_PROPER_NOUNS = new Set(['invictus', 'microsoft', 'entra', 'azure', 'dashboard']); + +// Multi-word product/domain terms that should be treated as a single +// familiar "word" rather than as separate tokens — e.g. "Entra ID" is one +// recognizable brand term to readers, but scoring it as two words ("Entra" +// + the bare word "ID") let the generic "ID" half get flagged as an +// unfamiliar Dale-Chall word on its own, and inflated the word count used +// for avgWords/percentDifficultWords. Listed lowercase, space-separated; +// joined with a hyphen before analysis so it's counted/matched as one token. +const MULTI_WORD_FAMILIAR_TERMS = ['entra id']; + +function joinMultiWordFamiliarTerms(text) { + let joined = text; + for (const term of MULTI_WORD_FAMILIAR_TERMS) { + const pattern = new RegExp('\\b' + term.replace(/\s+/g, '\\s+') + '\\b', 'gi'); + joined = joined.replace(pattern, m => m.replace(/\s+/g, '-')); + } + return joined; +} + +// The joined (hyphenated) form of each MULTI_WORD_FAMILIAR_TERMS entry, so +// isFamiliarWord() recognizes "entra-id" as familiar once joinMultiWordFamiliarTerms() +// has merged it into a single token. +const MULTI_WORD_FAMILIAR_TERMS_JOINED = new Set(MULTI_WORD_FAMILIAR_TERMS.map(t => t.replace(/\s+/g, '-'))); + function countSyllables(word) { - word = word.toLowerCase().replace(/[^a-z]/g, ''); - if (!word) return 0; - if (word.length <= 3) return 1; - word = word.replace(/(?:[^laeiouy]es|[^laeiouy]e)$/, ''); - word = word.replace(/^y/, ''); - const groups = word.match(/[aeiouy]{1,2}/g); + const lower = word.toLowerCase().replace(/[^a-z]/g, ''); + if (FAMILIAR_PROPER_NOUNS.has(lower)) return 1; + let word2 = lower; + if (!word2) return 0; + if (word2.length <= 3) return 1; + word2 = word2.replace(/(?:[^laeiouy]es|[^laeiouy]e)$/, ''); + word2 = word2.replace(/^y/, ''); + const groups = word2.match(/[aeiouy]{1,2}/g); return Math.max(1, groups ? groups.length : 1); } +// New Dale-Chall readability formula: text is "difficult" when it uses words +// outside a fixed list of ~3000 words familiar to most 4th-grade readers. +// The list itself (dale-chall-word-list.txt) is the public Dale-Chall word +// list, one word per line, lowercase. +const FAMILIAR_WORDS = new Set( + readFileSync(new URL('./dale-chall-word-list.txt', import.meta.url), 'utf8') + .split('\n') + .map(w => w.trim()) + .filter(Boolean), +); + +/** + * A word counts as "familiar" for Dale-Chall purposes if it's on the list + * itself, one of our known brand/product proper nouns (see + * FAMILIAR_PROPER_NOUNS above — readers recognize these instantly, the same + * reasoning used for syllable counting), or a simple inflected form (plural, + * past tense, -ing) of a word that's on the list — the official Dale-Chall + * rules treat these regular forms as familiar too, since a reader who knows + * "use" also recognizes "used"/"uses"/"using". + */ +function isFamiliarWord(wordLower) { + if (FAMILIAR_WORDS.has(wordLower) || FAMILIAR_PROPER_NOUNS.has(wordLower) || MULTI_WORD_FAMILIAR_TERMS_JOINED.has(wordLower)) return true; + if (wordLower.endsWith('es') && FAMILIAR_WORDS.has(wordLower.slice(0, -2))) return true; + if (wordLower.endsWith('s') && FAMILIAR_WORDS.has(wordLower.slice(0, -1))) return true; + if (wordLower.endsWith('ed') && (FAMILIAR_WORDS.has(wordLower.slice(0, -2)) || FAMILIAR_WORDS.has(wordLower.slice(0, -1)))) return true; + if (wordLower.endsWith('ing') && (FAMILIAR_WORDS.has(wordLower.slice(0, -3)) || FAMILIAR_WORDS.has(wordLower.slice(0, -3) + 'e'))) return true; + return false; +} + + function getSentences(text) { return text .split(/(?<=[.!?])\s+/) @@ -256,6 +363,64 @@ function getSentences(text) { .filter(s => (s.match(/\b[a-zA-Z'-]{2,}\b/g) ?? []).length > 2); } +const LIST_ITEM_LINE = /^\s*(?:[-*+]|\d+\.)\s+\S/; + +/** + * A "paragraph" block (raw text, before list-items-become-sentences conversion) + * counts as a bullet/numbered list when every non-blank line is a list item. + * Lists are already visually chunked one-idea-per-line, so they get their own + * (higher) item-count threshold instead of being judged as dense prose. + */ +function isListBlock(rawPara) { + // Ignore heading lines (e.g. "### Required deployment") that sit directly + // above a list with no blank line in between — they're not part of the + // list itself and shouldn't disqualify the block from list detection. + // Also ignore standalone JSX wrapper tag lines (e.g. "", + // "", "") that group list items + // without being list content themselves. + const lines = rawPara.split('\n') + .filter(l => l.trim().length > 0) + .filter(l => !/^\s*#{1,6}\s+/.test(l)) + .filter(l => !/^\s*<\/?[A-Za-z][A-Za-z0-9]*(?:\s[^>]*)?>\s*$/.test(l)); + return lines.length > 0 && lines.every(l => LIST_ITEM_LINE.test(l)); +} + +/** + * Detects a block that is really a short prose lead-in immediately followed + * by a real list (e.g. "Each flow shows how many messages are in each + * state:" then a bulleted list, with no blank line separating them). + * Announcing a list with a lead-in sentence like this is a standard, + * recommended technical-writing pattern (chunking/Miller's Law) — once + * rendered, the list markers create their own visually distinct block, so + * the lead-in and the list should be scored as two separate units instead of + * being fused into one "dense paragraph" with an inflated sentence count. + * Returns { intro, list } if the block splits cleanly this way (a run of + * plain prose lines followed by a run of pure list-item lines), or null + * otherwise — e.g. a pure list, a pure paragraph, or a list interrupted by + * more prose partway through, which should keep using the existing + * single-block logic rather than risk masking a genuinely dense paragraph. + */ +function splitIntroAndList(rawPara) { + const isHeading = l => /^\s*#{1,6}\s+/.test(l); + const isWrapperTag = l => /^\s*<\/?[A-Za-z][A-Za-z0-9]*(?:\s[^>]*)?>\s*$/.test(l); + // Headings and JSX wrapper-tag lines aren't real prose or list content — + // drop them first (mirrors isListBlock()'s own filtering) so a heading + // directly above a lead-in sentence (e.g. "### Required deployment" then + // "An Azure Virtual Network" then a list) doesn't wrongly disqualify the + // block from being recognised as lead-in-plus-list. + const lines = rawPara.split('\n') + .filter(l => l.trim().length > 0) + .filter(l => !isHeading(l) && !isWrapperTag(l)); + const firstListIdx = lines.findIndex(l => LIST_ITEM_LINE.test(l)); + if (firstListIdx <= 0) return null; // no list, or list starts at line 0 + const introLines = lines.slice(0, firstListIdx); + const listLines = lines.slice(firstListIdx); + const introIsProse = introLines.every(l => !LIST_ITEM_LINE.test(l)); + const listIsPure = listLines.every(l => LIST_ITEM_LINE.test(l)); + if (!introIsProse || !listIsPure) return null; + return { intro: introLines.join('\n'), list: listLines.join('\n') }; +} + function wordCount(sentence) { return (sentence.match(/\b[a-zA-Z'-]{2,}\b/g) ?? []).length; } @@ -267,6 +432,44 @@ function longestSentencePreview(sentences, maxLen = 120) { return longest.length > maxLen ? longest.slice(0, maxLen - 1) + '…' : longest; } +/** + * Returns the words in a sentence that Dale-Chall counts as "difficult" + * (not on the familiar-word list, see isFamiliarWord()). + */ +function difficultWordsInSentence(sentence) { + const words = joinMultiWordFamiliarTerms(sentence).match(/\b[a-zA-Z'-]{2,}\b/g) ?? []; + return words.filter(w => !isFamiliarWord(w.toLowerCase())); +} + +/** + * Dale-Chall failures are about *which words* are unfamiliar, not sentence + * length — showing the longest sentence (like the other checks do) doesn't + * tell the reader anything about the actual problem. Instead, this picks the + * sentence with the most difficult words and marks each one with asterisks + * (e.g. "The *orchestrator* retries the *transaction*.") so the preview + * points straight at the vocabulary to simplify. + */ +function dcSentencePreview(sentences, maxLen = 160) { + if (!sentences.length) return null; + let best = null; + let bestDifficult = []; + for (const s of sentences) { + const difficult = difficultWordsInSentence(s); + if (difficult.length > bestDifficult.length) { + best = s; + bestDifficult = difficult; + } + } + if (!best || bestDifficult.length === 0) return longestSentencePreview(sentences, maxLen); + + let highlighted = best; + for (const w of new Set(bestDifficult.map(w => w.toLowerCase()))) { + const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + highlighted = highlighted.replace(new RegExp(`\\b(${escaped})\\b`, 'gi'), '*$1*'); + } + return highlighted.length > maxLen ? highlighted.slice(0, maxLen - 1) + '…' : highlighted; +} + /** * Calculates Flesch-Kincaid grade level, Flesch Reading Ease, * and average words per sentence. @@ -274,7 +477,7 @@ function longestSentencePreview(sentences, maxLen = 120) { */ function analyzeText(text) { const sentences = getSentences(text); - const words = text.match(/\b[a-zA-Z'-]{2,}\b/g) ?? []; + const words = joinMultiWordFamiliarTerms(text).match(/\b[a-zA-Z'-]{2,}\b/g) ?? []; if (sentences.length < 2 || words.length < 15) return null; @@ -286,8 +489,10 @@ function analyzeText(text) { const syllables = words.reduce((n, w) => n + countSyllables(w), 0); const chars = words.reduce((n, w) => n + w.replace(/[^a-zA-Z]/g, '').length, 0); const longWords = words.filter(w => w.length >= 7).length; + const difficultWords = words.filter(w => !isFamiliarWord(w.toLowerCase())).length; const avgWords = words.length / sentences.length; const avgSyllables = syllables / words.length; + const percentDifficultWords = (difficultWords / words.length) * 100; const L = (chars / words.length) * 100; // avg letters per 100 words const S = (sentences.length / words.length) * 100; // avg sentences per 100 words @@ -295,15 +500,24 @@ function analyzeText(text) { const maxSentenceWords = Math.max(...sentenceWordCounts); const longestSentence = sentences[sentenceWordCounts.indexOf(maxSentenceWords)] ?? ''; + // New Dale-Chall raw score: 0.1579 * (% difficult words) + 0.0496 * + // (avg words/sentence), plus a 3.6365 penalty once difficult words exceed + // 5% of the text (the standard "unfamiliar vocabulary" cutoff). + const dcRaw = 0.1579 * percentDifficultWords + 0.0496 * avgWords + + (percentDifficultWords > 5 ? 3.6365 : 0); + return { fk: tooSmallForCorpusMetrics ? null : Math.round((0.39 * avgWords + 11.8 * avgSyllables - 15.59) * 10) / 10, fre: tooSmallForCorpusMetrics ? null : Math.round((206.835 - 1.015 * avgWords - 84.6 * avgSyllables) * 10) / 10, cl: tooSmallForCorpusMetrics ? null : Math.round((0.0588 * L - 0.296 * S - 15.8) * 10) / 10, lix: tooSmallForCorpusMetrics ? null : Math.round((avgWords + (longWords * 100 / words.length)) * 10) / 10, + dc: tooSmallForCorpusMetrics ? null : Math.round(dcRaw * 10) / 10, wordCount: words.length, sentenceCount: sentences.length, avgWords: Math.round(avgWords * 10) / 10, longWordCount: longWords, + difficultWordCount: difficultWords, + percentDifficultWords: Math.round(percentDifficultWords * 10) / 10, maxSentenceWords, longestSentence, sentences, @@ -351,7 +565,9 @@ const STATIC_SUGGESTIONS = { fre: 'Simplify by using shorter sentences and more common words. Avoid unnecessary multi-syllable vocabulary.', cl: 'Reduce average word length. Where two words mean the same thing, prefer the shorter one.', lix: 'Too many long words (7+ characters). Where possible, replace them with shorter alternatives.', + dc: 'Replace uncommon or technical words with everyday alternatives your audience already knows, or briefly define jargon on first use.', para:'Split at a natural topic boundary. Each paragraph should cover one idea. Aim for 3–5 sentences.', + list:'Split into two lists under separate sub-headings, or trim to the most important items.', }; @@ -372,9 +588,11 @@ function createWarning(filePath, startLine, startCol, label, checkName, stats, p fre: `Flesch Reading Ease ${stats.fre} is below target of ≥${stats.freMin}`, cl: `Coleman-Liau index ${stats.cl} exceeds target of ≤${stats.clMax} (character density too high)`, lix: `LIX score ${stats.lix} exceeds target of ≤${stats.lixMax} (${stats.longWordCount} long words of ${stats.wordCount} total)`, + dc: `Dale-Chall score ${stats.dc} exceeds target of ≤${stats.dcMax} (${stats.difficultWordCount} unfamiliar words, ${stats.percentDifficultWords}% of ${stats.wordCount} total)`, len: `Average sentence length is ${stats.avgWords} words (target: ≤${stats.lenMax})`, max: `Longest sentence is ${stats.maxSentenceWords} words (target: ≤${stats.maxLen}) — breaks reading flow`, para:`Paragraph has ${stats.sentenceCount} sentences (target: ≤${stats.paraMax}) — may overwhelm working memory`, + list:`List has ${stats.sentenceCount} items (target: ≤${stats.listMax}) — long lists are harder to scan and remember`, }; const suggestion = buildSuggestion(checkName, stats.sentences); return { filePath, startLine, startCol, label, checkName, message: messages[checkName], preview, suggestion }; @@ -391,12 +609,16 @@ const CHECK_WHY = { 'Longer words increase cognitive load even when sentences are short. Prefer shorter words where meaning is the same.', lix: 'LIX measures the proportion of long words (7+ characters). ' + 'A high ratio signals dense vocabulary that slows readers down, particularly non-native speakers and neurodiverse readers.', + dc: 'Dale-Chall measures the share of words outside a list of ~3,000 words familiar to most 4th-grade readers. ' + + 'A high score signals unfamiliar or jargon-heavy vocabulary — even short, simple sentences are harder to read if they\'re full of uncommon words.', len: 'Long average sentence length forces readers to hold more information in working memory before reaching the end of a thought. ' + 'This is especially taxing for readers with ADHD or working memory differences.', max: 'A single very long sentence disrupts reading flow even when the rest of the text is concise. ' + 'Readers must hold the entire sentence in memory to understand its structure and meaning.', para:'Dense paragraphs without visual breaks overwhelm working memory. ' + 'Paragraph breaks act as cognitive rest points — particularly important for neurodiverse readers who benefit from chunked information.', + list:'Very long lists are hard to scan and remember, even though each item is already on its own line. ' + + 'Miller\'s Law suggests working memory holds about 4 chunks of novel information — split long lists under sub-headings or trim to the essentials.', }; // ── Output rendering ───────────────────────────────────────────────────────── @@ -508,6 +730,13 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi passed = false; } + if (stats.dc !== null && thresholds.dcMax !== null && stats.dc > thresholds.dcMax) { + const dcPreview = dcSentencePreview(sentences); + fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'dc', enriched, dcPreview)); + recordIssue(filePath, label, 'dc', startLine); + passed = false; + } + if (avgWords > thresholds.lenMax) { fileWarnings.push(createWarning(filePath, startLine, startCol, label, 'len', enriched, preview)); recordIssue(filePath, label, 'len', startLine); @@ -521,21 +750,73 @@ function checkSection(label, text, filePath, startLine, startCol, thresholds, fi passed = false; } - // Paragraph density — uses raw text to preserve paragraph boundaries - let lineOffset = 0; - for (const para of text.split(/\n{2,}/)) { - const paraSentences = getSentences(toPlainText(para)); - if (paraSentences.length > thresholds.paraMax) { - const paraPreview = paraSentences.slice(0, 2).join(' '); - const truncated = paraPreview.length > 120 ? paraPreview.slice(0, 119) + '…' : paraPreview; + // Paragraph density — uses raw text to preserve paragraph boundaries. + // Strip whole HTML tables *before* splitting into paragraphs: tables can + // contain their own internal blank lines (e.g. formatting inside nested + // / cells), which would otherwise fragment the table across + // multiple paragraph blocks and defeat the ...
stripping + // regex (it only matches when a block still has both its opening and + // closing tag intact). + // Custom section-wrapper components (e.g. // + // from ReleaseNotes) are often placed back-to-back with no blank line + // between them, even though they render as visually distinct blocks + // (separate admonition boxes). Left alone, this silently merges their + // bullet lists into one giant list. A line containing only a JSX open or + // close tag is forced into a paragraph break (turned blank) so each + // wrapped block is judged on its own. + // Lines containing only whitespace (trailing spaces left by an editor) are + // normalized to truly empty first, so they still count as a blank-line + // paragraph break — otherwise a stray trailing space silently merges two + // unrelated blocks (e.g. a heading + list + next list) into one giant + // "paragraph". Bullet/numbered lists are already chunked one-idea-per-line, + // so they're judged against a separate, higher item-count threshold + // (listMax) instead of being penalized as dense prose (paraMax). + const paraSplitText = text + .replace(//gi, m => '\n'.repeat((m.match(/\n/g) ?? []).length)) + .replace(/^\s*<\/?[A-Za-z][A-Za-z0-9]*(?:\s[^>]*)?>\s*$/gm, '') + .replace(/^[ \t]+$/gm, ''); + // Scores one block (either a whole "paragraph"/"list" split-text chunk, or + // one half of a lead-in-plus-list block) and pushes a warning if it's over + // its threshold. Shared by both the normal per-chunk loop below and the + // lead-in/list split case, so both paths apply identical density rules. + function checkBlock(blockText, isList, lineNo) { + const checkName = isList ? 'list' : 'para'; + const limit = isList ? thresholds.listMax : thresholds.paraMax; + const blockSentences = getSentences(toPlainText(blockText)); + // List items are counted as raw list-item lines, not derived sentences: + // a "**Label:**" prefix (see toPlainText) splits a single list item into + // two sentences (label + description) so it scores correctly, but that + // must not double the apparent item count against listMax. + const itemCount = isList + ? blockText.split('\n').filter(l => LIST_ITEM_LINE.test(l)).length + : blockSentences.length; + if (itemCount > limit) { + const blockPreview = blockSentences.slice(0, 2).join(' '); + const truncated = blockPreview.length > 120 ? blockPreview.slice(0, 119) + '…' : blockPreview; fileWarnings.push(createWarning( - filePath, startLine + lineOffset, 1, label, 'para', - { ...enriched, sentenceCount: paraSentences.length, sentences: paraSentences }, + filePath, lineNo, 1, label, checkName, + { ...enriched, sentenceCount: itemCount, sentences: blockSentences }, truncated, )); - recordIssue(filePath, label, 'para', startLine + lineOffset); + recordIssue(filePath, label, checkName, lineNo); passed = false; } + } + + let lineOffset = 0; + for (const para of paraSplitText.split(/\n{2,}/)) { + // A short prose lead-in directly above a list (no blank line between + // them, e.g. "Each flow shows how many messages are in each state:" + // followed by a bulleted list) is a standard way to announce a list, not + // dense prose — score the lead-in and the list as two separate units + // instead of fusing them into one inflated "paragraph" sentence count. + const mixed = isListBlock(para) ? null : splitIntroAndList(para); + if (mixed) { + checkBlock(mixed.intro, false, startLine + lineOffset); + checkBlock(mixed.list, true, startLine + lineOffset); + } else { + checkBlock(para, isListBlock(para), startLine + lineOffset); + } lineOffset += (para.match(/\n/g) ?? []).length + 2; } @@ -660,15 +941,17 @@ const CHECK_LABELS = { fre: 'Flesch Reading Ease too low ', cl: 'Coleman-Liau index too high ', lix: 'LIX score too high (long words)', + dc: 'Dale-Chall score too high ', len: 'Avg sentence length > 25 words ', max: 'Single sentence > 40 words ', para:'Paragraph density > 5 sentences', + list:'List has too many items ', }; function printSummary(totalFiles, allPassed) { const LINE = '─'.repeat(COL_WIDTH); const filesWithIssues = [...new Set(issueLog.map(i => i.filePath))]; - const countByType = { fk: 0, fre: 0, cl: 0, lix: 0, len: 0, max: 0, para: 0 }; + const countByType = { fk: 0, fre: 0, cl: 0, lix: 0, dc: 0, len: 0, max: 0, para: 0, list: 0 }; for (const issue of issueLog) countByType[issue.checkName]++; const issuesByFile = {}; @@ -690,15 +973,6 @@ function printSummary(totalFiles, allPassed) { console.log(IS_GH_ACTIONS ? LINE : `${c.bold}${c.cyan}${LINE}${c.reset}`); console.log(' By check type:'); - const CHECK_LABELS = { - fk: 'Flesch-Kincaid grade too high ', - fre: 'Flesch Reading Ease too low ', - cl: 'Coleman-Liau index too high ', - lix: 'LIX score too high (long words)', - len: 'Avg sentence length > 25 words ', - max: 'Single sentence > 40 words ', - para:'Paragraph density > 5 sentences', - }; for (const [key, label] of Object.entries(CHECK_LABELS)) { const n = countByType[key]; if (n > 0) console.log(` ${c.yellow}${label}${c.reset} : ${c.bold}${n}${c.reset}`); @@ -740,7 +1014,7 @@ function writeJobSummary(totalFiles, allPassed, warningsByFile) { const totalWarnings = Object.values(warningsByFile).reduce((n, ws) => n + ws.length, 0); const filesWithIssues = Object.keys(warningsByFile).length; - const countByType = { fk: 0, fre: 0, cl: 0, lix: 0, len: 0, max: 0, para: 0 }; + const countByType = { fk: 0, fre: 0, cl: 0, lix: 0, dc: 0, len: 0, max: 0, para: 0, list: 0 }; for (const ws of Object.values(warningsByFile)) { for (const w of ws) countByType[w.checkName] = (countByType[w.checkName] ?? 0) + 1; } @@ -767,9 +1041,11 @@ function writeJobSummary(totalFiles, allPassed, warningsByFile) { fre: 'Flesch Reading Ease too low', cl: 'Coleman-Liau index too high', lix: 'LIX score too high (long words)', + dc: 'Dale-Chall score too high', len: 'Avg sentence length exceeded', max: 'Single sentence too long', para:'Paragraph density too high', + list:'List has too many items', }; for (const [key, label] of Object.entries(checkLabels)) { if (countByType[key] > 0) lines.push(`| ${label} | ${countByType[key]} |`); @@ -803,93 +1079,121 @@ function writeJobSummary(totalFiles, allPassed, warningsByFile) { } // ── Main ───────────────────────────────────────────────────────────────────── +// Guarded so this module can be `import`ed (e.g. from readability.test.mjs) +// without immediately scanning the whole docs tree or calling process.exit(). + +const isMainModule = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; -const arg = process.argv[2] ?? 'versioned_docs'; -const argStat = statSync(arg, { throwIfNoEntry: false }); - -if (!argStat) { - console.error(`${c.red}Error: '${arg}' is not a valid file or directory.${c.reset}`); - console.error(''); - console.error('Usage:'); - console.error(' node readability.mjs [target]'); - console.error(''); - console.error(' target can be:'); - console.error(' - A directory (default: versioned_docs) → scans all files'); - console.error(' - A single .mdx or .md file → checks only that file'); - console.error(' - A single .json data file → checks only that file\'s entries'); - process.exit(1); -} - -const isSingleFile = argStat.isFile(); -let files = []; -let dataFilesConfig = DATA_FILES; - -if (isSingleFile) { - const ext = extname(arg); - if (['.md', '.mdx'].includes(ext)) { - files = [arg]; - dataFilesConfig = []; - } else if (ext === '.json') { - files = []; - const normArg = arg.replace(/\\/g, '/'); - dataFilesConfig = DATA_FILES.filter(df => normArg.endsWith(df.path) || df.path.endsWith(normArg)); - if (dataFilesConfig.length === 0) { - console.error(`${c.red}Error: '${arg}' is not a recognised data file.${c.reset}`); - console.error('Supported data files: ' + DATA_FILES.map(df => df.path).join(', ')); +if (isMainModule) { + runCli(); +} + +function runCli() { + const arg = process.argv[2] ?? 'versioned_docs'; + const argStat = statSync(arg, { throwIfNoEntry: false }); + + if (!argStat) { + console.error(`${c.red}Error: '${arg}' is not a valid file or directory.${c.reset}`); + console.error(''); + console.error('Usage:'); + console.error(' node readability.mjs [target]'); + console.error(''); + console.error(' target can be:'); + console.error(' - A directory (default: versioned_docs) → scans all files'); + console.error(' - A single .mdx or .md file → checks only that file'); + console.error(' - A single .json data file → checks only that file\'s entries'); + process.exit(1); + } + + const isSingleFile = argStat.isFile(); + let files = []; + let dataFilesConfig = DATA_FILES; + + if (isSingleFile) { + const ext = extname(arg); + if (['.md', '.mdx'].includes(ext)) { + files = [arg]; + dataFilesConfig = []; + } else if (ext === '.json') { + files = []; + const normArg = arg.replace(/\\/g, '/'); + dataFilesConfig = DATA_FILES.filter(df => normArg.endsWith(df.path) || df.path.endsWith(normArg)); + if (dataFilesConfig.length === 0) { + console.error(`${c.red}Error: '${arg}' is not a recognised data file.${c.reset}`); + console.error('Supported data files: ' + DATA_FILES.map(df => df.path).join(', ')); + process.exit(1); + } + } else { + console.error(`${c.red}Error: '${arg}' is not a .md, .mdx, or .json file.${c.reset}`); process.exit(1); } } else { - console.error(`${c.red}Error: '${arg}' is not a .md, .mdx, or .json file.${c.reset}`); - process.exit(1); + files = findMdxFiles(arg); } -} else { - files = findMdxFiles(arg); -} -let allPassed = true; -const warningsByFile = {}; + let allPassed = true; + const warningsByFile = {}; -for (const file of files) { - const content = readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); - const { primary, collapsed } = extractSections(content); - const thresholds = getThresholds(file); - const relPath = relative(process.cwd(), file).replace(/\\/g, '/'); - const fileWarnings = []; + for (const file of files) { + const content = readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); + const { primary, collapsed } = extractSections(content); + const thresholds = getThresholds(file); + const relPath = relative(process.cwd(), file).replace(/\\/g, '/'); + const fileWarnings = []; - checkSection('Primary content', primary.text, relPath, primary.startLine, primary.startCol, thresholds, fileWarnings); + checkSection('Primary content', primary.text, relPath, primary.startLine, primary.startCol, thresholds, fileWarnings); - for (let i = 0; i < collapsed.length; i++) { - const { text, startLine, startCol } = collapsed[i]; - checkSection(`Collapsed section ${i + 1}`, text, relPath, startLine, startCol, thresholds, fileWarnings); - } + for (let i = 0; i < collapsed.length; i++) { + const { text, startLine, startCol } = collapsed[i]; + checkSection(`Collapsed section ${i + 1}`, text, relPath, startLine, startCol, thresholds, fileWarnings); + } - if (fileWarnings.length > 0) { - allPassed = false; - warningsByFile[relPath] = fileWarnings; - printFileWarnings(relPath, fileWarnings); + if (fileWarnings.length > 0) { + allPassed = false; + warningsByFile[relPath] = fileWarnings; + printFileWarnings(relPath, fileWarnings); + } } -} -const dataFilesPassed = processDataFiles(warningsByFile, dataFilesConfig); -if (!dataFilesPassed) allPassed = false; + const dataFilesPassed = processDataFiles(warningsByFile, dataFilesConfig); + if (!dataFilesPassed) allPassed = false; -if (isSingleFile) { - const totalWarnings = Object.values(warningsByFile).reduce((n, ws) => n + ws.length, 0); - if (allPassed) { - console.log(`\n${c.green}✅ No readability issues found.${c.reset}`); + if (isSingleFile) { + const totalWarnings = Object.values(warningsByFile).reduce((n, ws) => n + ws.length, 0); + if (allPassed) { + console.log(`\n${c.green}✅ No readability issues found.${c.reset}`); + } else { + const noun = totalWarnings === 1 ? 'warning' : 'warnings'; + console.log(`\n${c.yellow}✗ ${totalWarnings} ${noun} found — see above for details.${c.reset}`); + } } else { - const noun = totalWarnings === 1 ? 'warning' : 'warnings'; - console.log(`\n${c.yellow}✗ ${totalWarnings} ${noun} found — see above for details.${c.reset}`); + const totalChecked = files.length + dataFilesConfig.length; + printSummary(totalChecked, allPassed); + writeJobSummary(totalChecked, allPassed, warningsByFile); } -} else { - const totalChecked = files.length + dataFilesConfig.length; - printSummary(totalChecked, allPassed); - writeJobSummary(totalChecked, allPassed, warningsByFile); -} -if (!allPassed) { - if (!isSingleFile) { - console.log('::error::Readability check failed. See warnings above for details.'); + if (!allPassed) { + if (!isSingleFile) { + console.log('::error::Readability check failed. See warnings above for details.'); + } + process.exit(1); } - process.exit(1); } + +// ── Exports for unit testing (readability.test.mjs) ───────────────────────── +// Importing this module for tests does not trigger runCli() — see the +// isMainModule guard above. + +export { + getThresholds, + extractSections, + toPlainText, + countSyllables, + getSentences, + isListBlock, + splitIntroAndList, + analyzeText, + checkSection, + dcSentencePreview, + difficultWordsInSentence, +}; diff --git a/.github/scripts/readability.test.mjs b/.github/scripts/readability.test.mjs new file mode 100644 index 000000000..306b59c18 --- /dev/null +++ b/.github/scripts/readability.test.mjs @@ -0,0 +1,263 @@ +// Unit tests for readability.mjs's text-structure detection logic. +// +// Run with: node --test .github/scripts/readability.test.mjs +// (Node's built-in test runner — no extra dependency needed.) + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isListBlock, + splitIntroAndList, + toPlainText, + countSyllables, + extractSections, + analyzeText, + getThresholds, + dcSentencePreview, + difficultWordsInSentence, +} from './readability.mjs'; + +describe('isListBlock()', () => { + test('recognizes a pure bullet list', () => { + const block = '* item one\n* item two\n* item three'; + assert.ok(isListBlock(block), 'expected a pure bullet list to be recognized as a list'); + }); + + test('recognizes a pure numbered list', () => { + const block = '1. item one\n2. item two'; + assert.ok(isListBlock(block), 'expected a pure numbered list to be recognized as a list'); + }); + + test('a heading directly above a list does not disqualify it', () => { + const block = '### Required deployment\n* item one\n* item two'; + assert.ok(isListBlock(block), 'expected a heading directly above a list to be recognized as a list'); + }); + + test('a JSX wrapper tag directly above/around a list does not disqualify it', () => { + const block = '\n* item one\n* item two\n'; + assert.ok(isListBlock(block), 'expected a JSX wrapper around a list to be recognized as a list'); + }); + + test('a plain paragraph with no list items is not a list', () => { + const block = 'This is one sentence. This is another sentence.'; + assert.ok(!isListBlock(block), 'expected a plain paragraph with no list items to not be recognized as a list'); + }); + + test('a prose lead-in directly above a list is not itself a pure list', () => { + const block = 'Each flow shows how many messages are in each state:\n* Active\n* Completed\n* Error'; + assert.ok(!isListBlock(block), 'expected a prose lead-in directly above a list to not be recognized as a pure list'); + }); +}); + +describe('splitIntroAndList() — announcing a list with a lead-in sentence', () => { + test('splits a short lead-in from an immediately-following pure list', () => { + const block = [ + 'The Dashboard home page shows the status of all your flows.', + 'Each flow shows how many messages are in each state:', + '* **Active:** messages the flow is working on now.', + '* **Completed:** messages the flow has done with no errors.', + '* **Error:** messages the flow has paused, held, or stopped.', + ].join('\n'); + + const result = splitIntroAndList(block); + assert.ok(result, 'expected the block to split into an intro and a list'); + assert.match(result.intro, /Each flow shows/); + assert.doesNotMatch(result.intro, /Active/, 'list items must not leak into the intro half'); + assert.match(result.list, /Active/); + assert.match(result.list, /Error/); + assert.doesNotMatch(result.list, /Dashboard home page/, 'prose must not leak into the list half'); + }); + + test('does not split a plain dense paragraph that has no list at all', () => { + const block = 'This is one sentence. This is two. This is three. This is four. This is five.'; + assert.ok(!splitIntroAndList(block), 'expected a plain dense paragraph with no list to not be split'); + }); + + test('does not split when prose interrupts the list partway through', () => { + const block = '* item one\n* item two\nBut then more prose appears here.\n* item three'; + assert.ok(!splitIntroAndList(block), 'expected a list interrupted by prose to not be split'); + }); + + test('does not split a pure list with no lead-in (already handled by isListBlock)', () => { + const block = '* item one\n* item two\n* item three'; + assert.ok(!splitIntroAndList(block), 'expected a pure list with no lead-in to not be split'); + }); + + test('still splits when a heading sits above the lead-in and list (dashboard/installation/index.mdx case)', () => { + // Regression test: a heading directly above a lead-in-plus-list block + // (no blank line separating any of them) used to make splitIntroAndList() + // bail out entirely — because its intro-line check rejected headings — + // silently falling back to scoring the whole block as "para" using a + // near-meaningless derived sentence count (list items rarely end in a + // period), so a too-long list here could slip past the list-item-count + // check undetected. Headings must be dropped before the intro/list split, + // the same way isListBlock() already ignores them. + const block = [ + '### Required deployment', + 'An Azure Virtual Network', + '- Including two subnets, one for each', + ' - Private Endpoints', + ' - Container App Environment', + '- The subnets must have the following services enabled', + ].join('\n'); + + const result = splitIntroAndList(block); + assert.ok(result, 'expected the block to split despite the leading heading'); + assert.match(result.intro, /Azure Virtual Network/); + assert.doesNotMatch(result.intro, /Required deployment/, 'the heading itself must not leak into the intro text'); + assert.equal(result.list.split('\n').filter(l => /^\s*(?:[-*+]|\d+\.)\s+\S/.test(l)).length, 4); + }); +}); + +describe('toPlainText() — label-bearing self-closing JSX components', () => { + test('preserves a label="..." prop value instead of deleting the whole tag', () => { + const raw = 'Fill in the field.'; + const plain = toPlainText(raw); + assert.match(plain, /Username/); + }); + + test('does not confuse aria-label with label', () => { + const raw = 'Go to