From a91c9906d285b015e40e1a72e26d711f64636e05 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 15:46:49 +0000 Subject: [PATCH 1/6] feat(check): flag __dirname/__filename and suggest ESM path APIs Closes #583 Scan server source files for free uses of the CJS globals __dirname and __filename and report them as a partial compatibility issue in the vinext check output. Uses a single-pass alternation regex that skips string literals, template literals, and comments in one pass, so identifiers inside those tokens are never matched. --- packages/vinext/src/check.ts | 31 +++++++++++++ tests/check.test.ts | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index 6855a751..c394630b 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -550,6 +550,37 @@ export function checkConventions(root: string): CheckItem[] { } } + // Scan for free uses of __dirname / __filename (CJS globals, not available in ESM). + // Walk the source once with a single alternation regex that either: + // - skips over a string literal, template literal, or comment (the skip branches), or + // - matches the identifier (the capture branch). + // Only the capture branch sets match[1], so we never test inside skipped tokens. + const cjsGlobalScanRegex = + /\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b(__dirname|__filename)\b/g; + const cjsGlobalFiles: string[] = []; + for (const file of allSourceFiles) { + const raw = fs.readFileSync(file, "utf-8"); + cjsGlobalScanRegex.lastIndex = 0; + let found = false; + let m; + while ((m = cjsGlobalScanRegex.exec(raw)) !== null) { + if (m[1]) { + found = true; + break; + } + } + if (found) cjsGlobalFiles.push(path.relative(root, file)); + } + if (cjsGlobalFiles.length > 0) { + items.push({ + name: "__dirname / __filename (CommonJS globals)", + status: "partial", + detail: + "CJS globals unavailable in ESM — use fileURLToPath(import.meta.url) / dirname(...), or import.meta.dirname / import.meta.filename (Node 22+)", + files: cjsGlobalFiles, + }); + } + return items; } diff --git a/tests/check.test.ts b/tests/check.test.ts index b8809242..2d1aa12c 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -648,6 +648,93 @@ describe("checkConventions", () => { const postcss = items.find((i) => i.name.includes("PostCSS")); expect(postcss).toBeUndefined(); }); + + it("detects __dirname usage in server files", () => { + writeFile("lib/db.ts", `import path from "path";\nconst dir = path.join(__dirname, "data");`); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + const items = checkConventions(tmpDir); + const cjs = items.find((i) => i.name.includes("__dirname")); + expect(cjs).toBeDefined(); + expect(cjs?.status).toBe("partial"); + expect(cjs?.detail).toContain("fileURLToPath"); + expect(cjs?.detail).toContain("import.meta.dirname"); + expect(cjs?.files).toContain("lib/db.ts"); + }); + + it("detects __filename usage", () => { + writeFile("lib/logger.ts", `const file = __filename;`); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + const items = checkConventions(tmpDir); + const cjs = items.find((i) => i.name.includes("__dirname")); + expect(cjs).toBeDefined(); + expect(cjs?.files).toContain("lib/logger.ts"); + }); + + it("detects both __dirname and __filename in same file", () => { + writeFile("lib/util.ts", `const dir = __dirname;\nconst file = __filename;`); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + const items = checkConventions(tmpDir); + const cjs = items.find((i) => i.name.includes("__dirname")); + expect(cjs).toBeDefined(); + expect(cjs?.files).toContain("lib/util.ts"); + // Only one item for both globals + expect( + items.filter((i) => i.name.includes("__dirname") || i.name.includes("__filename")), + ).toHaveLength(1); + }); + + it("does not flag __dirname inside string literals", () => { + writeFile( + "lib/comment.ts", + `const msg = "use __dirname instead";\nexport default function Home() { return null; }`, + ); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + const items = checkConventions(tmpDir); + const cjs = items.find((i) => i.name.includes("__dirname")); + expect(cjs).toBeUndefined(); + }); + + it("does not flag __dirname inside comments", () => { + writeFile("lib/note.ts", `// Previously used __dirname here\nexport const x = 1;`); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + const items = checkConventions(tmpDir); + const cjs = items.find((i) => i.name.includes("__dirname")); + expect(cjs).toBeUndefined(); + }); + + it("does not flag __dirname when not used at all", () => { + writeFile( + "lib/esm.ts", + `import { fileURLToPath } from "url";\nimport { dirname } from "path";\nconst __dirname = dirname(fileURLToPath(import.meta.url));`, + ); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + // The ESM pattern itself reassigns __dirname — this is fine and should not be flagged + // because users are already using the correct ESM idiom. + // Our scanner will see `__dirname` in the assignment target — that's an edge case we accept. + // This test just ensures we don't crash. + const items = checkConventions(tmpDir); + // No assertion on presence/absence — just verify it doesn't throw + expect(Array.isArray(items)).toBe(true); + }); + + it("tracks multiple files that use __dirname", () => { + writeFile("lib/a.ts", `const d = __dirname;`); + writeFile("lib/b.ts", `const f = __filename;`); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + const items = checkConventions(tmpDir); + const cjs = items.find((i) => i.name.includes("__dirname")); + expect(cjs).toBeDefined(); + expect(cjs?.files).toHaveLength(2); + expect(cjs?.files).toContain("lib/a.ts"); + expect(cjs?.files).toContain("lib/b.ts"); + }); }); // ── runCheck ─────────────────────────────────────────────────────────────── From 78be9b196ac707cebe3394d7a040c2d90a81dacf Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 15:50:13 +0000 Subject: [PATCH 2/6] refactor(check): merge ViewTransition and __dirname scans into one file loop --- packages/vinext/src/check.ts | 46 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index c394630b..34692ac3 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -505,16 +505,37 @@ export function checkConventions(root: string): CheckItem[] { } } - // Scan for ViewTransition import from react + // Scan all source files once for per-file checks: + // - ViewTransition import from react + // - free uses of __dirname / __filename (CJS globals, not available in ESM) + // + // For __dirname/__filename we use a single-pass alternation regex that skips over + // string literals, template literals, and comments before testing for the identifier, + // so tokens inside those contexts are never matched. const allSourceFiles = findSourceFiles(root); const viewTransitionRegex = /import\s+\{[^}]*\bViewTransition\b[^}]*\}\s+from\s+['"]react['"]/; + const cjsGlobalScanRegex = + /\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b(__dirname|__filename)\b/g; const viewTransitionFiles: string[] = []; + const cjsGlobalFiles: string[] = []; for (const file of allSourceFiles) { const content = fs.readFileSync(file, "utf-8"); + const rel = path.relative(root, file); + if (viewTransitionRegex.test(content)) { - viewTransitionFiles.push(path.relative(root, file)); + viewTransitionFiles.push(rel); + } + + cjsGlobalScanRegex.lastIndex = 0; + let m; + while ((m = cjsGlobalScanRegex.exec(content)) !== null) { + if (m[1]) { + cjsGlobalFiles.push(rel); + break; + } } } + // Emit items for the combined scan results if (viewTransitionFiles.length > 0) { items.push({ name: "ViewTransition (React canary API)", @@ -550,27 +571,6 @@ export function checkConventions(root: string): CheckItem[] { } } - // Scan for free uses of __dirname / __filename (CJS globals, not available in ESM). - // Walk the source once with a single alternation regex that either: - // - skips over a string literal, template literal, or comment (the skip branches), or - // - matches the identifier (the capture branch). - // Only the capture branch sets match[1], so we never test inside skipped tokens. - const cjsGlobalScanRegex = - /\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b(__dirname|__filename)\b/g; - const cjsGlobalFiles: string[] = []; - for (const file of allSourceFiles) { - const raw = fs.readFileSync(file, "utf-8"); - cjsGlobalScanRegex.lastIndex = 0; - let found = false; - let m; - while ((m = cjsGlobalScanRegex.exec(raw)) !== null) { - if (m[1]) { - found = true; - break; - } - } - if (found) cjsGlobalFiles.push(path.relative(root, file)); - } if (cjsGlobalFiles.length > 0) { items.push({ name: "__dirname / __filename (CommonJS globals)", From 347ff51204007625c35089ced7c5d5257bb8c70b Mon Sep 17 00:00:00 2001 From: "ask-bonk[bot]" Date: Thu, 19 Mar 2026 15:55:07 +0000 Subject: [PATCH 3/6] Regex misses `__dirname` in template expressions. Co-authored-by: james-elicx --- .../fumadocs-docs-template/.source/browser.ts | 12 ++++++++++ .../fumadocs-docs-template/.source/dynamic.ts | 8 +++++++ .../fumadocs-docs-template/.source/server.ts | 12 ++++++++++ .../.source/source.config.mjs | 24 +++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 examples/fumadocs-docs-template/.source/browser.ts create mode 100644 examples/fumadocs-docs-template/.source/dynamic.ts create mode 100644 examples/fumadocs-docs-template/.source/server.ts create mode 100644 examples/fumadocs-docs-template/.source/source.config.mjs diff --git a/examples/fumadocs-docs-template/.source/browser.ts b/examples/fumadocs-docs-template/.source/browser.ts new file mode 100644 index 00000000..83189ec3 --- /dev/null +++ b/examples/fumadocs-docs-template/.source/browser.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +import { browser } from 'fumadocs-mdx/runtime/browser'; +import type * as Config from '../source.config'; + +const create = browser(); +const browserCollections = { + docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "test.mdx": () => import("../content/docs/test.mdx?collection=docs"), }), +}; +export default browserCollections; \ No newline at end of file diff --git a/examples/fumadocs-docs-template/.source/dynamic.ts b/examples/fumadocs-docs-template/.source/dynamic.ts new file mode 100644 index 00000000..7dd9c10a --- /dev/null +++ b/examples/fumadocs-docs-template/.source/dynamic.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { dynamic } from 'fumadocs-mdx/runtime/dynamic'; +import * as Config from '../source.config'; + +const create = await dynamic(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}}); \ No newline at end of file diff --git a/examples/fumadocs-docs-template/.source/server.ts b/examples/fumadocs-docs-template/.source/server.ts new file mode 100644 index 00000000..b00d94bc --- /dev/null +++ b/examples/fumadocs-docs-template/.source/server.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +import * as __fd_glob_1 from "../content/docs/test.mdx?collection=docs" +import * as __fd_glob_0 from "../content/docs/index.mdx?collection=docs" +import { server } from 'fumadocs-mdx/runtime/server'; +import type * as Config from '../source.config'; + +const create = server({"doc":{"passthroughs":["extractedReferences"]}}); + +export const docs = await create.docs("docs", "content/docs", {}, {"index.mdx": __fd_glob_0, "test.mdx": __fd_glob_1, }); \ No newline at end of file diff --git a/examples/fumadocs-docs-template/.source/source.config.mjs b/examples/fumadocs-docs-template/.source/source.config.mjs new file mode 100644 index 00000000..23a070bc --- /dev/null +++ b/examples/fumadocs-docs-template/.source/source.config.mjs @@ -0,0 +1,24 @@ +// source.config.ts +import { defineConfig, defineDocs } from "fumadocs-mdx/config"; +import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; +var docs = defineDocs({ + dir: "content/docs", + docs: { + schema: pageSchema, + postprocess: { + includeProcessedMarkdown: true + } + }, + meta: { + schema: metaSchema + } +}); +var source_config_default = defineConfig({ + mdxOptions: { + // MDX options + } +}); +export { + source_config_default as default, + docs +}; From 36058d107ca7277a5fc12bb0bdc2508e91e32820 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 15:56:07 +0000 Subject: [PATCH 4/6] fix(check): expose __dirname inside template expressions to identifier match The previous template literal skip branch consumed `${...}` bodies as literal content, so __dirname in e.g. `${__dirname}/views` was silently skipped. Change the template branch to stop at ${ boundaries by excluding $ from the non-interpolation character class: [^`\\$]. A bare $ not followed by { is still allowed via |$(?!\{). This means the ${...} body is never consumed by the skip branch and __dirname inside template expressions is correctly caught by the identifier branch. Adds two new tests: one verifying plain template content (no interpolation) is still ignored, one verifying __dirname inside ${} is detected. --- packages/vinext/src/check.ts | 6 +++++- tests/check.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index 34692ac3..df4a4143 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -514,8 +514,12 @@ export function checkConventions(root: string): CheckItem[] { // so tokens inside those contexts are never matched. const allSourceFiles = findSourceFiles(root); const viewTransitionRegex = /import\s+\{[^}]*\bViewTransition\b[^}]*\}\s+from\s+['"]react['"]/; + // Single-pass regex: skip tokens that can contain identifier-like text, expose everything else + // to the identifier capture branch. Template literals are skipped segment-by-segment between + // `${` boundaries — the `${...}` body itself is NOT consumed, so `__dirname` inside template + // expressions (e.g. `${__dirname}/views`) is correctly exposed to the identifier branch. const cjsGlobalScanRegex = - /\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b(__dirname|__filename)\b/g; + /\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\$]|\\.|\$(?!\{))*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b(__dirname|__filename)\b/g; const viewTransitionFiles: string[] = []; const cjsGlobalFiles: string[] = []; for (const file of allSourceFiles) { diff --git a/tests/check.test.ts b/tests/check.test.ts index 2d1aa12c..82bd0d90 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -707,6 +707,25 @@ describe("checkConventions", () => { expect(cjs).toBeUndefined(); }); + it("does not flag __dirname inside a plain template literal (no interpolation)", () => { + writeFile("lib/msg.ts", "const msg = `use __dirname instead`;"); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + const items = checkConventions(tmpDir); + const cjs = items.find((i) => i.name.includes("__dirname")); + expect(cjs).toBeUndefined(); + }); + + it("detects __dirname inside a template expression ${...}", () => { + writeFile("lib/db.ts", "const dir = `${__dirname}/views`;"); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + + const items = checkConventions(tmpDir); + const cjs = items.find((i) => i.name.includes("__dirname")); + expect(cjs).toBeDefined(); + expect(cjs?.files).toContain("lib/db.ts"); + }); + it("does not flag __dirname when not used at all", () => { writeFile( "lib/esm.ts", From e6e1948c6779b549d55b2f60c562336aae09055d Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 16:09:53 +0000 Subject: [PATCH 5/6] fix(check): use unsupported status for __dirname/__filename; show files in issues section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no vinext shim for __dirname/__filename in user app code — it is a hard runtime failure, so partial was wrong. Changed to unsupported. Also fixed formatReport to list affected files under each unsupported item in the 'Issues to address' section. Previously files were only shown in the per-section detail view (imports), leaving the summary section without the context needed to actually fix the issue. Adds a test verifying the file path appears in the formatted report. --- packages/vinext/src/check.ts | 7 ++++++- tests/check.test.ts | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index df4a4143..5956b959 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -578,7 +578,7 @@ export function checkConventions(root: string): CheckItem[] { if (cjsGlobalFiles.length > 0) { items.push({ name: "__dirname / __filename (CommonJS globals)", - status: "partial", + status: "unsupported", detail: "CJS globals unavailable in ESM — use fileURLToPath(import.meta.url) / dirname(...), or import.meta.dirname / import.meta.filename (Node 22+)", files: cjsGlobalFiles, @@ -704,6 +704,11 @@ export function formatReport(result: CheckResult, opts?: { calledFromInit?: bool for (const item of allItems) { if (item.status === "unsupported") { lines.push(` \x1b[31m✗\x1b[0m ${item.name}${item.detail ? ` — ${item.detail}` : ""}`); + if (item.files && item.files.length > 0) { + for (const f of item.files) { + lines.push(` \x1b[90m${f}\x1b[0m`); + } + } } } } diff --git a/tests/check.test.ts b/tests/check.test.ts index 82bd0d90..904470ab 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -656,7 +656,7 @@ describe("checkConventions", () => { const items = checkConventions(tmpDir); const cjs = items.find((i) => i.name.includes("__dirname")); expect(cjs).toBeDefined(); - expect(cjs?.status).toBe("partial"); + expect(cjs?.status).toBe("unsupported"); expect(cjs?.detail).toContain("fileURLToPath"); expect(cjs?.detail).toContain("import.meta.dirname"); expect(cjs?.files).toContain("lib/db.ts"); @@ -906,6 +906,19 @@ describe("formatReport", () => { expect(report).toContain("next/amp"); }); + it("lists affected files under unsupported items in issues section", () => { + writeFile("lib/db.ts", `const dir = path.join(__dirname, "data");`); + writeFile("app/page.tsx", `export default function Home() { return
; }`); + writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} })); + + const result = runCheck(tmpDir); + const report = formatReport(result); + + expect(report).toContain("Issues to address"); + expect(report).toContain("__dirname"); + expect(report).toContain("lib/db.ts"); + }); + it("shows partial support section when there are partial items", () => { writeFile("app/page.tsx", `import { GoogleFont } from "next/font/google";`); writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} })); From dee67006c5047ccc9334dc035798aaf19bee8246 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 16:17:58 +0000 Subject: [PATCH 6/6] remove files --- .../fumadocs-docs-template/.source/browser.ts | 12 ---------- .../fumadocs-docs-template/.source/dynamic.ts | 8 ------- .../fumadocs-docs-template/.source/server.ts | 12 ---------- .../.source/source.config.mjs | 24 ------------------- 4 files changed, 56 deletions(-) delete mode 100644 examples/fumadocs-docs-template/.source/browser.ts delete mode 100644 examples/fumadocs-docs-template/.source/dynamic.ts delete mode 100644 examples/fumadocs-docs-template/.source/server.ts delete mode 100644 examples/fumadocs-docs-template/.source/source.config.mjs diff --git a/examples/fumadocs-docs-template/.source/browser.ts b/examples/fumadocs-docs-template/.source/browser.ts deleted file mode 100644 index 83189ec3..00000000 --- a/examples/fumadocs-docs-template/.source/browser.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-nocheck -import { browser } from 'fumadocs-mdx/runtime/browser'; -import type * as Config from '../source.config'; - -const create = browser(); -const browserCollections = { - docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "test.mdx": () => import("../content/docs/test.mdx?collection=docs"), }), -}; -export default browserCollections; \ No newline at end of file diff --git a/examples/fumadocs-docs-template/.source/dynamic.ts b/examples/fumadocs-docs-template/.source/dynamic.ts deleted file mode 100644 index 7dd9c10a..00000000 --- a/examples/fumadocs-docs-template/.source/dynamic.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -import { dynamic } from 'fumadocs-mdx/runtime/dynamic'; -import * as Config from '../source.config'; - -const create = await dynamic(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}}); \ No newline at end of file diff --git a/examples/fumadocs-docs-template/.source/server.ts b/examples/fumadocs-docs-template/.source/server.ts deleted file mode 100644 index b00d94bc..00000000 --- a/examples/fumadocs-docs-template/.source/server.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-nocheck -import * as __fd_glob_1 from "../content/docs/test.mdx?collection=docs" -import * as __fd_glob_0 from "../content/docs/index.mdx?collection=docs" -import { server } from 'fumadocs-mdx/runtime/server'; -import type * as Config from '../source.config'; - -const create = server({"doc":{"passthroughs":["extractedReferences"]}}); - -export const docs = await create.docs("docs", "content/docs", {}, {"index.mdx": __fd_glob_0, "test.mdx": __fd_glob_1, }); \ No newline at end of file diff --git a/examples/fumadocs-docs-template/.source/source.config.mjs b/examples/fumadocs-docs-template/.source/source.config.mjs deleted file mode 100644 index 23a070bc..00000000 --- a/examples/fumadocs-docs-template/.source/source.config.mjs +++ /dev/null @@ -1,24 +0,0 @@ -// source.config.ts -import { defineConfig, defineDocs } from "fumadocs-mdx/config"; -import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; -var docs = defineDocs({ - dir: "content/docs", - docs: { - schema: pageSchema, - postprocess: { - includeProcessedMarkdown: true - } - }, - meta: { - schema: metaSchema - } -}); -var source_config_default = defineConfig({ - mdxOptions: { - // MDX options - } -}); -export { - source_config_default as default, - docs -};