scribe-cms 0.0.8 → 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 (50) hide show
  1. package/README.md +5 -5
  2. package/dist/cli/index.cjs +432 -384
  3. package/dist/cli/index.cjs.map +1 -1
  4. package/dist/cli/index.js +432 -384
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/index.cjs +642 -512
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +642 -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
@@ -237,14 +237,14 @@ var SLUG_PLACEHOLDER = "{slug}";
237
237
  function isRoutableType(type) {
238
238
  return typeof type.path === "string" && type.path.length > 0;
239
239
  }
240
- function assertValidPathTemplate(path13, typeId) {
241
- if (!path13.startsWith("/")) {
242
- throw new Error(`Content type "${typeId}": path must start with / (got "${path13}")`);
240
+ function assertValidPathTemplate(path15, typeId) {
241
+ if (!path15.startsWith("/")) {
242
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path15}")`);
243
243
  }
244
- const count = (path13.match(/\{slug\}/g) ?? []).length;
244
+ const count = (path15.match(/\{slug\}/g) ?? []).length;
245
245
  if (count !== 1) {
246
246
  throw new Error(
247
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path13}")`
247
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path15}")`
248
248
  );
249
249
  }
250
250
  }
@@ -258,19 +258,69 @@ function pathSuffix(template) {
258
258
  if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
259
259
  return template.slice(idx + SLUG_PLACEHOLDER.length);
260
260
  }
261
- function resolvePath(template, slug, locale, defaultLocale) {
262
- const relative = template.replace(SLUG_PLACEHOLDER, slug);
263
- if (locale === defaultLocale) return relative;
264
- return `/${locale}${relative}`;
265
- }
266
- function extractSlugFromResolvedPath(template, resolvedPath) {
267
- const prefix = pathPrefix(template);
268
- const suffix = pathSuffix(template);
269
- if (!resolvedPath.startsWith(prefix)) return null;
270
- if (suffix && !resolvedPath.endsWith(suffix)) return null;
271
- const slugEnd = suffix ? resolvedPath.length - suffix.length : resolvedPath.length;
272
- const slug = resolvedPath.slice(prefix.length, slugEnd);
273
- return slug.length > 0 ? slug : null;
261
+ function resolveDefaultLocaleRouting() {
262
+ return { strategy: "path-prefix", prefixDefaultLocale: false };
263
+ }
264
+ function splitPathAndSearch(pathname) {
265
+ const q = pathname.indexOf("?");
266
+ if (q === -1) return { pathname, search: "" };
267
+ return { pathname: pathname.slice(0, q), search: pathname.slice(q) };
268
+ }
269
+ function createUrlBuilder(config) {
270
+ const localeRouting = config.localeRouting ?? resolveDefaultLocaleRouting();
271
+ const defaultLocale = config.defaultLocale;
272
+ const prefixedLocales = localeRouting.strategy === "path-prefix" ? localeRouting.prefixDefaultLocale ? [...config.locales] : config.locales.filter((locale) => locale !== defaultLocale) : config.locales.filter((locale) => locale !== defaultLocale);
273
+ function applyLocaleToPath(pathname, locale) {
274
+ if (locale === defaultLocale) {
275
+ if (localeRouting.strategy === "path-prefix" && localeRouting.prefixDefaultLocale) {
276
+ return `/${defaultLocale}${pathname}`;
277
+ }
278
+ return pathname;
279
+ }
280
+ if (localeRouting.strategy === "search-param") {
281
+ const { pathname: base, search } = splitPathAndSearch(pathname);
282
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
283
+ params.set(localeRouting.param, locale);
284
+ const qs = params.toString();
285
+ return qs ? `${base}?${qs}` : base;
286
+ }
287
+ return `/${locale}${pathname}`;
288
+ }
289
+ function resolvePath(template, slug, locale) {
290
+ const relative = template.replace(SLUG_PLACEHOLDER, slug);
291
+ return applyLocaleToPath(relative, locale);
292
+ }
293
+ function extractSlugFromResolvedPath(template, resolvedPath) {
294
+ let pathname = resolvedPath;
295
+ if (localeRouting.strategy === "search-param") {
296
+ pathname = splitPathAndSearch(resolvedPath).pathname;
297
+ } else if (localeRouting.strategy === "path-prefix") {
298
+ for (const locale of config.locales) {
299
+ if (locale === defaultLocale && !localeRouting.prefixDefaultLocale) continue;
300
+ const prefix2 = `/${locale}`;
301
+ if (pathname === prefix2 || pathname.startsWith(`${prefix2}/`)) {
302
+ pathname = pathname.slice(prefix2.length) || "/";
303
+ break;
304
+ }
305
+ }
306
+ }
307
+ const prefix = pathPrefix(template);
308
+ const suffix = pathSuffix(template);
309
+ if (!pathname.startsWith(prefix)) return null;
310
+ if (suffix && !pathname.endsWith(suffix)) return null;
311
+ const slugEnd = suffix ? pathname.length - suffix.length : pathname.length;
312
+ const slug = pathname.slice(prefix.length, slugEnd);
313
+ return slug.length > 0 ? slug : null;
314
+ }
315
+ return {
316
+ defaultLocale,
317
+ locales: config.locales,
318
+ localeRouting,
319
+ prefixedLocales,
320
+ resolvePath,
321
+ extractSlugFromResolvedPath,
322
+ applyLocaleToPath
323
+ };
274
324
  }
275
325
 
276
326
  // src/config/resolve-config.ts
@@ -293,6 +343,13 @@ function resolveConfig(input, baseDir) {
293
343
  `scribe config: defaultLocale "${defaultLocale}" is not in locales [${raw.locales.join(", ")}]`
294
344
  );
295
345
  }
346
+ const localeRouting = raw.localeRouting ?? {
347
+ strategy: "path-prefix",
348
+ prefixDefaultLocale: false
349
+ };
350
+ if (localeRouting.strategy === "search-param" && !localeRouting.param) {
351
+ throw new Error('scribe config: localeRouting search-param requires a "param" name');
352
+ }
296
353
  const projectRoot = path3__default.default.resolve(baseDir ?? process.cwd(), raw.rootDir);
297
354
  const contentRoot = path3__default.default.resolve(projectRoot, raw.contentDir ?? "content");
298
355
  const storePath = path3__default.default.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
@@ -320,6 +377,7 @@ function resolveConfig(input, baseDir) {
320
377
  assetsPath,
321
378
  locales: [...raw.locales],
322
379
  defaultLocale,
380
+ localeRouting,
323
381
  localePresets: raw.localePresets,
324
382
  translate: raw.translate,
325
383
  types
@@ -374,10 +432,10 @@ function introspectSchema(schema, prefix = []) {
374
432
  }
375
433
  function extractByPaths(data, paths) {
376
434
  const out = {};
377
- for (const path13 of paths) {
378
- const value = getAtPath(data, path13);
435
+ for (const path15 of paths) {
436
+ const value = getAtPath(data, path15);
379
437
  if (value !== void 0) {
380
- setAtPath(out, path13.filter((p) => p !== "*"), value);
438
+ setAtPath(out, path15.filter((p) => p !== "*"), value);
381
439
  }
382
440
  }
383
441
  return out;
@@ -395,13 +453,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
395
453
  const translatable = pickTranslatable(localeData, schema);
396
454
  return deepMerge(structural, translatable);
397
455
  }
398
- function getAtPath(obj, path13) {
456
+ function getAtPath(obj, path15) {
399
457
  let current = obj;
400
- for (const segment of path13) {
458
+ for (const segment of path15) {
401
459
  if (segment === "*") {
402
460
  if (!Array.isArray(current)) return void 0;
403
461
  return current.map(
404
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path13.slice(path13.indexOf("*") + 1)) : void 0
462
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path15.slice(path15.indexOf("*") + 1)) : void 0
405
463
  );
406
464
  }
407
465
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -409,13 +467,13 @@ function getAtPath(obj, path13) {
409
467
  }
410
468
  return current;
411
469
  }
412
- function setAtPath(obj, path13, value) {
413
- if (path13.length === 0) return;
414
- if (path13.length === 1) {
415
- obj[path13[0]] = value;
470
+ function setAtPath(obj, path15, value) {
471
+ if (path15.length === 0) return;
472
+ if (path15.length === 1) {
473
+ obj[path15[0]] = value;
416
474
  return;
417
475
  }
418
- const [head, ...rest] = path13;
476
+ const [head, ...rest] = path15;
419
477
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
420
478
  obj[head] = {};
421
479
  }
@@ -466,45 +524,23 @@ var LOCALE_BUILTIN_KEYS = [
466
524
  ["translationOf", "translationOf is internal; remove from locale translation"],
467
525
  ["enSlug", "enSlug is internal; remove from locale translation"]
468
526
  ];
469
- function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
527
+ var DEPRECATED_REDIRECT_FIELDS = [
528
+ [
529
+ "aliases",
530
+ "aliases frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
531
+ ],
532
+ [
533
+ "redirect_to",
534
+ "redirect_to frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
535
+ ]
536
+ ];
537
+ function extractBuiltinEnFields(data, _pathTemplate, _enSlug, _defaultLocale) {
470
538
  const issues = [];
471
539
  const rest = { ...data };
472
- const aliasesResult = zod.z.array(slugPatternSchema).max(20).optional().default([]).safeParse(rest.aliases ?? []);
473
- delete rest.aliases;
474
- const redirectRaw = rest.redirect_to;
475
- delete rest.redirect_to;
476
- let redirectTo;
477
- if (redirectRaw !== void 0 && redirectRaw !== null && redirectRaw !== "") {
478
- if (typeof redirectRaw !== "string") {
479
- issues.push({
480
- field: "redirect_to",
481
- message: "redirect_to must be a string path",
482
- level: "error"
483
- });
484
- } else if (!pathTemplate) {
485
- issues.push({
486
- field: "redirect_to",
487
- message: "redirect_to is not allowed on reference-only content types",
488
- level: "error"
489
- });
490
- } else if (!extractSlugFromResolvedPath(pathTemplate, redirectRaw)) {
491
- issues.push({
492
- field: "redirect_to",
493
- message: `redirect_to must match path template "${pathTemplate}" (prefix "${pathPrefix(pathTemplate)}"${pathSuffix(pathTemplate) ? `, suffix "${pathSuffix(pathTemplate)}"` : ""})`,
494
- level: "error"
495
- });
496
- } else {
497
- redirectTo = redirectRaw;
498
- }
499
- }
500
- const aliases = aliasesResult.success ? aliasesResult.data : [];
501
- if (!aliasesResult.success) {
502
- for (const issue of aliasesResult.error.issues) {
503
- issues.push({
504
- field: `aliases.${issue.path.join(".")}`,
505
- message: issue.message,
506
- level: "error"
507
- });
540
+ for (const [field2, message] of DEPRECATED_REDIRECT_FIELDS) {
541
+ if (rest[field2] !== void 0) {
542
+ issues.push({ field: field2, message, level: "error" });
543
+ delete rest[field2];
508
544
  }
509
545
  }
510
546
  let publishedAt;
@@ -566,8 +602,6 @@ function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
566
602
  }
567
603
  return {
568
604
  builtin: {
569
- aliases,
570
- redirectTo,
571
605
  publishedAt,
572
606
  updatedAt,
573
607
  noindex,
@@ -586,20 +620,24 @@ function validateLocaleBuiltinFields(data) {
586
620
  }
587
621
  return issues;
588
622
  }
589
- function resolveCanonicalPathname(type, doc, defaultLocale) {
623
+ function resolveCanonicalPathname(type, doc, defaultLocale, localeRouting) {
624
+ const urlBuilder = createUrlBuilder({
625
+ locales: [defaultLocale, ...doc.locale !== defaultLocale ? [doc.locale] : []],
626
+ defaultLocale,
627
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
628
+ });
590
629
  if (doc.canonicalPathOverride) {
591
- if (doc.locale === defaultLocale) return doc.canonicalPathOverride;
592
- return `/${doc.locale}${doc.canonicalPathOverride}`;
630
+ return urlBuilder.applyLocaleToPath(doc.canonicalPathOverride, doc.locale);
593
631
  }
594
632
  if (!type.path) return `/${doc.slug}`;
595
- return resolvePath(type.path, doc.slug, doc.locale, defaultLocale);
633
+ return urlBuilder.resolvePath(type.path, doc.slug, doc.locale);
596
634
  }
597
- function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
635
+ function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale, localeRouting) {
598
636
  const out = { ...frontmatter };
599
637
  if (doc.publishedAt !== void 0) out.publishedAt = doc.publishedAt;
600
638
  if (doc.updatedAt !== void 0) out.updatedAt = doc.updatedAt;
601
639
  out.noindex = doc.noindex;
602
- out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
640
+ out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale, localeRouting);
603
641
  return out;
604
642
  }
605
643
  function seoFieldsFromEn(enDoc) {
@@ -607,8 +645,7 @@ function seoFieldsFromEn(enDoc) {
607
645
  publishedAt: enDoc.publishedAt,
608
646
  updatedAt: enDoc.updatedAt,
609
647
  noindex: enDoc.noindex,
610
- canonicalPathOverride: enDoc.canonicalPathOverride,
611
- redirectTo: enDoc.redirectTo
648
+ canonicalPathOverride: enDoc.canonicalPathOverride
612
649
  };
613
650
  }
614
651
 
@@ -683,11 +720,6 @@ function getTranslation(db, contentType, enSlug, locale) {
683
720
  function listTranslationsForType(db, contentType) {
684
721
  return db.prepare(`SELECT * FROM translations WHERE content_type = ? ORDER BY en_slug, locale`).all(contentType);
685
722
  }
686
- function listTranslationsForEnSlug(db, contentType, enSlug) {
687
- return db.prepare(
688
- `SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
689
- ).all(contentType, enSlug);
690
- }
691
723
  function listTranslationsForLocale(db, contentType, locale) {
692
724
  return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
693
725
  }
@@ -771,7 +803,8 @@ function parseEnMdx(filePath, config, type) {
771
803
  locale: config.defaultLocale
772
804
  },
773
805
  type,
774
- config.defaultLocale
806
+ config.defaultLocale,
807
+ config.localeRouting
775
808
  );
776
809
  const crossIssues = type.crossValidate?.(result.data, {
777
810
  locale: config.defaultLocale,
@@ -785,8 +818,6 @@ function parseEnMdx(filePath, config, type) {
785
818
  slug,
786
819
  enSlug: slug,
787
820
  locale: config.defaultLocale,
788
- aliases: builtin.aliases,
789
- redirectTo: builtin.redirectTo,
790
821
  publishedAt: builtin.publishedAt,
791
822
  updatedAt: builtin.updatedAt,
792
823
  noindex: builtin.noindex,
@@ -797,7 +828,7 @@ function parseEnMdx(filePath, config, type) {
797
828
  };
798
829
  return { document: document2, issues };
799
830
  }
800
- function buildDocumentFromTranslation(row, enDoc, type) {
831
+ function buildDocumentFromTranslation(row, enDoc, type, config) {
801
832
  const localeFm = JSON.parse(row.frontmatter_json);
802
833
  const merged = mergeStructuralOntoLocale(localeFm, enDoc.frontmatter, type.schema);
803
834
  const seo = seoFieldsFromEn(enDoc);
@@ -805,14 +836,13 @@ function buildDocumentFromTranslation(row, enDoc, type) {
805
836
  merged,
806
837
  { ...seo, slug: row.slug, locale: row.locale },
807
838
  type,
808
- enDoc.locale
839
+ config.defaultLocale,
840
+ config.localeRouting
809
841
  );
810
842
  return {
811
843
  slug: row.slug,
812
844
  enSlug: row.en_slug,
813
845
  locale: row.locale,
814
- aliases: [],
815
- redirectTo: seo.redirectTo,
816
846
  publishedAt: seo.publishedAt,
817
847
  updatedAt: seo.updatedAt,
818
848
  noindex: seo.noindex,
@@ -890,7 +920,7 @@ function createContentLoader(config, type) {
890
920
  for (const row of rowsByLocale.get(locale) ?? []) {
891
921
  const enDoc = englishBySlug.get(row.en_slug);
892
922
  if (!enDoc) continue;
893
- const doc = buildDocumentFromTranslation(row, enDoc, type);
923
+ const doc = buildDocumentFromTranslation(row, enDoc, type, config);
894
924
  bySlug.set(doc.slug, doc);
895
925
  byEnSlug.set(row.en_slug, doc);
896
926
  }
@@ -939,56 +969,16 @@ function getTranslatablePayload(doc, type) {
939
969
 
940
970
  // src/i18n/resolve-document.ts
941
971
  init_cjs_shims();
942
- function findEnDocByAlias(slug, allDocs, defaultLocale) {
943
- const enIdx = allDocs.get(defaultLocale);
944
- if (!enIdx) return null;
945
- for (const doc of enIdx.bySlug.values()) {
946
- if (doc.aliases.includes(slug)) return doc;
947
- }
948
- return null;
949
- }
950
- function enCanonicalDoc(doc, allDocs, defaultLocale) {
951
- if (doc.locale === defaultLocale) return doc;
952
- return allDocs.get(defaultLocale)?.bySlug.get(doc.enSlug) ?? doc;
953
- }
954
- function redirectPathForDocument(doc, locale, defaultLocale, allDocs, type) {
955
- if (!type.path) return void 0;
956
- const enDoc = enCanonicalDoc(doc, allDocs, defaultLocale);
957
- if (!enDoc.redirectTo) return void 0;
958
- const targetEnSlug = extractSlugFromResolvedPath(type.path, enDoc.redirectTo);
959
- if (!targetEnSlug) return void 0;
960
- const targetEnDoc = allDocs.get(defaultLocale)?.bySlug.get(targetEnSlug);
961
- const targetSlug = targetEnDoc ? getSlugForLocale(targetEnDoc, defaultLocale, locale, allDocs, defaultLocale) ?? targetEnSlug : targetEnSlug;
962
- return resolvePath(type.path, targetSlug, locale, defaultLocale);
963
- }
964
- function withRedirectTo(result, locale, defaultLocale, allDocs, type) {
965
- if (result.shouldRedirectTo || !result.document) return result;
966
- const redirect = redirectPathForDocument(result.document, locale, defaultLocale, allDocs, type);
967
- if (!redirect) return result;
968
- return {
969
- document: null,
970
- actualLocale: result.actualLocale,
971
- shouldRedirectTo: redirect
972
- };
973
- }
974
- function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
972
+ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type, localeRouting) {
973
+ const urlBuilder = createUrlBuilder({
974
+ locales: [defaultLocale, locale],
975
+ defaultLocale,
976
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
977
+ });
975
978
  const idx = allDocs.get(locale);
976
979
  const direct = idx?.bySlug.get(slug);
977
980
  if (direct) {
978
- return withRedirectTo({ document: direct, actualLocale: locale }, locale, defaultLocale, allDocs, type);
979
- }
980
- const aliasDoc = findEnDocByAlias(slug, allDocs, defaultLocale);
981
- if (aliasDoc && type.path) {
982
- const canonicalSlug = aliasDoc.slug;
983
- const localizedSlug = getSlugForLocale(aliasDoc, defaultLocale, locale, allDocs, defaultLocale);
984
- const targetSlug = localizedSlug ?? canonicalSlug;
985
- if (targetSlug !== slug) {
986
- return {
987
- document: null,
988
- actualLocale: locale,
989
- shouldRedirectTo: resolvePath(type.path, targetSlug, locale, defaultLocale)
990
- };
991
- }
981
+ return { document: direct, actualLocale: locale };
992
982
  }
993
983
  for (const [docLocale, docIdx] of allDocs) {
994
984
  const found = docIdx.bySlug.get(slug);
@@ -1001,17 +991,11 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
1001
991
  return {
1002
992
  document: null,
1003
993
  actualLocale: locale,
1004
- shouldRedirectTo: resolvePath(type.path, correctSlug, locale, defaultLocale)
994
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, correctSlug, locale)
1005
995
  };
1006
996
  }
1007
997
  if (docLocale === defaultLocale) {
1008
- return withRedirectTo(
1009
- { document: found, actualLocale: defaultLocale },
1010
- locale,
1011
- defaultLocale,
1012
- allDocs,
1013
- type
1014
- );
998
+ return { document: found, actualLocale: defaultLocale };
1015
999
  }
1016
1000
  if (!type.path) {
1017
1001
  return { document: null, actualLocale: locale };
@@ -1019,39 +1003,21 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
1019
1003
  return {
1020
1004
  document: null,
1021
1005
  actualLocale: locale,
1022
- shouldRedirectTo: resolvePath(type.path, found.enSlug, locale, defaultLocale)
1006
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, found.enSlug, locale)
1023
1007
  };
1024
1008
  }
1025
1009
  if (locale !== defaultLocale) {
1026
1010
  const enDoc = allDocs.get(defaultLocale)?.bySlug.get(slug);
1027
1011
  if (enDoc && type.indexFallback === "en") {
1028
- return withRedirectTo(
1029
- { document: enDoc, actualLocale: defaultLocale },
1030
- locale,
1031
- defaultLocale,
1032
- allDocs,
1033
- type
1034
- );
1012
+ return { document: enDoc, actualLocale: defaultLocale };
1035
1013
  }
1036
1014
  const byEn = idx?.byEnSlug.get(slug);
1037
1015
  if (byEn) {
1038
- return withRedirectTo(
1039
- { document: byEn, actualLocale: locale },
1040
- locale,
1041
- defaultLocale,
1042
- allDocs,
1043
- type
1044
- );
1016
+ return { document: byEn, actualLocale: locale };
1045
1017
  }
1046
1018
  const fallback = allDocs.get(defaultLocale)?.bySlug.get(slug);
1047
1019
  if (fallback && type.indexFallback === "en") {
1048
- return withRedirectTo(
1049
- { document: fallback, actualLocale: defaultLocale },
1050
- locale,
1051
- defaultLocale,
1052
- allDocs,
1053
- type
1054
- );
1020
+ return { document: fallback, actualLocale: defaultLocale };
1055
1021
  }
1056
1022
  }
1057
1023
  return { document: null, actualLocale: locale };
@@ -1093,6 +1059,7 @@ function buildRuntime(config, type, getRuntime) {
1093
1059
  }
1094
1060
  return type.path;
1095
1061
  }
1062
+ const urlBuilder = createUrlBuilder(config);
1096
1063
  const runtime = {
1097
1064
  id: type.id,
1098
1065
  config: type,
@@ -1108,15 +1075,21 @@ function buildRuntime(config, type, getRuntime) {
1108
1075
  return load().get(locale)?.bySlug.get(slug) ?? null;
1109
1076
  },
1110
1077
  resolve(slug, locale) {
1111
- const result = resolveLocalizedDocument(slug, locale, config.defaultLocale, load(), type);
1078
+ const result = resolveLocalizedDocument(
1079
+ slug,
1080
+ locale,
1081
+ config.defaultLocale,
1082
+ load(),
1083
+ type,
1084
+ config.localeRouting
1085
+ );
1112
1086
  if (result.document && type.path) {
1113
1087
  return {
1114
1088
  ...result,
1115
- canonicalPath: resolvePath(
1089
+ canonicalPath: urlBuilder.resolvePath(
1116
1090
  type.path,
1117
1091
  result.document.slug,
1118
- result.actualLocale,
1119
- config.defaultLocale
1092
+ result.actualLocale
1120
1093
  )
1121
1094
  };
1122
1095
  }
@@ -1140,10 +1113,9 @@ function buildRuntime(config, type, getRuntime) {
1140
1113
  alternates(doc) {
1141
1114
  const pathTemplate = assertRoutable("alternates");
1142
1115
  const out = {
1143
- [config.defaultLocale]: resolvePath(
1116
+ [config.defaultLocale]: urlBuilder.resolvePath(
1144
1117
  pathTemplate,
1145
1118
  doc.enSlug,
1146
- config.defaultLocale,
1147
1119
  config.defaultLocale
1148
1120
  )
1149
1121
  };
@@ -1152,7 +1124,7 @@ function buildRuntime(config, type, getRuntime) {
1152
1124
  if (locale === config.defaultLocale) continue;
1153
1125
  const translated = all.get(locale)?.byEnSlug.get(doc.enSlug);
1154
1126
  if (translated) {
1155
- out[locale] = resolvePath(pathTemplate, translated.slug, locale, config.defaultLocale);
1127
+ out[locale] = urlBuilder.resolvePath(pathTemplate, translated.slug, locale);
1156
1128
  }
1157
1129
  }
1158
1130
  return out;
@@ -1167,7 +1139,7 @@ function buildRuntime(config, type, getRuntime) {
1167
1139
  },
1168
1140
  url(slug, locale) {
1169
1141
  const pathTemplate = assertRoutable("url");
1170
- return resolvePath(pathTemplate, slug, locale, config.defaultLocale);
1142
+ return urlBuilder.resolvePath(pathTemplate, slug, locale);
1171
1143
  },
1172
1144
  related(doc, fieldName, locale) {
1173
1145
  const meta = relationFields.get(fieldName);
@@ -1236,208 +1208,141 @@ init_cjs_shims();
1236
1208
  // src/redirects/build-redirects.ts
1237
1209
  init_cjs_shims();
1238
1210
 
1239
- // src/core/slug-aliases.ts
1240
- init_cjs_shims();
1241
-
1242
1211
  // src/core/alias-helpers.ts
1243
1212
  init_cjs_shims();
1244
1213
  function enFileExists(config, type, enSlug) {
1245
1214
  const dir = path3__default.default.join(config.rootDir, type.contentDir);
1246
1215
  return fs2__default.default.existsSync(path3__default.default.join(dir, `${enSlug}.mdx`)) || fs2__default.default.existsSync(path3__default.default.join(dir, `${enSlug}.md`));
1247
1216
  }
1248
- function sqliteHasTranslations(db, contentTypeId, enSlug) {
1249
- return listTranslationsForEnSlug(db, contentTypeId, enSlug).length > 0;
1250
- }
1251
- function isAliasKnown(config, type, aliasEnSlug, db) {
1252
- return enFileExists(config, type, aliasEnSlug) || sqliteHasTranslations(db, type.id, aliasEnSlug);
1253
- }
1254
1217
  function listEnSlugs(rootDir, contentDir) {
1255
1218
  const dir = path3__default.default.join(rootDir, contentDir);
1256
1219
  if (!fs2__default.default.existsSync(dir)) return [];
1257
1220
  return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1258
1221
  }
1259
1222
 
1260
- // src/core/slug-aliases.ts
1223
+ // src/i18n/translation-index.ts
1224
+ init_cjs_shims();
1225
+
1226
+ // src/redirects/build-redirects.ts
1261
1227
  init_sqlite();
1262
- function buildGlobalAliasIndex(project) {
1263
- const { config } = project;
1264
- const aliasToTarget = /* @__PURE__ */ new Map();
1265
- const allAliasSlugs = /* @__PURE__ */ new Set();
1266
- const issues = [];
1267
- const canonicalSlugsByType = /* @__PURE__ */ new Map();
1268
- for (const type of config.types) {
1269
- const slugs = listEnSlugs(config.rootDir, type.contentDir);
1270
- canonicalSlugsByType.set(type.id, new Set(slugs));
1228
+
1229
+ // src/redirects/build-json-redirects.ts
1230
+ init_cjs_shims();
1231
+ init_sqlite();
1232
+
1233
+ // src/redirects/load-type-redirects.ts
1234
+ init_cjs_shims();
1235
+
1236
+ // src/redirects/redirect-schema.ts
1237
+ init_cjs_shims();
1238
+ var redirectFromSchema = zod.z.union([
1239
+ slugPatternSchema,
1240
+ zod.z.array(slugPatternSchema).min(1).max(20)
1241
+ ]);
1242
+ var typeRedirectEntrySchema = zod.z.object({
1243
+ from: redirectFromSchema,
1244
+ toSlug: slugPatternSchema.optional(),
1245
+ toType: zod.z.string().min(1).optional(),
1246
+ toUrl: zod.z.string().min(1).optional(),
1247
+ permanent: zod.z.boolean().optional()
1248
+ }).superRefine((entry, ctx) => {
1249
+ const hasToSlug = entry.toSlug !== void 0;
1250
+ const hasToUrl = entry.toUrl !== void 0;
1251
+ const hasToType = entry.toType !== void 0;
1252
+ const targetCount = Number(hasToSlug) + Number(hasToUrl);
1253
+ if (targetCount !== 1) {
1254
+ ctx.addIssue({
1255
+ code: "custom",
1256
+ message: "Each redirect must specify exactly one of toSlug/toType+toSlug or toUrl",
1257
+ path: ["toSlug"]
1258
+ });
1259
+ return;
1271
1260
  }
1272
- for (const type of config.types) {
1273
- if (!type.path) continue;
1274
- const canonicalSlugs = canonicalSlugsByType.get(type.id) ?? /* @__PURE__ */ new Set();
1275
- for (const enSlug of canonicalSlugs) {
1276
- const doc = readEnDocument(config, type, enSlug);
1277
- if (!doc) continue;
1278
- for (const alias of doc.aliases) {
1279
- if (alias === enSlug) {
1280
- issues.push({
1281
- contentTypeId: type.id,
1282
- enSlug,
1283
- field: "aliases",
1284
- message: `Alias "${alias}" must not equal the canonical slug`,
1285
- level: "error"
1286
- });
1287
- continue;
1288
- }
1289
- if (canonicalSlugs.has(alias)) {
1290
- issues.push({
1291
- contentTypeId: type.id,
1292
- enSlug,
1293
- field: "aliases",
1294
- message: `Alias "${alias}" collides with another document's canonical slug`,
1295
- level: "error"
1296
- });
1297
- continue;
1298
- }
1299
- const existing = aliasToTarget.get(alias);
1300
- if (existing) {
1301
- issues.push({
1302
- contentTypeId: type.id,
1303
- enSlug,
1304
- field: "aliases",
1305
- message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
1306
- level: "error"
1307
- });
1308
- continue;
1309
- }
1310
- aliasToTarget.set(alias, {
1311
- contentTypeId: type.id,
1312
- canonicalSlug: enSlug,
1313
- path: type.path
1314
- });
1315
- allAliasSlugs.add(alias);
1316
- }
1317
- }
1261
+ if (hasToUrl && hasToType) {
1262
+ ctx.addIssue({
1263
+ code: "custom",
1264
+ message: "toUrl cannot be combined with toType",
1265
+ path: ["toUrl"]
1266
+ });
1267
+ }
1268
+ if (hasToType && !hasToSlug) {
1269
+ ctx.addIssue({
1270
+ code: "custom",
1271
+ message: "Cross-type redirects require both toType and toSlug",
1272
+ path: ["toSlug"]
1273
+ });
1318
1274
  }
1319
- return { aliasToTarget, allAliasSlugs, issues };
1275
+ });
1276
+ var typeRedirectsFileSchema = zod.z.object({
1277
+ redirects: zod.z.array(typeRedirectEntrySchema).max(500)
1278
+ });
1279
+ function normalizeRedirectFrom(from) {
1280
+ return Array.isArray(from) ? from : [from];
1320
1281
  }
1321
- function validateAliasCoverage(project) {
1322
- const { config } = project;
1323
- const { aliasToTarget } = buildGlobalAliasIndex(project);
1324
- const issues = [];
1325
- const db = openStore(config, "readonly");
1326
- try {
1327
- for (const [alias, target] of aliasToTarget) {
1328
- const type = config.types.find((t) => t.id === target.contentTypeId);
1329
- if (!type) continue;
1330
- const known = isAliasKnown(config, type, alias, db);
1331
- if (!known) {
1332
- issues.push({
1333
- contentTypeId: type.id,
1334
- enSlug: target.canonicalSlug,
1335
- field: "aliases",
1336
- message: `Alias "${alias}" is unknown (no EN file or sqlite translations) \u2014 only EN redirect will work`,
1337
- level: "warning"
1338
- });
1339
- }
1340
- if (enFileExists(config, type, alias)) {
1341
- issues.push({
1342
- contentTypeId: type.id,
1343
- enSlug: target.canonicalSlug,
1344
- field: "aliases",
1345
- message: `Alias "${alias}" still has an EN file on disk \u2014 delete it manually`,
1346
- level: "warning"
1347
- });
1348
- }
1349
- const sqliteRows = listTranslationsForEnSlug(db, type.id, alias);
1350
- if (sqliteRows.length > 0) {
1351
- issues.push({
1352
- contentTypeId: type.id,
1353
- enSlug: target.canonicalSlug,
1354
- field: "aliases",
1355
- message: `${sqliteRows.length} sqlite translation(s) for alias "${alias}" retained for locale redirects and history`,
1356
- level: "info"
1357
- });
1358
- }
1359
- for (const locale of config.locales) {
1360
- if (locale === config.defaultLocale) continue;
1361
- const canonicalRow = listTranslationsForEnSlug(db, type.id, target.canonicalSlug).find(
1362
- (r) => r.locale === locale
1363
- );
1364
- if (!canonicalRow) continue;
1365
- const aliasRow = sqliteRows.find((r) => r.locale === locale);
1366
- if (!aliasRow && known) {
1367
- issues.push({
1368
- contentTypeId: type.id,
1369
- enSlug: target.canonicalSlug,
1370
- field: "aliases",
1371
- message: `Alias "${alias}" has no locale slug mapping for ${locale} \u2014 old localized URL may miss redirect`,
1372
- level: "warning"
1373
- });
1374
- }
1375
- }
1376
- }
1377
- } finally {
1378
- db.close();
1282
+ function parseRedirectEntry(entry) {
1283
+ const fromSlugs = normalizeRedirectFrom(entry.from);
1284
+ if (entry.toUrl !== void 0) {
1285
+ return {
1286
+ fromSlugs,
1287
+ kind: "anywhere",
1288
+ toUrl: entry.toUrl,
1289
+ permanent: entry.permanent ?? true
1290
+ };
1379
1291
  }
1380
- return issues;
1292
+ if (entry.toType !== void 0) {
1293
+ return {
1294
+ fromSlugs,
1295
+ kind: "cross-type",
1296
+ toType: entry.toType,
1297
+ toSlug: entry.toSlug,
1298
+ permanent: entry.permanent ?? true
1299
+ };
1300
+ }
1301
+ return {
1302
+ fromSlugs,
1303
+ kind: "same-type",
1304
+ toSlug: entry.toSlug,
1305
+ permanent: entry.permanent ?? true
1306
+ };
1381
1307
  }
1382
- function validateAliasRedirectChains(project) {
1383
- const { config } = project;
1384
- const { aliasToTarget, allAliasSlugs } = buildGlobalAliasIndex(project);
1385
- const issues = [];
1386
- for (const [alias, target] of aliasToTarget) {
1387
- const doc = readEnDocument(
1388
- config,
1389
- project.getType(target.contentTypeId).config,
1390
- target.canonicalSlug
1308
+
1309
+ // src/redirects/load-type-redirects.ts
1310
+ var TYPE_REDIRECTS_FILENAME = "_redirects.json";
1311
+ function redirectsFilePath(config, type) {
1312
+ return path3__default.default.join(config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1313
+ }
1314
+ function loadTypeRedirectsFile(config, type) {
1315
+ const filePath = redirectsFilePath(config, type);
1316
+ if (!fs2__default.default.existsSync(filePath)) return null;
1317
+ let raw;
1318
+ try {
1319
+ raw = JSON.parse(fs2__default.default.readFileSync(filePath, "utf8"));
1320
+ } catch (error) {
1321
+ throw new Error(
1322
+ `${type.id}: failed to parse ${TYPE_REDIRECTS_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
1391
1323
  );
1392
- if (doc?.redirectTo) {
1393
- issues.push({
1394
- contentTypeId: target.contentTypeId,
1395
- enSlug: target.canonicalSlug,
1396
- field: "aliases",
1397
- message: `Alias "${alias}" points at "${target.canonicalSlug}" which has redirect_to \u2014 resolve to the final canonical slug`,
1398
- level: "error"
1399
- });
1400
- }
1401
1324
  }
1325
+ const parsed = typeRedirectsFileSchema.safeParse(raw);
1326
+ if (!parsed.success) {
1327
+ const details = parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ");
1328
+ throw new Error(`${type.id}: invalid ${TYPE_REDIRECTS_FILENAME}: ${details}`);
1329
+ }
1330
+ return {
1331
+ contentTypeId: type.id,
1332
+ contentDir: type.contentDir,
1333
+ filePath,
1334
+ entries: parsed.data.redirects.map(parseRedirectEntry)
1335
+ };
1336
+ }
1337
+ function loadAllTypeRedirects(config) {
1338
+ const out = [];
1402
1339
  for (const type of config.types) {
1403
- if (!type.path) continue;
1404
- for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
1405
- const doc = readEnDocument(config, type, enSlug);
1406
- if (!doc?.redirectTo) continue;
1407
- const targetSlug = extractSlugFromResolvedPath(type.path, doc.redirectTo);
1408
- if (targetSlug && allAliasSlugs.has(targetSlug)) {
1409
- issues.push({
1410
- contentTypeId: type.id,
1411
- enSlug,
1412
- field: "redirect_to",
1413
- message: `redirect_to target "${targetSlug}" is an alias slug \u2014 chain detected`,
1414
- level: "error"
1415
- });
1416
- }
1417
- const targetDoc = targetSlug ? readEnDocument(config, type, targetSlug) : null;
1418
- if (targetDoc?.redirectTo) {
1419
- issues.push({
1420
- contentTypeId: type.id,
1421
- enSlug,
1422
- field: "redirect_to",
1423
- message: `redirect_to chain detected: "${enSlug}" \u2192 "${targetSlug}" \u2192 "${targetDoc.redirectTo}"`,
1424
- level: "error"
1425
- });
1426
- }
1427
- }
1340
+ const loaded = loadTypeRedirectsFile(config, type);
1341
+ if (loaded) out.push(loaded);
1428
1342
  }
1429
- return issues;
1343
+ return out;
1430
1344
  }
1431
1345
 
1432
- // src/redirects/translation-index.ts
1433
- init_cjs_shims();
1434
-
1435
- // src/i18n/translation-index.ts
1436
- init_cjs_shims();
1437
-
1438
- // src/redirects/translation-index.ts
1439
- init_sqlite();
1440
-
1441
1346
  // src/sitemap/join-base-url.ts
1442
1347
  init_cjs_shims();
1443
1348
 
@@ -1489,6 +1394,173 @@ function loadConfigSync(options = {}) {
1489
1394
 
1490
1395
  // src/validate/validate-project.ts
1491
1396
  init_cjs_shims();
1397
+
1398
+ // src/validate/validate-redirects.ts
1399
+ init_cjs_shims();
1400
+ function liveDocExists(config, typeId, enSlug) {
1401
+ const type = config.types.find((entry) => entry.id === typeId);
1402
+ if (!type) return false;
1403
+ return enFileExists(config, type, enSlug);
1404
+ }
1405
+ function resolveRedirectTarget(project, sourceTypeId, entry) {
1406
+ if (entry.kind === "anywhere") return null;
1407
+ if (entry.kind === "cross-type") {
1408
+ return { typeId: entry.toType, enSlug: entry.toSlug };
1409
+ }
1410
+ return { typeId: sourceTypeId, enSlug: entry.toSlug };
1411
+ }
1412
+ function matchRoutableTarget(project, toUrl) {
1413
+ const urlBuilder = createUrlBuilder(project.config);
1414
+ for (const type of project.config.types) {
1415
+ if (!type.path) continue;
1416
+ const enSlug = urlBuilder.extractSlugFromResolvedPath(type.path, toUrl);
1417
+ if (enSlug && liveDocExists(project.config, type.id, enSlug)) {
1418
+ return { typeId: type.id, enSlug };
1419
+ }
1420
+ }
1421
+ return null;
1422
+ }
1423
+ function validateTypeRedirects(project) {
1424
+ const issues = [];
1425
+ const globalFrom = /* @__PURE__ */ new Map();
1426
+ for (const type of project.config.types) {
1427
+ const filePath = path3__default.default.join(project.config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1428
+ if (!fs2__default.default.existsSync(filePath)) continue;
1429
+ let loaded;
1430
+ try {
1431
+ loaded = loadTypeRedirectsFile(project.config, type);
1432
+ } catch (error) {
1433
+ issues.push({
1434
+ level: "error",
1435
+ contentType: type.id,
1436
+ field: TYPE_REDIRECTS_FILENAME,
1437
+ message: error instanceof Error ? error.message : String(error)
1438
+ });
1439
+ continue;
1440
+ }
1441
+ if (!loaded) continue;
1442
+ if (!isRoutableType(type)) {
1443
+ issues.push({
1444
+ level: "error",
1445
+ contentType: type.id,
1446
+ field: TYPE_REDIRECTS_FILENAME,
1447
+ message: `Redirects are only supported on routable content types (missing path)`
1448
+ });
1449
+ continue;
1450
+ }
1451
+ for (const entry of loaded.entries) {
1452
+ for (const from of entry.fromSlugs) {
1453
+ if (from === entry.toSlug) {
1454
+ issues.push({
1455
+ level: "error",
1456
+ contentType: type.id,
1457
+ enSlug: from,
1458
+ field: TYPE_REDIRECTS_FILENAME,
1459
+ message: `Redirect source "${from}" must not equal its target slug`
1460
+ });
1461
+ }
1462
+ if (liveDocExists(project.config, type.id, from)) {
1463
+ issues.push({
1464
+ level: "error",
1465
+ contentType: type.id,
1466
+ enSlug: from,
1467
+ field: TYPE_REDIRECTS_FILENAME,
1468
+ message: `Redirect source "${from}" still has a live EN file \u2014 delete or rename the MDX first`
1469
+ });
1470
+ }
1471
+ const existing = globalFrom.get(from);
1472
+ if (existing && existing.typeId !== type.id) {
1473
+ issues.push({
1474
+ level: "error",
1475
+ contentType: type.id,
1476
+ enSlug: from,
1477
+ field: TYPE_REDIRECTS_FILENAME,
1478
+ message: `Redirect source "${from}" is already claimed by ${existing.typeId}`
1479
+ });
1480
+ } else if (existing) {
1481
+ issues.push({
1482
+ level: "error",
1483
+ contentType: type.id,
1484
+ enSlug: from,
1485
+ field: TYPE_REDIRECTS_FILENAME,
1486
+ message: `Duplicate redirect source "${from}" in ${TYPE_REDIRECTS_FILENAME}`
1487
+ });
1488
+ } else {
1489
+ globalFrom.set(from, { typeId: type.id, filePath: loaded.filePath });
1490
+ }
1491
+ }
1492
+ if (entry.kind === "cross-type") {
1493
+ const targetType = project.config.types.find((candidate) => candidate.id === entry.toType);
1494
+ if (!targetType) {
1495
+ issues.push({
1496
+ level: "error",
1497
+ contentType: type.id,
1498
+ field: TYPE_REDIRECTS_FILENAME,
1499
+ message: `Unknown redirect target type "${entry.toType}"`
1500
+ });
1501
+ continue;
1502
+ }
1503
+ if (!isRoutableType(targetType)) {
1504
+ issues.push({
1505
+ level: "error",
1506
+ contentType: type.id,
1507
+ field: TYPE_REDIRECTS_FILENAME,
1508
+ message: `Redirect target type "${entry.toType}" is not routable (missing path)`
1509
+ });
1510
+ continue;
1511
+ }
1512
+ }
1513
+ const target = resolveRedirectTarget(project, type.id, entry);
1514
+ if (target) {
1515
+ if (!liveDocExists(project.config, target.typeId, target.enSlug)) {
1516
+ issues.push({
1517
+ level: "error",
1518
+ contentType: type.id,
1519
+ field: TYPE_REDIRECTS_FILENAME,
1520
+ message: `Redirect target ${target.typeId}/${target.enSlug} does not exist`
1521
+ });
1522
+ }
1523
+ continue;
1524
+ }
1525
+ if (entry.kind === "anywhere" && entry.toUrl?.startsWith("/")) {
1526
+ const matched = matchRoutableTarget(project, entry.toUrl);
1527
+ if (matched && !liveDocExists(project.config, matched.typeId, matched.enSlug)) {
1528
+ issues.push({
1529
+ level: "error",
1530
+ contentType: type.id,
1531
+ field: TYPE_REDIRECTS_FILENAME,
1532
+ message: `Redirect toUrl "${entry.toUrl}" does not resolve to a live document`
1533
+ });
1534
+ }
1535
+ }
1536
+ }
1537
+ }
1538
+ const allLoaded = loadAllTypeRedirects(project.config);
1539
+ for (const file of allLoaded) {
1540
+ for (const entry of file.entries) {
1541
+ if (entry.kind === "anywhere" || !entry.toSlug) continue;
1542
+ const targetTypeId = entry.kind === "cross-type" ? entry.toType : file.contentTypeId;
1543
+ const targetType = project.config.types.find((type) => type.id === targetTypeId);
1544
+ if (!targetType?.path) continue;
1545
+ for (const from of entry.fromSlugs) {
1546
+ if (globalFrom.get(from)?.typeId !== file.contentTypeId) continue;
1547
+ const chainTarget = globalFrom.get(entry.toSlug);
1548
+ if (chainTarget) {
1549
+ issues.push({
1550
+ level: "error",
1551
+ contentType: file.contentTypeId,
1552
+ enSlug: from,
1553
+ field: TYPE_REDIRECTS_FILENAME,
1554
+ message: `Redirect chain detected: "${from}" \u2192 "${entry.toSlug}" \u2192 existing redirect source`
1555
+ });
1556
+ }
1557
+ }
1558
+ }
1559
+ }
1560
+ return issues;
1561
+ }
1562
+
1563
+ // src/validate/validate-project.ts
1492
1564
  init_sqlite();
1493
1565
 
1494
1566
  // src/validate/validate-relations.ts
@@ -1689,7 +1761,7 @@ function validateTranslationSlugSuffixes(config, db) {
1689
1761
  }
1690
1762
 
1691
1763
  // src/validate/validate-project.ts
1692
- function listEnSlugs2(rootDir, contentDir) {
1764
+ function listEnSlugs3(rootDir, contentDir) {
1693
1765
  const dir = path3__default.default.join(rootDir, contentDir);
1694
1766
  if (!fs2__default.default.existsSync(dir)) return [];
1695
1767
  return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -1707,7 +1779,7 @@ function validateProject(config) {
1707
1779
  }
1708
1780
  const db = openStore(config, "readonly");
1709
1781
  for (const type of config.types) {
1710
- const enSlugs = listEnSlugs2(config.rootDir, type.contentDir);
1782
+ const enSlugs = listEnSlugs3(config.rootDir, type.contentDir);
1711
1783
  const englishSlugs = new Set(enSlugs);
1712
1784
  if (!type.translate && isRoutableType(type)) {
1713
1785
  issues.push({
@@ -1789,33 +1861,8 @@ function validateProject(config) {
1789
1861
  }
1790
1862
  }
1791
1863
  db.close();
1792
- const aliasIndex = buildGlobalAliasIndex(project);
1793
- for (const issue of aliasIndex.issues) {
1794
- issues.push({
1795
- level: issue.level,
1796
- contentType: issue.contentTypeId,
1797
- enSlug: issue.enSlug,
1798
- field: issue.field,
1799
- message: issue.message
1800
- });
1801
- }
1802
- for (const issue of validateAliasRedirectChains(project)) {
1803
- issues.push({
1804
- level: issue.level,
1805
- contentType: issue.contentTypeId,
1806
- enSlug: issue.enSlug,
1807
- field: issue.field,
1808
- message: issue.message
1809
- });
1810
- }
1811
- for (const issue of validateAliasCoverage(project)) {
1812
- issues.push({
1813
- level: issue.level,
1814
- contentType: issue.contentTypeId,
1815
- enSlug: issue.enSlug,
1816
- field: issue.field,
1817
- message: issue.message
1818
- });
1864
+ for (const issue of validateTypeRedirects(project)) {
1865
+ issues.push(issue);
1819
1866
  }
1820
1867
  for (const issue of validateRelations(project)) {
1821
1868
  issues.push({
@@ -1859,7 +1906,7 @@ function computePageEnHash(translatableFrontmatter, body) {
1859
1906
 
1860
1907
  // src/translate/worklist.ts
1861
1908
  init_sqlite();
1862
- function listEnSlugs3(rootDir, contentDir) {
1909
+ function listEnSlugs4(rootDir, contentDir) {
1863
1910
  const dir = path3__default.default.join(rootDir, contentDir);
1864
1911
  if (!fs2__default.default.existsSync(dir)) return [];
1865
1912
  return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -1870,7 +1917,7 @@ function buildWorklist(config, options = {}) {
1870
1917
  const locales = options.locales ?? config.locales.filter((locale) => locale !== config.defaultLocale);
1871
1918
  for (const type of config.types) {
1872
1919
  if (options.contentType && type.id !== options.contentType) continue;
1873
- const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs3(config.rootDir, type.contentDir);
1920
+ const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs4(config.rootDir, type.contentDir);
1874
1921
  for (const enSlug of enSlugs) {
1875
1922
  const enDoc = readEnDocument(config, type, enSlug);
1876
1923
  if (!enDoc) continue;
@@ -2557,6 +2604,7 @@ function buildStaticRawExports(project, options = {}) {
2557
2604
  const excludeNoindex = options.excludeNoindex ?? false;
2558
2605
  const typeFilter = options.types ? new Set(options.types) : null;
2559
2606
  const out = [];
2607
+ const urlBuilder = createUrlBuilder(config);
2560
2608
  for (const type of project.listTypes()) {
2561
2609
  if (!isRoutableType(type.config)) continue;
2562
2610
  if (typeFilter && !typeFilter.has(type.id)) continue;
@@ -2572,7 +2620,7 @@ function buildStaticRawExports(project, options = {}) {
2572
2620
  if (excludeNoindex && resolved.document.noindex) continue;
2573
2621
  const doc = resolved.document;
2574
2622
  const slugWithExt = `${doc.slug}${extension}`;
2575
- const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2623
+ const urlPath = urlBuilder.resolvePath(pathTemplate, slugWithExt, locale);
2576
2624
  out.push({
2577
2625
  relativePath: urlPath.slice(1),
2578
2626
  urlPath,