diff options
author | 2025-04-18 14:09:44 +0200 | |
---|---|---|
committer | 2025-04-18 14:09:44 +0200 | |
commit | d75cac45de8790331aad134ae91bfeb1943cd458 (patch) | |
tree | afedeb8994927d3746b57f29a0a416937eb2acd0 | |
parent | 67448426fb4e2289ef8bc25d97bd617456b18b68 (diff) | |
download | astro-d75cac45de8790331aad134ae91bfeb1943cd458.tar.gz astro-d75cac45de8790331aad134ae91bfeb1943cd458.tar.zst astro-d75cac45de8790331aad134ae91bfeb1943cd458.zip |
feat(fonts): generate fallbacks for all faces (#13635)
-rw-r--r-- | .changeset/major-beds-press.md | 5 | ||||
-rw-r--r-- | packages/astro/src/assets/fonts/load.ts | 49 | ||||
-rw-r--r-- | packages/astro/src/assets/fonts/metrics.ts | 4 | ||||
-rw-r--r-- | packages/astro/src/assets/fonts/providers/local.ts | 10 | ||||
-rw-r--r-- | packages/astro/src/assets/fonts/utils.ts | 60 | ||||
-rw-r--r-- | packages/astro/test/units/assets/fonts/load.test.js | 2 | ||||
-rw-r--r-- | packages/astro/test/units/assets/fonts/providers.test.js | 4 | ||||
-rw-r--r-- | packages/astro/test/units/assets/fonts/utils.test.js | 104 |
8 files changed, 140 insertions, 98 deletions
diff --git a/.changeset/major-beds-press.md b/.changeset/major-beds-press.md new file mode 100644 index 000000000..13dda9899 --- /dev/null +++ b/.changeset/major-beds-press.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +The experimental fonts API now generates optimized fallbacks for every weight and style diff --git a/packages/astro/src/assets/fonts/load.ts b/packages/astro/src/assets/fonts/load.ts index 2fe6059d7..20222f439 100644 --- a/packages/astro/src/assets/fonts/load.ts +++ b/packages/astro/src/assets/fonts/load.ts @@ -48,15 +48,18 @@ export async function loadFonts({ for (const family of families) { const preloadData: PreloadData = []; let css = ''; - let fallbackFontData: GetMetricsForFamilyFont | null = null; + const fallbacks = family.fallbacks ?? DEFAULTS.fallbacks; + const fallbackFontData: Array<GetMetricsForFamilyFont> = []; // When going through the urls/filepaths returned by providers, // We save the hash and the associated original value so we can use // it in the vite middleware during development - const collect = ( - { hash, type, value }: Parameters<ProxyURLOptions['collect']>[0], + const collect: ( + parameters: Parameters<ProxyURLOptions['collect']>[0] & { + data: Partial<unifont.FontFaceData>; + }, collectPreload: boolean, - ): ReturnType<ProxyURLOptions['collect']> => { + ) => ReturnType<ProxyURLOptions['collect']> = ({ hash, type, value, data }, collectPreload) => { const url = base + hash; if (!hashToUrlMap.has(hash)) { hashToUrlMap.set(hash, value); @@ -65,13 +68,19 @@ export async function loadFonts({ } } // If a family has fallbacks, we store the first url we get that may - // be used for the fallback generation, if capsize doesn't have this - // family in its built-in collection - if (family.fallbacks && family.fallbacks.length > 0) { - fallbackFontData ??= { + // be used for the fallback generation + if ( + fallbacks && + fallbacks.length > 0 && + // If the same data has already been sent for this family, we don't want to have duplicate fallbacks + // Such scenario can occur with unicode ranges + !fallbackFontData.some((f) => JSON.stringify(f.data) === JSON.stringify(data)) + ) { + fallbackFontData.push({ hash, url: value, - }; + data, + }); } return url; }; @@ -81,7 +90,7 @@ export async function loadFonts({ if (family.provider === LOCAL_PROVIDER_NAME) { const result = resolveLocalFont({ family, - proxyURL: (value) => { + proxyURL: ({ value, data }) => { return proxyURL({ value, // We hash based on the filepath and the contents, since the user could replace @@ -95,7 +104,7 @@ export async function loadFonts({ } return hashString(v + content); }, - collect: (data) => collect(data, true), + collect: (input) => collect({ ...input, data }, true), }); }, }); @@ -141,7 +150,17 @@ export async function loadFonts({ hashString, // We only collect the first URL to avoid preloading fallback sources (eg. we only // preload woff2 if woff is available) - collect: (data) => collect(data, index === 0), + collect: (data) => + collect( + { + ...data, + data: { + weight: font.weight, + style: font.style, + }, + }, + index === 0, + ), }), }; index++; @@ -179,7 +198,7 @@ export async function loadFonts({ const fallbackData = await generateFallbacksCSS({ family, font: fallbackFontData, - fallbacks: family.fallbacks ?? DEFAULTS.fallbacks, + fallbacks, metrics: (family.optimizedFallbacks ?? DEFAULTS.optimizedFallbacks) ? { @@ -192,7 +211,9 @@ export async function loadFonts({ const cssVarValues = [family.nameWithHash]; if (fallbackData) { - css += fallbackData.css; + if (fallbackData.css) { + css += fallbackData.css; + } cssVarValues.push(...fallbackData.fallbacks); } diff --git a/packages/astro/src/assets/fonts/metrics.ts b/packages/astro/src/assets/fonts/metrics.ts index 0ab6f3fa4..1dfa2b618 100644 --- a/packages/astro/src/assets/fonts/metrics.ts +++ b/packages/astro/src/assets/fonts/metrics.ts @@ -43,13 +43,13 @@ export function generateFallbackFontFace({ fallbackMetrics, name: fallbackName, font: fallbackFontName, - properties = {}, + properties, }: { metrics: FontFaceMetrics; fallbackMetrics: FontFaceMetrics; name: string; font: string; - properties?: Record<string, string | undefined>; + properties: Record<string, string | undefined>; }) { // Credits to: https://github.com/seek-oss/capsize/blob/master/packages/core/src/createFontStack.ts diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 29c1f3935..72beb397d 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -13,7 +13,7 @@ type ResolveFontResult = NonNullable<Awaited<ReturnType<InitializedProvider['res interface Options { family: ResolvedLocalFontFamily; - proxyURL: (value: string) => string; + proxyURL: (params: { value: string; data: Partial<unifont.FontFaceData> }) => string; } export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResult { @@ -26,7 +26,13 @@ export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResu src: variant.src.map(({ url: originalURL, tech }) => { return { originalURL, - url: proxyURL(originalURL), + url: proxyURL({ + value: originalURL, + data: { + weight: variant.weight, + style: variant.style, + }, + }), format: FONT_FORMAT_MAP[extractFontType(originalURL)], tech, }; diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index a43af9f0f..e715580bc 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -26,10 +26,11 @@ export function renderFontFace(properties: Record<string, string | undefined>) { return `@font-face {\n\t${toCSS(properties)}\n}\n`; } -export function generateFontFace(family: string, font: unifont.FontFaceData) { - return renderFontFace({ - 'font-family': family, - src: renderFontSrc(font.src), +export function unifontFontFaceDataToProperties( + font: Partial<unifont.FontFaceData>, +): Record<string, string | undefined> { + return { + src: font.src ? renderFontSrc(font.src) : undefined, 'font-display': font.display ?? 'swap', 'unicode-range': font.unicodeRange?.join(','), 'font-weight': Array.isArray(font.weight) ? font.weight.join(' ') : font.weight?.toString(), @@ -37,6 +38,13 @@ export function generateFontFace(family: string, font: unifont.FontFaceData) { 'font-stretch': font.stretch, 'font-feature-settings': font.featureSettings, 'font-variation-settings': font.variationSettings, + }; +} + +export function generateFontFace(family: string, font: unifont.FontFaceData) { + return renderFontFace({ + 'font-family': family, + ...unifontFontFaceDataToProperties(font), }); } @@ -145,6 +153,7 @@ export function isGenericFontFamily(str: string): str is keyof typeof DEFAULT_FA export type GetMetricsForFamilyFont = { hash: string; url: string; + data: Partial<unifont.FontFaceData>; }; export type GetMetricsForFamily = ( @@ -169,42 +178,44 @@ export async function generateFallbacksCSS({ family: Pick<ResolvedFontFamily, 'name' | 'nameWithHash'>; /** The family fallbacks */ fallbacks: Array<string>; - font: GetMetricsForFamilyFont | null; + font: Array<GetMetricsForFamilyFont>; metrics: { getMetricsForFamily: GetMetricsForFamily; generateFontFace: typeof generateFallbackFontFace; } | null; -}): Promise<null | { css: string; fallbacks: Array<string> }> { +}): Promise<null | { css?: string; fallbacks: Array<string> }> { // We avoid mutating the original array let fallbacks = [..._fallbacks]; if (fallbacks.length === 0) { return null; } - let css = ''; - - if (!fontData || !metrics) { - return { css, fallbacks }; + if (fontData.length === 0 || !metrics) { + return { fallbacks }; } // The last element of the fallbacks is usually a generic family name (eg. serif) const lastFallback = fallbacks[fallbacks.length - 1]; // If it's not a generic family name, we can't infer local fonts to be used as fallbacks if (!isGenericFontFamily(lastFallback)) { - return { css, fallbacks }; + return { fallbacks }; } // If it's a generic family name, we get the associated local fonts (eg. Arial) const localFonts = DEFAULT_FALLBACKS[lastFallback]; // Some generic families do not have associated local fonts so we abort early if (localFonts.length === 0) { - return { css, fallbacks }; + return { fallbacks }; } - const foundMetrics = await metrics.getMetricsForFamily(family.name, fontData); - if (!foundMetrics) { - // If there are no metrics, we can't generate useful fallbacks - return { css, fallbacks }; + // If the family is already a system font, no need to generate fallbacks + if ( + localFonts.includes( + // @ts-expect-error TS is not smart enough + family.name, + ) + ) { + return { fallbacks }; } const localFontsMappings = localFonts.map((font) => ({ @@ -214,15 +225,18 @@ export async function generateFallbacksCSS({ // We prepend the fallbacks with the local fonts and we dedupe in case a local font is already provided fallbacks = [...new Set([...localFontsMappings.map((m) => m.name), ...fallbacks])]; + let css = ''; for (const { font, name } of localFontsMappings) { - css += metrics.generateFontFace({ - metrics: foundMetrics, - fallbackMetrics: SYSTEM_METRICS[font], - font, - name, - // TODO: forward some properties once we generate one fallback per font face data - }); + for (const { hash, url, data } of fontData) { + css += metrics.generateFontFace({ + metrics: await metrics.getMetricsForFamily(family.name, { hash, url, data }), + fallbackMetrics: SYSTEM_METRICS[font], + font, + name, + properties: unifontFontFaceDataToProperties(data), + }); + } } return { css, fallbacks }; diff --git a/packages/astro/test/units/assets/fonts/load.test.js b/packages/astro/test/units/assets/fonts/load.test.js index 857d1b732..18ae02bce 100644 --- a/packages/astro/test/units/assets/fonts/load.test.js +++ b/packages/astro/test/units/assets/fonts/load.test.js @@ -1,5 +1,5 @@ -import assert from 'node:assert/strict'; // @ts-check +import assert from 'node:assert/strict'; import { it } from 'node:test'; import { loadFonts } from '../../../../dist/assets/fonts/load.js'; import { fontProviders } from '../../../../dist/assets/fonts/providers/index.js'; diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 7ae267125..9478d956b 100644 --- a/packages/astro/test/units/assets/fonts/providers.test.js +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -1,6 +1,6 @@ +// @ts-check import assert from 'node:assert/strict'; import { basename, extname } from 'node:path'; -// @ts-check import { describe, it } from 'node:test'; import * as adobeEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/adobe.js'; import * as bunnyEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/bunny.js'; @@ -23,7 +23,7 @@ function resolveLocalFontSpy(family) { family, proxyURL: (v) => proxyURL({ - value: v, + value: v.value, hashString: (value) => basename(value, extname(value)), collect: ({ hash, value }) => { values.push(value); diff --git a/packages/astro/test/units/assets/fonts/utils.test.js b/packages/astro/test/units/assets/fonts/utils.test.js index ae64b802b..7e2eccdba 100644 --- a/packages/astro/test/units/assets/fonts/utils.test.js +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -1,5 +1,5 @@ -import assert from 'node:assert/strict'; // @ts-check +import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; import { fontProviders } from '../../../../dist/assets/fonts/providers/index.js'; @@ -121,14 +121,21 @@ describe('fonts utils', () => { }); describe('generateFallbacksCSS()', () => { + const METRICS_STUB = { + ascent: 0, + descent: 0, + lineGap: 0, + unitsPerEm: 0, + xWidthAvg: 0, + }; it('should return null if there are no fallbacks', async () => { assert.equal( await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: [], - font: null, + font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], metrics: { - getMetricsForFamily: async () => null, + getMetricsForFamily: async () => METRICS_STUB, generateFontFace: () => '', }, }), @@ -141,78 +148,62 @@ describe('fonts utils', () => { await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: ['foo'], - font: null, + font: [], metrics: null, }), { - css: '', fallbacks: ['foo'], }, ); }); - it('should return fallbacks if there are no metrics', async () => { + it('should return fallbacks if there are metrics but no generic font family', async () => { assert.deepStrictEqual( await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: ['foo'], - font: null, + font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], metrics: { - getMetricsForFamily: async () => null, + getMetricsForFamily: async () => METRICS_STUB, generateFontFace: () => '', }, }), { - css: '', fallbacks: ['foo'], }, ); }); - it('should return fallbacks if there are metrics but no generic font family', async () => { + it('should return fallbacks if the generic font family does not have fonts associated', async () => { assert.deepStrictEqual( await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['foo'], - font: null, + fallbacks: ['emoji'], + font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], metrics: { - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), + getMetricsForFamily: async () => METRICS_STUB, generateFontFace: () => '', }, }), { - css: '', - fallbacks: ['foo'], + fallbacks: ['emoji'], }, ); }); - it('shold return fallbacks if the generic font family does not have fonts associated', async () => { + it('should return fallbacks if the family name is a system font for the associated generic family name', async () => { assert.deepStrictEqual( await generateFallbacksCSS({ - family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['emoji'], - font: null, + family: { name: 'Arial', nameWithHash: 'Arial-xxx' }, + fallbacks: ['sans-serif'], + font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], metrics: { - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), + getMetricsForFamily: async () => METRICS_STUB, generateFontFace: () => '', }, }), { - css: '', - fallbacks: ['emoji'], + fallbacks: ['sans-serif'], }, ); }); @@ -222,20 +213,13 @@ describe('fonts utils', () => { await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: ['foo', 'bar'], - font: null, + font: [], metrics: { - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), - generateFontFace: (_metrics, fallback) => `[${fallback.font},${fallback.name}]`, + getMetricsForFamily: async () => METRICS_STUB, + generateFontFace: ({ font, name }) => `[${font},${name}]`, }, }), { - css: '', fallbacks: ['foo', 'bar'], }, ); @@ -243,23 +227,35 @@ describe('fonts utils', () => { await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: ['sans-serif', 'foo'], - font: null, + font: [], metrics: { - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), - generateFontFace: (_metrics, fallback) => `[${fallback.font},${fallback.name}]`, + getMetricsForFamily: async () => METRICS_STUB, + generateFontFace: ({ font, name }) => `[${font},${name}]`, }, }), { - css: '', fallbacks: ['sans-serif', 'foo'], }, ); + assert.deepStrictEqual( + await generateFallbacksCSS({ + family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, + fallbacks: ['foo', 'sans-serif'], + font: [ + { url: '/', hash: 'hash', data: { weight: '400' } }, + { url: '/', hash: 'hash', data: { weight: '500' } }, + ], + metrics: { + getMetricsForFamily: async () => METRICS_STUB, + generateFontFace: ({ font, name, properties }) => + `[${font},${name},${properties['font-weight']}]`, + }, + }), + { + css: '[Arial,"Roboto-xxx fallback: Arial",400][Arial,"Roboto-xxx fallback: Arial",500]', + fallbacks: ['"Roboto-xxx fallback: Arial"', 'foo', 'sans-serif'], + }, + ); }); }); |