@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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"),
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Mandu — generateStaticParams contract
3
+ *
4
+ * Next.js-style `generateStaticParams` lets page modules enumerate which
5
+ * concrete parameter combinations should be prerendered at build time.
6
+ *
7
+ * ```ts
8
+ * // app/docs/[slug]/page.tsx
9
+ * export async function generateStaticParams(): Promise<{ slug: string }[]> {
10
+ * return [{ slug: "intro" }, { slug: "quickstart" }];
11
+ * }
12
+ * export default function Page({ params }: { params: { slug: string } }) { ... }
13
+ * ```
14
+ *
15
+ * This module owns the *contract* side of that feature: introspecting a
16
+ * page module to invoke `generateStaticParams`, validating the shape of
17
+ * the returned param sets against the route's dynamic segments, and
18
+ * resolving each param set into a concrete URL path the prerender
19
+ * orchestrator can request.
20
+ *
21
+ * Router pattern → file-system mapping (kept in sync with
22
+ * `packages/core/src/router/fs-patterns.ts`):
23
+ *
24
+ * - `[slug]` → `:slug` (single required segment)
25
+ * - `[...slug]` → `:slug*` (catch-all, param value is `string[]`)
26
+ * - `[[...slug]]` → `:slug*?` (optional catch-all, param value is
27
+ * `string[]` — empty array resolves to
28
+ * the prefix path with no trailing
29
+ * segment)
30
+ *
31
+ * The prerender orchestrator in `prerender.ts` composes these helpers
32
+ * with a user-supplied fetch handler to materialize HTML on disk.
33
+ */
34
+
35
+ // Using the same loose pattern vocabulary as the rest of the bundler.
36
+
37
+ /**
38
+ * A single param set as returned from `generateStaticParams`. Scalar
39
+ * params map to a string; catch-all params map to a string array.
40
+ */
41
+ export type StaticParamSet = Record<string, string | string[]>;
42
+
43
+ /**
44
+ * A page module shape we care about. We deliberately avoid importing
45
+ * the full page-module type here — this module is invoked from the
46
+ * build orchestrator where modules are dynamic-imported, and the only
47
+ * thing we need is the optional `generateStaticParams` export.
48
+ */
49
+ export interface PageModuleWithStaticParams {
50
+ generateStaticParams?: () => Promise<StaticParamSet[]> | StaticParamSet[];
51
+ }
52
+
53
+ /**
54
+ * Structured description of one dynamic segment extracted from a
55
+ * router pattern. Mirrors the `kind` alphabet we support on disk.
56
+ */
57
+ export interface DynamicSegment {
58
+ name: string;
59
+ kind: "required" | "catchAll" | "optionalCatchAll";
60
+ }
61
+
62
+ /**
63
+ * Extract the dynamic segments from a router pattern.
64
+ *
65
+ * @example
66
+ * extractDynamicSegments("/docs/:slug") // [{name:"slug", kind:"required"}]
67
+ * extractDynamicSegments("/[lang]/:slug") // malformed, see below
68
+ * extractDynamicSegments("/:lang/:slug") // two required
69
+ * extractDynamicSegments("/docs/:slug*") // one catch-all
70
+ * extractDynamicSegments("/docs/:slug*?") // one optional catch-all
71
+ *
72
+ * Only router-style (`:name`, `:name*`, `:name*?`) patterns are
73
+ * recognized — the `[slug]` file-system syntax is normalized to router
74
+ * form at scan time (see `fs-patterns.ts`).
75
+ */
76
+ export function extractDynamicSegments(pattern: string): DynamicSegment[] {
77
+ const segments: DynamicSegment[] = [];
78
+ // Match :<name><optional-star><optional-question>
79
+ const re = /:([A-Za-z_][A-Za-z0-9_]*)(\*)?(\?)?/g;
80
+ let match: RegExpExecArray | null;
81
+ while ((match = re.exec(pattern)) !== null) {
82
+ const [, name, star, question] = match;
83
+ let kind: DynamicSegment["kind"] = "required";
84
+ if (star && question) kind = "optionalCatchAll";
85
+ else if (star) kind = "catchAll";
86
+ segments.push({ name, kind });
87
+ }
88
+ return segments;
89
+ }
90
+
91
+ /** Whether a router pattern has any dynamic segments at all. */
92
+ export function isDynamicPattern(pattern: string): boolean {
93
+ return extractDynamicSegments(pattern).length > 0;
94
+ }
95
+
96
+ /**
97
+ * Validate that a param set has the right keys and value kinds for a
98
+ * pattern. Returns a human-readable error message or `null` if OK.
99
+ *
100
+ * Rules:
101
+ * - Every dynamic segment must have a corresponding key in params.
102
+ * (Optional catch-all may be omitted or provided as `[]`.)
103
+ * - `required` segments must map to a non-empty string.
104
+ * - `catchAll`/`optionalCatchAll` segments must map to a `string[]`
105
+ * (or, as a convenience, a single string — interpreted as one
106
+ * segment). Required catch-all must be non-empty.
107
+ */
108
+ export function validateParamSet(
109
+ pattern: string,
110
+ params: StaticParamSet
111
+ ): string | null {
112
+ const segments = extractDynamicSegments(pattern);
113
+ for (const segment of segments) {
114
+ const value = params[segment.name];
115
+ if (segment.kind === "required") {
116
+ if (typeof value !== "string" || value.length === 0) {
117
+ return `expected string for param "${segment.name}" in pattern "${pattern}", got ${describe(value)}`;
118
+ }
119
+ if (value.includes("/")) {
120
+ return `param "${segment.name}" in pattern "${pattern}" must not contain "/"; use a catch-all segment ([...${segment.name}])`;
121
+ }
122
+ } else if (segment.kind === "catchAll") {
123
+ if (value === undefined || value === null) {
124
+ return `param "${segment.name}" is required for catch-all pattern "${pattern}"`;
125
+ }
126
+ if (Array.isArray(value)) {
127
+ if (value.length === 0) {
128
+ return `catch-all param "${segment.name}" in pattern "${pattern}" must not be empty; use [[...${segment.name}]] for optional`;
129
+ }
130
+ if (!value.every((v) => typeof v === "string" && v.length > 0)) {
131
+ return `catch-all param "${segment.name}" must be a non-empty string[] (pattern "${pattern}")`;
132
+ }
133
+ } else if (typeof value !== "string" || value.length === 0) {
134
+ return `catch-all param "${segment.name}" in pattern "${pattern}" must be string[] or non-empty string`;
135
+ }
136
+ } else {
137
+ // optionalCatchAll — may be absent, `[]`, or a populated array.
138
+ if (value !== undefined && value !== null) {
139
+ if (Array.isArray(value)) {
140
+ if (!value.every((v) => typeof v === "string")) {
141
+ return `optional catch-all "${segment.name}" must be string[] (pattern "${pattern}")`;
142
+ }
143
+ } else if (typeof value !== "string") {
144
+ return `optional catch-all "${segment.name}" must be string[] or string (pattern "${pattern}")`;
145
+ }
146
+ }
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Resolve a router pattern + param set into a concrete URL path.
154
+ *
155
+ * @example
156
+ * resolvePath("/docs/:slug", { slug: "intro" })
157
+ * // "/docs/intro"
158
+ * resolvePath("/:lang/:slug", { lang: "ko", slug: "intro" })
159
+ * // "/ko/intro"
160
+ * resolvePath("/docs/:path*", { path: ["guide", "advanced"] })
161
+ * // "/docs/guide/advanced"
162
+ * resolvePath("/docs/:path*?", { path: [] })
163
+ * // "/docs"
164
+ * resolvePath("/docs/:path*?", {})
165
+ * // "/docs"
166
+ *
167
+ * Individual segments are URI-encoded, but slashes between catch-all
168
+ * segments are preserved.
169
+ */
170
+ export function resolvePath(pattern: string, params: StaticParamSet): string {
171
+ let result = pattern;
172
+
173
+ // Resolve catch-all (optional + required) first because their regex
174
+ // (`:name*?`, `:name*`) is a superset of the `:name` pattern. Sort by
175
+ // longest first to be extra safe when names overlap.
176
+ const segments = [...extractDynamicSegments(pattern)].sort(
177
+ (a, b) => b.name.length - a.name.length
178
+ );
179
+
180
+ for (const segment of segments) {
181
+ const value = params[segment.name];
182
+
183
+ if (segment.kind === "optionalCatchAll") {
184
+ // Pattern is `/prefix/:name*?` — the leading slash belongs to the
185
+ // prefix and must be elided when the param is empty, otherwise
186
+ // we'd emit `/prefix/`.
187
+ const needle = `/:${segment.name}*?`;
188
+ if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) {
189
+ result = result.replace(needle, "");
190
+ } else {
191
+ const parts = Array.isArray(value) ? value : [value];
192
+ const encoded = parts.map(encodeURIComponent).join("/");
193
+ result = result.replace(needle, `/${encoded}`);
194
+ }
195
+ continue;
196
+ }
197
+
198
+ if (segment.kind === "catchAll") {
199
+ const parts = Array.isArray(value) ? value : [String(value)];
200
+ const encoded = parts.map(encodeURIComponent).join("/");
201
+ result = result.replace(`:${segment.name}*`, encoded);
202
+ continue;
203
+ }
204
+
205
+ // required — avoid accidentally matching `:slugs` when we wanted
206
+ // `:slug` by using a word-boundary style regex.
207
+ const requiredRe = new RegExp(`:${escapeRegex(segment.name)}(?![A-Za-z0-9_])`);
208
+ result = result.replace(requiredRe, encodeURIComponent(String(value)));
209
+ }
210
+
211
+ // Collapse any accidental double slashes (except the scheme — we have no scheme here).
212
+ result = result.replace(/\/{2,}/g, "/");
213
+ if (result.length > 1 && result.endsWith("/")) result = result.slice(0, -1);
214
+ if (!result.startsWith("/")) result = "/" + result;
215
+ return result;
216
+ }
217
+
218
+ /**
219
+ * Invoke `generateStaticParams` on a page module and return the list
220
+ * of resolved URL paths. Validates the shape of each param set against
221
+ * the pattern; invalid entries are collected in the `errors` array and
222
+ * *not* included in `paths`.
223
+ *
224
+ * The function never throws for contract-level problems (missing
225
+ * export, non-array return, invalid param shapes). It *does* propagate
226
+ * exceptions thrown from inside the user-supplied function, because
227
+ * those indicate a bug in the user code that the caller (the build
228
+ * orchestrator) should surface loudly.
229
+ */
230
+ export async function collectStaticPaths(
231
+ pattern: string,
232
+ mod: PageModuleWithStaticParams
233
+ ): Promise<{ paths: string[]; errors: string[]; paramSets: StaticParamSet[] }> {
234
+ const errors: string[] = [];
235
+ const paths: string[] = [];
236
+ const paramSets: StaticParamSet[] = [];
237
+
238
+ if (typeof mod.generateStaticParams !== "function") {
239
+ return { paths, errors, paramSets };
240
+ }
241
+
242
+ const result = await mod.generateStaticParams();
243
+
244
+ if (!Array.isArray(result)) {
245
+ errors.push(
246
+ `generateStaticParams() for "${pattern}" returned ${describe(result)}; expected an array of param objects`
247
+ );
248
+ return { paths, errors, paramSets };
249
+ }
250
+
251
+ const seen = new Set<string>();
252
+ for (let i = 0; i < result.length; i++) {
253
+ const entry = result[i];
254
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
255
+ errors.push(
256
+ `generateStaticParams()[${i}] for "${pattern}" is not a plain object (${describe(entry)})`
257
+ );
258
+ continue;
259
+ }
260
+ const validationError = validateParamSet(pattern, entry as StaticParamSet);
261
+ if (validationError) {
262
+ errors.push(`generateStaticParams()[${i}] for "${pattern}": ${validationError}`);
263
+ continue;
264
+ }
265
+ const resolved = resolvePath(pattern, entry as StaticParamSet);
266
+ if (seen.has(resolved)) {
267
+ // Duplicates are fine — just silently de-dupe. Users often return
268
+ // the same slug from multiple data sources during migrations.
269
+ continue;
270
+ }
271
+ seen.add(resolved);
272
+ paths.push(resolved);
273
+ paramSets.push(entry as StaticParamSet);
274
+ }
275
+
276
+ return { paths, errors, paramSets };
277
+ }
278
+
279
+ // ---------- Internals ----------
280
+
281
+ function describe(value: unknown): string {
282
+ if (value === null) return "null";
283
+ if (value === undefined) return "undefined";
284
+ if (Array.isArray(value)) return `array (length ${value.length})`;
285
+ return typeof value;
286
+ }
287
+
288
+ function escapeRegex(s: string): string {
289
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
290
+ }
@@ -7,3 +7,4 @@ export * from "./types";
7
7
  export * from "./build";
8
8
  export * from "./dev";
9
9
  export * from "./css";
10
+ export * from "./plugins";
@@ -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
+ });