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