Skip to content

Commit 5e582e7

Browse files
feat(fonts): local provider infer (#13640)
Co-authored-by: Matt Kane <m@mk.gg>
1 parent f8cec06 commit 5e582e7

18 files changed

Lines changed: 316 additions & 41 deletions

File tree

‎.changeset/tricky-papayas-stand.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Allows inferring `weight` and `style` when using the local provider of the experimental fonts API
6+
7+
If you want Astro to infer those properties directly from your local font files, leave them undefined:
8+
9+
```js
10+
{
11+
// No weight specified: infer
12+
style: 'normal' // Do not infer
13+
}
14+
```

‎packages/astro/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@
139139
"esbuild": "^0.25.0",
140140
"estree-walker": "^3.0.3",
141141
"flattie": "^1.1.1",
142+
"fontace": "~0.3.0",
142143
"github-slugger": "^2.0.0",
143144
"html-escaper": "3.0.3",
144145
"http-cache-semantics": "^4.1.1",

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { z } from 'zod';
22
import { LOCAL_PROVIDER_NAME } from './constants.js';
33

44
const weightSchema = z.union([z.string(), z.number()]);
5-
const styleSchema = z.enum(['normal', 'italic', 'oblique']);
5+
export const styleSchema = z.enum(['normal', 'italic', 'oblique']);
6+
const unicodeRangeSchema = z.array(z.string()).nonempty();
67

78
const familyPropertiesSchema = z.object({
89
/**
@@ -12,21 +13,17 @@ const familyPropertiesSchema = z.object({
1213
* weight: "100 900"
1314
* ```
1415
*/
15-
weight: weightSchema,
16+
weight: weightSchema.optional(),
1617
/**
1718
* A [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style).
1819
*/
19-
style: styleSchema,
20+
style: styleSchema.optional(),
2021
/**
2122
* @default `"swap"`
2223
*
2324
* A [font display](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display).
2425
*/
2526
display: z.enum(['auto', 'block', 'swap', 'fallback', 'optional']).optional(),
26-
/**
27-
* A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range).
28-
*/
29-
unicodeRange: z.array(z.string()).nonempty().optional(),
3027
/**
3128
* A [font stretch](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-stretch).
3229
*/
@@ -122,6 +119,10 @@ export const localFontFamilySchema = requiredFamilyAttributesSchema
122119
]),
123120
)
124121
.nonempty(),
122+
/**
123+
* A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range).
124+
*/
125+
unicodeRange: unicodeRangeSchema.optional(),
125126
// TODO: find a way to support subsets (through fontkit?)
126127
})
127128
.strict(),
@@ -168,6 +169,10 @@ export const remoteFontFamilySchema = requiredFamilyAttributesSchema
168169
* An array of [font subsets](https://knaap.dev/posts/font-subsetting/):
169170
*/
170171
subsets: z.array(z.string()).nonempty().optional(),
172+
/**
173+
* A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range).
174+
*/
175+
unicodeRange: unicodeRangeSchema.optional(),
171176
}),
172177
)
173178
.strict();

‎packages/astro/src/assets/fonts/definitions.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
FontType,
88
PreloadData,
99
ResolvedFontProvider,
10+
Style,
1011
} from './types.js';
1112
import type { FontFaceMetrics, GenericFallbackName } from './types.js';
1213

@@ -42,7 +43,8 @@ export type ErrorHandlerInput =
4243
>
4344
| SingleErrorInput<'unknown-fs-error', {}>
4445
| SingleErrorInput<'cannot-fetch-font-file', { url: string }>
45-
| SingleErrorInput<'cannot-extract-font-type', { url: string }>;
46+
| SingleErrorInput<'cannot-extract-font-type', { url: string }>
47+
| SingleErrorInput<'cannot-extract-data', { family: string; url: string }>;
4648

4749
export interface ErrorHandler {
4850
handle: (input: ErrorHandlerInput) => Error;
@@ -100,3 +102,10 @@ export interface FontFetcher {
100102
export interface FontTypeExtractor {
101103
extract: (url: string) => FontType;
102104
}
105+
106+
export interface FontFileReader {
107+
extract: (input: { family: string; url: string }) => {
108+
weight: string;
109+
style: Style;
110+
};
111+
}

‎packages/astro/src/assets/fonts/implementations/error-handler.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ function getProps(input: ErrorHandlerInput): ConstructorParameters<typeof AstroE
1919
...AstroErrorData.CannotExtractFontType,
2020
message: AstroErrorData.CannotExtractFontType.message(input.data.url),
2121
};
22+
} else if (input.type === 'cannot-extract-data') {
23+
return {
24+
...AstroErrorData.CannotDetermineWeightAndStyleFromFontFile,
25+
message: AstroErrorData.CannotDetermineWeightAndStyleFromFontFile.message(input.data.family, input.data.url),
26+
};
2227
}
2328
input satisfies never;
2429
// Should never happen but TS isn't happy
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { readFileSync } from 'node:fs';
2+
import type { ErrorHandler, FontFileReader } from '../definitions.js';
3+
import { fontace } from 'fontace';
4+
import type { Style } from '../types.js';
5+
6+
export function createFontaceFontFileReader({
7+
errorHandler,
8+
}: { errorHandler: ErrorHandler }): FontFileReader {
9+
return {
10+
extract({ family, url }) {
11+
try {
12+
const data = fontace(readFileSync(url));
13+
return {
14+
weight: data.weight,
15+
style: data.style as Style,
16+
};
17+
} catch (cause) {
18+
throw errorHandler.handle({
19+
type: 'cannot-extract-data',
20+
data: { family, url },
21+
cause,
22+
});
23+
}
24+
},
25+
};
26+
}

‎packages/astro/src/assets/fonts/logic/resolve-families.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function resolveVariants({
2121
}): ResolvedLocalFontFamily['variants'] {
2222
return variants.map((variant) => ({
2323
...variant,
24-
weight: variant.weight.toString(),
24+
weight: variant.weight?.toString(),
2525
src: variant.src.map((value) => {
2626
// A src can be a string or an object, we extract the value accordingly.
2727
const isValue = typeof value === 'string' || value instanceof URL;

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Logger } from '../../core/logger/core.js';
55
import { LOCAL_PROVIDER_NAME } from './constants.js';
66
import type {
77
CssRenderer,
8+
FontFileReader,
89
FontMetricsResolver,
910
FontTypeExtractor,
1011
Hasher,
@@ -56,6 +57,7 @@ export async function orchestrate({
5657
systemFallbacksProvider,
5758
fontMetricsResolver,
5859
fontTypeExtractor,
60+
fontFileReader,
5961
logger,
6062
createUrlProxy,
6163
defaults,
@@ -69,6 +71,7 @@ export async function orchestrate({
6971
systemFallbacksProvider: SystemFallbacksProvider;
7072
fontMetricsResolver: FontMetricsResolver;
7173
fontTypeExtractor: FontTypeExtractor;
74+
fontFileReader: FontFileReader;
7275
// TODO: follow this implementation: https://github.com/withastro/astro/pull/13756/commits/e30ac2b7082a3eed36225da6e88449890cbcbe6b
7376
logger: Logger;
7477
createUrlProxy: (params: CreateUrlProxyParams) => UrlProxy;
@@ -150,6 +153,7 @@ export async function orchestrate({
150153
family,
151154
urlProxy,
152155
fontTypeExtractor,
156+
fontFileReader,
153157
});
154158
// URLs are already proxied at this point so no further processing is required
155159
fonts = result.fonts;

‎packages/astro/src/assets/fonts/providers/index.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,6 @@ function fontsource() {
3030
});
3131
}
3232

33-
// TODO: https://github.com/unjs/unifont/issues/108. Once resolved, remove the unifont patch
34-
// This provider downloads too many files when there's a variable font
35-
// available. This is bad because it doesn't align with our default font settings
3633
/** [Google](https://fonts.google.com/) */
3734
function google(config?: Parameters<typeof providers.google>[0]) {
3835
return defineAstroFontProvider({
Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,67 @@
11
import type * as unifont from 'unifont';
22
import { FONT_FORMAT_MAP } from '../constants.js';
3-
import type { FontTypeExtractor, UrlProxy } from '../definitions.js';
3+
import type { FontFileReader, FontTypeExtractor, UrlProxy } from '../definitions.js';
44
import type { ResolvedLocalFontFamily } from '../types.js';
55

66
interface Options {
77
family: ResolvedLocalFontFamily;
88
urlProxy: UrlProxy;
99
fontTypeExtractor: FontTypeExtractor;
10+
fontFileReader: FontFileReader;
1011
}
1112

12-
export function resolveLocalFont({ family, urlProxy, fontTypeExtractor }: Options): {
13+
export function resolveLocalFont({
14+
family,
15+
urlProxy,
16+
fontTypeExtractor,
17+
fontFileReader,
18+
}: Options): {
1319
fonts: Array<unifont.FontFaceData>;
1420
} {
1521
return {
16-
fonts: family.variants.map((variant) => ({
17-
weight: variant.weight,
18-
style: variant.style,
22+
fonts: family.variants.map((variant) => {
23+
const shouldInfer = variant.weight === undefined || variant.style === undefined;
24+
25+
// We prepare the data
26+
const data: unifont.FontFaceData = {
27+
// If it should be inferred, we don't want to set the value
28+
weight: variant.weight,
29+
style: variant.style,
30+
src: [],
31+
unicodeRange: variant.unicodeRange,
32+
display: variant.display,
33+
stretch: variant.stretch,
34+
featureSettings: variant.featureSettings,
35+
variationSettings: variant.variationSettings,
36+
};
1937
// We proxy each source
20-
src: variant.src.map((source, index) => ({
21-
originalURL: source.url,
22-
url: urlProxy.proxy({
23-
url: source.url,
24-
// We only use the first source for preloading. For example if woff2 and woff
25-
// are available, we only keep woff2.
26-
collectPreload: index === 0,
27-
data: {
28-
weight: variant.weight,
29-
style: variant.style,
30-
},
31-
init: null,
32-
}),
33-
format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)],
34-
tech: source.tech,
35-
})),
36-
display: variant.display,
37-
unicodeRange: variant.unicodeRange,
38-
stretch: variant.stretch,
39-
featureSettings: variant.featureSettings,
40-
variationSettings: variant.variationSettings,
41-
})),
38+
data.src = variant.src.map((source, index) => {
39+
// We only try to infer for the first source. Indeed if it doesn't work, the function
40+
// call will throw an error so that will be interruped anyways
41+
if (shouldInfer && index === 0) {
42+
const result = fontFileReader.extract({ family: family.name, url: source.url });
43+
if (variant.weight === undefined) data.weight = result.weight;
44+
if (variant.style === undefined) data.style = result.style;
45+
}
46+
47+
return {
48+
originalURL: source.url,
49+
url: urlProxy.proxy({
50+
url: source.url,
51+
// We only use the first source for preloading. For example if woff2 and woff
52+
// are available, we only keep woff2.
53+
collectPreload: index === 0,
54+
data: {
55+
weight: data.weight,
56+
style: data.style,
57+
},
58+
init: null,
59+
}),
60+
format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)],
61+
tech: source.tech,
62+
};
63+
});
64+
return data;
65+
}),
4266
};
4367
}

0 commit comments

Comments
 (0)