Something went wrong. Try again.
Central platform for European atproto.<cc> country community websites
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153// check-copy-voice.mjs//// Fails when newly-added lines in our own user-facing copy contain characters the// voice guide bans outright: em dashes, the horizontal bar, and curly quotes. These are// the mechanically-detectable tells from the humanizer rules. The full voice pass// (rule-of-three, promotional tone, negative parallelism, etc.) still needs the// `/humanizer` skill; this only stops the hard bans reaching main.//// Ratchet design (mirrors Sifa's check-copy-voice): only ADDED lines in the diff are// scanned, so the existing backlog of stray characters does not block every commit. New// copy must be clean, and editing an old offending line forces it clean too.//// Scope: src/i18n/*.ts (the strings), plus .astro under src/components, src/pages,// src/layouts (inline JSX-ish copy), and src/data/*.ts (tool/community names + blurbs).//// Plain ESM so it runs with node (no tsx). The pure core (findVoiceViolations,// parseAddedLines) is exported for the vitest unit test.//// Usage:// node scripts/check-copy-voice.mjs --staged # pre-commit (cached diff)// node scripts/check-copy-voice.mjs --base HEAD~1 # push CI (diff vs previous tip)// node scripts/check-copy-voice.mjs --base origin/main # PR-style (diff vs base ref)
import { execFileSync } from 'node:child_process';import { fileURLToPath } from 'node:url';import { resolve } from 'node:path';
/** * Characters banned in user-facing copy. En dash (U+2013) and double-hyphen are * deliberately absent: en dashes are legitimate in numeric ranges, and `--` appears in * CSS custom properties and CLI flags. * @type {ReadonlyArray<{ char: string, name: string }>} */export const BANNED_CHARS = [ { char: '—', name: 'em dash' }, { char: '―', name: 'horizontal bar' }, { char: '“', name: 'curly double quote (open)' }, { char: '”', name: 'curly double quote (close)' }, { char: '‘', name: 'curly single quote (open)' }, { char: '’', name: 'curly apostrophe' },];
/** * Pure core: scan already-collected added lines for banned characters. * @param {Array<{ file: string, lineNo: number, content: string }>} lines * @returns {Array<{ file: string, lineNo: number, char: string, name: string, content: string }>} */export function findVoiceViolations(lines) { const out = []; for (const { file, lineNo, content } of lines) { for (const ch of content) { const banned = BANNED_CHARS.find((b) => b.char === ch); if (banned) out.push({ file, lineNo, char: ch, name: banned.name, content }); } } return out;}
// `:(glob)` magic so `**/` matches zero-or-more directories (including files sitting// directly in the scoped folder). Without it git treats `*` as not crossing slashes.const SCAN_PATHSPECS = [ ':(glob)src/i18n/*.ts', ':(glob)src/data/*.ts', ':(glob)src/components/**/*.astro', ':(glob)src/pages/**/*.astro', ':(glob)src/layouts/**/*.astro',];
/** * Parse a `git diff --unified=0` patch into the new-side added lines. * @param {string} diff * @returns {Array<{ file: string, lineNo: number, content: string }>} */export function parseAddedLines(diff) { const out = []; let file = ''; let newLineNo = 0; for (const raw of diff.split('\n')) { if (raw.startsWith('+++ ')) { // `+++ b/path` (or `+++ /dev/null` for deletions) const path = raw.slice(4).replace(/^b\//, ''); file = path === '/dev/null' ? '' : path; continue; } if (raw.startsWith('@@')) { // @@ -a,b +c,d @@ -> next added line starts at c const match = /\+(\d+)/.exec(raw.split('@@')[1] ?? ''); newLineNo = match ? Number(match[1]) : 0; continue; } if (raw.startsWith('+')) { // Real `+++ b/path` headers handled above; a bare `+` is an added line whose // content (raw.slice(1)) may itself start with `+`. if (file) out.push({ file, lineNo: newLineNo, content: raw.slice(1) }); newLineNo++; continue; } if (raw.startsWith('-') || raw.startsWith('\\')) continue; if (newLineNo > 0) newLineNo++; } return out;}
function gitDiff(args) { return execFileSync( 'git', ['diff', '--unified=0', '--no-color', '--diff-filter=AM', ...args, '--', ...SCAN_PATHSPECS], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, );}
function collectAddedLines() { const argv = process.argv.slice(2); if (argv.includes('--staged') || argv.includes('--cached')) { return parseAddedLines(gitDiff(['--cached'])); } const baseFlag = argv.indexOf('--base'); if (baseFlag !== -1) { const base = argv[baseFlag + 1]; if (!base) { console.error('check-copy-voice: --base requires a ref argument'); process.exit(2); } // two-dot: everything on the checked-out tip not in base. On a merge-to-main push // with --base HEAD~1 that is the whole merged feature; with --base origin/main on a // PR it is the PR's changes. return parseAddedLines(gitDiff([`${base}..HEAD`])); } console.error('check-copy-voice: pass --staged or --base <ref>'); process.exit(2);}
function main() { const violations = findVoiceViolations(collectAddedLines()); if (violations.length === 0) { console.log('check-copy-voice: OK, no banned characters in added copy.'); return; } console.error(`check-copy-voice: ${violations.length} banned character(s) in added copy:\n`); for (const v of violations) { console.error(` ${v.file}:${v.lineNo} ${v.name} (${v.char})`); console.error(` ${v.content.trim()}`); } console.error( '\nThese characters are banned in user-facing copy. Use straight quotes and replace em\n' + 'dashes with a colon, comma, or period. Run /humanizer on new copy before committing.', ); process.exit(1);}
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : '';if (invokedPath === fileURLToPath(import.meta.url)) main();