Skip to content

Commit c775fce

Browse files
feat(fonts)!: local provider unification (#15213)
1 parent edabeaa commit c775fce

58 files changed

Lines changed: 2950 additions & 2529 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/dull-cities-hang.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
**BREAKING CHANGE to the experimental Fonts API only**
6+
7+
Updates how the local provider must be used when using the experimental Fonts API
8+
9+
Previously, there were 2 kinds of font providers: remote and local.
10+
11+
Font providers are now unified. If you are using the local provider, here is how you can update:
12+
13+
```diff
14+
-import { defineConfig } from "astro/config";
15+
+import { defineConfig, fontProviders } from "astro/config";
16+
17+
export default defineConfig({
18+
experimental: {
19+
fonts: [{
20+
name: "Custom",
21+
cssVariable: "--font-custom",
22+
- provider: "local",
23+
+ provider: fontProviders.local(),
24+
+ options: {
25+
variants: [
26+
{
27+
weight: 400,
28+
style: "normal",
29+
src: ["./src/assets/fonts/custom-400.woff2"]
30+
},
31+
{
32+
weight: 700,
33+
style: "normal",
34+
src: ["./src/assets/fonts/custom-700.woff2"]
35+
}
36+
// ...
37+
]
38+
+ }
39+
}]
40+
}
41+
});
42+
```

.changeset/floppy-bikes-lick.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Exposes `root` on `FontProvider` `init()` context
6+
7+
When building a `FontProvider` for the experimental Fonts API, the `init()` method receives a `context`. This context now exposes a `root` URL, useful for resolving local files:
8+
9+
```diff
10+
import type { FontProvider } from "astro";
11+
12+
export function registryFontProvider(): FontProvider {
13+
return {
14+
// ...
15+
- init: async ({ storage }) => {
16+
+ init: async ({ storage, root }) => {
17+
// ...
18+
},
19+
};
20+
}
21+
```

packages/astro/components/Font.astro

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { filterPreloads } from '../dist/assets/fonts/core/filter-preloads.js';
44
import { AstroError, AstroErrorData } from '../dist/core/errors/index.js';
55
66
// TODO: remove check when fonts are stabilized
7-
const { internalConsumableMap } = mod;
8-
if (!internalConsumableMap) {
7+
const { componentDataByCssVariable } = mod;
8+
if (!componentDataByCssVariable) {
99
throw new AstroError(AstroErrorData.ExperimentalFontsNotEnabled);
1010
}
1111
@@ -17,15 +17,15 @@ interface Props {
1717
}
1818
1919
const { cssVariable, preload = false } = Astro.props as Props;
20-
const data = internalConsumableMap.get(cssVariable);
20+
const data = componentDataByCssVariable.get(cssVariable);
2121
if (!data) {
2222
throw new AstroError({
2323
...AstroErrorData.FontFamilyNotFound,
2424
message: AstroErrorData.FontFamilyNotFound.message(cssVariable),
2525
});
2626
}
2727
28-
const filteredPreloadData = filterPreloads(data.preloadData, preload);
28+
const filteredPreloadData = filterPreloads(data.preloads, preload);
2929
---
3030

3131
<style set:html={data.css}></style>

packages/astro/dev-only.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ declare module 'virtual:astro:env/internal' {
77
}
88

99
declare module 'virtual:astro:assets/fonts/internal' {
10-
export const internalConsumableMap: import('./src/assets/fonts/types.js').InternalConsumableMap;
11-
export const fontData: import('./src/assets/fonts/types.js').FontDataRecord;
10+
export const componentDataByCssVariable: import('./src/assets/fonts/types.js').ComponentDataByCssVariable;
11+
export const fontDataByCssVariable: import('./src/assets/fonts/types.js').FontDataByCssVariable;
1212
export const bufferImports: import('./src/assets/fonts/types.js').BufferImports;
1313
}
1414

packages/astro/src/assets/fonts/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Here is an overview of the architecture of the fonts in Astro:
44

55
- [`orchestrate()`](./orchestrate.ts) combines sub steps and takes care of getting useful data from the config
6-
- It resolves font families (eg. import remote font providers)
6+
- It resolves font families (eg. deduplication)
77
- It initializes the font resolver
88
- For each family, it resolves fonts data and normalizes them
99
- For each family, optimized fallbacks (and related CSS) are generated if applicable

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

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z } from 'zod';
2-
import { FONT_TYPES, LOCAL_PROVIDER_NAME } from './constants.js';
2+
import { FONT_TYPES } from './constants.js';
33
import type { FontProvider } from './types.js';
44

55
export const weightSchema = z.union([z.string(), z.number()]);
@@ -26,34 +26,6 @@ const requiredFamilyAttributesSchema = z.object({
2626
cssVariable: z.string(),
2727
});
2828

29-
const entrypointSchema = z.union([z.string(), z.instanceof(URL)]);
30-
31-
export const localFontFamilySchema = z
32-
.object({
33-
...requiredFamilyAttributesSchema.shape,
34-
...fallbacksSchema.shape,
35-
provider: z.literal(LOCAL_PROVIDER_NAME),
36-
variants: z
37-
.array(
38-
z
39-
.object({
40-
...familyPropertiesSchema.shape,
41-
src: z
42-
.array(
43-
z.union([
44-
entrypointSchema,
45-
z.object({ url: entrypointSchema, tech: z.string().optional() }).strict(),
46-
]),
47-
)
48-
.nonempty(),
49-
// TODO: find a way to support subsets (through fontkit?)
50-
})
51-
.strict(),
52-
)
53-
.nonempty(),
54-
})
55-
.strict();
56-
5729
export const fontProviderSchema = z
5830
.object({
5931
name: z.string(),
@@ -64,7 +36,7 @@ export const fontProviderSchema = z
6436
})
6537
.strict();
6638

67-
export const remoteFontFamilySchema = z
39+
export const fontFamilySchema = z
6840
.object({
6941
...requiredFamilyAttributesSchema.shape,
7042
...fallbacksSchema.shape,

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import type { Defaults, FontType } from './types.js';
22

3-
export const LOCAL_PROVIDER_NAME = 'local';
4-
53
export const DEFAULTS: Defaults = {
64
weights: ['400'],
75
styles: ['normal', 'italic'],
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { CssRenderer } from '../definitions.js';
2+
import type {
3+
Collaborator,
4+
ComponentDataByCssVariable,
5+
Defaults,
6+
FontFamilyAssets,
7+
} from '../types.js';
8+
import { unifontFontFaceDataToProperties } from '../utils.js';
9+
import type { optimizeFallbacks as _optimizeFallbacks } from './optimize-fallbacks.js';
10+
11+
export async function collectComponentData({
12+
fontFamilyAssets,
13+
cssRenderer,
14+
defaults,
15+
optimizeFallbacks,
16+
}: {
17+
fontFamilyAssets: Array<FontFamilyAssets>;
18+
cssRenderer: CssRenderer;
19+
defaults: Pick<Defaults, 'fallbacks' | 'optimizedFallbacks'>;
20+
optimizeFallbacks: Collaborator<
21+
typeof _optimizeFallbacks,
22+
'family' | 'fallbacks' | 'collectedFonts'
23+
>;
24+
}) {
25+
const componentDataByCssVariable: ComponentDataByCssVariable = new Map();
26+
27+
for (const { family, fonts, collectedFontsForMetricsByUniqueKey, preloads } of fontFamilyAssets) {
28+
let css = '';
29+
30+
for (const data of fonts) {
31+
css += cssRenderer.generateFontFace(
32+
family.uniqueName,
33+
unifontFontFaceDataToProperties({
34+
src: data.src,
35+
weight: data.weight,
36+
style: data.style,
37+
// User settings override the generated font settings
38+
display: data.display ?? family.display,
39+
unicodeRange: data.unicodeRange ?? family.unicodeRange,
40+
stretch: data.stretch ?? family.stretch,
41+
featureSettings: data.featureSettings ?? family.featureSettings,
42+
variationSettings: data.variationSettings ?? family.variationSettings,
43+
}),
44+
);
45+
}
46+
47+
const fallbacks = family.fallbacks ?? defaults.fallbacks;
48+
const cssVarValues = [family.uniqueName];
49+
const optimizeFallbacksResult =
50+
(family.optimizedFallbacks ?? defaults.optimizedFallbacks)
51+
? await optimizeFallbacks({
52+
family,
53+
fallbacks,
54+
collectedFonts: Array.from(collectedFontsForMetricsByUniqueKey.values()),
55+
})
56+
: null;
57+
58+
if (optimizeFallbacksResult) {
59+
css += optimizeFallbacksResult.css;
60+
cssVarValues.push(...optimizeFallbacksResult.fallbacks);
61+
} else {
62+
// If there are no optimized fallbacks, we pass the provided fallbacks as is.
63+
cssVarValues.push(...fallbacks);
64+
}
65+
66+
css += cssRenderer.generateCssVariable(family.cssVariable, cssVarValues);
67+
68+
componentDataByCssVariable.set(family.cssVariable, { preloads, css });
69+
}
70+
71+
return componentDataByCssVariable;
72+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type * as unifont from 'unifont';
2+
import { FONT_FORMATS } from '../constants.js';
3+
import type { FontFileIdGenerator, Hasher } from '../definitions.js';
4+
import type { Defaults, FontFileById, PreloadData, ResolvedFontFamily } from '../types.js';
5+
import { renderFontWeight } from '../utils.js';
6+
import type { CollectedFontForMetrics } from './optimize-fallbacks.js';
7+
8+
export function collectFontAssetsFromFaces({
9+
fonts,
10+
fontFileIdGenerator,
11+
family,
12+
fontFilesIds,
13+
collectedFontsIds,
14+
hasher,
15+
defaults,
16+
}: {
17+
fonts: Array<unifont.FontFaceData>;
18+
fontFileIdGenerator: FontFileIdGenerator;
19+
family: Pick<ResolvedFontFamily, 'cssVariable' | 'fallbacks'>;
20+
fontFilesIds: Set<string>;
21+
collectedFontsIds: Set<string>;
22+
hasher: Hasher;
23+
defaults: Pick<Defaults, 'fallbacks'>;
24+
}) {
25+
const fontFileById: FontFileById = new Map();
26+
const collectedFontsForMetricsByUniqueKey = new Map<string, CollectedFontForMetrics>();
27+
const preloads: Array<PreloadData> = [];
28+
29+
for (const font of fonts) {
30+
// The index keeps track of encountered URLs. We can't use a regular for loop
31+
// below because it may contain sources without urls, which would prevent preloading completely
32+
let index = 0;
33+
for (const source of font.src) {
34+
if ('name' in source) {
35+
continue;
36+
}
37+
const format = FONT_FORMATS.find((e) => e.format === source.format)!;
38+
const originalUrl = source.originalURL!;
39+
const id = fontFileIdGenerator.generate({
40+
cssVariable: family.cssVariable,
41+
font,
42+
originalUrl,
43+
type: format.type,
44+
});
45+
46+
if (!fontFilesIds.has(id) && !fontFileById.has(id)) {
47+
fontFileById.set(id, { url: originalUrl, init: font.meta?.init });
48+
// We only collect the first URL to avoid preloading fallback sources (eg. we only
49+
// preload woff2 if woff is available)
50+
if (index === 0) {
51+
preloads.push({
52+
style: font.style,
53+
subset: font.meta?.subset,
54+
type: format.type,
55+
url: source.url,
56+
weight: renderFontWeight(font.weight),
57+
});
58+
}
59+
}
60+
61+
const collected: CollectedFontForMetrics = {
62+
id,
63+
url: originalUrl,
64+
init: font.meta?.init,
65+
data: {
66+
weight: font.weight,
67+
style: font.style,
68+
meta: {
69+
subset: font.meta?.subset,
70+
},
71+
},
72+
};
73+
const collectedKey = hasher.hashObject(collected.data);
74+
const fallbacks = family.fallbacks ?? defaults.fallbacks;
75+
if (
76+
fallbacks.length > 0 &&
77+
// If the same data has already been sent for this family, we don't want to have
78+
// duplicated fallbacks. Such scenario can occur with unicode ranges.
79+
!collectedFontsIds.has(collectedKey) &&
80+
!collectedFontsForMetricsByUniqueKey.has(collectedKey)
81+
) {
82+
// If a family has fallbacks, we store the first url we get that may
83+
// be used for the fallback generation.
84+
collectedFontsForMetricsByUniqueKey.set(collectedKey, collected);
85+
}
86+
87+
index++;
88+
}
89+
}
90+
91+
return {
92+
fontFileById,
93+
preloads,
94+
collectedFontsForMetricsByUniqueKey,
95+
};
96+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { FontData, FontDataByCssVariable, FontFamilyAssets } from '../types.js';
2+
import { renderFontWeight } from '../utils.js';
3+
4+
export function collectFontData(
5+
fontFamilyAssets: Array<
6+
Pick<FontFamilyAssets, 'fonts'> & { family: Pick<FontFamilyAssets['family'], 'cssVariable'> }
7+
>,
8+
) {
9+
const fontDataByCssVariable: FontDataByCssVariable = {};
10+
11+
for (const { family, fonts } of fontFamilyAssets) {
12+
const fontData: Array<FontData> = [];
13+
for (const data of fonts) {
14+
fontData.push({
15+
weight: renderFontWeight(data.weight),
16+
style: data.style,
17+
src: data.src
18+
.filter((src) => 'url' in src)
19+
.map((src) => ({
20+
url: src.url,
21+
format: src.format,
22+
tech: src.tech,
23+
})),
24+
});
25+
}
26+
27+
fontDataByCssVariable[family.cssVariable] = fontData;
28+
}
29+
30+
return fontDataByCssVariable;
31+
}

0 commit comments

Comments
 (0)