diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index dbf41ae2..0bbb1973 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -73,6 +73,8 @@ import { generateGoogleFontsVirtualModule, createGoogleFontsPlugin, createLocalFontsPlugin, + _findBalancedObject, + _findCallEnd, } from "./plugins/fonts.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -4024,5 +4026,6 @@ export { _postcssCache }; export { hasMdxFiles as _hasMdxFiles }; export { _mdxScanCache }; export { parseStaticObjectLiteral as _parseStaticObjectLiteral }; +export { _findBalancedObject, _findCallEnd }; export { stripServerExports as _stripServerExports }; export { asyncHooksStubPlugin as _asyncHooksStubPlugin }; diff --git a/packages/vinext/src/plugins/fonts.ts b/packages/vinext/src/plugins/fonts.ts index d4980973..cb0a7624 100644 --- a/packages/vinext/src/plugins/fonts.ts +++ b/packages/vinext/src/plugins/fonts.ts @@ -393,6 +393,137 @@ async function fetchAndCacheFont( * files from transform (they contain `next/font/google` references that must * not be rewritten). */ + +/** + * Scan `code` forward from `searchStart` for a `{...}` object literal that + * may contain arbitrarily nested braces. Returns `[objStart, objEnd]` where + * `code[objStart] === '{'` and `code[objEnd - 1] === '}'`, or `null` if no + * balanced object is found. + * + * String literals (single-quoted, double-quoted, and backtick template + * literals including `${...}` interpolations) are fully skipped so that brace + * characters inside string values do not affect the depth count. + */ +export function _findBalancedObject(code: string, searchStart: number): [number, number] | null { + let i = searchStart; + // Skip leading whitespace before the opening brace + while ( + i < code.length && + (code[i] === " " || code[i] === "\t" || code[i] === "\n" || code[i] === "\r") + ) { + i++; + } + if (i >= code.length || code[i] !== "{") return null; + const objStart = i; + let depth = 0; + while (i < code.length) { + const ch = code[i]; + if (ch === '"' || ch === "'") { + // Skip a single- or double-quoted string literal, respecting backslash escapes. + const quote = ch; + i++; + while (i < code.length) { + const sc = code[i]; + if (sc === "\\") { + i += 2; // skip escaped character + } else if (sc === quote) { + i++; + break; + } else { + i++; + } + } + } else if (ch === "`") { + // Skip a template literal, including ${...} interpolation blocks. + // We need to track brace depth inside interpolations so that a `}` + // that closes an interpolation isn't mistaken for closing the object. + i++; // consume the opening backtick + while (i < code.length) { + const tc = code[i]; + if (tc === "\\") { + i += 2; // skip escape sequence + } else if (tc === "`") { + i++; // end of template literal + break; + } else if (tc === "$" && code[i + 1] === "{") { + // Enter a ${...} interpolation: scan forward tracking nested braces. + i += 2; // consume '${' + let exprDepth = 1; + while (i < code.length && exprDepth > 0) { + const ec = code[i]; + if (ec === "{") { + exprDepth++; + i++; + } else if (ec === "}") { + exprDepth--; + i++; + } else if (ec === '"' || ec === "'") { + // Quoted string inside interpolation — skip it + const q = ec; + i++; + while (i < code.length) { + if (code[i] === "\\") { + i += 2; + } else if (code[i] === q) { + i++; + break; + } else { + i++; + } + } + } else if (ec === "`") { + // Nested template literal inside interpolation — skip it + // (simple depth-1 skip; deeply nested templates are rare in font options) + i++; + while (i < code.length) { + if (code[i] === "\\") { + i += 2; + } else if (code[i] === "`") { + i++; + break; + } else { + i++; + } + } + } else { + i++; + } + } + } else { + i++; + } + } + } else if (ch === "{") { + depth++; + i++; + } else if (ch === "}") { + depth--; + i++; + if (depth === 0) return [objStart, i]; + } else { + i++; + } + } + return null; // unbalanced +} + +/** + * Given the index just past the closing `}` of an options object, skip + * optional whitespace and return the index after the closing `)`. + * Returns `null` if the next non-whitespace character is not `)`. + */ +export function _findCallEnd(code: string, objEnd: number): number | null { + let i = objEnd; + while ( + i < code.length && + (code[i] === " " || code[i] === "\t" || code[i] === "\n" || code[i] === "\r") + ) { + i++; + } + if (i >= code.length || code[i] !== ")") return null; + return i + 1; +} + export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: string): Plugin { // 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. @@ -602,15 +733,26 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st } if (isBuild) { - const namedCallRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; + // Match: Identifier( — where the argument starts with { + // The regex intentionally does NOT capture the options object; we use + // _findBalancedObject() to handle nested braces correctly. + const namedCallRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(?=\{)/g; let namedCallMatch; while ((namedCallMatch = namedCallRe.exec(code)) !== null) { - const [fullMatch, localName, optionsStr] = namedCallMatch; + const [fullMatch, localName] = namedCallMatch; const importedName = fontLocals.get(localName); if (!importedName) continue; const callStart = namedCallMatch.index; - const callEnd = callStart + fullMatch.length; + // The regex consumed up to (but not including) the '{' due to the + // lookahead — find the balanced object starting at the lookahead pos. + const openParenEnd = callStart + fullMatch.length; + const objRange = _findBalancedObject(code, openParenEnd); + if (!objRange) continue; + const optionsStr = code.slice(objRange[0], objRange[1]); + const callEnd = _findCallEnd(code, objRange[1]); + if (callEnd === null) continue; + if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) { continue; } @@ -624,15 +766,22 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st ); } + // Match: Identifier.Identifier( — where the argument starts with { const memberCallRe = - /\b([A-Za-z_$][A-Za-z0-9_$]*)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g; + /\b([A-Za-z_$][A-Za-z0-9_$]*)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(?=\{)/g; let memberCallMatch; while ((memberCallMatch = memberCallRe.exec(code)) !== null) { - const [fullMatch, objectName, propName, optionsStr] = memberCallMatch; + const [fullMatch, objectName, propName] = memberCallMatch; if (!proxyObjectLocals.has(objectName)) continue; const callStart = memberCallMatch.index; - const callEnd = callStart + fullMatch.length; + const openParenEnd = callStart + fullMatch.length; + const objRange = _findBalancedObject(code, openParenEnd); + if (!objRange) continue; + const optionsStr = code.slice(objRange[0], objRange[1]); + const callEnd = _findCallEnd(code, objRange[1]); + if (callEnd === null) continue; + if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) { continue; } diff --git a/tests/font-google.test.ts b/tests/font-google.test.ts index 422500c8..a01adfb2 100644 --- a/tests/font-google.test.ts +++ b/tests/font-google.test.ts @@ -3,6 +3,8 @@ import path from "node:path"; import fs from "node:fs"; import vinext, { _parseStaticObjectLiteral as parseStaticObjectLiteral, + _findBalancedObject as findBalancedObject, + _findCallEnd as findCallEnd, } from "../packages/vinext/src/index.js"; import type { Plugin } from "vite-plus"; @@ -551,6 +553,106 @@ describe("vinext:google-fonts plugin", () => { } }); + it("self-hosts font calls with nested-brace options (e.g. axes: { wght: 400 })", async () => { + // Regression: namedCallRe used \{[^}]*\} which stopped at the first '}' + // inside a nested object, so calls with nested braces were silently skipped. + const plugin = getGoogleFontsPlugin(); + const root = path.join(import.meta.dirname, ".test-font-root-nested"); + initPlugin(plugin, { command: "build", root }); + + 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" }, + }); + + try { + const transform = unwrapHook(plugin.transform); + + // Named-import form with a nested axes object + const code = [ + `import { Inter } from 'next/font/google';`, + `const inter = Inter({ subsets: ["latin"], axes: { wght: 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?"); + // _selfHostedCSS must have been injected — without the fix this was absent + expect(result.code).toContain("_selfHostedCSS"); + expect(result.code).toContain("@font-face"); + // Verify the injected object is syntactically valid (no double-comma) + expect(result.code).not.toMatch(/,\s*,/); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("self-hosts namespace member calls with nested-brace options", async () => { + // Same regression as above but for the memberCallRe path (fonts.Inter({...})) + const plugin = getGoogleFontsPlugin(); + const root = path.join(import.meta.dirname, ".test-font-root-nested-member"); + initPlugin(plugin, { command: "build", root }); + + 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" }, + }); + + try { + const transform = unwrapHook(plugin.transform); + + const code = [ + `import fonts from 'next/font/google';`, + `const inter = fonts.Inter({ subsets: ["latin"], axes: { wght: 400 } });`, + ].join("\n"); + + const result = await transform.call(plugin, code, "/app/layout.tsx"); + expect(result).not.toBeNull(); + expect(result.code).toContain("_selfHostedCSS"); + expect(result.code).not.toMatch(/,\s*,/); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("self-hosts font calls whose string values contain brace characters", async () => { + // Ensures _findBalancedObject doesn't treat '}' inside a string value as + // the end of the options object. + const plugin = getGoogleFontsPlugin(); + const root = path.join(import.meta.dirname, ".test-font-root-brace-string"); + initPlugin(plugin, { command: "build", root }); + + 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" }, + }); + + try { + const transform = unwrapHook(plugin.transform); + // String value contains '}' — old \{[^}]*\} regex would have stopped here. + const code = [ + `import { Inter } from 'next/font/google';`, + `const inter = Inter({ display: "swap", label: "font {bold}" });`, + ].join("\n"); + + const result = await transform.call(plugin, code, "/app/layout.tsx"); + expect(result).not.toBeNull(); + expect(result.code).toContain("_selfHostedCSS"); + expect(result.code).not.toMatch(/,\s*,/); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("self-hosts aliased lowercase font imports during build", async () => { const plugin = getGoogleFontsPlugin(); const root = path.join(import.meta.dirname, ".test-font-root-alias"); @@ -757,3 +859,84 @@ describe("parseStaticObjectLiteral", () => { expect(result).toBeNull(); }); }); + +// ── _findBalancedObject / _findCallEnd unit tests ───────────── + +describe("_findBalancedObject", () => { + it("returns [start, end] for a simple flat object", () => { + const code = `{ a: 1 }`; + expect(findBalancedObject(code, 0)).toEqual([0, code.length]); + }); + + it("handles nested objects", () => { + const code = `{ outer: { inner: 1 } }`; + expect(findBalancedObject(code, 0)).toEqual([0, code.length]); + }); + + it("stops at the correct closing brace, ignoring braces in single-quoted strings", () => { + const code = `{ key: 'val }' }`; + expect(findBalancedObject(code, 0)).toEqual([0, code.length]); + }); + + it("stops at the correct closing brace, ignoring braces in double-quoted strings", () => { + const code = `{ key: "font {bold}" }`; + expect(findBalancedObject(code, 0)).toEqual([0, code.length]); + }); + + it("handles backslash escapes inside strings", () => { + const code = String.raw`{ key: "a \"quoted\" {value}" }`; + expect(findBalancedObject(code, 0)).toEqual([0, code.length]); + }); + + it("ignores braces inside template literals", () => { + const code = "{ key: `val {x}` }"; + expect(findBalancedObject(code, 0)).toEqual([0, code.length]); + }); + + it("ignores braces inside template literal ${...} interpolations", () => { + // The '}' inside ${nested} must not end the string scan prematurely + const val = "val ${nested}"; + const code = "{ key: `" + val + "` }"; + expect(findBalancedObject(code, 0)).toEqual([0, code.length]); + }); + + it("returns null when no opening brace is found", () => { + expect(findBalancedObject(`foo`, 0)).toBeNull(); + }); + + it("returns null for an unbalanced object", () => { + expect(findBalancedObject(`{ a: 1`, 0)).toBeNull(); + }); + + it("skips leading whitespace before the opening brace", () => { + const code = ` { a: 1 }`; + const result = findBalancedObject(code, 0); + expect(result).not.toBeNull(); + expect(result![0]).toBe(3); // points to '{' + expect(result![1]).toBe(code.length); + }); +}); + +describe("_findCallEnd", () => { + it("returns index after ')' when it immediately follows objEnd", () => { + const code = `foo({})`; + // '{}' spans indices 4-5; objEnd (just after '}') is 6; ')' is at 6 + expect(findCallEnd(code, 6)).toBe(7); + }); + + it("skips whitespace before ')'", () => { + const code = `foo({}\n)`; + // objEnd is 6; whitespace at 6; ')' at 7 + expect(findCallEnd(code, 6)).toBe(code.length); + }); + + it("returns null when next non-whitespace is not ')'", () => { + const code = `foo({}, extra)`; + // objEnd is 6; next non-whitespace is ',' not ')' + expect(findCallEnd(code, 6)).toBeNull(); + }); + + it("returns null at end of string", () => { + expect(findCallEnd(`{`, 1)).toBeNull(); + }); +});