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
package/dist/cli/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import path4 from 'path';
3
- import 'url';
3
+ import { fileURLToPath } from 'url';
4
4
  import fs2 from 'fs';
5
5
  import Database from 'better-sqlite3';
6
6
  import { z } from 'zod';
@@ -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(path14, typeId) {
222
+ if (!path14.startsWith("/")) {
223
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path14}")`);
195
224
  }
196
- const count = (path11.match(/\{slug\}/g) ?? []).length;
225
+ const count = (path14.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 "${path14}")`
200
229
  );
201
230
  }
202
231
  }
@@ -248,6 +277,7 @@ function resolveConfig(input, baseDir) {
248
277
  const projectRoot = path4.resolve(baseDir ?? process.cwd(), raw.rootDir);
249
278
  const contentRoot = path4.resolve(projectRoot, raw.contentDir ?? "content");
250
279
  const storePath = path4.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
280
+ const assetsPath = raw.assetsDir ? path4.resolve(projectRoot, raw.assetsDir) : void 0;
251
281
  const seenIds = /* @__PURE__ */ new Set();
252
282
  const types = raw.types.map((type) => {
253
283
  if (seenIds.has(type.id)) {
@@ -268,6 +298,7 @@ function resolveConfig(input, baseDir) {
268
298
  const config = {
269
299
  rootDir: contentRoot,
270
300
  storePath,
301
+ assetsPath,
271
302
  locales: [...raw.locales],
272
303
  defaultLocale,
273
304
  localePresets: raw.localePresets,
@@ -324,10 +355,10 @@ function introspectSchema(schema, prefix = []) {
324
355
  }
325
356
  function extractByPaths(data, paths) {
326
357
  const out = {};
327
- for (const path11 of paths) {
328
- const value = getAtPath(data, path11);
358
+ for (const path14 of paths) {
359
+ const value = getAtPath(data, path14);
329
360
  if (value !== void 0) {
330
- setAtPath(out, path11.filter((p) => p !== "*"), value);
361
+ setAtPath(out, path14.filter((p) => p !== "*"), value);
331
362
  }
332
363
  }
333
364
  return out;
@@ -345,13 +376,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
345
376
  const translatable = pickTranslatable(localeData, schema);
346
377
  return deepMerge(structural, translatable);
347
378
  }
348
- function getAtPath(obj, path11) {
379
+ function getAtPath(obj, path14) {
349
380
  let current = obj;
350
- for (const segment of path11) {
381
+ for (const segment of path14) {
351
382
  if (segment === "*") {
352
383
  if (!Array.isArray(current)) return void 0;
353
384
  return current.map(
354
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path11.slice(path11.indexOf("*") + 1)) : void 0
385
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path14.slice(path14.indexOf("*") + 1)) : void 0
355
386
  );
356
387
  }
357
388
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -359,13 +390,13 @@ function getAtPath(obj, path11) {
359
390
  }
360
391
  return current;
361
392
  }
362
- function setAtPath(obj, path11, value) {
363
- if (path11.length === 0) return;
364
- if (path11.length === 1) {
365
- obj[path11[0]] = value;
393
+ function setAtPath(obj, path14, value) {
394
+ if (path14.length === 0) return;
395
+ if (path14.length === 1) {
396
+ obj[path14[0]] = value;
366
397
  return;
367
398
  }
368
- const [head, ...rest] = path11;
399
+ const [head, ...rest] = path14;
369
400
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
370
401
  obj[head] = {};
371
402
  }
@@ -604,14 +635,18 @@ function listTranslationsForEnSlug(db, contentType, enSlug) {
604
635
  `SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
605
636
  ).all(contentType, enSlug);
606
637
  }
638
+ function listTranslationsForLocale(db, contentType, locale) {
639
+ return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
640
+ }
607
641
  function bulkLoadTranslations(db) {
608
642
  return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
609
643
  }
610
644
  function appendRevision(db, input) {
611
645
  const result = db.prepare(
612
646
  `INSERT INTO revisions (
613
- content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at, model, body_preview
614
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
647
+ content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
648
+ model, body_preview, frontmatter_json, body
649
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
615
650
  ).run(
616
651
  input.contentType,
617
652
  input.enSlug,
@@ -621,7 +656,9 @@ function appendRevision(db, input) {
621
656
  input.bodyHash,
622
657
  input.createdAt,
623
658
  input.model ?? null,
624
- input.bodyPreview ?? null
659
+ input.bodyPreview ?? null,
660
+ input.frontmatterJson ?? null,
661
+ input.body ?? null
625
662
  );
626
663
  return Number(result.lastInsertRowid);
627
664
  }
@@ -1431,41 +1468,6 @@ function loadConfigSync(options = {}) {
1431
1468
 
1432
1469
  // src/validate/validate-project.ts
1433
1470
  init_esm_shims();
1434
-
1435
- // src/hash/page-hash.ts
1436
- init_esm_shims();
1437
- function sha256(input) {
1438
- return createHash("sha256").update(input, "utf8").digest("hex");
1439
- }
1440
- function computePageEnHash(translatableFrontmatter, body) {
1441
- const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1442
- return sha256(payload);
1443
- }
1444
- function computeBodyHash(body) {
1445
- return sha256(body);
1446
- }
1447
-
1448
- // src/history/record-revision.ts
1449
- init_esm_shims();
1450
- init_sqlite();
1451
- function recordRevision(config, input) {
1452
- const db = openStore(config, "readwrite");
1453
- const id = appendRevision(db, {
1454
- contentType: input.contentType,
1455
- enSlug: input.enSlug,
1456
- locale: input.locale,
1457
- revisionKind: input.revisionKind,
1458
- enHash: input.enHash,
1459
- bodyHash: computeBodyHash(input.body),
1460
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1461
- model: input.model,
1462
- bodyPreview: input.body.slice(0, 200)
1463
- });
1464
- db.close();
1465
- return id;
1466
- }
1467
-
1468
- // src/validate/validate-project.ts
1469
1471
  init_sqlite();
1470
1472
 
1471
1473
  // src/validate/validate-relations.ts
@@ -1550,6 +1552,75 @@ function validateRelations(project) {
1550
1552
  return issues;
1551
1553
  }
1552
1554
 
1555
+ // src/validate/validate-assets.ts
1556
+ init_esm_shims();
1557
+ var IMAGE_EXT = String.raw`(?:png|jpe?g|webp|gif|svg)`;
1558
+ var IMAGE_WEB_PATH = String.raw`\/[\w\-./]+\.${IMAGE_EXT}`;
1559
+ var MARKDOWN_IMAGE_RE = new RegExp(
1560
+ String.raw`!\[[^\]]*\]\((${IMAGE_WEB_PATH})\)`,
1561
+ "gi"
1562
+ );
1563
+ var HTML_SRC_RE = new RegExp(String.raw`src=["'](${IMAGE_WEB_PATH})["']`, "gi");
1564
+ function isImageWebPath(value) {
1565
+ return new RegExp(String.raw`^${IMAGE_WEB_PATH}$`, "i").test(value);
1566
+ }
1567
+ function isSiteAssetPath(webPath) {
1568
+ const segment = webPath.split("/")[1];
1569
+ return Boolean(segment && !segment.includes("."));
1570
+ }
1571
+ function addImagePath(webPath, out) {
1572
+ if (isSiteAssetPath(webPath)) out.add(webPath);
1573
+ }
1574
+ function collectBodyImagePaths(body, out) {
1575
+ for (const re of [MARKDOWN_IMAGE_RE, HTML_SRC_RE]) {
1576
+ for (const match of body.matchAll(re)) {
1577
+ addImagePath(match[1], out);
1578
+ }
1579
+ }
1580
+ }
1581
+ function collectFrontmatterImagePaths(value, out) {
1582
+ if (typeof value === "string") {
1583
+ if (isImageWebPath(value)) addImagePath(value, out);
1584
+ return;
1585
+ }
1586
+ if (Array.isArray(value)) {
1587
+ for (const item of value) collectFrontmatterImagePaths(item, out);
1588
+ return;
1589
+ }
1590
+ if (value && typeof value === "object") {
1591
+ for (const nested of Object.values(value)) {
1592
+ collectFrontmatterImagePaths(nested, out);
1593
+ }
1594
+ }
1595
+ }
1596
+ function collectImagePaths(frontmatter, body) {
1597
+ const paths = /* @__PURE__ */ new Set();
1598
+ collectFrontmatterImagePaths(frontmatter, paths);
1599
+ collectBodyImagePaths(body, paths);
1600
+ return [...paths].sort();
1601
+ }
1602
+ function assetFilePath(assetsPath, webPath) {
1603
+ return path4.join(assetsPath, webPath.replace(/^\//, ""));
1604
+ }
1605
+ function validateDocumentAssets(config, input) {
1606
+ const assetsPath = config.assetsPath;
1607
+ if (!assetsPath) return [];
1608
+ const issues = [];
1609
+ for (const webPath of collectImagePaths(input.frontmatter, input.body)) {
1610
+ const filePath = assetFilePath(assetsPath, webPath);
1611
+ if (fs2.existsSync(filePath)) continue;
1612
+ issues.push({
1613
+ level: "warning",
1614
+ contentType: input.contentType,
1615
+ enSlug: input.enSlug,
1616
+ locale: input.locale,
1617
+ field: "asset",
1618
+ message: `Missing image asset ${webPath} (expected ${filePath})`
1619
+ });
1620
+ }
1621
+ return issues;
1622
+ }
1623
+
1553
1624
  // src/validate/validate-slug-suffix.ts
1554
1625
  init_esm_shims();
1555
1626
  function validateTranslationSlugSuffixes(config, db) {
@@ -1612,8 +1683,6 @@ function validateProject(config) {
1612
1683
  });
1613
1684
  continue;
1614
1685
  }
1615
- const payload = getTranslatablePayload(enDoc, type);
1616
- const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1617
1686
  const crossIssues = type.crossValidate?.(enDoc.frontmatter, {
1618
1687
  locale: config.defaultLocale,
1619
1688
  defaultLocale: config.defaultLocale,
@@ -1631,28 +1700,18 @@ function validateProject(config) {
1631
1700
  message: issue.message
1632
1701
  });
1633
1702
  }
1703
+ for (const issue of validateDocumentAssets(config, {
1704
+ contentType: type.id,
1705
+ enSlug,
1706
+ frontmatter: enDoc.frontmatter,
1707
+ body: enDoc.content
1708
+ })) {
1709
+ issues.push(issue);
1710
+ }
1634
1711
  for (const locale of config.locales) {
1635
1712
  if (locale === config.defaultLocale) continue;
1636
1713
  const row = getTranslation(db, type.id, enSlug, locale);
1637
1714
  if (!row) continue;
1638
- if (row.en_hash !== currentEnHash) {
1639
- issues.push({
1640
- level: "warning",
1641
- contentType: type.id,
1642
- enSlug,
1643
- locale,
1644
- field: "en_hash",
1645
- message: "Translation is stale (en_hash mismatch)"
1646
- });
1647
- recordRevision(config, {
1648
- contentType: type.id,
1649
- enSlug,
1650
- locale,
1651
- revisionKind: "en_edit_detected",
1652
- enHash: currentEnHash,
1653
- body: row.body
1654
- });
1655
- }
1656
1715
  const localeFm = JSON.parse(row.frontmatter_json);
1657
1716
  for (const issue of validateLocaleBuiltinFields(localeFm)) {
1658
1717
  issues.push({
@@ -1664,6 +1723,15 @@ function validateProject(config) {
1664
1723
  message: issue.message
1665
1724
  });
1666
1725
  }
1726
+ for (const issue of validateDocumentAssets(config, {
1727
+ contentType: type.id,
1728
+ enSlug,
1729
+ locale,
1730
+ frontmatter: localeFm,
1731
+ body: row.body
1732
+ })) {
1733
+ issues.push(issue);
1734
+ }
1667
1735
  }
1668
1736
  }
1669
1737
  try {
@@ -1734,6 +1802,21 @@ function validateProject(config) {
1734
1802
 
1735
1803
  // src/translate/worklist.ts
1736
1804
  init_esm_shims();
1805
+
1806
+ // src/hash/page-hash.ts
1807
+ init_esm_shims();
1808
+ function sha256(input) {
1809
+ return createHash("sha256").update(input, "utf8").digest("hex");
1810
+ }
1811
+ function computePageEnHash(translatableFrontmatter, body) {
1812
+ const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1813
+ return sha256(payload);
1814
+ }
1815
+ function computeBodyHash(body) {
1816
+ return sha256(body);
1817
+ }
1818
+
1819
+ // src/translate/worklist.ts
1737
1820
  init_sqlite();
1738
1821
  function listEnSlugs3(rootDir, contentDir) {
1739
1822
  const dir = path4.join(rootDir, contentDir);
@@ -1779,6 +1862,10 @@ function buildWorklist(config, options = {}) {
1779
1862
  }
1780
1863
  }
1781
1864
  db.close();
1865
+ const strategy = options.strategy ?? "all";
1866
+ if (strategy === "missing-only") {
1867
+ return items.filter((item) => item.reason === "missing");
1868
+ }
1782
1869
  return items;
1783
1870
  }
1784
1871
  function resolveLocalesFromPreset(config, preset, explicitLocales) {
@@ -1791,11 +1878,59 @@ function resolveLocalesFromPreset(config, preset, explicitLocales) {
1791
1878
 
1792
1879
  // src/translate/page-translator.ts
1793
1880
  init_esm_shims();
1881
+
1882
+ // src/history/record-revision.ts
1883
+ init_esm_shims();
1884
+ init_sqlite();
1885
+ function recordRevision(config, input) {
1886
+ const db = openStore(config, "readwrite");
1887
+ const id = appendRevision(db, {
1888
+ contentType: input.contentType,
1889
+ enSlug: input.enSlug,
1890
+ locale: input.locale,
1891
+ revisionKind: input.revisionKind,
1892
+ enHash: input.enHash,
1893
+ bodyHash: computeBodyHash(input.body),
1894
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1895
+ model: input.model,
1896
+ bodyPreview: input.body.slice(0, 200),
1897
+ frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
1898
+ body: input.body
1899
+ });
1900
+ db.close();
1901
+ return id;
1902
+ }
1903
+
1904
+ // src/translate/page-translator.ts
1794
1905
  init_sqlite();
1795
1906
 
1796
1907
  // src/translate/gemini-client.ts
1797
1908
  init_esm_shims();
1798
- var DEFAULT_MODEL = "gemini-2.5-pro";
1909
+
1910
+ // src/translate/gemini-models.ts
1911
+ init_esm_shims();
1912
+ var GEMINI_MODEL_IDS = {
1913
+ "gemini-2.5-pro": "gemini-2.5-pro",
1914
+ "gemini-3.1-pro": "gemini-3.1-pro-preview"
1915
+ };
1916
+ var DEFAULT_GEMINI_MODEL = "gemini-3.1-pro";
1917
+ function stripModelsPrefix(model) {
1918
+ return model.replace(/^models\//, "");
1919
+ }
1920
+ function resolveGeminiModelId(model) {
1921
+ const name = stripModelsPrefix(model);
1922
+ return GEMINI_MODEL_IDS[name] ?? name;
1923
+ }
1924
+ function normalizeGeminiDisplayName(model) {
1925
+ const name = stripModelsPrefix(model);
1926
+ if (name in GEMINI_MODEL_IDS) return name;
1927
+ const alias = Object.entries(GEMINI_MODEL_IDS).find(([, apiId]) => apiId === name);
1928
+ if (alias) return alias[0];
1929
+ return name;
1930
+ }
1931
+
1932
+ // src/translate/gemini-client.ts
1933
+ var DEFAULT_MODEL = DEFAULT_GEMINI_MODEL;
1799
1934
  function extractJson(text) {
1800
1935
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
1801
1936
  if (fenced?.[1]) return fenced[1].trim();
@@ -1809,13 +1944,17 @@ async function translatePageWithGemini(input) {
1809
1944
  if (!apiKey) {
1810
1945
  throw new Error("GEMINI_API_KEY is required for scribe translate");
1811
1946
  }
1812
- const model = input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL;
1947
+ const displayModel = normalizeGeminiDisplayName(
1948
+ input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL
1949
+ );
1950
+ const apiModel = resolveGeminiModelId(displayModel);
1813
1951
  const ai = new GoogleGenAI({ apiKey });
1814
1952
  const response = await ai.models.generateContent({
1815
- model,
1953
+ model: apiModel,
1816
1954
  contents: input.prompt,
1817
1955
  config: {
1818
- responseMimeType: "application/json"
1956
+ responseMimeType: "application/json",
1957
+ ...input.responseSchema ? { responseSchema: input.responseSchema } : {}
1819
1958
  }
1820
1959
  });
1821
1960
  const raw = response.text ?? "";
@@ -1823,7 +1962,50 @@ async function translatePageWithGemini(input) {
1823
1962
  if (!parsed.frontmatter || typeof parsed.body !== "string") {
1824
1963
  throw new Error("Gemini response missing frontmatter/body");
1825
1964
  }
1826
- return { model, raw, parsed };
1965
+ const usageMetadata = response.usageMetadata;
1966
+ const usage = {
1967
+ inputTokens: usageMetadata?.promptTokenCount ?? 0,
1968
+ outputTokens: usageMetadata?.candidatesTokenCount ?? 0,
1969
+ totalTokens: usageMetadata?.totalTokenCount ?? 0
1970
+ };
1971
+ return { model: displayModel, raw, parsed, usage };
1972
+ }
1973
+
1974
+ // src/translate/gemini-pricing.ts
1975
+ init_esm_shims();
1976
+ var CONTEXT_TIER_TOKENS = 2e5;
1977
+ var GEMINI_PRICING_USD = {
1978
+ "gemini-3.1-pro": { input: [2, 4], output: [12, 18] }
1979
+ };
1980
+ function normalizeModelId(model) {
1981
+ return model.replace(/^models\//, "").toLowerCase();
1982
+ }
1983
+ function tierRate(tokens, tiers) {
1984
+ return tokens <= CONTEXT_TIER_TOKENS ? tiers[0] : tiers[1];
1985
+ }
1986
+ function resolveModelPricing(model) {
1987
+ const id = normalizeModelId(model);
1988
+ if (GEMINI_PRICING_USD[id]) return GEMINI_PRICING_USD[id];
1989
+ const match = Object.entries(GEMINI_PRICING_USD).find(([key]) => id.includes(key));
1990
+ return match?.[1];
1991
+ }
1992
+ function estimateTranslationCostUsd(model, inputTokens, outputTokens) {
1993
+ const pricing = resolveModelPricing(model);
1994
+ if (!pricing) return void 0;
1995
+ const inputRate = tierRate(inputTokens, pricing.input);
1996
+ const outputRate = tierRate(outputTokens, pricing.output);
1997
+ return inputTokens / 1e6 * inputRate + outputTokens / 1e6 * outputRate;
1998
+ }
1999
+ function formatTokenCount(tokens) {
2000
+ if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(2)}M`;
2001
+ if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`;
2002
+ return String(tokens);
2003
+ }
2004
+ function formatUsd(amount) {
2005
+ if (amount === void 0) return "n/a";
2006
+ if (amount >= 1) return `$${amount.toFixed(2)}`;
2007
+ if (amount >= 0.01) return `$${amount.toFixed(3)}`;
2008
+ return `$${amount.toFixed(4)}`;
1827
2009
  }
1828
2010
 
1829
2011
  // src/translate/prompts/translation-prompt.ts
@@ -1860,6 +2042,7 @@ function buildPageTranslationPrompt(input) {
1860
2042
  prompt,
1861
2043
  "",
1862
2044
  ...input.resolved.context ? ["## Context", input.resolved.context, ""] : [],
2045
+ ...input.contextLabel ? [`Document: ${input.contextLabel}`, ""] : [],
1863
2046
  "## Rules",
1864
2047
  ...input.resolved.rules.map((rule) => `- ${rule}`),
1865
2048
  "",
@@ -1867,10 +2050,6 @@ function buildPageTranslationPrompt(input) {
1867
2050
  "Return ONLY valid JSON with keys:",
1868
2051
  input.slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).",
1869
2052
  "",
1870
- "## EN metadata",
1871
- `title: ${input.enTitle}`,
1872
- `description: ${input.enDescription}`,
1873
- "",
1874
2053
  "## EN translatable frontmatter (JSON)",
1875
2054
  JSON.stringify(input.translatableFrontmatter, null, 2),
1876
2055
  "",
@@ -1880,6 +2059,74 @@ function buildPageTranslationPrompt(input) {
1880
2059
  return lines.join("\n");
1881
2060
  }
1882
2061
 
2062
+ // src/translate/response-schema.ts
2063
+ init_esm_shims();
2064
+ function getObjectShape(schema) {
2065
+ const base = peelOptionalWrappers(schema);
2066
+ if (base instanceof Object && "shape" in base) {
2067
+ return base.shape;
2068
+ }
2069
+ return null;
2070
+ }
2071
+ function getArraySchema(schema) {
2072
+ const base = peelOptionalWrappers(schema);
2073
+ if (base instanceof Object && "element" in base && base._def?.type === "array") {
2074
+ return base;
2075
+ }
2076
+ return null;
2077
+ }
2078
+ function extractTranslatableFromStructural(schema) {
2079
+ const arraySchema = getArraySchema(schema);
2080
+ if (arraySchema) {
2081
+ const inner = buildTranslatableSubschema(arraySchema.element);
2082
+ if (inner) return z.array(inner);
2083
+ return null;
2084
+ }
2085
+ if (getObjectShape(schema)) {
2086
+ return buildTranslatableSubschema(schema);
2087
+ }
2088
+ return null;
2089
+ }
2090
+ function buildTranslatableSubschema(schema) {
2091
+ const shape = getObjectShape(schema);
2092
+ if (!shape) return null;
2093
+ const out = {};
2094
+ for (const [key, child] of Object.entries(shape)) {
2095
+ const childSchema = child;
2096
+ const kind = getFieldKind(childSchema);
2097
+ if (kind === "translatable") {
2098
+ out[key] = peelOptionalWrappers(childSchema);
2099
+ } else if (kind === "structural") {
2100
+ const nested = extractTranslatableFromStructural(childSchema);
2101
+ if (nested) out[key] = nested;
2102
+ }
2103
+ }
2104
+ if (Object.keys(out).length === 0) return null;
2105
+ return z.object(out);
2106
+ }
2107
+ function buildGeminiResponseSchema(schema, slugStrategy) {
2108
+ try {
2109
+ const translatable = buildTranslatableSubschema(schema);
2110
+ if (!translatable) return null;
2111
+ const responseShape = {
2112
+ frontmatter: translatable,
2113
+ body: z.string()
2114
+ };
2115
+ if (slugStrategy === "localized") {
2116
+ responseShape.slug = z.string();
2117
+ }
2118
+ const jsonSchema = z.toJSONSchema(z.object(responseShape), {
2119
+ target: "draft-2020-12",
2120
+ unrepresentable: "any",
2121
+ io: "output"
2122
+ });
2123
+ delete jsonSchema.$schema;
2124
+ return jsonSchema;
2125
+ } catch {
2126
+ return null;
2127
+ }
2128
+ }
2129
+
1883
2130
  // src/translate/resolve-translate-config.ts
1884
2131
  init_esm_shims();
1885
2132
  function slugStrategyRules(slugStrategy, preserveTerms) {
@@ -1920,86 +2167,313 @@ function resolveTranslateConfig(config, type) {
1920
2167
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
1921
2168
  }
1922
2169
 
2170
+ // src/translate/validate-translation.ts
2171
+ init_esm_shims();
2172
+ function formatZodIssues(error) {
2173
+ return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
2174
+ }
2175
+ function sanitizeTranslatedFrontmatter(rawFrontmatter, schema) {
2176
+ return pickTranslatable(rawFrontmatter, schema);
2177
+ }
2178
+ function validateTranslatedFrontmatter(enDoc, localeFrontmatter, typeSchema) {
2179
+ const frontmatter = sanitizeTranslatedFrontmatter(localeFrontmatter, typeSchema);
2180
+ const merged = mergeStructuralOntoLocale(
2181
+ frontmatter,
2182
+ enDoc.frontmatter,
2183
+ typeSchema
2184
+ );
2185
+ const parsed = typeSchema.safeParse(merged);
2186
+ if (!parsed.success) {
2187
+ return { ok: false, error: formatZodIssues(parsed.error) };
2188
+ }
2189
+ return { ok: true, frontmatter };
2190
+ }
2191
+
1923
2192
  // src/translate/page-translator.ts
2193
+ function summarizeResults(results, durationMs) {
2194
+ return results.reduce(
2195
+ (totals, result) => {
2196
+ if (result.failed) totals.failed += 1;
2197
+ else if (result.skipped) totals.skipped += 1;
2198
+ else totals.translated += 1;
2199
+ totals.inputTokens += result.usage?.inputTokens ?? 0;
2200
+ totals.outputTokens += result.usage?.outputTokens ?? 0;
2201
+ totals.estimatedCostUsd += result.estimatedCostUsd ?? 0;
2202
+ return totals;
2203
+ },
2204
+ {
2205
+ translated: 0,
2206
+ skipped: 0,
2207
+ failed: 0,
2208
+ inputTokens: 0,
2209
+ outputTokens: 0,
2210
+ estimatedCostUsd: 0,
2211
+ durationMs
2212
+ }
2213
+ );
2214
+ }
2215
+ function formatTranslateError(error) {
2216
+ const message = error instanceof Error ? error.message : String(error);
2217
+ try {
2218
+ const parsed = JSON.parse(message);
2219
+ if (parsed.error?.message) return parsed.error.message;
2220
+ } catch {
2221
+ }
2222
+ const embedded = message.match(/"message"\s*:\s*"((?:\\.|[^"\\])*)"/);
2223
+ if (embedded?.[1]) {
2224
+ return embedded[1].replace(/\\"/g, '"').replace(/\\n/g, "\n");
2225
+ }
2226
+ return message.length > 160 ? `${message.slice(0, 157)}\u2026` : message;
2227
+ }
2228
+ async function runPool(items, concurrency, worker) {
2229
+ if (items.length === 0) return;
2230
+ let nextIndex = 0;
2231
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
2232
+ while (nextIndex < items.length) {
2233
+ const index = nextIndex++;
2234
+ await worker(items[index], index);
2235
+ }
2236
+ });
2237
+ await Promise.all(runners);
2238
+ }
2239
+ function resolveContextLabel(enDoc, enSlug) {
2240
+ const fm = enDoc.frontmatter;
2241
+ for (const key of ["title", "name", "h1"]) {
2242
+ const value = fm[key];
2243
+ if (typeof value === "string" && value.trim()) return value;
2244
+ }
2245
+ return enSlug;
2246
+ }
1924
2247
  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,
2248
+ const startedAt = Date.now();
2249
+ const base = {
2250
+ contentType: item.contentType,
2251
+ enSlug: item.enSlug,
2252
+ locale: item.locale
2253
+ };
2254
+ try {
2255
+ const type = config.types.find((t) => t.id === item.contentType);
2256
+ if (!type) throw new Error(`Unknown content type ${item.contentType}`);
2257
+ const enDoc = readEnDocument(config, type, item.enSlug);
2258
+ if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
2259
+ const payload = getTranslatablePayload(enDoc, type);
2260
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
2261
+ const db = openStore(config, "readonly");
2262
+ const existing = getTranslation(db, type.id, item.enSlug, item.locale);
2263
+ db.close();
2264
+ if (!options.force && existing && existing.en_hash === currentEnHash) {
2265
+ return {
2266
+ ...base,
2267
+ skipped: true,
2268
+ reason: "fresh",
2269
+ durationMs: Date.now() - startedAt
2270
+ };
2271
+ }
2272
+ const resolvedTranslate = resolveTranslateConfig(config, type);
2273
+ const model = options.model ?? resolvedTranslate.model;
2274
+ const prompt = buildPageTranslationPrompt({
2275
+ resolved: resolvedTranslate,
2276
+ targetLocale: item.locale,
2277
+ contextLabel: resolveContextLabel(enDoc, item.enSlug),
2278
+ translatableFrontmatter: payload.frontmatter,
2279
+ enBody: payload.body,
2280
+ slugStrategy: type.slugStrategy
2281
+ });
2282
+ if (options.dryRun) {
2283
+ return {
2284
+ ...base,
2285
+ skipped: false,
2286
+ model,
2287
+ durationMs: Date.now() - startedAt
2288
+ };
2289
+ }
2290
+ const responseSchema = buildGeminiResponseSchema(type.schema, type.slugStrategy);
2291
+ const result = await translatePageWithGemini({
2292
+ prompt,
2293
+ model,
2294
+ responseSchema: responseSchema ?? void 0
2295
+ });
2296
+ const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
2297
+ const validated = validateTranslatedFrontmatter(enDoc, result.parsed.frontmatter, type.schema);
2298
+ if (!validated.ok) {
2299
+ throw new Error(`Translation validation failed: ${validated.error}`);
2300
+ }
2301
+ const writeDb = openStore(config, "readwrite");
2302
+ upsertTranslation(writeDb, {
2303
+ contentType: type.id,
1937
2304
  enSlug: item.enSlug,
1938
2305
  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,
2306
+ slug,
2307
+ frontmatter: validated.frontmatter,
2308
+ body: result.parsed.body,
2309
+ enHash: currentEnHash,
2310
+ translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2311
+ model: result.model
2312
+ });
2313
+ writeDb.close();
2314
+ recordRevision(config, {
2315
+ contentType: type.id,
1956
2316
  enSlug: item.enSlug,
1957
2317
  locale: item.locale,
2318
+ revisionKind: "translation",
2319
+ enHash: currentEnHash,
2320
+ body: result.parsed.body,
2321
+ frontmatter: validated.frontmatter,
2322
+ model: result.model
2323
+ });
2324
+ const estimatedCostUsd = estimateTranslationCostUsd(
2325
+ normalizeGeminiDisplayName(result.model),
2326
+ result.usage.inputTokens,
2327
+ result.usage.outputTokens
2328
+ );
2329
+ return {
2330
+ ...base,
2331
+ skipped: false,
2332
+ model: result.model,
2333
+ usage: result.usage,
2334
+ estimatedCostUsd,
2335
+ durationMs: Date.now() - startedAt
2336
+ };
2337
+ } catch (error) {
2338
+ return {
2339
+ ...base,
1958
2340
  skipped: false,
1959
- model: options.model
2341
+ failed: true,
2342
+ error: formatTranslateError(error),
2343
+ durationMs: Date.now() - startedAt
1960
2344
  };
1961
2345
  }
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
2346
+ }
2347
+ function labelForItem(item) {
2348
+ return `${item.contentType}/${item.enSlug}@${item.locale}`;
2349
+ }
2350
+ async function translateWorklist(config, items, options = {}) {
2351
+ const concurrency = Math.max(1, options.concurrency ?? 3);
2352
+ const startedAt = Date.now();
2353
+ const results = new Array(items.length);
2354
+ const active = /* @__PURE__ */ new Set();
2355
+ options.onProgress?.({
2356
+ type: "start",
2357
+ total: items.length,
2358
+ concurrency,
2359
+ dryRun: Boolean(options.dryRun),
2360
+ model: options.model
1978
2361
  });
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
2362
+ await runPool(items, concurrency, async (item, index) => {
2363
+ const label = labelForItem(item);
2364
+ active.add(label);
2365
+ options.onProgress?.({ type: "item-start", item, active: [...active] });
2366
+ const result = await translatePage(config, item, options);
2367
+ results[index] = result;
2368
+ active.delete(label);
2369
+ options.onProgress?.({ type: "item-done", result });
1988
2370
  });
1989
- return {
1990
- contentType: item.contentType,
1991
- enSlug: item.enSlug,
1992
- locale: item.locale,
1993
- skipped: false,
1994
- model: result.model
1995
- };
2371
+ const totals = summarizeResults(results, Date.now() - startedAt);
2372
+ options.onProgress?.({ type: "done", results, totals });
2373
+ return results;
1996
2374
  }
1997
- async function translateWorklist(config, items, options = {}) {
1998
- const results = [];
1999
- for (const item of items) {
2000
- results.push(await translatePage(config, item, options));
2375
+
2376
+ // src/export/build-static-raw-exports.ts
2377
+ init_esm_shims();
2378
+
2379
+ // src/loader/parse-mdx.ts
2380
+ init_esm_shims();
2381
+ function serializeMdx(frontmatter, content) {
2382
+ return matter.stringify(content, frontmatter);
2383
+ }
2384
+
2385
+ // src/export/build-static-raw-exports.ts
2386
+ function normalizeExtension(ext) {
2387
+ return ext.startsWith(".") ? ext : `.${ext}`;
2388
+ }
2389
+ function exportDirSegment(pathTemplate) {
2390
+ const prefix = pathPrefix(pathTemplate);
2391
+ return prefix.replace(/^\/+|\/+$/g, "");
2392
+ }
2393
+ function getStaticExportRoots(project, options = {}) {
2394
+ const { config } = project;
2395
+ const locales = options.locales ?? config.locales;
2396
+ const typeFilter = options.types ? new Set(options.types) : null;
2397
+ const roots = /* @__PURE__ */ new Set();
2398
+ for (const type of project.listTypes()) {
2399
+ if (!isRoutableType(type.config)) continue;
2400
+ if (typeFilter && !typeFilter.has(type.id)) continue;
2401
+ const segment = exportDirSegment(type.config.path);
2402
+ roots.add(`${segment}/`);
2403
+ for (const locale of locales) {
2404
+ if (locale === config.defaultLocale) continue;
2405
+ roots.add(`${locale}/${segment}/`);
2406
+ }
2001
2407
  }
2002
- return results;
2408
+ return [...roots].sort();
2409
+ }
2410
+ function buildStaticRawExports(project, options = {}) {
2411
+ const { config } = project;
2412
+ const extension = normalizeExtension(options.extension ?? ".mdx");
2413
+ const locales = options.locales ?? config.locales;
2414
+ const excludeRedirected = options.excludeRedirected ?? true;
2415
+ const excludeNoindex = options.excludeNoindex ?? false;
2416
+ const typeFilter = options.types ? new Set(options.types) : null;
2417
+ const out = [];
2418
+ for (const type of project.listTypes()) {
2419
+ if (!isRoutableType(type.config)) continue;
2420
+ if (typeFilter && !typeFilter.has(type.id)) continue;
2421
+ const pathTemplate = type.config.path;
2422
+ const all = type.load();
2423
+ const enIdx = all.get(config.defaultLocale);
2424
+ if (!enIdx) continue;
2425
+ for (const locale of locales) {
2426
+ for (const enDoc of enIdx.bySlug.values()) {
2427
+ const resolved = type.resolve(enDoc.slug, locale);
2428
+ if (!resolved.document) continue;
2429
+ if (excludeRedirected && resolved.shouldRedirectTo) continue;
2430
+ if (excludeNoindex && resolved.document.noindex) continue;
2431
+ const doc = resolved.document;
2432
+ const slugWithExt = `${doc.slug}${extension}`;
2433
+ const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2434
+ out.push({
2435
+ relativePath: urlPath.slice(1),
2436
+ urlPath,
2437
+ locale,
2438
+ typeId: type.id,
2439
+ enSlug: doc.enSlug,
2440
+ source: serializeMdx(doc.frontmatter, doc.content)
2441
+ });
2442
+ }
2443
+ }
2444
+ }
2445
+ return out;
2446
+ }
2447
+
2448
+ // src/export/write-static-raw-exports.ts
2449
+ init_esm_shims();
2450
+ function rmDirIfExists(dir) {
2451
+ if (fs2.existsSync(dir)) {
2452
+ fs2.rmSync(dir, { recursive: true, force: true });
2453
+ }
2454
+ }
2455
+ function writeStaticRawExports(project, options = {}) {
2456
+ const outDir = path4.resolve(options.outDir ?? "public");
2457
+ const typeFilter = options.types;
2458
+ for (const root of getStaticExportRoots(project, {
2459
+ types: typeFilter,
2460
+ locales: options.locales
2461
+ })) {
2462
+ rmDirIfExists(path4.join(outDir, root));
2463
+ }
2464
+ const exports = buildStaticRawExports(project, options);
2465
+ const counts = /* @__PURE__ */ new Map();
2466
+ for (const item of exports) {
2467
+ const filePath = path4.join(outDir, item.relativePath);
2468
+ fs2.mkdirSync(path4.dirname(filePath), { recursive: true });
2469
+ fs2.writeFileSync(filePath, item.source, "utf8");
2470
+ const key = `${item.typeId}/${item.locale}`;
2471
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2472
+ }
2473
+ for (const [key, count] of [...counts.entries()].sort()) {
2474
+ console.log(` ${key}: ${count}`);
2475
+ }
2476
+ return { exports, written: exports.length };
2003
2477
  }
2004
2478
 
2005
2479
  // src/config/load-env.ts
@@ -2020,99 +2494,464 @@ function loadEnvFromCwd(cwd) {
2020
2494
  // studio/server.ts
2021
2495
  init_esm_shims();
2022
2496
  init_sqlite();
2023
- function renderLayout(title, body) {
2497
+ function escapeHtml(value) {
2498
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2499
+ }
2500
+ function encodePathSegment(value) {
2501
+ return encodeURIComponent(value);
2502
+ }
2503
+ function statusDot(status) {
2504
+ const labels = {
2505
+ source: "en",
2506
+ "up-to-date": "ok",
2507
+ stale: "stale",
2508
+ missing: "\u2014"
2509
+ };
2510
+ return `<span class="status" title="${status}"><span class="dot dot-${status}"></span>${labels[status]}</span>`;
2511
+ }
2512
+ function documentStatus(config, db, type, enSlug, locale) {
2513
+ if (locale === config.defaultLocale) {
2514
+ return { status: "source" };
2515
+ }
2516
+ const enDoc = readEnDocument(config, type.config, enSlug);
2517
+ if (!enDoc) {
2518
+ return { status: "missing" };
2519
+ }
2520
+ const payload = getTranslatablePayload(enDoc, type.config);
2521
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
2522
+ const row = getTranslation(db, type.id, enSlug, locale);
2523
+ if (!row) {
2524
+ return { status: "missing", currentEnHash };
2525
+ }
2526
+ if (row.en_hash !== currentEnHash) {
2527
+ return { status: "stale", currentEnHash, storedEnHash: row.en_hash };
2528
+ }
2529
+ return { status: "up-to-date", currentEnHash, storedEnHash: row.en_hash };
2530
+ }
2531
+ function flattenFrontmatter(data, prefix = "") {
2532
+ const rows = [];
2533
+ for (const [key, value] of Object.entries(data)) {
2534
+ const fullKey = prefix ? `${prefix}.${key}` : key;
2535
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2536
+ rows.push(...flattenFrontmatter(value, fullKey));
2537
+ } else {
2538
+ const display = typeof value === "string" ? value : value === void 0 ? "" : JSON.stringify(value, null, 2);
2539
+ rows.push({ key: fullKey, value: display });
2540
+ }
2541
+ }
2542
+ return rows;
2543
+ }
2544
+ function translatableKeySet(schema) {
2545
+ return new Set(
2546
+ introspectSchema(schema).filter((field2) => field2.kind === "translatable").map((field2) => field2.path.join("."))
2547
+ );
2548
+ }
2549
+ function renderFrontmatterTable(frontmatter, schema) {
2550
+ const translatable = translatableKeySet(schema);
2551
+ const rows = flattenFrontmatter(frontmatter).map((row) => {
2552
+ const flag = translatable.has(row.key) ? `<span class="flag t" title="Translatable">T</span>` : `<span class="flag s" title="Structural">S</span>`;
2553
+ return `<tr>
2554
+ <td class="k">${flag}${escapeHtml(row.key)}</td>
2555
+ <td class="v">${escapeHtml(row.value)}</td>
2556
+ </tr>`;
2557
+ }).join("");
2558
+ return `<table class="kv">
2559
+ <tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
2560
+ </table>`;
2561
+ }
2562
+ function renderRevisionSnapshot(revision) {
2563
+ if (revision.frontmatter_json && revision.body) {
2564
+ let frontmatter = {};
2565
+ try {
2566
+ frontmatter = JSON.parse(revision.frontmatter_json);
2567
+ } catch {
2568
+ frontmatter = {};
2569
+ }
2570
+ const fmRows = flattenFrontmatter(frontmatter).map(
2571
+ (row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
2572
+ ).join("");
2573
+ return `<table class="kv"><tbody>${fmRows}</tbody></table>
2574
+ <pre class="code">${escapeHtml(revision.body)}</pre>`;
2575
+ }
2576
+ return `<p class="dim">${escapeHtml(revision.body_preview ?? "(no snapshot)")}</p>`;
2577
+ }
2578
+ function renderRevisionTimeline(revisions) {
2579
+ if (revisions.length === 0) {
2580
+ return `<p class="dim">No history.</p>`;
2581
+ }
2582
+ const items = revisions.map(
2583
+ (row) => `<tr>
2584
+ <td class="dim">${escapeHtml(row.created_at.slice(0, 19))}</td>
2585
+ <td>${escapeHtml(row.revision_kind)}</td>
2586
+ <td class="dim">${row.model ? escapeHtml(row.model) : "\u2014"}</td>
2587
+ <td class="dim mono">${escapeHtml(row.en_hash.slice(0, 8))}</td>
2588
+ <td><details><summary>view</summary>${renderRevisionSnapshot(row)}</details></td>
2589
+ </tr>`
2590
+ ).join("");
2591
+ return `<table class="data"><thead><tr>
2592
+ <th>When</th><th>Kind</th><th>Model</th><th>Hash</th><th></th>
2593
+ </tr></thead><tbody>${items}</tbody></table>`;
2594
+ }
2595
+ function renderLayout(title, body, project, options = {}) {
2596
+ const typeLinks = project.listTypes().map((type) => {
2597
+ const active = type.id === options.activeTypeId ? " active" : "";
2598
+ return `<a class="tree-item${active}" href="/type/${encodePathSegment(type.id)}">
2599
+ <span class="tree-label">${escapeHtml(type.config.label)}</span>
2600
+ <span class="tree-meta">${escapeHtml(type.id)}</span>
2601
+ </a>`;
2602
+ }).join("");
2024
2603
  return `<!doctype html>
2025
2604
  <html lang="en">
2026
2605
  <head>
2027
2606
  <meta charset="utf-8" />
2028
2607
  <meta name="viewport" content="width=device-width, initial-scale=1" />
2029
- <title>${title} \u2014 Scribe Studio</title>
2608
+ <title>${escapeHtml(title)} \u2014 Scribe</title>
2030
2609
  <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; }
2610
+ :root {
2611
+ --bg: #1e1e1e;
2612
+ --sidebar: #252526;
2613
+ --bar: #333333;
2614
+ --panel: #1e1e1e;
2615
+ --border: #3c3c3c;
2616
+ --text: #cccccc;
2617
+ --dim: #858585;
2618
+ --accent: #3794ff;
2619
+ --hover: #2a2d2e;
2620
+ --active: #37373d;
2621
+ --ok: #89d185;
2622
+ --stale: #cca700;
2623
+ --missing: #f48771;
2624
+ --source: #75beff;
2625
+ --mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
2626
+ --ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2627
+ --fs: 13px;
2628
+ --fs-sm: 11px;
2629
+ }
2630
+ * { box-sizing: border-box; margin: 0; padding: 0; }
2631
+ body { font: var(--fs)/1.4 var(--ui); background: var(--bg); color: var(--text); }
2632
+ a { color: var(--accent); text-decoration: none; }
2633
+ a:hover { text-decoration: underline; }
2634
+ .app { display: flex; height: 100vh; overflow: hidden; }
2635
+
2636
+ /* activity bar */
2637
+ .actbar {
2638
+ width: 48px; flex-shrink: 0; background: var(--bar);
2639
+ display: flex; flex-direction: column; align-items: center;
2640
+ padding: 8px 0; gap: 4px; border-right: 1px solid var(--border);
2641
+ }
2642
+ .actbar a {
2643
+ width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
2644
+ color: var(--dim); font-size: 18px; text-decoration: none; position: relative;
2645
+ }
2646
+ .actbar a:hover { color: var(--text); }
2647
+ .actbar a.active { color: var(--text); }
2648
+ .actbar a.active::before {
2649
+ content: ""; position: absolute; left: 0; top: 8px; bottom: 8px;
2650
+ width: 2px; background: var(--accent);
2651
+ }
2652
+
2653
+ /* sidebar */
2654
+ .sidebar {
2655
+ width: 200px; flex-shrink: 0; background: var(--sidebar);
2656
+ border-right: 1px solid var(--border); display: flex; flex-direction: column;
2657
+ overflow: hidden;
2658
+ }
2659
+ .sidebar-head {
2660
+ padding: 8px 12px; font-size: var(--fs-sm); font-weight: 600;
2661
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
2662
+ }
2663
+ .sidebar-body { flex: 1; overflow-y: auto; padding: 2px 0; }
2664
+ .tree-item {
2665
+ display: flex; flex-direction: column; padding: 3px 12px 3px 20px;
2666
+ color: var(--text); text-decoration: none; line-height: 1.3;
2667
+ }
2668
+ .tree-item:hover { background: var(--hover); text-decoration: none; }
2669
+ .tree-item.active { background: var(--active); }
2670
+ .tree-label { font-size: var(--fs); }
2671
+ .tree-meta { font-size: var(--fs-sm); color: var(--dim); }
2672
+
2673
+ /* main */
2674
+ .main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
2675
+ .toolbar {
2676
+ display: flex; align-items: center; gap: 6px; padding: 0 12px;
2677
+ height: 35px; background: var(--sidebar); border-bottom: 1px solid var(--border);
2678
+ font-size: var(--fs-sm); color: var(--dim); flex-shrink: 0; overflow: hidden;
2679
+ }
2680
+ .toolbar a { color: var(--dim); }
2681
+ .toolbar a:hover { color: var(--text); }
2682
+ .toolbar .sep { color: var(--border); }
2683
+ .content { flex: 1; overflow-y: auto; padding: 0; }
2684
+
2685
+ /* tabs (locale switcher) */
2686
+ .tabs {
2687
+ display: flex; background: var(--sidebar); border-bottom: 1px solid var(--border);
2688
+ overflow-x: auto; flex-shrink: 0;
2689
+ }
2690
+ .tab {
2691
+ display: flex; align-items: center; gap: 6px; padding: 6px 12px;
2692
+ font-size: var(--fs-sm); color: var(--dim); border-right: 1px solid var(--border);
2693
+ text-decoration: none; white-space: nowrap;
2694
+ }
2695
+ .tab:hover { background: var(--hover); color: var(--text); text-decoration: none; }
2696
+ .tab.active {
2697
+ background: var(--bg); color: var(--text);
2698
+ border-bottom: 1px solid var(--bg); margin-bottom: -1px;
2699
+ }
2700
+
2701
+ /* status dots */
2702
+ .status { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); color: var(--dim); }
2703
+ .dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
2704
+ .dot-source { background: var(--source); }
2705
+ .dot-up-to-date { background: var(--ok); }
2706
+ .dot-stale { background: var(--stale); }
2707
+ .dot-missing { background: var(--missing); opacity: 0.7; }
2708
+
2709
+ /* sections */
2710
+ .section { border-bottom: 1px solid var(--border); }
2711
+ .section-head {
2712
+ padding: 4px 12px; font-size: var(--fs-sm); font-weight: 600;
2713
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
2714
+ background: var(--sidebar);
2715
+ }
2716
+ .section-body { padding: 0; }
2717
+
2718
+ /* tables */
2719
+ table { border-collapse: collapse; width: 100%; font-size: var(--fs); }
2720
+ .data th, .data td { padding: 2px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
2721
+ .data th { font-size: var(--fs-sm); font-weight: 600; color: var(--dim); background: var(--sidebar); height: 22px; }
2722
+ .data tr:hover td { background: var(--hover); }
2723
+ .data .mono { font-family: var(--mono); font-size: var(--fs-sm); }
2724
+
2725
+ /* key-value (frontmatter) */
2726
+ .kv td { padding: 1px 12px; font-family: var(--mono); font-size: var(--fs-sm); vertical-align: top; border-bottom: 1px solid var(--border); }
2727
+ .kv .k { width: 160px; color: #9cdcfe; white-space: nowrap; }
2728
+ .kv .v { color: #ce9178; white-space: pre-wrap; word-break: break-word; }
2729
+ .flag { font-size: 9px; font-weight: 700; margin-right: 4px; opacity: 0.5; }
2730
+ .flag.t { color: var(--ok); }
2731
+ .flag.s { color: var(--dim); }
2732
+
2733
+ /* code block */
2734
+ .code {
2735
+ margin: 0; padding: 8px 12px; font: var(--fs-sm)/1.5 var(--mono);
2736
+ white-space: pre-wrap; word-break: break-word; color: var(--text);
2737
+ background: var(--bg); border: none;
2738
+ }
2739
+
2740
+ /* meta row */
2741
+ .meta { display: flex; flex-wrap: wrap; font-size: var(--fs-sm); border-bottom: 1px solid var(--border); padding: 4px 0; }
2742
+ .meta dt { padding: 0 4px 0 12px; color: var(--dim); }
2743
+ .meta dt::after { content: ":"; }
2744
+ .meta dd { padding: 0 12px 0 0; font-family: var(--mono); }
2745
+
2746
+ /* misc */
2747
+ .dim { color: var(--dim); }
2748
+ .page-title { padding: 8px 12px 4px; font-size: 14px; font-weight: 400; }
2749
+ .page-sub { padding: 0 12px 8px; font-size: var(--fs-sm); color: var(--dim); }
2750
+ details summary { cursor: pointer; color: var(--accent); font-size: var(--fs-sm); }
2751
+ details[open] summary { margin-bottom: 4px; }
2752
+ .tag { font-size: var(--fs-sm); color: var(--dim); margin-left: 6px; }
2753
+ .tag-warn { color: var(--stale); }
2754
+ .tag-err { color: var(--missing); }
2042
2755
  </style>
2043
2756
  </head>
2044
2757
  <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>
2758
+ <div class="app">
2759
+ <nav class="actbar">
2760
+ <a href="/" title="Overview">\u2302</a>
2761
+ <a href="/staleness" title="Staleness">\u26A0</a>
2762
+ </nav>
2763
+ <aside class="sidebar">
2764
+ <div class="sidebar-head">Types</div>
2765
+ <div class="sidebar-body">${typeLinks || `<span class="dim" style="padding:8px 12px">\u2014</span>`}</div>
2766
+ </aside>
2767
+ <div class="main">
2768
+ <div class="content">${body}</div>
2769
+ </div>
2770
+ </div>
2052
2771
  </body>
2053
2772
  </html>`;
2054
2773
  }
2774
+ function docTitleFromFrontmatter(frontmatter, enSlug) {
2775
+ const title = frontmatter.title;
2776
+ if (typeof title === "string" && title.trim()) return title;
2777
+ return enSlug;
2778
+ }
2055
2779
  async function startStudio(project, options = {}) {
2056
2780
  const app = new Hono();
2057
2781
  const config = project.config;
2058
2782
  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>`;
2783
+ const db = openStore(config, "readonly");
2784
+ const worklist = buildWorklist(config);
2785
+ const rows = project.listTypes().map((type) => {
2786
+ const enCount = type.list().length;
2787
+ const localeCells = config.locales.filter((locale) => locale !== config.defaultLocale).map((locale) => {
2788
+ const translated = listTranslationsForLocale(db, type.id, locale).length;
2789
+ const stale = worklist.filter(
2790
+ (item) => item.contentType === type.id && item.locale === locale && item.reason === "stale"
2791
+ ).length;
2792
+ const missing = worklist.filter(
2793
+ (item) => item.contentType === type.id && item.locale === locale && item.reason === "missing"
2794
+ ).length;
2795
+ const tags = [
2796
+ stale ? `<span class="tag tag-warn">${stale}s</span>` : "",
2797
+ missing ? `<span class="tag tag-err">${missing}m</span>` : ""
2798
+ ].join("");
2799
+ return `<td>${translated}${tags}</td>`;
2063
2800
  }).join("");
2064
- return `<div class="card"><h2>${type.config.label}</h2><ul>${localeRows}</ul></div>`;
2801
+ return `<tr>
2802
+ <td><a href="/type/${encodePathSegment(type.id)}">${escapeHtml(type.config.label)}</a></td>
2803
+ <td class="dim">${escapeHtml(type.id)}</td>
2804
+ <td>${enCount}</td>
2805
+ ${localeCells}
2806
+ </tr>`;
2065
2807
  });
2066
- return c.html(renderLayout("Browser", cards.join("")));
2808
+ db.close();
2809
+ const localeHeaders = config.locales.filter((l) => l !== config.defaultLocale).map((l) => `<th>${escapeHtml(l)}</th>`).join("");
2810
+ const html = `<div class="toolbar">Overview</div>
2811
+ <table class="data">
2812
+ <thead><tr><th>Type</th><th>ID</th><th>EN</th>${localeHeaders}</tr></thead>
2813
+ <tbody>${rows.join("")}</tbody>
2814
+ </table>`;
2815
+ return c.html(renderLayout("Overview", html, project));
2067
2816
  });
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));
2817
+ app.get("/type/:id", (c) => {
2818
+ const typeId = c.req.param("id");
2819
+ const type = project.getType(typeId);
2820
+ if (!type) {
2821
+ return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
2822
+ }
2823
+ const db = openStore(config, "readonly");
2824
+ const locales = config.locales;
2825
+ const headerCells = locales.map((locale) => `<th>${escapeHtml(locale)}</th>`).join("");
2826
+ const rows = type.list().map((doc) => {
2827
+ const title = docTitleFromFrontmatter(doc.frontmatter, doc.slug);
2828
+ const statusCells = locales.map((locale) => {
2829
+ const { status } = documentStatus(config, db, type, doc.slug, locale);
2830
+ return `<td>${statusDot(status)}</td>`;
2831
+ }).join("");
2832
+ return `<tr>
2833
+ <td class="mono"><a href="/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(doc.slug)}">${escapeHtml(doc.slug)}</a></td>
2834
+ <td>${escapeHtml(title)}</td>
2835
+ ${statusCells}
2836
+ </tr>`;
2837
+ }).join("");
2838
+ db.close();
2839
+ const html = `<div class="toolbar">
2840
+ <a href="/">Overview</a><span class="sep">\u203A</span>${escapeHtml(type.config.label)}
2841
+ </div>
2842
+ <table class="data">
2843
+ <thead><tr><th>Slug</th><th>Title</th>${headerCells}</tr></thead>
2844
+ <tbody>${rows || `<tr><td colspan="${2 + locales.length}" class="dim">No documents</td></tr>`}</tbody>
2845
+ </table>`;
2846
+ return c.html(renderLayout(type.config.label, html, project, { activeTypeId: typeId }));
2076
2847
  });
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>"));
2848
+ app.get("/type/:id/doc/:enSlug", (c) => {
2849
+ const typeId = c.req.param("id");
2850
+ const enSlug = c.req.param("enSlug");
2851
+ const locale = c.req.query("locale") ?? config.defaultLocale;
2852
+ const showRaw = c.req.query("raw") === "1";
2853
+ const type = project.getType(typeId);
2854
+ if (!type) {
2855
+ return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
2856
+ }
2081
2857
  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>`;
2858
+ const enDoc = readEnDocument(config, type.config, enSlug);
2859
+ if (!enDoc) {
2860
+ db.close();
2861
+ return c.html(
2862
+ renderLayout("Not found", `<div class="toolbar">Not found</div><p class="dim" style="padding:12px">${escapeHtml(enSlug)}</p>`, project, {
2863
+ activeTypeId: typeId
2864
+ }),
2865
+ 404
2866
+ );
2867
+ }
2868
+ const localeTabs = config.locales.map((loc) => {
2869
+ const { status } = documentStatus(config, db, type, enSlug, loc);
2870
+ const active = loc === locale ? " active" : "";
2871
+ const href = `/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(enSlug)}?locale=${encodePathSegment(loc)}`;
2872
+ return `<a class="tab${active}" href="${href}">${escapeHtml(loc)} ${statusDot(status)}</a>`;
2873
+ }).join("");
2874
+ let contentPanel = "";
2875
+ let metaPanel = "";
2876
+ let historyPanel = "";
2877
+ if (locale === config.defaultLocale) {
2878
+ contentPanel = `<div class="section">
2879
+ <div class="section-head">Frontmatter</div>
2880
+ <div class="section-body">${renderFrontmatterTable(enDoc.frontmatter, type.config.schema)}</div>
2881
+ <div class="section-head">Body</div>
2882
+ <pre class="code">${escapeHtml(enDoc.content)}</pre>
2883
+ </div>`;
2088
2884
  } 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>`;
2885
+ const translation = getTranslation(db, typeId, enSlug, locale);
2886
+ const { status, currentEnHash, storedEnHash } = documentStatus(config, db, type, enSlug, locale);
2887
+ if (translation) {
2888
+ const rawFrontmatter = JSON.parse(translation.frontmatter_json);
2889
+ const displayFrontmatter = showRaw ? rawFrontmatter : mergeStructuralOntoLocale(rawFrontmatter, enDoc.frontmatter, type.config.schema);
2890
+ 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>`;
2891
+ contentPanel = `<div class="section">
2892
+ <div class="section-head">Frontmatter${rawToggle}</div>
2893
+ <div class="section-body">${renderFrontmatterTable(displayFrontmatter, type.config.schema)}</div>
2894
+ <div class="section-head">Body</div>
2895
+ <pre class="code">${escapeHtml(translation.body)}</pre>
2896
+ </div>`;
2897
+ metaPanel = `<dl class="meta">
2898
+ <dt>status</dt><dd>${statusDot(status)}</dd>
2899
+ <dt>model</dt><dd>${escapeHtml(translation.model)}</dd>
2900
+ <dt>translated</dt><dd>${escapeHtml(translation.translated_at.slice(0, 19))}</dd>
2901
+ <dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
2902
+ <dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
2903
+ </dl>`;
2904
+ const revisions = listRevisions(db, typeId, enSlug, locale);
2905
+ historyPanel = `<div class="section">
2906
+ <div class="section-head">History</div>
2907
+ <div class="section-body">${renderRevisionTimeline(revisions)}</div>
2908
+ </div>`;
2909
+ } else {
2910
+ contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
2911
+ metaPanel = `<dl class="meta"><dt>status</dt><dd>${statusDot(status)}</dd></dl>`;
2912
+ }
2091
2913
  }
2092
2914
  db.close();
2093
- return c.html(renderLayout("History", body));
2915
+ const title = docTitleFromFrontmatter(enDoc.frontmatter, enSlug);
2916
+ const html = `<div class="toolbar">
2917
+ <a href="/">Overview</a><span class="sep">\u203A</span>
2918
+ <a href="/type/${encodePathSegment(typeId)}">${escapeHtml(type.config.label)}</a><span class="sep">\u203A</span>
2919
+ <span>${escapeHtml(enSlug)}</span>
2920
+ </div>
2921
+ <div class="tabs">${localeTabs}</div>
2922
+ ${metaPanel}
2923
+ ${contentPanel}
2924
+ ${historyPanel}`;
2925
+ return c.html(renderLayout(title, html, project, { activeTypeId: typeId }));
2926
+ });
2927
+ app.get("/staleness", (c) => {
2928
+ const worklist = buildWorklist(config);
2929
+ const rows = worklist.slice(0, 500).map((item) => {
2930
+ const href = `/type/${encodePathSegment(item.contentType)}/doc/${encodePathSegment(item.enSlug)}?locale=${encodePathSegment(item.locale)}`;
2931
+ return `<tr>
2932
+ <td>${escapeHtml(item.contentType)}</td>
2933
+ <td class="mono"><a href="${href}">${escapeHtml(item.enSlug)}</a></td>
2934
+ <td>${escapeHtml(item.locale)}</td>
2935
+ <td>${statusDot(item.reason === "stale" ? "stale" : "missing")}</td>
2936
+ </tr>`;
2937
+ }).join("");
2938
+ const html = `<div class="toolbar">Staleness <span class="dim">\xB7 ${worklist.length} entries</span></div>
2939
+ <table class="data">
2940
+ <thead><tr><th>Type</th><th>Slug</th><th>Locale</th><th>Status</th></tr></thead>
2941
+ <tbody>${rows || `<tr><td colspan="4" class="dim">All up to date</td></tr>`}</tbody>
2942
+ </table>`;
2943
+ return c.html(renderLayout("Staleness", html, project));
2094
2944
  });
2095
2945
  app.get("/api/staleness-matrix", (c) => {
2946
+ const worklist = buildWorklist(config);
2096
2947
  const matrix = {};
2097
2948
  for (const type of project.listTypes()) {
2098
2949
  matrix[type.id] = {};
2099
2950
  for (const locale of config.locales) {
2100
2951
  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;
2952
+ matrix[type.id][locale] = worklist.filter(
2953
+ (item) => item.contentType === type.id && item.locale === locale
2954
+ ).length;
2116
2955
  }
2117
2956
  }
2118
2957
  return c.json(matrix);
@@ -2124,6 +2963,294 @@ async function startStudio(project, options = {}) {
2124
2963
  });
2125
2964
  }
2126
2965
 
2966
+ // cli/prompt-translate.ts
2967
+ init_esm_shims();
2968
+
2969
+ // ../../node_modules/@inquirer/core/dist/lib/errors.js
2970
+ init_esm_shims();
2971
+ var CancelPromptError = class extends Error {
2972
+ constructor() {
2973
+ super(...arguments);
2974
+ __publicField(this, "name", "CancelPromptError");
2975
+ __publicField(this, "message", "Prompt was canceled");
2976
+ }
2977
+ };
2978
+ function isInteractive() {
2979
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
2980
+ }
2981
+ function nonDefaultLocales(config) {
2982
+ return config.locales.filter((locale) => locale !== config.defaultLocale);
2983
+ }
2984
+ async function runPrompt(prompt) {
2985
+ try {
2986
+ return await prompt;
2987
+ } catch (error) {
2988
+ if (error instanceof CancelPromptError) process.exit(0);
2989
+ throw error;
2990
+ }
2991
+ }
2992
+ async function promptContentType(config) {
2993
+ return runPrompt(
2994
+ select({
2995
+ message: "Content type to translate",
2996
+ choices: [
2997
+ { name: "All content types", value: void 0 },
2998
+ ...config.types.map((type) => ({ name: type.id, value: type.id }))
2999
+ ]
3000
+ })
3001
+ );
3002
+ }
3003
+ async function promptLocalePreset(config) {
3004
+ const presets = Object.entries(config.localePresets ?? {}).filter(
3005
+ (entry) => Array.isArray(entry[1])
3006
+ );
3007
+ if (presets.length === 0) {
3008
+ const locales = nonDefaultLocales(config);
3009
+ if (locales.length <= 1) return {};
3010
+ const picked = await runPrompt(
3011
+ checkbox({
3012
+ message: "Locales to translate",
3013
+ choices: locales.map((locale) => ({ name: locale, value: locale, checked: true })),
3014
+ validate: (values) => values.length > 0 || "Pick at least one locale"
3015
+ })
3016
+ );
3017
+ return picked.length === locales.length ? {} : { locale: picked };
3018
+ }
3019
+ const choice = await runPrompt(
3020
+ select({
3021
+ message: "Locale preset",
3022
+ choices: [
3023
+ { name: "All locales", value: void 0 },
3024
+ ...presets.map(([name, locales]) => ({
3025
+ name: `${name} (${locales.join(", ")})`,
3026
+ value: name
3027
+ }))
3028
+ ]
3029
+ })
3030
+ );
3031
+ return choice ? { preset: choice } : {};
3032
+ }
3033
+ async function promptStrategy() {
3034
+ return runPrompt(
3035
+ select({
3036
+ message: "Translation strategy",
3037
+ choices: [
3038
+ { name: "Stale and missing", value: "all" },
3039
+ { name: "Missing only (skip stale)", value: "missing-only" }
3040
+ ],
3041
+ default: "all"
3042
+ })
3043
+ );
3044
+ }
3045
+ async function promptTranslateSelection(config, flags) {
3046
+ const selection = {
3047
+ contentType: flags.type,
3048
+ preset: flags.preset,
3049
+ locale: flags.locale,
3050
+ strategy: flags.strategy
3051
+ };
3052
+ if (!isInteractive()) return selection;
3053
+ if (!selection.contentType) {
3054
+ selection.contentType = await promptContentType(config);
3055
+ }
3056
+ if (!selection.preset && !selection.locale?.length) {
3057
+ Object.assign(selection, await promptLocalePreset(config));
3058
+ }
3059
+ if (!selection.strategy) {
3060
+ selection.strategy = await promptStrategy();
3061
+ }
3062
+ return selection;
3063
+ }
3064
+
3065
+ // cli/translate-progress.ts
3066
+ init_esm_shims();
3067
+ var dim = (text) => `\x1B[2m${text}\x1B[0m`;
3068
+ var green = (text) => `\x1B[32m${text}\x1B[0m`;
3069
+ var yellow = (text) => `\x1B[33m${text}\x1B[0m`;
3070
+ var red = (text) => `\x1B[31m${text}\x1B[0m`;
3071
+ var cyan = (text) => `\x1B[36m${text}\x1B[0m`;
3072
+ function isInteractive2() {
3073
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
3074
+ }
3075
+ function progressBar(ratio, width = 28) {
3076
+ const clamped = Math.max(0, Math.min(1, ratio));
3077
+ const filled = Math.round(clamped * width);
3078
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
3079
+ }
3080
+ function labelForResult(result) {
3081
+ return `${result.contentType}/${result.enSlug}@${result.locale}`;
3082
+ }
3083
+ function statusForResult(result, dryRun) {
3084
+ if (result.failed) return red("failed");
3085
+ if (result.skipped) return dim("skipped");
3086
+ if (dryRun) return yellow("would translate");
3087
+ return green("translated");
3088
+ }
3089
+ function detailForResult(result) {
3090
+ const parts = [];
3091
+ if (result.durationMs !== void 0) parts.push(`${(result.durationMs / 1e3).toFixed(1)}s`);
3092
+ if (result.usage) {
3093
+ parts.push(`${formatTokenCount(result.usage.inputTokens)} in`);
3094
+ parts.push(`${formatTokenCount(result.usage.outputTokens)} out`);
3095
+ }
3096
+ if (result.estimatedCostUsd !== void 0) parts.push(formatUsd(result.estimatedCostUsd));
3097
+ if (result.error) parts.push(red(result.error));
3098
+ return parts.length > 0 ? dim(parts.join(" \xB7 ")) : "";
3099
+ }
3100
+ function formatSummaryLine(totals, dryRun) {
3101
+ const action = dryRun ? "Would translate" : "Translated";
3102
+ const parts = [
3103
+ `${action} ${totals.translated}`,
3104
+ `skipped ${totals.skipped}`
3105
+ ];
3106
+ if (totals.failed > 0) parts.push(red(`failed ${totals.failed}`));
3107
+ parts.push(`${formatTokenCount(totals.inputTokens)} in / ${formatTokenCount(totals.outputTokens)} out`);
3108
+ parts.push(`${formatUsd(totals.estimatedCostUsd)} est.`);
3109
+ parts.push(`${(totals.durationMs / 1e3).toFixed(1)}s`);
3110
+ return parts.join(" \xB7 ");
3111
+ }
3112
+ function createTranslateProgressReporter(options = {}) {
3113
+ const enabled = options.enabled ?? isInteractive2();
3114
+ const dryRun = Boolean(options.dryRun);
3115
+ const recentLimit = options.recentLimit ?? 6;
3116
+ if (!enabled) {
3117
+ return {
3118
+ onEvent(event) {
3119
+ if (event.type === "item-done") {
3120
+ const label = labelForResult(event.result);
3121
+ const status = statusForResult(event.result, dryRun);
3122
+ const detail = detailForResult(event.result);
3123
+ console.log(`${label}: ${status}${detail ? ` (${detail.replace(/\x1b\[[0-9;]*m/g, "")})` : ""}`);
3124
+ if (event.result.failed && event.result.error) {
3125
+ console.error(event.result.error);
3126
+ }
3127
+ return;
3128
+ }
3129
+ if (event.type === "done") {
3130
+ console.log(formatSummaryLine(event.totals, dryRun));
3131
+ }
3132
+ },
3133
+ finish() {
3134
+ }
3135
+ };
3136
+ }
3137
+ let total = 0;
3138
+ let concurrency = 1;
3139
+ let model;
3140
+ let done = 0;
3141
+ let inputTokens = 0;
3142
+ let outputTokens = 0;
3143
+ let estimatedCostUsd = 0;
3144
+ let active = [];
3145
+ const recent = [];
3146
+ let renderedLines = 0;
3147
+ let cursorHidden = false;
3148
+ function hideCursor() {
3149
+ if (!cursorHidden) {
3150
+ process.stdout.write("\x1B[?25l");
3151
+ cursorHidden = true;
3152
+ }
3153
+ }
3154
+ function showCursor() {
3155
+ if (cursorHidden) {
3156
+ process.stdout.write("\x1B[?25h");
3157
+ cursorHidden = false;
3158
+ }
3159
+ }
3160
+ function render() {
3161
+ hideCursor();
3162
+ if (renderedLines > 0) {
3163
+ process.stdout.write(`\x1B[${renderedLines}A`);
3164
+ }
3165
+ const lines = [];
3166
+ const title = dryRun ? "Dry run" : "Translating";
3167
+ lines.push(cyan(`${title} ${done}/${total}`) + dim(` \xB7 ${concurrency} parallel`) + (model ? dim(` \xB7 ${model}`) : ""));
3168
+ lines.push(progressBar(total === 0 ? 0 : done / total) + dim(` ${Math.round(total === 0 ? 0 : done / total * 100)}%`));
3169
+ lines.push(
3170
+ dim("Tokens ") + `${formatTokenCount(inputTokens)} in \xB7 ${formatTokenCount(outputTokens)} out` + dim(" \xB7 Cost ") + formatUsd(estimatedCostUsd) + dim(" est.")
3171
+ );
3172
+ if (active.length > 0) {
3173
+ lines.push(dim("Active ") + active.slice(0, 3).join(", ") + (active.length > 3 ? dim(` +${active.length - 3}`) : ""));
3174
+ } else if (done < total) {
3175
+ lines.push(dim("Active ") + "starting\u2026");
3176
+ } else {
3177
+ lines.push("");
3178
+ }
3179
+ lines.push("");
3180
+ if (recent.length === 0) {
3181
+ lines.push(dim("Waiting for first page\u2026"));
3182
+ } else {
3183
+ for (const result of recent) {
3184
+ const detail = detailForResult(result);
3185
+ lines.push(`${statusForResult(result, dryRun)} ${labelForResult(result)}${detail ? ` ${detail}` : ""}`);
3186
+ }
3187
+ }
3188
+ for (const line of lines) {
3189
+ process.stdout.write("\x1B[2K" + line + "\n");
3190
+ }
3191
+ renderedLines = lines.length;
3192
+ }
3193
+ return {
3194
+ onEvent(event) {
3195
+ switch (event.type) {
3196
+ case "start":
3197
+ total = event.total;
3198
+ concurrency = event.concurrency;
3199
+ model = event.model;
3200
+ render();
3201
+ break;
3202
+ case "item-start":
3203
+ active = event.active;
3204
+ render();
3205
+ break;
3206
+ case "item-done": {
3207
+ done += 1;
3208
+ active = active.filter((label) => label !== labelForResult(event.result));
3209
+ inputTokens += event.result.usage?.inputTokens ?? 0;
3210
+ outputTokens += event.result.usage?.outputTokens ?? 0;
3211
+ estimatedCostUsd += event.result.estimatedCostUsd ?? 0;
3212
+ recent.unshift(event.result);
3213
+ recent.splice(recentLimit);
3214
+ render();
3215
+ break;
3216
+ }
3217
+ case "done":
3218
+ showCursor();
3219
+ if (renderedLines > 0) {
3220
+ process.stdout.write(`\x1B[${renderedLines}A`);
3221
+ }
3222
+ process.stdout.write("\x1B[2K" + green("Done") + " \xB7 " + formatSummaryLine(event.totals, dryRun) + "\n");
3223
+ renderedLines = 0;
3224
+ for (const result of event.results.filter((entry) => entry.failed)) {
3225
+ process.stdout.write("\x1B[2K" + red(`${labelForResult(result)}: ${result.error ?? "failed"}`) + "\n");
3226
+ }
3227
+ break;
3228
+ }
3229
+ },
3230
+ finish() {
3231
+ showCursor();
3232
+ }
3233
+ };
3234
+ }
3235
+
3236
+ // src/version.ts
3237
+ init_esm_shims();
3238
+ function readScribeVersion() {
3239
+ let dir = path4.dirname(fileURLToPath(import.meta.url));
3240
+ for (; ; ) {
3241
+ const candidate = path4.join(dir, "package.json");
3242
+ if (fs2.existsSync(candidate)) {
3243
+ const pkg = JSON.parse(fs2.readFileSync(candidate, "utf8"));
3244
+ if (pkg.name === "scribe-cms" && pkg.version) return pkg.version;
3245
+ }
3246
+ const parent = path4.dirname(dir);
3247
+ if (parent === dir) break;
3248
+ dir = parent;
3249
+ }
3250
+ return "unknown";
3251
+ }
3252
+ var SCRIBE_VERSION = readScribeVersion();
3253
+
2127
3254
  // cli/index.ts
2128
3255
  function parseArgs(argv) {
2129
3256
  const options = { cwd: process.cwd() };
@@ -2180,6 +3307,35 @@ function parseArgs(argv) {
2180
3307
  i++;
2181
3308
  continue;
2182
3309
  }
3310
+ if (arg === "--concurrency") {
3311
+ options.concurrency = Number(argv[++i]);
3312
+ i++;
3313
+ continue;
3314
+ }
3315
+ if (arg === "--no-progress") {
3316
+ options.noProgress = true;
3317
+ i++;
3318
+ continue;
3319
+ }
3320
+ if (arg === "--strategy") {
3321
+ const value = argv[++i];
3322
+ if (value !== "all" && value !== "missing-only") {
3323
+ throw new Error(`Unknown strategy: ${value} (expected all or missing-only)`);
3324
+ }
3325
+ options.strategy = value;
3326
+ i++;
3327
+ continue;
3328
+ }
3329
+ if (arg === "--out") {
3330
+ options.out = argv[++i];
3331
+ i++;
3332
+ continue;
3333
+ }
3334
+ if (arg === "--extension") {
3335
+ options.extension = argv[++i];
3336
+ i++;
3337
+ continue;
3338
+ }
2183
3339
  if (arg.startsWith("--")) {
2184
3340
  throw new Error(`Unknown flag: ${arg}`);
2185
3341
  }
@@ -2197,7 +3353,13 @@ function loadProject(options) {
2197
3353
  return createProject(config);
2198
3354
  }
2199
3355
  async function main() {
2200
- const { command, options, rest } = parseArgs(process.argv.slice(2));
3356
+ const argv = process.argv.slice(2);
3357
+ const first = argv[0];
3358
+ if (first === "--version" || first === "-V" || first === "version") {
3359
+ console.log(SCRIBE_VERSION);
3360
+ return;
3361
+ }
3362
+ const { command, options, rest } = parseArgs(argv);
2201
3363
  loadEnvFromCwd(options.cwd);
2202
3364
  if (command === "help" || command === "--help") {
2203
3365
  console.log(`Usage: scribe <command>
@@ -2205,12 +3367,36 @@ async function main() {
2205
3367
  Commands:
2206
3368
  status Show EN docs + translation counts
2207
3369
  validate Validate EN files and sqlite consistency
3370
+ export-static Write raw MDX files for static hosting
2208
3371
  translate Translate stale/missing locale pages
2209
3372
  history <type> <slug> Show revision timeline
2210
3373
  studio Start read-only local studio
3374
+ version Print scribe-cms version
3375
+
3376
+ Export-static flags:
3377
+ --out <dir> Output directory (default: public)
3378
+ --extension <ext> File extension (default: mdx)
3379
+ --type <id> Comma-separated content types (default: all routable)
3380
+ --locale <code>... Locales to export (default: all configured)
3381
+
3382
+ Translate flags:
3383
+ --type <id> Content type (interactive picker in a TTY when omitted)
3384
+ --preset <name> Locale preset from config (interactive picker in a TTY)
3385
+ --locale <code>... Target locale(s); overrides --preset
3386
+ --slug <en-slug> Single English document
3387
+ --model <id> Gemini model override
3388
+ --concurrency <n> Parallel translations (default: 3)
3389
+ --dry-run List work without writing
3390
+ --force Re-translate even when hashes match
3391
+ --strategy <mode> all (default) or missing-only
3392
+ --no-progress Plain line logging instead of live progress
2211
3393
  `);
2212
3394
  return;
2213
3395
  }
3396
+ if (command === "version") {
3397
+ console.log(SCRIBE_VERSION);
3398
+ return;
3399
+ }
2214
3400
  const project = loadProject(options);
2215
3401
  const config = project.config;
2216
3402
  switch (command) {
@@ -2237,24 +3423,49 @@ Commands:
2237
3423
  if (!result.ok) process.exitCode = 1;
2238
3424
  break;
2239
3425
  }
3426
+ case "export-static": {
3427
+ const types = options.type?.split(",").map((t) => t.trim()).filter(Boolean);
3428
+ const { written } = writeStaticRawExports(project, {
3429
+ outDir: options.out,
3430
+ extension: options.extension ? `.${options.extension.replace(/^\./, "")}` : void 0,
3431
+ types,
3432
+ locales: options.locale
3433
+ });
3434
+ console.log(`Wrote ${written} static raw exports to ${options.out ?? "public"}`);
3435
+ break;
3436
+ }
2240
3437
  case "translate": {
2241
- const locales = resolveLocalesFromPreset(config, options.preset, options.locale);
3438
+ const selection = await promptTranslateSelection(config, {
3439
+ type: options.type,
3440
+ preset: options.preset,
3441
+ locale: options.locale,
3442
+ strategy: options.strategy
3443
+ });
3444
+ const locales = resolveLocalesFromPreset(config, selection.preset, selection.locale);
2242
3445
  const worklist = buildWorklist(config, {
2243
- contentType: options.type,
3446
+ contentType: selection.contentType,
2244
3447
  locales,
2245
- enSlug: options.slug
3448
+ enSlug: options.slug,
3449
+ strategy: selection.strategy ?? "all"
3450
+ });
3451
+ if (worklist.length === 0) {
3452
+ console.log("Nothing to translate.");
3453
+ break;
3454
+ }
3455
+ const reporter = createTranslateProgressReporter({
3456
+ enabled: !options.noProgress,
3457
+ dryRun: options.dryRun
2246
3458
  });
2247
- console.log(`Translating ${worklist.length} page(s)...`);
2248
3459
  const results = await translateWorklist(config, worklist, {
2249
3460
  model: options.model,
2250
3461
  dryRun: options.dryRun,
2251
- force: options.force
3462
+ force: options.force,
3463
+ concurrency: options.concurrency,
3464
+ onProgress: reporter.onEvent
2252
3465
  });
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
- }
3466
+ reporter.finish();
3467
+ const failed = results.filter((result) => result.failed);
3468
+ if (failed.length > 0) process.exitCode = 1;
2258
3469
  break;
2259
3470
  }
2260
3471
  case "history": {