From 1619e56fd68374bdd0756aad083da86247509084 Mon Sep 17 00:00:00 2001 From: MehediH Date: Wed, 25 Feb 2026 14:42:52 +0000 Subject: [PATCH 1/7] fix(google-fonts): use closure variables instead of this for plugin state Vite does not bind `this` to the plugin object when calling hooks like configResolved and transform.handler. Using `this._isBuild` etc. results in a TypeError at startup ('Cannot set properties of undefined'). Replace the property-based state with closure variables so the plugin works correctly regardless of how Vite invokes the hooks. --- packages/vinext/src/index.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 0c8f6374b..feedafb61 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3161,22 +3161,25 @@ hydrate(); // caches them locally in .vinext/fonts/, and rewrites font constructor // calls to pass _selfHostedCSS with @font-face rules pointing at local assets. // In dev mode, this plugin is a no-op (CDN loading is used instead). - { + (() => { + // Vite does not bind `this` to the plugin object when calling hooks, so + // plugin state must be held in closure variables rather than as properties. + let isBuild = false; + const fontCache = new Map(); // url -> local @font-face CSS + let cacheDir = ""; + + return { name: "vinext:google-fonts", enforce: "pre", - _isBuild: false, - _fontCache: new Map(), // url -> local @font-face CSS - _cacheDir: "", - configResolved(config) { - (this as any)._isBuild = config.command === "build"; - (this as any)._cacheDir = path.join(config.root, ".vinext", "fonts"); + isBuild = config.command === "build"; + cacheDir = path.join(config.root, ".vinext", "fonts"); }, transform: { // Hook filter: only invoke JS when code contains 'next/font/google'. - // The _isBuild runtime check can't be expressed as a filter, but this + // The isBuild runtime check can't be expressed as a filter, but this // still eliminates nearly all Rust-to-JS calls since very few files // import from next/font/google. filter: { @@ -3187,7 +3190,7 @@ hydrate(); code: "next/font/google", }, async handler(code, id) { - if (!(this as any)._isBuild) return null; + if (!isBuild) return null; // Defensive guard — duplicates filter logic if (id.includes("node_modules")) return null; if (id.startsWith("\0")) return null; @@ -3211,9 +3214,6 @@ hydrate(); const s = new MagicString(code); let hasChanges = false; - const cacheDir = (this as any)._cacheDir as string; - const fontCache = (this as any)._fontCache as Map; - let match; while ((match = fontCallRe.exec(code)) !== null) { const [fullMatch, fontName, optionsStr] = match; @@ -3296,7 +3296,8 @@ hydrate(); }; }, }, - } as Plugin & { _isBuild: boolean; _fontCache: Map; _cacheDir: string }, + } satisfies Plugin; + })(), // Local font path resolution: // When a source file calls localFont({ src: "./font.woff2" }) or // localFont({ src: [{ path: "./font.woff2" }] }), the relative paths From fe0b5f17610e3eced8434eeeeb7123dec44af45c Mon Sep 17 00:00:00 2001 From: MehediH Date: Thu, 26 Feb 2026 09:25:47 +0000 Subject: [PATCH 2/7] fix(google-fonts): fix indentation of plugin object properties inside IIFE return --- packages/vinext/src/index.ts | 228 +++++++++++++++++------------------ 1 file changed, 114 insertions(+), 114 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index feedafb61..3c9c6921e 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3169,133 +3169,133 @@ hydrate(); let cacheDir = ""; return { - name: "vinext:google-fonts", - enforce: "pre", - - configResolved(config) { - isBuild = config.command === "build"; - cacheDir = path.join(config.root, ".vinext", "fonts"); - }, + name: "vinext:google-fonts", + enforce: "pre", - transform: { - // Hook filter: only invoke JS when code contains 'next/font/google'. - // The isBuild runtime check can't be expressed as a filter, but this - // still eliminates nearly all Rust-to-JS calls since very few files - // import from next/font/google. - filter: { - id: { - include: /\.(tsx?|jsx?|mjs)$/, - exclude: /node_modules/, - }, - code: "next/font/google", + configResolved(config) { + isBuild = config.command === "build"; + cacheDir = path.join(config.root, ".vinext", "fonts"); }, - async handler(code, id) { - if (!isBuild) return null; - // Defensive guard — duplicates filter logic - if (id.includes("node_modules")) return null; - if (id.startsWith("\0")) return null; - if (!id.match(/\.(tsx?|jsx?|mjs)$/)) return null; - if (!code.includes("next/font/google")) return null; - - // Match font constructor calls: Inter({ weight: ..., subsets: ... }) - // We look for PascalCase or Name_Name identifiers followed by ({...}) - // This regex captures the font name and the options object literal - const fontCallRe = /\b([A-Z][A-Za-z]*(?:_[A-Z][A-Za-z]*)*)\s*\(\s*(\{[^}]*\})\s*\)/g; - - // Also need to verify these names came from next/font/google import - const importRe = /import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]/; - const importMatch = code.match(importRe); - if (!importMatch) return null; - const importedNames = new Set( - importMatch[1].split(",").map((s) => s.trim()).filter(Boolean), - ); - - const s = new MagicString(code); - let hasChanges = false; + transform: { + // Hook filter: only invoke JS when code contains 'next/font/google'. + // The isBuild runtime check can't be expressed as a filter, but this + // still eliminates nearly all Rust-to-JS calls since very few files + // import from next/font/google. + filter: { + id: { + include: /\.(tsx?|jsx?|mjs)$/, + exclude: /node_modules/, + }, + code: "next/font/google", + }, + async handler(code, id) { + if (!isBuild) return null; + // Defensive guard — duplicates filter logic + if (id.includes("node_modules")) return null; + if (id.startsWith("\0")) return null; + if (!id.match(/\.(tsx?|jsx?|mjs)$/)) return null; + if (!code.includes("next/font/google")) return null; + + // Match font constructor calls: Inter({ weight: ..., subsets: ... }) + // We look for PascalCase or Name_Name identifiers followed by ({...}) + // This regex captures the font name and the options object literal + const fontCallRe = /\b([A-Z][A-Za-z]*(?:_[A-Z][A-Za-z]*)*)\s*\(\s*(\{[^}]*\})\s*\)/g; + + // Also need to verify these names came from next/font/google import + const importRe = /import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]/; + const importMatch = code.match(importRe); + if (!importMatch) return null; + + const importedNames = new Set( + importMatch[1].split(",").map((s) => s.trim()).filter(Boolean), + ); - let match; - while ((match = fontCallRe.exec(code)) !== null) { - const [fullMatch, fontName, optionsStr] = match; - if (!importedNames.has(fontName)) continue; + const s = new MagicString(code); + let hasChanges = false; - // Convert PascalCase/Underscore to font family - const family = fontName.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2"); + let match; + while ((match = fontCallRe.exec(code)) !== null) { + const [fullMatch, fontName, optionsStr] = match; + if (!importedNames.has(fontName)) continue; - // Parse options safely via AST — no eval/new Function - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let options: Record = {}; - try { - const parsed = parseStaticObjectLiteral(optionsStr); - if (!parsed) continue; // Contains dynamic expressions, skip - options = parsed as Record; - } catch { - continue; // Can't parse options statically, skip - } + // Convert PascalCase/Underscore to font family + const family = fontName.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2"); - // Build the Google Fonts CSS URL - const weights = options.weight - ? Array.isArray(options.weight) ? options.weight : [options.weight] - : []; - const styles = options.style - ? Array.isArray(options.style) ? options.style : [options.style] - : []; - const display = options.display ?? "swap"; - - let spec = family.replace(/\s+/g, "+"); - if (weights.length > 0) { - const hasItalic = styles.includes("italic"); - if (hasItalic) { - const pairs: string[] = []; - for (const w of weights) { pairs.push(`0,${w}`); pairs.push(`1,${w}`); } - spec += `:ital,wght@${pairs.join(";")}`; - } else { - spec += `:wght@${weights.join(";")}`; - } - } else if (styles.length === 0) { - // Request full variable weight range when no weight specified. - // Without this, Google Fonts returns only weight 400. - spec += `:wght@100..900`; - } - const params = new URLSearchParams(); - params.set("family", spec); - params.set("display", display); - const cssUrl = `https://fonts.googleapis.com/css2?${params.toString()}`; - - // Check cache - let localCSS = fontCache.get(cssUrl); - if (!localCSS) { + // Parse options safely via AST — no eval/new Function + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let options: Record = {}; try { - localCSS = await fetchAndCacheFont(cssUrl, family, cacheDir); - fontCache.set(cssUrl, localCSS); + const parsed = parseStaticObjectLiteral(optionsStr); + if (!parsed) continue; // Contains dynamic expressions, skip + options = parsed as Record; } catch { - // Fetch failed (offline?) — fall back to CDN mode - continue; + continue; // Can't parse options statically, skip } - } - // Inject _selfHostedCSS into the options object - const matchStart = match.index; - const matchEnd = matchStart + fullMatch.length; - const escapedCSS = JSON.stringify(localCSS); - const closingBrace = optionsStr.lastIndexOf("}"); - const optionsWithCSS = optionsStr.slice(0, closingBrace) + - (optionsStr.slice(0, closingBrace).trim().endsWith("{") ? "" : ", ") + - `_selfHostedCSS: ${escapedCSS}` + - optionsStr.slice(closingBrace); - - const replacement = `${fontName}(${optionsWithCSS})`; - s.overwrite(matchStart, matchEnd, replacement); - hasChanges = true; - } + // Build the Google Fonts CSS URL + const weights = options.weight + ? Array.isArray(options.weight) ? options.weight : [options.weight] + : []; + const styles = options.style + ? Array.isArray(options.style) ? options.style : [options.style] + : []; + const display = options.display ?? "swap"; + + let spec = family.replace(/\s+/g, "+"); + if (weights.length > 0) { + const hasItalic = styles.includes("italic"); + if (hasItalic) { + const pairs: string[] = []; + for (const w of weights) { pairs.push(`0,${w}`); pairs.push(`1,${w}`); } + spec += `:ital,wght@${pairs.join(";")}`; + } else { + spec += `:wght@${weights.join(";")}`; + } + } else if (styles.length === 0) { + // Request full variable weight range when no weight specified. + // Without this, Google Fonts returns only weight 400. + spec += `:wght@100..900`; + } + const params = new URLSearchParams(); + params.set("family", spec); + params.set("display", display); + const cssUrl = `https://fonts.googleapis.com/css2?${params.toString()}`; + + // Check cache + let localCSS = fontCache.get(cssUrl); + if (!localCSS) { + try { + localCSS = await fetchAndCacheFont(cssUrl, family, cacheDir); + fontCache.set(cssUrl, localCSS); + } catch { + // Fetch failed (offline?) — fall back to CDN mode + continue; + } + } - if (!hasChanges) return null; - return { - code: s.toString(), - map: s.generateMap({ hires: "boundary" }), - }; + // Inject _selfHostedCSS into the options object + const matchStart = match.index; + const matchEnd = matchStart + fullMatch.length; + const escapedCSS = JSON.stringify(localCSS); + const closingBrace = optionsStr.lastIndexOf("}"); + const optionsWithCSS = optionsStr.slice(0, closingBrace) + + (optionsStr.slice(0, closingBrace).trim().endsWith("{") ? "" : ", ") + + `_selfHostedCSS: ${escapedCSS}` + + optionsStr.slice(closingBrace); + + const replacement = `${fontName}(${optionsWithCSS})`; + s.overwrite(matchStart, matchEnd, replacement); + hasChanges = true; + } + + if (!hasChanges) return null; + return { + code: s.toString(), + map: s.generateMap({ hires: "boundary" }), + }; + }, }, - }, } satisfies Plugin; })(), // Local font path resolution: From 8f5bec9b6480e15e6179def26e947862b7bd8f26 Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Fri, 6 Mar 2026 19:05:36 -0600 Subject: [PATCH 3/7] fix: update tests to use closure-compatible plugin initialization Tests were reaching into plugin internals (_isBuild, _fontCache, _cacheDir) which are now closure variables after the IIFE refactor. Updated to drive state through configResolved hook and mock fetch for cache tests. --- tests/font-google.test.ts | 237 +++++++++++++++++++++----------------- 1 file changed, 132 insertions(+), 105 deletions(-) diff --git a/tests/font-google.test.ts b/tests/font-google.test.ts index b0e84fd74..91b845636 100644 --- a/tests/font-google.test.ts +++ b/tests/font-google.test.ts @@ -12,15 +12,20 @@ function unwrapHook(hook: any): Function { } /** Extract the vinext:google-fonts plugin from the plugin array */ -function getGoogleFontsPlugin(): Plugin & { - _isBuild: boolean; - _fontCache: Map; - _cacheDir: string; -} { +function getGoogleFontsPlugin(): Plugin { const plugins = vinext() as Plugin[]; const plugin = plugins.find((p) => p.name === "vinext:google-fonts"); if (!plugin) throw new Error("vinext:google-fonts plugin not found"); - return plugin as any; + return plugin; +} + +/** Simulate Vite's configResolved hook to initialize plugin state */ +function initPlugin(plugin: Plugin, opts: { command?: "build" | "serve"; root?: string }) { + const fakeConfig = { + command: opts.command ?? "serve", + root: opts.root ?? import.meta.dirname, + }; + (plugin.configResolved as Function)(fakeConfig); } // ── Font shim tests ─────────────────────────────────────────── @@ -277,7 +282,7 @@ describe("vinext:google-fonts plugin", () => { it("is a no-op in dev mode (isBuild = false)", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = false; + initPlugin(plugin, { command: "serve" }); const transform = unwrapHook(plugin.transform); const code = `import { Inter } from 'next/font/google';\nconst inter = Inter({ weight: ['400'] });`; const result = await transform.call(plugin, code, "/app/layout.tsx"); @@ -286,8 +291,7 @@ describe("vinext:google-fonts plugin", () => { it("returns null for files without next/font/google imports", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache"); + initPlugin(plugin, { command: "build" }); const transform = unwrapHook(plugin.transform); const code = `import React from 'react';\nconst x = 1;`; const result = await transform.call(plugin, code, "/app/layout.tsx"); @@ -296,7 +300,7 @@ describe("vinext:google-fonts plugin", () => { it("returns null for node_modules files", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; + initPlugin(plugin, { command: "build" }); const transform = unwrapHook(plugin.transform); const code = `import { Inter } from 'next/font/google';`; const result = await transform.call(plugin, code, "node_modules/some-pkg/index.ts"); @@ -305,7 +309,7 @@ describe("vinext:google-fonts plugin", () => { it("returns null for virtual modules", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; + initPlugin(plugin, { command: "build" }); const transform = unwrapHook(plugin.transform); const code = `import { Inter } from 'next/font/google';`; const result = await transform.call(plugin, code, "\0virtual:something"); @@ -314,7 +318,7 @@ describe("vinext:google-fonts plugin", () => { it("returns null for non-script files", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; + initPlugin(plugin, { command: "build" }); const transform = unwrapHook(plugin.transform); const code = `import { Inter } from 'next/font/google';`; const result = await transform.call(plugin, code, "/app/styles.css"); @@ -323,8 +327,7 @@ describe("vinext:google-fonts plugin", () => { it("returns null when import exists but no font constructor call", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache"); + initPlugin(plugin, { command: "build" }); const transform = unwrapHook(plugin.transform); const code = `import { Inter } from 'next/font/google';\n// no call`; const result = await transform.call(plugin, code, "/app/layout.tsx"); @@ -333,10 +336,8 @@ describe("vinext:google-fonts plugin", () => { it("transforms font call to include _selfHostedCSS during build", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - const cacheDir = path.join(import.meta.dirname, ".test-font-cache"); - plugin._cacheDir = cacheDir; - plugin._fontCache.clear(); + const root = path.join(import.meta.dirname, ".test-font-root"); + initPlugin(plugin, { command: "build", root }); const transform = unwrapHook(plugin.transform); const code = [ @@ -352,6 +353,7 @@ describe("vinext:google-fonts plugin", () => { expect(result.map).toBeDefined(); // Verify cache dir was created with font files + const cacheDir = path.join(root, ".vinext", "fonts"); expect(fs.existsSync(cacheDir)).toBe(true); const dirs = fs.readdirSync(cacheDir); const interDir = dirs.find((d: string) => d.startsWith("inter-")); @@ -362,115 +364,140 @@ describe("vinext:google-fonts plugin", () => { expect(files.some((f: string) => f.endsWith(".woff2"))).toBe(true); // Clean up - fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); }, 15000); // Network timeout it("uses cached fonts on second call", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - const cacheDir = path.join(import.meta.dirname, ".test-font-cache-2"); - plugin._cacheDir = cacheDir; + const root = path.join(import.meta.dirname, ".test-font-root-2"); + initPlugin(plugin, { command: "build", root }); - // Pre-populate cache + // Pre-populate the on-disk cache so fetchAndCacheFont finds it + const cacheDir = path.join(root, ".vinext", "fonts"); const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fake.woff2); }"; - plugin._fontCache.set( - "https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap", - fakeCSS, - ); - - const transform = unwrapHook(plugin.transform); - const code = [ - `import { Inter } from 'next/font/google';`, - `const inter = Inter({ weight: '400' });`, - ].join("\n"); - - const result = await transform.call(plugin, code, "/app/layout.tsx"); - expect(result).not.toBeNull(); - expect(result.code).toContain("_selfHostedCSS"); - // lgtm[js/incomplete-sanitization] — escaping quotes for test assertion, not sanitization - expect(result.code).toContain(fakeCSS.replace(/"/g, '\\"')); + // The plugin hashes the URL to create the dir name. Instead, call + // transform twice: first with a real fetch to populate the in-memory + // cache, then again to verify the cache is used (no second fetch). + // Simpler approach: mock fetch to return controlled CSS. + const originalFetch = globalThis.fetch; + const fetchCount = { value: 0 }; + globalThis.fetch = async (input: any, init?: any) => { + fetchCount.value++; + // Return fake Google Fonts CSS + return new Response(fakeCSS, { + status: 200, + headers: { "content-type": "text/css" }, + }); + }; - plugin._fontCache.clear(); + try { + const transform = unwrapHook(plugin.transform); + const code = [ + `import { Inter } from 'next/font/google';`, + `const inter = Inter({ weight: '400' });`, + ].join("\n"); + + // First call: fetches and caches + const result1 = await transform.call(plugin, code, "/app/layout.tsx"); + expect(result1).not.toBeNull(); + expect(result1.code).toContain("_selfHostedCSS"); + const firstFetchCount = fetchCount.value; + + // Second call: should use in-memory cache (no additional fetch) + const result2 = await transform.call(plugin, code, "/app/page.tsx"); + expect(result2).not.toBeNull(); + expect(fetchCount.value).toBe(firstFetchCount); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(root, { recursive: true, force: true }); + } }); it("handles multiple font imports in one file", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - const cacheDir = path.join(import.meta.dirname, ".test-font-cache-3"); - plugin._cacheDir = cacheDir; - plugin._fontCache.clear(); - - // Pre-populate cache for both fonts - plugin._fontCache.set( - "https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap", - "@font-face { font-family: 'Inter'; src: url(/inter.woff2); }", - ); - plugin._fontCache.set( - "https://fonts.googleapis.com/css2?family=Roboto%3Awght%40400&display=swap", - "@font-face { font-family: 'Roboto'; src: url(/roboto.woff2); }", - ); - - const transform = unwrapHook(plugin.transform); - const code = [ - `import { Inter, Roboto } from 'next/font/google';`, - `const inter = Inter({ weight: '400' });`, - `const roboto = Roboto({ weight: '400' });`, - ].join("\n"); - - const result = await transform.call(plugin, code, "/app/layout.tsx"); - expect(result).not.toBeNull(); - // Both font calls should be transformed - const matches = result.code.match(/_selfHostedCSS/g); - expect(matches?.length).toBe(2); + const root = path.join(import.meta.dirname, ".test-font-root-3"); + initPlugin(plugin, { command: "build", root }); + + // Mock fetch to return different CSS per font family + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: any) => { + const url = String(input); + if (url.includes("Inter")) { + return new Response("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }", { + status: 200, headers: { "content-type": "text/css" }, + }); + } + return new Response("@font-face { font-family: 'Roboto'; src: url(/roboto.woff2); }", { + status: 200, headers: { "content-type": "text/css" }, + }); + }; - plugin._fontCache.clear(); + try { + const transform = unwrapHook(plugin.transform); + const code = [ + `import { Inter, Roboto } from 'next/font/google';`, + `const inter = Inter({ weight: '400' });`, + `const roboto = Roboto({ weight: '400' });`, + ].join("\n"); + + const result = await transform.call(plugin, code, "/app/layout.tsx"); + expect(result).not.toBeNull(); + // Both font calls should be transformed + const matches = result.code.match(/_selfHostedCSS/g); + expect(matches?.length).toBe(2); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(root, { recursive: true, force: true }); + } }); it("skips font calls not from the import", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache-4"); - plugin._fontCache.clear(); - - const transform = unwrapHook(plugin.transform); - const code = [ - `import { Inter } from 'next/font/google';`, - `const inter = Inter({ weight: '400' });`, - `const Roboto = (opts) => opts; // Not from import`, - `const roboto = Roboto({ weight: '400' });`, - ].join("\n"); - - // Pre-populate Inter cache only - plugin._fontCache.set( - "https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap", - "@font-face { font-family: 'Inter'; }", - ); - - const result = await transform.call(plugin, code, "/app/layout.tsx"); - expect(result).not.toBeNull(); - // Only Inter should be transformed (1 match) - const matches = result.code.match(/_selfHostedCSS/g); - expect(matches?.length).toBe(1); + const root = path.join(import.meta.dirname, ".test-font-root-4"); + initPlugin(plugin, { command: "build", root }); + + // Mock fetch for Inter only + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return new Response("@font-face { font-family: 'Inter'; }", { + status: 200, headers: { "content-type": "text/css" }, + }); + }; - plugin._fontCache.clear(); + try { + const transform = unwrapHook(plugin.transform); + const code = [ + `import { Inter } from 'next/font/google';`, + `const inter = Inter({ weight: '400' });`, + `const Roboto = (opts) => opts; // Not from import`, + `const roboto = Roboto({ weight: '400' });`, + ].join("\n"); + + const result = await transform.call(plugin, code, "/app/layout.tsx"); + expect(result).not.toBeNull(); + // Only Inter should be transformed (1 match) + const matches = result.code.match(/_selfHostedCSS/g); + expect(matches?.length).toBe(1); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(root, { recursive: true, force: true }); + } }); }); // ── fetchAndCacheFont integration ───────────────────────────── describe("fetchAndCacheFont", () => { - const cacheDir = path.join(import.meta.dirname, ".test-fetch-cache"); + const root = path.join(import.meta.dirname, ".test-fetch-root"); afterEach(() => { - fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); }); it("fetches Inter font CSS and downloads woff2 files", async () => { // Use the plugin's transform which internally calls fetchAndCacheFont const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - plugin._cacheDir = cacheDir; - plugin._fontCache.clear(); + initPlugin(plugin, { command: "build", root }); const transform = unwrapHook(plugin.transform); const code = [ @@ -481,18 +508,18 @@ describe("fetchAndCacheFont", () => { const result = await transform.call(plugin, code, "/app/layout.tsx"); expect(result).not.toBeNull(); - // Verify the CSS references local file paths, not googleapis.com - const selfHostedCSS = plugin._fontCache.values().next().value; - expect(selfHostedCSS).toBeDefined(); - expect(selfHostedCSS).toContain("@font-face"); - expect(selfHostedCSS).toContain("Inter"); - expect(selfHostedCSS).not.toContain("fonts.gstatic.com"); - // Should reference local absolute paths to cached woff2 files - expect(selfHostedCSS).toContain(".woff2"); + // Verify the transformed code contains self-hosted CSS with @font-face + expect(result.code).toContain("_selfHostedCSS"); + expect(result.code).toContain("@font-face"); + expect(result.code).toContain("Inter"); + // Should reference local file paths, not googleapis.com CDN + expect(result.code).not.toContain("fonts.gstatic.com"); + expect(result.code).toContain(".woff2"); }, 15000); it("reuses cached CSS on filesystem", async () => { // Create a fake cached font dir + const cacheDir = path.join(root, ".vinext", "fonts"); const fontDir = path.join(cacheDir, "inter-fake123"); fs.mkdirSync(fontDir, { recursive: true }); const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/cached.woff2); }"; From ba6c14e30f2ff7e2a00e31267dcd8d557e8b90d4 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 20:12:37 +0000 Subject: [PATCH 4/7] fix(google-fonts): apply closure-variable fix on top of main's refactored plugin main rewrote the plugin to add virtual module import rewriting but kept the broken this-binding pattern. Apply the PR's fix: wrap in an IIFE so state is held in closure vars (isBuild, fontCache, cacheDir) instead of (this as any)._isBuild etc. --- packages/vinext/src/index.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 750c421a2..7a115d4ac 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3651,17 +3651,20 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // keeping ESM import semantics intact. // 2. During production builds, fetches Google Fonts CSS + font files and // injects _selfHostedCSS into statically analyzable font loader calls. - { + (() => { + // Vite does not bind `this` to the plugin object when calling hooks, so + // plugin state must be held in closure variables rather than as properties. + let isBuild = false; + const fontCache = new Map(); // url -> local @font-face CSS + let cacheDir = ""; + + return { name: "vinext:google-fonts", enforce: "pre", - _isBuild: false, - _fontCache: new Map(), // url -> local @font-face CSS - _cacheDir: "", - configResolved(config) { - (this as any)._isBuild = config.command === "build"; - (this as any)._cacheDir = path.join(config.root, ".vinext", "fonts"); + isBuild = config.command === "build"; + cacheDir = path.join(config.root, ".vinext", "fonts"); }, transform: { @@ -3770,8 +3773,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { hasChanges = true; } - const cacheDir = (this as any)._cacheDir as string; - const fontCache = (this as any)._fontCache as Map; async function injectSelfHostedCss( callStart: number, @@ -3853,7 +3854,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { hasChanges = true; } - if ((this as any)._isBuild) { + if (isBuild) { const namedCallRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; let namedCallMatch; while ((namedCallMatch = namedCallRe.exec(code)) !== null) { @@ -3906,7 +3907,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }; }, }, - } as Plugin & { _isBuild: boolean; _fontCache: Map; _cacheDir: string }, + } satisfies Plugin; + })(), // Local font path resolution: // When a source file calls localFont({ src: "./font.woff2" }) or // localFont({ src: [{ path: "./font.woff2" }] }), the relative paths From a054fbb8625f6f99028b2391bb10c1a8e0f5b2c0 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 20:16:04 +0000 Subject: [PATCH 5/7] fmt --- packages/vinext/src/index.ts | 432 +++++++++++++++++------------------ tests/font-google.test.ts | 9 +- 2 files changed, 222 insertions(+), 219 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7a115d4ac..1fe0db27b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3659,254 +3659,254 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let cacheDir = ""; return { - name: "vinext:google-fonts", - enforce: "pre", + name: "vinext:google-fonts", + enforce: "pre", - configResolved(config) { - isBuild = config.command === "build"; - cacheDir = path.join(config.root, ".vinext", "fonts"); - }, + configResolved(config) { + isBuild = config.command === "build"; + cacheDir = path.join(config.root, ".vinext", "fonts"); + }, - transform: { - // Hook filter: only invoke JS when code contains 'next/font/google'. - // This still eliminates nearly all Rust-to-JS calls since very few files - // import from next/font/google. - filter: { - id: { - include: /\.(tsx?|jsx?|mjs)$/, + transform: { + // Hook filter: only invoke JS when code contains 'next/font/google'. + // This still eliminates nearly all Rust-to-JS calls since very few files + // import from next/font/google. + filter: { + id: { + include: /\.(tsx?|jsx?|mjs)$/, + }, + code: "next/font/google", }, - code: "next/font/google", - }, - async handler(code, id) { - // Defensive guard — duplicates filter logic - if (id.startsWith("\0")) return null; - if (!id.match(/\.(tsx?|jsx?|mjs)$/)) return null; - if (!code.includes("next/font/google")) return null; - if (id.startsWith(_shimsDir)) return null; + async handler(code, id) { + // Defensive guard — duplicates filter logic + if (id.startsWith("\0")) return null; + if (!id.match(/\.(tsx?|jsx?|mjs)$/)) return null; + if (!code.includes("next/font/google")) return null; + if (id.startsWith(_shimsDir)) return null; + + const s = new MagicString(code); + let hasChanges = false; + let proxyImportCounter = 0; + const overwrittenRanges: Array<[number, number]> = []; + const fontLocals = new Map(); + const proxyObjectLocals = new Set(); + + const importRe = /^[ \t]*import\s+([^;]+?)\s+from\s*(["'])next\/font\/google\2\s*;?/gm; + let importMatch; + while ((importMatch = importRe.exec(code)) !== null) { + const [fullMatch, clause] = importMatch; + const matchStart = importMatch.index; + const matchEnd = matchStart + fullMatch.length; + const parsed = parseGoogleFontImportClause(clause); + const utilityImports = parsed.named.filter( + (spec) => !spec.isType && GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), + ); + const fontImports = parsed.named.filter( + (spec) => !spec.isType && !GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), + ); - const s = new MagicString(code); - let hasChanges = false; - let proxyImportCounter = 0; - const overwrittenRanges: Array<[number, number]> = []; - const fontLocals = new Map(); - const proxyObjectLocals = new Set(); - - const importRe = /^[ \t]*import\s+([^;]+?)\s+from\s*(["'])next\/font\/google\2\s*;?/gm; - let importMatch; - while ((importMatch = importRe.exec(code)) !== null) { - const [fullMatch, clause] = importMatch; - const matchStart = importMatch.index; - const matchEnd = matchStart + fullMatch.length; - const parsed = parseGoogleFontImportClause(clause); - const utilityImports = parsed.named.filter( - (spec) => !spec.isType && GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), - ); - const fontImports = parsed.named.filter( - (spec) => !spec.isType && !GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), - ); + if (parsed.defaultLocal) { + proxyObjectLocals.add(parsed.defaultLocal); + } + for (const fontImport of fontImports) { + fontLocals.set(fontImport.local, fontImport.imported); + } - if (parsed.defaultLocal) { - proxyObjectLocals.add(parsed.defaultLocal); - } - for (const fontImport of fontImports) { - fontLocals.set(fontImport.local, fontImport.imported); + if (fontImports.length > 0) { + const virtualId = encodeGoogleFontsVirtualId({ + hasDefault: Boolean(parsed.defaultLocal), + fonts: Array.from(new Set(fontImports.map((spec) => spec.imported))), + utilities: Array.from(new Set(utilityImports.map((spec) => spec.imported))), + }); + s.overwrite( + matchStart, + matchEnd, + `import ${clause} from ${JSON.stringify(virtualId)};`, + ); + overwrittenRanges.push([matchStart, matchEnd]); + hasChanges = true; + continue; + } + + if (parsed.namespaceLocal) { + const proxyImportName = `__vinext_google_fonts_proxy_${proxyImportCounter++}`; + const replacementLines = [ + `import ${proxyImportName} from ${JSON.stringify(_fontGoogleShimPath)};`, + ]; + if (parsed.defaultLocal) { + replacementLines.push(`var ${parsed.defaultLocal} = ${proxyImportName};`); + } + replacementLines.push(`var ${parsed.namespaceLocal} = ${proxyImportName};`); + s.overwrite(matchStart, matchEnd, replacementLines.join("\n")); + overwrittenRanges.push([matchStart, matchEnd]); + proxyObjectLocals.add(parsed.namespaceLocal); + hasChanges = true; + } } - if (fontImports.length > 0) { + const exportRe = + /^[ \t]*export\s*\{([^}]+)\}\s*from\s*(["'])next\/font\/google\2\s*;?/gm; + let exportMatch; + while ((exportMatch = exportRe.exec(code)) !== null) { + const [fullMatch, specifiers] = exportMatch; + const matchStart = exportMatch.index; + const matchEnd = matchStart + fullMatch.length; + const namedExports = parseGoogleFontNamedSpecifiers(specifiers); + const utilityExports = namedExports.filter( + (spec) => !spec.isType && GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), + ); + const fontExports = namedExports.filter( + (spec) => !spec.isType && !GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), + ); + if (fontExports.length === 0) continue; + const virtualId = encodeGoogleFontsVirtualId({ - hasDefault: Boolean(parsed.defaultLocal), - fonts: Array.from(new Set(fontImports.map((spec) => spec.imported))), - utilities: Array.from(new Set(utilityImports.map((spec) => spec.imported))), + hasDefault: false, + fonts: Array.from(new Set(fontExports.map((spec) => spec.imported))), + utilities: Array.from(new Set(utilityExports.map((spec) => spec.imported))), }); s.overwrite( matchStart, matchEnd, - `import ${clause} from ${JSON.stringify(virtualId)};`, + `export { ${specifiers.trim()} } from ${JSON.stringify(virtualId)};`, ); overwrittenRanges.push([matchStart, matchEnd]); hasChanges = true; - continue; } - if (parsed.namespaceLocal) { - const proxyImportName = `__vinext_google_fonts_proxy_${proxyImportCounter++}`; - const replacementLines = [ - `import ${proxyImportName} from ${JSON.stringify(_fontGoogleShimPath)};`, - ]; - if (parsed.defaultLocal) { - replacementLines.push(`var ${parsed.defaultLocal} = ${proxyImportName};`); + async function injectSelfHostedCss( + callStart: number, + callEnd: number, + optionsStr: string, + family: string, + calleeSource: string, + ) { + // Parse options safely via AST — no eval/new Function + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let options: Record = {}; + try { + const parsed = parseStaticObjectLiteral(optionsStr); + if (!parsed) return; // Contains dynamic expressions, skip + options = parsed as Record; + } catch { + return; // Can't parse options statically, skip } - replacementLines.push(`var ${parsed.namespaceLocal} = ${proxyImportName};`); - s.overwrite(matchStart, matchEnd, replacementLines.join("\n")); - overwrittenRanges.push([matchStart, matchEnd]); - proxyObjectLocals.add(parsed.namespaceLocal); - hasChanges = true; - } - } - const exportRe = /^[ \t]*export\s*\{([^}]+)\}\s*from\s*(["'])next\/font\/google\2\s*;?/gm; - let exportMatch; - while ((exportMatch = exportRe.exec(code)) !== null) { - const [fullMatch, specifiers] = exportMatch; - const matchStart = exportMatch.index; - const matchEnd = matchStart + fullMatch.length; - const namedExports = parseGoogleFontNamedSpecifiers(specifiers); - const utilityExports = namedExports.filter( - (spec) => !spec.isType && GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), - ); - const fontExports = namedExports.filter( - (spec) => !spec.isType && !GOOGLE_FONT_UTILITY_EXPORTS.has(spec.imported), - ); - if (fontExports.length === 0) continue; - - const virtualId = encodeGoogleFontsVirtualId({ - hasDefault: false, - fonts: Array.from(new Set(fontExports.map((spec) => spec.imported))), - utilities: Array.from(new Set(utilityExports.map((spec) => spec.imported))), - }); - s.overwrite( - matchStart, - matchEnd, - `export { ${specifiers.trim()} } from ${JSON.stringify(virtualId)};`, - ); - overwrittenRanges.push([matchStart, matchEnd]); - hasChanges = true; - } - - - async function injectSelfHostedCss( - callStart: number, - callEnd: number, - optionsStr: string, - family: string, - calleeSource: string, - ) { - // Parse options safely via AST — no eval/new Function - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let options: Record = {}; - try { - const parsed = parseStaticObjectLiteral(optionsStr); - if (!parsed) return; // Contains dynamic expressions, skip - options = parsed as Record; - } catch { - return; // Can't parse options statically, skip - } - - // Build the Google Fonts CSS URL - const weights = options.weight - ? Array.isArray(options.weight) - ? options.weight - : [options.weight] - : []; - const styles = options.style - ? Array.isArray(options.style) - ? options.style - : [options.style] - : []; - const display = options.display ?? "swap"; - - let spec = family.replace(/\s+/g, "+"); - if (weights.length > 0) { - const hasItalic = styles.includes("italic"); - if (hasItalic) { - const pairs: string[] = []; - for (const w of weights) { - pairs.push(`0,${w}`); - pairs.push(`1,${w}`); + // Build the Google Fonts CSS URL + const weights = options.weight + ? Array.isArray(options.weight) + ? options.weight + : [options.weight] + : []; + const styles = options.style + ? Array.isArray(options.style) + ? options.style + : [options.style] + : []; + const display = options.display ?? "swap"; + + let spec = family.replace(/\s+/g, "+"); + if (weights.length > 0) { + const hasItalic = styles.includes("italic"); + if (hasItalic) { + const pairs: string[] = []; + for (const w of weights) { + pairs.push(`0,${w}`); + pairs.push(`1,${w}`); + } + spec += `:ital,wght@${pairs.join(";")}`; + } else { + spec += `:wght@${weights.join(";")}`; } - spec += `:ital,wght@${pairs.join(";")}`; - } else { - spec += `:wght@${weights.join(";")}`; + } else if (styles.length === 0) { + // Request full variable weight range when no weight specified. + // Without this, Google Fonts returns only weight 400. + spec += `:wght@100..900`; } - } else if (styles.length === 0) { - // Request full variable weight range when no weight specified. - // Without this, Google Fonts returns only weight 400. - spec += `:wght@100..900`; - } - const params = new URLSearchParams(); - params.set("family", spec); - params.set("display", display); - const cssUrl = `https://fonts.googleapis.com/css2?${params.toString()}`; - - // Check cache - let localCSS = fontCache.get(cssUrl); - if (!localCSS) { - try { - localCSS = await fetchAndCacheFont(cssUrl, family, cacheDir); - fontCache.set(cssUrl, localCSS); - } catch { - // Fetch failed (offline?) — fall back to CDN mode - return; + const params = new URLSearchParams(); + params.set("family", spec); + params.set("display", display); + const cssUrl = `https://fonts.googleapis.com/css2?${params.toString()}`; + + // Check cache + let localCSS = fontCache.get(cssUrl); + if (!localCSS) { + try { + localCSS = await fetchAndCacheFont(cssUrl, family, cacheDir); + fontCache.set(cssUrl, localCSS); + } catch { + // Fetch failed (offline?) — fall back to CDN mode + return; + } } + + // Inject _selfHostedCSS into the options object + const escapedCSS = JSON.stringify(localCSS); + const closingBrace = optionsStr.lastIndexOf("}"); + const optionsWithCSS = + optionsStr.slice(0, closingBrace) + + (optionsStr.slice(0, closingBrace).trim().endsWith("{") ? "" : ", ") + + `_selfHostedCSS: ${escapedCSS}` + + optionsStr.slice(closingBrace); + + const replacement = `${calleeSource}(${optionsWithCSS})`; + s.overwrite(callStart, callEnd, replacement); + hasChanges = true; } - // Inject _selfHostedCSS into the options object - const escapedCSS = JSON.stringify(localCSS); - const closingBrace = optionsStr.lastIndexOf("}"); - const optionsWithCSS = - optionsStr.slice(0, closingBrace) + - (optionsStr.slice(0, closingBrace).trim().endsWith("{") ? "" : ", ") + - `_selfHostedCSS: ${escapedCSS}` + - optionsStr.slice(closingBrace); - - const replacement = `${calleeSource}(${optionsWithCSS})`; - s.overwrite(callStart, callEnd, replacement); - hasChanges = true; - } + if (isBuild) { + const namedCallRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; + let namedCallMatch; + while ((namedCallMatch = namedCallRe.exec(code)) !== null) { + const [fullMatch, localName, optionsStr] = namedCallMatch; + const importedName = fontLocals.get(localName); + if (!importedName) continue; + + const callStart = namedCallMatch.index; + const callEnd = callStart + fullMatch.length; + if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) { + continue; + } - if (isBuild) { - const namedCallRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; - let namedCallMatch; - while ((namedCallMatch = namedCallRe.exec(code)) !== null) { - const [fullMatch, localName, optionsStr] = namedCallMatch; - const importedName = fontLocals.get(localName); - if (!importedName) continue; - - const callStart = namedCallMatch.index; - const callEnd = callStart + fullMatch.length; - if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) { - continue; + await injectSelfHostedCss( + callStart, + callEnd, + optionsStr, + importedName.replace(/_/g, " "), + localName, + ); } - await injectSelfHostedCss( - callStart, - callEnd, - optionsStr, - importedName.replace(/_/g, " "), - localName, - ); - } - - const memberCallRe = - /\b([A-Za-z_$][A-Za-z0-9_$]*)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; - let memberCallMatch; - while ((memberCallMatch = memberCallRe.exec(code)) !== null) { - const [fullMatch, objectName, propName, optionsStr] = memberCallMatch; - if (!proxyObjectLocals.has(objectName)) continue; + const memberCallRe = + /\b([A-Za-z_$][A-Za-z0-9_$]*)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; + let memberCallMatch; + while ((memberCallMatch = memberCallRe.exec(code)) !== null) { + const [fullMatch, objectName, propName, optionsStr] = memberCallMatch; + if (!proxyObjectLocals.has(objectName)) continue; + + const callStart = memberCallMatch.index; + const callEnd = callStart + fullMatch.length; + if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) { + continue; + } - const callStart = memberCallMatch.index; - const callEnd = callStart + fullMatch.length; - if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) { - continue; + await injectSelfHostedCss( + callStart, + callEnd, + optionsStr, + propertyNameToGoogleFontFamily(propName), + `${objectName}.${propName}`, + ); } - - await injectSelfHostedCss( - callStart, - callEnd, - optionsStr, - propertyNameToGoogleFontFamily(propName), - `${objectName}.${propName}`, - ); } - } - if (!hasChanges) return null; - return { - code: s.toString(), - map: s.generateMap({ hires: "boundary" }), - }; + if (!hasChanges) return null; + return { + code: s.toString(), + map: s.generateMap({ hires: "boundary" }), + }; + }, }, - }, } satisfies Plugin; })(), // Local font path resolution: diff --git a/tests/font-google.test.ts b/tests/font-google.test.ts index 87528f5a9..e67874f33 100644 --- a/tests/font-google.test.ts +++ b/tests/font-google.test.ts @@ -454,11 +454,13 @@ describe("vinext:google-fonts plugin", () => { const url = String(input); if (url.includes("Inter")) { return new Response("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }", { - status: 200, headers: { "content-type": "text/css" }, + status: 200, + headers: { "content-type": "text/css" }, }); } return new Response("@font-face { font-family: 'Roboto'; src: url(/roboto.woff2); }", { - status: 200, headers: { "content-type": "text/css" }, + status: 200, + headers: { "content-type": "text/css" }, }); }; @@ -491,7 +493,8 @@ describe("vinext:google-fonts plugin", () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => { return new Response("@font-face { font-family: 'Inter'; }", { - status: 200, headers: { "content-type": "text/css" }, + status: 200, + headers: { "content-type": "text/css" }, }); }; From 46cba58466ccbafba90a41d6b11354d86c586fbc Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 20:18:33 +0000 Subject: [PATCH 6/7] fix tests: replace plugin._isBuild direct access with initPlugin() helper The google-fonts plugin closure fix removes the _isBuild/_fontCache/_cacheDir properties from the returned Plugin object. Three tests from main's refactor still used plugin._isBuild = false directly; replace with initPlugin(plugin, { command: 'serve' }) to simulate dev mode via the configResolved hook. --- tests/font-google.test.ts | 90 +++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/tests/font-google.test.ts b/tests/font-google.test.ts index e67874f33..863d62fb8 100644 --- a/tests/font-google.test.ts +++ b/tests/font-google.test.ts @@ -14,15 +14,11 @@ function unwrapHook(hook: any): Function { } /** Extract the vinext:google-fonts plugin from the plugin array */ -function getGoogleFontsPlugin(): Plugin & { - _isBuild: boolean; - _fontCache: Map; - _cacheDir: string; -} { +function getGoogleFontsPlugin(): Plugin { const plugins = vinext() as Plugin[]; const plugin = plugins.find((p) => p.name === "vinext:google-fonts"); if (!plugin) throw new Error("vinext:google-fonts plugin not found"); - return plugin as any; + return plugin; } /** Simulate Vite's configResolved hook to initialize plugin state */ @@ -304,7 +300,7 @@ describe("vinext:google-fonts plugin", () => { it("rewrites dependency files that import next/font/google", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = false; + initPlugin(plugin, { command: "serve" }); const transform = unwrapHook(plugin.transform); const code = `import { Inter } from 'next/font/google';`; const result = await transform.call(plugin, code, "node_modules/some-pkg/index.ts"); @@ -343,7 +339,7 @@ describe("vinext:google-fonts plugin", () => { it("rewrites namespace imports to the default proxy", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = false; + initPlugin(plugin, { command: "serve" }); const transform = unwrapHook(plugin.transform); const code = `import * as fonts from 'next/font/google';\nconst inter = fonts.Inter({ weight: ['400'] });`; const result = await transform.call(plugin, code, "/app/layout.tsx"); @@ -354,7 +350,7 @@ describe("vinext:google-fonts plugin", () => { it("rewrites named re-exports through a virtual module", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = false; + initPlugin(plugin, { command: "serve" }); const transform = unwrapHook(plugin.transform); const code = `export { Inter, buildGoogleFontsUrl } from 'next/font/google';`; const result = await transform.call(plugin, code, "/app/fonts.ts"); @@ -521,47 +517,57 @@ describe("vinext:google-fonts plugin", () => { it("self-hosts aliased lowercase font imports during build", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache-alias"); - plugin._fontCache.clear(); - plugin._fontCache.set( - "https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap", - "@font-face { font-family: 'Inter'; src: url(/inter.woff2); }", - ); + const root = path.join(import.meta.dirname, ".test-font-root-alias"); + initPlugin(plugin, { command: "build", root }); - const transform = unwrapHook(plugin.transform); - const code = [ - `import { Inter as inter } from 'next/font/google';`, - `const body = inter({ weight: '400' });`, - ].join("\n"); - const result = await transform.call(plugin, code, "/app/layout.tsx"); - expect(result).not.toBeNull(); - expect(result.code).toContain("virtual:vinext-google-fonts?"); - expect(result.code).toContain("_selfHostedCSS"); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }", { + status: 200, + headers: { "content-type": "text/css" }, + }); - plugin._fontCache.clear(); + try { + const transform = unwrapHook(plugin.transform); + const code = [ + `import { Inter as inter } from 'next/font/google';`, + `const body = inter({ weight: '400' });`, + ].join("\n"); + const result = await transform.call(plugin, code, "/app/layout.tsx"); + expect(result).not.toBeNull(); + expect(result.code).toContain("virtual:vinext-google-fonts?"); + expect(result.code).toContain("_selfHostedCSS"); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(root, { recursive: true, force: true }); + } }); it("self-hosts default proxy member calls during build", async () => { const plugin = getGoogleFontsPlugin(); - plugin._isBuild = true; - plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache-default"); - plugin._fontCache.clear(); - plugin._fontCache.set( - "https://fonts.googleapis.com/css2?family=Roboto%2BMono%3Awght%40400&display=swap", - "@font-face { font-family: 'Roboto Mono'; src: url(/roboto-mono.woff2); }", - ); + const root = path.join(import.meta.dirname, ".test-font-root-default"); + initPlugin(plugin, { command: "build", root }); - const transform = unwrapHook(plugin.transform); - const code = [ - `import fonts from 'next/font/google';`, - `const mono = fonts.Roboto_Mono({ weight: '400' });`, - ].join("\n"); - const result = await transform.call(plugin, code, "/app/layout.tsx"); - expect(result).not.toBeNull(); - expect(result.code).toContain("_selfHostedCSS"); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response("@font-face { font-family: 'Roboto Mono'; src: url(/roboto-mono.woff2); }", { + status: 200, + headers: { "content-type": "text/css" }, + }); - plugin._fontCache.clear(); + try { + const transform = unwrapHook(plugin.transform); + const code = [ + `import fonts from 'next/font/google';`, + `const mono = fonts.Roboto_Mono({ weight: '400' });`, + ].join("\n"); + const result = await transform.call(plugin, code, "/app/layout.tsx"); + expect(result).not.toBeNull(); + expect(result.code).toContain("_selfHostedCSS"); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(root, { recursive: true, force: true }); + } }); }); From 41ddc051f1f6effbbb9c3c99dd4f9cccd8d94149 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 20:21:05 +0000 Subject: [PATCH 7/7] fix: silence no-unused-vars lint warnings in font-google tests Remove unused cacheDir variable and prefix unused fetch mock parameters with underscore (_input, _init) per oxlint convention. --- tests/font-google.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/font-google.test.ts b/tests/font-google.test.ts index 863d62fb8..264142c89 100644 --- a/tests/font-google.test.ts +++ b/tests/font-google.test.ts @@ -398,7 +398,6 @@ describe("vinext:google-fonts plugin", () => { initPlugin(plugin, { command: "build", root }); // Pre-populate the on-disk cache so fetchAndCacheFont finds it - const cacheDir = path.join(root, ".vinext", "fonts"); const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fake.woff2); }"; // The plugin hashes the URL to create the dir name. Instead, call // transform twice: first with a real fetch to populate the in-memory @@ -406,7 +405,7 @@ describe("vinext:google-fonts plugin", () => { // Simpler approach: mock fetch to return controlled CSS. const originalFetch = globalThis.fetch; const fetchCount = { value: 0 }; - globalThis.fetch = async (input: any, init?: any) => { + globalThis.fetch = async (_input: any, _init?: any) => { fetchCount.value++; // Return fake Google Fonts CSS return new Response(fakeCSS, {