diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 8a25adaa..f9d1054d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -348,7 +348,9 @@ function toViteAliasReplacement(absolutePath: string, projectRoot: string): stri for (const rootCandidate of rootCandidates) { for (const pathCandidate of pathCandidates) { - if (pathCandidate === rootCandidate) return "/"; + if (pathCandidate === rootCandidate) { + return normalizedPath; + } const relativeId = relativeWithinRoot(rootCandidate, pathCandidate); if (relativeId) return "/" + relativeId; } @@ -1307,9 +1309,69 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const imageImportDimCache = new Map(); - // Shared state for the MDX proxy plugin. Populated during config() if MDX - // files are detected and @mdx-js/rollup is installed. + // Shared state for the MDX proxy plugin. We auto-inject @mdx-js/rollup when + // MDX is detected in app/pages during config(), and lazily on first plain + // .mdx transform for MDX that only enters the graph via import.meta.glob. let mdxDelegate: Plugin | null = null; + // Cached across calls — only the first invocation's `reason` affects logging. + // This is correct because config() always runs before transform() in the same build. + let mdxDelegatePromise: Promise | null = null; + let hasUserMdxPlugin = false; + let warnedMissingMdxPlugin = false; + + async function ensureMdxDelegate(reason: "detected" | "on-demand"): Promise { + // If user registered their own MDX plugin, don't interfere — their plugin + // will handle .mdx transforms later in the pipeline. + // Note: hasUserMdxPlugin is set during config() which always runs before transform(). + // When true, we return null (mdxDelegate is null) and the caller silently + // returns undefined to let the user's plugin handle the file. + if (mdxDelegate || hasUserMdxPlugin) return mdxDelegate; + if (!mdxDelegatePromise) { + mdxDelegatePromise = (async () => { + try { + const mdxRollup = await import("@mdx-js/rollup"); + const mdxFactory = (mdxRollup.default ?? mdxRollup) as ( + options: Record, + ) => Plugin; + const mdxOpts: Record = {}; + if (nextConfig.mdx) { + if (nextConfig.mdx.remarkPlugins) mdxOpts.remarkPlugins = nextConfig.mdx.remarkPlugins; + if (nextConfig.mdx.rehypePlugins) mdxOpts.rehypePlugins = nextConfig.mdx.rehypePlugins; + if (nextConfig.mdx.recmaPlugins) mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins; + } + const delegate = mdxFactory(mdxOpts); + mdxDelegate = delegate; + if (reason === "detected") { + if (nextConfig.mdx) { + console.log( + "[vinext] Auto-injected @mdx-js/rollup with remark/rehype plugins from next.config", + ); + } else { + console.log("[vinext] Auto-injected @mdx-js/rollup for MDX support"); + } + } else { + console.log("[vinext] Auto-injected @mdx-js/rollup for on-demand MDX support"); + } + return delegate; + } catch { + // Only warn during "detected" path (MDX files in app/pages at config time). + // For "on-demand" (MDX encountered during transform), the error thrown + // in transform() is more actionable and immediate. Avoid double messaging. + if (reason === "detected" && !warnedMissingMdxPlugin) { + warnedMissingMdxPlugin = true; + console.warn( + "[vinext] MDX files detected but @mdx-js/rollup is not installed. " + + "Install it with: " + + detectPackageManager(process.cwd()) + + " @mdx-js/rollup", + ); + } + return null; + } + })(); + } + return mdxDelegatePromise; + } const plugins: PluginOption[] = [ // Resolve tsconfig paths/baseUrl aliases so real-world Next.js repos @@ -2035,7 +2097,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Auto-inject @mdx-js/rollup when MDX files exist and no MDX plugin is // already configured. Applies remark/rehype plugins from next.config. - const hasMdxPlugin = pluginsFlat.some( + hasUserMdxPlugin = pluginsFlat.some( (p: any) => p && typeof p === "object" && @@ -2043,37 +2105,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { (p.name === "@mdx-js/rollup" || p.name === "mdx"), ); if ( - !hasMdxPlugin && + !hasUserMdxPlugin && hasMdxFiles(root, hasAppDir ? appDir : null, hasPagesDir ? pagesDir : null) ) { - try { - const mdxRollup = await import("@mdx-js/rollup"); - const mdxFactory = mdxRollup.default ?? mdxRollup; - const mdxOpts: Record = {}; - if (nextConfig.mdx) { - if (nextConfig.mdx.remarkPlugins) - mdxOpts.remarkPlugins = nextConfig.mdx.remarkPlugins; - if (nextConfig.mdx.rehypePlugins) - mdxOpts.rehypePlugins = nextConfig.mdx.rehypePlugins; - if (nextConfig.mdx.recmaPlugins) mdxOpts.recmaPlugins = nextConfig.mdx.recmaPlugins; - } - mdxDelegate = mdxFactory(mdxOpts); - if (nextConfig.mdx) { - console.log( - "[vinext] Auto-injected @mdx-js/rollup with remark/rehype plugins from next.config", - ); - } else { - console.log("[vinext] Auto-injected @mdx-js/rollup for MDX support"); - } - } catch { - // @mdx-js/rollup not installed — warn but don't fail - console.warn( - "[vinext] MDX files detected but @mdx-js/rollup is not installed. " + - "Install it with: " + - detectPackageManager(process.cwd()) + - " @mdx-js/rollup", - ); - } + await ensureMdxDelegate("detected"); } // Detect if this is a standalone SSR build (set by `vite build --ssr` @@ -2591,14 +2626,27 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const fn = typeof hook === "function" ? hook : hook.handler; return fn.call(this, config, env); }, - transform(code, id, options) { + async transform(code, id, options) { // Skip ?raw and other query imports — @mdx-js/rollup ignores the query // and would compile the file as MDX instead of returning raw text. if (id.includes("?")) return; - if (!mdxDelegate?.transform) return; - const hook = mdxDelegate.transform; - const fn = typeof hook === "function" ? hook : hook.handler; - return fn.call(this, code, id, options); + // Case-insensitive extension check for cross-platform compatibility + // (Windows/macOS case-insensitive, Linux case-sensitive) + if (!id.toLowerCase().endsWith(".mdx")) return; + + const delegate = mdxDelegate ?? (await ensureMdxDelegate("on-demand")); + if (delegate?.transform) { + const hook = delegate.transform; + const transform = typeof hook === "function" ? hook : hook.handler; + return transform.call(this, code, id, options); + } + + if (!hasUserMdxPlugin) { + throw new Error( + `[vinext] Encountered MDX module ${id} but no MDX plugin is configured. ` + + `Install @mdx-js/rollup or register an MDX plugin manually.`, + ); + } }, }, // Shim React canary/experimental APIs (ViewTransition, addTransitionType) diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 675ad5eb..f09354e3 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1331,7 +1331,7 @@ describe("Plugin config", () => { expect(defaultHandler).not.toHaveBeenCalled(); }); - it("registers vinext:mdx proxy plugin with enforce pre for correct ordering", () => { + it("registers vinext:mdx proxy plugin with enforce pre for correct ordering", async () => { const plugins = vinext() as any[]; const mdxProxy = plugins.find((p) => p.name === "vinext:mdx"); expect(mdxProxy).toBeDefined(); @@ -1341,10 +1341,10 @@ describe("Plugin config", () => { expect(typeof mdxProxy.transform).toBe("function"); // Proxy should be inert when no MDX files are detected (mdxDelegate is null) expect(mdxProxy.config({}, { command: "build", mode: "production" })).toBeUndefined(); - expect(mdxProxy.transform("code", "./foo.ts", {})).toBeUndefined(); + await expect(mdxProxy.transform("code", "./foo.ts", {})).resolves.toBeUndefined(); }); - it("vinext:mdx transform skips ids that contain a query string (regression: ?raw)", () => { + it("vinext:mdx transform skips ids that contain a query string (regression: ?raw)", async () => { // @mdx-js/rollup strips the query before matching the file extension, so // it would compile "foo.mdx?raw" as MDX and return compiled JSX instead of // raw text. The proxy must short-circuit on any id that contains "?". @@ -1352,9 +1352,72 @@ describe("Plugin config", () => { const mdxProxy = plugins.find((p: any) => p.name === "vinext:mdx"); // Common query-param import patterns that must be skipped - expect(mdxProxy.transform("# hello", "/app/content.mdx?raw", {})).toBeUndefined(); - expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).toBeUndefined(); - expect(mdxProxy.transform("# hello", "/app/page.mdx?inline", {})).toBeUndefined(); + await expect( + mdxProxy.transform("# hello", "/app/content.mdx?raw", {}), + ).resolves.toBeUndefined(); + await expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).resolves.toBeUndefined(); + await expect( + mdxProxy.transform("# hello", "/app/page.mdx?inline", {}), + ).resolves.toBeUndefined(); + }); + + it("vinext:mdx lazily compiles plain .mdx imports that were not pre-detected", async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mdx-lazy-")); + + try { + await fsp.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "vinext-mdx-lazy", private: true, type: "module" }), + ); + + const plugins = vinext({ appDir: tmpDir }) as any[]; + const configPlugin = plugins.find((p) => p.name === "vinext:config"); + const mdxProxy = plugins.find((p) => p.name === "vinext:mdx"); + + await configPlugin.config( + { root: tmpDir, plugins: [] }, + { command: "build", mode: "production" }, + ); + + const result = await mdxProxy.transform( + `--- +title: "Second Post" +--- + +export const marker = "mdx-evaluated"; + +# Hello world +`, + path.join(tmpDir, "content", "post.mdx"), + {}, + ); + + expect(result).toBeDefined(); + expect(result.code).toContain("mdx-evaluated"); + expect(result.code).not.toContain('title: "Second Post"'); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("skips MDX files with various query parameters", async () => { + const plugins = vinext() as any[]; + const mdxProxy = plugins.find((p: any) => p.name === "vinext:mdx"); + + // All query variants should be skipped (return undefined) + await expect( + mdxProxy.transform("# hello", "/app/content.mdx?raw", {}), + ).resolves.toBeUndefined(); + await expect(mdxProxy.transform("# hello", "/app/page.mdx?v=123", {})).resolves.toBeUndefined(); + await expect( + mdxProxy.transform("# hello", "/app/page.mdx?inline", {}), + ).resolves.toBeUndefined(); + await expect(mdxProxy.transform("# hello", "/app/page.mdx?url", {})).resolves.toBeUndefined(); + await expect(mdxProxy.transform("# hello", "/app/page.mdx?mdx", {})).resolves.toBeUndefined(); + // Edge case: query value contains .mdx but isn't the extension + await expect( + mdxProxy.transform("# hello", "/app/page.mdx?something.mdx", {}), + ).resolves.toBeUndefined(); }); it("vinext:mdx proxy logic — ?raw guard prevents delegate from compiling query imports", () => { diff --git a/tests/tsconfig-path-alias-build.test.ts b/tests/tsconfig-path-alias-build.test.ts index 147a9650..e822f4f7 100644 --- a/tests/tsconfig-path-alias-build.test.ts +++ b/tests/tsconfig-path-alias-build.test.ts @@ -7,6 +7,17 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import vinext from "../packages/vinext/src/index.js"; const tmpDirs: string[] = []; +const workerEntryPath = path + .resolve(import.meta.dirname, "../packages/vinext/src/server/app-router-entry.ts") + .replace(/\\/g, "/"); +const cfPluginPath = path.resolve( + import.meta.dirname, + "./fixtures/cf-app-basic/node_modules/@cloudflare/vite-plugin/dist/index.mjs", +); + +type CloudflarePluginFactory = (opts?: { + viteEnvironment?: { name: string; childEnvironments?: string[] }; +}) => import("vite").Plugin; function writeFixtureFile(root: string, filePath: string, content: string) { const absPath = path.join(root, filePath); @@ -28,53 +39,38 @@ function readTextFilesRecursive(root: string): string { return output; } -describe("App Router tsconfig path aliases in production builds", () => { - afterEach(() => { - for (const dir of tmpDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); +async function loadCloudflarePlugin(): Promise { + const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as { + cloudflare: CloudflarePluginFactory; + }; + return cloudflare; +} - it("transforms alias-based import.meta.glob and alias-based dynamic import in Cloudflare builds", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-alias-build-")); - tmpDirs.push(root); - const workerEntryPath = path - .resolve(import.meta.dirname, "../packages/vinext/src/server/app-router-entry.ts") - .replace(/\\/g, "/"); - const cfPluginPath = path.resolve( - import.meta.dirname, - "./fixtures/cf-app-basic/node_modules/@cloudflare/vite-plugin/dist/index.mjs", - ); - const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as { - cloudflare: (opts?: { - viteEnvironment?: { name: string; childEnvironments?: string[] }; - }) => import("vite").Plugin; - }; - - fs.symlinkSync( - path.resolve(import.meta.dirname, "../node_modules"), - path.join(root, "node_modules"), - "junction", - ); +function writeCloudflareAppFixture(root: string, name: string) { + fs.symlinkSync( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); - writeFixtureFile( - root, - "package.json", - JSON.stringify( - { - name: "vinext-tsconfig-alias-build", - private: true, - type: "module", - }, - null, - 2, - ), - ); - writeFixtureFile( - root, - "wrangler.jsonc", - `{ - "name": "vinext-tsconfig-alias-build", + writeFixtureFile( + root, + "package.json", + JSON.stringify( + { + name, + private: true, + type: "module", + }, + null, + 2, + ), + ); + writeFixtureFile( + root, + "wrangler.jsonc", + `{ + "name": ${JSON.stringify(name)}, "compatibility_date": "2026-02-12", "compatibility_flags": ["nodejs_compat"], "main": "./worker/index.ts", @@ -84,34 +80,34 @@ describe("App Router tsconfig path aliases in production builds", () => { } } `, - ); - writeFixtureFile( - root, - "tsconfig.json", - JSON.stringify( - { - compilerOptions: { - target: "ES2022", - module: "ESNext", - moduleResolution: "bundler", - jsx: "react-jsx", - strict: true, - skipLibCheck: true, - types: ["vite/client", "@vitejs/plugin-rsc/types"], - paths: { - "@/*": ["./*"], - }, + ); + writeFixtureFile( + root, + "tsconfig.json", + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "bundler", + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + types: ["vite/client", "@vitejs/plugin-rsc/types"], + paths: { + "@/*": ["./*"], }, - include: ["app", "lib", "content", "*.ts", "*.tsx"], }, - null, - 2, - ), - ); - writeFixtureFile( - root, - "app/layout.tsx", - `export default function RootLayout({ + include: ["app", "lib", "content", "*.ts", "*.tsx"], + }, + null, + 2, + ), + ); + writeFixtureFile( + root, + "app/layout.tsx", + `export default function RootLayout({ children, }: { children: React.ReactNode; @@ -123,7 +119,50 @@ describe("App Router tsconfig path aliases in production builds", () => { ); } `, - ); + ); + writeFixtureFile( + root, + "mdx-components.tsx", + `export function useMDXComponents(components: Record) { + return components; +} +`, + ); + writeFixtureFile( + root, + "worker/index.ts", + `import handler from ${JSON.stringify(workerEntryPath)}; + +export default handler; +`, + ); +} + +async function buildCloudflareAppFixture(root: string) { + const cloudflare = await loadCloudflarePlugin(); + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + vinext({ appDir: root }), + cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }), + ], + logLevel: "silent", + }); + await builder.buildApp(); +} + +describe("App Router tsconfig path aliases in production builds", () => { + afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("transforms alias-based import.meta.glob and alias-based dynamic import in Cloudflare builds", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-alias-build-")); + tmpDirs.push(root); + writeCloudflareAppFixture(root, "vinext-tsconfig-alias-build"); writeFixtureFile( root, "app/page.tsx", @@ -211,28 +250,66 @@ This content came from an alias-based MDX import. This content came from an alias-based dynamic MDX import. `, ); + + await buildCloudflareAppFixture(root); + + const buildOutput = readTextFilesRecursive(path.join(root, "dist")); + expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"'); + expect(buildOutput).not.toContain("@/content/posts/"); + }, 60_000); + + it("import.meta.glob with MDX files containing frontmatter does not cause parse errors (issue #659)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mdx-frontmatter-build-")); + tmpDirs.push(root); + writeCloudflareAppFixture(root, "vinext-mdx-frontmatter-test"); writeFixtureFile( root, - "worker/index.ts", - `import handler from ${JSON.stringify(workerEntryPath)}; + "app/page.tsx", + `import { getGlobPostCount } from "../lib/mdx-loader"; -export default handler; +export default function HomePage() { + return
home {getGlobPostCount()}
; +} `, ); + writeFixtureFile( + root, + "lib/mdx-loader.ts", + `type MdxModule = { + default: React.ComponentType; + frontmatter?: { + title: string; + date: string; + }; +}; + +export const mdxModules = import.meta.glob("@/content/posts/**/*.mdx", { + eager: true, +}) as Record; - const builder = await createBuilder({ +export function getGlobPostCount(): number { + return Object.keys(mdxModules).length; +} +`, + ); + writeFixtureFile( root, - configFile: false, - plugins: [ - vinext({ appDir: root }), - cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }), - ], - logLevel: "silent", - }); - await builder.buildApp(); + "content/posts/2025/08/20/second-post/index.mdx", + `--- +title: "Second Post" +date: "2025-08-20" +--- + +This is a post with frontmatter and JSX. +`, + ); + + await buildCloudflareAppFixture(root); const buildOutput = readTextFilesRecursive(path.join(root, "dist")); expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"'); expect(buildOutput).not.toContain("@/content/posts/"); + expect(buildOutput).not.toContain('title: "Second Post"'); + expect(buildOutput).toContain("text-red-500"); }, 60_000); }); diff --git a/tests/tsconfig-paths-vite8.test.ts b/tests/tsconfig-paths-vite8.test.ts index 1a1f5543..1c4c2864 100644 --- a/tests/tsconfig-paths-vite8.test.ts +++ b/tests/tsconfig-paths-vite8.test.ts @@ -110,11 +110,11 @@ describe("Vite tsconfig paths support", () => { { command: "serve", mode: "development" }, ); - expect(resolvedConfig?.resolve?.alias).toEqual( - expect.objectContaining({ - "@": "/", - }), - ); + const alias = resolvedConfig?.resolve?.alias as Record; + expect(alias).toBeDefined(); + expect(alias["@"]).toBeDefined(); + expect(path.isAbsolute(alias["@"])).toBe(true); + expect(alias["@"].replace(/\\/g, "/")).toContain(root.replace(/\\/g, "/")); fs.rmSync(root, { recursive: true, force: true }); });