Skip to content

Commit f8c04d0

Browse files
committed
chore: add locale key sync script and docs
- new scripts/sync-locale-keys.mjs for keeping translations in sync - added corresponding unit test and documentation updates - updated workspace.instructions.md and locales README with usage info
1 parent 95e3e0b commit f8c04d0

File tree

3 files changed

+371
-0
lines changed

3 files changed

+371
-0
lines changed

assets/locales/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ External translation files:
1010

1111
To translate all strings, you also need to translate the external translation files.
1212

13+
## Scripts
14+
15+
When adding or removing translation keys in `assets/locales/en/translation.json`, execute:
16+
17+
```sh
18+
node scripts/sync-locale-keys.mjs
19+
```
20+
21+
This updates all other translation files and sorts their keys alphabetically.
22+
1323
## Instructions
1424

1525
Each locale has its own directory named with its corresponding __[IETF language tag](https://wikipedia.org/wiki/IETF_language_tag)__.

scripts/sync-locale-keys.mjs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* sync-locale-keys.mjs
3+
* --------------------
4+
* Utility script used during development to keep every non-English locale
5+
* synchronized with the English `translation.json` file. When new keys are
6+
* added, renamed or reordered in `assets/locales/en/translation.json` the same
7+
* keys should exist in all other translations so that translators have a
8+
* reference file. This script walks the English tree, copies values over to
9+
* each other language (overwriting existing entries) and then writes the
10+
* result back to disk sorted by key. Sorting makes git diffs meaningful and
11+
* keeps locale files easy to scan manually.
12+
*
13+
* This file is a standalone ES module (`.mjs`) so that the project can simply
14+
* execute it with `node scripts/sync-locale-keys.mjs` regardless of the
15+
* `package.json` "type" field. It also allows use of `import`/`export` and
16+
* `async`/`await` without flags.
17+
*
18+
* Usage:
19+
* node scripts/sync-locale-keys.mjs # can be run from any working
20+
* # directory; it locates itself
21+
* # via `import.meta.url`
22+
*
23+
* There is no external dependency; the script only uses the built‑in `fs` and
24+
* `path` modules. It deliberately avoids mutating the English file and
25+
* ignores directories that lack a `translation.json`.
26+
*
27+
* This script is intended to be run manually (or via an npm script) whenever
28+
* translation keys are added or modified. It is *not* run as part of the
29+
* build pipeline, but keeping it in `scripts/` and referenced in the repo
30+
* documentation makes it a formal part of our workflow.
31+
*/
32+
33+
import fs from "fs";
34+
import path from "path";
35+
36+
// These variables need to be computed at runtime rather than at module load
37+
// time. By default we derive paths relative to the location of this script so
38+
// that the utility works regardless of the current working directory. Tests
39+
// can override by passing an explicit `rootDir` argument to `main`.
40+
import { fileURLToPath } from "url";
41+
42+
function makePaths(rootDir) {
43+
// `rootDir`, if provided, should be the project root containing the
44+
// `assets` directory. Otherwise we derive the repo root from the location
45+
// of this script (which lives under `<repo>/scripts`).
46+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
47+
const repoRoot = rootDir ? path.resolve(rootDir) : path.dirname(scriptDir);
48+
const localesDir = path.join(repoRoot, "assets", "locales");
49+
const enPath = path.join(localesDir, "en", "translation.json");
50+
return { localesDir, enPath };
51+
}
52+
53+
function readJson(filePath) {
54+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
55+
}
56+
57+
function writeJson(filePath, obj) {
58+
// sort prior to serialization so disk output is deterministic
59+
const sorted = sortObject(obj);
60+
fs.writeFileSync(filePath, JSON.stringify(sorted, null, 2) + "\n", "utf-8");
61+
}
62+
63+
/**
64+
* Recursively sorts the keys of an object. Arrays and non-object values are
65+
* returned unchanged. This is a pure function that returns a new object.
66+
*/
67+
function sortObject(value) {
68+
if (Array.isArray(value)) {
69+
return value.slice();
70+
}
71+
if (value && typeof value === "object") {
72+
const sorted = {};
73+
for (const key of Object.keys(value).sort()) {
74+
sorted[key] = sortObject(value[key]);
75+
}
76+
return sorted;
77+
}
78+
return value;
79+
}
80+
81+
/**
82+
* Merge keys from `source` into `target` in place. If a value is an object
83+
* (but not an array) we recurse; otherwise we copy the source value over the
84+
* target, overwriting whatever was there.
85+
*/
86+
function merge(source, target) {
87+
// Add or merge keys from `source` into `target`, handling "variant"
88+
// groups: keys with a shared prefix before the first underscore. When any
89+
// member of a group exists in the translation, we treat the group as
90+
// satisfied and do not introduce other members. After merging we also
91+
// remove any groups whose base prefix no longer exists in the source.
92+
93+
// helper to determine the base portion of a key
94+
const baseOf = (k) => k.split("_")[0];
95+
96+
// build set of existing bases in target
97+
const targetBases = new Set(Object.keys(target).map(baseOf));
98+
99+
for (const key of Object.keys(source)) {
100+
const srcVal = source[key];
101+
const base = baseOf(key);
102+
103+
// if any key sharing the base already exists, we skip adding new members
104+
// of that group. However, if the exact key exists and both values are
105+
// objects we still recurse to merge children.
106+
if (targetBases.has(base)) {
107+
if (
108+
key in target &&
109+
srcVal &&
110+
typeof srcVal === "object" &&
111+
!Array.isArray(srcVal) &&
112+
target[key] &&
113+
typeof target[key] === "object" &&
114+
!Array.isArray(target[key])
115+
) {
116+
merge(srcVal, target[key]);
117+
}
118+
continue;
119+
}
120+
121+
// no member of this base group exists; copy the key/value
122+
if (srcVal && typeof srcVal === "object" && !Array.isArray(srcVal)) {
123+
target[key] = merge(srcVal, {});
124+
} else {
125+
if (!(key in target)) {
126+
target[key] = srcVal;
127+
}
128+
}
129+
targetBases.add(base); // newly added group satisfies base
130+
}
131+
132+
// prune any groups whose base is not present in the source
133+
const sourceBases = new Set(Object.keys(source).map(baseOf));
134+
for (const key of Object.keys(target)) {
135+
const base = baseOf(key);
136+
if (!sourceBases.has(base)) {
137+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
138+
delete target[key];
139+
}
140+
}
141+
142+
return target;
143+
}
144+
145+
async function main(rootDir) {
146+
const { localesDir, enPath } = makePaths(rootDir);
147+
148+
if (!fs.existsSync(enPath)) {
149+
console.error(`English file not found at ${enPath}`);
150+
process.exit(1);
151+
}
152+
153+
const enData = readJson(enPath);
154+
155+
for (const entry of fs.readdirSync(localesDir)) {
156+
const subdir = path.join(localesDir, entry);
157+
if (!fs.statSync(subdir).isDirectory()) continue;
158+
if (entry === "en") continue;
159+
160+
const file = path.join(subdir, "translation.json");
161+
if (!fs.existsSync(file)) continue;
162+
163+
const data = readJson(file);
164+
merge(enData, data);
165+
writeJson(file, data);
166+
console.log(`updated ${file}`);
167+
}
168+
169+
console.log("sync complete");
170+
}
171+
172+
export { main };
173+
174+
// if this module is evaluated as the entrypoint, run the main function.
175+
// we compare the resolved file URL to `process.argv[1]` so that running
176+
// `node scripts/sync-locale-keys.mjs` from anywhere triggers execution. This
177+
// also leaves `main` exported for tests and other callers.
178+
{
179+
const { fileURLToPath } = await import("url");
180+
const scriptFile = fileURLToPath(import.meta.url);
181+
if (process.argv[1] && path.resolve(process.argv[1]) === scriptFile) {
182+
main().catch((err) => {
183+
console.error(err);
184+
process.exit(1);
185+
});
186+
}
187+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// @vitest-environment node
2+
3+
import fs from "node:fs";
4+
import os from "node:os";
5+
import path from "node:path";
6+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
7+
8+
// This integration test exercises the sync-locale-keys.mjs script on a temporary
9+
// locale directory tree. It verifies that keys are copied from the English
10+
// file to an existing translation, that sorting is applied, and that files with
11+
// missing translation.json are ignored.
12+
13+
const SCRIPT_PATH = path.join(process.cwd(), "scripts", "sync-locale-keys.mjs");
14+
15+
describe("scripts/sync-locale-keys.mjs", () => {
16+
let tmpdir;
17+
let origCwd;
18+
19+
beforeEach(() => {
20+
origCwd = process.cwd();
21+
tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "locales-"));
22+
});
23+
24+
afterEach(() => {
25+
process.chdir(origCwd);
26+
fs.rmSync(tmpdir, { recursive: true, force: true });
27+
});
28+
29+
it("copies keys and sorts result", async () => {
30+
// create minimal repo structure inside tmpdir. we no longer rely on cwd
31+
// inside the script; instead we pass `tmpdir` explicitly when invoking
32+
// `main`.
33+
const localesDir = path.join(tmpdir, "assets", "locales");
34+
fs.mkdirSync(localesDir, { recursive: true });
35+
36+
const enDir = path.join(localesDir, "en");
37+
const frDir = path.join(localesDir, "fr");
38+
fs.mkdirSync(enDir, { recursive: true });
39+
fs.mkdirSync(frDir, { recursive: true });
40+
41+
const enData = {
42+
b: "second",
43+
a: {
44+
z: "nested-z",
45+
y: "nested-y",
46+
},
47+
c: "third",
48+
};
49+
fs.writeFileSync(
50+
path.join(enDir, "translation.json"),
51+
JSON.stringify(enData, null, 2),
52+
);
53+
54+
// french file already has some keys in wrong order and a stale value
55+
const frData = {
56+
c: "troisieme",
57+
a: {
58+
y: "ancien",
59+
},
60+
};
61+
fs.writeFileSync(
62+
path.join(frDir, "translation.json"),
63+
JSON.stringify(frData, null, 2),
64+
);
65+
66+
// run the script
67+
const { main } = await import(SCRIPT_PATH);
68+
await main(tmpdir);
69+
70+
const result = JSON.parse(
71+
fs.readFileSync(path.join(frDir, "translation.json"), "utf-8"),
72+
);
73+
74+
// after sync the french file should reflect english structure
75+
expect(result).toEqual({
76+
a: {
77+
y: "ancien", // original translation preserved
78+
z: "nested-z", // new key added from English
79+
},
80+
b: "second", // new key added from English
81+
c: "troisieme", // existing translation untouched
82+
});
83+
84+
// verify keys are sorted at each level
85+
const keys = Object.keys(result);
86+
expect(keys).toEqual(["a", "b", "c"]);
87+
expect(Object.keys(result.a)).toEqual(["y", "z"]);
88+
});
89+
90+
it("ignores directories without translation.json", async () => {
91+
const localesDir = path.join(tmpdir, "assets", "locales");
92+
fs.mkdirSync(path.join(localesDir, "en"), { recursive: true });
93+
fs.writeFileSync(path.join(localesDir, "en", "translation.json"), "{}");
94+
fs.mkdirSync(path.join(localesDir, "es")); // no translation.json
95+
96+
// should not throw
97+
const { main } = await import(SCRIPT_PATH);
98+
await main(tmpdir);
99+
});
100+
101+
it("treats base key and variants as a group when adding", async () => {
102+
const localesDir = path.join(tmpdir, "assets", "locales");
103+
fs.mkdirSync(localesDir, { recursive: true });
104+
105+
const enDir = path.join(localesDir, "en");
106+
const frDir = path.join(localesDir, "fr");
107+
fs.mkdirSync(enDir, { recursive: true });
108+
fs.mkdirSync(frDir, { recursive: true });
109+
110+
const enData = {
111+
spawn: "to spawn",
112+
spawn_gerund: "spawning",
113+
other: "value",
114+
};
115+
fs.writeFileSync(
116+
path.join(enDir, "translation.json"),
117+
JSON.stringify(enData, null, 2),
118+
);
119+
120+
// french file already has the variant but not the base
121+
const frData = {
122+
spawn_gerund: "exist",
123+
};
124+
fs.writeFileSync(
125+
path.join(frDir, "translation.json"),
126+
JSON.stringify(frData, null, 2),
127+
);
128+
129+
const { main } = await import(SCRIPT_PATH);
130+
await main(tmpdir);
131+
132+
const result = JSON.parse(
133+
fs.readFileSync(path.join(frDir, "translation.json"), "utf-8"),
134+
);
135+
// since the variant existed, the base should NOT have been added
136+
expect(result).toEqual({
137+
spawn_gerund: "exist",
138+
other: "value",
139+
});
140+
});
141+
142+
it("removes group when base is deleted from English", async () => {
143+
const localesDir = path.join(tmpdir, "assets", "locales");
144+
fs.mkdirSync(localesDir, { recursive: true });
145+
const enDir = path.join(localesDir, "en");
146+
const frDir = path.join(localesDir, "fr");
147+
fs.mkdirSync(enDir, { recursive: true });
148+
fs.mkdirSync(frDir, { recursive: true });
149+
150+
// english has no spawn keys at all
151+
fs.writeFileSync(
152+
path.join(enDir, "translation.json"),
153+
JSON.stringify({ other: "x" }, null, 2),
154+
);
155+
156+
const frData = {
157+
spawn: "foo",
158+
spawn_gerund: "bar",
159+
other: "baz",
160+
};
161+
fs.writeFileSync(
162+
path.join(frDir, "translation.json"),
163+
JSON.stringify(frData, null, 2),
164+
);
165+
166+
const { main } = await import(SCRIPT_PATH);
167+
await main(tmpdir);
168+
169+
const result = JSON.parse(
170+
fs.readFileSync(path.join(frDir, "translation.json"), "utf-8"),
171+
);
172+
expect(result).toEqual({ other: "baz" });
173+
});
174+
});

0 commit comments

Comments
 (0)