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
2 changes: 1 addition & 1 deletion CLAUDE.md
317 changes: 215 additions & 102 deletions packages/vinext/src/index.ts

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions packages/vinext/src/shims/font-google-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,9 +422,10 @@ const googleFonts = new Proxy({} as Record<string, (options?: FontOptions) => Fo
get(_target, prop: string) {
if (prop === "__esModule") return true;
if (prop === "default") return googleFonts;
// Convert camelCase/PascalCase to proper font family name
// e.g., "Inter" -> "Inter", "RobotoMono" -> "Roboto Mono"
const family = prop.replace(/([a-z])([A-Z])/g, "$1 $2");
// Convert export-style names to proper font family names:
// - Underscores to spaces: "Roboto_Mono" -> "Roboto Mono"
// - PascalCase to spaces: "RobotoMono" -> "Roboto Mono"
const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2");
return createFontLoader(family);
},
});
Expand Down
2,251 changes: 0 additions & 2,251 deletions packages/vinext/src/shims/font-google.generated.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/vinext/src/shims/font-google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ export {
getSSRFontLinks,
getSSRFontStyles,
getSSRFontPreloads,
createFontLoader,
} from "./font-google-base";
export * from "./font-google.generated";
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Source: https://fonts.google.com/metadata/fonts
// @generated
declare module "next/font/google" {
export function createFontLoader(family: string): FontLoader;
export const ABeeZee: FontLoader;
export const Abel: FontLoader;
export const Abhaya_Libre: FontLoader;
Expand Down
18 changes: 1 addition & 17 deletions scripts/generate-google-fonts.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,6 @@ function writeFixture(families) {
fs.writeFileSync(fixturePath, JSON.stringify(data, null, 2) + "\n");
}

function writeGeneratedTs(entries) {
const outPath = path.join(process.cwd(), "packages/vinext/src/shims/font-google.generated.ts");
const lines = [];
lines.push("// Generated by scripts/generate-google-fonts.js");
lines.push(`// Source: ${METADATA_URL}`);
lines.push("// @generated");
lines.push('import { createFontLoader, type FontLoader } from "./font-google-base";');
for (const { exportName, family } of entries) {
lines.push(
`export const ${exportName}: FontLoader = /*#__PURE__*/ createFontLoader(${JSON.stringify(family)});`,
);
}
lines.push("");
fs.writeFileSync(outPath, lines.join("\n"));
}

function writeGeneratedDts(exportNames) {
const outPath = path.join(
process.cwd(),
Expand All @@ -86,6 +70,7 @@ function writeGeneratedDts(exportNames) {
lines.push(`// Source: ${METADATA_URL}`);
lines.push("// @generated");
lines.push('declare module "next/font/google" {');
lines.push(" export function createFontLoader(family: string): FontLoader;");
for (const name of exportNames) {
lines.push(` export const ${name}: FontLoader;`);
}
Expand Down Expand Up @@ -123,7 +108,6 @@ async function main() {
assertValidExports(exportNames);

writeFixture(families);
writeGeneratedTs(entries);
writeGeneratedDts(exportNames);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator now only writes the .d.ts file and the test fixture JSON, no longer generating the .ts catalog. This is correct — the .d.ts is still needed for IDE autocomplete (TypeScript needs to know the available font names), but the runtime catalog is replaced by the Vite transform.

Worth noting: the .d.ts still declares every font as export const FontName: FontLoader, which means TypeScript won't error if someone imports a font that doesn't actually exist on Google Fonts (e.g., a typo). The error would only surface at runtime. This is the same behavior as before, just making it explicit.


console.log(`Generated ${entries.length} fonts`);
Expand Down
165 changes: 108 additions & 57 deletions tests/font-google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ describe("next/font/google shim", () => {
expect(typeof Inter).toBe("function");
});

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

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

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

it("generates unique classNames for each call", async () => {
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
const Inter = createFontLoader("Inter");
const a = Inter({ weight: ["400"] });
const b = Inter({ weight: ["700"] });
expect(a.className).not.toBe(b.className);
Expand All @@ -80,7 +84,8 @@ describe("next/font/google shim", () => {
});

it("accepts _selfHostedCSS option for self-hosted mode", async () => {
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
const Inter = createFontLoader("Inter");
const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fonts/inter.woff2); }";
const result = Inter({ weight: ["400"], _selfHostedCSS: fakeCSS } as any);
expect(result.className).toBeDefined();
Expand Down Expand Up @@ -147,52 +152,21 @@ describe("next/font/google shim", () => {
expect(styles2.length).toBe(styles.length);
});

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

it("exports all Google Fonts as named exports", async () => {
it("proxy handles underscore-style names (e.g. Roboto_Mono)", async () => {
const mod = await import("../packages/vinext/src/shims/font-google.js");
const fixturePath = path.join(process.cwd(), "tests/fixtures/google-fonts.json");
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf-8")) as {
families: string[];
};
const toExportName = (family: string): string =>
family
.replace(/[^0-9A-Za-z]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
const expected = fixture.families.map(toExportName).sort();
const nonFontExports = new Set([
"default",
"buildGoogleFontsUrl",
"getSSRFontLinks",
"getSSRFontStyles",
"getSSRFontPreloads",
]);
const actual = Object.keys(mod)
.filter((name) => !nonFontExports.has(name))
.sort();
expect(actual).toEqual(expected);
for (const name of actual) {
expect(typeof (mod as any)[name]).toBe("function");
}
const fonts = mod.default as any;
const rm = fonts.Roboto_Mono({ weight: ["400"] });
expect(rm.style.fontFamily).toContain("Roboto Mono");
});

// ── Security: CSS injection via font family names ──
Expand Down Expand Up @@ -221,7 +195,7 @@ describe("next/font/google shim", () => {

it("sanitizes fallback font names with CSS injection attempts", async () => {
const mod = await import("../packages/vinext/src/shims/font-google.js");
const { Inter } = mod;
const Inter = mod.createFontLoader("Inter");
const result = Inter({
weight: ["400"],
fallback: ["sans-serif", "'); } body { color: red; } .x { font-family: ('"],
Expand All @@ -241,7 +215,7 @@ describe("next/font/google shim", () => {

it("rejects invalid CSS variable names and falls back to auto-generated", async () => {
const mod = await import("../packages/vinext/src/shims/font-google.js");
const { Inter } = mod;
const Inter = mod.createFontLoader("Inter");
const beforeStyles = mod.getSSRFontStyles().length;
const result = Inter({
weight: ["400"],
Expand All @@ -261,7 +235,7 @@ describe("next/font/google shim", () => {

it("accepts valid CSS variable names", async () => {
const mod = await import("../packages/vinext/src/shims/font-google.js");
const { Inter } = mod;
const Inter = mod.createFontLoader("Inter");
const beforeStyles = mod.getSSRFontStyles().length;
const result = Inter({
weight: ["400"],
Expand All @@ -285,13 +259,18 @@ describe("vinext:google-fonts plugin", () => {
expect(plugin.enforce).toBe("pre");
});

it("is a no-op in dev mode (isBuild = false)", async () => {
it("rewrites font imports in dev mode (no _selfHostedCSS)", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = false;
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");
expect(result).toBeNull();
// Import rewriting should happen even in dev mode
expect(result).not.toBeNull();
expect(result.code).toContain("createFontLoader as __vinext_clf");
expect(result.code).toContain('__vinext_clf("Inter")');
// But no self-hosted CSS in dev mode
expect(result.code).not.toContain("_selfHostedCSS");
});

it("returns null for files without next/font/google imports", async () => {
Expand Down Expand Up @@ -331,14 +310,19 @@ describe("vinext:google-fonts plugin", () => {
expect(result).toBeNull();
});

it("returns null when import exists but no font constructor call", async () => {
it("rewrites import even when no constructor call exists", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache");
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");
expect(result).toBeNull();
// Import rewriting should still happen even without a constructor call
expect(result).not.toBeNull();
expect(result.code).toContain("createFontLoader as __vinext_clf");
expect(result.code).toContain('__vinext_clf("Inter")');
// No constructor call, so no _selfHostedCSS
expect(result.code).not.toContain("_selfHostedCSS");
});

it("transforms font call to include _selfHostedCSS during build", async () => {
Expand All @@ -356,6 +340,10 @@ describe("vinext:google-fonts plugin", () => {

const result = await transform.call(plugin, code, "/app/layout.tsx");
expect(result).not.toBeNull();
// Should rewrite the import
expect(result.code).toContain("createFontLoader as __vinext_clf");
expect(result.code).toContain('__vinext_clf("Inter")');
// Should inject self-hosted CSS
expect(result.code).toContain("_selfHostedCSS");
expect(result.code).toContain("@font-face");
expect(result.code).toContain("Inter");
Expand Down Expand Up @@ -396,6 +384,8 @@ describe("vinext:google-fonts plugin", () => {

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

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

Expand Down Expand Up @@ -458,12 +450,71 @@ describe("vinext:google-fonts plugin", () => {

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

plugin._fontCache.clear();
});

it("rewrites aliased font imports (import { Inter as MyFont })", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — this alias test was called out as missing in the previous review and has been added. Coverage for the import { X as Y } pattern is important since the rewriting code has separate imported vs local handling.

const plugin = getGoogleFontsPlugin();
plugin._isBuild = false;
const transform = unwrapHook(plugin.transform);
const code = `import { Inter as MyFont } from 'next/font/google';\nconst font = MyFont({ weight: ['400'] });`;
const result = await transform.call(plugin, code, "/app/layout.tsx");
expect(result).not.toBeNull();
expect(result.code).toContain("createFontLoader as __vinext_clf");
// Should use the original name (Inter) for family and alias (MyFont) for local binding
expect(result.code).toContain('const MyFont = /*#__PURE__*/ __vinext_clf("Inter")');
});

it("handles multiple separate import statements from next/font/google", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = false;
const transform = unwrapHook(plugin.transform);
const code = [
`import { Inter } from 'next/font/google';`,
`import { 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 fonts should be transformed
expect(result.code).toContain('__vinext_clf("Inter")');
expect(result.code).toContain('__vinext_clf("Roboto")');
// Second import should be removed/merged
expect(result.code).toContain("merged into first");
});

it("handles font names with digits after underscore (e.g. Baloo_2)", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — test for fonts with digits after underscore (Baloo_2) was also called out as missing and has been added. The updated fontCallRe regex on line 2998 of index.ts now matches [A-Za-z0-9] after underscore, which handles this correctly.

const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache-digits");
plugin._fontCache.clear();

// Pre-populate cache — URLSearchParams encodes "+" as "%2B"
plugin._fontCache.set(
"https://fonts.googleapis.com/css2?family=Baloo%2B2%3Awght%40400&display=swap",
"@font-face { font-family: 'Baloo 2'; src: url(/baloo.woff2); }",
);

const transform = unwrapHook(plugin.transform);
const code = [
`import { Baloo_2 } from 'next/font/google';`,
`const font = Baloo_2({ weight: '400' });`,
].join("\n");
const result = await transform.call(plugin, code, "/app/layout.tsx");
expect(result).not.toBeNull();
expect(result.code).toContain('__vinext_clf("Baloo 2")');
// Self-hosting should match the Baloo_2 call
expect(result.code).toContain("_selfHostedCSS");

plugin._fontCache.clear();
});
});

// ── fetchAndCacheFont integration ─────────────────────────────
Expand Down
Loading
Loading