scribe-cms 0.0.7 → 0.0.9

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 (56) hide show
  1. package/README.md +5 -5
  2. package/dist/cli/index.cjs +534 -388
  3. package/dist/cli/index.cjs.map +1 -1
  4. package/dist/cli/index.js +534 -388
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/index.cjs +745 -516
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +744 -517
  9. package/dist/index.js.map +1 -1
  10. package/dist/runtime.cjs +292 -261
  11. package/dist/runtime.cjs.map +1 -1
  12. package/dist/runtime.js +290 -261
  13. package/dist/runtime.js.map +1 -1
  14. package/dist/src/config/resolve-config.d.ts.map +1 -1
  15. package/dist/src/core/builtin-fields.d.ts +8 -8
  16. package/dist/src/core/builtin-fields.d.ts.map +1 -1
  17. package/dist/src/core/types.d.ts +14 -4
  18. package/dist/src/core/types.d.ts.map +1 -1
  19. package/dist/src/create-project.d.ts.map +1 -1
  20. package/dist/src/create-scribe.d.ts.map +1 -1
  21. package/dist/src/export/build-static-raw-exports.d.ts.map +1 -1
  22. package/dist/src/i18n/build-url.d.ts +16 -3
  23. package/dist/src/i18n/build-url.d.ts.map +1 -1
  24. package/dist/src/i18n/resolve-document.d.ts +3 -2
  25. package/dist/src/i18n/resolve-document.d.ts.map +1 -1
  26. package/dist/src/index.d.ts +3 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/loader/create-loader.d.ts.map +1 -1
  29. package/dist/src/redirects/build-json-redirects.d.ts +6 -0
  30. package/dist/src/redirects/build-json-redirects.d.ts.map +1 -0
  31. package/dist/src/redirects/build-redirects.d.ts +9 -7
  32. package/dist/src/redirects/build-redirects.d.ts.map +1 -1
  33. package/dist/src/redirects/load-type-redirects.d.ts +14 -0
  34. package/dist/src/redirects/load-type-redirects.d.ts.map +1 -0
  35. package/dist/src/redirects/redirect-schema.d.ts +31 -0
  36. package/dist/src/redirects/redirect-schema.d.ts.map +1 -0
  37. package/dist/src/runtime.d.ts +3 -0
  38. package/dist/src/runtime.d.ts.map +1 -1
  39. package/dist/src/sitemap/generate-sitemap.d.ts.map +1 -1
  40. package/dist/src/translate/page-translator.d.ts +1 -0
  41. package/dist/src/translate/page-translator.d.ts.map +1 -1
  42. package/dist/src/translate/resolve-translate-config.d.ts.map +1 -1
  43. package/dist/src/translate/sanitize-mdx-jsx.d.ts +9 -0
  44. package/dist/src/translate/sanitize-mdx-jsx.d.ts.map +1 -0
  45. package/dist/src/validate/validate-project.d.ts.map +1 -1
  46. package/dist/src/validate/validate-redirects.d.ts +4 -0
  47. package/dist/src/validate/validate-redirects.d.ts.map +1 -0
  48. package/dist/studio/server.cjs +90 -61
  49. package/dist/studio/server.cjs.map +1 -1
  50. package/dist/studio/server.js +90 -61
  51. package/dist/studio/server.js.map +1 -1
  52. package/package.json +1 -1
  53. package/dist/src/core/slug-aliases.d.ts +0 -27
  54. package/dist/src/core/slug-aliases.d.ts.map +0 -1
  55. package/dist/src/redirects/translation-index.d.ts +0 -4
  56. package/dist/src/redirects/translation-index.d.ts.map +0 -1
package/dist/index.cjs CHANGED
@@ -139,14 +139,14 @@ var SLUG_PLACEHOLDER = "{slug}";
139
139
  function isRoutableType(type) {
140
140
  return typeof type.path === "string" && type.path.length > 0;
141
141
  }
142
- function assertValidPathTemplate(path11, typeId) {
143
- if (!path11.startsWith("/")) {
144
- throw new Error(`Content type "${typeId}": path must start with / (got "${path11}")`);
142
+ function assertValidPathTemplate(path13, typeId) {
143
+ if (!path13.startsWith("/")) {
144
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path13}")`);
145
145
  }
146
- const count = (path11.match(/\{slug\}/g) ?? []).length;
146
+ const count = (path13.match(/\{slug\}/g) ?? []).length;
147
147
  if (count !== 1) {
148
148
  throw new Error(
149
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path11}")`
149
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path13}")`
150
150
  );
151
151
  }
152
152
  }
@@ -160,19 +160,69 @@ function pathSuffix(template) {
160
160
  if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
161
161
  return template.slice(idx + SLUG_PLACEHOLDER.length);
162
162
  }
163
- function resolvePath(template, slug, locale, defaultLocale) {
164
- const relative = template.replace(SLUG_PLACEHOLDER, slug);
165
- if (locale === defaultLocale) return relative;
166
- return `/${locale}${relative}`;
167
- }
168
- function extractSlugFromResolvedPath(template, resolvedPath) {
169
- const prefix = pathPrefix(template);
170
- const suffix = pathSuffix(template);
171
- if (!resolvedPath.startsWith(prefix)) return null;
172
- if (suffix && !resolvedPath.endsWith(suffix)) return null;
173
- const slugEnd = suffix ? resolvedPath.length - suffix.length : resolvedPath.length;
174
- const slug = resolvedPath.slice(prefix.length, slugEnd);
175
- return slug.length > 0 ? slug : null;
163
+ function resolveDefaultLocaleRouting() {
164
+ return { strategy: "path-prefix", prefixDefaultLocale: false };
165
+ }
166
+ function splitPathAndSearch(pathname) {
167
+ const q = pathname.indexOf("?");
168
+ if (q === -1) return { pathname, search: "" };
169
+ return { pathname: pathname.slice(0, q), search: pathname.slice(q) };
170
+ }
171
+ function createUrlBuilder(config) {
172
+ const localeRouting = config.localeRouting ?? resolveDefaultLocaleRouting();
173
+ const defaultLocale = config.defaultLocale;
174
+ const prefixedLocales = localeRouting.strategy === "path-prefix" ? localeRouting.prefixDefaultLocale ? [...config.locales] : config.locales.filter((locale) => locale !== defaultLocale) : config.locales.filter((locale) => locale !== defaultLocale);
175
+ function applyLocaleToPath(pathname, locale) {
176
+ if (locale === defaultLocale) {
177
+ if (localeRouting.strategy === "path-prefix" && localeRouting.prefixDefaultLocale) {
178
+ return `/${defaultLocale}${pathname}`;
179
+ }
180
+ return pathname;
181
+ }
182
+ if (localeRouting.strategy === "search-param") {
183
+ const { pathname: base, search } = splitPathAndSearch(pathname);
184
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
185
+ params.set(localeRouting.param, locale);
186
+ const qs = params.toString();
187
+ return qs ? `${base}?${qs}` : base;
188
+ }
189
+ return `/${locale}${pathname}`;
190
+ }
191
+ function resolvePath(template, slug, locale) {
192
+ const relative = template.replace(SLUG_PLACEHOLDER, slug);
193
+ return applyLocaleToPath(relative, locale);
194
+ }
195
+ function extractSlugFromResolvedPath(template, resolvedPath) {
196
+ let pathname = resolvedPath;
197
+ if (localeRouting.strategy === "search-param") {
198
+ pathname = splitPathAndSearch(resolvedPath).pathname;
199
+ } else if (localeRouting.strategy === "path-prefix") {
200
+ for (const locale of config.locales) {
201
+ if (locale === defaultLocale && !localeRouting.prefixDefaultLocale) continue;
202
+ const prefix2 = `/${locale}`;
203
+ if (pathname === prefix2 || pathname.startsWith(`${prefix2}/`)) {
204
+ pathname = pathname.slice(prefix2.length) || "/";
205
+ break;
206
+ }
207
+ }
208
+ }
209
+ const prefix = pathPrefix(template);
210
+ const suffix = pathSuffix(template);
211
+ if (!pathname.startsWith(prefix)) return null;
212
+ if (suffix && !pathname.endsWith(suffix)) return null;
213
+ const slugEnd = suffix ? pathname.length - suffix.length : pathname.length;
214
+ const slug = pathname.slice(prefix.length, slugEnd);
215
+ return slug.length > 0 ? slug : null;
216
+ }
217
+ return {
218
+ defaultLocale,
219
+ locales: config.locales,
220
+ localeRouting,
221
+ prefixedLocales,
222
+ resolvePath,
223
+ extractSlugFromResolvedPath,
224
+ applyLocaleToPath
225
+ };
176
226
  }
177
227
 
178
228
  // src/config/resolve-config.ts
@@ -195,6 +245,13 @@ function resolveConfig(input, baseDir) {
195
245
  `scribe config: defaultLocale "${defaultLocale}" is not in locales [${raw.locales.join(", ")}]`
196
246
  );
197
247
  }
248
+ const localeRouting = raw.localeRouting ?? {
249
+ strategy: "path-prefix",
250
+ prefixDefaultLocale: false
251
+ };
252
+ if (localeRouting.strategy === "search-param" && !localeRouting.param) {
253
+ throw new Error('scribe config: localeRouting search-param requires a "param" name');
254
+ }
198
255
  const projectRoot = path3__default.default.resolve(baseDir ?? process.cwd(), raw.rootDir);
199
256
  const contentRoot = path3__default.default.resolve(projectRoot, raw.contentDir ?? "content");
200
257
  const storePath = path3__default.default.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
@@ -222,6 +279,7 @@ function resolveConfig(input, baseDir) {
222
279
  assetsPath,
223
280
  locales: [...raw.locales],
224
281
  defaultLocale,
282
+ localeRouting,
225
283
  localePresets: raw.localePresets,
226
284
  translate: raw.translate,
227
285
  types
@@ -272,10 +330,10 @@ function introspectSchema(schema, prefix = []) {
272
330
  }
273
331
  function extractByPaths(data, paths) {
274
332
  const out = {};
275
- for (const path11 of paths) {
276
- const value = getAtPath(data, path11);
333
+ for (const path13 of paths) {
334
+ const value = getAtPath(data, path13);
277
335
  if (value !== void 0) {
278
- setAtPath(out, path11.filter((p) => p !== "*"), value);
336
+ setAtPath(out, path13.filter((p) => p !== "*"), value);
279
337
  }
280
338
  }
281
339
  return out;
@@ -293,13 +351,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
293
351
  const translatable = pickTranslatable(localeData, schema);
294
352
  return deepMerge(structural, translatable);
295
353
  }
296
- function getAtPath(obj, path11) {
354
+ function getAtPath(obj, path13) {
297
355
  let current = obj;
298
- for (const segment of path11) {
356
+ for (const segment of path13) {
299
357
  if (segment === "*") {
300
358
  if (!Array.isArray(current)) return void 0;
301
359
  return current.map(
302
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path11.slice(path11.indexOf("*") + 1)) : void 0
360
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path13.slice(path13.indexOf("*") + 1)) : void 0
303
361
  );
304
362
  }
305
363
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -307,13 +365,13 @@ function getAtPath(obj, path11) {
307
365
  }
308
366
  return current;
309
367
  }
310
- function setAtPath(obj, path11, value) {
311
- if (path11.length === 0) return;
312
- if (path11.length === 1) {
313
- obj[path11[0]] = value;
368
+ function setAtPath(obj, path13, value) {
369
+ if (path13.length === 0) return;
370
+ if (path13.length === 1) {
371
+ obj[path13[0]] = value;
314
372
  return;
315
373
  }
316
- const [head, ...rest] = path11;
374
+ const [head, ...rest] = path13;
317
375
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
318
376
  obj[head] = {};
319
377
  }
@@ -358,45 +416,23 @@ var LOCALE_BUILTIN_KEYS = [
358
416
  ["translationOf", "translationOf is internal; remove from locale translation"],
359
417
  ["enSlug", "enSlug is internal; remove from locale translation"]
360
418
  ];
361
- function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
419
+ var DEPRECATED_REDIRECT_FIELDS = [
420
+ [
421
+ "aliases",
422
+ "aliases frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
423
+ ],
424
+ [
425
+ "redirect_to",
426
+ "redirect_to frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
427
+ ]
428
+ ];
429
+ function extractBuiltinEnFields(data, _pathTemplate, _enSlug, _defaultLocale) {
362
430
  const issues = [];
363
431
  const rest = { ...data };
364
- const aliasesResult = zod.z.array(slugPatternSchema).max(20).optional().default([]).safeParse(rest.aliases ?? []);
365
- delete rest.aliases;
366
- const redirectRaw = rest.redirect_to;
367
- delete rest.redirect_to;
368
- let redirectTo;
369
- if (redirectRaw !== void 0 && redirectRaw !== null && redirectRaw !== "") {
370
- if (typeof redirectRaw !== "string") {
371
- issues.push({
372
- field: "redirect_to",
373
- message: "redirect_to must be a string path",
374
- level: "error"
375
- });
376
- } else if (!pathTemplate) {
377
- issues.push({
378
- field: "redirect_to",
379
- message: "redirect_to is not allowed on reference-only content types",
380
- level: "error"
381
- });
382
- } else if (!extractSlugFromResolvedPath(pathTemplate, redirectRaw)) {
383
- issues.push({
384
- field: "redirect_to",
385
- message: `redirect_to must match path template "${pathTemplate}" (prefix "${pathPrefix(pathTemplate)}"${pathSuffix(pathTemplate) ? `, suffix "${pathSuffix(pathTemplate)}"` : ""})`,
386
- level: "error"
387
- });
388
- } else {
389
- redirectTo = redirectRaw;
390
- }
391
- }
392
- const aliases = aliasesResult.success ? aliasesResult.data : [];
393
- if (!aliasesResult.success) {
394
- for (const issue of aliasesResult.error.issues) {
395
- issues.push({
396
- field: `aliases.${issue.path.join(".")}`,
397
- message: issue.message,
398
- level: "error"
399
- });
432
+ for (const [field2, message] of DEPRECATED_REDIRECT_FIELDS) {
433
+ if (rest[field2] !== void 0) {
434
+ issues.push({ field: field2, message, level: "error" });
435
+ delete rest[field2];
400
436
  }
401
437
  }
402
438
  let publishedAt;
@@ -458,8 +494,6 @@ function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
458
494
  }
459
495
  return {
460
496
  builtin: {
461
- aliases,
462
- redirectTo,
463
497
  publishedAt,
464
498
  updatedAt,
465
499
  noindex,
@@ -484,20 +518,24 @@ function documentLastModified(doc) {
484
518
  const date = new Date(raw);
485
519
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
486
520
  }
487
- function resolveCanonicalPathname(type, doc, defaultLocale) {
521
+ function resolveCanonicalPathname(type, doc, defaultLocale, localeRouting) {
522
+ const urlBuilder = createUrlBuilder({
523
+ locales: [defaultLocale, ...doc.locale !== defaultLocale ? [doc.locale] : []],
524
+ defaultLocale,
525
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
526
+ });
488
527
  if (doc.canonicalPathOverride) {
489
- if (doc.locale === defaultLocale) return doc.canonicalPathOverride;
490
- return `/${doc.locale}${doc.canonicalPathOverride}`;
528
+ return urlBuilder.applyLocaleToPath(doc.canonicalPathOverride, doc.locale);
491
529
  }
492
530
  if (!type.path) return `/${doc.slug}`;
493
- return resolvePath(type.path, doc.slug, doc.locale, defaultLocale);
531
+ return urlBuilder.resolvePath(type.path, doc.slug, doc.locale);
494
532
  }
495
- function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
533
+ function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale, localeRouting) {
496
534
  const out = { ...frontmatter };
497
535
  if (doc.publishedAt !== void 0) out.publishedAt = doc.publishedAt;
498
536
  if (doc.updatedAt !== void 0) out.updatedAt = doc.updatedAt;
499
537
  out.noindex = doc.noindex;
500
- out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
538
+ out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale, localeRouting);
501
539
  return out;
502
540
  }
503
541
  function seoFieldsFromEn(enDoc) {
@@ -505,8 +543,7 @@ function seoFieldsFromEn(enDoc) {
505
543
  publishedAt: enDoc.publishedAt,
506
544
  updatedAt: enDoc.updatedAt,
507
545
  noindex: enDoc.noindex,
508
- canonicalPathOverride: enDoc.canonicalPathOverride,
509
- redirectTo: enDoc.redirectTo
546
+ canonicalPathOverride: enDoc.canonicalPathOverride
510
547
  };
511
548
  }
512
549
  var SCHEMA_VERSION = 4;
@@ -736,7 +773,8 @@ function parseEnMdx(filePath, config, type) {
736
773
  locale: config.defaultLocale
737
774
  },
738
775
  type,
739
- config.defaultLocale
776
+ config.defaultLocale,
777
+ config.localeRouting
740
778
  );
741
779
  const crossIssues = type.crossValidate?.(result.data, {
742
780
  locale: config.defaultLocale,
@@ -750,8 +788,6 @@ function parseEnMdx(filePath, config, type) {
750
788
  slug,
751
789
  enSlug: slug,
752
790
  locale: config.defaultLocale,
753
- aliases: builtin.aliases,
754
- redirectTo: builtin.redirectTo,
755
791
  publishedAt: builtin.publishedAt,
756
792
  updatedAt: builtin.updatedAt,
757
793
  noindex: builtin.noindex,
@@ -762,7 +798,7 @@ function parseEnMdx(filePath, config, type) {
762
798
  };
763
799
  return { document: document2, issues };
764
800
  }
765
- function buildDocumentFromTranslation(row, enDoc, type) {
801
+ function buildDocumentFromTranslation(row, enDoc, type, config) {
766
802
  const localeFm = JSON.parse(row.frontmatter_json);
767
803
  const merged = mergeStructuralOntoLocale(localeFm, enDoc.frontmatter, type.schema);
768
804
  const seo = seoFieldsFromEn(enDoc);
@@ -770,14 +806,13 @@ function buildDocumentFromTranslation(row, enDoc, type) {
770
806
  merged,
771
807
  { ...seo, slug: row.slug, locale: row.locale },
772
808
  type,
773
- enDoc.locale
809
+ config.defaultLocale,
810
+ config.localeRouting
774
811
  );
775
812
  return {
776
813
  slug: row.slug,
777
814
  enSlug: row.en_slug,
778
815
  locale: row.locale,
779
- aliases: [],
780
- redirectTo: seo.redirectTo,
781
816
  publishedAt: seo.publishedAt,
782
817
  updatedAt: seo.updatedAt,
783
818
  noindex: seo.noindex,
@@ -855,7 +890,7 @@ function createContentLoader(config, type) {
855
890
  for (const row of rowsByLocale.get(locale) ?? []) {
856
891
  const enDoc = englishBySlug.get(row.en_slug);
857
892
  if (!enDoc) continue;
858
- const doc = buildDocumentFromTranslation(row, enDoc, type);
893
+ const doc = buildDocumentFromTranslation(row, enDoc, type, config);
859
894
  bySlug.set(doc.slug, doc);
860
895
  byEnSlug.set(row.en_slug, doc);
861
896
  }
@@ -903,56 +938,16 @@ function getTranslatablePayload(doc, type) {
903
938
  }
904
939
 
905
940
  // src/i18n/resolve-document.ts
906
- function findEnDocByAlias(slug, allDocs, defaultLocale) {
907
- const enIdx = allDocs.get(defaultLocale);
908
- if (!enIdx) return null;
909
- for (const doc of enIdx.bySlug.values()) {
910
- if (doc.aliases.includes(slug)) return doc;
911
- }
912
- return null;
913
- }
914
- function enCanonicalDoc(doc, allDocs, defaultLocale) {
915
- if (doc.locale === defaultLocale) return doc;
916
- return allDocs.get(defaultLocale)?.bySlug.get(doc.enSlug) ?? doc;
917
- }
918
- function redirectPathForDocument(doc, locale, defaultLocale, allDocs, type) {
919
- if (!type.path) return void 0;
920
- const enDoc = enCanonicalDoc(doc, allDocs, defaultLocale);
921
- if (!enDoc.redirectTo) return void 0;
922
- const targetEnSlug = extractSlugFromResolvedPath(type.path, enDoc.redirectTo);
923
- if (!targetEnSlug) return void 0;
924
- const targetEnDoc = allDocs.get(defaultLocale)?.bySlug.get(targetEnSlug);
925
- const targetSlug = targetEnDoc ? getSlugForLocale(targetEnDoc, defaultLocale, locale, allDocs, defaultLocale) ?? targetEnSlug : targetEnSlug;
926
- return resolvePath(type.path, targetSlug, locale, defaultLocale);
927
- }
928
- function withRedirectTo(result, locale, defaultLocale, allDocs, type) {
929
- if (result.shouldRedirectTo || !result.document) return result;
930
- const redirect = redirectPathForDocument(result.document, locale, defaultLocale, allDocs, type);
931
- if (!redirect) return result;
932
- return {
933
- document: null,
934
- actualLocale: result.actualLocale,
935
- shouldRedirectTo: redirect
936
- };
937
- }
938
- function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
941
+ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type, localeRouting) {
942
+ const urlBuilder = createUrlBuilder({
943
+ locales: [defaultLocale, locale],
944
+ defaultLocale,
945
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
946
+ });
939
947
  const idx = allDocs.get(locale);
940
948
  const direct = idx?.bySlug.get(slug);
941
949
  if (direct) {
942
- return withRedirectTo({ document: direct, actualLocale: locale }, locale, defaultLocale, allDocs, type);
943
- }
944
- const aliasDoc = findEnDocByAlias(slug, allDocs, defaultLocale);
945
- if (aliasDoc && type.path) {
946
- const canonicalSlug = aliasDoc.slug;
947
- const localizedSlug = getSlugForLocale(aliasDoc, defaultLocale, locale, allDocs, defaultLocale);
948
- const targetSlug = localizedSlug ?? canonicalSlug;
949
- if (targetSlug !== slug) {
950
- return {
951
- document: null,
952
- actualLocale: locale,
953
- shouldRedirectTo: resolvePath(type.path, targetSlug, locale, defaultLocale)
954
- };
955
- }
950
+ return { document: direct, actualLocale: locale };
956
951
  }
957
952
  for (const [docLocale, docIdx] of allDocs) {
958
953
  const found = docIdx.bySlug.get(slug);
@@ -965,17 +960,11 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
965
960
  return {
966
961
  document: null,
967
962
  actualLocale: locale,
968
- shouldRedirectTo: resolvePath(type.path, correctSlug, locale, defaultLocale)
963
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, correctSlug, locale)
969
964
  };
970
965
  }
971
966
  if (docLocale === defaultLocale) {
972
- return withRedirectTo(
973
- { document: found, actualLocale: defaultLocale },
974
- locale,
975
- defaultLocale,
976
- allDocs,
977
- type
978
- );
967
+ return { document: found, actualLocale: defaultLocale };
979
968
  }
980
969
  if (!type.path) {
981
970
  return { document: null, actualLocale: locale };
@@ -983,39 +972,21 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
983
972
  return {
984
973
  document: null,
985
974
  actualLocale: locale,
986
- shouldRedirectTo: resolvePath(type.path, found.enSlug, locale, defaultLocale)
975
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, found.enSlug, locale)
987
976
  };
988
977
  }
989
978
  if (locale !== defaultLocale) {
990
979
  const enDoc = allDocs.get(defaultLocale)?.bySlug.get(slug);
991
980
  if (enDoc && type.indexFallback === "en") {
992
- return withRedirectTo(
993
- { document: enDoc, actualLocale: defaultLocale },
994
- locale,
995
- defaultLocale,
996
- allDocs,
997
- type
998
- );
981
+ return { document: enDoc, actualLocale: defaultLocale };
999
982
  }
1000
983
  const byEn = idx?.byEnSlug.get(slug);
1001
984
  if (byEn) {
1002
- return withRedirectTo(
1003
- { document: byEn, actualLocale: locale },
1004
- locale,
1005
- defaultLocale,
1006
- allDocs,
1007
- type
1008
- );
985
+ return { document: byEn, actualLocale: locale };
1009
986
  }
1010
987
  const fallback = allDocs.get(defaultLocale)?.bySlug.get(slug);
1011
988
  if (fallback && type.indexFallback === "en") {
1012
- return withRedirectTo(
1013
- { document: fallback, actualLocale: defaultLocale },
1014
- locale,
1015
- defaultLocale,
1016
- allDocs,
1017
- type
1018
- );
989
+ return { document: fallback, actualLocale: defaultLocale };
1019
990
  }
1020
991
  }
1021
992
  return { document: null, actualLocale: locale };
@@ -1057,6 +1028,7 @@ function buildRuntime(config, type, getRuntime) {
1057
1028
  }
1058
1029
  return type.path;
1059
1030
  }
1031
+ const urlBuilder = createUrlBuilder(config);
1060
1032
  const runtime = {
1061
1033
  id: type.id,
1062
1034
  config: type,
@@ -1072,15 +1044,21 @@ function buildRuntime(config, type, getRuntime) {
1072
1044
  return load().get(locale)?.bySlug.get(slug) ?? null;
1073
1045
  },
1074
1046
  resolve(slug, locale) {
1075
- const result = resolveLocalizedDocument(slug, locale, config.defaultLocale, load(), type);
1047
+ const result = resolveLocalizedDocument(
1048
+ slug,
1049
+ locale,
1050
+ config.defaultLocale,
1051
+ load(),
1052
+ type,
1053
+ config.localeRouting
1054
+ );
1076
1055
  if (result.document && type.path) {
1077
1056
  return {
1078
1057
  ...result,
1079
- canonicalPath: resolvePath(
1058
+ canonicalPath: urlBuilder.resolvePath(
1080
1059
  type.path,
1081
1060
  result.document.slug,
1082
- result.actualLocale,
1083
- config.defaultLocale
1061
+ result.actualLocale
1084
1062
  )
1085
1063
  };
1086
1064
  }
@@ -1104,10 +1082,9 @@ function buildRuntime(config, type, getRuntime) {
1104
1082
  alternates(doc) {
1105
1083
  const pathTemplate = assertRoutable("alternates");
1106
1084
  const out = {
1107
- [config.defaultLocale]: resolvePath(
1085
+ [config.defaultLocale]: urlBuilder.resolvePath(
1108
1086
  pathTemplate,
1109
1087
  doc.enSlug,
1110
- config.defaultLocale,
1111
1088
  config.defaultLocale
1112
1089
  )
1113
1090
  };
@@ -1116,7 +1093,7 @@ function buildRuntime(config, type, getRuntime) {
1116
1093
  if (locale === config.defaultLocale) continue;
1117
1094
  const translated = all.get(locale)?.byEnSlug.get(doc.enSlug);
1118
1095
  if (translated) {
1119
- out[locale] = resolvePath(pathTemplate, translated.slug, locale, config.defaultLocale);
1096
+ out[locale] = urlBuilder.resolvePath(pathTemplate, translated.slug, locale);
1120
1097
  }
1121
1098
  }
1122
1099
  return out;
@@ -1131,7 +1108,7 @@ function buildRuntime(config, type, getRuntime) {
1131
1108
  },
1132
1109
  url(slug, locale) {
1133
1110
  const pathTemplate = assertRoutable("url");
1134
- return resolvePath(pathTemplate, slug, locale, config.defaultLocale);
1111
+ return urlBuilder.resolvePath(pathTemplate, slug, locale);
1135
1112
  },
1136
1113
  related(doc, fieldName, locale) {
1137
1114
  const meta = relationFields.get(fieldName);
@@ -1187,6 +1164,9 @@ function createProject(config) {
1187
1164
  getType: getRuntime,
1188
1165
  listTypes() {
1189
1166
  return Array.from(runtimes.values());
1167
+ },
1168
+ listRoutableTypes() {
1169
+ return Array.from(runtimes.values()).filter((runtime) => isRoutableType(runtime.config));
1190
1170
  }
1191
1171
  };
1192
1172
  }
@@ -1194,207 +1174,329 @@ function enFileExists(config, type, enSlug) {
1194
1174
  const dir = path3__default.default.join(config.rootDir, type.contentDir);
1195
1175
  return fs2__default.default.existsSync(path3__default.default.join(dir, `${enSlug}.mdx`)) || fs2__default.default.existsSync(path3__default.default.join(dir, `${enSlug}.md`));
1196
1176
  }
1197
- function sqliteHasTranslations(db, contentTypeId, enSlug) {
1198
- return listTranslationsForEnSlug(db, contentTypeId, enSlug).length > 0;
1199
- }
1200
- function isAliasKnown(config, type, aliasEnSlug, db) {
1201
- return enFileExists(config, type, aliasEnSlug) || sqliteHasTranslations(db, type.id, aliasEnSlug);
1202
- }
1203
1177
  function listEnSlugs(rootDir, contentDir) {
1204
1178
  const dir = path3__default.default.join(rootDir, contentDir);
1205
1179
  if (!fs2__default.default.existsSync(dir)) return [];
1206
1180
  return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1207
1181
  }
1208
1182
 
1209
- // src/core/slug-aliases.ts
1210
- function buildGlobalAliasIndex(project) {
1211
- const { config } = project;
1212
- const aliasToTarget = /* @__PURE__ */ new Map();
1213
- const allAliasSlugs = /* @__PURE__ */ new Set();
1214
- const issues = [];
1215
- const canonicalSlugsByType = /* @__PURE__ */ new Map();
1216
- for (const type of config.types) {
1217
- const slugs = listEnSlugs(config.rootDir, type.contentDir);
1218
- canonicalSlugsByType.set(type.id, new Set(slugs));
1183
+ // src/i18n/translation-index.ts
1184
+ function buildTranslationIndex(allDocs, locales, defaultLocale) {
1185
+ const index = /* @__PURE__ */ new Map();
1186
+ for (const locale of locales) {
1187
+ if (locale === defaultLocale) continue;
1188
+ const idx = allDocs.get(locale);
1189
+ if (!idx) continue;
1190
+ const localeMap = /* @__PURE__ */ new Map();
1191
+ for (const [enSlug, doc] of idx.byEnSlug) {
1192
+ localeMap.set(enSlug, doc.slug);
1193
+ }
1194
+ index.set(locale, localeMap);
1219
1195
  }
1196
+ return index;
1197
+ }
1198
+ var redirectFromSchema = zod.z.union([
1199
+ slugPatternSchema,
1200
+ zod.z.array(slugPatternSchema).min(1).max(20)
1201
+ ]);
1202
+ var typeRedirectEntrySchema = zod.z.object({
1203
+ from: redirectFromSchema,
1204
+ toSlug: slugPatternSchema.optional(),
1205
+ toType: zod.z.string().min(1).optional(),
1206
+ toUrl: zod.z.string().min(1).optional(),
1207
+ permanent: zod.z.boolean().optional()
1208
+ }).superRefine((entry, ctx) => {
1209
+ const hasToSlug = entry.toSlug !== void 0;
1210
+ const hasToUrl = entry.toUrl !== void 0;
1211
+ const hasToType = entry.toType !== void 0;
1212
+ const targetCount = Number(hasToSlug) + Number(hasToUrl);
1213
+ if (targetCount !== 1) {
1214
+ ctx.addIssue({
1215
+ code: "custom",
1216
+ message: "Each redirect must specify exactly one of toSlug/toType+toSlug or toUrl",
1217
+ path: ["toSlug"]
1218
+ });
1219
+ return;
1220
+ }
1221
+ if (hasToUrl && hasToType) {
1222
+ ctx.addIssue({
1223
+ code: "custom",
1224
+ message: "toUrl cannot be combined with toType",
1225
+ path: ["toUrl"]
1226
+ });
1227
+ }
1228
+ if (hasToType && !hasToSlug) {
1229
+ ctx.addIssue({
1230
+ code: "custom",
1231
+ message: "Cross-type redirects require both toType and toSlug",
1232
+ path: ["toSlug"]
1233
+ });
1234
+ }
1235
+ });
1236
+ var typeRedirectsFileSchema = zod.z.object({
1237
+ redirects: zod.z.array(typeRedirectEntrySchema).max(500)
1238
+ });
1239
+ function normalizeRedirectFrom(from) {
1240
+ return Array.isArray(from) ? from : [from];
1241
+ }
1242
+ function parseRedirectEntry(entry) {
1243
+ const fromSlugs = normalizeRedirectFrom(entry.from);
1244
+ if (entry.toUrl !== void 0) {
1245
+ return {
1246
+ fromSlugs,
1247
+ kind: "anywhere",
1248
+ toUrl: entry.toUrl,
1249
+ permanent: entry.permanent ?? true
1250
+ };
1251
+ }
1252
+ if (entry.toType !== void 0) {
1253
+ return {
1254
+ fromSlugs,
1255
+ kind: "cross-type",
1256
+ toType: entry.toType,
1257
+ toSlug: entry.toSlug,
1258
+ permanent: entry.permanent ?? true
1259
+ };
1260
+ }
1261
+ return {
1262
+ fromSlugs,
1263
+ kind: "same-type",
1264
+ toSlug: entry.toSlug,
1265
+ permanent: entry.permanent ?? true
1266
+ };
1267
+ }
1268
+
1269
+ // src/redirects/load-type-redirects.ts
1270
+ var TYPE_REDIRECTS_FILENAME = "_redirects.json";
1271
+ function redirectsFilePath(config, type) {
1272
+ return path3__default.default.join(config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1273
+ }
1274
+ function loadTypeRedirectsFile(config, type) {
1275
+ const filePath = redirectsFilePath(config, type);
1276
+ if (!fs2__default.default.existsSync(filePath)) return null;
1277
+ let raw;
1278
+ try {
1279
+ raw = JSON.parse(fs2__default.default.readFileSync(filePath, "utf8"));
1280
+ } catch (error) {
1281
+ throw new Error(
1282
+ `${type.id}: failed to parse ${TYPE_REDIRECTS_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
1283
+ );
1284
+ }
1285
+ const parsed = typeRedirectsFileSchema.safeParse(raw);
1286
+ if (!parsed.success) {
1287
+ const details = parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ");
1288
+ throw new Error(`${type.id}: invalid ${TYPE_REDIRECTS_FILENAME}: ${details}`);
1289
+ }
1290
+ return {
1291
+ contentTypeId: type.id,
1292
+ contentDir: type.contentDir,
1293
+ filePath,
1294
+ entries: parsed.data.redirects.map(parseRedirectEntry)
1295
+ };
1296
+ }
1297
+ function loadAllTypeRedirects(config) {
1298
+ const out = [];
1220
1299
  for (const type of config.types) {
1221
- if (!type.path) continue;
1222
- const canonicalSlugs = canonicalSlugsByType.get(type.id) ?? /* @__PURE__ */ new Set();
1223
- for (const enSlug of canonicalSlugs) {
1224
- const doc = readEnDocument(config, type, enSlug);
1225
- if (!doc) continue;
1226
- for (const alias of doc.aliases) {
1227
- if (alias === enSlug) {
1228
- issues.push({
1229
- contentTypeId: type.id,
1230
- enSlug,
1231
- field: "aliases",
1232
- message: `Alias "${alias}" must not equal the canonical slug`,
1233
- level: "error"
1234
- });
1235
- continue;
1236
- }
1237
- if (canonicalSlugs.has(alias)) {
1238
- issues.push({
1239
- contentTypeId: type.id,
1240
- enSlug,
1241
- field: "aliases",
1242
- message: `Alias "${alias}" collides with another document's canonical slug`,
1243
- level: "error"
1244
- });
1245
- continue;
1246
- }
1247
- const existing = aliasToTarget.get(alias);
1248
- if (existing) {
1249
- issues.push({
1250
- contentTypeId: type.id,
1251
- enSlug,
1252
- field: "aliases",
1253
- message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
1254
- level: "error"
1255
- });
1256
- continue;
1257
- }
1258
- aliasToTarget.set(alias, {
1259
- contentTypeId: type.id,
1260
- canonicalSlug: enSlug,
1261
- path: type.path
1262
- });
1263
- allAliasSlugs.add(alias);
1300
+ const loaded = loadTypeRedirectsFile(config, type);
1301
+ if (loaded) out.push(loaded);
1302
+ }
1303
+ return out;
1304
+ }
1305
+ function collectRedirectSourceSlugs(loaded) {
1306
+ const slugs = /* @__PURE__ */ new Set();
1307
+ for (const file of loaded) {
1308
+ for (const entry of file.entries) {
1309
+ for (const from of entry.fromSlugs) {
1310
+ slugs.add(from);
1264
1311
  }
1265
1312
  }
1266
1313
  }
1267
- return { aliasToTarget, allAliasSlugs, issues };
1314
+ return slugs;
1268
1315
  }
1269
- function validateAliasCoverage(project) {
1270
- const { config } = project;
1271
- const { aliasToTarget } = buildGlobalAliasIndex(project);
1272
- const issues = [];
1273
- const db = openStore(config, "readonly");
1274
- try {
1275
- for (const [alias, target] of aliasToTarget) {
1276
- const type = config.types.find((t) => t.id === target.contentTypeId);
1277
- if (!type) continue;
1278
- const known = isAliasKnown(config, type, alias, db);
1279
- if (!known) {
1280
- issues.push({
1281
- contentTypeId: type.id,
1282
- enSlug: target.canonicalSlug,
1283
- field: "aliases",
1284
- message: `Alias "${alias}" is unknown (no EN file or sqlite translations) \u2014 only EN redirect will work`,
1285
- level: "warning"
1286
- });
1287
- }
1288
- if (enFileExists(config, type, alias)) {
1289
- issues.push({
1290
- contentTypeId: type.id,
1291
- enSlug: target.canonicalSlug,
1292
- field: "aliases",
1293
- message: `Alias "${alias}" still has an EN file on disk \u2014 delete it manually`,
1294
- level: "warning"
1295
- });
1316
+ function collectOutboundRedirectSourcesByType(loaded) {
1317
+ const out = /* @__PURE__ */ new Map();
1318
+ for (const file of loaded) {
1319
+ const set = out.get(file.contentTypeId) ?? /* @__PURE__ */ new Set();
1320
+ for (const entry of file.entries) {
1321
+ for (const from of entry.fromSlugs) {
1322
+ set.add(from);
1296
1323
  }
1297
- const sqliteRows = listTranslationsForEnSlug(db, type.id, alias);
1298
- if (sqliteRows.length > 0) {
1299
- issues.push({
1300
- contentTypeId: type.id,
1301
- enSlug: target.canonicalSlug,
1302
- field: "aliases",
1303
- message: `${sqliteRows.length} sqlite translation(s) for alias "${alias}" retained for locale redirects and history`,
1304
- level: "info"
1305
- });
1324
+ }
1325
+ out.set(file.contentTypeId, set);
1326
+ }
1327
+ return out;
1328
+ }
1329
+
1330
+ // src/redirects/build-json-redirects.ts
1331
+ function localizedSlug(db, contentTypeId, enSlug, locale, defaultLocale) {
1332
+ if (locale === defaultLocale) return enSlug;
1333
+ const row = listTranslationsForEnSlug(db, contentTypeId, enSlug).find(
1334
+ (translation) => translation.locale === locale
1335
+ );
1336
+ return row?.slug ?? enSlug;
1337
+ }
1338
+ function resolveSlugTargetDestination(project, db, targetType, toSlug, locale, urlBuilder) {
1339
+ const targetSlug = localizedSlug(
1340
+ db,
1341
+ targetType.id,
1342
+ toSlug,
1343
+ locale,
1344
+ project.config.defaultLocale
1345
+ );
1346
+ return urlBuilder.resolvePath(targetType.path, targetSlug, locale);
1347
+ }
1348
+ function buildEntryRules(project, sourceType, entry, db, urlBuilder) {
1349
+ if (!isRoutableType(sourceType)) return [];
1350
+ const out = [];
1351
+ const { defaultLocale } = project.config;
1352
+ let targetType = sourceType;
1353
+ if (entry.kind === "cross-type") {
1354
+ const resolvedTarget = project.config.types.find((type) => type.id === entry.toType);
1355
+ if (!resolvedTarget?.path) return out;
1356
+ targetType = resolvedTarget;
1357
+ }
1358
+ for (const fromEnSlug of entry.fromSlugs) {
1359
+ const locales = project.config.locales;
1360
+ for (const locale of locales) {
1361
+ const fromSlug = localizedSlug(db, sourceType.id, fromEnSlug, locale, defaultLocale);
1362
+ const source = urlBuilder.resolvePath(sourceType.path, fromSlug, locale);
1363
+ let destination;
1364
+ if (entry.kind === "anywhere") {
1365
+ destination = entry.toUrl;
1366
+ } else {
1367
+ destination = resolveSlugTargetDestination(
1368
+ project,
1369
+ db,
1370
+ entry.kind === "cross-type" ? targetType : sourceType,
1371
+ entry.toSlug,
1372
+ locale,
1373
+ urlBuilder
1374
+ );
1306
1375
  }
1307
- for (const locale of config.locales) {
1308
- if (locale === config.defaultLocale) continue;
1309
- const canonicalRow = listTranslationsForEnSlug(db, type.id, target.canonicalSlug).find(
1310
- (r) => r.locale === locale
1376
+ if (source === destination) continue;
1377
+ out.push({
1378
+ source,
1379
+ destination,
1380
+ permanent: entry.permanent
1381
+ });
1382
+ }
1383
+ if (sourceType.id === "blog") {
1384
+ let destinationEn;
1385
+ if (entry.kind === "anywhere") {
1386
+ destinationEn = entry.toUrl;
1387
+ } else {
1388
+ destinationEn = resolveSlugTargetDestination(
1389
+ project,
1390
+ db,
1391
+ entry.kind === "cross-type" ? targetType : sourceType,
1392
+ entry.toSlug,
1393
+ defaultLocale,
1394
+ urlBuilder
1311
1395
  );
1312
- if (!canonicalRow) continue;
1313
- const aliasRow = sqliteRows.find((r) => r.locale === locale);
1314
- if (!aliasRow && known) {
1315
- issues.push({
1316
- contentTypeId: type.id,
1317
- enSlug: target.canonicalSlug,
1318
- field: "aliases",
1319
- message: `Alias "${alias}" has no locale slug mapping for ${locale} \u2014 old localized URL may miss redirect`,
1320
- level: "warning"
1321
- });
1396
+ }
1397
+ out.push({
1398
+ source: `/${fromEnSlug}`,
1399
+ destination: destinationEn,
1400
+ permanent: entry.permanent
1401
+ });
1402
+ for (const locale of urlBuilder.prefixedLocales) {
1403
+ const fromSlug = localizedSlug(db, sourceType.id, fromEnSlug, locale, defaultLocale);
1404
+ let destination;
1405
+ if (entry.kind === "anywhere") {
1406
+ destination = entry.toUrl;
1407
+ } else {
1408
+ destination = resolveSlugTargetDestination(
1409
+ project,
1410
+ db,
1411
+ entry.kind === "cross-type" ? targetType : sourceType,
1412
+ entry.toSlug,
1413
+ locale,
1414
+ urlBuilder
1415
+ );
1322
1416
  }
1417
+ out.push({
1418
+ source: `/${locale}/${fromSlug}`,
1419
+ destination,
1420
+ permanent: entry.permanent
1421
+ });
1422
+ out.push({
1423
+ source: `/${locale}/${fromEnSlug}`,
1424
+ destination,
1425
+ permanent: entry.permanent
1426
+ });
1427
+ }
1428
+ }
1429
+ }
1430
+ return out;
1431
+ }
1432
+ function buildJsonRedirects(project) {
1433
+ const urlBuilder = createUrlBuilder(project.config);
1434
+ const db = openStore(project.config, "readonly");
1435
+ const out = [];
1436
+ try {
1437
+ for (const type of project.config.types) {
1438
+ if (!isRoutableType(type)) continue;
1439
+ const loaded = loadTypeRedirectsFile(project.config, type);
1440
+ if (!loaded) continue;
1441
+ for (const entry of loaded.entries) {
1442
+ out.push(...buildEntryRules(project, type, entry, db, urlBuilder));
1323
1443
  }
1324
1444
  }
1325
1445
  } finally {
1326
1446
  db.close();
1327
1447
  }
1328
- return issues;
1448
+ return out;
1329
1449
  }
1330
- function validateAliasRedirectChains(project) {
1331
- const { config } = project;
1332
- const { aliasToTarget, allAliasSlugs } = buildGlobalAliasIndex(project);
1333
- const issues = [];
1334
- for (const [alias, target] of aliasToTarget) {
1335
- const doc = readEnDocument(
1336
- config,
1337
- project.getType(target.contentTypeId).config,
1338
- target.canonicalSlug
1339
- );
1340
- if (doc?.redirectTo) {
1341
- issues.push({
1342
- contentTypeId: target.contentTypeId,
1343
- enSlug: target.canonicalSlug,
1344
- field: "aliases",
1345
- message: `Alias "${alias}" points at "${target.canonicalSlug}" which has redirect_to \u2014 resolve to the final canonical slug`,
1346
- level: "error"
1347
- });
1450
+ function buildRedirectSourceSlugSet(project) {
1451
+ const out = /* @__PURE__ */ new Set();
1452
+ for (const type of project.config.types) {
1453
+ const loaded = loadTypeRedirectsFile(project.config, type);
1454
+ if (!loaded) continue;
1455
+ for (const entry of loaded.entries) {
1456
+ for (const from of entry.fromSlugs) {
1457
+ out.add(`${type.id}:${from}`);
1458
+ }
1348
1459
  }
1349
1460
  }
1350
- for (const type of config.types) {
1351
- if (!type.path) continue;
1352
- for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
1353
- const doc = readEnDocument(config, type, enSlug);
1354
- if (!doc?.redirectTo) continue;
1355
- const targetSlug = extractSlugFromResolvedPath(type.path, doc.redirectTo);
1356
- if (targetSlug && allAliasSlugs.has(targetSlug)) {
1357
- issues.push({
1358
- contentTypeId: type.id,
1359
- enSlug,
1360
- field: "redirect_to",
1361
- message: `redirect_to target "${targetSlug}" is an alias slug \u2014 chain detected`,
1362
- level: "error"
1363
- });
1364
- }
1365
- const targetDoc = targetSlug ? readEnDocument(config, type, targetSlug) : null;
1366
- if (targetDoc?.redirectTo) {
1367
- issues.push({
1368
- contentTypeId: type.id,
1461
+ return out;
1462
+ }
1463
+ function buildLegacyNoBlogPathRedirects(project, redirectSourceKeys) {
1464
+ const blogType = project.config.types.find((type) => type.id === "blog" && type.path);
1465
+ if (!blogType?.path) return [];
1466
+ const urlBuilder = createUrlBuilder(project.config);
1467
+ const db = openStore(project.config, "readonly");
1468
+ const out = [];
1469
+ try {
1470
+ for (const enSlug of listEnSlugs(project.config.rootDir, blogType.contentDir)) {
1471
+ if (redirectSourceKeys.has(`blog:${enSlug}`)) continue;
1472
+ out.push({
1473
+ source: `/${enSlug}`,
1474
+ destination: urlBuilder.resolvePath(blogType.path, enSlug, project.config.defaultLocale),
1475
+ permanent: true
1476
+ });
1477
+ for (const locale of urlBuilder.prefixedLocales) {
1478
+ const translatedSlug = localizedSlug(
1479
+ db,
1480
+ blogType.id,
1369
1481
  enSlug,
1370
- field: "redirect_to",
1371
- message: `redirect_to chain detected: "${enSlug}" \u2192 "${targetSlug}" \u2192 "${targetDoc.redirectTo}"`,
1372
- level: "error"
1482
+ locale,
1483
+ project.config.defaultLocale
1484
+ );
1485
+ out.push({
1486
+ source: `/${locale}/${enSlug}`,
1487
+ destination: urlBuilder.resolvePath(blogType.path, translatedSlug, locale),
1488
+ permanent: true
1373
1489
  });
1374
1490
  }
1375
1491
  }
1492
+ } finally {
1493
+ db.close();
1376
1494
  }
1377
- return issues;
1378
- }
1379
-
1380
- // src/i18n/translation-index.ts
1381
- function buildTranslationIndex(allDocs, locales, defaultLocale) {
1382
- const index = /* @__PURE__ */ new Map();
1383
- for (const locale of locales) {
1384
- if (locale === defaultLocale) continue;
1385
- const idx = allDocs.get(locale);
1386
- if (!idx) continue;
1387
- const localeMap = /* @__PURE__ */ new Map();
1388
- for (const [enSlug, doc] of idx.byEnSlug) {
1389
- localeMap.set(enSlug, doc.slug);
1390
- }
1391
- index.set(locale, localeMap);
1392
- }
1393
- return index;
1495
+ return out;
1394
1496
  }
1395
1497
 
1396
- // src/redirects/translation-index.ts
1397
- function buildRedirectTranslationIndex(project, typeId) {
1498
+ // src/redirects/build-redirects.ts
1499
+ function buildRedirectTranslationIndex(project, typeId, redirectSourceSlugs) {
1398
1500
  const runtime = project.getType(typeId);
1399
1501
  const { config } = project;
1400
1502
  const merged = buildTranslationIndex(
@@ -1402,11 +1504,9 @@ function buildRedirectTranslationIndex(project, typeId) {
1402
1504
  config.locales,
1403
1505
  config.defaultLocale
1404
1506
  );
1405
- const { aliasToTarget } = buildGlobalAliasIndex(project);
1406
1507
  const db = openStore(project.config, "readonly");
1407
1508
  try {
1408
- for (const [alias, target] of aliasToTarget) {
1409
- if (target.contentTypeId !== typeId) continue;
1509
+ for (const alias of redirectSourceSlugs) {
1410
1510
  for (const row of listTranslationsForEnSlug(db, typeId, alias)) {
1411
1511
  const localeMap = merged.get(row.locale) ?? /* @__PURE__ */ new Map();
1412
1512
  if (!localeMap.has(alias)) {
@@ -1420,168 +1520,63 @@ function buildRedirectTranslationIndex(project, typeId) {
1420
1520
  }
1421
1521
  return merged;
1422
1522
  }
1423
-
1424
- // src/redirects/build-redirects.ts
1425
- function buildAliasRedirects(type, aliasIndex, translationIndex, prefixedLocales, defaultLocale) {
1426
- const out = [];
1427
- const { aliasToTarget } = aliasIndex;
1428
- const pathTemplate = type.path;
1429
- for (const [alias, target] of aliasToTarget) {
1430
- if (target.contentTypeId !== type.id) continue;
1431
- const canonical = target.canonicalSlug;
1432
- out.push({
1433
- source: resolvePath(pathTemplate, alias, defaultLocale, defaultLocale),
1434
- destination: resolvePath(pathTemplate, canonical, defaultLocale, defaultLocale),
1435
- permanent: true
1436
- });
1437
- for (const locale of prefixedLocales) {
1438
- const localeMap = translationIndex.get(locale);
1439
- const toLocale = localeMap?.get(canonical) ?? canonical;
1440
- const fromLocale = localeMap?.get(alias);
1441
- if (fromLocale) {
1442
- out.push({
1443
- source: resolvePath(pathTemplate, fromLocale, locale, defaultLocale),
1444
- destination: resolvePath(pathTemplate, toLocale, locale, defaultLocale),
1445
- permanent: true
1446
- });
1447
- }
1448
- out.push({
1449
- source: resolvePath(pathTemplate, alias, locale, defaultLocale),
1450
- destination: resolvePath(pathTemplate, toLocale, locale, defaultLocale),
1451
- permanent: true
1452
- });
1453
- }
1454
- }
1455
- return out;
1456
- }
1457
- function buildAliasLegacyNoBlogPathRedirects(type, aliasIndex, translationIndex, prefixedLocales, defaultLocale) {
1458
- if (type.id !== "blog") return [];
1459
- const out = [];
1460
- for (const [alias, target] of aliasIndex.aliasToTarget) {
1461
- if (target.contentTypeId !== type.id) continue;
1462
- for (const locale of prefixedLocales) {
1463
- const localeMap = translationIndex.get(locale);
1464
- const toLocale = localeMap?.get(target.canonicalSlug) ?? target.canonicalSlug;
1465
- out.push({
1466
- source: `/${locale}/${alias}`,
1467
- destination: resolvePath(type.path, toLocale, locale, defaultLocale),
1468
- permanent: true
1469
- });
1470
- }
1471
- }
1472
- return out;
1473
- }
1474
- function buildRedirectToRules(type, config, translationIndex, prefixedLocales, defaultLocale) {
1475
- const out = [];
1476
- const pathTemplate = type.path;
1477
- for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
1478
- const doc = readEnDocument(config, type, enSlug);
1479
- if (!doc?.redirectTo) continue;
1480
- out.push({
1481
- source: resolvePath(pathTemplate, enSlug, defaultLocale, defaultLocale),
1482
- destination: doc.redirectTo,
1483
- permanent: true
1484
- });
1485
- for (const locale of prefixedLocales) {
1486
- const localeMap = translationIndex.get(locale);
1487
- const fromLocale = localeMap?.get(enSlug);
1488
- if (!fromLocale) continue;
1489
- const targetEnSlug = extractSlugFromResolvedPath(pathTemplate, doc.redirectTo);
1490
- if (!targetEnSlug) continue;
1491
- const toLocale = localeMap?.get(targetEnSlug) ?? targetEnSlug;
1492
- out.push({
1493
- source: resolvePath(pathTemplate, fromLocale, locale, defaultLocale),
1494
- destination: resolvePath(pathTemplate, toLocale, locale, defaultLocale),
1495
- permanent: true
1496
- });
1497
- }
1498
- }
1499
- return out;
1500
- }
1501
- function buildCrossLocaleSlugRedirects(type, translationIndex, aliasSourceSlugs, defaultLocale) {
1523
+ function buildCrossLocaleSlugRedirects(project, typeId, redirectSourceSlugs) {
1524
+ const type = project.getType(typeId).config;
1525
+ if (!isRoutableType(type)) return [];
1526
+ const urlBuilder = createUrlBuilder(project.config);
1527
+ const translationIndex = buildRedirectTranslationIndex(project, typeId, redirectSourceSlugs);
1502
1528
  const out = [];
1503
1529
  const pathTemplate = type.path;
1504
1530
  for (const [locale, localeMap] of translationIndex) {
1505
1531
  for (const [enSlug, translatedSlug] of localeMap) {
1506
1532
  if (enSlug === translatedSlug) continue;
1507
- if (aliasSourceSlugs.has(enSlug)) continue;
1533
+ if (redirectSourceSlugs.has(enSlug)) continue;
1508
1534
  out.push({
1509
- source: resolvePath(pathTemplate, enSlug, locale, defaultLocale),
1510
- destination: resolvePath(pathTemplate, translatedSlug, locale, defaultLocale),
1535
+ source: urlBuilder.resolvePath(pathTemplate, enSlug, locale),
1536
+ destination: urlBuilder.resolvePath(pathTemplate, translatedSlug, locale),
1511
1537
  permanent: true
1512
1538
  });
1513
1539
  }
1514
1540
  }
1515
1541
  return out;
1516
1542
  }
1517
- function buildLegacyNoBlogPathRedirects(type, config, translationIndex, prefixedLocales, defaultLocale) {
1518
- if (type.id !== "blog") return [];
1519
- const out = [];
1520
- const enSlugs = listEnSlugs(config.rootDir, type.contentDir);
1521
- const pathTemplate = type.path;
1522
- for (const enSlug of enSlugs) {
1523
- out.push({
1524
- source: `/${enSlug}`,
1525
- destination: resolvePath(pathTemplate, enSlug, defaultLocale, defaultLocale),
1526
- permanent: true
1527
- });
1528
- for (const locale of prefixedLocales) {
1529
- const translatedSlug = translationIndex.get(locale)?.get(enSlug);
1530
- const canonicalSlug = translatedSlug ?? enSlug;
1531
- out.push({
1532
- source: `/${locale}/${enSlug}`,
1533
- destination: resolvePath(pathTemplate, canonicalSlug, locale, defaultLocale),
1534
- permanent: true
1535
- });
1543
+ function buildTypeRedirects(project, typeId) {
1544
+ const loaded = loadAllTypeRedirects(project.config).find((file) => file.contentTypeId === typeId);
1545
+ const redirectSourceSlugs = /* @__PURE__ */ new Set();
1546
+ if (loaded) {
1547
+ for (const entry of loaded.entries) {
1548
+ for (const from of entry.fromSlugs) {
1549
+ redirectSourceSlugs.add(from);
1550
+ }
1536
1551
  }
1537
1552
  }
1538
- return out;
1539
- }
1540
- function buildTypeRedirects(project, typeId, aliasIndex, options) {
1541
- const type = project.getType(typeId).config;
1542
- if (!isRoutableType(type)) return [];
1543
- const { defaultLocale } = project.config;
1544
- const translationIndex = buildRedirectTranslationIndex(project, typeId);
1545
- const aliasSourceSlugs = /* @__PURE__ */ new Set();
1546
- for (const [alias, target] of aliasIndex.aliasToTarget) {
1547
- if (target.contentTypeId === typeId) aliasSourceSlugs.add(alias);
1548
- }
1549
1553
  return [
1550
- ...buildAliasRedirects(type, aliasIndex, translationIndex, options.prefixedLocales, defaultLocale),
1551
- ...buildAliasLegacyNoBlogPathRedirects(
1552
- type,
1553
- aliasIndex,
1554
- translationIndex,
1555
- options.prefixedLocales,
1556
- defaultLocale
1557
- ),
1558
- ...buildRedirectToRules(type, project.config, translationIndex, options.prefixedLocales, defaultLocale),
1559
- ...buildCrossLocaleSlugRedirects(type, translationIndex, aliasSourceSlugs, defaultLocale),
1560
- ...buildLegacyNoBlogPathRedirects(type, project.config, translationIndex, options.prefixedLocales, defaultLocale)
1554
+ ...buildCrossLocaleSlugRedirects(project, typeId, redirectSourceSlugs)
1561
1555
  ];
1562
1556
  }
1563
- function buildAllContentRedirects(project, options) {
1564
- const aliasIndex = buildGlobalAliasIndex(project);
1565
- const out = [];
1557
+ function buildAllContentRedirects(project, _options = {}) {
1558
+ const redirectSourceKeys = buildRedirectSourceSlugSet(project);
1559
+ const out = [
1560
+ ...buildJsonRedirects(project),
1561
+ ...buildLegacyNoBlogPathRedirects(project, redirectSourceKeys)
1562
+ ];
1566
1563
  for (const type of project.config.types) {
1567
1564
  if (!isRoutableType(type)) continue;
1568
- out.push(...buildTypeRedirects(project, type.id, aliasIndex, options));
1565
+ out.push(...buildTypeRedirects(project, type.id));
1569
1566
  }
1570
1567
  return out;
1571
1568
  }
1572
1569
  function getRedirectSourceSlugs(project) {
1573
- const aliasIndex = buildGlobalAliasIndex(project);
1574
- const outboundByType = /* @__PURE__ */ new Map();
1570
+ const loaded = loadAllTypeRedirects(project.config);
1571
+ const aliasSlugs = collectRedirectSourceSlugs(loaded);
1572
+ const outboundByType = collectOutboundRedirectSourcesByType(loaded);
1575
1573
  for (const type of project.config.types) {
1576
- const outbound = /* @__PURE__ */ new Set();
1577
- for (const enSlug of listEnSlugs(project.config.rootDir, type.contentDir)) {
1578
- const doc = readEnDocument(project.config, type, enSlug);
1579
- if (doc?.redirectTo) outbound.add(enSlug);
1574
+ if (!outboundByType.has(type.id)) {
1575
+ outboundByType.set(type.id, /* @__PURE__ */ new Set());
1580
1576
  }
1581
- outboundByType.set(type.id, outbound);
1582
1577
  }
1583
1578
  return {
1584
- aliasSlugs: aliasIndex.allAliasSlugs,
1579
+ aliasSlugs,
1585
1580
  outboundByType
1586
1581
  };
1587
1582
  }
@@ -1589,13 +1584,12 @@ function getRedirectSourceSlugs(project) {
1589
1584
  // src/sitemap/join-base-url.ts
1590
1585
  function joinBaseUrl(baseUrl, pathname) {
1591
1586
  const origin = baseUrl.replace(/\/$/, "");
1592
- const path11 = pathname.startsWith("/") ? pathname : `/${pathname}`;
1593
- return `${origin}${path11}`;
1587
+ const path13 = pathname.startsWith("/") ? pathname : `/${pathname}`;
1588
+ return `${origin}${path13}`;
1594
1589
  }
1595
1590
 
1596
1591
  // src/sitemap/generate-sitemap.ts
1597
1592
  function shouldIncludeEnDoc(enDoc, enSlug, redirectSources, contentTypeId, excludeNoindex) {
1598
- if (enDoc.redirectTo) return false;
1599
1593
  if (redirectSources.aliasSlugs.has(enSlug)) return false;
1600
1594
  const outbound = redirectSources.outboundByType.get(contentTypeId);
1601
1595
  if (outbound?.has(enSlug)) return false;
@@ -1682,6 +1676,7 @@ function createScribe(input) {
1682
1676
  project,
1683
1677
  getType: project.getType,
1684
1678
  listTypes: project.listTypes,
1679
+ listRoutableTypes: project.listRoutableTypes,
1685
1680
  sitemap(options) {
1686
1681
  return generateSitemap(project, options);
1687
1682
  }
@@ -1734,6 +1729,168 @@ function loadConfigSync(options = {}) {
1734
1729
  }
1735
1730
  return resolveConfig(config, path3__default.default.dirname(configPath));
1736
1731
  }
1732
+ function liveDocExists(config, typeId, enSlug) {
1733
+ const type = config.types.find((entry) => entry.id === typeId);
1734
+ if (!type) return false;
1735
+ return enFileExists(config, type, enSlug);
1736
+ }
1737
+ function resolveRedirectTarget(project, sourceTypeId, entry) {
1738
+ if (entry.kind === "anywhere") return null;
1739
+ if (entry.kind === "cross-type") {
1740
+ return { typeId: entry.toType, enSlug: entry.toSlug };
1741
+ }
1742
+ return { typeId: sourceTypeId, enSlug: entry.toSlug };
1743
+ }
1744
+ function matchRoutableTarget(project, toUrl) {
1745
+ const urlBuilder = createUrlBuilder(project.config);
1746
+ for (const type of project.config.types) {
1747
+ if (!type.path) continue;
1748
+ const enSlug = urlBuilder.extractSlugFromResolvedPath(type.path, toUrl);
1749
+ if (enSlug && liveDocExists(project.config, type.id, enSlug)) {
1750
+ return { typeId: type.id, enSlug };
1751
+ }
1752
+ }
1753
+ return null;
1754
+ }
1755
+ function validateTypeRedirects(project) {
1756
+ const issues = [];
1757
+ const globalFrom = /* @__PURE__ */ new Map();
1758
+ for (const type of project.config.types) {
1759
+ const filePath = path3__default.default.join(project.config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1760
+ if (!fs2__default.default.existsSync(filePath)) continue;
1761
+ let loaded;
1762
+ try {
1763
+ loaded = loadTypeRedirectsFile(project.config, type);
1764
+ } catch (error) {
1765
+ issues.push({
1766
+ level: "error",
1767
+ contentType: type.id,
1768
+ field: TYPE_REDIRECTS_FILENAME,
1769
+ message: error instanceof Error ? error.message : String(error)
1770
+ });
1771
+ continue;
1772
+ }
1773
+ if (!loaded) continue;
1774
+ if (!isRoutableType(type)) {
1775
+ issues.push({
1776
+ level: "error",
1777
+ contentType: type.id,
1778
+ field: TYPE_REDIRECTS_FILENAME,
1779
+ message: `Redirects are only supported on routable content types (missing path)`
1780
+ });
1781
+ continue;
1782
+ }
1783
+ for (const entry of loaded.entries) {
1784
+ for (const from of entry.fromSlugs) {
1785
+ if (from === entry.toSlug) {
1786
+ issues.push({
1787
+ level: "error",
1788
+ contentType: type.id,
1789
+ enSlug: from,
1790
+ field: TYPE_REDIRECTS_FILENAME,
1791
+ message: `Redirect source "${from}" must not equal its target slug`
1792
+ });
1793
+ }
1794
+ if (liveDocExists(project.config, type.id, from)) {
1795
+ issues.push({
1796
+ level: "error",
1797
+ contentType: type.id,
1798
+ enSlug: from,
1799
+ field: TYPE_REDIRECTS_FILENAME,
1800
+ message: `Redirect source "${from}" still has a live EN file \u2014 delete or rename the MDX first`
1801
+ });
1802
+ }
1803
+ const existing = globalFrom.get(from);
1804
+ if (existing && existing.typeId !== type.id) {
1805
+ issues.push({
1806
+ level: "error",
1807
+ contentType: type.id,
1808
+ enSlug: from,
1809
+ field: TYPE_REDIRECTS_FILENAME,
1810
+ message: `Redirect source "${from}" is already claimed by ${existing.typeId}`
1811
+ });
1812
+ } else if (existing) {
1813
+ issues.push({
1814
+ level: "error",
1815
+ contentType: type.id,
1816
+ enSlug: from,
1817
+ field: TYPE_REDIRECTS_FILENAME,
1818
+ message: `Duplicate redirect source "${from}" in ${TYPE_REDIRECTS_FILENAME}`
1819
+ });
1820
+ } else {
1821
+ globalFrom.set(from, { typeId: type.id, filePath: loaded.filePath });
1822
+ }
1823
+ }
1824
+ if (entry.kind === "cross-type") {
1825
+ const targetType = project.config.types.find((candidate) => candidate.id === entry.toType);
1826
+ if (!targetType) {
1827
+ issues.push({
1828
+ level: "error",
1829
+ contentType: type.id,
1830
+ field: TYPE_REDIRECTS_FILENAME,
1831
+ message: `Unknown redirect target type "${entry.toType}"`
1832
+ });
1833
+ continue;
1834
+ }
1835
+ if (!isRoutableType(targetType)) {
1836
+ issues.push({
1837
+ level: "error",
1838
+ contentType: type.id,
1839
+ field: TYPE_REDIRECTS_FILENAME,
1840
+ message: `Redirect target type "${entry.toType}" is not routable (missing path)`
1841
+ });
1842
+ continue;
1843
+ }
1844
+ }
1845
+ const target = resolveRedirectTarget(project, type.id, entry);
1846
+ if (target) {
1847
+ if (!liveDocExists(project.config, target.typeId, target.enSlug)) {
1848
+ issues.push({
1849
+ level: "error",
1850
+ contentType: type.id,
1851
+ field: TYPE_REDIRECTS_FILENAME,
1852
+ message: `Redirect target ${target.typeId}/${target.enSlug} does not exist`
1853
+ });
1854
+ }
1855
+ continue;
1856
+ }
1857
+ if (entry.kind === "anywhere" && entry.toUrl?.startsWith("/")) {
1858
+ const matched = matchRoutableTarget(project, entry.toUrl);
1859
+ if (matched && !liveDocExists(project.config, matched.typeId, matched.enSlug)) {
1860
+ issues.push({
1861
+ level: "error",
1862
+ contentType: type.id,
1863
+ field: TYPE_REDIRECTS_FILENAME,
1864
+ message: `Redirect toUrl "${entry.toUrl}" does not resolve to a live document`
1865
+ });
1866
+ }
1867
+ }
1868
+ }
1869
+ }
1870
+ const allLoaded = loadAllTypeRedirects(project.config);
1871
+ for (const file of allLoaded) {
1872
+ for (const entry of file.entries) {
1873
+ if (entry.kind === "anywhere" || !entry.toSlug) continue;
1874
+ const targetTypeId = entry.kind === "cross-type" ? entry.toType : file.contentTypeId;
1875
+ const targetType = project.config.types.find((type) => type.id === targetTypeId);
1876
+ if (!targetType?.path) continue;
1877
+ for (const from of entry.fromSlugs) {
1878
+ if (globalFrom.get(from)?.typeId !== file.contentTypeId) continue;
1879
+ const chainTarget = globalFrom.get(entry.toSlug);
1880
+ if (chainTarget) {
1881
+ issues.push({
1882
+ level: "error",
1883
+ contentType: file.contentTypeId,
1884
+ enSlug: from,
1885
+ field: TYPE_REDIRECTS_FILENAME,
1886
+ message: `Redirect chain detected: "${from}" \u2192 "${entry.toSlug}" \u2192 existing redirect source`
1887
+ });
1888
+ }
1889
+ }
1890
+ }
1891
+ }
1892
+ return issues;
1893
+ }
1737
1894
  function getAtPath2(obj, fieldPath) {
1738
1895
  let current = obj;
1739
1896
  for (const segment of fieldPath) {
@@ -1923,7 +2080,7 @@ function validateTranslationSlugSuffixes(config, db) {
1923
2080
  }
1924
2081
 
1925
2082
  // src/validate/validate-project.ts
1926
- function listEnSlugs2(rootDir, contentDir) {
2083
+ function listEnSlugs3(rootDir, contentDir) {
1927
2084
  const dir = path3__default.default.join(rootDir, contentDir);
1928
2085
  if (!fs2__default.default.existsSync(dir)) return [];
1929
2086
  return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -1941,7 +2098,7 @@ function validateProject(config) {
1941
2098
  }
1942
2099
  const db = openStore(config, "readonly");
1943
2100
  for (const type of config.types) {
1944
- const enSlugs = listEnSlugs2(config.rootDir, type.contentDir);
2101
+ const enSlugs = listEnSlugs3(config.rootDir, type.contentDir);
1945
2102
  const englishSlugs = new Set(enSlugs);
1946
2103
  if (!type.translate && isRoutableType(type)) {
1947
2104
  issues.push({
@@ -2023,33 +2180,8 @@ function validateProject(config) {
2023
2180
  }
2024
2181
  }
2025
2182
  db.close();
2026
- const aliasIndex = buildGlobalAliasIndex(project);
2027
- for (const issue of aliasIndex.issues) {
2028
- issues.push({
2029
- level: issue.level,
2030
- contentType: issue.contentTypeId,
2031
- enSlug: issue.enSlug,
2032
- field: issue.field,
2033
- message: issue.message
2034
- });
2035
- }
2036
- for (const issue of validateAliasRedirectChains(project)) {
2037
- issues.push({
2038
- level: issue.level,
2039
- contentType: issue.contentTypeId,
2040
- enSlug: issue.enSlug,
2041
- field: issue.field,
2042
- message: issue.message
2043
- });
2044
- }
2045
- for (const issue of validateAliasCoverage(project)) {
2046
- issues.push({
2047
- level: issue.level,
2048
- contentType: issue.contentTypeId,
2049
- enSlug: issue.enSlug,
2050
- field: issue.field,
2051
- message: issue.message
2052
- });
2183
+ for (const issue of validateTypeRedirects(project)) {
2184
+ issues.push(issue);
2053
2185
  }
2054
2186
  for (const issue of validateRelations(project)) {
2055
2187
  issues.push({
@@ -2086,7 +2218,7 @@ function computePageEnHash(translatableFrontmatter, body) {
2086
2218
  }
2087
2219
 
2088
2220
  // src/translate/worklist.ts
2089
- function listEnSlugs3(rootDir, contentDir) {
2221
+ function listEnSlugs4(rootDir, contentDir) {
2090
2222
  const dir = path3__default.default.join(rootDir, contentDir);
2091
2223
  if (!fs2__default.default.existsSync(dir)) return [];
2092
2224
  return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -2097,7 +2229,7 @@ function buildWorklist(config, options = {}) {
2097
2229
  const locales = options.locales ?? config.locales.filter((locale) => locale !== config.defaultLocale);
2098
2230
  for (const type of config.types) {
2099
2231
  if (options.contentType && type.id !== options.contentType) continue;
2100
- const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs3(config.rootDir, type.contentDir);
2232
+ const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs4(config.rootDir, type.contentDir);
2101
2233
  for (const enSlug of enSlugs) {
2102
2234
  const enDoc = readEnDocument(config, type, enSlug);
2103
2235
  if (!enDoc) continue;
@@ -2369,11 +2501,12 @@ function buildGeminiResponseSchema(schema, slugStrategy) {
2369
2501
  function slugStrategyRules(slugStrategy, preserveTerms) {
2370
2502
  if (slugStrategy === "localized") {
2371
2503
  const rules = [
2372
- "Provide a URL slug in JSON field `slug` that is TRANSLATED into the target language.",
2504
+ "Provide a URL slug in JSON field `slug` that is Localized into the target language.",
2373
2505
  "Base the slug on the meaning of the translated title \u2014 do NOT reuse the English slug words.",
2374
2506
  "Slug MUST be ASCII only: a-z, 0-9, hyphens. No uppercase, accents, underscores, or spaces.",
2375
2507
  "For non-Latin languages, write the words in the target language and transliterate them into Latin script (e.g. Russian -> romanized Russian, not English).",
2376
- "Do NOT append locale codes to the slug (e.g. -fr, -he, -zh-cn). Locale routing is handled by the URL prefix, not the slug."
2508
+ "Do NOT append locale codes to the slug (e.g. -fr, -he, -zh-cn). Locale routing is handled by the URL prefix, not the slug.",
2509
+ 'In JSX attributes (e.g. FaqItem question="..."), use single quotes when the value contains double-quote characters (e.g. Hebrew \u05D3\u05D5\u05D0"\u05DC), or escape them as \\".'
2377
2510
  ];
2378
2511
  if (preserveTerms?.length) {
2379
2512
  rules.push(
@@ -2406,6 +2539,95 @@ function resolveTranslateConfig(config, type) {
2406
2539
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
2407
2540
  }
2408
2541
 
2542
+ // src/translate/sanitize-mdx-jsx.ts
2543
+ function sanitizeMdxJsxAttributeQuotes(body) {
2544
+ let adjusted = false;
2545
+ let out = "";
2546
+ let i = 0;
2547
+ while (i < body.length) {
2548
+ if (body[i] !== "<" || !/[\w.]/.test(body[i + 1] ?? "")) {
2549
+ out += body[i];
2550
+ i += 1;
2551
+ continue;
2552
+ }
2553
+ const tagStart = i;
2554
+ i += 1;
2555
+ while (i < body.length && /[\w.-]/.test(body[i] ?? "")) i += 1;
2556
+ const tagName = body.slice(tagStart + 1, i);
2557
+ let tagOut = `<${tagName}`;
2558
+ let tagAdjusted = false;
2559
+ while (i < body.length && body[i] !== ">" && body[i] !== "/") {
2560
+ while (i < body.length && /\s/.test(body[i] ?? "")) {
2561
+ tagOut += body[i];
2562
+ i += 1;
2563
+ }
2564
+ if (i >= body.length || body[i] === ">" || body[i] === "/") break;
2565
+ const attrStart = i;
2566
+ while (i < body.length && /[\w:.-]/.test(body[i] ?? "")) i += 1;
2567
+ body.slice(attrStart, i);
2568
+ while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2569
+ if (body[i] !== "=") {
2570
+ tagOut += body.slice(attrStart, i);
2571
+ continue;
2572
+ }
2573
+ tagOut += body.slice(attrStart, i);
2574
+ tagOut += "=";
2575
+ i += 1;
2576
+ while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2577
+ const quote = body[i];
2578
+ if (quote !== '"' && quote !== "'") {
2579
+ continue;
2580
+ }
2581
+ if (quote === "'") {
2582
+ const valStart2 = i + 1;
2583
+ i += 1;
2584
+ while (i < body.length) {
2585
+ if (body[i] === "\\") {
2586
+ i += 2;
2587
+ continue;
2588
+ }
2589
+ if (body[i] === "'") break;
2590
+ i += 1;
2591
+ }
2592
+ tagOut += body.slice(valStart2 - 1, i + 1);
2593
+ i += 1;
2594
+ continue;
2595
+ }
2596
+ const valStart = i + 1;
2597
+ i += 1;
2598
+ let closeIdx = -1;
2599
+ for (let scan = valStart; scan < body.length; scan += 1) {
2600
+ if (body[scan] !== '"') continue;
2601
+ let j = scan + 1;
2602
+ while (j < body.length && /\s/.test(body[j] ?? "")) j += 1;
2603
+ if (j < body.length && (body[j] === ">" || body[j] === "/" || /[a-zA-Z]/.test(body[j] ?? ""))) {
2604
+ closeIdx = scan;
2605
+ break;
2606
+ }
2607
+ }
2608
+ if (closeIdx === -1) {
2609
+ tagOut += body.slice(valStart - 1, i);
2610
+ break;
2611
+ }
2612
+ const value = body.slice(valStart, closeIdx);
2613
+ const hasInternalQuote = value.includes('"');
2614
+ if (hasInternalQuote) {
2615
+ const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2616
+ tagOut += `'${escaped}'`;
2617
+ tagAdjusted = true;
2618
+ } else {
2619
+ tagOut += `"${value}"`;
2620
+ }
2621
+ i = closeIdx + 1;
2622
+ }
2623
+ if (tagAdjusted) adjusted = true;
2624
+ tagOut += body[i] ?? "";
2625
+ out += tagOut;
2626
+ i += 1;
2627
+ }
2628
+ return { body: out, adjusted };
2629
+ }
2630
+
2409
2631
  // src/translate/validate-translation.ts
2410
2632
  function formatZodIssues(error) {
2411
2633
  return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
@@ -2539,6 +2761,9 @@ async function translatePage(config, item, options = {}) {
2539
2761
  if (!validated.ok) {
2540
2762
  throw new Error(`Translation validation failed: ${validated.error}`);
2541
2763
  }
2764
+ const { body: translatedBody, adjusted: mdxAdjusted } = sanitizeMdxJsxAttributeQuotes(
2765
+ result.parsed.body
2766
+ );
2542
2767
  const writeDb = openStore(config, "readwrite");
2543
2768
  const snapshotId = recordEnSnapshot(
2544
2769
  config,
@@ -2557,7 +2782,7 @@ async function translatePage(config, item, options = {}) {
2557
2782
  locale: item.locale,
2558
2783
  slug,
2559
2784
  frontmatter: validated.frontmatter,
2560
- body: result.parsed.body,
2785
+ body: translatedBody,
2561
2786
  enHash: currentEnHash,
2562
2787
  translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2563
2788
  model: result.model,
@@ -2576,7 +2801,8 @@ async function translatePage(config, item, options = {}) {
2576
2801
  usage: result.usage,
2577
2802
  estimatedCostUsd,
2578
2803
  durationMs: Date.now() - startedAt,
2579
- slugAdjusted
2804
+ slugAdjusted,
2805
+ mdxAdjusted: mdxAdjusted || void 0
2580
2806
  };
2581
2807
  } catch (error) {
2582
2808
  return {
@@ -2653,6 +2879,7 @@ function buildStaticRawExports(project, options = {}) {
2653
2879
  const excludeNoindex = options.excludeNoindex ?? false;
2654
2880
  const typeFilter = options.types ? new Set(options.types) : null;
2655
2881
  const out = [];
2882
+ const urlBuilder = createUrlBuilder(config);
2656
2883
  for (const type of project.listTypes()) {
2657
2884
  if (!isRoutableType(type.config)) continue;
2658
2885
  if (typeFilter && !typeFilter.has(type.id)) continue;
@@ -2668,7 +2895,7 @@ function buildStaticRawExports(project, options = {}) {
2668
2895
  if (excludeNoindex && resolved.document.noindex) continue;
2669
2896
  const doc = resolved.document;
2670
2897
  const slugWithExt = `${doc.slug}${extension}`;
2671
- const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2898
+ const urlPath = urlBuilder.resolvePath(pathTemplate, slugWithExt, locale);
2672
2899
  out.push({
2673
2900
  relativePath: urlPath.slice(1),
2674
2901
  urlPath,
@@ -2716,6 +2943,7 @@ exports.buildStaticRawExports = buildStaticRawExports;
2716
2943
  exports.buildWorklist = buildWorklist;
2717
2944
  exports.createProject = createProject;
2718
2945
  exports.createScribe = createScribe;
2946
+ exports.createUrlBuilder = createUrlBuilder;
2719
2947
  exports.defineConfig = defineConfig;
2720
2948
  exports.defineContentType = defineContentType;
2721
2949
  exports.exportDirSegment = exportDirSegment;
@@ -2727,6 +2955,7 @@ exports.getRedirectSourceSlugs = getRedirectSourceSlugs;
2727
2955
  exports.getRelationTarget = getRelationTarget;
2728
2956
  exports.getStaticExportRoots = getStaticExportRoots;
2729
2957
  exports.isResolvedConfig = isResolvedConfig;
2958
+ exports.isRoutableType = isRoutableType;
2730
2959
  exports.loadConfigSync = loadConfigSync;
2731
2960
  exports.resolveConfig = resolveConfig;
2732
2961
  exports.resolveLocalesFromPreset = resolveLocalesFromPreset;