@mandujs/core 0.26.0 → 0.28.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/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/types.ts +8 -0
- package/src/client/spa-nav-helper.ts +82 -0
- package/src/config/mandu.ts +16 -0
- package/src/config/validate.ts +6 -0
- package/src/guard/check.ts +36 -6
- package/src/runtime/server.ts +26 -0
- package/src/runtime/ssr.ts +12 -0
- package/src/runtime/streaming-ssr.ts +16 -0
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type {
|
|
|
16
16
|
import { HYDRATION } from "../constants";
|
|
17
17
|
import { safeBuild } from "./safe-build";
|
|
18
18
|
import { fastRefreshPlugin } from "./fast-refresh-plugin";
|
|
19
|
+
import { defaultBundlerPlugins } from "./plugins";
|
|
20
|
+
import type { BunPlugin } from "bun";
|
|
19
21
|
import { mark, measure } from "../perf";
|
|
20
22
|
import { HMR_PERF } from "../perf/hmr-markers";
|
|
21
23
|
import {
|
|
@@ -29,6 +31,24 @@ import {
|
|
|
29
31
|
import path from "path";
|
|
30
32
|
import fs from "fs/promises";
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Resolve Mandu's default bundler plugin set from a `BundlerOptions`
|
|
36
|
+
* object. Currently returns the `mandu:block-generated-imports` plugin
|
|
37
|
+
* unless `options.blockGeneratedImport === false`. Centralised here so
|
|
38
|
+
* every `safeBuild(...)` call-site can compose
|
|
39
|
+
* `[...manduDefaultPlugins(options), ...buildLocalPlugins]` and stay in
|
|
40
|
+
* sync as the default set grows.
|
|
41
|
+
*/
|
|
42
|
+
function manduDefaultPlugins(options: BundlerOptions): BunPlugin[] {
|
|
43
|
+
return defaultBundlerPlugins({
|
|
44
|
+
config: {
|
|
45
|
+
guard: {
|
|
46
|
+
blockGeneratedImport: options.blockGeneratedImport,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
/**
|
|
33
53
|
* Scan for *.island.tsx / *.island.ts files across hydrated route directories.
|
|
34
54
|
*
|
|
@@ -121,7 +141,7 @@ async function buildPerIslandBundle(
|
|
|
121
141
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
122
142
|
target: "browser",
|
|
123
143
|
...(isDev ? { reactFastRefresh: true } : {}),
|
|
124
|
-
plugins: isDev ? [fastRefreshPlugin()] : [],
|
|
144
|
+
plugins: [...manduDefaultPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
125
145
|
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
126
146
|
define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
|
|
127
147
|
});
|
|
@@ -1063,6 +1083,7 @@ if (typeof window !== 'undefined') {
|
|
|
1063
1083
|
minify: false, // dev only
|
|
1064
1084
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1065
1085
|
target: "browser",
|
|
1086
|
+
plugins: manduDefaultPlugins(options),
|
|
1066
1087
|
// React를 인라인 번들링 (import map 없이도 독립 동작)
|
|
1067
1088
|
// DevTools는 Shadow DOM 격리 → 앱 React와 충돌 없음
|
|
1068
1089
|
define: {
|
|
@@ -1117,6 +1138,7 @@ async function buildRouterRuntime(
|
|
|
1117
1138
|
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
1118
1139
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1119
1140
|
target: "browser",
|
|
1141
|
+
plugins: manduDefaultPlugins(options),
|
|
1120
1142
|
define: {
|
|
1121
1143
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
1122
1144
|
...options.define,
|
|
@@ -1193,6 +1215,7 @@ async function buildRuntime(
|
|
|
1193
1215
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1194
1216
|
target: "browser",
|
|
1195
1217
|
external: ["react", "react-dom", "react-dom/client"],
|
|
1218
|
+
plugins: manduDefaultPlugins(options),
|
|
1196
1219
|
define: {
|
|
1197
1220
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
1198
1221
|
...options.define,
|
|
@@ -1482,6 +1505,7 @@ async function buildVendorShims(
|
|
|
1482
1505
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1483
1506
|
target: "browser",
|
|
1484
1507
|
external: shimExternal,
|
|
1508
|
+
plugins: manduDefaultPlugins(options),
|
|
1485
1509
|
define: {
|
|
1486
1510
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
1487
1511
|
...options.define,
|
|
@@ -1590,7 +1614,7 @@ async function buildIsland(
|
|
|
1590
1614
|
target: "browser",
|
|
1591
1615
|
splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
|
|
1592
1616
|
...(isDev ? { reactFastRefresh: true } : {}),
|
|
1593
|
-
plugins: isDev ? [fastRefreshPlugin()] : [],
|
|
1617
|
+
plugins: [...manduDefaultPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
1594
1618
|
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
1595
1619
|
define: {
|
|
1596
1620
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
package/src/bundler/index.ts
CHANGED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for `mandu:block-generated-imports` (issue #207).
|
|
3
|
+
*
|
|
4
|
+
* Split into two sections:
|
|
5
|
+
* A — pure unit tests on `ForbiddenGeneratedImportError` + helpers.
|
|
6
|
+
* B — integration tests driving a real `Bun.build` with a synthetic
|
|
7
|
+
* `__generated__/` import in a tmpdir, asserting the build fails
|
|
8
|
+
* with the expected error text.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ForbiddenGeneratedImportError,
|
|
18
|
+
blockGeneratedImports,
|
|
19
|
+
defaultAllowImporter,
|
|
20
|
+
DEFAULT_BLOCK_FILTER,
|
|
21
|
+
defaultBundlerPlugins,
|
|
22
|
+
} from "../index";
|
|
23
|
+
import { GENERATED_IMPORT_DOCS_URL } from "../../../guard/check";
|
|
24
|
+
|
|
25
|
+
// ────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Section A — unit
|
|
27
|
+
// ────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("ForbiddenGeneratedImportError", () => {
|
|
30
|
+
test("captures specifier and importer fields", () => {
|
|
31
|
+
const err = new ForbiddenGeneratedImportError(
|
|
32
|
+
"./__generated__/routes",
|
|
33
|
+
"/repo/src/app.ts",
|
|
34
|
+
);
|
|
35
|
+
expect(err).toBeInstanceOf(Error);
|
|
36
|
+
expect(err).toBeInstanceOf(ForbiddenGeneratedImportError);
|
|
37
|
+
expect(err.specifier).toBe("./__generated__/routes");
|
|
38
|
+
expect(err.importer).toBe("/repo/src/app.ts");
|
|
39
|
+
expect(err.docsUrl).toBe(GENERATED_IMPORT_DOCS_URL);
|
|
40
|
+
expect(err.name).toBe("ForbiddenGeneratedImportError");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("message includes specifier, importer, docs URL, and getGenerated() hint", () => {
|
|
44
|
+
const err = new ForbiddenGeneratedImportError(
|
|
45
|
+
"./__generated__/data",
|
|
46
|
+
"/repo/src/page.tsx",
|
|
47
|
+
);
|
|
48
|
+
expect(err.message).toContain("./__generated__/data");
|
|
49
|
+
expect(err.message).toContain("/repo/src/page.tsx");
|
|
50
|
+
expect(err.message).toContain(GENERATED_IMPORT_DOCS_URL);
|
|
51
|
+
expect(err.message).toContain("getGenerated");
|
|
52
|
+
expect(err.message).toContain('@mandujs/core/runtime');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("message falls back to <unknown> importer when empty", () => {
|
|
56
|
+
const err = new ForbiddenGeneratedImportError("./__generated__/x", "");
|
|
57
|
+
expect(err.message).toContain("<unknown>");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("defaultAllowImporter", () => {
|
|
62
|
+
test("exempts packages/core/src/runtime/** paths", () => {
|
|
63
|
+
expect(
|
|
64
|
+
defaultAllowImporter("/repo/packages/core/src/runtime/registry.ts"),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("normalises Windows backslashes", () => {
|
|
69
|
+
expect(
|
|
70
|
+
defaultAllowImporter(
|
|
71
|
+
"C:\\repo\\packages\\core\\src\\runtime\\registry.ts",
|
|
72
|
+
),
|
|
73
|
+
).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("does NOT exempt regular user code", () => {
|
|
77
|
+
expect(defaultAllowImporter("/repo/src/app.ts")).toBe(false);
|
|
78
|
+
expect(defaultAllowImporter("")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("DEFAULT_BLOCK_FILTER", () => {
|
|
83
|
+
test("matches __generated__ specifiers", () => {
|
|
84
|
+
expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
85
|
+
expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
86
|
+
});
|
|
87
|
+
test("does NOT match look-alikes without double underscores", () => {
|
|
88
|
+
expect("./generated/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
89
|
+
expect("./src/generate/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("defaultBundlerPlugins", () => {
|
|
94
|
+
test("installs block-generated-imports by default", () => {
|
|
95
|
+
const plugins = defaultBundlerPlugins();
|
|
96
|
+
expect(plugins).toHaveLength(1);
|
|
97
|
+
expect(plugins[0]?.name).toBe("mandu:block-generated-imports");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("respects opt-out via guard.blockGeneratedImport === false", () => {
|
|
101
|
+
const plugins = defaultBundlerPlugins({
|
|
102
|
+
config: { guard: { blockGeneratedImport: false } },
|
|
103
|
+
});
|
|
104
|
+
expect(plugins).toHaveLength(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("treats undefined as default-on", () => {
|
|
108
|
+
const plugins = defaultBundlerPlugins({ config: { guard: {} } });
|
|
109
|
+
expect(plugins).toHaveLength(1);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ────────────────────────────────────────────────────────────────────
|
|
114
|
+
// Section B — integration against real Bun.build
|
|
115
|
+
// ────────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Integration tests invoke real `Bun.build`. On shards where the
|
|
119
|
+
* Windows `onResolve` panic is flaky, set `MANDU_SKIP_BUNDLER_TESTS=1`
|
|
120
|
+
* to skip — matches the convention used by `fast-refresh.test.ts`.
|
|
121
|
+
*/
|
|
122
|
+
const SKIP_BUNDLER = process.env.MANDU_SKIP_BUNDLER_TESTS === "1";
|
|
123
|
+
|
|
124
|
+
describe.skipIf(SKIP_BUNDLER)("blockGeneratedImports — Bun.build integration", () => {
|
|
125
|
+
let tmpRoot: string;
|
|
126
|
+
|
|
127
|
+
beforeAll(async () => {
|
|
128
|
+
tmpRoot = await mkdtemp(path.join(tmpdir(), "mandu-block-gen-"));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
afterAll(async () => {
|
|
132
|
+
await rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("Bun.build fails when source imports a __generated__ file", async () => {
|
|
136
|
+
const dir = path.join(tmpRoot, "scenario-block");
|
|
137
|
+
await mkdir(path.join(dir, "__generated__"), { recursive: true });
|
|
138
|
+
await writeFile(
|
|
139
|
+
path.join(dir, "__generated__/data.ts"),
|
|
140
|
+
"export const routes = [];\n",
|
|
141
|
+
);
|
|
142
|
+
await writeFile(
|
|
143
|
+
path.join(dir, "entry.ts"),
|
|
144
|
+
`import { routes } from "./__generated__/data";\nconsole.log(routes);\n`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Bun's plugin host surfaces a thrown onResolve error either as an
|
|
148
|
+
// exception from `Bun.build(...)` OR as an unsuccessful result whose
|
|
149
|
+
// logs contain the message. Accept either shape so the assertion is
|
|
150
|
+
// robust across Bun patch releases.
|
|
151
|
+
// Bun's plugin host surfaces a thrown onResolve error via multiple
|
|
152
|
+
// channels across patch releases: an exception from `Bun.build()`
|
|
153
|
+
// (with `AggregateError.errors[]` on recent versions), or an
|
|
154
|
+
// unsuccessful `result.logs[]`. Collect from every channel and assert
|
|
155
|
+
// the aggregated text.
|
|
156
|
+
const collected: string[] = [];
|
|
157
|
+
try {
|
|
158
|
+
const result = await Bun.build({
|
|
159
|
+
entrypoints: [path.join(dir, "entry.ts")],
|
|
160
|
+
outdir: path.join(dir, "out"),
|
|
161
|
+
target: "browser",
|
|
162
|
+
plugins: [blockGeneratedImports()],
|
|
163
|
+
});
|
|
164
|
+
expect(result.success).toBe(false);
|
|
165
|
+
for (const log of result.logs) {
|
|
166
|
+
collected.push(String(log?.message ?? log));
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err instanceof Error) collected.push(err.message);
|
|
170
|
+
const maybeAgg = err as { errors?: unknown[] };
|
|
171
|
+
if (Array.isArray(maybeAgg.errors)) {
|
|
172
|
+
for (const inner of maybeAgg.errors) {
|
|
173
|
+
if (inner instanceof Error) collected.push(inner.message);
|
|
174
|
+
else collected.push(String(inner));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const message = collected.join("\n");
|
|
180
|
+
expect(message).toContain("__generated__");
|
|
181
|
+
expect(message).toContain(GENERATED_IMPORT_DOCS_URL);
|
|
182
|
+
expect(message).toContain("getGenerated");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("Bun.build succeeds when opt-out is set (no plugin installed)", async () => {
|
|
186
|
+
const dir = path.join(tmpRoot, "scenario-optout");
|
|
187
|
+
await mkdir(path.join(dir, "__generated__"), { recursive: true });
|
|
188
|
+
await writeFile(
|
|
189
|
+
path.join(dir, "__generated__/data.ts"),
|
|
190
|
+
"export const routes = [];\n",
|
|
191
|
+
);
|
|
192
|
+
await writeFile(
|
|
193
|
+
path.join(dir, "entry.ts"),
|
|
194
|
+
`import { routes } from "./__generated__/data";\nconsole.log(routes);\n`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Simulate the opt-out path: defaultBundlerPlugins with the flag off
|
|
198
|
+
// returns an empty plugin list, so the build has nothing to reject it.
|
|
199
|
+
const plugins = defaultBundlerPlugins({
|
|
200
|
+
config: { guard: { blockGeneratedImport: false } },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const result = await Bun.build({
|
|
204
|
+
entrypoints: [path.join(dir, "entry.ts")],
|
|
205
|
+
outdir: path.join(dir, "out"),
|
|
206
|
+
target: "browser",
|
|
207
|
+
plugins,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(result.success).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("Bun.build succeeds when source has no __generated__ imports", async () => {
|
|
214
|
+
const dir = path.join(tmpRoot, "scenario-clean");
|
|
215
|
+
await mkdir(dir, { recursive: true });
|
|
216
|
+
await writeFile(
|
|
217
|
+
path.join(dir, "clean.ts"),
|
|
218
|
+
`export const value = 42;\n`,
|
|
219
|
+
);
|
|
220
|
+
await writeFile(
|
|
221
|
+
path.join(dir, "entry.ts"),
|
|
222
|
+
`import { value } from "./clean";\nconsole.log(value);\n`,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const result = await Bun.build({
|
|
226
|
+
entrypoints: [path.join(dir, "entry.ts")],
|
|
227
|
+
outdir: path.join(dir, "out"),
|
|
228
|
+
target: "browser",
|
|
229
|
+
plugins: [blockGeneratedImports()],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(result.success).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("allowImporter lets whitelisted files import __generated__", async () => {
|
|
236
|
+
const dir = path.join(tmpRoot, "scenario-allow");
|
|
237
|
+
const runtimeDir = path.join(dir, "packages/core/src/runtime");
|
|
238
|
+
await mkdir(path.join(dir, "__generated__"), { recursive: true });
|
|
239
|
+
await mkdir(runtimeDir, { recursive: true });
|
|
240
|
+
await writeFile(
|
|
241
|
+
path.join(dir, "__generated__/data.ts"),
|
|
242
|
+
"export const routes = [];\n",
|
|
243
|
+
);
|
|
244
|
+
// Use a relative import so the filter can see `__generated__` in the
|
|
245
|
+
// specifier and invoke our hook — at which point the importer path
|
|
246
|
+
// matches the default allow-list.
|
|
247
|
+
await writeFile(
|
|
248
|
+
path.join(runtimeDir, "registry.ts"),
|
|
249
|
+
`import { routes } from "../../../../__generated__/data";\nexport { routes };\n`,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const result = await Bun.build({
|
|
253
|
+
entrypoints: [path.join(runtimeDir, "registry.ts")],
|
|
254
|
+
outdir: path.join(dir, "out"),
|
|
255
|
+
target: "browser",
|
|
256
|
+
plugins: [blockGeneratedImports()],
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// The allow predicate lets the import fall through to Bun's default
|
|
260
|
+
// resolution, which succeeds because the file exists.
|
|
261
|
+
expect(result.success).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -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/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
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #208 — Minimal inline SPA-navigation helper.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained IIFE injected into the SSR `<head>` that upgrades plain
|
|
5
|
+
* full-page navigations into client-side `history.pushState` +
|
|
6
|
+
* `fetch` + DOM-swap transitions, without loading any JS bundle.
|
|
7
|
+
*
|
|
8
|
+
* Motivating use case: docs / blog / marketing sites that build with
|
|
9
|
+
* `hydration: "none"` (no islands). Under Issue #193 the opt-out SPA
|
|
10
|
+
* router lives in `@mandujs/core/client` (`router.ts`), which only ships
|
|
11
|
+
* inside a hydration bundle. Zero-JS pages therefore lost the "feels
|
|
12
|
+
* like a SPA" behavior that `spa: true` (the framework default) promises.
|
|
13
|
+
*
|
|
14
|
+
* This helper fills the gap: ~1.6 KB of inline JavaScript that the
|
|
15
|
+
* browser parses and runs immediately, no module graph, no network
|
|
16
|
+
* round-trip. Paired with the `@view-transition { navigation: auto }`
|
|
17
|
+
* style block (#192) the result is a visually-animated pushState
|
|
18
|
+
* navigation on every internal link click.
|
|
19
|
+
*
|
|
20
|
+
* Design constraints (locked — changing any of these needs an explicit
|
|
21
|
+
* rationale in the PR):
|
|
22
|
+
*
|
|
23
|
+
* 1. **Exclusion parity with the full router** (`router.ts`
|
|
24
|
+
* `handleLinkClick`): every browser-owned escape hatch — modifier
|
|
25
|
+
* keys, non-left click, `target` other than `_self`, `download`,
|
|
26
|
+
* `mailto:` / `tel:` / `javascript:` / …, cross-origin, hash-only,
|
|
27
|
+
* no `href`, `data-no-spa`, and `event.defaultPrevented` — is
|
|
28
|
+
* checked here too. Regression matrix lives at
|
|
29
|
+
* `tests/client/spa-nav-helper-exclusions.test.ts`.
|
|
30
|
+
*
|
|
31
|
+
* 2. **Co-existence with the full router**: both handlers listen
|
|
32
|
+
* on `document` `click`. The helper bails out early when
|
|
33
|
+
* `window.__MANDU_ROUTER_STATE__` is present — that global is
|
|
34
|
+
* installed by `initializeRouter()` before it calls
|
|
35
|
+
* `addEventListener`, so on hydrated pages the full router wins.
|
|
36
|
+
* On pure-SSR pages the state global is missing and the helper
|
|
37
|
+
* is authoritative.
|
|
38
|
+
*
|
|
39
|
+
* 3. **View Transitions API** — we call
|
|
40
|
+
* `document.startViewTransition(cb)` when available, mirroring the
|
|
41
|
+
* `@view-transition` at-rule we already inject. Browsers without
|
|
42
|
+
* the API (Firefox, Safari < 18.2) execute the callback
|
|
43
|
+
* synchronously so the feature is a pure progressive enhancement.
|
|
44
|
+
*
|
|
45
|
+
* 4. **DOM swap strategy**: replace `document.body.innerHTML` using
|
|
46
|
+
* the parsed incoming document's `<body>`. This preserves the
|
|
47
|
+
* `<head>` across navigations (avoids re-running inline scripts
|
|
48
|
+
* like this helper) while still picking up `<title>` and
|
|
49
|
+
* `<meta>` changes via a selective head-element merge. We also
|
|
50
|
+
* reset `document.title`.
|
|
51
|
+
*
|
|
52
|
+
* 5. **Inline, not external**: same rationale as #192's prefetch
|
|
53
|
+
* helper — inline removes the extra round-trip on every SSR
|
|
54
|
+
* response, keeps the CSP posture simple (only two inline scripts:
|
|
55
|
+
* prefetch + spa-nav), and sidesteps the "zero-JS but loads one
|
|
56
|
+
* JS file anyway" awkwardness.
|
|
57
|
+
*
|
|
58
|
+
* 6. **Opt-out via `ssr.spa: false`**: the injection site
|
|
59
|
+
* (`ssr.ts::renderToHTML`, `streaming-ssr.ts::generateHTMLShell`)
|
|
60
|
+
* omits the `<script>` block entirely when the user's config sets
|
|
61
|
+
* `spa: false`. No runtime check needed inside the IIFE.
|
|
62
|
+
*
|
|
63
|
+
* The exported `SPA_NAV_HELPER_SCRIPT` wraps the IIFE in a
|
|
64
|
+
* `<script>` tag, ready to paste into `<head>` alongside the prefetch
|
|
65
|
+
* helper and `@view-transition` style block.
|
|
66
|
+
*
|
|
67
|
+
* Size target: ≤3 KB raw (currently ≈2.7 KB after the defensive
|
|
68
|
+
* hardNav / DOMParser-availability guards). If this grows past 3 KB we
|
|
69
|
+
* should revisit the inline-vs-external trade-off.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inner IIFE — exposed for unit tests that want to parse the source.
|
|
74
|
+
*
|
|
75
|
+
* Byte-minified on purpose (no comments, short names). The high-level
|
|
76
|
+
* flow is documented in this file's JSDoc; anyone editing this string
|
|
77
|
+
* MUST update the exclusion-matrix test to match.
|
|
78
|
+
*/
|
|
79
|
+
export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;function hardNav(u){try{L.href=u;}catch(_){}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h||h.charAt(0)==="#")return null;var u;try{u=new URL(h,L.origin);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;return u;}function swap(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(nh&&ch){var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}var nb=doc.body;if(nb)document.body.innerHTML=nb.innerHTML;try{window.scrollTo(0,0);}catch(_){}}catch(_){}}function nav(url,push){fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok||!r.headers.get("content-type")||r.headers.get("content-type").indexOf("text/html")<0){hardNav(url);return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url);return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(_){hardNav(url);return;}if(push){try{H.pushState({mandu:1},"",url);}catch(_){hardNav(url);return;}}var run=function(){swap(doc);try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(_){run();}}else{run();}}).catch(function(){hardNav(url);});}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
|
|
80
|
+
|
|
81
|
+
/** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
|
|
82
|
+
export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
|
package/src/config/mandu.ts
CHANGED
|
@@ -147,6 +147,22 @@ export interface ManduConfig {
|
|
|
147
147
|
realtime?: boolean;
|
|
148
148
|
rules?: Record<string, GuardRuleSeverity>;
|
|
149
149
|
contractRequired?: GuardRuleSeverity;
|
|
150
|
+
/**
|
|
151
|
+
* Issue #207 — hard-fail on direct `__generated__/` imports at the
|
|
152
|
+
* bundler level. When `true` (default), every `mandu dev` /
|
|
153
|
+
* `mandu build` pass installs the
|
|
154
|
+
* `mandu:block-generated-imports` Bun plugin, which throws
|
|
155
|
+
* `ForbiddenGeneratedImportError` the moment any source file
|
|
156
|
+
* resolves an import whose specifier contains `__generated__`.
|
|
157
|
+
*
|
|
158
|
+
* Set to `false` only when a migration path literally requires
|
|
159
|
+
* the legacy barrel re-export pattern. User code should always
|
|
160
|
+
* prefer `getGenerated()` from `@mandujs/core/runtime`; see
|
|
161
|
+
* https://mandujs.com/docs/architect/generated-access.
|
|
162
|
+
*
|
|
163
|
+
* Default: `true`.
|
|
164
|
+
*/
|
|
165
|
+
blockGeneratedImport?: boolean;
|
|
150
166
|
};
|
|
151
167
|
build?: {
|
|
152
168
|
outDir?: string;
|
package/src/config/validate.ts
CHANGED
|
@@ -80,6 +80,12 @@ const GuardConfigSchema = z
|
|
|
80
80
|
exclude: z.array(z.string()).default([]),
|
|
81
81
|
realtime: z.boolean().default(true),
|
|
82
82
|
rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
|
|
83
|
+
/**
|
|
84
|
+
* Issue #207 — bundler-level hard-fail on direct `__generated__/`
|
|
85
|
+
* imports. Default `true`. Set `false` to opt out of the
|
|
86
|
+
* `mandu:block-generated-imports` Bun plugin.
|
|
87
|
+
*/
|
|
88
|
+
blockGeneratedImport: z.boolean().default(true),
|
|
83
89
|
})
|
|
84
90
|
.strict();
|
|
85
91
|
|
package/src/guard/check.ts
CHANGED
|
@@ -12,6 +12,40 @@ export interface GuardCheckResult {
|
|
|
12
12
|
violations: GuardViolation[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Canonical docs URL for the `getGenerated()` runtime-registry pattern.
|
|
17
|
+
* Shared by the Guard `INVALID_GENERATED_IMPORT` rule and the bundler
|
|
18
|
+
* plugin `block-generated-imports` so both paths surface the same
|
|
19
|
+
* remediation target.
|
|
20
|
+
*/
|
|
21
|
+
export const GENERATED_IMPORT_DOCS_URL =
|
|
22
|
+
"https://mandujs.com/docs/architect/generated-access";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the user-facing message for a detected direct `__generated__/`
|
|
26
|
+
* import. `specifier` is the literal import string that tripped the
|
|
27
|
+
* guard (not the resolved path).
|
|
28
|
+
*
|
|
29
|
+
* This helper is the single source of truth for the message text — both
|
|
30
|
+
* the static Guard pass (`checkInvalidGeneratedImport`) and the bundler
|
|
31
|
+
* plugin (`blockGeneratedImports`) call through it so the two paths
|
|
32
|
+
* cannot drift.
|
|
33
|
+
*/
|
|
34
|
+
export function buildForbiddenGeneratedImportMessage(specifier: string): string {
|
|
35
|
+
return (
|
|
36
|
+
`Direct __generated__/ imports are forbidden: ${specifier}. ` +
|
|
37
|
+
`Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Shared remediation hint. Points at `getGenerated()` from
|
|
43
|
+
* `@mandujs/core/runtime` and the decision-tree docs.
|
|
44
|
+
*/
|
|
45
|
+
export const FORBIDDEN_GENERATED_IMPORT_SUGGESTION =
|
|
46
|
+
"Import getGenerated() from @mandujs/core/runtime and read the generated artifact through the manifest. " +
|
|
47
|
+
`See ${GENERATED_IMPORT_DOCS_URL} for the decision tree.`;
|
|
48
|
+
|
|
15
49
|
function normalizeSeverity(level: GuardRuleSeverity): "error" | "warning" | "off" {
|
|
16
50
|
if (level === "warn") return "warning";
|
|
17
51
|
return level;
|
|
@@ -119,12 +153,8 @@ export async function checkInvalidGeneratedImport(
|
|
|
119
153
|
violations.push({
|
|
120
154
|
ruleId: GUARD_RULES.INVALID_GENERATED_IMPORT.id,
|
|
121
155
|
file: relativePath,
|
|
122
|
-
message:
|
|
123
|
-
|
|
124
|
-
`Use the runtime registry: see https://mandujs.com/docs/architect/generated-access`,
|
|
125
|
-
suggestion:
|
|
126
|
-
"Import getGenerated() from @mandujs/core/runtime and read the generated artifact through the manifest. " +
|
|
127
|
-
"See https://mandujs.com/docs/architect/generated-access for the decision tree.",
|
|
156
|
+
message: buildForbiddenGeneratedImportMessage(match[1]),
|
|
157
|
+
suggestion: FORBIDDEN_GENERATED_IMPORT_SUGGESTION,
|
|
128
158
|
});
|
|
129
159
|
}
|
|
130
160
|
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -376,6 +376,17 @@ export interface ServerOptions {
|
|
|
376
376
|
* also opt out via `data-no-prefetch`.
|
|
377
377
|
*/
|
|
378
378
|
prefetch?: boolean;
|
|
379
|
+
/**
|
|
380
|
+
* Issue #193 / #208 — enable opt-out SPA navigation (default `true`).
|
|
381
|
+
* When `true`, every SSR response gets (a) the `window.__MANDU_SPA__`
|
|
382
|
+
* global elided (the router's default) and (b) the inline SPA-nav
|
|
383
|
+
* IIFE (~1.6 KB) that intercepts internal `<a>` clicks with pushState
|
|
384
|
+
* + fetch + View-Transitions DOM-swap, so `hydration: "none"` projects
|
|
385
|
+
* still feel like a SPA. `false` reverts to legacy full-reload (and
|
|
386
|
+
* the full router's opt-in `data-mandu-link` requirement). Wired from
|
|
387
|
+
* `ManduConfig.spa`; per-link opt-out lives on `data-no-spa`.
|
|
388
|
+
*/
|
|
389
|
+
spa?: boolean;
|
|
379
390
|
/**
|
|
380
391
|
* Issue #191 — override dev-mode `_devtools.js` injection.
|
|
381
392
|
* Wired from `ManduConfig.dev.devtools`.
|
|
@@ -527,6 +538,14 @@ export interface ServerRegistrySettings {
|
|
|
527
538
|
* default); `false` suppresses the hover prefetch `<script>` injection.
|
|
528
539
|
*/
|
|
529
540
|
prefetch?: boolean;
|
|
541
|
+
/**
|
|
542
|
+
* Issue #193 / #208 — threaded from `ServerOptions.spa`.
|
|
543
|
+
* `undefined` is treated as `true` at the SSR call-site (default SPA
|
|
544
|
+
* nav on, helper injected). `false` both disables the full client
|
|
545
|
+
* router (opt-in via `data-mandu-link` only) AND omits the inline
|
|
546
|
+
* SPA-nav IIFE from `<head>`.
|
|
547
|
+
*/
|
|
548
|
+
spa?: boolean;
|
|
530
549
|
/**
|
|
531
550
|
* Issue #191 — threaded from `ServerOptions.devtools`. `undefined`
|
|
532
551
|
* means "use default (islands → inject)"; `true` / `false` force the
|
|
@@ -2081,6 +2100,7 @@ async function renderPageSSR(
|
|
|
2081
2100
|
cssPath: settings.cssPath,
|
|
2082
2101
|
transitions: settings.transitions,
|
|
2083
2102
|
prefetch: settings.prefetch,
|
|
2103
|
+
spa: settings.spa,
|
|
2084
2104
|
devtools: settings.devtools,
|
|
2085
2105
|
onShellReady: () => {
|
|
2086
2106
|
if (settings.isDev) {
|
|
@@ -2119,6 +2139,7 @@ async function renderPageSSR(
|
|
|
2119
2139
|
islandPreWrapped: !!needsIslandWrap,
|
|
2120
2140
|
transitions: settings.transitions,
|
|
2121
2141
|
prefetch: settings.prefetch,
|
|
2142
|
+
spa: settings.spa,
|
|
2122
2143
|
devtools: settings.devtools,
|
|
2123
2144
|
});
|
|
2124
2145
|
return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
|
|
@@ -2162,6 +2183,7 @@ async function renderPageSSR(
|
|
|
2162
2183
|
cssPath: settings.cssPath,
|
|
2163
2184
|
transitions: settings.transitions,
|
|
2164
2185
|
prefetch: settings.prefetch,
|
|
2186
|
+
spa: settings.spa,
|
|
2165
2187
|
devtools: settings.devtools,
|
|
2166
2188
|
});
|
|
2167
2189
|
return ok(cookies ? cookies.applyToResponse(errorHtml) : errorHtml);
|
|
@@ -2268,6 +2290,7 @@ async function renderNotFoundPage(
|
|
|
2268
2290
|
cssPath: settings.cssPath,
|
|
2269
2291
|
transitions: settings.transitions,
|
|
2270
2292
|
prefetch: settings.prefetch,
|
|
2293
|
+
spa: settings.spa,
|
|
2271
2294
|
devtools: settings.devtools,
|
|
2272
2295
|
});
|
|
2273
2296
|
|
|
@@ -2749,6 +2772,7 @@ async function handleRequestInternal(
|
|
|
2749
2772
|
cssPath: settings.cssPath,
|
|
2750
2773
|
transitions: settings.transitions,
|
|
2751
2774
|
prefetch: settings.prefetch,
|
|
2775
|
+
spa: settings.spa,
|
|
2752
2776
|
devtools: settings.devtools,
|
|
2753
2777
|
});
|
|
2754
2778
|
const headers = new Headers(html.headers);
|
|
@@ -2907,6 +2931,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2907
2931
|
managementToken,
|
|
2908
2932
|
transitions,
|
|
2909
2933
|
prefetch,
|
|
2934
|
+
spa,
|
|
2910
2935
|
devtools,
|
|
2911
2936
|
observability: observabilityOption,
|
|
2912
2937
|
} = options;
|
|
@@ -2946,6 +2971,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2946
2971
|
managementToken,
|
|
2947
2972
|
transitions,
|
|
2948
2973
|
prefetch,
|
|
2974
|
+
spa,
|
|
2949
2975
|
devtools,
|
|
2950
2976
|
heapEndpoint: observabilityOption?.heapEndpoint,
|
|
2951
2977
|
metricsEndpoint: observabilityOption?.metricsEndpoint,
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./esc
|
|
|
10
10
|
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
11
11
|
import { generateFastRefreshPreamble } from "../bundler/dev";
|
|
12
12
|
import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
|
|
13
|
+
import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Issue #192 — `@view-transition` at-rule block.
|
|
@@ -579,6 +580,16 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
579
580
|
// or cancel with a later inline style. False disables each independently.
|
|
580
581
|
const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
|
|
581
582
|
const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
|
|
583
|
+
// Issue #208 — Inline SPA-nav IIFE. Pre-#208 Issue #193 wired the SPA
|
|
584
|
+
// click handler into the client bundle (`router.ts::handleLinkClick`),
|
|
585
|
+
// which never ships to `hydration: "none"` projects (docs / marketing
|
|
586
|
+
// sites). The inline helper closes that gap: ~1.6 KB of JS that runs
|
|
587
|
+
// parse-time on every SSR response and intercepts internal anchor
|
|
588
|
+
// clicks using the same 10 exclusion cases as the full router.
|
|
589
|
+
// Coexists safely with the full router via a `__MANDU_ROUTER_STATE__`
|
|
590
|
+
// early-exit. Emit when `spa !== false`; skip entirely when the user
|
|
591
|
+
// opts out via `ssr.spa: false` (same flag the big-router reads).
|
|
592
|
+
const spaNavHelperTag = spa !== false ? SPA_NAV_HELPER_SCRIPT : "";
|
|
582
593
|
|
|
583
594
|
// useHead/useSeoMeta SSR 수집
|
|
584
595
|
let collectedHeadTags = "";
|
|
@@ -709,6 +720,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
709
720
|
${cssLinkTag}
|
|
710
721
|
${viewTransitionTag}
|
|
711
722
|
${prefetchScriptTag}
|
|
723
|
+
${spaNavHelperTag}
|
|
712
724
|
${hoistedLinkTags}
|
|
713
725
|
${headTags}
|
|
714
726
|
${collectedHeadTags}
|
|
@@ -25,6 +25,7 @@ import { getRenderToString } from "./react-renderer";
|
|
|
25
25
|
import { mark, measure } from "../perf";
|
|
26
26
|
import { generateFastRefreshPreamble } from "../bundler/dev";
|
|
27
27
|
import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
|
|
28
|
+
import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Issue #192 — `@view-transition` at-rule, mirror of the constant in
|
|
@@ -176,6 +177,14 @@ export interface StreamingSSROptions {
|
|
|
176
177
|
* Default: `true`.
|
|
177
178
|
*/
|
|
178
179
|
prefetch?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Issue #208 — emit the inline SPA-nav helper `<script>` (~1.6 KB)
|
|
182
|
+
* into the streaming shell `<head>`. Mirrors `SSROptions.spa`:
|
|
183
|
+
* `true` (default) injects so zero-JS / `hydration: "none"` projects
|
|
184
|
+
* still get pushState navigations + View Transitions API; `false`
|
|
185
|
+
* omits the block entirely, matching the legacy full-reload default.
|
|
186
|
+
*/
|
|
187
|
+
spa?: boolean;
|
|
179
188
|
/**
|
|
180
189
|
* Issue #191 — control dev-mode injection of the `_devtools.js`
|
|
181
190
|
* bundle. Mirrors `SSROptions.devtools`:
|
|
@@ -533,6 +542,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
533
542
|
isDev = false,
|
|
534
543
|
transitions = true,
|
|
535
544
|
prefetch = true,
|
|
545
|
+
spa = true,
|
|
536
546
|
} = options;
|
|
537
547
|
|
|
538
548
|
// CSS 링크 태그 생성
|
|
@@ -549,6 +559,11 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
549
559
|
// an inline style later in the document order.
|
|
550
560
|
const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
|
|
551
561
|
const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
|
|
562
|
+
// Issue #208 — Inline SPA-nav IIFE, mirror of `ssr.ts::renderToHTML`.
|
|
563
|
+
// See that call-site for the full rationale. Streaming SSR follows
|
|
564
|
+
// the same opt-out contract: `spa !== false` injects, `spa: false`
|
|
565
|
+
// omits the `<script>` block entirely.
|
|
566
|
+
const spaNavHelperTag = spa !== false ? SPA_NAV_HELPER_SCRIPT : "";
|
|
552
567
|
|
|
553
568
|
// Island wrapper (hydration이 필요한 경우)
|
|
554
569
|
const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
@@ -630,6 +645,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
630
645
|
${cssLinkTag}
|
|
631
646
|
${viewTransitionTag}
|
|
632
647
|
${prefetchScriptTag}
|
|
648
|
+
${spaNavHelperTag}
|
|
633
649
|
${loadingStyles}
|
|
634
650
|
${importMapScript}
|
|
635
651
|
${headTags}
|