@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.
- package/package.json +1 -1
- package/src/bundler/build.ts +26 -2
- package/src/bundler/generate-static-params.ts +290 -0
- package/src/bundler/index.ts +1 -0
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -0
- package/src/bundler/plugins/block-generated-imports.ts +155 -0
- package/src/bundler/plugins/index.ts +63 -0
- package/src/bundler/prerender.ts +242 -69
- package/src/bundler/types.ts +8 -0
- package/src/client/hydrate.ts +340 -0
- package/src/client/index.ts +11 -0
- package/src/config/mandu.ts +56 -0
- package/src/config/validate.ts +42 -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/guard/check.ts +36 -6
- 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
|
@@ -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
|
+
}
|
package/src/bundler/prerender.ts
CHANGED
|
@@ -1,32 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mandu Prerender Engine
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
57
|
-
|
|
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
|
|
139
|
+
if (route.kind === "page" && !isDynamicPattern(route.pattern)) {
|
|
71
140
|
pathsToRender.add(route.pattern);
|
|
72
141
|
}
|
|
73
142
|
}
|
|
74
143
|
|
|
75
|
-
// 3.
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
206
|
+
// 5. Optional crawl — harvest internal links for next pass.
|
|
123
207
|
if (crawl) {
|
|
124
|
-
const links = extractInternalLinks(html
|
|
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
|
-
|
|
133
|
-
errors.push(`[${pathname}] ${message}`);
|
|
216
|
+
errors.push(`[${pathname}] ${describeError(error)}`);
|
|
134
217
|
}
|
|
135
218
|
}
|
|
136
219
|
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
299
|
+
return null;
|
|
159
300
|
}
|
|
160
301
|
|
|
302
|
+
// ========== Helpers ==========
|
|
303
|
+
|
|
161
304
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
-
//
|
|
170
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/bundler/types.ts
CHANGED
|
@@ -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
|
}
|