scribe-cms 0.0.2 → 0.0.4
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 +1284 -171
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +1284 -171
- 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 +479 -101
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +475 -102
- package/dist/index.js.map +1 -1
- package/dist/runtime.cjs +12 -2
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +12 -2
- package/dist/runtime.js.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 +1 -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-project.d.ts.map +1 -1
- 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
|
@@ -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(path12, typeId) {
|
|
222
|
+
if (!path12.startsWith("/")) {
|
|
223
|
+
throw new Error(`Content type "${typeId}": path must start with / (got "${path12}")`);
|
|
195
224
|
}
|
|
196
|
-
const count = (
|
|
225
|
+
const count = (path12.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 "${path12}")`
|
|
200
229
|
);
|
|
201
230
|
}
|
|
202
231
|
}
|
|
@@ -324,10 +353,10 @@ function introspectSchema(schema, prefix = []) {
|
|
|
324
353
|
}
|
|
325
354
|
function extractByPaths(data, paths) {
|
|
326
355
|
const out = {};
|
|
327
|
-
for (const
|
|
328
|
-
const value = getAtPath(data,
|
|
356
|
+
for (const path12 of paths) {
|
|
357
|
+
const value = getAtPath(data, path12);
|
|
329
358
|
if (value !== void 0) {
|
|
330
|
-
setAtPath(out,
|
|
359
|
+
setAtPath(out, path12.filter((p) => p !== "*"), value);
|
|
331
360
|
}
|
|
332
361
|
}
|
|
333
362
|
return out;
|
|
@@ -345,13 +374,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
|
|
|
345
374
|
const translatable = pickTranslatable(localeData, schema);
|
|
346
375
|
return deepMerge(structural, translatable);
|
|
347
376
|
}
|
|
348
|
-
function getAtPath(obj,
|
|
377
|
+
function getAtPath(obj, path12) {
|
|
349
378
|
let current = obj;
|
|
350
|
-
for (const segment of
|
|
379
|
+
for (const segment of path12) {
|
|
351
380
|
if (segment === "*") {
|
|
352
381
|
if (!Array.isArray(current)) return void 0;
|
|
353
382
|
return current.map(
|
|
354
|
-
(item) => typeof item === "object" && item !== null ? getAtPath(item,
|
|
383
|
+
(item) => typeof item === "object" && item !== null ? getAtPath(item, path12.slice(path12.indexOf("*") + 1)) : void 0
|
|
355
384
|
);
|
|
356
385
|
}
|
|
357
386
|
if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
|
|
@@ -359,13 +388,13 @@ function getAtPath(obj, path11) {
|
|
|
359
388
|
}
|
|
360
389
|
return current;
|
|
361
390
|
}
|
|
362
|
-
function setAtPath(obj,
|
|
363
|
-
if (
|
|
364
|
-
if (
|
|
365
|
-
obj[
|
|
391
|
+
function setAtPath(obj, path12, value) {
|
|
392
|
+
if (path12.length === 0) return;
|
|
393
|
+
if (path12.length === 1) {
|
|
394
|
+
obj[path12[0]] = value;
|
|
366
395
|
return;
|
|
367
396
|
}
|
|
368
|
-
const [head, ...rest] =
|
|
397
|
+
const [head, ...rest] = path12;
|
|
369
398
|
if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
|
|
370
399
|
obj[head] = {};
|
|
371
400
|
}
|
|
@@ -604,14 +633,18 @@ function listTranslationsForEnSlug(db, contentType, enSlug) {
|
|
|
604
633
|
`SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
|
|
605
634
|
).all(contentType, enSlug);
|
|
606
635
|
}
|
|
636
|
+
function listTranslationsForLocale(db, contentType, locale) {
|
|
637
|
+
return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
|
|
638
|
+
}
|
|
607
639
|
function bulkLoadTranslations(db) {
|
|
608
640
|
return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
|
|
609
641
|
}
|
|
610
642
|
function appendRevision(db, input) {
|
|
611
643
|
const result = db.prepare(
|
|
612
644
|
`INSERT INTO revisions (
|
|
613
|
-
content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
|
|
614
|
-
|
|
645
|
+
content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
|
|
646
|
+
model, body_preview, frontmatter_json, body
|
|
647
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
615
648
|
).run(
|
|
616
649
|
input.contentType,
|
|
617
650
|
input.enSlug,
|
|
@@ -621,7 +654,9 @@ function appendRevision(db, input) {
|
|
|
621
654
|
input.bodyHash,
|
|
622
655
|
input.createdAt,
|
|
623
656
|
input.model ?? null,
|
|
624
|
-
input.bodyPreview ?? null
|
|
657
|
+
input.bodyPreview ?? null,
|
|
658
|
+
input.frontmatterJson ?? null,
|
|
659
|
+
input.body ?? null
|
|
625
660
|
);
|
|
626
661
|
return Number(result.lastInsertRowid);
|
|
627
662
|
}
|
|
@@ -1459,7 +1494,9 @@ function recordRevision(config, input) {
|
|
|
1459
1494
|
bodyHash: computeBodyHash(input.body),
|
|
1460
1495
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1461
1496
|
model: input.model,
|
|
1462
|
-
bodyPreview: input.body.slice(0, 200)
|
|
1497
|
+
bodyPreview: input.body.slice(0, 200),
|
|
1498
|
+
frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
|
|
1499
|
+
body: input.body
|
|
1463
1500
|
});
|
|
1464
1501
|
db.close();
|
|
1465
1502
|
return id;
|
|
@@ -1650,7 +1687,8 @@ function validateProject(config) {
|
|
|
1650
1687
|
locale,
|
|
1651
1688
|
revisionKind: "en_edit_detected",
|
|
1652
1689
|
enHash: currentEnHash,
|
|
1653
|
-
body: row.body
|
|
1690
|
+
body: row.body,
|
|
1691
|
+
frontmatter: JSON.parse(row.frontmatter_json)
|
|
1654
1692
|
});
|
|
1655
1693
|
}
|
|
1656
1694
|
const localeFm = JSON.parse(row.frontmatter_json);
|
|
@@ -1779,6 +1817,10 @@ function buildWorklist(config, options = {}) {
|
|
|
1779
1817
|
}
|
|
1780
1818
|
}
|
|
1781
1819
|
db.close();
|
|
1820
|
+
const strategy = options.strategy ?? "all";
|
|
1821
|
+
if (strategy === "missing-only") {
|
|
1822
|
+
return items.filter((item) => item.reason === "missing");
|
|
1823
|
+
}
|
|
1782
1824
|
return items;
|
|
1783
1825
|
}
|
|
1784
1826
|
function resolveLocalesFromPreset(config, preset, explicitLocales) {
|
|
@@ -1795,7 +1837,31 @@ init_sqlite();
|
|
|
1795
1837
|
|
|
1796
1838
|
// src/translate/gemini-client.ts
|
|
1797
1839
|
init_esm_shims();
|
|
1798
|
-
|
|
1840
|
+
|
|
1841
|
+
// src/translate/gemini-models.ts
|
|
1842
|
+
init_esm_shims();
|
|
1843
|
+
var GEMINI_MODEL_IDS = {
|
|
1844
|
+
"gemini-2.5-pro": "gemini-2.5-pro",
|
|
1845
|
+
"gemini-3.1-pro": "gemini-3.1-pro-preview"
|
|
1846
|
+
};
|
|
1847
|
+
var DEFAULT_GEMINI_MODEL = "gemini-3.1-pro";
|
|
1848
|
+
function stripModelsPrefix(model) {
|
|
1849
|
+
return model.replace(/^models\//, "");
|
|
1850
|
+
}
|
|
1851
|
+
function resolveGeminiModelId(model) {
|
|
1852
|
+
const name = stripModelsPrefix(model);
|
|
1853
|
+
return GEMINI_MODEL_IDS[name] ?? name;
|
|
1854
|
+
}
|
|
1855
|
+
function normalizeGeminiDisplayName(model) {
|
|
1856
|
+
const name = stripModelsPrefix(model);
|
|
1857
|
+
if (name in GEMINI_MODEL_IDS) return name;
|
|
1858
|
+
const alias = Object.entries(GEMINI_MODEL_IDS).find(([, apiId]) => apiId === name);
|
|
1859
|
+
if (alias) return alias[0];
|
|
1860
|
+
return name;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// src/translate/gemini-client.ts
|
|
1864
|
+
var DEFAULT_MODEL = DEFAULT_GEMINI_MODEL;
|
|
1799
1865
|
function extractJson(text) {
|
|
1800
1866
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
1801
1867
|
if (fenced?.[1]) return fenced[1].trim();
|
|
@@ -1809,13 +1875,17 @@ async function translatePageWithGemini(input) {
|
|
|
1809
1875
|
if (!apiKey) {
|
|
1810
1876
|
throw new Error("GEMINI_API_KEY is required for scribe translate");
|
|
1811
1877
|
}
|
|
1812
|
-
const
|
|
1878
|
+
const displayModel = normalizeGeminiDisplayName(
|
|
1879
|
+
input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL
|
|
1880
|
+
);
|
|
1881
|
+
const apiModel = resolveGeminiModelId(displayModel);
|
|
1813
1882
|
const ai = new GoogleGenAI({ apiKey });
|
|
1814
1883
|
const response = await ai.models.generateContent({
|
|
1815
|
-
model,
|
|
1884
|
+
model: apiModel,
|
|
1816
1885
|
contents: input.prompt,
|
|
1817
1886
|
config: {
|
|
1818
|
-
responseMimeType: "application/json"
|
|
1887
|
+
responseMimeType: "application/json",
|
|
1888
|
+
...input.responseSchema ? { responseSchema: input.responseSchema } : {}
|
|
1819
1889
|
}
|
|
1820
1890
|
});
|
|
1821
1891
|
const raw = response.text ?? "";
|
|
@@ -1823,7 +1893,50 @@ async function translatePageWithGemini(input) {
|
|
|
1823
1893
|
if (!parsed.frontmatter || typeof parsed.body !== "string") {
|
|
1824
1894
|
throw new Error("Gemini response missing frontmatter/body");
|
|
1825
1895
|
}
|
|
1826
|
-
|
|
1896
|
+
const usageMetadata = response.usageMetadata;
|
|
1897
|
+
const usage = {
|
|
1898
|
+
inputTokens: usageMetadata?.promptTokenCount ?? 0,
|
|
1899
|
+
outputTokens: usageMetadata?.candidatesTokenCount ?? 0,
|
|
1900
|
+
totalTokens: usageMetadata?.totalTokenCount ?? 0
|
|
1901
|
+
};
|
|
1902
|
+
return { model: displayModel, raw, parsed, usage };
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// src/translate/gemini-pricing.ts
|
|
1906
|
+
init_esm_shims();
|
|
1907
|
+
var CONTEXT_TIER_TOKENS = 2e5;
|
|
1908
|
+
var GEMINI_PRICING_USD = {
|
|
1909
|
+
"gemini-3.1-pro": { input: [2, 4], output: [12, 18] }
|
|
1910
|
+
};
|
|
1911
|
+
function normalizeModelId(model) {
|
|
1912
|
+
return model.replace(/^models\//, "").toLowerCase();
|
|
1913
|
+
}
|
|
1914
|
+
function tierRate(tokens, tiers) {
|
|
1915
|
+
return tokens <= CONTEXT_TIER_TOKENS ? tiers[0] : tiers[1];
|
|
1916
|
+
}
|
|
1917
|
+
function resolveModelPricing(model) {
|
|
1918
|
+
const id = normalizeModelId(model);
|
|
1919
|
+
if (GEMINI_PRICING_USD[id]) return GEMINI_PRICING_USD[id];
|
|
1920
|
+
const match = Object.entries(GEMINI_PRICING_USD).find(([key]) => id.includes(key));
|
|
1921
|
+
return match?.[1];
|
|
1922
|
+
}
|
|
1923
|
+
function estimateTranslationCostUsd(model, inputTokens, outputTokens) {
|
|
1924
|
+
const pricing = resolveModelPricing(model);
|
|
1925
|
+
if (!pricing) return void 0;
|
|
1926
|
+
const inputRate = tierRate(inputTokens, pricing.input);
|
|
1927
|
+
const outputRate = tierRate(outputTokens, pricing.output);
|
|
1928
|
+
return inputTokens / 1e6 * inputRate + outputTokens / 1e6 * outputRate;
|
|
1929
|
+
}
|
|
1930
|
+
function formatTokenCount(tokens) {
|
|
1931
|
+
if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(2)}M`;
|
|
1932
|
+
if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`;
|
|
1933
|
+
return String(tokens);
|
|
1934
|
+
}
|
|
1935
|
+
function formatUsd(amount) {
|
|
1936
|
+
if (amount === void 0) return "n/a";
|
|
1937
|
+
if (amount >= 1) return `$${amount.toFixed(2)}`;
|
|
1938
|
+
if (amount >= 0.01) return `$${amount.toFixed(3)}`;
|
|
1939
|
+
return `$${amount.toFixed(4)}`;
|
|
1827
1940
|
}
|
|
1828
1941
|
|
|
1829
1942
|
// src/translate/prompts/translation-prompt.ts
|
|
@@ -1860,6 +1973,7 @@ function buildPageTranslationPrompt(input) {
|
|
|
1860
1973
|
prompt,
|
|
1861
1974
|
"",
|
|
1862
1975
|
...input.resolved.context ? ["## Context", input.resolved.context, ""] : [],
|
|
1976
|
+
...input.contextLabel ? [`Document: ${input.contextLabel}`, ""] : [],
|
|
1863
1977
|
"## Rules",
|
|
1864
1978
|
...input.resolved.rules.map((rule) => `- ${rule}`),
|
|
1865
1979
|
"",
|
|
@@ -1867,10 +1981,6 @@ function buildPageTranslationPrompt(input) {
|
|
|
1867
1981
|
"Return ONLY valid JSON with keys:",
|
|
1868
1982
|
input.slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).",
|
|
1869
1983
|
"",
|
|
1870
|
-
"## EN metadata",
|
|
1871
|
-
`title: ${input.enTitle}`,
|
|
1872
|
-
`description: ${input.enDescription}`,
|
|
1873
|
-
"",
|
|
1874
1984
|
"## EN translatable frontmatter (JSON)",
|
|
1875
1985
|
JSON.stringify(input.translatableFrontmatter, null, 2),
|
|
1876
1986
|
"",
|
|
@@ -1880,6 +1990,74 @@ function buildPageTranslationPrompt(input) {
|
|
|
1880
1990
|
return lines.join("\n");
|
|
1881
1991
|
}
|
|
1882
1992
|
|
|
1993
|
+
// src/translate/response-schema.ts
|
|
1994
|
+
init_esm_shims();
|
|
1995
|
+
function getObjectShape(schema) {
|
|
1996
|
+
const base = peelOptionalWrappers(schema);
|
|
1997
|
+
if (base instanceof Object && "shape" in base) {
|
|
1998
|
+
return base.shape;
|
|
1999
|
+
}
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
function getArraySchema(schema) {
|
|
2003
|
+
const base = peelOptionalWrappers(schema);
|
|
2004
|
+
if (base instanceof Object && "element" in base && base._def?.type === "array") {
|
|
2005
|
+
return base;
|
|
2006
|
+
}
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
function extractTranslatableFromStructural(schema) {
|
|
2010
|
+
const arraySchema = getArraySchema(schema);
|
|
2011
|
+
if (arraySchema) {
|
|
2012
|
+
const inner = buildTranslatableSubschema(arraySchema.element);
|
|
2013
|
+
if (inner) return z.array(inner);
|
|
2014
|
+
return null;
|
|
2015
|
+
}
|
|
2016
|
+
if (getObjectShape(schema)) {
|
|
2017
|
+
return buildTranslatableSubschema(schema);
|
|
2018
|
+
}
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
function buildTranslatableSubschema(schema) {
|
|
2022
|
+
const shape = getObjectShape(schema);
|
|
2023
|
+
if (!shape) return null;
|
|
2024
|
+
const out = {};
|
|
2025
|
+
for (const [key, child] of Object.entries(shape)) {
|
|
2026
|
+
const childSchema = child;
|
|
2027
|
+
const kind = getFieldKind(childSchema);
|
|
2028
|
+
if (kind === "translatable") {
|
|
2029
|
+
out[key] = peelOptionalWrappers(childSchema);
|
|
2030
|
+
} else if (kind === "structural") {
|
|
2031
|
+
const nested = extractTranslatableFromStructural(childSchema);
|
|
2032
|
+
if (nested) out[key] = nested;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
if (Object.keys(out).length === 0) return null;
|
|
2036
|
+
return z.object(out);
|
|
2037
|
+
}
|
|
2038
|
+
function buildGeminiResponseSchema(schema, slugStrategy) {
|
|
2039
|
+
try {
|
|
2040
|
+
const translatable = buildTranslatableSubschema(schema);
|
|
2041
|
+
if (!translatable) return null;
|
|
2042
|
+
const responseShape = {
|
|
2043
|
+
frontmatter: translatable,
|
|
2044
|
+
body: z.string()
|
|
2045
|
+
};
|
|
2046
|
+
if (slugStrategy === "localized") {
|
|
2047
|
+
responseShape.slug = z.string();
|
|
2048
|
+
}
|
|
2049
|
+
const jsonSchema = z.toJSONSchema(z.object(responseShape), {
|
|
2050
|
+
target: "draft-2020-12",
|
|
2051
|
+
unrepresentable: "any",
|
|
2052
|
+
io: "output"
|
|
2053
|
+
});
|
|
2054
|
+
delete jsonSchema.$schema;
|
|
2055
|
+
return jsonSchema;
|
|
2056
|
+
} catch {
|
|
2057
|
+
return null;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1883
2061
|
// src/translate/resolve-translate-config.ts
|
|
1884
2062
|
init_esm_shims();
|
|
1885
2063
|
function slugStrategyRules(slugStrategy, preserveTerms) {
|
|
@@ -1920,86 +2098,313 @@ function resolveTranslateConfig(config, type) {
|
|
|
1920
2098
|
return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
|
|
1921
2099
|
}
|
|
1922
2100
|
|
|
2101
|
+
// src/translate/validate-translation.ts
|
|
2102
|
+
init_esm_shims();
|
|
2103
|
+
function formatZodIssues(error) {
|
|
2104
|
+
return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
2105
|
+
}
|
|
2106
|
+
function sanitizeTranslatedFrontmatter(rawFrontmatter, schema) {
|
|
2107
|
+
return pickTranslatable(rawFrontmatter, schema);
|
|
2108
|
+
}
|
|
2109
|
+
function validateTranslatedFrontmatter(enDoc, localeFrontmatter, typeSchema) {
|
|
2110
|
+
const frontmatter = sanitizeTranslatedFrontmatter(localeFrontmatter, typeSchema);
|
|
2111
|
+
const merged = mergeStructuralOntoLocale(
|
|
2112
|
+
frontmatter,
|
|
2113
|
+
enDoc.frontmatter,
|
|
2114
|
+
typeSchema
|
|
2115
|
+
);
|
|
2116
|
+
const parsed = typeSchema.safeParse(merged);
|
|
2117
|
+
if (!parsed.success) {
|
|
2118
|
+
return { ok: false, error: formatZodIssues(parsed.error) };
|
|
2119
|
+
}
|
|
2120
|
+
return { ok: true, frontmatter };
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1923
2123
|
// src/translate/page-translator.ts
|
|
2124
|
+
function summarizeResults(results, durationMs) {
|
|
2125
|
+
return results.reduce(
|
|
2126
|
+
(totals, result) => {
|
|
2127
|
+
if (result.failed) totals.failed += 1;
|
|
2128
|
+
else if (result.skipped) totals.skipped += 1;
|
|
2129
|
+
else totals.translated += 1;
|
|
2130
|
+
totals.inputTokens += result.usage?.inputTokens ?? 0;
|
|
2131
|
+
totals.outputTokens += result.usage?.outputTokens ?? 0;
|
|
2132
|
+
totals.estimatedCostUsd += result.estimatedCostUsd ?? 0;
|
|
2133
|
+
return totals;
|
|
2134
|
+
},
|
|
2135
|
+
{
|
|
2136
|
+
translated: 0,
|
|
2137
|
+
skipped: 0,
|
|
2138
|
+
failed: 0,
|
|
2139
|
+
inputTokens: 0,
|
|
2140
|
+
outputTokens: 0,
|
|
2141
|
+
estimatedCostUsd: 0,
|
|
2142
|
+
durationMs
|
|
2143
|
+
}
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
function formatTranslateError(error) {
|
|
2147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2148
|
+
try {
|
|
2149
|
+
const parsed = JSON.parse(message);
|
|
2150
|
+
if (parsed.error?.message) return parsed.error.message;
|
|
2151
|
+
} catch {
|
|
2152
|
+
}
|
|
2153
|
+
const embedded = message.match(/"message"\s*:\s*"((?:\\.|[^"\\])*)"/);
|
|
2154
|
+
if (embedded?.[1]) {
|
|
2155
|
+
return embedded[1].replace(/\\"/g, '"').replace(/\\n/g, "\n");
|
|
2156
|
+
}
|
|
2157
|
+
return message.length > 160 ? `${message.slice(0, 157)}\u2026` : message;
|
|
2158
|
+
}
|
|
2159
|
+
async function runPool(items, concurrency, worker) {
|
|
2160
|
+
if (items.length === 0) return;
|
|
2161
|
+
let nextIndex = 0;
|
|
2162
|
+
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
2163
|
+
while (nextIndex < items.length) {
|
|
2164
|
+
const index = nextIndex++;
|
|
2165
|
+
await worker(items[index], index);
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
await Promise.all(runners);
|
|
2169
|
+
}
|
|
2170
|
+
function resolveContextLabel(enDoc, enSlug) {
|
|
2171
|
+
const fm = enDoc.frontmatter;
|
|
2172
|
+
for (const key of ["title", "name", "h1"]) {
|
|
2173
|
+
const value = fm[key];
|
|
2174
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
2175
|
+
}
|
|
2176
|
+
return enSlug;
|
|
2177
|
+
}
|
|
1924
2178
|
async function translatePage(config, item, options = {}) {
|
|
1925
|
-
const
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2179
|
+
const startedAt = Date.now();
|
|
2180
|
+
const base = {
|
|
2181
|
+
contentType: item.contentType,
|
|
2182
|
+
enSlug: item.enSlug,
|
|
2183
|
+
locale: item.locale
|
|
2184
|
+
};
|
|
2185
|
+
try {
|
|
2186
|
+
const type = config.types.find((t) => t.id === item.contentType);
|
|
2187
|
+
if (!type) throw new Error(`Unknown content type ${item.contentType}`);
|
|
2188
|
+
const enDoc = readEnDocument(config, type, item.enSlug);
|
|
2189
|
+
if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
|
|
2190
|
+
const payload = getTranslatablePayload(enDoc, type);
|
|
2191
|
+
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
2192
|
+
const db = openStore(config, "readonly");
|
|
2193
|
+
const existing = getTranslation(db, type.id, item.enSlug, item.locale);
|
|
2194
|
+
db.close();
|
|
2195
|
+
if (!options.force && existing && existing.en_hash === currentEnHash) {
|
|
2196
|
+
return {
|
|
2197
|
+
...base,
|
|
2198
|
+
skipped: true,
|
|
2199
|
+
reason: "fresh",
|
|
2200
|
+
durationMs: Date.now() - startedAt
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
const resolvedTranslate = resolveTranslateConfig(config, type);
|
|
2204
|
+
const model = options.model ?? resolvedTranslate.model;
|
|
2205
|
+
const prompt = buildPageTranslationPrompt({
|
|
2206
|
+
resolved: resolvedTranslate,
|
|
2207
|
+
targetLocale: item.locale,
|
|
2208
|
+
contextLabel: resolveContextLabel(enDoc, item.enSlug),
|
|
2209
|
+
translatableFrontmatter: payload.frontmatter,
|
|
2210
|
+
enBody: payload.body,
|
|
2211
|
+
slugStrategy: type.slugStrategy
|
|
2212
|
+
});
|
|
2213
|
+
if (options.dryRun) {
|
|
2214
|
+
return {
|
|
2215
|
+
...base,
|
|
2216
|
+
skipped: false,
|
|
2217
|
+
model,
|
|
2218
|
+
durationMs: Date.now() - startedAt
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
const responseSchema = buildGeminiResponseSchema(type.schema, type.slugStrategy);
|
|
2222
|
+
const result = await translatePageWithGemini({
|
|
2223
|
+
prompt,
|
|
2224
|
+
model,
|
|
2225
|
+
responseSchema: responseSchema ?? void 0
|
|
2226
|
+
});
|
|
2227
|
+
const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
|
|
2228
|
+
const validated = validateTranslatedFrontmatter(enDoc, result.parsed.frontmatter, type.schema);
|
|
2229
|
+
if (!validated.ok) {
|
|
2230
|
+
throw new Error(`Translation validation failed: ${validated.error}`);
|
|
2231
|
+
}
|
|
2232
|
+
const writeDb = openStore(config, "readwrite");
|
|
2233
|
+
upsertTranslation(writeDb, {
|
|
2234
|
+
contentType: type.id,
|
|
1937
2235
|
enSlug: item.enSlug,
|
|
1938
2236
|
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,
|
|
2237
|
+
slug,
|
|
2238
|
+
frontmatter: validated.frontmatter,
|
|
2239
|
+
body: result.parsed.body,
|
|
2240
|
+
enHash: currentEnHash,
|
|
2241
|
+
translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2242
|
+
model: result.model
|
|
2243
|
+
});
|
|
2244
|
+
writeDb.close();
|
|
2245
|
+
recordRevision(config, {
|
|
2246
|
+
contentType: type.id,
|
|
1956
2247
|
enSlug: item.enSlug,
|
|
1957
2248
|
locale: item.locale,
|
|
2249
|
+
revisionKind: "translation",
|
|
2250
|
+
enHash: currentEnHash,
|
|
2251
|
+
body: result.parsed.body,
|
|
2252
|
+
frontmatter: validated.frontmatter,
|
|
2253
|
+
model: result.model
|
|
2254
|
+
});
|
|
2255
|
+
const estimatedCostUsd = estimateTranslationCostUsd(
|
|
2256
|
+
normalizeGeminiDisplayName(result.model),
|
|
2257
|
+
result.usage.inputTokens,
|
|
2258
|
+
result.usage.outputTokens
|
|
2259
|
+
);
|
|
2260
|
+
return {
|
|
2261
|
+
...base,
|
|
1958
2262
|
skipped: false,
|
|
1959
|
-
model:
|
|
2263
|
+
model: result.model,
|
|
2264
|
+
usage: result.usage,
|
|
2265
|
+
estimatedCostUsd,
|
|
2266
|
+
durationMs: Date.now() - startedAt
|
|
2267
|
+
};
|
|
2268
|
+
} catch (error) {
|
|
2269
|
+
return {
|
|
2270
|
+
...base,
|
|
2271
|
+
skipped: false,
|
|
2272
|
+
failed: true,
|
|
2273
|
+
error: formatTranslateError(error),
|
|
2274
|
+
durationMs: Date.now() - startedAt
|
|
1960
2275
|
};
|
|
1961
2276
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
const
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
model: result.model
|
|
2277
|
+
}
|
|
2278
|
+
function labelForItem(item) {
|
|
2279
|
+
return `${item.contentType}/${item.enSlug}@${item.locale}`;
|
|
2280
|
+
}
|
|
2281
|
+
async function translateWorklist(config, items, options = {}) {
|
|
2282
|
+
const concurrency = Math.max(1, options.concurrency ?? 3);
|
|
2283
|
+
const startedAt = Date.now();
|
|
2284
|
+
const results = new Array(items.length);
|
|
2285
|
+
const active = /* @__PURE__ */ new Set();
|
|
2286
|
+
options.onProgress?.({
|
|
2287
|
+
type: "start",
|
|
2288
|
+
total: items.length,
|
|
2289
|
+
concurrency,
|
|
2290
|
+
dryRun: Boolean(options.dryRun),
|
|
2291
|
+
model: options.model
|
|
1978
2292
|
});
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
model: result.model
|
|
2293
|
+
await runPool(items, concurrency, async (item, index) => {
|
|
2294
|
+
const label = labelForItem(item);
|
|
2295
|
+
active.add(label);
|
|
2296
|
+
options.onProgress?.({ type: "item-start", item, active: [...active] });
|
|
2297
|
+
const result = await translatePage(config, item, options);
|
|
2298
|
+
results[index] = result;
|
|
2299
|
+
active.delete(label);
|
|
2300
|
+
options.onProgress?.({ type: "item-done", result });
|
|
1988
2301
|
});
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
locale: item.locale,
|
|
1993
|
-
skipped: false,
|
|
1994
|
-
model: result.model
|
|
1995
|
-
};
|
|
2302
|
+
const totals = summarizeResults(results, Date.now() - startedAt);
|
|
2303
|
+
options.onProgress?.({ type: "done", results, totals });
|
|
2304
|
+
return results;
|
|
1996
2305
|
}
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2306
|
+
|
|
2307
|
+
// src/export/build-static-raw-exports.ts
|
|
2308
|
+
init_esm_shims();
|
|
2309
|
+
|
|
2310
|
+
// src/loader/parse-mdx.ts
|
|
2311
|
+
init_esm_shims();
|
|
2312
|
+
function serializeMdx(frontmatter, content) {
|
|
2313
|
+
return matter.stringify(content, frontmatter);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// src/export/build-static-raw-exports.ts
|
|
2317
|
+
function normalizeExtension(ext) {
|
|
2318
|
+
return ext.startsWith(".") ? ext : `.${ext}`;
|
|
2319
|
+
}
|
|
2320
|
+
function exportDirSegment(pathTemplate) {
|
|
2321
|
+
const prefix = pathPrefix(pathTemplate);
|
|
2322
|
+
return prefix.replace(/^\/+|\/+$/g, "");
|
|
2323
|
+
}
|
|
2324
|
+
function getStaticExportRoots(project, options = {}) {
|
|
2325
|
+
const { config } = project;
|
|
2326
|
+
const locales = options.locales ?? config.locales;
|
|
2327
|
+
const typeFilter = options.types ? new Set(options.types) : null;
|
|
2328
|
+
const roots = /* @__PURE__ */ new Set();
|
|
2329
|
+
for (const type of project.listTypes()) {
|
|
2330
|
+
if (!isRoutableType(type.config)) continue;
|
|
2331
|
+
if (typeFilter && !typeFilter.has(type.id)) continue;
|
|
2332
|
+
const segment = exportDirSegment(type.config.path);
|
|
2333
|
+
roots.add(`${segment}/`);
|
|
2334
|
+
for (const locale of locales) {
|
|
2335
|
+
if (locale === config.defaultLocale) continue;
|
|
2336
|
+
roots.add(`${locale}/${segment}/`);
|
|
2337
|
+
}
|
|
2001
2338
|
}
|
|
2002
|
-
return
|
|
2339
|
+
return [...roots].sort();
|
|
2340
|
+
}
|
|
2341
|
+
function buildStaticRawExports(project, options = {}) {
|
|
2342
|
+
const { config } = project;
|
|
2343
|
+
const extension = normalizeExtension(options.extension ?? ".mdx");
|
|
2344
|
+
const locales = options.locales ?? config.locales;
|
|
2345
|
+
const excludeRedirected = options.excludeRedirected ?? true;
|
|
2346
|
+
const excludeNoindex = options.excludeNoindex ?? false;
|
|
2347
|
+
const typeFilter = options.types ? new Set(options.types) : null;
|
|
2348
|
+
const out = [];
|
|
2349
|
+
for (const type of project.listTypes()) {
|
|
2350
|
+
if (!isRoutableType(type.config)) continue;
|
|
2351
|
+
if (typeFilter && !typeFilter.has(type.id)) continue;
|
|
2352
|
+
const pathTemplate = type.config.path;
|
|
2353
|
+
const all = type.load();
|
|
2354
|
+
const enIdx = all.get(config.defaultLocale);
|
|
2355
|
+
if (!enIdx) continue;
|
|
2356
|
+
for (const locale of locales) {
|
|
2357
|
+
for (const enDoc of enIdx.bySlug.values()) {
|
|
2358
|
+
const resolved = type.resolve(enDoc.slug, locale);
|
|
2359
|
+
if (!resolved.document) continue;
|
|
2360
|
+
if (excludeRedirected && resolved.shouldRedirectTo) continue;
|
|
2361
|
+
if (excludeNoindex && resolved.document.noindex) continue;
|
|
2362
|
+
const doc = resolved.document;
|
|
2363
|
+
const slugWithExt = `${doc.slug}${extension}`;
|
|
2364
|
+
const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
|
|
2365
|
+
out.push({
|
|
2366
|
+
relativePath: urlPath.slice(1),
|
|
2367
|
+
urlPath,
|
|
2368
|
+
locale,
|
|
2369
|
+
typeId: type.id,
|
|
2370
|
+
enSlug: doc.enSlug,
|
|
2371
|
+
source: serializeMdx(doc.frontmatter, doc.content)
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
return out;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
// src/export/write-static-raw-exports.ts
|
|
2380
|
+
init_esm_shims();
|
|
2381
|
+
function rmDirIfExists(dir) {
|
|
2382
|
+
if (fs2.existsSync(dir)) {
|
|
2383
|
+
fs2.rmSync(dir, { recursive: true, force: true });
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
function writeStaticRawExports(project, options = {}) {
|
|
2387
|
+
const outDir = path4.resolve(options.outDir ?? "public");
|
|
2388
|
+
const typeFilter = options.types;
|
|
2389
|
+
for (const root of getStaticExportRoots(project, {
|
|
2390
|
+
types: typeFilter,
|
|
2391
|
+
locales: options.locales
|
|
2392
|
+
})) {
|
|
2393
|
+
rmDirIfExists(path4.join(outDir, root));
|
|
2394
|
+
}
|
|
2395
|
+
const exports = buildStaticRawExports(project, options);
|
|
2396
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2397
|
+
for (const item of exports) {
|
|
2398
|
+
const filePath = path4.join(outDir, item.relativePath);
|
|
2399
|
+
fs2.mkdirSync(path4.dirname(filePath), { recursive: true });
|
|
2400
|
+
fs2.writeFileSync(filePath, item.source, "utf8");
|
|
2401
|
+
const key = `${item.typeId}/${item.locale}`;
|
|
2402
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
2403
|
+
}
|
|
2404
|
+
for (const [key, count] of [...counts.entries()].sort()) {
|
|
2405
|
+
console.log(` ${key}: ${count}`);
|
|
2406
|
+
}
|
|
2407
|
+
return { exports, written: exports.length };
|
|
2003
2408
|
}
|
|
2004
2409
|
|
|
2005
2410
|
// src/config/load-env.ts
|
|
@@ -2020,99 +2425,464 @@ function loadEnvFromCwd(cwd) {
|
|
|
2020
2425
|
// studio/server.ts
|
|
2021
2426
|
init_esm_shims();
|
|
2022
2427
|
init_sqlite();
|
|
2023
|
-
function
|
|
2428
|
+
function escapeHtml(value) {
|
|
2429
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2430
|
+
}
|
|
2431
|
+
function encodePathSegment(value) {
|
|
2432
|
+
return encodeURIComponent(value);
|
|
2433
|
+
}
|
|
2434
|
+
function statusDot(status) {
|
|
2435
|
+
const labels = {
|
|
2436
|
+
source: "en",
|
|
2437
|
+
"up-to-date": "ok",
|
|
2438
|
+
stale: "stale",
|
|
2439
|
+
missing: "\u2014"
|
|
2440
|
+
};
|
|
2441
|
+
return `<span class="status" title="${status}"><span class="dot dot-${status}"></span>${labels[status]}</span>`;
|
|
2442
|
+
}
|
|
2443
|
+
function documentStatus(config, db, type, enSlug, locale) {
|
|
2444
|
+
if (locale === config.defaultLocale) {
|
|
2445
|
+
return { status: "source" };
|
|
2446
|
+
}
|
|
2447
|
+
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
2448
|
+
if (!enDoc) {
|
|
2449
|
+
return { status: "missing" };
|
|
2450
|
+
}
|
|
2451
|
+
const payload = getTranslatablePayload(enDoc, type.config);
|
|
2452
|
+
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
2453
|
+
const row = getTranslation(db, type.id, enSlug, locale);
|
|
2454
|
+
if (!row) {
|
|
2455
|
+
return { status: "missing", currentEnHash };
|
|
2456
|
+
}
|
|
2457
|
+
if (row.en_hash !== currentEnHash) {
|
|
2458
|
+
return { status: "stale", currentEnHash, storedEnHash: row.en_hash };
|
|
2459
|
+
}
|
|
2460
|
+
return { status: "up-to-date", currentEnHash, storedEnHash: row.en_hash };
|
|
2461
|
+
}
|
|
2462
|
+
function flattenFrontmatter(data, prefix = "") {
|
|
2463
|
+
const rows = [];
|
|
2464
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2465
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
2466
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
2467
|
+
rows.push(...flattenFrontmatter(value, fullKey));
|
|
2468
|
+
} else {
|
|
2469
|
+
const display = typeof value === "string" ? value : value === void 0 ? "" : JSON.stringify(value, null, 2);
|
|
2470
|
+
rows.push({ key: fullKey, value: display });
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
return rows;
|
|
2474
|
+
}
|
|
2475
|
+
function translatableKeySet(schema) {
|
|
2476
|
+
return new Set(
|
|
2477
|
+
introspectSchema(schema).filter((field2) => field2.kind === "translatable").map((field2) => field2.path.join("."))
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
function renderFrontmatterTable(frontmatter, schema) {
|
|
2481
|
+
const translatable = translatableKeySet(schema);
|
|
2482
|
+
const rows = flattenFrontmatter(frontmatter).map((row) => {
|
|
2483
|
+
const flag = translatable.has(row.key) ? `<span class="flag t" title="Translatable">T</span>` : `<span class="flag s" title="Structural">S</span>`;
|
|
2484
|
+
return `<tr>
|
|
2485
|
+
<td class="k">${flag}${escapeHtml(row.key)}</td>
|
|
2486
|
+
<td class="v">${escapeHtml(row.value)}</td>
|
|
2487
|
+
</tr>`;
|
|
2488
|
+
}).join("");
|
|
2489
|
+
return `<table class="kv">
|
|
2490
|
+
<tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
|
|
2491
|
+
</table>`;
|
|
2492
|
+
}
|
|
2493
|
+
function renderRevisionSnapshot(revision) {
|
|
2494
|
+
if (revision.frontmatter_json && revision.body) {
|
|
2495
|
+
let frontmatter = {};
|
|
2496
|
+
try {
|
|
2497
|
+
frontmatter = JSON.parse(revision.frontmatter_json);
|
|
2498
|
+
} catch {
|
|
2499
|
+
frontmatter = {};
|
|
2500
|
+
}
|
|
2501
|
+
const fmRows = flattenFrontmatter(frontmatter).map(
|
|
2502
|
+
(row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
|
|
2503
|
+
).join("");
|
|
2504
|
+
return `<table class="kv"><tbody>${fmRows}</tbody></table>
|
|
2505
|
+
<pre class="code">${escapeHtml(revision.body)}</pre>`;
|
|
2506
|
+
}
|
|
2507
|
+
return `<p class="dim">${escapeHtml(revision.body_preview ?? "(no snapshot)")}</p>`;
|
|
2508
|
+
}
|
|
2509
|
+
function renderRevisionTimeline(revisions) {
|
|
2510
|
+
if (revisions.length === 0) {
|
|
2511
|
+
return `<p class="dim">No history.</p>`;
|
|
2512
|
+
}
|
|
2513
|
+
const items = revisions.map(
|
|
2514
|
+
(row) => `<tr>
|
|
2515
|
+
<td class="dim">${escapeHtml(row.created_at.slice(0, 19))}</td>
|
|
2516
|
+
<td>${escapeHtml(row.revision_kind)}</td>
|
|
2517
|
+
<td class="dim">${row.model ? escapeHtml(row.model) : "\u2014"}</td>
|
|
2518
|
+
<td class="dim mono">${escapeHtml(row.en_hash.slice(0, 8))}</td>
|
|
2519
|
+
<td><details><summary>view</summary>${renderRevisionSnapshot(row)}</details></td>
|
|
2520
|
+
</tr>`
|
|
2521
|
+
).join("");
|
|
2522
|
+
return `<table class="data"><thead><tr>
|
|
2523
|
+
<th>When</th><th>Kind</th><th>Model</th><th>Hash</th><th></th>
|
|
2524
|
+
</tr></thead><tbody>${items}</tbody></table>`;
|
|
2525
|
+
}
|
|
2526
|
+
function renderLayout(title, body, project, options = {}) {
|
|
2527
|
+
const typeLinks = project.listTypes().map((type) => {
|
|
2528
|
+
const active = type.id === options.activeTypeId ? " active" : "";
|
|
2529
|
+
return `<a class="tree-item${active}" href="/type/${encodePathSegment(type.id)}">
|
|
2530
|
+
<span class="tree-label">${escapeHtml(type.config.label)}</span>
|
|
2531
|
+
<span class="tree-meta">${escapeHtml(type.id)}</span>
|
|
2532
|
+
</a>`;
|
|
2533
|
+
}).join("");
|
|
2024
2534
|
return `<!doctype html>
|
|
2025
2535
|
<html lang="en">
|
|
2026
2536
|
<head>
|
|
2027
2537
|
<meta charset="utf-8" />
|
|
2028
2538
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2029
|
-
<title>${title} \u2014 Scribe
|
|
2539
|
+
<title>${escapeHtml(title)} \u2014 Scribe</title>
|
|
2030
2540
|
<style>
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2541
|
+
:root {
|
|
2542
|
+
--bg: #1e1e1e;
|
|
2543
|
+
--sidebar: #252526;
|
|
2544
|
+
--bar: #333333;
|
|
2545
|
+
--panel: #1e1e1e;
|
|
2546
|
+
--border: #3c3c3c;
|
|
2547
|
+
--text: #cccccc;
|
|
2548
|
+
--dim: #858585;
|
|
2549
|
+
--accent: #3794ff;
|
|
2550
|
+
--hover: #2a2d2e;
|
|
2551
|
+
--active: #37373d;
|
|
2552
|
+
--ok: #89d185;
|
|
2553
|
+
--stale: #cca700;
|
|
2554
|
+
--missing: #f48771;
|
|
2555
|
+
--source: #75beff;
|
|
2556
|
+
--mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
|
|
2557
|
+
--ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2558
|
+
--fs: 13px;
|
|
2559
|
+
--fs-sm: 11px;
|
|
2560
|
+
}
|
|
2561
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2562
|
+
body { font: var(--fs)/1.4 var(--ui); background: var(--bg); color: var(--text); }
|
|
2563
|
+
a { color: var(--accent); text-decoration: none; }
|
|
2564
|
+
a:hover { text-decoration: underline; }
|
|
2565
|
+
.app { display: flex; height: 100vh; overflow: hidden; }
|
|
2566
|
+
|
|
2567
|
+
/* activity bar */
|
|
2568
|
+
.actbar {
|
|
2569
|
+
width: 48px; flex-shrink: 0; background: var(--bar);
|
|
2570
|
+
display: flex; flex-direction: column; align-items: center;
|
|
2571
|
+
padding: 8px 0; gap: 4px; border-right: 1px solid var(--border);
|
|
2572
|
+
}
|
|
2573
|
+
.actbar a {
|
|
2574
|
+
width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
|
|
2575
|
+
color: var(--dim); font-size: 18px; text-decoration: none; position: relative;
|
|
2576
|
+
}
|
|
2577
|
+
.actbar a:hover { color: var(--text); }
|
|
2578
|
+
.actbar a.active { color: var(--text); }
|
|
2579
|
+
.actbar a.active::before {
|
|
2580
|
+
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px;
|
|
2581
|
+
width: 2px; background: var(--accent);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
/* sidebar */
|
|
2585
|
+
.sidebar {
|
|
2586
|
+
width: 200px; flex-shrink: 0; background: var(--sidebar);
|
|
2587
|
+
border-right: 1px solid var(--border); display: flex; flex-direction: column;
|
|
2588
|
+
overflow: hidden;
|
|
2589
|
+
}
|
|
2590
|
+
.sidebar-head {
|
|
2591
|
+
padding: 8px 12px; font-size: var(--fs-sm); font-weight: 600;
|
|
2592
|
+
text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
|
|
2593
|
+
}
|
|
2594
|
+
.sidebar-body { flex: 1; overflow-y: auto; padding: 2px 0; }
|
|
2595
|
+
.tree-item {
|
|
2596
|
+
display: flex; flex-direction: column; padding: 3px 12px 3px 20px;
|
|
2597
|
+
color: var(--text); text-decoration: none; line-height: 1.3;
|
|
2598
|
+
}
|
|
2599
|
+
.tree-item:hover { background: var(--hover); text-decoration: none; }
|
|
2600
|
+
.tree-item.active { background: var(--active); }
|
|
2601
|
+
.tree-label { font-size: var(--fs); }
|
|
2602
|
+
.tree-meta { font-size: var(--fs-sm); color: var(--dim); }
|
|
2603
|
+
|
|
2604
|
+
/* main */
|
|
2605
|
+
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
2606
|
+
.toolbar {
|
|
2607
|
+
display: flex; align-items: center; gap: 6px; padding: 0 12px;
|
|
2608
|
+
height: 35px; background: var(--sidebar); border-bottom: 1px solid var(--border);
|
|
2609
|
+
font-size: var(--fs-sm); color: var(--dim); flex-shrink: 0; overflow: hidden;
|
|
2610
|
+
}
|
|
2611
|
+
.toolbar a { color: var(--dim); }
|
|
2612
|
+
.toolbar a:hover { color: var(--text); }
|
|
2613
|
+
.toolbar .sep { color: var(--border); }
|
|
2614
|
+
.content { flex: 1; overflow-y: auto; padding: 0; }
|
|
2615
|
+
|
|
2616
|
+
/* tabs (locale switcher) */
|
|
2617
|
+
.tabs {
|
|
2618
|
+
display: flex; background: var(--sidebar); border-bottom: 1px solid var(--border);
|
|
2619
|
+
overflow-x: auto; flex-shrink: 0;
|
|
2620
|
+
}
|
|
2621
|
+
.tab {
|
|
2622
|
+
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
|
2623
|
+
font-size: var(--fs-sm); color: var(--dim); border-right: 1px solid var(--border);
|
|
2624
|
+
text-decoration: none; white-space: nowrap;
|
|
2625
|
+
}
|
|
2626
|
+
.tab:hover { background: var(--hover); color: var(--text); text-decoration: none; }
|
|
2627
|
+
.tab.active {
|
|
2628
|
+
background: var(--bg); color: var(--text);
|
|
2629
|
+
border-bottom: 1px solid var(--bg); margin-bottom: -1px;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/* status dots */
|
|
2633
|
+
.status { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); color: var(--dim); }
|
|
2634
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
2635
|
+
.dot-source { background: var(--source); }
|
|
2636
|
+
.dot-up-to-date { background: var(--ok); }
|
|
2637
|
+
.dot-stale { background: var(--stale); }
|
|
2638
|
+
.dot-missing { background: var(--missing); opacity: 0.7; }
|
|
2639
|
+
|
|
2640
|
+
/* sections */
|
|
2641
|
+
.section { border-bottom: 1px solid var(--border); }
|
|
2642
|
+
.section-head {
|
|
2643
|
+
padding: 4px 12px; font-size: var(--fs-sm); font-weight: 600;
|
|
2644
|
+
text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
|
|
2645
|
+
background: var(--sidebar);
|
|
2646
|
+
}
|
|
2647
|
+
.section-body { padding: 0; }
|
|
2648
|
+
|
|
2649
|
+
/* tables */
|
|
2650
|
+
table { border-collapse: collapse; width: 100%; font-size: var(--fs); }
|
|
2651
|
+
.data th, .data td { padding: 2px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
2652
|
+
.data th { font-size: var(--fs-sm); font-weight: 600; color: var(--dim); background: var(--sidebar); height: 22px; }
|
|
2653
|
+
.data tr:hover td { background: var(--hover); }
|
|
2654
|
+
.data .mono { font-family: var(--mono); font-size: var(--fs-sm); }
|
|
2655
|
+
|
|
2656
|
+
/* key-value (frontmatter) */
|
|
2657
|
+
.kv td { padding: 1px 12px; font-family: var(--mono); font-size: var(--fs-sm); vertical-align: top; border-bottom: 1px solid var(--border); }
|
|
2658
|
+
.kv .k { width: 160px; color: #9cdcfe; white-space: nowrap; }
|
|
2659
|
+
.kv .v { color: #ce9178; white-space: pre-wrap; word-break: break-word; }
|
|
2660
|
+
.flag { font-size: 9px; font-weight: 700; margin-right: 4px; opacity: 0.5; }
|
|
2661
|
+
.flag.t { color: var(--ok); }
|
|
2662
|
+
.flag.s { color: var(--dim); }
|
|
2663
|
+
|
|
2664
|
+
/* code block */
|
|
2665
|
+
.code {
|
|
2666
|
+
margin: 0; padding: 8px 12px; font: var(--fs-sm)/1.5 var(--mono);
|
|
2667
|
+
white-space: pre-wrap; word-break: break-word; color: var(--text);
|
|
2668
|
+
background: var(--bg); border: none;
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/* meta row */
|
|
2672
|
+
.meta { display: flex; flex-wrap: wrap; font-size: var(--fs-sm); border-bottom: 1px solid var(--border); padding: 4px 0; }
|
|
2673
|
+
.meta dt { padding: 0 4px 0 12px; color: var(--dim); }
|
|
2674
|
+
.meta dt::after { content: ":"; }
|
|
2675
|
+
.meta dd { padding: 0 12px 0 0; font-family: var(--mono); }
|
|
2676
|
+
|
|
2677
|
+
/* misc */
|
|
2678
|
+
.dim { color: var(--dim); }
|
|
2679
|
+
.page-title { padding: 8px 12px 4px; font-size: 14px; font-weight: 400; }
|
|
2680
|
+
.page-sub { padding: 0 12px 8px; font-size: var(--fs-sm); color: var(--dim); }
|
|
2681
|
+
details summary { cursor: pointer; color: var(--accent); font-size: var(--fs-sm); }
|
|
2682
|
+
details[open] summary { margin-bottom: 4px; }
|
|
2683
|
+
.tag { font-size: var(--fs-sm); color: var(--dim); margin-left: 6px; }
|
|
2684
|
+
.tag-warn { color: var(--stale); }
|
|
2685
|
+
.tag-err { color: var(--missing); }
|
|
2042
2686
|
</style>
|
|
2043
2687
|
</head>
|
|
2044
2688
|
<body>
|
|
2045
|
-
<
|
|
2046
|
-
<
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2689
|
+
<div class="app">
|
|
2690
|
+
<nav class="actbar">
|
|
2691
|
+
<a href="/" title="Overview">\u2302</a>
|
|
2692
|
+
<a href="/staleness" title="Staleness">\u26A0</a>
|
|
2693
|
+
</nav>
|
|
2694
|
+
<aside class="sidebar">
|
|
2695
|
+
<div class="sidebar-head">Types</div>
|
|
2696
|
+
<div class="sidebar-body">${typeLinks || `<span class="dim" style="padding:8px 12px">\u2014</span>`}</div>
|
|
2697
|
+
</aside>
|
|
2698
|
+
<div class="main">
|
|
2699
|
+
<div class="content">${body}</div>
|
|
2700
|
+
</div>
|
|
2701
|
+
</div>
|
|
2052
2702
|
</body>
|
|
2053
2703
|
</html>`;
|
|
2054
2704
|
}
|
|
2705
|
+
function docTitleFromFrontmatter(frontmatter, enSlug) {
|
|
2706
|
+
const title = frontmatter.title;
|
|
2707
|
+
if (typeof title === "string" && title.trim()) return title;
|
|
2708
|
+
return enSlug;
|
|
2709
|
+
}
|
|
2055
2710
|
async function startStudio(project, options = {}) {
|
|
2056
2711
|
const app = new Hono();
|
|
2057
2712
|
const config = project.config;
|
|
2058
2713
|
app.get("/", (c) => {
|
|
2059
|
-
const
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2714
|
+
const db = openStore(config, "readonly");
|
|
2715
|
+
const worklist = buildWorklist(config);
|
|
2716
|
+
const rows = project.listTypes().map((type) => {
|
|
2717
|
+
const enCount = type.list().length;
|
|
2718
|
+
const localeCells = config.locales.filter((locale) => locale !== config.defaultLocale).map((locale) => {
|
|
2719
|
+
const translated = listTranslationsForLocale(db, type.id, locale).length;
|
|
2720
|
+
const stale = worklist.filter(
|
|
2721
|
+
(item) => item.contentType === type.id && item.locale === locale && item.reason === "stale"
|
|
2722
|
+
).length;
|
|
2723
|
+
const missing = worklist.filter(
|
|
2724
|
+
(item) => item.contentType === type.id && item.locale === locale && item.reason === "missing"
|
|
2725
|
+
).length;
|
|
2726
|
+
const tags = [
|
|
2727
|
+
stale ? `<span class="tag tag-warn">${stale}s</span>` : "",
|
|
2728
|
+
missing ? `<span class="tag tag-err">${missing}m</span>` : ""
|
|
2729
|
+
].join("");
|
|
2730
|
+
return `<td>${translated}${tags}</td>`;
|
|
2063
2731
|
}).join("");
|
|
2064
|
-
return `<
|
|
2732
|
+
return `<tr>
|
|
2733
|
+
<td><a href="/type/${encodePathSegment(type.id)}">${escapeHtml(type.config.label)}</a></td>
|
|
2734
|
+
<td class="dim">${escapeHtml(type.id)}</td>
|
|
2735
|
+
<td>${enCount}</td>
|
|
2736
|
+
${localeCells}
|
|
2737
|
+
</tr>`;
|
|
2065
2738
|
});
|
|
2066
|
-
|
|
2739
|
+
db.close();
|
|
2740
|
+
const localeHeaders = config.locales.filter((l) => l !== config.defaultLocale).map((l) => `<th>${escapeHtml(l)}</th>`).join("");
|
|
2741
|
+
const html = `<div class="toolbar">Overview</div>
|
|
2742
|
+
<table class="data">
|
|
2743
|
+
<thead><tr><th>Type</th><th>ID</th><th>EN</th>${localeHeaders}</tr></thead>
|
|
2744
|
+
<tbody>${rows.join("")}</tbody>
|
|
2745
|
+
</table>`;
|
|
2746
|
+
return c.html(renderLayout("Overview", html, project));
|
|
2067
2747
|
});
|
|
2068
|
-
app.get("/
|
|
2069
|
-
const
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2748
|
+
app.get("/type/:id", (c) => {
|
|
2749
|
+
const typeId = c.req.param("id");
|
|
2750
|
+
const type = project.getType(typeId);
|
|
2751
|
+
if (!type) {
|
|
2752
|
+
return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
|
|
2753
|
+
}
|
|
2754
|
+
const db = openStore(config, "readonly");
|
|
2755
|
+
const locales = config.locales;
|
|
2756
|
+
const headerCells = locales.map((locale) => `<th>${escapeHtml(locale)}</th>`).join("");
|
|
2757
|
+
const rows = type.list().map((doc) => {
|
|
2758
|
+
const title = docTitleFromFrontmatter(doc.frontmatter, doc.slug);
|
|
2759
|
+
const statusCells = locales.map((locale) => {
|
|
2760
|
+
const { status } = documentStatus(config, db, type, doc.slug, locale);
|
|
2761
|
+
return `<td>${statusDot(status)}</td>`;
|
|
2762
|
+
}).join("");
|
|
2763
|
+
return `<tr>
|
|
2764
|
+
<td class="mono"><a href="/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(doc.slug)}">${escapeHtml(doc.slug)}</a></td>
|
|
2765
|
+
<td>${escapeHtml(title)}</td>
|
|
2766
|
+
${statusCells}
|
|
2767
|
+
</tr>`;
|
|
2768
|
+
}).join("");
|
|
2769
|
+
db.close();
|
|
2770
|
+
const html = `<div class="toolbar">
|
|
2771
|
+
<a href="/">Overview</a><span class="sep">\u203A</span>${escapeHtml(type.config.label)}
|
|
2772
|
+
</div>
|
|
2773
|
+
<table class="data">
|
|
2774
|
+
<thead><tr><th>Slug</th><th>Title</th>${headerCells}</tr></thead>
|
|
2775
|
+
<tbody>${rows || `<tr><td colspan="${2 + locales.length}" class="dim">No documents</td></tr>`}</tbody>
|
|
2776
|
+
</table>`;
|
|
2777
|
+
return c.html(renderLayout(type.config.label, html, project, { activeTypeId: typeId }));
|
|
2076
2778
|
});
|
|
2077
|
-
app.get("/
|
|
2078
|
-
const typeId = c.req.
|
|
2079
|
-
const enSlug = c.req.
|
|
2080
|
-
|
|
2779
|
+
app.get("/type/:id/doc/:enSlug", (c) => {
|
|
2780
|
+
const typeId = c.req.param("id");
|
|
2781
|
+
const enSlug = c.req.param("enSlug");
|
|
2782
|
+
const locale = c.req.query("locale") ?? config.defaultLocale;
|
|
2783
|
+
const showRaw = c.req.query("raw") === "1";
|
|
2784
|
+
const type = project.getType(typeId);
|
|
2785
|
+
if (!type) {
|
|
2786
|
+
return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
|
|
2787
|
+
}
|
|
2081
2788
|
const db = openStore(config, "readonly");
|
|
2082
|
-
|
|
2083
|
-
if (
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2789
|
+
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
2790
|
+
if (!enDoc) {
|
|
2791
|
+
db.close();
|
|
2792
|
+
return c.html(
|
|
2793
|
+
renderLayout("Not found", `<div class="toolbar">Not found</div><p class="dim" style="padding:12px">${escapeHtml(enSlug)}</p>`, project, {
|
|
2794
|
+
activeTypeId: typeId
|
|
2795
|
+
}),
|
|
2796
|
+
404
|
|
2797
|
+
);
|
|
2798
|
+
}
|
|
2799
|
+
const localeTabs = config.locales.map((loc) => {
|
|
2800
|
+
const { status } = documentStatus(config, db, type, enSlug, loc);
|
|
2801
|
+
const active = loc === locale ? " active" : "";
|
|
2802
|
+
const href = `/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(enSlug)}?locale=${encodePathSegment(loc)}`;
|
|
2803
|
+
return `<a class="tab${active}" href="${href}">${escapeHtml(loc)} ${statusDot(status)}</a>`;
|
|
2804
|
+
}).join("");
|
|
2805
|
+
let contentPanel = "";
|
|
2806
|
+
let metaPanel = "";
|
|
2807
|
+
let historyPanel = "";
|
|
2808
|
+
if (locale === config.defaultLocale) {
|
|
2809
|
+
contentPanel = `<div class="section">
|
|
2810
|
+
<div class="section-head">Frontmatter</div>
|
|
2811
|
+
<div class="section-body">${renderFrontmatterTable(enDoc.frontmatter, type.config.schema)}</div>
|
|
2812
|
+
<div class="section-head">Body</div>
|
|
2813
|
+
<pre class="code">${escapeHtml(enDoc.content)}</pre>
|
|
2814
|
+
</div>`;
|
|
2088
2815
|
} else {
|
|
2089
|
-
const
|
|
2090
|
-
|
|
2816
|
+
const translation = getTranslation(db, typeId, enSlug, locale);
|
|
2817
|
+
const { status, currentEnHash, storedEnHash } = documentStatus(config, db, type, enSlug, locale);
|
|
2818
|
+
if (translation) {
|
|
2819
|
+
const rawFrontmatter = JSON.parse(translation.frontmatter_json);
|
|
2820
|
+
const displayFrontmatter = showRaw ? rawFrontmatter : mergeStructuralOntoLocale(rawFrontmatter, enDoc.frontmatter, type.config.schema);
|
|
2821
|
+
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>`;
|
|
2822
|
+
contentPanel = `<div class="section">
|
|
2823
|
+
<div class="section-head">Frontmatter${rawToggle}</div>
|
|
2824
|
+
<div class="section-body">${renderFrontmatterTable(displayFrontmatter, type.config.schema)}</div>
|
|
2825
|
+
<div class="section-head">Body</div>
|
|
2826
|
+
<pre class="code">${escapeHtml(translation.body)}</pre>
|
|
2827
|
+
</div>`;
|
|
2828
|
+
metaPanel = `<dl class="meta">
|
|
2829
|
+
<dt>status</dt><dd>${statusDot(status)}</dd>
|
|
2830
|
+
<dt>model</dt><dd>${escapeHtml(translation.model)}</dd>
|
|
2831
|
+
<dt>translated</dt><dd>${escapeHtml(translation.translated_at.slice(0, 19))}</dd>
|
|
2832
|
+
<dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
|
|
2833
|
+
<dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
|
|
2834
|
+
</dl>`;
|
|
2835
|
+
const revisions = listRevisions(db, typeId, enSlug, locale);
|
|
2836
|
+
historyPanel = `<div class="section">
|
|
2837
|
+
<div class="section-head">History</div>
|
|
2838
|
+
<div class="section-body">${renderRevisionTimeline(revisions)}</div>
|
|
2839
|
+
</div>`;
|
|
2840
|
+
} else {
|
|
2841
|
+
contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
|
|
2842
|
+
metaPanel = `<dl class="meta"><dt>status</dt><dd>${statusDot(status)}</dd></dl>`;
|
|
2843
|
+
}
|
|
2091
2844
|
}
|
|
2092
2845
|
db.close();
|
|
2093
|
-
|
|
2846
|
+
const title = docTitleFromFrontmatter(enDoc.frontmatter, enSlug);
|
|
2847
|
+
const html = `<div class="toolbar">
|
|
2848
|
+
<a href="/">Overview</a><span class="sep">\u203A</span>
|
|
2849
|
+
<a href="/type/${encodePathSegment(typeId)}">${escapeHtml(type.config.label)}</a><span class="sep">\u203A</span>
|
|
2850
|
+
<span>${escapeHtml(enSlug)}</span>
|
|
2851
|
+
</div>
|
|
2852
|
+
<div class="tabs">${localeTabs}</div>
|
|
2853
|
+
${metaPanel}
|
|
2854
|
+
${contentPanel}
|
|
2855
|
+
${historyPanel}`;
|
|
2856
|
+
return c.html(renderLayout(title, html, project, { activeTypeId: typeId }));
|
|
2857
|
+
});
|
|
2858
|
+
app.get("/staleness", (c) => {
|
|
2859
|
+
const worklist = buildWorklist(config);
|
|
2860
|
+
const rows = worklist.slice(0, 500).map((item) => {
|
|
2861
|
+
const href = `/type/${encodePathSegment(item.contentType)}/doc/${encodePathSegment(item.enSlug)}?locale=${encodePathSegment(item.locale)}`;
|
|
2862
|
+
return `<tr>
|
|
2863
|
+
<td>${escapeHtml(item.contentType)}</td>
|
|
2864
|
+
<td class="mono"><a href="${href}">${escapeHtml(item.enSlug)}</a></td>
|
|
2865
|
+
<td>${escapeHtml(item.locale)}</td>
|
|
2866
|
+
<td>${statusDot(item.reason === "stale" ? "stale" : "missing")}</td>
|
|
2867
|
+
</tr>`;
|
|
2868
|
+
}).join("");
|
|
2869
|
+
const html = `<div class="toolbar">Staleness <span class="dim">\xB7 ${worklist.length} entries</span></div>
|
|
2870
|
+
<table class="data">
|
|
2871
|
+
<thead><tr><th>Type</th><th>Slug</th><th>Locale</th><th>Status</th></tr></thead>
|
|
2872
|
+
<tbody>${rows || `<tr><td colspan="4" class="dim">All up to date</td></tr>`}</tbody>
|
|
2873
|
+
</table>`;
|
|
2874
|
+
return c.html(renderLayout("Staleness", html, project));
|
|
2094
2875
|
});
|
|
2095
2876
|
app.get("/api/staleness-matrix", (c) => {
|
|
2877
|
+
const worklist = buildWorklist(config);
|
|
2096
2878
|
const matrix = {};
|
|
2097
2879
|
for (const type of project.listTypes()) {
|
|
2098
2880
|
matrix[type.id] = {};
|
|
2099
2881
|
for (const locale of config.locales) {
|
|
2100
2882
|
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;
|
|
2883
|
+
matrix[type.id][locale] = worklist.filter(
|
|
2884
|
+
(item) => item.contentType === type.id && item.locale === locale
|
|
2885
|
+
).length;
|
|
2116
2886
|
}
|
|
2117
2887
|
}
|
|
2118
2888
|
return c.json(matrix);
|
|
@@ -2124,6 +2894,276 @@ async function startStudio(project, options = {}) {
|
|
|
2124
2894
|
});
|
|
2125
2895
|
}
|
|
2126
2896
|
|
|
2897
|
+
// cli/prompt-translate.ts
|
|
2898
|
+
init_esm_shims();
|
|
2899
|
+
|
|
2900
|
+
// ../../node_modules/@inquirer/core/dist/lib/errors.js
|
|
2901
|
+
init_esm_shims();
|
|
2902
|
+
var CancelPromptError = class extends Error {
|
|
2903
|
+
constructor() {
|
|
2904
|
+
super(...arguments);
|
|
2905
|
+
__publicField(this, "name", "CancelPromptError");
|
|
2906
|
+
__publicField(this, "message", "Prompt was canceled");
|
|
2907
|
+
}
|
|
2908
|
+
};
|
|
2909
|
+
function isInteractive() {
|
|
2910
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
2911
|
+
}
|
|
2912
|
+
function nonDefaultLocales(config) {
|
|
2913
|
+
return config.locales.filter((locale) => locale !== config.defaultLocale);
|
|
2914
|
+
}
|
|
2915
|
+
async function runPrompt(prompt) {
|
|
2916
|
+
try {
|
|
2917
|
+
return await prompt;
|
|
2918
|
+
} catch (error) {
|
|
2919
|
+
if (error instanceof CancelPromptError) process.exit(0);
|
|
2920
|
+
throw error;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
async function promptContentType(config) {
|
|
2924
|
+
return runPrompt(
|
|
2925
|
+
select({
|
|
2926
|
+
message: "Content type to translate",
|
|
2927
|
+
choices: [
|
|
2928
|
+
{ name: "All content types", value: void 0 },
|
|
2929
|
+
...config.types.map((type) => ({ name: type.id, value: type.id }))
|
|
2930
|
+
]
|
|
2931
|
+
})
|
|
2932
|
+
);
|
|
2933
|
+
}
|
|
2934
|
+
async function promptLocalePreset(config) {
|
|
2935
|
+
const presets = Object.entries(config.localePresets ?? {}).filter(
|
|
2936
|
+
(entry) => Array.isArray(entry[1])
|
|
2937
|
+
);
|
|
2938
|
+
if (presets.length === 0) {
|
|
2939
|
+
const locales = nonDefaultLocales(config);
|
|
2940
|
+
if (locales.length <= 1) return {};
|
|
2941
|
+
const picked = await runPrompt(
|
|
2942
|
+
checkbox({
|
|
2943
|
+
message: "Locales to translate",
|
|
2944
|
+
choices: locales.map((locale) => ({ name: locale, value: locale, checked: true })),
|
|
2945
|
+
validate: (values) => values.length > 0 || "Pick at least one locale"
|
|
2946
|
+
})
|
|
2947
|
+
);
|
|
2948
|
+
return picked.length === locales.length ? {} : { locale: picked };
|
|
2949
|
+
}
|
|
2950
|
+
const choice = await runPrompt(
|
|
2951
|
+
select({
|
|
2952
|
+
message: "Locale preset",
|
|
2953
|
+
choices: [
|
|
2954
|
+
{ name: "All locales", value: void 0 },
|
|
2955
|
+
...presets.map(([name, locales]) => ({
|
|
2956
|
+
name: `${name} (${locales.join(", ")})`,
|
|
2957
|
+
value: name
|
|
2958
|
+
}))
|
|
2959
|
+
]
|
|
2960
|
+
})
|
|
2961
|
+
);
|
|
2962
|
+
return choice ? { preset: choice } : {};
|
|
2963
|
+
}
|
|
2964
|
+
async function promptStrategy() {
|
|
2965
|
+
return runPrompt(
|
|
2966
|
+
select({
|
|
2967
|
+
message: "Translation strategy",
|
|
2968
|
+
choices: [
|
|
2969
|
+
{ name: "Stale and missing", value: "all" },
|
|
2970
|
+
{ name: "Missing only (skip stale)", value: "missing-only" }
|
|
2971
|
+
],
|
|
2972
|
+
default: "all"
|
|
2973
|
+
})
|
|
2974
|
+
);
|
|
2975
|
+
}
|
|
2976
|
+
async function promptTranslateSelection(config, flags) {
|
|
2977
|
+
const selection = {
|
|
2978
|
+
contentType: flags.type,
|
|
2979
|
+
preset: flags.preset,
|
|
2980
|
+
locale: flags.locale,
|
|
2981
|
+
strategy: flags.strategy
|
|
2982
|
+
};
|
|
2983
|
+
if (!isInteractive()) return selection;
|
|
2984
|
+
if (!selection.contentType) {
|
|
2985
|
+
selection.contentType = await promptContentType(config);
|
|
2986
|
+
}
|
|
2987
|
+
if (!selection.preset && !selection.locale?.length) {
|
|
2988
|
+
Object.assign(selection, await promptLocalePreset(config));
|
|
2989
|
+
}
|
|
2990
|
+
if (!selection.strategy) {
|
|
2991
|
+
selection.strategy = await promptStrategy();
|
|
2992
|
+
}
|
|
2993
|
+
return selection;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// cli/translate-progress.ts
|
|
2997
|
+
init_esm_shims();
|
|
2998
|
+
var dim = (text) => `\x1B[2m${text}\x1B[0m`;
|
|
2999
|
+
var green = (text) => `\x1B[32m${text}\x1B[0m`;
|
|
3000
|
+
var yellow = (text) => `\x1B[33m${text}\x1B[0m`;
|
|
3001
|
+
var red = (text) => `\x1B[31m${text}\x1B[0m`;
|
|
3002
|
+
var cyan = (text) => `\x1B[36m${text}\x1B[0m`;
|
|
3003
|
+
function isInteractive2() {
|
|
3004
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
3005
|
+
}
|
|
3006
|
+
function progressBar(ratio, width = 28) {
|
|
3007
|
+
const clamped = Math.max(0, Math.min(1, ratio));
|
|
3008
|
+
const filled = Math.round(clamped * width);
|
|
3009
|
+
return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
|
|
3010
|
+
}
|
|
3011
|
+
function labelForResult(result) {
|
|
3012
|
+
return `${result.contentType}/${result.enSlug}@${result.locale}`;
|
|
3013
|
+
}
|
|
3014
|
+
function statusForResult(result, dryRun) {
|
|
3015
|
+
if (result.failed) return red("failed");
|
|
3016
|
+
if (result.skipped) return dim("skipped");
|
|
3017
|
+
if (dryRun) return yellow("would translate");
|
|
3018
|
+
return green("translated");
|
|
3019
|
+
}
|
|
3020
|
+
function detailForResult(result) {
|
|
3021
|
+
const parts = [];
|
|
3022
|
+
if (result.durationMs !== void 0) parts.push(`${(result.durationMs / 1e3).toFixed(1)}s`);
|
|
3023
|
+
if (result.usage) {
|
|
3024
|
+
parts.push(`${formatTokenCount(result.usage.inputTokens)} in`);
|
|
3025
|
+
parts.push(`${formatTokenCount(result.usage.outputTokens)} out`);
|
|
3026
|
+
}
|
|
3027
|
+
if (result.estimatedCostUsd !== void 0) parts.push(formatUsd(result.estimatedCostUsd));
|
|
3028
|
+
if (result.error) parts.push(red(result.error));
|
|
3029
|
+
return parts.length > 0 ? dim(parts.join(" \xB7 ")) : "";
|
|
3030
|
+
}
|
|
3031
|
+
function formatSummaryLine(totals, dryRun) {
|
|
3032
|
+
const action = dryRun ? "Would translate" : "Translated";
|
|
3033
|
+
const parts = [
|
|
3034
|
+
`${action} ${totals.translated}`,
|
|
3035
|
+
`skipped ${totals.skipped}`
|
|
3036
|
+
];
|
|
3037
|
+
if (totals.failed > 0) parts.push(red(`failed ${totals.failed}`));
|
|
3038
|
+
parts.push(`${formatTokenCount(totals.inputTokens)} in / ${formatTokenCount(totals.outputTokens)} out`);
|
|
3039
|
+
parts.push(`${formatUsd(totals.estimatedCostUsd)} est.`);
|
|
3040
|
+
parts.push(`${(totals.durationMs / 1e3).toFixed(1)}s`);
|
|
3041
|
+
return parts.join(" \xB7 ");
|
|
3042
|
+
}
|
|
3043
|
+
function createTranslateProgressReporter(options = {}) {
|
|
3044
|
+
const enabled = options.enabled ?? isInteractive2();
|
|
3045
|
+
const dryRun = Boolean(options.dryRun);
|
|
3046
|
+
const recentLimit = options.recentLimit ?? 6;
|
|
3047
|
+
if (!enabled) {
|
|
3048
|
+
return {
|
|
3049
|
+
onEvent(event) {
|
|
3050
|
+
if (event.type === "item-done") {
|
|
3051
|
+
const label = labelForResult(event.result);
|
|
3052
|
+
const status = statusForResult(event.result, dryRun);
|
|
3053
|
+
const detail = detailForResult(event.result);
|
|
3054
|
+
console.log(`${label}: ${status}${detail ? ` (${detail.replace(/\x1b\[[0-9;]*m/g, "")})` : ""}`);
|
|
3055
|
+
if (event.result.failed && event.result.error) {
|
|
3056
|
+
console.error(event.result.error);
|
|
3057
|
+
}
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
if (event.type === "done") {
|
|
3061
|
+
console.log(formatSummaryLine(event.totals, dryRun));
|
|
3062
|
+
}
|
|
3063
|
+
},
|
|
3064
|
+
finish() {
|
|
3065
|
+
}
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
let total = 0;
|
|
3069
|
+
let concurrency = 1;
|
|
3070
|
+
let model;
|
|
3071
|
+
let done = 0;
|
|
3072
|
+
let inputTokens = 0;
|
|
3073
|
+
let outputTokens = 0;
|
|
3074
|
+
let estimatedCostUsd = 0;
|
|
3075
|
+
let active = [];
|
|
3076
|
+
const recent = [];
|
|
3077
|
+
let renderedLines = 0;
|
|
3078
|
+
let cursorHidden = false;
|
|
3079
|
+
function hideCursor() {
|
|
3080
|
+
if (!cursorHidden) {
|
|
3081
|
+
process.stdout.write("\x1B[?25l");
|
|
3082
|
+
cursorHidden = true;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
function showCursor() {
|
|
3086
|
+
if (cursorHidden) {
|
|
3087
|
+
process.stdout.write("\x1B[?25h");
|
|
3088
|
+
cursorHidden = false;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
function render() {
|
|
3092
|
+
hideCursor();
|
|
3093
|
+
if (renderedLines > 0) {
|
|
3094
|
+
process.stdout.write(`\x1B[${renderedLines}A`);
|
|
3095
|
+
}
|
|
3096
|
+
const lines = [];
|
|
3097
|
+
const title = dryRun ? "Dry run" : "Translating";
|
|
3098
|
+
lines.push(cyan(`${title} ${done}/${total}`) + dim(` \xB7 ${concurrency} parallel`) + (model ? dim(` \xB7 ${model}`) : ""));
|
|
3099
|
+
lines.push(progressBar(total === 0 ? 0 : done / total) + dim(` ${Math.round(total === 0 ? 0 : done / total * 100)}%`));
|
|
3100
|
+
lines.push(
|
|
3101
|
+
dim("Tokens ") + `${formatTokenCount(inputTokens)} in \xB7 ${formatTokenCount(outputTokens)} out` + dim(" \xB7 Cost ") + formatUsd(estimatedCostUsd) + dim(" est.")
|
|
3102
|
+
);
|
|
3103
|
+
if (active.length > 0) {
|
|
3104
|
+
lines.push(dim("Active ") + active.slice(0, 3).join(", ") + (active.length > 3 ? dim(` +${active.length - 3}`) : ""));
|
|
3105
|
+
} else if (done < total) {
|
|
3106
|
+
lines.push(dim("Active ") + "starting\u2026");
|
|
3107
|
+
} else {
|
|
3108
|
+
lines.push("");
|
|
3109
|
+
}
|
|
3110
|
+
lines.push("");
|
|
3111
|
+
if (recent.length === 0) {
|
|
3112
|
+
lines.push(dim("Waiting for first page\u2026"));
|
|
3113
|
+
} else {
|
|
3114
|
+
for (const result of recent) {
|
|
3115
|
+
const detail = detailForResult(result);
|
|
3116
|
+
lines.push(`${statusForResult(result, dryRun)} ${labelForResult(result)}${detail ? ` ${detail}` : ""}`);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
for (const line of lines) {
|
|
3120
|
+
process.stdout.write("\x1B[2K" + line + "\n");
|
|
3121
|
+
}
|
|
3122
|
+
renderedLines = lines.length;
|
|
3123
|
+
}
|
|
3124
|
+
return {
|
|
3125
|
+
onEvent(event) {
|
|
3126
|
+
switch (event.type) {
|
|
3127
|
+
case "start":
|
|
3128
|
+
total = event.total;
|
|
3129
|
+
concurrency = event.concurrency;
|
|
3130
|
+
model = event.model;
|
|
3131
|
+
render();
|
|
3132
|
+
break;
|
|
3133
|
+
case "item-start":
|
|
3134
|
+
active = event.active;
|
|
3135
|
+
render();
|
|
3136
|
+
break;
|
|
3137
|
+
case "item-done": {
|
|
3138
|
+
done += 1;
|
|
3139
|
+
active = active.filter((label) => label !== labelForResult(event.result));
|
|
3140
|
+
inputTokens += event.result.usage?.inputTokens ?? 0;
|
|
3141
|
+
outputTokens += event.result.usage?.outputTokens ?? 0;
|
|
3142
|
+
estimatedCostUsd += event.result.estimatedCostUsd ?? 0;
|
|
3143
|
+
recent.unshift(event.result);
|
|
3144
|
+
recent.splice(recentLimit);
|
|
3145
|
+
render();
|
|
3146
|
+
break;
|
|
3147
|
+
}
|
|
3148
|
+
case "done":
|
|
3149
|
+
showCursor();
|
|
3150
|
+
if (renderedLines > 0) {
|
|
3151
|
+
process.stdout.write(`\x1B[${renderedLines}A`);
|
|
3152
|
+
}
|
|
3153
|
+
process.stdout.write("\x1B[2K" + green("Done") + " \xB7 " + formatSummaryLine(event.totals, dryRun) + "\n");
|
|
3154
|
+
renderedLines = 0;
|
|
3155
|
+
for (const result of event.results.filter((entry) => entry.failed)) {
|
|
3156
|
+
process.stdout.write("\x1B[2K" + red(`${labelForResult(result)}: ${result.error ?? "failed"}`) + "\n");
|
|
3157
|
+
}
|
|
3158
|
+
break;
|
|
3159
|
+
}
|
|
3160
|
+
},
|
|
3161
|
+
finish() {
|
|
3162
|
+
showCursor();
|
|
3163
|
+
}
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
|
|
2127
3167
|
// cli/index.ts
|
|
2128
3168
|
function parseArgs(argv) {
|
|
2129
3169
|
const options = { cwd: process.cwd() };
|
|
@@ -2180,6 +3220,35 @@ function parseArgs(argv) {
|
|
|
2180
3220
|
i++;
|
|
2181
3221
|
continue;
|
|
2182
3222
|
}
|
|
3223
|
+
if (arg === "--concurrency") {
|
|
3224
|
+
options.concurrency = Number(argv[++i]);
|
|
3225
|
+
i++;
|
|
3226
|
+
continue;
|
|
3227
|
+
}
|
|
3228
|
+
if (arg === "--no-progress") {
|
|
3229
|
+
options.noProgress = true;
|
|
3230
|
+
i++;
|
|
3231
|
+
continue;
|
|
3232
|
+
}
|
|
3233
|
+
if (arg === "--strategy") {
|
|
3234
|
+
const value = argv[++i];
|
|
3235
|
+
if (value !== "all" && value !== "missing-only") {
|
|
3236
|
+
throw new Error(`Unknown strategy: ${value} (expected all or missing-only)`);
|
|
3237
|
+
}
|
|
3238
|
+
options.strategy = value;
|
|
3239
|
+
i++;
|
|
3240
|
+
continue;
|
|
3241
|
+
}
|
|
3242
|
+
if (arg === "--out") {
|
|
3243
|
+
options.out = argv[++i];
|
|
3244
|
+
i++;
|
|
3245
|
+
continue;
|
|
3246
|
+
}
|
|
3247
|
+
if (arg === "--extension") {
|
|
3248
|
+
options.extension = argv[++i];
|
|
3249
|
+
i++;
|
|
3250
|
+
continue;
|
|
3251
|
+
}
|
|
2183
3252
|
if (arg.startsWith("--")) {
|
|
2184
3253
|
throw new Error(`Unknown flag: ${arg}`);
|
|
2185
3254
|
}
|
|
@@ -2205,9 +3274,28 @@ async function main() {
|
|
|
2205
3274
|
Commands:
|
|
2206
3275
|
status Show EN docs + translation counts
|
|
2207
3276
|
validate Validate EN files and sqlite consistency
|
|
3277
|
+
export-static Write raw MDX files for static hosting
|
|
2208
3278
|
translate Translate stale/missing locale pages
|
|
2209
3279
|
history <type> <slug> Show revision timeline
|
|
2210
3280
|
studio Start read-only local studio
|
|
3281
|
+
|
|
3282
|
+
Export-static flags:
|
|
3283
|
+
--out <dir> Output directory (default: public)
|
|
3284
|
+
--extension <ext> File extension (default: mdx)
|
|
3285
|
+
--type <id> Comma-separated content types (default: all routable)
|
|
3286
|
+
--locale <code>... Locales to export (default: all configured)
|
|
3287
|
+
|
|
3288
|
+
Translate flags:
|
|
3289
|
+
--type <id> Content type (interactive picker in a TTY when omitted)
|
|
3290
|
+
--preset <name> Locale preset from config (interactive picker in a TTY)
|
|
3291
|
+
--locale <code>... Target locale(s); overrides --preset
|
|
3292
|
+
--slug <en-slug> Single English document
|
|
3293
|
+
--model <id> Gemini model override
|
|
3294
|
+
--concurrency <n> Parallel translations (default: 3)
|
|
3295
|
+
--dry-run List work without writing
|
|
3296
|
+
--force Re-translate even when hashes match
|
|
3297
|
+
--strategy <mode> all (default) or missing-only
|
|
3298
|
+
--no-progress Plain line logging instead of live progress
|
|
2211
3299
|
`);
|
|
2212
3300
|
return;
|
|
2213
3301
|
}
|
|
@@ -2237,24 +3325,49 @@ Commands:
|
|
|
2237
3325
|
if (!result.ok) process.exitCode = 1;
|
|
2238
3326
|
break;
|
|
2239
3327
|
}
|
|
3328
|
+
case "export-static": {
|
|
3329
|
+
const types = options.type?.split(",").map((t) => t.trim()).filter(Boolean);
|
|
3330
|
+
const { written } = writeStaticRawExports(project, {
|
|
3331
|
+
outDir: options.out,
|
|
3332
|
+
extension: options.extension ? `.${options.extension.replace(/^\./, "")}` : void 0,
|
|
3333
|
+
types,
|
|
3334
|
+
locales: options.locale
|
|
3335
|
+
});
|
|
3336
|
+
console.log(`Wrote ${written} static raw exports to ${options.out ?? "public"}`);
|
|
3337
|
+
break;
|
|
3338
|
+
}
|
|
2240
3339
|
case "translate": {
|
|
2241
|
-
const
|
|
3340
|
+
const selection = await promptTranslateSelection(config, {
|
|
3341
|
+
type: options.type,
|
|
3342
|
+
preset: options.preset,
|
|
3343
|
+
locale: options.locale,
|
|
3344
|
+
strategy: options.strategy
|
|
3345
|
+
});
|
|
3346
|
+
const locales = resolveLocalesFromPreset(config, selection.preset, selection.locale);
|
|
2242
3347
|
const worklist = buildWorklist(config, {
|
|
2243
|
-
contentType:
|
|
3348
|
+
contentType: selection.contentType,
|
|
2244
3349
|
locales,
|
|
2245
|
-
enSlug: options.slug
|
|
3350
|
+
enSlug: options.slug,
|
|
3351
|
+
strategy: selection.strategy ?? "all"
|
|
3352
|
+
});
|
|
3353
|
+
if (worklist.length === 0) {
|
|
3354
|
+
console.log("Nothing to translate.");
|
|
3355
|
+
break;
|
|
3356
|
+
}
|
|
3357
|
+
const reporter = createTranslateProgressReporter({
|
|
3358
|
+
enabled: !options.noProgress,
|
|
3359
|
+
dryRun: options.dryRun
|
|
2246
3360
|
});
|
|
2247
|
-
console.log(`Translating ${worklist.length} page(s)...`);
|
|
2248
3361
|
const results = await translateWorklist(config, worklist, {
|
|
2249
3362
|
model: options.model,
|
|
2250
3363
|
dryRun: options.dryRun,
|
|
2251
|
-
force: options.force
|
|
3364
|
+
force: options.force,
|
|
3365
|
+
concurrency: options.concurrency,
|
|
3366
|
+
onProgress: reporter.onEvent
|
|
2252
3367
|
});
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
);
|
|
2257
|
-
}
|
|
3368
|
+
reporter.finish();
|
|
3369
|
+
const failed = results.filter((result) => result.failed);
|
|
3370
|
+
if (failed.length > 0) process.exitCode = 1;
|
|
2258
3371
|
break;
|
|
2259
3372
|
}
|
|
2260
3373
|
case "history": {
|