diff --git a/.changeset/tricky-papayas-stand.md b/.changeset/tricky-papayas-stand.md new file mode 100644 index 000000000000..93568f4c7ee3 --- /dev/null +++ b/.changeset/tricky-papayas-stand.md @@ -0,0 +1,14 @@ +--- +'astro': patch +--- + +Allows inferring `weight` and `style` when using the local provider of the experimental fonts API + +If you want Astro to infer those properties directly from your local font files, leave them undefined: + +```js +{ + // No weight specified: infer + style: 'normal' // Do not infer +} +``` diff --git a/packages/astro/package.json b/packages/astro/package.json index f316a1bd9e9a..284c0f019bff 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -139,6 +139,7 @@ "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", + "fontace": "~0.3.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.1.1", diff --git a/packages/astro/src/assets/fonts/config.ts b/packages/astro/src/assets/fonts/config.ts index 5ab12276ef6c..73b28105bf64 100644 --- a/packages/astro/src/assets/fonts/config.ts +++ b/packages/astro/src/assets/fonts/config.ts @@ -2,7 +2,8 @@ import { z } from 'zod'; import { LOCAL_PROVIDER_NAME } from './constants.js'; const weightSchema = z.union([z.string(), z.number()]); -const styleSchema = z.enum(['normal', 'italic', 'oblique']); +export const styleSchema = z.enum(['normal', 'italic', 'oblique']); +const unicodeRangeSchema = z.array(z.string()).nonempty(); const familyPropertiesSchema = z.object({ /** @@ -12,21 +13,17 @@ const familyPropertiesSchema = z.object({ * weight: "100 900" * ``` */ - weight: weightSchema, + weight: weightSchema.optional(), /** * A [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style). */ - style: styleSchema, + style: styleSchema.optional(), /** * @default `"swap"` * * A [font display](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display). */ display: z.enum(['auto', 'block', 'swap', 'fallback', 'optional']).optional(), - /** - * A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range). - */ - unicodeRange: z.array(z.string()).nonempty().optional(), /** * A [font stretch](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-stretch). */ @@ -122,6 +119,10 @@ export const localFontFamilySchema = requiredFamilyAttributesSchema ]), ) .nonempty(), + /** + * A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range). + */ + unicodeRange: unicodeRangeSchema.optional(), // TODO: find a way to support subsets (through fontkit?) }) .strict(), @@ -168,6 +169,10 @@ export const remoteFontFamilySchema = requiredFamilyAttributesSchema * An array of [font subsets](https://knaap.dev/posts/font-subsetting/): */ subsets: z.array(z.string()).nonempty().optional(), + /** + * A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range). + */ + unicodeRange: unicodeRangeSchema.optional(), }), ) .strict(); diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index caae27f9588a..46c430979305 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -7,6 +7,7 @@ import type { FontType, PreloadData, ResolvedFontProvider, + Style, } from './types.js'; import type { FontFaceMetrics, GenericFallbackName } from './types.js'; @@ -42,7 +43,8 @@ export type ErrorHandlerInput = > | SingleErrorInput<'unknown-fs-error', {}> | SingleErrorInput<'cannot-fetch-font-file', { url: string }> - | SingleErrorInput<'cannot-extract-font-type', { url: string }>; + | SingleErrorInput<'cannot-extract-font-type', { url: string }> + | SingleErrorInput<'cannot-extract-data', { family: string; url: string }>; export interface ErrorHandler { handle: (input: ErrorHandlerInput) => Error; @@ -100,3 +102,10 @@ export interface FontFetcher { export interface FontTypeExtractor { extract: (url: string) => FontType; } + +export interface FontFileReader { + extract: (input: { family: string; url: string }) => { + weight: string; + style: Style; + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/error-handler.ts b/packages/astro/src/assets/fonts/implementations/error-handler.ts index 5b911a7c0959..1df6ed29c263 100644 --- a/packages/astro/src/assets/fonts/implementations/error-handler.ts +++ b/packages/astro/src/assets/fonts/implementations/error-handler.ts @@ -19,6 +19,11 @@ function getProps(input: ErrorHandlerInput): ConstructorParameters ({ ...variant, - weight: variant.weight.toString(), + weight: variant.weight?.toString(), src: variant.src.map((value) => { // A src can be a string or an object, we extract the value accordingly. const isValue = typeof value === 'string' || value instanceof URL; diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index d6adc31dc0a6..21b64a154a76 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -5,6 +5,7 @@ import type { Logger } from '../../core/logger/core.js'; import { LOCAL_PROVIDER_NAME } from './constants.js'; import type { CssRenderer, + FontFileReader, FontMetricsResolver, FontTypeExtractor, Hasher, @@ -56,6 +57,7 @@ export async function orchestrate({ systemFallbacksProvider, fontMetricsResolver, fontTypeExtractor, + fontFileReader, logger, createUrlProxy, defaults, @@ -69,6 +71,7 @@ export async function orchestrate({ systemFallbacksProvider: SystemFallbacksProvider; fontMetricsResolver: FontMetricsResolver; fontTypeExtractor: FontTypeExtractor; + fontFileReader: FontFileReader; // TODO: follow this implementation: https://github.com/withastro/astro/pull/13756/commits/e30ac2b7082a3eed36225da6e88449890cbcbe6b logger: Logger; createUrlProxy: (params: CreateUrlProxyParams) => UrlProxy; @@ -150,6 +153,7 @@ export async function orchestrate({ family, urlProxy, fontTypeExtractor, + fontFileReader, }); // URLs are already proxied at this point so no further processing is required fonts = result.fonts; diff --git a/packages/astro/src/assets/fonts/providers/index.ts b/packages/astro/src/assets/fonts/providers/index.ts index cb1f744ef082..7c41a4b6d010 100644 --- a/packages/astro/src/assets/fonts/providers/index.ts +++ b/packages/astro/src/assets/fonts/providers/index.ts @@ -30,9 +30,6 @@ function fontsource() { }); } -// TODO: https://github.com/unjs/unifont/issues/108. Once resolved, remove the unifont patch -// This provider downloads too many files when there's a variable font -// available. This is bad because it doesn't align with our default font settings /** [Google](https://fonts.google.com/) */ function google(config?: Parameters[0]) { return defineAstroFontProvider({ diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 670ba3d4296d..af8a04e2522d 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -1,43 +1,67 @@ import type * as unifont from 'unifont'; import { FONT_FORMAT_MAP } from '../constants.js'; -import type { FontTypeExtractor, UrlProxy } from '../definitions.js'; +import type { FontFileReader, FontTypeExtractor, UrlProxy } from '../definitions.js'; import type { ResolvedLocalFontFamily } from '../types.js'; interface Options { family: ResolvedLocalFontFamily; urlProxy: UrlProxy; fontTypeExtractor: FontTypeExtractor; + fontFileReader: FontFileReader; } -export function resolveLocalFont({ family, urlProxy, fontTypeExtractor }: Options): { +export function resolveLocalFont({ + family, + urlProxy, + fontTypeExtractor, + fontFileReader, +}: Options): { fonts: Array; } { return { - fonts: family.variants.map((variant) => ({ - weight: variant.weight, - style: variant.style, + fonts: family.variants.map((variant) => { + const shouldInfer = variant.weight === undefined || variant.style === undefined; + + // We prepare the data + const data: unifont.FontFaceData = { + // If it should be inferred, we don't want to set the value + weight: variant.weight, + style: variant.style, + src: [], + unicodeRange: variant.unicodeRange, + display: variant.display, + stretch: variant.stretch, + featureSettings: variant.featureSettings, + variationSettings: variant.variationSettings, + }; // We proxy each source - src: variant.src.map((source, index) => ({ - originalURL: source.url, - url: urlProxy.proxy({ - url: source.url, - // We only use the first source for preloading. For example if woff2 and woff - // are available, we only keep woff2. - collectPreload: index === 0, - data: { - weight: variant.weight, - style: variant.style, - }, - init: null, - }), - format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)], - tech: source.tech, - })), - display: variant.display, - unicodeRange: variant.unicodeRange, - stretch: variant.stretch, - featureSettings: variant.featureSettings, - variationSettings: variant.variationSettings, - })), + data.src = variant.src.map((source, index) => { + // We only try to infer for the first source. Indeed if it doesn't work, the function + // call will throw an error so that will be interruped anyways + if (shouldInfer && index === 0) { + const result = fontFileReader.extract({ family: family.name, url: source.url }); + if (variant.weight === undefined) data.weight = result.weight; + if (variant.style === undefined) data.style = result.style; + } + + return { + originalURL: source.url, + url: urlProxy.proxy({ + url: source.url, + // We only use the first source for preloading. For example if woff2 and woff + // are available, we only keep woff2. + collectPreload: index === 0, + data: { + weight: data.weight, + style: data.style, + }, + init: null, + }), + format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)], + tech: source.tech, + }; + }); + return data; + }), }; } diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index e55a9d663439..fda2971ce5d8 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -5,6 +5,7 @@ import type { fontProviderSchema, localFontFamilySchema, remoteFontFamilySchema, + styleSchema, } from './config.js'; import type { FONT_TYPES, GENERIC_FALLBACK_NAMES } from './constants.js'; import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; @@ -28,7 +29,7 @@ export interface ResolvedLocalFontFamily Omit { variants: Array< Omit & { - weight: string; + weight?: string; src: Array<{ url: string; tech?: string }>; } >; @@ -101,3 +102,5 @@ export type FontFileDataMap = Map; css: string }>; + +export type Style = z.output; diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 963f24b7be17..d56feed6dd7a 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -15,7 +15,7 @@ export function unifontFontFaceDataToProperties( return { src: font.src ? renderFontSrc(font.src) : undefined, 'font-display': font.display ?? 'swap', - 'unicode-range': font.unicodeRange?.join(','), + 'unicode-range': font.unicodeRange?.length ? font.unicodeRange.join(',') : undefined, 'font-weight': Array.isArray(font.weight) ? font.weight.join(' ') : font.weight?.toString(), 'font-style': font.style, 'font-stretch': font.stretch, diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 6209d88807ab..0d5a7a1fc851 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -45,6 +45,7 @@ import { import { createUrlProxy } from './implementations/url-proxy.js'; import { orchestrate } from './orchestrate.js'; import type { ConsumableMap, FontFileDataMap } from './types.js'; +import { createFontaceFontFileReader } from './implementations/font-file-reader.js'; interface Options { settings: AstroSettings; @@ -132,6 +133,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { fontFetcher = createCachedFontFetcher({ storage, errorHandler, fetch, readFile }); const fontMetricsResolver = createCapsizeFontMetricsResolver({ fontFetcher, cssRenderer }); fontTypeExtractor = createFontTypeExtractor({ errorHandler }); + const fontFileReader = createFontaceFontFileReader({ errorHandler }); const res = await orchestrate({ families: settings.config.experimental.fonts!, @@ -143,6 +145,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { systemFallbacksProvider, fontMetricsResolver, fontTypeExtractor, + fontFileReader, logger, createUrlProxy: ({ local, ...params }) => { const dataCollector = createDataCollector(params); diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 43fc222b2923..f10772dbfa97 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1309,6 +1309,21 @@ export const CannotExtractFontType = { hint: 'Open an issue at https://github.com/withastro/astro/issues.', } satisfies ErrorData; +/** + * @docs + * @description + * Cannot determine weight and style from font file. + * @message + * An error occured while determining the weight and style from the local font file. + */ +export const CannotDetermineWeightAndStyleFromFontFile = { + name: 'CannotDetermineWeightAndStyleFromFontFile', + title: 'Cannot determine weight and style from font file.', + message: (family: string, url: string) => + `An error occurred while determining the \`weight\` and \`style\` from local family "${family}" font file: ${url}`, + hint: 'Update your family config and set `weight` and `style` manually instead.', +} satisfies ErrorData; + /** * @docs * @description diff --git a/packages/astro/test/units/assets/fonts/orchestrate.test.js b/packages/astro/test/units/assets/fonts/orchestrate.test.js index 022d89b8741e..78aa841e472b 100644 --- a/packages/astro/test/units/assets/fonts/orchestrate.test.js +++ b/packages/astro/test/units/assets/fonts/orchestrate.test.js @@ -12,6 +12,7 @@ import { createBuildRemoteFontProviderModResolver } from '../../../../dist/asset import { createRemoteFontProviderResolver } from '../../../../dist/assets/fonts/implementations/remote-font-provider-resolver.js'; import { createSystemFallbacksProvider } from '../../../../dist/assets/fonts/implementations/system-fallbacks-provider.js'; import { createRemoteUrlProxyContentResolver } from '../../../../dist/assets/fonts/implementations/url-proxy-content-resolver.js'; +import { createFontaceFontFileReader } from '../../../../dist/assets/fonts/implementations/font-file-reader.js'; import { createUrlProxy } from '../../../../dist/assets/fonts/implementations/url-proxy.js'; import { orchestrate } from '../../../../dist/assets/fonts/orchestrate.js'; import { defineAstroFontProvider } from '../../../../dist/assets/fonts/providers/index.js'; @@ -56,6 +57,7 @@ describe('fonts orchestrate()', () => { systemFallbacksProvider: createSystemFallbacksProvider(), fontMetricsResolver: fakeFontMetricsResolver, fontTypeExtractor, + fontFileReader: createFontaceFontFileReader({ errorHandler }), createUrlProxy: ({ local, ...params }) => { const dataCollector = createDataCollector(params); const contentResolver = createRemoteUrlProxyContentResolver(); @@ -156,6 +158,7 @@ describe('fonts orchestrate()', () => { systemFallbacksProvider: createSystemFallbacksProvider(), fontMetricsResolver: fakeFontMetricsResolver, fontTypeExtractor, + fontFileReader: createFontaceFontFileReader({ errorHandler }), createUrlProxy: ({ local, ...params }) => { const dataCollector = createDataCollector(params); const contentResolver = createRemoteUrlProxyContentResolver(); diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 9d88a7b4a295..00916fbb5e6d 100644 --- a/packages/astro/test/units/assets/fonts/providers.test.js +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -10,6 +10,7 @@ import * as googleEntrypoint from '../../../../dist/assets/fonts/providers/entry import { resolveLocalFont } from '../../../../dist/assets/fonts/providers/local.js'; import { fontProviders } from '../../../../dist/config/entrypoint.js'; import { createSpyUrlProxy, simpleErrorHandler } from './utils.js'; +import { createFontaceFontFileReader } from '../../../../dist/assets/fonts/implementations/font-file-reader.js'; describe('fonts providers', () => { describe('config objects', () => { @@ -83,6 +84,7 @@ describe('fonts providers', () => { }, ], }, + fontFileReader: createFontaceFontFileReader({ errorHandler: simpleErrorHandler }), }); assert.deepStrictEqual(collected, [ { @@ -129,6 +131,7 @@ describe('fonts providers', () => { }, ], }, + fontFileReader: createFontaceFontFileReader({ errorHandler: simpleErrorHandler }), }); assert.deepStrictEqual(collected, [ { @@ -157,5 +160,120 @@ describe('fonts providers', () => { }, ]); }); + + describe('properties inference', () => { + it('infers properties correctly', async () => { + const { collected, urlProxy } = createSpyUrlProxy(); + const { fonts } = resolveLocalFont({ + urlProxy, + fontTypeExtractor, + family: { + name: 'Test', + nameWithHash: 'Test-xxx', + cssVariable: '--test', + provider: 'local', + variants: [ + { + src: [{ url: '/test.woff2' }], + }, + ], + }, + fontFileReader: { + extract() { + return { + weight: '300', + style: 'italic', + }; + }, + }, + }); + + assert.deepStrictEqual(fonts, [ + { + display: undefined, + featureSettings: undefined, + src: [ + { + format: 'woff2', + originalURL: '/test.woff2', + tech: undefined, + url: '/test.woff2', + }, + ], + stretch: undefined, + style: 'italic', + unicodeRange: undefined, + variationSettings: undefined, + weight: '300', + }, + ]); + assert.deepStrictEqual(collected, [ + { + url: '/test.woff2', + collectPreload: true, + data: { weight: '300', style: 'italic' }, + init: null, + }, + ]); + }); + + it('respects what property should be inferred', async () => { + const { collected, urlProxy } = createSpyUrlProxy(); + const { fonts } = resolveLocalFont({ + urlProxy, + fontTypeExtractor, + family: { + name: 'Test', + nameWithHash: 'Test-xxx', + cssVariable: '--test', + provider: 'local', + variants: [ + { + style: 'normal', + unicodeRange: ['bar'], + src: [{ url: '/test.woff2' }], + }, + ], + }, + fontFileReader: { + extract() { + return { + weight: '300', + style: 'italic', + unicodeRange: ['foo'], + }; + }, + }, + }); + + assert.deepStrictEqual(fonts, [ + { + display: undefined, + featureSettings: undefined, + src: [ + { + format: 'woff2', + originalURL: '/test.woff2', + tech: undefined, + url: '/test.woff2', + }, + ], + stretch: undefined, + style: 'normal', + unicodeRange: ['bar'], + variationSettings: undefined, + weight: '300', + }, + ]); + assert.deepStrictEqual(collected, [ + { + url: '/test.woff2', + collectPreload: true, + data: { weight: '300', style: 'normal' }, + init: null, + }, + ]); + }); + }); }); }); diff --git a/packages/astro/test/units/assets/fonts/utils.test.js b/packages/astro/test/units/assets/fonts/utils.test.js index 17f01215cda7..77f62f7db11a 100644 --- a/packages/astro/test/units/assets/fonts/utils.test.js +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -82,6 +82,36 @@ describe('fonts utils', () => { 'font-variation-settings': 'bar', }, ); + assert.deepStrictEqual( + unifontFontFaceDataToProperties({ + unicodeRange: [], + }), + { + src: undefined, + 'font-weight': undefined, + 'font-style': undefined, + 'font-stretch': undefined, + 'font-feature-settings': undefined, + 'font-variation-settings': undefined, + 'unicode-range': undefined, + 'font-display': 'swap', + }, + ); + assert.deepStrictEqual( + unifontFontFaceDataToProperties({ + unicodeRange: undefined, + }), + { + src: undefined, + 'font-weight': undefined, + 'font-style': undefined, + 'font-stretch': undefined, + 'font-feature-settings': undefined, + 'font-variation-settings': undefined, + 'unicode-range': undefined, + 'font-display': 'swap', + }, + ); }); it('sortObjectByKey()', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb3f6607a7c2..501dbfa4e1d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -541,6 +541,9 @@ importers: flattie: specifier: ^1.1.1 version: 1.1.1 + fontace: + specifier: ~0.3.0 + version: 0.3.0 github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -8192,6 +8195,9 @@ packages: '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/fontkit@2.0.8': + resolution: {integrity: sha512-wN+8bYxIpJf+5oZdrdtaX04qUuWHcKxcDEgRS9Qm9ZClSHjzEn13SxUC+5eRM+4yXIeTYk8mTzLAWGF64847ew==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -9895,6 +9901,9 @@ packages: debug: optional: true + fontace@0.3.0: + resolution: {integrity: sha512-czoqATrcnxgWb/nAkfyIrRp6Q8biYj7nGnL6zfhTcX+JKKpWHFBnb8uNMw/kZr7u++3Y3wYSYoZgHkCcsuBpBg==} + fontkit@2.0.4: resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} @@ -15162,6 +15171,10 @@ snapshots: '@types/estree@1.0.7': {} + '@types/fontkit@2.0.8': + dependencies: + '@types/node': 18.19.50 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -17070,6 +17083,11 @@ snapshots: follow-redirects@1.15.9: {} + fontace@0.3.0: + dependencies: + '@types/fontkit': 2.0.8 + fontkit: 2.0.4 + fontkit@2.0.4: dependencies: '@swc/helpers': 0.5.17