Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 25 additions & 10 deletions packages/astro/src/assets/fonts/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,17 @@ 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: ProxyURLOptions['collect'] = ({ hash, type, value }) => {
const collect: (
parameters: Parameters<ProxyURLOptions['collect']>[0] & {
data: Partial<unifont.FontFaceData>;
},
) => ReturnType<ProxyURLOptions['collect']> = ({ hash, type, value, data }) => {
const url = base + hash;
if (!hashToUrlMap.has(hash)) {
hashToUrlMap.set(hash, value);
Expand All @@ -62,11 +67,12 @@ 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) {
fallbackFontData.push({
hash,
url: value,
};
data,
});
}
return url;
};
Expand All @@ -76,7 +82,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 @@ -90,7 +96,7 @@ export async function loadFonts({
}
return hashString(v + content);
},
collect,
collect: (input) => collect({ ...input, data }),
});
},
});
Expand Down Expand Up @@ -133,7 +139,14 @@ export async function loadFonts({
value: source.url,
// We only use the url for hashing since the service returns urls with a hash already
hashString,
collect,
collect: (data) =>
collect({
...data,
data: {
weight: font.weight,
style: font.style,
},
}),
}),
},
),
Expand Down Expand Up @@ -168,7 +181,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 @@ -181,7 +194,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 @@ -12,7 +12,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 @@ -25,7 +25,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: 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