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
@@ -13,6 +13,7 @@ var dotenv = require('dotenv');
13
13
  var nodeServer = require('@hono/node-server');
14
14
  var hono = require('hono');
15
15
  var prompts = require('@inquirer/prompts');
16
+ var url = require('url');
16
17
 
17
18
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
18
19
 
@@ -70,12 +71,19 @@ function addColumnIfMissing(db, table, column, ddlType) {
70
71
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
71
72
  }
72
73
  }
74
+ function readSchemaVersion(db) {
75
+ const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
76
+ return row ? Number.parseInt(row.value, 10) || 0 : 0;
77
+ }
73
78
  function migrate(db) {
79
+ const previousVersion = readSchemaVersion(db);
74
80
  for (const sql of MIGRATIONS) {
75
81
  db.exec(sql);
76
82
  }
77
- addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
78
- addColumnIfMissing(db, "revisions", "body", "TEXT");
83
+ if (previousVersion < 4) {
84
+ db.exec(`DROP TABLE IF EXISTS revisions`);
85
+ }
86
+ addColumnIfMissing(db, "translations", "snapshot_id", "INTEGER");
79
87
  db.prepare(
80
88
  `INSERT INTO meta(key, value) VALUES('schema_version', ?)
81
89
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
@@ -88,7 +96,7 @@ var SCHEMA_VERSION, MIGRATIONS;
88
96
  var init_sqlite = __esm({
89
97
  "src/storage/sqlite.ts"() {
90
98
  init_cjs_shims();
91
- SCHEMA_VERSION = 3;
99
+ SCHEMA_VERSION = 4;
92
100
  MIGRATIONS = [
93
101
  `CREATE TABLE IF NOT EXISTS meta (
94
102
  key TEXT PRIMARY KEY,
@@ -108,20 +116,6 @@ var init_sqlite = __esm({
108
116
  )`,
109
117
  `CREATE INDEX IF NOT EXISTS idx_translations_type_locale
110
118
  ON translations(content_type, locale)`,
111
- `CREATE TABLE IF NOT EXISTS revisions (
112
- id INTEGER PRIMARY KEY AUTOINCREMENT,
113
- content_type TEXT NOT NULL,
114
- en_slug TEXT NOT NULL,
115
- locale TEXT,
116
- revision_kind TEXT NOT NULL,
117
- en_hash TEXT NOT NULL,
118
- body_hash TEXT NOT NULL,
119
- created_at TEXT NOT NULL,
120
- model TEXT,
121
- body_preview TEXT
122
- )`,
123
- `CREATE INDEX IF NOT EXISTS idx_revisions_lookup
124
- ON revisions(content_type, en_slug, locale, created_at DESC)`,
125
119
  `CREATE TABLE IF NOT EXISTS slug_aliases (
126
120
  content_type TEXT NOT NULL,
127
121
  canonical_en_slug TEXT NOT NULL,
@@ -138,7 +132,19 @@ var init_sqlite = __esm({
138
132
  locale_slug TEXT NOT NULL,
139
133
  captured_at TEXT NOT NULL,
140
134
  PRIMARY KEY (content_type, alias_en_slug, locale)
141
- )`
135
+ )`,
136
+ `CREATE TABLE IF NOT EXISTS en_snapshots (
137
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
138
+ content_type TEXT NOT NULL,
139
+ en_slug TEXT NOT NULL,
140
+ en_hash TEXT NOT NULL,
141
+ frontmatter_json TEXT NOT NULL,
142
+ body TEXT NOT NULL,
143
+ created_at TEXT NOT NULL,
144
+ UNIQUE (content_type, en_slug, en_hash)
145
+ )`,
146
+ `CREATE INDEX IF NOT EXISTS idx_en_snapshots_lookup
147
+ ON en_snapshots(content_type, en_slug, created_at DESC)`
142
148
  ];
143
149
  }
144
150
  });
@@ -232,14 +238,14 @@ var SLUG_PLACEHOLDER = "{slug}";
232
238
  function isRoutableType(type) {
233
239
  return typeof type.path === "string" && type.path.length > 0;
234
240
  }
235
- function assertValidPathTemplate(path11, typeId) {
236
- if (!path11.startsWith("/")) {
237
- throw new Error(`Content type "${typeId}": path must start with / (got "${path11}")`);
241
+ function assertValidPathTemplate(path13, typeId) {
242
+ if (!path13.startsWith("/")) {
243
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path13}")`);
238
244
  }
239
- const count = (path11.match(/\{slug\}/g) ?? []).length;
245
+ const count = (path13.match(/\{slug\}/g) ?? []).length;
240
246
  if (count !== 1) {
241
247
  throw new Error(
242
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path11}")`
248
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path13}")`
243
249
  );
244
250
  }
245
251
  }
@@ -291,6 +297,7 @@ function resolveConfig(input, baseDir) {
291
297
  const projectRoot = path3__default.default.resolve(baseDir ?? process.cwd(), raw.rootDir);
292
298
  const contentRoot = path3__default.default.resolve(projectRoot, raw.contentDir ?? "content");
293
299
  const storePath = path3__default.default.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
300
+ const assetsPath = raw.assetsDir ? path3__default.default.resolve(projectRoot, raw.assetsDir) : void 0;
294
301
  const seenIds = /* @__PURE__ */ new Set();
295
302
  const types = raw.types.map((type) => {
296
303
  if (seenIds.has(type.id)) {
@@ -311,6 +318,7 @@ function resolveConfig(input, baseDir) {
311
318
  const config = {
312
319
  rootDir: contentRoot,
313
320
  storePath,
321
+ assetsPath,
314
322
  locales: [...raw.locales],
315
323
  defaultLocale,
316
324
  localePresets: raw.localePresets,
@@ -367,10 +375,10 @@ function introspectSchema(schema, prefix = []) {
367
375
  }
368
376
  function extractByPaths(data, paths) {
369
377
  const out = {};
370
- for (const path11 of paths) {
371
- const value = getAtPath(data, path11);
378
+ for (const path13 of paths) {
379
+ const value = getAtPath(data, path13);
372
380
  if (value !== void 0) {
373
- setAtPath(out, path11.filter((p) => p !== "*"), value);
381
+ setAtPath(out, path13.filter((p) => p !== "*"), value);
374
382
  }
375
383
  }
376
384
  return out;
@@ -388,13 +396,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
388
396
  const translatable = pickTranslatable(localeData, schema);
389
397
  return deepMerge(structural, translatable);
390
398
  }
391
- function getAtPath(obj, path11) {
399
+ function getAtPath(obj, path13) {
392
400
  let current = obj;
393
- for (const segment of path11) {
401
+ for (const segment of path13) {
394
402
  if (segment === "*") {
395
403
  if (!Array.isArray(current)) return void 0;
396
404
  return current.map(
397
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path11.slice(path11.indexOf("*") + 1)) : void 0
405
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path13.slice(path13.indexOf("*") + 1)) : void 0
398
406
  );
399
407
  }
400
408
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -402,13 +410,13 @@ function getAtPath(obj, path11) {
402
410
  }
403
411
  return current;
404
412
  }
405
- function setAtPath(obj, path11, value) {
406
- if (path11.length === 0) return;
407
- if (path11.length === 1) {
408
- obj[path11[0]] = value;
413
+ function setAtPath(obj, path13, value) {
414
+ if (path13.length === 0) return;
415
+ if (path13.length === 1) {
416
+ obj[path13[0]] = value;
409
417
  return;
410
418
  }
411
- const [head, ...rest] = path11;
419
+ const [head, ...rest] = path13;
412
420
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
413
421
  obj[head] = {};
414
422
  }
@@ -610,18 +618,51 @@ init_sqlite();
610
618
 
611
619
  // src/storage/translations.ts
612
620
  init_cjs_shims();
621
+ function getOrCreateEnSnapshot(db, input) {
622
+ db.prepare(
623
+ `INSERT OR IGNORE INTO en_snapshots (
624
+ content_type, en_slug, en_hash, frontmatter_json, body, created_at
625
+ ) VALUES (?, ?, ?, ?, ?, ?)`
626
+ ).run(
627
+ input.contentType,
628
+ input.enSlug,
629
+ input.enHash,
630
+ JSON.stringify(input.frontmatter),
631
+ input.body,
632
+ input.createdAt
633
+ );
634
+ const row = db.prepare(
635
+ `SELECT id FROM en_snapshots
636
+ WHERE content_type = ? AND en_slug = ? AND en_hash = ?`
637
+ ).get(input.contentType, input.enSlug, input.enHash);
638
+ return row.id;
639
+ }
640
+ function getEnSnapshot(db, snapshotId) {
641
+ return db.prepare(`SELECT * FROM en_snapshots WHERE id = ?`).get(snapshotId);
642
+ }
643
+ function listEnSnapshotsForEnSlug(db, contentType, enSlug) {
644
+ return db.prepare(
645
+ `SELECT s.*, GROUP_CONCAT(t.locale) AS locales
646
+ FROM en_snapshots s
647
+ LEFT JOIN translations t ON t.snapshot_id = s.id
648
+ WHERE s.content_type = ? AND s.en_slug = ?
649
+ GROUP BY s.id
650
+ ORDER BY s.created_at DESC, s.id DESC`
651
+ ).all(contentType, enSlug);
652
+ }
613
653
  function upsertTranslation(db, input) {
614
654
  db.prepare(
615
655
  `INSERT INTO translations (
616
- content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model
617
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
656
+ content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model, snapshot_id
657
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
618
658
  ON CONFLICT(content_type, en_slug, locale) DO UPDATE SET
619
659
  slug = excluded.slug,
620
660
  frontmatter_json = excluded.frontmatter_json,
621
661
  body = excluded.body,
622
662
  en_hash = excluded.en_hash,
623
663
  translated_at = excluded.translated_at,
624
- model = excluded.model`
664
+ model = excluded.model,
665
+ snapshot_id = excluded.snapshot_id`
625
666
  ).run(
626
667
  input.contentType,
627
668
  input.enSlug,
@@ -631,7 +672,8 @@ function upsertTranslation(db, input) {
631
672
  input.body,
632
673
  input.enHash,
633
674
  input.translatedAt,
634
- input.model
675
+ input.model,
676
+ input.snapshotId
635
677
  );
636
678
  }
637
679
  function getTranslation(db, contentType, enSlug, locale) {
@@ -653,41 +695,6 @@ function listTranslationsForLocale(db, contentType, locale) {
653
695
  function bulkLoadTranslations(db) {
654
696
  return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
655
697
  }
656
- function appendRevision(db, input) {
657
- const result = db.prepare(
658
- `INSERT INTO revisions (
659
- content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
660
- model, body_preview, frontmatter_json, body
661
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
662
- ).run(
663
- input.contentType,
664
- input.enSlug,
665
- input.locale,
666
- input.revisionKind,
667
- input.enHash,
668
- input.bodyHash,
669
- input.createdAt,
670
- input.model ?? null,
671
- input.bodyPreview ?? null,
672
- input.frontmatterJson ?? null,
673
- input.body ?? null
674
- );
675
- return Number(result.lastInsertRowid);
676
- }
677
- function listRevisions(db, contentType, enSlug, locale) {
678
- if (locale) {
679
- return db.prepare(
680
- `SELECT * FROM revisions
681
- WHERE content_type = ? AND en_slug = ? AND locale = ?
682
- ORDER BY created_at DESC, id DESC`
683
- ).all(contentType, enSlug, locale);
684
- }
685
- return db.prepare(
686
- `SELECT * FROM revisions
687
- WHERE content_type = ? AND en_slug = ?
688
- ORDER BY created_at DESC, id DESC`
689
- ).all(contentType, enSlug);
690
- }
691
698
 
692
699
  // src/loader/normalize-en.ts
693
700
  init_cjs_shims();
@@ -1480,43 +1487,6 @@ function loadConfigSync(options = {}) {
1480
1487
 
1481
1488
  // src/validate/validate-project.ts
1482
1489
  init_cjs_shims();
1483
-
1484
- // src/hash/page-hash.ts
1485
- init_cjs_shims();
1486
- function sha256(input) {
1487
- return crypto.createHash("sha256").update(input, "utf8").digest("hex");
1488
- }
1489
- function computePageEnHash(translatableFrontmatter, body) {
1490
- const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1491
- return sha256(payload);
1492
- }
1493
- function computeBodyHash(body) {
1494
- return sha256(body);
1495
- }
1496
-
1497
- // src/history/record-revision.ts
1498
- init_cjs_shims();
1499
- init_sqlite();
1500
- function recordRevision(config, input) {
1501
- const db = openStore(config, "readwrite");
1502
- const id = appendRevision(db, {
1503
- contentType: input.contentType,
1504
- enSlug: input.enSlug,
1505
- locale: input.locale,
1506
- revisionKind: input.revisionKind,
1507
- enHash: input.enHash,
1508
- bodyHash: computeBodyHash(input.body),
1509
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1510
- model: input.model,
1511
- bodyPreview: input.body.slice(0, 200),
1512
- frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
1513
- body: input.body
1514
- });
1515
- db.close();
1516
- return id;
1517
- }
1518
-
1519
- // src/validate/validate-project.ts
1520
1490
  init_sqlite();
1521
1491
 
1522
1492
  // src/validate/validate-relations.ts
@@ -1601,6 +1571,75 @@ function validateRelations(project) {
1601
1571
  return issues;
1602
1572
  }
1603
1573
 
1574
+ // src/validate/validate-assets.ts
1575
+ init_cjs_shims();
1576
+ var IMAGE_EXT = String.raw`(?:png|jpe?g|webp|gif|svg)`;
1577
+ var IMAGE_WEB_PATH = String.raw`\/[\w\-./]+\.${IMAGE_EXT}`;
1578
+ var MARKDOWN_IMAGE_RE = new RegExp(
1579
+ String.raw`!\[[^\]]*\]\((${IMAGE_WEB_PATH})\)`,
1580
+ "gi"
1581
+ );
1582
+ var HTML_SRC_RE = new RegExp(String.raw`src=["'](${IMAGE_WEB_PATH})["']`, "gi");
1583
+ function isImageWebPath(value) {
1584
+ return new RegExp(String.raw`^${IMAGE_WEB_PATH}$`, "i").test(value);
1585
+ }
1586
+ function isSiteAssetPath(webPath) {
1587
+ const segment = webPath.split("/")[1];
1588
+ return Boolean(segment && !segment.includes("."));
1589
+ }
1590
+ function addImagePath(webPath, out) {
1591
+ if (isSiteAssetPath(webPath)) out.add(webPath);
1592
+ }
1593
+ function collectBodyImagePaths(body, out) {
1594
+ for (const re of [MARKDOWN_IMAGE_RE, HTML_SRC_RE]) {
1595
+ for (const match of body.matchAll(re)) {
1596
+ addImagePath(match[1], out);
1597
+ }
1598
+ }
1599
+ }
1600
+ function collectFrontmatterImagePaths(value, out) {
1601
+ if (typeof value === "string") {
1602
+ if (isImageWebPath(value)) addImagePath(value, out);
1603
+ return;
1604
+ }
1605
+ if (Array.isArray(value)) {
1606
+ for (const item of value) collectFrontmatterImagePaths(item, out);
1607
+ return;
1608
+ }
1609
+ if (value && typeof value === "object") {
1610
+ for (const nested of Object.values(value)) {
1611
+ collectFrontmatterImagePaths(nested, out);
1612
+ }
1613
+ }
1614
+ }
1615
+ function collectImagePaths(frontmatter, body) {
1616
+ const paths = /* @__PURE__ */ new Set();
1617
+ collectFrontmatterImagePaths(frontmatter, paths);
1618
+ collectBodyImagePaths(body, paths);
1619
+ return [...paths].sort();
1620
+ }
1621
+ function assetFilePath(assetsPath, webPath) {
1622
+ return path3__default.default.join(assetsPath, webPath.replace(/^\//, ""));
1623
+ }
1624
+ function validateDocumentAssets(config, input) {
1625
+ const assetsPath = config.assetsPath;
1626
+ if (!assetsPath) return [];
1627
+ const issues = [];
1628
+ for (const webPath of collectImagePaths(input.frontmatter, input.body)) {
1629
+ const filePath = assetFilePath(assetsPath, webPath);
1630
+ if (fs2__default.default.existsSync(filePath)) continue;
1631
+ issues.push({
1632
+ level: "warning",
1633
+ contentType: input.contentType,
1634
+ enSlug: input.enSlug,
1635
+ locale: input.locale,
1636
+ field: "asset",
1637
+ message: `Missing image asset ${webPath} (expected ${filePath})`
1638
+ });
1639
+ }
1640
+ return issues;
1641
+ }
1642
+
1604
1643
  // src/validate/validate-slug-suffix.ts
1605
1644
  init_cjs_shims();
1606
1645
  function validateTranslationSlugSuffixes(config, db) {
@@ -1663,8 +1702,6 @@ function validateProject(config) {
1663
1702
  });
1664
1703
  continue;
1665
1704
  }
1666
- const payload = getTranslatablePayload(enDoc, type);
1667
- const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1668
1705
  const crossIssues = type.crossValidate?.(enDoc.frontmatter, {
1669
1706
  locale: config.defaultLocale,
1670
1707
  defaultLocale: config.defaultLocale,
@@ -1682,29 +1719,18 @@ function validateProject(config) {
1682
1719
  message: issue.message
1683
1720
  });
1684
1721
  }
1722
+ for (const issue of validateDocumentAssets(config, {
1723
+ contentType: type.id,
1724
+ enSlug,
1725
+ frontmatter: enDoc.frontmatter,
1726
+ body: enDoc.content
1727
+ })) {
1728
+ issues.push(issue);
1729
+ }
1685
1730
  for (const locale of config.locales) {
1686
1731
  if (locale === config.defaultLocale) continue;
1687
1732
  const row = getTranslation(db, type.id, enSlug, locale);
1688
1733
  if (!row) continue;
1689
- if (row.en_hash !== currentEnHash) {
1690
- issues.push({
1691
- level: "warning",
1692
- contentType: type.id,
1693
- enSlug,
1694
- locale,
1695
- field: "en_hash",
1696
- message: "Translation is stale (en_hash mismatch)"
1697
- });
1698
- recordRevision(config, {
1699
- contentType: type.id,
1700
- enSlug,
1701
- locale,
1702
- revisionKind: "en_edit_detected",
1703
- enHash: currentEnHash,
1704
- body: row.body,
1705
- frontmatter: JSON.parse(row.frontmatter_json)
1706
- });
1707
- }
1708
1734
  const localeFm = JSON.parse(row.frontmatter_json);
1709
1735
  for (const issue of validateLocaleBuiltinFields(localeFm)) {
1710
1736
  issues.push({
@@ -1716,6 +1742,15 @@ function validateProject(config) {
1716
1742
  message: issue.message
1717
1743
  });
1718
1744
  }
1745
+ for (const issue of validateDocumentAssets(config, {
1746
+ contentType: type.id,
1747
+ enSlug,
1748
+ locale,
1749
+ frontmatter: localeFm,
1750
+ body: row.body
1751
+ })) {
1752
+ issues.push(issue);
1753
+ }
1719
1754
  }
1720
1755
  }
1721
1756
  try {
@@ -1786,6 +1821,18 @@ function validateProject(config) {
1786
1821
 
1787
1822
  // src/translate/worklist.ts
1788
1823
  init_cjs_shims();
1824
+
1825
+ // src/hash/page-hash.ts
1826
+ init_cjs_shims();
1827
+ function sha256(input) {
1828
+ return crypto.createHash("sha256").update(input, "utf8").digest("hex");
1829
+ }
1830
+ function computePageEnHash(translatableFrontmatter, body) {
1831
+ const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1832
+ return sha256(payload);
1833
+ }
1834
+
1835
+ // src/translate/worklist.ts
1789
1836
  init_sqlite();
1790
1837
  function listEnSlugs3(rootDir, contentDir) {
1791
1838
  const dir = path3__default.default.join(rootDir, contentDir);
@@ -1847,6 +1894,25 @@ function resolveLocalesFromPreset(config, preset, explicitLocales) {
1847
1894
 
1848
1895
  // src/translate/page-translator.ts
1849
1896
  init_cjs_shims();
1897
+
1898
+ // src/history/record-snapshot.ts
1899
+ init_cjs_shims();
1900
+ init_sqlite();
1901
+ function recordEnSnapshot(config, input, db) {
1902
+ const ownDb = db ?? openStore(config, "readwrite");
1903
+ const id = getOrCreateEnSnapshot(ownDb, {
1904
+ contentType: input.contentType,
1905
+ enSlug: input.enSlug,
1906
+ enHash: input.enHash,
1907
+ frontmatter: input.frontmatter,
1908
+ body: input.body,
1909
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1910
+ });
1911
+ if (!db) ownDb.close();
1912
+ return id;
1913
+ }
1914
+
1915
+ // src/translate/page-translator.ts
1850
1916
  init_sqlite();
1851
1917
 
1852
1918
  // src/translate/gemini-client.ts
@@ -2244,6 +2310,17 @@ async function translatePage(config, item, options = {}) {
2244
2310
  throw new Error(`Translation validation failed: ${validated.error}`);
2245
2311
  }
2246
2312
  const writeDb = openStore(config, "readwrite");
2313
+ const snapshotId = recordEnSnapshot(
2314
+ config,
2315
+ {
2316
+ contentType: type.id,
2317
+ enSlug: item.enSlug,
2318
+ enHash: currentEnHash,
2319
+ frontmatter: payload.frontmatter,
2320
+ body: payload.body
2321
+ },
2322
+ writeDb
2323
+ );
2247
2324
  upsertTranslation(writeDb, {
2248
2325
  contentType: type.id,
2249
2326
  enSlug: item.enSlug,
@@ -2253,19 +2330,10 @@ async function translatePage(config, item, options = {}) {
2253
2330
  body: result.parsed.body,
2254
2331
  enHash: currentEnHash,
2255
2332
  translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2256
- model: result.model
2333
+ model: result.model,
2334
+ snapshotId
2257
2335
  });
2258
2336
  writeDb.close();
2259
- recordRevision(config, {
2260
- contentType: type.id,
2261
- enSlug: item.enSlug,
2262
- locale: item.locale,
2263
- revisionKind: "translation",
2264
- enHash: currentEnHash,
2265
- body: result.parsed.body,
2266
- frontmatter: validated.frontmatter,
2267
- model: result.model
2268
- });
2269
2337
  const estimatedCostUsd = estimateTranslationCostUsd(
2270
2338
  normalizeGeminiDisplayName(result.model),
2271
2339
  result.usage.inputTokens,
@@ -2504,38 +2572,31 @@ function renderFrontmatterTable(frontmatter, schema) {
2504
2572
  <tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
2505
2573
  </table>`;
2506
2574
  }
2507
- function renderRevisionSnapshot(revision) {
2508
- if (revision.frontmatter_json && revision.body) {
2509
- let frontmatter = {};
2510
- try {
2511
- frontmatter = JSON.parse(revision.frontmatter_json);
2512
- } catch {
2513
- frontmatter = {};
2514
- }
2515
- const fmRows = flattenFrontmatter(frontmatter).map(
2516
- (row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
2517
- ).join("");
2518
- return `<table class="kv"><tbody>${fmRows}</tbody></table>
2519
- <pre class="code">${escapeHtml(revision.body)}</pre>`;
2520
- }
2521
- return `<p class="dim">${escapeHtml(revision.body_preview ?? "(no snapshot)")}</p>`;
2522
- }
2523
- function renderRevisionTimeline(revisions) {
2524
- if (revisions.length === 0) {
2525
- return `<p class="dim">No history.</p>`;
2526
- }
2527
- const items = revisions.map(
2528
- (row) => `<tr>
2529
- <td class="dim">${escapeHtml(row.created_at.slice(0, 19))}</td>
2530
- <td>${escapeHtml(row.revision_kind)}</td>
2531
- <td class="dim">${row.model ? escapeHtml(row.model) : "\u2014"}</td>
2532
- <td class="dim mono">${escapeHtml(row.en_hash.slice(0, 8))}</td>
2533
- <td><details><summary>view</summary>${renderRevisionSnapshot(row)}</details></td>
2534
- </tr>`
2575
+ function renderEnSnapshotPreview(snapshot) {
2576
+ let frontmatter = {};
2577
+ try {
2578
+ frontmatter = JSON.parse(snapshot.frontmatter_json);
2579
+ } catch {
2580
+ frontmatter = {};
2581
+ }
2582
+ const fmRows = flattenFrontmatter(frontmatter).map(
2583
+ (row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
2535
2584
  ).join("");
2536
- return `<table class="data"><thead><tr>
2537
- <th>When</th><th>Kind</th><th>Model</th><th>Hash</th><th></th>
2538
- </tr></thead><tbody>${items}</tbody></table>`;
2585
+ return `<table class="kv"><tbody>${fmRows}</tbody></table>
2586
+ <pre class="code">${escapeHtml(snapshot.body)}</pre>`;
2587
+ }
2588
+ function renderTranslationSnapshotPanel(snapshot, currentEnHash) {
2589
+ if (!snapshot) {
2590
+ return `<p class="dim">No EN snapshot linked to this translation.</p>`;
2591
+ }
2592
+ 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>` : "";
2593
+ return `<dl class="meta">
2594
+ <dt>snapshot</dt><dd>#${snapshot.id}</dd>
2595
+ <dt>captured</dt><dd>${escapeHtml(snapshot.created_at.slice(0, 19))}</dd>
2596
+ <dt>en_hash</dt><dd class="mono">${escapeHtml(snapshot.en_hash.slice(0, 12))}</dd>
2597
+ </dl>
2598
+ ${staleNote}
2599
+ <details><summary>EN source at translation time</summary>${renderEnSnapshotPreview(snapshot)}</details>`;
2539
2600
  }
2540
2601
  function renderLayout(title, body, project, options = {}) {
2541
2602
  const typeLinks = project.listTypes().map((type) => {
@@ -2846,10 +2907,10 @@ async function startStudio(project, options = {}) {
2846
2907
  <dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
2847
2908
  <dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
2848
2909
  </dl>`;
2849
- const revisions = listRevisions(db, typeId, enSlug, locale);
2910
+ const snapshot = translation.snapshot_id != null ? getEnSnapshot(db, translation.snapshot_id) : void 0;
2850
2911
  historyPanel = `<div class="section">
2851
- <div class="section-head">History</div>
2852
- <div class="section-body">${renderRevisionTimeline(revisions)}</div>
2912
+ <div class="section-head">EN snapshot</div>
2913
+ <div class="section-body">${renderTranslationSnapshotPanel(snapshot, currentEnHash)}</div>
2853
2914
  </div>`;
2854
2915
  } else {
2855
2916
  contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
@@ -3178,6 +3239,24 @@ function createTranslateProgressReporter(options = {}) {
3178
3239
  };
3179
3240
  }
3180
3241
 
3242
+ // src/version.ts
3243
+ init_cjs_shims();
3244
+ function readScribeVersion() {
3245
+ let dir = path3__default.default.dirname(url.fileURLToPath(importMetaUrl));
3246
+ for (; ; ) {
3247
+ const candidate = path3__default.default.join(dir, "package.json");
3248
+ if (fs2__default.default.existsSync(candidate)) {
3249
+ const pkg = JSON.parse(fs2__default.default.readFileSync(candidate, "utf8"));
3250
+ if (pkg.name === "scribe-cms" && pkg.version) return pkg.version;
3251
+ }
3252
+ const parent = path3__default.default.dirname(dir);
3253
+ if (parent === dir) break;
3254
+ dir = parent;
3255
+ }
3256
+ return "unknown";
3257
+ }
3258
+ var SCRIBE_VERSION = readScribeVersion();
3259
+
3181
3260
  // cli/index.ts
3182
3261
  function parseArgs(argv) {
3183
3262
  const options = { cwd: process.cwd() };
@@ -3280,7 +3359,13 @@ function loadProject(options) {
3280
3359
  return createProject(config);
3281
3360
  }
3282
3361
  async function main() {
3283
- const { command, options, rest } = parseArgs(process.argv.slice(2));
3362
+ const argv = process.argv.slice(2);
3363
+ const first = argv[0];
3364
+ if (first === "--version" || first === "-V" || first === "version") {
3365
+ console.log(SCRIBE_VERSION);
3366
+ return;
3367
+ }
3368
+ const { command, options, rest } = parseArgs(argv);
3284
3369
  loadEnvFromCwd(options.cwd);
3285
3370
  if (command === "help" || command === "--help") {
3286
3371
  console.log(`Usage: scribe <command>
@@ -3290,8 +3375,9 @@ Commands:
3290
3375
  validate Validate EN files and sqlite consistency
3291
3376
  export-static Write raw MDX files for static hosting
3292
3377
  translate Translate stale/missing locale pages
3293
- history <type> <slug> Show revision timeline
3378
+ history <type> <slug> Show EN snapshot timeline
3294
3379
  studio Start read-only local studio
3380
+ version Print scribe-cms version
3295
3381
 
3296
3382
  Export-static flags:
3297
3383
  --out <dir> Output directory (default: public)
@@ -3313,6 +3399,10 @@ Translate flags:
3313
3399
  `);
3314
3400
  return;
3315
3401
  }
3402
+ if (command === "version") {
3403
+ console.log(SCRIBE_VERSION);
3404
+ return;
3405
+ }
3316
3406
  const project = loadProject(options);
3317
3407
  const config = project.config;
3318
3408
  switch (command) {
@@ -3391,11 +3481,13 @@ Translate flags:
3391
3481
  }
3392
3482
  const { openStore: openStore2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
3393
3483
  const db = openStore2(config, "readonly");
3394
- const rows = listRevisions(db, typeId, enSlug, locale);
3484
+ const rows = listEnSnapshotsForEnSlug(db, typeId, enSlug);
3395
3485
  db.close();
3396
3486
  for (const row of rows) {
3487
+ const locales = row.locales ?? "";
3488
+ if (locale && !locales.split(",").includes(locale)) continue;
3397
3489
  console.log(
3398
- `${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)}`
3490
+ `${row.created_at} snapshot=#${row.id} en_hash=${row.en_hash.slice(0, 8)} locales=${locales || "\u2014"}`
3399
3491
  );
3400
3492
  }
3401
3493
  break;