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.
Files changed (64) hide show
  1. package/README.md +2 -3
  2. package/bin/scribe.js +2 -11
  3. package/dist/cli/index.cjs +1437 -225
  4. package/dist/cli/index.cjs.map +1 -1
  5. package/dist/cli/index.js +1437 -226
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/prompt-translate.d.ts +16 -0
  8. package/dist/cli/prompt-translate.d.ts.map +1 -0
  9. package/dist/cli/translate-progress.d.ts +11 -0
  10. package/dist/cli/translate-progress.d.ts.map +1 -0
  11. package/dist/index.cjs +591 -147
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.js +587 -148
  14. package/dist/index.js.map +1 -1
  15. package/dist/runtime.cjs +14 -2
  16. package/dist/runtime.cjs.map +1 -1
  17. package/dist/runtime.js +14 -2
  18. package/dist/runtime.js.map +1 -1
  19. package/dist/src/config/resolve-config.d.ts.map +1 -1
  20. package/dist/src/core/field.d.ts +2 -0
  21. package/dist/src/core/field.d.ts.map +1 -1
  22. package/dist/src/core/types.d.ts +5 -1
  23. package/dist/src/core/types.d.ts.map +1 -1
  24. package/dist/src/export/build-static-raw-exports.d.ts +37 -0
  25. package/dist/src/export/build-static-raw-exports.d.ts.map +1 -0
  26. package/dist/src/export/write-static-raw-exports.d.ts +12 -0
  27. package/dist/src/export/write-static-raw-exports.d.ts.map +1 -0
  28. package/dist/src/history/record-revision.d.ts +2 -0
  29. package/dist/src/history/record-revision.d.ts.map +1 -1
  30. package/dist/src/index.d.ts +7 -0
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/storage/sqlite.d.ts.map +1 -1
  33. package/dist/src/storage/translations.d.ts +6 -0
  34. package/dist/src/storage/translations.d.ts.map +1 -1
  35. package/dist/src/translate/gemini-client.d.ts +7 -0
  36. package/dist/src/translate/gemini-client.d.ts.map +1 -1
  37. package/dist/src/translate/gemini-models.d.ts +8 -0
  38. package/dist/src/translate/gemini-models.d.ts.map +1 -0
  39. package/dist/src/translate/gemini-pricing.d.ts +11 -0
  40. package/dist/src/translate/gemini-pricing.d.ts.map +1 -0
  41. package/dist/src/translate/page-translator.d.ts +38 -1
  42. package/dist/src/translate/page-translator.d.ts.map +1 -1
  43. package/dist/src/translate/prompts/translation-prompt.d.ts +1 -2
  44. package/dist/src/translate/prompts/translation-prompt.d.ts.map +1 -1
  45. package/dist/src/translate/response-schema.d.ts +7 -0
  46. package/dist/src/translate/response-schema.d.ts.map +1 -0
  47. package/dist/src/translate/validate-translation.d.ts +14 -0
  48. package/dist/src/translate/validate-translation.d.ts.map +1 -0
  49. package/dist/src/translate/worklist.d.ts +3 -0
  50. package/dist/src/translate/worklist.d.ts.map +1 -1
  51. package/dist/src/validate/validate-assets.d.ts +20 -0
  52. package/dist/src/validate/validate-assets.d.ts.map +1 -0
  53. package/dist/src/validate/validate-project.d.ts +1 -1
  54. package/dist/src/validate/validate-project.d.ts.map +1 -1
  55. package/dist/src/version.d.ts +4 -0
  56. package/dist/src/version.d.ts.map +1 -0
  57. package/dist/studio/server.cjs +611 -194
  58. package/dist/studio/server.cjs.map +1 -1
  59. package/dist/studio/server.d.ts +1 -1
  60. package/dist/studio/server.d.ts.map +1 -1
  61. package/dist/studio/server.js +611 -194
  62. package/dist/studio/server.js.map +1 -1
  63. package/package.json +16 -5
  64. package/bin/ensure-built.mjs +0 -31
package/dist/index.js CHANGED
@@ -98,6 +98,22 @@ function unwrapSchema(schema) {
98
98
  }
99
99
  return current;
100
100
  }
101
+ function peelOptionalWrappers(schema) {
102
+ let current = schema;
103
+ for (let i = 0; i < 8; i++) {
104
+ const type = current._def?.type;
105
+ if (type === "optional" || type === "nullable") {
106
+ current = current.unwrap();
107
+ continue;
108
+ }
109
+ if (type === "default") {
110
+ current = current.removeDefault();
111
+ continue;
112
+ }
113
+ break;
114
+ }
115
+ return current;
116
+ }
101
117
 
102
118
  // src/core/types.ts
103
119
  function defineConfig(config) {
@@ -112,14 +128,14 @@ var SLUG_PLACEHOLDER = "{slug}";
112
128
  function isRoutableType(type) {
113
129
  return typeof type.path === "string" && type.path.length > 0;
114
130
  }
115
- function assertValidPathTemplate(path9, typeId) {
116
- if (!path9.startsWith("/")) {
117
- throw new Error(`Content type "${typeId}": path must start with / (got "${path9}")`);
131
+ function assertValidPathTemplate(path11, typeId) {
132
+ if (!path11.startsWith("/")) {
133
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path11}")`);
118
134
  }
119
- const count = (path9.match(/\{slug\}/g) ?? []).length;
135
+ const count = (path11.match(/\{slug\}/g) ?? []).length;
120
136
  if (count !== 1) {
121
137
  throw new Error(
122
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path9}")`
138
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path11}")`
123
139
  );
124
140
  }
125
141
  }
@@ -171,6 +187,7 @@ function resolveConfig(input, baseDir) {
171
187
  const projectRoot = path3.resolve(baseDir ?? process.cwd(), raw.rootDir);
172
188
  const contentRoot = path3.resolve(projectRoot, raw.contentDir ?? "content");
173
189
  const storePath = path3.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
190
+ const assetsPath = raw.assetsDir ? path3.resolve(projectRoot, raw.assetsDir) : void 0;
174
191
  const seenIds = /* @__PURE__ */ new Set();
175
192
  const types = raw.types.map((type) => {
176
193
  if (seenIds.has(type.id)) {
@@ -191,6 +208,7 @@ function resolveConfig(input, baseDir) {
191
208
  const config = {
192
209
  rootDir: contentRoot,
193
210
  storePath,
211
+ assetsPath,
194
212
  locales: [...raw.locales],
195
213
  defaultLocale,
196
214
  localePresets: raw.localePresets,
@@ -243,10 +261,10 @@ function introspectSchema(schema, prefix = []) {
243
261
  }
244
262
  function extractByPaths(data, paths) {
245
263
  const out = {};
246
- for (const path9 of paths) {
247
- const value = getAtPath(data, path9);
264
+ for (const path11 of paths) {
265
+ const value = getAtPath(data, path11);
248
266
  if (value !== void 0) {
249
- setAtPath(out, path9.filter((p) => p !== "*"), value);
267
+ setAtPath(out, path11.filter((p) => p !== "*"), value);
250
268
  }
251
269
  }
252
270
  return out;
@@ -264,13 +282,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
264
282
  const translatable = pickTranslatable(localeData, schema);
265
283
  return deepMerge(structural, translatable);
266
284
  }
267
- function getAtPath(obj, path9) {
285
+ function getAtPath(obj, path11) {
268
286
  let current = obj;
269
- for (const segment of path9) {
287
+ for (const segment of path11) {
270
288
  if (segment === "*") {
271
289
  if (!Array.isArray(current)) return void 0;
272
290
  return current.map(
273
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path9.slice(path9.indexOf("*") + 1)) : void 0
291
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path11.slice(path11.indexOf("*") + 1)) : void 0
274
292
  );
275
293
  }
276
294
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -278,13 +296,13 @@ function getAtPath(obj, path9) {
278
296
  }
279
297
  return current;
280
298
  }
281
- function setAtPath(obj, path9, value) {
282
- if (path9.length === 0) return;
283
- if (path9.length === 1) {
284
- obj[path9[0]] = value;
299
+ function setAtPath(obj, path11, value) {
300
+ if (path11.length === 0) return;
301
+ if (path11.length === 1) {
302
+ obj[path11[0]] = value;
285
303
  return;
286
304
  }
287
- const [head, ...rest] = path9;
305
+ const [head, ...rest] = path11;
288
306
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
289
307
  obj[head] = {};
290
308
  }
@@ -480,7 +498,7 @@ function seoFieldsFromEn(enDoc) {
480
498
  redirectTo: enDoc.redirectTo
481
499
  };
482
500
  }
483
- var SCHEMA_VERSION = 2;
501
+ var SCHEMA_VERSION = 3;
484
502
  var MIGRATIONS = [
485
503
  `CREATE TABLE IF NOT EXISTS meta (
486
504
  key TEXT PRIMARY KEY,
@@ -537,17 +555,27 @@ function resolveStorePath(config) {
537
555
  }
538
556
  function openStore(config, mode = "readwrite") {
539
557
  const storePath = resolveStorePath(config);
540
- fs2.mkdirSync(path3.dirname(storePath), { recursive: true });
558
+ if (mode === "readwrite") {
559
+ fs2.mkdirSync(path3.dirname(storePath), { recursive: true });
560
+ }
541
561
  const db = new Database(storePath, { readonly: mode === "readonly" });
542
562
  if (mode === "readwrite") {
543
563
  migrate(db);
544
564
  }
545
565
  return db;
546
566
  }
567
+ function addColumnIfMissing(db, table, column, ddlType) {
568
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
569
+ if (!columns.some((c) => c.name === column)) {
570
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
571
+ }
572
+ }
547
573
  function migrate(db) {
548
574
  for (const sql of MIGRATIONS) {
549
575
  db.exec(sql);
550
576
  }
577
+ addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
578
+ addColumnIfMissing(db, "revisions", "body", "TEXT");
551
579
  db.prepare(
552
580
  `INSERT INTO meta(key, value) VALUES('schema_version', ?)
553
581
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
@@ -598,8 +626,9 @@ function bulkLoadTranslations(db) {
598
626
  function appendRevision(db, input) {
599
627
  const result = db.prepare(
600
628
  `INSERT INTO revisions (
601
- content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at, model, body_preview
602
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
629
+ content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at,
630
+ model, body_preview, frontmatter_json, body
631
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
603
632
  ).run(
604
633
  input.contentType,
605
634
  input.enSlug,
@@ -609,7 +638,9 @@ function appendRevision(db, input) {
609
638
  input.bodyHash,
610
639
  input.createdAt,
611
640
  input.model ?? null,
612
- input.bodyPreview ?? null
641
+ input.bodyPreview ?? null,
642
+ input.frontmatterJson ?? null,
643
+ input.body ?? null
613
644
  );
614
645
  return Number(result.lastInsertRowid);
615
646
  }
@@ -1542,8 +1573,8 @@ function getRedirectSourceSlugs(project) {
1542
1573
  // src/sitemap/join-base-url.ts
1543
1574
  function joinBaseUrl(baseUrl, pathname) {
1544
1575
  const origin = baseUrl.replace(/\/$/, "");
1545
- const path9 = pathname.startsWith("/") ? pathname : `/${pathname}`;
1546
- return `${origin}${path9}`;
1576
+ const path11 = pathname.startsWith("/") ? pathname : `/${pathname}`;
1577
+ return `${origin}${path11}`;
1547
1578
  }
1548
1579
 
1549
1580
  // src/sitemap/generate-sitemap.ts
@@ -1687,34 +1718,6 @@ function loadConfigSync(options = {}) {
1687
1718
  }
1688
1719
  return resolveConfig(config, path3.dirname(configPath));
1689
1720
  }
1690
- function sha256(input) {
1691
- return createHash("sha256").update(input, "utf8").digest("hex");
1692
- }
1693
- function computePageEnHash(translatableFrontmatter, body) {
1694
- const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1695
- return sha256(payload);
1696
- }
1697
- function computeBodyHash(body) {
1698
- return sha256(body);
1699
- }
1700
-
1701
- // src/history/record-revision.ts
1702
- function recordRevision(config, input) {
1703
- const db = openStore(config, "readwrite");
1704
- const id = appendRevision(db, {
1705
- contentType: input.contentType,
1706
- enSlug: input.enSlug,
1707
- locale: input.locale,
1708
- revisionKind: input.revisionKind,
1709
- enHash: input.enHash,
1710
- bodyHash: computeBodyHash(input.body),
1711
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1712
- model: input.model,
1713
- bodyPreview: input.body.slice(0, 200)
1714
- });
1715
- db.close();
1716
- return id;
1717
- }
1718
1721
  function getAtPath2(obj, fieldPath) {
1719
1722
  let current = obj;
1720
1723
  for (const segment of fieldPath) {
@@ -1794,6 +1797,72 @@ function validateRelations(project) {
1794
1797
  }
1795
1798
  return issues;
1796
1799
  }
1800
+ var IMAGE_EXT = String.raw`(?:png|jpe?g|webp|gif|svg)`;
1801
+ var IMAGE_WEB_PATH = String.raw`\/[\w\-./]+\.${IMAGE_EXT}`;
1802
+ var MARKDOWN_IMAGE_RE = new RegExp(
1803
+ String.raw`!\[[^\]]*\]\((${IMAGE_WEB_PATH})\)`,
1804
+ "gi"
1805
+ );
1806
+ var HTML_SRC_RE = new RegExp(String.raw`src=["'](${IMAGE_WEB_PATH})["']`, "gi");
1807
+ function isImageWebPath(value) {
1808
+ return new RegExp(String.raw`^${IMAGE_WEB_PATH}$`, "i").test(value);
1809
+ }
1810
+ function isSiteAssetPath(webPath) {
1811
+ const segment = webPath.split("/")[1];
1812
+ return Boolean(segment && !segment.includes("."));
1813
+ }
1814
+ function addImagePath(webPath, out) {
1815
+ if (isSiteAssetPath(webPath)) out.add(webPath);
1816
+ }
1817
+ function collectBodyImagePaths(body, out) {
1818
+ for (const re of [MARKDOWN_IMAGE_RE, HTML_SRC_RE]) {
1819
+ for (const match of body.matchAll(re)) {
1820
+ addImagePath(match[1], out);
1821
+ }
1822
+ }
1823
+ }
1824
+ function collectFrontmatterImagePaths(value, out) {
1825
+ if (typeof value === "string") {
1826
+ if (isImageWebPath(value)) addImagePath(value, out);
1827
+ return;
1828
+ }
1829
+ if (Array.isArray(value)) {
1830
+ for (const item of value) collectFrontmatterImagePaths(item, out);
1831
+ return;
1832
+ }
1833
+ if (value && typeof value === "object") {
1834
+ for (const nested of Object.values(value)) {
1835
+ collectFrontmatterImagePaths(nested, out);
1836
+ }
1837
+ }
1838
+ }
1839
+ function collectImagePaths(frontmatter, body) {
1840
+ const paths = /* @__PURE__ */ new Set();
1841
+ collectFrontmatterImagePaths(frontmatter, paths);
1842
+ collectBodyImagePaths(body, paths);
1843
+ return [...paths].sort();
1844
+ }
1845
+ function assetFilePath(assetsPath, webPath) {
1846
+ return path3.join(assetsPath, webPath.replace(/^\//, ""));
1847
+ }
1848
+ function validateDocumentAssets(config, input) {
1849
+ const assetsPath = config.assetsPath;
1850
+ if (!assetsPath) return [];
1851
+ const issues = [];
1852
+ for (const webPath of collectImagePaths(input.frontmatter, input.body)) {
1853
+ const filePath = assetFilePath(assetsPath, webPath);
1854
+ if (fs2.existsSync(filePath)) continue;
1855
+ issues.push({
1856
+ level: "warning",
1857
+ contentType: input.contentType,
1858
+ enSlug: input.enSlug,
1859
+ locale: input.locale,
1860
+ field: "asset",
1861
+ message: `Missing image asset ${webPath} (expected ${filePath})`
1862
+ });
1863
+ }
1864
+ return issues;
1865
+ }
1797
1866
 
1798
1867
  // src/validate/validate-slug-suffix.ts
1799
1868
  function validateTranslationSlugSuffixes(config, db) {
@@ -1856,8 +1925,6 @@ function validateProject(config) {
1856
1925
  });
1857
1926
  continue;
1858
1927
  }
1859
- const payload = getTranslatablePayload(enDoc, type);
1860
- const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1861
1928
  const crossIssues = type.crossValidate?.(enDoc.frontmatter, {
1862
1929
  locale: config.defaultLocale,
1863
1930
  defaultLocale: config.defaultLocale,
@@ -1875,28 +1942,18 @@ function validateProject(config) {
1875
1942
  message: issue.message
1876
1943
  });
1877
1944
  }
1945
+ for (const issue of validateDocumentAssets(config, {
1946
+ contentType: type.id,
1947
+ enSlug,
1948
+ frontmatter: enDoc.frontmatter,
1949
+ body: enDoc.content
1950
+ })) {
1951
+ issues.push(issue);
1952
+ }
1878
1953
  for (const locale of config.locales) {
1879
1954
  if (locale === config.defaultLocale) continue;
1880
1955
  const row = getTranslation(db, type.id, enSlug, locale);
1881
1956
  if (!row) continue;
1882
- if (row.en_hash !== currentEnHash) {
1883
- issues.push({
1884
- level: "warning",
1885
- contentType: type.id,
1886
- enSlug,
1887
- locale,
1888
- field: "en_hash",
1889
- message: "Translation is stale (en_hash mismatch)"
1890
- });
1891
- recordRevision(config, {
1892
- contentType: type.id,
1893
- enSlug,
1894
- locale,
1895
- revisionKind: "en_edit_detected",
1896
- enHash: currentEnHash,
1897
- body: row.body
1898
- });
1899
- }
1900
1957
  const localeFm = JSON.parse(row.frontmatter_json);
1901
1958
  for (const issue of validateLocaleBuiltinFields(localeFm)) {
1902
1959
  issues.push({
@@ -1908,6 +1965,15 @@ function validateProject(config) {
1908
1965
  message: issue.message
1909
1966
  });
1910
1967
  }
1968
+ for (const issue of validateDocumentAssets(config, {
1969
+ contentType: type.id,
1970
+ enSlug,
1971
+ locale,
1972
+ frontmatter: localeFm,
1973
+ body: row.body
1974
+ })) {
1975
+ issues.push(issue);
1976
+ }
1911
1977
  }
1912
1978
  }
1913
1979
  try {
@@ -1975,6 +2041,18 @@ function validateProject(config) {
1975
2041
  }
1976
2042
  return { ok: issues.every((i) => i.level !== "error"), issues };
1977
2043
  }
2044
+ function sha256(input) {
2045
+ return createHash("sha256").update(input, "utf8").digest("hex");
2046
+ }
2047
+ function computePageEnHash(translatableFrontmatter, body) {
2048
+ const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
2049
+ return sha256(payload);
2050
+ }
2051
+ function computeBodyHash(body) {
2052
+ return sha256(body);
2053
+ }
2054
+
2055
+ // src/translate/worklist.ts
1978
2056
  function listEnSlugs3(rootDir, contentDir) {
1979
2057
  const dir = path3.join(rootDir, contentDir);
1980
2058
  if (!fs2.existsSync(dir)) return [];
@@ -2019,6 +2097,10 @@ function buildWorklist(config, options = {}) {
2019
2097
  }
2020
2098
  }
2021
2099
  db.close();
2100
+ const strategy = options.strategy ?? "all";
2101
+ if (strategy === "missing-only") {
2102
+ return items.filter((item) => item.reason === "missing");
2103
+ }
2022
2104
  return items;
2023
2105
  }
2024
2106
  function resolveLocalesFromPreset(config, preset, explicitLocales) {
@@ -2028,7 +2110,50 @@ function resolveLocalesFromPreset(config, preset, explicitLocales) {
2028
2110
  }
2029
2111
  return config.locales.filter((l) => l !== config.defaultLocale);
2030
2112
  }
2031
- var DEFAULT_MODEL = "gemini-2.5-pro";
2113
+
2114
+ // src/history/record-revision.ts
2115
+ function recordRevision(config, input) {
2116
+ const db = openStore(config, "readwrite");
2117
+ const id = appendRevision(db, {
2118
+ contentType: input.contentType,
2119
+ enSlug: input.enSlug,
2120
+ locale: input.locale,
2121
+ revisionKind: input.revisionKind,
2122
+ enHash: input.enHash,
2123
+ bodyHash: computeBodyHash(input.body),
2124
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2125
+ model: input.model,
2126
+ bodyPreview: input.body.slice(0, 200),
2127
+ frontmatterJson: input.frontmatter ? JSON.stringify(input.frontmatter) : null,
2128
+ body: input.body
2129
+ });
2130
+ db.close();
2131
+ return id;
2132
+ }
2133
+
2134
+ // src/translate/gemini-models.ts
2135
+ var GEMINI_MODEL_IDS = {
2136
+ "gemini-2.5-pro": "gemini-2.5-pro",
2137
+ "gemini-3.1-pro": "gemini-3.1-pro-preview"
2138
+ };
2139
+ var DEFAULT_GEMINI_MODEL = "gemini-3.1-pro";
2140
+ function stripModelsPrefix(model) {
2141
+ return model.replace(/^models\//, "");
2142
+ }
2143
+ function resolveGeminiModelId(model) {
2144
+ const name = stripModelsPrefix(model);
2145
+ return GEMINI_MODEL_IDS[name] ?? name;
2146
+ }
2147
+ function normalizeGeminiDisplayName(model) {
2148
+ const name = stripModelsPrefix(model);
2149
+ if (name in GEMINI_MODEL_IDS) return name;
2150
+ const alias = Object.entries(GEMINI_MODEL_IDS).find(([, apiId]) => apiId === name);
2151
+ if (alias) return alias[0];
2152
+ return name;
2153
+ }
2154
+
2155
+ // src/translate/gemini-client.ts
2156
+ var DEFAULT_MODEL = DEFAULT_GEMINI_MODEL;
2032
2157
  function extractJson(text) {
2033
2158
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
2034
2159
  if (fenced?.[1]) return fenced[1].trim();
@@ -2042,13 +2167,17 @@ async function translatePageWithGemini(input) {
2042
2167
  if (!apiKey) {
2043
2168
  throw new Error("GEMINI_API_KEY is required for scribe translate");
2044
2169
  }
2045
- const model = input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL;
2170
+ const displayModel = normalizeGeminiDisplayName(
2171
+ input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL
2172
+ );
2173
+ const apiModel = resolveGeminiModelId(displayModel);
2046
2174
  const ai = new GoogleGenAI({ apiKey });
2047
2175
  const response = await ai.models.generateContent({
2048
- model,
2176
+ model: apiModel,
2049
2177
  contents: input.prompt,
2050
2178
  config: {
2051
- responseMimeType: "application/json"
2179
+ responseMimeType: "application/json",
2180
+ ...input.responseSchema ? { responseSchema: input.responseSchema } : {}
2052
2181
  }
2053
2182
  });
2054
2183
  const raw = response.text ?? "";
@@ -2056,7 +2185,38 @@ async function translatePageWithGemini(input) {
2056
2185
  if (!parsed.frontmatter || typeof parsed.body !== "string") {
2057
2186
  throw new Error("Gemini response missing frontmatter/body");
2058
2187
  }
2059
- return { model, raw, parsed };
2188
+ const usageMetadata = response.usageMetadata;
2189
+ const usage = {
2190
+ inputTokens: usageMetadata?.promptTokenCount ?? 0,
2191
+ outputTokens: usageMetadata?.candidatesTokenCount ?? 0,
2192
+ totalTokens: usageMetadata?.totalTokenCount ?? 0
2193
+ };
2194
+ return { model: displayModel, raw, parsed, usage };
2195
+ }
2196
+
2197
+ // src/translate/gemini-pricing.ts
2198
+ var CONTEXT_TIER_TOKENS = 2e5;
2199
+ var GEMINI_PRICING_USD = {
2200
+ "gemini-3.1-pro": { input: [2, 4], output: [12, 18] }
2201
+ };
2202
+ function normalizeModelId(model) {
2203
+ return model.replace(/^models\//, "").toLowerCase();
2204
+ }
2205
+ function tierRate(tokens, tiers) {
2206
+ return tokens <= CONTEXT_TIER_TOKENS ? tiers[0] : tiers[1];
2207
+ }
2208
+ function resolveModelPricing(model) {
2209
+ const id = normalizeModelId(model);
2210
+ if (GEMINI_PRICING_USD[id]) return GEMINI_PRICING_USD[id];
2211
+ const match = Object.entries(GEMINI_PRICING_USD).find(([key]) => id.includes(key));
2212
+ return match?.[1];
2213
+ }
2214
+ function estimateTranslationCostUsd(model, inputTokens, outputTokens) {
2215
+ const pricing = resolveModelPricing(model);
2216
+ if (!pricing) return void 0;
2217
+ const inputRate = tierRate(inputTokens, pricing.input);
2218
+ const outputRate = tierRate(outputTokens, pricing.output);
2219
+ return inputTokens / 1e6 * inputRate + outputTokens / 1e6 * outputRate;
2060
2220
  }
2061
2221
 
2062
2222
  // src/translate/prompts/translation-prompt.ts
@@ -2092,6 +2252,7 @@ function buildPageTranslationPrompt(input) {
2092
2252
  prompt,
2093
2253
  "",
2094
2254
  ...input.resolved.context ? ["## Context", input.resolved.context, ""] : [],
2255
+ ...input.contextLabel ? [`Document: ${input.contextLabel}`, ""] : [],
2095
2256
  "## Rules",
2096
2257
  ...input.resolved.rules.map((rule) => `- ${rule}`),
2097
2258
  "",
@@ -2099,10 +2260,6 @@ function buildPageTranslationPrompt(input) {
2099
2260
  "Return ONLY valid JSON with keys:",
2100
2261
  input.slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).",
2101
2262
  "",
2102
- "## EN metadata",
2103
- `title: ${input.enTitle}`,
2104
- `description: ${input.enDescription}`,
2105
- "",
2106
2263
  "## EN translatable frontmatter (JSON)",
2107
2264
  JSON.stringify(input.translatableFrontmatter, null, 2),
2108
2265
  "",
@@ -2111,6 +2268,71 @@ function buildPageTranslationPrompt(input) {
2111
2268
  ];
2112
2269
  return lines.join("\n");
2113
2270
  }
2271
+ function getObjectShape(schema) {
2272
+ const base = peelOptionalWrappers(schema);
2273
+ if (base instanceof Object && "shape" in base) {
2274
+ return base.shape;
2275
+ }
2276
+ return null;
2277
+ }
2278
+ function getArraySchema(schema) {
2279
+ const base = peelOptionalWrappers(schema);
2280
+ if (base instanceof Object && "element" in base && base._def?.type === "array") {
2281
+ return base;
2282
+ }
2283
+ return null;
2284
+ }
2285
+ function extractTranslatableFromStructural(schema) {
2286
+ const arraySchema = getArraySchema(schema);
2287
+ if (arraySchema) {
2288
+ const inner = buildTranslatableSubschema(arraySchema.element);
2289
+ if (inner) return z.array(inner);
2290
+ return null;
2291
+ }
2292
+ if (getObjectShape(schema)) {
2293
+ return buildTranslatableSubschema(schema);
2294
+ }
2295
+ return null;
2296
+ }
2297
+ function buildTranslatableSubschema(schema) {
2298
+ const shape = getObjectShape(schema);
2299
+ if (!shape) return null;
2300
+ const out = {};
2301
+ for (const [key, child] of Object.entries(shape)) {
2302
+ const childSchema = child;
2303
+ const kind = getFieldKind(childSchema);
2304
+ if (kind === "translatable") {
2305
+ out[key] = peelOptionalWrappers(childSchema);
2306
+ } else if (kind === "structural") {
2307
+ const nested = extractTranslatableFromStructural(childSchema);
2308
+ if (nested) out[key] = nested;
2309
+ }
2310
+ }
2311
+ if (Object.keys(out).length === 0) return null;
2312
+ return z.object(out);
2313
+ }
2314
+ function buildGeminiResponseSchema(schema, slugStrategy) {
2315
+ try {
2316
+ const translatable = buildTranslatableSubschema(schema);
2317
+ if (!translatable) return null;
2318
+ const responseShape = {
2319
+ frontmatter: translatable,
2320
+ body: z.string()
2321
+ };
2322
+ if (slugStrategy === "localized") {
2323
+ responseShape.slug = z.string();
2324
+ }
2325
+ const jsonSchema = z.toJSONSchema(z.object(responseShape), {
2326
+ target: "draft-2020-12",
2327
+ unrepresentable: "any",
2328
+ io: "output"
2329
+ });
2330
+ delete jsonSchema.$schema;
2331
+ return jsonSchema;
2332
+ } catch {
2333
+ return null;
2334
+ }
2335
+ }
2114
2336
 
2115
2337
  // src/translate/resolve-translate-config.ts
2116
2338
  function slugStrategyRules(slugStrategy, preserveTerms) {
@@ -2151,88 +2373,305 @@ function resolveTranslateConfig(config, type) {
2151
2373
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
2152
2374
  }
2153
2375
 
2376
+ // src/translate/validate-translation.ts
2377
+ function formatZodIssues(error) {
2378
+ return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
2379
+ }
2380
+ function sanitizeTranslatedFrontmatter(rawFrontmatter, schema) {
2381
+ return pickTranslatable(rawFrontmatter, schema);
2382
+ }
2383
+ function validateTranslatedFrontmatter(enDoc, localeFrontmatter, typeSchema) {
2384
+ const frontmatter = sanitizeTranslatedFrontmatter(localeFrontmatter, typeSchema);
2385
+ const merged = mergeStructuralOntoLocale(
2386
+ frontmatter,
2387
+ enDoc.frontmatter,
2388
+ typeSchema
2389
+ );
2390
+ const parsed = typeSchema.safeParse(merged);
2391
+ if (!parsed.success) {
2392
+ return { ok: false, error: formatZodIssues(parsed.error) };
2393
+ }
2394
+ return { ok: true, frontmatter };
2395
+ }
2396
+
2154
2397
  // src/translate/page-translator.ts
2398
+ function summarizeResults(results, durationMs) {
2399
+ return results.reduce(
2400
+ (totals, result) => {
2401
+ if (result.failed) totals.failed += 1;
2402
+ else if (result.skipped) totals.skipped += 1;
2403
+ else totals.translated += 1;
2404
+ totals.inputTokens += result.usage?.inputTokens ?? 0;
2405
+ totals.outputTokens += result.usage?.outputTokens ?? 0;
2406
+ totals.estimatedCostUsd += result.estimatedCostUsd ?? 0;
2407
+ return totals;
2408
+ },
2409
+ {
2410
+ translated: 0,
2411
+ skipped: 0,
2412
+ failed: 0,
2413
+ inputTokens: 0,
2414
+ outputTokens: 0,
2415
+ estimatedCostUsd: 0,
2416
+ durationMs
2417
+ }
2418
+ );
2419
+ }
2420
+ function formatTranslateError(error) {
2421
+ const message = error instanceof Error ? error.message : String(error);
2422
+ try {
2423
+ const parsed = JSON.parse(message);
2424
+ if (parsed.error?.message) return parsed.error.message;
2425
+ } catch {
2426
+ }
2427
+ const embedded = message.match(/"message"\s*:\s*"((?:\\.|[^"\\])*)"/);
2428
+ if (embedded?.[1]) {
2429
+ return embedded[1].replace(/\\"/g, '"').replace(/\\n/g, "\n");
2430
+ }
2431
+ return message.length > 160 ? `${message.slice(0, 157)}\u2026` : message;
2432
+ }
2433
+ async function runPool(items, concurrency, worker) {
2434
+ if (items.length === 0) return;
2435
+ let nextIndex = 0;
2436
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
2437
+ while (nextIndex < items.length) {
2438
+ const index = nextIndex++;
2439
+ await worker(items[index], index);
2440
+ }
2441
+ });
2442
+ await Promise.all(runners);
2443
+ }
2444
+ function resolveContextLabel(enDoc, enSlug) {
2445
+ const fm = enDoc.frontmatter;
2446
+ for (const key of ["title", "name", "h1"]) {
2447
+ const value = fm[key];
2448
+ if (typeof value === "string" && value.trim()) return value;
2449
+ }
2450
+ return enSlug;
2451
+ }
2155
2452
  async function translatePage(config, item, options = {}) {
2156
- const type = config.types.find((t) => t.id === item.contentType);
2157
- if (!type) throw new Error(`Unknown content type ${item.contentType}`);
2158
- const enDoc = readEnDocument(config, type, item.enSlug);
2159
- if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
2160
- const payload = getTranslatablePayload(enDoc, type);
2161
- const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
2162
- const db = openStore(config, "readonly");
2163
- const existing = getTranslation(db, type.id, item.enSlug, item.locale);
2164
- db.close();
2165
- if (!options.force && existing && existing.en_hash === currentEnHash) {
2166
- return {
2167
- contentType: item.contentType,
2453
+ const startedAt = Date.now();
2454
+ const base = {
2455
+ contentType: item.contentType,
2456
+ enSlug: item.enSlug,
2457
+ locale: item.locale
2458
+ };
2459
+ try {
2460
+ const type = config.types.find((t) => t.id === item.contentType);
2461
+ if (!type) throw new Error(`Unknown content type ${item.contentType}`);
2462
+ const enDoc = readEnDocument(config, type, item.enSlug);
2463
+ if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
2464
+ const payload = getTranslatablePayload(enDoc, type);
2465
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
2466
+ const db = openStore(config, "readonly");
2467
+ const existing = getTranslation(db, type.id, item.enSlug, item.locale);
2468
+ db.close();
2469
+ if (!options.force && existing && existing.en_hash === currentEnHash) {
2470
+ return {
2471
+ ...base,
2472
+ skipped: true,
2473
+ reason: "fresh",
2474
+ durationMs: Date.now() - startedAt
2475
+ };
2476
+ }
2477
+ const resolvedTranslate = resolveTranslateConfig(config, type);
2478
+ const model = options.model ?? resolvedTranslate.model;
2479
+ const prompt = buildPageTranslationPrompt({
2480
+ resolved: resolvedTranslate,
2481
+ targetLocale: item.locale,
2482
+ contextLabel: resolveContextLabel(enDoc, item.enSlug),
2483
+ translatableFrontmatter: payload.frontmatter,
2484
+ enBody: payload.body,
2485
+ slugStrategy: type.slugStrategy
2486
+ });
2487
+ if (options.dryRun) {
2488
+ return {
2489
+ ...base,
2490
+ skipped: false,
2491
+ model,
2492
+ durationMs: Date.now() - startedAt
2493
+ };
2494
+ }
2495
+ const responseSchema = buildGeminiResponseSchema(type.schema, type.slugStrategy);
2496
+ const result = await translatePageWithGemini({
2497
+ prompt,
2498
+ model,
2499
+ responseSchema: responseSchema ?? void 0
2500
+ });
2501
+ const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
2502
+ const validated = validateTranslatedFrontmatter(enDoc, result.parsed.frontmatter, type.schema);
2503
+ if (!validated.ok) {
2504
+ throw new Error(`Translation validation failed: ${validated.error}`);
2505
+ }
2506
+ const writeDb = openStore(config, "readwrite");
2507
+ upsertTranslation(writeDb, {
2508
+ contentType: type.id,
2168
2509
  enSlug: item.enSlug,
2169
2510
  locale: item.locale,
2170
- skipped: true,
2171
- reason: "fresh"
2172
- };
2173
- }
2174
- const resolvedTranslate = resolveTranslateConfig(config, type);
2175
- const prompt = buildPageTranslationPrompt({
2176
- resolved: resolvedTranslate,
2177
- targetLocale: item.locale,
2178
- enTitle: String(enDoc.frontmatter.title ?? item.enSlug),
2179
- enDescription: String(enDoc.frontmatter.description ?? ""),
2180
- translatableFrontmatter: payload.frontmatter,
2181
- enBody: payload.body,
2182
- slugStrategy: type.slugStrategy
2183
- });
2184
- if (options.dryRun) {
2185
- return {
2186
- contentType: item.contentType,
2511
+ slug,
2512
+ frontmatter: validated.frontmatter,
2513
+ body: result.parsed.body,
2514
+ enHash: currentEnHash,
2515
+ translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2516
+ model: result.model
2517
+ });
2518
+ writeDb.close();
2519
+ recordRevision(config, {
2520
+ contentType: type.id,
2187
2521
  enSlug: item.enSlug,
2188
2522
  locale: item.locale,
2523
+ revisionKind: "translation",
2524
+ enHash: currentEnHash,
2525
+ body: result.parsed.body,
2526
+ frontmatter: validated.frontmatter,
2527
+ model: result.model
2528
+ });
2529
+ const estimatedCostUsd = estimateTranslationCostUsd(
2530
+ normalizeGeminiDisplayName(result.model),
2531
+ result.usage.inputTokens,
2532
+ result.usage.outputTokens
2533
+ );
2534
+ return {
2535
+ ...base,
2189
2536
  skipped: false,
2190
- model: options.model
2537
+ model: result.model,
2538
+ usage: result.usage,
2539
+ estimatedCostUsd,
2540
+ durationMs: Date.now() - startedAt
2541
+ };
2542
+ } catch (error) {
2543
+ return {
2544
+ ...base,
2545
+ skipped: false,
2546
+ failed: true,
2547
+ error: formatTranslateError(error),
2548
+ durationMs: Date.now() - startedAt
2191
2549
  };
2192
2550
  }
2193
- const result = await translatePageWithGemini({
2194
- prompt,
2195
- model: options.model ?? resolvedTranslate.model
2196
- });
2197
- const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
2198
- const writeDb = openStore(config, "readwrite");
2199
- upsertTranslation(writeDb, {
2200
- contentType: type.id,
2201
- enSlug: item.enSlug,
2202
- locale: item.locale,
2203
- slug,
2204
- frontmatter: result.parsed.frontmatter,
2205
- body: result.parsed.body,
2206
- enHash: currentEnHash,
2207
- translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2208
- model: result.model
2551
+ }
2552
+ function labelForItem(item) {
2553
+ return `${item.contentType}/${item.enSlug}@${item.locale}`;
2554
+ }
2555
+ async function translateWorklist(config, items, options = {}) {
2556
+ const concurrency = Math.max(1, options.concurrency ?? 3);
2557
+ const startedAt = Date.now();
2558
+ const results = new Array(items.length);
2559
+ const active = /* @__PURE__ */ new Set();
2560
+ options.onProgress?.({
2561
+ type: "start",
2562
+ total: items.length,
2563
+ concurrency,
2564
+ dryRun: Boolean(options.dryRun),
2565
+ model: options.model
2209
2566
  });
2210
- writeDb.close();
2211
- recordRevision(config, {
2212
- contentType: type.id,
2213
- enSlug: item.enSlug,
2214
- locale: item.locale,
2215
- revisionKind: "translation",
2216
- enHash: currentEnHash,
2217
- body: result.parsed.body,
2218
- model: result.model
2567
+ await runPool(items, concurrency, async (item, index) => {
2568
+ const label = labelForItem(item);
2569
+ active.add(label);
2570
+ options.onProgress?.({ type: "item-start", item, active: [...active] });
2571
+ const result = await translatePage(config, item, options);
2572
+ results[index] = result;
2573
+ active.delete(label);
2574
+ options.onProgress?.({ type: "item-done", result });
2219
2575
  });
2220
- return {
2221
- contentType: item.contentType,
2222
- enSlug: item.enSlug,
2223
- locale: item.locale,
2224
- skipped: false,
2225
- model: result.model
2226
- };
2576
+ const totals = summarizeResults(results, Date.now() - startedAt);
2577
+ options.onProgress?.({ type: "done", results, totals });
2578
+ return results;
2227
2579
  }
2228
- async function translateWorklist(config, items, options = {}) {
2229
- const results = [];
2230
- for (const item of items) {
2231
- results.push(await translatePage(config, item, options));
2580
+ function serializeMdx(frontmatter, content) {
2581
+ return matter.stringify(content, frontmatter);
2582
+ }
2583
+
2584
+ // src/export/build-static-raw-exports.ts
2585
+ function normalizeExtension(ext) {
2586
+ return ext.startsWith(".") ? ext : `.${ext}`;
2587
+ }
2588
+ function exportDirSegment(pathTemplate) {
2589
+ const prefix = pathPrefix(pathTemplate);
2590
+ return prefix.replace(/^\/+|\/+$/g, "");
2591
+ }
2592
+ function getStaticExportRoots(project, options = {}) {
2593
+ const { config } = project;
2594
+ const locales = options.locales ?? config.locales;
2595
+ const typeFilter = options.types ? new Set(options.types) : null;
2596
+ const roots = /* @__PURE__ */ new Set();
2597
+ for (const type of project.listTypes()) {
2598
+ if (!isRoutableType(type.config)) continue;
2599
+ if (typeFilter && !typeFilter.has(type.id)) continue;
2600
+ const segment = exportDirSegment(type.config.path);
2601
+ roots.add(`${segment}/`);
2602
+ for (const locale of locales) {
2603
+ if (locale === config.defaultLocale) continue;
2604
+ roots.add(`${locale}/${segment}/`);
2605
+ }
2232
2606
  }
2233
- return results;
2607
+ return [...roots].sort();
2608
+ }
2609
+ function buildStaticRawExports(project, options = {}) {
2610
+ const { config } = project;
2611
+ const extension = normalizeExtension(options.extension ?? ".mdx");
2612
+ const locales = options.locales ?? config.locales;
2613
+ const excludeRedirected = options.excludeRedirected ?? true;
2614
+ const excludeNoindex = options.excludeNoindex ?? false;
2615
+ const typeFilter = options.types ? new Set(options.types) : null;
2616
+ const out = [];
2617
+ for (const type of project.listTypes()) {
2618
+ if (!isRoutableType(type.config)) continue;
2619
+ if (typeFilter && !typeFilter.has(type.id)) continue;
2620
+ const pathTemplate = type.config.path;
2621
+ const all = type.load();
2622
+ const enIdx = all.get(config.defaultLocale);
2623
+ if (!enIdx) continue;
2624
+ for (const locale of locales) {
2625
+ for (const enDoc of enIdx.bySlug.values()) {
2626
+ const resolved = type.resolve(enDoc.slug, locale);
2627
+ if (!resolved.document) continue;
2628
+ if (excludeRedirected && resolved.shouldRedirectTo) continue;
2629
+ if (excludeNoindex && resolved.document.noindex) continue;
2630
+ const doc = resolved.document;
2631
+ const slugWithExt = `${doc.slug}${extension}`;
2632
+ const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2633
+ out.push({
2634
+ relativePath: urlPath.slice(1),
2635
+ urlPath,
2636
+ locale,
2637
+ typeId: type.id,
2638
+ enSlug: doc.enSlug,
2639
+ source: serializeMdx(doc.frontmatter, doc.content)
2640
+ });
2641
+ }
2642
+ }
2643
+ }
2644
+ return out;
2645
+ }
2646
+ function rmDirIfExists(dir) {
2647
+ if (fs2.existsSync(dir)) {
2648
+ fs2.rmSync(dir, { recursive: true, force: true });
2649
+ }
2650
+ }
2651
+ function writeStaticRawExports(project, options = {}) {
2652
+ const outDir = path3.resolve(options.outDir ?? "public");
2653
+ const typeFilter = options.types;
2654
+ for (const root of getStaticExportRoots(project, {
2655
+ types: typeFilter,
2656
+ locales: options.locales
2657
+ })) {
2658
+ rmDirIfExists(path3.join(outDir, root));
2659
+ }
2660
+ const exports = buildStaticRawExports(project, options);
2661
+ const counts = /* @__PURE__ */ new Map();
2662
+ for (const item of exports) {
2663
+ const filePath = path3.join(outDir, item.relativePath);
2664
+ fs2.mkdirSync(path3.dirname(filePath), { recursive: true });
2665
+ fs2.writeFileSync(filePath, item.source, "utf8");
2666
+ const key = `${item.typeId}/${item.locale}`;
2667
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2668
+ }
2669
+ for (const [key, count] of [...counts.entries()].sort()) {
2670
+ console.log(` ${key}: ${count}`);
2671
+ }
2672
+ return { exports, written: exports.length };
2234
2673
  }
2235
2674
 
2236
- export { buildAllContentRedirects, buildWorklist, createProject, createScribe, defineConfig, defineContentType, field, findConfigPath, generateSitemap, getFieldKind, getRedirectSourceSlugs, getRelationTarget, isResolvedConfig, loadConfigSync, resolveConfig, resolveLocalesFromPreset, translatePage, translateWorklist, unwrapSchema, validateProject };
2675
+ export { buildAllContentRedirects, buildStaticRawExports, buildWorklist, createProject, createScribe, defineConfig, defineContentType, exportDirSegment, field, findConfigPath, generateSitemap, getFieldKind, getRedirectSourceSlugs, getRelationTarget, getStaticExportRoots, isResolvedConfig, loadConfigSync, resolveConfig, resolveLocalesFromPreset, serializeMdx, translatePage, translateWorklist, unwrapSchema, validateProject, writeStaticRawExports };
2237
2676
  //# sourceMappingURL=index.js.map
2238
2677
  //# sourceMappingURL=index.js.map