@mandujs/core 0.27.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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Bun bundler plugin — hard-fail on direct `__generated__/` imports.
3
+ *
4
+ * Background
5
+ * ──────────
6
+ * The Guard rule `INVALID_GENERATED_IMPORT` (see `guard/check.ts`) already
7
+ * scans source files for literal `import … from '…generated…'` statements,
8
+ * but it only runs when the user (or CI) invokes `mandu guard check`.
9
+ * Autonomous coding agents routinely bypass that step. This plugin closes
10
+ * the gap at the bundler level: every `mandu dev` / `mandu build` pass
11
+ * installs it by default, and any import whose specifier contains
12
+ * `__generated__` fails the build with a structured, actionable error.
13
+ *
14
+ * Design
15
+ * ──────
16
+ * - `onResolve({ filter: /__generated__/ })` — Bun hands us every import
17
+ * whose *specifier* matches the regex, along with the importer's path
18
+ * (`args.importer`). We never return a result; we always throw.
19
+ * - The error is `ForbiddenGeneratedImportError`, a named subclass of
20
+ * `Error`. Tests can `instanceof`-check; Bun surfaces `error.message` in
21
+ * its `result.logs` output for CLI display.
22
+ * - The message is built via the shared Guard helper
23
+ * (`buildForbiddenGeneratedImportMessage`) so the bundler path and the
24
+ * static Guard pass cannot drift out of sync.
25
+ *
26
+ * Legitimate escape hatches
27
+ * ─────────────────────────
28
+ * 1. `getGenerated()` / `tryGetGenerated()` from `@mandujs/core/runtime`
29
+ * read through a global manifest slot (`__MANDU_MANIFEST__`). They do
30
+ * NOT trigger an ESM import for the generated artifact, so they never
31
+ * hit this plugin. That is the officially supported API.
32
+ * 2. `import type` statements are allowed by the rule — TS erases them
33
+ * before emit, so they never become runtime imports. However, a
34
+ * bundler `onResolve` hook cannot distinguish `import type` from a
35
+ * value import because Bun strips the `type` keyword before plugin
36
+ * dispatch. For this reason the plugin exposes an `allowImporter`
37
+ * option (defaulted to recognise `@mandujs/core/runtime` internals)
38
+ * but deliberately does NOT try to parse the source for `type`-only
39
+ * imports. User type imports go through the type-checker, not the
40
+ * bundler, so they remain unaffected in practice.
41
+ * 3. The per-project opt-out lives in `ManduConfig.guard.blockGeneratedImport
42
+ * = false`. The plugin is simply not installed when the flag is off.
43
+ */
44
+
45
+ import type { BunPlugin } from "bun";
46
+ import {
47
+ buildForbiddenGeneratedImportMessage,
48
+ FORBIDDEN_GENERATED_IMPORT_SUGGESTION,
49
+ GENERATED_IMPORT_DOCS_URL,
50
+ } from "../../guard/check";
51
+
52
+ /**
53
+ * Raised by the plugin's `onResolve` hook. Named so tests can
54
+ * `instanceof`-check, and so Bun's log output clearly attributes the
55
+ * failure to the plugin.
56
+ */
57
+ export class ForbiddenGeneratedImportError extends Error {
58
+ /** The literal `from "…"` specifier that tripped the guard. */
59
+ readonly specifier: string;
60
+ /** Absolute path of the file that issued the import (best-effort). */
61
+ readonly importer: string;
62
+ /** Docs URL that explains the official replacement. */
63
+ readonly docsUrl: string;
64
+ /** Short, one-line remediation hint. */
65
+ readonly suggestion: string;
66
+
67
+ constructor(specifier: string, importer: string) {
68
+ const message =
69
+ `${buildForbiddenGeneratedImportMessage(specifier)}\n` +
70
+ ` Importer: ${importer || "<unknown>"}\n` +
71
+ ` Replacement: import { getGenerated } from "@mandujs/core/runtime";\n` +
72
+ ` Then: const data = getGenerated(<key>);\n` +
73
+ ` Docs: ${GENERATED_IMPORT_DOCS_URL}`;
74
+ super(message);
75
+ this.name = "ForbiddenGeneratedImportError";
76
+ this.specifier = specifier;
77
+ this.importer = importer;
78
+ this.docsUrl = GENERATED_IMPORT_DOCS_URL;
79
+ this.suggestion = FORBIDDEN_GENERATED_IMPORT_SUGGESTION;
80
+ }
81
+ }
82
+
83
+ export interface BlockGeneratedImportsOptions {
84
+ /**
85
+ * Predicate that returns `true` when the importer should be exempted
86
+ * from the rule. Default exempts `@mandujs/core/runtime` (which in
87
+ * principle never imports `__generated__`, but is listed here so
88
+ * framework boot code cannot trip over itself during upgrades).
89
+ */
90
+ allowImporter?: (importerPath: string) => boolean;
91
+ /**
92
+ * Custom filter regex applied to the import specifier. Defaults to
93
+ * `/__generated__/`. Mandu ships a single default — exposing this for
94
+ * test harnesses that want to narrow or broaden the filter.
95
+ */
96
+ filter?: RegExp;
97
+ }
98
+
99
+ /**
100
+ * Default exempt predicate — matches `@mandujs/core/runtime` internals.
101
+ * The runtime package reads generated artifacts via the global registry,
102
+ * so in practice it never imports `__generated__/*`. Kept as a belt-and-
103
+ * suspenders guard against self-inflicted regressions.
104
+ */
105
+ export function defaultAllowImporter(importerPath: string): boolean {
106
+ if (!importerPath) return false;
107
+ // Normalize Windows backslashes so a single check covers both platforms.
108
+ const norm = importerPath.replace(/\\/g, "/");
109
+ return (
110
+ norm.includes("/@mandujs/core/runtime/") ||
111
+ norm.includes("/packages/core/src/runtime/") ||
112
+ norm.includes("packages/core/src/runtime/")
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Build a `BunPlugin` that blocks direct `__generated__/` imports.
118
+ *
119
+ * Usage — call from `defaultBundlerPlugins(config)` (see `./index.ts`).
120
+ * Every `safeBuild` / `Bun.build` invocation in Mandu funnels through
121
+ * that helper, so a single install point enforces the rule everywhere.
122
+ */
123
+ export function blockGeneratedImports(
124
+ options: BlockGeneratedImportsOptions = {},
125
+ ): BunPlugin {
126
+ const filter = options.filter ?? /__generated__/;
127
+ const allowImporter = options.allowImporter ?? defaultAllowImporter;
128
+
129
+ return {
130
+ name: "mandu:block-generated-imports",
131
+ setup(build) {
132
+ build.onResolve({ filter }, (args) => {
133
+ // Normalise the specifier so a Windows-style import (which would
134
+ // be exotic but technically legal in some toolchains) is still
135
+ // caught.
136
+ const specifier = args.path;
137
+ const importer = args.importer ?? "";
138
+
139
+ if (allowImporter(importer)) {
140
+ // Internal runtime code gets a pass. Return `undefined` so
141
+ // Bun resolves the path through its normal pipeline.
142
+ return undefined;
143
+ }
144
+
145
+ // Throw a structured error. Bun surfaces `error.message` in
146
+ // `BuildResult.logs` (non-success) or re-throws on an exception
147
+ // path; either way the message reaches the developer.
148
+ throw new ForbiddenGeneratedImportError(specifier, importer);
149
+ });
150
+ },
151
+ };
152
+ }
153
+
154
+ /** Exported for unit-test convenience — keep the filter text assertable. */
155
+ export const DEFAULT_BLOCK_FILTER = /__generated__/;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Bundler-plugin barrel.
3
+ *
4
+ * `defaultBundlerPlugins()` is the single choke point for the plugin
5
+ * set that Mandu installs on every `Bun.build` invocation. Adding a new
6
+ * default-on plugin means adding it here — every call-site in
7
+ * `bundler/build.ts` and `cli/src/util/bun.ts` composes the result of
8
+ * this helper with any build-specific plugins.
9
+ */
10
+
11
+ import type { BunPlugin } from "bun";
12
+ import {
13
+ blockGeneratedImports,
14
+ type BlockGeneratedImportsOptions,
15
+ } from "./block-generated-imports";
16
+
17
+ export {
18
+ blockGeneratedImports,
19
+ ForbiddenGeneratedImportError,
20
+ defaultAllowImporter,
21
+ DEFAULT_BLOCK_FILTER,
22
+ type BlockGeneratedImportsOptions,
23
+ } from "./block-generated-imports";
24
+
25
+ /**
26
+ * Subset of `ManduConfig.guard` consumed by `defaultBundlerPlugins()`.
27
+ * We deliberately don't import the full `ManduConfig` type to keep the
28
+ * plugins module cycle-free.
29
+ */
30
+ export interface DefaultBundlerPluginsConfig {
31
+ guard?: {
32
+ blockGeneratedImport?: boolean;
33
+ };
34
+ }
35
+
36
+ export interface DefaultBundlerPluginsOptions {
37
+ /** Mandu config (only `guard.blockGeneratedImport` is consulted). */
38
+ config?: DefaultBundlerPluginsConfig;
39
+ /** Override options for the block-generated-imports plugin. */
40
+ blockGeneratedImports?: BlockGeneratedImportsOptions;
41
+ }
42
+
43
+ /**
44
+ * Compose Mandu's default plugin list. Current contents:
45
+ *
46
+ * - `mandu:block-generated-imports` — hard-fail on direct
47
+ * `__generated__/` imports. Opt-out via
48
+ * `config.guard.blockGeneratedImport = false`.
49
+ *
50
+ * Always returns a fresh array; callers are free to concat build-local
51
+ * plugins (e.g. `fastRefreshPlugin()` in dev) without mutating the
52
+ * default set.
53
+ */
54
+ export function defaultBundlerPlugins(
55
+ options: DefaultBundlerPluginsOptions = {},
56
+ ): BunPlugin[] {
57
+ const plugins: BunPlugin[] = [];
58
+ const blockEnabled = options.config?.guard?.blockGeneratedImport !== false;
59
+ if (blockEnabled) {
60
+ plugins.push(blockGeneratedImports(options.blockGeneratedImports));
61
+ }
62
+ return plugins;
63
+ }
@@ -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
+ }
@@ -156,4 +156,12 @@ export interface BundlerOptions {
156
156
  * - 안전성: 기존 `.mandu/manifest.json` 이 없으면 자동으로 full build로 fallback.
157
157
  */
158
158
  skipFrameworkBundles?: boolean;
159
+ /**
160
+ * Issue #207 — opt-out for the `mandu:block-generated-imports` bundler
161
+ * plugin. Default `true` (plugin installed on every build). Set
162
+ * `false` to skip installation — mirrors
163
+ * `ManduConfig.guard.blockGeneratedImport`. CLI callers pass the
164
+ * resolved config flag straight through.
165
+ */
166
+ blockGeneratedImport?: boolean;
159
167
  }