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
package/dist/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import path3 from 'path';
1
+ import path from 'path';
2
2
  import { z } from 'zod';
3
3
  import fs2 from 'fs';
4
4
  import matter from 'gray-matter';
@@ -11,14 +11,14 @@ var SLUG_PLACEHOLDER = "{slug}";
11
11
  function isRoutableType(type) {
12
12
  return typeof type.path === "string" && type.path.length > 0;
13
13
  }
14
- function assertValidPathTemplate(path5, typeId) {
15
- if (!path5.startsWith("/")) {
16
- throw new Error(`Content type "${typeId}": path must start with / (got "${path5}")`);
14
+ function assertValidPathTemplate(path6, typeId) {
15
+ if (!path6.startsWith("/")) {
16
+ throw new Error(`Content type "${typeId}": path must start with / (got "${path6}")`);
17
17
  }
18
- const count = (path5.match(/\{slug\}/g) ?? []).length;
18
+ const count = (path6.match(/\{slug\}/g) ?? []).length;
19
19
  if (count !== 1) {
20
20
  throw new Error(
21
- `Content type "${typeId}": path must contain exactly one {slug} (got "${path5}")`
21
+ `Content type "${typeId}": path must contain exactly one {slug} (got "${path6}")`
22
22
  );
23
23
  }
24
24
  }
@@ -32,19 +32,69 @@ function pathSuffix(template) {
32
32
  if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
33
33
  return template.slice(idx + SLUG_PLACEHOLDER.length);
34
34
  }
35
- function resolvePath(template, slug, locale, defaultLocale) {
36
- const relative = template.replace(SLUG_PLACEHOLDER, slug);
37
- if (locale === defaultLocale) return relative;
38
- return `/${locale}${relative}`;
35
+ function resolveDefaultLocaleRouting() {
36
+ return { strategy: "path-prefix", prefixDefaultLocale: false };
39
37
  }
40
- function extractSlugFromResolvedPath(template, resolvedPath) {
41
- const prefix = pathPrefix(template);
42
- const suffix = pathSuffix(template);
43
- if (!resolvedPath.startsWith(prefix)) return null;
44
- if (suffix && !resolvedPath.endsWith(suffix)) return null;
45
- const slugEnd = suffix ? resolvedPath.length - suffix.length : resolvedPath.length;
46
- const slug = resolvedPath.slice(prefix.length, slugEnd);
47
- return slug.length > 0 ? slug : null;
38
+ function splitPathAndSearch(pathname) {
39
+ const q = pathname.indexOf("?");
40
+ if (q === -1) return { pathname, search: "" };
41
+ return { pathname: pathname.slice(0, q), search: pathname.slice(q) };
42
+ }
43
+ function createUrlBuilder(config) {
44
+ const localeRouting = config.localeRouting ?? resolveDefaultLocaleRouting();
45
+ const defaultLocale = config.defaultLocale;
46
+ const prefixedLocales = localeRouting.strategy === "path-prefix" ? localeRouting.prefixDefaultLocale ? [...config.locales] : config.locales.filter((locale) => locale !== defaultLocale) : config.locales.filter((locale) => locale !== defaultLocale);
47
+ function applyLocaleToPath(pathname, locale) {
48
+ if (locale === defaultLocale) {
49
+ if (localeRouting.strategy === "path-prefix" && localeRouting.prefixDefaultLocale) {
50
+ return `/${defaultLocale}${pathname}`;
51
+ }
52
+ return pathname;
53
+ }
54
+ if (localeRouting.strategy === "search-param") {
55
+ const { pathname: base, search } = splitPathAndSearch(pathname);
56
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
57
+ params.set(localeRouting.param, locale);
58
+ const qs = params.toString();
59
+ return qs ? `${base}?${qs}` : base;
60
+ }
61
+ return `/${locale}${pathname}`;
62
+ }
63
+ function resolvePath(template, slug, locale) {
64
+ const relative = template.replace(SLUG_PLACEHOLDER, slug);
65
+ return applyLocaleToPath(relative, locale);
66
+ }
67
+ function extractSlugFromResolvedPath(template, resolvedPath) {
68
+ let pathname = resolvedPath;
69
+ if (localeRouting.strategy === "search-param") {
70
+ pathname = splitPathAndSearch(resolvedPath).pathname;
71
+ } else if (localeRouting.strategy === "path-prefix") {
72
+ for (const locale of config.locales) {
73
+ if (locale === defaultLocale && !localeRouting.prefixDefaultLocale) continue;
74
+ const prefix2 = `/${locale}`;
75
+ if (pathname === prefix2 || pathname.startsWith(`${prefix2}/`)) {
76
+ pathname = pathname.slice(prefix2.length) || "/";
77
+ break;
78
+ }
79
+ }
80
+ }
81
+ const prefix = pathPrefix(template);
82
+ const suffix = pathSuffix(template);
83
+ if (!pathname.startsWith(prefix)) return null;
84
+ if (suffix && !pathname.endsWith(suffix)) return null;
85
+ const slugEnd = suffix ? pathname.length - suffix.length : pathname.length;
86
+ const slug = pathname.slice(prefix.length, slugEnd);
87
+ return slug.length > 0 ? slug : null;
88
+ }
89
+ return {
90
+ defaultLocale,
91
+ locales: config.locales,
92
+ localeRouting,
93
+ prefixedLocales,
94
+ resolvePath,
95
+ extractSlugFromResolvedPath,
96
+ applyLocaleToPath
97
+ };
48
98
  }
49
99
 
50
100
  // src/config/resolve-config.ts
@@ -67,10 +117,17 @@ function resolveConfig(input, baseDir) {
67
117
  `scribe config: defaultLocale "${defaultLocale}" is not in locales [${raw.locales.join(", ")}]`
68
118
  );
69
119
  }
70
- const projectRoot = path3.resolve(process.cwd(), raw.rootDir);
71
- const contentRoot = path3.resolve(projectRoot, raw.contentDir ?? "content");
72
- const storePath = path3.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
73
- const assetsPath = raw.assetsDir ? path3.resolve(projectRoot, raw.assetsDir) : void 0;
120
+ const localeRouting = raw.localeRouting ?? {
121
+ strategy: "path-prefix",
122
+ prefixDefaultLocale: false
123
+ };
124
+ if (localeRouting.strategy === "search-param" && !localeRouting.param) {
125
+ throw new Error('scribe config: localeRouting search-param requires a "param" name');
126
+ }
127
+ const projectRoot = path.resolve(process.cwd(), raw.rootDir);
128
+ const contentRoot = path.resolve(projectRoot, raw.contentDir ?? "content");
129
+ const storePath = path.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
130
+ const assetsPath = raw.assetsDir ? path.resolve(projectRoot, raw.assetsDir) : void 0;
74
131
  const seenIds = /* @__PURE__ */ new Set();
75
132
  const types = raw.types.map((type) => {
76
133
  if (seenIds.has(type.id)) {
@@ -94,6 +151,7 @@ function resolveConfig(input, baseDir) {
94
151
  assetsPath,
95
152
  locales: [...raw.locales],
96
153
  defaultLocale,
154
+ localeRouting,
97
155
  localePresets: raw.localePresets,
98
156
  translate: raw.translate,
99
157
  types
@@ -193,10 +251,10 @@ function introspectSchema(schema, prefix = []) {
193
251
  }
194
252
  function extractByPaths(data, paths) {
195
253
  const out = {};
196
- for (const path5 of paths) {
197
- const value = getAtPath(data, path5);
254
+ for (const path6 of paths) {
255
+ const value = getAtPath(data, path6);
198
256
  if (value !== void 0) {
199
- setAtPath(out, path5.filter((p) => p !== "*"), value);
257
+ setAtPath(out, path6.filter((p) => p !== "*"), value);
200
258
  }
201
259
  }
202
260
  return out;
@@ -214,13 +272,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
214
272
  const translatable = pickTranslatable(localeData, schema);
215
273
  return deepMerge(structural, translatable);
216
274
  }
217
- function getAtPath(obj, path5) {
275
+ function getAtPath(obj, path6) {
218
276
  let current = obj;
219
- for (const segment of path5) {
277
+ for (const segment of path6) {
220
278
  if (segment === "*") {
221
279
  if (!Array.isArray(current)) return void 0;
222
280
  return current.map(
223
- (item) => typeof item === "object" && item !== null ? getAtPath(item, path5.slice(path5.indexOf("*") + 1)) : void 0
281
+ (item) => typeof item === "object" && item !== null ? getAtPath(item, path6.slice(path6.indexOf("*") + 1)) : void 0
224
282
  );
225
283
  }
226
284
  if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
@@ -228,13 +286,13 @@ function getAtPath(obj, path5) {
228
286
  }
229
287
  return current;
230
288
  }
231
- function setAtPath(obj, path5, value) {
232
- if (path5.length === 0) return;
233
- if (path5.length === 1) {
234
- obj[path5[0]] = value;
289
+ function setAtPath(obj, path6, value) {
290
+ if (path6.length === 0) return;
291
+ if (path6.length === 1) {
292
+ obj[path6[0]] = value;
235
293
  return;
236
294
  }
237
- const [head, ...rest] = path5;
295
+ const [head, ...rest] = path6;
238
296
  if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
239
297
  obj[head] = {};
240
298
  }
@@ -269,45 +327,23 @@ var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
269
327
  var slugPatternSchema = z.string().regex(SLUG_PATTERN, "slug must be lowercase-kebab-case");
270
328
  var isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]*)?$/, "Use ISO date YYYY-MM-DD or full ISO 8601");
271
329
  var canonicalPathSchema = z.string().regex(/^\//, "canonicalPath must start with /");
272
- function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
330
+ var DEPRECATED_REDIRECT_FIELDS = [
331
+ [
332
+ "aliases",
333
+ "aliases frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
334
+ ],
335
+ [
336
+ "redirect_to",
337
+ "redirect_to frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
338
+ ]
339
+ ];
340
+ function extractBuiltinEnFields(data, _pathTemplate, _enSlug, _defaultLocale) {
273
341
  const issues = [];
274
342
  const rest = { ...data };
275
- const aliasesResult = z.array(slugPatternSchema).max(20).optional().default([]).safeParse(rest.aliases ?? []);
276
- delete rest.aliases;
277
- const redirectRaw = rest.redirect_to;
278
- delete rest.redirect_to;
279
- let redirectTo;
280
- if (redirectRaw !== void 0 && redirectRaw !== null && redirectRaw !== "") {
281
- if (typeof redirectRaw !== "string") {
282
- issues.push({
283
- field: "redirect_to",
284
- message: "redirect_to must be a string path",
285
- level: "error"
286
- });
287
- } else if (!pathTemplate) {
288
- issues.push({
289
- field: "redirect_to",
290
- message: "redirect_to is not allowed on reference-only content types",
291
- level: "error"
292
- });
293
- } else if (!extractSlugFromResolvedPath(pathTemplate, redirectRaw)) {
294
- issues.push({
295
- field: "redirect_to",
296
- message: `redirect_to must match path template "${pathTemplate}" (prefix "${pathPrefix(pathTemplate)}"${pathSuffix(pathTemplate) ? `, suffix "${pathSuffix(pathTemplate)}"` : ""})`,
297
- level: "error"
298
- });
299
- } else {
300
- redirectTo = redirectRaw;
301
- }
302
- }
303
- const aliases = aliasesResult.success ? aliasesResult.data : [];
304
- if (!aliasesResult.success) {
305
- for (const issue of aliasesResult.error.issues) {
306
- issues.push({
307
- field: `aliases.${issue.path.join(".")}`,
308
- message: issue.message,
309
- level: "error"
310
- });
343
+ for (const [field, message] of DEPRECATED_REDIRECT_FIELDS) {
344
+ if (rest[field] !== void 0) {
345
+ issues.push({ field, message, level: "error" });
346
+ delete rest[field];
311
347
  }
312
348
  }
313
349
  let publishedAt;
@@ -369,8 +405,6 @@ function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
369
405
  }
370
406
  return {
371
407
  builtin: {
372
- aliases,
373
- redirectTo,
374
408
  publishedAt,
375
409
  updatedAt,
376
410
  noindex,
@@ -386,20 +420,24 @@ function documentLastModified(doc) {
386
420
  const date = new Date(raw);
387
421
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
388
422
  }
389
- function resolveCanonicalPathname(type, doc, defaultLocale) {
423
+ function resolveCanonicalPathname(type, doc, defaultLocale, localeRouting) {
424
+ const urlBuilder = createUrlBuilder({
425
+ locales: [defaultLocale, ...doc.locale !== defaultLocale ? [doc.locale] : []],
426
+ defaultLocale,
427
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
428
+ });
390
429
  if (doc.canonicalPathOverride) {
391
- if (doc.locale === defaultLocale) return doc.canonicalPathOverride;
392
- return `/${doc.locale}${doc.canonicalPathOverride}`;
430
+ return urlBuilder.applyLocaleToPath(doc.canonicalPathOverride, doc.locale);
393
431
  }
394
432
  if (!type.path) return `/${doc.slug}`;
395
- return resolvePath(type.path, doc.slug, doc.locale, defaultLocale);
433
+ return urlBuilder.resolvePath(type.path, doc.slug, doc.locale);
396
434
  }
397
- function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
435
+ function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale, localeRouting) {
398
436
  const out = { ...frontmatter };
399
437
  if (doc.publishedAt !== void 0) out.publishedAt = doc.publishedAt;
400
438
  if (doc.updatedAt !== void 0) out.updatedAt = doc.updatedAt;
401
439
  out.noindex = doc.noindex;
402
- out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
440
+ out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale, localeRouting);
403
441
  return out;
404
442
  }
405
443
  function seoFieldsFromEn(enDoc) {
@@ -407,8 +445,7 @@ function seoFieldsFromEn(enDoc) {
407
445
  publishedAt: enDoc.publishedAt,
408
446
  updatedAt: enDoc.updatedAt,
409
447
  noindex: enDoc.noindex,
410
- canonicalPathOverride: enDoc.canonicalPathOverride,
411
- redirectTo: enDoc.redirectTo
448
+ canonicalPathOverride: enDoc.canonicalPathOverride
412
449
  };
413
450
  }
414
451
  var SCHEMA_VERSION = 4;
@@ -467,7 +504,7 @@ function resolveStorePath(config) {
467
504
  function openStore(config, mode = "readwrite") {
468
505
  const storePath = resolveStorePath(config);
469
506
  if (mode === "readwrite") {
470
- fs2.mkdirSync(path3.dirname(storePath), { recursive: true });
507
+ fs2.mkdirSync(path.dirname(storePath), { recursive: true });
471
508
  }
472
509
  const db = new Database(storePath, { readonly: mode === "readonly" });
473
510
  if (mode === "readwrite") {
@@ -531,10 +568,10 @@ function isPostFile(name) {
531
568
  }
532
569
  function listEnFiles(contentDir) {
533
570
  if (!fs2.existsSync(contentDir)) return [];
534
- return fs2.readdirSync(contentDir).filter(isPostFile).map((f) => path3.join(contentDir, f));
571
+ return fs2.readdirSync(contentDir).filter(isPostFile).map((f) => path.join(contentDir, f));
535
572
  }
536
573
  function slugFromPath(filePath) {
537
- return path3.basename(filePath).replace(/\.(md|mdx)$/, "");
574
+ return path.basename(filePath).replace(/\.(md|mdx)$/, "");
538
575
  }
539
576
  function parseEnMdx(filePath, config, type) {
540
577
  const raw = fs2.readFileSync(filePath, "utf8");
@@ -580,7 +617,8 @@ function parseEnMdx(filePath, config, type) {
580
617
  locale: config.defaultLocale
581
618
  },
582
619
  type,
583
- config.defaultLocale
620
+ config.defaultLocale,
621
+ config.localeRouting
584
622
  );
585
623
  const crossIssues = type.crossValidate?.(result.data, {
586
624
  locale: config.defaultLocale,
@@ -594,8 +632,6 @@ function parseEnMdx(filePath, config, type) {
594
632
  slug,
595
633
  enSlug: slug,
596
634
  locale: config.defaultLocale,
597
- aliases: builtin.aliases,
598
- redirectTo: builtin.redirectTo,
599
635
  publishedAt: builtin.publishedAt,
600
636
  updatedAt: builtin.updatedAt,
601
637
  noindex: builtin.noindex,
@@ -606,7 +642,7 @@ function parseEnMdx(filePath, config, type) {
606
642
  };
607
643
  return { document, issues };
608
644
  }
609
- function buildDocumentFromTranslation(row, enDoc, type) {
645
+ function buildDocumentFromTranslation(row, enDoc, type, config) {
610
646
  const localeFm = JSON.parse(row.frontmatter_json);
611
647
  const merged = mergeStructuralOntoLocale(localeFm, enDoc.frontmatter, type.schema);
612
648
  const seo = seoFieldsFromEn(enDoc);
@@ -614,14 +650,13 @@ function buildDocumentFromTranslation(row, enDoc, type) {
614
650
  merged,
615
651
  { ...seo, slug: row.slug, locale: row.locale },
616
652
  type,
617
- enDoc.locale
653
+ config.defaultLocale,
654
+ config.localeRouting
618
655
  );
619
656
  return {
620
657
  slug: row.slug,
621
658
  enSlug: row.en_slug,
622
659
  locale: row.locale,
623
- aliases: [],
624
- redirectTo: seo.redirectTo,
625
660
  publishedAt: seo.publishedAt,
626
661
  updatedAt: seo.updatedAt,
627
662
  noindex: seo.noindex,
@@ -635,7 +670,7 @@ function createContentLoader(config, type) {
635
670
  let cached = null;
636
671
  let signature = "";
637
672
  let lastCheck = 0;
638
- const contentDir = path3.join(
673
+ const contentDir = path.join(
639
674
  /* turbopackIgnore: true */
640
675
  config.rootDir,
641
676
  type.contentDir
@@ -699,7 +734,7 @@ function createContentLoader(config, type) {
699
734
  for (const row of rowsByLocale.get(locale) ?? []) {
700
735
  const enDoc = englishBySlug.get(row.en_slug);
701
736
  if (!enDoc) continue;
702
- const doc = buildDocumentFromTranslation(row, enDoc, type);
737
+ const doc = buildDocumentFromTranslation(row, enDoc, type, config);
703
738
  bySlug.set(doc.slug, doc);
704
739
  byEnSlug.set(row.en_slug, doc);
705
740
  }
@@ -725,72 +760,18 @@ function createContentLoader(config, type) {
725
760
  return cached;
726
761
  };
727
762
  }
728
- function readEnDocument(config, type, enSlug) {
729
- const contentDir = path3.join(
730
- /* turbopackIgnore: true */
731
- config.rootDir,
732
- type.contentDir
733
- );
734
- for (const ext of [".mdx", ".md"]) {
735
- const filePath = path3.join(contentDir, `${enSlug}${ext}`);
736
- if (!fs2.existsSync(filePath)) continue;
737
- const { document } = parseEnMdx(filePath, config, type);
738
- return document;
739
- }
740
- return null;
741
- }
742
763
 
743
764
  // src/i18n/resolve-document.ts
744
- function findEnDocByAlias(slug, allDocs, defaultLocale) {
745
- const enIdx = allDocs.get(defaultLocale);
746
- if (!enIdx) return null;
747
- for (const doc of enIdx.bySlug.values()) {
748
- if (doc.aliases.includes(slug)) return doc;
749
- }
750
- return null;
751
- }
752
- function enCanonicalDoc(doc, allDocs, defaultLocale) {
753
- if (doc.locale === defaultLocale) return doc;
754
- return allDocs.get(defaultLocale)?.bySlug.get(doc.enSlug) ?? doc;
755
- }
756
- function redirectPathForDocument(doc, locale, defaultLocale, allDocs, type) {
757
- if (!type.path) return void 0;
758
- const enDoc = enCanonicalDoc(doc, allDocs, defaultLocale);
759
- if (!enDoc.redirectTo) return void 0;
760
- const targetEnSlug = extractSlugFromResolvedPath(type.path, enDoc.redirectTo);
761
- if (!targetEnSlug) return void 0;
762
- const targetEnDoc = allDocs.get(defaultLocale)?.bySlug.get(targetEnSlug);
763
- const targetSlug = targetEnDoc ? getSlugForLocale(targetEnDoc, defaultLocale, locale, allDocs, defaultLocale) ?? targetEnSlug : targetEnSlug;
764
- return resolvePath(type.path, targetSlug, locale, defaultLocale);
765
- }
766
- function withRedirectTo(result, locale, defaultLocale, allDocs, type) {
767
- if (result.shouldRedirectTo || !result.document) return result;
768
- const redirect = redirectPathForDocument(result.document, locale, defaultLocale, allDocs, type);
769
- if (!redirect) return result;
770
- return {
771
- document: null,
772
- actualLocale: result.actualLocale,
773
- shouldRedirectTo: redirect
774
- };
775
- }
776
- function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
765
+ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type, localeRouting) {
766
+ const urlBuilder = createUrlBuilder({
767
+ locales: [defaultLocale, locale],
768
+ defaultLocale,
769
+ localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
770
+ });
777
771
  const idx = allDocs.get(locale);
778
772
  const direct = idx?.bySlug.get(slug);
779
773
  if (direct) {
780
- return withRedirectTo({ document: direct, actualLocale: locale }, locale, defaultLocale, allDocs, type);
781
- }
782
- const aliasDoc = findEnDocByAlias(slug, allDocs, defaultLocale);
783
- if (aliasDoc && type.path) {
784
- const canonicalSlug = aliasDoc.slug;
785
- const localizedSlug = getSlugForLocale(aliasDoc, defaultLocale, locale, allDocs, defaultLocale);
786
- const targetSlug = localizedSlug ?? canonicalSlug;
787
- if (targetSlug !== slug) {
788
- return {
789
- document: null,
790
- actualLocale: locale,
791
- shouldRedirectTo: resolvePath(type.path, targetSlug, locale, defaultLocale)
792
- };
793
- }
774
+ return { document: direct, actualLocale: locale };
794
775
  }
795
776
  for (const [docLocale, docIdx] of allDocs) {
796
777
  const found = docIdx.bySlug.get(slug);
@@ -803,17 +784,11 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
803
784
  return {
804
785
  document: null,
805
786
  actualLocale: locale,
806
- shouldRedirectTo: resolvePath(type.path, correctSlug, locale, defaultLocale)
787
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, correctSlug, locale)
807
788
  };
808
789
  }
809
790
  if (docLocale === defaultLocale) {
810
- return withRedirectTo(
811
- { document: found, actualLocale: defaultLocale },
812
- locale,
813
- defaultLocale,
814
- allDocs,
815
- type
816
- );
791
+ return { document: found, actualLocale: defaultLocale };
817
792
  }
818
793
  if (!type.path) {
819
794
  return { document: null, actualLocale: locale };
@@ -821,39 +796,21 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
821
796
  return {
822
797
  document: null,
823
798
  actualLocale: locale,
824
- shouldRedirectTo: resolvePath(type.path, found.enSlug, locale, defaultLocale)
799
+ shouldRedirectTo: urlBuilder.resolvePath(type.path, found.enSlug, locale)
825
800
  };
826
801
  }
827
802
  if (locale !== defaultLocale) {
828
803
  const enDoc = allDocs.get(defaultLocale)?.bySlug.get(slug);
829
804
  if (enDoc && type.indexFallback === "en") {
830
- return withRedirectTo(
831
- { document: enDoc, actualLocale: defaultLocale },
832
- locale,
833
- defaultLocale,
834
- allDocs,
835
- type
836
- );
805
+ return { document: enDoc, actualLocale: defaultLocale };
837
806
  }
838
807
  const byEn = idx?.byEnSlug.get(slug);
839
808
  if (byEn) {
840
- return withRedirectTo(
841
- { document: byEn, actualLocale: locale },
842
- locale,
843
- defaultLocale,
844
- allDocs,
845
- type
846
- );
809
+ return { document: byEn, actualLocale: locale };
847
810
  }
848
811
  const fallback = allDocs.get(defaultLocale)?.bySlug.get(slug);
849
812
  if (fallback && type.indexFallback === "en") {
850
- return withRedirectTo(
851
- { document: fallback, actualLocale: defaultLocale },
852
- locale,
853
- defaultLocale,
854
- allDocs,
855
- type
856
- );
813
+ return { document: fallback, actualLocale: defaultLocale };
857
814
  }
858
815
  }
859
816
  return { document: null, actualLocale: locale };
@@ -895,6 +852,7 @@ function buildRuntime(config, type, getRuntime) {
895
852
  }
896
853
  return type.path;
897
854
  }
855
+ const urlBuilder = createUrlBuilder(config);
898
856
  const runtime = {
899
857
  id: type.id,
900
858
  config: type,
@@ -910,15 +868,21 @@ function buildRuntime(config, type, getRuntime) {
910
868
  return load().get(locale)?.bySlug.get(slug) ?? null;
911
869
  },
912
870
  resolve(slug, locale) {
913
- const result = resolveLocalizedDocument(slug, locale, config.defaultLocale, load(), type);
871
+ const result = resolveLocalizedDocument(
872
+ slug,
873
+ locale,
874
+ config.defaultLocale,
875
+ load(),
876
+ type,
877
+ config.localeRouting
878
+ );
914
879
  if (result.document && type.path) {
915
880
  return {
916
881
  ...result,
917
- canonicalPath: resolvePath(
882
+ canonicalPath: urlBuilder.resolvePath(
918
883
  type.path,
919
884
  result.document.slug,
920
- result.actualLocale,
921
- config.defaultLocale
885
+ result.actualLocale
922
886
  )
923
887
  };
924
888
  }
@@ -942,10 +906,9 @@ function buildRuntime(config, type, getRuntime) {
942
906
  alternates(doc) {
943
907
  const pathTemplate = assertRoutable("alternates");
944
908
  const out = {
945
- [config.defaultLocale]: resolvePath(
909
+ [config.defaultLocale]: urlBuilder.resolvePath(
946
910
  pathTemplate,
947
911
  doc.enSlug,
948
- config.defaultLocale,
949
912
  config.defaultLocale
950
913
  )
951
914
  };
@@ -954,7 +917,7 @@ function buildRuntime(config, type, getRuntime) {
954
917
  if (locale === config.defaultLocale) continue;
955
918
  const translated = all.get(locale)?.byEnSlug.get(doc.enSlug);
956
919
  if (translated) {
957
- out[locale] = resolvePath(pathTemplate, translated.slug, locale, config.defaultLocale);
920
+ out[locale] = urlBuilder.resolvePath(pathTemplate, translated.slug, locale);
958
921
  }
959
922
  }
960
923
  return out;
@@ -969,7 +932,7 @@ function buildRuntime(config, type, getRuntime) {
969
932
  },
970
933
  url(slug, locale) {
971
934
  const pathTemplate = assertRoutable("url");
972
- return resolvePath(pathTemplate, slug, locale, config.defaultLocale);
935
+ return urlBuilder.resolvePath(pathTemplate, slug, locale);
973
936
  },
974
937
  related(doc, fieldName, locale) {
975
938
  const meta = relationFields.get(fieldName);
@@ -1031,87 +994,150 @@ function createProject(config) {
1031
994
  }
1032
995
  };
1033
996
  }
1034
- function listEnSlugs(rootDir, contentDir) {
1035
- const dir = path3.join(rootDir, contentDir);
1036
- if (!fs2.existsSync(dir)) return [];
1037
- return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
997
+ var redirectFromSchema = z.union([
998
+ slugPatternSchema,
999
+ z.array(slugPatternSchema).min(1).max(20)
1000
+ ]);
1001
+ var typeRedirectEntrySchema = z.object({
1002
+ from: redirectFromSchema,
1003
+ toSlug: slugPatternSchema.optional(),
1004
+ toType: z.string().min(1).optional(),
1005
+ toUrl: z.string().min(1).optional(),
1006
+ permanent: z.boolean().optional()
1007
+ }).superRefine((entry, ctx) => {
1008
+ const hasToSlug = entry.toSlug !== void 0;
1009
+ const hasToUrl = entry.toUrl !== void 0;
1010
+ const hasToType = entry.toType !== void 0;
1011
+ const targetCount = Number(hasToSlug) + Number(hasToUrl);
1012
+ if (targetCount !== 1) {
1013
+ ctx.addIssue({
1014
+ code: "custom",
1015
+ message: "Each redirect must specify exactly one of toSlug/toType+toSlug or toUrl",
1016
+ path: ["toSlug"]
1017
+ });
1018
+ return;
1019
+ }
1020
+ if (hasToUrl && hasToType) {
1021
+ ctx.addIssue({
1022
+ code: "custom",
1023
+ message: "toUrl cannot be combined with toType",
1024
+ path: ["toUrl"]
1025
+ });
1026
+ }
1027
+ if (hasToType && !hasToSlug) {
1028
+ ctx.addIssue({
1029
+ code: "custom",
1030
+ message: "Cross-type redirects require both toType and toSlug",
1031
+ path: ["toSlug"]
1032
+ });
1033
+ }
1034
+ });
1035
+ var typeRedirectsFileSchema = z.object({
1036
+ redirects: z.array(typeRedirectEntrySchema).max(500)
1037
+ });
1038
+ function normalizeRedirectFrom(from) {
1039
+ return Array.isArray(from) ? from : [from];
1040
+ }
1041
+ function parseRedirectEntry(entry) {
1042
+ const fromSlugs = normalizeRedirectFrom(entry.from);
1043
+ if (entry.toUrl !== void 0) {
1044
+ return {
1045
+ fromSlugs,
1046
+ kind: "anywhere",
1047
+ toUrl: entry.toUrl,
1048
+ permanent: entry.permanent ?? true
1049
+ };
1050
+ }
1051
+ if (entry.toType !== void 0) {
1052
+ return {
1053
+ fromSlugs,
1054
+ kind: "cross-type",
1055
+ toType: entry.toType,
1056
+ toSlug: entry.toSlug,
1057
+ permanent: entry.permanent ?? true
1058
+ };
1059
+ }
1060
+ return {
1061
+ fromSlugs,
1062
+ kind: "same-type",
1063
+ toSlug: entry.toSlug,
1064
+ permanent: entry.permanent ?? true
1065
+ };
1038
1066
  }
1039
1067
 
1040
- // src/core/slug-aliases.ts
1041
- function buildGlobalAliasIndex(project) {
1042
- const { config } = project;
1043
- const aliasToTarget = /* @__PURE__ */ new Map();
1044
- const allAliasSlugs = /* @__PURE__ */ new Set();
1045
- const issues = [];
1046
- const canonicalSlugsByType = /* @__PURE__ */ new Map();
1047
- for (const type of config.types) {
1048
- const slugs = listEnSlugs(config.rootDir, type.contentDir);
1049
- canonicalSlugsByType.set(type.id, new Set(slugs));
1068
+ // src/redirects/load-type-redirects.ts
1069
+ var TYPE_REDIRECTS_FILENAME = "_redirects.json";
1070
+ function redirectsFilePath(config, type) {
1071
+ return path.join(config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
1072
+ }
1073
+ function loadTypeRedirectsFile(config, type) {
1074
+ const filePath = redirectsFilePath(config, type);
1075
+ if (!fs2.existsSync(filePath)) return null;
1076
+ let raw;
1077
+ try {
1078
+ raw = JSON.parse(fs2.readFileSync(filePath, "utf8"));
1079
+ } catch (error) {
1080
+ throw new Error(
1081
+ `${type.id}: failed to parse ${TYPE_REDIRECTS_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
1082
+ );
1083
+ }
1084
+ const parsed = typeRedirectsFileSchema.safeParse(raw);
1085
+ if (!parsed.success) {
1086
+ const details = parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ");
1087
+ throw new Error(`${type.id}: invalid ${TYPE_REDIRECTS_FILENAME}: ${details}`);
1050
1088
  }
1089
+ return {
1090
+ contentTypeId: type.id,
1091
+ contentDir: type.contentDir,
1092
+ filePath,
1093
+ entries: parsed.data.redirects.map(parseRedirectEntry)
1094
+ };
1095
+ }
1096
+ function loadAllTypeRedirects(config) {
1097
+ const out = [];
1051
1098
  for (const type of config.types) {
1052
- if (!type.path) continue;
1053
- const canonicalSlugs = canonicalSlugsByType.get(type.id) ?? /* @__PURE__ */ new Set();
1054
- for (const enSlug of canonicalSlugs) {
1055
- const doc = readEnDocument(config, type, enSlug);
1056
- if (!doc) continue;
1057
- for (const alias of doc.aliases) {
1058
- if (alias === enSlug) {
1059
- issues.push({
1060
- contentTypeId: type.id,
1061
- enSlug,
1062
- field: "aliases",
1063
- message: `Alias "${alias}" must not equal the canonical slug`,
1064
- level: "error"
1065
- });
1066
- continue;
1067
- }
1068
- if (canonicalSlugs.has(alias)) {
1069
- issues.push({
1070
- contentTypeId: type.id,
1071
- enSlug,
1072
- field: "aliases",
1073
- message: `Alias "${alias}" collides with another document's canonical slug`,
1074
- level: "error"
1075
- });
1076
- continue;
1077
- }
1078
- const existing = aliasToTarget.get(alias);
1079
- if (existing) {
1080
- issues.push({
1081
- contentTypeId: type.id,
1082
- enSlug,
1083
- field: "aliases",
1084
- message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
1085
- level: "error"
1086
- });
1087
- continue;
1088
- }
1089
- aliasToTarget.set(alias, {
1090
- contentTypeId: type.id,
1091
- canonicalSlug: enSlug,
1092
- path: type.path
1093
- });
1094
- allAliasSlugs.add(alias);
1099
+ const loaded = loadTypeRedirectsFile(config, type);
1100
+ if (loaded) out.push(loaded);
1101
+ }
1102
+ return out;
1103
+ }
1104
+ function collectRedirectSourceSlugs(loaded) {
1105
+ const slugs = /* @__PURE__ */ new Set();
1106
+ for (const file of loaded) {
1107
+ for (const entry of file.entries) {
1108
+ for (const from of entry.fromSlugs) {
1109
+ slugs.add(from);
1110
+ }
1111
+ }
1112
+ }
1113
+ return slugs;
1114
+ }
1115
+ function collectOutboundRedirectSourcesByType(loaded) {
1116
+ const out = /* @__PURE__ */ new Map();
1117
+ for (const file of loaded) {
1118
+ const set = out.get(file.contentTypeId) ?? /* @__PURE__ */ new Set();
1119
+ for (const entry of file.entries) {
1120
+ for (const from of entry.fromSlugs) {
1121
+ set.add(from);
1095
1122
  }
1096
1123
  }
1124
+ out.set(file.contentTypeId, set);
1097
1125
  }
1098
- return { aliasToTarget, allAliasSlugs, issues };
1126
+ return out;
1099
1127
  }
1100
1128
 
1101
1129
  // src/redirects/build-redirects.ts
1102
1130
  function getRedirectSourceSlugs(project) {
1103
- const aliasIndex = buildGlobalAliasIndex(project);
1104
- const outboundByType = /* @__PURE__ */ new Map();
1131
+ const loaded = loadAllTypeRedirects(project.config);
1132
+ const aliasSlugs = collectRedirectSourceSlugs(loaded);
1133
+ const outboundByType = collectOutboundRedirectSourcesByType(loaded);
1105
1134
  for (const type of project.config.types) {
1106
- const outbound = /* @__PURE__ */ new Set();
1107
- for (const enSlug of listEnSlugs(project.config.rootDir, type.contentDir)) {
1108
- const doc = readEnDocument(project.config, type, enSlug);
1109
- if (doc?.redirectTo) outbound.add(enSlug);
1135
+ if (!outboundByType.has(type.id)) {
1136
+ outboundByType.set(type.id, /* @__PURE__ */ new Set());
1110
1137
  }
1111
- outboundByType.set(type.id, outbound);
1112
1138
  }
1113
1139
  return {
1114
- aliasSlugs: aliasIndex.allAliasSlugs,
1140
+ aliasSlugs,
1115
1141
  outboundByType
1116
1142
  };
1117
1143
  }
@@ -1119,13 +1145,12 @@ function getRedirectSourceSlugs(project) {
1119
1145
  // src/sitemap/join-base-url.ts
1120
1146
  function joinBaseUrl(baseUrl, pathname) {
1121
1147
  const origin = baseUrl.replace(/\/$/, "");
1122
- const path5 = pathname.startsWith("/") ? pathname : `/${pathname}`;
1123
- return `${origin}${path5}`;
1148
+ const path6 = pathname.startsWith("/") ? pathname : `/${pathname}`;
1149
+ return `${origin}${path6}`;
1124
1150
  }
1125
1151
 
1126
1152
  // src/sitemap/generate-sitemap.ts
1127
1153
  function shouldIncludeEnDoc(enDoc, enSlug, redirectSources, contentTypeId, excludeNoindex) {
1128
- if (enDoc.redirectTo) return false;
1129
1154
  if (redirectSources.aliasSlugs.has(enSlug)) return false;
1130
1155
  const outbound = redirectSources.outboundByType.get(contentTypeId);
1131
1156
  if (outbound?.has(enSlug)) return false;
@@ -1223,6 +1248,6 @@ function createScribe(input) {
1223
1248
  return scribe;
1224
1249
  }
1225
1250
 
1226
- export { createScribe, isRoutableType };
1251
+ export { createScribe, createUrlBuilder, isRoutableType };
1227
1252
  //# sourceMappingURL=runtime.js.map
1228
1253
  //# sourceMappingURL=runtime.js.map