@kasperpeulen/vitest-plugin-nextjs 0.1.0-alpha.0 → 0.1.0-alpha.1

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/README.md CHANGED
@@ -55,7 +55,7 @@ That also makes it a nice loop for coding agents: edit, run, fix, repeat.
55
55
 
56
56
  ### Is it really Next.js?
57
57
 
58
- Yes. The plugin doesn't reimplement Next. It swaps the bundler for Vite and runs Next's runtime unchanged. To keep that honest, the repo runs Next's own e2e tests against the plugin: **402 of the 474 that apply pass (85%)** on `next@16.4.0`. Every failure is listed with a reason in the [conformance report](https://github.com/storybookjs/storybook-for-pages/blob/main/docs/internals/next-conformance.md). CI also tests against `next@latest` and `next@canary`.
58
+ Yes. The plugin doesn't reimplement Next. It swaps the bundler for Vite and runs Next's runtime unchanged. To keep that honest, the repo runs Next's own e2e tests against the plugin: **967 of the 1,139 that apply pass (85%)** on `next@16.4.0`. Every failure is listed with a reason in the [conformance report](https://github.com/storybookjs/storybook-for-pages/blob/main/docs/internals/next-conformance.md). CI also tests against `next@latest` and `next@canary`.
59
59
 
60
60
  ## Quick start
61
61
 
package/dist/compile.d.ts CHANGED
@@ -8,6 +8,7 @@ import { Plugin } from "vite";
8
8
  * module.
9
9
  */
10
10
  declare function fontRequestOf(id: string): string | undefined;
11
+ declare const codeFile: RegExp;
11
12
  declare function createDependencyCompilePlugin(getProject: () => NextProject, layer: NextLayer, isAppCode: (file: string, layer: NextLayer) => boolean): {
12
13
  name: string;
13
14
  resolveId(source: string): {
@@ -19,8 +20,15 @@ declare function createDependencyCompilePlugin(getProject: () => NextProject, la
19
20
  map: string | null;
20
21
  } | undefined>;
21
22
  };
22
- declare function createScanCompilePlugin(getProject: () => NextProject, layer: NextLayer, isAppCode: (file: string, layer: NextLayer) => boolean): {
23
+ declare function createScanCompilePlugin(getProject: () => NextProject, layer: NextLayer, isAppCode: (file: string, layer: NextLayer) => boolean, resolve: (source: string, importer: string) => Promise<string | undefined>): {
23
24
  name: string;
25
+ resolveId(source: string, importer: string | undefined): Promise<{
26
+ id: string;
27
+ } | undefined>;
28
+ load(id: string): Promise<{
29
+ code: string;
30
+ moduleType: "jsx";
31
+ } | undefined>;
24
32
  transform(code: string, id: string): Promise<{
25
33
  code: string;
26
34
  map: string | null;
@@ -28,4 +36,4 @@ declare function createScanCompilePlugin(getProject: () => NextProject, layer: N
28
36
  };
29
37
  declare function createCompilePlugin(getProject: () => NextProject, layerOf: (environment: string) => NextLayer | undefined, isAppCode: (file: string, layer: NextLayer) => boolean): Plugin;
30
38
  //#endregion
31
- export { createCompilePlugin, createDependencyCompilePlugin, createScanCompilePlugin, fontRequestOf };
39
+ export { codeFile, createCompilePlugin, createDependencyCompilePlugin, createScanCompilePlugin, fontRequestOf };
package/dist/compile.js CHANGED
@@ -18,6 +18,7 @@ function fontRequestOf(id) {
18
18
  return Buffer.from(name.slice(0, -4), "base64url").toString();
19
19
  }
20
20
  const sourceFile = /\.(?:tsx|ts|jsx|js|mjs)$/;
21
+ const codeFile = /\.(?:[cm]?[jt]sx?)$/;
21
22
  function createDependencyCompilePlugin(getProject, layer, isAppCode) {
22
23
  return {
23
24
  name: "vitest-plugin-rsc:next-compile-dependency",
@@ -38,9 +39,33 @@ function createDependencyCompilePlugin(getProject, layer, isAppCode) {
38
39
  }
39
40
  };
40
41
  }
41
- function createScanCompilePlugin(getProject, layer, isAppCode) {
42
+ function createScanCompilePlugin(getProject, layer, isAppCode, resolve) {
43
+ const ruleOf = (id) => {
44
+ const file = id.split("?")[0];
45
+ if (codeFile.test(file) || file.endsWith(".json")) return;
46
+ return getProject().webpackRuleOf(id, layer);
47
+ };
42
48
  return {
43
49
  name: `vitest-plugin-rsc:next-compile-scan:${layer}`,
50
+ async resolveId(source, importer) {
51
+ if (!importer) return;
52
+ const named = source.startsWith(".") ? path.join(path.dirname(importer), source) : source;
53
+ if (!ruleOf(named)) return;
54
+ const id = await resolve(source, importer);
55
+ if (!id || !ruleOf(id) || !isAppCode(id.split("?")[0], layer)) return;
56
+ return { id };
57
+ },
58
+ async load(id) {
59
+ const rule = ruleOf(id);
60
+ const file = id.split("?")[0];
61
+ if (!rule || !path.isAbsolute(file) || !fs.existsSync(file)) return;
62
+ const code = await getProject().loadWithWebpackRule(id, layer).catch(() => "");
63
+ const compiled = rule.compile && code ? await getProject().compile(code, file, layer).catch(() => void 0) : void 0;
64
+ return {
65
+ code: compiled && !compiled.refused ? compiled.code : code ?? "",
66
+ moduleType: "jsx"
67
+ };
68
+ },
44
69
  async transform(code, id) {
45
70
  if (id.includes("?") || !sourceFile.test(id) || !isAppCode(id, layer)) return;
46
71
  const compiled = await getProject().compile(code, id, layer).catch(() => void 0);
@@ -114,27 +139,37 @@ function createCompilePlugin(getProject, layerOf, isAppCode) {
114
139
  return `import ${JSON.stringify(stylesheet)};\nexport default ${JSON.stringify(exports)};\n`;
115
140
  }
116
141
  const layer = layerOf(this.environment.name);
117
- if (!layer || id.includes("?")) return;
118
- if (getProject().webpackRuleOf(id, layer) && fs.existsSync(id)) return {
142
+ if (!layer) return;
143
+ const file = id.split("?")[0];
144
+ if (!codeFile.test(file) && getProject().webpackRuleOf(id, layer) && fs.existsSync(file)) return {
119
145
  code: await getProject().loadWithWebpackRule(id, layer),
120
- moduleType: "js"
146
+ moduleType: file.endsWith(".json") ? "json" : "js"
121
147
  };
122
- if (!getProject().isImage(id) || !fs.existsSync(id)) return;
148
+ if (id.includes("?") || !getProject().isImage(id) || !fs.existsSync(id)) return;
123
149
  return built(await getProject().loadImage(id), "js");
124
150
  },
125
151
  async transform(code, id) {
126
152
  const layer = layerOf(this.environment.name);
127
- if (!layer || id.includes("?")) return;
128
- if (!sourceFile.test(id) && !getProject().webpackRuleOf(id, layer)?.compile) return;
129
- if (!isAppCode(id, layer) || !fs.existsSync(id)) return;
130
- if (id.startsWith(`${normalizePath(this.environment.config.cacheDir)}/`)) return;
131
- const compiled = await getProject().compile(code, id, layer);
132
- return compiled && {
153
+ if (!layer) return;
154
+ const rule = getProject().webpackRuleOf(id, layer);
155
+ const file = id.split("?")[0];
156
+ const compiles = rule?.compile || !id.includes("?") && sourceFile.test(id);
157
+ const loads = rule && codeFile.test(file);
158
+ if (!compiles && !loads) return;
159
+ if (!isAppCode(file, layer) || !fs.existsSync(file)) return;
160
+ if (file.startsWith(`${normalizePath(this.environment.config.cacheDir)}/`)) return;
161
+ const loaded = loads ? await getProject().loadWithWebpackRule(id, layer, code) : code;
162
+ const compiled = compiles ? await getProject().compile(loaded, file, layer) : void 0;
163
+ if (compiled) return {
133
164
  code: compiled.code,
134
165
  map: compiled.map ?? null
135
166
  };
167
+ if (loaded !== code) return {
168
+ code: loaded,
169
+ map: null
170
+ };
136
171
  }
137
172
  };
138
173
  }
139
174
  //#endregion
140
- export { createCompilePlugin, createDependencyCompilePlugin, createScanCompilePlugin, fontRequestOf };
175
+ export { codeFile, createCompilePlugin, createDependencyCompilePlugin, createScanCompilePlugin, fontRequestOf };
package/dist/document.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import "./styles-shared.js";
2
+ import { rememberHost } from "./host-return.js";
2
3
  import pageHost from "virtual:vitest-plugin-rsc/page-host";
3
4
  import { releaseTimers, runnerTimers } from "@kasperpeulen/vitest-plugin-rsc/internal/timers";
4
5
  //#region src/document.ts
@@ -169,6 +170,7 @@ async function loadDocument(html, url, container, after, inKeptApp = false) {
169
170
  preloads = [];
170
171
  const runnerUrl = window.location.href;
171
172
  runnerHref = runnerUrl;
173
+ rememberHost(runnerUrl);
172
174
  const runnerBody = document.body;
173
175
  const runnerAttributes = elements(document).map(attributesOf);
174
176
  const reactTargets = [
@@ -0,0 +1,5 @@
1
+ //#region src/host-return-shared.d.ts
2
+ declare const hostKey = "vitest-plugin-rsc:host";
3
+ declare const topLevelHostKey = "vitest-plugin-rsc:host-top-level";
4
+ //#endregion
5
+ export { hostKey, topLevelHostKey };
@@ -0,0 +1,5 @@
1
+ //#region src/host-return-shared.ts
2
+ const hostKey = "vitest-plugin-rsc:host";
3
+ const topLevelHostKey = "vitest-plugin-rsc:host-top-level";
4
+ //#endregion
5
+ export { hostKey, topLevelHostKey };
@@ -0,0 +1,5 @@
1
+ //#region src/host-return.d.ts
2
+ /** Keeps `url`, the URL of the host's document, as the one a page that opens now goes back to. */
3
+ declare function rememberHost(url: string): void;
4
+ //#endregion
5
+ export { rememberHost };
@@ -0,0 +1,16 @@
1
+ import { buildDirectory } from "./built-files.js";
2
+ import { hostKey, topLevelHostKey } from "./host-return-shared.js";
3
+ //#region src/host-return.ts
4
+ const ownStorages = ["sessionStorage", "localStorage"].map((name) => Object.getOwnPropertyDescriptor(window, name)?.get);
5
+ /** Keeps `url`, the URL of the host's document, as the one a page that opens now goes back to. */
6
+ function rememberHost(url) {
7
+ if (!buildDirectory) return;
8
+ const [session, local] = ownStorages.map((get) => get?.call(window));
9
+ try {
10
+ session?.setItem(hostKey, url);
11
+ session?.setItem(topLevelHostKey, window.top === window ? "1" : "0");
12
+ local?.setItem(hostKey, url);
13
+ } catch {}
14
+ }
15
+ //#endregion
16
+ export { rememberHost };
@@ -0,0 +1,19 @@
1
+ import { Plugin } from "vite";
2
+
3
+ //#region src/not-found-page.d.ts
4
+ /**
5
+ * A script that loads the host's document of the last page of a static build
6
+ * that this browser opened, when the expression `when` holds: in place of a
7
+ * document that the static host has at the URL of that page. Nothing when no
8
+ * page was opened, or the host is at this URL itself.
9
+ */
10
+ declare function returnToHostScript(when?: string): string;
11
+ /**
12
+ * An expression of a script for `returnToHostScript()`: whether the host's
13
+ * document of the last page that this tab opened was the tab's own, not in
14
+ * a frame.
15
+ */
16
+ declare const topLevelHost: string;
17
+ declare function notFoundPagePlugin(environment: string): Plugin;
18
+ //#endregion
19
+ export { notFoundPagePlugin, returnToHostScript, topLevelHost };
@@ -0,0 +1,74 @@
1
+ import { hostKey, topLevelHostKey } from "./host-return-shared.js";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { getPluginApi } from "@vitejs/plugin-rsc/plugin";
5
+ //#region src/not-found-page.ts
6
+ /**
7
+ * A script that loads the host's document of the last page of a static build
8
+ * that this browser opened, when the expression `when` holds: in place of a
9
+ * document that the static host has at the URL of that page. Nothing when no
10
+ * page was opened, or the host is at this URL itself.
11
+ */
12
+ function returnToHostScript(when = "true") {
13
+ const key = JSON.stringify(hostKey);
14
+ return `(function () {
15
+ if (!(${when})) return;
16
+ var host;
17
+ try {
18
+ host = sessionStorage.getItem(${key}) || localStorage.getItem(${key});
19
+ } catch (error) {}
20
+ if (!host) return;
21
+ var url = new URL(host, location.href);
22
+ if (url.origin !== location.origin || url.pathname === location.pathname) return;
23
+ // What the document started to load is not needed.
24
+ window.stop();
25
+ location.replace(url.href);
26
+ })();`;
27
+ }
28
+ /**
29
+ * An expression of a script for `returnToHostScript()`: whether the host's
30
+ * document of the last page that this tab opened was the tab's own, not in
31
+ * a frame.
32
+ */
33
+ const topLevelHost = `(function () {
34
+ try {
35
+ return sessionStorage.getItem(${JSON.stringify(topLevelHostKey)}) === "1";
36
+ } catch (error) {
37
+ return false;
38
+ }
39
+ })()`;
40
+ const notFoundPage = `<!doctype html>
41
+ <html lang="en">
42
+ <head>
43
+ <meta charset="utf-8" />
44
+ <meta name="robots" content="noindex" />
45
+ <title>404: This page could not be found.</title>
46
+ <script>
47
+ ${returnToHostScript()}
48
+ <\/script>
49
+ </head>
50
+ <body>
51
+ <p>404: This page could not be found.</p>
52
+ </body>
53
+ </html>
54
+ `;
55
+ function notFoundPagePlugin(environment) {
56
+ return {
57
+ name: "vitest-plugin-rsc:next-not-found-page",
58
+ apply: "build",
59
+ generateBundle(_options, bundle) {
60
+ if (this.environment.name !== environment) return;
61
+ if (getPluginApi(this.environment.config)?.manager.isScanBuild) return;
62
+ const { publicDir } = this.environment.config;
63
+ if ("404.html" in bundle) return;
64
+ if (publicDir && fs.existsSync(path.join(publicDir, "404.html"))) return;
65
+ this.emitFile({
66
+ type: "asset",
67
+ fileName: "404.html",
68
+ source: notFoundPage
69
+ });
70
+ }
71
+ };
72
+ }
73
+ //#endregion
74
+ export { notFoundPagePlugin, returnToHostScript, topLevelHost };
package/dist/plugin.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Mode, ModeOptions, SpeedOptions } from "./mode.js";
2
2
  import { NextBuild } from "./build.js";
3
+ import { returnToHostScript, topLevelHost } from "./not-found-page.js";
3
4
  import { ServerCodeOptions } from "./server-code.js";
4
5
  import { Plugin } from "vite";
5
6
  //#region src/plugin.d.ts
@@ -19,4 +20,4 @@ type VitestPluginNextOptions = ServerCodeOptions & ModeOptions & {
19
20
  };
20
21
  declare function vitestPluginNext(options?: VitestPluginNextOptions): Plugin[];
21
22
  //#endregion
22
- export { type Mode, type NextBuild, type SpeedOptions, VitestPluginNextOptions, vitestPluginNext };
23
+ export { type Mode, type NextBuild, type SpeedOptions, VitestPluginNextOptions, returnToHostScript, topLevelHost, vitestPluginNext };
package/dist/plugin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { registryGlobal } from "./registry-global.js";
2
2
  import { nextBuilds, nextProductionPlugin } from "./build.js";
3
- import { createCompilePlugin, createDependencyCompilePlugin, createScanCompilePlugin } from "./compile.js";
3
+ import { codeFile, createCompilePlugin, createDependencyCompilePlugin, createScanCompilePlugin } from "./compile.js";
4
4
  import { builtHostFileOf, changesInPlace, createStyles } from "./styles.js";
5
5
  import { clientFiles, clientReferenceOfModule } from "./client-files.js";
6
6
  import { createCommonJsPlugin } from "./commonjs.js";
@@ -11,6 +11,7 @@ import { resolveSpeedFeatures } from "./mode.js";
11
11
  import { browserMapping, isNativeNodeModule, isNodeModule, nativeRequirePlugin, nodeModuleOf } from "./node-builtins.js";
12
12
  import { devAssetPrefix, prerenderImports, runtimeImports as runtimeImports$1, serverReferenceInfo } from "./compat/paths.js";
13
13
  import { createNodePlatform, instrumentationPlugin } from "./node-platform.js";
14
+ import { notFoundPagePlugin, returnToHostScript, topLevelHost } from "./not-found-page.js";
14
15
  import { optimizerCacheKey } from "./optimizer-cache-key.js";
15
16
  import "./own-package.js";
16
17
  import { pageTimersDefine, pageTimersPlugin } from "./page-timers.js";
@@ -46,7 +47,14 @@ const environmentOf = {
46
47
  const layers = Object.keys(environmentOf);
47
48
  const registry = `globalThis.${registryGlobal}`;
48
49
  const fileEntry = (file) => escapePath(normalizePath(file));
49
- const appEntriesOf = (project) => `${fileEntry(project.appDir)}/**/*.{js,jsx,ts,tsx}`;
50
+ const ruleExtensionsOf = (project) => (project.config.pageExtensions ?? []).filter((extension) => !codeFile.test(`.${extension}`) && project.webpackRuleOf(path.join(project.appDir, `page.${extension}`), "rsc"));
51
+ const appEntriesOf = (project) => `${fileEntry(project.appDir)}/**/*.{${[
52
+ "js",
53
+ "jsx",
54
+ "ts",
55
+ "tsx",
56
+ ...ruleExtensionsOf(project)
57
+ ].join(",")}}`;
50
58
  const manifestId = "virtual:vitest-plugin-rsc/next-manifest";
51
59
  const virtual = (name) => `virtual:vitest-plugin-rsc/next-${name}`;
52
60
  const routeEntryPrefix = virtual("route/");
@@ -640,12 +648,14 @@ function vitestPluginNext(options = {}) {
640
648
  ])
641
649
  };
642
650
  const dependenciesOfNext = (layer) => Object.entries(project.aliases[layer]).flatMap(([key, target]) => key.endsWith("$") && target && resolvers[layer].isDependencyOfNext(target) ? [`next > ${key.slice(0, -1)}`] : []);
651
+ const ruleExtensions = ruleExtensionsOf(project).map((extension) => `.${extension}`);
643
652
  const optimizeDeps = (layer) => ({
644
653
  include: [
645
654
  ...include[layer],
646
655
  ...dependenciesOfNext(layer),
647
656
  ...project.webpackPackages[layer]
648
657
  ],
658
+ ...ruleExtensions.length > 0 && { extensions: ruleExtensions },
649
659
  rolldownOptions: {
650
660
  plugins: [
651
661
  ...build === "production" ? [nextProductionPlugin(project.nextDir)] : [],
@@ -653,7 +663,7 @@ function vitestPluginNext(options = {}) {
653
663
  ...layer === "browser" ? [hostPackagesPlugin] : [],
654
664
  resolvers[layer].plugin(),
655
665
  createDependencyCompilePlugin(getProject, layer, serverCode.isAppCode),
656
- createScanCompilePlugin(getProject, layer, serverCode.isAppCode),
666
+ createScanCompilePlugin(getProject, layer, serverCode.isAppCode, async (source, importer) => (await server?.environments[environmentOf[layer]]?.pluginContainer.resolveId(source, importer))?.id),
657
667
  ...layer === "rsc" ? [nextClientBoundaryPlugin(getProject, resolvers.rsc)] : [],
658
668
  flightConsolePlugin(),
659
669
  nextPatchesPlugin(),
@@ -921,6 +931,7 @@ function vitestPluginNext(options = {}) {
921
931
  [environmentOf.ssr]: "ssr"
922
932
  }),
923
933
  pageTimersPlugin(environmentOf.browser, serverCode.isHostFile),
934
+ notFoundPagePlugin(environmentOf.rsc),
924
935
  ...styles.plugins,
925
936
  routesAheadPlugin({
926
937
  root: () => getProject().root,
@@ -992,4 +1003,4 @@ function dependencyBuildPlugin(getProject, serverCode, layer) {
992
1003
  };
993
1004
  }
994
1005
  //#endregion
995
- export { vitestPluginNext };
1006
+ export { returnToHostScript, topLevelHost, vitestPluginNext };
@@ -3,15 +3,26 @@ import { NextContext } from "./context.js";
3
3
  import { NextProject } from "../project.js";
4
4
 
5
5
  //#region src/project/webpack.d.ts
6
+ /**
7
+ * What a rule matches, as webpack takes it: a string is the start of the path,
8
+ * a function decides, and a list matches where one of it does.
9
+ */
10
+ type WebpackCondition = RegExp | string | ((value: string) => boolean) | WebpackCondition[];
6
11
  /** A rule of the `webpack` function: the files it is for, and their loaders. */
7
12
  type WebpackRule = {
8
- test: RegExp; /** As webpack calls them: the last first. */
13
+ /** Of the path of the file, as `include` and `exclude`. */test: WebpackCondition;
14
+ include?: WebpackCondition;
15
+ exclude?: WebpackCondition; /** Of the query of the file, `?collection=docs`, with its `?`. */
16
+ resourceQuery?: WebpackCondition;
17
+ enforce?: "pre" | "post"; /** As webpack calls them: the last first. */
9
18
  loaders: {
10
19
  loader: string;
11
20
  options: unknown;
12
21
  }[]; /** Whether Next's compiler, `defaultLoaders.babel`, runs after them. */
13
22
  compile: boolean;
14
23
  };
24
+ /** The loaders of the rules that match a file, as webpack runs them. */
25
+ type WebpackLoaders = Pick<WebpackRule, "loaders" | "compile">;
15
26
  type WebpackFunction = {
16
27
  rules: WebpackRule[]; /** The candidates of each alias it sets, in order: the first that resolves is the one. */
17
28
  aliases: Record<string, string[]>; /** What of it does not apply, to say when a run starts. */
@@ -28,6 +39,14 @@ declare function readWebpackFunction(webpack: unknown, options: {
28
39
  buildId: string;
29
40
  config: unknown;
30
41
  }): WebpackFunction;
42
+ /**
43
+ * The loaders of the rules for a file, as webpack matches them: `test`,
44
+ * `include` and `exclude` its path, `resourceQuery` its query. Each rule that
45
+ * matches adds its loaders, as webpack runs them: those of `enforce: "pre"`
46
+ * first, then the others, then those of `enforce: "post"`. Fumadocs matches an
47
+ * `.mdx` file with `?collection=docs`, and a `meta.json` with it by its query.
48
+ */
49
+ declare function loadersOf(rules: WebpackRule[], id: string): WebpackLoaders | undefined;
31
50
  /**
32
51
  * The candidates of an alias for an import, as webpack takes them: a name
33
52
  * matches the import and what is under it, and one that ends with `$` only
@@ -43,4 +62,4 @@ declare function importOfFile(file: string): string;
43
62
  /** The rules and aliases of the `webpack` function in `next.config`, for each layer. */
44
63
  declare function createWebpackRules(context: NextContext, runLoader: RunLoader): Pick<NextProject, "webpackRuleOf" | "loadWithWebpackRule" | "webpackAliasOf" | "webpackPackages" | "webpackDifferences">;
45
64
  //#endregion
46
- export { WebpackFunction, WebpackRule, aliasOf, createWebpackRules, importOfFile, readWebpackFunction };
65
+ export { WebpackCondition, WebpackFunction, WebpackLoaders, WebpackRule, aliasOf, createWebpackRules, importOfFile, loadersOf, readWebpackFunction };
@@ -39,7 +39,7 @@ function readWebpackFunction(webpack, options) {
39
39
  return read;
40
40
  }
41
41
  for (const rule of after?.module?.rules ?? []) {
42
- const { test, use, ...rest } = rule ?? {};
42
+ const { test, include, exclude, resourceQuery, enforce, use, ...rest } = rule ?? {};
43
43
  const uses = Array.isArray(use) ? use : [use];
44
44
  const loaders = uses.flatMap((entry) => {
45
45
  if (entry === babel) return [];
@@ -53,12 +53,20 @@ function readWebpackFunction(webpack, options) {
53
53
  options
54
54
  }] : [];
55
55
  });
56
- if (!(test instanceof RegExp) || Object.keys(rest).length > 0 || loaders.length + (uses.includes(babel) ? 1 : 0) !== uses.length) {
57
- read.differences.push(`a rule of the \`webpack\` function of next.config does not apply: only one with a \`test\` and the loaders in \`use\` does, not ${String(test ?? "this one")}`);
56
+ if (!isCondition(test) || [
57
+ include,
58
+ exclude,
59
+ resourceQuery
60
+ ].some((condition) => condition !== void 0 && !isCondition(condition)) || enforce !== void 0 && enforce !== "pre" && enforce !== "post" || Object.keys(rest).length > 0 || loaders.length + (uses.includes(babel) ? 1 : 0) !== uses.length) {
61
+ read.differences.push(`a rule of the \`webpack\` function of next.config does not apply: only one with a \`test\` and the loaders in \`use\` does, with \`include\`, \`exclude\`, \`resourceQuery\` and \`enforce\` or not, not ${String(test ?? "this one")}`);
58
62
  continue;
59
63
  }
60
64
  read.rules.push({
61
65
  test,
66
+ ...include !== void 0 && { include },
67
+ ...exclude !== void 0 && { exclude },
68
+ ...resourceQuery !== void 0 && { resourceQuery },
69
+ ...enforce !== void 0 && { enforce },
62
70
  loaders,
63
71
  compile: uses.includes(babel)
64
72
  });
@@ -70,6 +78,29 @@ function readWebpackFunction(webpack, options) {
70
78
  if ((after?.plugins?.length ?? 0) > 0) read.differences.push("the plugins of webpack that the `webpack` function of next.config adds do not run");
71
79
  return read;
72
80
  }
81
+ const isCondition = (condition) => condition instanceof RegExp || typeof condition === "string" || typeof condition === "function" || Array.isArray(condition) && condition.every(isCondition);
82
+ const matches = (condition, value) => condition instanceof RegExp ? condition.test(value) : typeof condition === "string" ? value.startsWith(condition) : typeof condition === "function" ? condition(value) : condition.some((entry) => matches(entry, value));
83
+ const viteQuery = /[?&](?:raw|url|inline|no-inline|worker|worker_file|sharedworker)(?=[=&]|$)/;
84
+ /**
85
+ * The loaders of the rules for a file, as webpack matches them: `test`,
86
+ * `include` and `exclude` its path, `resourceQuery` its query. Each rule that
87
+ * matches adds its loaders, as webpack runs them: those of `enforce: "pre"`
88
+ * first, then the others, then those of `enforce: "post"`. Fumadocs matches an
89
+ * `.mdx` file with `?collection=docs`, and a `meta.json` with it by its query.
90
+ */
91
+ function loadersOf(rules, id) {
92
+ if (viteQuery.test(id)) return;
93
+ const queryAt = id.indexOf("?");
94
+ const file = queryAt < 0 ? id : id.slice(0, queryAt);
95
+ const query = queryAt < 0 ? "" : id.slice(queryAt);
96
+ const matched = rules.filter((rule) => matches(rule.test, file) && (rule.include === void 0 || matches(rule.include, file)) && (rule.exclude === void 0 || !matches(rule.exclude, file)) && (rule.resourceQuery === void 0 || matches(rule.resourceQuery, query)));
97
+ if (matched.length === 0) return;
98
+ const phase = ({ enforce }) => enforce === "post" ? 0 : enforce === "pre" ? 2 : 1;
99
+ return {
100
+ loaders: matched.toSorted((a, b) => phase(a) - phase(b)).flatMap((rule) => rule.loaders),
101
+ compile: matched.some((rule) => rule.compile)
102
+ };
103
+ }
73
104
  /**
74
105
  * The candidates of an alias for an import, as webpack takes them: a name
75
106
  * matches the import and what is under it, and one that ends with `$` only
@@ -114,7 +145,7 @@ function createWebpackRules(context, runLoader) {
114
145
  ssr: server,
115
146
  browser: client
116
147
  };
117
- const ruleOf = (file, layer) => of[layer].rules.find((rule) => rule.test.test(file));
148
+ const ruleOf = (id, layer) => loadersOf(of[layer].rules, id);
118
149
  const loaders = /* @__PURE__ */ new Map();
119
150
  const loaderAt = (request) => {
120
151
  let loader = loaders.get(request);
@@ -141,14 +172,14 @@ function createWebpackRules(context, runLoader) {
141
172
  ssr: packagesOf(server),
142
173
  browser: packagesOf(client)
143
174
  },
144
- async loadWithWebpackRule(file, layer) {
145
- const rule = ruleOf(file, layer);
175
+ async loadWithWebpackRule(id, layer, source) {
176
+ const rule = ruleOf(id, layer);
146
177
  if (!rule) return;
147
- let code = await fs.promises.readFile(file);
178
+ let code = source ?? await fs.promises.readFile(id.split("?")[0]);
148
179
  for (const { loader, options } of rule.loaders.toReversed()) {
149
180
  const run = loaderAt(loader);
150
181
  const input = run.raw ? Buffer.from(code) : String(code);
151
- [code] = await runLoader(run, options, file, input);
182
+ [code] = await runLoader(run, options, id, input);
152
183
  }
153
184
  return String(code);
154
185
  },
@@ -157,4 +188,4 @@ function createWebpackRules(context, runLoader) {
157
188
  };
158
189
  }
159
190
  //#endregion
160
- export { aliasOf, createWebpackRules, importOfFile, readWebpackFunction };
191
+ export { aliasOf, createWebpackRules, importOfFile, loadersOf, readWebpackFunction };
package/dist/project.d.ts CHANGED
@@ -3,7 +3,7 @@ import { RoutesManifest } from "./compat/types.js";
3
3
  import { FlightEntry } from "./flight.js";
4
4
  import { PageEnv } from "./project/env.js";
5
5
  import { NewExport } from "./node-platform.js";
6
- import { WebpackRule } from "./project/webpack.js";
6
+ import { WebpackLoaders } from "./project/webpack.js";
7
7
  import { ResolveRoutesParams } from "@next/routing";
8
8
  import * as _$vite from "vite";
9
9
  import { IncomingMessage, ServerResponse } from "node:http";
@@ -155,11 +155,16 @@ type NextProject = {
155
155
  */
156
156
  dependencyCompileOptions: Record<NextLayer, unknown>;
157
157
  /**
158
- * The rule of the `webpack` function in `next.config` for a file, like the
159
- * one of `@next/mdx` for an `.mdx` file: see project/webpack.ts.
158
+ * The loaders of the rules of the `webpack` function in `next.config` for a
159
+ * module, like the rule of `@next/mdx` for an `.mdx` file, or Fumadocs' for
160
+ * one with `?collection=docs`: see project/webpack.ts.
160
161
  */
161
- webpackRuleOf(file: string, layer: NextLayer): WebpackRule | undefined; /** What the loaders of that rule make of the file. Nothing for a file without one. */
162
- loadWithWebpackRule(file: string, layer: NextLayer): Promise<string | undefined>; /** The candidates of an alias of the `webpack` function for an import, in order. */
162
+ webpackRuleOf(id: string, layer: NextLayer): WebpackLoaders | undefined;
163
+ /**
164
+ * What those loaders make of the file, or of `source`, its code as Vite
165
+ * read it. Nothing for a module without them.
166
+ */
167
+ loadWithWebpackRule(id: string, layer: NextLayer, source?: string): Promise<string | undefined>; /** The candidates of an alias of the `webpack` function for an import, in order. */
163
168
  webpackAliasOf(source: string, layer: NextLayer): string[] | undefined; /** The packages among those candidates, to pre-bundle. */
164
169
  webpackPackages: Record<NextLayer, string[]>; /** What of the `webpack` function does not apply, to say when a run starts. */
165
170
  webpackDifferences: string[];
package/dist/rsc.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { actionModulePrefix, registry } from "./registry.js";
2
2
  import { builtStylesheetsOf, stylesheetsEvent } from "./styles-shared.js";
3
- import { preloadPage } from "./document.js";
4
3
  import { buildDirectory, builtFiles } from "./built-files.js";
4
+ import { preloadPage } from "./document.js";
5
5
  import { NoFallbackError } from "./compat/rsc/handlers.js";
6
6
  import { nextConfig, speedFeatures } from "virtual:vitest-plugin-rsc/next-manifest";
7
7
  import { askDevServer, versionsOfAnswers } from "@kasperpeulen/vitest-plugin-rsc/internal/utils";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kasperpeulen/vitest-plugin-nextjs",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.1",
4
4
  "description": "Test and render Next.js App Router pages with Vite: in Vitest Browser Mode, vite dev and Storybook, with Next's own runtime.",
5
5
  "keywords": [
6
6
  "nextjs",
@@ -76,7 +76,7 @@
76
76
  "tinyglobby": "^0.2.17"
77
77
  },
78
78
  "devDependencies": {
79
- "@next/routing": "16.4.0",
79
+ "@next/routing": "16.4.3",
80
80
  "@noble/hashes": "2.4.0",
81
81
  "@types/node": "^24.12.4",
82
82
  "@types/react": "^19.2.14",
@@ -86,7 +86,7 @@
86
86
  "fflate": "0.8.3",
87
87
  "happy-dom": "^21.0.4",
88
88
  "memfs": "4.80.0",
89
- "next": "16.4.0",
89
+ "next": "16.4.3",
90
90
  "node-inspect-extracted": "3.3.4",
91
91
  "path-browserify": "1.0.1",
92
92
  "react": "^19.2.6",