Skip to content

Commit 533748d

Browse files
authored
next/font refactoring and additional unit tests (#46731)
Currently all helper functions are exported from huge utils files. This moves the helper functions to their own files, this approach aligns better with the rest of the codebase. The unit tests are split up and colocated with the function it tests. Also added some missing tests. Plus some overall cleanup, added comments and fixed types. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md) ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md) ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
1 parent 003b3af commit 533748d

27 files changed

+1237
-1336
lines changed

errors/google-fonts-missing-subsets.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Missing specified subset for a `@next/font/google` font
1+
# Missing specified subset for a `next/font/google` font
22

33
#### Why This Error Occurred
44

@@ -21,7 +21,7 @@ const inter = Inter({ subsets: ['latin'] })
2121
module.exports = {
2222
experimental: {
2323
fontLoaders: [
24-
{ loader: '@next/font/google', options: { subsets: ['latin'] } },
24+
{ loader: 'next/font/google', options: { subsets: ['latin'] } },
2525
],
2626
},
2727
}

packages/font/src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const allowedDisplayValues = [
2+
'auto',
3+
'block',
4+
'swap',
5+
'fallback',
6+
'optional',
7+
]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Formats an array of values into a string that can be used error messages.
3+
* ["a", "b", "c"] => "`a`, `b`, `c`"
4+
*/
5+
export const formatAvailableValues = (values: string[]) =>
6+
values.map((val) => `\`${val}\``).join(', ')
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// @ts-ignore
2+
import fetch from 'next/dist/compiled/node-fetch'
3+
import { nextFontError } from '../next-font-error'
4+
5+
/**
6+
* Fetches the CSS containing the @font-face declarations from Google Fonts.
7+
* The fetch has a user agent header with a modern browser to ensure we'll get .woff2 files.
8+
*
9+
* The env variable NEXT_FONT_GOOGLE_MOCKED_RESPONSES may be set containing a path to mocked data.
10+
* It's used to defined mocked data to avoid hitting the Google Fonts API during tests.
11+
*/
12+
export async function fetchCSSFromGoogleFonts(
13+
url: string,
14+
fontFamily: string
15+
): Promise<string> {
16+
// Check if mocked responses are defined, if so use them instead of fetching from Google Fonts
17+
let mockedResponse: string | undefined
18+
if (process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES) {
19+
const mockFile = require(process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES)
20+
mockedResponse = mockFile[url]
21+
if (!mockedResponse) {
22+
nextFontError('Missing mocked response for URL: ' + url)
23+
}
24+
}
25+
26+
let cssResponse: string
27+
if (mockedResponse) {
28+
// Just use the mocked CSS if it's set
29+
cssResponse = mockedResponse
30+
} else {
31+
const res = await fetch(url, {
32+
headers: {
33+
// The file format is based off of the user agent, make sure woff2 files are fetched
34+
'user-agent':
35+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36',
36+
},
37+
})
38+
39+
if (!res.ok) {
40+
nextFontError(`Failed to fetch font \`${fontFamily}\`.\nURL: ${url}`)
41+
}
42+
43+
cssResponse = await res.text()
44+
}
45+
46+
return cssResponse
47+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// @ts-ignore
2+
import fetch from 'next/dist/compiled/node-fetch'
3+
4+
/**
5+
* Fetch the url and return a buffer with the font file.
6+
*/
7+
export async function fetchFontFile(url: string) {
8+
// Check if we're using mocked data
9+
if (process.env.NEXT_FONT_GOOGLE_MOCKED_RESPONSES) {
10+
// If it's an absolute path, read the file from the filesystem
11+
if (url.startsWith('/')) {
12+
return require('fs').readFileSync(url)
13+
}
14+
// Otherwise just return a unique buffer
15+
return Buffer.from(url)
16+
}
17+
18+
const arrayBuffer = await fetch(url).then((r: any) => r.arrayBuffer())
19+
return Buffer.from(arrayBuffer)
20+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { findFontFilesInCss } from './find-font-files-in-css'
2+
3+
describe('findFontFilesInCss', () => {
4+
it('should find all font files and preload requested subsets', () => {
5+
const css = `/* latin */
6+
@font-face {
7+
font-family: 'Fraunces';
8+
font-style: normal;
9+
font-weight: 300;
10+
src: url(latin1.woff2) format('woff2');
11+
}
12+
13+
/* greek */
14+
@font-face {
15+
font-family: 'Fraunces';
16+
font-style: normal;
17+
font-weight: 300;
18+
src: url(greek1.woff2) format('woff2');
19+
}
20+
21+
/* latin */
22+
@font-face {
23+
font-family: 'Fraunces';
24+
font-style: normal;
25+
font-weight: 400;
26+
src: url(latin2.woff2) format('woff2');
27+
}
28+
29+
/* greek */
30+
@font-face {
31+
font-family: 'Fraunces';
32+
font-style: normal;
33+
font-weight: 400;
34+
src: url(greek2.woff2) format('woff2');
35+
}
36+
37+
/* cyrilic */
38+
@font-face {
39+
font-family: 'Fraunces';
40+
font-style: normal;
41+
font-weight: 400;
42+
src: url(cyrilic.woff2) format('woff2');
43+
}
44+
`
45+
46+
expect(findFontFilesInCss(css, ['latin', 'cyrilic'])).toEqual([
47+
{ googleFontFileUrl: 'latin1.woff2', preloadFontFile: true },
48+
{ googleFontFileUrl: 'greek1.woff2', preloadFontFile: false },
49+
{ googleFontFileUrl: 'latin2.woff2', preloadFontFile: true },
50+
{ googleFontFileUrl: 'greek2.woff2', preloadFontFile: false },
51+
{ googleFontFileUrl: 'cyrilic.woff2', preloadFontFile: true },
52+
])
53+
})
54+
55+
it('should not return duplicate font files when several variants use the same font file', () => {
56+
const css = `/* latin */
57+
@font-face {
58+
font-family: 'Fraunces';
59+
font-style: normal;
60+
font-weight: 100;
61+
font-display: swap;
62+
src: url(https://fonts.gstatic.com/s/fraunces/v24/6NUu8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib14c7qv8oRcTn.woff2) format('woff2');
63+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
64+
}
65+
/* latin */
66+
@font-face {
67+
font-family: 'Fraunces';
68+
font-style: normal;
69+
font-weight: 300;
70+
font-display: swap;
71+
src: url(https://fonts.gstatic.com/s/fraunces/v24/6NUu8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib14c7qv8oRcTn.woff2) format('woff2');
72+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
73+
}
74+
/* latin */
75+
@font-face {
76+
font-family: 'Fraunces';
77+
font-style: normal;
78+
font-weight: 900;
79+
font-display: swap;
80+
src: url(https://fonts.gstatic.com/s/fraunces/v24/6NUu8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib14c7qv8oRcTn.woff2) format('woff2');
81+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
82+
}
83+
`
84+
85+
expect(findFontFilesInCss(css)).toEqual([
86+
{
87+
googleFontFileUrl:
88+
'https://fonts.gstatic.com/s/fraunces/v24/6NUu8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib14c7qv8oRcTn.woff2',
89+
preloadFontFile: false,
90+
},
91+
])
92+
})
93+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Find all font files in the CSS response and determine which files should be preloaded.
3+
* In Google Fonts responses, the @font-face's subset is above it in a comment.
4+
* Walk through the CSS from top to bottom, keeping track of the current subset.
5+
*/
6+
export function findFontFilesInCss(css: string, subsetsToPreload?: string[]) {
7+
// Find font files to download
8+
const fontFiles: Array<{
9+
googleFontFileUrl: string
10+
preloadFontFile: boolean
11+
}> = []
12+
13+
// Keep track of the current subset
14+
let currentSubset = ''
15+
for (const line of css.split('\n')) {
16+
const newSubset = /\/\* (.+?) \*\//.exec(line)?.[1]
17+
if (newSubset) {
18+
// Found new subset in a comment above the next @font-face declaration
19+
currentSubset = newSubset
20+
} else {
21+
const googleFontFileUrl = /src: url\((.+?)\)/.exec(line)?.[1]
22+
if (
23+
googleFontFileUrl &&
24+
!fontFiles.some(
25+
(foundFile) => foundFile.googleFontFileUrl === googleFontFileUrl
26+
)
27+
) {
28+
// Found the font file in the @font-face declaration.
29+
fontFiles.push({
30+
googleFontFileUrl,
31+
preloadFontFile: !!subsetsToPreload?.includes(currentSubset),
32+
})
33+
}
34+
}
35+
}
36+
37+
return fontFiles
38+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// @ts-ignore
2+
import { calculateSizeAdjustValues } from 'next/dist/server/font-utils'
3+
// @ts-ignore
4+
import * as Log from 'next/dist/build/output/log'
5+
6+
/**
7+
* Get precalculated fallback font metrics for the Google Fonts family.
8+
*
9+
* TODO:
10+
* We might want to calculate these values with fontkit instead (like in next/font/local).
11+
* That way we don't have to update the precalculated values every time a new font is added to Google Fonts.
12+
*/
13+
export function getFallbackFontOverrideMetrics(fontFamily: string) {
14+
try {
15+
const { ascent, descent, lineGap, fallbackFont, sizeAdjust } =
16+
calculateSizeAdjustValues(
17+
require('next/dist/server/google-font-metrics.json')[fontFamily]
18+
)
19+
return {
20+
fallbackFont,
21+
ascentOverride: `${ascent}%`,
22+
descentOverride: `${descent}%`,
23+
lineGapOverride: `${lineGap}%`,
24+
sizeAdjust: `${sizeAdjust}%`,
25+
}
26+
} catch {
27+
Log.error(`Failed to find font override values for font \`${fontFamily}\``)
28+
}
29+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { getFontAxes } from './get-font-axes'
2+
3+
describe('getFontAxes errors', () => {
4+
test('Setting axes on font without definable axes', () => {
5+
expect(() =>
6+
getFontAxes('Lora', ['variable'], [], [])
7+
).toThrowErrorMatchingInlineSnapshot(
8+
`"Font \`Lora\` has no definable \`axes\`"`
9+
)
10+
})
11+
12+
test('Invalid axes value', async () => {
13+
expect(() => getFontAxes('Inter', ['variable'], [], true as any))
14+
.toThrowErrorMatchingInlineSnapshot(`
15+
"Invalid axes value for font \`Inter\`, expected an array of axes.
16+
Available axes: \`slnt\`"
17+
`)
18+
})
19+
20+
test('Invalid value in axes array', async () => {
21+
expect(() => getFontAxes('Roboto Flex', ['variable'], [], ['INVALID']))
22+
.toThrowErrorMatchingInlineSnapshot(`
23+
"Invalid axes value \`INVALID\` for font \`Roboto Flex\`.
24+
Available axes: \`GRAD\`, \`XTRA\`, \`YOPQ\`, \`YTAS\`, \`YTDE\`, \`YTFI\`, \`YTLC\`, \`YTUC\`, \`opsz\`, \`slnt\`, \`wdth\`"
25+
`)
26+
})
27+
})
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { formatAvailableValues } from '../format-available-values'
2+
import { nextFontError } from '../next-font-error'
3+
import { googleFontsMetadata } from './google-fonts-metadata'
4+
5+
/**
6+
* Validates and gets the data for each font axis required to generate the Google Fonts URL.
7+
*/
8+
export function getFontAxes(
9+
fontFamily: string,
10+
weights: string[],
11+
styles: string[],
12+
selectedVariableAxes?: string[]
13+
): {
14+
wght?: string[]
15+
ital?: string[]
16+
variableAxes?: [string, string][]
17+
} {
18+
const hasItalic = styles.includes('italic')
19+
const hasNormal = styles.includes('normal')
20+
// Make sure the order is correct, otherwise Google Fonts will return an error
21+
// If only normal is set, we can skip returning the ital axis as normal is the default
22+
const ital = hasItalic ? [...(hasNormal ? ['0'] : []), '1'] : undefined
23+
24+
// Weights will always contain one element if it's a variable font
25+
if (weights[0] === 'variable') {
26+
// Get all the available axes for the current font from the metadata file
27+
const allAxes = googleFontsMetadata[fontFamily].axes
28+
if (!allAxes) {
29+
throw new Error('invariant variable font without axes')
30+
}
31+
32+
if (selectedVariableAxes) {
33+
// The axes other than weight and style that can be defined for the current variable font
34+
const defineAbleAxes: string[] = allAxes
35+
.map(({ tag }) => tag)
36+
.filter((tag) => tag !== 'wght')
37+
38+
if (defineAbleAxes.length === 0) {
39+
nextFontError(`Font \`${fontFamily}\` has no definable \`axes\``)
40+
}
41+
if (!Array.isArray(selectedVariableAxes)) {
42+
nextFontError(
43+
`Invalid axes value for font \`${fontFamily}\`, expected an array of axes.\nAvailable axes: ${formatAvailableValues(
44+
defineAbleAxes
45+
)}`
46+
)
47+
}
48+
selectedVariableAxes.forEach((key) => {
49+
if (!defineAbleAxes.some((tag) => tag === key)) {
50+
nextFontError(
51+
`Invalid axes value \`${key}\` for font \`${fontFamily}\`.\nAvailable axes: ${formatAvailableValues(
52+
defineAbleAxes
53+
)}`
54+
)
55+
}
56+
})
57+
}
58+
59+
let weightAxis: string | undefined
60+
let variableAxes: [string, string][] | undefined
61+
for (const { tag, min, max } of allAxes) {
62+
if (tag === 'wght') {
63+
// In variable fonts the weight is a range
64+
weightAxis = `${min}..${max}`
65+
} else if (selectedVariableAxes?.includes(tag)) {
66+
if (!variableAxes) {
67+
variableAxes = []
68+
}
69+
variableAxes.push([tag, `${min}..${max}`])
70+
}
71+
}
72+
73+
return {
74+
wght: weightAxis ? [weightAxis] : undefined,
75+
ital,
76+
variableAxes,
77+
}
78+
} else {
79+
return {
80+
ital,
81+
wght: weights,
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)