chore: consolidate the skill packages under .agents/
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>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user