mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
fef54db9b4
* fix: skip MDX files during RSC scan-build to prevent parse errors (issue #659) Adds a vinext:rsc-scan-mdx-guard plugin that runs with enforce:"pre" to ensure MDX files are skipped before rsc:scan-strip processes them. This prevents es-module-lexer parse errors when MDX files contain frontmatter (---) or JSX that isn't valid JavaScript syntax. Also adds a regression test that uses MDX files with YAML frontmatter loaded via import.meta.glob to verify the build succeeds. * fix(mdx): return stub module for undetected MDX files during scan-build The guard plugin was a no-op that passed raw MDX through unchanged. When rsc:scan-strip received this raw MDX, es-module-lexer.parse() failed on frontmatter (---) and JSX syntax. The real fix is in vinext:mdx: when mdxDelegate is null (no MDX files in app/ or pages/), return a valid JS stub module instead of undefined. This handles MDX files that enter the build graph via import.meta.glob outside app/pages. - Remove vinext:rsc-scan-mdx-guard plugin (was a no-op) - Modify vinext:mdx to return stub module when mdxDelegate is null - Remove app/mdx-probe/page.mdx from test (was hiding the bug) - Replace fragile '---' assertion with specific frontmatter checks Fixes issue #659 * fix(mdx): lazily compile globbed mdx imports Compile plain .mdx files on demand when they enter the graph via import.meta.glob outside app/pages so scan-build stops crashing without discarding runtime content. Restore the existing happy-path MDX fixture, add focused lazy-compilation coverage, and extract shared Cloudflare fixture setup to keep the regression tests honest. * fix review issues * fix: use absolute path for root tsconfig alias to fix import.meta.glob resolution toViteAliasReplacement was returning "/" (Vite root-relative convention) when the tsconfig path alias pointed to the project root (e.g. "@/*": ["./*"]). This worked for regular imports but broke import.meta.glob because Vite's glob plugin resolves patterns via this.resolve(), which couldn't properly resolve the "/" replacement to matching filesystem files. Changed to return the absolute path instead, which works correctly for both regular imports and glob pattern resolution. Signed-off-by: Divanshu Chauhan <divkix@divkix.me> Made-with: Cursor * refactor: address review feedback on MDX lazy initialization - Add clarifying comments for mdxDelegatePromise caching semantics - Document subtle control flow when hasUserMdxPlugin is true - Suppress duplicate warning on on-demand MDX init (only warn during detection) - Add case-insensitive extension check in transform hook - Add test for query parameter edge cases (?raw, ?v=123, etc.) * refactor: address review feedback on MDX handling - Merge duplicate query-param MDX skip tests in pages-router.test.ts - Tighten hasUserMdxPlugin control flow comment for clarity - Make scanDirForMdx case-insensitive to align with transform check Refs: PR #668 review feedback * docs: clarify mdx delegate fallback comment * chore: trigger ci --------- Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
316 lines
7.9 KiB
TypeScript
316 lines
7.9 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { createBuilder } from "vite";
|
|
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);
|
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
fs.writeFileSync(absPath, content);
|
|
}
|
|
|
|
function readTextFilesRecursive(root: string): string {
|
|
let output = "";
|
|
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
const entryPath = path.join(root, entry.name);
|
|
if (entry.isDirectory()) {
|
|
output += readTextFilesRecursive(entryPath);
|
|
continue;
|
|
}
|
|
if (!entry.name.endsWith(".js")) continue;
|
|
output += fs.readFileSync(entryPath, "utf-8");
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function loadCloudflarePlugin(): Promise<CloudflarePluginFactory> {
|
|
const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as {
|
|
cloudflare: CloudflarePluginFactory;
|
|
};
|
|
return cloudflare;
|
|
}
|
|
|
|
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,
|
|
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",
|
|
"assets": {
|
|
"not_found_handling": "none",
|
|
"binding": "ASSETS"
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
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({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<html lang="en">
|
|
<body>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"mdx-components.tsx",
|
|
`export function useMDXComponents(components: Record<string, unknown>) {
|
|
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",
|
|
`import { getGlobPostCount } from "../lib/mdx-loader";
|
|
|
|
export default function HomePage() {
|
|
return <main>home {getGlobPostCount()}</main>;
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"app/mdx-probe/page.mdx",
|
|
`# Probe
|
|
|
|
This file exists only to trigger vinext's MDX auto-detection in the fixture.
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"lib/mdx-loader.ts",
|
|
`type MdxModule = {
|
|
default: React.ComponentType;
|
|
};
|
|
|
|
export const mdxModules = import.meta.glob("@/content/posts/**/*.mdx", {
|
|
eager: true,
|
|
}) as Record<string, MdxModule>;
|
|
|
|
export function getGlobPostCount(): number {
|
|
return Object.keys(mdxModules).length;
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"lib/mdx-dynamic.ts",
|
|
`export async function loadDynamicPost(year: string, month: string, day: string, slug: string) {
|
|
return await import(\`@/content/posts/\${year}/\${month}/\${day}/\${slug}/index.mdx\`);
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"app/dynamic-posts/[year]/[month]/[day]/[slug]/page.tsx",
|
|
`import { notFound } from "next/navigation";
|
|
import { loadDynamicPost } from "../../../../../../lib/mdx-dynamic";
|
|
|
|
export default async function DynamicPostPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ year: string; month: string; day: string; slug: string }>;
|
|
}) {
|
|
const { year, month, day, slug } = await params;
|
|
const mod = await loadDynamicPost(year, month, day, slug).catch(() => null);
|
|
|
|
if (!mod) {
|
|
notFound();
|
|
}
|
|
|
|
const Content = mod.default;
|
|
|
|
return (
|
|
<main data-testid="dynamic-post-page">
|
|
<h1>Dynamic Post</h1>
|
|
<Content />
|
|
</main>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"content/posts/2024/01/02/glob-post/index.mdx",
|
|
`# Globbed-Only MDX Post
|
|
|
|
This content came from an alias-based MDX import.
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"content/posts/2024/01/02/dynamic-post/index.mdx",
|
|
`# Dynamic MDX Post
|
|
|
|
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,
|
|
"app/page.tsx",
|
|
`import { getGlobPostCount } from "../lib/mdx-loader";
|
|
|
|
export default function HomePage() {
|
|
return <main>home {getGlobPostCount()}</main>;
|
|
}
|
|
`,
|
|
);
|
|
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<string, MdxModule>;
|
|
|
|
export function getGlobPostCount(): number {
|
|
return Object.keys(mdxModules).length;
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"content/posts/2025/08/20/second-post/index.mdx",
|
|
`---
|
|
title: "Second Post"
|
|
date: "2025-08-20"
|
|
---
|
|
|
|
<span className="text-red-500">This is a post with frontmatter and JSX.</span>
|
|
`,
|
|
);
|
|
|
|
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);
|
|
});
|