@mandujs/core 0.31.0 → 0.33.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.
Files changed (41) hide show
  1. package/package.json +7 -1
  2. package/src/bundler/build.ts +29 -1
  3. package/src/bundler/generate-static-params.ts +302 -290
  4. package/src/bundler/prerender.ts +446 -368
  5. package/src/bundler/types.ts +20 -0
  6. package/src/config/mandu.ts +58 -1
  7. package/src/config/validate.ts +119 -1
  8. package/src/diagnose/__tests__/checks.test.ts +378 -0
  9. package/src/diagnose/checks.ts +599 -0
  10. package/src/diagnose/index.ts +15 -0
  11. package/src/diagnose/run.ts +87 -0
  12. package/src/diagnose/types.ts +53 -0
  13. package/src/filling/context.ts +60 -0
  14. package/src/guard/check.ts +225 -1
  15. package/src/guard/define-rule.ts +243 -0
  16. package/src/guard/graph.ts +898 -0
  17. package/src/guard/index.ts +40 -0
  18. package/src/guard/rule-presets.ts +379 -0
  19. package/src/i18n/define.ts +126 -0
  20. package/src/i18n/index.ts +52 -0
  21. package/src/i18n/locale-resolver.ts +214 -0
  22. package/src/i18n/message-registry.ts +173 -0
  23. package/src/i18n/types.ts +112 -0
  24. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
  25. package/src/plugins/__tests__/runner.test.ts +409 -0
  26. package/src/plugins/define.ts +124 -0
  27. package/src/plugins/examples/dep-check-plugin.ts +80 -0
  28. package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
  29. package/src/plugins/examples/sitemap-plugin.ts +65 -0
  30. package/src/plugins/hooks.ts +297 -64
  31. package/src/plugins/index.ts +80 -41
  32. package/src/plugins/runner.ts +361 -0
  33. package/src/router/fs-routes.ts +64 -1
  34. package/src/router/fs-scanner.ts +101 -0
  35. package/src/router/index.ts +7 -1
  36. package/src/runtime/server.ts +409 -10
  37. package/src/runtime/ssr.ts +9 -0
  38. package/src/spec/schema.ts +25 -0
  39. package/src/testing/__tests__/reporter.test.ts +454 -0
  40. package/src/testing/index.ts +29 -0
  41. package/src/testing/reporter.ts +676 -0
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Canonical Plugin Hook Runner — Phase 18.τ.
3
+ *
4
+ * This module is the only place where plugin hook dispatch + merge
5
+ * semantics are implemented. Every integration point (bundler, prerender,
6
+ * router scanner, runtime server) imports from here so the rules are
7
+ * consistent and easy to audit.
8
+ *
9
+ * Dispatch order, common to every hook:
10
+ * 1. Config-level hooks (from `ManduConfig.hooks`) run FIRST.
11
+ * 2. Plugin hooks (from `ManduConfig.plugins[].hooks`) run in
12
+ * declaration order.
13
+ * 3. Each invocation is isolated in try/catch — one failure does not
14
+ * block subsequent hooks. Errors are collected in the returned
15
+ * {@link HookRunReport} so callers can surface a single rollup.
16
+ *
17
+ * Merge semantics (per hook type):
18
+ *
19
+ * | Hook | Return type | Merge rule |
20
+ * | ----------------------- | ---------------------- | ------------------- |
21
+ * | `onRouteRegistered` | void | — |
22
+ * | `onBundleComplete` | void | — |
23
+ * | `definePrerenderHook` | PrerenderOverride|void | Object spread |
24
+ * | `onManifestBuilt` | RoutesManifest|void | Pipe (last wins) |
25
+ * | `defineBundlerPlugin` | BunPlugin|BunPlugin[] | Concat |
26
+ * | `defineMiddlewareChain` | Middleware[] | Concat |
27
+ * | `defineTestTransform` | string | Pipe (each sees prev)|
28
+ *
29
+ * @see `docs/architect/plugin-api.md`
30
+ * @see `./hooks.ts` for the typed surface
31
+ */
32
+
33
+ import type { BunPlugin } from "bun";
34
+ import type { Middleware } from "../middleware/define";
35
+ import type { BundleStats } from "../bundler/types";
36
+ import type { RouteSpec, RoutesManifest } from "../spec/schema";
37
+ import type {
38
+ ManduPlugin,
39
+ ManduHooks,
40
+ PluginContext,
41
+ PrerenderContext,
42
+ PrerenderOverride,
43
+ TestTransformContext,
44
+ } from "./hooks";
45
+
46
+ // ═══════════════════════════════════════════════════════════════════════
47
+ // Shared types
48
+ // ═══════════════════════════════════════════════════════════════════════
49
+
50
+ /**
51
+ * Per-hook-invocation error record. Attached to the returned report so
52
+ * callers can log "plugin X failed on hook Y" without swallowing the
53
+ * stack trace.
54
+ */
55
+ export interface HookError {
56
+ /** Hook name that failed. */
57
+ hook: keyof ManduHooks;
58
+ /** Plugin label — either `"config"` or `plugin.name`. */
59
+ source: string;
60
+ /** Captured error. */
61
+ error: Error;
62
+ }
63
+
64
+ export interface HookRunReport<T = unknown> {
65
+ /** Merged result (shape varies by hook — see merge table above). */
66
+ result: T;
67
+ /** Errors swallowed per hook invocation. Empty when all plugins succeeded. */
68
+ errors: HookError[];
69
+ }
70
+
71
+ /**
72
+ * Args common to every runner call.
73
+ */
74
+ export interface RunnerArgs {
75
+ plugins: readonly ManduPlugin[];
76
+ configHooks?: Partial<ManduHooks>;
77
+ }
78
+
79
+ // ═══════════════════════════════════════════════════════════════════════
80
+ // Internal helpers
81
+ // ═══════════════════════════════════════════════════════════════════════
82
+
83
+ /**
84
+ * Yield every `(source, fn)` pair for a given hook name, in the canonical
85
+ * dispatch order: config hook first, then plugin hooks by declaration.
86
+ */
87
+ function* iterateHook<K extends keyof ManduHooks>(
88
+ hookName: K,
89
+ args: RunnerArgs,
90
+ ): Generator<{ source: string; fn: NonNullable<ManduHooks[K]> }> {
91
+ const configFn = args.configHooks?.[hookName];
92
+ if (configFn) {
93
+ yield { source: "config", fn: configFn as NonNullable<ManduHooks[K]> };
94
+ }
95
+ for (const plugin of args.plugins) {
96
+ const pluginFn = plugin.hooks?.[hookName];
97
+ if (pluginFn) {
98
+ yield {
99
+ source: plugin.name,
100
+ fn: pluginFn as NonNullable<ManduHooks[K]>,
101
+ };
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Invoke a hook function and surface any thrown error as a HookError so
108
+ * the caller can carry on with the next plugin. Returns the function's
109
+ * return value (or `undefined` on error).
110
+ */
111
+ async function invokeSafely<R>(
112
+ hookName: keyof ManduHooks,
113
+ source: string,
114
+ thunk: () => R | Promise<R>,
115
+ errors: HookError[],
116
+ ): Promise<R | undefined> {
117
+ try {
118
+ return await thunk();
119
+ } catch (raw) {
120
+ const error = raw instanceof Error ? raw : new Error(String(raw));
121
+ errors.push({ hook: hookName, source, error });
122
+ // Surface to stderr too — matches the legacy `runHook` behaviour so
123
+ // users see the failure even if the caller ignores the report.
124
+ console.error(`[plugin] ${hookName} failed in ${source}: ${error.message}`);
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ // ═══════════════════════════════════════════════════════════════════════
130
+ // Observer runners (void return — "fire and forget")
131
+ // ═══════════════════════════════════════════════════════════════════════
132
+
133
+ /**
134
+ * Fire `onRouteRegistered` for each scanned route. Errors in one plugin
135
+ * don't stop the scan, or even other plugins for the same route.
136
+ */
137
+ export async function runOnRouteRegistered(
138
+ route: RouteSpec,
139
+ args: RunnerArgs,
140
+ ): Promise<HookRunReport<void>> {
141
+ const errors: HookError[] = [];
142
+ for (const { source, fn } of iterateHook("onRouteRegistered", args)) {
143
+ await invokeSafely("onRouteRegistered", source, () => fn(route), errors);
144
+ }
145
+ return { result: undefined, errors };
146
+ }
147
+
148
+ /**
149
+ * Fire `onBundleComplete` with the final BundleStats.
150
+ */
151
+ export async function runOnBundleComplete(
152
+ stats: BundleStats,
153
+ args: RunnerArgs,
154
+ ): Promise<HookRunReport<void>> {
155
+ const errors: HookError[] = [];
156
+ for (const { source, fn } of iterateHook("onBundleComplete", args)) {
157
+ await invokeSafely("onBundleComplete", source, () => fn(stats), errors);
158
+ }
159
+ return { result: undefined, errors };
160
+ }
161
+
162
+ // ═══════════════════════════════════════════════════════════════════════
163
+ // Transformer runners (return-value pipe / spread)
164
+ // ═══════════════════════════════════════════════════════════════════════
165
+
166
+ /**
167
+ * Merge strategy for `definePrerenderHook`:
168
+ *
169
+ * - Each plugin's non-undefined return spreads onto the accumulator so
170
+ * later plugins can override earlier fields (`{ skip: true }` wins
171
+ * unless a later plugin sets `{ skip: false }` explicitly).
172
+ * - An undefined / void return is treated as "no change".
173
+ */
174
+ export async function runDefinePrerenderHook(
175
+ ctx: PrerenderContext,
176
+ args: RunnerArgs,
177
+ ): Promise<HookRunReport<PrerenderOverride>> {
178
+ const errors: HookError[] = [];
179
+ let merged: PrerenderOverride = {};
180
+ for (const { source, fn } of iterateHook("definePrerenderHook", args)) {
181
+ const out = await invokeSafely(
182
+ "definePrerenderHook",
183
+ source,
184
+ () => fn(ctx),
185
+ errors,
186
+ );
187
+ if (out && typeof out === "object") {
188
+ merged = { ...merged, ...out };
189
+ }
190
+ }
191
+ return { result: merged, errors };
192
+ }
193
+
194
+ /**
195
+ * Pipe `onManifestBuilt` across plugins. Each plugin receives the
196
+ * manifest the previous plugin returned. A `void` / `undefined` return
197
+ * means "no change, pass through".
198
+ */
199
+ export async function runOnManifestBuilt(
200
+ manifest: RoutesManifest,
201
+ args: RunnerArgs,
202
+ ): Promise<HookRunReport<RoutesManifest>> {
203
+ const errors: HookError[] = [];
204
+ let current: RoutesManifest = manifest;
205
+ for (const { source, fn } of iterateHook("onManifestBuilt", args)) {
206
+ const out = await invokeSafely(
207
+ "onManifestBuilt",
208
+ source,
209
+ () => fn(current),
210
+ errors,
211
+ );
212
+ if (out && typeof out === "object") {
213
+ current = out;
214
+ }
215
+ }
216
+ return { result: current, errors };
217
+ }
218
+
219
+ /**
220
+ * Collect BunPlugin contributions across every plugin. Scalar returns are
221
+ * wrapped into a single-element array; array returns are concatenated.
222
+ * `undefined` is skipped.
223
+ */
224
+ export async function runDefineBundlerPlugin(
225
+ args: RunnerArgs,
226
+ ): Promise<HookRunReport<BunPlugin[]>> {
227
+ const errors: HookError[] = [];
228
+ const collected: BunPlugin[] = [];
229
+ for (const { source, fn } of iterateHook("defineBundlerPlugin", args)) {
230
+ const out = await invokeSafely(
231
+ "defineBundlerPlugin",
232
+ source,
233
+ () => fn(),
234
+ errors,
235
+ );
236
+ if (!out) continue;
237
+ if (Array.isArray(out)) {
238
+ collected.push(...out);
239
+ } else {
240
+ collected.push(out);
241
+ }
242
+ }
243
+ return { result: collected, errors };
244
+ }
245
+
246
+ /**
247
+ * Concatenate Middleware arrays from every plugin that provides a
248
+ * `defineMiddlewareChain`. The returned array is prepended to the
249
+ * user-declared `ManduConfig.middleware` at server boot time (see
250
+ * `runtime/server.ts`).
251
+ */
252
+ export async function runDefineMiddlewareChain(
253
+ ctx: PluginContext,
254
+ args: RunnerArgs,
255
+ ): Promise<HookRunReport<Middleware[]>> {
256
+ const errors: HookError[] = [];
257
+ const collected: Middleware[] = [];
258
+ for (const { source, fn } of iterateHook("defineMiddlewareChain", args)) {
259
+ const out = await invokeSafely(
260
+ "defineMiddlewareChain",
261
+ source,
262
+ () => fn(ctx),
263
+ errors,
264
+ );
265
+ if (Array.isArray(out)) {
266
+ collected.push(...out);
267
+ }
268
+ }
269
+ return { result: collected, errors };
270
+ }
271
+
272
+ /**
273
+ * Pipe test-file source through every plugin's `defineTestTransform`.
274
+ * Each plugin sees the output of the previous plugin. A thrown error
275
+ * leaves the source unchanged for that step.
276
+ */
277
+ export async function runDefineTestTransform(
278
+ ctx: TestTransformContext,
279
+ args: RunnerArgs,
280
+ ): Promise<HookRunReport<string>> {
281
+ const errors: HookError[] = [];
282
+ let current = ctx.source;
283
+ for (const { source, fn } of iterateHook("defineTestTransform", args)) {
284
+ const out = await invokeSafely(
285
+ "defineTestTransform",
286
+ source,
287
+ () => fn({ testFile: ctx.testFile, source: current }),
288
+ errors,
289
+ );
290
+ if (typeof out === "string") {
291
+ current = out;
292
+ }
293
+ }
294
+ return { result: current, errors };
295
+ }
296
+
297
+ /**
298
+ * Driver-friendly wrapper for `runDefineMiddlewareChain` that returns
299
+ * the merged Middleware[] directly (errors are logged to stderr via
300
+ * the same path as the inner runner). Use when a sync caller like
301
+ * `startServer()` needs the array pre-resolved — wrap the call in
302
+ * `await` from your driver (the CLI), then pass the result in as a
303
+ * PREFIX to `options.middleware`.
304
+ *
305
+ * @example
306
+ * ```ts
307
+ * const pluginMiddleware = await resolvePluginMiddleware({
308
+ * plugins: config.plugins ?? [],
309
+ * configHooks: config.hooks,
310
+ * rootDir: process.cwd(),
311
+ * mode: isDev ? "development" : "production",
312
+ * });
313
+ * startServer(manifest, {
314
+ * ...serverOptions,
315
+ * middleware: [...pluginMiddleware, ...(config.middleware ?? [])],
316
+ * plugins: config.plugins,
317
+ * configHooks: config.hooks,
318
+ * });
319
+ * ```
320
+ */
321
+ export async function resolvePluginMiddleware(input: {
322
+ plugins: readonly ManduPlugin[];
323
+ configHooks?: Partial<ManduHooks>;
324
+ rootDir: string;
325
+ mode: "development" | "production";
326
+ }): Promise<Middleware[]> {
327
+ const ctx: PluginContext = {
328
+ rootDir: input.rootDir,
329
+ mode: input.mode,
330
+ logger: {
331
+ debug: (m, d) => console.debug(`[plugin] ${m}`, d ?? ""),
332
+ info: (m, d) => console.info(`[plugin] ${m}`, d ?? ""),
333
+ warn: (m, d) => console.warn(`[plugin] ${m}`, d ?? ""),
334
+ error: (m, d) => console.error(`[plugin] ${m}`, d ?? ""),
335
+ },
336
+ };
337
+ const report = await runDefineMiddlewareChain(ctx, {
338
+ plugins: input.plugins,
339
+ configHooks: input.configHooks,
340
+ });
341
+ return report.result;
342
+ }
343
+
344
+ // ═══════════════════════════════════════════════════════════════════════
345
+ // Convenience: summarize errors for CLI output
346
+ // ═══════════════════════════════════════════════════════════════════════
347
+
348
+ /**
349
+ * Render an error rollup the CLI can print. Returns `null` when the
350
+ * report is clean — callers should treat `null` as "nothing to log".
351
+ */
352
+ export function formatHookErrors(
353
+ report: HookRunReport<unknown>,
354
+ ): string | null {
355
+ if (report.errors.length === 0) return null;
356
+ const lines = [`[plugin] ${report.errors.length} hook failure(s):`];
357
+ for (const e of report.errors) {
358
+ lines.push(` - ${e.hook} in ${e.source}: ${e.error.message}`);
359
+ }
360
+ return lines.join("\n");
361
+ }
@@ -13,6 +13,11 @@ import type { FSRouteConfig, FSScannerConfig, ScanResult } from "./fs-types";
13
13
  import { DEFAULT_SCANNER_CONFIG } from "./fs-types";
14
14
  import { scanRoutes } from "./fs-scanner";
15
15
  import { loadManduConfig } from "../config";
16
+ import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
17
+ import {
18
+ runOnRouteRegistered,
19
+ runOnManifestBuilt,
20
+ } from "../plugins/runner";
16
21
 
17
22
  // ═══════════════════════════════════════════════════════════════════════════
18
23
  // Types
@@ -41,6 +46,20 @@ export interface GenerateOptions {
41
46
 
42
47
  /** 출력 파일 경로 (지정 시 파일로 저장) */
43
48
  outputPath?: string;
49
+
50
+ /**
51
+ * Phase 18.τ — optional plugins + config-level hooks bundle.
52
+ *
53
+ * When provided, `generateManifest()` fires two plugin hooks:
54
+ * - `onRouteRegistered(route)` — once per scanned route.
55
+ * - `onManifestBuilt(manifest)` — after assembly, BEFORE the
56
+ * manifest is written to disk. A plugin may return a mutated
57
+ * manifest (pipe semantics across plugins).
58
+ *
59
+ * Omitted → zero overhead, identical to pre-τ behaviour.
60
+ */
61
+ plugins?: readonly ManduPlugin[];
62
+ configHooks?: Partial<ManduHooks>;
44
63
  }
45
64
 
46
65
  // ═══════════════════════════════════════════════════════════════════════════
@@ -244,12 +263,36 @@ export async function generateManifest(
244
263
  }
245
264
 
246
265
  // FS Routes 매니페스트 생성
247
- const manifest = scanResultToManifest(scanResult);
266
+ let manifest = scanResultToManifest(scanResult);
248
267
  const warnings: string[] = [];
249
268
 
250
269
  // Auto-linking: spec/slots/, spec/contracts/ 자동 연결
251
270
  await resolveAutoLinks(manifest, rootDir);
252
271
 
272
+ // Phase 18.τ — fire plugin hooks AFTER auto-linking so plugins see the
273
+ // final RouteSpec (including slot/contract/client modules). Both hooks
274
+ // are opt-in; zero-overhead when `options.plugins` / `options.configHooks`
275
+ // are omitted.
276
+ const pluginArgs = {
277
+ plugins: options.plugins ?? [],
278
+ configHooks: options.configHooks,
279
+ };
280
+ if (pluginArgs.plugins.length > 0 || pluginArgs.configHooks) {
281
+ for (const route of manifest.routes) {
282
+ const { errors } = await runOnRouteRegistered(route, pluginArgs);
283
+ for (const e of errors) {
284
+ warnings.push(
285
+ `onRouteRegistered[${e.source}] ${route.id}: ${e.error.message}`
286
+ );
287
+ }
288
+ }
289
+ const manifestReport = await runOnManifestBuilt(manifest, pluginArgs);
290
+ manifest = manifestReport.result;
291
+ for (const e of manifestReport.errors) {
292
+ warnings.push(`onManifestBuilt[${e.source}]: ${e.error.message}`);
293
+ }
294
+ }
295
+
253
296
  // 기존 매니페스트에서 사용자 설정 필드 보존 (clientModule, hydration 등)
254
297
  const outputPath = options.outputPath ?? ".mandu/routes.manifest.json";
255
298
  const outputFullPath = join(rootDir, outputPath);
@@ -272,6 +315,26 @@ export async function generateManifest(
272
315
  if (prev.hydration && !route.hydration) {
273
316
  route.hydration = prev.hydration;
274
317
  }
318
+ // Issue #214 — preserve prerender-time fields (`dynamicParams`,
319
+ // `staticParams`) across manifest rescans. `mandu build`'s prerender
320
+ // phase stamps these onto the manifest; a subsequent `mandu dev`
321
+ // rescan must not wipe them out.
322
+ if (route.kind === "page" && prev.kind === "page") {
323
+ const prevPage = prev as typeof prev & {
324
+ dynamicParams?: boolean;
325
+ staticParams?: unknown[];
326
+ };
327
+ const routePage = route as typeof route & {
328
+ dynamicParams?: boolean;
329
+ staticParams?: unknown[];
330
+ };
331
+ if (prevPage.dynamicParams !== undefined && routePage.dynamicParams === undefined) {
332
+ routePage.dynamicParams = prevPage.dynamicParams;
333
+ }
334
+ if (prevPage.staticParams !== undefined && routePage.staticParams === undefined) {
335
+ routePage.staticParams = prevPage.staticParams;
336
+ }
337
+ }
275
338
  }
276
339
  }
277
340
  } catch {
@@ -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";