Commit Graph

2 Commits

Author SHA1 Message Date
Nathan Nguyen 696948aca4 fix(next/font/google): use real axis range, validate options at build time (#885) (#893)
* shim: rewrite buildGoogleFontsUrl on top of the new build-url helper

Replace the inline URL builder in shims/font-google-base.ts with a thin
wrapper around the pure buildGoogleFontsUrl helper from PR 1
(packages/vinext/src/build/google-fonts/build-url.ts). The shim still
takes a FontOptions object and still returns a CSS URL, so external
callers and the existing 84 unit tests are unaffected.

Two visible behaviour changes:

1. Empty options no longer emit `:wght@100..900`. They emit no axis
   segment at all, which makes Google return the default static face
   (HTTP 200) for every font, including narrow-axis families like Sen
   that previously got HTTP 400. This is the dev fallback only; the
   build plugin always pre-resolves the real axis range from metadata
   in production.

2. Italic-only requests no longer drop the ital axis. Pre-fix the outer
   guard `weights.length > 0 || styles.length > 0` entered the block
   but the inner branch only handled the wght path, so a call like
   `Inter({ style: ['italic'] })` produced `family=Inter&display=swap`
   with no italic anywhere in the URL. The shim now derives an ital
   array from style and falls back to weight '400' when ital is set
   without an explicit weight, so Google has somewhere to attach the
   ital axis in the URL.

The shim deliberately does not import font-data.json or validate.ts: a
388 KB metadata file in the Worker bundle would dwarf the rest of the
shim, and the production path already has the metadata-aware variant.

Refs #885.

* plugin: validate options against metadata, surface HTTP errors as build errors

Replace the inline URL builder in plugins/fonts.ts with the pipeline added
in PR 1: validateGoogleFontOptions parses the call's options against the
bundled font metadata, getFontAxes resolves the actual `wght` range for
variable fonts, and buildGoogleFontsUrl emits the URL.

Three behaviour changes:

1. Variable fonts called with no `weight` now request the font's real
   axis range (Sen 400..800, Inter 100..900) instead of a hardcoded
   100..900. This is the user-visible fix for issue #885: Sen returns
   HTTP 200 with all five face files instead of HTTP 400.

2. Validation errors surface as build errors with the source file path
   attached. Unknown family, missing weight on a static font (Anton,
   Archivo Black, etc.), unknown weight, mixing 'variable' with explicit
   weights, axes on non-variable fonts, invalid display values, and
   missing/unknown subsets are now all caught at transform time. Pre-fix
   these silently produced URLs that Google rejected at request time.

3. HTTP non-2xx responses from Google are now distinguished from network
   errors. A 4xx/5xx is wrapped in GoogleFontsHttpError and surfaced
   with the failing URL plus the response body, because pre-fix the
   silent `catch { return; }` skipped self-hosting and let the runtime
   CDN path retry the same broken URL in the browser. Network errors
   (offline dev) still fall through silently to keep `vp dev` usable
   without a connection.

The fontCache key is the URL, so existing on-disk caches under
`.vinext/fonts/<hash>/` will be stale for affected fonts after this
change. One refetch per font on the next build.

Refs #885.

* tests: regression coverage for the #885 narrow-axis bug and validator throws

Five new tests in tests/font-google.test.ts plus subset additions to
existing fixtures whose Inter() / Roboto() calls were missing them.

Shim regressions:
- buildGoogleFontsUrl with empty options must not emit `:wght@100..900`
- buildGoogleFontsUrl with style-only options must keep the ital axis
  visible in the URL

Plugin regressions:
- Sen() with no weight must fetch `:wght@400..800`, not `:wght@100..900`
- unknown families throw a build error with the file path attached
- non-variable fonts called without an explicit weight throw at build
  time instead of silently emitting a broken URL
- HTTP 4xx/5xx from Google surfaces as a build error containing the URL
  and the response body
- network errors (DNS, ECONNREFUSED, AbortError) still fall through
  silently so offline dev keeps working

The existing plugin transform tests passed `Inter({ weight: '400' })`
without subsets; the validator now treats that as a missing-subsets
error (matches Next.js behaviour), so those fixtures gain
`subsets: ['latin']`. The two nested-brace regression tests still
exercise the parser's balanced-brace logic but use a benign
`_placeholder` field instead of `axes: { wght: 400 }`, which is
semantically invalid for Inter under the new validator.

Refs #885.

* address google font review feedback
2026-04-27 07:50:39 +01:00
Nathan Nguyen 428c199ec1 port Next.js Google Fonts metadata + URL pipeline (PR 1 of 2 for #885) (#892)
* vendor Google Fonts metadata from Next.js

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 #885.

* port Google Fonts URL pipeline from Next.js

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 #885 (variable Sen resolves to wght 400..800) and the italic-only
branch that vinext currently drops.

Refs #885.

* port Google Fonts options validator from Next.js

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 #885.

* fold attribution into font-metadata header, drop unused exports

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.

* address PR 892 review: document subsets parity, harden preload-disable 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.
2026-04-26 15:08:32 +01:00