import fs from 'node:fs'; import path from 'node:path'; import nspell from 'nspell'; import dictionary from 'dictionary-en'; import { DIRS } from './config.js'; /** * Spell checking for page prose. Uses nspell with the en dictionary, * plus an optional project allowlist (config/spelling-allowlist.txt, one * term per line, # comments) for domain jargon — government, medical, * and program terms that aren't in a general dictionary but are correct. * * The dictionary is loaded once and reused. Numbers, ALL-CAPS acronyms, * URLs/emails, and very short tokens are skipped: those are not spelling * mistakes and would only add noise. */ let speller = null; function getSpeller() { if (speller) return speller; speller = nspell(dictionary); // Layer the allowlist on top so jargon stops being flagged. const allowPath = path.join(DIRS.config, 'spelling-allowlist.txt'); if (fs.existsSync(allowPath)) { for (const line of fs.readFileSync(allowPath, 'utf8').split('\n')) { const t = line.trim(); if (t && !t.startsWith('#')) speller.add(t); } } return speller; } /** A token worth spell-checking? Skip numbers, acronyms, URLs, short bits. */ function checkable(word) { if (word.length < 3) return false; if (/\d/.test(word)) return false; // contains a digit if (/^[A-Z]{2,}$/.test(word)) return false; // ALL-CAPS acronym if (/[@/:.]/.test(word)) return false; // URL/email-ish return /^[A-Za-z][A-Za-z'-]*$/.test(word); } /** * Find misspelled words in a list of tokens. Returns * { misspelledCount, misspelled: [...capped distinct words...] }. * Possessives and a leading capital (sentence start) are tolerated. * * extraAllowlist: optional string[] of per-target terms (from * target.spelling_allowlist in targets.yml) layered on top of the * project-wide config/spelling-allowlist.txt. */ export function findMisspellings(words, cap = 25, extraAllowlist = []) { const spell = getSpeller(); // Build a per-call allowed set so the shared speller is never mutated. const extra = new Set(extraAllowlist.map((w) => w.toLowerCase())); const distinct = new Set(); let count = 0; for (const w of words) { if (!checkable(w)) continue; const bare = w.replace(/['']s$/, ''); // strip possessive if (extra.has(bare.toLowerCase())) continue; if (spell.correct(bare) || spell.correct(bare.toLowerCase())) continue; count++; if (distinct.size < cap) distinct.add(bare); } return { misspelledCount: count, misspelled: [...distinct] }; }