|
| 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 | +} |
0 commit comments