scribe-cms 0.0.5 → 0.0.7

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.
package/dist/index.js CHANGED
@@ -498,7 +498,7 @@ function seoFieldsFromEn(enDoc) {
498
498
  redirectTo: enDoc.redirectTo
499
499
  };
500
500
  }
501
- var SCHEMA_VERSION = 3;
501
+ var SCHEMA_VERSION = 4;
502
502
  var MIGRATIONS = [
503
503
  `CREATE TABLE IF NOT EXISTS meta (
504
504
  key TEXT PRIMARY KEY,
@@ -518,20 +518,6 @@ var MIGRATIONS = [
518
518
  )`,
519
519
  `CREATE INDEX IF NOT EXISTS idx_translations_type_locale
520
520
  ON translations(content_type, locale)`,
521
- `CREATE TABLE IF NOT EXISTS revisions (
522
- id INTEGER PRIMARY KEY AUTOINCREMENT,
523
- content_type TEXT NOT NULL,
524
- en_slug TEXT NOT NULL,
525
- locale TEXT,
526
- revision_kind TEXT NOT NULL,
527
- en_hash TEXT NOT NULL,
528
- body_hash TEXT NOT NULL,
529
- created_at TEXT NOT NULL,
530
- model TEXT,
531
- body_preview TEXT
532
- )`,
533
- `CREATE INDEX IF NOT EXISTS idx_revisions_lookup
534
- ON revisions(content_type, en_slug, locale, created_at DESC)`,
535
521
  `CREATE TABLE IF NOT EXISTS slug_aliases (
536
522
  content_type TEXT NOT NULL,
537
523
  canonical_en_slug TEXT NOT NULL,
@@ -548,7 +534,19 @@ var MIGRATIONS = [
548
534
  locale_slug TEXT NOT NULL,
549
535
  captured_at TEXT NOT NULL,
550
536
  PRIMARY KEY (content_type, alias_en_slug, locale)
551
- )`
537
+ )`,
538
+ `CREATE TABLE IF NOT EXISTS en_snapshots (
539
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
540
+ content_type TEXT NOT NULL,
541
+ en_slug TEXT NOT NULL,
542
+ en_hash TEXT NOT NULL,
543
+ frontmatter_json TEXT NOT NULL,
544
+ body TEXT NOT NULL,
545
+ created_at TEXT NOT NULL,
546
+ UNIQUE (content_type, en_slug, en_hash)
547
+ )`,
548
+ `CREATE INDEX IF NOT EXISTS idx_en_snapshots_lookup
549
+ ON en_snapshots(content_type, en_slug, created_at DESC)`
552
550
  ];
553
551
  function resolveStorePath(config) {
554
552
  return config.storePath;
@@ -570,12 +568,19 @@ function addColumnIfMissing(db, table, column, ddlType) {
570
568
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
571
569
  }
572
570
  }
571
+ function readSchemaVersion(db) {
572
+ const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
573
+ return row ? Number.parseInt(row.value, 10) || 0 : 0;
574
+ }
573
575
  function migrate(db) {
576
+ const previousVersion = readSchemaVersion(db);
574
577
  for (const sql of MIGRATIONS) {
575
578
  db.exec(sql);
576
579
  }
577
- addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
578
- addColumnIfMissing(db, "revisions", "body", "TEXT");
580
+ if (previousVersion < 4) {
581
+ db.exec(`DROP TABLE IF EXISTS revisions`);
582
+ }
583
+ addColumnIfMissing(db, "translations", "snapshot_id", "INTEGER");
579
584
  db.prepare(
580
585
  `INSERT INTO meta(key, value) VALUES('schema_version', ?)
581
586
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
@@ -583,18 +588,38 @@ function migrate(db) {
583
588
  }
584
589
 
585
590
  // src/storage/translations.ts
591
+ function getOrCreateEnSnapshot(db, input) {
592
+ db.prepare(
593
+ `INSERT OR IGNORE INTO en_snapshots (
594
+ content_type, en_slug, en_hash, frontmatter_json, body, created_at
595
+ ) VALUES (?, ?, ?, ?, ?, ?)`
596
+ ).run(
597
+ input.contentType,
598
+ input.enSlug,
599
+ input.enHash,
600
+ JSON.stringify(input.frontmatter),
601
+ input.body,
602
+ input.createdAt
603
+ );
604
+ const row = db.prepare(
605
+ `SELECT id FROM en_snapshots
606
+ WHERE content_type = ? AND en_slug = ? AND en_hash = ?`
607
+ ).get(input.contentType, input.enSlug, input.enHash);
608
+ return row.id;
609
+ }
586
610
  function upsertTranslation(db, input) {
587
611
  db.prepare(
588
612
  `INSERT INTO translations (
589
- content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model
590
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
613
+ content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model, snapshot_id
614
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
591
615
  ON CONFLICT(content_type, en_slug, locale) DO UPDATE SET
592
616
  slug = excluded.slug,
593
617
  frontmatter_json = excluded.frontmatter_json,
594
618
  body = excluded.body,
595
619
  en_hash = excluded.en_hash,
596
620
  translated_at = excluded.translated_at,
597
- model = excluded.model`
621
+ model = excluded.model,
622
+ snapshot_id = excluded.snapshot_id`
598
623
  ).run(
599
624
  input.contentType,
600
625
  input.enSlug,
@@ -604,7 +629,8 @@ function upsertTranslation(db, input) {
604
629
  input.body,
605
630
  input.enHash,
606
631
  input.translatedAt,
607
- input.model
632
+ input.model,
633
+ input.snapshotId
608
634
  );
609
635
  }
610
636
  function getTranslation(db, contentType, enSlug, locale) {
@@ -623,27 +649,6 @@ function listTranslationsForEnSlug(db, contentType, enSlug) {
623
649
  function bulkLoadTranslations(db) {
624
650
  return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
625
651
  }
626
- function appendRevision(db, input) {
627
- const result = db.prepare(
628
- `INSERT INTO revisions (
629
- content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
630
- model, body_preview, frontmatter_json, body
631
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
632
- ).run(
633
- input.contentType,
634
- input.enSlug,
635
- input.locale,
636
- input.revisionKind,
637
- input.enHash,
638
- input.bodyHash,
639
- input.createdAt,
640
- input.model ?? null,
641
- input.bodyPreview ?? null,
642
- input.frontmatterJson ?? null,
643
- input.body ?? null
644
- );
645
- return Number(result.lastInsertRowid);
646
- }
647
652
 
648
653
  // src/loader/normalize-en.ts
649
654
  function normalizeEnFrontmatter(data) {
@@ -1864,23 +1869,43 @@ function validateDocumentAssets(config, input) {
1864
1869
  return issues;
1865
1870
  }
1866
1871
 
1872
+ // src/core/localized-slug.ts
1873
+ function findLocaleSuffixInSlug(slug, localeCodes) {
1874
+ const sorted = [...localeCodes].sort((a, b) => b.length - a.length);
1875
+ for (const code of sorted) {
1876
+ if (slug.endsWith(`-${code}`)) {
1877
+ return code;
1878
+ }
1879
+ }
1880
+ return void 0;
1881
+ }
1882
+ function stripLocaleSuffixFromSlug(slug, localeCodes) {
1883
+ const matchedCode = findLocaleSuffixInSlug(slug, localeCodes);
1884
+ if (!matchedCode) {
1885
+ return { slug, stripped: false };
1886
+ }
1887
+ return {
1888
+ slug: slug.slice(0, -(matchedCode.length + 1)),
1889
+ stripped: true,
1890
+ matchedCode
1891
+ };
1892
+ }
1893
+
1867
1894
  // src/validate/validate-slug-suffix.ts
1868
1895
  function validateTranslationSlugSuffixes(config, db) {
1869
1896
  const issues = [];
1870
1897
  const localeCodes = config.locales.filter((l) => l !== config.defaultLocale);
1871
1898
  for (const row of bulkLoadTranslations(db)) {
1872
1899
  if (row.locale === config.defaultLocale) continue;
1873
- for (const code of localeCodes) {
1874
- if (row.slug.endsWith(`-${code}`)) {
1875
- issues.push({
1876
- contentTypeId: row.content_type,
1877
- enSlug: row.en_slug,
1878
- locale: row.locale,
1879
- slug: row.slug,
1880
- message: `Translation slug "${row.slug}" ends with locale code "-${code}"`
1881
- });
1882
- break;
1883
- }
1900
+ const matchedCode = findLocaleSuffixInSlug(row.slug, localeCodes);
1901
+ if (matchedCode) {
1902
+ issues.push({
1903
+ contentTypeId: row.content_type,
1904
+ enSlug: row.en_slug,
1905
+ locale: row.locale,
1906
+ slug: row.slug,
1907
+ message: `Translation slug "${row.slug}" ends with locale code "-${matchedCode}"`
1908
+ });
1884
1909
  }
1885
1910
  }
1886
1911
  return issues;
@@ -2028,7 +2053,7 @@ function validateProject(config) {
2028
2053
  try {
2029
2054
  for (const issue of validateTranslationSlugSuffixes(config, dbForSuffix)) {
2030
2055
  issues.push({
2031
- level: "error",
2056
+ level: "warning",
2032
2057
  contentType: issue.contentTypeId,
2033
2058
  enSlug: issue.enSlug,
2034
2059
  locale: issue.locale,
@@ -2048,9 +2073,6 @@ function computePageEnHash(translatableFrontmatter, body) {
2048
2073
  const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
2049
2074
  return sha256(payload);
2050
2075
  }
2051
- function computeBodyHash(body) {
2052
- return sha256(body);
2053
- }
2054
2076
 
2055
2077
  // src/translate/worklist.ts
2056
2078
  function listEnSlugs3(rootDir, contentDir) {
@@ -2111,23 +2133,18 @@ function resolveLocalesFromPreset(config, preset, explicitLocales) {
2111
2133
  return config.locales.filter((l) => l !== config.defaultLocale);
2112
2134
  }
2113
2135
 
2114
- // src/history/record-revision.ts
2115
- function recordRevision(config, input) {
2116
- const db = openStore(config, "readwrite");
2117
- const id = appendRevision(db, {
2136
+ // src/history/record-snapshot.ts
2137
+ function recordEnSnapshot(config, input, db) {
2138
+ const ownDb = db ?? openStore(config, "readwrite");
2139
+ const id = getOrCreateEnSnapshot(ownDb, {
2118
2140
  contentType: input.contentType,
2119
2141
  enSlug: input.enSlug,
2120
- locale: input.locale,
2121
- revisionKind: input.revisionKind,
2122
2142
  enHash: input.enHash,
2123
- bodyHash: computeBodyHash(input.body),
2124
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2125
- model: input.model,
2126
- bodyPreview: input.body.slice(0, 200),
2127
- frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
2128
- body: input.body
2143
+ frontmatter: input.frontmatter,
2144
+ body: input.body,
2145
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2129
2146
  });
2130
- db.close();
2147
+ if (!db) ownDb.close();
2131
2148
  return id;
2132
2149
  }
2133
2150
 
@@ -2255,6 +2272,9 @@ function buildPageTranslationPrompt(input) {
2255
2272
  ...input.contextLabel ? [`Document: ${input.contextLabel}`, ""] : [],
2256
2273
  "## Rules",
2257
2274
  ...input.resolved.rules.map((rule) => `- ${rule}`),
2275
+ ...input.slugStrategy === "localized" ? [
2276
+ `- The slug MUST be written in ${localeName}, derived from the ${localeName} title and its meaning \u2014 never the English slug. Transliterate non-Latin ${localeName} into ASCII Latin.`
2277
+ ] : [],
2258
2278
  "",
2259
2279
  "## Output format",
2260
2280
  "Return ONLY valid JSON with keys:",
@@ -2338,9 +2358,11 @@ function buildGeminiResponseSchema(schema, slugStrategy) {
2338
2358
  function slugStrategyRules(slugStrategy, preserveTerms) {
2339
2359
  if (slugStrategy === "localized") {
2340
2360
  const rules = [
2341
- "Provide a localized URL slug in JSON field `slug`.",
2361
+ "Provide a URL slug in JSON field `slug` that is TRANSLATED into the target language.",
2362
+ "Base the slug on the meaning of the translated title \u2014 do NOT reuse the English slug words.",
2342
2363
  "Slug MUST be ASCII only: a-z, 0-9, hyphens. No uppercase, accents, underscores, or spaces.",
2343
- "For non-Latin locales, transliterate into Latin script."
2364
+ "For non-Latin languages, write the words in the target language and transliterate them into Latin script (e.g. Russian -> romanized Russian, not English).",
2365
+ "Do NOT append locale codes to the slug (e.g. -fr, -he, -zh-cn). Locale routing is handled by the URL prefix, not the slug."
2344
2366
  ];
2345
2367
  if (preserveTerms?.length) {
2346
2368
  rules.push(
@@ -2498,12 +2520,26 @@ async function translatePage(config, item, options = {}) {
2498
2520
  model,
2499
2521
  responseSchema: responseSchema ?? void 0
2500
2522
  });
2501
- const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
2523
+ const rawSlug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
2524
+ const localeCodes = config.locales.filter((l) => l !== config.defaultLocale);
2525
+ const { slug, stripped, matchedCode } = stripLocaleSuffixFromSlug(rawSlug, localeCodes);
2526
+ const slugAdjusted = stripped && matchedCode ? { from: rawSlug, to: slug, matchedCode } : void 0;
2502
2527
  const validated = validateTranslatedFrontmatter(enDoc, result.parsed.frontmatter, type.schema);
2503
2528
  if (!validated.ok) {
2504
2529
  throw new Error(`Translation validation failed: ${validated.error}`);
2505
2530
  }
2506
2531
  const writeDb = openStore(config, "readwrite");
2532
+ const snapshotId = recordEnSnapshot(
2533
+ config,
2534
+ {
2535
+ contentType: type.id,
2536
+ enSlug: item.enSlug,
2537
+ enHash: currentEnHash,
2538
+ frontmatter: payload.frontmatter,
2539
+ body: payload.body
2540
+ },
2541
+ writeDb
2542
+ );
2507
2543
  upsertTranslation(writeDb, {
2508
2544
  contentType: type.id,
2509
2545
  enSlug: item.enSlug,
@@ -2513,19 +2549,10 @@ async function translatePage(config, item, options = {}) {
2513
2549
  body: result.parsed.body,
2514
2550
  enHash: currentEnHash,
2515
2551
  translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2516
- model: result.model
2552
+ model: result.model,
2553
+ snapshotId
2517
2554
  });
2518
2555
  writeDb.close();
2519
- recordRevision(config, {
2520
- contentType: type.id,
2521
- enSlug: item.enSlug,
2522
- locale: item.locale,
2523
- revisionKind: "translation",
2524
- enHash: currentEnHash,
2525
- body: result.parsed.body,
2526
- frontmatter: validated.frontmatter,
2527
- model: result.model
2528
- });
2529
2556
  const estimatedCostUsd = estimateTranslationCostUsd(
2530
2557
  normalizeGeminiDisplayName(result.model),
2531
2558
  result.usage.inputTokens,
@@ -2537,7 +2564,8 @@ async function translatePage(config, item, options = {}) {
2537
2564
  model: result.model,
2538
2565
  usage: result.usage,
2539
2566
  estimatedCostUsd,
2540
- durationMs: Date.now() - startedAt
2567
+ durationMs: Date.now() - startedAt,
2568
+ slugAdjusted
2541
2569
  };
2542
2570
  } catch (error) {
2543
2571
  return {