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/cli/index.js CHANGED
@@ -222,14 +222,14 @@ var SLUG_PLACEHOLDER = "{slug}";
222
222
  function isRoutableType(type) {
223
223
  return typeof type.path === "string" && type.path.length > 0;
224
224
  }
225
- function assertValidPathTemplate(path14, typeId) {
226
- if (!path14.startsWith("/")) {
227
- throw new Error(`Content type "${typeId}": path must start with / (got "${path14}")`);
225
+ function assertValidPathTemplate(path16, typeId) {
226
+ if (!path16.startsWith("/")) {
227
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path16}")`);
228
228
  }
229
- const count = (path14.match(/\{slug\}/g) ?? []).length;
229
+ const count = (path16.match(/\{slug\}/g) ?? []).length;
230
230
  if (count !== 1) {
231
231
  throw new Error(
232
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path14}")`
232
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path16}")`
233
233
  );
234
234
  }
235
235
  }
@@ -243,19 +243,69 @@ function pathSuffix(template) {
243
243
  if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
244
244
  return template.slice(idx + SLUG_PLACEHOLDER.length);
245
245
  }
246
- function resolvePath(template, slug, locale, defaultLocale) {
247
- const relative = template.replace(SLUG_PLACEHOLDER, slug);
248
- if (locale === defaultLocale) return relative;
249
- return `/${locale}${relative}`;
250
- }
251
- function extractSlugFromResolvedPath(template, resolvedPath) {
252
- const prefix = pathPrefix(template);
253
- const suffix = pathSuffix(template);
254
- if (!resolvedPath.startsWith(prefix)) return null;
255
- if (suffix && !resolvedPath.endsWith(suffix)) return null;
256
- const slugEnd = suffix ? resolvedPath.length - suffix.length : resolvedPath.length;
257
- const slug = resolvedPath.slice(prefix.length, slugEnd);
258
- return slug.length > 0 ? slug : null;
246
+ function resolveDefaultLocaleRouting() {
247
+ return { strategy: "path-prefix", prefixDefaultLocale: false };
248
+ }
249
+ function splitPathAndSearch(pathname) {
250
+ const q = pathname.indexOf("?");
251
+ if (q === -1) return { pathname, search: "" };
252
+ return { pathname: pathname.slice(0, q), search: pathname.slice(q) };
253
+ }
254
+ function createUrlBuilder(config) {
255
+ const localeRouting = config.localeRouting ?? resolveDefaultLocaleRouting();
256
+ const defaultLocale = config.defaultLocale;
257
+ const prefixedLocales = localeRouting.strategy === "path-prefix" ? localeRouting.prefixDefaultLocale ? [...config.locales] : config.locales.filter((locale) => locale !== defaultLocale) : config.locales.filter((locale) => locale !== defaultLocale);
258
+ function applyLocaleToPath(pathname, locale) {
259
+ if (locale === defaultLocale) {
260
+ if (localeRouting.strategy === "path-prefix" && localeRouting.prefixDefaultLocale) {
261
+ return `/${defaultLocale}${pathname}`;
262
+ }
263
+ return pathname;
264
+ }
265
+ if (localeRouting.strategy === "search-param") {
266
+ const { pathname: base, search } = splitPathAndSearch(pathname);
267
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
268
+ params.set(localeRouting.param, locale);
269
+ const qs = params.toString();
270
+ return qs ? `${base}?${qs}` : base;
271
+ }
272
+ return `/${locale}${pathname}`;
273
+ }
274
+ function resolvePath(template, slug, locale) {
275
+ const relative = template.replace(SLUG_PLACEHOLDER, slug);
276
+ return applyLocaleToPath(relative, locale);
277
+ }
278
+ function extractSlugFromResolvedPath(template, resolvedPath) {
279
+ let pathname = resolvedPath;
280
+ if (localeRouting.strategy === "search-param") {
281
+ pathname = splitPathAndSearch(resolvedPath).pathname;
282
+ } else if (localeRouting.strategy === "path-prefix") {
283
+ for (const locale of config.locales) {
284
+ if (locale === defaultLocale && !localeRouting.prefixDefaultLocale) continue;
285
+ const prefix2 = `/${locale}`;
286
+ if (pathname === prefix2 || pathname.startsWith(`${prefix2}/`)) {
287
+ pathname = pathname.slice(prefix2.length) || "/";
288
+ break;
289
+ }
290
+ }
291
+ }
292
+ const prefix = pathPrefix(template);
293
+ const suffix = pathSuffix(template);
294
+ if (!pathname.startsWith(prefix)) return null;
295
+ if (suffix && !pathname.endsWith(suffix)) return null;
296
+ const slugEnd = suffix ? pathname.length - suffix.length : pathname.length;
297
+ const slug = pathname.slice(prefix.length, slugEnd);
298
+ return slug.length > 0 ? slug : null;
299
+ }
300
+ return {
301
+ defaultLocale,
302
+ locales: config.locales,
303
+ localeRouting,
304
+ prefixedLocales,
305
+ resolvePath,
306
+ extractSlugFromResolvedPath,
307
+ applyLocaleToPath
308
+ };
259
309
  }
260
310
 
261
311
  // src/config/resolve-config.ts
@@ -278,6 +328,13 @@ function resolveConfig(input, baseDir) {
278
328
  `scribe config: defaultLocale "${defaultLocale}" is not in locales [${raw.locales.join(", ")}]`
279
329
  );
280
330
  }
331
+ const localeRouting = raw.localeRouting ?? {
332
+ strategy: "path-prefix",
333
+ prefixDefaultLocale: false
334
+ };
335
+ if (localeRouting.strategy === "search-param" && !localeRouting.param) {
336
+ throw new Error('scribe config: localeRouting search-param requires a "param" name');
337
+ }
281
338
  const projectRoot = path4.resolve(baseDir ?? process.cwd(), raw.rootDir);
282
339
  const contentRoot = path4.resolve(projectRoot, raw.contentDir ?? "content");
283
340
  const storePath = path4.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
@@ -305,6 +362,7 @@ function resolveConfig(input, baseDir) {
305
362
  assetsPath,
306
363
  locales: [...raw.locales],
307
364
  defaultLocale,
365
+ localeRouting,
308
366
  localePresets: raw.localePresets,
309
367
  translate: raw.translate,
310
368
  types
@@ -359,10 +417,10 @@ function introspectSchema(schema, prefix = []) {
359
417
  }
360
418
  function extractByPaths(data, paths) {
361
419
  const out = {};
362
- for (const path14 of paths) {
363
- const value = getAtPath(data, path14);
420
+ for (const path16 of paths) {
421
+ const value = getAtPath(data, path16);
364
422
  if (value !== void 0) {
365
- setAtPath(out, path14.filter((p) => p !== "*"), value);
423
+ setAtPath(out, path16.filter((p) => p !== "*"), value);
366
424
  }
367
425
  }
368
426
  return out;
@@ -380,13 +438,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
380
438
  const translatable = pickTranslatable(localeData, schema);
381
439
  return deepMerge(structural, translatable);
382
440
  }
383
- function getAtPath(obj, path14) {
441
+ function getAtPath(obj, path16) {
384
442
  let current = obj;
385
- for (const segment of path14) {
443
+ for (const segment of path16) {
386
444
  if (segment === "*") {
387
445
  if (!Array.isArray(current)) return void 0;
388
446
  return current.map(
389
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path14.slice(path14.indexOf("*") + 1)) : void 0
447
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path16.slice(path16.indexOf("*") + 1)) : void 0
390
448
  );
391
449
  }
392
450
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -394,13 +452,13 @@ function getAtPath(obj, path14) {
394
452
  }
395
453
  return current;
396
454
  }
397
- function setAtPath(obj, path14, value) {
398
- if (path14.length === 0) return;
399
- if (path14.length === 1) {
400
- obj[path14[0]] = value;
455
+ function setAtPath(obj, path16, value) {
456
+ if (path16.length === 0) return;
457
+ if (path16.length === 1) {
458
+ obj[path16[0]] = value;
401
459
  return;
402
460
  }
403
- const [head, ...rest] = path14;
461
+ const [head, ...rest] = path16;
404
462
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
405
463
  obj[head] = {};
406
464
  }
@@ -451,45 +509,23 @@ var LOCALE_BUILTIN_KEYS = [
451
509
  ["translationOf", "translationOf is internal; remove from locale translation"],
452
510
  ["enSlug", "enSlug is internal; remove from locale translation"]
453
511
  ];
454
- function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
512
+ var DEPRECATED_REDIRECT_FIELDS = [
513
+ [
514
+ "aliases",
515
+ "aliases frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
516
+ ],
517
+ [
518
+ "redirect_to",
519
+ "redirect_to frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
520
+ ]
521
+ ];
522
+ function extractBuiltinEnFields(data, _pathTemplate, _enSlug, _defaultLocale) {
455
523
  const issues = [];
456
524
  const rest = { ...data };
457
- const aliasesResult = z.array(slugPatternSchema).max(20).optional().default([]).safeParse(rest.aliases ?? []);
458
- delete rest.aliases;
459
- const redirectRaw = rest.redirect_to;
460
- delete rest.redirect_to;
461
- let redirectTo;
462
- if (redirectRaw !== void 0 && redirectRaw !== null && redirectRaw !== "") {
463
- if (typeof redirectRaw !== "string") {
464
- issues.push({
465
- field: "redirect_to",
466
- message: "redirect_to must be a string path",
467
- level: "error"
468
- });
469
- } else if (!pathTemplate) {
470
- issues.push({
471
- field: "redirect_to",
472
- message: "redirect_to is not allowed on reference-only content types",
473
- level: "error"
474
- });
475
- } else if (!extractSlugFromResolvedPath(pathTemplate, redirectRaw)) {
476
- issues.push({
477
- field: "redirect_to",
478
- message: `redirect_to must match path template "${pathTemplate}" (prefix "${pathPrefix(pathTemplate)}"${pathSuffix(pathTemplate) ? `, suffix "${pathSuffix(pathTemplate)}"` : ""})`,
479
- level: "error"
480
- });
481
- } else {
482
- redirectTo = redirectRaw;
483
- }
484
- }
485
- const aliases = aliasesResult.success ? aliasesResult.data : [];
486
- if (!aliasesResult.success) {
487
- for (const issue of aliasesResult.error.issues) {
488
- issues.push({
489
- field: `aliases.${issue.path.join(".")}`,
490
- message: issue.message,
491
- level: "error"
492
- });
525
+ for (const [field2, message] of DEPRECATED_REDIRECT_FIELDS) {
526
+ if (rest[field2] !== void 0) {
527
+ issues.push({ field: field2, message, level: "error" });
528
+ delete rest[field2];
493
529
  }
494
530
  }
495
531
  let publishedAt;
@@ -551,8 +587,6 @@ function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
551
587
  }
552
588
  return {
553
589
  builtin: {
554
- aliases,
555
- redirectTo,
556
590
  publishedAt,
557
591
  updatedAt,
558
592
  noindex,
@@ -571,20 +605,24 @@ function validateLocaleBuiltinFields(data) {
571
605
  }
572
606
  return issues;
573
607
  }
574
- function resolveCanonicalPathname(type, doc, defaultLocale) {
608
+ function resolveCanonicalPathname(type, doc, defaultLocale, localeRouting) {
609
+ const urlBuilder = createUrlBuilder({
610
+ locales: [defaultLocale, ...doc.locale !== defaultLocale ? [doc.locale] : []],
611
+ defaultLocale,
612
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
613
+ });
575
614
  if (doc.canonicalPathOverride) {
576
- if (doc.locale === defaultLocale) return doc.canonicalPathOverride;
577
- return `/${doc.locale}${doc.canonicalPathOverride}`;
615
+ return urlBuilder.applyLocaleToPath(doc.canonicalPathOverride, doc.locale);
578
616
  }
579
617
  if (!type.path) return `/${doc.slug}`;
580
- return resolvePath(type.path, doc.slug, doc.locale, defaultLocale);
618
+ return urlBuilder.resolvePath(type.path, doc.slug, doc.locale);
581
619
  }
582
- function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
620
+ function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale, localeRouting) {
583
621
  const out = { ...frontmatter };
584
622
  if (doc.publishedAt !== void 0) out.publishedAt = doc.publishedAt;
585
623
  if (doc.updatedAt !== void 0) out.updatedAt = doc.updatedAt;
586
624
  out.noindex = doc.noindex;
587
- out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
625
+ out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale, localeRouting);
588
626
  return out;
589
627
  }
590
628
  function seoFieldsFromEn(enDoc) {
@@ -592,8 +630,7 @@ function seoFieldsFromEn(enDoc) {
592
630
  publishedAt: enDoc.publishedAt,
593
631
  updatedAt: enDoc.updatedAt,
594
632
  noindex: enDoc.noindex,
595
- canonicalPathOverride: enDoc.canonicalPathOverride,
596
- redirectTo: enDoc.redirectTo
633
+ canonicalPathOverride: enDoc.canonicalPathOverride
597
634
  };
598
635
  }
599
636
 
@@ -668,11 +705,6 @@ function getTranslation(db, contentType, enSlug, locale) {
668
705
  function listTranslationsForType(db, contentType) {
669
706
  return db.prepare(`SELECT * FROM translations WHERE content_type = ? ORDER BY en_slug, locale`).all(contentType);
670
707
  }
671
- function listTranslationsForEnSlug(db, contentType, enSlug) {
672
- return db.prepare(
673
- `SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
674
- ).all(contentType, enSlug);
675
- }
676
708
  function listTranslationsForLocale(db, contentType, locale) {
677
709
  return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
678
710
  }
@@ -756,7 +788,8 @@ function parseEnMdx(filePath, config, type) {
756
788
  locale: config.defaultLocale
757
789
  },
758
790
  type,
759
- config.defaultLocale
791
+ config.defaultLocale,
792
+ config.localeRouting
760
793
  );
761
794
  const crossIssues = type.crossValidate?.(result.data, {
762
795
  locale: config.defaultLocale,
@@ -770,8 +803,6 @@ function parseEnMdx(filePath, config, type) {
770
803
  slug,
771
804
  enSlug: slug,
772
805
  locale: config.defaultLocale,
773
- aliases: builtin.aliases,
774
- redirectTo: builtin.redirectTo,
775
806
  publishedAt: builtin.publishedAt,
776
807
  updatedAt: builtin.updatedAt,
777
808
  noindex: builtin.noindex,
@@ -782,7 +813,7 @@ function parseEnMdx(filePath, config, type) {
782
813
  };
783
814
  return { document, issues };
784
815
  }
785
- function buildDocumentFromTranslation(row, enDoc, type) {
816
+ function buildDocumentFromTranslation(row, enDoc, type, config) {
786
817
  const localeFm = JSON.parse(row.frontmatter_json);
787
818
  const merged = mergeStructuralOntoLocale(localeFm, enDoc.frontmatter, type.schema);
788
819
  const seo = seoFieldsFromEn(enDoc);
@@ -790,14 +821,13 @@ function buildDocumentFromTranslation(row, enDoc, type) {
790
821
  merged,
791
822
  { ...seo, slug: row.slug, locale: row.locale },
792
823
  type,
793
- enDoc.locale
824
+ config.defaultLocale,
825
+ config.localeRouting
794
826
  );
795
827
  return {
796
828
  slug: row.slug,
797
829
  enSlug: row.en_slug,
798
830
  locale: row.locale,
799
- aliases: [],
800
- redirectTo: seo.redirectTo,
801
831
  publishedAt: seo.publishedAt,
802
832
  updatedAt: seo.updatedAt,
803
833
  noindex: seo.noindex,
@@ -875,7 +905,7 @@ function createContentLoader(config, type) {
875
905
  for (const row of rowsByLocale.get(locale) ?? []) {
876
906
  const enDoc = englishBySlug.get(row.en_slug);
877
907
  if (!enDoc) continue;
878
- const doc = buildDocumentFromTranslation(row, enDoc, type);
908
+ const doc = buildDocumentFromTranslation(row, enDoc, type, config);
879
909
  bySlug.set(doc.slug, doc);
880
910
  byEnSlug.set(row.en_slug, doc);
881
911
  }
@@ -924,56 +954,16 @@ function getTranslatablePayload(doc, type) {
924
954
 
925
955
  // src/i18n/resolve-document.ts
926
956
  init_esm_shims();
927
- function findEnDocByAlias(slug, allDocs, defaultLocale) {
928
- const enIdx = allDocs.get(defaultLocale);
929
- if (!enIdx) return null;
930
- for (const doc of enIdx.bySlug.values()) {
931
- if (doc.aliases.includes(slug)) return doc;
932
- }
933
- return null;
934
- }
935
- function enCanonicalDoc(doc, allDocs, defaultLocale) {
936
- if (doc.locale === defaultLocale) return doc;
937
- return allDocs.get(defaultLocale)?.bySlug.get(doc.enSlug) ?? doc;
938
- }
939
- function redirectPathForDocument(doc, locale, defaultLocale, allDocs, type) {
940
- if (!type.path) return void 0;
941
- const enDoc = enCanonicalDoc(doc, allDocs, defaultLocale);
942
- if (!enDoc.redirectTo) return void 0;
943
- const targetEnSlug = extractSlugFromResolvedPath(type.path, enDoc.redirectTo);
944
- if (!targetEnSlug) return void 0;
945
- const targetEnDoc = allDocs.get(defaultLocale)?.bySlug.get(targetEnSlug);
946
- const targetSlug = targetEnDoc ? getSlugForLocale(targetEnDoc, defaultLocale, locale, allDocs, defaultLocale) ?? targetEnSlug : targetEnSlug;
947
- return resolvePath(type.path, targetSlug, locale, defaultLocale);
948
- }
949
- function withRedirectTo(result, locale, defaultLocale, allDocs, type) {
950
- if (result.shouldRedirectTo || !result.document) return result;
951
- const redirect = redirectPathForDocument(result.document, locale, defaultLocale, allDocs, type);
952
- if (!redirect) return result;
953
- return {
954
- document: null,
955
- actualLocale: result.actualLocale,
956
- shouldRedirectTo: redirect
957
- };
958
- }
959
- function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
957
+ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type, localeRouting) {
958
+ const urlBuilder = createUrlBuilder({
959
+ locales: [defaultLocale, locale],
960
+ defaultLocale,
961
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
962
+ });
960
963
  const idx = allDocs.get(locale);
961
964
  const direct = idx?.bySlug.get(slug);
962
965
  if (direct) {
963
- return withRedirectTo({ document: direct, actualLocale: locale }, locale, defaultLocale, allDocs, type);
964
- }
965
- const aliasDoc = findEnDocByAlias(slug, allDocs, defaultLocale);
966
- if (aliasDoc && type.path) {
967
- const canonicalSlug = aliasDoc.slug;
968
- const localizedSlug = getSlugForLocale(aliasDoc, defaultLocale, locale, allDocs, defaultLocale);
969
- const targetSlug = localizedSlug ?? canonicalSlug;
970
- if (targetSlug !== slug) {
971
- return {
972
- document: null,
973
- actualLocale: locale,
974
- shouldRedirectTo: resolvePath(type.path, targetSlug, locale, defaultLocale)
975
- };
976
- }
966
+ return { document: direct, actualLocale: locale };
977
967
  }
978
968
  for (const [docLocale, docIdx] of allDocs) {
979
969
  const found = docIdx.bySlug.get(slug);
@@ -986,17 +976,11 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
986
976
  return {
987
977
  document: null,
988
978
  actualLocale: locale,
989
- shouldRedirectTo: resolvePath(type.path, correctSlug, locale, defaultLocale)
979
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, correctSlug, locale)
990
980
  };
991
981
  }
992
982
  if (docLocale === defaultLocale) {
993
- return withRedirectTo(
994
- { document: found, actualLocale: defaultLocale },
995
- locale,
996
- defaultLocale,
997
- allDocs,
998
- type
999
- );
983
+ return { document: found, actualLocale: defaultLocale };
1000
984
  }
1001
985
  if (!type.path) {
1002
986
  return { document: null, actualLocale: locale };
@@ -1004,39 +988,21 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
1004
988
  return {
1005
989
  document: null,
1006
990
  actualLocale: locale,
1007
- shouldRedirectTo: resolvePath(type.path, found.enSlug, locale, defaultLocale)
991
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, found.enSlug, locale)
1008
992
  };
1009
993
  }
1010
994
  if (locale !== defaultLocale) {
1011
995
  const enDoc = allDocs.get(defaultLocale)?.bySlug.get(slug);
1012
996
  if (enDoc && type.indexFallback === "en") {
1013
- return withRedirectTo(
1014
- { document: enDoc, actualLocale: defaultLocale },
1015
- locale,
1016
- defaultLocale,
1017
- allDocs,
1018
- type
1019
- );
997
+ return { document: enDoc, actualLocale: defaultLocale };
1020
998
  }
1021
999
  const byEn = idx?.byEnSlug.get(slug);
1022
1000
  if (byEn) {
1023
- return withRedirectTo(
1024
- { document: byEn, actualLocale: locale },
1025
- locale,
1026
- defaultLocale,
1027
- allDocs,
1028
- type
1029
- );
1001
+ return { document: byEn, actualLocale: locale };
1030
1002
  }
1031
1003
  const fallback = allDocs.get(defaultLocale)?.bySlug.get(slug);
1032
1004
  if (fallback && type.indexFallback === "en") {
1033
- return withRedirectTo(
1034
- { document: fallback, actualLocale: defaultLocale },
1035
- locale,
1036
- defaultLocale,
1037
- allDocs,
1038
- type
1039
- );
1005
+ return { document: fallback, actualLocale: defaultLocale };
1040
1006
  }
1041
1007
  }
1042
1008
  return { document: null, actualLocale: locale };
@@ -1078,6 +1044,7 @@ function buildRuntime(config, type, getRuntime) {
1078
1044
  }
1079
1045
  return type.path;
1080
1046
  }
1047
+ const urlBuilder = createUrlBuilder(config);
1081
1048
  const runtime = {
1082
1049
  id: type.id,
1083
1050
  config: type,
@@ -1093,15 +1060,21 @@ function buildRuntime(config, type, getRuntime) {
1093
1060
  return load().get(locale)?.bySlug.get(slug) ?? null;
1094
1061
  },
1095
1062
  resolve(slug, locale) {
1096
- const result = resolveLocalizedDocument(slug, locale, config.defaultLocale, load(), type);
1063
+ const result = resolveLocalizedDocument(
1064
+ slug,
1065
+ locale,
1066
+ config.defaultLocale,
1067
+ load(),
1068
+ type,
1069
+ config.localeRouting
1070
+ );
1097
1071
  if (result.document && type.path) {
1098
1072
  return {
1099
1073
  ...result,
1100
- canonicalPath: resolvePath(
1074
+ canonicalPath: urlBuilder.resolvePath(
1101
1075
  type.path,
1102
1076
  result.document.slug,
1103
- result.actualLocale,
1104
- config.defaultLocale
1077
+ result.actualLocale
1105
1078
  )
1106
1079
  };
1107
1080
  }
@@ -1125,10 +1098,9 @@ function buildRuntime(config, type, getRuntime) {
1125
1098
  alternates(doc) {
1126
1099
  const pathTemplate = assertRoutable("alternates");
1127
1100
  const out = {
1128
- [config.defaultLocale]: resolvePath(
1101
+ [config.defaultLocale]: urlBuilder.resolvePath(
1129
1102
  pathTemplate,
1130
1103
  doc.enSlug,
1131
- config.defaultLocale,
1132
1104
  config.defaultLocale
1133
1105
  )
1134
1106
  };
@@ -1137,7 +1109,7 @@ function buildRuntime(config, type, getRuntime) {
1137
1109
  if (locale === config.defaultLocale) continue;
1138
1110
  const translated = all.get(locale)?.byEnSlug.get(doc.enSlug);
1139
1111
  if (translated) {
1140
- out[locale] = resolvePath(pathTemplate, translated.slug, locale, config.defaultLocale);
1112
+ out[locale] = urlBuilder.resolvePath(pathTemplate, translated.slug, locale);
1141
1113
  }
1142
1114
  }
1143
1115
  return out;
@@ -1152,7 +1124,7 @@ function buildRuntime(config, type, getRuntime) {
1152
1124
  },
1153
1125
  url(slug, locale) {
1154
1126
  const pathTemplate = assertRoutable("url");
1155
- return resolvePath(pathTemplate, slug, locale, config.defaultLocale);
1127
+ return urlBuilder.resolvePath(pathTemplate, slug, locale);
1156
1128
  },
1157
1129
  related(doc, fieldName, locale) {
1158
1130
  const meta = relationFields.get(fieldName);
@@ -1208,6 +1180,9 @@ function createProject(config) {
1208
1180
  getType: getRuntime,
1209
1181
  listTypes() {
1210
1182
  return Array.from(runtimes.values());
1183
+ },
1184
+ listRoutableTypes() {
1185
+ return Array.from(runtimes.values()).filter((runtime) => isRoutableType(runtime.config));
1211
1186
  }
1212
1187
  };
1213
1188
  }
@@ -1218,208 +1193,141 @@ init_esm_shims();
1218
1193
  // src/redirects/build-redirects.ts
1219
1194
  init_esm_shims();
1220
1195
 
1221
- // src/core/slug-aliases.ts
1222
- init_esm_shims();
1223
-
1224
1196
  // src/core/alias-helpers.ts
1225
1197
  init_esm_shims();
1226
1198
  function enFileExists(config, type, enSlug) {
1227
1199
  const dir = path4.join(config.rootDir, type.contentDir);
1228
1200
  return fs2.existsSync(path4.join(dir, `${enSlug}.mdx`)) || fs2.existsSync(path4.join(dir, `${enSlug}.md`));
1229
1201
  }
1230
- function sqliteHasTranslations(db, contentTypeId, enSlug) {
1231
- return listTranslationsForEnSlug(db, contentTypeId, enSlug).length > 0;
1232
- }
1233
- function isAliasKnown(config, type, aliasEnSlug, db) {
1234
- return enFileExists(config, type, aliasEnSlug) || sqliteHasTranslations(db, type.id, aliasEnSlug);
1235
- }
1236
1202
  function listEnSlugs(rootDir, contentDir) {
1237
1203
  const dir = path4.join(rootDir, contentDir);
1238
1204
  if (!fs2.existsSync(dir)) return [];
1239
1205
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1240
1206
  }
1241
1207
 
1242
- // src/core/slug-aliases.ts
1208
+ // src/i18n/translation-index.ts
1209
+ init_esm_shims();
1210
+
1211
+ // src/redirects/build-redirects.ts
1243
1212
  init_sqlite();
1244
- function buildGlobalAliasIndex(project) {
1245
- const { config } = project;
1246
- const aliasToTarget = /* @__PURE__ */ new Map();
1247
- const allAliasSlugs = /* @__PURE__ */ new Set();
1248
- const issues = [];
1249
- const canonicalSlugsByType = /* @__PURE__ */ new Map();
1250
- for (const type of config.types) {
1251
- const slugs = listEnSlugs(config.rootDir, type.contentDir);
1252
- canonicalSlugsByType.set(type.id, new Set(slugs));
1213
+
1214
+ // src/redirects/build-json-redirects.ts
1215
+ init_esm_shims();
1216
+ init_sqlite();
1217
+
1218
+ // src/redirects/load-type-redirects.ts
1219
+ init_esm_shims();
1220
+
1221
+ // src/redirects/redirect-schema.ts
1222
+ init_esm_shims();
1223
+ var redirectFromSchema = z.union([
1224
+ slugPatternSchema,
1225
+ z.array(slugPatternSchema).min(1).max(20)
1226
+ ]);
1227
+ var typeRedirectEntrySchema = z.object({
1228
+ from: redirectFromSchema,
1229
+ toSlug: slugPatternSchema.optional(),
1230
+ toType: z.string().min(1).optional(),
1231
+ toUrl: z.string().min(1).optional(),
1232
+ permanent: z.boolean().optional()
1233
+ }).superRefine((entry, ctx) => {
1234
+ const hasToSlug = entry.toSlug !== void 0;
1235
+ const hasToUrl = entry.toUrl !== void 0;
1236
+ const hasToType = entry.toType !== void 0;
1237
+ const targetCount = Number(hasToSlug) + Number(hasToUrl);
1238
+ if (targetCount !== 1) {
1239
+ ctx.addIssue({
1240
+ code: "custom",
1241
+ message: "Each redirect must specify exactly one of toSlug/toType+toSlug or toUrl",
1242
+ path: ["toSlug"]
1243
+ });
1244
+ return;
1253
1245
  }
1254
- for (const type of config.types) {
1255
- if (!type.path) continue;
1256
- const canonicalSlugs = canonicalSlugsByType.get(type.id) ?? /* @__PURE__ */ new Set();
1257
- for (const enSlug of canonicalSlugs) {
1258
- const doc = readEnDocument(config, type, enSlug);
1259
- if (!doc) continue;
1260
- for (const alias of doc.aliases) {
1261
- if (alias === enSlug) {
1262
- issues.push({
1263
- contentTypeId: type.id,
1264
- enSlug,
1265
- field: "aliases",
1266
- message: `Alias "${alias}" must not equal the canonical slug`,
1267
- level: "error"
1268
- });
1269
- continue;
1270
- }
1271
- if (canonicalSlugs.has(alias)) {
1272
- issues.push({
1273
- contentTypeId: type.id,
1274
- enSlug,
1275
- field: "aliases",
1276
- message: `Alias "${alias}" collides with another document's canonical slug`,
1277
- level: "error"
1278
- });
1279
- continue;
1280
- }
1281
- const existing = aliasToTarget.get(alias);
1282
- if (existing) {
1283
- issues.push({
1284
- contentTypeId: type.id,
1285
- enSlug,
1286
- field: "aliases",
1287
- message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
1288
- level: "error"
1289
- });
1290
- continue;
1291
- }
1292
- aliasToTarget.set(alias, {
1293
- contentTypeId: type.id,
1294
- canonicalSlug: enSlug,
1295
- path: type.path
1296
- });
1297
- allAliasSlugs.add(alias);
1298
- }
1299
- }
1246
+ if (hasToUrl && hasToType) {
1247
+ ctx.addIssue({
1248
+ code: "custom",
1249
+ message: "toUrl cannot be combined with toType",
1250
+ path: ["toUrl"]
1251
+ });
1252
+ }
1253
+ if (hasToType && !hasToSlug) {
1254
+ ctx.addIssue({
1255
+ code: "custom",
1256
+ message: "Cross-type redirects require both toType and toSlug",
1257
+ path: ["toSlug"]
1258
+ });
1300
1259
  }
1301
- return { aliasToTarget, allAliasSlugs, issues };
1260
+ });
1261
+ var typeRedirectsFileSchema = z.object({
1262
+ redirects: z.array(typeRedirectEntrySchema).max(500)
1263
+ });
1264
+ function normalizeRedirectFrom(from) {
1265
+ return Array.isArray(from) ? from : [from];
1302
1266
  }
1303
- function validateAliasCoverage(project) {
1304
- const { config } = project;
1305
- const { aliasToTarget } = buildGlobalAliasIndex(project);
1306
- const issues = [];
1307
- const db = openStore(config, "readonly");
1308
- try {
1309
- for (const [alias, target] of aliasToTarget) {
1310
- const type = config.types.find((t) => t.id === target.contentTypeId);
1311
- if (!type) continue;
1312
- const known = isAliasKnown(config, type, alias, db);
1313
- if (!known) {
1314
- issues.push({
1315
- contentTypeId: type.id,
1316
- enSlug: target.canonicalSlug,
1317
- field: "aliases",
1318
- message: `Alias "${alias}" is unknown (no EN file or sqlite translations) \u2014 only EN redirect will work`,
1319
- level: "warning"
1320
- });
1321
- }
1322
- if (enFileExists(config, type, alias)) {
1323
- issues.push({
1324
- contentTypeId: type.id,
1325
- enSlug: target.canonicalSlug,
1326
- field: "aliases",
1327
- message: `Alias "${alias}" still has an EN file on disk \u2014 delete it manually`,
1328
- level: "warning"
1329
- });
1330
- }
1331
- const sqliteRows = listTranslationsForEnSlug(db, type.id, alias);
1332
- if (sqliteRows.length > 0) {
1333
- issues.push({
1334
- contentTypeId: type.id,
1335
- enSlug: target.canonicalSlug,
1336
- field: "aliases",
1337
- message: `${sqliteRows.length} sqlite translation(s) for alias "${alias}" retained for locale redirects and history`,
1338
- level: "info"
1339
- });
1340
- }
1341
- for (const locale of config.locales) {
1342
- if (locale === config.defaultLocale) continue;
1343
- const canonicalRow = listTranslationsForEnSlug(db, type.id, target.canonicalSlug).find(
1344
- (r) => r.locale === locale
1345
- );
1346
- if (!canonicalRow) continue;
1347
- const aliasRow = sqliteRows.find((r) => r.locale === locale);
1348
- if (!aliasRow && known) {
1349
- issues.push({
1350
- contentTypeId: type.id,
1351
- enSlug: target.canonicalSlug,
1352
- field: "aliases",
1353
- message: `Alias "${alias}" has no locale slug mapping for ${locale} \u2014 old localized URL may miss redirect`,
1354
- level: "warning"
1355
- });
1356
- }
1357
- }
1358
- }
1359
- } finally {
1360
- db.close();
1267
+ function parseRedirectEntry(entry) {
1268
+ const fromSlugs = normalizeRedirectFrom(entry.from);
1269
+ if (entry.toUrl !== void 0) {
1270
+ return {
1271
+ fromSlugs,
1272
+ kind: "anywhere",
1273
+ toUrl: entry.toUrl,
1274
+ permanent: entry.permanent ?? true
1275
+ };
1361
1276
  }
1362
- return issues;
1277
+ if (entry.toType !== void 0) {
1278
+ return {
1279
+ fromSlugs,
1280
+ kind: "cross-type",
1281
+ toType: entry.toType,
1282
+ toSlug: entry.toSlug,
1283
+ permanent: entry.permanent ?? true
1284
+ };
1285
+ }
1286
+ return {
1287
+ fromSlugs,
1288
+ kind: "same-type",
1289
+ toSlug: entry.toSlug,
1290
+ permanent: entry.permanent ?? true
1291
+ };
1363
1292
  }
1364
- function validateAliasRedirectChains(project) {
1365
- const { config } = project;
1366
- const { aliasToTarget, allAliasSlugs } = buildGlobalAliasIndex(project);
1367
- const issues = [];
1368
- for (const [alias, target] of aliasToTarget) {
1369
- const doc = readEnDocument(
1370
- config,
1371
- project.getType(target.contentTypeId).config,
1372
- target.canonicalSlug
1293
+
1294
+ // src/redirects/load-type-redirects.ts
1295
+ var TYPE_REDIRECTS_FILENAME = "_redirects.json";
1296
+ function redirectsFilePath(config, type) {
1297
+ return path4.join(config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1298
+ }
1299
+ function loadTypeRedirectsFile(config, type) {
1300
+ const filePath = redirectsFilePath(config, type);
1301
+ if (!fs2.existsSync(filePath)) return null;
1302
+ let raw;
1303
+ try {
1304
+ raw = JSON.parse(fs2.readFileSync(filePath, "utf8"));
1305
+ } catch (error) {
1306
+ throw new Error(
1307
+ `${type.id}: failed to parse ${TYPE_REDIRECTS_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
1373
1308
  );
1374
- if (doc?.redirectTo) {
1375
- issues.push({
1376
- contentTypeId: target.contentTypeId,
1377
- enSlug: target.canonicalSlug,
1378
- field: "aliases",
1379
- message: `Alias "${alias}" points at "${target.canonicalSlug}" which has redirect_to \u2014 resolve to the final canonical slug`,
1380
- level: "error"
1381
- });
1382
- }
1383
1309
  }
1310
+ const parsed = typeRedirectsFileSchema.safeParse(raw);
1311
+ if (!parsed.success) {
1312
+ const details = parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ");
1313
+ throw new Error(`${type.id}: invalid ${TYPE_REDIRECTS_FILENAME}: ${details}`);
1314
+ }
1315
+ return {
1316
+ contentTypeId: type.id,
1317
+ contentDir: type.contentDir,
1318
+ filePath,
1319
+ entries: parsed.data.redirects.map(parseRedirectEntry)
1320
+ };
1321
+ }
1322
+ function loadAllTypeRedirects(config) {
1323
+ const out = [];
1384
1324
  for (const type of config.types) {
1385
- if (!type.path) continue;
1386
- for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
1387
- const doc = readEnDocument(config, type, enSlug);
1388
- if (!doc?.redirectTo) continue;
1389
- const targetSlug = extractSlugFromResolvedPath(type.path, doc.redirectTo);
1390
- if (targetSlug && allAliasSlugs.has(targetSlug)) {
1391
- issues.push({
1392
- contentTypeId: type.id,
1393
- enSlug,
1394
- field: "redirect_to",
1395
- message: `redirect_to target "${targetSlug}" is an alias slug \u2014 chain detected`,
1396
- level: "error"
1397
- });
1398
- }
1399
- const targetDoc = targetSlug ? readEnDocument(config, type, targetSlug) : null;
1400
- if (targetDoc?.redirectTo) {
1401
- issues.push({
1402
- contentTypeId: type.id,
1403
- enSlug,
1404
- field: "redirect_to",
1405
- message: `redirect_to chain detected: "${enSlug}" \u2192 "${targetSlug}" \u2192 "${targetDoc.redirectTo}"`,
1406
- level: "error"
1407
- });
1408
- }
1409
- }
1325
+ const loaded = loadTypeRedirectsFile(config, type);
1326
+ if (loaded) out.push(loaded);
1410
1327
  }
1411
- return issues;
1328
+ return out;
1412
1329
  }
1413
1330
 
1414
- // src/redirects/translation-index.ts
1415
- init_esm_shims();
1416
-
1417
- // src/i18n/translation-index.ts
1418
- init_esm_shims();
1419
-
1420
- // src/redirects/translation-index.ts
1421
- init_sqlite();
1422
-
1423
1331
  // src/sitemap/join-base-url.ts
1424
1332
  init_esm_shims();
1425
1333
 
@@ -1471,6 +1379,173 @@ function loadConfigSync(options = {}) {
1471
1379
 
1472
1380
  // src/validate/validate-project.ts
1473
1381
  init_esm_shims();
1382
+
1383
+ // src/validate/validate-redirects.ts
1384
+ init_esm_shims();
1385
+ function liveDocExists(config, typeId, enSlug) {
1386
+ const type = config.types.find((entry) => entry.id === typeId);
1387
+ if (!type) return false;
1388
+ return enFileExists(config, type, enSlug);
1389
+ }
1390
+ function resolveRedirectTarget(project, sourceTypeId, entry) {
1391
+ if (entry.kind === "anywhere") return null;
1392
+ if (entry.kind === "cross-type") {
1393
+ return { typeId: entry.toType, enSlug: entry.toSlug };
1394
+ }
1395
+ return { typeId: sourceTypeId, enSlug: entry.toSlug };
1396
+ }
1397
+ function matchRoutableTarget(project, toUrl) {
1398
+ const urlBuilder = createUrlBuilder(project.config);
1399
+ for (const type of project.config.types) {
1400
+ if (!type.path) continue;
1401
+ const enSlug = urlBuilder.extractSlugFromResolvedPath(type.path, toUrl);
1402
+ if (enSlug && liveDocExists(project.config, type.id, enSlug)) {
1403
+ return { typeId: type.id, enSlug };
1404
+ }
1405
+ }
1406
+ return null;
1407
+ }
1408
+ function validateTypeRedirects(project) {
1409
+ const issues = [];
1410
+ const globalFrom = /* @__PURE__ */ new Map();
1411
+ for (const type of project.config.types) {
1412
+ const filePath = path4.join(project.config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1413
+ if (!fs2.existsSync(filePath)) continue;
1414
+ let loaded;
1415
+ try {
1416
+ loaded = loadTypeRedirectsFile(project.config, type);
1417
+ } catch (error) {
1418
+ issues.push({
1419
+ level: "error",
1420
+ contentType: type.id,
1421
+ field: TYPE_REDIRECTS_FILENAME,
1422
+ message: error instanceof Error ? error.message : String(error)
1423
+ });
1424
+ continue;
1425
+ }
1426
+ if (!loaded) continue;
1427
+ if (!isRoutableType(type)) {
1428
+ issues.push({
1429
+ level: "error",
1430
+ contentType: type.id,
1431
+ field: TYPE_REDIRECTS_FILENAME,
1432
+ message: `Redirects are only supported on routable content types (missing path)`
1433
+ });
1434
+ continue;
1435
+ }
1436
+ for (const entry of loaded.entries) {
1437
+ for (const from of entry.fromSlugs) {
1438
+ if (from === entry.toSlug) {
1439
+ issues.push({
1440
+ level: "error",
1441
+ contentType: type.id,
1442
+ enSlug: from,
1443
+ field: TYPE_REDIRECTS_FILENAME,
1444
+ message: `Redirect source "${from}" must not equal its target slug`
1445
+ });
1446
+ }
1447
+ if (liveDocExists(project.config, type.id, from)) {
1448
+ issues.push({
1449
+ level: "error",
1450
+ contentType: type.id,
1451
+ enSlug: from,
1452
+ field: TYPE_REDIRECTS_FILENAME,
1453
+ message: `Redirect source "${from}" still has a live EN file \u2014 delete or rename the MDX first`
1454
+ });
1455
+ }
1456
+ const existing = globalFrom.get(from);
1457
+ if (existing && existing.typeId !== type.id) {
1458
+ issues.push({
1459
+ level: "error",
1460
+ contentType: type.id,
1461
+ enSlug: from,
1462
+ field: TYPE_REDIRECTS_FILENAME,
1463
+ message: `Redirect source "${from}" is already claimed by ${existing.typeId}`
1464
+ });
1465
+ } else if (existing) {
1466
+ issues.push({
1467
+ level: "error",
1468
+ contentType: type.id,
1469
+ enSlug: from,
1470
+ field: TYPE_REDIRECTS_FILENAME,
1471
+ message: `Duplicate redirect source "${from}" in ${TYPE_REDIRECTS_FILENAME}`
1472
+ });
1473
+ } else {
1474
+ globalFrom.set(from, { typeId: type.id, filePath: loaded.filePath });
1475
+ }
1476
+ }
1477
+ if (entry.kind === "cross-type") {
1478
+ const targetType = project.config.types.find((candidate) => candidate.id === entry.toType);
1479
+ if (!targetType) {
1480
+ issues.push({
1481
+ level: "error",
1482
+ contentType: type.id,
1483
+ field: TYPE_REDIRECTS_FILENAME,
1484
+ message: `Unknown redirect target type "${entry.toType}"`
1485
+ });
1486
+ continue;
1487
+ }
1488
+ if (!isRoutableType(targetType)) {
1489
+ issues.push({
1490
+ level: "error",
1491
+ contentType: type.id,
1492
+ field: TYPE_REDIRECTS_FILENAME,
1493
+ message: `Redirect target type "${entry.toType}" is not routable (missing path)`
1494
+ });
1495
+ continue;
1496
+ }
1497
+ }
1498
+ const target = resolveRedirectTarget(project, type.id, entry);
1499
+ if (target) {
1500
+ if (!liveDocExists(project.config, target.typeId, target.enSlug)) {
1501
+ issues.push({
1502
+ level: "error",
1503
+ contentType: type.id,
1504
+ field: TYPE_REDIRECTS_FILENAME,
1505
+ message: `Redirect target ${target.typeId}/${target.enSlug} does not exist`
1506
+ });
1507
+ }
1508
+ continue;
1509
+ }
1510
+ if (entry.kind === "anywhere" && entry.toUrl?.startsWith("/")) {
1511
+ const matched = matchRoutableTarget(project, entry.toUrl);
1512
+ if (matched && !liveDocExists(project.config, matched.typeId, matched.enSlug)) {
1513
+ issues.push({
1514
+ level: "error",
1515
+ contentType: type.id,
1516
+ field: TYPE_REDIRECTS_FILENAME,
1517
+ message: `Redirect toUrl "${entry.toUrl}" does not resolve to a live document`
1518
+ });
1519
+ }
1520
+ }
1521
+ }
1522
+ }
1523
+ const allLoaded = loadAllTypeRedirects(project.config);
1524
+ for (const file of allLoaded) {
1525
+ for (const entry of file.entries) {
1526
+ if (entry.kind === "anywhere" || !entry.toSlug) continue;
1527
+ const targetTypeId = entry.kind === "cross-type" ? entry.toType : file.contentTypeId;
1528
+ const targetType = project.config.types.find((type) => type.id === targetTypeId);
1529
+ if (!targetType?.path) continue;
1530
+ for (const from of entry.fromSlugs) {
1531
+ if (globalFrom.get(from)?.typeId !== file.contentTypeId) continue;
1532
+ const chainTarget = globalFrom.get(entry.toSlug);
1533
+ if (chainTarget) {
1534
+ issues.push({
1535
+ level: "error",
1536
+ contentType: file.contentTypeId,
1537
+ enSlug: from,
1538
+ field: TYPE_REDIRECTS_FILENAME,
1539
+ message: `Redirect chain detected: "${from}" \u2192 "${entry.toSlug}" \u2192 existing redirect source`
1540
+ });
1541
+ }
1542
+ }
1543
+ }
1544
+ }
1545
+ return issues;
1546
+ }
1547
+
1548
+ // src/validate/validate-project.ts
1474
1549
  init_sqlite();
1475
1550
 
1476
1551
  // src/validate/validate-relations.ts
@@ -1671,7 +1746,7 @@ function validateTranslationSlugSuffixes(config, db) {
1671
1746
  }
1672
1747
 
1673
1748
  // src/validate/validate-project.ts
1674
- function listEnSlugs2(rootDir, contentDir) {
1749
+ function listEnSlugs3(rootDir, contentDir) {
1675
1750
  const dir = path4.join(rootDir, contentDir);
1676
1751
  if (!fs2.existsSync(dir)) return [];
1677
1752
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -1689,7 +1764,7 @@ function validateProject(config) {
1689
1764
  }
1690
1765
  const db = openStore(config, "readonly");
1691
1766
  for (const type of config.types) {
1692
- const enSlugs = listEnSlugs2(config.rootDir, type.contentDir);
1767
+ const enSlugs = listEnSlugs3(config.rootDir, type.contentDir);
1693
1768
  const englishSlugs = new Set(enSlugs);
1694
1769
  if (!type.translate && isRoutableType(type)) {
1695
1770
  issues.push({
@@ -1771,33 +1846,8 @@ function validateProject(config) {
1771
1846
  }
1772
1847
  }
1773
1848
  db.close();
1774
- const aliasIndex = buildGlobalAliasIndex(project);
1775
- for (const issue of aliasIndex.issues) {
1776
- issues.push({
1777
- level: issue.level,
1778
- contentType: issue.contentTypeId,
1779
- enSlug: issue.enSlug,
1780
- field: issue.field,
1781
- message: issue.message
1782
- });
1783
- }
1784
- for (const issue of validateAliasRedirectChains(project)) {
1785
- issues.push({
1786
- level: issue.level,
1787
- contentType: issue.contentTypeId,
1788
- enSlug: issue.enSlug,
1789
- field: issue.field,
1790
- message: issue.message
1791
- });
1792
- }
1793
- for (const issue of validateAliasCoverage(project)) {
1794
- issues.push({
1795
- level: issue.level,
1796
- contentType: issue.contentTypeId,
1797
- enSlug: issue.enSlug,
1798
- field: issue.field,
1799
- message: issue.message
1800
- });
1849
+ for (const issue of validateTypeRedirects(project)) {
1850
+ issues.push(issue);
1801
1851
  }
1802
1852
  for (const issue of validateRelations(project)) {
1803
1853
  issues.push({
@@ -1841,7 +1891,7 @@ function computePageEnHash(translatableFrontmatter, body) {
1841
1891
 
1842
1892
  // src/translate/worklist.ts
1843
1893
  init_sqlite();
1844
- function listEnSlugs3(rootDir, contentDir) {
1894
+ function listEnSlugs4(rootDir, contentDir) {
1845
1895
  const dir = path4.join(rootDir, contentDir);
1846
1896
  if (!fs2.existsSync(dir)) return [];
1847
1897
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -1852,7 +1902,7 @@ function buildWorklist(config, options = {}) {
1852
1902
  const locales = options.locales ?? config.locales.filter((locale) => locale !== config.defaultLocale);
1853
1903
  for (const type of config.types) {
1854
1904
  if (options.contentType && type.id !== options.contentType) continue;
1855
- const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs3(config.rootDir, type.contentDir);
1905
+ const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs4(config.rootDir, type.contentDir);
1856
1906
  for (const enSlug of enSlugs) {
1857
1907
  const enDoc = readEnDocument(config, type, enSlug);
1858
1908
  if (!enDoc) continue;
@@ -2153,11 +2203,12 @@ init_esm_shims();
2153
2203
  function slugStrategyRules(slugStrategy, preserveTerms) {
2154
2204
  if (slugStrategy === "localized") {
2155
2205
  const rules = [
2156
- "Provide a URL slug in JSON field `slug` that is TRANSLATED into the target language.",
2206
+ "Provide a URL slug in JSON field `slug` that is Localized into the target language.",
2157
2207
  "Base the slug on the meaning of the translated title \u2014 do NOT reuse the English slug words.",
2158
2208
  "Slug MUST be ASCII only: a-z, 0-9, hyphens. No uppercase, accents, underscores, or spaces.",
2159
2209
  "For non-Latin languages, write the words in the target language and transliterate them into Latin script (e.g. Russian -> romanized Russian, not English).",
2160
- "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."
2210
+ "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.",
2211
+ '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 \\".'
2161
2212
  ];
2162
2213
  if (preserveTerms?.length) {
2163
2214
  rules.push(
@@ -2190,6 +2241,96 @@ function resolveTranslateConfig(config, type) {
2190
2241
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
2191
2242
  }
2192
2243
 
2244
+ // src/translate/sanitize-mdx-jsx.ts
2245
+ init_esm_shims();
2246
+ function sanitizeMdxJsxAttributeQuotes(body) {
2247
+ let adjusted = false;
2248
+ let out = "";
2249
+ let i = 0;
2250
+ while (i < body.length) {
2251
+ if (body[i] !== "<" || !/[\w.]/.test(body[i + 1] ?? "")) {
2252
+ out += body[i];
2253
+ i += 1;
2254
+ continue;
2255
+ }
2256
+ const tagStart = i;
2257
+ i += 1;
2258
+ while (i < body.length && /[\w.-]/.test(body[i] ?? "")) i += 1;
2259
+ const tagName = body.slice(tagStart + 1, i);
2260
+ let tagOut = `<${tagName}`;
2261
+ let tagAdjusted = false;
2262
+ while (i < body.length && body[i] !== ">" && body[i] !== "/") {
2263
+ while (i < body.length && /\s/.test(body[i] ?? "")) {
2264
+ tagOut += body[i];
2265
+ i += 1;
2266
+ }
2267
+ if (i >= body.length || body[i] === ">" || body[i] === "/") break;
2268
+ const attrStart = i;
2269
+ while (i < body.length && /[\w:.-]/.test(body[i] ?? "")) i += 1;
2270
+ body.slice(attrStart, i);
2271
+ while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2272
+ if (body[i] !== "=") {
2273
+ tagOut += body.slice(attrStart, i);
2274
+ continue;
2275
+ }
2276
+ tagOut += body.slice(attrStart, i);
2277
+ tagOut += "=";
2278
+ i += 1;
2279
+ while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2280
+ const quote = body[i];
2281
+ if (quote !== '"' && quote !== "'") {
2282
+ continue;
2283
+ }
2284
+ if (quote === "'") {
2285
+ const valStart2 = i + 1;
2286
+ i += 1;
2287
+ while (i < body.length) {
2288
+ if (body[i] === "\\") {
2289
+ i += 2;
2290
+ continue;
2291
+ }
2292
+ if (body[i] === "'") break;
2293
+ i += 1;
2294
+ }
2295
+ tagOut += body.slice(valStart2 - 1, i + 1);
2296
+ i += 1;
2297
+ continue;
2298
+ }
2299
+ const valStart = i + 1;
2300
+ i += 1;
2301
+ let closeIdx = -1;
2302
+ for (let scan = valStart; scan < body.length; scan += 1) {
2303
+ if (body[scan] !== '"') continue;
2304
+ let j = scan + 1;
2305
+ while (j < body.length && /\s/.test(body[j] ?? "")) j += 1;
2306
+ if (j < body.length && (body[j] === ">" || body[j] === "/" || /[a-zA-Z]/.test(body[j] ?? ""))) {
2307
+ closeIdx = scan;
2308
+ break;
2309
+ }
2310
+ }
2311
+ if (closeIdx === -1) {
2312
+ tagOut += body.slice(valStart - 1, i);
2313
+ break;
2314
+ }
2315
+ const value = body.slice(valStart, closeIdx);
2316
+ const hasInternalQuote = value.includes('"');
2317
+ if (hasInternalQuote) {
2318
+ const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2319
+ tagOut += `'${escaped}'`;
2320
+ tagAdjusted = true;
2321
+ } else {
2322
+ tagOut += `"${value}"`;
2323
+ }
2324
+ i = closeIdx + 1;
2325
+ }
2326
+ if (tagAdjusted) adjusted = true;
2327
+ tagOut += body[i] ?? "";
2328
+ out += tagOut;
2329
+ i += 1;
2330
+ }
2331
+ return { body: out, adjusted };
2332
+ }
2333
+
2193
2334
  // src/translate/validate-translation.ts
2194
2335
  init_esm_shims();
2195
2336
  function formatZodIssues(error) {
@@ -2324,6 +2465,9 @@ async function translatePage(config, item, options = {}) {
2324
2465
  if (!validated.ok) {
2325
2466
  throw new Error(`Translation validation failed: ${validated.error}`);
2326
2467
  }
2468
+ const { body: translatedBody, adjusted: mdxAdjusted } = sanitizeMdxJsxAttributeQuotes(
2469
+ result.parsed.body
2470
+ );
2327
2471
  const writeDb = openStore(config, "readwrite");
2328
2472
  const snapshotId = recordEnSnapshot(
2329
2473
  config,
@@ -2342,7 +2486,7 @@ async function translatePage(config, item, options = {}) {
2342
2486
  locale: item.locale,
2343
2487
  slug,
2344
2488
  frontmatter: validated.frontmatter,
2345
- body: result.parsed.body,
2489
+ body: translatedBody,
2346
2490
  enHash: currentEnHash,
2347
2491
  translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2348
2492
  model: result.model,
@@ -2361,7 +2505,8 @@ async function translatePage(config, item, options = {}) {
2361
2505
  usage: result.usage,
2362
2506
  estimatedCostUsd,
2363
2507
  durationMs: Date.now() - startedAt,
2364
- slugAdjusted
2508
+ slugAdjusted,
2509
+ mdxAdjusted: mdxAdjusted || void 0
2365
2510
  };
2366
2511
  } catch (error) {
2367
2512
  return {
@@ -2444,6 +2589,7 @@ function buildStaticRawExports(project, options = {}) {
2444
2589
  const excludeNoindex = options.excludeNoindex ?? false;
2445
2590
  const typeFilter = options.types ? new Set(options.types) : null;
2446
2591
  const out = [];
2592
+ const urlBuilder = createUrlBuilder(config);
2447
2593
  for (const type of project.listTypes()) {
2448
2594
  if (!isRoutableType(type.config)) continue;
2449
2595
  if (typeFilter && !typeFilter.has(type.id)) continue;
@@ -2459,7 +2605,7 @@ function buildStaticRawExports(project, options = {}) {
2459
2605
  if (excludeNoindex && resolved.document.noindex) continue;
2460
2606
  const doc = resolved.document;
2461
2607
  const slugWithExt = `${doc.slug}${extension}`;
2462
- const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2608
+ const urlPath = urlBuilder.resolvePath(pathTemplate, slugWithExt, locale);
2463
2609
  out.push({
2464
2610
  relativePath: urlPath.slice(1),
2465
2611
  urlPath,