#!/usr/bin/env node // Advisory readability report for the handbook MDX. Nudges toward clear language for a // technical-but-mixed audience. It PRINTS numbers and never fails: exit code is always 0, so it is // safe as a non-blocking CI step (see .tangled/workflows/deploy.yml) and a local `npm run // check:readability`. No dependencies, Node built-ins only. // // What it flags per page: // - long sentences (> LONG_SENTENCE words) — the main clarity lever // - Flesch Reading Ease (higher = easier; ~50+ is comfortable for this audience) // - average words per sentence // Headings, code blocks, imports, and JSX are stripped before counting so only real prose is scored. import { readdir, readFile } from 'node:fs/promises'; const DOCS_URL = new URL('../src/content/docs/', import.meta.url); const LONG_SENTENCE = 30; // words; a soft ceiling, not a rule const LOW_FLESCH = 45; // below this reads as dense for a general-technical audience /** Strip MDX/markdown down to plain prose sentences (drops frontmatter, code, JSX, headings). */ function toProse(raw) { let text = raw; text = text.replace(/^---\n[\s\S]*?\n---\n/, ''); // frontmatter text = text.replace(/```[\s\S]*?```/g, ''); // fenced code text = text.replace(/`[^`]*`/g, ''); // inline code const kept = []; for (const line of text.split('\n')) { const t = line.trim(); if (!t) continue; if (t.startsWith('#')) continue; // headings are not sentences if (t.startsWith('import ') || t.startsWith('export ')) continue; if (t.startsWith('<') || t.startsWith('/>') || t.startsWith('{')) continue; // JSX/expr kept.push(t); } return kept .join(' ') .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // [text](url) and ![alt](src) -> text/alt .replace(/[*_>#]+/g, ' ') // markdown emphasis / list / quote marks .replace(/^\s*[-\d.]+\s+/gm, ' ') .replace(/\s+/g, ' ') .trim(); } function splitSentences(prose) { return prose .split(/(?<=[.!?])\s+/) .map((s) => s.trim()) .filter((s) => /[a-z]/i.test(s)); } function words(s) { return s.split(/\s+/).filter((w) => /[a-z0-9]/i.test(w)); } /** Rough syllable count: vowel groups, minus common silent-e, floor of 1. Good enough for Flesch. */ function syllables(word) { const w = word.toLowerCase().replace(/[^a-z]/g, ''); if (!w) return 0; const groups = w.match(/[aeiouy]+/g); let n = groups ? groups.length : 1; if (w.endsWith('e') && !/[aeiouy]e$/.test(w) && n > 1) n -= 1; return Math.max(1, n); } function flesch(sentenceCount, wordCount, syllableCount) { if (!sentenceCount || !wordCount) return null; return 206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (syllableCount / wordCount); } async function mdxFiles(dir, base = '') { const out = []; for (const ent of await readdir(dir, { withFileTypes: true })) { const rel = base ? `${base}/${ent.name}` : ent.name; if (ent.isDirectory()) out.push(...(await mdxFiles(new URL(`${ent.name}/`, dir), rel))); else if (ent.name.endsWith('.mdx') || ent.name.endsWith('.md')) out.push(rel); } return out; } const files = (await mdxFiles(DOCS_URL)).sort(); let totalLong = 0; const rows = []; for (const rel of files) { const raw = await readFile(new URL(rel, DOCS_URL), 'utf8'); const prose = toProse(raw); const sentences = splitSentences(prose); const wordCount = sentences.reduce((n, s) => n + words(s).length, 0); const syllableCount = sentences.reduce( (n, s) => n + words(s).reduce((m, w) => m + syllables(w), 0), 0, ); const longOnes = sentences.filter((s) => words(s).length > LONG_SENTENCE); totalLong += longOnes.length; const avg = sentences.length ? Math.round(wordCount / sentences.length) : 0; const fre = flesch(sentences.length, wordCount, syllableCount); rows.push({ rel, avg, fre, long: longOnes.length, longest: longOnes }); } console.log('docs readability report (advisory, never fails the build)\n'); for (const r of rows) { const freStr = r.fre === null ? ' n/a' : r.fre.toFixed(0).padStart(4); const flags = []; if (r.long) flags.push(`${r.long} long`); if (r.fre !== null && r.fre < LOW_FLESCH) flags.push('dense'); const tail = flags.length ? ` <- ${flags.join(', ')}` : ''; console.log(` ${r.rel.padEnd(34)} avg ${String(r.avg).padStart(2)} wps Flesch ${freStr}${tail}`); } console.log( `\nThresholds: sentence > ${LONG_SENTENCE} words = "long"; Flesch < ${LOW_FLESCH} = "dense". Higher Flesch is easier.`, ); if (totalLong) { console.log(`\n${totalLong} long sentence(s) worth a second look:`); for (const r of rows) { for (const s of r.longest) { const w = words(s).length; console.log(` ${r.rel} (${w}w): ${s.slice(0, 100)}${s.length > 100 ? '...' : ''}`); } } } // Advisory: always succeed. process.exit(0);