scribe-cms 0.0.2 → 0.0.5
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/README.md +2 -3
- package/bin/scribe.js +2 -11
- package/dist/cli/index.cjs +1437 -225
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +1437 -226
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/prompt-translate.d.ts +16 -0
- package/dist/cli/prompt-translate.d.ts.map +1 -0
- package/dist/cli/translate-progress.d.ts +11 -0
- package/dist/cli/translate-progress.d.ts.map +1 -0
- package/dist/index.cjs +591 -147
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +587 -148
- package/dist/index.js.map +1 -1
- package/dist/runtime.cjs +14 -2
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +14 -2
- package/dist/runtime.js.map +1 -1
- package/dist/src/config/resolve-config.d.ts.map +1 -1
- package/dist/src/core/field.d.ts +2 -0
- package/dist/src/core/field.d.ts.map +1 -1
- package/dist/src/core/types.d.ts +5 -1
- package/dist/src/core/types.d.ts.map +1 -1
- package/dist/src/export/build-static-raw-exports.d.ts +37 -0
- package/dist/src/export/build-static-raw-exports.d.ts.map +1 -0
- package/dist/src/export/write-static-raw-exports.d.ts +12 -0
- package/dist/src/export/write-static-raw-exports.d.ts.map +1 -0
- package/dist/src/history/record-revision.d.ts +2 -0
- package/dist/src/history/record-revision.d.ts.map +1 -1
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/storage/sqlite.d.ts.map +1 -1
- package/dist/src/storage/translations.d.ts +6 -0
- package/dist/src/storage/translations.d.ts.map +1 -1
- package/dist/src/translate/gemini-client.d.ts +7 -0
- package/dist/src/translate/gemini-client.d.ts.map +1 -1
- package/dist/src/translate/gemini-models.d.ts +8 -0
- package/dist/src/translate/gemini-models.d.ts.map +1 -0
- package/dist/src/translate/gemini-pricing.d.ts +11 -0
- package/dist/src/translate/gemini-pricing.d.ts.map +1 -0
- package/dist/src/translate/page-translator.d.ts +38 -1
- package/dist/src/translate/page-translator.d.ts.map +1 -1
- package/dist/src/translate/prompts/translation-prompt.d.ts +1 -2
- package/dist/src/translate/prompts/translation-prompt.d.ts.map +1 -1
- package/dist/src/translate/response-schema.d.ts +7 -0
- package/dist/src/translate/response-schema.d.ts.map +1 -0
- package/dist/src/translate/validate-translation.d.ts +14 -0
- package/dist/src/translate/validate-translation.d.ts.map +1 -0
- package/dist/src/translate/worklist.d.ts +3 -0
- package/dist/src/translate/worklist.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 +611 -194
- package/dist/studio/server.cjs.map +1 -1
- package/dist/studio/server.d.ts +1 -1
- package/dist/studio/server.d.ts.map +1 -1
- package/dist/studio/server.js +611 -194
- package/dist/studio/server.js.map +1 -1
- package/package.json +16 -5
- package/bin/ensure-built.mjs +0 -31
package/dist/cli/index.cjs
CHANGED
|
@@ -12,6 +12,8 @@ var genai = require('@google/genai');
|
|
|
12
12
|
var dotenv = require('dotenv');
|
|
13
13
|
var nodeServer = require('@hono/node-server');
|
|
14
14
|
var hono = require('hono');
|
|
15
|
+
var prompts = require('@inquirer/prompts');
|
|
16
|
+
var url = require('url');
|
|
15
17
|
|
|
16
18
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
17
19
|
|
|
@@ -23,6 +25,7 @@ var dotenv__default = /*#__PURE__*/_interopDefault(dotenv);
|
|
|
23
25
|
|
|
24
26
|
var __defProp = Object.defineProperty;
|
|
25
27
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
28
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
26
29
|
var __esm = (fn, res) => function __init() {
|
|
27
30
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
28
31
|
};
|
|
@@ -30,6 +33,7 @@ var __export = (target, all) => {
|
|
|
30
33
|
for (var name in all)
|
|
31
34
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
32
35
|
};
|
|
36
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
33
37
|
|
|
34
38
|
// ../../node_modules/tsup/assets/cjs_shims.js
|
|
35
39
|
var getImportMetaUrl, importMetaUrl;
|
|
@@ -52,17 +56,27 @@ function resolveStorePath(config) {
|
|
|
52
56
|
}
|
|
53
57
|
function openStore(config, mode = "readwrite") {
|
|
54
58
|
const storePath = resolveStorePath(config);
|
|
55
|
-
|
|
59
|
+
if (mode === "readwrite") {
|
|
60
|
+
fs2__default.default.mkdirSync(path3__default.default.dirname(storePath), { recursive: true });
|
|
61
|
+
}
|
|
56
62
|
const db = new Database__default.default(storePath, { readonly: mode === "readonly" });
|
|
57
63
|
if (mode === "readwrite") {
|
|
58
64
|
migrate(db);
|
|
59
65
|
}
|
|
60
66
|
return db;
|
|
61
67
|
}
|
|
68
|
+
function addColumnIfMissing(db, table, column, ddlType) {
|
|
69
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
70
|
+
if (!columns.some((c) => c.name === column)) {
|
|
71
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
function migrate(db) {
|
|
63
75
|
for (const sql of MIGRATIONS) {
|
|
64
76
|
db.exec(sql);
|
|
65
77
|
}
|
|
78
|
+
addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
|
|
79
|
+
addColumnIfMissing(db, "revisions", "body", "TEXT");
|
|
66
80
|
db.prepare(
|
|
67
81
|
`INSERT INTO meta(key, value) VALUES('schema_version', ?)
|
|
68
82
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
@@ -75,7 +89,7 @@ var SCHEMA_VERSION, MIGRATIONS;
|
|
|
75
89
|
var init_sqlite = __esm({
|
|
76
90
|
"src/storage/sqlite.ts"() {
|
|
77
91
|
init_cjs_shims();
|
|
78
|
-
SCHEMA_VERSION =
|
|
92
|
+
SCHEMA_VERSION = 3;
|
|
79
93
|
MIGRATIONS = [
|
|
80
94
|
`CREATE TABLE IF NOT EXISTS meta (
|
|
81
95
|
key TEXT PRIMARY KEY,
|
|
@@ -187,6 +201,22 @@ function unwrapSchema(schema) {
|
|
|
187
201
|
}
|
|
188
202
|
return current;
|
|
189
203
|
}
|
|
204
|
+
function peelOptionalWrappers(schema) {
|
|
205
|
+
let current = schema;
|
|
206
|
+
for (let i = 0; i < 8; i++) {
|
|
207
|
+
const type = current._def?.type;
|
|
208
|
+
if (type === "optional" || type === "nullable") {
|
|
209
|
+
current = current.unwrap();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (type === "default") {
|
|
213
|
+
current = current.removeDefault();
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
return current;
|
|
219
|
+
}
|
|
190
220
|
|
|
191
221
|
// src/core/types.ts
|
|
192
222
|
init_cjs_shims();
|
|
@@ -203,14 +233,14 @@ var SLUG_PLACEHOLDER = "{slug}";
|
|
|
203
233
|
function isRoutableType(type) {
|
|
204
234
|
return typeof type.path === "string" && type.path.length > 0;
|
|
205
235
|
}
|
|
206
|
-
function assertValidPathTemplate(
|
|
207
|
-
if (!
|
|
208
|
-
throw new Error(`Content type "${typeId}": path must start with / (got "${
|
|
236
|
+
function assertValidPathTemplate(path13, typeId) {
|
|
237
|
+
if (!path13.startsWith("/")) {
|
|
238
|
+
throw new Error(`Content type "${typeId}": path must start with / (got "${path13}")`);
|
|
209
239
|
}
|
|
210
|
-
const count = (
|
|
240
|
+
const count = (path13.match(/\{slug\}/g) ?? []).length;
|
|
211
241
|
if (count !== 1) {
|
|
212
242
|
throw new Error(
|
|
213
|
-
`Content type "${typeId}": path must contain exactly one {slug} (got "${
|
|
243
|
+
`Content type "${typeId}": path must contain exactly one {slug} (got "${path13}")`
|
|
214
244
|
);
|
|
215
245
|
}
|
|
216
246
|
}
|
|
@@ -262,6 +292,7 @@ function resolveConfig(input, baseDir) {
|
|
|
262
292
|
const projectRoot = path3__default.default.resolve(baseDir ?? process.cwd(), raw.rootDir);
|
|
263
293
|
const contentRoot = path3__default.default.resolve(projectRoot, raw.contentDir ?? "content");
|
|
264
294
|
const storePath = path3__default.default.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
|
|
295
|
+
const assetsPath = raw.assetsDir ? path3__default.default.resolve(projectRoot, raw.assetsDir) : void 0;
|
|
265
296
|
const seenIds = /* @__PURE__ */ new Set();
|
|
266
297
|
const types = raw.types.map((type) => {
|
|
267
298
|
if (seenIds.has(type.id)) {
|
|
@@ -282,6 +313,7 @@ function resolveConfig(input, baseDir) {
|
|
|
282
313
|
const config = {
|
|
283
314
|
rootDir: contentRoot,
|
|
284
315
|
storePath,
|
|
316
|
+
assetsPath,
|
|
285
317
|
locales: [...raw.locales],
|
|
286
318
|
defaultLocale,
|
|
287
319
|
localePresets: raw.localePresets,
|
|
@@ -338,10 +370,10 @@ function introspectSchema(schema, prefix = []) {
|
|
|
338
370
|
}
|
|
339
371
|
function extractByPaths(data, paths) {
|
|
340
372
|
const out = {};
|
|
341
|
-
for (const
|
|
342
|
-
const value = getAtPath(data,
|
|
373
|
+
for (const path13 of paths) {
|
|
374
|
+
const value = getAtPath(data, path13);
|
|
343
375
|
if (value !== void 0) {
|
|
344
|
-
setAtPath(out,
|
|
376
|
+
setAtPath(out, path13.filter((p) => p !== "*"), value);
|
|
345
377
|
}
|
|
346
378
|
}
|
|
347
379
|
return out;
|
|
@@ -359,13 +391,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
|
|
|
359
391
|
const translatable = pickTranslatable(localeData, schema);
|
|
360
392
|
return deepMerge(structural, translatable);
|
|
361
393
|
}
|
|
362
|
-
function getAtPath(obj,
|
|
394
|
+
function getAtPath(obj, path13) {
|
|
363
395
|
let current = obj;
|
|
364
|
-
for (const segment of
|
|
396
|
+
for (const segment of path13) {
|
|
365
397
|
if (segment === "*") {
|
|
366
398
|
if (!Array.isArray(current)) return void 0;
|
|
367
399
|
return current.map(
|
|
368
|
-
(item) => typeof item === "object" && item !== null ? getAtPath(item,
|
|
400
|
+
(item) => typeof item === "object" && item !== null ? getAtPath(item, path13.slice(path13.indexOf("*") + 1)) : void 0
|
|
369
401
|
);
|
|
370
402
|
}
|
|
371
403
|
if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
|
|
@@ -373,13 +405,13 @@ function getAtPath(obj, path10) {
|
|
|
373
405
|
}
|
|
374
406
|
return current;
|
|
375
407
|
}
|
|
376
|
-
function setAtPath(obj,
|
|
377
|
-
if (
|
|
378
|
-
if (
|
|
379
|
-
obj[
|
|
408
|
+
function setAtPath(obj, path13, value) {
|
|
409
|
+
if (path13.length === 0) return;
|
|
410
|
+
if (path13.length === 1) {
|
|
411
|
+
obj[path13[0]] = value;
|
|
380
412
|
return;
|
|
381
413
|
}
|
|
382
|
-
const [head, ...rest] =
|
|
414
|
+
const [head, ...rest] = path13;
|
|
383
415
|
if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
|
|
384
416
|
obj[head] = {};
|
|
385
417
|
}
|
|
@@ -618,14 +650,18 @@ function listTranslationsForEnSlug(db, contentType, enSlug) {
|
|
|
618
650
|
`SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
|
|
619
651
|
).all(contentType, enSlug);
|
|
620
652
|
}
|
|
653
|
+
function listTranslationsForLocale(db, contentType, locale) {
|
|
654
|
+
return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
|
|
655
|
+
}
|
|
621
656
|
function bulkLoadTranslations(db) {
|
|
622
657
|
return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
|
|
623
658
|
}
|
|
624
659
|
function appendRevision(db, input) {
|
|
625
660
|
const result = db.prepare(
|
|
626
661
|
`INSERT INTO revisions (
|
|
627
|
-
content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
|
|
628
|
-
|
|
662
|
+
content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
|
|
663
|
+
model, body_preview, frontmatter_json, body
|
|
664
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
629
665
|
).run(
|
|
630
666
|
input.contentType,
|
|
631
667
|
input.enSlug,
|
|
@@ -635,7 +671,9 @@ function appendRevision(db, input) {
|
|
|
635
671
|
input.bodyHash,
|
|
636
672
|
input.createdAt,
|
|
637
673
|
input.model ?? null,
|
|
638
|
-
input.bodyPreview ?? null
|
|
674
|
+
input.bodyPreview ?? null,
|
|
675
|
+
input.frontmatterJson ?? null,
|
|
676
|
+
input.body ?? null
|
|
639
677
|
);
|
|
640
678
|
return Number(result.lastInsertRowid);
|
|
641
679
|
}
|
|
@@ -1445,41 +1483,6 @@ function loadConfigSync(options = {}) {
|
|
|
1445
1483
|
|
|
1446
1484
|
// src/validate/validate-project.ts
|
|
1447
1485
|
init_cjs_shims();
|
|
1448
|
-
|
|
1449
|
-
// src/hash/page-hash.ts
|
|
1450
|
-
init_cjs_shims();
|
|
1451
|
-
function sha256(input) {
|
|
1452
|
-
return crypto.createHash("sha256").update(input, "utf8").digest("hex");
|
|
1453
|
-
}
|
|
1454
|
-
function computePageEnHash(translatableFrontmatter, body) {
|
|
1455
|
-
const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
|
|
1456
|
-
return sha256(payload);
|
|
1457
|
-
}
|
|
1458
|
-
function computeBodyHash(body) {
|
|
1459
|
-
return sha256(body);
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
// src/history/record-revision.ts
|
|
1463
|
-
init_cjs_shims();
|
|
1464
|
-
init_sqlite();
|
|
1465
|
-
function recordRevision(config, input) {
|
|
1466
|
-
const db = openStore(config, "readwrite");
|
|
1467
|
-
const id = appendRevision(db, {
|
|
1468
|
-
contentType: input.contentType,
|
|
1469
|
-
enSlug: input.enSlug,
|
|
1470
|
-
locale: input.locale,
|
|
1471
|
-
revisionKind: input.revisionKind,
|
|
1472
|
-
enHash: input.enHash,
|
|
1473
|
-
bodyHash: computeBodyHash(input.body),
|
|
1474
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1475
|
-
model: input.model,
|
|
1476
|
-
bodyPreview: input.body.slice(0, 200)
|
|
1477
|
-
});
|
|
1478
|
-
db.close();
|
|
1479
|
-
return id;
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
// src/validate/validate-project.ts
|
|
1483
1486
|
init_sqlite();
|
|
1484
1487
|
|
|
1485
1488
|
// src/validate/validate-relations.ts
|
|
@@ -1564,6 +1567,75 @@ function validateRelations(project) {
|
|
|
1564
1567
|
return issues;
|
|
1565
1568
|
}
|
|
1566
1569
|
|
|
1570
|
+
// src/validate/validate-assets.ts
|
|
1571
|
+
init_cjs_shims();
|
|
1572
|
+
var IMAGE_EXT = String.raw`(?:png|jpe?g|webp|gif|svg)`;
|
|
1573
|
+
var IMAGE_WEB_PATH = String.raw`\/[\w\-./]+\.${IMAGE_EXT}`;
|
|
1574
|
+
var MARKDOWN_IMAGE_RE = new RegExp(
|
|
1575
|
+
String.raw`!\[[^\]]*\]\((${IMAGE_WEB_PATH})\)`,
|
|
1576
|
+
"gi"
|
|
1577
|
+
);
|
|
1578
|
+
var HTML_SRC_RE = new RegExp(String.raw`src=["'](${IMAGE_WEB_PATH})["']`, "gi");
|
|
1579
|
+
function isImageWebPath(value) {
|
|
1580
|
+
return new RegExp(String.raw`^${IMAGE_WEB_PATH}$`, "i").test(value);
|
|
1581
|
+
}
|
|
1582
|
+
function isSiteAssetPath(webPath) {
|
|
1583
|
+
const segment = webPath.split("/")[1];
|
|
1584
|
+
return Boolean(segment && !segment.includes("."));
|
|
1585
|
+
}
|
|
1586
|
+
function addImagePath(webPath, out) {
|
|
1587
|
+
if (isSiteAssetPath(webPath)) out.add(webPath);
|
|
1588
|
+
}
|
|
1589
|
+
function collectBodyImagePaths(body, out) {
|
|
1590
|
+
for (const re of [MARKDOWN_IMAGE_RE, HTML_SRC_RE]) {
|
|
1591
|
+
for (const match of body.matchAll(re)) {
|
|
1592
|
+
addImagePath(match[1], out);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
function collectFrontmatterImagePaths(value, out) {
|
|
1597
|
+
if (typeof value === "string") {
|
|
1598
|
+
if (isImageWebPath(value)) addImagePath(value, out);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (Array.isArray(value)) {
|
|
1602
|
+
for (const item of value) collectFrontmatterImagePaths(item, out);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
if (value && typeof value === "object") {
|
|
1606
|
+
for (const nested of Object.values(value)) {
|
|
1607
|
+
collectFrontmatterImagePaths(nested, out);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
function collectImagePaths(frontmatter, body) {
|
|
1612
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1613
|
+
collectFrontmatterImagePaths(frontmatter, paths);
|
|
1614
|
+
collectBodyImagePaths(body, paths);
|
|
1615
|
+
return [...paths].sort();
|
|
1616
|
+
}
|
|
1617
|
+
function assetFilePath(assetsPath, webPath) {
|
|
1618
|
+
return path3__default.default.join(assetsPath, webPath.replace(/^\//, ""));
|
|
1619
|
+
}
|
|
1620
|
+
function validateDocumentAssets(config, input) {
|
|
1621
|
+
const assetsPath = config.assetsPath;
|
|
1622
|
+
if (!assetsPath) return [];
|
|
1623
|
+
const issues = [];
|
|
1624
|
+
for (const webPath of collectImagePaths(input.frontmatter, input.body)) {
|
|
1625
|
+
const filePath = assetFilePath(assetsPath, webPath);
|
|
1626
|
+
if (fs2__default.default.existsSync(filePath)) continue;
|
|
1627
|
+
issues.push({
|
|
1628
|
+
level: "warning",
|
|
1629
|
+
contentType: input.contentType,
|
|
1630
|
+
enSlug: input.enSlug,
|
|
1631
|
+
locale: input.locale,
|
|
1632
|
+
field: "asset",
|
|
1633
|
+
message: `Missing image asset ${webPath} (expected ${filePath})`
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
return issues;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1567
1639
|
// src/validate/validate-slug-suffix.ts
|
|
1568
1640
|
init_cjs_shims();
|
|
1569
1641
|
function validateTranslationSlugSuffixes(config, db) {
|
|
@@ -1626,8 +1698,6 @@ function validateProject(config) {
|
|
|
1626
1698
|
});
|
|
1627
1699
|
continue;
|
|
1628
1700
|
}
|
|
1629
|
-
const payload = getTranslatablePayload(enDoc, type);
|
|
1630
|
-
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
1631
1701
|
const crossIssues = type.crossValidate?.(enDoc.frontmatter, {
|
|
1632
1702
|
locale: config.defaultLocale,
|
|
1633
1703
|
defaultLocale: config.defaultLocale,
|
|
@@ -1645,28 +1715,18 @@ function validateProject(config) {
|
|
|
1645
1715
|
message: issue.message
|
|
1646
1716
|
});
|
|
1647
1717
|
}
|
|
1718
|
+
for (const issue of validateDocumentAssets(config, {
|
|
1719
|
+
contentType: type.id,
|
|
1720
|
+
enSlug,
|
|
1721
|
+
frontmatter: enDoc.frontmatter,
|
|
1722
|
+
body: enDoc.content
|
|
1723
|
+
})) {
|
|
1724
|
+
issues.push(issue);
|
|
1725
|
+
}
|
|
1648
1726
|
for (const locale of config.locales) {
|
|
1649
1727
|
if (locale === config.defaultLocale) continue;
|
|
1650
1728
|
const row = getTranslation(db, type.id, enSlug, locale);
|
|
1651
1729
|
if (!row) continue;
|
|
1652
|
-
if (row.en_hash !== currentEnHash) {
|
|
1653
|
-
issues.push({
|
|
1654
|
-
level: "warning",
|
|
1655
|
-
contentType: type.id,
|
|
1656
|
-
enSlug,
|
|
1657
|
-
locale,
|
|
1658
|
-
field: "en_hash",
|
|
1659
|
-
message: "Translation is stale (en_hash mismatch)"
|
|
1660
|
-
});
|
|
1661
|
-
recordRevision(config, {
|
|
1662
|
-
contentType: type.id,
|
|
1663
|
-
enSlug,
|
|
1664
|
-
locale,
|
|
1665
|
-
revisionKind: "en_edit_detected",
|
|
1666
|
-
enHash: currentEnHash,
|
|
1667
|
-
body: row.body
|
|
1668
|
-
});
|
|
1669
|
-
}
|
|
1670
1730
|
const localeFm = JSON.parse(row.frontmatter_json);
|
|
1671
1731
|
for (const issue of validateLocaleBuiltinFields(localeFm)) {
|
|
1672
1732
|
issues.push({
|
|
@@ -1678,6 +1738,15 @@ function validateProject(config) {
|
|
|
1678
1738
|
message: issue.message
|
|
1679
1739
|
});
|
|
1680
1740
|
}
|
|
1741
|
+
for (const issue of validateDocumentAssets(config, {
|
|
1742
|
+
contentType: type.id,
|
|
1743
|
+
enSlug,
|
|
1744
|
+
locale,
|
|
1745
|
+
frontmatter: localeFm,
|
|
1746
|
+
body: row.body
|
|
1747
|
+
})) {
|
|
1748
|
+
issues.push(issue);
|
|
1749
|
+
}
|
|
1681
1750
|
}
|
|
1682
1751
|
}
|
|
1683
1752
|
try {
|
|
@@ -1748,6 +1817,21 @@ function validateProject(config) {
|
|
|
1748
1817
|
|
|
1749
1818
|
// src/translate/worklist.ts
|
|
1750
1819
|
init_cjs_shims();
|
|
1820
|
+
|
|
1821
|
+
// src/hash/page-hash.ts
|
|
1822
|
+
init_cjs_shims();
|
|
1823
|
+
function sha256(input) {
|
|
1824
|
+
return crypto.createHash("sha256").update(input, "utf8").digest("hex");
|
|
1825
|
+
}
|
|
1826
|
+
function computePageEnHash(translatableFrontmatter, body) {
|
|
1827
|
+
const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
|
|
1828
|
+
return sha256(payload);
|
|
1829
|
+
}
|
|
1830
|
+
function computeBodyHash(body) {
|
|
1831
|
+
return sha256(body);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// src/translate/worklist.ts
|
|
1751
1835
|
init_sqlite();
|
|
1752
1836
|
function listEnSlugs3(rootDir, contentDir) {
|
|
1753
1837
|
const dir = path3__default.default.join(rootDir, contentDir);
|
|
@@ -1793,6 +1877,10 @@ function buildWorklist(config, options = {}) {
|
|
|
1793
1877
|
}
|
|
1794
1878
|
}
|
|
1795
1879
|
db.close();
|
|
1880
|
+
const strategy = options.strategy ?? "all";
|
|
1881
|
+
if (strategy === "missing-only") {
|
|
1882
|
+
return items.filter((item) => item.reason === "missing");
|
|
1883
|
+
}
|
|
1796
1884
|
return items;
|
|
1797
1885
|
}
|
|
1798
1886
|
function resolveLocalesFromPreset(config, preset, explicitLocales) {
|
|
@@ -1805,11 +1893,59 @@ function resolveLocalesFromPreset(config, preset, explicitLocales) {
|
|
|
1805
1893
|
|
|
1806
1894
|
// src/translate/page-translator.ts
|
|
1807
1895
|
init_cjs_shims();
|
|
1896
|
+
|
|
1897
|
+
// src/history/record-revision.ts
|
|
1898
|
+
init_cjs_shims();
|
|
1899
|
+
init_sqlite();
|
|
1900
|
+
function recordRevision(config, input) {
|
|
1901
|
+
const db = openStore(config, "readwrite");
|
|
1902
|
+
const id = appendRevision(db, {
|
|
1903
|
+
contentType: input.contentType,
|
|
1904
|
+
enSlug: input.enSlug,
|
|
1905
|
+
locale: input.locale,
|
|
1906
|
+
revisionKind: input.revisionKind,
|
|
1907
|
+
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
|
|
1914
|
+
});
|
|
1915
|
+
db.close();
|
|
1916
|
+
return id;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// src/translate/page-translator.ts
|
|
1808
1920
|
init_sqlite();
|
|
1809
1921
|
|
|
1810
1922
|
// src/translate/gemini-client.ts
|
|
1811
1923
|
init_cjs_shims();
|
|
1812
|
-
|
|
1924
|
+
|
|
1925
|
+
// src/translate/gemini-models.ts
|
|
1926
|
+
init_cjs_shims();
|
|
1927
|
+
var GEMINI_MODEL_IDS = {
|
|
1928
|
+
"gemini-2.5-pro": "gemini-2.5-pro",
|
|
1929
|
+
"gemini-3.1-pro": "gemini-3.1-pro-preview"
|
|
1930
|
+
};
|
|
1931
|
+
var DEFAULT_GEMINI_MODEL = "gemini-3.1-pro";
|
|
1932
|
+
function stripModelsPrefix(model) {
|
|
1933
|
+
return model.replace(/^models\//, "");
|
|
1934
|
+
}
|
|
1935
|
+
function resolveGeminiModelId(model) {
|
|
1936
|
+
const name = stripModelsPrefix(model);
|
|
1937
|
+
return GEMINI_MODEL_IDS[name] ?? name;
|
|
1938
|
+
}
|
|
1939
|
+
function normalizeGeminiDisplayName(model) {
|
|
1940
|
+
const name = stripModelsPrefix(model);
|
|
1941
|
+
if (name in GEMINI_MODEL_IDS) return name;
|
|
1942
|
+
const alias = Object.entries(GEMINI_MODEL_IDS).find(([, apiId]) => apiId === name);
|
|
1943
|
+
if (alias) return alias[0];
|
|
1944
|
+
return name;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// src/translate/gemini-client.ts
|
|
1948
|
+
var DEFAULT_MODEL = DEFAULT_GEMINI_MODEL;
|
|
1813
1949
|
function extractJson(text) {
|
|
1814
1950
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
1815
1951
|
if (fenced?.[1]) return fenced[1].trim();
|
|
@@ -1823,13 +1959,17 @@ async function translatePageWithGemini(input) {
|
|
|
1823
1959
|
if (!apiKey) {
|
|
1824
1960
|
throw new Error("GEMINI_API_KEY is required for scribe translate");
|
|
1825
1961
|
}
|
|
1826
|
-
const
|
|
1962
|
+
const displayModel = normalizeGeminiDisplayName(
|
|
1963
|
+
input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL
|
|
1964
|
+
);
|
|
1965
|
+
const apiModel = resolveGeminiModelId(displayModel);
|
|
1827
1966
|
const ai = new genai.GoogleGenAI({ apiKey });
|
|
1828
1967
|
const response = await ai.models.generateContent({
|
|
1829
|
-
model,
|
|
1968
|
+
model: apiModel,
|
|
1830
1969
|
contents: input.prompt,
|
|
1831
1970
|
config: {
|
|
1832
|
-
responseMimeType: "application/json"
|
|
1971
|
+
responseMimeType: "application/json",
|
|
1972
|
+
...input.responseSchema ? { responseSchema: input.responseSchema } : {}
|
|
1833
1973
|
}
|
|
1834
1974
|
});
|
|
1835
1975
|
const raw = response.text ?? "";
|
|
@@ -1837,7 +1977,50 @@ async function translatePageWithGemini(input) {
|
|
|
1837
1977
|
if (!parsed.frontmatter || typeof parsed.body !== "string") {
|
|
1838
1978
|
throw new Error("Gemini response missing frontmatter/body");
|
|
1839
1979
|
}
|
|
1840
|
-
|
|
1980
|
+
const usageMetadata = response.usageMetadata;
|
|
1981
|
+
const usage = {
|
|
1982
|
+
inputTokens: usageMetadata?.promptTokenCount ?? 0,
|
|
1983
|
+
outputTokens: usageMetadata?.candidatesTokenCount ?? 0,
|
|
1984
|
+
totalTokens: usageMetadata?.totalTokenCount ?? 0
|
|
1985
|
+
};
|
|
1986
|
+
return { model: displayModel, raw, parsed, usage };
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// src/translate/gemini-pricing.ts
|
|
1990
|
+
init_cjs_shims();
|
|
1991
|
+
var CONTEXT_TIER_TOKENS = 2e5;
|
|
1992
|
+
var GEMINI_PRICING_USD = {
|
|
1993
|
+
"gemini-3.1-pro": { input: [2, 4], output: [12, 18] }
|
|
1994
|
+
};
|
|
1995
|
+
function normalizeModelId(model) {
|
|
1996
|
+
return model.replace(/^models\//, "").toLowerCase();
|
|
1997
|
+
}
|
|
1998
|
+
function tierRate(tokens, tiers) {
|
|
1999
|
+
return tokens <= CONTEXT_TIER_TOKENS ? tiers[0] : tiers[1];
|
|
2000
|
+
}
|
|
2001
|
+
function resolveModelPricing(model) {
|
|
2002
|
+
const id = normalizeModelId(model);
|
|
2003
|
+
if (GEMINI_PRICING_USD[id]) return GEMINI_PRICING_USD[id];
|
|
2004
|
+
const match = Object.entries(GEMINI_PRICING_USD).find(([key]) => id.includes(key));
|
|
2005
|
+
return match?.[1];
|
|
2006
|
+
}
|
|
2007
|
+
function estimateTranslationCostUsd(model, inputTokens, outputTokens) {
|
|
2008
|
+
const pricing = resolveModelPricing(model);
|
|
2009
|
+
if (!pricing) return void 0;
|
|
2010
|
+
const inputRate = tierRate(inputTokens, pricing.input);
|
|
2011
|
+
const outputRate = tierRate(outputTokens, pricing.output);
|
|
2012
|
+
return inputTokens / 1e6 * inputRate + outputTokens / 1e6 * outputRate;
|
|
2013
|
+
}
|
|
2014
|
+
function formatTokenCount(tokens) {
|
|
2015
|
+
if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(2)}M`;
|
|
2016
|
+
if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`;
|
|
2017
|
+
return String(tokens);
|
|
2018
|
+
}
|
|
2019
|
+
function formatUsd(amount) {
|
|
2020
|
+
if (amount === void 0) return "n/a";
|
|
2021
|
+
if (amount >= 1) return `$${amount.toFixed(2)}`;
|
|
2022
|
+
if (amount >= 0.01) return `$${amount.toFixed(3)}`;
|
|
2023
|
+
return `$${amount.toFixed(4)}`;
|
|
1841
2024
|
}
|
|
1842
2025
|
|
|
1843
2026
|
// src/translate/prompts/translation-prompt.ts
|
|
@@ -1874,6 +2057,7 @@ function buildPageTranslationPrompt(input) {
|
|
|
1874
2057
|
prompt,
|
|
1875
2058
|
"",
|
|
1876
2059
|
...input.resolved.context ? ["## Context", input.resolved.context, ""] : [],
|
|
2060
|
+
...input.contextLabel ? [`Document: ${input.contextLabel}`, ""] : [],
|
|
1877
2061
|
"## Rules",
|
|
1878
2062
|
...input.resolved.rules.map((rule) => `- ${rule}`),
|
|
1879
2063
|
"",
|
|
@@ -1881,10 +2065,6 @@ function buildPageTranslationPrompt(input) {
|
|
|
1881
2065
|
"Return ONLY valid JSON with keys:",
|
|
1882
2066
|
input.slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).",
|
|
1883
2067
|
"",
|
|
1884
|
-
"## EN metadata",
|
|
1885
|
-
`title: ${input.enTitle}`,
|
|
1886
|
-
`description: ${input.enDescription}`,
|
|
1887
|
-
"",
|
|
1888
2068
|
"## EN translatable frontmatter (JSON)",
|
|
1889
2069
|
JSON.stringify(input.translatableFrontmatter, null, 2),
|
|
1890
2070
|
"",
|
|
@@ -1894,6 +2074,74 @@ function buildPageTranslationPrompt(input) {
|
|
|
1894
2074
|
return lines.join("\n");
|
|
1895
2075
|
}
|
|
1896
2076
|
|
|
2077
|
+
// src/translate/response-schema.ts
|
|
2078
|
+
init_cjs_shims();
|
|
2079
|
+
function getObjectShape(schema) {
|
|
2080
|
+
const base = peelOptionalWrappers(schema);
|
|
2081
|
+
if (base instanceof Object && "shape" in base) {
|
|
2082
|
+
return base.shape;
|
|
2083
|
+
}
|
|
2084
|
+
return null;
|
|
2085
|
+
}
|
|
2086
|
+
function getArraySchema(schema) {
|
|
2087
|
+
const base = peelOptionalWrappers(schema);
|
|
2088
|
+
if (base instanceof Object && "element" in base && base._def?.type === "array") {
|
|
2089
|
+
return base;
|
|
2090
|
+
}
|
|
2091
|
+
return null;
|
|
2092
|
+
}
|
|
2093
|
+
function extractTranslatableFromStructural(schema) {
|
|
2094
|
+
const arraySchema = getArraySchema(schema);
|
|
2095
|
+
if (arraySchema) {
|
|
2096
|
+
const inner = buildTranslatableSubschema(arraySchema.element);
|
|
2097
|
+
if (inner) return zod.z.array(inner);
|
|
2098
|
+
return null;
|
|
2099
|
+
}
|
|
2100
|
+
if (getObjectShape(schema)) {
|
|
2101
|
+
return buildTranslatableSubschema(schema);
|
|
2102
|
+
}
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
function buildTranslatableSubschema(schema) {
|
|
2106
|
+
const shape = getObjectShape(schema);
|
|
2107
|
+
if (!shape) return null;
|
|
2108
|
+
const out = {};
|
|
2109
|
+
for (const [key, child] of Object.entries(shape)) {
|
|
2110
|
+
const childSchema = child;
|
|
2111
|
+
const kind = getFieldKind(childSchema);
|
|
2112
|
+
if (kind === "translatable") {
|
|
2113
|
+
out[key] = peelOptionalWrappers(childSchema);
|
|
2114
|
+
} else if (kind === "structural") {
|
|
2115
|
+
const nested = extractTranslatableFromStructural(childSchema);
|
|
2116
|
+
if (nested) out[key] = nested;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
if (Object.keys(out).length === 0) return null;
|
|
2120
|
+
return zod.z.object(out);
|
|
2121
|
+
}
|
|
2122
|
+
function buildGeminiResponseSchema(schema, slugStrategy) {
|
|
2123
|
+
try {
|
|
2124
|
+
const translatable = buildTranslatableSubschema(schema);
|
|
2125
|
+
if (!translatable) return null;
|
|
2126
|
+
const responseShape = {
|
|
2127
|
+
frontmatter: translatable,
|
|
2128
|
+
body: zod.z.string()
|
|
2129
|
+
};
|
|
2130
|
+
if (slugStrategy === "localized") {
|
|
2131
|
+
responseShape.slug = zod.z.string();
|
|
2132
|
+
}
|
|
2133
|
+
const jsonSchema = zod.z.toJSONSchema(zod.z.object(responseShape), {
|
|
2134
|
+
target: "draft-2020-12",
|
|
2135
|
+
unrepresentable: "any",
|
|
2136
|
+
io: "output"
|
|
2137
|
+
});
|
|
2138
|
+
delete jsonSchema.$schema;
|
|
2139
|
+
return jsonSchema;
|
|
2140
|
+
} catch {
|
|
2141
|
+
return null;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
1897
2145
|
// src/translate/resolve-translate-config.ts
|
|
1898
2146
|
init_cjs_shims();
|
|
1899
2147
|
function slugStrategyRules(slugStrategy, preserveTerms) {
|
|
@@ -1934,86 +2182,313 @@ function resolveTranslateConfig(config, type) {
|
|
|
1934
2182
|
return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
|
|
1935
2183
|
}
|
|
1936
2184
|
|
|
2185
|
+
// src/translate/validate-translation.ts
|
|
2186
|
+
init_cjs_shims();
|
|
2187
|
+
function formatZodIssues(error) {
|
|
2188
|
+
return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
2189
|
+
}
|
|
2190
|
+
function sanitizeTranslatedFrontmatter(rawFrontmatter, schema) {
|
|
2191
|
+
return pickTranslatable(rawFrontmatter, schema);
|
|
2192
|
+
}
|
|
2193
|
+
function validateTranslatedFrontmatter(enDoc, localeFrontmatter, typeSchema) {
|
|
2194
|
+
const frontmatter = sanitizeTranslatedFrontmatter(localeFrontmatter, typeSchema);
|
|
2195
|
+
const merged = mergeStructuralOntoLocale(
|
|
2196
|
+
frontmatter,
|
|
2197
|
+
enDoc.frontmatter,
|
|
2198
|
+
typeSchema
|
|
2199
|
+
);
|
|
2200
|
+
const parsed = typeSchema.safeParse(merged);
|
|
2201
|
+
if (!parsed.success) {
|
|
2202
|
+
return { ok: false, error: formatZodIssues(parsed.error) };
|
|
2203
|
+
}
|
|
2204
|
+
return { ok: true, frontmatter };
|
|
2205
|
+
}
|
|
2206
|
+
|
|
1937
2207
|
// src/translate/page-translator.ts
|
|
2208
|
+
function summarizeResults(results, durationMs) {
|
|
2209
|
+
return results.reduce(
|
|
2210
|
+
(totals, result) => {
|
|
2211
|
+
if (result.failed) totals.failed += 1;
|
|
2212
|
+
else if (result.skipped) totals.skipped += 1;
|
|
2213
|
+
else totals.translated += 1;
|
|
2214
|
+
totals.inputTokens += result.usage?.inputTokens ?? 0;
|
|
2215
|
+
totals.outputTokens += result.usage?.outputTokens ?? 0;
|
|
2216
|
+
totals.estimatedCostUsd += result.estimatedCostUsd ?? 0;
|
|
2217
|
+
return totals;
|
|
2218
|
+
},
|
|
2219
|
+
{
|
|
2220
|
+
translated: 0,
|
|
2221
|
+
skipped: 0,
|
|
2222
|
+
failed: 0,
|
|
2223
|
+
inputTokens: 0,
|
|
2224
|
+
outputTokens: 0,
|
|
2225
|
+
estimatedCostUsd: 0,
|
|
2226
|
+
durationMs
|
|
2227
|
+
}
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
function formatTranslateError(error) {
|
|
2231
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2232
|
+
try {
|
|
2233
|
+
const parsed = JSON.parse(message);
|
|
2234
|
+
if (parsed.error?.message) return parsed.error.message;
|
|
2235
|
+
} catch {
|
|
2236
|
+
}
|
|
2237
|
+
const embedded = message.match(/"message"\s*:\s*"((?:\\.|[^"\\])*)"/);
|
|
2238
|
+
if (embedded?.[1]) {
|
|
2239
|
+
return embedded[1].replace(/\\"/g, '"').replace(/\\n/g, "\n");
|
|
2240
|
+
}
|
|
2241
|
+
return message.length > 160 ? `${message.slice(0, 157)}\u2026` : message;
|
|
2242
|
+
}
|
|
2243
|
+
async function runPool(items, concurrency, worker) {
|
|
2244
|
+
if (items.length === 0) return;
|
|
2245
|
+
let nextIndex = 0;
|
|
2246
|
+
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
2247
|
+
while (nextIndex < items.length) {
|
|
2248
|
+
const index = nextIndex++;
|
|
2249
|
+
await worker(items[index], index);
|
|
2250
|
+
}
|
|
2251
|
+
});
|
|
2252
|
+
await Promise.all(runners);
|
|
2253
|
+
}
|
|
2254
|
+
function resolveContextLabel(enDoc, enSlug) {
|
|
2255
|
+
const fm = enDoc.frontmatter;
|
|
2256
|
+
for (const key of ["title", "name", "h1"]) {
|
|
2257
|
+
const value = fm[key];
|
|
2258
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
2259
|
+
}
|
|
2260
|
+
return enSlug;
|
|
2261
|
+
}
|
|
1938
2262
|
async function translatePage(config, item, options = {}) {
|
|
1939
|
-
const
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2263
|
+
const startedAt = Date.now();
|
|
2264
|
+
const base = {
|
|
2265
|
+
contentType: item.contentType,
|
|
2266
|
+
enSlug: item.enSlug,
|
|
2267
|
+
locale: item.locale
|
|
2268
|
+
};
|
|
2269
|
+
try {
|
|
2270
|
+
const type = config.types.find((t) => t.id === item.contentType);
|
|
2271
|
+
if (!type) throw new Error(`Unknown content type ${item.contentType}`);
|
|
2272
|
+
const enDoc = readEnDocument(config, type, item.enSlug);
|
|
2273
|
+
if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
|
|
2274
|
+
const payload = getTranslatablePayload(enDoc, type);
|
|
2275
|
+
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
2276
|
+
const db = openStore(config, "readonly");
|
|
2277
|
+
const existing = getTranslation(db, type.id, item.enSlug, item.locale);
|
|
2278
|
+
db.close();
|
|
2279
|
+
if (!options.force && existing && existing.en_hash === currentEnHash) {
|
|
2280
|
+
return {
|
|
2281
|
+
...base,
|
|
2282
|
+
skipped: true,
|
|
2283
|
+
reason: "fresh",
|
|
2284
|
+
durationMs: Date.now() - startedAt
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
const resolvedTranslate = resolveTranslateConfig(config, type);
|
|
2288
|
+
const model = options.model ?? resolvedTranslate.model;
|
|
2289
|
+
const prompt = buildPageTranslationPrompt({
|
|
2290
|
+
resolved: resolvedTranslate,
|
|
2291
|
+
targetLocale: item.locale,
|
|
2292
|
+
contextLabel: resolveContextLabel(enDoc, item.enSlug),
|
|
2293
|
+
translatableFrontmatter: payload.frontmatter,
|
|
2294
|
+
enBody: payload.body,
|
|
2295
|
+
slugStrategy: type.slugStrategy
|
|
2296
|
+
});
|
|
2297
|
+
if (options.dryRun) {
|
|
2298
|
+
return {
|
|
2299
|
+
...base,
|
|
2300
|
+
skipped: false,
|
|
2301
|
+
model,
|
|
2302
|
+
durationMs: Date.now() - startedAt
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
const responseSchema = buildGeminiResponseSchema(type.schema, type.slugStrategy);
|
|
2306
|
+
const result = await translatePageWithGemini({
|
|
2307
|
+
prompt,
|
|
2308
|
+
model,
|
|
2309
|
+
responseSchema: responseSchema ?? void 0
|
|
2310
|
+
});
|
|
2311
|
+
const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
|
|
2312
|
+
const validated = validateTranslatedFrontmatter(enDoc, result.parsed.frontmatter, type.schema);
|
|
2313
|
+
if (!validated.ok) {
|
|
2314
|
+
throw new Error(`Translation validation failed: ${validated.error}`);
|
|
2315
|
+
}
|
|
2316
|
+
const writeDb = openStore(config, "readwrite");
|
|
2317
|
+
upsertTranslation(writeDb, {
|
|
2318
|
+
contentType: type.id,
|
|
1951
2319
|
enSlug: item.enSlug,
|
|
1952
2320
|
locale: item.locale,
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
translatableFrontmatter: payload.frontmatter,
|
|
1964
|
-
enBody: payload.body,
|
|
1965
|
-
slugStrategy: type.slugStrategy
|
|
1966
|
-
});
|
|
1967
|
-
if (options.dryRun) {
|
|
1968
|
-
return {
|
|
1969
|
-
contentType: item.contentType,
|
|
2321
|
+
slug,
|
|
2322
|
+
frontmatter: validated.frontmatter,
|
|
2323
|
+
body: result.parsed.body,
|
|
2324
|
+
enHash: currentEnHash,
|
|
2325
|
+
translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2326
|
+
model: result.model
|
|
2327
|
+
});
|
|
2328
|
+
writeDb.close();
|
|
2329
|
+
recordRevision(config, {
|
|
2330
|
+
contentType: type.id,
|
|
1970
2331
|
enSlug: item.enSlug,
|
|
1971
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
|
+
const estimatedCostUsd = estimateTranslationCostUsd(
|
|
2340
|
+
normalizeGeminiDisplayName(result.model),
|
|
2341
|
+
result.usage.inputTokens,
|
|
2342
|
+
result.usage.outputTokens
|
|
2343
|
+
);
|
|
2344
|
+
return {
|
|
2345
|
+
...base,
|
|
2346
|
+
skipped: false,
|
|
2347
|
+
model: result.model,
|
|
2348
|
+
usage: result.usage,
|
|
2349
|
+
estimatedCostUsd,
|
|
2350
|
+
durationMs: Date.now() - startedAt
|
|
2351
|
+
};
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
return {
|
|
2354
|
+
...base,
|
|
1972
2355
|
skipped: false,
|
|
1973
|
-
|
|
2356
|
+
failed: true,
|
|
2357
|
+
error: formatTranslateError(error),
|
|
2358
|
+
durationMs: Date.now() - startedAt
|
|
1974
2359
|
};
|
|
1975
2360
|
}
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
model: result.model
|
|
2361
|
+
}
|
|
2362
|
+
function labelForItem(item) {
|
|
2363
|
+
return `${item.contentType}/${item.enSlug}@${item.locale}`;
|
|
2364
|
+
}
|
|
2365
|
+
async function translateWorklist(config, items, options = {}) {
|
|
2366
|
+
const concurrency = Math.max(1, options.concurrency ?? 3);
|
|
2367
|
+
const startedAt = Date.now();
|
|
2368
|
+
const results = new Array(items.length);
|
|
2369
|
+
const active = /* @__PURE__ */ new Set();
|
|
2370
|
+
options.onProgress?.({
|
|
2371
|
+
type: "start",
|
|
2372
|
+
total: items.length,
|
|
2373
|
+
concurrency,
|
|
2374
|
+
dryRun: Boolean(options.dryRun),
|
|
2375
|
+
model: options.model
|
|
1992
2376
|
});
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
model: result.model
|
|
2377
|
+
await runPool(items, concurrency, async (item, index) => {
|
|
2378
|
+
const label = labelForItem(item);
|
|
2379
|
+
active.add(label);
|
|
2380
|
+
options.onProgress?.({ type: "item-start", item, active: [...active] });
|
|
2381
|
+
const result = await translatePage(config, item, options);
|
|
2382
|
+
results[index] = result;
|
|
2383
|
+
active.delete(label);
|
|
2384
|
+
options.onProgress?.({ type: "item-done", result });
|
|
2002
2385
|
});
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
locale: item.locale,
|
|
2007
|
-
skipped: false,
|
|
2008
|
-
model: result.model
|
|
2009
|
-
};
|
|
2386
|
+
const totals = summarizeResults(results, Date.now() - startedAt);
|
|
2387
|
+
options.onProgress?.({ type: "done", results, totals });
|
|
2388
|
+
return results;
|
|
2010
2389
|
}
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2390
|
+
|
|
2391
|
+
// src/export/build-static-raw-exports.ts
|
|
2392
|
+
init_cjs_shims();
|
|
2393
|
+
|
|
2394
|
+
// src/loader/parse-mdx.ts
|
|
2395
|
+
init_cjs_shims();
|
|
2396
|
+
function serializeMdx(frontmatter, content) {
|
|
2397
|
+
return matter__default.default.stringify(content, frontmatter);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// src/export/build-static-raw-exports.ts
|
|
2401
|
+
function normalizeExtension(ext) {
|
|
2402
|
+
return ext.startsWith(".") ? ext : `.${ext}`;
|
|
2403
|
+
}
|
|
2404
|
+
function exportDirSegment(pathTemplate) {
|
|
2405
|
+
const prefix = pathPrefix(pathTemplate);
|
|
2406
|
+
return prefix.replace(/^\/+|\/+$/g, "");
|
|
2407
|
+
}
|
|
2408
|
+
function getStaticExportRoots(project, options = {}) {
|
|
2409
|
+
const { config } = project;
|
|
2410
|
+
const locales = options.locales ?? config.locales;
|
|
2411
|
+
const typeFilter = options.types ? new Set(options.types) : null;
|
|
2412
|
+
const roots = /* @__PURE__ */ new Set();
|
|
2413
|
+
for (const type of project.listTypes()) {
|
|
2414
|
+
if (!isRoutableType(type.config)) continue;
|
|
2415
|
+
if (typeFilter && !typeFilter.has(type.id)) continue;
|
|
2416
|
+
const segment = exportDirSegment(type.config.path);
|
|
2417
|
+
roots.add(`${segment}/`);
|
|
2418
|
+
for (const locale of locales) {
|
|
2419
|
+
if (locale === config.defaultLocale) continue;
|
|
2420
|
+
roots.add(`${locale}/${segment}/`);
|
|
2421
|
+
}
|
|
2015
2422
|
}
|
|
2016
|
-
return
|
|
2423
|
+
return [...roots].sort();
|
|
2424
|
+
}
|
|
2425
|
+
function buildStaticRawExports(project, options = {}) {
|
|
2426
|
+
const { config } = project;
|
|
2427
|
+
const extension = normalizeExtension(options.extension ?? ".mdx");
|
|
2428
|
+
const locales = options.locales ?? config.locales;
|
|
2429
|
+
const excludeRedirected = options.excludeRedirected ?? true;
|
|
2430
|
+
const excludeNoindex = options.excludeNoindex ?? false;
|
|
2431
|
+
const typeFilter = options.types ? new Set(options.types) : null;
|
|
2432
|
+
const out = [];
|
|
2433
|
+
for (const type of project.listTypes()) {
|
|
2434
|
+
if (!isRoutableType(type.config)) continue;
|
|
2435
|
+
if (typeFilter && !typeFilter.has(type.id)) continue;
|
|
2436
|
+
const pathTemplate = type.config.path;
|
|
2437
|
+
const all = type.load();
|
|
2438
|
+
const enIdx = all.get(config.defaultLocale);
|
|
2439
|
+
if (!enIdx) continue;
|
|
2440
|
+
for (const locale of locales) {
|
|
2441
|
+
for (const enDoc of enIdx.bySlug.values()) {
|
|
2442
|
+
const resolved = type.resolve(enDoc.slug, locale);
|
|
2443
|
+
if (!resolved.document) continue;
|
|
2444
|
+
if (excludeRedirected && resolved.shouldRedirectTo) continue;
|
|
2445
|
+
if (excludeNoindex && resolved.document.noindex) continue;
|
|
2446
|
+
const doc = resolved.document;
|
|
2447
|
+
const slugWithExt = `${doc.slug}${extension}`;
|
|
2448
|
+
const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
|
|
2449
|
+
out.push({
|
|
2450
|
+
relativePath: urlPath.slice(1),
|
|
2451
|
+
urlPath,
|
|
2452
|
+
locale,
|
|
2453
|
+
typeId: type.id,
|
|
2454
|
+
enSlug: doc.enSlug,
|
|
2455
|
+
source: serializeMdx(doc.frontmatter, doc.content)
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return out;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/export/write-static-raw-exports.ts
|
|
2464
|
+
init_cjs_shims();
|
|
2465
|
+
function rmDirIfExists(dir) {
|
|
2466
|
+
if (fs2__default.default.existsSync(dir)) {
|
|
2467
|
+
fs2__default.default.rmSync(dir, { recursive: true, force: true });
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
function writeStaticRawExports(project, options = {}) {
|
|
2471
|
+
const outDir = path3__default.default.resolve(options.outDir ?? "public");
|
|
2472
|
+
const typeFilter = options.types;
|
|
2473
|
+
for (const root of getStaticExportRoots(project, {
|
|
2474
|
+
types: typeFilter,
|
|
2475
|
+
locales: options.locales
|
|
2476
|
+
})) {
|
|
2477
|
+
rmDirIfExists(path3__default.default.join(outDir, root));
|
|
2478
|
+
}
|
|
2479
|
+
const exports = buildStaticRawExports(project, options);
|
|
2480
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2481
|
+
for (const item of exports) {
|
|
2482
|
+
const filePath = path3__default.default.join(outDir, item.relativePath);
|
|
2483
|
+
fs2__default.default.mkdirSync(path3__default.default.dirname(filePath), { recursive: true });
|
|
2484
|
+
fs2__default.default.writeFileSync(filePath, item.source, "utf8");
|
|
2485
|
+
const key = `${item.typeId}/${item.locale}`;
|
|
2486
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
2487
|
+
}
|
|
2488
|
+
for (const [key, count] of [...counts.entries()].sort()) {
|
|
2489
|
+
console.log(` ${key}: ${count}`);
|
|
2490
|
+
}
|
|
2491
|
+
return { exports, written: exports.length };
|
|
2017
2492
|
}
|
|
2018
2493
|
|
|
2019
2494
|
// src/config/load-env.ts
|
|
@@ -2034,99 +2509,464 @@ function loadEnvFromCwd(cwd) {
|
|
|
2034
2509
|
// studio/server.ts
|
|
2035
2510
|
init_cjs_shims();
|
|
2036
2511
|
init_sqlite();
|
|
2037
|
-
function
|
|
2512
|
+
function escapeHtml(value) {
|
|
2513
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2514
|
+
}
|
|
2515
|
+
function encodePathSegment(value) {
|
|
2516
|
+
return encodeURIComponent(value);
|
|
2517
|
+
}
|
|
2518
|
+
function statusDot(status) {
|
|
2519
|
+
const labels = {
|
|
2520
|
+
source: "en",
|
|
2521
|
+
"up-to-date": "ok",
|
|
2522
|
+
stale: "stale",
|
|
2523
|
+
missing: "\u2014"
|
|
2524
|
+
};
|
|
2525
|
+
return `<span class="status" title="${status}"><span class="dot dot-${status}"></span>${labels[status]}</span>`;
|
|
2526
|
+
}
|
|
2527
|
+
function documentStatus(config, db, type, enSlug, locale) {
|
|
2528
|
+
if (locale === config.defaultLocale) {
|
|
2529
|
+
return { status: "source" };
|
|
2530
|
+
}
|
|
2531
|
+
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
2532
|
+
if (!enDoc) {
|
|
2533
|
+
return { status: "missing" };
|
|
2534
|
+
}
|
|
2535
|
+
const payload = getTranslatablePayload(enDoc, type.config);
|
|
2536
|
+
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
2537
|
+
const row = getTranslation(db, type.id, enSlug, locale);
|
|
2538
|
+
if (!row) {
|
|
2539
|
+
return { status: "missing", currentEnHash };
|
|
2540
|
+
}
|
|
2541
|
+
if (row.en_hash !== currentEnHash) {
|
|
2542
|
+
return { status: "stale", currentEnHash, storedEnHash: row.en_hash };
|
|
2543
|
+
}
|
|
2544
|
+
return { status: "up-to-date", currentEnHash, storedEnHash: row.en_hash };
|
|
2545
|
+
}
|
|
2546
|
+
function flattenFrontmatter(data, prefix = "") {
|
|
2547
|
+
const rows = [];
|
|
2548
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2549
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
2550
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
2551
|
+
rows.push(...flattenFrontmatter(value, fullKey));
|
|
2552
|
+
} else {
|
|
2553
|
+
const display = typeof value === "string" ? value : value === void 0 ? "" : JSON.stringify(value, null, 2);
|
|
2554
|
+
rows.push({ key: fullKey, value: display });
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
return rows;
|
|
2558
|
+
}
|
|
2559
|
+
function translatableKeySet(schema) {
|
|
2560
|
+
return new Set(
|
|
2561
|
+
introspectSchema(schema).filter((field2) => field2.kind === "translatable").map((field2) => field2.path.join("."))
|
|
2562
|
+
);
|
|
2563
|
+
}
|
|
2564
|
+
function renderFrontmatterTable(frontmatter, schema) {
|
|
2565
|
+
const translatable = translatableKeySet(schema);
|
|
2566
|
+
const rows = flattenFrontmatter(frontmatter).map((row) => {
|
|
2567
|
+
const flag = translatable.has(row.key) ? `<span class="flag t" title="Translatable">T</span>` : `<span class="flag s" title="Structural">S</span>`;
|
|
2568
|
+
return `<tr>
|
|
2569
|
+
<td class="k">${flag}${escapeHtml(row.key)}</td>
|
|
2570
|
+
<td class="v">${escapeHtml(row.value)}</td>
|
|
2571
|
+
</tr>`;
|
|
2572
|
+
}).join("");
|
|
2573
|
+
return `<table class="kv">
|
|
2574
|
+
<tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
|
|
2575
|
+
</table>`;
|
|
2576
|
+
}
|
|
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>`
|
|
2605
|
+
).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>`;
|
|
2609
|
+
}
|
|
2610
|
+
function renderLayout(title, body, project, options = {}) {
|
|
2611
|
+
const typeLinks = project.listTypes().map((type) => {
|
|
2612
|
+
const active = type.id === options.activeTypeId ? " active" : "";
|
|
2613
|
+
return `<a class="tree-item${active}" href="/type/${encodePathSegment(type.id)}">
|
|
2614
|
+
<span class="tree-label">${escapeHtml(type.config.label)}</span>
|
|
2615
|
+
<span class="tree-meta">${escapeHtml(type.id)}</span>
|
|
2616
|
+
</a>`;
|
|
2617
|
+
}).join("");
|
|
2038
2618
|
return `<!doctype html>
|
|
2039
2619
|
<html lang="en">
|
|
2040
2620
|
<head>
|
|
2041
2621
|
<meta charset="utf-8" />
|
|
2042
2622
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2043
|
-
<title>${title} \u2014 Scribe
|
|
2623
|
+
<title>${escapeHtml(title)} \u2014 Scribe</title>
|
|
2044
2624
|
<style>
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2625
|
+
:root {
|
|
2626
|
+
--bg: #1e1e1e;
|
|
2627
|
+
--sidebar: #252526;
|
|
2628
|
+
--bar: #333333;
|
|
2629
|
+
--panel: #1e1e1e;
|
|
2630
|
+
--border: #3c3c3c;
|
|
2631
|
+
--text: #cccccc;
|
|
2632
|
+
--dim: #858585;
|
|
2633
|
+
--accent: #3794ff;
|
|
2634
|
+
--hover: #2a2d2e;
|
|
2635
|
+
--active: #37373d;
|
|
2636
|
+
--ok: #89d185;
|
|
2637
|
+
--stale: #cca700;
|
|
2638
|
+
--missing: #f48771;
|
|
2639
|
+
--source: #75beff;
|
|
2640
|
+
--mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
|
|
2641
|
+
--ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2642
|
+
--fs: 13px;
|
|
2643
|
+
--fs-sm: 11px;
|
|
2644
|
+
}
|
|
2645
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2646
|
+
body { font: var(--fs)/1.4 var(--ui); background: var(--bg); color: var(--text); }
|
|
2647
|
+
a { color: var(--accent); text-decoration: none; }
|
|
2648
|
+
a:hover { text-decoration: underline; }
|
|
2649
|
+
.app { display: flex; height: 100vh; overflow: hidden; }
|
|
2650
|
+
|
|
2651
|
+
/* activity bar */
|
|
2652
|
+
.actbar {
|
|
2653
|
+
width: 48px; flex-shrink: 0; background: var(--bar);
|
|
2654
|
+
display: flex; flex-direction: column; align-items: center;
|
|
2655
|
+
padding: 8px 0; gap: 4px; border-right: 1px solid var(--border);
|
|
2656
|
+
}
|
|
2657
|
+
.actbar a {
|
|
2658
|
+
width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
|
|
2659
|
+
color: var(--dim); font-size: 18px; text-decoration: none; position: relative;
|
|
2660
|
+
}
|
|
2661
|
+
.actbar a:hover { color: var(--text); }
|
|
2662
|
+
.actbar a.active { color: var(--text); }
|
|
2663
|
+
.actbar a.active::before {
|
|
2664
|
+
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px;
|
|
2665
|
+
width: 2px; background: var(--accent);
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/* sidebar */
|
|
2669
|
+
.sidebar {
|
|
2670
|
+
width: 200px; flex-shrink: 0; background: var(--sidebar);
|
|
2671
|
+
border-right: 1px solid var(--border); display: flex; flex-direction: column;
|
|
2672
|
+
overflow: hidden;
|
|
2673
|
+
}
|
|
2674
|
+
.sidebar-head {
|
|
2675
|
+
padding: 8px 12px; font-size: var(--fs-sm); font-weight: 600;
|
|
2676
|
+
text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
|
|
2677
|
+
}
|
|
2678
|
+
.sidebar-body { flex: 1; overflow-y: auto; padding: 2px 0; }
|
|
2679
|
+
.tree-item {
|
|
2680
|
+
display: flex; flex-direction: column; padding: 3px 12px 3px 20px;
|
|
2681
|
+
color: var(--text); text-decoration: none; line-height: 1.3;
|
|
2682
|
+
}
|
|
2683
|
+
.tree-item:hover { background: var(--hover); text-decoration: none; }
|
|
2684
|
+
.tree-item.active { background: var(--active); }
|
|
2685
|
+
.tree-label { font-size: var(--fs); }
|
|
2686
|
+
.tree-meta { font-size: var(--fs-sm); color: var(--dim); }
|
|
2687
|
+
|
|
2688
|
+
/* main */
|
|
2689
|
+
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
2690
|
+
.toolbar {
|
|
2691
|
+
display: flex; align-items: center; gap: 6px; padding: 0 12px;
|
|
2692
|
+
height: 35px; background: var(--sidebar); border-bottom: 1px solid var(--border);
|
|
2693
|
+
font-size: var(--fs-sm); color: var(--dim); flex-shrink: 0; overflow: hidden;
|
|
2694
|
+
}
|
|
2695
|
+
.toolbar a { color: var(--dim); }
|
|
2696
|
+
.toolbar a:hover { color: var(--text); }
|
|
2697
|
+
.toolbar .sep { color: var(--border); }
|
|
2698
|
+
.content { flex: 1; overflow-y: auto; padding: 0; }
|
|
2699
|
+
|
|
2700
|
+
/* tabs (locale switcher) */
|
|
2701
|
+
.tabs {
|
|
2702
|
+
display: flex; background: var(--sidebar); border-bottom: 1px solid var(--border);
|
|
2703
|
+
overflow-x: auto; flex-shrink: 0;
|
|
2704
|
+
}
|
|
2705
|
+
.tab {
|
|
2706
|
+
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
|
2707
|
+
font-size: var(--fs-sm); color: var(--dim); border-right: 1px solid var(--border);
|
|
2708
|
+
text-decoration: none; white-space: nowrap;
|
|
2709
|
+
}
|
|
2710
|
+
.tab:hover { background: var(--hover); color: var(--text); text-decoration: none; }
|
|
2711
|
+
.tab.active {
|
|
2712
|
+
background: var(--bg); color: var(--text);
|
|
2713
|
+
border-bottom: 1px solid var(--bg); margin-bottom: -1px;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
/* status dots */
|
|
2717
|
+
.status { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); color: var(--dim); }
|
|
2718
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
2719
|
+
.dot-source { background: var(--source); }
|
|
2720
|
+
.dot-up-to-date { background: var(--ok); }
|
|
2721
|
+
.dot-stale { background: var(--stale); }
|
|
2722
|
+
.dot-missing { background: var(--missing); opacity: 0.7; }
|
|
2723
|
+
|
|
2724
|
+
/* sections */
|
|
2725
|
+
.section { border-bottom: 1px solid var(--border); }
|
|
2726
|
+
.section-head {
|
|
2727
|
+
padding: 4px 12px; font-size: var(--fs-sm); font-weight: 600;
|
|
2728
|
+
text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
|
|
2729
|
+
background: var(--sidebar);
|
|
2730
|
+
}
|
|
2731
|
+
.section-body { padding: 0; }
|
|
2732
|
+
|
|
2733
|
+
/* tables */
|
|
2734
|
+
table { border-collapse: collapse; width: 100%; font-size: var(--fs); }
|
|
2735
|
+
.data th, .data td { padding: 2px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
2736
|
+
.data th { font-size: var(--fs-sm); font-weight: 600; color: var(--dim); background: var(--sidebar); height: 22px; }
|
|
2737
|
+
.data tr:hover td { background: var(--hover); }
|
|
2738
|
+
.data .mono { font-family: var(--mono); font-size: var(--fs-sm); }
|
|
2739
|
+
|
|
2740
|
+
/* key-value (frontmatter) */
|
|
2741
|
+
.kv td { padding: 1px 12px; font-family: var(--mono); font-size: var(--fs-sm); vertical-align: top; border-bottom: 1px solid var(--border); }
|
|
2742
|
+
.kv .k { width: 160px; color: #9cdcfe; white-space: nowrap; }
|
|
2743
|
+
.kv .v { color: #ce9178; white-space: pre-wrap; word-break: break-word; }
|
|
2744
|
+
.flag { font-size: 9px; font-weight: 700; margin-right: 4px; opacity: 0.5; }
|
|
2745
|
+
.flag.t { color: var(--ok); }
|
|
2746
|
+
.flag.s { color: var(--dim); }
|
|
2747
|
+
|
|
2748
|
+
/* code block */
|
|
2749
|
+
.code {
|
|
2750
|
+
margin: 0; padding: 8px 12px; font: var(--fs-sm)/1.5 var(--mono);
|
|
2751
|
+
white-space: pre-wrap; word-break: break-word; color: var(--text);
|
|
2752
|
+
background: var(--bg); border: none;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/* meta row */
|
|
2756
|
+
.meta { display: flex; flex-wrap: wrap; font-size: var(--fs-sm); border-bottom: 1px solid var(--border); padding: 4px 0; }
|
|
2757
|
+
.meta dt { padding: 0 4px 0 12px; color: var(--dim); }
|
|
2758
|
+
.meta dt::after { content: ":"; }
|
|
2759
|
+
.meta dd { padding: 0 12px 0 0; font-family: var(--mono); }
|
|
2760
|
+
|
|
2761
|
+
/* misc */
|
|
2762
|
+
.dim { color: var(--dim); }
|
|
2763
|
+
.page-title { padding: 8px 12px 4px; font-size: 14px; font-weight: 400; }
|
|
2764
|
+
.page-sub { padding: 0 12px 8px; font-size: var(--fs-sm); color: var(--dim); }
|
|
2765
|
+
details summary { cursor: pointer; color: var(--accent); font-size: var(--fs-sm); }
|
|
2766
|
+
details[open] summary { margin-bottom: 4px; }
|
|
2767
|
+
.tag { font-size: var(--fs-sm); color: var(--dim); margin-left: 6px; }
|
|
2768
|
+
.tag-warn { color: var(--stale); }
|
|
2769
|
+
.tag-err { color: var(--missing); }
|
|
2056
2770
|
</style>
|
|
2057
2771
|
</head>
|
|
2058
2772
|
<body>
|
|
2059
|
-
<
|
|
2060
|
-
<
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2773
|
+
<div class="app">
|
|
2774
|
+
<nav class="actbar">
|
|
2775
|
+
<a href="/" title="Overview">\u2302</a>
|
|
2776
|
+
<a href="/staleness" title="Staleness">\u26A0</a>
|
|
2777
|
+
</nav>
|
|
2778
|
+
<aside class="sidebar">
|
|
2779
|
+
<div class="sidebar-head">Types</div>
|
|
2780
|
+
<div class="sidebar-body">${typeLinks || `<span class="dim" style="padding:8px 12px">\u2014</span>`}</div>
|
|
2781
|
+
</aside>
|
|
2782
|
+
<div class="main">
|
|
2783
|
+
<div class="content">${body}</div>
|
|
2784
|
+
</div>
|
|
2785
|
+
</div>
|
|
2066
2786
|
</body>
|
|
2067
2787
|
</html>`;
|
|
2068
2788
|
}
|
|
2789
|
+
function docTitleFromFrontmatter(frontmatter, enSlug) {
|
|
2790
|
+
const title = frontmatter.title;
|
|
2791
|
+
if (typeof title === "string" && title.trim()) return title;
|
|
2792
|
+
return enSlug;
|
|
2793
|
+
}
|
|
2069
2794
|
async function startStudio(project, options = {}) {
|
|
2070
2795
|
const app = new hono.Hono();
|
|
2071
2796
|
const config = project.config;
|
|
2072
2797
|
app.get("/", (c) => {
|
|
2073
|
-
const
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2798
|
+
const db = openStore(config, "readonly");
|
|
2799
|
+
const worklist = buildWorklist(config);
|
|
2800
|
+
const rows = project.listTypes().map((type) => {
|
|
2801
|
+
const enCount = type.list().length;
|
|
2802
|
+
const localeCells = config.locales.filter((locale) => locale !== config.defaultLocale).map((locale) => {
|
|
2803
|
+
const translated = listTranslationsForLocale(db, type.id, locale).length;
|
|
2804
|
+
const stale = worklist.filter(
|
|
2805
|
+
(item) => item.contentType === type.id && item.locale === locale && item.reason === "stale"
|
|
2806
|
+
).length;
|
|
2807
|
+
const missing = worklist.filter(
|
|
2808
|
+
(item) => item.contentType === type.id && item.locale === locale && item.reason === "missing"
|
|
2809
|
+
).length;
|
|
2810
|
+
const tags = [
|
|
2811
|
+
stale ? `<span class="tag tag-warn">${stale}s</span>` : "",
|
|
2812
|
+
missing ? `<span class="tag tag-err">${missing}m</span>` : ""
|
|
2813
|
+
].join("");
|
|
2814
|
+
return `<td>${translated}${tags}</td>`;
|
|
2077
2815
|
}).join("");
|
|
2078
|
-
return `<
|
|
2816
|
+
return `<tr>
|
|
2817
|
+
<td><a href="/type/${encodePathSegment(type.id)}">${escapeHtml(type.config.label)}</a></td>
|
|
2818
|
+
<td class="dim">${escapeHtml(type.id)}</td>
|
|
2819
|
+
<td>${enCount}</td>
|
|
2820
|
+
${localeCells}
|
|
2821
|
+
</tr>`;
|
|
2079
2822
|
});
|
|
2080
|
-
|
|
2823
|
+
db.close();
|
|
2824
|
+
const localeHeaders = config.locales.filter((l) => l !== config.defaultLocale).map((l) => `<th>${escapeHtml(l)}</th>`).join("");
|
|
2825
|
+
const html = `<div class="toolbar">Overview</div>
|
|
2826
|
+
<table class="data">
|
|
2827
|
+
<thead><tr><th>Type</th><th>ID</th><th>EN</th>${localeHeaders}</tr></thead>
|
|
2828
|
+
<tbody>${rows.join("")}</tbody>
|
|
2829
|
+
</table>`;
|
|
2830
|
+
return c.html(renderLayout("Overview", html, project));
|
|
2081
2831
|
});
|
|
2082
|
-
app.get("/
|
|
2083
|
-
const
|
|
2084
|
-
const
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2832
|
+
app.get("/type/:id", (c) => {
|
|
2833
|
+
const typeId = c.req.param("id");
|
|
2834
|
+
const type = project.getType(typeId);
|
|
2835
|
+
if (!type) {
|
|
2836
|
+
return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
|
|
2837
|
+
}
|
|
2838
|
+
const db = openStore(config, "readonly");
|
|
2839
|
+
const locales = config.locales;
|
|
2840
|
+
const headerCells = locales.map((locale) => `<th>${escapeHtml(locale)}</th>`).join("");
|
|
2841
|
+
const rows = type.list().map((doc) => {
|
|
2842
|
+
const title = docTitleFromFrontmatter(doc.frontmatter, doc.slug);
|
|
2843
|
+
const statusCells = locales.map((locale) => {
|
|
2844
|
+
const { status } = documentStatus(config, db, type, doc.slug, locale);
|
|
2845
|
+
return `<td>${statusDot(status)}</td>`;
|
|
2846
|
+
}).join("");
|
|
2847
|
+
return `<tr>
|
|
2848
|
+
<td class="mono"><a href="/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(doc.slug)}">${escapeHtml(doc.slug)}</a></td>
|
|
2849
|
+
<td>${escapeHtml(title)}</td>
|
|
2850
|
+
${statusCells}
|
|
2851
|
+
</tr>`;
|
|
2852
|
+
}).join("");
|
|
2853
|
+
db.close();
|
|
2854
|
+
const html = `<div class="toolbar">
|
|
2855
|
+
<a href="/">Overview</a><span class="sep">\u203A</span>${escapeHtml(type.config.label)}
|
|
2856
|
+
</div>
|
|
2857
|
+
<table class="data">
|
|
2858
|
+
<thead><tr><th>Slug</th><th>Title</th>${headerCells}</tr></thead>
|
|
2859
|
+
<tbody>${rows || `<tr><td colspan="${2 + locales.length}" class="dim">No documents</td></tr>`}</tbody>
|
|
2860
|
+
</table>`;
|
|
2861
|
+
return c.html(renderLayout(type.config.label, html, project, { activeTypeId: typeId }));
|
|
2090
2862
|
});
|
|
2091
|
-
app.get("/
|
|
2092
|
-
const typeId = c.req.
|
|
2093
|
-
const enSlug = c.req.
|
|
2094
|
-
|
|
2863
|
+
app.get("/type/:id/doc/:enSlug", (c) => {
|
|
2864
|
+
const typeId = c.req.param("id");
|
|
2865
|
+
const enSlug = c.req.param("enSlug");
|
|
2866
|
+
const locale = c.req.query("locale") ?? config.defaultLocale;
|
|
2867
|
+
const showRaw = c.req.query("raw") === "1";
|
|
2868
|
+
const type = project.getType(typeId);
|
|
2869
|
+
if (!type) {
|
|
2870
|
+
return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
|
|
2871
|
+
}
|
|
2095
2872
|
const db = openStore(config, "readonly");
|
|
2096
|
-
|
|
2097
|
-
if (
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2873
|
+
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
2874
|
+
if (!enDoc) {
|
|
2875
|
+
db.close();
|
|
2876
|
+
return c.html(
|
|
2877
|
+
renderLayout("Not found", `<div class="toolbar">Not found</div><p class="dim" style="padding:12px">${escapeHtml(enSlug)}</p>`, project, {
|
|
2878
|
+
activeTypeId: typeId
|
|
2879
|
+
}),
|
|
2880
|
+
404
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
const localeTabs = config.locales.map((loc) => {
|
|
2884
|
+
const { status } = documentStatus(config, db, type, enSlug, loc);
|
|
2885
|
+
const active = loc === locale ? " active" : "";
|
|
2886
|
+
const href = `/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(enSlug)}?locale=${encodePathSegment(loc)}`;
|
|
2887
|
+
return `<a class="tab${active}" href="${href}">${escapeHtml(loc)} ${statusDot(status)}</a>`;
|
|
2888
|
+
}).join("");
|
|
2889
|
+
let contentPanel = "";
|
|
2890
|
+
let metaPanel = "";
|
|
2891
|
+
let historyPanel = "";
|
|
2892
|
+
if (locale === config.defaultLocale) {
|
|
2893
|
+
contentPanel = `<div class="section">
|
|
2894
|
+
<div class="section-head">Frontmatter</div>
|
|
2895
|
+
<div class="section-body">${renderFrontmatterTable(enDoc.frontmatter, type.config.schema)}</div>
|
|
2896
|
+
<div class="section-head">Body</div>
|
|
2897
|
+
<pre class="code">${escapeHtml(enDoc.content)}</pre>
|
|
2898
|
+
</div>`;
|
|
2102
2899
|
} else {
|
|
2103
|
-
const
|
|
2104
|
-
|
|
2900
|
+
const translation = getTranslation(db, typeId, enSlug, locale);
|
|
2901
|
+
const { status, currentEnHash, storedEnHash } = documentStatus(config, db, type, enSlug, locale);
|
|
2902
|
+
if (translation) {
|
|
2903
|
+
const rawFrontmatter = JSON.parse(translation.frontmatter_json);
|
|
2904
|
+
const displayFrontmatter = showRaw ? rawFrontmatter : mergeStructuralOntoLocale(rawFrontmatter, enDoc.frontmatter, type.config.schema);
|
|
2905
|
+
const rawToggle = showRaw ? `<span class="dim"> \xB7 <a href="?locale=${encodePathSegment(locale)}">merged</a></span>` : `<span class="dim"> \xB7 <a href="?locale=${encodePathSegment(locale)}&raw=1">raw</a></span>`;
|
|
2906
|
+
contentPanel = `<div class="section">
|
|
2907
|
+
<div class="section-head">Frontmatter${rawToggle}</div>
|
|
2908
|
+
<div class="section-body">${renderFrontmatterTable(displayFrontmatter, type.config.schema)}</div>
|
|
2909
|
+
<div class="section-head">Body</div>
|
|
2910
|
+
<pre class="code">${escapeHtml(translation.body)}</pre>
|
|
2911
|
+
</div>`;
|
|
2912
|
+
metaPanel = `<dl class="meta">
|
|
2913
|
+
<dt>status</dt><dd>${statusDot(status)}</dd>
|
|
2914
|
+
<dt>model</dt><dd>${escapeHtml(translation.model)}</dd>
|
|
2915
|
+
<dt>translated</dt><dd>${escapeHtml(translation.translated_at.slice(0, 19))}</dd>
|
|
2916
|
+
<dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
|
|
2917
|
+
<dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
|
|
2918
|
+
</dl>`;
|
|
2919
|
+
const revisions = listRevisions(db, typeId, enSlug, locale);
|
|
2920
|
+
historyPanel = `<div class="section">
|
|
2921
|
+
<div class="section-head">History</div>
|
|
2922
|
+
<div class="section-body">${renderRevisionTimeline(revisions)}</div>
|
|
2923
|
+
</div>`;
|
|
2924
|
+
} else {
|
|
2925
|
+
contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
|
|
2926
|
+
metaPanel = `<dl class="meta"><dt>status</dt><dd>${statusDot(status)}</dd></dl>`;
|
|
2927
|
+
}
|
|
2105
2928
|
}
|
|
2106
2929
|
db.close();
|
|
2107
|
-
|
|
2930
|
+
const title = docTitleFromFrontmatter(enDoc.frontmatter, enSlug);
|
|
2931
|
+
const html = `<div class="toolbar">
|
|
2932
|
+
<a href="/">Overview</a><span class="sep">\u203A</span>
|
|
2933
|
+
<a href="/type/${encodePathSegment(typeId)}">${escapeHtml(type.config.label)}</a><span class="sep">\u203A</span>
|
|
2934
|
+
<span>${escapeHtml(enSlug)}</span>
|
|
2935
|
+
</div>
|
|
2936
|
+
<div class="tabs">${localeTabs}</div>
|
|
2937
|
+
${metaPanel}
|
|
2938
|
+
${contentPanel}
|
|
2939
|
+
${historyPanel}`;
|
|
2940
|
+
return c.html(renderLayout(title, html, project, { activeTypeId: typeId }));
|
|
2941
|
+
});
|
|
2942
|
+
app.get("/staleness", (c) => {
|
|
2943
|
+
const worklist = buildWorklist(config);
|
|
2944
|
+
const rows = worklist.slice(0, 500).map((item) => {
|
|
2945
|
+
const href = `/type/${encodePathSegment(item.contentType)}/doc/${encodePathSegment(item.enSlug)}?locale=${encodePathSegment(item.locale)}`;
|
|
2946
|
+
return `<tr>
|
|
2947
|
+
<td>${escapeHtml(item.contentType)}</td>
|
|
2948
|
+
<td class="mono"><a href="${href}">${escapeHtml(item.enSlug)}</a></td>
|
|
2949
|
+
<td>${escapeHtml(item.locale)}</td>
|
|
2950
|
+
<td>${statusDot(item.reason === "stale" ? "stale" : "missing")}</td>
|
|
2951
|
+
</tr>`;
|
|
2952
|
+
}).join("");
|
|
2953
|
+
const html = `<div class="toolbar">Staleness <span class="dim">\xB7 ${worklist.length} entries</span></div>
|
|
2954
|
+
<table class="data">
|
|
2955
|
+
<thead><tr><th>Type</th><th>Slug</th><th>Locale</th><th>Status</th></tr></thead>
|
|
2956
|
+
<tbody>${rows || `<tr><td colspan="4" class="dim">All up to date</td></tr>`}</tbody>
|
|
2957
|
+
</table>`;
|
|
2958
|
+
return c.html(renderLayout("Staleness", html, project));
|
|
2108
2959
|
});
|
|
2109
2960
|
app.get("/api/staleness-matrix", (c) => {
|
|
2961
|
+
const worklist = buildWorklist(config);
|
|
2110
2962
|
const matrix = {};
|
|
2111
2963
|
for (const type of project.listTypes()) {
|
|
2112
2964
|
matrix[type.id] = {};
|
|
2113
2965
|
for (const locale of config.locales) {
|
|
2114
2966
|
if (locale === config.defaultLocale) continue;
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
2119
|
-
if (!enDoc) continue;
|
|
2120
|
-
const payload = getTranslatablePayload(enDoc, type.config);
|
|
2121
|
-
const hash = computePageEnHash(payload.frontmatter, payload.body);
|
|
2122
|
-
const db = openStore(config, "readonly");
|
|
2123
|
-
const row = db.prepare(
|
|
2124
|
-
`SELECT en_hash FROM translations WHERE content_type = ? AND en_slug = ? AND locale = ?`
|
|
2125
|
-
).get(type.id, enSlug, locale);
|
|
2126
|
-
db.close();
|
|
2127
|
-
if (!row || row.en_hash !== hash) stale++;
|
|
2128
|
-
}
|
|
2129
|
-
matrix[type.id][locale] = stale;
|
|
2967
|
+
matrix[type.id][locale] = worklist.filter(
|
|
2968
|
+
(item) => item.contentType === type.id && item.locale === locale
|
|
2969
|
+
).length;
|
|
2130
2970
|
}
|
|
2131
2971
|
}
|
|
2132
2972
|
return c.json(matrix);
|
|
@@ -2138,6 +2978,294 @@ async function startStudio(project, options = {}) {
|
|
|
2138
2978
|
});
|
|
2139
2979
|
}
|
|
2140
2980
|
|
|
2981
|
+
// cli/prompt-translate.ts
|
|
2982
|
+
init_cjs_shims();
|
|
2983
|
+
|
|
2984
|
+
// ../../node_modules/@inquirer/core/dist/lib/errors.js
|
|
2985
|
+
init_cjs_shims();
|
|
2986
|
+
var CancelPromptError = class extends Error {
|
|
2987
|
+
constructor() {
|
|
2988
|
+
super(...arguments);
|
|
2989
|
+
__publicField(this, "name", "CancelPromptError");
|
|
2990
|
+
__publicField(this, "message", "Prompt was canceled");
|
|
2991
|
+
}
|
|
2992
|
+
};
|
|
2993
|
+
function isInteractive() {
|
|
2994
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
2995
|
+
}
|
|
2996
|
+
function nonDefaultLocales(config) {
|
|
2997
|
+
return config.locales.filter((locale) => locale !== config.defaultLocale);
|
|
2998
|
+
}
|
|
2999
|
+
async function runPrompt(prompt) {
|
|
3000
|
+
try {
|
|
3001
|
+
return await prompt;
|
|
3002
|
+
} catch (error) {
|
|
3003
|
+
if (error instanceof CancelPromptError) process.exit(0);
|
|
3004
|
+
throw error;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
async function promptContentType(config) {
|
|
3008
|
+
return runPrompt(
|
|
3009
|
+
prompts.select({
|
|
3010
|
+
message: "Content type to translate",
|
|
3011
|
+
choices: [
|
|
3012
|
+
{ name: "All content types", value: void 0 },
|
|
3013
|
+
...config.types.map((type) => ({ name: type.id, value: type.id }))
|
|
3014
|
+
]
|
|
3015
|
+
})
|
|
3016
|
+
);
|
|
3017
|
+
}
|
|
3018
|
+
async function promptLocalePreset(config) {
|
|
3019
|
+
const presets = Object.entries(config.localePresets ?? {}).filter(
|
|
3020
|
+
(entry) => Array.isArray(entry[1])
|
|
3021
|
+
);
|
|
3022
|
+
if (presets.length === 0) {
|
|
3023
|
+
const locales = nonDefaultLocales(config);
|
|
3024
|
+
if (locales.length <= 1) return {};
|
|
3025
|
+
const picked = await runPrompt(
|
|
3026
|
+
prompts.checkbox({
|
|
3027
|
+
message: "Locales to translate",
|
|
3028
|
+
choices: locales.map((locale) => ({ name: locale, value: locale, checked: true })),
|
|
3029
|
+
validate: (values) => values.length > 0 || "Pick at least one locale"
|
|
3030
|
+
})
|
|
3031
|
+
);
|
|
3032
|
+
return picked.length === locales.length ? {} : { locale: picked };
|
|
3033
|
+
}
|
|
3034
|
+
const choice = await runPrompt(
|
|
3035
|
+
prompts.select({
|
|
3036
|
+
message: "Locale preset",
|
|
3037
|
+
choices: [
|
|
3038
|
+
{ name: "All locales", value: void 0 },
|
|
3039
|
+
...presets.map(([name, locales]) => ({
|
|
3040
|
+
name: `${name} (${locales.join(", ")})`,
|
|
3041
|
+
value: name
|
|
3042
|
+
}))
|
|
3043
|
+
]
|
|
3044
|
+
})
|
|
3045
|
+
);
|
|
3046
|
+
return choice ? { preset: choice } : {};
|
|
3047
|
+
}
|
|
3048
|
+
async function promptStrategy() {
|
|
3049
|
+
return runPrompt(
|
|
3050
|
+
prompts.select({
|
|
3051
|
+
message: "Translation strategy",
|
|
3052
|
+
choices: [
|
|
3053
|
+
{ name: "Stale and missing", value: "all" },
|
|
3054
|
+
{ name: "Missing only (skip stale)", value: "missing-only" }
|
|
3055
|
+
],
|
|
3056
|
+
default: "all"
|
|
3057
|
+
})
|
|
3058
|
+
);
|
|
3059
|
+
}
|
|
3060
|
+
async function promptTranslateSelection(config, flags) {
|
|
3061
|
+
const selection = {
|
|
3062
|
+
contentType: flags.type,
|
|
3063
|
+
preset: flags.preset,
|
|
3064
|
+
locale: flags.locale,
|
|
3065
|
+
strategy: flags.strategy
|
|
3066
|
+
};
|
|
3067
|
+
if (!isInteractive()) return selection;
|
|
3068
|
+
if (!selection.contentType) {
|
|
3069
|
+
selection.contentType = await promptContentType(config);
|
|
3070
|
+
}
|
|
3071
|
+
if (!selection.preset && !selection.locale?.length) {
|
|
3072
|
+
Object.assign(selection, await promptLocalePreset(config));
|
|
3073
|
+
}
|
|
3074
|
+
if (!selection.strategy) {
|
|
3075
|
+
selection.strategy = await promptStrategy();
|
|
3076
|
+
}
|
|
3077
|
+
return selection;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// cli/translate-progress.ts
|
|
3081
|
+
init_cjs_shims();
|
|
3082
|
+
var dim = (text) => `\x1B[2m${text}\x1B[0m`;
|
|
3083
|
+
var green = (text) => `\x1B[32m${text}\x1B[0m`;
|
|
3084
|
+
var yellow = (text) => `\x1B[33m${text}\x1B[0m`;
|
|
3085
|
+
var red = (text) => `\x1B[31m${text}\x1B[0m`;
|
|
3086
|
+
var cyan = (text) => `\x1B[36m${text}\x1B[0m`;
|
|
3087
|
+
function isInteractive2() {
|
|
3088
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
3089
|
+
}
|
|
3090
|
+
function progressBar(ratio, width = 28) {
|
|
3091
|
+
const clamped = Math.max(0, Math.min(1, ratio));
|
|
3092
|
+
const filled = Math.round(clamped * width);
|
|
3093
|
+
return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
|
|
3094
|
+
}
|
|
3095
|
+
function labelForResult(result) {
|
|
3096
|
+
return `${result.contentType}/${result.enSlug}@${result.locale}`;
|
|
3097
|
+
}
|
|
3098
|
+
function statusForResult(result, dryRun) {
|
|
3099
|
+
if (result.failed) return red("failed");
|
|
3100
|
+
if (result.skipped) return dim("skipped");
|
|
3101
|
+
if (dryRun) return yellow("would translate");
|
|
3102
|
+
return green("translated");
|
|
3103
|
+
}
|
|
3104
|
+
function detailForResult(result) {
|
|
3105
|
+
const parts = [];
|
|
3106
|
+
if (result.durationMs !== void 0) parts.push(`${(result.durationMs / 1e3).toFixed(1)}s`);
|
|
3107
|
+
if (result.usage) {
|
|
3108
|
+
parts.push(`${formatTokenCount(result.usage.inputTokens)} in`);
|
|
3109
|
+
parts.push(`${formatTokenCount(result.usage.outputTokens)} out`);
|
|
3110
|
+
}
|
|
3111
|
+
if (result.estimatedCostUsd !== void 0) parts.push(formatUsd(result.estimatedCostUsd));
|
|
3112
|
+
if (result.error) parts.push(red(result.error));
|
|
3113
|
+
return parts.length > 0 ? dim(parts.join(" \xB7 ")) : "";
|
|
3114
|
+
}
|
|
3115
|
+
function formatSummaryLine(totals, dryRun) {
|
|
3116
|
+
const action = dryRun ? "Would translate" : "Translated";
|
|
3117
|
+
const parts = [
|
|
3118
|
+
`${action} ${totals.translated}`,
|
|
3119
|
+
`skipped ${totals.skipped}`
|
|
3120
|
+
];
|
|
3121
|
+
if (totals.failed > 0) parts.push(red(`failed ${totals.failed}`));
|
|
3122
|
+
parts.push(`${formatTokenCount(totals.inputTokens)} in / ${formatTokenCount(totals.outputTokens)} out`);
|
|
3123
|
+
parts.push(`${formatUsd(totals.estimatedCostUsd)} est.`);
|
|
3124
|
+
parts.push(`${(totals.durationMs / 1e3).toFixed(1)}s`);
|
|
3125
|
+
return parts.join(" \xB7 ");
|
|
3126
|
+
}
|
|
3127
|
+
function createTranslateProgressReporter(options = {}) {
|
|
3128
|
+
const enabled = options.enabled ?? isInteractive2();
|
|
3129
|
+
const dryRun = Boolean(options.dryRun);
|
|
3130
|
+
const recentLimit = options.recentLimit ?? 6;
|
|
3131
|
+
if (!enabled) {
|
|
3132
|
+
return {
|
|
3133
|
+
onEvent(event) {
|
|
3134
|
+
if (event.type === "item-done") {
|
|
3135
|
+
const label = labelForResult(event.result);
|
|
3136
|
+
const status = statusForResult(event.result, dryRun);
|
|
3137
|
+
const detail = detailForResult(event.result);
|
|
3138
|
+
console.log(`${label}: ${status}${detail ? ` (${detail.replace(/\x1b\[[0-9;]*m/g, "")})` : ""}`);
|
|
3139
|
+
if (event.result.failed && event.result.error) {
|
|
3140
|
+
console.error(event.result.error);
|
|
3141
|
+
}
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
if (event.type === "done") {
|
|
3145
|
+
console.log(formatSummaryLine(event.totals, dryRun));
|
|
3146
|
+
}
|
|
3147
|
+
},
|
|
3148
|
+
finish() {
|
|
3149
|
+
}
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
let total = 0;
|
|
3153
|
+
let concurrency = 1;
|
|
3154
|
+
let model;
|
|
3155
|
+
let done = 0;
|
|
3156
|
+
let inputTokens = 0;
|
|
3157
|
+
let outputTokens = 0;
|
|
3158
|
+
let estimatedCostUsd = 0;
|
|
3159
|
+
let active = [];
|
|
3160
|
+
const recent = [];
|
|
3161
|
+
let renderedLines = 0;
|
|
3162
|
+
let cursorHidden = false;
|
|
3163
|
+
function hideCursor() {
|
|
3164
|
+
if (!cursorHidden) {
|
|
3165
|
+
process.stdout.write("\x1B[?25l");
|
|
3166
|
+
cursorHidden = true;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
function showCursor() {
|
|
3170
|
+
if (cursorHidden) {
|
|
3171
|
+
process.stdout.write("\x1B[?25h");
|
|
3172
|
+
cursorHidden = false;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
function render() {
|
|
3176
|
+
hideCursor();
|
|
3177
|
+
if (renderedLines > 0) {
|
|
3178
|
+
process.stdout.write(`\x1B[${renderedLines}A`);
|
|
3179
|
+
}
|
|
3180
|
+
const lines = [];
|
|
3181
|
+
const title = dryRun ? "Dry run" : "Translating";
|
|
3182
|
+
lines.push(cyan(`${title} ${done}/${total}`) + dim(` \xB7 ${concurrency} parallel`) + (model ? dim(` \xB7 ${model}`) : ""));
|
|
3183
|
+
lines.push(progressBar(total === 0 ? 0 : done / total) + dim(` ${Math.round(total === 0 ? 0 : done / total * 100)}%`));
|
|
3184
|
+
lines.push(
|
|
3185
|
+
dim("Tokens ") + `${formatTokenCount(inputTokens)} in \xB7 ${formatTokenCount(outputTokens)} out` + dim(" \xB7 Cost ") + formatUsd(estimatedCostUsd) + dim(" est.")
|
|
3186
|
+
);
|
|
3187
|
+
if (active.length > 0) {
|
|
3188
|
+
lines.push(dim("Active ") + active.slice(0, 3).join(", ") + (active.length > 3 ? dim(` +${active.length - 3}`) : ""));
|
|
3189
|
+
} else if (done < total) {
|
|
3190
|
+
lines.push(dim("Active ") + "starting\u2026");
|
|
3191
|
+
} else {
|
|
3192
|
+
lines.push("");
|
|
3193
|
+
}
|
|
3194
|
+
lines.push("");
|
|
3195
|
+
if (recent.length === 0) {
|
|
3196
|
+
lines.push(dim("Waiting for first page\u2026"));
|
|
3197
|
+
} else {
|
|
3198
|
+
for (const result of recent) {
|
|
3199
|
+
const detail = detailForResult(result);
|
|
3200
|
+
lines.push(`${statusForResult(result, dryRun)} ${labelForResult(result)}${detail ? ` ${detail}` : ""}`);
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
for (const line of lines) {
|
|
3204
|
+
process.stdout.write("\x1B[2K" + line + "\n");
|
|
3205
|
+
}
|
|
3206
|
+
renderedLines = lines.length;
|
|
3207
|
+
}
|
|
3208
|
+
return {
|
|
3209
|
+
onEvent(event) {
|
|
3210
|
+
switch (event.type) {
|
|
3211
|
+
case "start":
|
|
3212
|
+
total = event.total;
|
|
3213
|
+
concurrency = event.concurrency;
|
|
3214
|
+
model = event.model;
|
|
3215
|
+
render();
|
|
3216
|
+
break;
|
|
3217
|
+
case "item-start":
|
|
3218
|
+
active = event.active;
|
|
3219
|
+
render();
|
|
3220
|
+
break;
|
|
3221
|
+
case "item-done": {
|
|
3222
|
+
done += 1;
|
|
3223
|
+
active = active.filter((label) => label !== labelForResult(event.result));
|
|
3224
|
+
inputTokens += event.result.usage?.inputTokens ?? 0;
|
|
3225
|
+
outputTokens += event.result.usage?.outputTokens ?? 0;
|
|
3226
|
+
estimatedCostUsd += event.result.estimatedCostUsd ?? 0;
|
|
3227
|
+
recent.unshift(event.result);
|
|
3228
|
+
recent.splice(recentLimit);
|
|
3229
|
+
render();
|
|
3230
|
+
break;
|
|
3231
|
+
}
|
|
3232
|
+
case "done":
|
|
3233
|
+
showCursor();
|
|
3234
|
+
if (renderedLines > 0) {
|
|
3235
|
+
process.stdout.write(`\x1B[${renderedLines}A`);
|
|
3236
|
+
}
|
|
3237
|
+
process.stdout.write("\x1B[2K" + green("Done") + " \xB7 " + formatSummaryLine(event.totals, dryRun) + "\n");
|
|
3238
|
+
renderedLines = 0;
|
|
3239
|
+
for (const result of event.results.filter((entry) => entry.failed)) {
|
|
3240
|
+
process.stdout.write("\x1B[2K" + red(`${labelForResult(result)}: ${result.error ?? "failed"}`) + "\n");
|
|
3241
|
+
}
|
|
3242
|
+
break;
|
|
3243
|
+
}
|
|
3244
|
+
},
|
|
3245
|
+
finish() {
|
|
3246
|
+
showCursor();
|
|
3247
|
+
}
|
|
3248
|
+
};
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
// src/version.ts
|
|
3252
|
+
init_cjs_shims();
|
|
3253
|
+
function readScribeVersion() {
|
|
3254
|
+
let dir = path3__default.default.dirname(url.fileURLToPath(importMetaUrl));
|
|
3255
|
+
for (; ; ) {
|
|
3256
|
+
const candidate = path3__default.default.join(dir, "package.json");
|
|
3257
|
+
if (fs2__default.default.existsSync(candidate)) {
|
|
3258
|
+
const pkg = JSON.parse(fs2__default.default.readFileSync(candidate, "utf8"));
|
|
3259
|
+
if (pkg.name === "scribe-cms" && pkg.version) return pkg.version;
|
|
3260
|
+
}
|
|
3261
|
+
const parent = path3__default.default.dirname(dir);
|
|
3262
|
+
if (parent === dir) break;
|
|
3263
|
+
dir = parent;
|
|
3264
|
+
}
|
|
3265
|
+
return "unknown";
|
|
3266
|
+
}
|
|
3267
|
+
var SCRIBE_VERSION = readScribeVersion();
|
|
3268
|
+
|
|
2141
3269
|
// cli/index.ts
|
|
2142
3270
|
function parseArgs(argv) {
|
|
2143
3271
|
const options = { cwd: process.cwd() };
|
|
@@ -2194,6 +3322,35 @@ function parseArgs(argv) {
|
|
|
2194
3322
|
i++;
|
|
2195
3323
|
continue;
|
|
2196
3324
|
}
|
|
3325
|
+
if (arg === "--concurrency") {
|
|
3326
|
+
options.concurrency = Number(argv[++i]);
|
|
3327
|
+
i++;
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
if (arg === "--no-progress") {
|
|
3331
|
+
options.noProgress = true;
|
|
3332
|
+
i++;
|
|
3333
|
+
continue;
|
|
3334
|
+
}
|
|
3335
|
+
if (arg === "--strategy") {
|
|
3336
|
+
const value = argv[++i];
|
|
3337
|
+
if (value !== "all" && value !== "missing-only") {
|
|
3338
|
+
throw new Error(`Unknown strategy: ${value} (expected all or missing-only)`);
|
|
3339
|
+
}
|
|
3340
|
+
options.strategy = value;
|
|
3341
|
+
i++;
|
|
3342
|
+
continue;
|
|
3343
|
+
}
|
|
3344
|
+
if (arg === "--out") {
|
|
3345
|
+
options.out = argv[++i];
|
|
3346
|
+
i++;
|
|
3347
|
+
continue;
|
|
3348
|
+
}
|
|
3349
|
+
if (arg === "--extension") {
|
|
3350
|
+
options.extension = argv[++i];
|
|
3351
|
+
i++;
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
2197
3354
|
if (arg.startsWith("--")) {
|
|
2198
3355
|
throw new Error(`Unknown flag: ${arg}`);
|
|
2199
3356
|
}
|
|
@@ -2211,7 +3368,13 @@ function loadProject(options) {
|
|
|
2211
3368
|
return createProject(config);
|
|
2212
3369
|
}
|
|
2213
3370
|
async function main() {
|
|
2214
|
-
const
|
|
3371
|
+
const argv = process.argv.slice(2);
|
|
3372
|
+
const first = argv[0];
|
|
3373
|
+
if (first === "--version" || first === "-V" || first === "version") {
|
|
3374
|
+
console.log(SCRIBE_VERSION);
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
const { command, options, rest } = parseArgs(argv);
|
|
2215
3378
|
loadEnvFromCwd(options.cwd);
|
|
2216
3379
|
if (command === "help" || command === "--help") {
|
|
2217
3380
|
console.log(`Usage: scribe <command>
|
|
@@ -2219,12 +3382,36 @@ async function main() {
|
|
|
2219
3382
|
Commands:
|
|
2220
3383
|
status Show EN docs + translation counts
|
|
2221
3384
|
validate Validate EN files and sqlite consistency
|
|
3385
|
+
export-static Write raw MDX files for static hosting
|
|
2222
3386
|
translate Translate stale/missing locale pages
|
|
2223
3387
|
history <type> <slug> Show revision timeline
|
|
2224
3388
|
studio Start read-only local studio
|
|
3389
|
+
version Print scribe-cms version
|
|
3390
|
+
|
|
3391
|
+
Export-static flags:
|
|
3392
|
+
--out <dir> Output directory (default: public)
|
|
3393
|
+
--extension <ext> File extension (default: mdx)
|
|
3394
|
+
--type <id> Comma-separated content types (default: all routable)
|
|
3395
|
+
--locale <code>... Locales to export (default: all configured)
|
|
3396
|
+
|
|
3397
|
+
Translate flags:
|
|
3398
|
+
--type <id> Content type (interactive picker in a TTY when omitted)
|
|
3399
|
+
--preset <name> Locale preset from config (interactive picker in a TTY)
|
|
3400
|
+
--locale <code>... Target locale(s); overrides --preset
|
|
3401
|
+
--slug <en-slug> Single English document
|
|
3402
|
+
--model <id> Gemini model override
|
|
3403
|
+
--concurrency <n> Parallel translations (default: 3)
|
|
3404
|
+
--dry-run List work without writing
|
|
3405
|
+
--force Re-translate even when hashes match
|
|
3406
|
+
--strategy <mode> all (default) or missing-only
|
|
3407
|
+
--no-progress Plain line logging instead of live progress
|
|
2225
3408
|
`);
|
|
2226
3409
|
return;
|
|
2227
3410
|
}
|
|
3411
|
+
if (command === "version") {
|
|
3412
|
+
console.log(SCRIBE_VERSION);
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
2228
3415
|
const project = loadProject(options);
|
|
2229
3416
|
const config = project.config;
|
|
2230
3417
|
switch (command) {
|
|
@@ -2251,24 +3438,49 @@ Commands:
|
|
|
2251
3438
|
if (!result.ok) process.exitCode = 1;
|
|
2252
3439
|
break;
|
|
2253
3440
|
}
|
|
3441
|
+
case "export-static": {
|
|
3442
|
+
const types = options.type?.split(",").map((t) => t.trim()).filter(Boolean);
|
|
3443
|
+
const { written } = writeStaticRawExports(project, {
|
|
3444
|
+
outDir: options.out,
|
|
3445
|
+
extension: options.extension ? `.${options.extension.replace(/^\./, "")}` : void 0,
|
|
3446
|
+
types,
|
|
3447
|
+
locales: options.locale
|
|
3448
|
+
});
|
|
3449
|
+
console.log(`Wrote ${written} static raw exports to ${options.out ?? "public"}`);
|
|
3450
|
+
break;
|
|
3451
|
+
}
|
|
2254
3452
|
case "translate": {
|
|
2255
|
-
const
|
|
3453
|
+
const selection = await promptTranslateSelection(config, {
|
|
3454
|
+
type: options.type,
|
|
3455
|
+
preset: options.preset,
|
|
3456
|
+
locale: options.locale,
|
|
3457
|
+
strategy: options.strategy
|
|
3458
|
+
});
|
|
3459
|
+
const locales = resolveLocalesFromPreset(config, selection.preset, selection.locale);
|
|
2256
3460
|
const worklist = buildWorklist(config, {
|
|
2257
|
-
contentType:
|
|
3461
|
+
contentType: selection.contentType,
|
|
2258
3462
|
locales,
|
|
2259
|
-
enSlug: options.slug
|
|
3463
|
+
enSlug: options.slug,
|
|
3464
|
+
strategy: selection.strategy ?? "all"
|
|
3465
|
+
});
|
|
3466
|
+
if (worklist.length === 0) {
|
|
3467
|
+
console.log("Nothing to translate.");
|
|
3468
|
+
break;
|
|
3469
|
+
}
|
|
3470
|
+
const reporter = createTranslateProgressReporter({
|
|
3471
|
+
enabled: !options.noProgress,
|
|
3472
|
+
dryRun: options.dryRun
|
|
2260
3473
|
});
|
|
2261
|
-
console.log(`Translating ${worklist.length} page(s)...`);
|
|
2262
3474
|
const results = await translateWorklist(config, worklist, {
|
|
2263
3475
|
model: options.model,
|
|
2264
3476
|
dryRun: options.dryRun,
|
|
2265
|
-
force: options.force
|
|
3477
|
+
force: options.force,
|
|
3478
|
+
concurrency: options.concurrency,
|
|
3479
|
+
onProgress: reporter.onEvent
|
|
2266
3480
|
});
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
);
|
|
2271
|
-
}
|
|
3481
|
+
reporter.finish();
|
|
3482
|
+
const failed = results.filter((result) => result.failed);
|
|
3483
|
+
if (failed.length > 0) process.exitCode = 1;
|
|
2272
3484
|
break;
|
|
2273
3485
|
}
|
|
2274
3486
|
case "history": {
|