@mandujs/core 0.28.0 → 0.29.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
+ }