scribe-cms 0.0.4 → 0.0.6

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 (35) hide show
  1. package/dist/cli/index.cjs +272 -180
  2. package/dist/cli/index.cjs.map +1 -1
  3. package/dist/cli/index.js +272 -181
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/index.cjs +188 -123
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.js +188 -123
  8. package/dist/index.js.map +1 -1
  9. package/dist/runtime.cjs +25 -18
  10. package/dist/runtime.cjs.map +1 -1
  11. package/dist/runtime.js +25 -18
  12. package/dist/runtime.js.map +1 -1
  13. package/dist/src/config/resolve-config.d.ts.map +1 -1
  14. package/dist/src/core/types.d.ts +4 -0
  15. package/dist/src/core/types.d.ts.map +1 -1
  16. package/dist/src/history/record-snapshot.d.ts +11 -0
  17. package/dist/src/history/record-snapshot.d.ts.map +1 -0
  18. package/dist/src/storage/sqlite.d.ts.map +1 -1
  19. package/dist/src/storage/translations.d.ts +14 -21
  20. package/dist/src/storage/translations.d.ts.map +1 -1
  21. package/dist/src/translate/page-translator.d.ts.map +1 -1
  22. package/dist/src/validate/validate-assets.d.ts +20 -0
  23. package/dist/src/validate/validate-assets.d.ts.map +1 -0
  24. package/dist/src/validate/validate-project.d.ts +1 -1
  25. package/dist/src/validate/validate-project.d.ts.map +1 -1
  26. package/dist/src/version.d.ts +4 -0
  27. package/dist/src/version.d.ts.map +1 -0
  28. package/dist/studio/server.cjs +51 -64
  29. package/dist/studio/server.cjs.map +1 -1
  30. package/dist/studio/server.d.ts.map +1 -1
  31. package/dist/studio/server.js +51 -64
  32. package/dist/studio/server.js.map +1 -1
  33. package/package.json +1 -1
  34. package/dist/src/history/record-revision.d.ts +0 -14
  35. package/dist/src/history/record-revision.d.ts.map +0 -1
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';
@@ -56,12 +56,19 @@ function addColumnIfMissing(db, table, column, ddlType) {
56
56
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
57
57
  }
58
58
  }
59
+ function readSchemaVersion(db) {
60
+ const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
61
+ return row ? Number.parseInt(row.value, 10) || 0 : 0;
62
+ }
59
63
  function migrate(db) {
64
+ const previousVersion = readSchemaVersion(db);
60
65
  for (const sql of MIGRATIONS) {
61
66
  db.exec(sql);
62
67
  }
63
- addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
64
- addColumnIfMissing(db, "revisions", "body", "TEXT");
68
+ if (previousVersion < 4) {
69
+ db.exec(`DROP TABLE IF EXISTS revisions`);
70
+ }
71
+ addColumnIfMissing(db, "translations", "snapshot_id", "INTEGER");
65
72
  db.prepare(
66
73
  `INSERT INTO meta(key, value) VALUES('schema_version', ?)
67
74
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
@@ -74,7 +81,7 @@ var SCHEMA_VERSION, MIGRATIONS;
74
81
  var init_sqlite = __esm({
75
82
  "src/storage/sqlite.ts"() {
76
83
  init_esm_shims();
77
- SCHEMA_VERSION = 3;
84
+ SCHEMA_VERSION = 4;
78
85
  MIGRATIONS = [
79
86
  `CREATE TABLE IF NOT EXISTS meta (
80
87
  key TEXT PRIMARY KEY,
@@ -94,20 +101,6 @@ var init_sqlite = __esm({
94
101
  )`,
95
102
  `CREATE INDEX IF NOT EXISTS idx_translations_type_locale
96
103
  ON translations(content_type, locale)`,
97
- `CREATE TABLE IF NOT EXISTS revisions (
98
- id INTEGER PRIMARY KEY AUTOINCREMENT,
99
- content_type TEXT NOT NULL,
100
- en_slug TEXT NOT NULL,
101
- locale TEXT,
102
- revision_kind TEXT NOT NULL,
103
- en_hash TEXT NOT NULL,
104
- body_hash TEXT NOT NULL,
105
- created_at TEXT NOT NULL,
106
- model TEXT,
107
- body_preview TEXT
108
- )`,
109
- `CREATE INDEX IF NOT EXISTS idx_revisions_lookup
110
- ON revisions(content_type, en_slug, locale, created_at DESC)`,
111
104
  `CREATE TABLE IF NOT EXISTS slug_aliases (
112
105
  content_type TEXT NOT NULL,
113
106
  canonical_en_slug TEXT NOT NULL,
@@ -124,7 +117,19 @@ var init_sqlite = __esm({
124
117
  locale_slug TEXT NOT NULL,
125
118
  captured_at TEXT NOT NULL,
126
119
  PRIMARY KEY (content_type, alias_en_slug, locale)
127
- )`
120
+ )`,
121
+ `CREATE TABLE IF NOT EXISTS en_snapshots (
122
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
123
+ content_type TEXT NOT NULL,
124
+ en_slug TEXT NOT NULL,
125
+ en_hash TEXT NOT NULL,
126
+ frontmatter_json TEXT NOT NULL,
127
+ body TEXT NOT NULL,
128
+ created_at TEXT NOT NULL,
129
+ UNIQUE (content_type, en_slug, en_hash)
130
+ )`,
131
+ `CREATE INDEX IF NOT EXISTS idx_en_snapshots_lookup
132
+ ON en_snapshots(content_type, en_slug, created_at DESC)`
128
133
  ];
129
134
  }
130
135
  });
@@ -218,14 +223,14 @@ var SLUG_PLACEHOLDER = "{slug}";
218
223
  function isRoutableType(type) {
219
224
  return typeof type.path === "string" && type.path.length > 0;
220
225
  }
221
- function assertValidPathTemplate(path12, typeId) {
222
- if (!path12.startsWith("/")) {
223
- throw new Error(`Content type "${typeId}": path must start with / (got "${path12}")`);
226
+ function assertValidPathTemplate(path14, typeId) {
227
+ if (!path14.startsWith("/")) {
228
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path14}")`);
224
229
  }
225
- const count = (path12.match(/\{slug\}/g) ?? []).length;
230
+ const count = (path14.match(/\{slug\}/g) ?? []).length;
226
231
  if (count !== 1) {
227
232
  throw new Error(
228
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path12}")`
233
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path14}")`
229
234
  );
230
235
  }
231
236
  }
@@ -277,6 +282,7 @@ function resolveConfig(input, baseDir) {
277
282
  const projectRoot = path4.resolve(baseDir ?? process.cwd(), raw.rootDir);
278
283
  const contentRoot = path4.resolve(projectRoot, raw.contentDir ?? "content");
279
284
  const storePath = path4.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
285
+ const assetsPath = raw.assetsDir ? path4.resolve(projectRoot, raw.assetsDir) : void 0;
280
286
  const seenIds = /* @__PURE__ */ new Set();
281
287
  const types = raw.types.map((type) => {
282
288
  if (seenIds.has(type.id)) {
@@ -297,6 +303,7 @@ function resolveConfig(input, baseDir) {
297
303
  const config = {
298
304
  rootDir: contentRoot,
299
305
  storePath,
306
+ assetsPath,
300
307
  locales: [...raw.locales],
301
308
  defaultLocale,
302
309
  localePresets: raw.localePresets,
@@ -353,10 +360,10 @@ function introspectSchema(schema, prefix = []) {
353
360
  }
354
361
  function extractByPaths(data, paths) {
355
362
  const out = {};
356
- for (const path12 of paths) {
357
- const value = getAtPath(data, path12);
363
+ for (const path14 of paths) {
364
+ const value = getAtPath(data, path14);
358
365
  if (value !== void 0) {
359
- setAtPath(out, path12.filter((p) => p !== "*"), value);
366
+ setAtPath(out, path14.filter((p) => p !== "*"), value);
360
367
  }
361
368
  }
362
369
  return out;
@@ -374,13 +381,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
374
381
  const translatable = pickTranslatable(localeData, schema);
375
382
  return deepMerge(structural, translatable);
376
383
  }
377
- function getAtPath(obj, path12) {
384
+ function getAtPath(obj, path14) {
378
385
  let current = obj;
379
- for (const segment of path12) {
386
+ for (const segment of path14) {
380
387
  if (segment === "*") {
381
388
  if (!Array.isArray(current)) return void 0;
382
389
  return current.map(
383
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path12.slice(path12.indexOf("*") + 1)) : void 0
390
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path14.slice(path14.indexOf("*") + 1)) : void 0
384
391
  );
385
392
  }
386
393
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -388,13 +395,13 @@ function getAtPath(obj, path12) {
388
395
  }
389
396
  return current;
390
397
  }
391
- function setAtPath(obj, path12, value) {
392
- if (path12.length === 0) return;
393
- if (path12.length === 1) {
394
- obj[path12[0]] = value;
398
+ function setAtPath(obj, path14, value) {
399
+ if (path14.length === 0) return;
400
+ if (path14.length === 1) {
401
+ obj[path14[0]] = value;
395
402
  return;
396
403
  }
397
- const [head, ...rest] = path12;
404
+ const [head, ...rest] = path14;
398
405
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
399
406
  obj[head] = {};
400
407
  }
@@ -596,18 +603,51 @@ init_sqlite();
596
603
 
597
604
  // src/storage/translations.ts
598
605
  init_esm_shims();
606
+ function getOrCreateEnSnapshot(db, input) {
607
+ db.prepare(
608
+ `INSERT OR IGNORE INTO en_snapshots (
609
+ content_type, en_slug, en_hash, frontmatter_json, body, created_at
610
+ ) VALUES (?, ?, ?, ?, ?, ?)`
611
+ ).run(
612
+ input.contentType,
613
+ input.enSlug,
614
+ input.enHash,
615
+ JSON.stringify(input.frontmatter),
616
+ input.body,
617
+ input.createdAt
618
+ );
619
+ const row = db.prepare(
620
+ `SELECT id FROM en_snapshots
621
+ WHERE content_type = ? AND en_slug = ? AND en_hash = ?`
622
+ ).get(input.contentType, input.enSlug, input.enHash);
623
+ return row.id;
624
+ }
625
+ function getEnSnapshot(db, snapshotId) {
626
+ return db.prepare(`SELECT * FROM en_snapshots WHERE id = ?`).get(snapshotId);
627
+ }
628
+ function listEnSnapshotsForEnSlug(db, contentType, enSlug) {
629
+ return db.prepare(
630
+ `SELECT s.*, GROUP_CONCAT(t.locale) AS locales
631
+ FROM en_snapshots s
632
+ LEFT JOIN translations t ON t.snapshot_id = s.id
633
+ WHERE s.content_type = ? AND s.en_slug = ?
634
+ GROUP BY s.id
635
+ ORDER BY s.created_at DESC, s.id DESC`
636
+ ).all(contentType, enSlug);
637
+ }
599
638
  function upsertTranslation(db, input) {
600
639
  db.prepare(
601
640
  `INSERT INTO translations (
602
- content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model
603
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
641
+ content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model, snapshot_id
642
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
604
643
  ON CONFLICT(content_type, en_slug, locale) DO UPDATE SET
605
644
  slug = excluded.slug,
606
645
  frontmatter_json = excluded.frontmatter_json,
607
646
  body = excluded.body,
608
647
  en_hash = excluded.en_hash,
609
648
  translated_at = excluded.translated_at,
610
- model = excluded.model`
649
+ model = excluded.model,
650
+ snapshot_id = excluded.snapshot_id`
611
651
  ).run(
612
652
  input.contentType,
613
653
  input.enSlug,
@@ -617,7 +657,8 @@ function upsertTranslation(db, input) {
617
657
  input.body,
618
658
  input.enHash,
619
659
  input.translatedAt,
620
- input.model
660
+ input.model,
661
+ input.snapshotId
621
662
  );
622
663
  }
623
664
  function getTranslation(db, contentType, enSlug, locale) {
@@ -639,41 +680,6 @@ function listTranslationsForLocale(db, contentType, locale) {
639
680
  function bulkLoadTranslations(db) {
640
681
  return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
641
682
  }
642
- function appendRevision(db, input) {
643
- const result = db.prepare(
644
- `INSERT INTO revisions (
645
- content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
646
- model, body_preview, frontmatter_json, body
647
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
648
- ).run(
649
- input.contentType,
650
- input.enSlug,
651
- input.locale,
652
- input.revisionKind,
653
- input.enHash,
654
- input.bodyHash,
655
- input.createdAt,
656
- input.model ?? null,
657
- input.bodyPreview ?? null,
658
- input.frontmatterJson ?? null,
659
- input.body ?? null
660
- );
661
- return Number(result.lastInsertRowid);
662
- }
663
- function listRevisions(db, contentType, enSlug, locale) {
664
- if (locale) {
665
- return db.prepare(
666
- `SELECT * FROM revisions
667
- WHERE content_type = ? AND en_slug = ? AND locale = ?
668
- ORDER BY created_at DESC, id DESC`
669
- ).all(contentType, enSlug, locale);
670
- }
671
- return db.prepare(
672
- `SELECT * FROM revisions
673
- WHERE content_type = ? AND en_slug = ?
674
- ORDER BY created_at DESC, id DESC`
675
- ).all(contentType, enSlug);
676
- }
677
683
 
678
684
  // src/loader/normalize-en.ts
679
685
  init_esm_shims();
@@ -1466,43 +1472,6 @@ function loadConfigSync(options = {}) {
1466
1472
 
1467
1473
  // src/validate/validate-project.ts
1468
1474
  init_esm_shims();
1469
-
1470
- // src/hash/page-hash.ts
1471
- init_esm_shims();
1472
- function sha256(input) {
1473
- return createHash("sha256").update(input, "utf8").digest("hex");
1474
- }
1475
- function computePageEnHash(translatableFrontmatter, body) {
1476
- const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1477
- return sha256(payload);
1478
- }
1479
- function computeBodyHash(body) {
1480
- return sha256(body);
1481
- }
1482
-
1483
- // src/history/record-revision.ts
1484
- init_esm_shims();
1485
- init_sqlite();
1486
- function recordRevision(config, input) {
1487
- const db = openStore(config, "readwrite");
1488
- const id = appendRevision(db, {
1489
- contentType: input.contentType,
1490
- enSlug: input.enSlug,
1491
- locale: input.locale,
1492
- revisionKind: input.revisionKind,
1493
- enHash: input.enHash,
1494
- bodyHash: computeBodyHash(input.body),
1495
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1496
- model: input.model,
1497
- bodyPreview: input.body.slice(0, 200),
1498
- frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
1499
- body: input.body
1500
- });
1501
- db.close();
1502
- return id;
1503
- }
1504
-
1505
- // src/validate/validate-project.ts
1506
1475
  init_sqlite();
1507
1476
 
1508
1477
  // src/validate/validate-relations.ts
@@ -1587,6 +1556,75 @@ function validateRelations(project) {
1587
1556
  return issues;
1588
1557
  }
1589
1558
 
1559
+ // src/validate/validate-assets.ts
1560
+ init_esm_shims();
1561
+ var IMAGE_EXT = String.raw`(?:png|jpe?g|webp|gif|svg)`;
1562
+ var IMAGE_WEB_PATH = String.raw`\/[\w\-./]+\.${IMAGE_EXT}`;
1563
+ var MARKDOWN_IMAGE_RE = new RegExp(
1564
+ String.raw`!\[[^\]]*\]\((${IMAGE_WEB_PATH})\)`,
1565
+ "gi"
1566
+ );
1567
+ var HTML_SRC_RE = new RegExp(String.raw`src=["'](${IMAGE_WEB_PATH})["']`, "gi");
1568
+ function isImageWebPath(value) {
1569
+ return new RegExp(String.raw`^${IMAGE_WEB_PATH}$`, "i").test(value);
1570
+ }
1571
+ function isSiteAssetPath(webPath) {
1572
+ const segment = webPath.split("/")[1];
1573
+ return Boolean(segment && !segment.includes("."));
1574
+ }
1575
+ function addImagePath(webPath, out) {
1576
+ if (isSiteAssetPath(webPath)) out.add(webPath);
1577
+ }
1578
+ function collectBodyImagePaths(body, out) {
1579
+ for (const re of [MARKDOWN_IMAGE_RE, HTML_SRC_RE]) {
1580
+ for (const match of body.matchAll(re)) {
1581
+ addImagePath(match[1], out);
1582
+ }
1583
+ }
1584
+ }
1585
+ function collectFrontmatterImagePaths(value, out) {
1586
+ if (typeof value === "string") {
1587
+ if (isImageWebPath(value)) addImagePath(value, out);
1588
+ return;
1589
+ }
1590
+ if (Array.isArray(value)) {
1591
+ for (const item of value) collectFrontmatterImagePaths(item, out);
1592
+ return;
1593
+ }
1594
+ if (value && typeof value === "object") {
1595
+ for (const nested of Object.values(value)) {
1596
+ collectFrontmatterImagePaths(nested, out);
1597
+ }
1598
+ }
1599
+ }
1600
+ function collectImagePaths(frontmatter, body) {
1601
+ const paths = /* @__PURE__ */ new Set();
1602
+ collectFrontmatterImagePaths(frontmatter, paths);
1603
+ collectBodyImagePaths(body, paths);
1604
+ return [...paths].sort();
1605
+ }
1606
+ function assetFilePath(assetsPath, webPath) {
1607
+ return path4.join(assetsPath, webPath.replace(/^\//, ""));
1608
+ }
1609
+ function validateDocumentAssets(config, input) {
1610
+ const assetsPath = config.assetsPath;
1611
+ if (!assetsPath) return [];
1612
+ const issues = [];
1613
+ for (const webPath of collectImagePaths(input.frontmatter, input.body)) {
1614
+ const filePath = assetFilePath(assetsPath, webPath);
1615
+ if (fs2.existsSync(filePath)) continue;
1616
+ issues.push({
1617
+ level: "warning",
1618
+ contentType: input.contentType,
1619
+ enSlug: input.enSlug,
1620
+ locale: input.locale,
1621
+ field: "asset",
1622
+ message: `Missing image asset ${webPath} (expected ${filePath})`
1623
+ });
1624
+ }
1625
+ return issues;
1626
+ }
1627
+
1590
1628
  // src/validate/validate-slug-suffix.ts
1591
1629
  init_esm_shims();
1592
1630
  function validateTranslationSlugSuffixes(config, db) {
@@ -1649,8 +1687,6 @@ function validateProject(config) {
1649
1687
  });
1650
1688
  continue;
1651
1689
  }
1652
- const payload = getTranslatablePayload(enDoc, type);
1653
- const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1654
1690
  const crossIssues = type.crossValidate?.(enDoc.frontmatter, {
1655
1691
  locale: config.defaultLocale,
1656
1692
  defaultLocale: config.defaultLocale,
@@ -1668,29 +1704,18 @@ function validateProject(config) {
1668
1704
  message: issue.message
1669
1705
  });
1670
1706
  }
1707
+ for (const issue of validateDocumentAssets(config, {
1708
+ contentType: type.id,
1709
+ enSlug,
1710
+ frontmatter: enDoc.frontmatter,
1711
+ body: enDoc.content
1712
+ })) {
1713
+ issues.push(issue);
1714
+ }
1671
1715
  for (const locale of config.locales) {
1672
1716
  if (locale === config.defaultLocale) continue;
1673
1717
  const row = getTranslation(db, type.id, enSlug, locale);
1674
1718
  if (!row) continue;
1675
- if (row.en_hash !== currentEnHash) {
1676
- issues.push({
1677
- level: "warning",
1678
- contentType: type.id,
1679
- enSlug,
1680
- locale,
1681
- field: "en_hash",
1682
- message: "Translation is stale (en_hash mismatch)"
1683
- });
1684
- recordRevision(config, {
1685
- contentType: type.id,
1686
- enSlug,
1687
- locale,
1688
- revisionKind: "en_edit_detected",
1689
- enHash: currentEnHash,
1690
- body: row.body,
1691
- frontmatter: JSON.parse(row.frontmatter_json)
1692
- });
1693
- }
1694
1719
  const localeFm = JSON.parse(row.frontmatter_json);
1695
1720
  for (const issue of validateLocaleBuiltinFields(localeFm)) {
1696
1721
  issues.push({
@@ -1702,6 +1727,15 @@ function validateProject(config) {
1702
1727
  message: issue.message
1703
1728
  });
1704
1729
  }
1730
+ for (const issue of validateDocumentAssets(config, {
1731
+ contentType: type.id,
1732
+ enSlug,
1733
+ locale,
1734
+ frontmatter: localeFm,
1735
+ body: row.body
1736
+ })) {
1737
+ issues.push(issue);
1738
+ }
1705
1739
  }
1706
1740
  }
1707
1741
  try {
@@ -1772,6 +1806,18 @@ function validateProject(config) {
1772
1806
 
1773
1807
  // src/translate/worklist.ts
1774
1808
  init_esm_shims();
1809
+
1810
+ // src/hash/page-hash.ts
1811
+ init_esm_shims();
1812
+ function sha256(input) {
1813
+ return createHash("sha256").update(input, "utf8").digest("hex");
1814
+ }
1815
+ function computePageEnHash(translatableFrontmatter, body) {
1816
+ const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1817
+ return sha256(payload);
1818
+ }
1819
+
1820
+ // src/translate/worklist.ts
1775
1821
  init_sqlite();
1776
1822
  function listEnSlugs3(rootDir, contentDir) {
1777
1823
  const dir = path4.join(rootDir, contentDir);
@@ -1833,6 +1879,25 @@ function resolveLocalesFromPreset(config, preset, explicitLocales) {
1833
1879
 
1834
1880
  // src/translate/page-translator.ts
1835
1881
  init_esm_shims();
1882
+
1883
+ // src/history/record-snapshot.ts
1884
+ init_esm_shims();
1885
+ init_sqlite();
1886
+ function recordEnSnapshot(config, input, db) {
1887
+ const ownDb = db ?? openStore(config, "readwrite");
1888
+ const id = getOrCreateEnSnapshot(ownDb, {
1889
+ contentType: input.contentType,
1890
+ enSlug: input.enSlug,
1891
+ enHash: input.enHash,
1892
+ frontmatter: input.frontmatter,
1893
+ body: input.body,
1894
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1895
+ });
1896
+ if (!db) ownDb.close();
1897
+ return id;
1898
+ }
1899
+
1900
+ // src/translate/page-translator.ts
1836
1901
  init_sqlite();
1837
1902
 
1838
1903
  // src/translate/gemini-client.ts
@@ -2230,6 +2295,17 @@ async function translatePage(config, item, options = {}) {
2230
2295
  throw new Error(`Translation validation failed: ${validated.error}`);
2231
2296
  }
2232
2297
  const writeDb = openStore(config, "readwrite");
2298
+ const snapshotId = recordEnSnapshot(
2299
+ config,
2300
+ {
2301
+ contentType: type.id,
2302
+ enSlug: item.enSlug,
2303
+ enHash: currentEnHash,
2304
+ frontmatter: payload.frontmatter,
2305
+ body: payload.body
2306
+ },
2307
+ writeDb
2308
+ );
2233
2309
  upsertTranslation(writeDb, {
2234
2310
  contentType: type.id,
2235
2311
  enSlug: item.enSlug,
@@ -2239,19 +2315,10 @@ async function translatePage(config, item, options = {}) {
2239
2315
  body: result.parsed.body,
2240
2316
  enHash: currentEnHash,
2241
2317
  translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2242
- model: result.model
2318
+ model: result.model,
2319
+ snapshotId
2243
2320
  });
2244
2321
  writeDb.close();
2245
- recordRevision(config, {
2246
- contentType: type.id,
2247
- enSlug: item.enSlug,
2248
- locale: item.locale,
2249
- revisionKind: "translation",
2250
- enHash: currentEnHash,
2251
- body: result.parsed.body,
2252
- frontmatter: validated.frontmatter,
2253
- model: result.model
2254
- });
2255
2322
  const estimatedCostUsd = estimateTranslationCostUsd(
2256
2323
  normalizeGeminiDisplayName(result.model),
2257
2324
  result.usage.inputTokens,
@@ -2490,38 +2557,31 @@ function renderFrontmatterTable(frontmatter, schema) {
2490
2557
  <tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
2491
2558
  </table>`;
2492
2559
  }
2493
- function renderRevisionSnapshot(revision) {
2494
- if (revision.frontmatter_json && revision.body) {
2495
- let frontmatter = {};
2496
- try {
2497
- frontmatter = JSON.parse(revision.frontmatter_json);
2498
- } catch {
2499
- frontmatter = {};
2500
- }
2501
- const fmRows = flattenFrontmatter(frontmatter).map(
2502
- (row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
2503
- ).join("");
2504
- return `<table class="kv"><tbody>${fmRows}</tbody></table>
2505
- <pre class="code">${escapeHtml(revision.body)}</pre>`;
2506
- }
2507
- return `<p class="dim">${escapeHtml(revision.body_preview ?? "(no snapshot)")}</p>`;
2508
- }
2509
- function renderRevisionTimeline(revisions) {
2510
- if (revisions.length === 0) {
2511
- return `<p class="dim">No history.</p>`;
2512
- }
2513
- const items = revisions.map(
2514
- (row) => `<tr>
2515
- <td class="dim">${escapeHtml(row.created_at.slice(0, 19))}</td>
2516
- <td>${escapeHtml(row.revision_kind)}</td>
2517
- <td class="dim">${row.model ? escapeHtml(row.model) : "\u2014"}</td>
2518
- <td class="dim mono">${escapeHtml(row.en_hash.slice(0, 8))}</td>
2519
- <td><details><summary>view</summary>${renderRevisionSnapshot(row)}</details></td>
2520
- </tr>`
2560
+ function renderEnSnapshotPreview(snapshot) {
2561
+ let frontmatter = {};
2562
+ try {
2563
+ frontmatter = JSON.parse(snapshot.frontmatter_json);
2564
+ } catch {
2565
+ frontmatter = {};
2566
+ }
2567
+ const fmRows = flattenFrontmatter(frontmatter).map(
2568
+ (row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
2521
2569
  ).join("");
2522
- return `<table class="data"><thead><tr>
2523
- <th>When</th><th>Kind</th><th>Model</th><th>Hash</th><th></th>
2524
- </tr></thead><tbody>${items}</tbody></table>`;
2570
+ return `<table class="kv"><tbody>${fmRows}</tbody></table>
2571
+ <pre class="code">${escapeHtml(snapshot.body)}</pre>`;
2572
+ }
2573
+ function renderTranslationSnapshotPanel(snapshot, currentEnHash) {
2574
+ if (!snapshot) {
2575
+ return `<p class="dim">No EN snapshot linked to this translation.</p>`;
2576
+ }
2577
+ const staleNote = currentEnHash && currentEnHash !== snapshot.en_hash ? `<p class="dim">Current EN hash differs from snapshot (${escapeHtml(currentEnHash.slice(0, 12))} vs ${escapeHtml(snapshot.en_hash.slice(0, 12))}).</p>` : "";
2578
+ return `<dl class="meta">
2579
+ <dt>snapshot</dt><dd>#${snapshot.id}</dd>
2580
+ <dt>captured</dt><dd>${escapeHtml(snapshot.created_at.slice(0, 19))}</dd>
2581
+ <dt>en_hash</dt><dd class="mono">${escapeHtml(snapshot.en_hash.slice(0, 12))}</dd>
2582
+ </dl>
2583
+ ${staleNote}
2584
+ <details><summary>EN source at translation time</summary>${renderEnSnapshotPreview(snapshot)}</details>`;
2525
2585
  }
2526
2586
  function renderLayout(title, body, project, options = {}) {
2527
2587
  const typeLinks = project.listTypes().map((type) => {
@@ -2832,10 +2892,10 @@ async function startStudio(project, options = {}) {
2832
2892
  <dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
2833
2893
  <dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
2834
2894
  </dl>`;
2835
- const revisions = listRevisions(db, typeId, enSlug, locale);
2895
+ const snapshot = translation.snapshot_id != null ? getEnSnapshot(db, translation.snapshot_id) : void 0;
2836
2896
  historyPanel = `<div class="section">
2837
- <div class="section-head">History</div>
2838
- <div class="section-body">${renderRevisionTimeline(revisions)}</div>
2897
+ <div class="section-head">EN snapshot</div>
2898
+ <div class="section-body">${renderTranslationSnapshotPanel(snapshot, currentEnHash)}</div>
2839
2899
  </div>`;
2840
2900
  } else {
2841
2901
  contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
@@ -3164,6 +3224,24 @@ function createTranslateProgressReporter(options = {}) {
3164
3224
  };
3165
3225
  }
3166
3226
 
3227
+ // src/version.ts
3228
+ init_esm_shims();
3229
+ function readScribeVersion() {
3230
+ let dir = path4.dirname(fileURLToPath(import.meta.url));
3231
+ for (; ; ) {
3232
+ const candidate = path4.join(dir, "package.json");
3233
+ if (fs2.existsSync(candidate)) {
3234
+ const pkg = JSON.parse(fs2.readFileSync(candidate, "utf8"));
3235
+ if (pkg.name === "scribe-cms" && pkg.version) return pkg.version;
3236
+ }
3237
+ const parent = path4.dirname(dir);
3238
+ if (parent === dir) break;
3239
+ dir = parent;
3240
+ }
3241
+ return "unknown";
3242
+ }
3243
+ var SCRIBE_VERSION = readScribeVersion();
3244
+
3167
3245
  // cli/index.ts
3168
3246
  function parseArgs(argv) {
3169
3247
  const options = { cwd: process.cwd() };
@@ -3266,7 +3344,13 @@ function loadProject(options) {
3266
3344
  return createProject(config);
3267
3345
  }
3268
3346
  async function main() {
3269
- const { command, options, rest } = parseArgs(process.argv.slice(2));
3347
+ const argv = process.argv.slice(2);
3348
+ const first = argv[0];
3349
+ if (first === "--version" || first === "-V" || first === "version") {
3350
+ console.log(SCRIBE_VERSION);
3351
+ return;
3352
+ }
3353
+ const { command, options, rest } = parseArgs(argv);
3270
3354
  loadEnvFromCwd(options.cwd);
3271
3355
  if (command === "help" || command === "--help") {
3272
3356
  console.log(`Usage: scribe <command>
@@ -3276,8 +3360,9 @@ Commands:
3276
3360
  validate Validate EN files and sqlite consistency
3277
3361
  export-static Write raw MDX files for static hosting
3278
3362
  translate Translate stale/missing locale pages
3279
- history <type> <slug> Show revision timeline
3363
+ history <type> <slug> Show EN snapshot timeline
3280
3364
  studio Start read-only local studio
3365
+ version Print scribe-cms version
3281
3366
 
3282
3367
  Export-static flags:
3283
3368
  --out <dir> Output directory (default: public)
@@ -3299,6 +3384,10 @@ Translate flags:
3299
3384
  `);
3300
3385
  return;
3301
3386
  }
3387
+ if (command === "version") {
3388
+ console.log(SCRIBE_VERSION);
3389
+ return;
3390
+ }
3302
3391
  const project = loadProject(options);
3303
3392
  const config = project.config;
3304
3393
  switch (command) {
@@ -3377,11 +3466,13 @@ Translate flags:
3377
3466
  }
3378
3467
  const { openStore: openStore2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
3379
3468
  const db = openStore2(config, "readonly");
3380
- const rows = listRevisions(db, typeId, enSlug, locale);
3469
+ const rows = listEnSnapshotsForEnSlug(db, typeId, enSlug);
3381
3470
  db.close();
3382
3471
  for (const row of rows) {
3472
+ const locales = row.locales ?? "";
3473
+ if (locale && !locales.split(",").includes(locale)) continue;
3383
3474
  console.log(
3384
- `${row.created_at} ${row.revision_kind} locale=${row.locale ?? "en"} en_hash=${row.en_hash.slice(0, 8)} body_hash=${row.body_hash.slice(0, 8)}`
3475
+ `${row.created_at} snapshot=#${row.id} en_hash=${row.en_hash.slice(0, 8)} locales=${locales || "\u2014"}`
3385
3476
  );
3386
3477
  }
3387
3478
  break;