@mandujs/core 0.30.0 → 0.32.0

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.
@@ -619,6 +619,107 @@ function escapeRegex(char: string): string {
619
619
  return /[\\^$.*+?()[\]{}|]/.test(char) ? `\\${char}` : char;
620
620
  }
621
621
 
622
+ // ═══════════════════════════════════════════════════════════════════════════
623
+ // Phase 18.μ — i18n path-prefix route synthesis
624
+ // ═══════════════════════════════════════════════════════════════════════════
625
+
626
+ /**
627
+ * Options for {@link synthesizeLocaleRoutes}. Mirrors the relevant subset
628
+ * of `I18nDefinition` so callers don't need to pull the whole
629
+ * `@mandujs/core/i18n` surface into pure-router code paths.
630
+ */
631
+ export interface LocaleSynthesisOptions {
632
+ /** Allow-list of locale codes to materialize. */
633
+ locales: readonly string[];
634
+ /** Default locale — its routes stay unprefixed (Next.js parity). */
635
+ defaultLocale: string;
636
+ }
637
+
638
+ /**
639
+ * Phase 18.μ — synthesize per-locale route variants at manifest-build
640
+ * time. Given a set of scanned routes, produces `locales.length` copies
641
+ * for every `page` / `api` route with a locale prefix baked into
642
+ * `id` + `pattern`. The default locale's routes are emitted unprefixed
643
+ * (so legacy links keep working and SEO stays intact).
644
+ *
645
+ * The synthesis is pure: it re-uses existing `FSRouteConfig` objects as
646
+ * source of truth, producing *new* objects with:
647
+ *
648
+ * - `pattern` : `/en/blog/:slug`
649
+ * - `id` : `en::<original-id>`
650
+ * - `module` : unchanged (same loader on disk)
651
+ * - everything else: shallow-copied
652
+ *
653
+ * Metadata routes (sitemap/robots/llms-txt/manifest) are NOT duplicated —
654
+ * they always sit at site root regardless of locale (same SEO rule as
655
+ * Next.js).
656
+ *
657
+ * The caller is responsible for passing the output through
658
+ * {@link sortRoutesByPriority} before writing the manifest.
659
+ *
660
+ * @example
661
+ * ```ts
662
+ * const scan = await scanRoutes(rootDir);
663
+ * const prefixed = synthesizeLocaleRoutes(scan.routes, {
664
+ * locales: ["en", "ko"],
665
+ * defaultLocale: "en",
666
+ * });
667
+ * // scan.routes : [/, /blog, /blog/:slug]
668
+ * // prefixed : [/, /blog, /blog/:slug, /ko, /ko/blog, /ko/blog/:slug]
669
+ * ```
670
+ */
671
+ export function synthesizeLocaleRoutes(
672
+ routes: FSRouteConfig[],
673
+ options: LocaleSynthesisOptions
674
+ ): FSRouteConfig[] {
675
+ const { locales, defaultLocale } = options;
676
+ if (!Array.isArray(locales) || locales.length === 0) return [...routes];
677
+ if (!locales.includes(defaultLocale)) {
678
+ throw new Error(
679
+ `[router] synthesizeLocaleRoutes: defaultLocale "${defaultLocale}" not in locales [${locales.join(", ")}]`
680
+ );
681
+ }
682
+
683
+ const out: FSRouteConfig[] = [];
684
+ for (const route of routes) {
685
+ // Metadata routes live at site root; never prefix them.
686
+ if (route.kind === "metadata") {
687
+ out.push(route);
688
+ continue;
689
+ }
690
+
691
+ // Default locale: unprefixed copy preserved verbatim (legacy +
692
+ // SEO neutral).
693
+ out.push(route);
694
+
695
+ for (const locale of locales) {
696
+ if (locale === defaultLocale) continue;
697
+ const prefixed = prefixRouteWithLocale(route, locale);
698
+ out.push(prefixed);
699
+ }
700
+ }
701
+ return out;
702
+ }
703
+
704
+ function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
705
+ const prefixedPattern = route.pattern === "/"
706
+ ? `/${locale}`
707
+ : `/${locale}${route.pattern.startsWith("/") ? route.pattern : `/${route.pattern}`}`;
708
+
709
+ return {
710
+ ...route,
711
+ id: `${locale}::${route.id}`,
712
+ pattern: prefixedPattern,
713
+ // `segments` is used for priority calculation + layout resolution;
714
+ // prepending a static locale segment keeps priority sensible and
715
+ // avoids collisions with real `[param]` segments.
716
+ segments: [
717
+ { raw: locale, type: "static" },
718
+ ...route.segments,
719
+ ],
720
+ };
721
+ }
722
+
622
723
  // ═══════════════════════════════════════════════════════════════════════════
623
724
  // Factory Function
624
725
  // ═══════════════════════════════════════════════════════════════════════════
@@ -68,7 +68,13 @@ export {
68
68
  } from "./fs-patterns";
69
69
 
70
70
  // Scanner
71
- export { FSScanner, createFSScanner, scanRoutes } from "./fs-scanner";
71
+ export {
72
+ FSScanner,
73
+ createFSScanner,
74
+ scanRoutes,
75
+ synthesizeLocaleRoutes,
76
+ type LocaleSynthesisOptions,
77
+ } from "./fs-scanner";
72
78
 
73
79
  // Generator
74
80
  export type { FSGenerateResult, GenerateOptions, RouteChangeCallback, FSRoutesWatcher } from "./fs-routes";