vercel/next.js · #100158
Fix extensionless Google font URLs in both bundlers
crates/next-core/src/next_font/google/mod.rs145 + / 8 −
@@ -3,6 +3,7 @@ use std::{path::Path, sync::LazyLock}; use anyhow::{Context, Result, bail}; use futures::FutureExt; use indoc::formatdoc;+use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use regex::Regex; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize};@@ -40,7 +41,6 @@ use turbopack_node::{ use crate::{ embed_js::next_js_file_path, mode::NextMode,- next_app::metadata::split_extension, next_font::{ font_fallback::FontFallback, google::{@@ -341,6 +341,7 @@ impl ImportMappingReplacement for NextFontGoogleCssModuleReplacer { #[derive(Clone, Debug, Serialize, Deserialize)] struct NextFontGoogleFontFileOptions { pub url: String,+ pub ext: String, pub preload: bool, pub has_size_adjust: bool, }@@ -397,15 +398,13 @@ impl ImportMappingReplacement for NextFontGoogleFontFileReplacer { let NextFontGoogleFontFileOptions { url,+ ext, preload, has_size_adjust: size_adjust, } = font_file_options_from_query_map(query)?; - let (filename, ext) = split_extension(&url);- let ext = ext.with_context(|| format!("font url {} is missing an extension", url))?;- // remove dashes and dots as they might be used for the markers below.- let mut name = format!("{:016x}", hash_xxh3_hash64(filename.as_bytes()));+ let mut name = format!("{:016x}", hash_xxh3_hash64(url.as_bytes())); if size_adjust { name.push_str("-s") }@@ -492,13 +491,19 @@ async fn update_google_stylesheet( let has_size_adjust = *has_size_adjust.await?; - for FontFile { font_url, preload } in font_files {+ for FontFile {+ font_url,+ ext,+ preload,+ } in font_files+ { let query = NextFontGoogleFontFileOptions { url: font_url.clone(),+ ext, preload, has_size_adjust, };- let query_str = qstring::QString::from(serde_json::to_string(&query)?.as_str());+ let query_str = font_file_options_to_query(&query)?; stylesheet = stylesheet.replace( &font_url,@@ -509,9 +514,10 @@ async fn update_google_stylesheet( Ok(Vc::cell(stylesheet.into())) } -#[derive(Debug)]+#[derive(Debug, PartialEq, Eq)] struct FontFile { font_url: String,+ ext: String, preload: bool, } @@ -539,8 +545,31 @@ fn find_font_files_in_css(css: &str, subsets_to_preload: &[RcStr]) -> Vec<FontFi continue; } + // Google sometimes serves extensionless /l/font?kit=... URLs. Resolve+ // the type from the pathname or CSS hint, falling back to WOFF2 for the+ // Chrome user agent used by our fetches.+ let pathname = font_url.split(['?', '#']).next().unwrap_or(font_url);+ static FONT_EXT_RE: LazyLock<Regex> =+ LazyLock::new(|| Regex::new(r#"\.(woff|woff2|eot|ttf|otf)$"#).unwrap());+ static FONT_FORMAT_RE: LazyLock<Regex> =+ LazyLock::new(|| Regex::new(r#"format\(['\"](.+?)['\"]\)"#).unwrap());+ let format = FONT_FORMAT_RE.captures(line);+ let format_ext = match format.as_ref().and_then(|c| c.get(1)).map(|m| m.as_str()) {+ Some("woff") => "woff",+ Some("truetype") => "ttf",+ Some("opentype") => "otf",+ Some("embedded-opentype") => "eot",+ _ => "woff2",+ };+ let extension = FONT_EXT_RE.captures(pathname);+ let ext = extension+ .as_ref()+ .and_then(|c| c.get(1))+ .map_or(format_ext, |m| m.as_str());+ font_files.push(FontFile { font_url: font_url.to_string(),+ ext: ext.to_string(), preload: subsets_to_preload .iter() .any(|subset| subset == current_subset),@@ -648,6 +677,13 @@ async fn font_options_from_query_map( options_from_request(&parse_json_with_source_context(&json)?, &*font_data.await?)?; Ok(NextFontGoogleOptions::new(options)) }+fn font_file_options_to_query(options: &NextFontGoogleFontFileOptions) -> Result<String> {+ // Encode the JSON as a single query key. Parsing JSON as a query would+ // split font URL parameters at '&'; QString's encoder also leaves '%' and+ // '#' unescaped, so use a complete percent-encoding set here.+ Ok(utf8_percent_encode(&serde_json::to_string(options)?, NON_ALPHANUMERIC).to_string())+}+ fn font_file_options_from_query_map(query: &RcStr) -> Result<NextFontGoogleFontFileOptions> { let query_map = qstring::QString::from(query.as_str()); @@ -794,3 +830,104 @@ async fn get_mock_stylesheet( } } }++#[cfg(test)]+mod tests {+ use super::{+ FontFile, NextFontGoogleFontFileOptions, find_font_files_in_css,+ font_file_options_from_query_map, font_file_options_to_query,+ };++ #[test]+ fn font_extensions() {+ for (url, format, ext) in [+ (+ "https://fonts.gstatic.com/l/font?kit=a=b&skey=c&v=v20",+ "format('woff2')",+ "woff2",+ ),+ (+ "https://fonts.gstatic.com/l/font?kit=a%2Bb&skey=c",+ "format(\"woff\")",+ "woff",+ ),+ (+ "https://fonts.gstatic.com/l/font",+ "format('truetype')",+ "ttf",+ ),+ (+ "https://fonts.gstatic.com/l/font",+ "format('opentype')",+ "otf",+ ),+ (+ "https://fonts.gstatic.com/l/font",+ "format('embedded-opentype')",+ "eot",+ ),+ ("https://fonts.gstatic.com/l/font", "", "woff2"),+ (+ "https://fonts.gstatic.com/l/font",+ "format('unknown')",+ "woff2",+ ),+ (+ "https://fonts.gstatic.com/s/font.ttf?kit=a&v=1#font",+ "format('woff2')",+ "ttf",+ ),+ (+ "https://fonts.gstatic.com/l/font?kit=a.woff",+ "format('woff2')",+ "woff2",+ ),+ (+ "https://fonts.gstatic.com/s/font.woff2",+ "format('woff2')",+ "woff2",+ ),+ ] {+ let css = format!(+ "/* latin */\n@font-face {{\n src: url({url}) {format};\n}}\n@font-face {{\n \+ src: url({url}) {format};\n}}"+ );+ let expected = FontFile {+ font_url: url.to_string(),+ ext: ext.to_string(),+ preload: true,+ };+ assert_eq!(+ find_font_files_in_css(&css, &["latin".into()]),+ vec![expected]+ );+ assert!(!find_font_files_in_css(&css, &[])[0].preload);+ }+ }++ #[test]+ fn font_file_query_round_trip() {+ for url in [+ "https://fonts.gstatic.com/s/font.woff2",+ "https://fonts.gstatic.com/l/font?kit=a=b&skey=c&v=v20",+ "https://fonts.gstatic.com/l/font?kit=a%2Bb%25c+d&skey=c#font",+ ] {+ for preload in [false, true] {+ for has_size_adjust in [false, true] {+ let options = NextFontGoogleFontFileOptions {+ url: url.to_string(),+ ext: "woff2".to_string(),+ preload,+ has_size_adjust,+ };+ let query = font_file_options_to_query(&options).unwrap();+ let decoded = font_file_options_from_query_map(&query.into()).unwrap();+ assert_eq!(decoded.url, options.url);+ assert_eq!(decoded.ext, options.ext);+ assert_eq!(decoded.preload, preload);+ assert_eq!(decoded.has_size_adjust, has_size_adjust);+ }+ }+ }+ }+}packages/font/src/google/find-font-files-in-css.test.ts61 + / 5 −
@@ -1,6 +1,41 @@ import { findFontFilesInCss } from './find-font-files-in-css' describe('findFontFilesInCss', () => {+ it.each([+ [+ 'https://fonts.gstatic.com/l/font?kit=a=b&skey=c&v=v20',+ "format('woff2')",+ 'woff2',+ ],+ [+ 'https://fonts.gstatic.com/l/font?kit=a%2Bb&skey=c',+ 'format("woff")',+ 'woff',+ ],+ ['https://fonts.gstatic.com/l/font', "format('truetype')", 'ttf'],+ ['https://fonts.gstatic.com/l/font', "format('opentype')", 'otf'],+ ['https://fonts.gstatic.com/l/font', "format('embedded-opentype')", 'eot'],+ ['https://fonts.gstatic.com/l/font', '', 'woff2'],+ ['https://fonts.gstatic.com/l/font', "format('unknown')", 'woff2'],+ [+ 'https://fonts.gstatic.com/s/font.ttf?kit=a&v=1#font',+ "format('woff2')",+ 'ttf',+ ],+ ['https://fonts.gstatic.com/l/font?kit=a.woff', "format('woff2')", 'woff2'],+ ])('resolves the extension of %s %s as %s', (url, format, ext) => {+ const css = `/* latin */+@font-face {+ src: url(${url}) ${format};+}+@font-face {+ src: url(${url}) ${format};+}`+ expect(findFontFilesInCss(css, ['latin'])).toEqual([+ { googleFontFileUrl: url, preloadFontFile: true, ext },+ ])+ })+ it('should find all font files and preload requested subsets', () => { const css = `/* latin */ @font-face {@@ -44,11 +79,31 @@ describe('findFontFilesInCss', () => { ` expect(findFontFilesInCss(css, ['latin', 'cyrilic'])).toEqual([- { googleFontFileUrl: 'latin1.woff2', preloadFontFile: true },- { googleFontFileUrl: 'greek1.woff2', preloadFontFile: false },- { googleFontFileUrl: 'latin2.woff2', preloadFontFile: true },- { googleFontFileUrl: 'greek2.woff2', preloadFontFile: false },- { googleFontFileUrl: 'cyrilic.woff2', preloadFontFile: true },+ {+ googleFontFileUrl: 'latin1.woff2',+ preloadFontFile: true,+ ext: 'woff2',+ },+ {+ googleFontFileUrl: 'greek1.woff2',+ preloadFontFile: false,+ ext: 'woff2',+ },+ {+ googleFontFileUrl: 'latin2.woff2',+ preloadFontFile: true,+ ext: 'woff2',+ },+ {+ googleFontFileUrl: 'greek2.woff2',+ preloadFontFile: false,+ ext: 'woff2',+ },+ {+ googleFontFileUrl: 'cyrilic.woff2',+ preloadFontFile: true,+ ext: 'woff2',+ }, ]) }) @@ -87,6 +142,7 @@ describe('findFontFilesInCss', () => { googleFontFileUrl: 'https://fonts.gstatic.com/s/fraunces/v24/6NUu8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib14c7qv8oRcTn.woff2', preloadFontFile: false,+ ext: 'woff2', }, ]) })packages/font/src/google/find-font-files-in-css.ts20 + / 1 −
@@ -1,3 +1,11 @@+const formatToExtension = new Map([+ ['woff', 'woff'],+ ['woff2', 'woff2'],+ ['truetype', 'ttf'],+ ['opentype', 'otf'],+ ['embedded-opentype', 'eot'],+])+ /** * Find all font files in the CSS response and determine which files should be preloaded. * In Google Fonts responses, the @font-face's subset is above it in a comment.@@ -8,6 +16,7 @@ export function findFontFilesInCss(css: string, subsetsToPreload?: string[]) { const fontFiles: Array<{ googleFontFileUrl: string preloadFontFile: boolean+ ext: string }> = [] // Keep track of the current subset@@ -25,10 +34,20 @@ export function findFontFilesInCss(css: string, subsetsToPreload?: string[]) { (foundFile) => foundFile.googleFontFileUrl === googleFontFileUrl ) ) {- // Found the font file in the @font-face declaration.+ // Google can return extensionless /l/font?kit=... URLs. Prefer the+ // pathname extension, then the CSS format hint. Our Chrome user agent+ // requests WOFF2, so use that when neither specifies a supported type.+ const pathname = googleFontFileUrl.split(/[?#]/, 1)[0]+ const format = /format\(['"](.+?)['"]\)/.exec(line)?.[1]+ const ext =+ /\.(woff|woff2|eot|ttf|otf)$/.exec(pathname)?.[1] ??+ formatToExtension.get(format ?? '') ??+ 'woff2'+ fontFiles.push({ googleFontFileUrl, preloadFontFile: !!subsetsToPreload?.includes(currentSubset),+ ext, }) } }packages/font/src/google/loader.ts1 + / 2 −
@@ -105,7 +105,7 @@ const nextFontGoogleFontLoader: FontLoader = async ({ // Download the font files extracted from the CSS const downloadedFiles = await Promise.all(- fontFiles.map(async ({ googleFontFileUrl, preloadFontFile }) => {+ fontFiles.map(async ({ googleFontFileUrl, preloadFontFile, ext }) => { const hasCachedFont = fontCache.has(googleFontFileUrl) // Download the font file or get it from cache const fontFileBuffer = hasCachedFont@@ -123,7 +123,6 @@ const nextFontGoogleFontLoader: FontLoader = async ({ nextFontError(`Failed to fetch \`${fontFamily}\` from Google Fonts.`) } - const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(googleFontFileUrl)![1] // Emit font file to .next/static/media const selfHostedFileUrl = emitFontFile( fontFileBuffer,test/e2e/app-dir/next-font-extensionless/app/layout.tsxadded12 + / 0 −
@@ -0,0 +1,12 @@+import type { ReactNode } from 'react'+import { Inter } from 'next/font/google'++const inter = Inter({ subsets: ['latin'] })++export default function Root({ children }: { children: ReactNode }) {+ return (+ <html>+ <body className={inter.className}>{children}</body>+ </html>+ )+}test/e2e/app-dir/next-font-extensionless/app/page.tsxadded3 + / 0 −
@@ -0,0 +1,3 @@+export default function Page() {+ return <p>hello world</p>+}test/e2e/app-dir/next-font-extensionless/google-font-mocked-responses.jsadded12 + / 0 −
@@ -0,0 +1,12 @@+module.exports = {+ 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap': `+/* latin */+@font-face {+ font-family: 'Inter';+ font-style: normal;+ font-weight: 100 900;+ font-display: swap;+ src: url(${process.env.NEXT_TEST_FONT_SERVER_URL}/l/font?kit=a%2Bb%25c=d&skey=test&v=v20) format('woff2');+}+`,+}test/e2e/app-dir/next-font-extensionless/next-font-extensionless.test.tsadded112 + / 0 −
@@ -0,0 +1,112 @@+import cheerio from 'cheerio'+import { nextTestSetup } from 'e2e-utils'+import { readFileSync } from 'fs'+import { createServer, type Server } from 'http'+import type { AddressInfo } from 'net'++// This fixture fetches fonts from a local server, which cannot be reached in deploy mode.+// @force-gate !deploy+describe('next/font/google with an extensionless URL', () => {+ const { next, isNextDev } = nextTestSetup({+ files: __dirname,+ skipStart: true,+ env: {+ NEXT_FONT_GOOGLE_MOCKED_RESPONSES: require.resolve(+ './google-font-mocked-responses.js'+ ),+ },+ })++ const font = readFileSync(+ require.resolve('../../next-font/app/fonts/my-font.woff2')+ )+ const requests: string[] = []+ let server: Server+ let fontServerUrl: string++ beforeAll(async () => {+ server = createServer((req, res) => {+ requests.push(req.url!)+ res.setHeader('Content-Type', 'font/woff2')+ res.end(font)+ })+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))+ fontServerUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`+ await next.start({ env: { NEXT_TEST_FONT_SERVER_URL: fontServerUrl } })+ })++ afterAll(async () => {+ await new Promise<void>((resolve, reject) => {+ server?.close((err) => (err ? reject(err) : resolve()))+ })+ })++ it('self-hosts the font as WOFF2 with size-adjust and production preloads', async () => {+ let href: string+ let css: string+ if (isNextDev) {+ // Dev injects styles in the browser rather than emitting production preloads.+ const browser = await next.browser('/')+ const stylesheets: { href: string | null; css: string }[] =+ await browser.eval(`+ Array.from(document.styleSheets).map(sheet => ({+ href: sheet.href,+ css: Array.from(sheet.cssRules).map(rule => rule.cssText).join('\\n')+ }))+ `)+ css = stylesheets.map((sheet) => sheet.css).join('\n')+ const stylesheet = stylesheets.find((sheet) =>+ sheet.css.includes('.woff2')+ )+ const source = /url\(["']?([^"')]+\.woff2)["']?\)/.exec(+ stylesheet.css+ )?.[1]+ // Turbopack's font URLs are relative to the stylesheet, not the page.+ href = new URL(source, stylesheet.href || next.url).pathname+ } else {+ const res = await next.fetch('/')+ const $ = cheerio.load(await res.text())+ const preloadLink = $('link[rel="preload"][as="font"]')+ let type: string | undefined+ if (preloadLink.length) {+ href = preloadLink.attr('href')+ type = preloadLink.attr('type')+ } else {+ // Prerenders (e.g. with Cache Components) can hoist preloads from the+ // HTML into the `Link` response header instead.+ const preloadHeader = (res.headers.get('link') ?? '')+ .split(/,\s*(?=<)/)+ .find((link) => /;\s*as="?font"?/.test(link))+ href = /^<([^>]+)>/.exec(preloadHeader ?? '')?.[1]+ type = /;\s*type="?([^";]+)"?/.exec(preloadHeader ?? '')?.[1]+ }+ expect(type).toBe('font/woff2')+ const stylesheets = await Promise.all(+ $('link[rel="stylesheet"]')+ .map((_, link) =>+ next.fetch($(link).attr('href')).then((r) => r.text())+ )+ .get()+ )+ css = stylesheets.join('\n')+ }+ expect(href).toMatch(/\/media\/.*-s\.p\.(?:.*\.)?woff2$/)+ expect(css).toContain(href.split('/').pop())+ expect(css).toMatch(/format\(['"]woff2['"]\)/)+ expect(css).not.toContain('/l/font?')++ const response = await next.fetch(href)+ expect(response.status).toBe(200)+ const body = Buffer.from(await response.arrayBuffer())+ if (process.env.IS_TURBOPACK_TEST) {+ expect(requests).toContain('/l/font?kit=a%2Bb%25c=d&skey=test&v=v20')+ expect(body).toEqual(font)+ } else {+ // The Webpack font-fetch mock returns the URL as the file contents.+ expect(body.toString()).toBe(+ `${fontServerUrl}/l/font?kit=a%2Bb%25c=d&skey=test&v=v20`+ )+ }+ expect(next.cliOutput).not.toContain('Using fallback font instead')+ })+})test/e2e/app-dir/next-font-extensionless/next.config.tsadded5 + / 0 −
@@ -0,0 +1,5 @@+import type { NextConfig } from 'next'++const nextConfig: NextConfig = {}++export default nextConfig