ab2308441c
The repository had two skill directories. `skills/` held the four written for this project; `.agents/skills/` held the ones the agent context refers to. Nothing said which an agent should read, and `.agents/ORCHESTRATOR.md` only ever pointed at the second. Move the first four into `.agents/skills/` so there is one location, and add the vendored packages this chapter work used: `animation-vocabulary` and `improve-animations` (emilkowalski/skills), `teach` (mattpocock/skills), plus a local `translation` skill and `audit-translations.mjs` for the EN/PT pairs. `skills-lock.json` pins the vendored three by source and content hash, so a later re-vendor is a diff rather than a guess. `.claude/skills/` is symlinks into `.agents/skills/`, which is what makes them loadable here without a second copy on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
4.0 KiB
JavaScript
124 lines
4.0 KiB
JavaScript
#!/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) : '<non-string>';
|
|
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);
|