scribe-cms 0.0.1

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 (96) hide show
  1. package/README.md +150 -0
  2. package/dist/cli/index.cjs +2303 -0
  3. package/dist/cli/index.cjs.map +1 -0
  4. package/dist/cli/index.d.ts +3 -0
  5. package/dist/cli/index.d.ts.map +1 -0
  6. package/dist/cli/index.js +2289 -0
  7. package/dist/cli/index.js.map +1 -0
  8. package/dist/index.cjs +2268 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +2238 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/runtime.cjs +1216 -0
  13. package/dist/runtime.cjs.map +1 -0
  14. package/dist/runtime.js +1207 -0
  15. package/dist/runtime.js.map +1 -0
  16. package/dist/src/config/load-config.d.ts +10 -0
  17. package/dist/src/config/load-config.d.ts.map +1 -0
  18. package/dist/src/config/load-env.d.ts +3 -0
  19. package/dist/src/config/load-env.d.ts.map +1 -0
  20. package/dist/src/config/resolve-config.d.ts +13 -0
  21. package/dist/src/config/resolve-config.d.ts.map +1 -0
  22. package/dist/src/core/alias-helpers.d.ts +7 -0
  23. package/dist/src/core/alias-helpers.d.ts.map +1 -0
  24. package/dist/src/core/builtin-fields.d.ts +38 -0
  25. package/dist/src/core/builtin-fields.d.ts.map +1 -0
  26. package/dist/src/core/field.d.ts +60 -0
  27. package/dist/src/core/field.d.ts.map +1 -0
  28. package/dist/src/core/introspect-schema.d.ts +20 -0
  29. package/dist/src/core/introspect-schema.d.ts.map +1 -0
  30. package/dist/src/core/slug-aliases.d.ts +27 -0
  31. package/dist/src/core/slug-aliases.d.ts.map +1 -0
  32. package/dist/src/core/types.d.ts +261 -0
  33. package/dist/src/core/types.d.ts.map +1 -0
  34. package/dist/src/create-project.d.ts +10 -0
  35. package/dist/src/create-project.d.ts.map +1 -0
  36. package/dist/src/create-scribe.d.ts +11 -0
  37. package/dist/src/create-scribe.d.ts.map +1 -0
  38. package/dist/src/hash/page-hash.d.ts +7 -0
  39. package/dist/src/hash/page-hash.d.ts.map +1 -0
  40. package/dist/src/history/record-revision.d.ts +12 -0
  41. package/dist/src/history/record-revision.d.ts.map +1 -0
  42. package/dist/src/i18n/build-url.d.ts +14 -0
  43. package/dist/src/i18n/build-url.d.ts.map +1 -0
  44. package/dist/src/i18n/resolve-document.d.ts +12 -0
  45. package/dist/src/i18n/resolve-document.d.ts.map +1 -0
  46. package/dist/src/i18n/translation-index.d.ts +7 -0
  47. package/dist/src/i18n/translation-index.d.ts.map +1 -0
  48. package/dist/src/index.d.ts +18 -0
  49. package/dist/src/index.d.ts.map +1 -0
  50. package/dist/src/loader/create-loader.d.ts +17 -0
  51. package/dist/src/loader/create-loader.d.ts.map +1 -0
  52. package/dist/src/loader/normalize-en.d.ts +3 -0
  53. package/dist/src/loader/normalize-en.d.ts.map +1 -0
  54. package/dist/src/loader/parse-mdx.d.ts +7 -0
  55. package/dist/src/loader/parse-mdx.d.ts.map +1 -0
  56. package/dist/src/redirects/build-redirects.d.ts +17 -0
  57. package/dist/src/redirects/build-redirects.d.ts.map +1 -0
  58. package/dist/src/redirects/translation-index.d.ts +4 -0
  59. package/dist/src/redirects/translation-index.d.ts.map +1 -0
  60. package/dist/src/redirects/types.d.ts +6 -0
  61. package/dist/src/redirects/types.d.ts.map +1 -0
  62. package/dist/src/runtime.d.ts +5 -0
  63. package/dist/src/runtime.d.ts.map +1 -0
  64. package/dist/src/sitemap/generate-sitemap.d.ts +5 -0
  65. package/dist/src/sitemap/generate-sitemap.d.ts.map +1 -0
  66. package/dist/src/sitemap/join-base-url.d.ts +3 -0
  67. package/dist/src/sitemap/join-base-url.d.ts.map +1 -0
  68. package/dist/src/sitemap/types.d.ts +39 -0
  69. package/dist/src/sitemap/types.d.ts.map +1 -0
  70. package/dist/src/storage/sqlite.d.ts +9 -0
  71. package/dist/src/storage/sqlite.d.ts.map +1 -0
  72. package/dist/src/storage/translations.d.ts +58 -0
  73. package/dist/src/storage/translations.d.ts.map +1 -0
  74. package/dist/src/translate/gemini-client.d.ts +15 -0
  75. package/dist/src/translate/gemini-client.d.ts.map +1 -0
  76. package/dist/src/translate/page-translator.d.ts +24 -0
  77. package/dist/src/translate/page-translator.d.ts.map +1 -0
  78. package/dist/src/translate/prompts/translation-prompt.d.ts +14 -0
  79. package/dist/src/translate/prompts/translation-prompt.d.ts.map +1 -0
  80. package/dist/src/translate/resolve-translate-config.d.ts +10 -0
  81. package/dist/src/translate/resolve-translate-config.d.ts.map +1 -0
  82. package/dist/src/translate/worklist.d.ts +19 -0
  83. package/dist/src/translate/worklist.d.ts.map +1 -0
  84. package/dist/src/validate/validate-project.d.ts +16 -0
  85. package/dist/src/validate/validate-project.d.ts.map +1 -0
  86. package/dist/src/validate/validate-relations.d.ts +19 -0
  87. package/dist/src/validate/validate-relations.d.ts.map +1 -0
  88. package/dist/src/validate/validate-slug-suffix.d.ts +12 -0
  89. package/dist/src/validate/validate-slug-suffix.d.ts.map +1 -0
  90. package/dist/studio/server.cjs +673 -0
  91. package/dist/studio/server.cjs.map +1 -0
  92. package/dist/studio/server.d.ts +7 -0
  93. package/dist/studio/server.d.ts.map +1 -0
  94. package/dist/studio/server.js +664 -0
  95. package/dist/studio/server.js.map +1 -0
  96. package/package.json +70 -0
@@ -0,0 +1,2289 @@
1
+ #!/usr/bin/env node
2
+ import path4 from 'path';
3
+ import 'url';
4
+ import fs2 from 'fs';
5
+ import Database from 'better-sqlite3';
6
+ import { z } from 'zod';
7
+ import matter from 'gray-matter';
8
+ import { createJiti } from 'jiti';
9
+ import { createHash } from 'crypto';
10
+ import { GoogleGenAI } from '@google/genai';
11
+ import dotenv from 'dotenv';
12
+ import { serve } from '@hono/node-server';
13
+ import { Hono } from 'hono';
14
+
15
+ var __defProp = Object.defineProperty;
16
+ var __getOwnPropNames = Object.getOwnPropertyNames;
17
+ var __esm = (fn, res) => function __init() {
18
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
19
+ };
20
+ var __export = (target, all) => {
21
+ for (var name in all)
22
+ __defProp(target, name, { get: all[name], enumerable: true });
23
+ };
24
+ var init_esm_shims = __esm({
25
+ "../../node_modules/tsup/assets/esm_shims.js"() {
26
+ }
27
+ });
28
+
29
+ // src/storage/sqlite.ts
30
+ var sqlite_exports = {};
31
+ __export(sqlite_exports, {
32
+ closeStore: () => closeStore,
33
+ openStore: () => openStore,
34
+ resolveStorePath: () => resolveStorePath
35
+ });
36
+ function resolveStorePath(config) {
37
+ return config.storePath;
38
+ }
39
+ function openStore(config, mode = "readwrite") {
40
+ const storePath = resolveStorePath(config);
41
+ fs2.mkdirSync(path4.dirname(storePath), { recursive: true });
42
+ const db = new Database(storePath, { readonly: mode === "readonly" });
43
+ if (mode === "readwrite") {
44
+ migrate(db);
45
+ }
46
+ return db;
47
+ }
48
+ function migrate(db) {
49
+ for (const sql of MIGRATIONS) {
50
+ db.exec(sql);
51
+ }
52
+ db.prepare(
53
+ `INSERT INTO meta(key, value) VALUES('schema_version', ?)
54
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
55
+ ).run(String(SCHEMA_VERSION));
56
+ }
57
+ function closeStore(db) {
58
+ db.close();
59
+ }
60
+ var SCHEMA_VERSION, MIGRATIONS;
61
+ var init_sqlite = __esm({
62
+ "src/storage/sqlite.ts"() {
63
+ init_esm_shims();
64
+ SCHEMA_VERSION = 2;
65
+ MIGRATIONS = [
66
+ `CREATE TABLE IF NOT EXISTS meta (
67
+ key TEXT PRIMARY KEY,
68
+ value TEXT NOT NULL
69
+ )`,
70
+ `CREATE TABLE IF NOT EXISTS translations (
71
+ content_type TEXT NOT NULL,
72
+ en_slug TEXT NOT NULL,
73
+ locale TEXT NOT NULL,
74
+ slug TEXT NOT NULL,
75
+ frontmatter_json TEXT NOT NULL,
76
+ body TEXT NOT NULL,
77
+ en_hash TEXT NOT NULL,
78
+ translated_at TEXT NOT NULL,
79
+ model TEXT NOT NULL,
80
+ PRIMARY KEY (content_type, en_slug, locale)
81
+ )`,
82
+ `CREATE INDEX IF NOT EXISTS idx_translations_type_locale
83
+ ON translations(content_type, locale)`,
84
+ `CREATE TABLE IF NOT EXISTS revisions (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ content_type TEXT NOT NULL,
87
+ en_slug TEXT NOT NULL,
88
+ locale TEXT,
89
+ revision_kind TEXT NOT NULL,
90
+ en_hash TEXT NOT NULL,
91
+ body_hash TEXT NOT NULL,
92
+ created_at TEXT NOT NULL,
93
+ model TEXT,
94
+ body_preview TEXT
95
+ )`,
96
+ `CREATE INDEX IF NOT EXISTS idx_revisions_lookup
97
+ ON revisions(content_type, en_slug, locale, created_at DESC)`,
98
+ `CREATE TABLE IF NOT EXISTS slug_aliases (
99
+ content_type TEXT NOT NULL,
100
+ canonical_en_slug TEXT NOT NULL,
101
+ alias_en_slug TEXT NOT NULL,
102
+ created_at TEXT NOT NULL,
103
+ PRIMARY KEY (content_type, alias_en_slug)
104
+ )`,
105
+ `CREATE INDEX IF NOT EXISTS idx_slug_aliases_canonical
106
+ ON slug_aliases(content_type, canonical_en_slug)`,
107
+ `CREATE TABLE IF NOT EXISTS alias_locale_slugs (
108
+ content_type TEXT NOT NULL,
109
+ alias_en_slug TEXT NOT NULL,
110
+ locale TEXT NOT NULL,
111
+ locale_slug TEXT NOT NULL,
112
+ captured_at TEXT NOT NULL,
113
+ PRIMARY KEY (content_type, alias_en_slug, locale)
114
+ )`
115
+ ];
116
+ }
117
+ });
118
+
119
+ // cli/index.ts
120
+ init_esm_shims();
121
+
122
+ // src/index.ts
123
+ init_esm_shims();
124
+
125
+ // src/core/field.ts
126
+ init_esm_shims();
127
+ var FIELD_KIND = /* @__PURE__ */ Symbol.for("@genlook/scribe/fieldKind");
128
+ var RELATION_META = /* @__PURE__ */ Symbol.for("@genlook/scribe/relationMeta");
129
+ function getFieldKind(schema) {
130
+ const tagged = schema;
131
+ return tagged[FIELD_KIND] ?? "structural";
132
+ }
133
+ function getRelationTarget(schema) {
134
+ let current = schema;
135
+ for (let i = 0; i < 8; i++) {
136
+ const tagged = current;
137
+ if (tagged[RELATION_META]) {
138
+ return tagged[RELATION_META];
139
+ }
140
+ if (typeof tagged.unwrap === "function") {
141
+ current = tagged.unwrap();
142
+ continue;
143
+ }
144
+ if (typeof tagged.removeDefault === "function") {
145
+ current = tagged.removeDefault();
146
+ continue;
147
+ }
148
+ if (tagged._def?.innerType) {
149
+ current = tagged._def.innerType;
150
+ continue;
151
+ }
152
+ break;
153
+ }
154
+ return null;
155
+ }
156
+ function unwrapSchema(schema) {
157
+ let current = schema;
158
+ for (let i = 0; i < 8; i++) {
159
+ const anySchema = current;
160
+ if (typeof anySchema.unwrap === "function") {
161
+ current = anySchema.unwrap();
162
+ continue;
163
+ }
164
+ if (typeof anySchema.removeDefault === "function") {
165
+ current = anySchema.removeDefault();
166
+ continue;
167
+ }
168
+ if (anySchema._def?.innerType) {
169
+ current = anySchema._def.innerType;
170
+ continue;
171
+ }
172
+ break;
173
+ }
174
+ return current;
175
+ }
176
+
177
+ // src/core/types.ts
178
+ init_esm_shims();
179
+
180
+ // src/create-scribe.ts
181
+ init_esm_shims();
182
+
183
+ // src/config/resolve-config.ts
184
+ init_esm_shims();
185
+
186
+ // src/i18n/build-url.ts
187
+ init_esm_shims();
188
+ var SLUG_PLACEHOLDER = "{slug}";
189
+ function isRoutableType(type) {
190
+ return typeof type.path === "string" && type.path.length > 0;
191
+ }
192
+ function assertValidPathTemplate(path11, typeId) {
193
+ if (!path11.startsWith("/")) {
194
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path11}")`);
195
+ }
196
+ const count = (path11.match(/\{slug\}/g) ?? []).length;
197
+ if (count !== 1) {
198
+ throw new Error(
199
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path11}")`
200
+ );
201
+ }
202
+ }
203
+ function pathPrefix(template) {
204
+ const idx = template.indexOf(SLUG_PLACEHOLDER);
205
+ if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
206
+ return template.slice(0, idx);
207
+ }
208
+ function pathSuffix(template) {
209
+ const idx = template.indexOf(SLUG_PLACEHOLDER);
210
+ if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
211
+ return template.slice(idx + SLUG_PLACEHOLDER.length);
212
+ }
213
+ function resolvePath(template, slug, locale, defaultLocale) {
214
+ const relative = template.replace(SLUG_PLACEHOLDER, slug);
215
+ if (locale === defaultLocale) return relative;
216
+ return `/${locale}${relative}`;
217
+ }
218
+ function extractSlugFromResolvedPath(template, resolvedPath) {
219
+ const prefix = pathPrefix(template);
220
+ const suffix = pathSuffix(template);
221
+ if (!resolvedPath.startsWith(prefix)) return null;
222
+ if (suffix && !resolvedPath.endsWith(suffix)) return null;
223
+ const slugEnd = suffix ? resolvedPath.length - suffix.length : resolvedPath.length;
224
+ const slug = resolvedPath.slice(prefix.length, slugEnd);
225
+ return slug.length > 0 ? slug : null;
226
+ }
227
+
228
+ // src/config/resolve-config.ts
229
+ var RESOLVED = /* @__PURE__ */ Symbol.for("@genlook/scribe/resolvedConfig");
230
+ function isResolvedConfig(config) {
231
+ return typeof config === "object" && config !== null && config[RESOLVED] === true;
232
+ }
233
+ function resolveConfig(input, baseDir) {
234
+ if (isResolvedConfig(input)) return input;
235
+ const raw = input;
236
+ if (!raw.rootDir) {
237
+ throw new Error("scribe config: rootDir is required");
238
+ }
239
+ if (!raw.locales || raw.locales.length === 0) {
240
+ throw new Error("scribe config: locales must be a non-empty array");
241
+ }
242
+ const defaultLocale = raw.defaultLocale ?? "en";
243
+ if (!raw.locales.includes(defaultLocale)) {
244
+ throw new Error(
245
+ `scribe config: defaultLocale "${defaultLocale}" is not in locales [${raw.locales.join(", ")}]`
246
+ );
247
+ }
248
+ const projectRoot = path4.resolve(baseDir ?? process.cwd(), raw.rootDir);
249
+ const contentRoot = path4.resolve(projectRoot, raw.contentDir ?? "content");
250
+ const storePath = path4.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
251
+ const seenIds = /* @__PURE__ */ new Set();
252
+ const types = raw.types.map((type) => {
253
+ if (seenIds.has(type.id)) {
254
+ throw new Error(`scribe config: duplicate content type id "${type.id}"`);
255
+ }
256
+ seenIds.add(type.id);
257
+ if (type.path) {
258
+ assertValidPathTemplate(type.path, type.id);
259
+ }
260
+ return {
261
+ ...type,
262
+ contentDir: type.contentDir ?? type.id,
263
+ label: type.label ?? type.id.charAt(0).toUpperCase() + type.id.slice(1),
264
+ slugStrategy: type.slugStrategy ?? "fixed",
265
+ indexFallback: type.indexFallback ?? (type.path ? "en" : "none")
266
+ };
267
+ });
268
+ const config = {
269
+ rootDir: contentRoot,
270
+ storePath,
271
+ locales: [...raw.locales],
272
+ defaultLocale,
273
+ localePresets: raw.localePresets,
274
+ translate: raw.translate,
275
+ types
276
+ };
277
+ Object.defineProperty(config, RESOLVED, {
278
+ value: true,
279
+ enumerable: false
280
+ });
281
+ return config;
282
+ }
283
+
284
+ // src/create-project.ts
285
+ init_esm_shims();
286
+
287
+ // src/core/introspect-schema.ts
288
+ init_esm_shims();
289
+ function introspectSchema(schema, prefix = []) {
290
+ const relation = getRelationTarget(schema);
291
+ if (relation && prefix.length > 0) {
292
+ return [
293
+ {
294
+ path: prefix,
295
+ kind: "relation",
296
+ relationTarget: relation.typeId,
297
+ relationMultiple: relation.multiple,
298
+ relationOptional: relation.optional
299
+ }
300
+ ];
301
+ }
302
+ const base = unwrapSchema(schema);
303
+ if (base instanceof Object && "shape" in base) {
304
+ const shape = base.shape;
305
+ const fields = [];
306
+ for (const [key, child] of Object.entries(shape)) {
307
+ fields.push(...introspectSchema(child, [...prefix, key]));
308
+ }
309
+ return fields;
310
+ }
311
+ if (base instanceof Object && "element" in base) {
312
+ const element = base.element;
313
+ const elementBase = unwrapSchema(element);
314
+ if (elementBase instanceof Object && "shape" in elementBase) {
315
+ const shape = elementBase.shape;
316
+ const fields = [];
317
+ for (const [key, child] of Object.entries(shape)) {
318
+ fields.push(...introspectSchema(child, [...prefix, "*", key]));
319
+ }
320
+ return fields;
321
+ }
322
+ }
323
+ return [{ path: prefix, kind: getFieldKind(schema) }];
324
+ }
325
+ function extractByPaths(data, paths) {
326
+ const out = {};
327
+ for (const path11 of paths) {
328
+ const value = getAtPath(data, path11);
329
+ if (value !== void 0) {
330
+ setAtPath(out, path11.filter((p) => p !== "*"), value);
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+ function pickTranslatable(data, schema) {
336
+ const translatablePaths = introspectSchema(schema).filter((f) => f.kind === "translatable").map((f) => f.path);
337
+ return extractByPaths(data, translatablePaths);
338
+ }
339
+ function pickStructural(data, schema) {
340
+ const structuralPaths = introspectSchema(schema).filter((f) => f.kind === "structural" || f.kind === "relation").map((f) => f.path);
341
+ return extractByPaths(data, structuralPaths);
342
+ }
343
+ function mergeStructuralOntoLocale(localeData, enData, schema) {
344
+ const structural = pickStructural(enData, schema);
345
+ const translatable = pickTranslatable(localeData, schema);
346
+ return deepMerge(structural, translatable);
347
+ }
348
+ function getAtPath(obj, path11) {
349
+ let current = obj;
350
+ for (const segment of path11) {
351
+ if (segment === "*") {
352
+ if (!Array.isArray(current)) return void 0;
353
+ return current.map(
354
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path11.slice(path11.indexOf("*") + 1)) : void 0
355
+ );
356
+ }
357
+ if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
358
+ current = current[segment];
359
+ }
360
+ return current;
361
+ }
362
+ function setAtPath(obj, path11, value) {
363
+ if (path11.length === 0) return;
364
+ if (path11.length === 1) {
365
+ obj[path11[0]] = value;
366
+ return;
367
+ }
368
+ const [head, ...rest] = path11;
369
+ if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
370
+ obj[head] = {};
371
+ }
372
+ setAtPath(obj[head], rest, value);
373
+ }
374
+ function deepMerge(base, overlay) {
375
+ const out = { ...base };
376
+ for (const [key, value] of Object.entries(overlay)) {
377
+ if (value && typeof value === "object" && !Array.isArray(value) && out[key] && typeof out[key] === "object" && !Array.isArray(out[key])) {
378
+ out[key] = deepMerge(out[key], value);
379
+ } else if (Array.isArray(value) && Array.isArray(out[key])) {
380
+ out[key] = mergeArrayOverlay(out[key], value);
381
+ } else {
382
+ out[key] = value;
383
+ }
384
+ }
385
+ return out;
386
+ }
387
+ function mergeArrayOverlay(base, overlay) {
388
+ return base.map((item, index) => {
389
+ const overlayItem = overlay[index];
390
+ if (item && typeof item === "object" && !Array.isArray(item) && overlayItem && typeof overlayItem === "object" && !Array.isArray(overlayItem)) {
391
+ return deepMerge(item, overlayItem);
392
+ }
393
+ return overlayItem ?? item;
394
+ });
395
+ }
396
+ function listRelationFields(schema) {
397
+ return introspectSchema(schema).filter((f) => f.kind === "relation");
398
+ }
399
+
400
+ // src/loader/create-loader.ts
401
+ init_esm_shims();
402
+
403
+ // src/core/builtin-fields.ts
404
+ init_esm_shims();
405
+ var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
406
+ var slugPatternSchema = z.string().regex(SLUG_PATTERN, "slug must be lowercase-kebab-case");
407
+ var isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]*)?$/, "Use ISO date YYYY-MM-DD or full ISO 8601");
408
+ var canonicalPathSchema = z.string().regex(/^\//, "canonicalPath must start with /");
409
+ var LOCALE_BUILTIN_KEYS = [
410
+ ["publishedAt", "publishedAt is EN-only; inherited from the EN parent at load time"],
411
+ ["updatedAt", "updatedAt is EN-only; inherited from the EN parent at load time"],
412
+ ["noindex", "noindex is EN-only; inherited from the EN parent at load time"],
413
+ ["canonicalPath", "canonicalPath is EN-only; inherited from the EN parent at load time"],
414
+ ["aliases", "aliases is EN-only; remove from locale translation"],
415
+ ["redirect_to", "redirect_to is EN-only; remove from locale translation"],
416
+ ["translationOf", "translationOf is internal; remove from locale translation"],
417
+ ["enSlug", "enSlug is internal; remove from locale translation"]
418
+ ];
419
+ function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
420
+ const issues = [];
421
+ const rest = { ...data };
422
+ const aliasesResult = z.array(slugPatternSchema).max(20).optional().default([]).safeParse(rest.aliases ?? []);
423
+ delete rest.aliases;
424
+ const redirectRaw = rest.redirect_to;
425
+ delete rest.redirect_to;
426
+ let redirectTo;
427
+ if (redirectRaw !== void 0 && redirectRaw !== null && redirectRaw !== "") {
428
+ if (typeof redirectRaw !== "string") {
429
+ issues.push({
430
+ field: "redirect_to",
431
+ message: "redirect_to must be a string path",
432
+ level: "error"
433
+ });
434
+ } else if (!pathTemplate) {
435
+ issues.push({
436
+ field: "redirect_to",
437
+ message: "redirect_to is not allowed on reference-only content types",
438
+ level: "error"
439
+ });
440
+ } else if (!extractSlugFromResolvedPath(pathTemplate, redirectRaw)) {
441
+ issues.push({
442
+ field: "redirect_to",
443
+ message: `redirect_to must match path template "${pathTemplate}" (prefix "${pathPrefix(pathTemplate)}"${pathSuffix(pathTemplate) ? `, suffix "${pathSuffix(pathTemplate)}"` : ""})`,
444
+ level: "error"
445
+ });
446
+ } else {
447
+ redirectTo = redirectRaw;
448
+ }
449
+ }
450
+ const aliases = aliasesResult.success ? aliasesResult.data : [];
451
+ if (!aliasesResult.success) {
452
+ for (const issue of aliasesResult.error.issues) {
453
+ issues.push({
454
+ field: `aliases.${issue.path.join(".")}`,
455
+ message: issue.message,
456
+ level: "error"
457
+ });
458
+ }
459
+ }
460
+ let publishedAt;
461
+ if (rest.publishedAt !== void 0 && rest.publishedAt !== null && rest.publishedAt !== "") {
462
+ const parsed = isoDateSchema.safeParse(rest.publishedAt);
463
+ if (parsed.success) {
464
+ publishedAt = parsed.data;
465
+ } else {
466
+ for (const issue of parsed.error.issues) {
467
+ issues.push({ field: "publishedAt", message: issue.message, level: "error" });
468
+ }
469
+ }
470
+ delete rest.publishedAt;
471
+ }
472
+ let updatedAt;
473
+ if (rest.updatedAt !== void 0 && rest.updatedAt !== null && rest.updatedAt !== "") {
474
+ const parsed = isoDateSchema.safeParse(rest.updatedAt);
475
+ if (parsed.success) {
476
+ updatedAt = parsed.data;
477
+ } else {
478
+ for (const issue of parsed.error.issues) {
479
+ issues.push({ field: "updatedAt", message: issue.message, level: "error" });
480
+ }
481
+ }
482
+ delete rest.updatedAt;
483
+ } else if (publishedAt) {
484
+ updatedAt = publishedAt;
485
+ }
486
+ let noindex = false;
487
+ if (rest.noindex !== void 0 && rest.noindex !== null && rest.noindex !== "") {
488
+ if (typeof rest.noindex === "boolean") {
489
+ noindex = rest.noindex;
490
+ } else {
491
+ issues.push({ field: "noindex", message: "noindex must be a boolean", level: "error" });
492
+ }
493
+ delete rest.noindex;
494
+ }
495
+ let canonicalPathOverride;
496
+ if (rest.canonicalPath !== void 0 && rest.canonicalPath !== null && rest.canonicalPath !== "") {
497
+ const parsed = canonicalPathSchema.safeParse(rest.canonicalPath);
498
+ if (parsed.success) {
499
+ canonicalPathOverride = parsed.data;
500
+ } else {
501
+ for (const issue of parsed.error.issues) {
502
+ issues.push({ field: "canonicalPath", message: issue.message, level: "error" });
503
+ }
504
+ }
505
+ delete rest.canonicalPath;
506
+ }
507
+ for (const internalKey of ["translationOf", "enSlug"]) {
508
+ if (rest[internalKey] !== void 0) {
509
+ issues.push({
510
+ field: internalKey,
511
+ message: `${internalKey} is managed internally by Scribe; remove from frontmatter`,
512
+ level: "warning"
513
+ });
514
+ delete rest[internalKey];
515
+ }
516
+ }
517
+ return {
518
+ builtin: {
519
+ aliases,
520
+ redirectTo,
521
+ publishedAt,
522
+ updatedAt,
523
+ noindex,
524
+ canonicalPathOverride
525
+ },
526
+ rest,
527
+ issues
528
+ };
529
+ }
530
+ function validateLocaleBuiltinFields(data) {
531
+ const issues = [];
532
+ for (const [key, message] of LOCALE_BUILTIN_KEYS) {
533
+ if (data[key] !== void 0) {
534
+ issues.push({ field: key, message, level: "error" });
535
+ }
536
+ }
537
+ return issues;
538
+ }
539
+ function resolveCanonicalPathname(type, doc, defaultLocale) {
540
+ if (doc.canonicalPathOverride) {
541
+ if (doc.locale === defaultLocale) return doc.canonicalPathOverride;
542
+ return `/${doc.locale}${doc.canonicalPathOverride}`;
543
+ }
544
+ if (!type.path) return `/${doc.slug}`;
545
+ return resolvePath(type.path, doc.slug, doc.locale, defaultLocale);
546
+ }
547
+ function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
548
+ const out = { ...frontmatter };
549
+ if (doc.publishedAt !== void 0) out.publishedAt = doc.publishedAt;
550
+ if (doc.updatedAt !== void 0) out.updatedAt = doc.updatedAt;
551
+ out.noindex = doc.noindex;
552
+ out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
553
+ return out;
554
+ }
555
+ function seoFieldsFromEn(enDoc) {
556
+ return {
557
+ publishedAt: enDoc.publishedAt,
558
+ updatedAt: enDoc.updatedAt,
559
+ noindex: enDoc.noindex,
560
+ canonicalPathOverride: enDoc.canonicalPathOverride,
561
+ redirectTo: enDoc.redirectTo
562
+ };
563
+ }
564
+
565
+ // src/loader/create-loader.ts
566
+ init_sqlite();
567
+
568
+ // src/storage/translations.ts
569
+ init_esm_shims();
570
+ function upsertTranslation(db, input) {
571
+ db.prepare(
572
+ `INSERT INTO translations (
573
+ content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model
574
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
575
+ ON CONFLICT(content_type, en_slug, locale) DO UPDATE SET
576
+ slug = excluded.slug,
577
+ frontmatter_json = excluded.frontmatter_json,
578
+ body = excluded.body,
579
+ en_hash = excluded.en_hash,
580
+ translated_at = excluded.translated_at,
581
+ model = excluded.model`
582
+ ).run(
583
+ input.contentType,
584
+ input.enSlug,
585
+ input.locale,
586
+ input.slug,
587
+ JSON.stringify(input.frontmatter),
588
+ input.body,
589
+ input.enHash,
590
+ input.translatedAt,
591
+ input.model
592
+ );
593
+ }
594
+ function getTranslation(db, contentType, enSlug, locale) {
595
+ return db.prepare(
596
+ `SELECT * FROM translations WHERE content_type = ? AND en_slug = ? AND locale = ?`
597
+ ).get(contentType, enSlug, locale);
598
+ }
599
+ function listTranslationsForType(db, contentType) {
600
+ return db.prepare(`SELECT * FROM translations WHERE content_type = ? ORDER BY en_slug, locale`).all(contentType);
601
+ }
602
+ function listTranslationsForEnSlug(db, contentType, enSlug) {
603
+ return db.prepare(
604
+ `SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
605
+ ).all(contentType, enSlug);
606
+ }
607
+ function bulkLoadTranslations(db) {
608
+ return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
609
+ }
610
+ function appendRevision(db, input) {
611
+ const result = db.prepare(
612
+ `INSERT INTO revisions (
613
+ content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at, model, body_preview
614
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
615
+ ).run(
616
+ input.contentType,
617
+ input.enSlug,
618
+ input.locale,
619
+ input.revisionKind,
620
+ input.enHash,
621
+ input.bodyHash,
622
+ input.createdAt,
623
+ input.model ?? null,
624
+ input.bodyPreview ?? null
625
+ );
626
+ return Number(result.lastInsertRowid);
627
+ }
628
+ function listRevisions(db, contentType, enSlug, locale) {
629
+ if (locale) {
630
+ return db.prepare(
631
+ `SELECT * FROM revisions
632
+ WHERE content_type = ? AND en_slug = ? AND locale = ?
633
+ ORDER BY created_at DESC, id DESC`
634
+ ).all(contentType, enSlug, locale);
635
+ }
636
+ return db.prepare(
637
+ `SELECT * FROM revisions
638
+ WHERE content_type = ? AND en_slug = ?
639
+ ORDER BY created_at DESC, id DESC`
640
+ ).all(contentType, enSlug);
641
+ }
642
+
643
+ // src/loader/normalize-en.ts
644
+ init_esm_shims();
645
+ function normalizeEnFrontmatter(data) {
646
+ const out = { ...data };
647
+ if (!out.publishedAt && typeof out.date === "string") {
648
+ out.publishedAt = out.date;
649
+ }
650
+ if (!out.heroImage && typeof out.image === "string" && out.image.startsWith("/")) {
651
+ out.heroImage = out.image;
652
+ }
653
+ if (!out.updatedAt && typeof out.publishedAt === "string") {
654
+ out.updatedAt = out.publishedAt;
655
+ }
656
+ return out;
657
+ }
658
+ function isPublishableContentFile(name) {
659
+ if (!name.endsWith(".md") && !name.endsWith(".mdx")) return false;
660
+ const first = name.charAt(0);
661
+ return first === first.toLowerCase() && first !== "_";
662
+ }
663
+
664
+ // src/loader/create-loader.ts
665
+ function isPostFile(name) {
666
+ return isPublishableContentFile(name);
667
+ }
668
+ function listEnFiles(contentDir) {
669
+ if (!fs2.existsSync(contentDir)) return [];
670
+ return fs2.readdirSync(contentDir).filter(isPostFile).map((f) => path4.join(contentDir, f));
671
+ }
672
+ function slugFromPath(filePath) {
673
+ return path4.basename(filePath).replace(/\.(md|mdx)$/, "");
674
+ }
675
+ function parseEnMdx(filePath, config, type) {
676
+ const raw = fs2.readFileSync(filePath, "utf8");
677
+ const parsed = matter(raw);
678
+ const slug = slugFromPath(filePath);
679
+ const normalized = normalizeEnFrontmatter(parsed.data);
680
+ const { builtin, rest, issues: builtinIssues } = extractBuiltinEnFields(
681
+ normalized,
682
+ type.path,
683
+ slug,
684
+ config.defaultLocale
685
+ );
686
+ const issues = builtinIssues.map((issue) => ({
687
+ field: issue.field,
688
+ message: issue.message,
689
+ level: issue.level
690
+ }));
691
+ const result = type.schema.safeParse(rest);
692
+ if (!result.success) {
693
+ return {
694
+ document: null,
695
+ issues: [
696
+ ...issues,
697
+ ...result.error.issues.map((issue) => ({
698
+ field: issue.path.join("."),
699
+ message: issue.message,
700
+ level: "error"
701
+ }))
702
+ ]
703
+ };
704
+ }
705
+ if (issues.some((i) => i.level === "error")) {
706
+ return { document: null, issues };
707
+ }
708
+ const frontmatter = mergeBuiltinsIntoFrontmatter(
709
+ result.data,
710
+ {
711
+ publishedAt: builtin.publishedAt,
712
+ updatedAt: builtin.updatedAt,
713
+ noindex: builtin.noindex,
714
+ canonicalPathOverride: builtin.canonicalPathOverride,
715
+ slug,
716
+ locale: config.defaultLocale
717
+ },
718
+ type,
719
+ config.defaultLocale
720
+ );
721
+ const crossIssues = type.crossValidate?.(result.data, {
722
+ locale: config.defaultLocale,
723
+ defaultLocale: config.defaultLocale,
724
+ slug,
725
+ enSlug: slug,
726
+ knownLocales: config.locales
727
+ }) ?? [];
728
+ issues.push(...crossIssues);
729
+ const document = {
730
+ slug,
731
+ enSlug: slug,
732
+ locale: config.defaultLocale,
733
+ aliases: builtin.aliases,
734
+ redirectTo: builtin.redirectTo,
735
+ publishedAt: builtin.publishedAt,
736
+ updatedAt: builtin.updatedAt,
737
+ noindex: builtin.noindex,
738
+ canonicalPathOverride: builtin.canonicalPathOverride,
739
+ frontmatter,
740
+ content: parsed.content,
741
+ filePath
742
+ };
743
+ return { document, issues };
744
+ }
745
+ function buildDocumentFromTranslation(row, enDoc, type) {
746
+ const localeFm = JSON.parse(row.frontmatter_json);
747
+ const merged = mergeStructuralOntoLocale(localeFm, enDoc.frontmatter, type.schema);
748
+ const seo = seoFieldsFromEn(enDoc);
749
+ const frontmatter = mergeBuiltinsIntoFrontmatter(
750
+ merged,
751
+ { ...seo, slug: row.slug, locale: row.locale },
752
+ type,
753
+ enDoc.locale
754
+ );
755
+ return {
756
+ slug: row.slug,
757
+ enSlug: row.en_slug,
758
+ locale: row.locale,
759
+ aliases: [],
760
+ redirectTo: seo.redirectTo,
761
+ publishedAt: seo.publishedAt,
762
+ updatedAt: seo.updatedAt,
763
+ noindex: seo.noindex,
764
+ canonicalPathOverride: seo.canonicalPathOverride,
765
+ frontmatter,
766
+ content: row.body
767
+ };
768
+ }
769
+ var DEV_REVALIDATE_MS = 1500;
770
+ function createContentLoader(config, type) {
771
+ let cached = null;
772
+ let signature = "";
773
+ let lastCheck = 0;
774
+ const contentDir = path4.join(
775
+ /* turbopackIgnore: true */
776
+ config.rootDir,
777
+ type.contentDir
778
+ );
779
+ const storePath = resolveStorePath(config);
780
+ const isProd = process.env.NODE_ENV === "production";
781
+ function computeSignature() {
782
+ const files = listEnFiles(contentDir);
783
+ let newest = 0;
784
+ for (const f of files) {
785
+ try {
786
+ newest = Math.max(newest, fs2.statSync(f).mtimeMs);
787
+ } catch {
788
+ }
789
+ }
790
+ let store = 0;
791
+ try {
792
+ store = fs2.statSync(storePath).mtimeMs;
793
+ } catch {
794
+ }
795
+ return `${files.length}:${newest}:${store}`;
796
+ }
797
+ function build() {
798
+ const out = /* @__PURE__ */ new Map();
799
+ const englishBySlug = /* @__PURE__ */ new Map();
800
+ for (const file of listEnFiles(contentDir)) {
801
+ const { document, issues } = parseEnMdx(file, config, type);
802
+ if (document) {
803
+ englishBySlug.set(document.slug, document);
804
+ if (issues.length > 0) {
805
+ for (const issue of issues) {
806
+ console.warn(
807
+ `[scribe:${type.id}] ${file} ${issue.level}: ${issue.field}: ${issue.message}`
808
+ );
809
+ }
810
+ }
811
+ } else {
812
+ console.warn(
813
+ `[scribe:${type.id}] Skipping ${file} \u2014 validation failed:
814
+ ` + issues.map((i) => ` - ${i.field}: ${i.message}`).join("\n")
815
+ );
816
+ }
817
+ }
818
+ out.set(config.defaultLocale, {
819
+ bySlug: englishBySlug,
820
+ byEnSlug: englishBySlug
821
+ });
822
+ const db = openStore(config, "readonly");
823
+ const rows = listTranslationsForType(db, type.id);
824
+ db.close();
825
+ const rowsByLocale = /* @__PURE__ */ new Map();
826
+ for (const row of rows) {
827
+ const list = rowsByLocale.get(row.locale) ?? [];
828
+ list.push(row);
829
+ rowsByLocale.set(row.locale, list);
830
+ }
831
+ for (const locale of config.locales) {
832
+ if (locale === config.defaultLocale) continue;
833
+ const bySlug = /* @__PURE__ */ new Map();
834
+ const byEnSlug = /* @__PURE__ */ new Map();
835
+ for (const row of rowsByLocale.get(locale) ?? []) {
836
+ const enDoc = englishBySlug.get(row.en_slug);
837
+ if (!enDoc) continue;
838
+ const doc = buildDocumentFromTranslation(row, enDoc, type);
839
+ bySlug.set(doc.slug, doc);
840
+ byEnSlug.set(row.en_slug, doc);
841
+ }
842
+ out.set(locale, { bySlug, byEnSlug });
843
+ }
844
+ return out;
845
+ }
846
+ return () => {
847
+ if (cached) {
848
+ if (isProd) return cached;
849
+ const now = Date.now();
850
+ if (now - lastCheck < DEV_REVALIDATE_MS) return cached;
851
+ lastCheck = now;
852
+ const sig = computeSignature();
853
+ if (sig === signature) return cached;
854
+ cached = build();
855
+ signature = sig;
856
+ return cached;
857
+ }
858
+ lastCheck = Date.now();
859
+ cached = build();
860
+ signature = isProd ? "" : computeSignature();
861
+ return cached;
862
+ };
863
+ }
864
+ function readEnDocument(config, type, enSlug) {
865
+ const contentDir = path4.join(
866
+ /* turbopackIgnore: true */
867
+ config.rootDir,
868
+ type.contentDir
869
+ );
870
+ for (const ext of [".mdx", ".md"]) {
871
+ const filePath = path4.join(contentDir, `${enSlug}${ext}`);
872
+ if (!fs2.existsSync(filePath)) continue;
873
+ const { document } = parseEnMdx(filePath, config, type);
874
+ return document;
875
+ }
876
+ return null;
877
+ }
878
+ function getTranslatablePayload(doc, type) {
879
+ return {
880
+ frontmatter: pickTranslatable(doc.frontmatter, type.schema),
881
+ body: doc.content
882
+ };
883
+ }
884
+
885
+ // src/i18n/resolve-document.ts
886
+ init_esm_shims();
887
+ function findEnDocByAlias(slug, allDocs, defaultLocale) {
888
+ const enIdx = allDocs.get(defaultLocale);
889
+ if (!enIdx) return null;
890
+ for (const doc of enIdx.bySlug.values()) {
891
+ if (doc.aliases.includes(slug)) return doc;
892
+ }
893
+ return null;
894
+ }
895
+ function enCanonicalDoc(doc, allDocs, defaultLocale) {
896
+ if (doc.locale === defaultLocale) return doc;
897
+ return allDocs.get(defaultLocale)?.bySlug.get(doc.enSlug) ?? doc;
898
+ }
899
+ function redirectPathForDocument(doc, locale, defaultLocale, allDocs, type) {
900
+ if (!type.path) return void 0;
901
+ const enDoc = enCanonicalDoc(doc, allDocs, defaultLocale);
902
+ if (!enDoc.redirectTo) return void 0;
903
+ const targetEnSlug = extractSlugFromResolvedPath(type.path, enDoc.redirectTo);
904
+ if (!targetEnSlug) return void 0;
905
+ const targetEnDoc = allDocs.get(defaultLocale)?.bySlug.get(targetEnSlug);
906
+ const targetSlug = targetEnDoc ? getSlugForLocale(targetEnDoc, defaultLocale, locale, allDocs, defaultLocale) ?? targetEnSlug : targetEnSlug;
907
+ return resolvePath(type.path, targetSlug, locale, defaultLocale);
908
+ }
909
+ function withRedirectTo(result, locale, defaultLocale, allDocs, type) {
910
+ if (result.shouldRedirectTo || !result.document) return result;
911
+ const redirect = redirectPathForDocument(result.document, locale, defaultLocale, allDocs, type);
912
+ if (!redirect) return result;
913
+ return {
914
+ document: null,
915
+ actualLocale: result.actualLocale,
916
+ shouldRedirectTo: redirect
917
+ };
918
+ }
919
+ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
920
+ const idx = allDocs.get(locale);
921
+ const direct = idx?.bySlug.get(slug);
922
+ if (direct) {
923
+ return withRedirectTo({ document: direct, actualLocale: locale }, locale, defaultLocale, allDocs, type);
924
+ }
925
+ const aliasDoc = findEnDocByAlias(slug, allDocs, defaultLocale);
926
+ if (aliasDoc && type.path) {
927
+ const canonicalSlug = aliasDoc.slug;
928
+ const localizedSlug = getSlugForLocale(aliasDoc, defaultLocale, locale, allDocs, defaultLocale);
929
+ const targetSlug = localizedSlug ?? canonicalSlug;
930
+ if (targetSlug !== slug) {
931
+ return {
932
+ document: null,
933
+ actualLocale: locale,
934
+ shouldRedirectTo: resolvePath(type.path, targetSlug, locale, defaultLocale)
935
+ };
936
+ }
937
+ }
938
+ for (const [docLocale, docIdx] of allDocs) {
939
+ const found = docIdx.bySlug.get(slug);
940
+ if (!found) continue;
941
+ const correctSlug = getSlugForLocale(found, docLocale, locale, allDocs, defaultLocale);
942
+ if (correctSlug && correctSlug !== slug) {
943
+ if (!type.path) {
944
+ return { document: null, actualLocale: locale };
945
+ }
946
+ return {
947
+ document: null,
948
+ actualLocale: locale,
949
+ shouldRedirectTo: resolvePath(type.path, correctSlug, locale, defaultLocale)
950
+ };
951
+ }
952
+ if (docLocale === defaultLocale) {
953
+ return withRedirectTo(
954
+ { document: found, actualLocale: defaultLocale },
955
+ locale,
956
+ defaultLocale,
957
+ allDocs,
958
+ type
959
+ );
960
+ }
961
+ if (!type.path) {
962
+ return { document: null, actualLocale: locale };
963
+ }
964
+ return {
965
+ document: null,
966
+ actualLocale: locale,
967
+ shouldRedirectTo: resolvePath(type.path, found.enSlug, locale, defaultLocale)
968
+ };
969
+ }
970
+ if (locale !== defaultLocale) {
971
+ const enDoc = allDocs.get(defaultLocale)?.bySlug.get(slug);
972
+ if (enDoc && type.indexFallback === "en") {
973
+ return withRedirectTo(
974
+ { document: enDoc, actualLocale: defaultLocale },
975
+ locale,
976
+ defaultLocale,
977
+ allDocs,
978
+ type
979
+ );
980
+ }
981
+ const byEn = idx?.byEnSlug.get(slug);
982
+ if (byEn) {
983
+ return withRedirectTo(
984
+ { document: byEn, actualLocale: locale },
985
+ locale,
986
+ defaultLocale,
987
+ allDocs,
988
+ type
989
+ );
990
+ }
991
+ const fallback = allDocs.get(defaultLocale)?.bySlug.get(slug);
992
+ if (fallback && type.indexFallback === "en") {
993
+ return withRedirectTo(
994
+ { document: fallback, actualLocale: defaultLocale },
995
+ locale,
996
+ defaultLocale,
997
+ allDocs,
998
+ type
999
+ );
1000
+ }
1001
+ }
1002
+ return { document: null, actualLocale: locale };
1003
+ }
1004
+ function getSlugForLocale(document, sourceLocale, targetLocale, allDocs, defaultLocale) {
1005
+ if (sourceLocale === targetLocale) return document.slug;
1006
+ const englishSlug = sourceLocale === defaultLocale ? document.slug : document.enSlug;
1007
+ if (!englishSlug) return null;
1008
+ if (targetLocale === defaultLocale) return englishSlug;
1009
+ return allDocs.get(targetLocale)?.byEnSlug.get(englishSlug)?.slug ?? null;
1010
+ }
1011
+
1012
+ // src/create-project.ts
1013
+ function comparatorFor(orderBy) {
1014
+ if (typeof orderBy === "function") return orderBy;
1015
+ switch (orderBy) {
1016
+ case "publishedAt":
1017
+ return (a, b) => (a.publishedAt ?? "").localeCompare(b.publishedAt ?? "");
1018
+ case "-publishedAt":
1019
+ return (a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "");
1020
+ case "updatedAt":
1021
+ return (a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? "");
1022
+ case "-updatedAt":
1023
+ return (a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
1024
+ default:
1025
+ return (a, b) => a.slug.localeCompare(b.slug);
1026
+ }
1027
+ }
1028
+ function buildRuntime(config, type, getRuntime) {
1029
+ const load = createContentLoader(config, type);
1030
+ const relationFields = new Map(
1031
+ listRelationFields(type.schema).filter((f) => f.path.length === 1).map((f) => [f.path[0], f])
1032
+ );
1033
+ function assertRoutable(method) {
1034
+ if (!type.path) {
1035
+ throw new Error(
1036
+ `Content type "${type.id}" has no path template \u2014 ${method}() requires one`
1037
+ );
1038
+ }
1039
+ return type.path;
1040
+ }
1041
+ const runtime = {
1042
+ id: type.id,
1043
+ config: type,
1044
+ load,
1045
+ list(locale = config.defaultLocale, options = {}) {
1046
+ const idx = load().get(locale);
1047
+ if (!idx) return [];
1048
+ const docs = Array.from(idx.bySlug.values());
1049
+ docs.sort(comparatorFor(options.orderBy ?? type.orderBy ?? "slug"));
1050
+ return options.limit !== void 0 ? docs.slice(0, options.limit) : docs;
1051
+ },
1052
+ get(slug, locale = config.defaultLocale) {
1053
+ return load().get(locale)?.bySlug.get(slug) ?? null;
1054
+ },
1055
+ resolve(slug, locale) {
1056
+ const result = resolveLocalizedDocument(slug, locale, config.defaultLocale, load(), type);
1057
+ if (result.document && type.path) {
1058
+ return {
1059
+ ...result,
1060
+ canonicalPath: resolvePath(
1061
+ type.path,
1062
+ result.document.slug,
1063
+ result.actualLocale,
1064
+ config.defaultLocale
1065
+ )
1066
+ };
1067
+ }
1068
+ return result;
1069
+ },
1070
+ staticParams(options = {}) {
1071
+ assertRoutable("staticParams");
1072
+ const all = load();
1073
+ const enIdx = all.get(config.defaultLocale);
1074
+ if (!enIdx) return [];
1075
+ const params = [];
1076
+ for (const locale of options.locales ?? config.locales) {
1077
+ const localeIdx = all.get(locale);
1078
+ for (const doc of enIdx.bySlug.values()) {
1079
+ const slug = locale === config.defaultLocale ? doc.slug : localeIdx?.byEnSlug.get(doc.slug)?.slug ?? doc.slug;
1080
+ params.push({ locale, slug });
1081
+ }
1082
+ }
1083
+ return params;
1084
+ },
1085
+ alternates(doc) {
1086
+ const pathTemplate = assertRoutable("alternates");
1087
+ const out = {
1088
+ [config.defaultLocale]: resolvePath(
1089
+ pathTemplate,
1090
+ doc.enSlug,
1091
+ config.defaultLocale,
1092
+ config.defaultLocale
1093
+ )
1094
+ };
1095
+ const all = load();
1096
+ for (const locale of config.locales) {
1097
+ if (locale === config.defaultLocale) continue;
1098
+ const translated = all.get(locale)?.byEnSlug.get(doc.enSlug);
1099
+ if (translated) {
1100
+ out[locale] = resolvePath(pathTemplate, translated.slug, locale, config.defaultLocale);
1101
+ }
1102
+ }
1103
+ return out;
1104
+ },
1105
+ translation(doc, targetLocale) {
1106
+ if (targetLocale === doc.locale) return doc;
1107
+ const all = load();
1108
+ if (targetLocale === config.defaultLocale) {
1109
+ return all.get(config.defaultLocale)?.bySlug.get(doc.enSlug) ?? null;
1110
+ }
1111
+ return all.get(targetLocale)?.byEnSlug.get(doc.enSlug) ?? null;
1112
+ },
1113
+ url(slug, locale) {
1114
+ const pathTemplate = assertRoutable("url");
1115
+ return resolvePath(pathTemplate, slug, locale, config.defaultLocale);
1116
+ },
1117
+ related(doc, fieldName, locale) {
1118
+ const meta = relationFields.get(fieldName);
1119
+ if (!meta) {
1120
+ throw new Error(
1121
+ `Content type "${type.id}" has no top-level relation field "${fieldName}"`
1122
+ );
1123
+ }
1124
+ const target = getRuntime(meta.relationTarget);
1125
+ const lookupLocale = locale ?? doc.locale;
1126
+ const value = doc.frontmatter[fieldName];
1127
+ const deref = (enSlug) => {
1128
+ const all = target.load();
1129
+ return all.get(lookupLocale)?.byEnSlug.get(enSlug) ?? all.get(config.defaultLocale)?.bySlug.get(enSlug) ?? null;
1130
+ };
1131
+ if (meta.relationMultiple) {
1132
+ if (!Array.isArray(value)) return [];
1133
+ return value.filter((slug) => typeof slug === "string" && slug.length > 0).map(deref).filter((d) => d !== null);
1134
+ }
1135
+ if (typeof value !== "string" || value.length === 0) {
1136
+ if (meta.relationOptional) return null;
1137
+ throw new Error(
1138
+ `${type.id} "${doc.enSlug}": required relation "${fieldName}" is missing`
1139
+ );
1140
+ }
1141
+ const resolved = deref(value);
1142
+ if (!resolved && !meta.relationOptional) {
1143
+ throw new Error(
1144
+ `${type.id} "${doc.enSlug}": relation "${fieldName}" references missing ${meta.relationTarget} "${value}" \u2014 run \`scribe validate\``
1145
+ );
1146
+ }
1147
+ return resolved;
1148
+ }
1149
+ };
1150
+ return runtime;
1151
+ }
1152
+ function createProject(config) {
1153
+ const runtimes = /* @__PURE__ */ new Map();
1154
+ const getRuntime = (id) => {
1155
+ const runtime = runtimes.get(id);
1156
+ if (!runtime) {
1157
+ throw new Error(`Unknown content type "${id}"`);
1158
+ }
1159
+ return runtime;
1160
+ };
1161
+ for (const type of config.types) {
1162
+ runtimes.set(type.id, buildRuntime(config, type, getRuntime));
1163
+ }
1164
+ return {
1165
+ config,
1166
+ rootDir: config.rootDir,
1167
+ storePath: config.storePath,
1168
+ getType: getRuntime,
1169
+ listTypes() {
1170
+ return Array.from(runtimes.values());
1171
+ }
1172
+ };
1173
+ }
1174
+
1175
+ // src/sitemap/generate-sitemap.ts
1176
+ init_esm_shims();
1177
+
1178
+ // src/redirects/build-redirects.ts
1179
+ init_esm_shims();
1180
+
1181
+ // src/core/slug-aliases.ts
1182
+ init_esm_shims();
1183
+
1184
+ // src/core/alias-helpers.ts
1185
+ init_esm_shims();
1186
+ function enFileExists(config, type, enSlug) {
1187
+ const dir = path4.join(config.rootDir, type.contentDir);
1188
+ return fs2.existsSync(path4.join(dir, `${enSlug}.mdx`)) || fs2.existsSync(path4.join(dir, `${enSlug}.md`));
1189
+ }
1190
+ function sqliteHasTranslations(db, contentTypeId, enSlug) {
1191
+ return listTranslationsForEnSlug(db, contentTypeId, enSlug).length > 0;
1192
+ }
1193
+ function isAliasKnown(config, type, aliasEnSlug, db) {
1194
+ return enFileExists(config, type, aliasEnSlug) || sqliteHasTranslations(db, type.id, aliasEnSlug);
1195
+ }
1196
+ function listEnSlugs(rootDir, contentDir) {
1197
+ const dir = path4.join(rootDir, contentDir);
1198
+ if (!fs2.existsSync(dir)) return [];
1199
+ return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1200
+ }
1201
+
1202
+ // src/core/slug-aliases.ts
1203
+ init_sqlite();
1204
+ function buildGlobalAliasIndex(project) {
1205
+ const { config } = project;
1206
+ const aliasToTarget = /* @__PURE__ */ new Map();
1207
+ const allAliasSlugs = /* @__PURE__ */ new Set();
1208
+ const issues = [];
1209
+ const canonicalSlugsByType = /* @__PURE__ */ new Map();
1210
+ for (const type of config.types) {
1211
+ const slugs = listEnSlugs(config.rootDir, type.contentDir);
1212
+ canonicalSlugsByType.set(type.id, new Set(slugs));
1213
+ }
1214
+ for (const type of config.types) {
1215
+ if (!type.path) continue;
1216
+ const canonicalSlugs = canonicalSlugsByType.get(type.id) ?? /* @__PURE__ */ new Set();
1217
+ for (const enSlug of canonicalSlugs) {
1218
+ const doc = readEnDocument(config, type, enSlug);
1219
+ if (!doc) continue;
1220
+ for (const alias of doc.aliases) {
1221
+ if (alias === enSlug) {
1222
+ issues.push({
1223
+ contentTypeId: type.id,
1224
+ enSlug,
1225
+ field: "aliases",
1226
+ message: `Alias "${alias}" must not equal the canonical slug`,
1227
+ level: "error"
1228
+ });
1229
+ continue;
1230
+ }
1231
+ if (canonicalSlugs.has(alias)) {
1232
+ issues.push({
1233
+ contentTypeId: type.id,
1234
+ enSlug,
1235
+ field: "aliases",
1236
+ message: `Alias "${alias}" collides with another document's canonical slug`,
1237
+ level: "error"
1238
+ });
1239
+ continue;
1240
+ }
1241
+ const existing = aliasToTarget.get(alias);
1242
+ if (existing) {
1243
+ issues.push({
1244
+ contentTypeId: type.id,
1245
+ enSlug,
1246
+ field: "aliases",
1247
+ message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
1248
+ level: "error"
1249
+ });
1250
+ continue;
1251
+ }
1252
+ aliasToTarget.set(alias, {
1253
+ contentTypeId: type.id,
1254
+ canonicalSlug: enSlug,
1255
+ path: type.path
1256
+ });
1257
+ allAliasSlugs.add(alias);
1258
+ }
1259
+ }
1260
+ }
1261
+ return { aliasToTarget, allAliasSlugs, issues };
1262
+ }
1263
+ function validateAliasCoverage(project) {
1264
+ const { config } = project;
1265
+ const { aliasToTarget } = buildGlobalAliasIndex(project);
1266
+ const issues = [];
1267
+ const db = openStore(config, "readonly");
1268
+ try {
1269
+ for (const [alias, target] of aliasToTarget) {
1270
+ const type = config.types.find((t) => t.id === target.contentTypeId);
1271
+ if (!type) continue;
1272
+ const known = isAliasKnown(config, type, alias, db);
1273
+ if (!known) {
1274
+ issues.push({
1275
+ contentTypeId: type.id,
1276
+ enSlug: target.canonicalSlug,
1277
+ field: "aliases",
1278
+ message: `Alias "${alias}" is unknown (no EN file or sqlite translations) \u2014 only EN redirect will work`,
1279
+ level: "warning"
1280
+ });
1281
+ }
1282
+ if (enFileExists(config, type, alias)) {
1283
+ issues.push({
1284
+ contentTypeId: type.id,
1285
+ enSlug: target.canonicalSlug,
1286
+ field: "aliases",
1287
+ message: `Alias "${alias}" still has an EN file on disk \u2014 delete it manually`,
1288
+ level: "warning"
1289
+ });
1290
+ }
1291
+ const sqliteRows = listTranslationsForEnSlug(db, type.id, alias);
1292
+ if (sqliteRows.length > 0) {
1293
+ issues.push({
1294
+ contentTypeId: type.id,
1295
+ enSlug: target.canonicalSlug,
1296
+ field: "aliases",
1297
+ message: `${sqliteRows.length} sqlite translation(s) for alias "${alias}" retained for locale redirects and history`,
1298
+ level: "info"
1299
+ });
1300
+ }
1301
+ for (const locale of config.locales) {
1302
+ if (locale === config.defaultLocale) continue;
1303
+ const canonicalRow = listTranslationsForEnSlug(db, type.id, target.canonicalSlug).find(
1304
+ (r) => r.locale === locale
1305
+ );
1306
+ if (!canonicalRow) continue;
1307
+ const aliasRow = sqliteRows.find((r) => r.locale === locale);
1308
+ if (!aliasRow && known) {
1309
+ issues.push({
1310
+ contentTypeId: type.id,
1311
+ enSlug: target.canonicalSlug,
1312
+ field: "aliases",
1313
+ message: `Alias "${alias}" has no locale slug mapping for ${locale} \u2014 old localized URL may miss redirect`,
1314
+ level: "warning"
1315
+ });
1316
+ }
1317
+ }
1318
+ }
1319
+ } finally {
1320
+ db.close();
1321
+ }
1322
+ return issues;
1323
+ }
1324
+ function validateAliasRedirectChains(project) {
1325
+ const { config } = project;
1326
+ const { aliasToTarget, allAliasSlugs } = buildGlobalAliasIndex(project);
1327
+ const issues = [];
1328
+ for (const [alias, target] of aliasToTarget) {
1329
+ const doc = readEnDocument(
1330
+ config,
1331
+ project.getType(target.contentTypeId).config,
1332
+ target.canonicalSlug
1333
+ );
1334
+ if (doc?.redirectTo) {
1335
+ issues.push({
1336
+ contentTypeId: target.contentTypeId,
1337
+ enSlug: target.canonicalSlug,
1338
+ field: "aliases",
1339
+ message: `Alias "${alias}" points at "${target.canonicalSlug}" which has redirect_to \u2014 resolve to the final canonical slug`,
1340
+ level: "error"
1341
+ });
1342
+ }
1343
+ }
1344
+ for (const type of config.types) {
1345
+ if (!type.path) continue;
1346
+ for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
1347
+ const doc = readEnDocument(config, type, enSlug);
1348
+ if (!doc?.redirectTo) continue;
1349
+ const targetSlug = extractSlugFromResolvedPath(type.path, doc.redirectTo);
1350
+ if (targetSlug && allAliasSlugs.has(targetSlug)) {
1351
+ issues.push({
1352
+ contentTypeId: type.id,
1353
+ enSlug,
1354
+ field: "redirect_to",
1355
+ message: `redirect_to target "${targetSlug}" is an alias slug \u2014 chain detected`,
1356
+ level: "error"
1357
+ });
1358
+ }
1359
+ const targetDoc = targetSlug ? readEnDocument(config, type, targetSlug) : null;
1360
+ if (targetDoc?.redirectTo) {
1361
+ issues.push({
1362
+ contentTypeId: type.id,
1363
+ enSlug,
1364
+ field: "redirect_to",
1365
+ message: `redirect_to chain detected: "${enSlug}" \u2192 "${targetSlug}" \u2192 "${targetDoc.redirectTo}"`,
1366
+ level: "error"
1367
+ });
1368
+ }
1369
+ }
1370
+ }
1371
+ return issues;
1372
+ }
1373
+
1374
+ // src/redirects/translation-index.ts
1375
+ init_esm_shims();
1376
+
1377
+ // src/i18n/translation-index.ts
1378
+ init_esm_shims();
1379
+
1380
+ // src/redirects/translation-index.ts
1381
+ init_sqlite();
1382
+
1383
+ // src/sitemap/join-base-url.ts
1384
+ init_esm_shims();
1385
+
1386
+ // src/config/load-config.ts
1387
+ init_esm_shims();
1388
+ function findConfigPath(cwd) {
1389
+ const candidates = [
1390
+ path4.join(
1391
+ /* turbopackIgnore: true */
1392
+ cwd,
1393
+ "scribe.config.ts"
1394
+ ),
1395
+ path4.join(
1396
+ /* turbopackIgnore: true */
1397
+ cwd,
1398
+ "scribe.config.mts"
1399
+ ),
1400
+ path4.join(
1401
+ /* turbopackIgnore: true */
1402
+ cwd,
1403
+ "scribe.config.js"
1404
+ )
1405
+ ];
1406
+ for (const candidate of candidates) {
1407
+ if (fs2.existsSync(candidate) && fs2.statSync(candidate).isFile()) {
1408
+ return candidate;
1409
+ }
1410
+ }
1411
+ return null;
1412
+ }
1413
+ function loadConfigSync(options = {}) {
1414
+ const cwd = options.cwd ?? /* turbopackIgnore: true */
1415
+ process.cwd();
1416
+ const configPath = options.configPath ?? findConfigPath(cwd);
1417
+ if (!configPath) {
1418
+ throw new Error(`No scribe.config.ts found in ${cwd} (pass --config)`);
1419
+ }
1420
+ const jiti = createJiti(import.meta.url, {
1421
+ interopDefault: true,
1422
+ moduleCache: false
1423
+ });
1424
+ const loaded = jiti(configPath);
1425
+ const config = "default" in loaded ? loaded.default : loaded;
1426
+ if (!config?.rootDir) {
1427
+ throw new Error(`Invalid scribe config at ${configPath}: missing rootDir`);
1428
+ }
1429
+ return resolveConfig(config, path4.dirname(configPath));
1430
+ }
1431
+
1432
+ // src/validate/validate-project.ts
1433
+ init_esm_shims();
1434
+
1435
+ // src/hash/page-hash.ts
1436
+ init_esm_shims();
1437
+ function sha256(input) {
1438
+ return createHash("sha256").update(input, "utf8").digest("hex");
1439
+ }
1440
+ function computePageEnHash(translatableFrontmatter, body) {
1441
+ const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
1442
+ return sha256(payload);
1443
+ }
1444
+ function computeBodyHash(body) {
1445
+ return sha256(body);
1446
+ }
1447
+
1448
+ // src/history/record-revision.ts
1449
+ init_esm_shims();
1450
+ init_sqlite();
1451
+ function recordRevision(config, input) {
1452
+ const db = openStore(config, "readwrite");
1453
+ const id = appendRevision(db, {
1454
+ contentType: input.contentType,
1455
+ enSlug: input.enSlug,
1456
+ locale: input.locale,
1457
+ revisionKind: input.revisionKind,
1458
+ enHash: input.enHash,
1459
+ bodyHash: computeBodyHash(input.body),
1460
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1461
+ model: input.model,
1462
+ bodyPreview: input.body.slice(0, 200)
1463
+ });
1464
+ db.close();
1465
+ return id;
1466
+ }
1467
+
1468
+ // src/validate/validate-project.ts
1469
+ init_sqlite();
1470
+
1471
+ // src/validate/validate-relations.ts
1472
+ init_esm_shims();
1473
+ function getAtPath2(obj, fieldPath) {
1474
+ let current = obj;
1475
+ for (const segment of fieldPath) {
1476
+ if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
1477
+ current = current[segment];
1478
+ }
1479
+ return current;
1480
+ }
1481
+ function buildEnSlugIndex(config) {
1482
+ const index = /* @__PURE__ */ new Map();
1483
+ for (const type of config.types) {
1484
+ const slugs = new Set(listEnSlugs(config.rootDir, type.contentDir));
1485
+ index.set(type.id, slugs);
1486
+ }
1487
+ return index;
1488
+ }
1489
+ function listEnSlugsForType(rootDir, contentDir) {
1490
+ const dir = path4.join(rootDir, contentDir);
1491
+ if (!fs2.existsSync(dir)) return [];
1492
+ return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1493
+ }
1494
+ function validateRelations(project) {
1495
+ const { config } = project;
1496
+ const slugIndex = buildEnSlugIndex(config);
1497
+ const issues = [];
1498
+ const typeIds = new Set(config.types.map((t) => t.id));
1499
+ for (const type of config.types) {
1500
+ const relationFields = listRelationFields(type.schema);
1501
+ if (relationFields.length === 0) continue;
1502
+ for (const enSlug of listEnSlugsForType(config.rootDir, type.contentDir)) {
1503
+ const doc = readEnDocument(config, type, enSlug);
1504
+ if (!doc) continue;
1505
+ for (const fieldMeta of relationFields) {
1506
+ const targetTypeId = fieldMeta.relationTarget;
1507
+ const danglingLevel = fieldMeta.relationOptional || fieldMeta.relationMultiple ? "warning" : "error";
1508
+ if (!typeIds.has(targetTypeId)) {
1509
+ issues.push({
1510
+ contentTypeId: type.id,
1511
+ enSlug,
1512
+ field: fieldMeta.path.join("."),
1513
+ message: `Relation target type "${targetTypeId}" is not registered`,
1514
+ level: "error"
1515
+ });
1516
+ continue;
1517
+ }
1518
+ const value = getAtPath2(doc.frontmatter, fieldMeta.path);
1519
+ if (value === void 0 || value === null || value === "") continue;
1520
+ const targetSlugs = slugIndex.get(targetTypeId) ?? /* @__PURE__ */ new Set();
1521
+ const fieldLabel = fieldMeta.path.join(".");
1522
+ if (fieldMeta.relationMultiple) {
1523
+ if (!Array.isArray(value)) continue;
1524
+ for (const slug of value) {
1525
+ if (typeof slug !== "string" || !slug) continue;
1526
+ if (!targetSlugs.has(slug)) {
1527
+ issues.push({
1528
+ contentTypeId: type.id,
1529
+ enSlug,
1530
+ field: fieldLabel,
1531
+ message: `${fieldLabel} references "${slug}" \u2014 no ${targetTypeId} doc with that slug`,
1532
+ level: danglingLevel
1533
+ });
1534
+ }
1535
+ }
1536
+ } else if (typeof value === "string") {
1537
+ if (!targetSlugs.has(value)) {
1538
+ issues.push({
1539
+ contentTypeId: type.id,
1540
+ enSlug,
1541
+ field: fieldLabel,
1542
+ message: `${fieldLabel} references "${value}" \u2014 no ${targetTypeId} doc with slug "${value}"`,
1543
+ level: danglingLevel
1544
+ });
1545
+ }
1546
+ }
1547
+ }
1548
+ }
1549
+ }
1550
+ return issues;
1551
+ }
1552
+
1553
+ // src/validate/validate-slug-suffix.ts
1554
+ init_esm_shims();
1555
+ function validateTranslationSlugSuffixes(config, db) {
1556
+ const issues = [];
1557
+ const localeCodes = config.locales.filter((l) => l !== config.defaultLocale);
1558
+ for (const row of bulkLoadTranslations(db)) {
1559
+ if (row.locale === config.defaultLocale) continue;
1560
+ for (const code of localeCodes) {
1561
+ if (row.slug.endsWith(`-${code}`)) {
1562
+ issues.push({
1563
+ contentTypeId: row.content_type,
1564
+ enSlug: row.en_slug,
1565
+ locale: row.locale,
1566
+ slug: row.slug,
1567
+ message: `Translation slug "${row.slug}" ends with locale code "-${code}"`
1568
+ });
1569
+ break;
1570
+ }
1571
+ }
1572
+ }
1573
+ return issues;
1574
+ }
1575
+
1576
+ // src/validate/validate-project.ts
1577
+ function listEnSlugs2(rootDir, contentDir) {
1578
+ const dir = path4.join(rootDir, contentDir);
1579
+ if (!fs2.existsSync(dir)) return [];
1580
+ return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1581
+ }
1582
+ function validateProject(config) {
1583
+ const issues = [];
1584
+ const project = createProject(config);
1585
+ const storePath = project.storePath;
1586
+ if (!fs2.existsSync(storePath)) {
1587
+ issues.push({
1588
+ level: "error",
1589
+ message: `Missing store.sqlite at ${storePath}`
1590
+ });
1591
+ return { ok: false, issues };
1592
+ }
1593
+ const db = openStore(config, "readonly");
1594
+ for (const type of config.types) {
1595
+ const enSlugs = listEnSlugs2(config.rootDir, type.contentDir);
1596
+ const englishSlugs = new Set(enSlugs);
1597
+ if (!type.translate && isRoutableType(type)) {
1598
+ issues.push({
1599
+ level: "warning",
1600
+ contentType: type.id,
1601
+ message: `No translate config on content type "${type.id}" \u2014 using Scribe defaults`
1602
+ });
1603
+ }
1604
+ for (const enSlug of enSlugs) {
1605
+ const enDoc = readEnDocument(config, type, enSlug);
1606
+ if (!enDoc) {
1607
+ issues.push({
1608
+ level: "error",
1609
+ contentType: type.id,
1610
+ enSlug,
1611
+ message: "Failed to parse EN document"
1612
+ });
1613
+ continue;
1614
+ }
1615
+ const payload = getTranslatablePayload(enDoc, type);
1616
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1617
+ const crossIssues = type.crossValidate?.(enDoc.frontmatter, {
1618
+ locale: config.defaultLocale,
1619
+ defaultLocale: config.defaultLocale,
1620
+ slug: enSlug,
1621
+ enSlug,
1622
+ knownLocales: config.locales,
1623
+ englishSlugs
1624
+ }) ?? [];
1625
+ for (const issue of crossIssues) {
1626
+ issues.push({
1627
+ level: issue.level,
1628
+ contentType: type.id,
1629
+ enSlug,
1630
+ field: issue.field,
1631
+ message: issue.message
1632
+ });
1633
+ }
1634
+ for (const locale of config.locales) {
1635
+ if (locale === config.defaultLocale) continue;
1636
+ const row = getTranslation(db, type.id, enSlug, locale);
1637
+ if (!row) continue;
1638
+ if (row.en_hash !== currentEnHash) {
1639
+ issues.push({
1640
+ level: "warning",
1641
+ contentType: type.id,
1642
+ enSlug,
1643
+ locale,
1644
+ field: "en_hash",
1645
+ message: "Translation is stale (en_hash mismatch)"
1646
+ });
1647
+ recordRevision(config, {
1648
+ contentType: type.id,
1649
+ enSlug,
1650
+ locale,
1651
+ revisionKind: "en_edit_detected",
1652
+ enHash: currentEnHash,
1653
+ body: row.body
1654
+ });
1655
+ }
1656
+ const localeFm = JSON.parse(row.frontmatter_json);
1657
+ for (const issue of validateLocaleBuiltinFields(localeFm)) {
1658
+ issues.push({
1659
+ level: issue.level,
1660
+ contentType: type.id,
1661
+ enSlug,
1662
+ locale,
1663
+ field: issue.field,
1664
+ message: issue.message
1665
+ });
1666
+ }
1667
+ }
1668
+ }
1669
+ try {
1670
+ project.getType(type.id).load();
1671
+ } catch (error) {
1672
+ issues.push({
1673
+ level: "error",
1674
+ contentType: type.id,
1675
+ message: error instanceof Error ? error.message : String(error)
1676
+ });
1677
+ }
1678
+ }
1679
+ db.close();
1680
+ const aliasIndex = buildGlobalAliasIndex(project);
1681
+ for (const issue of aliasIndex.issues) {
1682
+ issues.push({
1683
+ level: issue.level,
1684
+ contentType: issue.contentTypeId,
1685
+ enSlug: issue.enSlug,
1686
+ field: issue.field,
1687
+ message: issue.message
1688
+ });
1689
+ }
1690
+ for (const issue of validateAliasRedirectChains(project)) {
1691
+ issues.push({
1692
+ level: issue.level,
1693
+ contentType: issue.contentTypeId,
1694
+ enSlug: issue.enSlug,
1695
+ field: issue.field,
1696
+ message: issue.message
1697
+ });
1698
+ }
1699
+ for (const issue of validateAliasCoverage(project)) {
1700
+ issues.push({
1701
+ level: issue.level,
1702
+ contentType: issue.contentTypeId,
1703
+ enSlug: issue.enSlug,
1704
+ field: issue.field,
1705
+ message: issue.message
1706
+ });
1707
+ }
1708
+ for (const issue of validateRelations(project)) {
1709
+ issues.push({
1710
+ level: issue.level,
1711
+ contentType: issue.contentTypeId,
1712
+ enSlug: issue.enSlug,
1713
+ field: issue.field,
1714
+ message: issue.message
1715
+ });
1716
+ }
1717
+ const dbForSuffix = openStore(config, "readonly");
1718
+ try {
1719
+ for (const issue of validateTranslationSlugSuffixes(config, dbForSuffix)) {
1720
+ issues.push({
1721
+ level: "error",
1722
+ contentType: issue.contentTypeId,
1723
+ enSlug: issue.enSlug,
1724
+ locale: issue.locale,
1725
+ field: "slug",
1726
+ message: issue.message
1727
+ });
1728
+ }
1729
+ } finally {
1730
+ dbForSuffix.close();
1731
+ }
1732
+ return { ok: issues.every((i) => i.level !== "error"), issues };
1733
+ }
1734
+
1735
+ // src/translate/worklist.ts
1736
+ init_esm_shims();
1737
+ init_sqlite();
1738
+ function listEnSlugs3(rootDir, contentDir) {
1739
+ const dir = path4.join(rootDir, contentDir);
1740
+ if (!fs2.existsSync(dir)) return [];
1741
+ return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1742
+ }
1743
+ function buildWorklist(config, options = {}) {
1744
+ const db = openStore(config, "readonly");
1745
+ const items = [];
1746
+ const locales = options.locales ?? config.locales.filter((locale) => locale !== config.defaultLocale);
1747
+ for (const type of config.types) {
1748
+ if (options.contentType && type.id !== options.contentType) continue;
1749
+ const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs3(config.rootDir, type.contentDir);
1750
+ for (const enSlug of enSlugs) {
1751
+ const enDoc = readEnDocument(config, type, enSlug);
1752
+ if (!enDoc) continue;
1753
+ const payload = getTranslatablePayload(enDoc, type);
1754
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1755
+ for (const locale of locales) {
1756
+ if (locale === config.defaultLocale) continue;
1757
+ const existing = getTranslation(db, type.id, enSlug, locale);
1758
+ if (!existing) {
1759
+ items.push({
1760
+ contentType: type.id,
1761
+ enSlug,
1762
+ locale,
1763
+ reason: "missing",
1764
+ currentEnHash
1765
+ });
1766
+ continue;
1767
+ }
1768
+ if (existing.en_hash !== currentEnHash) {
1769
+ items.push({
1770
+ contentType: type.id,
1771
+ enSlug,
1772
+ locale,
1773
+ reason: "stale",
1774
+ currentEnHash,
1775
+ storedEnHash: existing.en_hash
1776
+ });
1777
+ }
1778
+ }
1779
+ }
1780
+ }
1781
+ db.close();
1782
+ return items;
1783
+ }
1784
+ function resolveLocalesFromPreset(config, preset, explicitLocales) {
1785
+ if (explicitLocales?.length) return explicitLocales;
1786
+ if (preset && config.localePresets?.[preset]) {
1787
+ return config.localePresets[preset].filter((l) => l !== config.defaultLocale);
1788
+ }
1789
+ return config.locales.filter((l) => l !== config.defaultLocale);
1790
+ }
1791
+
1792
+ // src/translate/page-translator.ts
1793
+ init_esm_shims();
1794
+ init_sqlite();
1795
+
1796
+ // src/translate/gemini-client.ts
1797
+ init_esm_shims();
1798
+ var DEFAULT_MODEL = "gemini-2.5-pro";
1799
+ function extractJson(text) {
1800
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
1801
+ if (fenced?.[1]) return fenced[1].trim();
1802
+ const start = text.indexOf("{");
1803
+ const end = text.lastIndexOf("}");
1804
+ if (start >= 0 && end > start) return text.slice(start, end + 1);
1805
+ return text.trim();
1806
+ }
1807
+ async function translatePageWithGemini(input) {
1808
+ const apiKey = input.apiKey ?? process.env.GEMINI_API_KEY;
1809
+ if (!apiKey) {
1810
+ throw new Error("GEMINI_API_KEY is required for scribe translate");
1811
+ }
1812
+ const model = input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL;
1813
+ const ai = new GoogleGenAI({ apiKey });
1814
+ const response = await ai.models.generateContent({
1815
+ model,
1816
+ contents: input.prompt,
1817
+ config: {
1818
+ responseMimeType: "application/json"
1819
+ }
1820
+ });
1821
+ const raw = response.text ?? "";
1822
+ const parsed = JSON.parse(extractJson(raw));
1823
+ if (!parsed.frontmatter || typeof parsed.body !== "string") {
1824
+ throw new Error("Gemini response missing frontmatter/body");
1825
+ }
1826
+ return { model, raw, parsed };
1827
+ }
1828
+
1829
+ // src/translate/prompts/translation-prompt.ts
1830
+ init_esm_shims();
1831
+ var LOCALE_NAMES = {
1832
+ fr: "French",
1833
+ es: "Spanish",
1834
+ de: "German",
1835
+ nl: "Dutch",
1836
+ uk: "Ukrainian",
1837
+ tr: "Turkish",
1838
+ da: "Danish",
1839
+ he: "Hebrew",
1840
+ "zh-CN": "Simplified Chinese",
1841
+ "zh-TW": "Traditional Chinese",
1842
+ pt: "Portuguese",
1843
+ ja: "Japanese",
1844
+ ru: "Russian",
1845
+ it: "Italian",
1846
+ ar: "Arabic"
1847
+ };
1848
+ function defaultLocalizationPrompt(localeName, locale) {
1849
+ return [
1850
+ `Localize the content below into natural ${localeName} (${locale}).`,
1851
+ "Do not translate word-for-word.",
1852
+ "Preserve the source tone and brand voice.",
1853
+ "Write as if a native speaker authored it for the target market."
1854
+ ].join(" ");
1855
+ }
1856
+ function buildPageTranslationPrompt(input) {
1857
+ const localeName = LOCALE_NAMES[input.targetLocale] ?? input.targetLocale;
1858
+ const prompt = input.resolved.promptOverride ?? defaultLocalizationPrompt(localeName, input.targetLocale);
1859
+ const lines = [
1860
+ prompt,
1861
+ "",
1862
+ ...input.resolved.context ? ["## Context", input.resolved.context, ""] : [],
1863
+ "## Rules",
1864
+ ...input.resolved.rules.map((rule) => `- ${rule}`),
1865
+ "",
1866
+ "## Output format",
1867
+ "Return ONLY valid JSON with keys:",
1868
+ input.slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).",
1869
+ "",
1870
+ "## EN metadata",
1871
+ `title: ${input.enTitle}`,
1872
+ `description: ${input.enDescription}`,
1873
+ "",
1874
+ "## EN translatable frontmatter (JSON)",
1875
+ JSON.stringify(input.translatableFrontmatter, null, 2),
1876
+ "",
1877
+ "## EN body (MDX)",
1878
+ input.enBody
1879
+ ];
1880
+ return lines.join("\n");
1881
+ }
1882
+
1883
+ // src/translate/resolve-translate-config.ts
1884
+ init_esm_shims();
1885
+ function slugStrategyRules(slugStrategy, preserveTerms) {
1886
+ if (slugStrategy === "localized") {
1887
+ const rules = [
1888
+ "Provide a localized URL slug in JSON field `slug`.",
1889
+ "Slug MUST be ASCII only: a-z, 0-9, hyphens. No uppercase, accents, underscores, or spaces.",
1890
+ "For non-Latin locales, transliterate into Latin script."
1891
+ ];
1892
+ if (preserveTerms?.length) {
1893
+ rules.push(
1894
+ `Preserve these terms verbatim in slugs (lowercase): ${preserveTerms.join(", ")}.`
1895
+ );
1896
+ } else {
1897
+ rules.push("Preserve 4-digit years and proper nouns in slugs.");
1898
+ }
1899
+ return rules;
1900
+ }
1901
+ return ["Do NOT output a slug \u2014 slugStrategy is fixed."];
1902
+ }
1903
+ function mergeContext(project, type) {
1904
+ const parts = [project?.context, type?.context].filter(Boolean);
1905
+ return parts.length > 0 ? parts.join("\n\n") : void 0;
1906
+ }
1907
+ function mergeTranslateConfig(project, type, slugStrategy) {
1908
+ return {
1909
+ promptOverride: type?.prompt ?? project?.prompt,
1910
+ context: mergeContext(project, type),
1911
+ rules: [
1912
+ ...project?.rules ?? [],
1913
+ ...type?.rules ?? [],
1914
+ ...slugStrategyRules(slugStrategy, project?.slugPreserveTerms)
1915
+ ],
1916
+ model: type?.model ?? project?.defaultModel
1917
+ };
1918
+ }
1919
+ function resolveTranslateConfig(config, type) {
1920
+ return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
1921
+ }
1922
+
1923
+ // src/translate/page-translator.ts
1924
+ async function translatePage(config, item, options = {}) {
1925
+ const type = config.types.find((t) => t.id === item.contentType);
1926
+ if (!type) throw new Error(`Unknown content type ${item.contentType}`);
1927
+ const enDoc = readEnDocument(config, type, item.enSlug);
1928
+ if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
1929
+ const payload = getTranslatablePayload(enDoc, type);
1930
+ const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
1931
+ const db = openStore(config, "readonly");
1932
+ const existing = getTranslation(db, type.id, item.enSlug, item.locale);
1933
+ db.close();
1934
+ if (!options.force && existing && existing.en_hash === currentEnHash) {
1935
+ return {
1936
+ contentType: item.contentType,
1937
+ enSlug: item.enSlug,
1938
+ locale: item.locale,
1939
+ skipped: true,
1940
+ reason: "fresh"
1941
+ };
1942
+ }
1943
+ const resolvedTranslate = resolveTranslateConfig(config, type);
1944
+ const prompt = buildPageTranslationPrompt({
1945
+ resolved: resolvedTranslate,
1946
+ targetLocale: item.locale,
1947
+ enTitle: String(enDoc.frontmatter.title ?? item.enSlug),
1948
+ enDescription: String(enDoc.frontmatter.description ?? ""),
1949
+ translatableFrontmatter: payload.frontmatter,
1950
+ enBody: payload.body,
1951
+ slugStrategy: type.slugStrategy
1952
+ });
1953
+ if (options.dryRun) {
1954
+ return {
1955
+ contentType: item.contentType,
1956
+ enSlug: item.enSlug,
1957
+ locale: item.locale,
1958
+ skipped: false,
1959
+ model: options.model
1960
+ };
1961
+ }
1962
+ const result = await translatePageWithGemini({
1963
+ prompt,
1964
+ model: options.model ?? resolvedTranslate.model
1965
+ });
1966
+ const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
1967
+ const writeDb = openStore(config, "readwrite");
1968
+ upsertTranslation(writeDb, {
1969
+ contentType: type.id,
1970
+ enSlug: item.enSlug,
1971
+ locale: item.locale,
1972
+ slug,
1973
+ frontmatter: result.parsed.frontmatter,
1974
+ body: result.parsed.body,
1975
+ enHash: currentEnHash,
1976
+ translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1977
+ model: result.model
1978
+ });
1979
+ writeDb.close();
1980
+ recordRevision(config, {
1981
+ contentType: type.id,
1982
+ enSlug: item.enSlug,
1983
+ locale: item.locale,
1984
+ revisionKind: "translation",
1985
+ enHash: currentEnHash,
1986
+ body: result.parsed.body,
1987
+ model: result.model
1988
+ });
1989
+ return {
1990
+ contentType: item.contentType,
1991
+ enSlug: item.enSlug,
1992
+ locale: item.locale,
1993
+ skipped: false,
1994
+ model: result.model
1995
+ };
1996
+ }
1997
+ async function translateWorklist(config, items, options = {}) {
1998
+ const results = [];
1999
+ for (const item of items) {
2000
+ results.push(await translatePage(config, item, options));
2001
+ }
2002
+ return results;
2003
+ }
2004
+
2005
+ // src/config/load-env.ts
2006
+ init_esm_shims();
2007
+ var ENV_FILES = [".env", ".env.local"];
2008
+ function loadEnvFromCwd(cwd) {
2009
+ for (const file of ENV_FILES) {
2010
+ const envPath = path4.join(
2011
+ /* turbopackIgnore: true */
2012
+ cwd,
2013
+ file
2014
+ );
2015
+ if (!fs2.existsSync(envPath)) continue;
2016
+ dotenv.config({ path: envPath, override: false });
2017
+ }
2018
+ }
2019
+
2020
+ // studio/server.ts
2021
+ init_esm_shims();
2022
+ init_sqlite();
2023
+ function renderLayout(title, body) {
2024
+ return `<!doctype html>
2025
+ <html lang="en">
2026
+ <head>
2027
+ <meta charset="utf-8" />
2028
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2029
+ <title>${title} \u2014 Scribe Studio</title>
2030
+ <style>
2031
+ body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b0d10; color: #e8eaed; }
2032
+ header { padding: 16px 24px; border-bottom: 1px solid #222; display: flex; gap: 16px; }
2033
+ header a { color: #9ecbff; text-decoration: none; }
2034
+ main { padding: 24px; }
2035
+ table { border-collapse: collapse; width: 100%; }
2036
+ th, td { border: 1px solid #333; padding: 8px 10px; text-align: left; vertical-align: top; }
2037
+ th { background: #151922; }
2038
+ .muted { color: #9aa0a6; }
2039
+ .stale { color: #ffb4a2; }
2040
+ .ok { color: #8ce99a; }
2041
+ .card { background: #151922; border: 1px solid #333; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
2042
+ </style>
2043
+ </head>
2044
+ <body>
2045
+ <header>
2046
+ <strong>Scribe Studio</strong>
2047
+ <a href="/">Browser</a>
2048
+ <a href="/staleness">Staleness</a>
2049
+ <a href="/history">History</a>
2050
+ </header>
2051
+ <main>${body}</main>
2052
+ </body>
2053
+ </html>`;
2054
+ }
2055
+ async function startStudio(project, options = {}) {
2056
+ const app = new Hono();
2057
+ const config = project.config;
2058
+ app.get("/", (c) => {
2059
+ const cards = project.listTypes().map((type) => {
2060
+ const localeRows = config.locales.map((locale) => {
2061
+ const count = type.load().get(locale)?.bySlug.size ?? 0;
2062
+ return `<li>${locale}: ${count}</li>`;
2063
+ }).join("");
2064
+ return `<div class="card"><h2>${type.config.label}</h2><ul>${localeRows}</ul></div>`;
2065
+ });
2066
+ return c.html(renderLayout("Browser", cards.join("")));
2067
+ });
2068
+ app.get("/staleness", (c) => {
2069
+ const worklist = buildWorklist(config);
2070
+ const rows = worklist.slice(0, 500).map(
2071
+ (item) => `<tr><td>${item.contentType}</td><td>${item.enSlug}</td><td>${item.locale}</td><td class="stale">${item.reason}</td></tr>`
2072
+ ).join("");
2073
+ const html = `<h1>Translation staleness</h1><p class="muted">${worklist.length} stale/missing entries (showing up to 500)</p>
2074
+ <table><thead><tr><th>Type</th><th>EN slug</th><th>Locale</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
2075
+ return c.html(renderLayout("Staleness", html));
2076
+ });
2077
+ app.get("/history", (c) => {
2078
+ const typeId = c.req.query("type") ?? project.listTypes()[0]?.id;
2079
+ const enSlug = c.req.query("slug");
2080
+ if (!typeId) return c.html(renderLayout("History", "<p>No content types.</p>"));
2081
+ const db = openStore(config, "readonly");
2082
+ let body = `<h1>History</h1><form><label>Type <select name="type">${project.listTypes().map((t) => `<option value="${t.id}" ${t.id === typeId ? "selected" : ""}>${t.id}</option>`).join("")}</select></label> <label>EN slug <input name="slug" value="${enSlug ?? ""}" /></label> <button>View</button></form>`;
2083
+ if (enSlug) {
2084
+ const rows = listRevisions(db, typeId, enSlug).slice(0, 100).map(
2085
+ (row) => `<tr><td>${row.created_at}</td><td>${row.revision_kind}</td><td>${row.locale ?? "en"}</td><td>${row.en_hash.slice(0, 8)}</td><td class="muted">${row.body_preview ?? ""}</td></tr>`
2086
+ ).join("");
2087
+ body += `<table><thead><tr><th>When</th><th>Kind</th><th>Locale</th><th>EN hash</th><th>Preview</th></tr></thead><tbody>${rows}</tbody></table>`;
2088
+ } else {
2089
+ const translations = listTranslationsForType(db, typeId).slice(0, 100);
2090
+ body += `<ul>${translations.map((row) => `<li><a href="/history?type=${typeId}&slug=${row.en_slug}">${row.en_slug}</a> (${row.locale})</li>`).join("")}</ul>`;
2091
+ }
2092
+ db.close();
2093
+ return c.html(renderLayout("History", body));
2094
+ });
2095
+ app.get("/api/staleness-matrix", (c) => {
2096
+ const matrix = {};
2097
+ for (const type of project.listTypes()) {
2098
+ matrix[type.id] = {};
2099
+ for (const locale of config.locales) {
2100
+ if (locale === config.defaultLocale) continue;
2101
+ const enSlugs = type.list().map((doc) => doc.slug);
2102
+ let stale = 0;
2103
+ for (const enSlug of enSlugs) {
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;
2116
+ }
2117
+ }
2118
+ return c.json(matrix);
2119
+ });
2120
+ const port = options.port ?? 3600;
2121
+ const host = options.host ?? "127.0.0.1";
2122
+ serve({ fetch: app.fetch, port, hostname: host }, () => {
2123
+ console.log(`Scribe studio listening on http://${host}:${port}`);
2124
+ });
2125
+ }
2126
+
2127
+ // cli/index.ts
2128
+ function parseArgs(argv) {
2129
+ const options = { cwd: process.cwd() };
2130
+ const positional = [];
2131
+ let i = 0;
2132
+ while (i < argv.length) {
2133
+ const arg = argv[i];
2134
+ if (arg === "--config") {
2135
+ options.config = argv[++i];
2136
+ i++;
2137
+ continue;
2138
+ }
2139
+ if (arg === "--locale") {
2140
+ const locales = [];
2141
+ while (argv[i + 1] && !argv[i + 1].startsWith("--")) {
2142
+ locales.push(argv[++i]);
2143
+ }
2144
+ options.locale = locales;
2145
+ i++;
2146
+ continue;
2147
+ }
2148
+ if (arg === "--preset") {
2149
+ options.preset = argv[++i];
2150
+ i++;
2151
+ continue;
2152
+ }
2153
+ if (arg === "--type") {
2154
+ options.type = argv[++i];
2155
+ i++;
2156
+ continue;
2157
+ }
2158
+ if (arg === "--slug") {
2159
+ options.slug = argv[++i];
2160
+ i++;
2161
+ continue;
2162
+ }
2163
+ if (arg === "--model") {
2164
+ options.model = argv[++i];
2165
+ i++;
2166
+ continue;
2167
+ }
2168
+ if (arg === "--port") {
2169
+ options.port = Number(argv[++i]);
2170
+ i++;
2171
+ continue;
2172
+ }
2173
+ if (arg === "--dry-run") {
2174
+ options.dryRun = true;
2175
+ i++;
2176
+ continue;
2177
+ }
2178
+ if (arg === "--force") {
2179
+ options.force = true;
2180
+ i++;
2181
+ continue;
2182
+ }
2183
+ if (arg.startsWith("--")) {
2184
+ throw new Error(`Unknown flag: ${arg}`);
2185
+ }
2186
+ positional.push(arg);
2187
+ i++;
2188
+ }
2189
+ const [command = "help", ...rest] = positional;
2190
+ return { command, options, rest };
2191
+ }
2192
+ function loadProject(options) {
2193
+ const config = loadConfigSync({
2194
+ cwd: options.cwd,
2195
+ configPath: options.config
2196
+ });
2197
+ return createProject(config);
2198
+ }
2199
+ async function main() {
2200
+ const { command, options, rest } = parseArgs(process.argv.slice(2));
2201
+ loadEnvFromCwd(options.cwd);
2202
+ if (command === "help" || command === "--help") {
2203
+ console.log(`Usage: scribe <command>
2204
+
2205
+ Commands:
2206
+ status Show EN docs + translation counts
2207
+ validate Validate EN files and sqlite consistency
2208
+ translate Translate stale/missing locale pages
2209
+ history <type> <slug> Show revision timeline
2210
+ studio Start read-only local studio
2211
+ `);
2212
+ return;
2213
+ }
2214
+ const project = loadProject(options);
2215
+ const config = project.config;
2216
+ switch (command) {
2217
+ case "status": {
2218
+ for (const type of project.listTypes()) {
2219
+ const enCount = type.list().length;
2220
+ console.log(`${type.id}: ${enCount} EN docs`);
2221
+ for (const locale of config.locales) {
2222
+ if (locale === config.defaultLocale) continue;
2223
+ const count = type.load().get(locale)?.bySlug.size ?? 0;
2224
+ console.log(` ${locale}: ${count}`);
2225
+ }
2226
+ }
2227
+ console.log(`store: ${project.storePath}`);
2228
+ break;
2229
+ }
2230
+ case "validate": {
2231
+ const result = validateProject(config);
2232
+ for (const issue of result.issues) {
2233
+ console.log(
2234
+ `[${issue.level}] ${issue.contentType ?? ""} ${issue.enSlug ?? ""} ${issue.locale ?? ""} ${issue.message}`
2235
+ );
2236
+ }
2237
+ if (!result.ok) process.exitCode = 1;
2238
+ break;
2239
+ }
2240
+ case "translate": {
2241
+ const locales = resolveLocalesFromPreset(config, options.preset, options.locale);
2242
+ const worklist = buildWorklist(config, {
2243
+ contentType: options.type,
2244
+ locales,
2245
+ enSlug: options.slug
2246
+ });
2247
+ console.log(`Translating ${worklist.length} page(s)...`);
2248
+ const results = await translateWorklist(config, worklist, {
2249
+ model: options.model,
2250
+ dryRun: options.dryRun,
2251
+ force: options.force
2252
+ });
2253
+ for (const result of results) {
2254
+ console.log(
2255
+ `${result.contentType}/${result.enSlug}@${result.locale}: ${result.skipped ? "skipped" : "translated"}${result.model ? ` (${result.model})` : ""}`
2256
+ );
2257
+ }
2258
+ break;
2259
+ }
2260
+ case "history": {
2261
+ const [typeId, enSlug, locale] = rest;
2262
+ if (!typeId || !enSlug) {
2263
+ throw new Error("Usage: scribe history <type> <en-slug> [locale]");
2264
+ }
2265
+ const { openStore: openStore2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
2266
+ const db = openStore2(config, "readonly");
2267
+ const rows = listRevisions(db, typeId, enSlug, locale);
2268
+ db.close();
2269
+ for (const row of rows) {
2270
+ console.log(
2271
+ `${row.created_at} ${row.revision_kind} locale=${row.locale ?? "en"} en_hash=${row.en_hash.slice(0, 8)} body_hash=${row.body_hash.slice(0, 8)}`
2272
+ );
2273
+ }
2274
+ break;
2275
+ }
2276
+ case "studio": {
2277
+ await startStudio(project, { port: options.port ?? 3600 });
2278
+ break;
2279
+ }
2280
+ default:
2281
+ throw new Error(`Unknown command: ${command}`);
2282
+ }
2283
+ }
2284
+ main().catch((error) => {
2285
+ console.error(error instanceof Error ? error.message : error);
2286
+ process.exit(1);
2287
+ });
2288
+ //# sourceMappingURL=index.js.map
2289
+ //# sourceMappingURL=index.js.map