scribe-cms 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +2 -3
  2. package/bin/scribe.js +2 -11
  3. package/dist/cli/index.cjs +1284 -171
  4. package/dist/cli/index.cjs.map +1 -1
  5. package/dist/cli/index.js +1284 -171
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/prompt-translate.d.ts +16 -0
  8. package/dist/cli/prompt-translate.d.ts.map +1 -0
  9. package/dist/cli/translate-progress.d.ts +11 -0
  10. package/dist/cli/translate-progress.d.ts.map +1 -0
  11. package/dist/index.cjs +479 -101
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.js +475 -102
  14. package/dist/index.js.map +1 -1
  15. package/dist/runtime.cjs +12 -2
  16. package/dist/runtime.cjs.map +1 -1
  17. package/dist/runtime.js +12 -2
  18. package/dist/runtime.js.map +1 -1
  19. package/dist/src/core/field.d.ts +2 -0
  20. package/dist/src/core/field.d.ts.map +1 -1
  21. package/dist/src/core/types.d.ts +1 -1
  22. package/dist/src/core/types.d.ts.map +1 -1
  23. package/dist/src/export/build-static-raw-exports.d.ts +37 -0
  24. package/dist/src/export/build-static-raw-exports.d.ts.map +1 -0
  25. package/dist/src/export/write-static-raw-exports.d.ts +12 -0
  26. package/dist/src/export/write-static-raw-exports.d.ts.map +1 -0
  27. package/dist/src/history/record-revision.d.ts +2 -0
  28. package/dist/src/history/record-revision.d.ts.map +1 -1
  29. package/dist/src/index.d.ts +7 -0
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/storage/sqlite.d.ts.map +1 -1
  32. package/dist/src/storage/translations.d.ts +6 -0
  33. package/dist/src/storage/translations.d.ts.map +1 -1
  34. package/dist/src/translate/gemini-client.d.ts +7 -0
  35. package/dist/src/translate/gemini-client.d.ts.map +1 -1
  36. package/dist/src/translate/gemini-models.d.ts +8 -0
  37. package/dist/src/translate/gemini-models.d.ts.map +1 -0
  38. package/dist/src/translate/gemini-pricing.d.ts +11 -0
  39. package/dist/src/translate/gemini-pricing.d.ts.map +1 -0
  40. package/dist/src/translate/page-translator.d.ts +38 -1
  41. package/dist/src/translate/page-translator.d.ts.map +1 -1
  42. package/dist/src/translate/prompts/translation-prompt.d.ts +1 -2
  43. package/dist/src/translate/prompts/translation-prompt.d.ts.map +1 -1
  44. package/dist/src/translate/response-schema.d.ts +7 -0
  45. package/dist/src/translate/response-schema.d.ts.map +1 -0
  46. package/dist/src/translate/validate-translation.d.ts +14 -0
  47. package/dist/src/translate/validate-translation.d.ts.map +1 -0
  48. package/dist/src/translate/worklist.d.ts +3 -0
  49. package/dist/src/translate/worklist.d.ts.map +1 -1
  50. package/dist/src/validate/validate-project.d.ts.map +1 -1
  51. package/dist/studio/server.cjs +611 -194
  52. package/dist/studio/server.cjs.map +1 -1
  53. package/dist/studio/server.d.ts +1 -1
  54. package/dist/studio/server.d.ts.map +1 -1
  55. package/dist/studio/server.js +611 -194
  56. package/dist/studio/server.js.map +1 -1
  57. package/package.json +16 -5
  58. package/bin/ensure-built.mjs +0 -31
@@ -12,6 +12,7 @@ var genai = require('@google/genai');
12
12
  var dotenv = require('dotenv');
13
13
  var nodeServer = require('@hono/node-server');
14
14
  var hono = require('hono');
15
+ var prompts = require('@inquirer/prompts');
15
16
 
16
17
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
17
18
 
@@ -23,6 +24,7 @@ var dotenv__default = /*#__PURE__*/_interopDefault(dotenv);
23
24
 
24
25
  var __defProp = Object.defineProperty;
25
26
  var __getOwnPropNames = Object.getOwnPropertyNames;
27
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
26
28
  var __esm = (fn, res) => function __init() {
27
29
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
28
30
  };
@@ -30,6 +32,7 @@ var __export = (target, all) => {
30
32
  for (var name in all)
31
33
  __defProp(target, name, { get: all[name], enumerable: true });
32
34
  };
35
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
33
36
 
34
37
  // ../../node_modules/tsup/assets/cjs_shims.js
35
38
  var getImportMetaUrl, importMetaUrl;
@@ -52,17 +55,27 @@ function resolveStorePath(config) {
52
55
  }
53
56
  function openStore(config, mode = "readwrite") {
54
57
  const storePath = resolveStorePath(config);
55
- fs2__default.default.mkdirSync(path3__default.default.dirname(storePath), { recursive: true });
58
+ if (mode === "readwrite") {
59
+ fs2__default.default.mkdirSync(path3__default.default.dirname(storePath), { recursive: true });
60
+ }
56
61
  const db = new Database__default.default(storePath, { readonly: mode === "readonly" });
57
62
  if (mode === "readwrite") {
58
63
  migrate(db);
59
64
  }
60
65
  return db;
61
66
  }
67
+ function addColumnIfMissing(db, table, column, ddlType) {
68
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
69
+ if (!columns.some((c) => c.name === column)) {
70
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
71
+ }
72
+ }
62
73
  function migrate(db) {
63
74
  for (const sql of MIGRATIONS) {
64
75
  db.exec(sql);
65
76
  }
77
+ addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
78
+ addColumnIfMissing(db, "revisions", "body", "TEXT");
66
79
  db.prepare(
67
80
  `INSERT INTO meta(key, value) VALUES('schema_version', ?)
68
81
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
@@ -75,7 +88,7 @@ var SCHEMA_VERSION, MIGRATIONS;
75
88
  var init_sqlite = __esm({
76
89
  "src/storage/sqlite.ts"() {
77
90
  init_cjs_shims();
78
- SCHEMA_VERSION = 2;
91
+ SCHEMA_VERSION = 3;
79
92
  MIGRATIONS = [
80
93
  `CREATE TABLE IF NOT EXISTS meta (
81
94
  key TEXT PRIMARY KEY,
@@ -187,6 +200,22 @@ function unwrapSchema(schema) {
187
200
  }
188
201
  return current;
189
202
  }
203
+ function peelOptionalWrappers(schema) {
204
+ let current = schema;
205
+ for (let i = 0; i < 8; i++) {
206
+ const type = current._def?.type;
207
+ if (type === "optional" || type === "nullable") {
208
+ current = current.unwrap();
209
+ continue;
210
+ }
211
+ if (type === "default") {
212
+ current = current.removeDefault();
213
+ continue;
214
+ }
215
+ break;
216
+ }
217
+ return current;
218
+ }
190
219
 
191
220
  // src/core/types.ts
192
221
  init_cjs_shims();
@@ -203,14 +232,14 @@ var SLUG_PLACEHOLDER = "{slug}";
203
232
  function isRoutableType(type) {
204
233
  return typeof type.path === "string" && type.path.length > 0;
205
234
  }
206
- function assertValidPathTemplate(path10, typeId) {
207
- if (!path10.startsWith("/")) {
208
- throw new Error(`Content type "${typeId}": path must start with / (got "${path10}")`);
235
+ function assertValidPathTemplate(path11, typeId) {
236
+ if (!path11.startsWith("/")) {
237
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path11}")`);
209
238
  }
210
- const count = (path10.match(/\{slug\}/g) ?? []).length;
239
+ const count = (path11.match(/\{slug\}/g) ?? []).length;
211
240
  if (count !== 1) {
212
241
  throw new Error(
213
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path10}")`
242
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path11}")`
214
243
  );
215
244
  }
216
245
  }
@@ -338,10 +367,10 @@ function introspectSchema(schema, prefix = []) {
338
367
  }
339
368
  function extractByPaths(data, paths) {
340
369
  const out = {};
341
- for (const path10 of paths) {
342
- const value = getAtPath(data, path10);
370
+ for (const path11 of paths) {
371
+ const value = getAtPath(data, path11);
343
372
  if (value !== void 0) {
344
- setAtPath(out, path10.filter((p) => p !== "*"), value);
373
+ setAtPath(out, path11.filter((p) => p !== "*"), value);
345
374
  }
346
375
  }
347
376
  return out;
@@ -359,13 +388,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
359
388
  const translatable = pickTranslatable(localeData, schema);
360
389
  return deepMerge(structural, translatable);
361
390
  }
362
- function getAtPath(obj, path10) {
391
+ function getAtPath(obj, path11) {
363
392
  let current = obj;
364
- for (const segment of path10) {
393
+ for (const segment of path11) {
365
394
  if (segment === "*") {
366
395
  if (!Array.isArray(current)) return void 0;
367
396
  return current.map(
368
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path10.slice(path10.indexOf("*") + 1)) : void 0
397
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path11.slice(path11.indexOf("*") + 1)) : void 0
369
398
  );
370
399
  }
371
400
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -373,13 +402,13 @@ function getAtPath(obj, path10) {
373
402
  }
374
403
  return current;
375
404
  }
376
- function setAtPath(obj, path10, value) {
377
- if (path10.length === 0) return;
378
- if (path10.length === 1) {
379
- obj[path10[0]] = value;
405
+ function setAtPath(obj, path11, value) {
406
+ if (path11.length === 0) return;
407
+ if (path11.length === 1) {
408
+ obj[path11[0]] = value;
380
409
  return;
381
410
  }
382
- const [head, ...rest] = path10;
411
+ const [head, ...rest] = path11;
383
412
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
384
413
  obj[head] = {};
385
414
  }
@@ -618,14 +647,18 @@ function listTranslationsForEnSlug(db, contentType, enSlug) {
618
647
  `SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
619
648
  ).all(contentType, enSlug);
620
649
  }
650
+ function listTranslationsForLocale(db, contentType, locale) {
651
+ return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
652
+ }
621
653
  function bulkLoadTranslations(db) {
622
654
  return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
623
655
  }
624
656
  function appendRevision(db, input) {
625
657
  const result = db.prepare(
626
658
  `INSERT INTO revisions (
627
- content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at, model, body_preview
628
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
659
+ content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
660
+ model, body_preview, frontmatter_json, body
661
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
629
662
  ).run(
630
663
  input.contentType,
631
664
  input.enSlug,
@@ -635,7 +668,9 @@ function appendRevision(db, input) {
635
668
  input.bodyHash,
636
669
  input.createdAt,
637
670
  input.model ?? null,
638
- input.bodyPreview ?? null
671
+ input.bodyPreview ?? null,
672
+ input.frontmatterJson ?? null,
673
+ input.body ?? null
639
674
  );
640
675
  return Number(result.lastInsertRowid);
641
676
  }
@@ -1473,7 +1508,9 @@ function recordRevision(config, input) {
1473
1508
  bodyHash: computeBodyHash(input.body),
1474
1509
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1475
1510
  model: input.model,
1476
- bodyPreview: input.body.slice(0, 200)
1511
+ bodyPreview: input.body.slice(0, 200),
1512
+ frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
1513
+ body: input.body
1477
1514
  });
1478
1515
  db.close();
1479
1516
  return id;
@@ -1664,7 +1701,8 @@ function validateProject(config) {
1664
1701
  locale,
1665
1702
  revisionKind: "en_edit_detected",
1666
1703
  enHash: currentEnHash,
1667
- body: row.body
1704
+ body: row.body,
1705
+ frontmatter: JSON.parse(row.frontmatter_json)
1668
1706
  });
1669
1707
  }
1670
1708
  const localeFm = JSON.parse(row.frontmatter_json);
@@ -1793,6 +1831,10 @@ function buildWorklist(config, options = {}) {
1793
1831
  }
1794
1832
  }
1795
1833
  db.close();
1834
+ const strategy = options.strategy ?? "all";
1835
+ if (strategy === "missing-only") {
1836
+ return items.filter((item) => item.reason === "missing");
1837
+ }
1796
1838
  return items;
1797
1839
  }
1798
1840
  function resolveLocalesFromPreset(config, preset, explicitLocales) {
@@ -1809,7 +1851,31 @@ init_sqlite();
1809
1851
 
1810
1852
  // src/translate/gemini-client.ts
1811
1853
  init_cjs_shims();
1812
- var DEFAULT_MODEL = "gemini-2.5-pro";
1854
+
1855
+ // src/translate/gemini-models.ts
1856
+ init_cjs_shims();
1857
+ var GEMINI_MODEL_IDS = {
1858
+ "gemini-2.5-pro": "gemini-2.5-pro",
1859
+ "gemini-3.1-pro": "gemini-3.1-pro-preview"
1860
+ };
1861
+ var DEFAULT_GEMINI_MODEL = "gemini-3.1-pro";
1862
+ function stripModelsPrefix(model) {
1863
+ return model.replace(/^models\//, "");
1864
+ }
1865
+ function resolveGeminiModelId(model) {
1866
+ const name = stripModelsPrefix(model);
1867
+ return GEMINI_MODEL_IDS[name] ?? name;
1868
+ }
1869
+ function normalizeGeminiDisplayName(model) {
1870
+ const name = stripModelsPrefix(model);
1871
+ if (name in GEMINI_MODEL_IDS) return name;
1872
+ const alias = Object.entries(GEMINI_MODEL_IDS).find(([, apiId]) => apiId === name);
1873
+ if (alias) return alias[0];
1874
+ return name;
1875
+ }
1876
+
1877
+ // src/translate/gemini-client.ts
1878
+ var DEFAULT_MODEL = DEFAULT_GEMINI_MODEL;
1813
1879
  function extractJson(text) {
1814
1880
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
1815
1881
  if (fenced?.[1]) return fenced[1].trim();
@@ -1823,13 +1889,17 @@ async function translatePageWithGemini(input) {
1823
1889
  if (!apiKey) {
1824
1890
  throw new Error("GEMINI_API_KEY is required for scribe translate");
1825
1891
  }
1826
- const model = input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL;
1892
+ const displayModel = normalizeGeminiDisplayName(
1893
+ input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL
1894
+ );
1895
+ const apiModel = resolveGeminiModelId(displayModel);
1827
1896
  const ai = new genai.GoogleGenAI({ apiKey });
1828
1897
  const response = await ai.models.generateContent({
1829
- model,
1898
+ model: apiModel,
1830
1899
  contents: input.prompt,
1831
1900
  config: {
1832
- responseMimeType: "application/json"
1901
+ responseMimeType: "application/json",
1902
+ ...input.responseSchema ? { responseSchema: input.responseSchema } : {}
1833
1903
  }
1834
1904
  });
1835
1905
  const raw = response.text ?? "";
@@ -1837,7 +1907,50 @@ async function translatePageWithGemini(input) {
1837
1907
  if (!parsed.frontmatter || typeof parsed.body !== "string") {
1838
1908
  throw new Error("Gemini response missing frontmatter/body");
1839
1909
  }
1840
- return { model, raw, parsed };
1910
+ const usageMetadata = response.usageMetadata;
1911
+ const usage = {
1912
+ inputTokens: usageMetadata?.promptTokenCount ?? 0,
1913
+ outputTokens: usageMetadata?.candidatesTokenCount ?? 0,
1914
+ totalTokens: usageMetadata?.totalTokenCount ?? 0
1915
+ };
1916
+ return { model: displayModel, raw, parsed, usage };
1917
+ }
1918
+
1919
+ // src/translate/gemini-pricing.ts
1920
+ init_cjs_shims();
1921
+ var CONTEXT_TIER_TOKENS = 2e5;
1922
+ var GEMINI_PRICING_USD = {
1923
+ "gemini-3.1-pro": { input: [2, 4], output: [12, 18] }
1924
+ };
1925
+ function normalizeModelId(model) {
1926
+ return model.replace(/^models\//, "").toLowerCase();
1927
+ }
1928
+ function tierRate(tokens, tiers) {
1929
+ return tokens <= CONTEXT_TIER_TOKENS ? tiers[0] : tiers[1];
1930
+ }
1931
+ function resolveModelPricing(model) {
1932
+ const id = normalizeModelId(model);
1933
+ if (GEMINI_PRICING_USD[id]) return GEMINI_PRICING_USD[id];
1934
+ const match = Object.entries(GEMINI_PRICING_USD).find(([key]) => id.includes(key));
1935
+ return match?.[1];
1936
+ }
1937
+ function estimateTranslationCostUsd(model, inputTokens, outputTokens) {
1938
+ const pricing = resolveModelPricing(model);
1939
+ if (!pricing) return void 0;
1940
+ const inputRate = tierRate(inputTokens, pricing.input);
1941
+ const outputRate = tierRate(outputTokens, pricing.output);
1942
+ return inputTokens / 1e6 * inputRate + outputTokens / 1e6 * outputRate;
1943
+ }
1944
+ function formatTokenCount(tokens) {
1945
+ if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(2)}M`;
1946
+ if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`;
1947
+ return String(tokens);
1948
+ }
1949
+ function formatUsd(amount) {
1950
+ if (amount === void 0) return "n/a";
1951
+ if (amount >= 1) return `$${amount.toFixed(2)}`;
1952
+ if (amount >= 0.01) return `$${amount.toFixed(3)}`;
1953
+ return `$${amount.toFixed(4)}`;
1841
1954
  }
1842
1955
 
1843
1956
  // src/translate/prompts/translation-prompt.ts
@@ -1874,6 +1987,7 @@ function buildPageTranslationPrompt(input) {
1874
1987
  prompt,
1875
1988
  "",
1876
1989
  ...input.resolved.context ? ["## Context", input.resolved.context, ""] : [],
1990
+ ...input.contextLabel ? [`Document: ${input.contextLabel}`, ""] : [],
1877
1991
  "## Rules",
1878
1992
  ...input.resolved.rules.map((rule) => `- ${rule}`),
1879
1993
  "",
@@ -1881,10 +1995,6 @@ function buildPageTranslationPrompt(input) {
1881
1995
  "Return ONLY valid JSON with keys:",
1882
1996
  input.slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).",
1883
1997
  "",
1884
- "## EN metadata",
1885
- `title: ${input.enTitle}`,
1886
- `description: ${input.enDescription}`,
1887
- "",
1888
1998
  "## EN translatable frontmatter (JSON)",
1889
1999
  JSON.stringify(input.translatableFrontmatter, null, 2),
1890
2000
  "",
@@ -1894,6 +2004,74 @@ function buildPageTranslationPrompt(input) {
1894
2004
  return lines.join("\n");
1895
2005
  }
1896
2006
 
2007
+ // src/translate/response-schema.ts
2008
+ init_cjs_shims();
2009
+ function getObjectShape(schema) {
2010
+ const base = peelOptionalWrappers(schema);
2011
+ if (base instanceof Object && "shape" in base) {
2012
+ return base.shape;
2013
+ }
2014
+ return null;
2015
+ }
2016
+ function getArraySchema(schema) {
2017
+ const base = peelOptionalWrappers(schema);
2018
+ if (base instanceof Object && "element" in base && base._def?.type === "array") {
2019
+ return base;
2020
+ }
2021
+ return null;
2022
+ }
2023
+ function extractTranslatableFromStructural(schema) {
2024
+ const arraySchema = getArraySchema(schema);
2025
+ if (arraySchema) {
2026
+ const inner = buildTranslatableSubschema(arraySchema.element);
2027
+ if (inner) return zod.z.array(inner);
2028
+ return null;
2029
+ }
2030
+ if (getObjectShape(schema)) {
2031
+ return buildTranslatableSubschema(schema);
2032
+ }
2033
+ return null;
2034
+ }
2035
+ function buildTranslatableSubschema(schema) {
2036
+ const shape = getObjectShape(schema);
2037
+ if (!shape) return null;
2038
+ const out = {};
2039
+ for (const [key, child] of Object.entries(shape)) {
2040
+ const childSchema = child;
2041
+ const kind = getFieldKind(childSchema);
2042
+ if (kind === "translatable") {
2043
+ out[key] = peelOptionalWrappers(childSchema);
2044
+ } else if (kind === "structural") {
2045
+ const nested = extractTranslatableFromStructural(childSchema);
2046
+ if (nested) out[key] = nested;
2047
+ }
2048
+ }
2049
+ if (Object.keys(out).length === 0) return null;
2050
+ return zod.z.object(out);
2051
+ }
2052
+ function buildGeminiResponseSchema(schema, slugStrategy) {
2053
+ try {
2054
+ const translatable = buildTranslatableSubschema(schema);
2055
+ if (!translatable) return null;
2056
+ const responseShape = {
2057
+ frontmatter: translatable,
2058
+ body: zod.z.string()
2059
+ };
2060
+ if (slugStrategy === "localized") {
2061
+ responseShape.slug = zod.z.string();
2062
+ }
2063
+ const jsonSchema = zod.z.toJSONSchema(zod.z.object(responseShape), {
2064
+ target: "draft-2020-12",
2065
+ unrepresentable: "any",
2066
+ io: "output"
2067
+ });
2068
+ delete jsonSchema.$schema;
2069
+ return jsonSchema;
2070
+ } catch {
2071
+ return null;
2072
+ }
2073
+ }
2074
+
1897
2075
  // src/translate/resolve-translate-config.ts
1898
2076
  init_cjs_shims();
1899
2077
  function slugStrategyRules(slugStrategy, preserveTerms) {
@@ -1934,86 +2112,313 @@ function resolveTranslateConfig(config, type) {
1934
2112
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
1935
2113
  }
1936
2114
 
2115
+ // src/translate/validate-translation.ts
2116
+ init_cjs_shims();
2117
+ function formatZodIssues(error) {
2118
+ return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
2119
+ }
2120
+ function sanitizeTranslatedFrontmatter(rawFrontmatter, schema) {
2121
+ return pickTranslatable(rawFrontmatter, schema);
2122
+ }
2123
+ function validateTranslatedFrontmatter(enDoc, localeFrontmatter, typeSchema) {
2124
+ const frontmatter = sanitizeTranslatedFrontmatter(localeFrontmatter, typeSchema);
2125
+ const merged = mergeStructuralOntoLocale(
2126
+ frontmatter,
2127
+ enDoc.frontmatter,
2128
+ typeSchema
2129
+ );
2130
+ const parsed = typeSchema.safeParse(merged);
2131
+ if (!parsed.success) {
2132
+ return { ok: false, error: formatZodIssues(parsed.error) };
2133
+ }
2134
+ return { ok: true, frontmatter };
2135
+ }
2136
+
1937
2137
  // src/translate/page-translator.ts
2138
+ function summarizeResults(results, durationMs) {
2139
+ return results.reduce(
2140
+ (totals, result) => {
2141
+ if (result.failed) totals.failed += 1;
2142
+ else if (result.skipped) totals.skipped += 1;
2143
+ else totals.translated += 1;
2144
+ totals.inputTokens += result.usage?.inputTokens ?? 0;
2145
+ totals.outputTokens += result.usage?.outputTokens ?? 0;
2146
+ totals.estimatedCostUsd += result.estimatedCostUsd ?? 0;
2147
+ return totals;
2148
+ },
2149
+ {
2150
+ translated: 0,
2151
+ skipped: 0,
2152
+ failed: 0,
2153
+ inputTokens: 0,
2154
+ outputTokens: 0,
2155
+ estimatedCostUsd: 0,
2156
+ durationMs
2157
+ }
2158
+ );
2159
+ }
2160
+ function formatTranslateError(error) {
2161
+ const message = error instanceof Error ? error.message : String(error);
2162
+ try {
2163
+ const parsed = JSON.parse(message);
2164
+ if (parsed.error?.message) return parsed.error.message;
2165
+ } catch {
2166
+ }
2167
+ const embedded = message.match(/"message"\s*:\s*"((?:\\.|[^"\\])*)"/);
2168
+ if (embedded?.[1]) {
2169
+ return embedded[1].replace(/\\"/g, '"').replace(/\\n/g, "\n");
2170
+ }
2171
+ return message.length > 160 ? `${message.slice(0, 157)}\u2026` : message;
2172
+ }
2173
+ async function runPool(items, concurrency, worker) {
2174
+ if (items.length === 0) return;
2175
+ let nextIndex = 0;
2176
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
2177
+ while (nextIndex < items.length) {
2178
+ const index = nextIndex++;
2179
+ await worker(items[index], index);
2180
+ }
2181
+ });
2182
+ await Promise.all(runners);
2183
+ }
2184
+ function resolveContextLabel(enDoc, enSlug) {
2185
+ const fm = enDoc.frontmatter;
2186
+ for (const key of ["title", "name", "h1"]) {
2187
+ const value = fm[key];
2188
+ if (typeof value === "string" && value.trim()) return value;
2189
+ }
2190
+ return enSlug;
2191
+ }
1938
2192
  async function translatePage(config, item, options = {}) {
1939
- const type = config.types.find((t) => t.id === item.contentType);
1940
- if (!type) throw new Error(`Unknown content type ${item.contentType}`);
1941
- const enDoc = readEnDocument(config, type, item.enSlug);
1942
- if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
1943
- const payload = getTranslatablePayload(enDoc, type);
1944
- const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1945
- const db = openStore(config, "readonly");
1946
- const existing = getTranslation(db, type.id, item.enSlug, item.locale);
1947
- db.close();
1948
- if (!options.force && existing && existing.en_hash === currentEnHash) {
1949
- return {
1950
- contentType: item.contentType,
2193
+ const startedAt = Date.now();
2194
+ const base = {
2195
+ contentType: item.contentType,
2196
+ enSlug: item.enSlug,
2197
+ locale: item.locale
2198
+ };
2199
+ try {
2200
+ const type = config.types.find((t) => t.id === item.contentType);
2201
+ if (!type) throw new Error(`Unknown content type ${item.contentType}`);
2202
+ const enDoc = readEnDocument(config, type, item.enSlug);
2203
+ if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
2204
+ const payload = getTranslatablePayload(enDoc, type);
2205
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
2206
+ const db = openStore(config, "readonly");
2207
+ const existing = getTranslation(db, type.id, item.enSlug, item.locale);
2208
+ db.close();
2209
+ if (!options.force && existing && existing.en_hash === currentEnHash) {
2210
+ return {
2211
+ ...base,
2212
+ skipped: true,
2213
+ reason: "fresh",
2214
+ durationMs: Date.now() - startedAt
2215
+ };
2216
+ }
2217
+ const resolvedTranslate = resolveTranslateConfig(config, type);
2218
+ const model = options.model ?? resolvedTranslate.model;
2219
+ const prompt = buildPageTranslationPrompt({
2220
+ resolved: resolvedTranslate,
2221
+ targetLocale: item.locale,
2222
+ contextLabel: resolveContextLabel(enDoc, item.enSlug),
2223
+ translatableFrontmatter: payload.frontmatter,
2224
+ enBody: payload.body,
2225
+ slugStrategy: type.slugStrategy
2226
+ });
2227
+ if (options.dryRun) {
2228
+ return {
2229
+ ...base,
2230
+ skipped: false,
2231
+ model,
2232
+ durationMs: Date.now() - startedAt
2233
+ };
2234
+ }
2235
+ const responseSchema = buildGeminiResponseSchema(type.schema, type.slugStrategy);
2236
+ const result = await translatePageWithGemini({
2237
+ prompt,
2238
+ model,
2239
+ responseSchema: responseSchema ?? void 0
2240
+ });
2241
+ const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
2242
+ const validated = validateTranslatedFrontmatter(enDoc, result.parsed.frontmatter, type.schema);
2243
+ if (!validated.ok) {
2244
+ throw new Error(`Translation validation failed: ${validated.error}`);
2245
+ }
2246
+ const writeDb = openStore(config, "readwrite");
2247
+ upsertTranslation(writeDb, {
2248
+ contentType: type.id,
1951
2249
  enSlug: item.enSlug,
1952
2250
  locale: item.locale,
1953
- skipped: true,
1954
- reason: "fresh"
1955
- };
1956
- }
1957
- const resolvedTranslate = resolveTranslateConfig(config, type);
1958
- const prompt = buildPageTranslationPrompt({
1959
- resolved: resolvedTranslate,
1960
- targetLocale: item.locale,
1961
- enTitle: String(enDoc.frontmatter.title ?? item.enSlug),
1962
- enDescription: String(enDoc.frontmatter.description ?? ""),
1963
- translatableFrontmatter: payload.frontmatter,
1964
- enBody: payload.body,
1965
- slugStrategy: type.slugStrategy
1966
- });
1967
- if (options.dryRun) {
1968
- return {
1969
- contentType: item.contentType,
2251
+ slug,
2252
+ frontmatter: validated.frontmatter,
2253
+ body: result.parsed.body,
2254
+ enHash: currentEnHash,
2255
+ translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2256
+ model: result.model
2257
+ });
2258
+ writeDb.close();
2259
+ recordRevision(config, {
2260
+ contentType: type.id,
1970
2261
  enSlug: item.enSlug,
1971
2262
  locale: item.locale,
2263
+ revisionKind: "translation",
2264
+ enHash: currentEnHash,
2265
+ body: result.parsed.body,
2266
+ frontmatter: validated.frontmatter,
2267
+ model: result.model
2268
+ });
2269
+ const estimatedCostUsd = estimateTranslationCostUsd(
2270
+ normalizeGeminiDisplayName(result.model),
2271
+ result.usage.inputTokens,
2272
+ result.usage.outputTokens
2273
+ );
2274
+ return {
2275
+ ...base,
1972
2276
  skipped: false,
1973
- model: options.model
2277
+ model: result.model,
2278
+ usage: result.usage,
2279
+ estimatedCostUsd,
2280
+ durationMs: Date.now() - startedAt
2281
+ };
2282
+ } catch (error) {
2283
+ return {
2284
+ ...base,
2285
+ skipped: false,
2286
+ failed: true,
2287
+ error: formatTranslateError(error),
2288
+ durationMs: Date.now() - startedAt
1974
2289
  };
1975
2290
  }
1976
- const result = await translatePageWithGemini({
1977
- prompt,
1978
- model: options.model ?? resolvedTranslate.model
1979
- });
1980
- const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
1981
- const writeDb = openStore(config, "readwrite");
1982
- upsertTranslation(writeDb, {
1983
- contentType: type.id,
1984
- enSlug: item.enSlug,
1985
- locale: item.locale,
1986
- slug,
1987
- frontmatter: result.parsed.frontmatter,
1988
- body: result.parsed.body,
1989
- enHash: currentEnHash,
1990
- translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1991
- model: result.model
2291
+ }
2292
+ function labelForItem(item) {
2293
+ return `${item.contentType}/${item.enSlug}@${item.locale}`;
2294
+ }
2295
+ async function translateWorklist(config, items, options = {}) {
2296
+ const concurrency = Math.max(1, options.concurrency ?? 3);
2297
+ const startedAt = Date.now();
2298
+ const results = new Array(items.length);
2299
+ const active = /* @__PURE__ */ new Set();
2300
+ options.onProgress?.({
2301
+ type: "start",
2302
+ total: items.length,
2303
+ concurrency,
2304
+ dryRun: Boolean(options.dryRun),
2305
+ model: options.model
1992
2306
  });
1993
- writeDb.close();
1994
- recordRevision(config, {
1995
- contentType: type.id,
1996
- enSlug: item.enSlug,
1997
- locale: item.locale,
1998
- revisionKind: "translation",
1999
- enHash: currentEnHash,
2000
- body: result.parsed.body,
2001
- model: result.model
2307
+ await runPool(items, concurrency, async (item, index) => {
2308
+ const label = labelForItem(item);
2309
+ active.add(label);
2310
+ options.onProgress?.({ type: "item-start", item, active: [...active] });
2311
+ const result = await translatePage(config, item, options);
2312
+ results[index] = result;
2313
+ active.delete(label);
2314
+ options.onProgress?.({ type: "item-done", result });
2002
2315
  });
2003
- return {
2004
- contentType: item.contentType,
2005
- enSlug: item.enSlug,
2006
- locale: item.locale,
2007
- skipped: false,
2008
- model: result.model
2009
- };
2316
+ const totals = summarizeResults(results, Date.now() - startedAt);
2317
+ options.onProgress?.({ type: "done", results, totals });
2318
+ return results;
2010
2319
  }
2011
- async function translateWorklist(config, items, options = {}) {
2012
- const results = [];
2013
- for (const item of items) {
2014
- results.push(await translatePage(config, item, options));
2320
+
2321
+ // src/export/build-static-raw-exports.ts
2322
+ init_cjs_shims();
2323
+
2324
+ // src/loader/parse-mdx.ts
2325
+ init_cjs_shims();
2326
+ function serializeMdx(frontmatter, content) {
2327
+ return matter__default.default.stringify(content, frontmatter);
2328
+ }
2329
+
2330
+ // src/export/build-static-raw-exports.ts
2331
+ function normalizeExtension(ext) {
2332
+ return ext.startsWith(".") ? ext : `.${ext}`;
2333
+ }
2334
+ function exportDirSegment(pathTemplate) {
2335
+ const prefix = pathPrefix(pathTemplate);
2336
+ return prefix.replace(/^\/+|\/+$/g, "");
2337
+ }
2338
+ function getStaticExportRoots(project, options = {}) {
2339
+ const { config } = project;
2340
+ const locales = options.locales ?? config.locales;
2341
+ const typeFilter = options.types ? new Set(options.types) : null;
2342
+ const roots = /* @__PURE__ */ new Set();
2343
+ for (const type of project.listTypes()) {
2344
+ if (!isRoutableType(type.config)) continue;
2345
+ if (typeFilter && !typeFilter.has(type.id)) continue;
2346
+ const segment = exportDirSegment(type.config.path);
2347
+ roots.add(`${segment}/`);
2348
+ for (const locale of locales) {
2349
+ if (locale === config.defaultLocale) continue;
2350
+ roots.add(`${locale}/${segment}/`);
2351
+ }
2015
2352
  }
2016
- return results;
2353
+ return [...roots].sort();
2354
+ }
2355
+ function buildStaticRawExports(project, options = {}) {
2356
+ const { config } = project;
2357
+ const extension = normalizeExtension(options.extension ?? ".mdx");
2358
+ const locales = options.locales ?? config.locales;
2359
+ const excludeRedirected = options.excludeRedirected ?? true;
2360
+ const excludeNoindex = options.excludeNoindex ?? false;
2361
+ const typeFilter = options.types ? new Set(options.types) : null;
2362
+ const out = [];
2363
+ for (const type of project.listTypes()) {
2364
+ if (!isRoutableType(type.config)) continue;
2365
+ if (typeFilter && !typeFilter.has(type.id)) continue;
2366
+ const pathTemplate = type.config.path;
2367
+ const all = type.load();
2368
+ const enIdx = all.get(config.defaultLocale);
2369
+ if (!enIdx) continue;
2370
+ for (const locale of locales) {
2371
+ for (const enDoc of enIdx.bySlug.values()) {
2372
+ const resolved = type.resolve(enDoc.slug, locale);
2373
+ if (!resolved.document) continue;
2374
+ if (excludeRedirected && resolved.shouldRedirectTo) continue;
2375
+ if (excludeNoindex && resolved.document.noindex) continue;
2376
+ const doc = resolved.document;
2377
+ const slugWithExt = `${doc.slug}${extension}`;
2378
+ const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2379
+ out.push({
2380
+ relativePath: urlPath.slice(1),
2381
+ urlPath,
2382
+ locale,
2383
+ typeId: type.id,
2384
+ enSlug: doc.enSlug,
2385
+ source: serializeMdx(doc.frontmatter, doc.content)
2386
+ });
2387
+ }
2388
+ }
2389
+ }
2390
+ return out;
2391
+ }
2392
+
2393
+ // src/export/write-static-raw-exports.ts
2394
+ init_cjs_shims();
2395
+ function rmDirIfExists(dir) {
2396
+ if (fs2__default.default.existsSync(dir)) {
2397
+ fs2__default.default.rmSync(dir, { recursive: true, force: true });
2398
+ }
2399
+ }
2400
+ function writeStaticRawExports(project, options = {}) {
2401
+ const outDir = path3__default.default.resolve(options.outDir ?? "public");
2402
+ const typeFilter = options.types;
2403
+ for (const root of getStaticExportRoots(project, {
2404
+ types: typeFilter,
2405
+ locales: options.locales
2406
+ })) {
2407
+ rmDirIfExists(path3__default.default.join(outDir, root));
2408
+ }
2409
+ const exports = buildStaticRawExports(project, options);
2410
+ const counts = /* @__PURE__ */ new Map();
2411
+ for (const item of exports) {
2412
+ const filePath = path3__default.default.join(outDir, item.relativePath);
2413
+ fs2__default.default.mkdirSync(path3__default.default.dirname(filePath), { recursive: true });
2414
+ fs2__default.default.writeFileSync(filePath, item.source, "utf8");
2415
+ const key = `${item.typeId}/${item.locale}`;
2416
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2417
+ }
2418
+ for (const [key, count] of [...counts.entries()].sort()) {
2419
+ console.log(` ${key}: ${count}`);
2420
+ }
2421
+ return { exports, written: exports.length };
2017
2422
  }
2018
2423
 
2019
2424
  // src/config/load-env.ts
@@ -2034,99 +2439,464 @@ function loadEnvFromCwd(cwd) {
2034
2439
  // studio/server.ts
2035
2440
  init_cjs_shims();
2036
2441
  init_sqlite();
2037
- function renderLayout(title, body) {
2442
+ function escapeHtml(value) {
2443
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2444
+ }
2445
+ function encodePathSegment(value) {
2446
+ return encodeURIComponent(value);
2447
+ }
2448
+ function statusDot(status) {
2449
+ const labels = {
2450
+ source: "en",
2451
+ "up-to-date": "ok",
2452
+ stale: "stale",
2453
+ missing: "\u2014"
2454
+ };
2455
+ return `<span class="status" title="${status}"><span class="dot dot-${status}"></span>${labels[status]}</span>`;
2456
+ }
2457
+ function documentStatus(config, db, type, enSlug, locale) {
2458
+ if (locale === config.defaultLocale) {
2459
+ return { status: "source" };
2460
+ }
2461
+ const enDoc = readEnDocument(config, type.config, enSlug);
2462
+ if (!enDoc) {
2463
+ return { status: "missing" };
2464
+ }
2465
+ const payload = getTranslatablePayload(enDoc, type.config);
2466
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
2467
+ const row = getTranslation(db, type.id, enSlug, locale);
2468
+ if (!row) {
2469
+ return { status: "missing", currentEnHash };
2470
+ }
2471
+ if (row.en_hash !== currentEnHash) {
2472
+ return { status: "stale", currentEnHash, storedEnHash: row.en_hash };
2473
+ }
2474
+ return { status: "up-to-date", currentEnHash, storedEnHash: row.en_hash };
2475
+ }
2476
+ function flattenFrontmatter(data, prefix = "") {
2477
+ const rows = [];
2478
+ for (const [key, value] of Object.entries(data)) {
2479
+ const fullKey = prefix ? `${prefix}.${key}` : key;
2480
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2481
+ rows.push(...flattenFrontmatter(value, fullKey));
2482
+ } else {
2483
+ const display = typeof value === "string" ? value : value === void 0 ? "" : JSON.stringify(value, null, 2);
2484
+ rows.push({ key: fullKey, value: display });
2485
+ }
2486
+ }
2487
+ return rows;
2488
+ }
2489
+ function translatableKeySet(schema) {
2490
+ return new Set(
2491
+ introspectSchema(schema).filter((field2) => field2.kind === "translatable").map((field2) => field2.path.join("."))
2492
+ );
2493
+ }
2494
+ function renderFrontmatterTable(frontmatter, schema) {
2495
+ const translatable = translatableKeySet(schema);
2496
+ const rows = flattenFrontmatter(frontmatter).map((row) => {
2497
+ const flag = translatable.has(row.key) ? `<span class="flag t" title="Translatable">T</span>` : `<span class="flag s" title="Structural">S</span>`;
2498
+ return `<tr>
2499
+ <td class="k">${flag}${escapeHtml(row.key)}</td>
2500
+ <td class="v">${escapeHtml(row.value)}</td>
2501
+ </tr>`;
2502
+ }).join("");
2503
+ return `<table class="kv">
2504
+ <tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
2505
+ </table>`;
2506
+ }
2507
+ function renderRevisionSnapshot(revision) {
2508
+ if (revision.frontmatter_json && revision.body) {
2509
+ let frontmatter = {};
2510
+ try {
2511
+ frontmatter = JSON.parse(revision.frontmatter_json);
2512
+ } catch {
2513
+ frontmatter = {};
2514
+ }
2515
+ const fmRows = flattenFrontmatter(frontmatter).map(
2516
+ (row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
2517
+ ).join("");
2518
+ return `<table class="kv"><tbody>${fmRows}</tbody></table>
2519
+ <pre class="code">${escapeHtml(revision.body)}</pre>`;
2520
+ }
2521
+ return `<p class="dim">${escapeHtml(revision.body_preview ?? "(no snapshot)")}</p>`;
2522
+ }
2523
+ function renderRevisionTimeline(revisions) {
2524
+ if (revisions.length === 0) {
2525
+ return `<p class="dim">No history.</p>`;
2526
+ }
2527
+ const items = revisions.map(
2528
+ (row) => `<tr>
2529
+ <td class="dim">${escapeHtml(row.created_at.slice(0, 19))}</td>
2530
+ <td>${escapeHtml(row.revision_kind)}</td>
2531
+ <td class="dim">${row.model ? escapeHtml(row.model) : "\u2014"}</td>
2532
+ <td class="dim mono">${escapeHtml(row.en_hash.slice(0, 8))}</td>
2533
+ <td><details><summary>view</summary>${renderRevisionSnapshot(row)}</details></td>
2534
+ </tr>`
2535
+ ).join("");
2536
+ return `<table class="data"><thead><tr>
2537
+ <th>When</th><th>Kind</th><th>Model</th><th>Hash</th><th></th>
2538
+ </tr></thead><tbody>${items}</tbody></table>`;
2539
+ }
2540
+ function renderLayout(title, body, project, options = {}) {
2541
+ const typeLinks = project.listTypes().map((type) => {
2542
+ const active = type.id === options.activeTypeId ? " active" : "";
2543
+ return `<a class="tree-item${active}" href="/type/${encodePathSegment(type.id)}">
2544
+ <span class="tree-label">${escapeHtml(type.config.label)}</span>
2545
+ <span class="tree-meta">${escapeHtml(type.id)}</span>
2546
+ </a>`;
2547
+ }).join("");
2038
2548
  return `<!doctype html>
2039
2549
  <html lang="en">
2040
2550
  <head>
2041
2551
  <meta charset="utf-8" />
2042
2552
  <meta name="viewport" content="width=device-width, initial-scale=1" />
2043
- <title>${title} \u2014 Scribe Studio</title>
2553
+ <title>${escapeHtml(title)} \u2014 Scribe</title>
2044
2554
  <style>
2045
- body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b0d10; color: #e8eaed; }
2046
- header { padding: 16px 24px; border-bottom: 1px solid #222; display: flex; gap: 16px; }
2047
- header a { color: #9ecbff; text-decoration: none; }
2048
- main { padding: 24px; }
2049
- table { border-collapse: collapse; width: 100%; }
2050
- th, td { border: 1px solid #333; padding: 8px 10px; text-align: left; vertical-align: top; }
2051
- th { background: #151922; }
2052
- .muted { color: #9aa0a6; }
2053
- .stale { color: #ffb4a2; }
2054
- .ok { color: #8ce99a; }
2055
- .card { background: #151922; border: 1px solid #333; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
2555
+ :root {
2556
+ --bg: #1e1e1e;
2557
+ --sidebar: #252526;
2558
+ --bar: #333333;
2559
+ --panel: #1e1e1e;
2560
+ --border: #3c3c3c;
2561
+ --text: #cccccc;
2562
+ --dim: #858585;
2563
+ --accent: #3794ff;
2564
+ --hover: #2a2d2e;
2565
+ --active: #37373d;
2566
+ --ok: #89d185;
2567
+ --stale: #cca700;
2568
+ --missing: #f48771;
2569
+ --source: #75beff;
2570
+ --mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
2571
+ --ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2572
+ --fs: 13px;
2573
+ --fs-sm: 11px;
2574
+ }
2575
+ * { box-sizing: border-box; margin: 0; padding: 0; }
2576
+ body { font: var(--fs)/1.4 var(--ui); background: var(--bg); color: var(--text); }
2577
+ a { color: var(--accent); text-decoration: none; }
2578
+ a:hover { text-decoration: underline; }
2579
+ .app { display: flex; height: 100vh; overflow: hidden; }
2580
+
2581
+ /* activity bar */
2582
+ .actbar {
2583
+ width: 48px; flex-shrink: 0; background: var(--bar);
2584
+ display: flex; flex-direction: column; align-items: center;
2585
+ padding: 8px 0; gap: 4px; border-right: 1px solid var(--border);
2586
+ }
2587
+ .actbar a {
2588
+ width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
2589
+ color: var(--dim); font-size: 18px; text-decoration: none; position: relative;
2590
+ }
2591
+ .actbar a:hover { color: var(--text); }
2592
+ .actbar a.active { color: var(--text); }
2593
+ .actbar a.active::before {
2594
+ content: ""; position: absolute; left: 0; top: 8px; bottom: 8px;
2595
+ width: 2px; background: var(--accent);
2596
+ }
2597
+
2598
+ /* sidebar */
2599
+ .sidebar {
2600
+ width: 200px; flex-shrink: 0; background: var(--sidebar);
2601
+ border-right: 1px solid var(--border); display: flex; flex-direction: column;
2602
+ overflow: hidden;
2603
+ }
2604
+ .sidebar-head {
2605
+ padding: 8px 12px; font-size: var(--fs-sm); font-weight: 600;
2606
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
2607
+ }
2608
+ .sidebar-body { flex: 1; overflow-y: auto; padding: 2px 0; }
2609
+ .tree-item {
2610
+ display: flex; flex-direction: column; padding: 3px 12px 3px 20px;
2611
+ color: var(--text); text-decoration: none; line-height: 1.3;
2612
+ }
2613
+ .tree-item:hover { background: var(--hover); text-decoration: none; }
2614
+ .tree-item.active { background: var(--active); }
2615
+ .tree-label { font-size: var(--fs); }
2616
+ .tree-meta { font-size: var(--fs-sm); color: var(--dim); }
2617
+
2618
+ /* main */
2619
+ .main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
2620
+ .toolbar {
2621
+ display: flex; align-items: center; gap: 6px; padding: 0 12px;
2622
+ height: 35px; background: var(--sidebar); border-bottom: 1px solid var(--border);
2623
+ font-size: var(--fs-sm); color: var(--dim); flex-shrink: 0; overflow: hidden;
2624
+ }
2625
+ .toolbar a { color: var(--dim); }
2626
+ .toolbar a:hover { color: var(--text); }
2627
+ .toolbar .sep { color: var(--border); }
2628
+ .content { flex: 1; overflow-y: auto; padding: 0; }
2629
+
2630
+ /* tabs (locale switcher) */
2631
+ .tabs {
2632
+ display: flex; background: var(--sidebar); border-bottom: 1px solid var(--border);
2633
+ overflow-x: auto; flex-shrink: 0;
2634
+ }
2635
+ .tab {
2636
+ display: flex; align-items: center; gap: 6px; padding: 6px 12px;
2637
+ font-size: var(--fs-sm); color: var(--dim); border-right: 1px solid var(--border);
2638
+ text-decoration: none; white-space: nowrap;
2639
+ }
2640
+ .tab:hover { background: var(--hover); color: var(--text); text-decoration: none; }
2641
+ .tab.active {
2642
+ background: var(--bg); color: var(--text);
2643
+ border-bottom: 1px solid var(--bg); margin-bottom: -1px;
2644
+ }
2645
+
2646
+ /* status dots */
2647
+ .status { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); color: var(--dim); }
2648
+ .dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
2649
+ .dot-source { background: var(--source); }
2650
+ .dot-up-to-date { background: var(--ok); }
2651
+ .dot-stale { background: var(--stale); }
2652
+ .dot-missing { background: var(--missing); opacity: 0.7; }
2653
+
2654
+ /* sections */
2655
+ .section { border-bottom: 1px solid var(--border); }
2656
+ .section-head {
2657
+ padding: 4px 12px; font-size: var(--fs-sm); font-weight: 600;
2658
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
2659
+ background: var(--sidebar);
2660
+ }
2661
+ .section-body { padding: 0; }
2662
+
2663
+ /* tables */
2664
+ table { border-collapse: collapse; width: 100%; font-size: var(--fs); }
2665
+ .data th, .data td { padding: 2px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
2666
+ .data th { font-size: var(--fs-sm); font-weight: 600; color: var(--dim); background: var(--sidebar); height: 22px; }
2667
+ .data tr:hover td { background: var(--hover); }
2668
+ .data .mono { font-family: var(--mono); font-size: var(--fs-sm); }
2669
+
2670
+ /* key-value (frontmatter) */
2671
+ .kv td { padding: 1px 12px; font-family: var(--mono); font-size: var(--fs-sm); vertical-align: top; border-bottom: 1px solid var(--border); }
2672
+ .kv .k { width: 160px; color: #9cdcfe; white-space: nowrap; }
2673
+ .kv .v { color: #ce9178; white-space: pre-wrap; word-break: break-word; }
2674
+ .flag { font-size: 9px; font-weight: 700; margin-right: 4px; opacity: 0.5; }
2675
+ .flag.t { color: var(--ok); }
2676
+ .flag.s { color: var(--dim); }
2677
+
2678
+ /* code block */
2679
+ .code {
2680
+ margin: 0; padding: 8px 12px; font: var(--fs-sm)/1.5 var(--mono);
2681
+ white-space: pre-wrap; word-break: break-word; color: var(--text);
2682
+ background: var(--bg); border: none;
2683
+ }
2684
+
2685
+ /* meta row */
2686
+ .meta { display: flex; flex-wrap: wrap; font-size: var(--fs-sm); border-bottom: 1px solid var(--border); padding: 4px 0; }
2687
+ .meta dt { padding: 0 4px 0 12px; color: var(--dim); }
2688
+ .meta dt::after { content: ":"; }
2689
+ .meta dd { padding: 0 12px 0 0; font-family: var(--mono); }
2690
+
2691
+ /* misc */
2692
+ .dim { color: var(--dim); }
2693
+ .page-title { padding: 8px 12px 4px; font-size: 14px; font-weight: 400; }
2694
+ .page-sub { padding: 0 12px 8px; font-size: var(--fs-sm); color: var(--dim); }
2695
+ details summary { cursor: pointer; color: var(--accent); font-size: var(--fs-sm); }
2696
+ details[open] summary { margin-bottom: 4px; }
2697
+ .tag { font-size: var(--fs-sm); color: var(--dim); margin-left: 6px; }
2698
+ .tag-warn { color: var(--stale); }
2699
+ .tag-err { color: var(--missing); }
2056
2700
  </style>
2057
2701
  </head>
2058
2702
  <body>
2059
- <header>
2060
- <strong>Scribe Studio</strong>
2061
- <a href="/">Browser</a>
2062
- <a href="/staleness">Staleness</a>
2063
- <a href="/history">History</a>
2064
- </header>
2065
- <main>${body}</main>
2703
+ <div class="app">
2704
+ <nav class="actbar">
2705
+ <a href="/" title="Overview">\u2302</a>
2706
+ <a href="/staleness" title="Staleness">\u26A0</a>
2707
+ </nav>
2708
+ <aside class="sidebar">
2709
+ <div class="sidebar-head">Types</div>
2710
+ <div class="sidebar-body">${typeLinks || `<span class="dim" style="padding:8px 12px">\u2014</span>`}</div>
2711
+ </aside>
2712
+ <div class="main">
2713
+ <div class="content">${body}</div>
2714
+ </div>
2715
+ </div>
2066
2716
  </body>
2067
2717
  </html>`;
2068
2718
  }
2719
+ function docTitleFromFrontmatter(frontmatter, enSlug) {
2720
+ const title = frontmatter.title;
2721
+ if (typeof title === "string" && title.trim()) return title;
2722
+ return enSlug;
2723
+ }
2069
2724
  async function startStudio(project, options = {}) {
2070
2725
  const app = new hono.Hono();
2071
2726
  const config = project.config;
2072
2727
  app.get("/", (c) => {
2073
- const cards = project.listTypes().map((type) => {
2074
- const localeRows = config.locales.map((locale) => {
2075
- const count = type.load().get(locale)?.bySlug.size ?? 0;
2076
- return `<li>${locale}: ${count}</li>`;
2728
+ const db = openStore(config, "readonly");
2729
+ const worklist = buildWorklist(config);
2730
+ const rows = project.listTypes().map((type) => {
2731
+ const enCount = type.list().length;
2732
+ const localeCells = config.locales.filter((locale) => locale !== config.defaultLocale).map((locale) => {
2733
+ const translated = listTranslationsForLocale(db, type.id, locale).length;
2734
+ const stale = worklist.filter(
2735
+ (item) => item.contentType === type.id && item.locale === locale && item.reason === "stale"
2736
+ ).length;
2737
+ const missing = worklist.filter(
2738
+ (item) => item.contentType === type.id && item.locale === locale && item.reason === "missing"
2739
+ ).length;
2740
+ const tags = [
2741
+ stale ? `<span class="tag tag-warn">${stale}s</span>` : "",
2742
+ missing ? `<span class="tag tag-err">${missing}m</span>` : ""
2743
+ ].join("");
2744
+ return `<td>${translated}${tags}</td>`;
2077
2745
  }).join("");
2078
- return `<div class="card"><h2>${type.config.label}</h2><ul>${localeRows}</ul></div>`;
2746
+ return `<tr>
2747
+ <td><a href="/type/${encodePathSegment(type.id)}">${escapeHtml(type.config.label)}</a></td>
2748
+ <td class="dim">${escapeHtml(type.id)}</td>
2749
+ <td>${enCount}</td>
2750
+ ${localeCells}
2751
+ </tr>`;
2079
2752
  });
2080
- return c.html(renderLayout("Browser", cards.join("")));
2753
+ db.close();
2754
+ const localeHeaders = config.locales.filter((l) => l !== config.defaultLocale).map((l) => `<th>${escapeHtml(l)}</th>`).join("");
2755
+ const html = `<div class="toolbar">Overview</div>
2756
+ <table class="data">
2757
+ <thead><tr><th>Type</th><th>ID</th><th>EN</th>${localeHeaders}</tr></thead>
2758
+ <tbody>${rows.join("")}</tbody>
2759
+ </table>`;
2760
+ return c.html(renderLayout("Overview", html, project));
2081
2761
  });
2082
- app.get("/staleness", (c) => {
2083
- const worklist = buildWorklist(config);
2084
- const rows = worklist.slice(0, 500).map(
2085
- (item) => `<tr><td>${item.contentType}</td><td>${item.enSlug}</td><td>${item.locale}</td><td class="stale">${item.reason}</td></tr>`
2086
- ).join("");
2087
- const html = `<h1>Translation staleness</h1><p class="muted">${worklist.length} stale/missing entries (showing up to 500)</p>
2088
- <table><thead><tr><th>Type</th><th>EN slug</th><th>Locale</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
2089
- return c.html(renderLayout("Staleness", html));
2762
+ app.get("/type/:id", (c) => {
2763
+ const typeId = c.req.param("id");
2764
+ const type = project.getType(typeId);
2765
+ if (!type) {
2766
+ return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
2767
+ }
2768
+ const db = openStore(config, "readonly");
2769
+ const locales = config.locales;
2770
+ const headerCells = locales.map((locale) => `<th>${escapeHtml(locale)}</th>`).join("");
2771
+ const rows = type.list().map((doc) => {
2772
+ const title = docTitleFromFrontmatter(doc.frontmatter, doc.slug);
2773
+ const statusCells = locales.map((locale) => {
2774
+ const { status } = documentStatus(config, db, type, doc.slug, locale);
2775
+ return `<td>${statusDot(status)}</td>`;
2776
+ }).join("");
2777
+ return `<tr>
2778
+ <td class="mono"><a href="/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(doc.slug)}">${escapeHtml(doc.slug)}</a></td>
2779
+ <td>${escapeHtml(title)}</td>
2780
+ ${statusCells}
2781
+ </tr>`;
2782
+ }).join("");
2783
+ db.close();
2784
+ const html = `<div class="toolbar">
2785
+ <a href="/">Overview</a><span class="sep">\u203A</span>${escapeHtml(type.config.label)}
2786
+ </div>
2787
+ <table class="data">
2788
+ <thead><tr><th>Slug</th><th>Title</th>${headerCells}</tr></thead>
2789
+ <tbody>${rows || `<tr><td colspan="${2 + locales.length}" class="dim">No documents</td></tr>`}</tbody>
2790
+ </table>`;
2791
+ return c.html(renderLayout(type.config.label, html, project, { activeTypeId: typeId }));
2090
2792
  });
2091
- app.get("/history", (c) => {
2092
- const typeId = c.req.query("type") ?? project.listTypes()[0]?.id;
2093
- const enSlug = c.req.query("slug");
2094
- if (!typeId) return c.html(renderLayout("History", "<p>No content types.</p>"));
2793
+ app.get("/type/:id/doc/:enSlug", (c) => {
2794
+ const typeId = c.req.param("id");
2795
+ const enSlug = c.req.param("enSlug");
2796
+ const locale = c.req.query("locale") ?? config.defaultLocale;
2797
+ const showRaw = c.req.query("raw") === "1";
2798
+ const type = project.getType(typeId);
2799
+ if (!type) {
2800
+ return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
2801
+ }
2095
2802
  const db = openStore(config, "readonly");
2096
- let body = `<h1>History</h1><form><label>Type <select name="type">${project.listTypes().map((t) => `<option value="${t.id}" ${t.id === typeId ? "selected" : ""}>${t.id}</option>`).join("")}</select></label> <label>EN slug <input name="slug" value="${enSlug ?? ""}" /></label> <button>View</button></form>`;
2097
- if (enSlug) {
2098
- const rows = listRevisions(db, typeId, enSlug).slice(0, 100).map(
2099
- (row) => `<tr><td>${row.created_at}</td><td>${row.revision_kind}</td><td>${row.locale ?? "en"}</td><td>${row.en_hash.slice(0, 8)}</td><td class="muted">${row.body_preview ?? ""}</td></tr>`
2100
- ).join("");
2101
- body += `<table><thead><tr><th>When</th><th>Kind</th><th>Locale</th><th>EN hash</th><th>Preview</th></tr></thead><tbody>${rows}</tbody></table>`;
2803
+ const enDoc = readEnDocument(config, type.config, enSlug);
2804
+ if (!enDoc) {
2805
+ db.close();
2806
+ return c.html(
2807
+ renderLayout("Not found", `<div class="toolbar">Not found</div><p class="dim" style="padding:12px">${escapeHtml(enSlug)}</p>`, project, {
2808
+ activeTypeId: typeId
2809
+ }),
2810
+ 404
2811
+ );
2812
+ }
2813
+ const localeTabs = config.locales.map((loc) => {
2814
+ const { status } = documentStatus(config, db, type, enSlug, loc);
2815
+ const active = loc === locale ? " active" : "";
2816
+ const href = `/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(enSlug)}?locale=${encodePathSegment(loc)}`;
2817
+ return `<a class="tab${active}" href="${href}">${escapeHtml(loc)} ${statusDot(status)}</a>`;
2818
+ }).join("");
2819
+ let contentPanel = "";
2820
+ let metaPanel = "";
2821
+ let historyPanel = "";
2822
+ if (locale === config.defaultLocale) {
2823
+ contentPanel = `<div class="section">
2824
+ <div class="section-head">Frontmatter</div>
2825
+ <div class="section-body">${renderFrontmatterTable(enDoc.frontmatter, type.config.schema)}</div>
2826
+ <div class="section-head">Body</div>
2827
+ <pre class="code">${escapeHtml(enDoc.content)}</pre>
2828
+ </div>`;
2102
2829
  } else {
2103
- const translations = listTranslationsForType(db, typeId).slice(0, 100);
2104
- body += `<ul>${translations.map((row) => `<li><a href="/history?type=${typeId}&slug=${row.en_slug}">${row.en_slug}</a> (${row.locale})</li>`).join("")}</ul>`;
2830
+ const translation = getTranslation(db, typeId, enSlug, locale);
2831
+ const { status, currentEnHash, storedEnHash } = documentStatus(config, db, type, enSlug, locale);
2832
+ if (translation) {
2833
+ const rawFrontmatter = JSON.parse(translation.frontmatter_json);
2834
+ const displayFrontmatter = showRaw ? rawFrontmatter : mergeStructuralOntoLocale(rawFrontmatter, enDoc.frontmatter, type.config.schema);
2835
+ const rawToggle = showRaw ? `<span class="dim"> \xB7 <a href="?locale=${encodePathSegment(locale)}">merged</a></span>` : `<span class="dim"> \xB7 <a href="?locale=${encodePathSegment(locale)}&raw=1">raw</a></span>`;
2836
+ contentPanel = `<div class="section">
2837
+ <div class="section-head">Frontmatter${rawToggle}</div>
2838
+ <div class="section-body">${renderFrontmatterTable(displayFrontmatter, type.config.schema)}</div>
2839
+ <div class="section-head">Body</div>
2840
+ <pre class="code">${escapeHtml(translation.body)}</pre>
2841
+ </div>`;
2842
+ metaPanel = `<dl class="meta">
2843
+ <dt>status</dt><dd>${statusDot(status)}</dd>
2844
+ <dt>model</dt><dd>${escapeHtml(translation.model)}</dd>
2845
+ <dt>translated</dt><dd>${escapeHtml(translation.translated_at.slice(0, 19))}</dd>
2846
+ <dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
2847
+ <dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
2848
+ </dl>`;
2849
+ const revisions = listRevisions(db, typeId, enSlug, locale);
2850
+ historyPanel = `<div class="section">
2851
+ <div class="section-head">History</div>
2852
+ <div class="section-body">${renderRevisionTimeline(revisions)}</div>
2853
+ </div>`;
2854
+ } else {
2855
+ contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
2856
+ metaPanel = `<dl class="meta"><dt>status</dt><dd>${statusDot(status)}</dd></dl>`;
2857
+ }
2105
2858
  }
2106
2859
  db.close();
2107
- return c.html(renderLayout("History", body));
2860
+ const title = docTitleFromFrontmatter(enDoc.frontmatter, enSlug);
2861
+ const html = `<div class="toolbar">
2862
+ <a href="/">Overview</a><span class="sep">\u203A</span>
2863
+ <a href="/type/${encodePathSegment(typeId)}">${escapeHtml(type.config.label)}</a><span class="sep">\u203A</span>
2864
+ <span>${escapeHtml(enSlug)}</span>
2865
+ </div>
2866
+ <div class="tabs">${localeTabs}</div>
2867
+ ${metaPanel}
2868
+ ${contentPanel}
2869
+ ${historyPanel}`;
2870
+ return c.html(renderLayout(title, html, project, { activeTypeId: typeId }));
2871
+ });
2872
+ app.get("/staleness", (c) => {
2873
+ const worklist = buildWorklist(config);
2874
+ const rows = worklist.slice(0, 500).map((item) => {
2875
+ const href = `/type/${encodePathSegment(item.contentType)}/doc/${encodePathSegment(item.enSlug)}?locale=${encodePathSegment(item.locale)}`;
2876
+ return `<tr>
2877
+ <td>${escapeHtml(item.contentType)}</td>
2878
+ <td class="mono"><a href="${href}">${escapeHtml(item.enSlug)}</a></td>
2879
+ <td>${escapeHtml(item.locale)}</td>
2880
+ <td>${statusDot(item.reason === "stale" ? "stale" : "missing")}</td>
2881
+ </tr>`;
2882
+ }).join("");
2883
+ const html = `<div class="toolbar">Staleness <span class="dim">\xB7 ${worklist.length} entries</span></div>
2884
+ <table class="data">
2885
+ <thead><tr><th>Type</th><th>Slug</th><th>Locale</th><th>Status</th></tr></thead>
2886
+ <tbody>${rows || `<tr><td colspan="4" class="dim">All up to date</td></tr>`}</tbody>
2887
+ </table>`;
2888
+ return c.html(renderLayout("Staleness", html, project));
2108
2889
  });
2109
2890
  app.get("/api/staleness-matrix", (c) => {
2891
+ const worklist = buildWorklist(config);
2110
2892
  const matrix = {};
2111
2893
  for (const type of project.listTypes()) {
2112
2894
  matrix[type.id] = {};
2113
2895
  for (const locale of config.locales) {
2114
2896
  if (locale === config.defaultLocale) continue;
2115
- const enSlugs = type.list().map((doc) => doc.slug);
2116
- let stale = 0;
2117
- for (const enSlug of enSlugs) {
2118
- const enDoc = readEnDocument(config, type.config, enSlug);
2119
- if (!enDoc) continue;
2120
- const payload = getTranslatablePayload(enDoc, type.config);
2121
- const hash = computePageEnHash(payload.frontmatter, payload.body);
2122
- const db = openStore(config, "readonly");
2123
- const row = db.prepare(
2124
- `SELECT en_hash FROM translations WHERE content_type = ? AND en_slug = ? AND locale = ?`
2125
- ).get(type.id, enSlug, locale);
2126
- db.close();
2127
- if (!row || row.en_hash !== hash) stale++;
2128
- }
2129
- matrix[type.id][locale] = stale;
2897
+ matrix[type.id][locale] = worklist.filter(
2898
+ (item) => item.contentType === type.id && item.locale === locale
2899
+ ).length;
2130
2900
  }
2131
2901
  }
2132
2902
  return c.json(matrix);
@@ -2138,6 +2908,276 @@ async function startStudio(project, options = {}) {
2138
2908
  });
2139
2909
  }
2140
2910
 
2911
+ // cli/prompt-translate.ts
2912
+ init_cjs_shims();
2913
+
2914
+ // ../../node_modules/@inquirer/core/dist/lib/errors.js
2915
+ init_cjs_shims();
2916
+ var CancelPromptError = class extends Error {
2917
+ constructor() {
2918
+ super(...arguments);
2919
+ __publicField(this, "name", "CancelPromptError");
2920
+ __publicField(this, "message", "Prompt was canceled");
2921
+ }
2922
+ };
2923
+ function isInteractive() {
2924
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
2925
+ }
2926
+ function nonDefaultLocales(config) {
2927
+ return config.locales.filter((locale) => locale !== config.defaultLocale);
2928
+ }
2929
+ async function runPrompt(prompt) {
2930
+ try {
2931
+ return await prompt;
2932
+ } catch (error) {
2933
+ if (error instanceof CancelPromptError) process.exit(0);
2934
+ throw error;
2935
+ }
2936
+ }
2937
+ async function promptContentType(config) {
2938
+ return runPrompt(
2939
+ prompts.select({
2940
+ message: "Content type to translate",
2941
+ choices: [
2942
+ { name: "All content types", value: void 0 },
2943
+ ...config.types.map((type) => ({ name: type.id, value: type.id }))
2944
+ ]
2945
+ })
2946
+ );
2947
+ }
2948
+ async function promptLocalePreset(config) {
2949
+ const presets = Object.entries(config.localePresets ?? {}).filter(
2950
+ (entry) => Array.isArray(entry[1])
2951
+ );
2952
+ if (presets.length === 0) {
2953
+ const locales = nonDefaultLocales(config);
2954
+ if (locales.length <= 1) return {};
2955
+ const picked = await runPrompt(
2956
+ prompts.checkbox({
2957
+ message: "Locales to translate",
2958
+ choices: locales.map((locale) => ({ name: locale, value: locale, checked: true })),
2959
+ validate: (values) => values.length > 0 || "Pick at least one locale"
2960
+ })
2961
+ );
2962
+ return picked.length === locales.length ? {} : { locale: picked };
2963
+ }
2964
+ const choice = await runPrompt(
2965
+ prompts.select({
2966
+ message: "Locale preset",
2967
+ choices: [
2968
+ { name: "All locales", value: void 0 },
2969
+ ...presets.map(([name, locales]) => ({
2970
+ name: `${name} (${locales.join(", ")})`,
2971
+ value: name
2972
+ }))
2973
+ ]
2974
+ })
2975
+ );
2976
+ return choice ? { preset: choice } : {};
2977
+ }
2978
+ async function promptStrategy() {
2979
+ return runPrompt(
2980
+ prompts.select({
2981
+ message: "Translation strategy",
2982
+ choices: [
2983
+ { name: "Stale and missing", value: "all" },
2984
+ { name: "Missing only (skip stale)", value: "missing-only" }
2985
+ ],
2986
+ default: "all"
2987
+ })
2988
+ );
2989
+ }
2990
+ async function promptTranslateSelection(config, flags) {
2991
+ const selection = {
2992
+ contentType: flags.type,
2993
+ preset: flags.preset,
2994
+ locale: flags.locale,
2995
+ strategy: flags.strategy
2996
+ };
2997
+ if (!isInteractive()) return selection;
2998
+ if (!selection.contentType) {
2999
+ selection.contentType = await promptContentType(config);
3000
+ }
3001
+ if (!selection.preset && !selection.locale?.length) {
3002
+ Object.assign(selection, await promptLocalePreset(config));
3003
+ }
3004
+ if (!selection.strategy) {
3005
+ selection.strategy = await promptStrategy();
3006
+ }
3007
+ return selection;
3008
+ }
3009
+
3010
+ // cli/translate-progress.ts
3011
+ init_cjs_shims();
3012
+ var dim = (text) => `\x1B[2m${text}\x1B[0m`;
3013
+ var green = (text) => `\x1B[32m${text}\x1B[0m`;
3014
+ var yellow = (text) => `\x1B[33m${text}\x1B[0m`;
3015
+ var red = (text) => `\x1B[31m${text}\x1B[0m`;
3016
+ var cyan = (text) => `\x1B[36m${text}\x1B[0m`;
3017
+ function isInteractive2() {
3018
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
3019
+ }
3020
+ function progressBar(ratio, width = 28) {
3021
+ const clamped = Math.max(0, Math.min(1, ratio));
3022
+ const filled = Math.round(clamped * width);
3023
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
3024
+ }
3025
+ function labelForResult(result) {
3026
+ return `${result.contentType}/${result.enSlug}@${result.locale}`;
3027
+ }
3028
+ function statusForResult(result, dryRun) {
3029
+ if (result.failed) return red("failed");
3030
+ if (result.skipped) return dim("skipped");
3031
+ if (dryRun) return yellow("would translate");
3032
+ return green("translated");
3033
+ }
3034
+ function detailForResult(result) {
3035
+ const parts = [];
3036
+ if (result.durationMs !== void 0) parts.push(`${(result.durationMs / 1e3).toFixed(1)}s`);
3037
+ if (result.usage) {
3038
+ parts.push(`${formatTokenCount(result.usage.inputTokens)} in`);
3039
+ parts.push(`${formatTokenCount(result.usage.outputTokens)} out`);
3040
+ }
3041
+ if (result.estimatedCostUsd !== void 0) parts.push(formatUsd(result.estimatedCostUsd));
3042
+ if (result.error) parts.push(red(result.error));
3043
+ return parts.length > 0 ? dim(parts.join(" \xB7 ")) : "";
3044
+ }
3045
+ function formatSummaryLine(totals, dryRun) {
3046
+ const action = dryRun ? "Would translate" : "Translated";
3047
+ const parts = [
3048
+ `${action} ${totals.translated}`,
3049
+ `skipped ${totals.skipped}`
3050
+ ];
3051
+ if (totals.failed > 0) parts.push(red(`failed ${totals.failed}`));
3052
+ parts.push(`${formatTokenCount(totals.inputTokens)} in / ${formatTokenCount(totals.outputTokens)} out`);
3053
+ parts.push(`${formatUsd(totals.estimatedCostUsd)} est.`);
3054
+ parts.push(`${(totals.durationMs / 1e3).toFixed(1)}s`);
3055
+ return parts.join(" \xB7 ");
3056
+ }
3057
+ function createTranslateProgressReporter(options = {}) {
3058
+ const enabled = options.enabled ?? isInteractive2();
3059
+ const dryRun = Boolean(options.dryRun);
3060
+ const recentLimit = options.recentLimit ?? 6;
3061
+ if (!enabled) {
3062
+ return {
3063
+ onEvent(event) {
3064
+ if (event.type === "item-done") {
3065
+ const label = labelForResult(event.result);
3066
+ const status = statusForResult(event.result, dryRun);
3067
+ const detail = detailForResult(event.result);
3068
+ console.log(`${label}: ${status}${detail ? ` (${detail.replace(/\x1b\[[0-9;]*m/g, "")})` : ""}`);
3069
+ if (event.result.failed && event.result.error) {
3070
+ console.error(event.result.error);
3071
+ }
3072
+ return;
3073
+ }
3074
+ if (event.type === "done") {
3075
+ console.log(formatSummaryLine(event.totals, dryRun));
3076
+ }
3077
+ },
3078
+ finish() {
3079
+ }
3080
+ };
3081
+ }
3082
+ let total = 0;
3083
+ let concurrency = 1;
3084
+ let model;
3085
+ let done = 0;
3086
+ let inputTokens = 0;
3087
+ let outputTokens = 0;
3088
+ let estimatedCostUsd = 0;
3089
+ let active = [];
3090
+ const recent = [];
3091
+ let renderedLines = 0;
3092
+ let cursorHidden = false;
3093
+ function hideCursor() {
3094
+ if (!cursorHidden) {
3095
+ process.stdout.write("\x1B[?25l");
3096
+ cursorHidden = true;
3097
+ }
3098
+ }
3099
+ function showCursor() {
3100
+ if (cursorHidden) {
3101
+ process.stdout.write("\x1B[?25h");
3102
+ cursorHidden = false;
3103
+ }
3104
+ }
3105
+ function render() {
3106
+ hideCursor();
3107
+ if (renderedLines > 0) {
3108
+ process.stdout.write(`\x1B[${renderedLines}A`);
3109
+ }
3110
+ const lines = [];
3111
+ const title = dryRun ? "Dry run" : "Translating";
3112
+ lines.push(cyan(`${title} ${done}/${total}`) + dim(` \xB7 ${concurrency} parallel`) + (model ? dim(` \xB7 ${model}`) : ""));
3113
+ lines.push(progressBar(total === 0 ? 0 : done / total) + dim(` ${Math.round(total === 0 ? 0 : done / total * 100)}%`));
3114
+ lines.push(
3115
+ dim("Tokens ") + `${formatTokenCount(inputTokens)} in \xB7 ${formatTokenCount(outputTokens)} out` + dim(" \xB7 Cost ") + formatUsd(estimatedCostUsd) + dim(" est.")
3116
+ );
3117
+ if (active.length > 0) {
3118
+ lines.push(dim("Active ") + active.slice(0, 3).join(", ") + (active.length > 3 ? dim(` +${active.length - 3}`) : ""));
3119
+ } else if (done < total) {
3120
+ lines.push(dim("Active ") + "starting\u2026");
3121
+ } else {
3122
+ lines.push("");
3123
+ }
3124
+ lines.push("");
3125
+ if (recent.length === 0) {
3126
+ lines.push(dim("Waiting for first page\u2026"));
3127
+ } else {
3128
+ for (const result of recent) {
3129
+ const detail = detailForResult(result);
3130
+ lines.push(`${statusForResult(result, dryRun)} ${labelForResult(result)}${detail ? ` ${detail}` : ""}`);
3131
+ }
3132
+ }
3133
+ for (const line of lines) {
3134
+ process.stdout.write("\x1B[2K" + line + "\n");
3135
+ }
3136
+ renderedLines = lines.length;
3137
+ }
3138
+ return {
3139
+ onEvent(event) {
3140
+ switch (event.type) {
3141
+ case "start":
3142
+ total = event.total;
3143
+ concurrency = event.concurrency;
3144
+ model = event.model;
3145
+ render();
3146
+ break;
3147
+ case "item-start":
3148
+ active = event.active;
3149
+ render();
3150
+ break;
3151
+ case "item-done": {
3152
+ done += 1;
3153
+ active = active.filter((label) => label !== labelForResult(event.result));
3154
+ inputTokens += event.result.usage?.inputTokens ?? 0;
3155
+ outputTokens += event.result.usage?.outputTokens ?? 0;
3156
+ estimatedCostUsd += event.result.estimatedCostUsd ?? 0;
3157
+ recent.unshift(event.result);
3158
+ recent.splice(recentLimit);
3159
+ render();
3160
+ break;
3161
+ }
3162
+ case "done":
3163
+ showCursor();
3164
+ if (renderedLines > 0) {
3165
+ process.stdout.write(`\x1B[${renderedLines}A`);
3166
+ }
3167
+ process.stdout.write("\x1B[2K" + green("Done") + " \xB7 " + formatSummaryLine(event.totals, dryRun) + "\n");
3168
+ renderedLines = 0;
3169
+ for (const result of event.results.filter((entry) => entry.failed)) {
3170
+ process.stdout.write("\x1B[2K" + red(`${labelForResult(result)}: ${result.error ?? "failed"}`) + "\n");
3171
+ }
3172
+ break;
3173
+ }
3174
+ },
3175
+ finish() {
3176
+ showCursor();
3177
+ }
3178
+ };
3179
+ }
3180
+
2141
3181
  // cli/index.ts
2142
3182
  function parseArgs(argv) {
2143
3183
  const options = { cwd: process.cwd() };
@@ -2194,6 +3234,35 @@ function parseArgs(argv) {
2194
3234
  i++;
2195
3235
  continue;
2196
3236
  }
3237
+ if (arg === "--concurrency") {
3238
+ options.concurrency = Number(argv[++i]);
3239
+ i++;
3240
+ continue;
3241
+ }
3242
+ if (arg === "--no-progress") {
3243
+ options.noProgress = true;
3244
+ i++;
3245
+ continue;
3246
+ }
3247
+ if (arg === "--strategy") {
3248
+ const value = argv[++i];
3249
+ if (value !== "all" && value !== "missing-only") {
3250
+ throw new Error(`Unknown strategy: ${value} (expected all or missing-only)`);
3251
+ }
3252
+ options.strategy = value;
3253
+ i++;
3254
+ continue;
3255
+ }
3256
+ if (arg === "--out") {
3257
+ options.out = argv[++i];
3258
+ i++;
3259
+ continue;
3260
+ }
3261
+ if (arg === "--extension") {
3262
+ options.extension = argv[++i];
3263
+ i++;
3264
+ continue;
3265
+ }
2197
3266
  if (arg.startsWith("--")) {
2198
3267
  throw new Error(`Unknown flag: ${arg}`);
2199
3268
  }
@@ -2219,9 +3288,28 @@ async function main() {
2219
3288
  Commands:
2220
3289
  status Show EN docs + translation counts
2221
3290
  validate Validate EN files and sqlite consistency
3291
+ export-static Write raw MDX files for static hosting
2222
3292
  translate Translate stale/missing locale pages
2223
3293
  history <type> <slug> Show revision timeline
2224
3294
  studio Start read-only local studio
3295
+
3296
+ Export-static flags:
3297
+ --out <dir> Output directory (default: public)
3298
+ --extension <ext> File extension (default: mdx)
3299
+ --type <id> Comma-separated content types (default: all routable)
3300
+ --locale <code>... Locales to export (default: all configured)
3301
+
3302
+ Translate flags:
3303
+ --type <id> Content type (interactive picker in a TTY when omitted)
3304
+ --preset <name> Locale preset from config (interactive picker in a TTY)
3305
+ --locale <code>... Target locale(s); overrides --preset
3306
+ --slug <en-slug> Single English document
3307
+ --model <id> Gemini model override
3308
+ --concurrency <n> Parallel translations (default: 3)
3309
+ --dry-run List work without writing
3310
+ --force Re-translate even when hashes match
3311
+ --strategy <mode> all (default) or missing-only
3312
+ --no-progress Plain line logging instead of live progress
2225
3313
  `);
2226
3314
  return;
2227
3315
  }
@@ -2251,24 +3339,49 @@ Commands:
2251
3339
  if (!result.ok) process.exitCode = 1;
2252
3340
  break;
2253
3341
  }
3342
+ case "export-static": {
3343
+ const types = options.type?.split(",").map((t) => t.trim()).filter(Boolean);
3344
+ const { written } = writeStaticRawExports(project, {
3345
+ outDir: options.out,
3346
+ extension: options.extension ? `.${options.extension.replace(/^\./, "")}` : void 0,
3347
+ types,
3348
+ locales: options.locale
3349
+ });
3350
+ console.log(`Wrote ${written} static raw exports to ${options.out ?? "public"}`);
3351
+ break;
3352
+ }
2254
3353
  case "translate": {
2255
- const locales = resolveLocalesFromPreset(config, options.preset, options.locale);
3354
+ const selection = await promptTranslateSelection(config, {
3355
+ type: options.type,
3356
+ preset: options.preset,
3357
+ locale: options.locale,
3358
+ strategy: options.strategy
3359
+ });
3360
+ const locales = resolveLocalesFromPreset(config, selection.preset, selection.locale);
2256
3361
  const worklist = buildWorklist(config, {
2257
- contentType: options.type,
3362
+ contentType: selection.contentType,
2258
3363
  locales,
2259
- enSlug: options.slug
3364
+ enSlug: options.slug,
3365
+ strategy: selection.strategy ?? "all"
3366
+ });
3367
+ if (worklist.length === 0) {
3368
+ console.log("Nothing to translate.");
3369
+ break;
3370
+ }
3371
+ const reporter = createTranslateProgressReporter({
3372
+ enabled: !options.noProgress,
3373
+ dryRun: options.dryRun
2260
3374
  });
2261
- console.log(`Translating ${worklist.length} page(s)...`);
2262
3375
  const results = await translateWorklist(config, worklist, {
2263
3376
  model: options.model,
2264
3377
  dryRun: options.dryRun,
2265
- force: options.force
3378
+ force: options.force,
3379
+ concurrency: options.concurrency,
3380
+ onProgress: reporter.onEvent
2266
3381
  });
2267
- for (const result of results) {
2268
- console.log(
2269
- `${result.contentType}/${result.enSlug}@${result.locale}: ${result.skipped ? "skipped" : "translated"}${result.model ? ` (${result.model})` : ""}`
2270
- );
2271
- }
3382
+ reporter.finish();
3383
+ const failed = results.filter((result) => result.failed);
3384
+ if (failed.length > 0) process.exitCode = 1;
2272
3385
  break;
2273
3386
  }
2274
3387
  case "history": {