From 1619e56fd68374bdd0756aad083da86247509084 Mon Sep 17 00:00:00 2001 From: MehediH Date: Wed, 25 Feb 2026 14:42:52 +0000 Subject: [PATCH 1/3] 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 0c8f6374..feedafb6 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/3] 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 feedafb6..3c9c6921 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/3] 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 b0e84fd7..91b84563 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); }";