Skip to content

Commit 35122c2

Browse files
feat(fonts): merge families (#14750)
1 parent 72381ef commit 35122c2

File tree

6 files changed

+541
-19
lines changed

6 files changed

+541
-19
lines changed

.changeset/stale-dancers-call.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+
Updates the experimental Fonts API to log a warning if families with a conflicting `cssVariable` are provided

.changeset/tired-houses-sink.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Updates the experimental Fonts API to allow merging font families
6+
7+
Before, it was not possible to only download font files for weights normal `500`, italic `500` and normal `600`. This is because a font family is defined by a combination of weights and styles, so you'd necessarily have to also download italic `600`.
8+
9+
Now, families that have the same `cssVariable`, `name` and `provider` will be merged. That means you could achieve the desired result like this:
10+
11+
```ts
12+
// astro.config.mjs
13+
import { defineConfig, fontProviders } from "astro/config"
14+
15+
export default defineConfig({
16+
experimental: {
17+
fonts: [
18+
{
19+
name: "Roboto",
20+
cssVariable: "--roboto",
21+
provider: fontProviders.google(),
22+
weights: [500, 600],
23+
styles: ["normal"]
24+
},
25+
{
26+
name: "Roboto",
27+
cssVariable: "--roboto",
28+
provider: fontProviders.google(),
29+
weights: [500],
30+
styles: ["italic"]
31+
}
32+
]
33+
}
34+
})
35+
```
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type * as unifont from 'unifont';
2+
import { sortObjectByKey } from '../utils.js';
3+
4+
function computeIdFromSource(source: unifont.LocalFontSource | unifont.RemoteFontSource): string {
5+
return 'name' in source ? source.name : source.url;
6+
}
7+
8+
export function dedupeFontFaces(
9+
current: Array<unifont.FontFaceData>,
10+
incoming: Array<unifont.FontFaceData>,
11+
): Array<unifont.FontFaceData> {
12+
const result: Array<unifont.FontFaceData> = [...current];
13+
for (const font of incoming) {
14+
const existing = result.find(({ src: _src, ...rest }) => {
15+
const a = JSON.stringify(sortObjectByKey(rest));
16+
const { src: __src, ..._rest } = font;
17+
const b = JSON.stringify(sortObjectByKey(_rest));
18+
return a === b;
19+
});
20+
21+
if (!existing) {
22+
result.push(font);
23+
continue;
24+
}
25+
26+
const ids = new Set(existing.src.map((source) => computeIdFromSource(source)));
27+
28+
existing.src.push(
29+
...font.src.filter((source) => {
30+
const id = computeIdFromSource(source);
31+
return !ids.has(id) && ids.add(id);
32+
}),
33+
);
34+
}
35+
return result;
36+
}

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

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
SystemFallbacksProvider,
1515
UrlProxy,
1616
} from './definitions.js';
17+
import { dedupeFontFaces } from './logic/dedupe-font-faces.js';
1718
import { extractUnifontProviders } from './logic/extract-unifont-providers.js';
1819
import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js';
1920
import { type CollectedFontForMetrics, optimizeFallbacks } from './logic/optimize-fallbacks.js';
@@ -28,6 +29,7 @@ import type {
2829
FontFileDataMap,
2930
InternalConsumableMap,
3031
PreloadData,
32+
ResolvedFontFamily,
3133
} from './types.js';
3234
import {
3335
pickFontFaceProperty,
@@ -122,16 +124,50 @@ export async function orchestrate({
122124
*/
123125
const consumableMap: ConsumableMap = new Map();
124126

125-
for (const family of resolvedFamilies) {
126-
const preloadData: Array<PreloadData> = [];
127-
const consumableMapValue: Array<FontData> = [];
128-
let css = '';
127+
/**
128+
* Holds family data by a key, to allow merging families
129+
*/
130+
const resolvedFamiliesMap = new Map<
131+
string,
132+
{
133+
family: ResolvedFontFamily;
134+
fonts: Array<unifont.FontFaceData>;
135+
fallbacks: Array<string>;
136+
/**
137+
* Holds a list of font files to be used for optimized fallbacks generation
138+
*/
139+
collectedFonts: Array<CollectedFontForMetrics>;
140+
preloadData: Array<PreloadData>;
141+
}
142+
>();
129143

130-
/**
131-
* Holds a list of font files to be used for optimized fallbacks generation
132-
*/
133-
const collectedFonts: Array<CollectedFontForMetrics> = [];
134-
const fallbacks = family.fallbacks ?? defaults.fallbacks ?? [];
144+
// First loop: we try to merge families. This is useful for advanced cases, where eg. you want
145+
// 500, 600, 700 as normal but also 500 as italic. That requires 2 families
146+
for (const family of resolvedFamilies) {
147+
const key = `${family.cssVariable}:${family.name}:${typeof family.provider === 'string' ? family.provider : family.provider.name!}`;
148+
let resolvedFamily = resolvedFamiliesMap.get(key);
149+
if (!resolvedFamily) {
150+
if (
151+
Array.from(resolvedFamiliesMap.keys()).find((k) => k.startsWith(`${family.cssVariable}:`))
152+
) {
153+
logger.warn(
154+
'assets',
155+
`Several font families have been registered for the ${bold(family.cssVariable)} cssVariable but they do not share the same name and provider.`,
156+
);
157+
logger.warn(
158+
'assets',
159+
'These families will not be merged together. The last occurrence will override previous families for this cssVariable. Review your Astro configuration.',
160+
);
161+
}
162+
resolvedFamily = {
163+
family,
164+
fonts: [],
165+
fallbacks: family.fallbacks ?? defaults.fallbacks ?? [],
166+
collectedFonts: [],
167+
preloadData: [],
168+
};
169+
resolvedFamiliesMap.set(key, resolvedFamily);
170+
}
135171

136172
/**
137173
* Allows collecting and transforming original URLs from providers, so the Vite
@@ -144,26 +180,26 @@ export async function orchestrate({
144180
fontFileDataMap.set(hash, { url, init });
145181
},
146182
savePreload: (preload) => {
147-
preloadData.push(preload);
183+
resolvedFamily.preloadData.push(preload);
148184
},
149185
saveFontData: (collected) => {
150186
if (
151-
fallbacks &&
152-
fallbacks.length > 0 &&
187+
resolvedFamily.fallbacks &&
188+
resolvedFamily.fallbacks.length > 0 &&
153189
// If the same data has already been sent for this family, we don't want to have
154190
// duplicated fallbacks. Such scenario can occur with unicode ranges.
155-
!collectedFonts.some((f) => JSON.stringify(f.data) === JSON.stringify(collected.data))
191+
!resolvedFamily.collectedFonts.some(
192+
(f) => JSON.stringify(f.data) === JSON.stringify(collected.data),
193+
)
156194
) {
157195
// If a family has fallbacks, we store the first url we get that may
158196
// be used for the fallback generation.
159-
collectedFonts.push(collected);
197+
resolvedFamily.collectedFonts.push(collected);
160198
}
161199
},
162200
cssVariable: family.cssVariable,
163201
});
164202

165-
let fonts: Array<unifont.FontFaceData>;
166-
167203
if (family.provider === LOCAL_PROVIDER_NAME) {
168204
const result = resolveLocalFont({
169205
family,
@@ -172,7 +208,7 @@ export async function orchestrate({
172208
fontFileReader,
173209
});
174210
// URLs are already proxied at this point so no further processing is required
175-
fonts = result.fonts;
211+
resolvedFamily.fonts.push(...result.fonts);
176212
} else {
177213
const result = await resolveFont(
178214
family.name,
@@ -194,16 +230,35 @@ export async function orchestrate({
194230
`No data found for font family ${bold(family.name)}. Review your configuration`,
195231
);
196232
const availableFamilies = await listFonts([family.provider.name!]);
197-
if (availableFamilies && !availableFamilies.includes(family.name)) {
233+
if (
234+
availableFamilies &&
235+
availableFamilies.length > 0 &&
236+
!availableFamilies.includes(family.name)
237+
) {
198238
logger.warn(
199239
'assets',
200240
`${bold(family.name)} font family cannot be retrieved by the provider. Did you mean ${bold(stringMatcher.getClosestMatch(family.name, availableFamilies))}?`,
201241
);
202242
}
203243
}
204244
// The data returned by the remote provider contains original URLs. We proxy them.
205-
fonts = normalizeRemoteFontFaces({ fonts: result.fonts, urlProxy, fontTypeExtractor });
245+
resolvedFamily.fonts = dedupeFontFaces(
246+
resolvedFamily.fonts,
247+
normalizeRemoteFontFaces({ fonts: result.fonts, urlProxy, fontTypeExtractor }),
248+
);
206249
}
250+
}
251+
252+
// We know about all the families, let's generate css, fallbacks and more
253+
for (const {
254+
family,
255+
fonts,
256+
fallbacks,
257+
collectedFonts,
258+
preloadData,
259+
} of resolvedFamiliesMap.values()) {
260+
const consumableMapValue: Array<FontData> = [];
261+
let css = '';
207262

208263
for (const data of fonts) {
209264
css += cssRenderer.generateFontFace(

0 commit comments

Comments
 (0)