@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.
- package/package.json +7 -1
- package/src/bundler/build.ts +29 -1
- package/src/bundler/generate-static-params.ts +302 -290
- package/src/bundler/prerender.ts +446 -368
- package/src/bundler/types.ts +20 -0
- package/src/config/mandu.ts +58 -1
- package/src/config/validate.ts +119 -1
- package/src/diagnose/__tests__/checks.test.ts +378 -0
- package/src/diagnose/checks.ts +599 -0
- package/src/diagnose/index.ts +15 -0
- package/src/diagnose/run.ts +87 -0
- package/src/diagnose/types.ts +53 -0
- package/src/filling/context.ts +60 -0
- package/src/guard/check.ts +225 -1
- package/src/guard/define-rule.ts +243 -0
- package/src/guard/graph.ts +898 -0
- package/src/guard/index.ts +40 -0
- package/src/guard/rule-presets.ts +379 -0
- package/src/i18n/define.ts +126 -0
- package/src/i18n/index.ts +52 -0
- package/src/i18n/locale-resolver.ts +214 -0
- package/src/i18n/message-registry.ts +173 -0
- package/src/i18n/types.ts +112 -0
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
- package/src/plugins/__tests__/runner.test.ts +409 -0
- package/src/plugins/define.ts +124 -0
- package/src/plugins/examples/dep-check-plugin.ts +80 -0
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
- package/src/plugins/examples/sitemap-plugin.ts +65 -0
- package/src/plugins/hooks.ts +297 -64
- package/src/plugins/index.ts +80 -41
- package/src/plugins/runner.ts +361 -0
- package/src/router/fs-routes.ts +64 -1
- package/src/router/fs-scanner.ts +101 -0
- package/src/router/index.ts +7 -1
- package/src/runtime/server.ts +409 -10
- package/src/runtime/ssr.ts +9 -0
- package/src/spec/schema.ts +25 -0
- package/src/testing/__tests__/reporter.test.ts +454 -0
- package/src/testing/index.ts +29 -0
- 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
|
+
}
|
package/src/router/fs-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -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
|
// ═══════════════════════════════════════════════════════════════════════════
|
package/src/router/index.ts
CHANGED
|
@@ -68,7 +68,13 @@ export {
|
|
|
68
68
|
} from "./fs-patterns";
|
|
69
69
|
|
|
70
70
|
// Scanner
|
|
71
|
-
export {
|
|
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";
|