Commit 70c854e7 by PLN (Algolia)

prose: yarn prose — LanguageTool lint for blog posts, noise-filtered

parent c0998841
...@@ -16,7 +16,8 @@ ...@@ -16,7 +16,8 @@
"post": "node scripts/new-post.mjs", "post": "node scripts/new-post.mjs",
"post:image": "node scripts/add-image.mjs", "post:image": "node scripts/add-image.mjs",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui",
"prose": "node scripts/prose.mjs"
}, },
"engines": { "engines": {
"node": ">=18.17.0" "node": ">=18.17.0"
......
#!/usr/bin/env node
/**
* yarn prose [slug|file …] — LanguageTool lint for blog prose.
*
* No args → every post under content/blog/ (both languages). A slug arg
* checks that folder; a path ending in .md checks that file. The server is
* expected at localhost:8081 (`languagetool-server --port 8081`, brew).
*
* Noise filters, so the report stays worth reading:
* - repeated whitespace → markdown hard breaks (trailing two spaces),
* not typos;
* - underscore emphasis confusing the tokenizer (e.g. `_ensemble_`);
* - language-specific: FR "Parce que…" anaphora advice (REP_PARCE_QUE1)
* and anglicism gripes on words the author uses deliberately.
* Everything else prints rule, message, context and top suggestion.
*/
import fs from 'fs';
import path from 'path';
const BLOG_ROOT = path.join(process.cwd(), 'content', 'blog');
const LT_URL = 'http://localhost:8081/v2/check';
const LANG_FOR = { fr: 'fr-FR', en: 'en-US' };
const IGNORE_RULES = new Set([
'WHITESPACE_RULE', // markdown hard breaks read as "repeated space"
'FRENCH_WHITESPACE', // same, thin-space flavor (author's `?`/`!` spacing)
'REP_PARCE_QUE1', // deliberate anaphora is a feature of this blog's voice
'ENGLISH_WORD_REPEAT_BEGINNING_RULE', // same, EN flavor
]);
const NOISE_CONTEXT = [
// "_word_" italic underscores read as glued punctuation
(m) => /_[a-zà-ÿ]+_/i.test(m.context.text.slice(m.context.offset - 1, m.context.offset + m.context.length + 1)),
];
const targets = process.argv.slice(2);
const files = [];
function collect(slugOrFile) {
if (slugOrFile.endsWith('.md')) return files.push(path.resolve(slugOrFile));
const dir = path.isAbsolute(slugOrFile) ? slugOrFile : path.join(BLOG_ROOT, slugOrFile);
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (/^(fr|en)\.md$/.test(f)) files.push(path.join(dir, f));
}
return;
}
console.error(`prose: no post or file for "${slugOrFile}"`);
}
if (targets.length > 0) targets.forEach(collect);
else {
for (const slug of fs.readdirSync(BLOG_ROOT)) {
const dir = path.join(BLOG_ROOT, slug);
if (fs.statSync(dir).isDirectory()) collect(dir);
}
}
function bodyOf(file) {
const raw = fs.readFileSync(file, 'utf8');
const parts = raw.split('---');
return parts.length >= 3 ? parts.slice(2).join('---').trim() : raw.trim();
}
async function check(file) {
const lang = path.basename(file, '.md');
const text = bodyOf(file);
const res = await fetch(LT_URL, {
method: 'POST',
body: new URLSearchParams({ text, language: LANG_FOR[lang] ?? lang, level: 'picky' }),
});
if (!res.ok) throw new Error(`LanguageTool ${res.status} — is it running? (languagetool-server --port 8081)`);
const { matches } = await res.json();
const worth = matches.filter((m) => {
if (IGNORE_RULES.has(m.rule.id)) return false;
return !NOISE_CONTEXT.some((fn) => {
try { return fn(m); } catch { return false; }
});
});
const label = path.relative(process.cwd(), file);
console.log(`\n=== ${label} — ${worth.length} finding(s) (${matches.length} raw) ===`);
for (const m of worth) {
const { text: ctx, offset, length } = m.context;
const snippet = ctx.slice(Math.max(0, offset - 20), offset + length + 20).replace(/\n/g, '⏎');
const fix = m.replacements?.[0]?.value ?? '—';
console.log(`[${m.rule.id}] ${m.message.slice(0, 120)}`);
console.log(` …${snippet}… → ${fix.slice(0, 60)}`);
}
}
let failed = 0;
for (const file of files) {
try {
await check(file);
} catch (e) {
failed += 1;
console.error(`prose: ${path.basename(file)}: ${e.message}`);
}
}
process.exit(failed > 0 ? 1 : 0);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment