@mandujs/core 0.28.0 → 0.29.1
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 +9 -1
- package/src/bundler/generate-static-params.ts +290 -0
- package/src/bundler/prerender.ts +242 -69
- package/src/client/hydrate.ts +340 -0
- package/src/client/index.ts +11 -0
- package/src/config/mandu.ts +40 -0
- package/src/config/validate.ts +36 -0
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -0
- package/src/dev-error-overlay/index.ts +30 -0
- package/src/dev-error-overlay/overlay-client.ts +300 -0
- package/src/dev-error-overlay/overlay-injector.ts +243 -0
- package/src/dev-error-overlay/overlay-styles.ts +52 -0
- package/src/dev-error-overlay/types.ts +66 -0
- package/src/middleware/bridge.ts +147 -0
- package/src/middleware/compose.ts +134 -0
- package/src/middleware/define.ts +132 -0
- package/src/middleware/index.ts +76 -50
- package/src/router/fs-patterns.test.ts +96 -0
- package/src/router/fs-routes.ts +1 -0
- package/src/router/fs-scanner.ts +10 -1
- package/src/router/fs-types.ts +8 -0
- package/src/runtime/server.ts +310 -10
- package/src/runtime/ssr.ts +70 -2
- package/src/spec/schema.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -46,6 +46,14 @@
|
|
|
46
46
|
"./bundler/scenario-matrix": "./src/bundler/scenario-matrix.ts",
|
|
47
47
|
"./bundler/vendor-cache-types": "./src/bundler/vendor-cache-types.ts",
|
|
48
48
|
"./bundler/manifest-schema": "./src/bundler/manifest-schema.ts",
|
|
49
|
+
"./bundler/plugins": "./src/bundler/plugins/index.ts",
|
|
50
|
+
"./bundler/plugins/block-generated-imports": "./src/bundler/plugins/block-generated-imports.ts",
|
|
51
|
+
"./bundler/generate-static-params": "./src/bundler/generate-static-params.ts",
|
|
52
|
+
"./dev-error-overlay": "./src/dev-error-overlay/index.ts",
|
|
53
|
+
"./middleware/compose": "./src/middleware/compose.ts",
|
|
54
|
+
"./middleware/define": "./src/middleware/define.ts",
|
|
55
|
+
"./middleware/bridge": "./src/middleware/bridge.ts",
|
|
56
|
+
"./client/hydrate": "./src/client/hydrate.ts",
|
|
49
57
|
"./runtime/fast-refresh-types": "./src/runtime/fast-refresh-types.ts",
|
|
50
58
|
"./*": "./src/*"
|
|
51
59
|
},
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu — generateStaticParams contract
|
|
3
|
+
*
|
|
4
|
+
* Next.js-style `generateStaticParams` lets page modules enumerate which
|
|
5
|
+
* concrete parameter combinations should be prerendered at build time.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* // app/docs/[slug]/page.tsx
|
|
9
|
+
* export async function generateStaticParams(): Promise<{ slug: string }[]> {
|
|
10
|
+
* return [{ slug: "intro" }, { slug: "quickstart" }];
|
|
11
|
+
* }
|
|
12
|
+
* export default function Page({ params }: { params: { slug: string } }) { ... }
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* This module owns the *contract* side of that feature: introspecting a
|
|
16
|
+
* page module to invoke `generateStaticParams`, validating the shape of
|
|
17
|
+
* the returned param sets against the route's dynamic segments, and
|
|
18
|
+
* resolving each param set into a concrete URL path the prerender
|
|
19
|
+
* orchestrator can request.
|
|
20
|
+
*
|
|
21
|
+
* Router pattern → file-system mapping (kept in sync with
|
|
22
|
+
* `packages/core/src/router/fs-patterns.ts`):
|
|
23
|
+
*
|
|
24
|
+
* - `[slug]` → `:slug` (single required segment)
|
|
25
|
+
* - `[...slug]` → `:slug*` (catch-all, param value is `string[]`)
|
|
26
|
+
* - `[[...slug]]` → `:slug*?` (optional catch-all, param value is
|
|
27
|
+
* `string[]` — empty array resolves to
|
|
28
|
+
* the prefix path with no trailing
|
|
29
|
+
* segment)
|
|
30
|
+
*
|
|
31
|
+
* The prerender orchestrator in `prerender.ts` composes these helpers
|
|
32
|
+
* with a user-supplied fetch handler to materialize HTML on disk.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// Using the same loose pattern vocabulary as the rest of the bundler.
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A single param set as returned from `generateStaticParams`. Scalar
|
|
39
|
+
* params map to a string; catch-all params map to a string array.
|
|
40
|
+
*/
|
|
41
|
+
export type StaticParamSet = Record<string, string | string[]>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A page module shape we care about. We deliberately avoid importing
|
|
45
|
+
* the full page-module type here — this module is invoked from the
|
|
46
|
+
* build orchestrator where modules are dynamic-imported, and the only
|
|
47
|
+
* thing we need is the optional `generateStaticParams` export.
|
|
48
|
+
*/
|
|
49
|
+
export interface PageModuleWithStaticParams {
|
|
50
|
+
generateStaticParams?: () => Promise<StaticParamSet[]> | StaticParamSet[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Structured description of one dynamic segment extracted from a
|
|
55
|
+
* router pattern. Mirrors the `kind` alphabet we support on disk.
|
|
56
|
+
*/
|
|
57
|
+
export interface DynamicSegment {
|
|
58
|
+
name: string;
|
|
59
|
+
kind: "required" | "catchAll" | "optionalCatchAll";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract the dynamic segments from a router pattern.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* extractDynamicSegments("/docs/:slug") // [{name:"slug", kind:"required"}]
|
|
67
|
+
* extractDynamicSegments("/[lang]/:slug") // malformed, see below
|
|
68
|
+
* extractDynamicSegments("/:lang/:slug") // two required
|
|
69
|
+
* extractDynamicSegments("/docs/:slug*") // one catch-all
|
|
70
|
+
* extractDynamicSegments("/docs/:slug*?") // one optional catch-all
|
|
71
|
+
*
|
|
72
|
+
* Only router-style (`:name`, `:name*`, `:name*?`) patterns are
|
|
73
|
+
* recognized — the `[slug]` file-system syntax is normalized to router
|
|
74
|
+
* form at scan time (see `fs-patterns.ts`).
|
|
75
|
+
*/
|
|
76
|
+
export function extractDynamicSegments(pattern: string): DynamicSegment[] {
|
|
77
|
+
const segments: DynamicSegment[] = [];
|
|
78
|
+
// Match :<name><optional-star><optional-question>
|
|
79
|
+
const re = /:([A-Za-z_][A-Za-z0-9_]*)(\*)?(\?)?/g;
|
|
80
|
+
let match: RegExpExecArray | null;
|
|
81
|
+
while ((match = re.exec(pattern)) !== null) {
|
|
82
|
+
const [, name, star, question] = match;
|
|
83
|
+
let kind: DynamicSegment["kind"] = "required";
|
|
84
|
+
if (star && question) kind = "optionalCatchAll";
|
|
85
|
+
else if (star) kind = "catchAll";
|
|
86
|
+
segments.push({ name, kind });
|
|
87
|
+
}
|
|
88
|
+
return segments;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether a router pattern has any dynamic segments at all. */
|
|
92
|
+
export function isDynamicPattern(pattern: string): boolean {
|
|
93
|
+
return extractDynamicSegments(pattern).length > 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate that a param set has the right keys and value kinds for a
|
|
98
|
+
* pattern. Returns a human-readable error message or `null` if OK.
|
|
99
|
+
*
|
|
100
|
+
* Rules:
|
|
101
|
+
* - Every dynamic segment must have a corresponding key in params.
|
|
102
|
+
* (Optional catch-all may be omitted or provided as `[]`.)
|
|
103
|
+
* - `required` segments must map to a non-empty string.
|
|
104
|
+
* - `catchAll`/`optionalCatchAll` segments must map to a `string[]`
|
|
105
|
+
* (or, as a convenience, a single string — interpreted as one
|
|
106
|
+
* segment). Required catch-all must be non-empty.
|
|
107
|
+
*/
|
|
108
|
+
export function validateParamSet(
|
|
109
|
+
pattern: string,
|
|
110
|
+
params: StaticParamSet
|
|
111
|
+
): string | null {
|
|
112
|
+
const segments = extractDynamicSegments(pattern);
|
|
113
|
+
for (const segment of segments) {
|
|
114
|
+
const value = params[segment.name];
|
|
115
|
+
if (segment.kind === "required") {
|
|
116
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
117
|
+
return `expected string for param "${segment.name}" in pattern "${pattern}", got ${describe(value)}`;
|
|
118
|
+
}
|
|
119
|
+
if (value.includes("/")) {
|
|
120
|
+
return `param "${segment.name}" in pattern "${pattern}" must not contain "/"; use a catch-all segment ([...${segment.name}])`;
|
|
121
|
+
}
|
|
122
|
+
} else if (segment.kind === "catchAll") {
|
|
123
|
+
if (value === undefined || value === null) {
|
|
124
|
+
return `param "${segment.name}" is required for catch-all pattern "${pattern}"`;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(value)) {
|
|
127
|
+
if (value.length === 0) {
|
|
128
|
+
return `catch-all param "${segment.name}" in pattern "${pattern}" must not be empty; use [[...${segment.name}]] for optional`;
|
|
129
|
+
}
|
|
130
|
+
if (!value.every((v) => typeof v === "string" && v.length > 0)) {
|
|
131
|
+
return `catch-all param "${segment.name}" must be a non-empty string[] (pattern "${pattern}")`;
|
|
132
|
+
}
|
|
133
|
+
} else if (typeof value !== "string" || value.length === 0) {
|
|
134
|
+
return `catch-all param "${segment.name}" in pattern "${pattern}" must be string[] or non-empty string`;
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
// optionalCatchAll — may be absent, `[]`, or a populated array.
|
|
138
|
+
if (value !== undefined && value !== null) {
|
|
139
|
+
if (Array.isArray(value)) {
|
|
140
|
+
if (!value.every((v) => typeof v === "string")) {
|
|
141
|
+
return `optional catch-all "${segment.name}" must be string[] (pattern "${pattern}")`;
|
|
142
|
+
}
|
|
143
|
+
} else if (typeof value !== "string") {
|
|
144
|
+
return `optional catch-all "${segment.name}" must be string[] or string (pattern "${pattern}")`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve a router pattern + param set into a concrete URL path.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* resolvePath("/docs/:slug", { slug: "intro" })
|
|
157
|
+
* // "/docs/intro"
|
|
158
|
+
* resolvePath("/:lang/:slug", { lang: "ko", slug: "intro" })
|
|
159
|
+
* // "/ko/intro"
|
|
160
|
+
* resolvePath("/docs/:path*", { path: ["guide", "advanced"] })
|
|
161
|
+
* // "/docs/guide/advanced"
|
|
162
|
+
* resolvePath("/docs/:path*?", { path: [] })
|
|
163
|
+
* // "/docs"
|
|
164
|
+
* resolvePath("/docs/:path*?", {})
|
|
165
|
+
* // "/docs"
|
|
166
|
+
*
|
|
167
|
+
* Individual segments are URI-encoded, but slashes between catch-all
|
|
168
|
+
* segments are preserved.
|
|
169
|
+
*/
|
|
170
|
+
export function resolvePath(pattern: string, params: StaticParamSet): string {
|
|
171
|
+
let result = pattern;
|
|
172
|
+
|
|
173
|
+
// Resolve catch-all (optional + required) first because their regex
|
|
174
|
+
// (`:name*?`, `:name*`) is a superset of the `:name` pattern. Sort by
|
|
175
|
+
// longest first to be extra safe when names overlap.
|
|
176
|
+
const segments = [...extractDynamicSegments(pattern)].sort(
|
|
177
|
+
(a, b) => b.name.length - a.name.length
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
for (const segment of segments) {
|
|
181
|
+
const value = params[segment.name];
|
|
182
|
+
|
|
183
|
+
if (segment.kind === "optionalCatchAll") {
|
|
184
|
+
// Pattern is `/prefix/:name*?` — the leading slash belongs to the
|
|
185
|
+
// prefix and must be elided when the param is empty, otherwise
|
|
186
|
+
// we'd emit `/prefix/`.
|
|
187
|
+
const needle = `/:${segment.name}*?`;
|
|
188
|
+
if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) {
|
|
189
|
+
result = result.replace(needle, "");
|
|
190
|
+
} else {
|
|
191
|
+
const parts = Array.isArray(value) ? value : [value];
|
|
192
|
+
const encoded = parts.map(encodeURIComponent).join("/");
|
|
193
|
+
result = result.replace(needle, `/${encoded}`);
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (segment.kind === "catchAll") {
|
|
199
|
+
const parts = Array.isArray(value) ? value : [String(value)];
|
|
200
|
+
const encoded = parts.map(encodeURIComponent).join("/");
|
|
201
|
+
result = result.replace(`:${segment.name}*`, encoded);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// required — avoid accidentally matching `:slugs` when we wanted
|
|
206
|
+
// `:slug` by using a word-boundary style regex.
|
|
207
|
+
const requiredRe = new RegExp(`:${escapeRegex(segment.name)}(?![A-Za-z0-9_])`);
|
|
208
|
+
result = result.replace(requiredRe, encodeURIComponent(String(value)));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Collapse any accidental double slashes (except the scheme — we have no scheme here).
|
|
212
|
+
result = result.replace(/\/{2,}/g, "/");
|
|
213
|
+
if (result.length > 1 && result.endsWith("/")) result = result.slice(0, -1);
|
|
214
|
+
if (!result.startsWith("/")) result = "/" + result;
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Invoke `generateStaticParams` on a page module and return the list
|
|
220
|
+
* of resolved URL paths. Validates the shape of each param set against
|
|
221
|
+
* the pattern; invalid entries are collected in the `errors` array and
|
|
222
|
+
* *not* included in `paths`.
|
|
223
|
+
*
|
|
224
|
+
* The function never throws for contract-level problems (missing
|
|
225
|
+
* export, non-array return, invalid param shapes). It *does* propagate
|
|
226
|
+
* exceptions thrown from inside the user-supplied function, because
|
|
227
|
+
* those indicate a bug in the user code that the caller (the build
|
|
228
|
+
* orchestrator) should surface loudly.
|
|
229
|
+
*/
|
|
230
|
+
export async function collectStaticPaths(
|
|
231
|
+
pattern: string,
|
|
232
|
+
mod: PageModuleWithStaticParams
|
|
233
|
+
): Promise<{ paths: string[]; errors: string[]; paramSets: StaticParamSet[] }> {
|
|
234
|
+
const errors: string[] = [];
|
|
235
|
+
const paths: string[] = [];
|
|
236
|
+
const paramSets: StaticParamSet[] = [];
|
|
237
|
+
|
|
238
|
+
if (typeof mod.generateStaticParams !== "function") {
|
|
239
|
+
return { paths, errors, paramSets };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const result = await mod.generateStaticParams();
|
|
243
|
+
|
|
244
|
+
if (!Array.isArray(result)) {
|
|
245
|
+
errors.push(
|
|
246
|
+
`generateStaticParams() for "${pattern}" returned ${describe(result)}; expected an array of param objects`
|
|
247
|
+
);
|
|
248
|
+
return { paths, errors, paramSets };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const seen = new Set<string>();
|
|
252
|
+
for (let i = 0; i < result.length; i++) {
|
|
253
|
+
const entry = result[i];
|
|
254
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
255
|
+
errors.push(
|
|
256
|
+
`generateStaticParams()[${i}] for "${pattern}" is not a plain object (${describe(entry)})`
|
|
257
|
+
);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const validationError = validateParamSet(pattern, entry as StaticParamSet);
|
|
261
|
+
if (validationError) {
|
|
262
|
+
errors.push(`generateStaticParams()[${i}] for "${pattern}": ${validationError}`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const resolved = resolvePath(pattern, entry as StaticParamSet);
|
|
266
|
+
if (seen.has(resolved)) {
|
|
267
|
+
// Duplicates are fine — just silently de-dupe. Users often return
|
|
268
|
+
// the same slug from multiple data sources during migrations.
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
seen.add(resolved);
|
|
272
|
+
paths.push(resolved);
|
|
273
|
+
paramSets.push(entry as StaticParamSet);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { paths, errors, paramSets };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------- Internals ----------
|
|
280
|
+
|
|
281
|
+
function describe(value: unknown): string {
|
|
282
|
+
if (value === null) return "null";
|
|
283
|
+
if (value === undefined) return "undefined";
|
|
284
|
+
if (Array.isArray(value)) return `array (length ${value.length})`;
|
|
285
|
+
return typeof value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function escapeRegex(s: string): string {
|
|
289
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
290
|
+
}
|