Skip to content

Commit d75cac4

Browse files
feat(fonts): generate fallbacks for all faces (#13635)
1 parent 6744842 commit d75cac4

8 files changed

Lines changed: 140 additions & 98 deletions

File tree

.changeset/major-beds-press.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
The experimental fonts API now generates optimized fallbacks for every weight and style

packages/astro/src/assets/fonts/load.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,18 @@ export async function loadFonts({
4848
for (const family of families) {
4949
const preloadData: PreloadData = [];
5050
let css = '';
51-
let fallbackFontData: GetMetricsForFamilyFont | null = null;
51+
const fallbacks = family.fallbacks ?? DEFAULTS.fallbacks;
52+
const fallbackFontData: Array<GetMetricsForFamilyFont> = [];
5253

5354
// When going through the urls/filepaths returned by providers,
5455
// We save the hash and the associated original value so we can use
5556
// it in the vite middleware during development
56-
const collect = (
57-
{ hash, type, value }: Parameters<ProxyURLOptions['collect']>[0],
57+
const collect: (
58+
parameters: Parameters<ProxyURLOptions['collect']>[0] & {
59+
data: Partial<unifont.FontFaceData>;
60+
},
5861
collectPreload: boolean,
59-
): ReturnType<ProxyURLOptions['collect']> => {
62+
) => ReturnType<ProxyURLOptions['collect']> = ({ hash, type, value, data }, collectPreload) => {
6063
const url = base + hash;
6164
if (!hashToUrlMap.has(hash)) {
6265
hashToUrlMap.set(hash, value);
@@ -65,13 +68,19 @@ export async function loadFonts({
6568
}
6669
}
6770
// If a family has fallbacks, we store the first url we get that may
68-
// be used for the fallback generation, if capsize doesn't have this
69-
// family in its built-in collection
70-
if (family.fallbacks && family.fallbacks.length > 0) {
71-
fallbackFontData ??= {
71+
// be used for the fallback generation
72+
if (
73+
fallbacks &&
74+
fallbacks.length > 0 &&
75+
// If the same data has already been sent for this family, we don't want to have duplicate fallbacks
76+
// Such scenario can occur with unicode ranges
77+
!fallbackFontData.some((f) => JSON.stringify(f.data) === JSON.stringify(data))
78+
) {
79+
fallbackFontData.push({
7280
hash,
7381
url: value,
74-
};
82+
data,
83+
});
7584
}
7685
return url;
7786
};
@@ -81,7 +90,7 @@ export async function loadFonts({
8190
if (family.provider === LOCAL_PROVIDER_NAME) {
8291
const result = resolveLocalFont({
8392
family,
84-
proxyURL: (value) => {
93+
proxyURL: ({ value, data }) => {
8594
return proxyURL({
8695
value,
8796
// We hash based on the filepath and the contents, since the user could replace
@@ -95,7 +104,7 @@ export async function loadFonts({
95104
}
96105
return hashString(v + content);
97106
},
98-
collect: (data) => collect(data, true),
107+
collect: (input) => collect({ ...input, data }, true),
99108
});
100109
},
101110
});
@@ -141,7 +150,17 @@ export async function loadFonts({
141150
hashString,
142151
// We only collect the first URL to avoid preloading fallback sources (eg. we only
143152
// preload woff2 if woff is available)
144-
collect: (data) => collect(data, index === 0),
153+
collect: (data) =>
154+
collect(
155+
{
156+
...data,
157+
data: {
158+
weight: font.weight,
159+
style: font.style,
160+
},
161+
},
162+
index === 0,
163+
),
145164
}),
146165
};
147166
index++;
@@ -179,7 +198,7 @@ export async function loadFonts({
179198
const fallbackData = await generateFallbacksCSS({
180199
family,
181200
font: fallbackFontData,
182-
fallbacks: family.fallbacks ?? DEFAULTS.fallbacks,
201+
fallbacks,
183202
metrics:
184203
(family.optimizedFallbacks ?? DEFAULTS.optimizedFallbacks)
185204
? {
@@ -192,7 +211,9 @@ export async function loadFonts({
192211
const cssVarValues = [family.nameWithHash];
193212

194213
if (fallbackData) {
195-
css += fallbackData.css;
214+
if (fallbackData.css) {
215+
css += fallbackData.css;
216+
}
196217
cssVarValues.push(...fallbackData.fallbacks);
197218
}
198219

packages/astro/src/assets/fonts/metrics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ export function generateFallbackFontFace({
4343
fallbackMetrics,
4444
name: fallbackName,
4545
font: fallbackFontName,
46-
properties = {},
46+
properties,
4747
}: {
4848
metrics: FontFaceMetrics;
4949
fallbackMetrics: FontFaceMetrics;
5050
name: string;
5151
font: string;
52-
properties?: Record<string, string | undefined>;
52+
properties: Record<string, string | undefined>;
5353
}) {
5454
// Credits to: https://github.com/seek-oss/capsize/blob/master/packages/core/src/createFontStack.ts
5555

packages/astro/src/assets/fonts/providers/local.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type ResolveFontResult = NonNullable<Awaited<ReturnType<InitializedProvider['res
1313

1414
interface Options {
1515
family: ResolvedLocalFontFamily;
16-
proxyURL: (value: string) => string;
16+
proxyURL: (params: { value: string; data: Partial<unifont.FontFaceData> }) => string;
1717
}
1818

1919
export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResult {
@@ -26,7 +26,13 @@ export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResu
2626
src: variant.src.map(({ url: originalURL, tech }) => {
2727
return {
2828
originalURL,
29-
url: proxyURL(originalURL),
29+
url: proxyURL({
30+
value: originalURL,
31+
data: {
32+
weight: variant.weight,
33+
style: variant.style,
34+
},
35+
}),
3036
format: FONT_FORMAT_MAP[extractFontType(originalURL)],
3137
tech,
3238
};

packages/astro/src/assets/fonts/utils.ts

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,25 @@ export function renderFontFace(properties: Record<string, string | undefined>) {
2626
return `@font-face {\n\t${toCSS(properties)}\n}\n`;
2727
}
2828

29-
export function generateFontFace(family: string, font: unifont.FontFaceData) {
30-
return renderFontFace({
31-
'font-family': family,
32-
src: renderFontSrc(font.src),
29+
export function unifontFontFaceDataToProperties(
30+
font: Partial<unifont.FontFaceData>,
31+
): Record<string, string | undefined> {
32+
return {
33+
src: font.src ? renderFontSrc(font.src) : undefined,
3334
'font-display': font.display ?? 'swap',
3435
'unicode-range': font.unicodeRange?.join(','),
3536
'font-weight': Array.isArray(font.weight) ? font.weight.join(' ') : font.weight?.toString(),
3637
'font-style': font.style,
3738
'font-stretch': font.stretch,
3839
'font-feature-settings': font.featureSettings,
3940
'font-variation-settings': font.variationSettings,
41+
};
42+
}
43+
44+
export function generateFontFace(family: string, font: unifont.FontFaceData) {
45+
return renderFontFace({
46+
'font-family': family,
47+
...unifontFontFaceDataToProperties(font),
4048
});
4149
}
4250

@@ -145,6 +153,7 @@ export function isGenericFontFamily(str: string): str is keyof typeof DEFAULT_FA
145153
export type GetMetricsForFamilyFont = {
146154
hash: string;
147155
url: string;
156+
data: Partial<unifont.FontFaceData>;
148157
};
149158

150159
export type GetMetricsForFamily = (
@@ -169,42 +178,44 @@ export async function generateFallbacksCSS({
169178
family: Pick<ResolvedFontFamily, 'name' | 'nameWithHash'>;
170179
/** The family fallbacks */
171180
fallbacks: Array<string>;
172-
font: GetMetricsForFamilyFont | null;
181+
font: Array<GetMetricsForFamilyFont>;
173182
metrics: {
174183
getMetricsForFamily: GetMetricsForFamily;
175184
generateFontFace: typeof generateFallbackFontFace;
176185
} | null;
177-
}): Promise<null | { css: string; fallbacks: Array<string> }> {
186+
}): Promise<null | { css?: string; fallbacks: Array<string> }> {
178187
// We avoid mutating the original array
179188
let fallbacks = [..._fallbacks];
180189
if (fallbacks.length === 0) {
181190
return null;
182191
}
183192

184-
let css = '';
185-
186-
if (!fontData || !metrics) {
187-
return { css, fallbacks };
193+
if (fontData.length === 0 || !metrics) {
194+
return { fallbacks };
188195
}
189196

190197
// The last element of the fallbacks is usually a generic family name (eg. serif)
191198
const lastFallback = fallbacks[fallbacks.length - 1];
192199
// If it's not a generic family name, we can't infer local fonts to be used as fallbacks
193200
if (!isGenericFontFamily(lastFallback)) {
194-
return { css, fallbacks };
201+
return { fallbacks };
195202
}
196203

197204
// If it's a generic family name, we get the associated local fonts (eg. Arial)
198205
const localFonts = DEFAULT_FALLBACKS[lastFallback];
199206
// Some generic families do not have associated local fonts so we abort early
200207
if (localFonts.length === 0) {
201-
return { css, fallbacks };
208+
return { fallbacks };
202209
}
203210

204-
const foundMetrics = await metrics.getMetricsForFamily(family.name, fontData);
205-
if (!foundMetrics) {
206-
// If there are no metrics, we can't generate useful fallbacks
207-
return { css, fallbacks };
211+
// If the family is already a system font, no need to generate fallbacks
212+
if (
213+
localFonts.includes(
214+
// @ts-expect-error TS is not smart enough
215+
family.name,
216+
)
217+
) {
218+
return { fallbacks };
208219
}
209220

210221
const localFontsMappings = localFonts.map((font) => ({
@@ -214,15 +225,18 @@ export async function generateFallbacksCSS({
214225

215226
// We prepend the fallbacks with the local fonts and we dedupe in case a local font is already provided
216227
fallbacks = [...new Set([...localFontsMappings.map((m) => m.name), ...fallbacks])];
228+
let css = '';
217229

218230
for (const { font, name } of localFontsMappings) {
219-
css += metrics.generateFontFace({
220-
metrics: foundMetrics,
221-
fallbackMetrics: SYSTEM_METRICS[font],
222-
font,
223-
name,
224-
// TODO: forward some properties once we generate one fallback per font face data
225-
});
231+
for (const { hash, url, data } of fontData) {
232+
css += metrics.generateFontFace({
233+
metrics: await metrics.getMetricsForFamily(family.name, { hash, url, data }),
234+
fallbackMetrics: SYSTEM_METRICS[font],
235+
font,
236+
name,
237+
properties: unifontFontFaceDataToProperties(data),
238+
});
239+
}
226240
}
227241

228242
return { css, fallbacks };

packages/astro/test/units/assets/fonts/load.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import assert from 'node:assert/strict';
21
// @ts-check
2+
import assert from 'node:assert/strict';
33
import { it } from 'node:test';
44
import { loadFonts } from '../../../../dist/assets/fonts/load.js';
55
import { fontProviders } from '../../../../dist/assets/fonts/providers/index.js';

packages/astro/test/units/assets/fonts/providers.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
// @ts-check
12
import assert from 'node:assert/strict';
23
import { basename, extname } from 'node:path';
3-
// @ts-check
44
import { describe, it } from 'node:test';
55
import * as adobeEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/adobe.js';
66
import * as bunnyEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/bunny.js';
@@ -23,7 +23,7 @@ function resolveLocalFontSpy(family) {
2323
family,
2424
proxyURL: (v) =>
2525
proxyURL({
26-
value: v,
26+
value: v.value,
2727
hashString: (value) => basename(value, extname(value)),
2828
collect: ({ hash, value }) => {
2929
values.push(value);

0 commit comments

Comments
 (0)