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.
- package/dist/cli/index.cjs +272 -180
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +272 -181
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +188 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +188 -123
- package/dist/index.js.map +1 -1
- package/dist/runtime.cjs +25 -18
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +25 -18
- package/dist/runtime.js.map +1 -1
- package/dist/src/config/resolve-config.d.ts.map +1 -1
- package/dist/src/core/types.d.ts +4 -0
- package/dist/src/core/types.d.ts.map +1 -1
- package/dist/src/history/record-snapshot.d.ts +11 -0
- package/dist/src/history/record-snapshot.d.ts.map +1 -0
- package/dist/src/storage/sqlite.d.ts.map +1 -1
- package/dist/src/storage/translations.d.ts +14 -21
- package/dist/src/storage/translations.d.ts.map +1 -1
- package/dist/src/translate/page-translator.d.ts.map +1 -1
- package/dist/src/validate/validate-assets.d.ts +20 -0
- package/dist/src/validate/validate-assets.d.ts.map +1 -0
- package/dist/src/validate/validate-project.d.ts +1 -1
- package/dist/src/validate/validate-project.d.ts.map +1 -1
- package/dist/src/version.d.ts +4 -0
- package/dist/src/version.d.ts.map +1 -0
- package/dist/studio/server.cjs +51 -64
- package/dist/studio/server.cjs.map +1 -1
- package/dist/studio/server.d.ts.map +1 -1
- package/dist/studio/server.js +51 -64
- package/dist/studio/server.js.map +1 -1
- package/package.json +1 -1
- package/dist/src/history/record-revision.d.ts +0 -14
- 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
|
-
|
|
64
|
-
|
|
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 =
|
|
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(
|
|
222
|
-
if (!
|
|
223
|
-
throw new Error(`Content type "${typeId}": path must start with / (got "${
|
|
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 = (
|
|
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 "${
|
|
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
|
|
357
|
-
const value = getAtPath(data,
|
|
363
|
+
for (const path14 of paths) {
|
|
364
|
+
const value = getAtPath(data, path14);
|
|
358
365
|
if (value !== void 0) {
|
|
359
|
-
setAtPath(out,
|
|
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,
|
|
384
|
+
function getAtPath(obj, path14) {
|
|
378
385
|
let current = obj;
|
|
379
|
-
for (const segment of
|
|
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,
|
|
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,
|
|
392
|
-
if (
|
|
393
|
-
if (
|
|
394
|
-
obj[
|
|
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] =
|
|
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
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
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="
|
|
2523
|
-
<
|
|
2524
|
-
|
|
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
|
|
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">
|
|
2838
|
-
<div class="section-body">${
|
|
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
|
|
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
|
|
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 =
|
|
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}
|
|
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;
|