Skip to content

port Next.js Google Fonts metadata + URL pipeline (PR 1 of 2 for #885)#892

Merged
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:nathan/font-google-helpers
Apr 26, 2026
Merged

port Next.js Google Fonts metadata + URL pipeline (PR 1 of 2 for #885)#892
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:nathan/font-google-helpers

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 25, 2026

What this changes

Vendors font-data.json from vercel/next.js and ports four pure helpers that consume it. Build-time scaffolding only; no runtime wired up.

New file Ported from
font-data.json next.js/packages/font/src/google/font-data.json
font-metadata.ts typed re-export of the JSON
sort-variants.ts next.js/packages/font/src/google/sort-fonts-variant-values.ts
build-url.ts next.js/packages/font/src/google/get-google-fonts-url.ts
get-axes.ts next.js/packages/font/src/google/get-font-axes.ts
validate.ts next.js/packages/font/src/google/validate-google-font-function-call.ts

Why

Issue #885: :wght@100..900 is hardcoded in the shim and plugin URL builders, so fonts with narrower axes (Sen 400..800, Anton 400) get HTTP 400 from Google. Investigation also found four sibling bugs in the same code: italic-only requests drop italic, the axes option is ignored, weight: 'variable' 400s, unknown families build URLs silently.

The minimal fix (drop the hardcoded range) regresses Inter from 9 weights to 1. Next.js avoids both regressions by validating + resolving axes against bundled metadata. This PR vendors that metadata and ports the helpers; PR 2 swaps the consumers and adds a Sen fixture.

Approach

Three commits: vendor metadata, port URL pipeline, port validator. A fourth cleanup commit drops NOTICE.md and an unused barrel after vp run knip flagged them; Vercel's MIT attribution lives in the font-metadata.ts header instead.

Validation

Risks / follow-ups

  • The vendor commit is ~16 K added lines, the upstream pretty-printed JSON (1911 fonts). Refresh by re-copying from next.js/packages/font/src/google/font-data.json.
  • "Auto-disables preload" test pins on Playwrite AR Guides. If upstream adds a subset to that family the fixture needs to change.
  • Helpers are unused until PR 2.

PR 2 (next)

  • swap shims/font-google-base.ts and plugins/fonts.ts to use the new pipeline
  • drop the silent catch { return; } around the build-time fetch so 400s surface as build errors
  • add tests/fixtures/font-narrow-axis/ (Sen) for CI regression coverage
  • extend tests/font-google.test.ts for the new throw paths

Refs #885.

Adds packages/vinext/src/build/google-fonts/font-data.json (1911 entries,
388 KB pretty-printed) plus a typed re-export wrapper. The metadata
enumerates each Google Font's available weights, styles, variable axes, and
preloadable subsets, and is the source of truth a faithful next/font/google
port needs to encode the right URL for variable fonts and reject invalid
options at build time.

Today vinext hardcodes :wght@100..900 in both the shim runtime URL builder
and the build-time plugin, which produces HTTP 400 from Google for fonts
whose wght axis is narrower (Sen 400..800, Anton single 400). Without
metadata there is no way to emit the correct axis range, validate weights
against the font's real options, or auto-disable preload for fonts with no
preloadable subsets.

The JSON itself is consumed only at build time and during dev. Production
Workers run the self-hosted CSS injected by the build plugin and never read
this file. The pretty-printed shape matches the upstream layout so future
refreshes from canary remain reviewable.

This commit only vendors the data and types. URL pipeline and validator
land in the next two commits.

Refs cloudflare#885.
Adds three pure helpers under packages/vinext/src/build/google-fonts:
sortFontsVariantValues (numeric variant comparator), buildGoogleFontsUrl
(URL assembler), and getFontAxes (metadata-driven axis resolver). Each is a
direct port of the matching module in vercel/next.js packages/font/src and
has no defaults of its own. Defaults live in the validator (next commit).

This is the layer that translates a validated options object into the URL
shape Google Fonts CDN actually accepts. Sorting matters because Google
rejects out-of-order variant lists with HTTP 400. The metadata-driven axis
resolver is what lets a Sen({}) call produce :wght@400..800 instead of the
broken :wght@100..900 default.

No consumer wired up yet. The shim and plugin still call the old hardcoded
URL builders; the integration lands in PR 2.

Tests: 21 new tests across the three modules. Each helper is exercised in
isolation against the vendored metadata, including the regression case
from cloudflare#885 (variable Sen resolves to wght 400..800) and the italic-only
branch that vinext currently drops.

Refs cloudflare#885.
Adds validateGoogleFontOptions, the parse-once boundary for next/font/google
calls. Mirrors Next's validate-google-font-function-call.ts, dropping the
SWC-coupled (functionName, fontFunctionArgument) signature in favour of
(fontFamily, options). Returns ValidatedGoogleFontOptions, a type whose
existence proves the input was rejected if Google would have rejected it.

Behavior matches Next.js: throws on unknown family, unknown weight, mixing
'variable' with explicit weights, axes on non-variable fonts, axes without
weight 'variable', invalid display, unknown subsets, preload with no
subsets. Defaults missing weight to ['variable'] for variable fonts, and
auto-disables preload for fonts with no preloadable subsets.

This closes the loop on the URL pipeline added in the previous commit.
Together with getFontAxes and buildGoogleFontsUrl, vinext now has a
metadata-driven path from caller options to a valid Google Fonts URL.

The shim and plugin still build URLs the old way; PR 2 wires this through.

Tests: 16 new tests covering every throw branch and every default branch,
plus an index.ts barrel that the future plugin import will use. The
"auto-disable preload" test pins on Playwrite AR Guides, which has empty
subsets in upstream metadata; if a future canary refresh adds a subset to
that family the test will need a different fixture.

Refs cloudflare#885.
Copilot AI review requested due to automatic review settings April 25, 2026 09:11
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 25, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@892

commit: c1abf20

Drop NOTICE.md and the index.ts barrel, demote four type aliases from
exports to module-internal. Knip flagged the index file as unused (no
consumer until PR 2) and the type aliases as unused exports for the same
reason; YAGNI says wait for the consumer rather than ship dead public API.

Vercel's MIT copyright on font-data.json is preserved in the
font-metadata.ts header instead of in a sibling NOTICE.md, matching the
in-tree pattern Next.js uses for vendored third-party files.

Tests: vp run knip exits 0, 37 google-fonts tests still pass.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Nice scaffolding PR. The four ports faithfully match the Next.js originals (verified line-by-line against vercel/next.js@canary), the 37 new tests cover the throw branches and the #885 regression case, all 84 existing font-google.test.ts tests still pass, vp check is clean, and vp run vinext#build produces a working bundle that inlines the JSON. The PR description is unusually clear about scope and the cleanup commit rationale.

A few observations (none blocking):

  1. subsets: [] + preload: true diverges from Next.js. Next.js's validateGoogleFontFunctionCall checks if (!subsets) (truthy check), and [] is truthy, so explicitly passing subsets: [] with preload: true silently passes through. vinext defaults subsets = [] and then checks subsets.length === 0, so it throws on both undefined and []. In practice this only affects callers passing subsets: [] literally, which is a corner case, and arguably vinext's behavior is more correct — but it is a quiet behavioral divergence worth either matching or documenting.

  2. Refresh ergonomics for font-data.json. The 16K-line vendor commit is fine for a one-off, but a small scripts/refresh-google-fonts-metadata.{sh,ts} that fetches/copies and re-runs oxfmt would make periodic refreshes safer than "re-copy from upstream". Not for this PR; mentioning so it doesn't get lost.

  3. Playwrite AR Guides fixture fragility. The PR description already calls this out. If you want belt-and-braces, a tiny helper test like expect(Object.values(googleFontsMetadata).some((f) => f.subsets.length === 0)).toBe(true) would still cover the empty-subsets branch even if the specific family changes upstream, and the Playwrite AR Guides-specific test could become a soft skip if the metadata changes. Fine to defer.

  4. with { type: "json" } import attribute. Works (rolldown rewrites to a regular import in the bundled output), but the project's TS config uses moduleResolution: bundler with resolveJsonModule: true, so the attribute is decorative. Harmless either way — just calling it out in case you ever build under a stricter Node ESM loader where the attribute starts mattering.

  5. Error name parity. Next.js's nextFontError sets err.name = 'NextFontError' so its WellKnownErrorsPlugin can format them. vinext throws plain Error. That's correct for vinext (you don't have that plugin), but if PR 2 ever wants to surface these as build errors with a distinct prefix, a class GoogleFontError extends Error would make filtering easier later. Optional.

Overall this looks like a clean, low-risk scaffolding PR. LGTM pending any of the parity decisions above.

// No preloadable subsets means preload is meaningless. Silently disable
// it rather than forcing the caller to opt out.
preload = false;
} else if (preload) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Subtle parity gap with Next.js: upstream uses if (!subsets) (truthy check), so an explicit subsets: [] with preload: true passes silently and subsets.forEach is a no-op. Here subsets defaults to [] and then subsets.length === 0 throws, so vinext rejects subsets: [] where Next.js would accept it.

Probably benign — nobody passes [] deliberately — and vinext's behavior is arguably more correct, but it is a quiet behavioral divergence. If you want exact parity:

Suggested change
} else if (preload) {
} else if (preload) {
if (!options.subsets) {
throw new Error(
`Preload is enabled but no subsets were specified for font \`${fontFamily}\`. Please specify subsets or disable preloading if your intended subset can't be preloaded.\nAvailable subsets: ${formatAvailableValues(availableSubsets)}`,
);
}
for (const subset of subsets) {

Otherwise consider dropping a comment here noting the deliberate divergence so a future reader doesn't "fix" it back to upstream behavior.

// and vinext are MIT licensed, so the file is redistributed under the same
// terms.

import rawFontData from "./font-data.json" with { type: "json" };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: with moduleResolution: bundler + resolveJsonModule: true (this repo's tsconfig) the with { type: "json" } attribute is decorative — tsdown/rolldown rewrites it to a plain import in dist/build/google-fonts/font-data.js. It is harmless and forward-compatible with stricter Node ESM JSON loading, so I'd keep it as-is, but worth knowing in case the import ever fails to resolve in some downstream tool that doesn't understand the attribute syntax.

// need a different fixture.
const opts = validateGoogleFontOptions("Playwrite AR Guides", { weight: "400" });
expect(opts.preload).toBe(false);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Belt-and-braces idea (optional): the Playwrite AR Guides pin is fragile because if upstream Google ever adds a subset to that family, this test silently starts asserting the wrong branch. A complementary metadata-driven test would be more robust:

it("has at least one font family with no preloadable subsets (sanity check for the auto-disable branch)", () => {
  const families = Object.entries(googleFontsMetadata).filter(
    ([, meta]) => meta.subsets.length === 0,
  );
  expect(families.length).toBeGreaterThan(0);
});

Then the Playwrite AR Guides test can stay, but the branch coverage doesn't depend on that one family.

@@ -0,0 +1,16071 @@
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Follow-up suggestion (not blocking): a small scripts/refresh-google-fonts-metadata.{sh,ts} that fetches vercel/next.js@canary:packages/font/src/google/font-data.json and runs vp fmt would make periodic refreshes mechanical. Manually re-copying a 16K-line JSON every few months is a recipe for someone accidentally hand-editing it. Worth filing as a separate issue.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 25, 2026

Review posted successfully: #892 (review)

Summary

Reviewed PR #892 — a scaffolding PR that vendors font-data.json and ports four pure helpers (build-url.ts, sort-variants.ts, get-axes.ts, validate.ts) from vercel/next.js.

Verification performed:

  • Compared all four ported helpers line-by-line against current upstream Next.js sources
  • Ran vp test run tests/google-fonts/ — 37 new tests pass
  • Ran vp test run tests/font-google.test.ts — 84 existing tests still pass
  • Ran vp check on new files — clean
  • Ran vp run vinext#build — succeeds, JSON inlined correctly into dist/
  • Confirmed no consumers yet (PR 1-of-2 scope holds)

Findings (all non-blocking):

  1. subsets: [] + preload: true — quiet behavioral divergence from Next.js (Next.js silently accepts; vinext throws). Posted inline suggestion to either match upstream or comment the deliberate divergence.
  2. with { type: "json" } — decorative under this tsconfig but harmless.
  3. Playwrite AR Guides fixture fragility — suggested a complementary metadata-driven sanity test.
  4. Refresh ergonomics — suggested a scripts/refresh-google-fonts-metadata.* helper as a follow-up.
  5. Error name parity — vinext throws plain Error; consider a GoogleFontError subclass when PR 2 wires up build errors.

Overall LGTM.

github run

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +36 to +46
} else if (axes.variableAxes) {
// Variable font with no requested wght: emit only the other variable axes.
variants.push([...axes.variableAxes]);
}

// Google requires axis tags within a variant to be ordered with lowercase
// tags first, then alphabetically. Every variant must agree on the same
// key order, since the URL takes the key list from variants[0] and
// applies it to every variant's value list.
if (axes.variableAxes) {
for (const variant of variants) {
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

axes.variableAxes is treated as truthy even when it's an empty array. In that case this branch pushes an empty variant, and later the function will build an invalid axis segment (...family=Foo:@...) because the key/value lists are empty. Consider gating on axes.variableAxes?.length > 0 here (and in the later if (axes.variableAxes) block) so empty arrays behave like undefined.

Copilot uses AI. Check for mistakes.
const bIsLowercase = b.charCodeAt(0) > 96;
if (aIsLowercase && !bIsLowercase) return -1;
if (bIsLowercase && !aIsLowercase) return 1;
return a > b ? 1 : -1;
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

The axis-tag sort comparator never returns 0 when tags are equal (return a > b ? 1 : -1). This violates the comparator contract and can produce inconsistent ordering if duplicate keys ever appear in a variant (e.g. via malformed variableAxes). Return 0 for equality (or use localeCompare) to make the sort well-defined.

Suggested change
return a > b ? 1 : -1;
return a.localeCompare(b);

Copilot uses AI. Check for mistakes.
…e test

`validateGoogleFontOptions` rejects `subsets: []` with `preload: true`
where Next.js silently accepts it (Next uses a truthy check on
`subsets`, vinext defaults to `[]` and checks `length`). The behavior
is intentional (an empty subsets array with preload enabled is always
a caller mistake), but undocumented divergence from a faithful port
invites a future "fix back to upstream" PR. Annotate the divergence
at the throw site.

Auto-disable-preload test pins on `Playwrite AR Guides` having an
empty subsets array in the bundled metadata. If a metadata refresh
ever adds a subset to that family, the family-specific test silently
flips into a false positive. Add an invariant test asserting the
bundled metadata still contains at least one family with no
preloadable subsets, so loss of branch coverage fails CI loud.
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Reminder for Bonk: I'm not adding verbose comments spelling out everything.
Also, if the PR is good then please remember to approve it formally, it'll save a lot of time for me and the maintainer. Thanks.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Re-reviewed at c1abf20. The two follow-ups from the prior review are addressed: validate.ts:71-73 now has an explicit comment documenting the deliberate subsets: [] divergence from Next.js, and validate.test.ts:121-126 adds the metadata-driven sanity test so the empty-subsets branch coverage doesn't depend on Playwrite AR Guides staying empty upstream.

Re-verified parity against current vercel/next.js@canary:

  • build-url.ts — line-for-line port of get-google-fonts-url.ts, including the comparator and the if (axes.variableAxes) guard
  • sort-variants.ts — matches sort-fonts-variant-values.ts
  • get-axes.ts — matches get-font-axes.ts
  • validate.ts — matches validate-google-font-function-call.ts modulo the documented subsets: [] divergence and the SWC calling-convention drop noted in the file header

All required CI checks are green (Check, Vitest unit + 3 integration shards, all 9 E2E projects).

Addressing the new Copilot comments (both non-blocking, both already match upstream exactly):

  1. Empty variableAxes array treated as truthy — Next.js uses the same if (axes.variableAxes) truthiness guard and the same axes.variableAxes ?? [] spread. In practice variableAxes is constructed by get-axes.ts, which only assigns it via if (!variableAxes) variableAxes = []; variableAxes.push(...), so the empty-array shape is unreachable from the ported pipeline. Tightening the guard would be a deliberate divergence from upstream for an unreachable case.

  2. Sort comparator never returns 0 — Next.js's get-google-fonts-url.ts uses the exact same return a > b ? 1 : -1 form. Axis tags within a variant are unique by construction (one entry per axis tag), so the equal-tags case doesn't arise in practice. A port that diverges from upstream comparator behavior is more risk than reward here.

LGTM. The follow-up work belongs in PR 2 as already scoped in the description.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 26, 2026

Approval review posted: #892 (review)

Summary

Re-reviewed PR #892 at commit c1abf20 and approved it.

Verification performed:

  • Confirmed the two follow-ups from my prior review were addressed by the author:
    • validate.ts:71-73 now has an explicit comment documenting the deliberate subsets: [] divergence
    • validate.test.ts:121-126 adds the metadata-driven sanity test for the empty-subsets branch
  • Re-verified all four ported files line-by-line against current vercel/next.js@canary — faithful ports
  • Confirmed all required CI checks are green (Check, Vitest unit + 3 integration shards, all 9 E2E projects, create-next-app on Ubuntu + Windows)

Addressed the new Copilot review comments (both non-blocking, both already match upstream exactly):

  1. Empty variableAxes array truthiness — same as upstream, and unreachable from the get-axes.ts constructor path
  2. Sort comparator never returns 0 — identical to upstream, and axis tags are unique by construction

PR is good to merge; the deferred items (silent fetch catch, Sen fixture, GoogleFontError subclass) are explicitly scoped to PR 2 in the description.

github run

@james-elicx james-elicx merged commit 428c199 into cloudflare:main Apr 26, 2026
23 checks passed
@NathanDrake2406 NathanDrake2406 deleted the nathan/font-google-helpers branch April 26, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants