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