scribe-cms 0.0.5 → 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.
@@ -71,12 +71,19 @@ function addColumnIfMissing(db, table, column, ddlType) {
71
71
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
72
72
  }
73
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
+ }
74
78
  function migrate(db) {
79
+ const previousVersion = readSchemaVersion(db);
75
80
  for (const sql of MIGRATIONS) {
76
81
  db.exec(sql);
77
82
  }
78
- addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
79
- 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");
80
87
  db.prepare(
81
88
  `INSERT INTO meta(key, value) VALUES('schema_version', ?)
82
89
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
@@ -89,7 +96,7 @@ var SCHEMA_VERSION, MIGRATIONS;
89
96
  var init_sqlite = __esm({
90
97
  "src/storage/sqlite.ts"() {
91
98
  init_cjs_shims();
92
- SCHEMA_VERSION = 3;
99
+ SCHEMA_VERSION = 4;
93
100
  MIGRATIONS = [
94
101
  `CREATE TABLE IF NOT EXISTS meta (
95
102
  key TEXT PRIMARY KEY,
@@ -109,20 +116,6 @@ var init_sqlite = __esm({
109
116
  )`,
110
117
  `CREATE INDEX IF NOT EXISTS idx_translations_type_locale
111
118
  ON translations(content_type, locale)`,
112
- `CREATE TABLE IF NOT EXISTS revisions (
113
- id INTEGER PRIMARY KEY AUTOINCREMENT,
114
- content_type TEXT NOT NULL,
115
- en_slug TEXT NOT NULL,
116
- locale TEXT,
117
- revision_kind TEXT NOT NULL,
118
- en_hash TEXT NOT NULL,
119
- body_hash TEXT NOT NULL,
120
- created_at TEXT NOT NULL,
121
- model TEXT,
122
- body_preview TEXT
123
- )`,
124
- `CREATE INDEX IF NOT EXISTS idx_revisions_lookup
125
- ON revisions(content_type, en_slug, locale, created_at DESC)`,
126
119
  `CREATE TABLE IF NOT EXISTS slug_aliases (
127
120
  content_type TEXT NOT NULL,
128
121
  canonical_en_slug TEXT NOT NULL,
@@ -139,7 +132,19 @@ var init_sqlite = __esm({
139
132
  locale_slug TEXT NOT NULL,
140
133
  captured_at TEXT NOT NULL,
141
134
  PRIMARY KEY (content_type, alias_en_slug, locale)
142
- )`
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)`
143
148
  ];
144
149
  }
145
150
  });
@@ -613,18 +618,51 @@ init_sqlite();
613
618
 
614
619
  // src/storage/translations.ts
615
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
+ }
616
653
  function upsertTranslation(db, input) {
617
654
  db.prepare(
618
655
  `INSERT INTO translations (
619
- content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model
620
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
656
+ content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model, snapshot_id
657
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
621
658
  ON CONFLICT(content_type, en_slug, locale) DO UPDATE SET
622
659
  slug = excluded.slug,
623
660
  frontmatter_json = excluded.frontmatter_json,
624
661
  body = excluded.body,
625
662
  en_hash = excluded.en_hash,
626
663
  translated_at = excluded.translated_at,
627
- model = excluded.model`
664
+ model = excluded.model,
665
+ snapshot_id = excluded.snapshot_id`
628
666
  ).run(
629
667
  input.contentType,
630
668
  input.enSlug,
@@ -634,7 +672,8 @@ function upsertTranslation(db, input) {
634
672
  input.body,
635
673
  input.enHash,
636
674
  input.translatedAt,
637
- input.model
675
+ input.model,
676
+ input.snapshotId
638
677
  );
639
678
  }
640
679
  function getTranslation(db, contentType, enSlug, locale) {
@@ -656,41 +695,6 @@ function listTranslationsForLocale(db, contentType, locale) {
656
695
  function bulkLoadTranslations(db) {
657
696
  return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
658
697
  }
659
- function appendRevision(db, input) {
660
- const result = db.prepare(
661
- `INSERT INTO revisions (
662
- content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
663
- model, body_preview, frontmatter_json, body
664
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
665
- ).run(
666
- input.contentType,
667
- input.enSlug,
668
- input.locale,
669
- input.revisionKind,
670
- input.enHash,
671
- input.bodyHash,
672
- input.createdAt,
673
- input.model ?? null,
674
- input.bodyPreview ?? null,
675
- input.frontmatterJson ?? null,
676
- input.body ?? null
677
- );
678
- return Number(result.lastInsertRowid);
679
- }
680
- function listRevisions(db, contentType, enSlug, locale) {
681
- if (locale) {
682
- return db.prepare(
683
- `SELECT * FROM revisions
684
- WHERE content_type = ? AND en_slug = ? AND locale = ?
685
- ORDER BY created_at DESC, id DESC`
686
- ).all(contentType, enSlug, locale);
687
- }
688
- return db.prepare(
689
- `SELECT * FROM revisions
690
- WHERE content_type = ? AND en_slug = ?
691
- ORDER BY created_at DESC, id DESC`
692
- ).all(contentType, enSlug);
693
- }
694
698
 
695
699
  // src/loader/normalize-en.ts
696
700
  init_cjs_shims();
@@ -1827,9 +1831,6 @@ function computePageEnHash(translatableFrontmatter, body) {
1827
1831
  const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1828
1832
  return sha256(payload);
1829
1833
  }
1830
- function computeBodyHash(body) {
1831
- return sha256(body);
1832
- }
1833
1834
 
1834
1835
  // src/translate/worklist.ts
1835
1836
  init_sqlite();
@@ -1894,25 +1895,20 @@ function resolveLocalesFromPreset(config, preset, explicitLocales) {
1894
1895
  // src/translate/page-translator.ts
1895
1896
  init_cjs_shims();
1896
1897
 
1897
- // src/history/record-revision.ts
1898
+ // src/history/record-snapshot.ts
1898
1899
  init_cjs_shims();
1899
1900
  init_sqlite();
1900
- function recordRevision(config, input) {
1901
- const db = openStore(config, "readwrite");
1902
- const id = appendRevision(db, {
1901
+ function recordEnSnapshot(config, input, db) {
1902
+ const ownDb = db ?? openStore(config, "readwrite");
1903
+ const id = getOrCreateEnSnapshot(ownDb, {
1903
1904
  contentType: input.contentType,
1904
1905
  enSlug: input.enSlug,
1905
- locale: input.locale,
1906
- revisionKind: input.revisionKind,
1907
1906
  enHash: input.enHash,
1908
- bodyHash: computeBodyHash(input.body),
1909
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1910
- model: input.model,
1911
- bodyPreview: input.body.slice(0, 200),
1912
- frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
1913
- body: input.body
1907
+ frontmatter: input.frontmatter,
1908
+ body: input.body,
1909
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1914
1910
  });
1915
- db.close();
1911
+ if (!db) ownDb.close();
1916
1912
  return id;
1917
1913
  }
1918
1914
 
@@ -2314,6 +2310,17 @@ async function translatePage(config, item, options = {}) {
2314
2310
  throw new Error(`Translation validation failed: ${validated.error}`);
2315
2311
  }
2316
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
+ );
2317
2324
  upsertTranslation(writeDb, {
2318
2325
  contentType: type.id,
2319
2326
  enSlug: item.enSlug,
@@ -2323,19 +2330,10 @@ async function translatePage(config, item, options = {}) {
2323
2330
  body: result.parsed.body,
2324
2331
  enHash: currentEnHash,
2325
2332
  translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2326
- model: result.model
2333
+ model: result.model,
2334
+ snapshotId
2327
2335
  });
2328
2336
  writeDb.close();
2329
- recordRevision(config, {
2330
- contentType: type.id,
2331
- enSlug: item.enSlug,
2332
- locale: item.locale,
2333
- revisionKind: "translation",
2334
- enHash: currentEnHash,
2335
- body: result.parsed.body,
2336
- frontmatter: validated.frontmatter,
2337
- model: result.model
2338
- });
2339
2337
  const estimatedCostUsd = estimateTranslationCostUsd(
2340
2338
  normalizeGeminiDisplayName(result.model),
2341
2339
  result.usage.inputTokens,
@@ -2574,38 +2572,31 @@ function renderFrontmatterTable(frontmatter, schema) {
2574
2572
  <tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
2575
2573
  </table>`;
2576
2574
  }
2577
- function renderRevisionSnapshot(revision) {
2578
- if (revision.frontmatter_json && revision.body) {
2579
- let frontmatter = {};
2580
- try {
2581
- frontmatter = JSON.parse(revision.frontmatter_json);
2582
- } catch {
2583
- frontmatter = {};
2584
- }
2585
- const fmRows = flattenFrontmatter(frontmatter).map(
2586
- (row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
2587
- ).join("");
2588
- return `<table class="kv"><tbody>${fmRows}</tbody></table>
2589
- <pre class="code">${escapeHtml(revision.body)}</pre>`;
2590
- }
2591
- return `<p class="dim">${escapeHtml(revision.body_preview ?? "(no snapshot)")}</p>`;
2592
- }
2593
- function renderRevisionTimeline(revisions) {
2594
- if (revisions.length === 0) {
2595
- return `<p class="dim">No history.</p>`;
2596
- }
2597
- const items = revisions.map(
2598
- (row) => `<tr>
2599
- <td class="dim">${escapeHtml(row.created_at.slice(0, 19))}</td>
2600
- <td>${escapeHtml(row.revision_kind)}</td>
2601
- <td class="dim">${row.model ? escapeHtml(row.model) : "\u2014"}</td>
2602
- <td class="dim mono">${escapeHtml(row.en_hash.slice(0, 8))}</td>
2603
- <td><details><summary>view</summary>${renderRevisionSnapshot(row)}</details></td>
2604
- </tr>`
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>`
2605
2584
  ).join("");
2606
- return `<table class="data"><thead><tr>
2607
- <th>When</th><th>Kind</th><th>Model</th><th>Hash</th><th></th>
2608
- </tr></thead><tbody>${items}</tbody></table>`;
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>`;
2609
2600
  }
2610
2601
  function renderLayout(title, body, project, options = {}) {
2611
2602
  const typeLinks = project.listTypes().map((type) => {
@@ -2916,10 +2907,10 @@ async function startStudio(project, options = {}) {
2916
2907
  <dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
2917
2908
  <dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
2918
2909
  </dl>`;
2919
- const revisions = listRevisions(db, typeId, enSlug, locale);
2910
+ const snapshot = translation.snapshot_id != null ? getEnSnapshot(db, translation.snapshot_id) : void 0;
2920
2911
  historyPanel = `<div class="section">
2921
- <div class="section-head">History</div>
2922
- <div class="section-body">${renderRevisionTimeline(revisions)}</div>
2912
+ <div class="section-head">EN snapshot</div>
2913
+ <div class="section-body">${renderTranslationSnapshotPanel(snapshot, currentEnHash)}</div>
2923
2914
  </div>`;
2924
2915
  } else {
2925
2916
  contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
@@ -3384,7 +3375,7 @@ Commands:
3384
3375
  validate Validate EN files and sqlite consistency
3385
3376
  export-static Write raw MDX files for static hosting
3386
3377
  translate Translate stale/missing locale pages
3387
- history <type> <slug> Show revision timeline
3378
+ history <type> <slug> Show EN snapshot timeline
3388
3379
  studio Start read-only local studio
3389
3380
  version Print scribe-cms version
3390
3381
 
@@ -3490,11 +3481,13 @@ Translate flags:
3490
3481
  }
3491
3482
  const { openStore: openStore2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
3492
3483
  const db = openStore2(config, "readonly");
3493
- const rows = listRevisions(db, typeId, enSlug, locale);
3484
+ const rows = listEnSnapshotsForEnSlug(db, typeId, enSlug);
3494
3485
  db.close();
3495
3486
  for (const row of rows) {
3487
+ const locales = row.locales ?? "";
3488
+ if (locale && !locales.split(",").includes(locale)) continue;
3496
3489
  console.log(
3497
- `${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"}`
3498
3491
  );
3499
3492
  }
3500
3493
  break;