@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.
@@ -1,32 +1,71 @@
1
1
  /**
2
2
  * Mandu Prerender Engine
3
- * 빌드 타임에 정적 HTML 생성 (SSG)
3
+ *
4
+ * Build-time static HTML generation (SSG) driven by two signals:
5
+ *
6
+ * 1. Static page routes (no dynamic segments) in the routes manifest.
7
+ * 2. Dynamic page routes whose module exports `generateStaticParams`
8
+ * — see `./generate-static-params.ts` for the contract.
9
+ *
10
+ * For each resolved URL the engine invokes the build's fetch handler
11
+ * (a transient server spun up by `mandu build`) and writes the HTML
12
+ * payload under `.mandu/prerendered/` when callers opt into the new
13
+ * runtime-aware layout, or `.mandu/static/` for legacy callers.
14
+ *
15
+ * When `writeIndex: true` the engine also emits `_manifest.json`
16
+ * alongside the HTML — the runtime consults that index to serve
17
+ * prerendered pages directly with `Cache-Control: immutable`, skipping
18
+ * SSR entirely.
4
19
  */
5
20
 
6
21
  import path from "path";
7
22
  import fs from "fs/promises";
8
- import type { RoutesManifest } from "../spec/schema";
23
+ import type { RoutesManifest, RouteSpec } from "../spec/schema";
24
+ import {
25
+ collectStaticPaths,
26
+ isDynamicPattern,
27
+ type PageModuleWithStaticParams,
28
+ } from "./generate-static-params";
9
29
 
10
30
  // ========== Types ==========
11
31
 
12
32
  export interface PrerenderOptions {
13
- /** 프로젝트 루트 */
33
+ /** Project root — all relative paths resolve from here. */
14
34
  rootDir: string;
15
- /** 출력 디렉토리 (기본: ".mandu/static") */
35
+ /**
36
+ * Output directory (absolute, or relative to `rootDir`).
37
+ * Defaults to `.mandu/static` to preserve behavior for older
38
+ * callers; `mandu build` opts into `.mandu/prerendered` +
39
+ * `writeIndex: true` to enable runtime pass-through.
40
+ */
16
41
  outDir?: string;
17
- /** 프리렌더할 추가 경로 목록 */
42
+ /** Extra URL paths to prerender in addition to the manifest. */
18
43
  routes?: string[];
19
- /** 링크 크롤링으로 자동 발견 (기본: false) */
44
+ /** Follow internal `<a href>` links in rendered HTML (default: false). */
20
45
  crawl?: boolean;
46
+ /**
47
+ * When true, also write `<outDir>/_manifest.json` listing every
48
+ * prerendered pathname. The runtime uses this index to short-circuit
49
+ * dispatch for matching URLs.
50
+ */
51
+ writeIndex?: boolean;
52
+ /**
53
+ * Optional injected `import` function. Tests pass a stub so we can
54
+ * exercise `generateStaticParams` without touching disk; production
55
+ * callers leave this undefined (the default dynamic import is used).
56
+ */
57
+ importModule?: (specifier: string) => Promise<PageModuleWithStaticParams>;
21
58
  }
22
59
 
23
60
  export interface PrerenderResult {
24
- /** 생성된 페이지 */
61
+ /** Number of pages rendered successfully. */
25
62
  generated: number;
26
- /** 생성된 경로별 정보 */
63
+ /** Per-page telemetry. */
27
64
  pages: PrerenderPageResult[];
28
- /** 에러 목록 */
65
+ /** Errors encountered during the run (non-fatal). */
29
66
  errors: string[];
67
+ /** Pathnames that were rendered. */
68
+ paths: string[];
30
69
  }
31
70
 
32
71
  export interface PrerenderPageResult {
@@ -35,16 +74,39 @@ export interface PrerenderPageResult {
35
74
  duration: number;
36
75
  }
37
76
 
77
+ /** Shape of the index file written to `<outDir>/_manifest.json`. */
78
+ export interface PrerenderIndex {
79
+ version: 1;
80
+ generatedAt: string;
81
+ /** Pathname → relative HTML file path (posix separators). */
82
+ pages: Record<string, string>;
83
+ }
84
+
85
+ /** File name used for the runtime index. */
86
+ export const PRERENDER_INDEX_FILE = "_manifest.json";
87
+
88
+ /** Default output directory (runtime-aware location). */
89
+ export const DEFAULT_PRERENDER_DIR = ".mandu/prerendered";
90
+
91
+ /** Default output directory (legacy `prerenderRoutes` callers). */
92
+ export const LEGACY_PRERENDER_DIR = ".mandu/static";
93
+
94
+ /** Default cache policy stamped on runtime prerender responses. */
95
+ export const DEFAULT_PRERENDER_CACHE_CONTROL =
96
+ "public, max-age=31536000, immutable";
97
+
38
98
  // ========== Implementation ==========
39
99
 
40
100
  /**
41
- * 정적 라우트를 HTML로 프리렌더링
101
+ * Prerender the routes declared in a manifest (plus any extras) to
102
+ * static HTML. See `PrerenderOptions` for the full contract.
42
103
  *
43
104
  * @example
44
105
  * ```typescript
45
106
  * const result = await prerenderRoutes(manifest, fetchHandler, {
46
107
  * rootDir: process.cwd(),
47
- * routes: ["/about", "/blog/hello-world"],
108
+ * outDir: ".mandu/prerendered",
109
+ * writeIndex: true,
48
110
  * });
49
111
  * ```
50
112
  */
@@ -53,49 +115,70 @@ export async function prerenderRoutes(
53
115
  fetchHandler: (req: Request) => Promise<Response>,
54
116
  options: PrerenderOptions
55
117
  ): Promise<PrerenderResult> {
56
- const { rootDir, outDir = ".mandu/static", crawl = false } = options;
57
- const outputDir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
118
+ const {
119
+ rootDir,
120
+ outDir = LEGACY_PRERENDER_DIR,
121
+ crawl = false,
122
+ writeIndex = false,
123
+ importModule,
124
+ } = options;
58
125
 
126
+ const outputDir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
59
127
  await fs.mkdir(outputDir, { recursive: true });
60
128
 
61
129
  const pages: PrerenderPageResult[] = [];
62
130
  const errors: string[] = [];
63
131
  const renderedPaths = new Set<string>();
132
+ const pageIndex: Record<string, string> = {};
64
133
 
65
- // 1. 명시적으로 지정된 경로 수집
134
+ // 1. Explicit user-supplied routes.
66
135
  const pathsToRender = new Set<string>(options.routes ?? []);
67
136
 
68
- // 2. 매니페스트에서 정적 페이지 라우트 수집 (동적 파라미터 없는 것)
137
+ // 2. Static page routes (no dynamic segments).
69
138
  for (const route of manifest.routes) {
70
- if (route.kind === "page" && !route.pattern.includes(":")) {
139
+ if (route.kind === "page" && !isDynamicPattern(route.pattern)) {
71
140
  pathsToRender.add(route.pattern);
72
141
  }
73
142
  }
74
143
 
75
- // 3. 동적 라우트의 generateStaticParams 수집
144
+ // 3. Dynamic routes that export `generateStaticParams`.
145
+ const resolveModule =
146
+ importModule ?? ((specifier: string) => import(specifier));
147
+
76
148
  for (const route of manifest.routes) {
77
- if (route.kind === "page" && route.pattern.includes(":")) {
78
- try {
79
- const modulePath = path.join(rootDir, route.module).replace(/\\/g, "/");
80
- const mod = await import(modulePath);
81
- if (typeof mod.generateStaticParams === "function") {
82
- const paramSets = await mod.generateStaticParams();
83
- if (Array.isArray(paramSets)) {
84
- for (const params of paramSets) {
85
- const resolvedPath = resolvePattern(route.pattern, params);
86
- pathsToRender.add(resolvedPath);
87
- }
88
- } else if (paramSets) {
89
- console.warn(`[Mandu Prerender] generateStaticParams() for ${route.pattern} returned non-array. Expected an array of param objects.`);
90
- }
91
- }
92
- } catch {
93
- // generateStaticParams 없으면 스킵
94
- }
149
+ if (route.kind !== "page" || !isDynamicPattern(route.pattern)) continue;
150
+
151
+ let mod: PageModuleWithStaticParams;
152
+ try {
153
+ mod = await loadPageModule(rootDir, route, resolveModule);
154
+ } catch {
155
+ // Module failed to load entirely. Silent skip — the page may
156
+ // simply not opt into static params; SSR can still serve it.
157
+ continue;
158
+ }
159
+
160
+ if (typeof mod.generateStaticParams !== "function") {
161
+ // Not opted-in for this route perfectly fine.
162
+ continue;
163
+ }
164
+
165
+ try {
166
+ const { paths, errors: paramErrors } = await collectStaticPaths(
167
+ route.pattern,
168
+ mod
169
+ );
170
+ for (const p of paths) pathsToRender.add(p);
171
+ for (const e of paramErrors) errors.push(`[${route.pattern}] ${e}`);
172
+ } catch (error) {
173
+ // User code threw. Surface the error but keep going — other
174
+ // routes should not be blocked by one buggy generator.
175
+ errors.push(
176
+ `[${route.pattern}] generateStaticParams threw: ${describeError(error)}`
177
+ );
95
178
  }
96
179
  }
97
180
 
98
- // 4. 경로를 렌더링
181
+ // 4. Render every queued path.
99
182
  for (const pathname of pathsToRender) {
100
183
  if (renderedPaths.has(pathname)) continue;
101
184
  renderedPaths.add(pathname);
@@ -118,10 +201,11 @@ export async function prerenderRoutes(
118
201
 
119
202
  const duration = Date.now() - start;
120
203
  pages.push({ path: pathname, size: html.length, duration });
204
+ pageIndex[pathname] = toPosix(path.relative(outputDir, filePath));
121
205
 
122
- // 5. 크롤링: 생성된 HTML에서 내부 링크 추출
206
+ // 5. Optional crawl harvest internal links for next pass.
123
207
  if (crawl) {
124
- const links = extractInternalLinks(html, pathname);
208
+ const links = extractInternalLinks(html);
125
209
  for (const link of links) {
126
210
  if (!renderedPaths.has(link) && !pathsToRender.has(link)) {
127
211
  pathsToRender.add(link);
@@ -129,67 +213,156 @@ export async function prerenderRoutes(
129
213
  }
130
214
  }
131
215
  } catch (error) {
132
- const message = error instanceof Error ? error.message : String(error);
133
- errors.push(`[${pathname}] ${message}`);
216
+ errors.push(`[${pathname}] ${describeError(error)}`);
134
217
  }
135
218
  }
136
219
 
137
- return { generated: pages.length, pages, errors };
220
+ // 6. Emit runtime index.
221
+ if (writeIndex) {
222
+ const indexContents: PrerenderIndex = {
223
+ version: 1,
224
+ generatedAt: new Date().toISOString(),
225
+ pages: pageIndex,
226
+ };
227
+ await fs.writeFile(
228
+ path.join(outputDir, PRERENDER_INDEX_FILE),
229
+ JSON.stringify(indexContents, null, 2),
230
+ "utf-8"
231
+ );
232
+ }
233
+
234
+ return {
235
+ generated: pages.length,
236
+ pages,
237
+ errors,
238
+ paths: pages.map((p) => p.path),
239
+ };
138
240
  }
139
241
 
140
- // ========== Helpers ==========
242
+ /**
243
+ * Load the prerender manifest index emitted under `outDir`. Returns
244
+ * `null` if it doesn't exist or can't be parsed — callers should
245
+ * treat that as "no prerendered content" rather than an error.
246
+ */
247
+ export async function loadPrerenderIndex(
248
+ rootDir: string,
249
+ outDir: string = DEFAULT_PRERENDER_DIR
250
+ ): Promise<PrerenderIndex | null> {
251
+ const dir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
252
+ const file = path.join(dir, PRERENDER_INDEX_FILE);
253
+ try {
254
+ const contents = await fs.readFile(file, "utf-8");
255
+ const parsed = JSON.parse(contents) as PrerenderIndex;
256
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !parsed.pages) {
257
+ return null;
258
+ }
259
+ return parsed;
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
141
264
 
142
265
  /**
143
- * 라우트 패턴에 파라미터를 대입하여 실제 경로 생성
266
+ * Resolve a pathname against a loaded index. Returns the absolute
267
+ * file path of the prerendered HTML, or `null` on miss.
268
+ *
269
+ * Tolerates both `/foo` and `/foo/` forms, and an optional `.html`
270
+ * suffix. Path-traversal in the index value is defensively rejected
271
+ * so a hand-edited / malicious index cannot escape the output root.
144
272
  */
145
- function resolvePattern(pattern: string, params: Record<string, string>): string {
146
- let result = pattern;
147
- for (const [key, value] of Object.entries(params)) {
148
- // catch-all (:param*) / optional catch-all (:param*?) 지원
149
- const paramRegex = new RegExp(`:${key}\\*\\??`);
150
- if (paramRegex.test(result)) {
151
- // catch-all: 세그먼트를 개별 인코딩 (슬래시 보존)
152
- const encoded = value.split("/").map(encodeURIComponent).join("/");
153
- result = result.replace(paramRegex, encoded);
154
- } else {
155
- result = result.replace(`:${key}`, encodeURIComponent(value));
273
+ export function resolvePrerenderedFile(
274
+ index: PrerenderIndex,
275
+ rootDir: string,
276
+ outDir: string,
277
+ pathname: string
278
+ ): string | null {
279
+ const dir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
280
+ const candidates = [pathname];
281
+ if (pathname.length > 1 && pathname.endsWith("/")) {
282
+ candidates.push(pathname.slice(0, -1));
283
+ } else if (pathname !== "/") {
284
+ candidates.push(pathname + "/");
285
+ }
286
+ if (pathname.endsWith(".html")) {
287
+ candidates.push(pathname.slice(0, -".html".length));
288
+ }
289
+ for (const candidate of candidates) {
290
+ const rel = index.pages[candidate];
291
+ if (rel) {
292
+ const resolved = path.resolve(dir, rel);
293
+ const normalizedDir = path.resolve(dir) + path.sep;
294
+ if (resolved === path.resolve(dir) || resolved.startsWith(normalizedDir)) {
295
+ return resolved;
296
+ }
156
297
  }
157
298
  }
158
- return result;
299
+ return null;
159
300
  }
160
301
 
302
+ // ========== Helpers ==========
303
+
161
304
  /**
162
- * 출력 파일 경로 생성
163
- * /about .mandu/static/about/index.html
164
- * / .mandu/static/index.html
305
+ * Dynamic-import a page module given its declared `module` path in
306
+ * the manifest. Normalizes the path for Windows dynamic-import
307
+ * (forward slashes + absolute) before delegating.
308
+ */
309
+ async function loadPageModule(
310
+ rootDir: string,
311
+ route: RouteSpec,
312
+ importFn: (specifier: string) => Promise<PageModuleWithStaticParams>
313
+ ): Promise<PageModuleWithStaticParams> {
314
+ const absolute = path.isAbsolute(route.module)
315
+ ? route.module
316
+ : path.join(rootDir, route.module);
317
+ const specifier = absolute.replace(/\\/g, "/");
318
+ return importFn(specifier);
319
+ }
320
+
321
+ /**
322
+ * URL path → output file path.
323
+ * / → <outDir>/index.html
324
+ * /about → <outDir>/about/index.html (clean URL)
325
+ * /blog/a/b → <outDir>/blog/a/b/index.html
165
326
  */
166
327
  function getOutputPath(outDir: string, pathname: string): string {
167
328
  const trimmed = pathname === "/" ? "/" : pathname.replace(/\/+$/, "");
168
329
  if (trimmed === "/") return path.join(outDir, "index.html");
169
- // /blog/post .mandu/static/blog/post/index.html (clean URL)
170
- return path.join(outDir, trimmed, "index.html");
330
+ // Decode percent-encoding so on-disk names are stable across platforms.
331
+ const decoded = trimmed
332
+ .split("/")
333
+ .map((segment) => {
334
+ try {
335
+ return decodeURIComponent(segment);
336
+ } catch {
337
+ return segment;
338
+ }
339
+ })
340
+ .join("/");
341
+ return path.join(outDir, decoded, "index.html");
171
342
  }
172
343
 
173
- /**
174
- * HTML에서 내부 링크 추출 (크롤링용)
175
- */
176
- function extractInternalLinks(html: string, currentPath: string): string[] {
344
+ /** Extract absolute internal `<a href>` paths (same-origin only). */
345
+ function extractInternalLinks(html: string): string[] {
177
346
  const links: string[] = [];
178
347
  const hrefRegex = /href=["']([^"']+)["']/g;
179
348
  let match: RegExpExecArray | null;
180
-
181
349
  while ((match = hrefRegex.exec(html)) !== null) {
182
350
  const href = match[1];
183
- // 내부 링크만 (절대 경로이면서 프로토콜 없는 것)
184
351
  if (href.startsWith("/") && !href.startsWith("//")) {
185
- // 쿼리스트링/해시 제거
186
352
  const cleanPath = href.split("?")[0].split("#")[0];
187
- // 정적 파일 제외
188
353
  if (!cleanPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/)) {
189
354
  links.push(cleanPath);
190
355
  }
191
356
  }
192
357
  }
193
-
194
358
  return [...new Set(links)];
195
359
  }
360
+
361
+ function toPosix(p: string): string {
362
+ return p.replace(/\\/g, "/");
363
+ }
364
+
365
+ function describeError(error: unknown): string {
366
+ if (error instanceof Error) return error.message;
367
+ return String(error);
368
+ }