Commit Graph

20 Commits

Author SHA1 Message Date
Xplod13 b891b01d41 fix(fonts): detect trailing comma past comments when injecting font options (#2051)
The font plugins inject an extra option into the user's `next/font` options
object by string-splicing before the closing `}`. They chose the separator by
testing whether the text before `}` ends with `,`, but that test ran on the
raw source, so a trailing comment hid a real trailing comma:
`Inter({ weight: "400", /* x */ })` produced `{ weight: "400", /* x */, _vinext: … }`
— a double comma and a syntax error that broke the build.

Add a shared, string-aware `hasTrailingComma`/`lastSignificantChar` helper
(`utils/has-trailing-comma.ts`) that forward-scans tracking string and comment
state, and use it at both font injection sites and in dynamic-preload-metadata
(which previously kept a private comment-stripper). A stateful scan — rather
than stripping comments with a regex — is required so a `//` inside a string
literal (e.g. a URL) can never be mistaken for a comment and corrupt the
trailing-comma decision.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:42:16 +01:00
James Anderson b77dcfebdc test(msw): migrate font-google.test.ts from fetch hijacking to MSW (#1447)
Replaces 16 ad-hoc `globalThis.fetch = async () => new Response(...)`
swaps with a small `mockGoogleFontsCSS(body, opts?)` helper backed by
MSW's `server.use(http.get(...))`. The helper records every URL it
sees so the two tests that previously asserted on `fetchedUrls.find()`
just read `mock.urls` instead, and the one test that asserted on a
hit-count read `mock.count()`.

Two sites need bespoke handling:

  - The Inter/Roboto URL-branching test uses an inline
    `server.use(http.get("https://fonts.googleapis.com/*", ({ request }) => ...))`
    that returns different CSS based on the URL.
  - The "network error" test uses `HttpResponse.error()` to make the
    fetch reject (replacing the previous `throw new TypeError(...)` in
    the swapped fetch).

Handlers are reset between tests by the global `afterEach` in
`tests/_msw/setup.ts`, so the per-test `try { ... } finally
{ globalThis.fetch = originalFetch }` boilerplate disappears (the
`finally` blocks now only call `fs.rmSync` on the temp font root).

This is PR 2 of the MSW migration. PR 1 (#1444) introduced the
infrastructure and migrated `tests/font-google-build.test.ts`. A
follow-up will migrate `tests/fetch-cache.test.ts` and the in-test
fetch stub inside the font block of `tests/app-router.test.ts`.
2026-05-22 13:13:54 +00:00
James Anderson d504f022b0 fix(assets): default assetsDir to _next/static (Next.js parity) (#1411)
* fix(assets): default assetsDir to _next/static (Next.js parity)

Closes #1337. Supersedes #1383.

vinext's default `assetsDir` was Vite's historical `assets/`, so URL emission
(`/_next/static/...` via `resolveAssetUrlPrefix("")`) and on-disk layout
(`dist/client/assets/...`) disagreed in the empty-`assetPrefix` case. The
`build.assetsDir` and `experimental.renderBuiltUrl` Vite overrides only
applied when `assetPrefix` was configured, leaving the no-prefix branch
serving from a different path than the URL contract Next.js's client
runtime and test harness assert against.

This commit flips the default to Next.js's canonical layout:

- `resolveAssetsDir("")` returns `_next/static` (was `assets`)
- `build.assetsDir` is now set unconditionally from `resolveAssetsDir`
- Vite's default `base + assetsDir` composition produces correct URLs
  in the no-prefix case; `renderBuiltUrl` stays gated on `assetPrefix`
  because it's only needed for the configured cases
- Drops the legacy `/assets/` branches in prod-server (3 sites) and
  static-file-cache (hard cutover — see PR description for rationale)
- Updates `_headers` generation, precompress default, fonts plugin
  `DEFAULT_ASSETS_DIR` to match the new default

With the layout aligned, invalid `_next/static/*` requests naturally
return plain-text `404 + "Not Found"` from the static-file layer instead
of falling through to the page renderer (which would produce an HTML
404 with bootstrap scripts and CSS). This replaces #1383's parity
short-circuit with the natural code path:

- Node prod-server (App + Pages branches): missing asset under
  `resolveAppRouterAssetPath` returns `text/plain; charset=utf-8` 404
- Cloudflare worker entry: `isNextStaticPath` recognises asset-shape
  after ASSETS-binding misses; returns `notFoundStaticAssetResponse`

Mirrors Next.js: packages/next/src/server/lib/router-server.ts.

Adds `tests/invalid-static-asset-404.test.ts` (3 App Router + 3 Pages
Router cases — no prefix / basePath / assetPrefix) ported from Next.js
e2e suites. Updates 11 source files + 12 test files; 914+ tests pass
in the touched suites (asset-prefix, app-router, pages-router, deploy,
font-google, static-file-cache, serve-static, precompress, features,
routing, isr-cache, build-optimization, app-rsc/ssr, standalone,
middleware, shims).

Ported from Next.js:
- test/e2e/invalid-static-asset-404-app/*.test.ts
- test/e2e/invalid-static-asset-404-pages/*.test.ts

* fix(e2e): update cloudflare-pages-router hydration spec for _next/static

* PR #1411 approved. Clean fix, 2 nits.

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>

* refactor(assets): derive isHashed checks from ASSET_PREFIX_URL_DIR

Replace hardcoded '_next/static' string literals in runtime code paths
with the ASSET_PREFIX_URL_DIR constant so the asset URL convention has
a single source of truth.

- server/prod-server.ts isHashed: pathname.includes(`/${ASSET_PREFIX_URL_DIR}/`)
- server/prod-server.ts public-dir guard: startsWith(`/${ASSET_PREFIX_URL_DIR}/`)
- server/static-file-cache.ts isHashed: both startsWith + includes derived
- plugins/fonts.ts DEFAULT_ASSETS_DIR = ASSET_PREFIX_URL_DIR

Behavioural no-op — the constant is '_next/static' so the resolved
strings are identical. Centralises the URL contract so future changes
to ASSET_PREFIX_URL_DIR (or a refactor that derives it from build
config) flow through to every consumer.

Doc-comment references to '_next/static' left in place — they
describe the canonical value for human readers and are not code paths.

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-05-21 20:36:04 +00:00
Nathan Nguyen b46e9b8c44 fix(font): match Next.js font result style semantics (#1186)
* fix(font): match Next.js font result style semantics

Font loaders returned incomplete style exports, emitted Google variable classes unconditionally, and accepted adjustFontFallback without producing a metric-adjusted fallback face. Apps that read next/font results or rely on the generated class rule diverged from Next.js.

The shim assumed font-family was the only exported style concern and that the runtime Google loader should always synthesize a variable class. Reuse the existing static Google font transform boundary to inject fallback CSS, share the single-face style decision between Google and local loaders, and only return variable classes when requested.

Regression coverage ports the relevant Next.js font result expectations and checks adjusted fallback injection, disabled fallback behaviour, and single local source style exports.

* fix(font): avoid fallback metrics output collision

The package build emits imported JSON as JavaScript beside compiled TypeScript. Naming the data file fallback-metrics.json let the JSON artifact collide with fallback-metrics.ts on Windows, so the installed dist module could lack the named fallback helper exports.

Rename the generated data file to fallback-metrics-data.json and update the import so the packed output has distinct fallback-metrics.js and fallback-metrics-data.js modules.

* fix(font): preserve validated Google face metadata

Static Google font calls validated and fetched one concrete face, but the runtime shim recomputed exported style fields from raw options. Italic-only families could therefore load an italic face while returning a normal class rule.

Thread validated single-face weight and style through internal transform options, prefer them in the runtime shim, and harden emitted font descriptor values before class-rule and local font-face CSS generation.

Regression tests cover italic-only Google metadata handoff and unsafe font-style values for Google and local font shims.

* fix(font): omit ambiguous Google font style

Google style resolution treated a multi-style array the same as an omitted style option. That made ambiguous requests such as normal plus italic export fontStyle normal, even though no single face was selected.

Only default to normal when the style option is actually omitted. Multi-style arrays now produce no exported fontStyle, with a regression covering the class rule output.

* fix(font): tighten Google font runtime payload

* test(font): expect internal font payload in build output

* refactor(font): consolidate duplicated CSS sanitization functions into font-utils

Address review feedback:
- Remove sanitizeCSSValue wrapper in font-local.ts, use sanitizeFontDescriptorValue directly
- Add cross-module invariant comments for '{family} Fallback' naming convention
- Consolidate escapeCSSString, sanitizeCSSVarName, and sanitizeFallback into
  font-utils.ts (previously duplicated across font-google-base.ts, font-local.ts,
  and fallback-metrics.ts)

---------

Co-authored-by: James <james@eli.cx>
2026-05-15 09:48:42 +01:00
James Anderson aba335d5f3 fix(dev): self-host Google Fonts in dev mode (#1038)
The build pipeline already rewrites `next/font/google` calls to embed
self-hosted @font-face CSS pointing at `/<assetsDir>/_vinext_fonts/...`,
but the call-site rewrite was gated behind `isBuild`. Dev requests for
those URLs 404'd because nothing copies the cached `.vinext/fonts/`
files into the dev server's served paths, and once `_selfHostedCSS` is
present the shim no longer emits the fonts.googleapis.com `<link>` —
so dev showed unstyled glyphs with no CDN fallback.

Drop the build-only gate and add a `configureServer` middleware that
serves the cached files directly under the same URL prefix the
@font-face CSS embeds. Reuse the prod `CONTENT_TYPES` map so dev and
prod hand out identical MIME types.

Regression spec navigates to a new `/font-google-test` route in
app-basic that uses Geist + Geist_Mono, asserts no CDN URL leaks into
the HTML, and fetches a preload URL to verify the dev middleware
returns a real font/woff2 body.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:49:33 +01:00
Nathan Nguyen c63155d913 fix(fonts): stabilize next/font/google runtime registrations (#956)
* fix(fonts): make Google font shim registrations stable

Repeated next/font/google calls currently mint counter-based class names and append new SSR style rules. During dev HMR, the app module can re-evaluate while the shim module stays alive, so stale classes and links accumulate and the first emitted :root variable keeps winning.

The bad invariant was treating each runtime call as a new font registration. Use a deterministic identity for each family and options pair, remove the non-Next :root variable rule, and keep variable declarations scoped to the returned variable class.

Regression coverage verifies stable class identities, no :root emission, and no SSR style growth for repeated equivalent calls.

* fix(fonts): address Google font review feedback

The first PR version left two edge cases in the new stable identity path: crafted font family names could still leak unsafe selector characters into class names, and omitted display hashed differently from explicit display: swap even though both generate the same font URL.

Sanitize the readable family segment used in selectors, normalize display to the same default used by buildGoogleFontsUrl, and tighten the CSS variable regression so it checks CSS added by the current call instead of accumulated module state.

* fix(fonts): canonicalize Google font identity hashing

Equivalent Google font loader options could generate the same CSS and font URL while still producing different class identities. That reintroduced duplicate SSR style rules when option arrays used a different order or representation.

The identity hash now canonicalizes unordered option sets, default-style equivalence, and fallback whitespace before hashing. A focused regression test covers reordered weights and subsets, explicit normal style, and trimmed fallback names.

* fix(fonts): normalize default fallback font identities

Omitting next/font/google fallback produced the same CSS as fallback: ["sans-serif"] but still hashed a different raw option shape. That left the most common default call path outside the deterministic identity dedupe.

The identity hash now uses the resolved fallback list and no longer includes the serialized font-family CSS string. Regression coverage verifies implicit and explicit default fallback calls share class and variable identities, and the :root assertion now checks only styles added by its own test.
2026-04-29 17:23:21 +01:00
Iago Lima Toledo dcb417dfa3 fix(fonts): google fonts building perfectly (it was just a regex 😭) (#920)
* fix(fonts): google fonts building perfectly (it was just a regex 😭)

* fix(fonts): allow newlines inside bracket clauses

Previous fix's [^;\n]+? blocked the semicolon-less line bug but also
stopped matching multi-line bracket imports/exports — Prettier wraps
specifiers across lines once the clause crosses printWidth.

Clause is now a sequence of brace blocks (newlines allowed inside, but
[^}] keeps the lazy match from spanning past the matching close brace)
or single non-;\n chars. Multi-line shapes match again; the original
swallow-prevention guard still holds.

Reverts the export regex back to [^}]+ — } already delimits, so the
extra \n exclusion was over-defensive.

* fix(fonts): apply formatter to test file

---------

Co-authored-by: MrIago <28714061+MrIago@users.noreply.github.com>
2026-04-27 18:01:40 +01:00
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
James Anderson a5ff8653ad refactor: remove internal re-exports from entry (#828) 2026-04-13 08:26:21 +01:00
Shorebird Management 4f61aedc8f fix: emit served URLs for next/font/google self-hosted assets (#819)
* fix: emit served URLs for next/font/google self-hosted assets

`fetchAndCacheFont()` in `packages/vinext/src/plugins/fonts.ts` downloads
Google Fonts `.woff2` files into `<root>/.vinext/fonts/<family>-<urlHash>/`
and rewrites the cached `@font-face` CSS with
`css.split(fontUrl).join(path.join(fontDir, filename))` — an absolute
dev-machine filesystem path. That CSS is then embedded verbatim as
`_selfHostedCSS` in the server bundle, and every downstream consumer reads
from the same leaked string:

  1. The injected `<style data-vinext-fonts>` block's
     `@font-face { src: url(...) }` (via `ssrFontStyles` in
     `shims/font-google-base.ts`).
  2. The HTML body's `<link rel="preload">` tags emitted from
     `server/app-ssr-entry.ts:renderFontHtml()` via
     `collectFontPreloadsFromCSS()`'s url-extraction regex.
  3. The HTTP `Link:` response header emitted from
     `buildAppPageFontLinkHeader()` in `server/app-page-execution.ts` and
     set on the response in `server/app-page-response.ts:242`.

Production requests on workerd produce header and body preload entries
like `</home/<user>/<project>/.vinext/fonts/geist-<hash>/geist-<hash>.woff2>`
— the browser requests `<origin>/home/<user>/...`, workerd returns 404
(the cached files are never copied into `dist/client/` either), and the
console fills with `downloadable font: download failed` and
`preloaded with link preload was not used` warnings on every page view.
Because preload is high-priority for fonts, the broken request contends
with real critical-path traffic.

The fix adds `_rewriteCachedFontCssToServedUrls()` which replaces the
`cacheDir` prefix with the served URL namespace `/assets/_vinext_fonts`
right before `_selfHostedCSS` is embedded in the bundle. A matching
`writeBundle` hook on the `vinext:google-fonts` plugin (client environment
only) recursively copies every `.woff2`/`.woff`/`.ttf`/`.otf`/`.eot` file
out of `<root>/.vinext/fonts/` into
`<clientOutDir>/assets/_vinext_fonts/` preserving the subdirectory
structure, so the rewritten URLs resolve against the origin instead of
404ing. No config surface is added — this does not implement `#472`
(`assetPrefix` support), it just stops leaking the filesystem path
regardless of `assetPrefix`.

The existing `_headers` rule for `/assets/*` covers the new namespace,
and `StaticFileCache` picks the copied files up via its recursive walk of
`dist/client/`, so `Content-Type: font/woff2` and
`Cache-Control: public, max-age=31536000, immutable` and an automatic
content-hashed ETag all flow through without any server-side changes. On
Cloudflare the edge returns `CF-Cache-Status: HIT` on the second request,
unchanged from other assets.

## Tests

Two new regression tests, both verified to fail on `main` (before this
commit) and pass with the fix:

- **Unit** — `tests/font-google.test.ts`: five cases in a new
  `_rewriteCachedFontCssToServedUrls` describe block that drive the
  helper directly with crafted cache directories, multi-occurrence
  replacements, regex-metacharacter paths, empty cacheDir, and
  no-op CSS. Failure on `main`: the symbol does not exist, then
  asserts would detect the leaked `/home/` prefix even if it did.

- **Integration** — `tests/app-router.test.ts`, in a new
  `App Router Production server self-hosted next/font/google headers`
  describe block. Builds the existing `tests/fixtures/font-google-multiple`
  fixture (`Geist` + `Geist_Mono` via `next/font/google`) with a mocked
  fetch that stands in for the Google Fonts CDN — the mock returns CSS
  with real `https://fonts.gstatic.com/...` URLs so
  `fetchAndCacheFont`'s regex actually exercises the path-rewriting code
  that was the bug source. Starts `startProdServer()` on a random port
  and asserts on the raw HTTP response: (a) `Link:` header contains the
  served URL and neither the absolute fixture path nor `.vinext/fonts`
  appears anywhere in it, (b) body `<link rel="preload">` tags match
  `/assets/_vinext_fonts/...`, (c) the injected `<style data-vinext-fonts>`
  block's `@font-face src: url()` also uses the served URL, and (d) the
  copied font file serves 200 with `content-type: font/woff2` and an
  `immutable` cache-control. Failure on `main`: asserts produce the
  leaked path verbatim, e.g.
  `src: url(/home/<user>/.../.vinext/fonts/geist-<hash>/geist-<hash>.woff2)`.

A separate fixture is used instead of extending `app-basic` because
`app-basic` is shared by every integration test in the file — adding
`next/font/google` to its root layout would force a real Google Fonts
network fetch into every test run.

## Verified

With the fix applied:

| Check | Result |
|---|---|
| `pnpm test:unit` | **2853 / 2853** pass |
| `pnpm test:integration` | **1262 / 1262** pass (2 pre-existing skips) |
| `pnpm run check` (lint + format + types) | **0** warnings, **0** errors |
| `pnpm test:e2e` — `app-router` project | **322 / 322** pass (3 flaky on retry, 9 pre-existing skips) |
| `pnpm test:e2e` — `cloudflare-workers` project (Miniflare) | **38 / 38** pass (1 flaky on retry) |

Both new tests were also re-run with the fix reverted on a clean tree to
confirm they deterministically catch the regression — the integration
suite printed the leaked path verbatim and the unit suite failed at the
export site.

End-to-end runtime verification on `workerd` (Miniflare, via
`wrangler dev`) against `examples/app-router-cloudflare` with a
`next/font/google` layout import:

- **Before** — `curl -sI /` returns
  `Link: </home/<user>/<project>/.vinext/fonts/geist-<hash>/geist-<hash>.woff2>; rel=preload; as=font; ...`,
  the body `<link rel="preload">` tags contain the same path, and
  requesting the leaked path returns `HTTP/1.1 404 Not Found`.
- **After** — the same `curl -sI /` returns
  `Link: </assets/_vinext_fonts/geist-<hash>/geist-<hash>.woff2>; rel=preload; as=font; ...`,
  the body `<link>` tags match, and `curl -sI` on the served URL returns
  `HTTP/1.1 200 OK`, `Content-Type: font/woff2`,
  `Cache-Control: public, max-age=31536000, immutable`,
  `CF-Cache-Status: HIT` on the second request.

## References

- #472 — `assetPrefix` / `basePath` support in `next.config` (orthogonal:
  this PR does not implement `assetPrefix`, it just stops the absolute
  filesystem path from leaking into the preload emitters regardless of
  whether `assetPrefix` is set)
- #812 — parallel `applyMiddlewareRequestHeaders` sealed-snapshot
  invalidation fix that followed the same report-and-fixture pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(fonts): address review feedback on PR #819

Three follow-ups from `ask-bonk`'s review of df2710c:

## 1. Thread the real `build.assetsDir` through (real concern)

`_rewriteCachedFontCssToServedUrls` previously hardcoded the default
`assets` directory into the served URL while the `writeBundle` hook
read the real `envConfig.build.assetsDir` from Vite. A user who
customized `build.assetsDir` — for example to `"static"` — would get a
silent divergence: the embedded CSS would contain
`/assets/_vinext_fonts/...` while the font files were copied to
`<clientOutDir>/static/_vinext_fonts/...`, and every preload would 404
in production.

The fix adds an optional `assetsDir` parameter to the exported helper
(default `DEFAULT_ASSETS_DIR` so the existing unit tests still drive it
without plugin state) and threads the resolved value from the call
site in `injectSelfHostedCss`. Because `injectSelfHostedCss` is a
nested function declaration with no `this` binding, the value is read
once at the top of the outer `transform` handler (where `this.environment`
is bound by Rollup to the plugin context) and closed over by the inner
helper. Both the transform-time rewrite and the `writeBundle` copy hook
now read `this.environment?.config?.build?.assetsDir ?? DEFAULT_ASSETS_DIR`
from the same source, so they cannot diverge by construction.

The follow-up was verified end-to-end on workerd via Miniflare with a
fixture that sets `build.assetsDir: "static"` in its Vite config:

- `dist/client/static/_vinext_fonts/geist-<hash>/geist-<hash>.woff2`
  — files landed under the custom directory
- `_selfHostedCSS` in `dist/server/index.js` contains
  `src: url(/static/_vinext_fonts/...)`
- `curl -sI /` returns
  `Link: </static/_vinext_fonts/...>; rel=preload; as=font; ...`
- `curl -sI /static/_vinext_fonts/geist-<hash>/geist-<hash>.woff2`
  returns `200 OK`, `Content-Type: font/woff2`,
  `Cache-Control: public, max-age=31536000, immutable`,
  `CF-Cache-Status: HIT`

Two new unit test cases in `tests/font-google.test.ts` exercise
`rewriteCachedFontCssToServedUrls(..., "static")` and assert the URL
prefix tracks the argument, plus a defensive-guard case where an empty
string falls back to the default (so the URL never has a `//` segment).

Note: the Node.js production server's request handler hardcodes
`pathname.startsWith("/assets/")` as its static-file fast path (see
`server/prod-server.ts:995, 1286, 1551`) — this is a pre-existing
limitation affecting _all_ custom `build.assetsDir` users, not just
fonts, and is explicitly out of scope for this PR. On Cloudflare
Workers (the primary deployment target) the asset binding serves the
entire `dist/client/` tree recursively, so the custom-`assetsDir` case
works end-to-end today.

## 2. Document the `this.environment?` optional chaining (nit)

The reviewer asked whether the optional chaining on `this.environment`
in the `writeBundle` hook is defensive or masking bugs. It matches the
existing convention in `vinext:precompress` and `vinext:cloudflare-build`
(both in `src/index.ts`) but a short explainer keeps future readers
from wondering. Added a comment noting that under normal Vite builds
`this.environment` is always populated, the guard is there so the hook
safely no-ops if invoked outside a bound environment (e.g. from a
thin unit-test harness), and the early-return path is unreachable
during a real build.

The same comment clarifies why `this.environment.config` is read
inside the hook rather than via a closure variable from
`configResolved` — Vite's `configResolved` receives the top-level
resolved config and the per-environment `build.assetsDir` may differ,
so reading from `this.environment.config` at hook time is the only way
to get the value Vite actually used for the current environment.

## 3. Normalize `fetch()` input shapes in the test mock (nit)

The integration test's mock previously used `String(input)` to coerce
to a URL string, which would produce `[object Request]` for a `Request`
instance — not matching any of the mock's substring checks and
silently falling through to a real network fetch for Google Fonts.
The build plugin always passes string URLs today so this was
theoretical, but the fix is a four-line `resolveFetchUrl` helper that
handles `string`, `URL`, and `Request` explicitly before falling back
to `String()`. Prevents a future-vinext or test-helper regression
where the mock silently stops covering the build's real network calls.

## Verified

With the follow-up fixes applied:

| Check | Result |
|---|---|
| `pnpm test:unit` | **2855 / 2855** pass (+2 new `assetsDir` cases) |
| `pnpm test:integration` | **1262 / 1262** pass (2 pre-existing skips) |
| `pnpm run check` | **0** warnings, **0** errors |
| `pnpm test:e2e` — `app-router` project | **322 / 322** pass (2 flaky on retry, 9 pre-existing skips) |
| `pnpm test:e2e` — `cloudflare-workers` project (Miniflare) | **38 / 38** pass |

Custom `build.assetsDir: "static"` additionally verified end-to-end on
Miniflare against `examples/app-router-cloudflare` with a
`next/font/google` import — `Link:` header, body `<link rel="preload">`
tags, and the font file `200 OK` response all use `/static/...`
consistently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(fonts): address two non-blocking nits from PR #819 review

Both noted as non-blocking by `ask-bonk`'s approval review; landing
them keeps the review trail tidy and closes the documentation loop so
the next maintainer does not rebuild the incorrect mental model that
caused the original bug.

## 1. Stale comment in `fetchAndCacheFont`

The old comment at the `css.split(fontUrl).join(filePath)` line read

    // Rewrite CSS to use absolute path
    // (Vite will resolve /@fs/ for dev, or asset for build)

which was never true: the CSS ends up as a JavaScript string literal
inside the server bundle, and Vite's asset pipeline only scans real
CSS files and `import`/`new URL(...)` references — not string
literals. That incorrect assumption is exactly what seeded the leak
this PR fixes. Replaced the comment with an explicit description of
the handoff: `fetchAndCacheFont` writes absolute filesystem paths as
the on-disk intermediate form, `_rewriteCachedFontCssToServedUrls()`
rewrites the `cacheDir` prefix to the served URL namespace at embed
time, and the filesystem path must never reach the bundle, the
`<style data-vinext-fonts>` block, the body `<link rel="preload">`
tags, or the HTTP `Link:` header. The new comment ends with a "do not
resurrect that assumption" warning aimed at future maintainers.

## 2. Preserve `fetch(input, init)` signature in the test mock

`globalThis.fetch` accepts `(input, init)` but the integration test's
mock signature was `async (input: unknown) =>`, dropping `init` on
the floor. The build plugin only issues plain GETs for Google Fonts
so the current test does not need the `init` argument in any mock
branch — but the fallback `originalFetch(input as RequestInfo)` at
the bottom silently stripped headers/method/body from any unrelated
request that happened to run during the test and fall through.
Forwarding `init` through costs one extra parameter in the signature
and one extra argument at the fallback call site; it also keeps the
mock consistent with the `resolveFetchUrl` hardening landed in the
previous review round, which was justified as "future-proofing the
mock against a vinext internals change or test helper".

Neither change affects runtime behavior or test outcomes — the first
is a comment-only edit, the second is a 2-line signature-preserving
fix that never fires during the current test. Targeted runs after
the change:

- `pnpm run check` — 0 warnings, 0 errors, all 919 files formatted.
- `pnpm test:unit -t "_rewriteCachedFontCssToServedUrls"` — 7/7 pass
  (unchanged).
- `pnpm test:integration -t "self-hosted next/font/google"` — 4/4
  pass (unchanged).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Sam Hill <samhill7738@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:10:48 +01:00
James Anderson d60c8107bc fix(fonts): handle nested-brace options objects in self-hosting transform (#723)
* fix(fonts): handle nested-brace options objects in self-hosting transform

namedCallRe and memberCallRe used \{[^}]*\} which stops at the first
closing brace, so calls like Inter({ axes: { wght: 400 } }) were silently
skipped and fell back to CDN delivery.

Replace both regexes with a lookahead-only pattern plus a
findBalancedObject() helper that tracks brace depth and skips string
literals, correctly extracting the full options object regardless of
nesting depth.

Adds two regression tests: one for the named-import path and one for the
default-import proxy (namespace member) path.

* fix(fonts): address bonk review — template literal interpolations, dedup, hoisting

- Fix _findBalancedObject to correctly skip ${...} interpolations inside
  template literals. The previous backtick skipper treated any '}' as the
  end of the string, which would prematurely exit for values like
  `val ${nested}`. Now tracks an exprDepth counter inside interpolations.

- Extract _findCallEnd(code, objEnd) helper that skips whitespace and
  returns the index after ')'. Removes ~20 lines of duplicated logic
  between the named-call and member-call branches.

- Hoist both helpers to module level (top-level exported functions) so
  they are not recreated on every transform() call.

- Add tests: integration test for string values containing brace chars
  (Inter({ label: "font {bold}" })), plus unit describe blocks for
  _findBalancedObject and _findCallEnd covering string/template/escape/
  interpolation/whitespace/null edge cases.
2026-03-30 20:48:04 +01:00
James Anderson a7dd2d56af fix(fonts): double-comma syntax error when font options have a trailing comma (#719) 2026-03-30 11:00:29 +01:00
James Anderson 1ff166eb92 chore: turn on more lint rules (#714)
* chore: turn on more lint rules

* fmt

* add todos
2026-03-29 20:40:57 +01:00
mehedi c9d1ddbee6 fix(google-fonts): use closure variables instead of this for plugin sstate (#53)
* fix(google-fonts): use closure variables instead of this for plugin state

Vite does not bind `this` to the plugin object when calling hooks like
configResolved and transform.handler. Using `this._isBuild` etc. results
in a TypeError at startup ('Cannot set properties of undefined').

Replace the property-based state with closure variables so the plugin
works correctly regardless of how Vite invokes the hooks.

* fix(google-fonts): fix indentation of plugin object properties inside IIFE return

* fix: update tests to use closure-compatible plugin initialization

Tests were reaching into plugin internals (_isBuild, _fontCache,
_cacheDir) which are now closure variables after the IIFE refactor.
Updated to drive state through configResolved hook and mock fetch
for cache tests.

* fix(google-fonts): apply closure-variable fix on top of main's refactored plugin

main rewrote the plugin to add virtual module import rewriting but kept
the broken this-binding pattern. Apply the PR's fix: wrap in an IIFE so
state is held in closure vars (isBuild, fontCache, cacheDir) instead of
(this as any)._isBuild etc.

* fmt

* fix tests: replace plugin._isBuild direct access with initPlugin() helper

The google-fonts plugin closure fix removes the _isBuild/_fontCache/_cacheDir
properties from the returned Plugin object. Three tests from main's refactor
still used plugin._isBuild = false directly; replace with initPlugin(plugin,
{ command: 'serve' }) to simulate dev mode via the configResolved hook.

* fix: silence no-unused-vars lint warnings in font-google tests

Remove unused cacheDir variable and prefix unused fetch mock parameters
with underscore (_input, _init) per oxlint convention.

---------

Co-authored-by: Steve Faulkner <sfaulkner@cloudflare.com>
Co-authored-by: James <james@eli.cx>
2026-03-28 20:31:02 +00:00
Steve Faulkner 7325f0fc7d feat(font): replace generated google font catalog with virtual import rewrites (#656)
Reworks the font-catalog removal from #262 using per-import virtual modules so named imports keep normal ESM semantics, aliased/default calls still self-host in build mode, and dependency imports are transformed too.

Credit to @yunus25jmi1 for the original PR and direction in #262.
2026-03-22 14:06:29 -05:00
Stephen Zhou c17d6941be chore: migrate to vite plus (#535)
* chore: migrate to vite plus

* Disable typeAware and typeCheck

* Update CI

* Fix CI

* Fix test

* Clean

* Run test with vp

* Try revert

* react: false In test

* Fix test

* Revert "Try revert"

This reverts commit 009da10473.

* Update

* Update

* Try revert ci changes

* revert

* Run vp migrate

* Disable typeAware and typeCheck for now

* Better resolve for test

* Use vp dev instead of vite

* Update expect

* Fix NormalizeManifestModuleId

* Try increase timeout

* Update to use vp

* Try new check

* Bring back npx vp

* Migrate CI

* Make next-intl resolvable

* Update

* Update

* Update
2026-03-15 10:50:13 +00:00
James Anderson 764a496ce7 add oxfmt formatter (#380)
* add oxfmt formatter: config, scripts, CI, editor setup, docs

* rebuild lockfile

* fix: add Format to required checks list, remove dead ignore pattern

* run fmt

* add format to agents.md again
2026-03-09 14:56:14 +00:00
Stephen Zhou c0bc16c549 fix: export all google font (#57) 2026-02-28 21:35:54 +00:00
Dane 98b243493f fix: resolve CodeQL security alerts (ReDoS, incomplete sanitization, bad code gen) (#87)
Refactor chained .replace() to single-pass tokenizers in pattern matching:
- matchConfigPattern in config-matchers.ts and index.ts
- escapeHeaderSource (new) replaces inline chained escaping in matchHeaders/applyHeaders
- matchMiddlewarePattern in prod server entry template
- matchPattern in middleware.ts

Also:
- escapeHeaderSource correctly handles :param(constraint) patterns
- Fix decodeURIComponent parity gap in index.ts matchConfigPattern
- Fix redundant dot in middleware.ts tokenizer char class
- Add lgtm suppression comments for false positives in test files
- Add unit tests for escapeHeaderSource
2026-02-26 00:18:46 -06:00
Steve Faulkner 12fea722b6 Initial public release of vinext 2026-02-24 09:29:39 -06:00