#!/usr/bin/env node // Walk every entry in src/content/** and flag any localized field whose // `en` and `pt` values are identical. Identical pairs are how a bilingual // site quietly becomes monolingual — the schema accepts them, the build // passes, and a Portuguese speaker sees English. // // node .agents/scripts/audit-translations.mjs # walks src/content // node .agents/scripts/audit-translations.mjs src/content/ # explicit root // // Exits non-zero if any pair is identical. The output is grouped by // collection, then by file, then by field path, so the report reads like // a translation backlog rather than a wall of strings. // // This is the sibling of extract-strings.mjs: that one proves the // migration moved every string; this one proves every string actually // differs across locales. import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; const ROOT = process.argv[2] ?? 'src/content'; const walk = (dir) => readdirSync(dir).flatMap((name) => { const path = join(dir, name); return statSync(path).isDirectory() ? walk(path) : [path]; }); const files = walk(ROOT).filter((path) => path.endsWith('.json')); // Returns an array of [fieldPath, en, pt] tuples for a parsed document. // Handles the two shapes in the project: // 1. { ..., someKey: { en, pt }, ... } — the `localized` Zod helper // 2. { en: { ...string IDs... }, pt: { ...string IDs... } } — rules/copy.json const collect = (node, fieldPath = '', out = []) => { if (!node || typeof node !== 'object') return out; if ('en' in node && 'pt' in node) { out.push([fieldPath || '(root)', node.en, node.pt]); return out; } for (const [key, value] of Object.entries(node)) { const next = fieldPath ? `${fieldPath}.${key}` : key; // Top-level `{ en: {...}, pt: {...} }` — recurse into each side. if ( (key === 'en' || key === 'pt') && value && typeof value === 'object' && !Array.isArray(value) ) { collect(value, '', out); } else { collect(value, next, out); } } return out; }; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); // Compute the collection name from the path relative to ROOT. // src/content/chapters/landing.json -> chapters // src/content/rules/copy.json -> rules const collectionOf = (file, root) => { const rel = relative(root, file); const parts = rel.split('/'); // ['chapters', 'landing.json'] -> 'chapters' return parts.length >= 2 ? parts[0] : '(root)'; }; const groups = new Map(); let totalLocalized = 0; let totalIdentical = 0; for (const file of files) { let data; try { data = JSON.parse(readFileSync(file, 'utf8')); } catch (err) { console.error(`skip: ${file} is not valid JSON (${err.message})`); continue; } const pairs = collect(data); if (pairs.length === 0) continue; const offenders = pairs.filter(([, en, pt]) => same(en, pt)); if (offenders.length === 0) continue; totalLocalized += pairs.length; totalIdentical += offenders.length; const collection = collectionOf(file, ROOT); const filename = relative(ROOT, file); if (!groups.has(collection)) groups.set(collection, []); groups.get(collection).push({ file: filename, pairs: offenders }); } if (groups.size === 0) { console.log('OK: no identical en/pt pairs found under', ROOT); process.exit(0); } // Print grouped report. for (const [collection, entries] of groups) { console.log(`\n[${collection}]`); for (const { file, pairs } of entries) { console.log(` ${file}`); for (const [field, en, pt] of pairs) { const sample = typeof en === 'string' ? JSON.stringify(en).slice(0, 80) : ''; console.log(` - ${field.padEnd(28)} ${sample}`); } } } console.log( `\nFAIL: ${totalIdentical} identical localized field(s) across ${groups.size} collection(s) of ${ROOT}`, ); console.log( 'A `pt` identical to `en` is how a bilingual site becomes monolingual. Translate, then re-run.', ); process.exit(1);