summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Florian Lefebvre <contact@florian-lefebvre.dev> 2025-04-18 14:09:44 +0200
committerGravatar GitHub <noreply@github.com> 2025-04-18 14:09:44 +0200
commitd75cac45de8790331aad134ae91bfeb1943cd458 (patch)
treeafedeb8994927d3746b57f29a0a416937eb2acd0
parent67448426fb4e2289ef8bc25d97bd617456b18b68 (diff)
downloadastro-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.md5
-rw-r--r--packages/astro/src/assets/fonts/load.ts49
-rw-r--r--packages/astro/src/assets/fonts/metrics.ts4
-rw-r--r--packages/astro/src/assets/fonts/providers/local.ts10
-rw-r--r--packages/astro/src/assets/fonts/utils.ts60
-rw-r--r--packages/astro/test/units/assets/fonts/load.test.js2
-rw-r--r--packages/astro/test/units/assets/fonts/providers.test.js4
-rw-r--r--packages/astro/test/units/assets/fonts/utils.test.js104
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'],
+ },
+ );
});
});