Skip to content

Commit d60c810

Browse files
authored
fix(fonts): handle nested-brace options objects in self-hosting transform (#723)
* fix(fonts): handle nested-brace options objects in self-hosting transform namedCallRe and memberCallRe used \{[^}]*\} which stops at the first closing brace, so calls like Inter({ axes: { wght: 400 } }) were silently skipped and fell back to CDN delivery. Replace both regexes with a lookahead-only pattern plus a findBalancedObject() helper that tracks brace depth and skips string literals, correctly extracting the full options object regardless of nesting depth. Adds two regression tests: one for the named-import path and one for the default-import proxy (namespace member) path. * fix(fonts): address bonk review — template literal interpolations, dedup, hoisting - Fix _findBalancedObject to correctly skip ${...} interpolations inside template literals. The previous backtick skipper treated any '}' as the end of the string, which would prematurely exit for values like `val ${nested}`. Now tracks an exprDepth counter inside interpolations. - Extract _findCallEnd(code, objEnd) helper that skips whitespace and returns the index after ')'. Removes ~20 lines of duplicated logic between the named-call and member-call branches. - Hoist both helpers to module level (top-level exported functions) so they are not recreated on every transform() call. - Add tests: integration test for string values containing brace chars (Inter({ label: "font {bold}" })), plus unit describe blocks for _findBalancedObject and _findCallEnd covering string/template/escape/ interpolation/whitespace/null edge cases.
1 parent f6aedd3 commit d60c810

File tree

3 files changed

+341
-6
lines changed

3 files changed

+341
-6
lines changed

packages/vinext/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ import {
7474
generateGoogleFontsVirtualModule,
7575
createGoogleFontsPlugin,
7676
createLocalFontsPlugin,
77+
_findBalancedObject,
78+
_findCallEnd,
7779
} from "./plugins/fonts.js";
7880
import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js";
7981
import { computeLazyChunks } from "./utils/lazy-chunks.js";
@@ -3946,5 +3948,6 @@ export { _postcssCache };
39463948
export { hasMdxFiles as _hasMdxFiles };
39473949
export { _mdxScanCache };
39483950
export { parseStaticObjectLiteral as _parseStaticObjectLiteral };
3951+
export { _findBalancedObject, _findCallEnd };
39493952
export { stripServerExports as _stripServerExports };
39503953
export { asyncHooksStubPlugin as _asyncHooksStubPlugin };

packages/vinext/src/plugins/fonts.ts

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,137 @@ async function fetchAndCacheFont(
393393
* files from transform (they contain `next/font/google` references that must
394394
* not be rewritten).
395395
*/
396+
397+
/**
398+
* Scan `code` forward from `searchStart` for a `{...}` object literal that
399+
* may contain arbitrarily nested braces. Returns `[objStart, objEnd]` where
400+
* `code[objStart] === '{'` and `code[objEnd - 1] === '}'`, or `null` if no
401+
* balanced object is found.
402+
*
403+
* String literals (single-quoted, double-quoted, and backtick template
404+
* literals including `${...}` interpolations) are fully skipped so that brace
405+
* characters inside string values do not affect the depth count.
406+
*/
407+
export function _findBalancedObject(code: string, searchStart: number): [number, number] | null {
408+
let i = searchStart;
409+
// Skip leading whitespace before the opening brace
410+
while (
411+
i < code.length &&
412+
(code[i] === " " || code[i] === "\t" || code[i] === "\n" || code[i] === "\r")
413+
) {
414+
i++;
415+
}
416+
if (i >= code.length || code[i] !== "{") return null;
417+
const objStart = i;
418+
let depth = 0;
419+
while (i < code.length) {
420+
const ch = code[i];
421+
if (ch === '"' || ch === "'") {
422+
// Skip a single- or double-quoted string literal, respecting backslash escapes.
423+
const quote = ch;
424+
i++;
425+
while (i < code.length) {
426+
const sc = code[i];
427+
if (sc === "\\") {
428+
i += 2; // skip escaped character
429+
} else if (sc === quote) {
430+
i++;
431+
break;
432+
} else {
433+
i++;
434+
}
435+
}
436+
} else if (ch === "`") {
437+
// Skip a template literal, including ${...} interpolation blocks.
438+
// We need to track brace depth inside interpolations so that a `}`
439+
// that closes an interpolation isn't mistaken for closing the object.
440+
i++; // consume the opening backtick
441+
while (i < code.length) {
442+
const tc = code[i];
443+
if (tc === "\\") {
444+
i += 2; // skip escape sequence
445+
} else if (tc === "`") {
446+
i++; // end of template literal
447+
break;
448+
} else if (tc === "$" && code[i + 1] === "{") {
449+
// Enter a ${...} interpolation: scan forward tracking nested braces.
450+
i += 2; // consume '${'
451+
let exprDepth = 1;
452+
while (i < code.length && exprDepth > 0) {
453+
const ec = code[i];
454+
if (ec === "{") {
455+
exprDepth++;
456+
i++;
457+
} else if (ec === "}") {
458+
exprDepth--;
459+
i++;
460+
} else if (ec === '"' || ec === "'") {
461+
// Quoted string inside interpolation — skip it
462+
const q = ec;
463+
i++;
464+
while (i < code.length) {
465+
if (code[i] === "\\") {
466+
i += 2;
467+
} else if (code[i] === q) {
468+
i++;
469+
break;
470+
} else {
471+
i++;
472+
}
473+
}
474+
} else if (ec === "`") {
475+
// Nested template literal inside interpolation — skip it
476+
// (simple depth-1 skip; deeply nested templates are rare in font options)
477+
i++;
478+
while (i < code.length) {
479+
if (code[i] === "\\") {
480+
i += 2;
481+
} else if (code[i] === "`") {
482+
i++;
483+
break;
484+
} else {
485+
i++;
486+
}
487+
}
488+
} else {
489+
i++;
490+
}
491+
}
492+
} else {
493+
i++;
494+
}
495+
}
496+
} else if (ch === "{") {
497+
depth++;
498+
i++;
499+
} else if (ch === "}") {
500+
depth--;
501+
i++;
502+
if (depth === 0) return [objStart, i];
503+
} else {
504+
i++;
505+
}
506+
}
507+
return null; // unbalanced
508+
}
509+
510+
/**
511+
* Given the index just past the closing `}` of an options object, skip
512+
* optional whitespace and return the index after the closing `)`.
513+
* Returns `null` if the next non-whitespace character is not `)`.
514+
*/
515+
export function _findCallEnd(code: string, objEnd: number): number | null {
516+
let i = objEnd;
517+
while (
518+
i < code.length &&
519+
(code[i] === " " || code[i] === "\t" || code[i] === "\n" || code[i] === "\r")
520+
) {
521+
i++;
522+
}
523+
if (i >= code.length || code[i] !== ")") return null;
524+
return i + 1;
525+
}
526+
396527
export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: string): Plugin {
397528
// Vite does not bind `this` to the plugin object when calling hooks, so
398529
// plugin state must be held in closure variables rather than as properties.
@@ -602,15 +733,26 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st
602733
}
603734

604735
if (isBuild) {
605-
const namedCallRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g;
736+
// Match: Identifier( — where the argument starts with {
737+
// The regex intentionally does NOT capture the options object; we use
738+
// _findBalancedObject() to handle nested braces correctly.
739+
const namedCallRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(?=\{)/g;
606740
let namedCallMatch;
607741
while ((namedCallMatch = namedCallRe.exec(code)) !== null) {
608-
const [fullMatch, localName, optionsStr] = namedCallMatch;
742+
const [fullMatch, localName] = namedCallMatch;
609743
const importedName = fontLocals.get(localName);
610744
if (!importedName) continue;
611745

612746
const callStart = namedCallMatch.index;
613-
const callEnd = callStart + fullMatch.length;
747+
// The regex consumed up to (but not including) the '{' due to the
748+
// lookahead — find the balanced object starting at the lookahead pos.
749+
const openParenEnd = callStart + fullMatch.length;
750+
const objRange = _findBalancedObject(code, openParenEnd);
751+
if (!objRange) continue;
752+
const optionsStr = code.slice(objRange[0], objRange[1]);
753+
const callEnd = _findCallEnd(code, objRange[1]);
754+
if (callEnd === null) continue;
755+
614756
if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) {
615757
continue;
616758
}
@@ -624,15 +766,22 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st
624766
);
625767
}
626768

769+
// Match: Identifier.Identifier( — where the argument starts with {
627770
const memberCallRe =
628-
/\b([A-Za-z_$][A-Za-z0-9_$]*)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(\{[^}]*\})\s*\)/g;
771+
/\b([A-Za-z_$][A-Za-z0-9_$]*)\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(\s*(?=\{)/g;
629772
let memberCallMatch;
630773
while ((memberCallMatch = memberCallRe.exec(code)) !== null) {
631-
const [fullMatch, objectName, propName, optionsStr] = memberCallMatch;
774+
const [fullMatch, objectName, propName] = memberCallMatch;
632775
if (!proxyObjectLocals.has(objectName)) continue;
633776

634777
const callStart = memberCallMatch.index;
635-
const callEnd = callStart + fullMatch.length;
778+
const openParenEnd = callStart + fullMatch.length;
779+
const objRange = _findBalancedObject(code, openParenEnd);
780+
if (!objRange) continue;
781+
const optionsStr = code.slice(objRange[0], objRange[1]);
782+
const callEnd = _findCallEnd(code, objRange[1]);
783+
if (callEnd === null) continue;
784+
636785
if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) {
637786
continue;
638787
}

0 commit comments

Comments
 (0)