diff --git a/packages/font/src/google/loader.ts b/packages/font/src/google/loader.ts index 6c07f0d3d1c88..6a55a6cfa407f 100644 --- a/packages/font/src/google/loader.ts +++ b/packages/font/src/google/loader.ts @@ -1,19 +1,19 @@ import type { AdjustFontFallback, FontLoader } from 'next/font' // @ts-ignore -import { calculateSizeAdjustValues } from 'next/dist/server/font-utils' -// @ts-ignore import * as Log from 'next/dist/build/output/log' import { fetchCSSFromGoogleFonts, fetchFontFile, + findFontFilesInCss, + getFallbackFontOverrideMetrics, getFontAxes, getUrl, validateData, } from './utils' import { nextFontError } from '../utils' -const cssCache = new Map>() -const fontCache = new Map() +const cssCache = new Map() +const fontCache = new Map() // regexp is based on https://github.com/sindresorhus/escape-string-regexp const reHasRegExp = /[|\\{}()[\]^$+*?.-]/ @@ -27,7 +27,7 @@ function escapeStringRegexp(str: string) { return str } -const downloadGoogleFonts: FontLoader = async ({ +const nextFontGoogleFontLoader: FontLoader = async ({ functionName, data, config, @@ -49,35 +49,20 @@ const downloadGoogleFonts: FontLoader = async ({ subsets, } = validateData(functionName, data, config) + // Validate and get the font axes required to generated the URL const fontAxes = getFontAxes( fontFamily, weights, styles, selectedVariableAxes ) + + // Generate the Google Fonts URL from the font family, axes and display value const url = getUrl(fontFamily, fontAxes, display) - // Find fallback font metrics - let adjustFontFallbackMetrics: AdjustFontFallback | undefined - if (adjustFontFallback) { - try { - const { ascent, descent, lineGap, fallbackFont, sizeAdjust } = - calculateSizeAdjustValues( - require('next/dist/server/google-font-metrics.json')[fontFamily] - ) - adjustFontFallbackMetrics = { - fallbackFont, - ascentOverride: `${ascent}%`, - descentOverride: `${descent}%`, - lineGapOverride: `${lineGap}%`, - sizeAdjust: `${sizeAdjust}%`, - } - } catch { - Log.error( - `Failed to find font override values for font \`${fontFamily}\`` - ) - } - } + // Get precalculated fallback font metrics, used to generate the fallback font CSS + const adjustFontFallbackMetrics: AdjustFontFallback | undefined = + adjustFontFallback ? getFallbackFontOverrideMetrics(fontFamily) : undefined const result = { fallbackFonts: fallback, @@ -91,62 +76,48 @@ const downloadGoogleFonts: FontLoader = async ({ } try { + /** + * Hacky way to make sure the fetch is only done once. + * Otherwise both the client and server compiler would fetch the CSS. + * The reason we need to return the actual CSS from both the server and client is because a hash is generated based on the CSS content. + */ const hasCachedCSS = cssCache.has(url) + // Fetch CSS from Google Fonts or get it from the cache let fontFaceDeclarations = hasCachedCSS ? cssCache.get(url) : await fetchCSSFromGoogleFonts(url, fontFamily).catch(() => null) if (!hasCachedCSS) { - cssCache.set(url, fontFaceDeclarations) + cssCache.set(url, fontFaceDeclarations ?? null) } else { cssCache.delete(url) } - if (fontFaceDeclarations === null) { + if (fontFaceDeclarations == null) { nextFontError(`Failed to fetch \`${fontFamily}\` from Google Fonts.`) } // CSS Variables may be set on a body tag, ignore them to keep the CSS module pure fontFaceDeclarations = fontFaceDeclarations.split('body {')[0] - // Find font files to download - const fontFiles: Array<{ - googleFontFileUrl: string - preloadFontFile: boolean - }> = [] - let currentSubset = '' - for (const line of fontFaceDeclarations.split('\n')) { - // Each @font-face has the subset above it in a comment - const newSubset = /\/\* (.+?) \*\//.exec(line)?.[1] - if (newSubset) { - currentSubset = newSubset - } else { - const googleFontFileUrl = /src: url\((.+?)\)/.exec(line)?.[1] - if ( - googleFontFileUrl && - !fontFiles.some( - (foundFile) => foundFile.googleFontFileUrl === googleFontFileUrl - ) - ) { - fontFiles.push({ - googleFontFileUrl, - preloadFontFile: !!preload && subsets.includes(currentSubset), - }) - } - } - } + // Find font files to download, provide the array of subsets we want to preload if preloading is enabled + const fontFiles = findFontFilesInCss( + fontFaceDeclarations, + preload ? subsets : undefined + ) - // Download font files + // Download the font files extracted from the CSS const downloadedFiles = await Promise.all( fontFiles.map(async ({ googleFontFileUrl, preloadFontFile }) => { const hasCachedFont = fontCache.has(googleFontFileUrl) + // Download the font file or get it from cache const fontFileBuffer = hasCachedFont ? fontCache.get(googleFontFileUrl) : await fetchFontFile(googleFontFileUrl).catch(() => null) if (!hasCachedFont) { - fontCache.set(googleFontFileUrl, fontFileBuffer) + fontCache.set(googleFontFileUrl, fontFileBuffer ?? null) } else { fontCache.delete(googleFontFileUrl) } - if (fontFileBuffer === null) { + if (fontFileBuffer == null) { nextFontError(`Failed to fetch \`${fontFamily}\` from Google Fonts.`) } @@ -166,7 +137,15 @@ const downloadGoogleFonts: FontLoader = async ({ }) ) - // Replace @font-face sources with self-hosted files + /** + * Replace the @font-face sources with the self-hosted files we just downloaded to .next/static/media + * + * E.g. + * @font-face { + * font-family: 'Inter'; + * src: url(https://fonts.gstatic.com/...) -> url(/_next/static/media/_.woff2) + * } + */ let updatedCssResponse = fontFaceDeclarations for (const { googleFontFileUrl, selfHostedFileUrl } of downloadedFiles) { updatedCssResponse = updatedCssResponse.replace( @@ -212,4 +191,4 @@ const downloadGoogleFonts: FontLoader = async ({ } } -export default downloadGoogleFonts +export default nextFontGoogleFontLoader diff --git a/packages/font/src/google/utils.ts b/packages/font/src/google/utils.ts index 82295e68daa59..90abb7d3dc4c7 100644 --- a/packages/font/src/google/utils.ts +++ b/packages/font/src/google/utils.ts @@ -1,6 +1,10 @@ import fs from 'fs' // @ts-ignore import fetch from 'next/dist/compiled/node-fetch' +// @ts-ignore +import { calculateSizeAdjustValues } from 'next/dist/server/font-utils' +// @ts-ignore +import * as Log from 'next/dist/build/output/log' import { nextFontError } from '../utils' import fontData from './font-data.json' const allowedDisplayValues = ['auto', 'block', 'swap', 'fallback', 'optional'] @@ -36,6 +40,7 @@ export function validateData( variable, subsets: callSubsets, } = data[0] || ({} as any) + // Get the subsets defined for the font from either next.config.js or the function call. If both are present, pick the function call subsets. const subsets = callSubsets ?? config?.subsets ?? [] if (functionName === '') { @@ -43,6 +48,7 @@ export function validateData( } const fontFamily = functionName.replace(/_/g, ' ') + // Get the Google font metadata, we'll use this to validate the font arguments and to print better error messages const fontFamilyData = (fontData as any)[fontFamily] if (!fontFamilyData) { nextFontError(`Unknown font \`${fontFamily}\``) @@ -50,7 +56,7 @@ export function validateData( const availableSubsets = fontFamilyData.subsets if (availableSubsets.length === 0) { - // If the font doesn't have any preloaded subsets, disable preload + // If the font doesn't have any preloadeable subsets, disable preload preload = false } else { if (preload && !callSubsets && !config?.subsets) { @@ -72,6 +78,7 @@ export function validateData( const fontWeights = fontFamilyData.weights const fontStyles = fontFamilyData.styles + // Get the unique weights and styles from the function call const weights = !weight ? [] : [...new Set(Array.isArray(weight) ? weight : [weight])] @@ -110,8 +117,10 @@ export function validateData( if (styles.length === 0) { if (fontStyles.length === 1) { + // Handle default style for fonts that only have italic styles.push(fontStyles[0]) } else { + // Otherwise set default style to normal styles.push('normal') } } @@ -152,6 +161,9 @@ export function validateData( } } +/** + * Generate the Google Fonts URL given the requested weight(s), style(s) and additional variable axes + */ export function getUrl( fontFamily: string, axes: { @@ -213,7 +225,18 @@ export function getUrl( return url } -export async function fetchCSSFromGoogleFonts(url: string, fontFamily: string) { +/** + * Fetches the CSS containing the @font-face declarations from Google Fonts. + * The fetch has a user agent header with a modern browser to ensure we'll get .woff2 files. + * + * The env variable NEXT_FONT_GOOGLE_MOCKED_RESPONSES may be set containing a path to mocked data. + * It's used to defined mocked data to avoid hitting the Google Fonts API during tests. + */ +export async function fetchCSSFromGoogleFonts( + url: string, + fontFamily: string +): Promise { + // Check if mocked responses are defined, if so use them instead of fetching from Google Fonts let mockedResponse: string | undefined if (process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES) { const mockFile = require(process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES) @@ -223,8 +246,9 @@ export async function fetchCSSFromGoogleFonts(url: string, fontFamily: string) { } } - let cssResponse + let cssResponse: string if (mockedResponse) { + // Just use the mocked CSS if it's set cssResponse = mockedResponse } else { const res = await fetch(url, { @@ -245,17 +269,27 @@ export async function fetchCSSFromGoogleFonts(url: string, fontFamily: string) { return cssResponse } +/** + * Fetch the url and return a buffer with the font file. + */ export async function fetchFontFile(url: string) { + // Check if we're using mocked data if (process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES) { + // If it's an absolute path, read the file from the filesystem if (url.startsWith('/')) { return fs.readFileSync(url) } + // Otherwise just return a unique buffer return Buffer.from(url) } + const arrayBuffer = await fetch(url).then((r: any) => r.arrayBuffer()) return Buffer.from(arrayBuffer) } +/** + * Validates and gets the data for each font axis required to generate the Google Fonts URL. + */ export function getFontAxes( fontFamily: string, weights: string[], @@ -266,19 +300,25 @@ export function getFontAxes( ital?: string[] variableAxes?: [string, string][] } { + // Get all the available axes for the current font from the metadata file const allAxes: Array<{ tag: string; min: number; max: number }> = ( fontData as any )[fontFamily].axes + const hasItalic = styles.includes('italic') const hasNormal = styles.includes('normal') + // Make sure the order is correct, otherwise Google Fonts will return an error + // If only normal is set, we can skip returning the ital axis as normal is the default const ital = hasItalic ? [...(hasNormal ? ['0'] : []), '1'] : undefined // Weights will always contain one element if it's a variable font if (weights[0] === 'variable') { if (selectedVariableAxes) { + // The axes other than weight and style that can be defined for the current variable font const defineAbleAxes: string[] = allAxes .map(({ tag }) => tag) .filter((tag) => tag !== 'wght') + if (defineAbleAxes.length === 0) { nextFontError(`Font \`${fontFamily}\` has no definable \`axes\``) } @@ -304,6 +344,7 @@ export function getFontAxes( let variableAxes: [string, string][] | undefined for (const { tag, min, max } of allAxes) { if (tag === 'wght') { + // In variable fonts the weight is a range weightAxis = `${min}..${max}` } else if (selectedVariableAxes?.includes(tag)) { if (!variableAxes) { @@ -325,3 +366,67 @@ export function getFontAxes( } } } + +/** + * Get precalculated fallback font metrics for the Google Fonts family. + * + * TODO: + * We might want to calculate these values with fontkit instead (like in next/font/local). + * That way we don't have to update the precalculated values every time a new font is added to Google Fonts. + */ +export function getFallbackFontOverrideMetrics(fontFamily: string) { + try { + const { ascent, descent, lineGap, fallbackFont, sizeAdjust } = + calculateSizeAdjustValues( + require('next/dist/server/google-font-metrics.json')[fontFamily] + ) + return { + fallbackFont, + ascentOverride: `${ascent}%`, + descentOverride: `${descent}%`, + lineGapOverride: `${lineGap}%`, + sizeAdjust: `${sizeAdjust}%`, + } + } catch { + Log.error(`Failed to find font override values for font \`${fontFamily}\``) + } +} + +/** + * 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. + * Walk through the CSS from top to bottom, keeping track of the current subset. + */ +export function findFontFilesInCss(css: string, subsetsToPreload?: string[]) { + // Find font files to download + const fontFiles: Array<{ + googleFontFileUrl: string + preloadFontFile: boolean + }> = [] + + // Keep track of the current subset + let currentSubset = '' + for (const line of css.split('\n')) { + const newSubset = /\/\* (.+?) \*\//.exec(line)?.[1] + if (newSubset) { + // Found new subset in a comment above the next @font-face declaration + currentSubset = newSubset + } else { + const googleFontFileUrl = /src: url\((.+?)\)/.exec(line)?.[1] + if ( + googleFontFileUrl && + !fontFiles.some( + (foundFile) => foundFile.googleFontFileUrl === googleFontFileUrl + ) + ) { + // Found the font file in the @font-face declaration. + fontFiles.push({ + googleFontFileUrl, + preloadFontFile: !!subsetsToPreload?.includes(currentSubset), + }) + } + } + } + + return fontFiles +} diff --git a/packages/font/src/utils.ts b/packages/font/src/utils.ts index 9e8ec3d20c552..3f63af3edf71b 100644 --- a/packages/font/src/utils.ts +++ b/packages/font/src/utils.ts @@ -1,6 +1,8 @@ import type { Font } from 'fontkit' import type { AdjustFontFallback } from 'next/font' +// The font metadata of the fallback fonts, retrieved with fontkit on system font files +// The average width is calculated with the calcAverageWidth function below const DEFAULT_SANS_SERIF_FONT = { name: 'Arial', azAvgWidth: 934.5116279069767, @@ -12,9 +14,25 @@ const DEFAULT_SERIF_FONT = { unitsPerEm: 2048, } +/** + * Calculate the average character width of a font file. + * Used to calculate the size-adjust property by comparing the fallback average with the loaded font average. + */ function calcAverageWidth(font: Font): number | undefined { try { + /** + * Finding the right characters to use when calculating the average width is tricky. + * We can't just use the average width of all characters, because we have to take letter frequency into account. + * We also have to take word length into account, because the font's space width usually differ a lot from other characters. + * The goal is to find a string that'll give you a good average width, given most texts in most languages. + * + * TODO: Currently only works for the latin alphabet. Support more languages by finding the right characters for additional languages. + * + * The used characters were decided through trial and error with letter frequency and word length tables as a guideline. + * E.g. https://en.wikipedia.org/wiki/Letter_frequency + */ const avgCharacters = 'aaabcdeeeefghiijklmnnoopqrrssttuvwxyz ' + // Check if the font file has all the characters we need to calculate the average width const hasAllChars = font .glyphsForString(avgCharacters) .flatMap((glyph) => glyph.codePoints) @@ -37,6 +55,20 @@ function formatOverrideValue(val: number) { return Math.abs(val * 100).toFixed(2) + '%' } +/** + * Given a font file and category, calculate the fallback font override values. + * The returned values can be used to generate a CSS @font-face declaration. + * + * For example: + * @font-face { + * font-family: local-font; + * src: local(Arial); + * size-adjust: 90%; + * } + * + * Read more about this technique in this document by the Google Aurora team: + * https://docs.google.com/document/d/e/2PACX-1vRsazeNirATC7lIj2aErSHpK26hZ6dA9GsQ069GEbq5fyzXEhXbvByoftSfhG82aJXmrQ_sJCPBqcx_/pub + */ export function calculateFallbackFontValues( font: Font, category = 'serif'