Skip to content

Commit 8c1d17f

Browse files
committed
feat(font): replace generated font catalog with Vite import-rewriting transform
Closes #200 Instead of a 1928-line generated font catalog (font-google.generated.ts) that re-exports every Google Font as a createFontLoader call, the vinext:google-fonts Vite plugin now rewrites next/font/google import statements at compile time. Address review feedback: - Fix MagicString index corruption: add overlap guard for self-hosting regex so font constructor calls that share a region with imported statements are safely skipped - Handle multiple import statements: use matchAll with gm flags to process all import { ... } from 'next/font/google' in a single file - Remove unrelated vinext:nitro-compat plugin (belongs in separate PR) - Fix font family derivation consistency: remove dead PascalCase-to-space transform in build-mode path to match dev-mode (underscore-to-space only) - Fix font-google guard: use shimsDir path check instead of id.includes() to avoid false positives on user files like font-google-helpers.ts - Fix fontCallRe regex: allow digits and lowercase after underscores to match fonts like Baloo_2 and M_PLUS_1p - Add utilityExports sync comment referencing font-google.ts barrel - Add tests: aliased imports, multiple import statements, digit-underscore font names
1 parent 4fe92e9 commit 8c1d17f

8 files changed

Lines changed: 314 additions & 2094 deletions

File tree

packages/vinext/src/index.ts

Lines changed: 189 additions & 91 deletions
Large diffs are not rendered by default.

packages/vinext/src/shims/font-google-base.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,9 +415,10 @@ const googleFonts = new Proxy(
415415
get(_target, prop: string) {
416416
if (prop === "__esModule") return true;
417417
if (prop === "default") return googleFonts;
418-
// Convert camelCase/PascalCase to proper font family name
419-
// e.g., "Inter" -> "Inter", "RobotoMono" -> "Roboto Mono"
420-
const family = prop.replace(/([a-z])([A-Z])/g, "$1 $2");
418+
// Convert export-style names to proper font family names:
419+
// - Underscores to spaces: "Roboto_Mono" -> "Roboto Mono"
420+
// - PascalCase to spaces: "RobotoMono" -> "Roboto Mono"
421+
const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2");
421422
return createFontLoader(family);
422423
},
423424
},

packages/vinext/src/shims/font-google.generated.ts

Lines changed: 0 additions & 1927 deletions
This file was deleted.
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
export { default, buildGoogleFontsUrl, getSSRFontLinks, getSSRFontStyles, getSSRFontPreloads } from "./font-google-base";
2-
export * from "./font-google.generated";
1+
export { default, buildGoogleFontsUrl, getSSRFontLinks, getSSRFontStyles, getSSRFontPreloads, createFontLoader } from "./font-google-base";

packages/vinext/src/shims/next-shims-font-google.generated.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Source: https://fonts.google.com/metadata/fonts
33
// @generated
44
declare module "next/font/google" {
5+
export function createFontLoader(family: string): FontLoader;
56
export const ABeeZee: FontLoader;
67
export const Abel: FontLoader;
78
export const Abhaya_Libre: FontLoader;

scripts/generate-google-fonts.js

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -60,22 +60,6 @@ function writeFixture(families) {
6060
fs.writeFileSync(fixturePath, JSON.stringify(data, null, 2) + "\n");
6161
}
6262

63-
function writeGeneratedTs(entries) {
64-
const outPath = path.join(process.cwd(), "packages/vinext/src/shims/font-google.generated.ts");
65-
const lines = [];
66-
lines.push("// Generated by scripts/generate-google-fonts.js");
67-
lines.push(`// Source: ${METADATA_URL}`);
68-
lines.push("// @generated");
69-
lines.push("import { createFontLoader, type FontLoader } from \"./font-google-base\";");
70-
for (const { exportName, family } of entries) {
71-
lines.push(
72-
`export const ${exportName}: FontLoader = /*#__PURE__*/ createFontLoader(${JSON.stringify(family)});`,
73-
);
74-
}
75-
lines.push("");
76-
fs.writeFileSync(outPath, lines.join("\n"));
77-
}
78-
7963
function writeGeneratedDts(exportNames) {
8064
const outPath = path.join(
8165
process.cwd(),
@@ -86,6 +70,7 @@ function writeGeneratedDts(exportNames) {
8670
lines.push(`// Source: ${METADATA_URL}`);
8771
lines.push("// @generated");
8872
lines.push('declare module "next/font/google" {');
73+
lines.push(" export function createFontLoader(family: string): FontLoader;");
8974
for (const name of exportNames) {
9075
lines.push(` export const ${name}: FontLoader;`);
9176
}
@@ -123,7 +108,6 @@ async function main() {
123108
assertValidExports(exportNames);
124109

125110
writeFixture(families);
126-
writeGeneratedTs(entries);
127111
writeGeneratedDts(exportNames);
128112

129113
console.log(`Generated ${entries.length} fonts`);

tests/font-google.test.ts

Lines changed: 108 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ describe("next/font/google shim", () => {
3232
expect(typeof Inter).toBe("function");
3333
});
3434

35-
it("named export Inter returns className, style, variable", async () => {
36-
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
35+
it("createFontLoader returns className, style, variable", async () => {
36+
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
37+
const Inter = createFontLoader("Inter");
3738
const result = Inter({ weight: ["400", "700"], subsets: ["latin"] });
3839
expect(result.className).toMatch(/^__font_inter_\d+$/);
3940
expect(result.style.fontFamily).toContain("Inter");
@@ -42,21 +43,24 @@ describe("next/font/google shim", () => {
4243
});
4344

4445
it("supports custom variable name", async () => {
45-
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
46+
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
47+
const Inter = createFontLoader("Inter");
4648
const result = Inter({ weight: ["400"], variable: "--my-font" });
4749
// variable returns a class name that sets the CSS variable, not the variable name itself
4850
expect(result.variable).toMatch(/^__variable_inter_\d+$/);
4951
});
5052

5153
it("supports custom fallback fonts", async () => {
52-
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
54+
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
55+
const Inter = createFontLoader("Inter");
5356
const result = Inter({ weight: ["400"], fallback: ["Arial", "Helvetica"] });
5457
expect(result.style.fontFamily).toContain("Arial");
5558
expect(result.style.fontFamily).toContain("Helvetica");
5659
});
5760

5861
it("generates unique classNames for each call", async () => {
59-
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
62+
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
63+
const Inter = createFontLoader("Inter");
6064
const a = Inter({ weight: ["400"] });
6165
const b = Inter({ weight: ["700"] });
6266
expect(a.className).not.toBe(b.className);
@@ -78,7 +82,8 @@ describe("next/font/google shim", () => {
7882
});
7983

8084
it("accepts _selfHostedCSS option for self-hosted mode", async () => {
81-
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
85+
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
86+
const Inter = createFontLoader("Inter");
8287
const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fonts/inter.woff2); }";
8388
const result = Inter({ weight: ["400"], _selfHostedCSS: fakeCSS } as any);
8489
expect(result.className).toBeDefined();
@@ -145,44 +150,21 @@ describe("next/font/google shim", () => {
145150
expect(styles2.length).toBe(styles.length);
146151
});
147152

148-
it("exports common font families as named exports", async () => {
153+
it("exports createFontLoader for ad-hoc font creation", async () => {
149154
const mod = await import("../packages/vinext/src/shims/font-google.js");
150-
const names = [
151-
"Inter", "Roboto", "Roboto_Mono", "Open_Sans", "Lato",
152-
"Poppins", "Montserrat", "Geist", "Geist_Mono",
153-
"JetBrains_Mono", "Fira_Code",
154-
];
155-
for (const name of names) {
156-
expect(typeof (mod as any)[name]).toBe("function");
157-
}
155+
expect(typeof mod.createFontLoader).toBe("function");
156+
const loader = mod.createFontLoader("Inter");
157+
expect(typeof loader).toBe("function");
158+
const result = loader({ weight: ["400"] });
159+
expect(result.className).toMatch(/^__font_inter_\d+$/);
160+
expect(result.style.fontFamily).toContain("Inter");
158161
});
159162

160-
it("exports all Google Fonts as named exports", async () => {
163+
it("proxy handles underscore-style names (e.g. Roboto_Mono)", async () => {
161164
const mod = await import("../packages/vinext/src/shims/font-google.js");
162-
const fixturePath = path.join(process.cwd(), "tests/fixtures/google-fonts.json");
163-
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf-8")) as {
164-
families: string[];
165-
};
166-
const toExportName = (family: string): string =>
167-
family
168-
.replace(/[^0-9A-Za-z]+/g, "_")
169-
.replace(/^_+|_+$/g, "")
170-
.replace(/_+/g, "_");
171-
const expected = fixture.families.map(toExportName).sort();
172-
const nonFontExports = new Set([
173-
"default",
174-
"buildGoogleFontsUrl",
175-
"getSSRFontLinks",
176-
"getSSRFontStyles",
177-
"getSSRFontPreloads",
178-
]);
179-
const actual = Object.keys(mod)
180-
.filter((name) => !nonFontExports.has(name))
181-
.sort();
182-
expect(actual).toEqual(expected);
183-
for (const name of actual) {
184-
expect(typeof (mod as any)[name]).toBe("function");
185-
}
165+
const fonts = mod.default as any;
166+
const rm = fonts.Roboto_Mono({ weight: ["400"] });
167+
expect(rm.style.fontFamily).toContain("Roboto Mono");
186168
});
187169

188170
// ── Security: CSS injection via font family names ──
@@ -211,7 +193,7 @@ describe("next/font/google shim", () => {
211193

212194
it("sanitizes fallback font names with CSS injection attempts", async () => {
213195
const mod = await import("../packages/vinext/src/shims/font-google.js");
214-
const { Inter } = mod;
196+
const Inter = mod.createFontLoader("Inter");
215197
const result = Inter({
216198
weight: ["400"],
217199
fallback: ["sans-serif", "'); } body { color: red; } .x { font-family: ('"],
@@ -231,7 +213,7 @@ describe("next/font/google shim", () => {
231213

232214
it("rejects invalid CSS variable names and falls back to auto-generated", async () => {
233215
const mod = await import("../packages/vinext/src/shims/font-google.js");
234-
const { Inter } = mod;
216+
const Inter = mod.createFontLoader("Inter");
235217
const beforeStyles = mod.getSSRFontStyles().length;
236218
const result = Inter({
237219
weight: ["400"],
@@ -251,7 +233,7 @@ describe("next/font/google shim", () => {
251233

252234
it("accepts valid CSS variable names", async () => {
253235
const mod = await import("../packages/vinext/src/shims/font-google.js");
254-
const { Inter } = mod;
236+
const Inter = mod.createFontLoader("Inter");
255237
const beforeStyles = mod.getSSRFontStyles().length;
256238
const result = Inter({
257239
weight: ["400"],
@@ -275,13 +257,18 @@ describe("vinext:google-fonts plugin", () => {
275257
expect(plugin.enforce).toBe("pre");
276258
});
277259

278-
it("is a no-op in dev mode (isBuild = false)", async () => {
260+
it("rewrites font imports in dev mode (no _selfHostedCSS)", async () => {
279261
const plugin = getGoogleFontsPlugin();
280262
plugin._isBuild = false;
281263
const transform = unwrapHook(plugin.transform);
282264
const code = `import { Inter } from 'next/font/google';\nconst inter = Inter({ weight: ['400'] });`;
283265
const result = await transform.call(plugin, code, "/app/layout.tsx");
284-
expect(result).toBeNull();
266+
// Import rewriting should happen even in dev mode
267+
expect(result).not.toBeNull();
268+
expect(result.code).toContain("createFontLoader as __vinext_clf");
269+
expect(result.code).toContain('__vinext_clf("Inter")');
270+
// But no self-hosted CSS in dev mode
271+
expect(result.code).not.toContain("_selfHostedCSS");
285272
});
286273

287274
it("returns null for files without next/font/google imports", async () => {
@@ -321,14 +308,19 @@ describe("vinext:google-fonts plugin", () => {
321308
expect(result).toBeNull();
322309
});
323310

324-
it("returns null when import exists but no font constructor call", async () => {
311+
it("rewrites import even when no constructor call exists", async () => {
325312
const plugin = getGoogleFontsPlugin();
326313
plugin._isBuild = true;
327314
plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache");
328315
const transform = unwrapHook(plugin.transform);
329316
const code = `import { Inter } from 'next/font/google';\n// no call`;
330317
const result = await transform.call(plugin, code, "/app/layout.tsx");
331-
expect(result).toBeNull();
318+
// Import rewriting should still happen even without a constructor call
319+
expect(result).not.toBeNull();
320+
expect(result.code).toContain("createFontLoader as __vinext_clf");
321+
expect(result.code).toContain('__vinext_clf("Inter")');
322+
// No constructor call, so no _selfHostedCSS
323+
expect(result.code).not.toContain("_selfHostedCSS");
332324
});
333325

334326
it("transforms font call to include _selfHostedCSS during build", async () => {
@@ -346,6 +338,10 @@ describe("vinext:google-fonts plugin", () => {
346338

347339
const result = await transform.call(plugin, code, "/app/layout.tsx");
348340
expect(result).not.toBeNull();
341+
// Should rewrite the import
342+
expect(result.code).toContain("createFontLoader as __vinext_clf");
343+
expect(result.code).toContain('__vinext_clf("Inter")');
344+
// Should inject self-hosted CSS
349345
expect(result.code).toContain("_selfHostedCSS");
350346
expect(result.code).toContain("@font-face");
351347
expect(result.code).toContain("Inter");
@@ -386,6 +382,8 @@ describe("vinext:google-fonts plugin", () => {
386382

387383
const result = await transform.call(plugin, code, "/app/layout.tsx");
388384
expect(result).not.toBeNull();
385+
// Should rewrite import and inject self-hosted CSS
386+
expect(result.code).toContain("createFontLoader as __vinext_clf");
389387
expect(result.code).toContain("_selfHostedCSS");
390388
// lgtm[js/incomplete-sanitization] — escaping quotes for test assertion, not sanitization
391389
expect(result.code).toContain(fakeCSS.replace(/"/g, '\\"'));
@@ -419,7 +417,9 @@ describe("vinext:google-fonts plugin", () => {
419417

420418
const result = await transform.call(plugin, code, "/app/layout.tsx");
421419
expect(result).not.toBeNull();
422-
// Both font calls should be transformed
420+
// Import should be rewritten
421+
expect(result.code).toContain("createFontLoader as __vinext_clf");
422+
// Both font calls should have _selfHostedCSS injected
423423
const matches = result.code.match(/_selfHostedCSS/g);
424424
expect(matches?.length).toBe(2);
425425

@@ -448,12 +448,71 @@ describe("vinext:google-fonts plugin", () => {
448448

449449
const result = await transform.call(plugin, code, "/app/layout.tsx");
450450
expect(result).not.toBeNull();
451-
// Only Inter should be transformed (1 match)
451+
// Import should be rewritten for Inter
452+
expect(result.code).toContain("createFontLoader as __vinext_clf");
453+
// Only Inter should have _selfHostedCSS (1 match)
452454
const matches = result.code.match(/_selfHostedCSS/g);
453455
expect(matches?.length).toBe(1);
454456

455457
plugin._fontCache.clear();
456458
});
459+
460+
it("rewrites aliased font imports (import { Inter as MyFont })", async () => {
461+
const plugin = getGoogleFontsPlugin();
462+
plugin._isBuild = false;
463+
const transform = unwrapHook(plugin.transform);
464+
const code = `import { Inter as MyFont } from 'next/font/google';\nconst font = MyFont({ weight: ['400'] });`;
465+
const result = await transform.call(plugin, code, "/app/layout.tsx");
466+
expect(result).not.toBeNull();
467+
expect(result.code).toContain("createFontLoader as __vinext_clf");
468+
// Should use the original name (Inter) for family and alias (MyFont) for local binding
469+
expect(result.code).toContain('const MyFont = /*#__PURE__*/ __vinext_clf("Inter")');
470+
});
471+
472+
it("handles multiple separate import statements from next/font/google", async () => {
473+
const plugin = getGoogleFontsPlugin();
474+
plugin._isBuild = false;
475+
const transform = unwrapHook(plugin.transform);
476+
const code = [
477+
`import { Inter } from 'next/font/google';`,
478+
`import { Roboto } from 'next/font/google';`,
479+
`const inter = Inter({ weight: ['400'] });`,
480+
`const roboto = Roboto({ weight: ['400'] });`,
481+
].join("\n");
482+
const result = await transform.call(plugin, code, "/app/layout.tsx");
483+
expect(result).not.toBeNull();
484+
// Both fonts should be transformed
485+
expect(result.code).toContain('__vinext_clf("Inter")');
486+
expect(result.code).toContain('__vinext_clf("Roboto")');
487+
// Second import should be removed/merged
488+
expect(result.code).toContain("merged into first");
489+
});
490+
491+
it("handles font names with digits after underscore (e.g. Baloo_2)", async () => {
492+
const plugin = getGoogleFontsPlugin();
493+
plugin._isBuild = true;
494+
plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache-digits");
495+
plugin._fontCache.clear();
496+
497+
// Pre-populate cache — URLSearchParams encodes "+" as "%2B"
498+
plugin._fontCache.set(
499+
"https://fonts.googleapis.com/css2?family=Baloo%2B2%3Awght%40400&display=swap",
500+
"@font-face { font-family: 'Baloo 2'; src: url(/baloo.woff2); }",
501+
);
502+
503+
const transform = unwrapHook(plugin.transform);
504+
const code = [
505+
`import { Baloo_2 } from 'next/font/google';`,
506+
`const font = Baloo_2({ weight: '400' });`,
507+
].join("\n");
508+
const result = await transform.call(plugin, code, "/app/layout.tsx");
509+
expect(result).not.toBeNull();
510+
expect(result.code).toContain('__vinext_clf("Baloo 2")');
511+
// Self-hosting should match the Baloo_2 call
512+
expect(result.code).toContain("_selfHostedCSS");
513+
514+
plugin._fontCache.clear();
515+
});
457516
});
458517

459518
// ── fetchAndCacheFont integration ─────────────────────────────

0 commit comments

Comments
 (0)