返回 CodeWhale
gt-site.mjs
根目录 / web / scripts / gt-site.mjs
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
354 lines Plain Text