vital-core / src /lib /spell.js
vital-core sync
Deploy snapshot for Hugging Face Spaces
50841a0
Raw
History Blame Contribute Delete
2.53 kB
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] };
}