Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/major-beds-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

The experimental fonts API now generates optimized fallbacks for every weight and style
46 changes: 34 additions & 12 deletions packages/astro/src/assets/fonts/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -67,11 +70,18 @@ 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 ??= {
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;
};
Expand All @@ -81,7 +91,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
Expand All @@ -95,7 +105,7 @@ export async function loadFonts({
}
return hashString(v + content);
},
collect: (data) => collect(data, true),
collect: (input) => collect({ ...input, data }, true),
});
},
});
Expand Down Expand Up @@ -141,7 +151,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++;
Expand Down Expand Up @@ -179,7 +199,7 @@ export async function loadFonts({
const fallbackData = await generateFallbacksCSS({
family,
font: fallbackFontData,
fallbacks: family.fallbacks ?? DEFAULTS.fallbacks,
fallbacks,
metrics:
(family.optimizedFallbacks ?? DEFAULTS.optimizedFallbacks)
? {
Expand All @@ -192,7 +212,9 @@ export async function loadFonts({
const cssVarValues = [family.nameWithHash];

if (fallbackData) {
css += fallbackData.css;
if (fallbackData.css) {
css += fallbackData.css;
}
cssVarValues.push(...fallbackData.fallbacks);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/assets/fonts/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions packages/astro/src/assets/fonts/providers/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
};
Expand Down
60 changes: 37 additions & 23 deletions packages/astro/src/assets/fonts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,25 @@ 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(),
'font-style': font.style,
'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),
});
}

Expand Down Expand Up @@ -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 = (
Expand All @@ -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) => ({
Expand All @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/test/units/assets/fonts/load.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/test/units/assets/fonts/providers.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down
Loading
Loading