| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Website/docs General Translation pipeline. |
| 4 | * |
| 5 | * Runtime authority stays web/lib/i18n/dictionaries (one logical path). |
| 6 | * This script exports those dictionaries to web/gt-catalog/[locale].json |
| 7 | * so the MIT `gt` CLI can translate updated English copy, then imports |
| 8 | * reviewed JSON back into the same dictionary files. |
| 9 | * |
| 10 | * Not for the TUI. Not for model completions. Not a /translate replacement. |
| 11 | * Never call `gt generate` here — that scanner is framework-only and would |
| 12 | * look for <T> JSX this site does not use. |
| 13 | */ |
| 14 | import { spawnSync } from "node:child_process"; |
| 15 | import { createHash } from "node:crypto"; |
| 16 | import { existsSync } from "node:fs"; |
| 17 | import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; |
| 18 | import path from "node:path"; |
| 19 | import { fileURLToPath } from "node:url"; |
| 20 | |
| 21 | const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 22 | const repoRoot = path.resolve(webRoot, ".."); |
| 23 | const configPath = path.join(webRoot, "gt.config.json"); |
| 24 | const catalogDir = path.join(webRoot, "gt-catalog"); |
| 25 | const dictDir = path.join(webRoot, "lib", "i18n", "dictionaries"); |
| 26 | const pinnedCli = "2.17.2"; |
| 27 | const tuiLocaleDir = path.join(repoRoot, "crates", "tui", "locales"); |
| 28 | const promptPath = path.join(repoRoot, "crates", "tui", "src", "prompts", "text.rs"); |
| 29 | |
| 30 | const STEMS = { |
| 31 | chrome: { exportName: "chrome", typeName: "ChromeDict" }, |
| 32 | home: { exportName: "home", typeName: "HomeDict" }, |
| 33 | "docs-guide": { exportName: "docsGuide", typeName: "DocsGuideDict" }, |
| 34 | "docs-shell": { exportName: "docsShell", typeName: "DocsShellDict" }, |
| 35 | "docs-hooks": { exportName: "docsHooks", typeName: "DocsHooksDict" }, |
| 36 | "docs-troubleshooting": { exportName: "docsTroubleshooting", typeName: "DocsTroubleshootingDict" }, |
| 37 | "docs-configuration": { exportName: "docsConfiguration", typeName: "DocsConfigurationDict" }, |
| 38 | "docs-constitution": { exportName: "docsConstitution", typeName: "DocsConstitutionDict" }, |
| 39 | "docs-fleet": { exportName: "docsFleet", typeName: "DocsFleetDict" }, |
| 40 | "docs-mcp": { exportName: "docsMcp", typeName: "DocsMcpDict" }, |
| 41 | "docs-modes": { exportName: "docsModes", typeName: "DocsModesDict" }, |
| 42 | "docs-runtime-api": { exportName: "docsRuntimeApi", typeName: "DocsRuntimeApiDict" }, |
| 43 | "docs-sandbox": { exportName: "docsSandbox", typeName: "DocsSandboxDict" }, |
| 44 | "docs-subagents": { exportName: "docsSubagents", typeName: "DocsSubagentsDict" }, |
| 45 | "docs-web": { exportName: "docsWeb", typeName: "DocsWebDict" }, |
| 46 | "docs-computers": { exportName: "docsComputers", typeName: "DocsComputersDict" }, |
| 47 | "docs-auth": { exportName: "docsAuth", typeName: "DocsAuthDict" }, |
| 48 | "docs-trust": { exportName: "docsTrust", typeName: "DocsTrustDict" }, |
| 49 | states: { exportName: "states", typeName: "StatesDict" }, |
| 50 | changelog: { exportName: "changelog", typeName: "ChangelogDict" }, |
| 51 | "computer-use": { exportName: "computerUse", typeName: "ComputerUseDict" }, |
| 52 | }; |
| 53 | |
| 54 | const REQUIRED_STEMS = ["chrome", "home"]; |
| 55 | |
| 56 | function fail(message) { |
| 57 | throw new Error(message); |
| 58 | } |
| 59 | |
| 60 | function assertExactKeys(value, expected, label) { |
| 61 | if (!value || typeof value !== "object" || Array.isArray(value)) { |
| 62 | fail(`${label} must be an object`); |
| 63 | } |
| 64 | const actual = Object.keys(value).sort(); |
| 65 | const wanted = [...expected].sort(); |
| 66 | if (JSON.stringify(actual) !== JSON.stringify(wanted)) { |
| 67 | fail(`${label} keys must be exactly: ${wanted.join(", ")} (found: ${actual.join(", ")})`); |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | function stableStringify(value) { |
| 72 | return `${JSON.stringify(value, null, 2)}\n`; |
| 73 | } |
| 74 | |
| 75 | function deepEqual(left, right) { |
| 76 | return JSON.stringify(left) === JSON.stringify(right); |
| 77 | } |
| 78 | |
| 79 | async function readConfig() { |
| 80 | const config = JSON.parse(await readFile(configPath, "utf8")); |
| 81 | assertExactKeys(config, ["defaultLocale", "locales", "files"], "gt.config.json"); |
| 82 | assertExactKeys(config.files, ["json"], "gt.config.json files"); |
| 83 | assertExactKeys(config.files.json, ["include"], "gt.config.json files.json"); |
| 84 | if (config.defaultLocale !== "en") fail("defaultLocale must remain en"); |
| 85 | if ( |
| 86 | !Array.isArray(config.locales) || |
| 87 | config.locales.length === 0 || |
| 88 | !config.locales.every((locale) => typeof locale === "string" && locale.length > 0) || |
| 89 | new Set(config.locales).size !== config.locales.length |
| 90 | ) { |
| 91 | fail("locales must be a non-empty, duplicate-free string array"); |
| 92 | } |
| 93 | if (config.locales.includes("en")) fail("locales must not include the default locale"); |
| 94 | const include = config.files.json.include; |
| 95 | if (!Array.isArray(include) || include.length !== 1 || include[0] !== "gt-catalog/[locale].json") { |
| 96 | fail("the JSON source must remain web/gt-catalog/[locale].json"); |
| 97 | } |
| 98 | for (const locale of config.locales) { |
| 99 | if (locale.includes("..") || locale.includes("/") || locale.includes("\\")) { |
| 100 | fail(`locale ${locale} is not a safe catalog name`); |
| 101 | } |
| 102 | } |
| 103 | return config; |
| 104 | } |
| 105 | |
| 106 | async function assertWebsiteOnlyAsync() { |
| 107 | const source = await readFile(configPath, "utf8"); |
| 108 | if (source.includes("crates/tui") || source.includes("prompts/text.rs")) { |
| 109 | fail("gt.config.json must not mention TUI paths"); |
| 110 | } |
| 111 | if (!source.includes("gt-catalog/[locale].json")) { |
| 112 | fail("gt.config.json must target the website catalog only"); |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | async function loadDictionaryModule(locale, stem) { |
| 117 | const spec = STEMS[stem]; |
| 118 | if (!spec) fail(`unknown dictionary stem ${stem}`); |
| 119 | const filePath = path.join(dictDir, locale, `${stem}.ts`); |
| 120 | if (!existsSync(filePath)) return null; |
| 121 | const ts = await import("typescript"); |
| 122 | const source = await readFile(filePath, "utf8"); |
| 123 | const { outputText } = ts.transpileModule(source, { |
| 124 | compilerOptions: { |
| 125 | module: ts.ModuleKind.ESNext, |
| 126 | target: ts.ScriptTarget.ES2022, |
| 127 | }, |
| 128 | fileName: filePath, |
| 129 | }); |
| 130 | const mod = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`); |
| 131 | const value = mod[spec.exportName]; |
| 132 | if (value == null) fail(`missing export ${spec.exportName} in ${filePath}`); |
| 133 | return value; |
| 134 | } |
| 135 | |
| 136 | async function loadCatalogFromDictionaries(locale) { |
| 137 | const catalog = {}; |
| 138 | for (const stem of Object.keys(STEMS)) { |
| 139 | const value = await loadDictionaryModule(locale, stem); |
| 140 | if (value == null) { |
| 141 | if (REQUIRED_STEMS.includes(stem) && locale === "en") { |
| 142 | fail(`English is missing required dictionary ${stem}.ts`); |
| 143 | } |
| 144 | continue; |
| 145 | } |
| 146 | catalog[stem] = value; |
| 147 | } |
| 148 | return catalog; |
| 149 | } |
| 150 | |
| 151 | async function readCatalogFile(locale) { |
| 152 | const filePath = path.join(catalogDir, `${locale}.json`); |
| 153 | const source = await readFile(filePath, "utf8"); |
| 154 | let parsed; |
| 155 | try { |
| 156 | parsed = JSON.parse(source); |
| 157 | } catch (error) { |
| 158 | fail(`${filePath} is not valid JSON: ${error.message}`); |
| 159 | } |
| 160 | if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { |
| 161 | fail(`${filePath} must be a JSON object of dictionary stems`); |
| 162 | } |
| 163 | for (const stem of Object.keys(parsed)) { |
| 164 | if (!STEMS[stem]) fail(`${filePath} contains unknown stem ${stem}`); |
| 165 | } |
| 166 | for (const stem of REQUIRED_STEMS) { |
| 167 | if (!parsed[stem]) fail(`${filePath} is missing required stem ${stem}`); |
| 168 | } |
| 169 | return parsed; |
| 170 | } |
| 171 | |
| 172 | async function writeCatalogFile(locale, catalog) { |
| 173 | await mkdir(catalogDir, { recursive: true }); |
| 174 | const filePath = path.join(catalogDir, `${locale}.json`); |
| 175 | await writeFile(filePath, stableStringify(catalog)); |
| 176 | return filePath; |
| 177 | } |
| 178 | |
| 179 | function emitTsValue(value, indent) { |
| 180 | const pad = " ".repeat(indent); |
| 181 | if (typeof value === "string") return JSON.stringify(value); |
| 182 | if (Array.isArray(value)) { |
| 183 | if (value.every((row) => Array.isArray(row) && row.every((cell) => typeof cell === "string"))) { |
| 184 | const rows = value.map((row) => `${pad} [${row.map((cell) => JSON.stringify(cell)).join(", ")}]`); |
| 185 | return `[\n${rows.join(",\n")},\n${pad}]`; |
| 186 | } |
| 187 | fail("catalog arrays must be [string, string] rows"); |
| 188 | } |
| 189 | if (value && typeof value === "object") { |
| 190 | const keys = Object.keys(value); |
| 191 | const lines = keys.map((key) => `${pad} ${key}: ${emitTsValue(value[key], indent + 2)},`); |
| 192 | return `{\n${lines.join("\n")}\n${pad}}`; |
| 193 | } |
| 194 | fail(`unsupported catalog value type ${typeof value}`); |
| 195 | } |
| 196 | |
| 197 | async function writeDictionaryFile(locale, stem, value) { |
| 198 | const spec = STEMS[stem]; |
| 199 | const filePath = path.join(dictDir, locale, `${stem}.ts`); |
| 200 | await mkdir(path.dirname(filePath), { recursive: true }); |
| 201 | const body = `import type { ${spec.typeName} } from "../types"; |
| 202 | |
| 203 | /** |
| 204 | * Website dictionary for locale \`${locale}\`. |
| 205 | * Runtime authority is this file (web/lib/i18n/dictionaries). |
| 206 | * Source/sink for General Translation is web/gt-catalog/${locale}.json. |
| 207 | */ |
| 208 | export const ${spec.exportName}: ${spec.typeName} = ${emitTsValue(value, 0)}; |
| 209 | `; |
| 210 | await writeFile(filePath, body); |
| 211 | return filePath; |
| 212 | } |
| 213 | |
| 214 | async function check() { |
| 215 | const config = await readConfig(); |
| 216 | await assertWebsiteOnlyAsync(); |
| 217 | if (existsSync(tuiLocaleDir)) { |
| 218 | const tuiPacks = (await readdir(tuiLocaleDir)).filter((name) => name.endsWith(".json")); |
| 219 | if (tuiPacks.length === 0) fail("TUI locale directory exists but is empty — refusing to proceed"); |
| 220 | } |
| 221 | if (existsSync(promptPath)) { |
| 222 | const digest = createHash("sha256").update(await readFile(promptPath)).digest("hex"); |
| 223 | if (digest.length !== 64) fail("could not fingerprint prompts/text.rs"); |
| 224 | } |
| 225 | |
| 226 | const locales = [config.defaultLocale, ...config.locales]; |
| 227 | for (const locale of locales) { |
| 228 | const fromDict = await loadCatalogFromDictionaries(locale); |
| 229 | const fromFile = await readCatalogFile(locale); |
| 230 | if (!deepEqual(fromDict, fromFile)) { |
| 231 | fail( |
| 232 | `gt-catalog/${locale}.json is out of sync with web/lib/i18n/dictionaries/${locale}/ — run npm run i18n:gt -- export`, |
| 233 | ); |
| 234 | } |
| 235 | for (const stem of Object.keys(fromFile)) { |
| 236 | if (REQUIRED_STEMS.includes(stem)) continue; |
| 237 | const enValue = (await loadCatalogFromDictionaries("en"))[stem]; |
| 238 | if (enValue && !deepEqual(Object.keys(fromFile[stem]).sort(), Object.keys(enValue).sort())) { |
| 239 | fail(`gt-catalog/${locale}.json stem ${stem} does not have English key parity`); |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | console.log( |
| 244 | `Website GT catalog OK — ${config.locales.join(", ")} (local JSON; TUI packs untouched; no API)`, |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | async function exportCatalogs() { |
| 249 | const config = await readConfig(); |
| 250 | const locales = [config.defaultLocale, ...config.locales]; |
| 251 | const written = []; |
| 252 | for (const locale of locales) { |
| 253 | const catalog = await loadCatalogFromDictionaries(locale); |
| 254 | written.push(await writeCatalogFile(locale, catalog)); |
| 255 | } |
| 256 | console.log(`Exported ${written.length} website catalogs:\n${written.map((file) => ` ${path.relative(webRoot, file)}`).join("\n")}`); |
| 257 | } |
| 258 | |
| 259 | async function importCatalogs() { |
| 260 | const config = await readConfig(); |
| 261 | const written = []; |
| 262 | for (const locale of config.locales) { |
| 263 | const catalog = await readCatalogFile(locale); |
| 264 | for (const [stem, value] of Object.entries(catalog)) { |
| 265 | written.push(await writeDictionaryFile(locale, stem, value)); |
| 266 | } |
| 267 | } |
| 268 | console.log( |
| 269 | `Imported ${written.length} website dictionaries from gt-catalog (TUI packs not written):\n${written |
| 270 | .map((file) => ` ${path.relative(webRoot, file)}`) |
| 271 | .join("\n")}`, |
| 272 | ); |
| 273 | } |
| 274 | |
| 275 | async function validatePinnedCli() { |
| 276 | const packagePath = path.join(webRoot, "node_modules", "gt", "package.json"); |
| 277 | let packageJson; |
| 278 | try { |
| 279 | packageJson = JSON.parse(await readFile(packagePath, "utf8")); |
| 280 | } catch { |
| 281 | fail("gt is not installed locally; run npm ci in web/ (this wrapper never downloads packages)"); |
| 282 | } |
| 283 | if (packageJson.version !== pinnedCli) { |
| 284 | fail(`expected the locked gt ${pinnedCli} package (found ${packageJson.version})`); |
| 285 | } |
| 286 | const bin = packageJson.bin; |
| 287 | const binPath = typeof bin === "string" ? bin : bin.gt; |
| 288 | if (!binPath) fail("gt package.json is missing a bin"); |
| 289 | return path.join(path.dirname(packagePath), binPath); |
| 290 | } |
| 291 | |
| 292 | function translateEnvironment() { |
| 293 | const key = process.env.GT_API_KEY?.trim(); |
| 294 | const project = process.env.GT_PROJECT_ID?.trim(); |
| 295 | if (!key || !project) { |
| 296 | fail( |
| 297 | "gt translate is fail-closed. Set BYOK env GT_API_KEY and GT_PROJECT_ID (never commit them). Local export/import/check do not need a key.", |
| 298 | ); |
| 299 | } |
| 300 | const environment = { ...process.env }; |
| 301 | environment.NO_COLOR = "1"; |
| 302 | environment.GT_API_KEY = key; |
| 303 | environment.GT_PROJECT_ID = project; |
| 304 | return environment; |
| 305 | } |
| 306 | |
| 307 | async function translate() { |
| 308 | await readConfig(); |
| 309 | await assertWebsiteOnlyAsync(); |
| 310 | const environment = translateEnvironment(); |
| 311 | const cli = await validatePinnedCli(); |
| 312 | const result = spawnSync( |
| 313 | process.execPath, |
| 314 | [cli, "--skip-version-check", "translate", "--config", "gt.config.json"], |
| 315 | { |
| 316 | cwd: webRoot, |
| 317 | env: environment, |
| 318 | stdio: "inherit", |
| 319 | windowsHide: true, |
| 320 | }, |
| 321 | ); |
| 322 | if (result.error) fail(result.error.message); |
| 323 | if (result.status !== 0) fail(`gt translate exited with status ${result.status ?? "unknown"}`); |
| 324 | console.log("gt translate finished — review gt-catalog/*.json then run npm run i18n:gt -- import"); |
| 325 | } |
| 326 | |
| 327 | function usage() { |
| 328 | console.log(`Usage: node scripts/gt-site.mjs <check|export|import|translate> |
| 329 | |
| 330 | check catalogs match dictionaries; website-only schema |
| 331 | export dictionaries → gt-catalog/[locale].json (no API) |
| 332 | import gt-catalog → website dictionary TS only (no TUI writes) |
| 333 | translate fail-closed without GT_API_KEY + GT_PROJECT_ID |
| 334 | |
| 335 | Never wraps inference. Never edits crates/tui/src/prompts/text.rs.`); |
| 336 | } |
| 337 | |
| 338 | const command = process.argv[2] ?? "check"; |
| 339 | |
| 340 | try { |
| 341 | if (command === "check") await check(); |
| 342 | else if (command === "export") await exportCatalogs(); |
| 343 | else if (command === "import") await importCatalogs(); |
| 344 | else if (command === "translate") await translate(); |
| 345 | else if (command === "-h" || command === "--help") usage(); |
| 346 | else { |
| 347 | usage(); |
| 348 | fail(`unknown command ${command}`); |
| 349 | } |
| 350 | } catch (error) { |
| 351 | console.error(`[gt-site] FAIL — ${error.message}`); |
| 352 | process.exitCode = 1; |
| 353 | } |
| 354 |