Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 125 additions & 124 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3161,142 +3161,143 @@ 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).
{
name: "vinext:google-fonts",
enforce: "pre",

_isBuild: false,
_fontCache: new Map<string, string>(), // 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");
},

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",
(() => {
// 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<string, string>(); // url -> local @font-face CSS
let cacheDir = "";

return {
name: "vinext:google-fonts",
enforce: "pre",

configResolved(config) {
isBuild = config.command === "build";
cacheDir = path.join(config.root, ".vinext", "fonts");
},
async handler(code, id) {
if (!(this as any)._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;

const cacheDir = (this as any)._cacheDir as string;
const fontCache = (this as any)._fontCache as Map<string, string>;
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<string, any> = {};
try {
const parsed = parseStaticObjectLiteral(optionsStr);
if (!parsed) continue; // Contains dynamic expressions, skip
options = parsed as Record<string, any>;
} 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<string, any> = {};
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<string, any>;
} 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" }),
};
},
},
},
} as Plugin & { _isBuild: boolean; _fontCache: Map<string, string>; _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
Expand Down
Loading