diff --git a/packages/vinext/src/build/google-fonts/find-font-files-in-css.ts b/packages/vinext/src/build/google-fonts/find-font-files-in-css.ts new file mode 100644 index 000000000..c0c84016f --- /dev/null +++ b/packages/vinext/src/build/google-fonts/find-font-files-in-css.ts @@ -0,0 +1,49 @@ +// Ported from Next.js: packages/font/src/google/find-font-files-in-css.ts +// https://github.com/vercel/next.js/blob/canary/packages/font/src/google/find-font-files-in-css.ts +// +// Google Fonts css2 responses contain one @font-face block per available +// unicode subset, each preceded by a `/* */` comment and carrying +// its own `unicode-range`. Next.js keeps every block in the emitted CSS — +// the browser only downloads files whose unicode-range matches the page's +// content — but emits `` only for files whose subset +// the caller listed in `subsets`. This helper walks the CSS line by line, +// tracking the current subset comment, and flags which font files should +// be preloaded. +// +// vinext runs this against the *served* CSS (after gstatic URLs have been +// rewritten to `//_vinext_fonts/...`), so the returned URLs are +// directly usable in preload tags. The subset comments survive the URL +// rewrites because only `url(...)` contents are replaced. + +export type FontFileInCss = { + /** The file URL exactly as it appears in the CSS `src: url(...)`. */ + fontFileUrl: string; + /** True when this file's subset is listed in `subsetsToPreload`. */ + preloadFontFile: boolean; +}; + +export function findFontFilesInCss(css: string, subsetsToPreload?: string[]): FontFileInCss[] { + const fontFiles: FontFileInCss[] = []; + const seen = new Set(); + + // Current subset — set by the `/* */` comment Google emits + // immediately before each @font-face block. + let currentSubset = ""; + for (const line of css.split("\n")) { + const newSubset = /\/\* (.+?) \*\//.exec(line)?.[1]; + if (newSubset) { + currentSubset = newSubset; + } else { + const fontFileUrl = /src: url\((.+?)\)/.exec(line)?.[1]; + if (fontFileUrl && !seen.has(fontFileUrl)) { + seen.add(fontFileUrl); + fontFiles.push({ + fontFileUrl, + preloadFontFile: !!subsetsToPreload?.includes(currentSubset), + }); + } + } + } + + return fontFiles; +} diff --git a/packages/vinext/src/plugins/fonts.ts b/packages/vinext/src/plugins/fonts.ts index 9ecd86992..bf7d611e6 100644 --- a/packages/vinext/src/plugins/fonts.ts +++ b/packages/vinext/src/plugins/fonts.ts @@ -9,11 +9,13 @@ * delete the generated ~1,900-line runtime catalog while keeping ESM import * semantics intact. * 2. During production builds, fetches Google Fonts CSS + font files, caches - * them locally under `.vinext/fonts/`, and injects `_vinext.font` into - * statically analyzable font loader calls so fonts are served from the - * deployed origin rather than fonts.googleapis.com. Static calls also - * receive adjusted fallback CSS when Next.js-compatible fallback metrics - * exist for the selected Google Font. + * them locally under `.vinext/fonts/`, writes the @font-face CSS as an + * external `font..css` stylesheet, and injects `_vinext.font` + * (stylesheet URL + subset-filtered preload list) into statically + * analyzable font loader calls so fonts are served from the deployed + * origin rather than fonts.googleapis.com. Static calls also receive + * adjusted fallback CSS when Next.js-compatible fallback metrics exist + * for the selected Google Font. * * `createLocalFontsPlugin` — vinext:local-fonts * When a source file calls localFont({ src: "./font.woff2" }) or @@ -28,6 +30,7 @@ import type { Plugin } from "vite"; import { parseAst } from "vite"; import path from "node:path"; import fs from "node:fs"; +import { createHash } from "node:crypto"; import { escapeRegExp } from "../utils/regex.js"; import MagicString from "magic-string"; import { @@ -37,6 +40,7 @@ import { import { validateGoogleFontOptions } from "../build/google-fonts/validate.js"; import { getFontAxes } from "../build/google-fonts/get-axes.js"; import { buildGoogleFontsUrl } from "../build/google-fonts/build-url.js"; +import { findFontFilesInCss } from "../build/google-fonts/find-font-files-in-css.js"; import { CONTENT_TYPES } from "../server/static-file-cache.js"; import { ASSET_PREFIX_URL_DIR } from "../utils/asset-prefix.js"; @@ -81,17 +85,29 @@ const GOOGLE_FONT_UTILITY_EXPORTS = new Set([ * and writes an `@font-face` CSS snippet whose `src: url(...)` references * the files by absolute filesystem path — convenient for disk, unusable at * runtime because browsers resolve relative to the origin. Before the CSS - * is embedded in the bundle as `_vinext.font.selfHostedCSS`, the filesystem + * is written out as the served `font..css` stylesheet, the filesystem * prefix is rewritten to this URL prefix by `_rewriteCachedFontCssToServedUrls()`, * and the matching `writeBundle` hook in `createGoogleFontsPlugin` copies - * the font files into `//_vinext_fonts/` so the - * rewritten URL actually resolves against the origin at request time. + * the font files and stylesheet into + * `//_vinext_fonts/` so the rewritten URL actually + * resolves against the origin at request time. * * The leading `_` keeps the namespace distinct from Vite's content-hashed * asset names (which are emitted flat into `/`) and from any * user-provided public files. */ const VINEXT_FONT_URL_NAMESPACE = "_vinext_fonts"; + +/** + * Filename pattern for the served @font-face stylesheet written next to the + * cached font files (`font..css`). The content hash keeps the + * URL stable for CDN caching while still busting when the CSS changes (e.g. + * Google revs a font file URL after a cache refetch). Distinct from the + * `style.css` intermediate, which contains absolute filesystem paths and + * must never be served. + */ +const SERVED_FONT_CSS_RE = /^font\.[0-9a-f]{8}\.css$/; + const MAX_GOOGLE_FONTS_ERROR_BODY_LENGTH = 500; function formatGoogleFontsErrorBody(body: string): string { @@ -107,12 +123,13 @@ function formatGoogleFontsErrorBody(body: string): string { * `@font-face { src: url(...) }` references point at the served URL the * plugin's `writeBundle` hook copies the font files to. * - * This is called once per transform, before the CSS string is embedded in - * the bundle as `_vinext.font.selfHostedCSS`. Every downstream consumer reads - * from the same rewritten CSS: the injected `