scribe-cms 0.0.2 → 0.0.5

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 (64) hide show
  1. package/README.md +2 -3
  2. package/bin/scribe.js +2 -11
  3. package/dist/cli/index.cjs +1437 -225
  4. package/dist/cli/index.cjs.map +1 -1
  5. package/dist/cli/index.js +1437 -226
  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 +591 -147
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.js +587 -148
  14. package/dist/index.js.map +1 -1
  15. package/dist/runtime.cjs +14 -2
  16. package/dist/runtime.cjs.map +1 -1
  17. package/dist/runtime.js +14 -2
  18. package/dist/runtime.js.map +1 -1
  19. package/dist/src/config/resolve-config.d.ts.map +1 -1
  20. package/dist/src/core/field.d.ts +2 -0
  21. package/dist/src/core/field.d.ts.map +1 -1
  22. package/dist/src/core/types.d.ts +5 -1
  23. package/dist/src/core/types.d.ts.map +1 -1
  24. package/dist/src/export/build-static-raw-exports.d.ts +37 -0
  25. package/dist/src/export/build-static-raw-exports.d.ts.map +1 -0
  26. package/dist/src/export/write-static-raw-exports.d.ts +12 -0
  27. package/dist/src/export/write-static-raw-exports.d.ts.map +1 -0
  28. package/dist/src/history/record-revision.d.ts +2 -0
  29. package/dist/src/history/record-revision.d.ts.map +1 -1
  30. package/dist/src/index.d.ts +7 -0
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/storage/sqlite.d.ts.map +1 -1
  33. package/dist/src/storage/translations.d.ts +6 -0
  34. package/dist/src/storage/translations.d.ts.map +1 -1
  35. package/dist/src/translate/gemini-client.d.ts +7 -0
  36. package/dist/src/translate/gemini-client.d.ts.map +1 -1
  37. package/dist/src/translate/gemini-models.d.ts +8 -0
  38. package/dist/src/translate/gemini-models.d.ts.map +1 -0
  39. package/dist/src/translate/gemini-pricing.d.ts +11 -0
  40. package/dist/src/translate/gemini-pricing.d.ts.map +1 -0
  41. package/dist/src/translate/page-translator.d.ts +38 -1
  42. package/dist/src/translate/page-translator.d.ts.map +1 -1
  43. package/dist/src/translate/prompts/translation-prompt.d.ts +1 -2
  44. package/dist/src/translate/prompts/translation-prompt.d.ts.map +1 -1
  45. package/dist/src/translate/response-schema.d.ts +7 -0
  46. package/dist/src/translate/response-schema.d.ts.map +1 -0
  47. package/dist/src/translate/validate-translation.d.ts +14 -0
  48. package/dist/src/translate/validate-translation.d.ts.map +1 -0
  49. package/dist/src/translate/worklist.d.ts +3 -0
  50. package/dist/src/translate/worklist.d.ts.map +1 -1
  51. package/dist/src/validate/validate-assets.d.ts +20 -0
  52. package/dist/src/validate/validate-assets.d.ts.map +1 -0
  53. package/dist/src/validate/validate-project.d.ts +1 -1
  54. package/dist/src/validate/validate-project.d.ts.map +1 -1
  55. package/dist/src/version.d.ts +4 -0
  56. package/dist/src/version.d.ts.map +1 -0
  57. package/dist/studio/server.cjs +611 -194
  58. package/dist/studio/server.cjs.map +1 -1
  59. package/dist/studio/server.d.ts +1 -1
  60. package/dist/studio/server.d.ts.map +1 -1
  61. package/dist/studio/server.js +611 -194
  62. package/dist/studio/server.js.map +1 -1
  63. package/package.json +16 -5
  64. package/bin/ensure-built.mjs +0 -31
@@ -12,6 +12,8 @@ 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');
16
+ var url = require('url');
15
17
 
16
18
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
17
19
 
@@ -23,6 +25,7 @@ var dotenv__default = /*#__PURE__*/_interopDefault(dotenv);
23
25
 
24
26
  var __defProp = Object.defineProperty;
25
27
  var __getOwnPropNames = Object.getOwnPropertyNames;
28
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
26
29
  var __esm = (fn, res) => function __init() {
27
30
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
28
31
  };
@@ -30,6 +33,7 @@ var __export = (target, all) => {
30
33
  for (var name in all)
31
34
  __defProp(target, name, { get: all[name], enumerable: true });
32
35
  };
36
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
33
37
 
34
38
  // ../../node_modules/tsup/assets/cjs_shims.js
35
39
  var getImportMetaUrl, importMetaUrl;
@@ -52,17 +56,27 @@ function resolveStorePath(config) {
52
56
  }
53
57
  function openStore(config, mode = "readwrite") {
54
58
  const storePath = resolveStorePath(config);
55
- fs2__default.default.mkdirSync(path3__default.default.dirname(storePath), { recursive: true });
59
+ if (mode === "readwrite") {
60
+ fs2__default.default.mkdirSync(path3__default.default.dirname(storePath), { recursive: true });
61
+ }
56
62
  const db = new Database__default.default(storePath, { readonly: mode === "readonly" });
57
63
  if (mode === "readwrite") {
58
64
  migrate(db);
59
65
  }
60
66
  return db;
61
67
  }
68
+ function addColumnIfMissing(db, table, column, ddlType) {
69
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
70
+ if (!columns.some((c) => c.name === column)) {
71
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
72
+ }
73
+ }
62
74
  function migrate(db) {
63
75
  for (const sql of MIGRATIONS) {
64
76
  db.exec(sql);
65
77
  }
78
+ addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
79
+ addColumnIfMissing(db, "revisions", "body", "TEXT");
66
80
  db.prepare(
67
81
  `INSERT INTO meta(key, value) VALUES('schema_version', ?)
68
82
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
@@ -75,7 +89,7 @@ var SCHEMA_VERSION, MIGRATIONS;
75
89
  var init_sqlite = __esm({
76
90
  "src/storage/sqlite.ts"() {
77
91
  init_cjs_shims();
78
- SCHEMA_VERSION = 2;
92
+ SCHEMA_VERSION = 3;
79
93
  MIGRATIONS = [
80
94
  `CREATE TABLE IF NOT EXISTS meta (
81
95
  key TEXT PRIMARY KEY,
@@ -187,6 +201,22 @@ function unwrapSchema(schema) {
187
201
  }
188
202
  return current;
189
203
  }
204
+ function peelOptionalWrappers(schema) {
205
+ let current = schema;
206
+ for (let i = 0; i < 8; i++) {
207
+ const type = current._def?.type;
208
+ if (type === "optional" || type === "nullable") {
209
+ current = current.unwrap();
210
+ continue;
211
+ }
212
+ if (type === "default") {
213
+ current = current.removeDefault();
214
+ continue;
215
+ }
216
+ break;
217
+ }
218
+ return current;
219
+ }
190
220
 
191
221
  // src/core/types.ts
192
222
  init_cjs_shims();
@@ -203,14 +233,14 @@ var SLUG_PLACEHOLDER = "{slug}";
203
233
  function isRoutableType(type) {
204
234
  return typeof type.path === "string" && type.path.length > 0;
205
235
  }
206
- function assertValidPathTemplate(path10, typeId) {
207
- if (!path10.startsWith("/")) {
208
- throw new Error(`Content type "${typeId}": path must start with / (got "${path10}")`);
236
+ function assertValidPathTemplate(path13, typeId) {
237
+ if (!path13.startsWith("/")) {
238
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path13}")`);
209
239
  }
210
- const count = (path10.match(/\{slug\}/g) ?? []).length;
240
+ const count = (path13.match(/\{slug\}/g) ?? []).length;
211
241
  if (count !== 1) {
212
242
  throw new Error(
213
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path10}")`
243
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path13}")`
214
244
  );
215
245
  }
216
246
  }
@@ -262,6 +292,7 @@ function resolveConfig(input, baseDir) {
262
292
  const projectRoot = path3__default.default.resolve(baseDir ?? process.cwd(), raw.rootDir);
263
293
  const contentRoot = path3__default.default.resolve(projectRoot, raw.contentDir ?? "content");
264
294
  const storePath = path3__default.default.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
295
+ const assetsPath = raw.assetsDir ? path3__default.default.resolve(projectRoot, raw.assetsDir) : void 0;
265
296
  const seenIds = /* @__PURE__ */ new Set();
266
297
  const types = raw.types.map((type) => {
267
298
  if (seenIds.has(type.id)) {
@@ -282,6 +313,7 @@ function resolveConfig(input, baseDir) {
282
313
  const config = {
283
314
  rootDir: contentRoot,
284
315
  storePath,
316
+ assetsPath,
285
317
  locales: [...raw.locales],
286
318
  defaultLocale,
287
319
  localePresets: raw.localePresets,
@@ -338,10 +370,10 @@ function introspectSchema(schema, prefix = []) {
338
370
  }
339
371
  function extractByPaths(data, paths) {
340
372
  const out = {};
341
- for (const path10 of paths) {
342
- const value = getAtPath(data, path10);
373
+ for (const path13 of paths) {
374
+ const value = getAtPath(data, path13);
343
375
  if (value !== void 0) {
344
- setAtPath(out, path10.filter((p) => p !== "*"), value);
376
+ setAtPath(out, path13.filter((p) => p !== "*"), value);
345
377
  }
346
378
  }
347
379
  return out;
@@ -359,13 +391,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
359
391
  const translatable = pickTranslatable(localeData, schema);
360
392
  return deepMerge(structural, translatable);
361
393
  }
362
- function getAtPath(obj, path10) {
394
+ function getAtPath(obj, path13) {
363
395
  let current = obj;
364
- for (const segment of path10) {
396
+ for (const segment of path13) {
365
397
  if (segment === "*") {
366
398
  if (!Array.isArray(current)) return void 0;
367
399
  return current.map(
368
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path10.slice(path10.indexOf("*") + 1)) : void 0
400
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path13.slice(path13.indexOf("*") + 1)) : void 0
369
401
  );
370
402
  }
371
403
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -373,13 +405,13 @@ function getAtPath(obj, path10) {
373
405
  }
374
406
  return current;
375
407
  }
376
- function setAtPath(obj, path10, value) {
377
- if (path10.length === 0) return;
378
- if (path10.length === 1) {
379
- obj[path10[0]] = value;
408
+ function setAtPath(obj, path13, value) {
409
+ if (path13.length === 0) return;
410
+ if (path13.length === 1) {
411
+ obj[path13[0]] = value;
380
412
  return;
381
413
  }
382
- const [head, ...rest] = path10;
414
+ const [head, ...rest] = path13;
383
415
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
384
416
  obj[head] = {};
385
417
  }
@@ -618,14 +650,18 @@ function listTranslationsForEnSlug(db, contentType, enSlug) {
618
650
  `SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
619
651
  ).all(contentType, enSlug);
620
652
  }
653
+ function listTranslationsForLocale(db, contentType, locale) {
654
+ return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
655
+ }
621
656
  function bulkLoadTranslations(db) {
622
657
  return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
623
658
  }
624
659
  function appendRevision(db, input) {
625
660
  const result = db.prepare(
626
661
  `INSERT INTO revisions (
627
- content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at, model, body_preview
628
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
662
+ content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
663
+ model, body_preview, frontmatter_json, body
664
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
629
665
  ).run(
630
666
  input.contentType,
631
667
  input.enSlug,
@@ -635,7 +671,9 @@ function appendRevision(db, input) {
635
671
  input.bodyHash,
636
672
  input.createdAt,
637
673
  input.model ?? null,
638
- input.bodyPreview ?? null
674
+ input.bodyPreview ?? null,
675
+ input.frontmatterJson ?? null,
676
+ input.body ?? null
639
677
  );
640
678
  return Number(result.lastInsertRowid);
641
679
  }
@@ -1445,41 +1483,6 @@ function loadConfigSync(options = {}) {
1445
1483
 
1446
1484
  // src/validate/validate-project.ts
1447
1485
  init_cjs_shims();
1448
-
1449
- // src/hash/page-hash.ts
1450
- init_cjs_shims();
1451
- function sha256(input) {
1452
- return crypto.createHash("sha256").update(input, "utf8").digest("hex");
1453
- }
1454
- function computePageEnHash(translatableFrontmatter, body) {
1455
- const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1456
- return sha256(payload);
1457
- }
1458
- function computeBodyHash(body) {
1459
- return sha256(body);
1460
- }
1461
-
1462
- // src/history/record-revision.ts
1463
- init_cjs_shims();
1464
- init_sqlite();
1465
- function recordRevision(config, input) {
1466
- const db = openStore(config, "readwrite");
1467
- const id = appendRevision(db, {
1468
- contentType: input.contentType,
1469
- enSlug: input.enSlug,
1470
- locale: input.locale,
1471
- revisionKind: input.revisionKind,
1472
- enHash: input.enHash,
1473
- bodyHash: computeBodyHash(input.body),
1474
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1475
- model: input.model,
1476
- bodyPreview: input.body.slice(0, 200)
1477
- });
1478
- db.close();
1479
- return id;
1480
- }
1481
-
1482
- // src/validate/validate-project.ts
1483
1486
  init_sqlite();
1484
1487
 
1485
1488
  // src/validate/validate-relations.ts
@@ -1564,6 +1567,75 @@ function validateRelations(project) {
1564
1567
  return issues;
1565
1568
  }
1566
1569
 
1570
+ // src/validate/validate-assets.ts
1571
+ init_cjs_shims();
1572
+ var IMAGE_EXT = String.raw`(?:png|jpe?g|webp|gif|svg)`;
1573
+ var IMAGE_WEB_PATH = String.raw`\/[\w\-./]+\.${IMAGE_EXT}`;
1574
+ var MARKDOWN_IMAGE_RE = new RegExp(
1575
+ String.raw`!\[[^\]]*\]\((${IMAGE_WEB_PATH})\)`,
1576
+ "gi"
1577
+ );
1578
+ var HTML_SRC_RE = new RegExp(String.raw`src=["'](${IMAGE_WEB_PATH})["']`, "gi");
1579
+ function isImageWebPath(value) {
1580
+ return new RegExp(String.raw`^${IMAGE_WEB_PATH}$`, "i").test(value);
1581
+ }
1582
+ function isSiteAssetPath(webPath) {
1583
+ const segment = webPath.split("/")[1];
1584
+ return Boolean(segment && !segment.includes("."));
1585
+ }
1586
+ function addImagePath(webPath, out) {
1587
+ if (isSiteAssetPath(webPath)) out.add(webPath);
1588
+ }
1589
+ function collectBodyImagePaths(body, out) {
1590
+ for (const re of [MARKDOWN_IMAGE_RE, HTML_SRC_RE]) {
1591
+ for (const match of body.matchAll(re)) {
1592
+ addImagePath(match[1], out);
1593
+ }
1594
+ }
1595
+ }
1596
+ function collectFrontmatterImagePaths(value, out) {
1597
+ if (typeof value === "string") {
1598
+ if (isImageWebPath(value)) addImagePath(value, out);
1599
+ return;
1600
+ }
1601
+ if (Array.isArray(value)) {
1602
+ for (const item of value) collectFrontmatterImagePaths(item, out);
1603
+ return;
1604
+ }
1605
+ if (value && typeof value === "object") {
1606
+ for (const nested of Object.values(value)) {
1607
+ collectFrontmatterImagePaths(nested, out);
1608
+ }
1609
+ }
1610
+ }
1611
+ function collectImagePaths(frontmatter, body) {
1612
+ const paths = /* @__PURE__ */ new Set();
1613
+ collectFrontmatterImagePaths(frontmatter, paths);
1614
+ collectBodyImagePaths(body, paths);
1615
+ return [...paths].sort();
1616
+ }
1617
+ function assetFilePath(assetsPath, webPath) {
1618
+ return path3__default.default.join(assetsPath, webPath.replace(/^\//, ""));
1619
+ }
1620
+ function validateDocumentAssets(config, input) {
1621
+ const assetsPath = config.assetsPath;
1622
+ if (!assetsPath) return [];
1623
+ const issues = [];
1624
+ for (const webPath of collectImagePaths(input.frontmatter, input.body)) {
1625
+ const filePath = assetFilePath(assetsPath, webPath);
1626
+ if (fs2__default.default.existsSync(filePath)) continue;
1627
+ issues.push({
1628
+ level: "warning",
1629
+ contentType: input.contentType,
1630
+ enSlug: input.enSlug,
1631
+ locale: input.locale,
1632
+ field: "asset",
1633
+ message: `Missing image asset ${webPath} (expected ${filePath})`
1634
+ });
1635
+ }
1636
+ return issues;
1637
+ }
1638
+
1567
1639
  // src/validate/validate-slug-suffix.ts
1568
1640
  init_cjs_shims();
1569
1641
  function validateTranslationSlugSuffixes(config, db) {
@@ -1626,8 +1698,6 @@ function validateProject(config) {
1626
1698
  });
1627
1699
  continue;
1628
1700
  }
1629
- const payload = getTranslatablePayload(enDoc, type);
1630
- const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1631
1701
  const crossIssues = type.crossValidate?.(enDoc.frontmatter, {
1632
1702
  locale: config.defaultLocale,
1633
1703
  defaultLocale: config.defaultLocale,
@@ -1645,28 +1715,18 @@ function validateProject(config) {
1645
1715
  message: issue.message
1646
1716
  });
1647
1717
  }
1718
+ for (const issue of validateDocumentAssets(config, {
1719
+ contentType: type.id,
1720
+ enSlug,
1721
+ frontmatter: enDoc.frontmatter,
1722
+ body: enDoc.content
1723
+ })) {
1724
+ issues.push(issue);
1725
+ }
1648
1726
  for (const locale of config.locales) {
1649
1727
  if (locale === config.defaultLocale) continue;
1650
1728
  const row = getTranslation(db, type.id, enSlug, locale);
1651
1729
  if (!row) continue;
1652
- if (row.en_hash !== currentEnHash) {
1653
- issues.push({
1654
- level: "warning",
1655
- contentType: type.id,
1656
- enSlug,
1657
- locale,
1658
- field: "en_hash",
1659
- message: "Translation is stale (en_hash mismatch)"
1660
- });
1661
- recordRevision(config, {
1662
- contentType: type.id,
1663
- enSlug,
1664
- locale,
1665
- revisionKind: "en_edit_detected",
1666
- enHash: currentEnHash,
1667
- body: row.body
1668
- });
1669
- }
1670
1730
  const localeFm = JSON.parse(row.frontmatter_json);
1671
1731
  for (const issue of validateLocaleBuiltinFields(localeFm)) {
1672
1732
  issues.push({
@@ -1678,6 +1738,15 @@ function validateProject(config) {
1678
1738
  message: issue.message
1679
1739
  });
1680
1740
  }
1741
+ for (const issue of validateDocumentAssets(config, {
1742
+ contentType: type.id,
1743
+ enSlug,
1744
+ locale,
1745
+ frontmatter: localeFm,
1746
+ body: row.body
1747
+ })) {
1748
+ issues.push(issue);
1749
+ }
1681
1750
  }
1682
1751
  }
1683
1752
  try {
@@ -1748,6 +1817,21 @@ function validateProject(config) {
1748
1817
 
1749
1818
  // src/translate/worklist.ts
1750
1819
  init_cjs_shims();
1820
+
1821
+ // src/hash/page-hash.ts
1822
+ init_cjs_shims();
1823
+ function sha256(input) {
1824
+ return crypto.createHash("sha256").update(input, "utf8").digest("hex");
1825
+ }
1826
+ function computePageEnHash(translatableFrontmatter, body) {
1827
+ const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1828
+ return sha256(payload);
1829
+ }
1830
+ function computeBodyHash(body) {
1831
+ return sha256(body);
1832
+ }
1833
+
1834
+ // src/translate/worklist.ts
1751
1835
  init_sqlite();
1752
1836
  function listEnSlugs3(rootDir, contentDir) {
1753
1837
  const dir = path3__default.default.join(rootDir, contentDir);
@@ -1793,6 +1877,10 @@ function buildWorklist(config, options = {}) {
1793
1877
  }
1794
1878
  }
1795
1879
  db.close();
1880
+ const strategy = options.strategy ?? "all";
1881
+ if (strategy === "missing-only") {
1882
+ return items.filter((item) => item.reason === "missing");
1883
+ }
1796
1884
  return items;
1797
1885
  }
1798
1886
  function resolveLocalesFromPreset(config, preset, explicitLocales) {
@@ -1805,11 +1893,59 @@ function resolveLocalesFromPreset(config, preset, explicitLocales) {
1805
1893
 
1806
1894
  // src/translate/page-translator.ts
1807
1895
  init_cjs_shims();
1896
+
1897
+ // src/history/record-revision.ts
1898
+ init_cjs_shims();
1899
+ init_sqlite();
1900
+ function recordRevision(config, input) {
1901
+ const db = openStore(config, "readwrite");
1902
+ const id = appendRevision(db, {
1903
+ contentType: input.contentType,
1904
+ enSlug: input.enSlug,
1905
+ locale: input.locale,
1906
+ revisionKind: input.revisionKind,
1907
+ enHash: input.enHash,
1908
+ bodyHash: computeBodyHash(input.body),
1909
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1910
+ model: input.model,
1911
+ bodyPreview: input.body.slice(0, 200),
1912
+ frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
1913
+ body: input.body
1914
+ });
1915
+ db.close();
1916
+ return id;
1917
+ }
1918
+
1919
+ // src/translate/page-translator.ts
1808
1920
  init_sqlite();
1809
1921
 
1810
1922
  // src/translate/gemini-client.ts
1811
1923
  init_cjs_shims();
1812
- var DEFAULT_MODEL = "gemini-2.5-pro";
1924
+
1925
+ // src/translate/gemini-models.ts
1926
+ init_cjs_shims();
1927
+ var GEMINI_MODEL_IDS = {
1928
+ "gemini-2.5-pro": "gemini-2.5-pro",
1929
+ "gemini-3.1-pro": "gemini-3.1-pro-preview"
1930
+ };
1931
+ var DEFAULT_GEMINI_MODEL = "gemini-3.1-pro";
1932
+ function stripModelsPrefix(model) {
1933
+ return model.replace(/^models\//, "");
1934
+ }
1935
+ function resolveGeminiModelId(model) {
1936
+ const name = stripModelsPrefix(model);
1937
+ return GEMINI_MODEL_IDS[name] ?? name;
1938
+ }
1939
+ function normalizeGeminiDisplayName(model) {
1940
+ const name = stripModelsPrefix(model);
1941
+ if (name in GEMINI_MODEL_IDS) return name;
1942
+ const alias = Object.entries(GEMINI_MODEL_IDS).find(([, apiId]) => apiId === name);
1943
+ if (alias) return alias[0];
1944
+ return name;
1945
+ }
1946
+
1947
+ // src/translate/gemini-client.ts
1948
+ var DEFAULT_MODEL = DEFAULT_GEMINI_MODEL;
1813
1949
  function extractJson(text) {
1814
1950
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
1815
1951
  if (fenced?.[1]) return fenced[1].trim();
@@ -1823,13 +1959,17 @@ async function translatePageWithGemini(input) {
1823
1959
  if (!apiKey) {
1824
1960
  throw new Error("GEMINI_API_KEY is required for scribe translate");
1825
1961
  }
1826
- const model = input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL;
1962
+ const displayModel = normalizeGeminiDisplayName(
1963
+ input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL
1964
+ );
1965
+ const apiModel = resolveGeminiModelId(displayModel);
1827
1966
  const ai = new genai.GoogleGenAI({ apiKey });
1828
1967
  const response = await ai.models.generateContent({
1829
- model,
1968
+ model: apiModel,
1830
1969
  contents: input.prompt,
1831
1970
  config: {
1832
- responseMimeType: "application/json"
1971
+ responseMimeType: "application/json",
1972
+ ...input.responseSchema ? { responseSchema: input.responseSchema } : {}
1833
1973
  }
1834
1974
  });
1835
1975
  const raw = response.text ?? "";
@@ -1837,7 +1977,50 @@ async function translatePageWithGemini(input) {
1837
1977
  if (!parsed.frontmatter || typeof parsed.body !== "string") {
1838
1978
  throw new Error("Gemini response missing frontmatter/body");
1839
1979
  }
1840
- return { model, raw, parsed };
1980
+ const usageMetadata = response.usageMetadata;
1981
+ const usage = {
1982
+ inputTokens: usageMetadata?.promptTokenCount ?? 0,
1983
+ outputTokens: usageMetadata?.candidatesTokenCount ?? 0,
1984
+ totalTokens: usageMetadata?.totalTokenCount ?? 0
1985
+ };
1986
+ return { model: displayModel, raw, parsed, usage };
1987
+ }
1988
+
1989
+ // src/translate/gemini-pricing.ts
1990
+ init_cjs_shims();
1991
+ var CONTEXT_TIER_TOKENS = 2e5;
1992
+ var GEMINI_PRICING_USD = {
1993
+ "gemini-3.1-pro": { input: [2, 4], output: [12, 18] }
1994
+ };
1995
+ function normalizeModelId(model) {
1996
+ return model.replace(/^models\//, "").toLowerCase();
1997
+ }
1998
+ function tierRate(tokens, tiers) {
1999
+ return tokens <= CONTEXT_TIER_TOKENS ? tiers[0] : tiers[1];
2000
+ }
2001
+ function resolveModelPricing(model) {
2002
+ const id = normalizeModelId(model);
2003
+ if (GEMINI_PRICING_USD[id]) return GEMINI_PRICING_USD[id];
2004
+ const match = Object.entries(GEMINI_PRICING_USD).find(([key]) => id.includes(key));
2005
+ return match?.[1];
2006
+ }
2007
+ function estimateTranslationCostUsd(model, inputTokens, outputTokens) {
2008
+ const pricing = resolveModelPricing(model);
2009
+ if (!pricing) return void 0;
2010
+ const inputRate = tierRate(inputTokens, pricing.input);
2011
+ const outputRate = tierRate(outputTokens, pricing.output);
2012
+ return inputTokens / 1e6 * inputRate + outputTokens / 1e6 * outputRate;
2013
+ }
2014
+ function formatTokenCount(tokens) {
2015
+ if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(2)}M`;
2016
+ if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`;
2017
+ return String(tokens);
2018
+ }
2019
+ function formatUsd(amount) {
2020
+ if (amount === void 0) return "n/a";
2021
+ if (amount >= 1) return `$${amount.toFixed(2)}`;
2022
+ if (amount >= 0.01) return `$${amount.toFixed(3)}`;
2023
+ return `$${amount.toFixed(4)}`;
1841
2024
  }
1842
2025
 
1843
2026
  // src/translate/prompts/translation-prompt.ts
@@ -1874,6 +2057,7 @@ function buildPageTranslationPrompt(input) {
1874
2057
  prompt,
1875
2058
  "",
1876
2059
  ...input.resolved.context ? ["## Context", input.resolved.context, ""] : [],
2060
+ ...input.contextLabel ? [`Document: ${input.contextLabel}`, ""] : [],
1877
2061
  "## Rules",
1878
2062
  ...input.resolved.rules.map((rule) => `- ${rule}`),
1879
2063
  "",
@@ -1881,10 +2065,6 @@ function buildPageTranslationPrompt(input) {
1881
2065
  "Return ONLY valid JSON with keys:",
1882
2066
  input.slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).",
1883
2067
  "",
1884
- "## EN metadata",
1885
- `title: ${input.enTitle}`,
1886
- `description: ${input.enDescription}`,
1887
- "",
1888
2068
  "## EN translatable frontmatter (JSON)",
1889
2069
  JSON.stringify(input.translatableFrontmatter, null, 2),
1890
2070
  "",
@@ -1894,6 +2074,74 @@ function buildPageTranslationPrompt(input) {
1894
2074
  return lines.join("\n");
1895
2075
  }
1896
2076
 
2077
+ // src/translate/response-schema.ts
2078
+ init_cjs_shims();
2079
+ function getObjectShape(schema) {
2080
+ const base = peelOptionalWrappers(schema);
2081
+ if (base instanceof Object && "shape" in base) {
2082
+ return base.shape;
2083
+ }
2084
+ return null;
2085
+ }
2086
+ function getArraySchema(schema) {
2087
+ const base = peelOptionalWrappers(schema);
2088
+ if (base instanceof Object && "element" in base && base._def?.type === "array") {
2089
+ return base;
2090
+ }
2091
+ return null;
2092
+ }
2093
+ function extractTranslatableFromStructural(schema) {
2094
+ const arraySchema = getArraySchema(schema);
2095
+ if (arraySchema) {
2096
+ const inner = buildTranslatableSubschema(arraySchema.element);
2097
+ if (inner) return zod.z.array(inner);
2098
+ return null;
2099
+ }
2100
+ if (getObjectShape(schema)) {
2101
+ return buildTranslatableSubschema(schema);
2102
+ }
2103
+ return null;
2104
+ }
2105
+ function buildTranslatableSubschema(schema) {
2106
+ const shape = getObjectShape(schema);
2107
+ if (!shape) return null;
2108
+ const out = {};
2109
+ for (const [key, child] of Object.entries(shape)) {
2110
+ const childSchema = child;
2111
+ const kind = getFieldKind(childSchema);
2112
+ if (kind === "translatable") {
2113
+ out[key] = peelOptionalWrappers(childSchema);
2114
+ } else if (kind === "structural") {
2115
+ const nested = extractTranslatableFromStructural(childSchema);
2116
+ if (nested) out[key] = nested;
2117
+ }
2118
+ }
2119
+ if (Object.keys(out).length === 0) return null;
2120
+ return zod.z.object(out);
2121
+ }
2122
+ function buildGeminiResponseSchema(schema, slugStrategy) {
2123
+ try {
2124
+ const translatable = buildTranslatableSubschema(schema);
2125
+ if (!translatable) return null;
2126
+ const responseShape = {
2127
+ frontmatter: translatable,
2128
+ body: zod.z.string()
2129
+ };
2130
+ if (slugStrategy === "localized") {
2131
+ responseShape.slug = zod.z.string();
2132
+ }
2133
+ const jsonSchema = zod.z.toJSONSchema(zod.z.object(responseShape), {
2134
+ target: "draft-2020-12",
2135
+ unrepresentable: "any",
2136
+ io: "output"
2137
+ });
2138
+ delete jsonSchema.$schema;
2139
+ return jsonSchema;
2140
+ } catch {
2141
+ return null;
2142
+ }
2143
+ }
2144
+
1897
2145
  // src/translate/resolve-translate-config.ts
1898
2146
  init_cjs_shims();
1899
2147
  function slugStrategyRules(slugStrategy, preserveTerms) {
@@ -1934,86 +2182,313 @@ function resolveTranslateConfig(config, type) {
1934
2182
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
1935
2183
  }
1936
2184
 
2185
+ // src/translate/validate-translation.ts
2186
+ init_cjs_shims();
2187
+ function formatZodIssues(error) {
2188
+ return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
2189
+ }
2190
+ function sanitizeTranslatedFrontmatter(rawFrontmatter, schema) {
2191
+ return pickTranslatable(rawFrontmatter, schema);
2192
+ }
2193
+ function validateTranslatedFrontmatter(enDoc, localeFrontmatter, typeSchema) {
2194
+ const frontmatter = sanitizeTranslatedFrontmatter(localeFrontmatter, typeSchema);
2195
+ const merged = mergeStructuralOntoLocale(
2196
+ frontmatter,
2197
+ enDoc.frontmatter,
2198
+ typeSchema
2199
+ );
2200
+ const parsed = typeSchema.safeParse(merged);
2201
+ if (!parsed.success) {
2202
+ return { ok: false, error: formatZodIssues(parsed.error) };
2203
+ }
2204
+ return { ok: true, frontmatter };
2205
+ }
2206
+
1937
2207
  // src/translate/page-translator.ts
2208
+ function summarizeResults(results, durationMs) {
2209
+ return results.reduce(
2210
+ (totals, result) => {
2211
+ if (result.failed) totals.failed += 1;
2212
+ else if (result.skipped) totals.skipped += 1;
2213
+ else totals.translated += 1;
2214
+ totals.inputTokens += result.usage?.inputTokens ?? 0;
2215
+ totals.outputTokens += result.usage?.outputTokens ?? 0;
2216
+ totals.estimatedCostUsd += result.estimatedCostUsd ?? 0;
2217
+ return totals;
2218
+ },
2219
+ {
2220
+ translated: 0,
2221
+ skipped: 0,
2222
+ failed: 0,
2223
+ inputTokens: 0,
2224
+ outputTokens: 0,
2225
+ estimatedCostUsd: 0,
2226
+ durationMs
2227
+ }
2228
+ );
2229
+ }
2230
+ function formatTranslateError(error) {
2231
+ const message = error instanceof Error ? error.message : String(error);
2232
+ try {
2233
+ const parsed = JSON.parse(message);
2234
+ if (parsed.error?.message) return parsed.error.message;
2235
+ } catch {
2236
+ }
2237
+ const embedded = message.match(/"message"\s*:\s*"((?:\\.|[^"\\])*)"/);
2238
+ if (embedded?.[1]) {
2239
+ return embedded[1].replace(/\\"/g, '"').replace(/\\n/g, "\n");
2240
+ }
2241
+ return message.length > 160 ? `${message.slice(0, 157)}\u2026` : message;
2242
+ }
2243
+ async function runPool(items, concurrency, worker) {
2244
+ if (items.length === 0) return;
2245
+ let nextIndex = 0;
2246
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
2247
+ while (nextIndex < items.length) {
2248
+ const index = nextIndex++;
2249
+ await worker(items[index], index);
2250
+ }
2251
+ });
2252
+ await Promise.all(runners);
2253
+ }
2254
+ function resolveContextLabel(enDoc, enSlug) {
2255
+ const fm = enDoc.frontmatter;
2256
+ for (const key of ["title", "name", "h1"]) {
2257
+ const value = fm[key];
2258
+ if (typeof value === "string" && value.trim()) return value;
2259
+ }
2260
+ return enSlug;
2261
+ }
1938
2262
  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,
2263
+ const startedAt = Date.now();
2264
+ const base = {
2265
+ contentType: item.contentType,
2266
+ enSlug: item.enSlug,
2267
+ locale: item.locale
2268
+ };
2269
+ try {
2270
+ const type = config.types.find((t) => t.id === item.contentType);
2271
+ if (!type) throw new Error(`Unknown content type ${item.contentType}`);
2272
+ const enDoc = readEnDocument(config, type, item.enSlug);
2273
+ if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
2274
+ const payload = getTranslatablePayload(enDoc, type);
2275
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
2276
+ const db = openStore(config, "readonly");
2277
+ const existing = getTranslation(db, type.id, item.enSlug, item.locale);
2278
+ db.close();
2279
+ if (!options.force && existing && existing.en_hash === currentEnHash) {
2280
+ return {
2281
+ ...base,
2282
+ skipped: true,
2283
+ reason: "fresh",
2284
+ durationMs: Date.now() - startedAt
2285
+ };
2286
+ }
2287
+ const resolvedTranslate = resolveTranslateConfig(config, type);
2288
+ const model = options.model ?? resolvedTranslate.model;
2289
+ const prompt = buildPageTranslationPrompt({
2290
+ resolved: resolvedTranslate,
2291
+ targetLocale: item.locale,
2292
+ contextLabel: resolveContextLabel(enDoc, item.enSlug),
2293
+ translatableFrontmatter: payload.frontmatter,
2294
+ enBody: payload.body,
2295
+ slugStrategy: type.slugStrategy
2296
+ });
2297
+ if (options.dryRun) {
2298
+ return {
2299
+ ...base,
2300
+ skipped: false,
2301
+ model,
2302
+ durationMs: Date.now() - startedAt
2303
+ };
2304
+ }
2305
+ const responseSchema = buildGeminiResponseSchema(type.schema, type.slugStrategy);
2306
+ const result = await translatePageWithGemini({
2307
+ prompt,
2308
+ model,
2309
+ responseSchema: responseSchema ?? void 0
2310
+ });
2311
+ const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
2312
+ const validated = validateTranslatedFrontmatter(enDoc, result.parsed.frontmatter, type.schema);
2313
+ if (!validated.ok) {
2314
+ throw new Error(`Translation validation failed: ${validated.error}`);
2315
+ }
2316
+ const writeDb = openStore(config, "readwrite");
2317
+ upsertTranslation(writeDb, {
2318
+ contentType: type.id,
1951
2319
  enSlug: item.enSlug,
1952
2320
  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,
2321
+ slug,
2322
+ frontmatter: validated.frontmatter,
2323
+ body: result.parsed.body,
2324
+ enHash: currentEnHash,
2325
+ translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2326
+ model: result.model
2327
+ });
2328
+ writeDb.close();
2329
+ recordRevision(config, {
2330
+ contentType: type.id,
1970
2331
  enSlug: item.enSlug,
1971
2332
  locale: item.locale,
2333
+ revisionKind: "translation",
2334
+ enHash: currentEnHash,
2335
+ body: result.parsed.body,
2336
+ frontmatter: validated.frontmatter,
2337
+ model: result.model
2338
+ });
2339
+ const estimatedCostUsd = estimateTranslationCostUsd(
2340
+ normalizeGeminiDisplayName(result.model),
2341
+ result.usage.inputTokens,
2342
+ result.usage.outputTokens
2343
+ );
2344
+ return {
2345
+ ...base,
2346
+ skipped: false,
2347
+ model: result.model,
2348
+ usage: result.usage,
2349
+ estimatedCostUsd,
2350
+ durationMs: Date.now() - startedAt
2351
+ };
2352
+ } catch (error) {
2353
+ return {
2354
+ ...base,
1972
2355
  skipped: false,
1973
- model: options.model
2356
+ failed: true,
2357
+ error: formatTranslateError(error),
2358
+ durationMs: Date.now() - startedAt
1974
2359
  };
1975
2360
  }
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
2361
+ }
2362
+ function labelForItem(item) {
2363
+ return `${item.contentType}/${item.enSlug}@${item.locale}`;
2364
+ }
2365
+ async function translateWorklist(config, items, options = {}) {
2366
+ const concurrency = Math.max(1, options.concurrency ?? 3);
2367
+ const startedAt = Date.now();
2368
+ const results = new Array(items.length);
2369
+ const active = /* @__PURE__ */ new Set();
2370
+ options.onProgress?.({
2371
+ type: "start",
2372
+ total: items.length,
2373
+ concurrency,
2374
+ dryRun: Boolean(options.dryRun),
2375
+ model: options.model
1992
2376
  });
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
2377
+ await runPool(items, concurrency, async (item, index) => {
2378
+ const label = labelForItem(item);
2379
+ active.add(label);
2380
+ options.onProgress?.({ type: "item-start", item, active: [...active] });
2381
+ const result = await translatePage(config, item, options);
2382
+ results[index] = result;
2383
+ active.delete(label);
2384
+ options.onProgress?.({ type: "item-done", result });
2002
2385
  });
2003
- return {
2004
- contentType: item.contentType,
2005
- enSlug: item.enSlug,
2006
- locale: item.locale,
2007
- skipped: false,
2008
- model: result.model
2009
- };
2386
+ const totals = summarizeResults(results, Date.now() - startedAt);
2387
+ options.onProgress?.({ type: "done", results, totals });
2388
+ return results;
2010
2389
  }
2011
- async function translateWorklist(config, items, options = {}) {
2012
- const results = [];
2013
- for (const item of items) {
2014
- results.push(await translatePage(config, item, options));
2390
+
2391
+ // src/export/build-static-raw-exports.ts
2392
+ init_cjs_shims();
2393
+
2394
+ // src/loader/parse-mdx.ts
2395
+ init_cjs_shims();
2396
+ function serializeMdx(frontmatter, content) {
2397
+ return matter__default.default.stringify(content, frontmatter);
2398
+ }
2399
+
2400
+ // src/export/build-static-raw-exports.ts
2401
+ function normalizeExtension(ext) {
2402
+ return ext.startsWith(".") ? ext : `.${ext}`;
2403
+ }
2404
+ function exportDirSegment(pathTemplate) {
2405
+ const prefix = pathPrefix(pathTemplate);
2406
+ return prefix.replace(/^\/+|\/+$/g, "");
2407
+ }
2408
+ function getStaticExportRoots(project, options = {}) {
2409
+ const { config } = project;
2410
+ const locales = options.locales ?? config.locales;
2411
+ const typeFilter = options.types ? new Set(options.types) : null;
2412
+ const roots = /* @__PURE__ */ new Set();
2413
+ for (const type of project.listTypes()) {
2414
+ if (!isRoutableType(type.config)) continue;
2415
+ if (typeFilter && !typeFilter.has(type.id)) continue;
2416
+ const segment = exportDirSegment(type.config.path);
2417
+ roots.add(`${segment}/`);
2418
+ for (const locale of locales) {
2419
+ if (locale === config.defaultLocale) continue;
2420
+ roots.add(`${locale}/${segment}/`);
2421
+ }
2015
2422
  }
2016
- return results;
2423
+ return [...roots].sort();
2424
+ }
2425
+ function buildStaticRawExports(project, options = {}) {
2426
+ const { config } = project;
2427
+ const extension = normalizeExtension(options.extension ?? ".mdx");
2428
+ const locales = options.locales ?? config.locales;
2429
+ const excludeRedirected = options.excludeRedirected ?? true;
2430
+ const excludeNoindex = options.excludeNoindex ?? false;
2431
+ const typeFilter = options.types ? new Set(options.types) : null;
2432
+ const out = [];
2433
+ for (const type of project.listTypes()) {
2434
+ if (!isRoutableType(type.config)) continue;
2435
+ if (typeFilter && !typeFilter.has(type.id)) continue;
2436
+ const pathTemplate = type.config.path;
2437
+ const all = type.load();
2438
+ const enIdx = all.get(config.defaultLocale);
2439
+ if (!enIdx) continue;
2440
+ for (const locale of locales) {
2441
+ for (const enDoc of enIdx.bySlug.values()) {
2442
+ const resolved = type.resolve(enDoc.slug, locale);
2443
+ if (!resolved.document) continue;
2444
+ if (excludeRedirected && resolved.shouldRedirectTo) continue;
2445
+ if (excludeNoindex && resolved.document.noindex) continue;
2446
+ const doc = resolved.document;
2447
+ const slugWithExt = `${doc.slug}${extension}`;
2448
+ const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2449
+ out.push({
2450
+ relativePath: urlPath.slice(1),
2451
+ urlPath,
2452
+ locale,
2453
+ typeId: type.id,
2454
+ enSlug: doc.enSlug,
2455
+ source: serializeMdx(doc.frontmatter, doc.content)
2456
+ });
2457
+ }
2458
+ }
2459
+ }
2460
+ return out;
2461
+ }
2462
+
2463
+ // src/export/write-static-raw-exports.ts
2464
+ init_cjs_shims();
2465
+ function rmDirIfExists(dir) {
2466
+ if (fs2__default.default.existsSync(dir)) {
2467
+ fs2__default.default.rmSync(dir, { recursive: true, force: true });
2468
+ }
2469
+ }
2470
+ function writeStaticRawExports(project, options = {}) {
2471
+ const outDir = path3__default.default.resolve(options.outDir ?? "public");
2472
+ const typeFilter = options.types;
2473
+ for (const root of getStaticExportRoots(project, {
2474
+ types: typeFilter,
2475
+ locales: options.locales
2476
+ })) {
2477
+ rmDirIfExists(path3__default.default.join(outDir, root));
2478
+ }
2479
+ const exports = buildStaticRawExports(project, options);
2480
+ const counts = /* @__PURE__ */ new Map();
2481
+ for (const item of exports) {
2482
+ const filePath = path3__default.default.join(outDir, item.relativePath);
2483
+ fs2__default.default.mkdirSync(path3__default.default.dirname(filePath), { recursive: true });
2484
+ fs2__default.default.writeFileSync(filePath, item.source, "utf8");
2485
+ const key = `${item.typeId}/${item.locale}`;
2486
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2487
+ }
2488
+ for (const [key, count] of [...counts.entries()].sort()) {
2489
+ console.log(` ${key}: ${count}`);
2490
+ }
2491
+ return { exports, written: exports.length };
2017
2492
  }
2018
2493
 
2019
2494
  // src/config/load-env.ts
@@ -2034,99 +2509,464 @@ function loadEnvFromCwd(cwd) {
2034
2509
  // studio/server.ts
2035
2510
  init_cjs_shims();
2036
2511
  init_sqlite();
2037
- function renderLayout(title, body) {
2512
+ function escapeHtml(value) {
2513
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2514
+ }
2515
+ function encodePathSegment(value) {
2516
+ return encodeURIComponent(value);
2517
+ }
2518
+ function statusDot(status) {
2519
+ const labels = {
2520
+ source: "en",
2521
+ "up-to-date": "ok",
2522
+ stale: "stale",
2523
+ missing: "\u2014"
2524
+ };
2525
+ return `<span class="status" title="${status}"><span class="dot dot-${status}"></span>${labels[status]}</span>`;
2526
+ }
2527
+ function documentStatus(config, db, type, enSlug, locale) {
2528
+ if (locale === config.defaultLocale) {
2529
+ return { status: "source" };
2530
+ }
2531
+ const enDoc = readEnDocument(config, type.config, enSlug);
2532
+ if (!enDoc) {
2533
+ return { status: "missing" };
2534
+ }
2535
+ const payload = getTranslatablePayload(enDoc, type.config);
2536
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
2537
+ const row = getTranslation(db, type.id, enSlug, locale);
2538
+ if (!row) {
2539
+ return { status: "missing", currentEnHash };
2540
+ }
2541
+ if (row.en_hash !== currentEnHash) {
2542
+ return { status: "stale", currentEnHash, storedEnHash: row.en_hash };
2543
+ }
2544
+ return { status: "up-to-date", currentEnHash, storedEnHash: row.en_hash };
2545
+ }
2546
+ function flattenFrontmatter(data, prefix = "") {
2547
+ const rows = [];
2548
+ for (const [key, value] of Object.entries(data)) {
2549
+ const fullKey = prefix ? `${prefix}.${key}` : key;
2550
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2551
+ rows.push(...flattenFrontmatter(value, fullKey));
2552
+ } else {
2553
+ const display = typeof value === "string" ? value : value === void 0 ? "" : JSON.stringify(value, null, 2);
2554
+ rows.push({ key: fullKey, value: display });
2555
+ }
2556
+ }
2557
+ return rows;
2558
+ }
2559
+ function translatableKeySet(schema) {
2560
+ return new Set(
2561
+ introspectSchema(schema).filter((field2) => field2.kind === "translatable").map((field2) => field2.path.join("."))
2562
+ );
2563
+ }
2564
+ function renderFrontmatterTable(frontmatter, schema) {
2565
+ const translatable = translatableKeySet(schema);
2566
+ const rows = flattenFrontmatter(frontmatter).map((row) => {
2567
+ const flag = translatable.has(row.key) ? `<span class="flag t" title="Translatable">T</span>` : `<span class="flag s" title="Structural">S</span>`;
2568
+ return `<tr>
2569
+ <td class="k">${flag}${escapeHtml(row.key)}</td>
2570
+ <td class="v">${escapeHtml(row.value)}</td>
2571
+ </tr>`;
2572
+ }).join("");
2573
+ return `<table class="kv">
2574
+ <tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
2575
+ </table>`;
2576
+ }
2577
+ function renderRevisionSnapshot(revision) {
2578
+ if (revision.frontmatter_json && revision.body) {
2579
+ let frontmatter = {};
2580
+ try {
2581
+ frontmatter = JSON.parse(revision.frontmatter_json);
2582
+ } catch {
2583
+ frontmatter = {};
2584
+ }
2585
+ const fmRows = flattenFrontmatter(frontmatter).map(
2586
+ (row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
2587
+ ).join("");
2588
+ return `<table class="kv"><tbody>${fmRows}</tbody></table>
2589
+ <pre class="code">${escapeHtml(revision.body)}</pre>`;
2590
+ }
2591
+ return `<p class="dim">${escapeHtml(revision.body_preview ?? "(no snapshot)")}</p>`;
2592
+ }
2593
+ function renderRevisionTimeline(revisions) {
2594
+ if (revisions.length === 0) {
2595
+ return `<p class="dim">No history.</p>`;
2596
+ }
2597
+ const items = revisions.map(
2598
+ (row) => `<tr>
2599
+ <td class="dim">${escapeHtml(row.created_at.slice(0, 19))}</td>
2600
+ <td>${escapeHtml(row.revision_kind)}</td>
2601
+ <td class="dim">${row.model ? escapeHtml(row.model) : "\u2014"}</td>
2602
+ <td class="dim mono">${escapeHtml(row.en_hash.slice(0, 8))}</td>
2603
+ <td><details><summary>view</summary>${renderRevisionSnapshot(row)}</details></td>
2604
+ </tr>`
2605
+ ).join("");
2606
+ return `<table class="data"><thead><tr>
2607
+ <th>When</th><th>Kind</th><th>Model</th><th>Hash</th><th></th>
2608
+ </tr></thead><tbody>${items}</tbody></table>`;
2609
+ }
2610
+ function renderLayout(title, body, project, options = {}) {
2611
+ const typeLinks = project.listTypes().map((type) => {
2612
+ const active = type.id === options.activeTypeId ? " active" : "";
2613
+ return `<a class="tree-item${active}" href="/type/${encodePathSegment(type.id)}">
2614
+ <span class="tree-label">${escapeHtml(type.config.label)}</span>
2615
+ <span class="tree-meta">${escapeHtml(type.id)}</span>
2616
+ </a>`;
2617
+ }).join("");
2038
2618
  return `<!doctype html>
2039
2619
  <html lang="en">
2040
2620
  <head>
2041
2621
  <meta charset="utf-8" />
2042
2622
  <meta name="viewport" content="width=device-width, initial-scale=1" />
2043
- <title>${title} \u2014 Scribe Studio</title>
2623
+ <title>${escapeHtml(title)} \u2014 Scribe</title>
2044
2624
  <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; }
2625
+ :root {
2626
+ --bg: #1e1e1e;
2627
+ --sidebar: #252526;
2628
+ --bar: #333333;
2629
+ --panel: #1e1e1e;
2630
+ --border: #3c3c3c;
2631
+ --text: #cccccc;
2632
+ --dim: #858585;
2633
+ --accent: #3794ff;
2634
+ --hover: #2a2d2e;
2635
+ --active: #37373d;
2636
+ --ok: #89d185;
2637
+ --stale: #cca700;
2638
+ --missing: #f48771;
2639
+ --source: #75beff;
2640
+ --mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
2641
+ --ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2642
+ --fs: 13px;
2643
+ --fs-sm: 11px;
2644
+ }
2645
+ * { box-sizing: border-box; margin: 0; padding: 0; }
2646
+ body { font: var(--fs)/1.4 var(--ui); background: var(--bg); color: var(--text); }
2647
+ a { color: var(--accent); text-decoration: none; }
2648
+ a:hover { text-decoration: underline; }
2649
+ .app { display: flex; height: 100vh; overflow: hidden; }
2650
+
2651
+ /* activity bar */
2652
+ .actbar {
2653
+ width: 48px; flex-shrink: 0; background: var(--bar);
2654
+ display: flex; flex-direction: column; align-items: center;
2655
+ padding: 8px 0; gap: 4px; border-right: 1px solid var(--border);
2656
+ }
2657
+ .actbar a {
2658
+ width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
2659
+ color: var(--dim); font-size: 18px; text-decoration: none; position: relative;
2660
+ }
2661
+ .actbar a:hover { color: var(--text); }
2662
+ .actbar a.active { color: var(--text); }
2663
+ .actbar a.active::before {
2664
+ content: ""; position: absolute; left: 0; top: 8px; bottom: 8px;
2665
+ width: 2px; background: var(--accent);
2666
+ }
2667
+
2668
+ /* sidebar */
2669
+ .sidebar {
2670
+ width: 200px; flex-shrink: 0; background: var(--sidebar);
2671
+ border-right: 1px solid var(--border); display: flex; flex-direction: column;
2672
+ overflow: hidden;
2673
+ }
2674
+ .sidebar-head {
2675
+ padding: 8px 12px; font-size: var(--fs-sm); font-weight: 600;
2676
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
2677
+ }
2678
+ .sidebar-body { flex: 1; overflow-y: auto; padding: 2px 0; }
2679
+ .tree-item {
2680
+ display: flex; flex-direction: column; padding: 3px 12px 3px 20px;
2681
+ color: var(--text); text-decoration: none; line-height: 1.3;
2682
+ }
2683
+ .tree-item:hover { background: var(--hover); text-decoration: none; }
2684
+ .tree-item.active { background: var(--active); }
2685
+ .tree-label { font-size: var(--fs); }
2686
+ .tree-meta { font-size: var(--fs-sm); color: var(--dim); }
2687
+
2688
+ /* main */
2689
+ .main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
2690
+ .toolbar {
2691
+ display: flex; align-items: center; gap: 6px; padding: 0 12px;
2692
+ height: 35px; background: var(--sidebar); border-bottom: 1px solid var(--border);
2693
+ font-size: var(--fs-sm); color: var(--dim); flex-shrink: 0; overflow: hidden;
2694
+ }
2695
+ .toolbar a { color: var(--dim); }
2696
+ .toolbar a:hover { color: var(--text); }
2697
+ .toolbar .sep { color: var(--border); }
2698
+ .content { flex: 1; overflow-y: auto; padding: 0; }
2699
+
2700
+ /* tabs (locale switcher) */
2701
+ .tabs {
2702
+ display: flex; background: var(--sidebar); border-bottom: 1px solid var(--border);
2703
+ overflow-x: auto; flex-shrink: 0;
2704
+ }
2705
+ .tab {
2706
+ display: flex; align-items: center; gap: 6px; padding: 6px 12px;
2707
+ font-size: var(--fs-sm); color: var(--dim); border-right: 1px solid var(--border);
2708
+ text-decoration: none; white-space: nowrap;
2709
+ }
2710
+ .tab:hover { background: var(--hover); color: var(--text); text-decoration: none; }
2711
+ .tab.active {
2712
+ background: var(--bg); color: var(--text);
2713
+ border-bottom: 1px solid var(--bg); margin-bottom: -1px;
2714
+ }
2715
+
2716
+ /* status dots */
2717
+ .status { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); color: var(--dim); }
2718
+ .dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
2719
+ .dot-source { background: var(--source); }
2720
+ .dot-up-to-date { background: var(--ok); }
2721
+ .dot-stale { background: var(--stale); }
2722
+ .dot-missing { background: var(--missing); opacity: 0.7; }
2723
+
2724
+ /* sections */
2725
+ .section { border-bottom: 1px solid var(--border); }
2726
+ .section-head {
2727
+ padding: 4px 12px; font-size: var(--fs-sm); font-weight: 600;
2728
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
2729
+ background: var(--sidebar);
2730
+ }
2731
+ .section-body { padding: 0; }
2732
+
2733
+ /* tables */
2734
+ table { border-collapse: collapse; width: 100%; font-size: var(--fs); }
2735
+ .data th, .data td { padding: 2px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
2736
+ .data th { font-size: var(--fs-sm); font-weight: 600; color: var(--dim); background: var(--sidebar); height: 22px; }
2737
+ .data tr:hover td { background: var(--hover); }
2738
+ .data .mono { font-family: var(--mono); font-size: var(--fs-sm); }
2739
+
2740
+ /* key-value (frontmatter) */
2741
+ .kv td { padding: 1px 12px; font-family: var(--mono); font-size: var(--fs-sm); vertical-align: top; border-bottom: 1px solid var(--border); }
2742
+ .kv .k { width: 160px; color: #9cdcfe; white-space: nowrap; }
2743
+ .kv .v { color: #ce9178; white-space: pre-wrap; word-break: break-word; }
2744
+ .flag { font-size: 9px; font-weight: 700; margin-right: 4px; opacity: 0.5; }
2745
+ .flag.t { color: var(--ok); }
2746
+ .flag.s { color: var(--dim); }
2747
+
2748
+ /* code block */
2749
+ .code {
2750
+ margin: 0; padding: 8px 12px; font: var(--fs-sm)/1.5 var(--mono);
2751
+ white-space: pre-wrap; word-break: break-word; color: var(--text);
2752
+ background: var(--bg); border: none;
2753
+ }
2754
+
2755
+ /* meta row */
2756
+ .meta { display: flex; flex-wrap: wrap; font-size: var(--fs-sm); border-bottom: 1px solid var(--border); padding: 4px 0; }
2757
+ .meta dt { padding: 0 4px 0 12px; color: var(--dim); }
2758
+ .meta dt::after { content: ":"; }
2759
+ .meta dd { padding: 0 12px 0 0; font-family: var(--mono); }
2760
+
2761
+ /* misc */
2762
+ .dim { color: var(--dim); }
2763
+ .page-title { padding: 8px 12px 4px; font-size: 14px; font-weight: 400; }
2764
+ .page-sub { padding: 0 12px 8px; font-size: var(--fs-sm); color: var(--dim); }
2765
+ details summary { cursor: pointer; color: var(--accent); font-size: var(--fs-sm); }
2766
+ details[open] summary { margin-bottom: 4px; }
2767
+ .tag { font-size: var(--fs-sm); color: var(--dim); margin-left: 6px; }
2768
+ .tag-warn { color: var(--stale); }
2769
+ .tag-err { color: var(--missing); }
2056
2770
  </style>
2057
2771
  </head>
2058
2772
  <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>
2773
+ <div class="app">
2774
+ <nav class="actbar">
2775
+ <a href="/" title="Overview">\u2302</a>
2776
+ <a href="/staleness" title="Staleness">\u26A0</a>
2777
+ </nav>
2778
+ <aside class="sidebar">
2779
+ <div class="sidebar-head">Types</div>
2780
+ <div class="sidebar-body">${typeLinks || `<span class="dim" style="padding:8px 12px">\u2014</span>`}</div>
2781
+ </aside>
2782
+ <div class="main">
2783
+ <div class="content">${body}</div>
2784
+ </div>
2785
+ </div>
2066
2786
  </body>
2067
2787
  </html>`;
2068
2788
  }
2789
+ function docTitleFromFrontmatter(frontmatter, enSlug) {
2790
+ const title = frontmatter.title;
2791
+ if (typeof title === "string" && title.trim()) return title;
2792
+ return enSlug;
2793
+ }
2069
2794
  async function startStudio(project, options = {}) {
2070
2795
  const app = new hono.Hono();
2071
2796
  const config = project.config;
2072
2797
  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>`;
2798
+ const db = openStore(config, "readonly");
2799
+ const worklist = buildWorklist(config);
2800
+ const rows = project.listTypes().map((type) => {
2801
+ const enCount = type.list().length;
2802
+ const localeCells = config.locales.filter((locale) => locale !== config.defaultLocale).map((locale) => {
2803
+ const translated = listTranslationsForLocale(db, type.id, locale).length;
2804
+ const stale = worklist.filter(
2805
+ (item) => item.contentType === type.id && item.locale === locale && item.reason === "stale"
2806
+ ).length;
2807
+ const missing = worklist.filter(
2808
+ (item) => item.contentType === type.id && item.locale === locale && item.reason === "missing"
2809
+ ).length;
2810
+ const tags = [
2811
+ stale ? `<span class="tag tag-warn">${stale}s</span>` : "",
2812
+ missing ? `<span class="tag tag-err">${missing}m</span>` : ""
2813
+ ].join("");
2814
+ return `<td>${translated}${tags}</td>`;
2077
2815
  }).join("");
2078
- return `<div class="card"><h2>${type.config.label}</h2><ul>${localeRows}</ul></div>`;
2816
+ return `<tr>
2817
+ <td><a href="/type/${encodePathSegment(type.id)}">${escapeHtml(type.config.label)}</a></td>
2818
+ <td class="dim">${escapeHtml(type.id)}</td>
2819
+ <td>${enCount}</td>
2820
+ ${localeCells}
2821
+ </tr>`;
2079
2822
  });
2080
- return c.html(renderLayout("Browser", cards.join("")));
2823
+ db.close();
2824
+ const localeHeaders = config.locales.filter((l) => l !== config.defaultLocale).map((l) => `<th>${escapeHtml(l)}</th>`).join("");
2825
+ const html = `<div class="toolbar">Overview</div>
2826
+ <table class="data">
2827
+ <thead><tr><th>Type</th><th>ID</th><th>EN</th>${localeHeaders}</tr></thead>
2828
+ <tbody>${rows.join("")}</tbody>
2829
+ </table>`;
2830
+ return c.html(renderLayout("Overview", html, project));
2081
2831
  });
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));
2832
+ app.get("/type/:id", (c) => {
2833
+ const typeId = c.req.param("id");
2834
+ const type = project.getType(typeId);
2835
+ if (!type) {
2836
+ return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
2837
+ }
2838
+ const db = openStore(config, "readonly");
2839
+ const locales = config.locales;
2840
+ const headerCells = locales.map((locale) => `<th>${escapeHtml(locale)}</th>`).join("");
2841
+ const rows = type.list().map((doc) => {
2842
+ const title = docTitleFromFrontmatter(doc.frontmatter, doc.slug);
2843
+ const statusCells = locales.map((locale) => {
2844
+ const { status } = documentStatus(config, db, type, doc.slug, locale);
2845
+ return `<td>${statusDot(status)}</td>`;
2846
+ }).join("");
2847
+ return `<tr>
2848
+ <td class="mono"><a href="/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(doc.slug)}">${escapeHtml(doc.slug)}</a></td>
2849
+ <td>${escapeHtml(title)}</td>
2850
+ ${statusCells}
2851
+ </tr>`;
2852
+ }).join("");
2853
+ db.close();
2854
+ const html = `<div class="toolbar">
2855
+ <a href="/">Overview</a><span class="sep">\u203A</span>${escapeHtml(type.config.label)}
2856
+ </div>
2857
+ <table class="data">
2858
+ <thead><tr><th>Slug</th><th>Title</th>${headerCells}</tr></thead>
2859
+ <tbody>${rows || `<tr><td colspan="${2 + locales.length}" class="dim">No documents</td></tr>`}</tbody>
2860
+ </table>`;
2861
+ return c.html(renderLayout(type.config.label, html, project, { activeTypeId: typeId }));
2090
2862
  });
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>"));
2863
+ app.get("/type/:id/doc/:enSlug", (c) => {
2864
+ const typeId = c.req.param("id");
2865
+ const enSlug = c.req.param("enSlug");
2866
+ const locale = c.req.query("locale") ?? config.defaultLocale;
2867
+ const showRaw = c.req.query("raw") === "1";
2868
+ const type = project.getType(typeId);
2869
+ if (!type) {
2870
+ return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
2871
+ }
2095
2872
  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>`;
2873
+ const enDoc = readEnDocument(config, type.config, enSlug);
2874
+ if (!enDoc) {
2875
+ db.close();
2876
+ return c.html(
2877
+ renderLayout("Not found", `<div class="toolbar">Not found</div><p class="dim" style="padding:12px">${escapeHtml(enSlug)}</p>`, project, {
2878
+ activeTypeId: typeId
2879
+ }),
2880
+ 404
2881
+ );
2882
+ }
2883
+ const localeTabs = config.locales.map((loc) => {
2884
+ const { status } = documentStatus(config, db, type, enSlug, loc);
2885
+ const active = loc === locale ? " active" : "";
2886
+ const href = `/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(enSlug)}?locale=${encodePathSegment(loc)}`;
2887
+ return `<a class="tab${active}" href="${href}">${escapeHtml(loc)} ${statusDot(status)}</a>`;
2888
+ }).join("");
2889
+ let contentPanel = "";
2890
+ let metaPanel = "";
2891
+ let historyPanel = "";
2892
+ if (locale === config.defaultLocale) {
2893
+ contentPanel = `<div class="section">
2894
+ <div class="section-head">Frontmatter</div>
2895
+ <div class="section-body">${renderFrontmatterTable(enDoc.frontmatter, type.config.schema)}</div>
2896
+ <div class="section-head">Body</div>
2897
+ <pre class="code">${escapeHtml(enDoc.content)}</pre>
2898
+ </div>`;
2102
2899
  } 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>`;
2900
+ const translation = getTranslation(db, typeId, enSlug, locale);
2901
+ const { status, currentEnHash, storedEnHash } = documentStatus(config, db, type, enSlug, locale);
2902
+ if (translation) {
2903
+ const rawFrontmatter = JSON.parse(translation.frontmatter_json);
2904
+ const displayFrontmatter = showRaw ? rawFrontmatter : mergeStructuralOntoLocale(rawFrontmatter, enDoc.frontmatter, type.config.schema);
2905
+ 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>`;
2906
+ contentPanel = `<div class="section">
2907
+ <div class="section-head">Frontmatter${rawToggle}</div>
2908
+ <div class="section-body">${renderFrontmatterTable(displayFrontmatter, type.config.schema)}</div>
2909
+ <div class="section-head">Body</div>
2910
+ <pre class="code">${escapeHtml(translation.body)}</pre>
2911
+ </div>`;
2912
+ metaPanel = `<dl class="meta">
2913
+ <dt>status</dt><dd>${statusDot(status)}</dd>
2914
+ <dt>model</dt><dd>${escapeHtml(translation.model)}</dd>
2915
+ <dt>translated</dt><dd>${escapeHtml(translation.translated_at.slice(0, 19))}</dd>
2916
+ <dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
2917
+ <dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
2918
+ </dl>`;
2919
+ const revisions = listRevisions(db, typeId, enSlug, locale);
2920
+ historyPanel = `<div class="section">
2921
+ <div class="section-head">History</div>
2922
+ <div class="section-body">${renderRevisionTimeline(revisions)}</div>
2923
+ </div>`;
2924
+ } else {
2925
+ contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
2926
+ metaPanel = `<dl class="meta"><dt>status</dt><dd>${statusDot(status)}</dd></dl>`;
2927
+ }
2105
2928
  }
2106
2929
  db.close();
2107
- return c.html(renderLayout("History", body));
2930
+ const title = docTitleFromFrontmatter(enDoc.frontmatter, enSlug);
2931
+ const html = `<div class="toolbar">
2932
+ <a href="/">Overview</a><span class="sep">\u203A</span>
2933
+ <a href="/type/${encodePathSegment(typeId)}">${escapeHtml(type.config.label)}</a><span class="sep">\u203A</span>
2934
+ <span>${escapeHtml(enSlug)}</span>
2935
+ </div>
2936
+ <div class="tabs">${localeTabs}</div>
2937
+ ${metaPanel}
2938
+ ${contentPanel}
2939
+ ${historyPanel}`;
2940
+ return c.html(renderLayout(title, html, project, { activeTypeId: typeId }));
2941
+ });
2942
+ app.get("/staleness", (c) => {
2943
+ const worklist = buildWorklist(config);
2944
+ const rows = worklist.slice(0, 500).map((item) => {
2945
+ const href = `/type/${encodePathSegment(item.contentType)}/doc/${encodePathSegment(item.enSlug)}?locale=${encodePathSegment(item.locale)}`;
2946
+ return `<tr>
2947
+ <td>${escapeHtml(item.contentType)}</td>
2948
+ <td class="mono"><a href="${href}">${escapeHtml(item.enSlug)}</a></td>
2949
+ <td>${escapeHtml(item.locale)}</td>
2950
+ <td>${statusDot(item.reason === "stale" ? "stale" : "missing")}</td>
2951
+ </tr>`;
2952
+ }).join("");
2953
+ const html = `<div class="toolbar">Staleness <span class="dim">\xB7 ${worklist.length} entries</span></div>
2954
+ <table class="data">
2955
+ <thead><tr><th>Type</th><th>Slug</th><th>Locale</th><th>Status</th></tr></thead>
2956
+ <tbody>${rows || `<tr><td colspan="4" class="dim">All up to date</td></tr>`}</tbody>
2957
+ </table>`;
2958
+ return c.html(renderLayout("Staleness", html, project));
2108
2959
  });
2109
2960
  app.get("/api/staleness-matrix", (c) => {
2961
+ const worklist = buildWorklist(config);
2110
2962
  const matrix = {};
2111
2963
  for (const type of project.listTypes()) {
2112
2964
  matrix[type.id] = {};
2113
2965
  for (const locale of config.locales) {
2114
2966
  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;
2967
+ matrix[type.id][locale] = worklist.filter(
2968
+ (item) => item.contentType === type.id && item.locale === locale
2969
+ ).length;
2130
2970
  }
2131
2971
  }
2132
2972
  return c.json(matrix);
@@ -2138,6 +2978,294 @@ async function startStudio(project, options = {}) {
2138
2978
  });
2139
2979
  }
2140
2980
 
2981
+ // cli/prompt-translate.ts
2982
+ init_cjs_shims();
2983
+
2984
+ // ../../node_modules/@inquirer/core/dist/lib/errors.js
2985
+ init_cjs_shims();
2986
+ var CancelPromptError = class extends Error {
2987
+ constructor() {
2988
+ super(...arguments);
2989
+ __publicField(this, "name", "CancelPromptError");
2990
+ __publicField(this, "message", "Prompt was canceled");
2991
+ }
2992
+ };
2993
+ function isInteractive() {
2994
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
2995
+ }
2996
+ function nonDefaultLocales(config) {
2997
+ return config.locales.filter((locale) => locale !== config.defaultLocale);
2998
+ }
2999
+ async function runPrompt(prompt) {
3000
+ try {
3001
+ return await prompt;
3002
+ } catch (error) {
3003
+ if (error instanceof CancelPromptError) process.exit(0);
3004
+ throw error;
3005
+ }
3006
+ }
3007
+ async function promptContentType(config) {
3008
+ return runPrompt(
3009
+ prompts.select({
3010
+ message: "Content type to translate",
3011
+ choices: [
3012
+ { name: "All content types", value: void 0 },
3013
+ ...config.types.map((type) => ({ name: type.id, value: type.id }))
3014
+ ]
3015
+ })
3016
+ );
3017
+ }
3018
+ async function promptLocalePreset(config) {
3019
+ const presets = Object.entries(config.localePresets ?? {}).filter(
3020
+ (entry) => Array.isArray(entry[1])
3021
+ );
3022
+ if (presets.length === 0) {
3023
+ const locales = nonDefaultLocales(config);
3024
+ if (locales.length <= 1) return {};
3025
+ const picked = await runPrompt(
3026
+ prompts.checkbox({
3027
+ message: "Locales to translate",
3028
+ choices: locales.map((locale) => ({ name: locale, value: locale, checked: true })),
3029
+ validate: (values) => values.length > 0 || "Pick at least one locale"
3030
+ })
3031
+ );
3032
+ return picked.length === locales.length ? {} : { locale: picked };
3033
+ }
3034
+ const choice = await runPrompt(
3035
+ prompts.select({
3036
+ message: "Locale preset",
3037
+ choices: [
3038
+ { name: "All locales", value: void 0 },
3039
+ ...presets.map(([name, locales]) => ({
3040
+ name: `${name} (${locales.join(", ")})`,
3041
+ value: name
3042
+ }))
3043
+ ]
3044
+ })
3045
+ );
3046
+ return choice ? { preset: choice } : {};
3047
+ }
3048
+ async function promptStrategy() {
3049
+ return runPrompt(
3050
+ prompts.select({
3051
+ message: "Translation strategy",
3052
+ choices: [
3053
+ { name: "Stale and missing", value: "all" },
3054
+ { name: "Missing only (skip stale)", value: "missing-only" }
3055
+ ],
3056
+ default: "all"
3057
+ })
3058
+ );
3059
+ }
3060
+ async function promptTranslateSelection(config, flags) {
3061
+ const selection = {
3062
+ contentType: flags.type,
3063
+ preset: flags.preset,
3064
+ locale: flags.locale,
3065
+ strategy: flags.strategy
3066
+ };
3067
+ if (!isInteractive()) return selection;
3068
+ if (!selection.contentType) {
3069
+ selection.contentType = await promptContentType(config);
3070
+ }
3071
+ if (!selection.preset && !selection.locale?.length) {
3072
+ Object.assign(selection, await promptLocalePreset(config));
3073
+ }
3074
+ if (!selection.strategy) {
3075
+ selection.strategy = await promptStrategy();
3076
+ }
3077
+ return selection;
3078
+ }
3079
+
3080
+ // cli/translate-progress.ts
3081
+ init_cjs_shims();
3082
+ var dim = (text) => `\x1B[2m${text}\x1B[0m`;
3083
+ var green = (text) => `\x1B[32m${text}\x1B[0m`;
3084
+ var yellow = (text) => `\x1B[33m${text}\x1B[0m`;
3085
+ var red = (text) => `\x1B[31m${text}\x1B[0m`;
3086
+ var cyan = (text) => `\x1B[36m${text}\x1B[0m`;
3087
+ function isInteractive2() {
3088
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
3089
+ }
3090
+ function progressBar(ratio, width = 28) {
3091
+ const clamped = Math.max(0, Math.min(1, ratio));
3092
+ const filled = Math.round(clamped * width);
3093
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
3094
+ }
3095
+ function labelForResult(result) {
3096
+ return `${result.contentType}/${result.enSlug}@${result.locale}`;
3097
+ }
3098
+ function statusForResult(result, dryRun) {
3099
+ if (result.failed) return red("failed");
3100
+ if (result.skipped) return dim("skipped");
3101
+ if (dryRun) return yellow("would translate");
3102
+ return green("translated");
3103
+ }
3104
+ function detailForResult(result) {
3105
+ const parts = [];
3106
+ if (result.durationMs !== void 0) parts.push(`${(result.durationMs / 1e3).toFixed(1)}s`);
3107
+ if (result.usage) {
3108
+ parts.push(`${formatTokenCount(result.usage.inputTokens)} in`);
3109
+ parts.push(`${formatTokenCount(result.usage.outputTokens)} out`);
3110
+ }
3111
+ if (result.estimatedCostUsd !== void 0) parts.push(formatUsd(result.estimatedCostUsd));
3112
+ if (result.error) parts.push(red(result.error));
3113
+ return parts.length > 0 ? dim(parts.join(" \xB7 ")) : "";
3114
+ }
3115
+ function formatSummaryLine(totals, dryRun) {
3116
+ const action = dryRun ? "Would translate" : "Translated";
3117
+ const parts = [
3118
+ `${action} ${totals.translated}`,
3119
+ `skipped ${totals.skipped}`
3120
+ ];
3121
+ if (totals.failed > 0) parts.push(red(`failed ${totals.failed}`));
3122
+ parts.push(`${formatTokenCount(totals.inputTokens)} in / ${formatTokenCount(totals.outputTokens)} out`);
3123
+ parts.push(`${formatUsd(totals.estimatedCostUsd)} est.`);
3124
+ parts.push(`${(totals.durationMs / 1e3).toFixed(1)}s`);
3125
+ return parts.join(" \xB7 ");
3126
+ }
3127
+ function createTranslateProgressReporter(options = {}) {
3128
+ const enabled = options.enabled ?? isInteractive2();
3129
+ const dryRun = Boolean(options.dryRun);
3130
+ const recentLimit = options.recentLimit ?? 6;
3131
+ if (!enabled) {
3132
+ return {
3133
+ onEvent(event) {
3134
+ if (event.type === "item-done") {
3135
+ const label = labelForResult(event.result);
3136
+ const status = statusForResult(event.result, dryRun);
3137
+ const detail = detailForResult(event.result);
3138
+ console.log(`${label}: ${status}${detail ? ` (${detail.replace(/\x1b\[[0-9;]*m/g, "")})` : ""}`);
3139
+ if (event.result.failed && event.result.error) {
3140
+ console.error(event.result.error);
3141
+ }
3142
+ return;
3143
+ }
3144
+ if (event.type === "done") {
3145
+ console.log(formatSummaryLine(event.totals, dryRun));
3146
+ }
3147
+ },
3148
+ finish() {
3149
+ }
3150
+ };
3151
+ }
3152
+ let total = 0;
3153
+ let concurrency = 1;
3154
+ let model;
3155
+ let done = 0;
3156
+ let inputTokens = 0;
3157
+ let outputTokens = 0;
3158
+ let estimatedCostUsd = 0;
3159
+ let active = [];
3160
+ const recent = [];
3161
+ let renderedLines = 0;
3162
+ let cursorHidden = false;
3163
+ function hideCursor() {
3164
+ if (!cursorHidden) {
3165
+ process.stdout.write("\x1B[?25l");
3166
+ cursorHidden = true;
3167
+ }
3168
+ }
3169
+ function showCursor() {
3170
+ if (cursorHidden) {
3171
+ process.stdout.write("\x1B[?25h");
3172
+ cursorHidden = false;
3173
+ }
3174
+ }
3175
+ function render() {
3176
+ hideCursor();
3177
+ if (renderedLines > 0) {
3178
+ process.stdout.write(`\x1B[${renderedLines}A`);
3179
+ }
3180
+ const lines = [];
3181
+ const title = dryRun ? "Dry run" : "Translating";
3182
+ lines.push(cyan(`${title} ${done}/${total}`) + dim(` \xB7 ${concurrency} parallel`) + (model ? dim(` \xB7 ${model}`) : ""));
3183
+ lines.push(progressBar(total === 0 ? 0 : done / total) + dim(` ${Math.round(total === 0 ? 0 : done / total * 100)}%`));
3184
+ lines.push(
3185
+ dim("Tokens ") + `${formatTokenCount(inputTokens)} in \xB7 ${formatTokenCount(outputTokens)} out` + dim(" \xB7 Cost ") + formatUsd(estimatedCostUsd) + dim(" est.")
3186
+ );
3187
+ if (active.length > 0) {
3188
+ lines.push(dim("Active ") + active.slice(0, 3).join(", ") + (active.length > 3 ? dim(` +${active.length - 3}`) : ""));
3189
+ } else if (done < total) {
3190
+ lines.push(dim("Active ") + "starting\u2026");
3191
+ } else {
3192
+ lines.push("");
3193
+ }
3194
+ lines.push("");
3195
+ if (recent.length === 0) {
3196
+ lines.push(dim("Waiting for first page\u2026"));
3197
+ } else {
3198
+ for (const result of recent) {
3199
+ const detail = detailForResult(result);
3200
+ lines.push(`${statusForResult(result, dryRun)} ${labelForResult(result)}${detail ? ` ${detail}` : ""}`);
3201
+ }
3202
+ }
3203
+ for (const line of lines) {
3204
+ process.stdout.write("\x1B[2K" + line + "\n");
3205
+ }
3206
+ renderedLines = lines.length;
3207
+ }
3208
+ return {
3209
+ onEvent(event) {
3210
+ switch (event.type) {
3211
+ case "start":
3212
+ total = event.total;
3213
+ concurrency = event.concurrency;
3214
+ model = event.model;
3215
+ render();
3216
+ break;
3217
+ case "item-start":
3218
+ active = event.active;
3219
+ render();
3220
+ break;
3221
+ case "item-done": {
3222
+ done += 1;
3223
+ active = active.filter((label) => label !== labelForResult(event.result));
3224
+ inputTokens += event.result.usage?.inputTokens ?? 0;
3225
+ outputTokens += event.result.usage?.outputTokens ?? 0;
3226
+ estimatedCostUsd += event.result.estimatedCostUsd ?? 0;
3227
+ recent.unshift(event.result);
3228
+ recent.splice(recentLimit);
3229
+ render();
3230
+ break;
3231
+ }
3232
+ case "done":
3233
+ showCursor();
3234
+ if (renderedLines > 0) {
3235
+ process.stdout.write(`\x1B[${renderedLines}A`);
3236
+ }
3237
+ process.stdout.write("\x1B[2K" + green("Done") + " \xB7 " + formatSummaryLine(event.totals, dryRun) + "\n");
3238
+ renderedLines = 0;
3239
+ for (const result of event.results.filter((entry) => entry.failed)) {
3240
+ process.stdout.write("\x1B[2K" + red(`${labelForResult(result)}: ${result.error ?? "failed"}`) + "\n");
3241
+ }
3242
+ break;
3243
+ }
3244
+ },
3245
+ finish() {
3246
+ showCursor();
3247
+ }
3248
+ };
3249
+ }
3250
+
3251
+ // src/version.ts
3252
+ init_cjs_shims();
3253
+ function readScribeVersion() {
3254
+ let dir = path3__default.default.dirname(url.fileURLToPath(importMetaUrl));
3255
+ for (; ; ) {
3256
+ const candidate = path3__default.default.join(dir, "package.json");
3257
+ if (fs2__default.default.existsSync(candidate)) {
3258
+ const pkg = JSON.parse(fs2__default.default.readFileSync(candidate, "utf8"));
3259
+ if (pkg.name === "scribe-cms" && pkg.version) return pkg.version;
3260
+ }
3261
+ const parent = path3__default.default.dirname(dir);
3262
+ if (parent === dir) break;
3263
+ dir = parent;
3264
+ }
3265
+ return "unknown";
3266
+ }
3267
+ var SCRIBE_VERSION = readScribeVersion();
3268
+
2141
3269
  // cli/index.ts
2142
3270
  function parseArgs(argv) {
2143
3271
  const options = { cwd: process.cwd() };
@@ -2194,6 +3322,35 @@ function parseArgs(argv) {
2194
3322
  i++;
2195
3323
  continue;
2196
3324
  }
3325
+ if (arg === "--concurrency") {
3326
+ options.concurrency = Number(argv[++i]);
3327
+ i++;
3328
+ continue;
3329
+ }
3330
+ if (arg === "--no-progress") {
3331
+ options.noProgress = true;
3332
+ i++;
3333
+ continue;
3334
+ }
3335
+ if (arg === "--strategy") {
3336
+ const value = argv[++i];
3337
+ if (value !== "all" && value !== "missing-only") {
3338
+ throw new Error(`Unknown strategy: ${value} (expected all or missing-only)`);
3339
+ }
3340
+ options.strategy = value;
3341
+ i++;
3342
+ continue;
3343
+ }
3344
+ if (arg === "--out") {
3345
+ options.out = argv[++i];
3346
+ i++;
3347
+ continue;
3348
+ }
3349
+ if (arg === "--extension") {
3350
+ options.extension = argv[++i];
3351
+ i++;
3352
+ continue;
3353
+ }
2197
3354
  if (arg.startsWith("--")) {
2198
3355
  throw new Error(`Unknown flag: ${arg}`);
2199
3356
  }
@@ -2211,7 +3368,13 @@ function loadProject(options) {
2211
3368
  return createProject(config);
2212
3369
  }
2213
3370
  async function main() {
2214
- const { command, options, rest } = parseArgs(process.argv.slice(2));
3371
+ const argv = process.argv.slice(2);
3372
+ const first = argv[0];
3373
+ if (first === "--version" || first === "-V" || first === "version") {
3374
+ console.log(SCRIBE_VERSION);
3375
+ return;
3376
+ }
3377
+ const { command, options, rest } = parseArgs(argv);
2215
3378
  loadEnvFromCwd(options.cwd);
2216
3379
  if (command === "help" || command === "--help") {
2217
3380
  console.log(`Usage: scribe <command>
@@ -2219,12 +3382,36 @@ async function main() {
2219
3382
  Commands:
2220
3383
  status Show EN docs + translation counts
2221
3384
  validate Validate EN files and sqlite consistency
3385
+ export-static Write raw MDX files for static hosting
2222
3386
  translate Translate stale/missing locale pages
2223
3387
  history <type> <slug> Show revision timeline
2224
3388
  studio Start read-only local studio
3389
+ version Print scribe-cms version
3390
+
3391
+ Export-static flags:
3392
+ --out <dir> Output directory (default: public)
3393
+ --extension <ext> File extension (default: mdx)
3394
+ --type <id> Comma-separated content types (default: all routable)
3395
+ --locale <code>... Locales to export (default: all configured)
3396
+
3397
+ Translate flags:
3398
+ --type <id> Content type (interactive picker in a TTY when omitted)
3399
+ --preset <name> Locale preset from config (interactive picker in a TTY)
3400
+ --locale <code>... Target locale(s); overrides --preset
3401
+ --slug <en-slug> Single English document
3402
+ --model <id> Gemini model override
3403
+ --concurrency <n> Parallel translations (default: 3)
3404
+ --dry-run List work without writing
3405
+ --force Re-translate even when hashes match
3406
+ --strategy <mode> all (default) or missing-only
3407
+ --no-progress Plain line logging instead of live progress
2225
3408
  `);
2226
3409
  return;
2227
3410
  }
3411
+ if (command === "version") {
3412
+ console.log(SCRIBE_VERSION);
3413
+ return;
3414
+ }
2228
3415
  const project = loadProject(options);
2229
3416
  const config = project.config;
2230
3417
  switch (command) {
@@ -2251,24 +3438,49 @@ Commands:
2251
3438
  if (!result.ok) process.exitCode = 1;
2252
3439
  break;
2253
3440
  }
3441
+ case "export-static": {
3442
+ const types = options.type?.split(",").map((t) => t.trim()).filter(Boolean);
3443
+ const { written } = writeStaticRawExports(project, {
3444
+ outDir: options.out,
3445
+ extension: options.extension ? `.${options.extension.replace(/^\./, "")}` : void 0,
3446
+ types,
3447
+ locales: options.locale
3448
+ });
3449
+ console.log(`Wrote ${written} static raw exports to ${options.out ?? "public"}`);
3450
+ break;
3451
+ }
2254
3452
  case "translate": {
2255
- const locales = resolveLocalesFromPreset(config, options.preset, options.locale);
3453
+ const selection = await promptTranslateSelection(config, {
3454
+ type: options.type,
3455
+ preset: options.preset,
3456
+ locale: options.locale,
3457
+ strategy: options.strategy
3458
+ });
3459
+ const locales = resolveLocalesFromPreset(config, selection.preset, selection.locale);
2256
3460
  const worklist = buildWorklist(config, {
2257
- contentType: options.type,
3461
+ contentType: selection.contentType,
2258
3462
  locales,
2259
- enSlug: options.slug
3463
+ enSlug: options.slug,
3464
+ strategy: selection.strategy ?? "all"
3465
+ });
3466
+ if (worklist.length === 0) {
3467
+ console.log("Nothing to translate.");
3468
+ break;
3469
+ }
3470
+ const reporter = createTranslateProgressReporter({
3471
+ enabled: !options.noProgress,
3472
+ dryRun: options.dryRun
2260
3473
  });
2261
- console.log(`Translating ${worklist.length} page(s)...`);
2262
3474
  const results = await translateWorklist(config, worklist, {
2263
3475
  model: options.model,
2264
3476
  dryRun: options.dryRun,
2265
- force: options.force
3477
+ force: options.force,
3478
+ concurrency: options.concurrency,
3479
+ onProgress: reporter.onEvent
2266
3480
  });
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
- }
3481
+ reporter.finish();
3482
+ const failed = results.filter((result) => result.failed);
3483
+ if (failed.length > 0) process.exitCode = 1;
2272
3484
  break;
2273
3485
  }
2274
3486
  case "history": {