scribe-cms 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +5 -5
  2. package/dist/cli/index.cjs +534 -388
  3. package/dist/cli/index.cjs.map +1 -1
  4. package/dist/cli/index.js +534 -388
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/index.cjs +745 -516
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +744 -517
  9. package/dist/index.js.map +1 -1
  10. package/dist/runtime.cjs +292 -261
  11. package/dist/runtime.cjs.map +1 -1
  12. package/dist/runtime.js +290 -261
  13. package/dist/runtime.js.map +1 -1
  14. package/dist/src/config/resolve-config.d.ts.map +1 -1
  15. package/dist/src/core/builtin-fields.d.ts +8 -8
  16. package/dist/src/core/builtin-fields.d.ts.map +1 -1
  17. package/dist/src/core/types.d.ts +14 -4
  18. package/dist/src/core/types.d.ts.map +1 -1
  19. package/dist/src/create-project.d.ts.map +1 -1
  20. package/dist/src/create-scribe.d.ts.map +1 -1
  21. package/dist/src/export/build-static-raw-exports.d.ts.map +1 -1
  22. package/dist/src/i18n/build-url.d.ts +16 -3
  23. package/dist/src/i18n/build-url.d.ts.map +1 -1
  24. package/dist/src/i18n/resolve-document.d.ts +3 -2
  25. package/dist/src/i18n/resolve-document.d.ts.map +1 -1
  26. package/dist/src/index.d.ts +3 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/loader/create-loader.d.ts.map +1 -1
  29. package/dist/src/redirects/build-json-redirects.d.ts +6 -0
  30. package/dist/src/redirects/build-json-redirects.d.ts.map +1 -0
  31. package/dist/src/redirects/build-redirects.d.ts +9 -7
  32. package/dist/src/redirects/build-redirects.d.ts.map +1 -1
  33. package/dist/src/redirects/load-type-redirects.d.ts +14 -0
  34. package/dist/src/redirects/load-type-redirects.d.ts.map +1 -0
  35. package/dist/src/redirects/redirect-schema.d.ts +31 -0
  36. package/dist/src/redirects/redirect-schema.d.ts.map +1 -0
  37. package/dist/src/runtime.d.ts +3 -0
  38. package/dist/src/runtime.d.ts.map +1 -1
  39. package/dist/src/sitemap/generate-sitemap.d.ts.map +1 -1
  40. package/dist/src/translate/page-translator.d.ts +1 -0
  41. package/dist/src/translate/page-translator.d.ts.map +1 -1
  42. package/dist/src/translate/resolve-translate-config.d.ts.map +1 -1
  43. package/dist/src/translate/sanitize-mdx-jsx.d.ts +9 -0
  44. package/dist/src/translate/sanitize-mdx-jsx.d.ts.map +1 -0
  45. package/dist/src/validate/validate-project.d.ts.map +1 -1
  46. package/dist/src/validate/validate-redirects.d.ts +4 -0
  47. package/dist/src/validate/validate-redirects.d.ts.map +1 -0
  48. package/dist/studio/server.cjs +90 -61
  49. package/dist/studio/server.cjs.map +1 -1
  50. package/dist/studio/server.js +90 -61
  51. package/dist/studio/server.js.map +1 -1
  52. package/package.json +1 -1
  53. package/dist/src/core/slug-aliases.d.ts +0 -27
  54. package/dist/src/core/slug-aliases.d.ts.map +0 -1
  55. package/dist/src/redirects/translation-index.d.ts +0 -4
  56. package/dist/src/redirects/translation-index.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -128,14 +128,14 @@ var SLUG_PLACEHOLDER = "{slug}";
128
128
  function isRoutableType(type) {
129
129
  return typeof type.path === "string" && type.path.length > 0;
130
130
  }
131
- function assertValidPathTemplate(path11, typeId) {
132
- if (!path11.startsWith("/")) {
133
- throw new Error(`Content type "${typeId}": path must start with / (got "${path11}")`);
131
+ function assertValidPathTemplate(path13, typeId) {
132
+ if (!path13.startsWith("/")) {
133
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path13}")`);
134
134
  }
135
- const count = (path11.match(/\{slug\}/g) ?? []).length;
135
+ const count = (path13.match(/\{slug\}/g) ?? []).length;
136
136
  if (count !== 1) {
137
137
  throw new Error(
138
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path11}")`
138
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path13}")`
139
139
  );
140
140
  }
141
141
  }
@@ -149,19 +149,69 @@ function pathSuffix(template) {
149
149
  if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
150
150
  return template.slice(idx + SLUG_PLACEHOLDER.length);
151
151
  }
152
- function resolvePath(template, slug, locale, defaultLocale) {
153
- const relative = template.replace(SLUG_PLACEHOLDER, slug);
154
- if (locale === defaultLocale) return relative;
155
- return `/${locale}${relative}`;
156
- }
157
- function extractSlugFromResolvedPath(template, resolvedPath) {
158
- const prefix = pathPrefix(template);
159
- const suffix = pathSuffix(template);
160
- if (!resolvedPath.startsWith(prefix)) return null;
161
- if (suffix && !resolvedPath.endsWith(suffix)) return null;
162
- const slugEnd = suffix ? resolvedPath.length - suffix.length : resolvedPath.length;
163
- const slug = resolvedPath.slice(prefix.length, slugEnd);
164
- return slug.length > 0 ? slug : null;
152
+ function resolveDefaultLocaleRouting() {
153
+ return { strategy: "path-prefix", prefixDefaultLocale: false };
154
+ }
155
+ function splitPathAndSearch(pathname) {
156
+ const q = pathname.indexOf("?");
157
+ if (q === -1) return { pathname, search: "" };
158
+ return { pathname: pathname.slice(0, q), search: pathname.slice(q) };
159
+ }
160
+ function createUrlBuilder(config) {
161
+ const localeRouting = config.localeRouting ?? resolveDefaultLocaleRouting();
162
+ const defaultLocale = config.defaultLocale;
163
+ const prefixedLocales = localeRouting.strategy === "path-prefix" ? localeRouting.prefixDefaultLocale ? [...config.locales] : config.locales.filter((locale) => locale !== defaultLocale) : config.locales.filter((locale) => locale !== defaultLocale);
164
+ function applyLocaleToPath(pathname, locale) {
165
+ if (locale === defaultLocale) {
166
+ if (localeRouting.strategy === "path-prefix" && localeRouting.prefixDefaultLocale) {
167
+ return `/${defaultLocale}${pathname}`;
168
+ }
169
+ return pathname;
170
+ }
171
+ if (localeRouting.strategy === "search-param") {
172
+ const { pathname: base, search } = splitPathAndSearch(pathname);
173
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
174
+ params.set(localeRouting.param, locale);
175
+ const qs = params.toString();
176
+ return qs ? `${base}?${qs}` : base;
177
+ }
178
+ return `/${locale}${pathname}`;
179
+ }
180
+ function resolvePath(template, slug, locale) {
181
+ const relative = template.replace(SLUG_PLACEHOLDER, slug);
182
+ return applyLocaleToPath(relative, locale);
183
+ }
184
+ function extractSlugFromResolvedPath(template, resolvedPath) {
185
+ let pathname = resolvedPath;
186
+ if (localeRouting.strategy === "search-param") {
187
+ pathname = splitPathAndSearch(resolvedPath).pathname;
188
+ } else if (localeRouting.strategy === "path-prefix") {
189
+ for (const locale of config.locales) {
190
+ if (locale === defaultLocale && !localeRouting.prefixDefaultLocale) continue;
191
+ const prefix2 = `/${locale}`;
192
+ if (pathname === prefix2 || pathname.startsWith(`${prefix2}/`)) {
193
+ pathname = pathname.slice(prefix2.length) || "/";
194
+ break;
195
+ }
196
+ }
197
+ }
198
+ const prefix = pathPrefix(template);
199
+ const suffix = pathSuffix(template);
200
+ if (!pathname.startsWith(prefix)) return null;
201
+ if (suffix && !pathname.endsWith(suffix)) return null;
202
+ const slugEnd = suffix ? pathname.length - suffix.length : pathname.length;
203
+ const slug = pathname.slice(prefix.length, slugEnd);
204
+ return slug.length > 0 ? slug : null;
205
+ }
206
+ return {
207
+ defaultLocale,
208
+ locales: config.locales,
209
+ localeRouting,
210
+ prefixedLocales,
211
+ resolvePath,
212
+ extractSlugFromResolvedPath,
213
+ applyLocaleToPath
214
+ };
165
215
  }
166
216
 
167
217
  // src/config/resolve-config.ts
@@ -184,6 +234,13 @@ function resolveConfig(input, baseDir) {
184
234
  `scribe config: defaultLocale "${defaultLocale}" is not in locales [${raw.locales.join(", ")}]`
185
235
  );
186
236
  }
237
+ const localeRouting = raw.localeRouting ?? {
238
+ strategy: "path-prefix",
239
+ prefixDefaultLocale: false
240
+ };
241
+ if (localeRouting.strategy === "search-param" && !localeRouting.param) {
242
+ throw new Error('scribe config: localeRouting search-param requires a "param" name');
243
+ }
187
244
  const projectRoot = path3.resolve(baseDir ?? process.cwd(), raw.rootDir);
188
245
  const contentRoot = path3.resolve(projectRoot, raw.contentDir ?? "content");
189
246
  const storePath = path3.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
@@ -211,6 +268,7 @@ function resolveConfig(input, baseDir) {
211
268
  assetsPath,
212
269
  locales: [...raw.locales],
213
270
  defaultLocale,
271
+ localeRouting,
214
272
  localePresets: raw.localePresets,
215
273
  translate: raw.translate,
216
274
  types
@@ -261,10 +319,10 @@ function introspectSchema(schema, prefix = []) {
261
319
  }
262
320
  function extractByPaths(data, paths) {
263
321
  const out = {};
264
- for (const path11 of paths) {
265
- const value = getAtPath(data, path11);
322
+ for (const path13 of paths) {
323
+ const value = getAtPath(data, path13);
266
324
  if (value !== void 0) {
267
- setAtPath(out, path11.filter((p) => p !== "*"), value);
325
+ setAtPath(out, path13.filter((p) => p !== "*"), value);
268
326
  }
269
327
  }
270
328
  return out;
@@ -282,13 +340,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
282
340
  const translatable = pickTranslatable(localeData, schema);
283
341
  return deepMerge(structural, translatable);
284
342
  }
285
- function getAtPath(obj, path11) {
343
+ function getAtPath(obj, path13) {
286
344
  let current = obj;
287
- for (const segment of path11) {
345
+ for (const segment of path13) {
288
346
  if (segment === "*") {
289
347
  if (!Array.isArray(current)) return void 0;
290
348
  return current.map(
291
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path11.slice(path11.indexOf("*") + 1)) : void 0
349
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path13.slice(path13.indexOf("*") + 1)) : void 0
292
350
  );
293
351
  }
294
352
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -296,13 +354,13 @@ function getAtPath(obj, path11) {
296
354
  }
297
355
  return current;
298
356
  }
299
- function setAtPath(obj, path11, value) {
300
- if (path11.length === 0) return;
301
- if (path11.length === 1) {
302
- obj[path11[0]] = value;
357
+ function setAtPath(obj, path13, value) {
358
+ if (path13.length === 0) return;
359
+ if (path13.length === 1) {
360
+ obj[path13[0]] = value;
303
361
  return;
304
362
  }
305
- const [head, ...rest] = path11;
363
+ const [head, ...rest] = path13;
306
364
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
307
365
  obj[head] = {};
308
366
  }
@@ -347,45 +405,23 @@ var LOCALE_BUILTIN_KEYS = [
347
405
  ["translationOf", "translationOf is internal; remove from locale translation"],
348
406
  ["enSlug", "enSlug is internal; remove from locale translation"]
349
407
  ];
350
- function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
408
+ var DEPRECATED_REDIRECT_FIELDS = [
409
+ [
410
+ "aliases",
411
+ "aliases frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
412
+ ],
413
+ [
414
+ "redirect_to",
415
+ "redirect_to frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
416
+ ]
417
+ ];
418
+ function extractBuiltinEnFields(data, _pathTemplate, _enSlug, _defaultLocale) {
351
419
  const issues = [];
352
420
  const rest = { ...data };
353
- const aliasesResult = z.array(slugPatternSchema).max(20).optional().default([]).safeParse(rest.aliases ?? []);
354
- delete rest.aliases;
355
- const redirectRaw = rest.redirect_to;
356
- delete rest.redirect_to;
357
- let redirectTo;
358
- if (redirectRaw !== void 0 && redirectRaw !== null && redirectRaw !== "") {
359
- if (typeof redirectRaw !== "string") {
360
- issues.push({
361
- field: "redirect_to",
362
- message: "redirect_to must be a string path",
363
- level: "error"
364
- });
365
- } else if (!pathTemplate) {
366
- issues.push({
367
- field: "redirect_to",
368
- message: "redirect_to is not allowed on reference-only content types",
369
- level: "error"
370
- });
371
- } else if (!extractSlugFromResolvedPath(pathTemplate, redirectRaw)) {
372
- issues.push({
373
- field: "redirect_to",
374
- message: `redirect_to must match path template "${pathTemplate}" (prefix "${pathPrefix(pathTemplate)}"${pathSuffix(pathTemplate) ? `, suffix "${pathSuffix(pathTemplate)}"` : ""})`,
375
- level: "error"
376
- });
377
- } else {
378
- redirectTo = redirectRaw;
379
- }
380
- }
381
- const aliases = aliasesResult.success ? aliasesResult.data : [];
382
- if (!aliasesResult.success) {
383
- for (const issue of aliasesResult.error.issues) {
384
- issues.push({
385
- field: `aliases.${issue.path.join(".")}`,
386
- message: issue.message,
387
- level: "error"
388
- });
421
+ for (const [field2, message] of DEPRECATED_REDIRECT_FIELDS) {
422
+ if (rest[field2] !== void 0) {
423
+ issues.push({ field: field2, message, level: "error" });
424
+ delete rest[field2];
389
425
  }
390
426
  }
391
427
  let publishedAt;
@@ -447,8 +483,6 @@ function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
447
483
  }
448
484
  return {
449
485
  builtin: {
450
- aliases,
451
- redirectTo,
452
486
  publishedAt,
453
487
  updatedAt,
454
488
  noindex,
@@ -473,20 +507,24 @@ function documentLastModified(doc) {
473
507
  const date = new Date(raw);
474
508
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
475
509
  }
476
- function resolveCanonicalPathname(type, doc, defaultLocale) {
510
+ function resolveCanonicalPathname(type, doc, defaultLocale, localeRouting) {
511
+ const urlBuilder = createUrlBuilder({
512
+ locales: [defaultLocale, ...doc.locale !== defaultLocale ? [doc.locale] : []],
513
+ defaultLocale,
514
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
515
+ });
477
516
  if (doc.canonicalPathOverride) {
478
- if (doc.locale === defaultLocale) return doc.canonicalPathOverride;
479
- return `/${doc.locale}${doc.canonicalPathOverride}`;
517
+ return urlBuilder.applyLocaleToPath(doc.canonicalPathOverride, doc.locale);
480
518
  }
481
519
  if (!type.path) return `/${doc.slug}`;
482
- return resolvePath(type.path, doc.slug, doc.locale, defaultLocale);
520
+ return urlBuilder.resolvePath(type.path, doc.slug, doc.locale);
483
521
  }
484
- function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
522
+ function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale, localeRouting) {
485
523
  const out = { ...frontmatter };
486
524
  if (doc.publishedAt !== void 0) out.publishedAt = doc.publishedAt;
487
525
  if (doc.updatedAt !== void 0) out.updatedAt = doc.updatedAt;
488
526
  out.noindex = doc.noindex;
489
- out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
527
+ out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale, localeRouting);
490
528
  return out;
491
529
  }
492
530
  function seoFieldsFromEn(enDoc) {
@@ -494,8 +532,7 @@ function seoFieldsFromEn(enDoc) {
494
532
  publishedAt: enDoc.publishedAt,
495
533
  updatedAt: enDoc.updatedAt,
496
534
  noindex: enDoc.noindex,
497
- canonicalPathOverride: enDoc.canonicalPathOverride,
498
- redirectTo: enDoc.redirectTo
535
+ canonicalPathOverride: enDoc.canonicalPathOverride
499
536
  };
500
537
  }
501
538
  var SCHEMA_VERSION = 4;
@@ -725,7 +762,8 @@ function parseEnMdx(filePath, config, type) {
725
762
  locale: config.defaultLocale
726
763
  },
727
764
  type,
728
- config.defaultLocale
765
+ config.defaultLocale,
766
+ config.localeRouting
729
767
  );
730
768
  const crossIssues = type.crossValidate?.(result.data, {
731
769
  locale: config.defaultLocale,
@@ -739,8 +777,6 @@ function parseEnMdx(filePath, config, type) {
739
777
  slug,
740
778
  enSlug: slug,
741
779
  locale: config.defaultLocale,
742
- aliases: builtin.aliases,
743
- redirectTo: builtin.redirectTo,
744
780
  publishedAt: builtin.publishedAt,
745
781
  updatedAt: builtin.updatedAt,
746
782
  noindex: builtin.noindex,
@@ -751,7 +787,7 @@ function parseEnMdx(filePath, config, type) {
751
787
  };
752
788
  return { document, issues };
753
789
  }
754
- function buildDocumentFromTranslation(row, enDoc, type) {
790
+ function buildDocumentFromTranslation(row, enDoc, type, config) {
755
791
  const localeFm = JSON.parse(row.frontmatter_json);
756
792
  const merged = mergeStructuralOntoLocale(localeFm, enDoc.frontmatter, type.schema);
757
793
  const seo = seoFieldsFromEn(enDoc);
@@ -759,14 +795,13 @@ function buildDocumentFromTranslation(row, enDoc, type) {
759
795
  merged,
760
796
  { ...seo, slug: row.slug, locale: row.locale },
761
797
  type,
762
- enDoc.locale
798
+ config.defaultLocale,
799
+ config.localeRouting
763
800
  );
764
801
  return {
765
802
  slug: row.slug,
766
803
  enSlug: row.en_slug,
767
804
  locale: row.locale,
768
- aliases: [],
769
- redirectTo: seo.redirectTo,
770
805
  publishedAt: seo.publishedAt,
771
806
  updatedAt: seo.updatedAt,
772
807
  noindex: seo.noindex,
@@ -844,7 +879,7 @@ function createContentLoader(config, type) {
844
879
  for (const row of rowsByLocale.get(locale) ?? []) {
845
880
  const enDoc = englishBySlug.get(row.en_slug);
846
881
  if (!enDoc) continue;
847
- const doc = buildDocumentFromTranslation(row, enDoc, type);
882
+ const doc = buildDocumentFromTranslation(row, enDoc, type, config);
848
883
  bySlug.set(doc.slug, doc);
849
884
  byEnSlug.set(row.en_slug, doc);
850
885
  }
@@ -892,56 +927,16 @@ function getTranslatablePayload(doc, type) {
892
927
  }
893
928
 
894
929
  // src/i18n/resolve-document.ts
895
- function findEnDocByAlias(slug, allDocs, defaultLocale) {
896
- const enIdx = allDocs.get(defaultLocale);
897
- if (!enIdx) return null;
898
- for (const doc of enIdx.bySlug.values()) {
899
- if (doc.aliases.includes(slug)) return doc;
900
- }
901
- return null;
902
- }
903
- function enCanonicalDoc(doc, allDocs, defaultLocale) {
904
- if (doc.locale === defaultLocale) return doc;
905
- return allDocs.get(defaultLocale)?.bySlug.get(doc.enSlug) ?? doc;
906
- }
907
- function redirectPathForDocument(doc, locale, defaultLocale, allDocs, type) {
908
- if (!type.path) return void 0;
909
- const enDoc = enCanonicalDoc(doc, allDocs, defaultLocale);
910
- if (!enDoc.redirectTo) return void 0;
911
- const targetEnSlug = extractSlugFromResolvedPath(type.path, enDoc.redirectTo);
912
- if (!targetEnSlug) return void 0;
913
- const targetEnDoc = allDocs.get(defaultLocale)?.bySlug.get(targetEnSlug);
914
- const targetSlug = targetEnDoc ? getSlugForLocale(targetEnDoc, defaultLocale, locale, allDocs, defaultLocale) ?? targetEnSlug : targetEnSlug;
915
- return resolvePath(type.path, targetSlug, locale, defaultLocale);
916
- }
917
- function withRedirectTo(result, locale, defaultLocale, allDocs, type) {
918
- if (result.shouldRedirectTo || !result.document) return result;
919
- const redirect = redirectPathForDocument(result.document, locale, defaultLocale, allDocs, type);
920
- if (!redirect) return result;
921
- return {
922
- document: null,
923
- actualLocale: result.actualLocale,
924
- shouldRedirectTo: redirect
925
- };
926
- }
927
- function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
930
+ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type, localeRouting) {
931
+ const urlBuilder = createUrlBuilder({
932
+ locales: [defaultLocale, locale],
933
+ defaultLocale,
934
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
935
+ });
928
936
  const idx = allDocs.get(locale);
929
937
  const direct = idx?.bySlug.get(slug);
930
938
  if (direct) {
931
- return withRedirectTo({ document: direct, actualLocale: locale }, locale, defaultLocale, allDocs, type);
932
- }
933
- const aliasDoc = findEnDocByAlias(slug, allDocs, defaultLocale);
934
- if (aliasDoc && type.path) {
935
- const canonicalSlug = aliasDoc.slug;
936
- const localizedSlug = getSlugForLocale(aliasDoc, defaultLocale, locale, allDocs, defaultLocale);
937
- const targetSlug = localizedSlug ?? canonicalSlug;
938
- if (targetSlug !== slug) {
939
- return {
940
- document: null,
941
- actualLocale: locale,
942
- shouldRedirectTo: resolvePath(type.path, targetSlug, locale, defaultLocale)
943
- };
944
- }
939
+ return { document: direct, actualLocale: locale };
945
940
  }
946
941
  for (const [docLocale, docIdx] of allDocs) {
947
942
  const found = docIdx.bySlug.get(slug);
@@ -954,17 +949,11 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
954
949
  return {
955
950
  document: null,
956
951
  actualLocale: locale,
957
- shouldRedirectTo: resolvePath(type.path, correctSlug, locale, defaultLocale)
952
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, correctSlug, locale)
958
953
  };
959
954
  }
960
955
  if (docLocale === defaultLocale) {
961
- return withRedirectTo(
962
- { document: found, actualLocale: defaultLocale },
963
- locale,
964
- defaultLocale,
965
- allDocs,
966
- type
967
- );
956
+ return { document: found, actualLocale: defaultLocale };
968
957
  }
969
958
  if (!type.path) {
970
959
  return { document: null, actualLocale: locale };
@@ -972,39 +961,21 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
972
961
  return {
973
962
  document: null,
974
963
  actualLocale: locale,
975
- shouldRedirectTo: resolvePath(type.path, found.enSlug, locale, defaultLocale)
964
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, found.enSlug, locale)
976
965
  };
977
966
  }
978
967
  if (locale !== defaultLocale) {
979
968
  const enDoc = allDocs.get(defaultLocale)?.bySlug.get(slug);
980
969
  if (enDoc && type.indexFallback === "en") {
981
- return withRedirectTo(
982
- { document: enDoc, actualLocale: defaultLocale },
983
- locale,
984
- defaultLocale,
985
- allDocs,
986
- type
987
- );
970
+ return { document: enDoc, actualLocale: defaultLocale };
988
971
  }
989
972
  const byEn = idx?.byEnSlug.get(slug);
990
973
  if (byEn) {
991
- return withRedirectTo(
992
- { document: byEn, actualLocale: locale },
993
- locale,
994
- defaultLocale,
995
- allDocs,
996
- type
997
- );
974
+ return { document: byEn, actualLocale: locale };
998
975
  }
999
976
  const fallback = allDocs.get(defaultLocale)?.bySlug.get(slug);
1000
977
  if (fallback && type.indexFallback === "en") {
1001
- return withRedirectTo(
1002
- { document: fallback, actualLocale: defaultLocale },
1003
- locale,
1004
- defaultLocale,
1005
- allDocs,
1006
- type
1007
- );
978
+ return { document: fallback, actualLocale: defaultLocale };
1008
979
  }
1009
980
  }
1010
981
  return { document: null, actualLocale: locale };
@@ -1046,6 +1017,7 @@ function buildRuntime(config, type, getRuntime) {
1046
1017
  }
1047
1018
  return type.path;
1048
1019
  }
1020
+ const urlBuilder = createUrlBuilder(config);
1049
1021
  const runtime = {
1050
1022
  id: type.id,
1051
1023
  config: type,
@@ -1061,15 +1033,21 @@ function buildRuntime(config, type, getRuntime) {
1061
1033
  return load().get(locale)?.bySlug.get(slug) ?? null;
1062
1034
  },
1063
1035
  resolve(slug, locale) {
1064
- const result = resolveLocalizedDocument(slug, locale, config.defaultLocale, load(), type);
1036
+ const result = resolveLocalizedDocument(
1037
+ slug,
1038
+ locale,
1039
+ config.defaultLocale,
1040
+ load(),
1041
+ type,
1042
+ config.localeRouting
1043
+ );
1065
1044
  if (result.document && type.path) {
1066
1045
  return {
1067
1046
  ...result,
1068
- canonicalPath: resolvePath(
1047
+ canonicalPath: urlBuilder.resolvePath(
1069
1048
  type.path,
1070
1049
  result.document.slug,
1071
- result.actualLocale,
1072
- config.defaultLocale
1050
+ result.actualLocale
1073
1051
  )
1074
1052
  };
1075
1053
  }
@@ -1093,10 +1071,9 @@ function buildRuntime(config, type, getRuntime) {
1093
1071
  alternates(doc) {
1094
1072
  const pathTemplate = assertRoutable("alternates");
1095
1073
  const out = {
1096
- [config.defaultLocale]: resolvePath(
1074
+ [config.defaultLocale]: urlBuilder.resolvePath(
1097
1075
  pathTemplate,
1098
1076
  doc.enSlug,
1099
- config.defaultLocale,
1100
1077
  config.defaultLocale
1101
1078
  )
1102
1079
  };
@@ -1105,7 +1082,7 @@ function buildRuntime(config, type, getRuntime) {
1105
1082
  if (locale === config.defaultLocale) continue;
1106
1083
  const translated = all.get(locale)?.byEnSlug.get(doc.enSlug);
1107
1084
  if (translated) {
1108
- out[locale] = resolvePath(pathTemplate, translated.slug, locale, config.defaultLocale);
1085
+ out[locale] = urlBuilder.resolvePath(pathTemplate, translated.slug, locale);
1109
1086
  }
1110
1087
  }
1111
1088
  return out;
@@ -1120,7 +1097,7 @@ function buildRuntime(config, type, getRuntime) {
1120
1097
  },
1121
1098
  url(slug, locale) {
1122
1099
  const pathTemplate = assertRoutable("url");
1123
- return resolvePath(pathTemplate, slug, locale, config.defaultLocale);
1100
+ return urlBuilder.resolvePath(pathTemplate, slug, locale);
1124
1101
  },
1125
1102
  related(doc, fieldName, locale) {
1126
1103
  const meta = relationFields.get(fieldName);
@@ -1176,6 +1153,9 @@ function createProject(config) {
1176
1153
  getType: getRuntime,
1177
1154
  listTypes() {
1178
1155
  return Array.from(runtimes.values());
1156
+ },
1157
+ listRoutableTypes() {
1158
+ return Array.from(runtimes.values()).filter((runtime) => isRoutableType(runtime.config));
1179
1159
  }
1180
1160
  };
1181
1161
  }
@@ -1183,207 +1163,329 @@ function enFileExists(config, type, enSlug) {
1183
1163
  const dir = path3.join(config.rootDir, type.contentDir);
1184
1164
  return fs2.existsSync(path3.join(dir, `${enSlug}.mdx`)) || fs2.existsSync(path3.join(dir, `${enSlug}.md`));
1185
1165
  }
1186
- function sqliteHasTranslations(db, contentTypeId, enSlug) {
1187
- return listTranslationsForEnSlug(db, contentTypeId, enSlug).length > 0;
1188
- }
1189
- function isAliasKnown(config, type, aliasEnSlug, db) {
1190
- return enFileExists(config, type, aliasEnSlug) || sqliteHasTranslations(db, type.id, aliasEnSlug);
1191
- }
1192
1166
  function listEnSlugs(rootDir, contentDir) {
1193
1167
  const dir = path3.join(rootDir, contentDir);
1194
1168
  if (!fs2.existsSync(dir)) return [];
1195
1169
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
1196
1170
  }
1197
1171
 
1198
- // src/core/slug-aliases.ts
1199
- function buildGlobalAliasIndex(project) {
1200
- const { config } = project;
1201
- const aliasToTarget = /* @__PURE__ */ new Map();
1202
- const allAliasSlugs = /* @__PURE__ */ new Set();
1203
- const issues = [];
1204
- const canonicalSlugsByType = /* @__PURE__ */ new Map();
1205
- for (const type of config.types) {
1206
- const slugs = listEnSlugs(config.rootDir, type.contentDir);
1207
- canonicalSlugsByType.set(type.id, new Set(slugs));
1172
+ // src/i18n/translation-index.ts
1173
+ function buildTranslationIndex(allDocs, locales, defaultLocale) {
1174
+ const index = /* @__PURE__ */ new Map();
1175
+ for (const locale of locales) {
1176
+ if (locale === defaultLocale) continue;
1177
+ const idx = allDocs.get(locale);
1178
+ if (!idx) continue;
1179
+ const localeMap = /* @__PURE__ */ new Map();
1180
+ for (const [enSlug, doc] of idx.byEnSlug) {
1181
+ localeMap.set(enSlug, doc.slug);
1182
+ }
1183
+ index.set(locale, localeMap);
1208
1184
  }
1185
+ return index;
1186
+ }
1187
+ var redirectFromSchema = z.union([
1188
+ slugPatternSchema,
1189
+ z.array(slugPatternSchema).min(1).max(20)
1190
+ ]);
1191
+ var typeRedirectEntrySchema = z.object({
1192
+ from: redirectFromSchema,
1193
+ toSlug: slugPatternSchema.optional(),
1194
+ toType: z.string().min(1).optional(),
1195
+ toUrl: z.string().min(1).optional(),
1196
+ permanent: z.boolean().optional()
1197
+ }).superRefine((entry, ctx) => {
1198
+ const hasToSlug = entry.toSlug !== void 0;
1199
+ const hasToUrl = entry.toUrl !== void 0;
1200
+ const hasToType = entry.toType !== void 0;
1201
+ const targetCount = Number(hasToSlug) + Number(hasToUrl);
1202
+ if (targetCount !== 1) {
1203
+ ctx.addIssue({
1204
+ code: "custom",
1205
+ message: "Each redirect must specify exactly one of toSlug/toType+toSlug or toUrl",
1206
+ path: ["toSlug"]
1207
+ });
1208
+ return;
1209
+ }
1210
+ if (hasToUrl && hasToType) {
1211
+ ctx.addIssue({
1212
+ code: "custom",
1213
+ message: "toUrl cannot be combined with toType",
1214
+ path: ["toUrl"]
1215
+ });
1216
+ }
1217
+ if (hasToType && !hasToSlug) {
1218
+ ctx.addIssue({
1219
+ code: "custom",
1220
+ message: "Cross-type redirects require both toType and toSlug",
1221
+ path: ["toSlug"]
1222
+ });
1223
+ }
1224
+ });
1225
+ var typeRedirectsFileSchema = z.object({
1226
+ redirects: z.array(typeRedirectEntrySchema).max(500)
1227
+ });
1228
+ function normalizeRedirectFrom(from) {
1229
+ return Array.isArray(from) ? from : [from];
1230
+ }
1231
+ function parseRedirectEntry(entry) {
1232
+ const fromSlugs = normalizeRedirectFrom(entry.from);
1233
+ if (entry.toUrl !== void 0) {
1234
+ return {
1235
+ fromSlugs,
1236
+ kind: "anywhere",
1237
+ toUrl: entry.toUrl,
1238
+ permanent: entry.permanent ?? true
1239
+ };
1240
+ }
1241
+ if (entry.toType !== void 0) {
1242
+ return {
1243
+ fromSlugs,
1244
+ kind: "cross-type",
1245
+ toType: entry.toType,
1246
+ toSlug: entry.toSlug,
1247
+ permanent: entry.permanent ?? true
1248
+ };
1249
+ }
1250
+ return {
1251
+ fromSlugs,
1252
+ kind: "same-type",
1253
+ toSlug: entry.toSlug,
1254
+ permanent: entry.permanent ?? true
1255
+ };
1256
+ }
1257
+
1258
+ // src/redirects/load-type-redirects.ts
1259
+ var TYPE_REDIRECTS_FILENAME = "_redirects.json";
1260
+ function redirectsFilePath(config, type) {
1261
+ return path3.join(config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1262
+ }
1263
+ function loadTypeRedirectsFile(config, type) {
1264
+ const filePath = redirectsFilePath(config, type);
1265
+ if (!fs2.existsSync(filePath)) return null;
1266
+ let raw;
1267
+ try {
1268
+ raw = JSON.parse(fs2.readFileSync(filePath, "utf8"));
1269
+ } catch (error) {
1270
+ throw new Error(
1271
+ `${type.id}: failed to parse ${TYPE_REDIRECTS_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
1272
+ );
1273
+ }
1274
+ const parsed = typeRedirectsFileSchema.safeParse(raw);
1275
+ if (!parsed.success) {
1276
+ const details = parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ");
1277
+ throw new Error(`${type.id}: invalid ${TYPE_REDIRECTS_FILENAME}: ${details}`);
1278
+ }
1279
+ return {
1280
+ contentTypeId: type.id,
1281
+ contentDir: type.contentDir,
1282
+ filePath,
1283
+ entries: parsed.data.redirects.map(parseRedirectEntry)
1284
+ };
1285
+ }
1286
+ function loadAllTypeRedirects(config) {
1287
+ const out = [];
1209
1288
  for (const type of config.types) {
1210
- if (!type.path) continue;
1211
- const canonicalSlugs = canonicalSlugsByType.get(type.id) ?? /* @__PURE__ */ new Set();
1212
- for (const enSlug of canonicalSlugs) {
1213
- const doc = readEnDocument(config, type, enSlug);
1214
- if (!doc) continue;
1215
- for (const alias of doc.aliases) {
1216
- if (alias === enSlug) {
1217
- issues.push({
1218
- contentTypeId: type.id,
1219
- enSlug,
1220
- field: "aliases",
1221
- message: `Alias "${alias}" must not equal the canonical slug`,
1222
- level: "error"
1223
- });
1224
- continue;
1225
- }
1226
- if (canonicalSlugs.has(alias)) {
1227
- issues.push({
1228
- contentTypeId: type.id,
1229
- enSlug,
1230
- field: "aliases",
1231
- message: `Alias "${alias}" collides with another document's canonical slug`,
1232
- level: "error"
1233
- });
1234
- continue;
1235
- }
1236
- const existing = aliasToTarget.get(alias);
1237
- if (existing) {
1238
- issues.push({
1239
- contentTypeId: type.id,
1240
- enSlug,
1241
- field: "aliases",
1242
- message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
1243
- level: "error"
1244
- });
1245
- continue;
1246
- }
1247
- aliasToTarget.set(alias, {
1248
- contentTypeId: type.id,
1249
- canonicalSlug: enSlug,
1250
- path: type.path
1251
- });
1252
- allAliasSlugs.add(alias);
1289
+ const loaded = loadTypeRedirectsFile(config, type);
1290
+ if (loaded) out.push(loaded);
1291
+ }
1292
+ return out;
1293
+ }
1294
+ function collectRedirectSourceSlugs(loaded) {
1295
+ const slugs = /* @__PURE__ */ new Set();
1296
+ for (const file of loaded) {
1297
+ for (const entry of file.entries) {
1298
+ for (const from of entry.fromSlugs) {
1299
+ slugs.add(from);
1253
1300
  }
1254
1301
  }
1255
1302
  }
1256
- return { aliasToTarget, allAliasSlugs, issues };
1303
+ return slugs;
1257
1304
  }
1258
- function validateAliasCoverage(project) {
1259
- const { config } = project;
1260
- const { aliasToTarget } = buildGlobalAliasIndex(project);
1261
- const issues = [];
1262
- const db = openStore(config, "readonly");
1263
- try {
1264
- for (const [alias, target] of aliasToTarget) {
1265
- const type = config.types.find((t) => t.id === target.contentTypeId);
1266
- if (!type) continue;
1267
- const known = isAliasKnown(config, type, alias, db);
1268
- if (!known) {
1269
- issues.push({
1270
- contentTypeId: type.id,
1271
- enSlug: target.canonicalSlug,
1272
- field: "aliases",
1273
- message: `Alias "${alias}" is unknown (no EN file or sqlite translations) \u2014 only EN redirect will work`,
1274
- level: "warning"
1275
- });
1276
- }
1277
- if (enFileExists(config, type, alias)) {
1278
- issues.push({
1279
- contentTypeId: type.id,
1280
- enSlug: target.canonicalSlug,
1281
- field: "aliases",
1282
- message: `Alias "${alias}" still has an EN file on disk \u2014 delete it manually`,
1283
- level: "warning"
1284
- });
1305
+ function collectOutboundRedirectSourcesByType(loaded) {
1306
+ const out = /* @__PURE__ */ new Map();
1307
+ for (const file of loaded) {
1308
+ const set = out.get(file.contentTypeId) ?? /* @__PURE__ */ new Set();
1309
+ for (const entry of file.entries) {
1310
+ for (const from of entry.fromSlugs) {
1311
+ set.add(from);
1285
1312
  }
1286
- const sqliteRows = listTranslationsForEnSlug(db, type.id, alias);
1287
- if (sqliteRows.length > 0) {
1288
- issues.push({
1289
- contentTypeId: type.id,
1290
- enSlug: target.canonicalSlug,
1291
- field: "aliases",
1292
- message: `${sqliteRows.length} sqlite translation(s) for alias "${alias}" retained for locale redirects and history`,
1293
- level: "info"
1294
- });
1313
+ }
1314
+ out.set(file.contentTypeId, set);
1315
+ }
1316
+ return out;
1317
+ }
1318
+
1319
+ // src/redirects/build-json-redirects.ts
1320
+ function localizedSlug(db, contentTypeId, enSlug, locale, defaultLocale) {
1321
+ if (locale === defaultLocale) return enSlug;
1322
+ const row = listTranslationsForEnSlug(db, contentTypeId, enSlug).find(
1323
+ (translation) => translation.locale === locale
1324
+ );
1325
+ return row?.slug ?? enSlug;
1326
+ }
1327
+ function resolveSlugTargetDestination(project, db, targetType, toSlug, locale, urlBuilder) {
1328
+ const targetSlug = localizedSlug(
1329
+ db,
1330
+ targetType.id,
1331
+ toSlug,
1332
+ locale,
1333
+ project.config.defaultLocale
1334
+ );
1335
+ return urlBuilder.resolvePath(targetType.path, targetSlug, locale);
1336
+ }
1337
+ function buildEntryRules(project, sourceType, entry, db, urlBuilder) {
1338
+ if (!isRoutableType(sourceType)) return [];
1339
+ const out = [];
1340
+ const { defaultLocale } = project.config;
1341
+ let targetType = sourceType;
1342
+ if (entry.kind === "cross-type") {
1343
+ const resolvedTarget = project.config.types.find((type) => type.id === entry.toType);
1344
+ if (!resolvedTarget?.path) return out;
1345
+ targetType = resolvedTarget;
1346
+ }
1347
+ for (const fromEnSlug of entry.fromSlugs) {
1348
+ const locales = project.config.locales;
1349
+ for (const locale of locales) {
1350
+ const fromSlug = localizedSlug(db, sourceType.id, fromEnSlug, locale, defaultLocale);
1351
+ const source = urlBuilder.resolvePath(sourceType.path, fromSlug, locale);
1352
+ let destination;
1353
+ if (entry.kind === "anywhere") {
1354
+ destination = entry.toUrl;
1355
+ } else {
1356
+ destination = resolveSlugTargetDestination(
1357
+ project,
1358
+ db,
1359
+ entry.kind === "cross-type" ? targetType : sourceType,
1360
+ entry.toSlug,
1361
+ locale,
1362
+ urlBuilder
1363
+ );
1295
1364
  }
1296
- for (const locale of config.locales) {
1297
- if (locale === config.defaultLocale) continue;
1298
- const canonicalRow = listTranslationsForEnSlug(db, type.id, target.canonicalSlug).find(
1299
- (r) => r.locale === locale
1365
+ if (source === destination) continue;
1366
+ out.push({
1367
+ source,
1368
+ destination,
1369
+ permanent: entry.permanent
1370
+ });
1371
+ }
1372
+ if (sourceType.id === "blog") {
1373
+ let destinationEn;
1374
+ if (entry.kind === "anywhere") {
1375
+ destinationEn = entry.toUrl;
1376
+ } else {
1377
+ destinationEn = resolveSlugTargetDestination(
1378
+ project,
1379
+ db,
1380
+ entry.kind === "cross-type" ? targetType : sourceType,
1381
+ entry.toSlug,
1382
+ defaultLocale,
1383
+ urlBuilder
1300
1384
  );
1301
- if (!canonicalRow) continue;
1302
- const aliasRow = sqliteRows.find((r) => r.locale === locale);
1303
- if (!aliasRow && known) {
1304
- issues.push({
1305
- contentTypeId: type.id,
1306
- enSlug: target.canonicalSlug,
1307
- field: "aliases",
1308
- message: `Alias "${alias}" has no locale slug mapping for ${locale} \u2014 old localized URL may miss redirect`,
1309
- level: "warning"
1310
- });
1385
+ }
1386
+ out.push({
1387
+ source: `/${fromEnSlug}`,
1388
+ destination: destinationEn,
1389
+ permanent: entry.permanent
1390
+ });
1391
+ for (const locale of urlBuilder.prefixedLocales) {
1392
+ const fromSlug = localizedSlug(db, sourceType.id, fromEnSlug, locale, defaultLocale);
1393
+ let destination;
1394
+ if (entry.kind === "anywhere") {
1395
+ destination = entry.toUrl;
1396
+ } else {
1397
+ destination = resolveSlugTargetDestination(
1398
+ project,
1399
+ db,
1400
+ entry.kind === "cross-type" ? targetType : sourceType,
1401
+ entry.toSlug,
1402
+ locale,
1403
+ urlBuilder
1404
+ );
1311
1405
  }
1406
+ out.push({
1407
+ source: `/${locale}/${fromSlug}`,
1408
+ destination,
1409
+ permanent: entry.permanent
1410
+ });
1411
+ out.push({
1412
+ source: `/${locale}/${fromEnSlug}`,
1413
+ destination,
1414
+ permanent: entry.permanent
1415
+ });
1416
+ }
1417
+ }
1418
+ }
1419
+ return out;
1420
+ }
1421
+ function buildJsonRedirects(project) {
1422
+ const urlBuilder = createUrlBuilder(project.config);
1423
+ const db = openStore(project.config, "readonly");
1424
+ const out = [];
1425
+ try {
1426
+ for (const type of project.config.types) {
1427
+ if (!isRoutableType(type)) continue;
1428
+ const loaded = loadTypeRedirectsFile(project.config, type);
1429
+ if (!loaded) continue;
1430
+ for (const entry of loaded.entries) {
1431
+ out.push(...buildEntryRules(project, type, entry, db, urlBuilder));
1312
1432
  }
1313
1433
  }
1314
1434
  } finally {
1315
1435
  db.close();
1316
1436
  }
1317
- return issues;
1437
+ return out;
1318
1438
  }
1319
- function validateAliasRedirectChains(project) {
1320
- const { config } = project;
1321
- const { aliasToTarget, allAliasSlugs } = buildGlobalAliasIndex(project);
1322
- const issues = [];
1323
- for (const [alias, target] of aliasToTarget) {
1324
- const doc = readEnDocument(
1325
- config,
1326
- project.getType(target.contentTypeId).config,
1327
- target.canonicalSlug
1328
- );
1329
- if (doc?.redirectTo) {
1330
- issues.push({
1331
- contentTypeId: target.contentTypeId,
1332
- enSlug: target.canonicalSlug,
1333
- field: "aliases",
1334
- message: `Alias "${alias}" points at "${target.canonicalSlug}" which has redirect_to \u2014 resolve to the final canonical slug`,
1335
- level: "error"
1336
- });
1439
+ function buildRedirectSourceSlugSet(project) {
1440
+ const out = /* @__PURE__ */ new Set();
1441
+ for (const type of project.config.types) {
1442
+ const loaded = loadTypeRedirectsFile(project.config, type);
1443
+ if (!loaded) continue;
1444
+ for (const entry of loaded.entries) {
1445
+ for (const from of entry.fromSlugs) {
1446
+ out.add(`${type.id}:${from}`);
1447
+ }
1337
1448
  }
1338
1449
  }
1339
- for (const type of config.types) {
1340
- if (!type.path) continue;
1341
- for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
1342
- const doc = readEnDocument(config, type, enSlug);
1343
- if (!doc?.redirectTo) continue;
1344
- const targetSlug = extractSlugFromResolvedPath(type.path, doc.redirectTo);
1345
- if (targetSlug && allAliasSlugs.has(targetSlug)) {
1346
- issues.push({
1347
- contentTypeId: type.id,
1348
- enSlug,
1349
- field: "redirect_to",
1350
- message: `redirect_to target "${targetSlug}" is an alias slug \u2014 chain detected`,
1351
- level: "error"
1352
- });
1353
- }
1354
- const targetDoc = targetSlug ? readEnDocument(config, type, targetSlug) : null;
1355
- if (targetDoc?.redirectTo) {
1356
- issues.push({
1357
- contentTypeId: type.id,
1450
+ return out;
1451
+ }
1452
+ function buildLegacyNoBlogPathRedirects(project, redirectSourceKeys) {
1453
+ const blogType = project.config.types.find((type) => type.id === "blog" && type.path);
1454
+ if (!blogType?.path) return [];
1455
+ const urlBuilder = createUrlBuilder(project.config);
1456
+ const db = openStore(project.config, "readonly");
1457
+ const out = [];
1458
+ try {
1459
+ for (const enSlug of listEnSlugs(project.config.rootDir, blogType.contentDir)) {
1460
+ if (redirectSourceKeys.has(`blog:${enSlug}`)) continue;
1461
+ out.push({
1462
+ source: `/${enSlug}`,
1463
+ destination: urlBuilder.resolvePath(blogType.path, enSlug, project.config.defaultLocale),
1464
+ permanent: true
1465
+ });
1466
+ for (const locale of urlBuilder.prefixedLocales) {
1467
+ const translatedSlug = localizedSlug(
1468
+ db,
1469
+ blogType.id,
1358
1470
  enSlug,
1359
- field: "redirect_to",
1360
- message: `redirect_to chain detected: "${enSlug}" \u2192 "${targetSlug}" \u2192 "${targetDoc.redirectTo}"`,
1361
- level: "error"
1471
+ locale,
1472
+ project.config.defaultLocale
1473
+ );
1474
+ out.push({
1475
+ source: `/${locale}/${enSlug}`,
1476
+ destination: urlBuilder.resolvePath(blogType.path, translatedSlug, locale),
1477
+ permanent: true
1362
1478
  });
1363
1479
  }
1364
1480
  }
1481
+ } finally {
1482
+ db.close();
1365
1483
  }
1366
- return issues;
1367
- }
1368
-
1369
- // src/i18n/translation-index.ts
1370
- function buildTranslationIndex(allDocs, locales, defaultLocale) {
1371
- const index = /* @__PURE__ */ new Map();
1372
- for (const locale of locales) {
1373
- if (locale === defaultLocale) continue;
1374
- const idx = allDocs.get(locale);
1375
- if (!idx) continue;
1376
- const localeMap = /* @__PURE__ */ new Map();
1377
- for (const [enSlug, doc] of idx.byEnSlug) {
1378
- localeMap.set(enSlug, doc.slug);
1379
- }
1380
- index.set(locale, localeMap);
1381
- }
1382
- return index;
1484
+ return out;
1383
1485
  }
1384
1486
 
1385
- // src/redirects/translation-index.ts
1386
- function buildRedirectTranslationIndex(project, typeId) {
1487
+ // src/redirects/build-redirects.ts
1488
+ function buildRedirectTranslationIndex(project, typeId, redirectSourceSlugs) {
1387
1489
  const runtime = project.getType(typeId);
1388
1490
  const { config } = project;
1389
1491
  const merged = buildTranslationIndex(
@@ -1391,11 +1493,9 @@ function buildRedirectTranslationIndex(project, typeId) {
1391
1493
  config.locales,
1392
1494
  config.defaultLocale
1393
1495
  );
1394
- const { aliasToTarget } = buildGlobalAliasIndex(project);
1395
1496
  const db = openStore(project.config, "readonly");
1396
1497
  try {
1397
- for (const [alias, target] of aliasToTarget) {
1398
- if (target.contentTypeId !== typeId) continue;
1498
+ for (const alias of redirectSourceSlugs) {
1399
1499
  for (const row of listTranslationsForEnSlug(db, typeId, alias)) {
1400
1500
  const localeMap = merged.get(row.locale) ?? /* @__PURE__ */ new Map();
1401
1501
  if (!localeMap.has(alias)) {
@@ -1409,168 +1509,63 @@ function buildRedirectTranslationIndex(project, typeId) {
1409
1509
  }
1410
1510
  return merged;
1411
1511
  }
1412
-
1413
- // src/redirects/build-redirects.ts
1414
- function buildAliasRedirects(type, aliasIndex, translationIndex, prefixedLocales, defaultLocale) {
1415
- const out = [];
1416
- const { aliasToTarget } = aliasIndex;
1417
- const pathTemplate = type.path;
1418
- for (const [alias, target] of aliasToTarget) {
1419
- if (target.contentTypeId !== type.id) continue;
1420
- const canonical = target.canonicalSlug;
1421
- out.push({
1422
- source: resolvePath(pathTemplate, alias, defaultLocale, defaultLocale),
1423
- destination: resolvePath(pathTemplate, canonical, defaultLocale, defaultLocale),
1424
- permanent: true
1425
- });
1426
- for (const locale of prefixedLocales) {
1427
- const localeMap = translationIndex.get(locale);
1428
- const toLocale = localeMap?.get(canonical) ?? canonical;
1429
- const fromLocale = localeMap?.get(alias);
1430
- if (fromLocale) {
1431
- out.push({
1432
- source: resolvePath(pathTemplate, fromLocale, locale, defaultLocale),
1433
- destination: resolvePath(pathTemplate, toLocale, locale, defaultLocale),
1434
- permanent: true
1435
- });
1436
- }
1437
- out.push({
1438
- source: resolvePath(pathTemplate, alias, locale, defaultLocale),
1439
- destination: resolvePath(pathTemplate, toLocale, locale, defaultLocale),
1440
- permanent: true
1441
- });
1442
- }
1443
- }
1444
- return out;
1445
- }
1446
- function buildAliasLegacyNoBlogPathRedirects(type, aliasIndex, translationIndex, prefixedLocales, defaultLocale) {
1447
- if (type.id !== "blog") return [];
1448
- const out = [];
1449
- for (const [alias, target] of aliasIndex.aliasToTarget) {
1450
- if (target.contentTypeId !== type.id) continue;
1451
- for (const locale of prefixedLocales) {
1452
- const localeMap = translationIndex.get(locale);
1453
- const toLocale = localeMap?.get(target.canonicalSlug) ?? target.canonicalSlug;
1454
- out.push({
1455
- source: `/${locale}/${alias}`,
1456
- destination: resolvePath(type.path, toLocale, locale, defaultLocale),
1457
- permanent: true
1458
- });
1459
- }
1460
- }
1461
- return out;
1462
- }
1463
- function buildRedirectToRules(type, config, translationIndex, prefixedLocales, defaultLocale) {
1464
- const out = [];
1465
- const pathTemplate = type.path;
1466
- for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
1467
- const doc = readEnDocument(config, type, enSlug);
1468
- if (!doc?.redirectTo) continue;
1469
- out.push({
1470
- source: resolvePath(pathTemplate, enSlug, defaultLocale, defaultLocale),
1471
- destination: doc.redirectTo,
1472
- permanent: true
1473
- });
1474
- for (const locale of prefixedLocales) {
1475
- const localeMap = translationIndex.get(locale);
1476
- const fromLocale = localeMap?.get(enSlug);
1477
- if (!fromLocale) continue;
1478
- const targetEnSlug = extractSlugFromResolvedPath(pathTemplate, doc.redirectTo);
1479
- if (!targetEnSlug) continue;
1480
- const toLocale = localeMap?.get(targetEnSlug) ?? targetEnSlug;
1481
- out.push({
1482
- source: resolvePath(pathTemplate, fromLocale, locale, defaultLocale),
1483
- destination: resolvePath(pathTemplate, toLocale, locale, defaultLocale),
1484
- permanent: true
1485
- });
1486
- }
1487
- }
1488
- return out;
1489
- }
1490
- function buildCrossLocaleSlugRedirects(type, translationIndex, aliasSourceSlugs, defaultLocale) {
1512
+ function buildCrossLocaleSlugRedirects(project, typeId, redirectSourceSlugs) {
1513
+ const type = project.getType(typeId).config;
1514
+ if (!isRoutableType(type)) return [];
1515
+ const urlBuilder = createUrlBuilder(project.config);
1516
+ const translationIndex = buildRedirectTranslationIndex(project, typeId, redirectSourceSlugs);
1491
1517
  const out = [];
1492
1518
  const pathTemplate = type.path;
1493
1519
  for (const [locale, localeMap] of translationIndex) {
1494
1520
  for (const [enSlug, translatedSlug] of localeMap) {
1495
1521
  if (enSlug === translatedSlug) continue;
1496
- if (aliasSourceSlugs.has(enSlug)) continue;
1522
+ if (redirectSourceSlugs.has(enSlug)) continue;
1497
1523
  out.push({
1498
- source: resolvePath(pathTemplate, enSlug, locale, defaultLocale),
1499
- destination: resolvePath(pathTemplate, translatedSlug, locale, defaultLocale),
1524
+ source: urlBuilder.resolvePath(pathTemplate, enSlug, locale),
1525
+ destination: urlBuilder.resolvePath(pathTemplate, translatedSlug, locale),
1500
1526
  permanent: true
1501
1527
  });
1502
1528
  }
1503
1529
  }
1504
1530
  return out;
1505
1531
  }
1506
- function buildLegacyNoBlogPathRedirects(type, config, translationIndex, prefixedLocales, defaultLocale) {
1507
- if (type.id !== "blog") return [];
1508
- const out = [];
1509
- const enSlugs = listEnSlugs(config.rootDir, type.contentDir);
1510
- const pathTemplate = type.path;
1511
- for (const enSlug of enSlugs) {
1512
- out.push({
1513
- source: `/${enSlug}`,
1514
- destination: resolvePath(pathTemplate, enSlug, defaultLocale, defaultLocale),
1515
- permanent: true
1516
- });
1517
- for (const locale of prefixedLocales) {
1518
- const translatedSlug = translationIndex.get(locale)?.get(enSlug);
1519
- const canonicalSlug = translatedSlug ?? enSlug;
1520
- out.push({
1521
- source: `/${locale}/${enSlug}`,
1522
- destination: resolvePath(pathTemplate, canonicalSlug, locale, defaultLocale),
1523
- permanent: true
1524
- });
1532
+ function buildTypeRedirects(project, typeId) {
1533
+ const loaded = loadAllTypeRedirects(project.config).find((file) => file.contentTypeId === typeId);
1534
+ const redirectSourceSlugs = /* @__PURE__ */ new Set();
1535
+ if (loaded) {
1536
+ for (const entry of loaded.entries) {
1537
+ for (const from of entry.fromSlugs) {
1538
+ redirectSourceSlugs.add(from);
1539
+ }
1525
1540
  }
1526
1541
  }
1527
- return out;
1528
- }
1529
- function buildTypeRedirects(project, typeId, aliasIndex, options) {
1530
- const type = project.getType(typeId).config;
1531
- if (!isRoutableType(type)) return [];
1532
- const { defaultLocale } = project.config;
1533
- const translationIndex = buildRedirectTranslationIndex(project, typeId);
1534
- const aliasSourceSlugs = /* @__PURE__ */ new Set();
1535
- for (const [alias, target] of aliasIndex.aliasToTarget) {
1536
- if (target.contentTypeId === typeId) aliasSourceSlugs.add(alias);
1537
- }
1538
1542
  return [
1539
- ...buildAliasRedirects(type, aliasIndex, translationIndex, options.prefixedLocales, defaultLocale),
1540
- ...buildAliasLegacyNoBlogPathRedirects(
1541
- type,
1542
- aliasIndex,
1543
- translationIndex,
1544
- options.prefixedLocales,
1545
- defaultLocale
1546
- ),
1547
- ...buildRedirectToRules(type, project.config, translationIndex, options.prefixedLocales, defaultLocale),
1548
- ...buildCrossLocaleSlugRedirects(type, translationIndex, aliasSourceSlugs, defaultLocale),
1549
- ...buildLegacyNoBlogPathRedirects(type, project.config, translationIndex, options.prefixedLocales, defaultLocale)
1543
+ ...buildCrossLocaleSlugRedirects(project, typeId, redirectSourceSlugs)
1550
1544
  ];
1551
1545
  }
1552
- function buildAllContentRedirects(project, options) {
1553
- const aliasIndex = buildGlobalAliasIndex(project);
1554
- const out = [];
1546
+ function buildAllContentRedirects(project, _options = {}) {
1547
+ const redirectSourceKeys = buildRedirectSourceSlugSet(project);
1548
+ const out = [
1549
+ ...buildJsonRedirects(project),
1550
+ ...buildLegacyNoBlogPathRedirects(project, redirectSourceKeys)
1551
+ ];
1555
1552
  for (const type of project.config.types) {
1556
1553
  if (!isRoutableType(type)) continue;
1557
- out.push(...buildTypeRedirects(project, type.id, aliasIndex, options));
1554
+ out.push(...buildTypeRedirects(project, type.id));
1558
1555
  }
1559
1556
  return out;
1560
1557
  }
1561
1558
  function getRedirectSourceSlugs(project) {
1562
- const aliasIndex = buildGlobalAliasIndex(project);
1563
- const outboundByType = /* @__PURE__ */ new Map();
1559
+ const loaded = loadAllTypeRedirects(project.config);
1560
+ const aliasSlugs = collectRedirectSourceSlugs(loaded);
1561
+ const outboundByType = collectOutboundRedirectSourcesByType(loaded);
1564
1562
  for (const type of project.config.types) {
1565
- const outbound = /* @__PURE__ */ new Set();
1566
- for (const enSlug of listEnSlugs(project.config.rootDir, type.contentDir)) {
1567
- const doc = readEnDocument(project.config, type, enSlug);
1568
- if (doc?.redirectTo) outbound.add(enSlug);
1563
+ if (!outboundByType.has(type.id)) {
1564
+ outboundByType.set(type.id, /* @__PURE__ */ new Set());
1569
1565
  }
1570
- outboundByType.set(type.id, outbound);
1571
1566
  }
1572
1567
  return {
1573
- aliasSlugs: aliasIndex.allAliasSlugs,
1568
+ aliasSlugs,
1574
1569
  outboundByType
1575
1570
  };
1576
1571
  }
@@ -1578,13 +1573,12 @@ function getRedirectSourceSlugs(project) {
1578
1573
  // src/sitemap/join-base-url.ts
1579
1574
  function joinBaseUrl(baseUrl, pathname) {
1580
1575
  const origin = baseUrl.replace(/\/$/, "");
1581
- const path11 = pathname.startsWith("/") ? pathname : `/${pathname}`;
1582
- return `${origin}${path11}`;
1576
+ const path13 = pathname.startsWith("/") ? pathname : `/${pathname}`;
1577
+ return `${origin}${path13}`;
1583
1578
  }
1584
1579
 
1585
1580
  // src/sitemap/generate-sitemap.ts
1586
1581
  function shouldIncludeEnDoc(enDoc, enSlug, redirectSources, contentTypeId, excludeNoindex) {
1587
- if (enDoc.redirectTo) return false;
1588
1582
  if (redirectSources.aliasSlugs.has(enSlug)) return false;
1589
1583
  const outbound = redirectSources.outboundByType.get(contentTypeId);
1590
1584
  if (outbound?.has(enSlug)) return false;
@@ -1671,6 +1665,7 @@ function createScribe(input) {
1671
1665
  project,
1672
1666
  getType: project.getType,
1673
1667
  listTypes: project.listTypes,
1668
+ listRoutableTypes: project.listRoutableTypes,
1674
1669
  sitemap(options) {
1675
1670
  return generateSitemap(project, options);
1676
1671
  }
@@ -1723,6 +1718,168 @@ function loadConfigSync(options = {}) {
1723
1718
  }
1724
1719
  return resolveConfig(config, path3.dirname(configPath));
1725
1720
  }
1721
+ function liveDocExists(config, typeId, enSlug) {
1722
+ const type = config.types.find((entry) => entry.id === typeId);
1723
+ if (!type) return false;
1724
+ return enFileExists(config, type, enSlug);
1725
+ }
1726
+ function resolveRedirectTarget(project, sourceTypeId, entry) {
1727
+ if (entry.kind === "anywhere") return null;
1728
+ if (entry.kind === "cross-type") {
1729
+ return { typeId: entry.toType, enSlug: entry.toSlug };
1730
+ }
1731
+ return { typeId: sourceTypeId, enSlug: entry.toSlug };
1732
+ }
1733
+ function matchRoutableTarget(project, toUrl) {
1734
+ const urlBuilder = createUrlBuilder(project.config);
1735
+ for (const type of project.config.types) {
1736
+ if (!type.path) continue;
1737
+ const enSlug = urlBuilder.extractSlugFromResolvedPath(type.path, toUrl);
1738
+ if (enSlug && liveDocExists(project.config, type.id, enSlug)) {
1739
+ return { typeId: type.id, enSlug };
1740
+ }
1741
+ }
1742
+ return null;
1743
+ }
1744
+ function validateTypeRedirects(project) {
1745
+ const issues = [];
1746
+ const globalFrom = /* @__PURE__ */ new Map();
1747
+ for (const type of project.config.types) {
1748
+ const filePath = path3.join(project.config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1749
+ if (!fs2.existsSync(filePath)) continue;
1750
+ let loaded;
1751
+ try {
1752
+ loaded = loadTypeRedirectsFile(project.config, type);
1753
+ } catch (error) {
1754
+ issues.push({
1755
+ level: "error",
1756
+ contentType: type.id,
1757
+ field: TYPE_REDIRECTS_FILENAME,
1758
+ message: error instanceof Error ? error.message : String(error)
1759
+ });
1760
+ continue;
1761
+ }
1762
+ if (!loaded) continue;
1763
+ if (!isRoutableType(type)) {
1764
+ issues.push({
1765
+ level: "error",
1766
+ contentType: type.id,
1767
+ field: TYPE_REDIRECTS_FILENAME,
1768
+ message: `Redirects are only supported on routable content types (missing path)`
1769
+ });
1770
+ continue;
1771
+ }
1772
+ for (const entry of loaded.entries) {
1773
+ for (const from of entry.fromSlugs) {
1774
+ if (from === entry.toSlug) {
1775
+ issues.push({
1776
+ level: "error",
1777
+ contentType: type.id,
1778
+ enSlug: from,
1779
+ field: TYPE_REDIRECTS_FILENAME,
1780
+ message: `Redirect source "${from}" must not equal its target slug`
1781
+ });
1782
+ }
1783
+ if (liveDocExists(project.config, type.id, from)) {
1784
+ issues.push({
1785
+ level: "error",
1786
+ contentType: type.id,
1787
+ enSlug: from,
1788
+ field: TYPE_REDIRECTS_FILENAME,
1789
+ message: `Redirect source "${from}" still has a live EN file \u2014 delete or rename the MDX first`
1790
+ });
1791
+ }
1792
+ const existing = globalFrom.get(from);
1793
+ if (existing && existing.typeId !== type.id) {
1794
+ issues.push({
1795
+ level: "error",
1796
+ contentType: type.id,
1797
+ enSlug: from,
1798
+ field: TYPE_REDIRECTS_FILENAME,
1799
+ message: `Redirect source "${from}" is already claimed by ${existing.typeId}`
1800
+ });
1801
+ } else if (existing) {
1802
+ issues.push({
1803
+ level: "error",
1804
+ contentType: type.id,
1805
+ enSlug: from,
1806
+ field: TYPE_REDIRECTS_FILENAME,
1807
+ message: `Duplicate redirect source "${from}" in ${TYPE_REDIRECTS_FILENAME}`
1808
+ });
1809
+ } else {
1810
+ globalFrom.set(from, { typeId: type.id, filePath: loaded.filePath });
1811
+ }
1812
+ }
1813
+ if (entry.kind === "cross-type") {
1814
+ const targetType = project.config.types.find((candidate) => candidate.id === entry.toType);
1815
+ if (!targetType) {
1816
+ issues.push({
1817
+ level: "error",
1818
+ contentType: type.id,
1819
+ field: TYPE_REDIRECTS_FILENAME,
1820
+ message: `Unknown redirect target type "${entry.toType}"`
1821
+ });
1822
+ continue;
1823
+ }
1824
+ if (!isRoutableType(targetType)) {
1825
+ issues.push({
1826
+ level: "error",
1827
+ contentType: type.id,
1828
+ field: TYPE_REDIRECTS_FILENAME,
1829
+ message: `Redirect target type "${entry.toType}" is not routable (missing path)`
1830
+ });
1831
+ continue;
1832
+ }
1833
+ }
1834
+ const target = resolveRedirectTarget(project, type.id, entry);
1835
+ if (target) {
1836
+ if (!liveDocExists(project.config, target.typeId, target.enSlug)) {
1837
+ issues.push({
1838
+ level: "error",
1839
+ contentType: type.id,
1840
+ field: TYPE_REDIRECTS_FILENAME,
1841
+ message: `Redirect target ${target.typeId}/${target.enSlug} does not exist`
1842
+ });
1843
+ }
1844
+ continue;
1845
+ }
1846
+ if (entry.kind === "anywhere" && entry.toUrl?.startsWith("/")) {
1847
+ const matched = matchRoutableTarget(project, entry.toUrl);
1848
+ if (matched && !liveDocExists(project.config, matched.typeId, matched.enSlug)) {
1849
+ issues.push({
1850
+ level: "error",
1851
+ contentType: type.id,
1852
+ field: TYPE_REDIRECTS_FILENAME,
1853
+ message: `Redirect toUrl "${entry.toUrl}" does not resolve to a live document`
1854
+ });
1855
+ }
1856
+ }
1857
+ }
1858
+ }
1859
+ const allLoaded = loadAllTypeRedirects(project.config);
1860
+ for (const file of allLoaded) {
1861
+ for (const entry of file.entries) {
1862
+ if (entry.kind === "anywhere" || !entry.toSlug) continue;
1863
+ const targetTypeId = entry.kind === "cross-type" ? entry.toType : file.contentTypeId;
1864
+ const targetType = project.config.types.find((type) => type.id === targetTypeId);
1865
+ if (!targetType?.path) continue;
1866
+ for (const from of entry.fromSlugs) {
1867
+ if (globalFrom.get(from)?.typeId !== file.contentTypeId) continue;
1868
+ const chainTarget = globalFrom.get(entry.toSlug);
1869
+ if (chainTarget) {
1870
+ issues.push({
1871
+ level: "error",
1872
+ contentType: file.contentTypeId,
1873
+ enSlug: from,
1874
+ field: TYPE_REDIRECTS_FILENAME,
1875
+ message: `Redirect chain detected: "${from}" \u2192 "${entry.toSlug}" \u2192 existing redirect source`
1876
+ });
1877
+ }
1878
+ }
1879
+ }
1880
+ }
1881
+ return issues;
1882
+ }
1726
1883
  function getAtPath2(obj, fieldPath) {
1727
1884
  let current = obj;
1728
1885
  for (const segment of fieldPath) {
@@ -1912,7 +2069,7 @@ function validateTranslationSlugSuffixes(config, db) {
1912
2069
  }
1913
2070
 
1914
2071
  // src/validate/validate-project.ts
1915
- function listEnSlugs2(rootDir, contentDir) {
2072
+ function listEnSlugs3(rootDir, contentDir) {
1916
2073
  const dir = path3.join(rootDir, contentDir);
1917
2074
  if (!fs2.existsSync(dir)) return [];
1918
2075
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -1930,7 +2087,7 @@ function validateProject(config) {
1930
2087
  }
1931
2088
  const db = openStore(config, "readonly");
1932
2089
  for (const type of config.types) {
1933
- const enSlugs = listEnSlugs2(config.rootDir, type.contentDir);
2090
+ const enSlugs = listEnSlugs3(config.rootDir, type.contentDir);
1934
2091
  const englishSlugs = new Set(enSlugs);
1935
2092
  if (!type.translate && isRoutableType(type)) {
1936
2093
  issues.push({
@@ -2012,33 +2169,8 @@ function validateProject(config) {
2012
2169
  }
2013
2170
  }
2014
2171
  db.close();
2015
- const aliasIndex = buildGlobalAliasIndex(project);
2016
- for (const issue of aliasIndex.issues) {
2017
- issues.push({
2018
- level: issue.level,
2019
- contentType: issue.contentTypeId,
2020
- enSlug: issue.enSlug,
2021
- field: issue.field,
2022
- message: issue.message
2023
- });
2024
- }
2025
- for (const issue of validateAliasRedirectChains(project)) {
2026
- issues.push({
2027
- level: issue.level,
2028
- contentType: issue.contentTypeId,
2029
- enSlug: issue.enSlug,
2030
- field: issue.field,
2031
- message: issue.message
2032
- });
2033
- }
2034
- for (const issue of validateAliasCoverage(project)) {
2035
- issues.push({
2036
- level: issue.level,
2037
- contentType: issue.contentTypeId,
2038
- enSlug: issue.enSlug,
2039
- field: issue.field,
2040
- message: issue.message
2041
- });
2172
+ for (const issue of validateTypeRedirects(project)) {
2173
+ issues.push(issue);
2042
2174
  }
2043
2175
  for (const issue of validateRelations(project)) {
2044
2176
  issues.push({
@@ -2075,7 +2207,7 @@ function computePageEnHash(translatableFrontmatter, body) {
2075
2207
  }
2076
2208
 
2077
2209
  // src/translate/worklist.ts
2078
- function listEnSlugs3(rootDir, contentDir) {
2210
+ function listEnSlugs4(rootDir, contentDir) {
2079
2211
  const dir = path3.join(rootDir, contentDir);
2080
2212
  if (!fs2.existsSync(dir)) return [];
2081
2213
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
@@ -2086,7 +2218,7 @@ function buildWorklist(config, options = {}) {
2086
2218
  const locales = options.locales ?? config.locales.filter((locale) => locale !== config.defaultLocale);
2087
2219
  for (const type of config.types) {
2088
2220
  if (options.contentType && type.id !== options.contentType) continue;
2089
- const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs3(config.rootDir, type.contentDir);
2221
+ const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs4(config.rootDir, type.contentDir);
2090
2222
  for (const enSlug of enSlugs) {
2091
2223
  const enDoc = readEnDocument(config, type, enSlug);
2092
2224
  if (!enDoc) continue;
@@ -2358,11 +2490,12 @@ function buildGeminiResponseSchema(schema, slugStrategy) {
2358
2490
  function slugStrategyRules(slugStrategy, preserveTerms) {
2359
2491
  if (slugStrategy === "localized") {
2360
2492
  const rules = [
2361
- "Provide a URL slug in JSON field `slug` that is TRANSLATED into the target language.",
2493
+ "Provide a URL slug in JSON field `slug` that is Localized into the target language.",
2362
2494
  "Base the slug on the meaning of the translated title \u2014 do NOT reuse the English slug words.",
2363
2495
  "Slug MUST be ASCII only: a-z, 0-9, hyphens. No uppercase, accents, underscores, or spaces.",
2364
2496
  "For non-Latin languages, write the words in the target language and transliterate them into Latin script (e.g. Russian -> romanized Russian, not English).",
2365
- "Do NOT append locale codes to the slug (e.g. -fr, -he, -zh-cn). Locale routing is handled by the URL prefix, not the slug."
2497
+ "Do NOT append locale codes to the slug (e.g. -fr, -he, -zh-cn). Locale routing is handled by the URL prefix, not the slug.",
2498
+ 'In JSX attributes (e.g. FaqItem question="..."), use single quotes when the value contains double-quote characters (e.g. Hebrew \u05D3\u05D5\u05D0"\u05DC), or escape them as \\".'
2366
2499
  ];
2367
2500
  if (preserveTerms?.length) {
2368
2501
  rules.push(
@@ -2395,6 +2528,95 @@ function resolveTranslateConfig(config, type) {
2395
2528
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
2396
2529
  }
2397
2530
 
2531
+ // src/translate/sanitize-mdx-jsx.ts
2532
+ function sanitizeMdxJsxAttributeQuotes(body) {
2533
+ let adjusted = false;
2534
+ let out = "";
2535
+ let i = 0;
2536
+ while (i < body.length) {
2537
+ if (body[i] !== "<" || !/[\w.]/.test(body[i + 1] ?? "")) {
2538
+ out += body[i];
2539
+ i += 1;
2540
+ continue;
2541
+ }
2542
+ const tagStart = i;
2543
+ i += 1;
2544
+ while (i < body.length && /[\w.-]/.test(body[i] ?? "")) i += 1;
2545
+ const tagName = body.slice(tagStart + 1, i);
2546
+ let tagOut = `<${tagName}`;
2547
+ let tagAdjusted = false;
2548
+ while (i < body.length && body[i] !== ">" && body[i] !== "/") {
2549
+ while (i < body.length && /\s/.test(body[i] ?? "")) {
2550
+ tagOut += body[i];
2551
+ i += 1;
2552
+ }
2553
+ if (i >= body.length || body[i] === ">" || body[i] === "/") break;
2554
+ const attrStart = i;
2555
+ while (i < body.length && /[\w:.-]/.test(body[i] ?? "")) i += 1;
2556
+ body.slice(attrStart, i);
2557
+ while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2558
+ if (body[i] !== "=") {
2559
+ tagOut += body.slice(attrStart, i);
2560
+ continue;
2561
+ }
2562
+ tagOut += body.slice(attrStart, i);
2563
+ tagOut += "=";
2564
+ i += 1;
2565
+ while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2566
+ const quote = body[i];
2567
+ if (quote !== '"' && quote !== "'") {
2568
+ continue;
2569
+ }
2570
+ if (quote === "'") {
2571
+ const valStart2 = i + 1;
2572
+ i += 1;
2573
+ while (i < body.length) {
2574
+ if (body[i] === "\\") {
2575
+ i += 2;
2576
+ continue;
2577
+ }
2578
+ if (body[i] === "'") break;
2579
+ i += 1;
2580
+ }
2581
+ tagOut += body.slice(valStart2 - 1, i + 1);
2582
+ i += 1;
2583
+ continue;
2584
+ }
2585
+ const valStart = i + 1;
2586
+ i += 1;
2587
+ let closeIdx = -1;
2588
+ for (let scan = valStart; scan < body.length; scan += 1) {
2589
+ if (body[scan] !== '"') continue;
2590
+ let j = scan + 1;
2591
+ while (j < body.length && /\s/.test(body[j] ?? "")) j += 1;
2592
+ if (j < body.length && (body[j] === ">" || body[j] === "/" || /[a-zA-Z]/.test(body[j] ?? ""))) {
2593
+ closeIdx = scan;
2594
+ break;
2595
+ }
2596
+ }
2597
+ if (closeIdx === -1) {
2598
+ tagOut += body.slice(valStart - 1, i);
2599
+ break;
2600
+ }
2601
+ const value = body.slice(valStart, closeIdx);
2602
+ const hasInternalQuote = value.includes('"');
2603
+ if (hasInternalQuote) {
2604
+ const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2605
+ tagOut += `'${escaped}'`;
2606
+ tagAdjusted = true;
2607
+ } else {
2608
+ tagOut += `"${value}"`;
2609
+ }
2610
+ i = closeIdx + 1;
2611
+ }
2612
+ if (tagAdjusted) adjusted = true;
2613
+ tagOut += body[i] ?? "";
2614
+ out += tagOut;
2615
+ i += 1;
2616
+ }
2617
+ return { body: out, adjusted };
2618
+ }
2619
+
2398
2620
  // src/translate/validate-translation.ts
2399
2621
  function formatZodIssues(error) {
2400
2622
  return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
@@ -2528,6 +2750,9 @@ async function translatePage(config, item, options = {}) {
2528
2750
  if (!validated.ok) {
2529
2751
  throw new Error(`Translation validation failed: ${validated.error}`);
2530
2752
  }
2753
+ const { body: translatedBody, adjusted: mdxAdjusted } = sanitizeMdxJsxAttributeQuotes(
2754
+ result.parsed.body
2755
+ );
2531
2756
  const writeDb = openStore(config, "readwrite");
2532
2757
  const snapshotId = recordEnSnapshot(
2533
2758
  config,
@@ -2546,7 +2771,7 @@ async function translatePage(config, item, options = {}) {
2546
2771
  locale: item.locale,
2547
2772
  slug,
2548
2773
  frontmatter: validated.frontmatter,
2549
- body: result.parsed.body,
2774
+ body: translatedBody,
2550
2775
  enHash: currentEnHash,
2551
2776
  translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2552
2777
  model: result.model,
@@ -2565,7 +2790,8 @@ async function translatePage(config, item, options = {}) {
2565
2790
  usage: result.usage,
2566
2791
  estimatedCostUsd,
2567
2792
  durationMs: Date.now() - startedAt,
2568
- slugAdjusted
2793
+ slugAdjusted,
2794
+ mdxAdjusted: mdxAdjusted || void 0
2569
2795
  };
2570
2796
  } catch (error) {
2571
2797
  return {
@@ -2642,6 +2868,7 @@ function buildStaticRawExports(project, options = {}) {
2642
2868
  const excludeNoindex = options.excludeNoindex ?? false;
2643
2869
  const typeFilter = options.types ? new Set(options.types) : null;
2644
2870
  const out = [];
2871
+ const urlBuilder = createUrlBuilder(config);
2645
2872
  for (const type of project.listTypes()) {
2646
2873
  if (!isRoutableType(type.config)) continue;
2647
2874
  if (typeFilter && !typeFilter.has(type.id)) continue;
@@ -2657,7 +2884,7 @@ function buildStaticRawExports(project, options = {}) {
2657
2884
  if (excludeNoindex && resolved.document.noindex) continue;
2658
2885
  const doc = resolved.document;
2659
2886
  const slugWithExt = `${doc.slug}${extension}`;
2660
- const urlPath = resolvePath(pathTemplate, slugWithExt, locale, config.defaultLocale);
2887
+ const urlPath = urlBuilder.resolvePath(pathTemplate, slugWithExt, locale);
2661
2888
  out.push({
2662
2889
  relativePath: urlPath.slice(1),
2663
2890
  urlPath,
@@ -2700,6 +2927,6 @@ function writeStaticRawExports(project, options = {}) {
2700
2927
  return { exports, written: exports.length };
2701
2928
  }
2702
2929
 
2703
- export { buildAllContentRedirects, buildStaticRawExports, buildWorklist, createProject, createScribe, defineConfig, defineContentType, exportDirSegment, field, findConfigPath, generateSitemap, getFieldKind, getRedirectSourceSlugs, getRelationTarget, getStaticExportRoots, isResolvedConfig, loadConfigSync, resolveConfig, resolveLocalesFromPreset, serializeMdx, translatePage, translateWorklist, unwrapSchema, validateProject, writeStaticRawExports };
2930
+ export { buildAllContentRedirects, buildStaticRawExports, buildWorklist, createProject, createScribe, createUrlBuilder, defineConfig, defineContentType, exportDirSegment, field, findConfigPath, generateSitemap, getFieldKind, getRedirectSourceSlugs, getRelationTarget, getStaticExportRoots, isResolvedConfig, isRoutableType, loadConfigSync, resolveConfig, resolveLocalesFromPreset, serializeMdx, translatePage, translateWorklist, unwrapSchema, validateProject, writeStaticRawExports };
2704
2931
  //# sourceMappingURL=index.js.map
2705
2932
  //# sourceMappingURL=index.js.map