vercel/next.js · #100158

Fix extensionless Google font URLs in both bundlers

andrewimm · merged Oct 15, 20269 files · 371 + / 16 −
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