scribe-cms 0.0.8 → 0.0.10

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 (50) hide show
  1. package/README.md +5 -5
  2. package/dist/cli/index.cjs +433 -384
  3. package/dist/cli/index.cjs.map +1 -1
  4. package/dist/cli/index.js +433 -384
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/index.cjs +643 -512
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +643 -513
  9. package/dist/index.js.map +1 -1
  10. package/dist/runtime.cjs +287 -261
  11. package/dist/runtime.cjs.map +1 -1
  12. package/dist/runtime.js +286 -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 +11 -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/export/build-static-raw-exports.d.ts.map +1 -1
  21. package/dist/src/i18n/build-url.d.ts +16 -3
  22. package/dist/src/i18n/build-url.d.ts.map +1 -1
  23. package/dist/src/i18n/resolve-document.d.ts +3 -2
  24. package/dist/src/i18n/resolve-document.d.ts.map +1 -1
  25. package/dist/src/index.d.ts +3 -2
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/loader/create-loader.d.ts.map +1 -1
  28. package/dist/src/redirects/build-json-redirects.d.ts +6 -0
  29. package/dist/src/redirects/build-json-redirects.d.ts.map +1 -0
  30. package/dist/src/redirects/build-redirects.d.ts +9 -7
  31. package/dist/src/redirects/build-redirects.d.ts.map +1 -1
  32. package/dist/src/redirects/load-type-redirects.d.ts +14 -0
  33. package/dist/src/redirects/load-type-redirects.d.ts.map +1 -0
  34. package/dist/src/redirects/redirect-schema.d.ts +31 -0
  35. package/dist/src/redirects/redirect-schema.d.ts.map +1 -0
  36. package/dist/src/runtime.d.ts +3 -1
  37. package/dist/src/runtime.d.ts.map +1 -1
  38. package/dist/src/sitemap/generate-sitemap.d.ts.map +1 -1
  39. package/dist/src/validate/validate-project.d.ts.map +1 -1
  40. package/dist/src/validate/validate-redirects.d.ts +4 -0
  41. package/dist/src/validate/validate-redirects.d.ts.map +1 -0
  42. package/dist/studio/server.cjs +90 -61
  43. package/dist/studio/server.cjs.map +1 -1
  44. package/dist/studio/server.js +90 -61
  45. package/dist/studio/server.js.map +1 -1
  46. package/package.json +1 -1
  47. package/dist/src/core/slug-aliases.d.ts +0 -27
  48. package/dist/src/core/slug-aliases.d.ts.map +0 -1
  49. package/dist/src/redirects/translation-index.d.ts +0 -4
  50. 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);
@@ -1221,208 +1193,141 @@ init_esm_shims();
1221
1193
  // src/redirects/build-redirects.ts
1222
1194
  init_esm_shims();
1223
1195
 
1224
- // src/core/slug-aliases.ts
1225
- init_esm_shims();
1226
-
1227
1196
  // src/core/alias-helpers.ts
1228
1197
  init_esm_shims();
1229
1198
  function enFileExists(config, type, enSlug) {
1230
1199
  const dir = path4.join(config.rootDir, type.contentDir);
1231
1200
  return fs2.existsSync(path4.join(dir, `${enSlug}.mdx`)) || fs2.existsSync(path4.join(dir, `${enSlug}.md`));
1232
1201
  }
1233
- function sqliteHasTranslations(db, contentTypeId, enSlug) {
1234
- return listTranslationsForEnSlug(db, contentTypeId, enSlug).length > 0;
1235
- }
1236
- function isAliasKnown(config, type, aliasEnSlug, db) {
1237
- return enFileExists(config, type, aliasEnSlug) || sqliteHasTranslations(db, type.id, aliasEnSlug);
1238
- }
1239
1202
  function listEnSlugs(rootDir, contentDir) {
1240
1203
  const dir = path4.join(rootDir, contentDir);
1241
1204
  if (!fs2.existsSync(dir)) return [];
1242
1205
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1243
1206
  }
1244
1207
 
1245
- // src/core/slug-aliases.ts
1208
+ // src/i18n/translation-index.ts
1209
+ init_esm_shims();
1210
+
1211
+ // src/redirects/build-redirects.ts
1246
1212
  init_sqlite();
1247
- function buildGlobalAliasIndex(project) {
1248
- const { config } = project;
1249
- const aliasToTarget = /* @__PURE__ */ new Map();
1250
- const allAliasSlugs = /* @__PURE__ */ new Set();
1251
- const issues = [];
1252
- const canonicalSlugsByType = /* @__PURE__ */ new Map();
1253
- for (const type of config.types) {
1254
- const slugs = listEnSlugs(config.rootDir, type.contentDir);
1255
- 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;
1256
1245
  }
1257
- for (const type of config.types) {
1258
- if (!type.path) continue;
1259
- const canonicalSlugs = canonicalSlugsByType.get(type.id) ?? /* @__PURE__ */ new Set();
1260
- for (const enSlug of canonicalSlugs) {
1261
- const doc = readEnDocument(config, type, enSlug);
1262
- if (!doc) continue;
1263
- for (const alias of doc.aliases) {
1264
- if (alias === enSlug) {
1265
- issues.push({
1266
- contentTypeId: type.id,
1267
- enSlug,
1268
- field: "aliases",
1269
- message: `Alias "${alias}" must not equal the canonical slug`,
1270
- level: "error"
1271
- });
1272
- continue;
1273
- }
1274
- if (canonicalSlugs.has(alias)) {
1275
- issues.push({
1276
- contentTypeId: type.id,
1277
- enSlug,
1278
- field: "aliases",
1279
- message: `Alias "${alias}" collides with another document's canonical slug`,
1280
- level: "error"
1281
- });
1282
- continue;
1283
- }
1284
- const existing = aliasToTarget.get(alias);
1285
- if (existing) {
1286
- issues.push({
1287
- contentTypeId: type.id,
1288
- enSlug,
1289
- field: "aliases",
1290
- message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
1291
- level: "error"
1292
- });
1293
- continue;
1294
- }
1295
- aliasToTarget.set(alias, {
1296
- contentTypeId: type.id,
1297
- canonicalSlug: enSlug,
1298
- path: type.path
1299
- });
1300
- allAliasSlugs.add(alias);
1301
- }
1302
- }
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
+ });
1303
1259
  }
1304
- 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];
1305
1266
  }
1306
- function validateAliasCoverage(project) {
1307
- const { config } = project;
1308
- const { aliasToTarget } = buildGlobalAliasIndex(project);
1309
- const issues = [];
1310
- const db = openStore(config, "readonly");
1311
- try {
1312
- for (const [alias, target] of aliasToTarget) {
1313
- const type = config.types.find((t) => t.id === target.contentTypeId);
1314
- if (!type) continue;
1315
- const known = isAliasKnown(config, type, alias, db);
1316
- if (!known) {
1317
- issues.push({
1318
- contentTypeId: type.id,
1319
- enSlug: target.canonicalSlug,
1320
- field: "aliases",
1321
- message: `Alias "${alias}" is unknown (no EN file or sqlite translations) \u2014 only EN redirect will work`,
1322
- level: "warning"
1323
- });
1324
- }
1325
- if (enFileExists(config, type, alias)) {
1326
- issues.push({
1327
- contentTypeId: type.id,
1328
- enSlug: target.canonicalSlug,
1329
- field: "aliases",
1330
- message: `Alias "${alias}" still has an EN file on disk \u2014 delete it manually`,
1331
- level: "warning"
1332
- });
1333
- }
1334
- const sqliteRows = listTranslationsForEnSlug(db, type.id, alias);
1335
- if (sqliteRows.length > 0) {
1336
- issues.push({
1337
- contentTypeId: type.id,
1338
- enSlug: target.canonicalSlug,
1339
- field: "aliases",
1340
- message: `${sqliteRows.length} sqlite translation(s) for alias "${alias}" retained for locale redirects and history`,
1341
- level: "info"
1342
- });
1343
- }
1344
- for (const locale of config.locales) {
1345
- if (locale === config.defaultLocale) continue;
1346
- const canonicalRow = listTranslationsForEnSlug(db, type.id, target.canonicalSlug).find(
1347
- (r) => r.locale === locale
1348
- );
1349
- if (!canonicalRow) continue;
1350
- const aliasRow = sqliteRows.find((r) => r.locale === locale);
1351
- if (!aliasRow && known) {
1352
- issues.push({
1353
- contentTypeId: type.id,
1354
- enSlug: target.canonicalSlug,
1355
- field: "aliases",
1356
- message: `Alias "${alias}" has no locale slug mapping for ${locale} \u2014 old localized URL may miss redirect`,
1357
- level: "warning"
1358
- });
1359
- }
1360
- }
1361
- }
1362
- } finally {
1363
- 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
+ };
1364
1276
  }
1365
- 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
+ };
1366
1292
  }
1367
- function validateAliasRedirectChains(project) {
1368
- const { config } = project;
1369
- const { aliasToTarget, allAliasSlugs } = buildGlobalAliasIndex(project);
1370
- const issues = [];
1371
- for (const [alias, target] of aliasToTarget) {
1372
- const doc = readEnDocument(
1373
- config,
1374
- project.getType(target.contentTypeId).config,
1375
- 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)}`
1376
1308
  );
1377
- if (doc?.redirectTo) {
1378
- issues.push({
1379
- contentTypeId: target.contentTypeId,
1380
- enSlug: target.canonicalSlug,
1381
- field: "aliases",
1382
- message: `Alias "${alias}" points at "${target.canonicalSlug}" which has redirect_to \u2014 resolve to the final canonical slug`,
1383
- level: "error"
1384
- });
1385
- }
1386
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 = [];
1387
1324
  for (const type of config.types) {
1388
- if (!type.path) continue;
1389
- for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
1390
- const doc = readEnDocument(config, type, enSlug);
1391
- if (!doc?.redirectTo) continue;
1392
- const targetSlug = extractSlugFromResolvedPath(type.path, doc.redirectTo);
1393
- if (targetSlug && allAliasSlugs.has(targetSlug)) {
1394
- issues.push({
1395
- contentTypeId: type.id,
1396
- enSlug,
1397
- field: "redirect_to",
1398
- message: `redirect_to target "${targetSlug}" is an alias slug \u2014 chain detected`,
1399
- level: "error"
1400
- });
1401
- }
1402
- const targetDoc = targetSlug ? readEnDocument(config, type, targetSlug) : null;
1403
- if (targetDoc?.redirectTo) {
1404
- issues.push({
1405
- contentTypeId: type.id,
1406
- enSlug,
1407
- field: "redirect_to",
1408
- message: `redirect_to chain detected: "${enSlug}" \u2192 "${targetSlug}" \u2192 "${targetDoc.redirectTo}"`,
1409
- level: "error"
1410
- });
1411
- }
1412
- }
1325
+ const loaded = loadTypeRedirectsFile(config, type);
1326
+ if (loaded) out.push(loaded);
1413
1327
  }
1414
- return issues;
1328
+ return out;
1415
1329
  }
1416
1330
 
1417
- // src/redirects/translation-index.ts
1418
- init_esm_shims();
1419
-
1420
- // src/i18n/translation-index.ts
1421
- init_esm_shims();
1422
-
1423
- // src/redirects/translation-index.ts
1424
- init_sqlite();
1425
-
1426
1331
  // src/sitemap/join-base-url.ts
1427
1332
  init_esm_shims();
1428
1333
 
@@ -1474,6 +1379,174 @@ function loadConfigSync(options = {}) {
1474
1379
 
1475
1380
  // src/validate/validate-project.ts
1476
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 (entry.kind === "same-type" && 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 targetAsSource = globalFrom.get(entry.toSlug);
1533
+ if (!targetAsSource) continue;
1534
+ const sameSlugCrossType = entry.kind === "cross-type" && entry.fromSlugs.includes(entry.toSlug) && targetAsSource.typeId === file.contentTypeId;
1535
+ if (sameSlugCrossType) continue;
1536
+ issues.push({
1537
+ level: "error",
1538
+ contentType: file.contentTypeId,
1539
+ enSlug: from,
1540
+ field: TYPE_REDIRECTS_FILENAME,
1541
+ message: `Redirect chain detected: "${from}" \u2192 "${entry.toSlug}" \u2192 existing redirect source`
1542
+ });
1543
+ }
1544
+ }
1545
+ }
1546
+ return issues;
1547
+ }
1548
+
1549
+ // src/validate/validate-project.ts
1477
1550
  init_sqlite();
1478
1551
 
1479
1552
  // src/validate/validate-relations.ts
@@ -1674,7 +1747,7 @@ function validateTranslationSlugSuffixes(config, db) {
1674
1747
  }
1675
1748
 
1676
1749
  // src/validate/validate-project.ts
1677
- function listEnSlugs2(rootDir, contentDir) {
1750
+ function listEnSlugs3(rootDir, contentDir) {
1678
1751
  const dir = path4.join(rootDir, contentDir);
1679
1752
  if (!fs2.existsSync(dir)) return [];
1680
1753
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -1692,7 +1765,7 @@ function validateProject(config) {
1692
1765
  }
1693
1766
  const db = openStore(config, "readonly");
1694
1767
  for (const type of config.types) {
1695
- const enSlugs = listEnSlugs2(config.rootDir, type.contentDir);
1768
+ const enSlugs = listEnSlugs3(config.rootDir, type.contentDir);
1696
1769
  const englishSlugs = new Set(enSlugs);
1697
1770
  if (!type.translate && isRoutableType(type)) {
1698
1771
  issues.push({
@@ -1774,33 +1847,8 @@ function validateProject(config) {
1774
1847
  }
1775
1848
  }
1776
1849
  db.close();
1777
- const aliasIndex = buildGlobalAliasIndex(project);
1778
- for (const issue of aliasIndex.issues) {
1779
- issues.push({
1780
- level: issue.level,
1781
- contentType: issue.contentTypeId,
1782
- enSlug: issue.enSlug,
1783
- field: issue.field,
1784
- message: issue.message
1785
- });
1786
- }
1787
- for (const issue of validateAliasRedirectChains(project)) {
1788
- issues.push({
1789
- level: issue.level,
1790
- contentType: issue.contentTypeId,
1791
- enSlug: issue.enSlug,
1792
- field: issue.field,
1793
- message: issue.message
1794
- });
1795
- }
1796
- for (const issue of validateAliasCoverage(project)) {
1797
- issues.push({
1798
- level: issue.level,
1799
- contentType: issue.contentTypeId,
1800
- enSlug: issue.enSlug,
1801
- field: issue.field,
1802
- message: issue.message
1803
- });
1850
+ for (const issue of validateTypeRedirects(project)) {
1851
+ issues.push(issue);
1804
1852
  }
1805
1853
  for (const issue of validateRelations(project)) {
1806
1854
  issues.push({
@@ -1844,7 +1892,7 @@ function computePageEnHash(translatableFrontmatter, body) {
1844
1892
 
1845
1893
  // src/translate/worklist.ts
1846
1894
  init_sqlite();
1847
- function listEnSlugs3(rootDir, contentDir) {
1895
+ function listEnSlugs4(rootDir, contentDir) {
1848
1896
  const dir = path4.join(rootDir, contentDir);
1849
1897
  if (!fs2.existsSync(dir)) return [];
1850
1898
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -1855,7 +1903,7 @@ function buildWorklist(config, options = {}) {
1855
1903
  const locales = options.locales ?? config.locales.filter((locale) => locale !== config.defaultLocale);
1856
1904
  for (const type of config.types) {
1857
1905
  if (options.contentType && type.id !== options.contentType) continue;
1858
- const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs3(config.rootDir, type.contentDir);
1906
+ const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs4(config.rootDir, type.contentDir);
1859
1907
  for (const enSlug of enSlugs) {
1860
1908
  const enDoc = readEnDocument(config, type, enSlug);
1861
1909
  if (!enDoc) continue;
@@ -2542,6 +2590,7 @@ function buildStaticRawExports(project, options = {}) {
2542
2590
  const excludeNoindex = options.excludeNoindex ?? false;
2543
2591
  const typeFilter = options.types ? new Set(options.types) : null;
2544
2592
  const out = [];
2593
+ const urlBuilder = createUrlBuilder(config);
2545
2594
  for (const type of project.listTypes()) {
2546
2595
  if (!isRoutableType(type.config)) continue;
2547
2596
  if (typeFilter && !typeFilter.has(type.id)) continue;
@@ -2557,7 +2606,7 @@ function buildStaticRawExports(project, options = {}) {
2557
2606
  if (excludeNoindex && resolved.document.noindex) continue;
2558
2607
  const doc = resolved.document;
2559
2608
  const slugWithExt = `${doc.slug}${extension}`;
2560
- const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2609
+ const urlPath = urlBuilder.resolvePath(pathTemplate, slugWithExt, locale);
2561
2610
  out.push({
2562
2611
  relativePath: urlPath.slice(1),
2563
2612
  urlPath,