diff --git a/.changeset/forty-zebras-enter.md b/.changeset/forty-zebras-enter.md new file mode 100644 index 000000000000..4ee22b251882 --- /dev/null +++ b/.changeset/forty-zebras-enter.md @@ -0,0 +1,54 @@ +--- +'astro': patch +--- + +**BREAKING CHANGE to the experimental Fonts API only** + +Changes how font providers are implemented with updates to the `FontProvider` type + +This is an implementation detail that changes how font providers are created. This process allows Astro to take more control rather than relying directly on `unifont` types. **All of Astro's built-in font providers have been updated to reflect this new type, and can be configured as before**. However, using third-party unifont providers that rely on `unifont` types will require an update to your project code. + +Previously, an Astro `FontProvider` was made of a config and a runtime part. It relied directly on `unifont` types, which allowed a simple configuration for third-party unifont providers, but also coupled Astro's implementation to unifont, which was limiting. + +Astro's font provider implementation is now only made of a config part with dedicated hooks. This allows for the separation of config and runtime, but requires you to create a font provider object in order to use custom font providers (e.g. third-party unifont providers, or private font registeries). + +#### What should I do? + +If you were using a 3rd-party `unifont` font provider, you will now need to write an Astro `FontProvider` using it under the hood. For example: + +```diff +// astro.config.ts +import { defineConfig } from "astro/config"; +import { acmeProvider, type AcmeOptions } from '@acme/unifont-provider' ++import type { FontProvider } from "astro"; ++import type { InitializedProvider } from 'unifont'; + ++function acme(config?: AcmeOptions): FontProvider { ++ const provider = acmeProvider(config); ++ let initializedProvider: InitializedProvider | undefined; ++ return { ++ name: provider._name, ++ config, ++ async init(context) { ++ initializedProvider = await provider(context); ++ }, ++ async resolveFont({ familyName, ...rest }) { ++ return await initializedProvider?.resolveFont(familyName, rest); ++ }, ++ async listFonts() { ++ return await initializedProvider?.listFonts?.(); ++ }, ++ }; ++} + +export default defineConfig({ + experimental: { + fonts: [{ +- provider: acmeProvider({ /* ... */ }), ++ provider: acme({ /* ... */ }), + name: "Material Symbols Outlined", + cssVariable: "--font-material" + }] + } +}); +``` diff --git a/packages/astro/package.json b/packages/astro/package.json index e20900607c30..c1261b7c105c 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -63,7 +63,6 @@ "./assets/endpoint/*": "./dist/assets/endpoint/*.js", "./assets/services/sharp": "./dist/assets/services/sharp.js", "./assets/services/noop": "./dist/assets/services/noop.js", - "./assets/fonts/providers/*": "./dist/assets/fonts/providers/entrypoints/*.js", "./assets/fonts/runtime": "./dist/assets/fonts/runtime.js", "./loaders": "./dist/content/loaders/index.js", "./content/config": "./dist/content/config.js", diff --git a/packages/astro/src/assets/fonts/README.md b/packages/astro/src/assets/fonts/README.md index 24e03c72b15a..7326407f6fbb 100644 --- a/packages/astro/src/assets/fonts/README.md +++ b/packages/astro/src/assets/fonts/README.md @@ -4,8 +4,7 @@ Here is an overview of the architecture of the fonts in Astro: - [`orchestrate()`](./orchestrate.ts) combines sub steps and takes care of getting useful data from the config - It resolves font families (eg. import remote font providers) - - It prepares [`unifont`](https://github.com/unjs/unifont) providers - - It initializes `unifont` + - It initializes the font resolver - For each family, it resolves fonts data and normalizes them - For each family, optimized fallbacks (and related CSS) are generated if applicable - It returns the data diff --git a/packages/astro/src/assets/fonts/config.ts b/packages/astro/src/assets/fonts/config.ts index 59a3dd84c94c..456e3c16641b 100644 --- a/packages/astro/src/assets/fonts/config.ts +++ b/packages/astro/src/assets/fonts/config.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { FONT_TYPES, LOCAL_PROVIDER_NAME } from './constants.js'; +import type { FontProvider } from './types.js'; export const weightSchema = z.union([z.string(), z.number()]); export const styleSchema = z.enum(['normal', 'italic', 'oblique']); @@ -53,6 +54,16 @@ export const localFontFamilySchema = z }) .strict(); +export const fontProviderSchema = z + .object({ + name: z.string(), + config: z.record(z.string(), z.any()).optional(), + init: z.custom((v) => typeof v === 'function').optional(), + resolveFont: z.custom((v) => typeof v === 'function'), + listFonts: z.custom((v) => typeof v === 'function').optional(), + }) + .strict(); + export const remoteFontFamilySchema = z .object({ ...requiredFamilyAttributesSchema.shape, @@ -61,12 +72,7 @@ export const remoteFontFamilySchema = z weight: true, style: true, }).shape, - provider: z - .object({ - entrypoint: entrypointSchema, - config: z.record(z.string(), z.any()).optional(), - }) - .strict(), + provider: fontProviderSchema, weights: z.array(weightSchema).nonempty().optional(), styles: z.array(styleSchema).nonempty().optional(), subsets: z.array(z.string()).nonempty().optional(), diff --git a/packages/astro/src/assets/fonts/core/resolve-families.ts b/packages/astro/src/assets/fonts/core/resolve-families.ts index ce7d3ab24642..8e6141fbfa07 100644 --- a/packages/astro/src/assets/fonts/core/resolve-families.ts +++ b/packages/astro/src/assets/fonts/core/resolve-families.ts @@ -1,9 +1,5 @@ import { LOCAL_PROVIDER_NAME } from '../constants.js'; -import type { - Hasher, - LocalProviderUrlResolver, - RemoteFontProviderResolver, -} from '../definitions.js'; +import type { Hasher, LocalProviderUrlResolver } from '../definitions.js'; import type { FontFamily, LocalFontFamily, @@ -38,17 +34,15 @@ function resolveVariants({ /** * Dedupes properties if applicable and resolves entrypoints. */ -export async function resolveFamily({ +export function resolveFamily({ family, hasher, - remoteFontProviderResolver, localProviderUrlResolver, }: { family: FontFamily; hasher: Hasher; - remoteFontProviderResolver: RemoteFontProviderResolver; localProviderUrlResolver: LocalProviderUrlResolver; -}): Promise { +}): ResolvedFontFamily { // We remove quotes from the name so they can be properly resolved by providers. const name = withoutQuotes(family.name); // This will be used in CSS font faces. Quotes are added by the CSS renderer if @@ -75,26 +69,23 @@ export async function resolveFamily({ formats: family.formats ? dedupe(family.formats) : undefined, fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, unicodeRange: family.unicodeRange ? dedupe(family.unicodeRange) : undefined, - // This will be Astro specific eventually - provider: await remoteFontProviderResolver.resolve(family.provider), }; } /** * A function for convenience. The actual logic lives in resolveFamily */ -export async function resolveFamilies({ +export function resolveFamilies({ families, ...dependencies -}: { families: Array } & Omit[0], 'family'>): Promise< - Array -> { - return await Promise.all( - families.map((family) => - resolveFamily({ - family, - ...dependencies, - }), - ), +}: { families: Array } & Omit< + Parameters[0], + 'family' +>): Array { + return families.map((family) => + resolveFamily({ + family, + ...dependencies, + }), ); } diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index f6394db66f40..3847c66d4639 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -3,11 +3,9 @@ import type { CollectedFontForMetrics } from './core/optimize-fallbacks.js'; import type { FontFaceMetrics, FontFileData, - FontProvider, FontType, GenericFallbackName, PreloadData, - ResolvedFontProvider, ResolveFontOptions, Style, } from './types.js'; @@ -17,14 +15,6 @@ export interface Hasher { hashObject: (input: Record) => string; } -export interface RemoteFontProviderModResolver { - resolve: (id: string) => Promise; -} - -export interface RemoteFontProviderResolver { - resolve: (provider: FontProvider) => Promise; -} - export interface LocalProviderUrlResolver { resolve: (input: string) => string; } diff --git a/packages/astro/src/assets/fonts/infra/build-remote-font-provider-mod-resolver.ts b/packages/astro/src/assets/fonts/infra/build-remote-font-provider-mod-resolver.ts deleted file mode 100644 index a47363500d62..000000000000 --- a/packages/astro/src/assets/fonts/infra/build-remote-font-provider-mod-resolver.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { RemoteFontProviderModResolver } from '../definitions.js'; - -export class BuildRemoteFontProviderModResolver implements RemoteFontProviderModResolver { - async resolve(id: string): Promise { - return await import(id); - } -} diff --git a/packages/astro/src/assets/fonts/infra/dev-remote-font-provider-mod-resolver.ts b/packages/astro/src/assets/fonts/infra/dev-remote-font-provider-mod-resolver.ts deleted file mode 100644 index 00b6c5580d83..000000000000 --- a/packages/astro/src/assets/fonts/infra/dev-remote-font-provider-mod-resolver.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ViteDevServer } from 'vite'; -import type { RemoteFontProviderModResolver } from '../definitions.js'; - -export class DevServerRemoteFontProviderModResolver implements RemoteFontProviderModResolver { - readonly #server: ViteDevServer; - - constructor({ - server, - }: { - server: ViteDevServer; - }) { - this.#server = server; - } - - async resolve(id: string): Promise { - return await this.#server.ssrLoadModule(id); - } -} diff --git a/packages/astro/src/assets/fonts/infra/remote-font-provider-resolver.ts b/packages/astro/src/assets/fonts/infra/remote-font-provider-resolver.ts deleted file mode 100644 index c204202bb945..000000000000 --- a/packages/astro/src/assets/fonts/infra/remote-font-provider-resolver.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; -import type { RemoteFontProviderModResolver, RemoteFontProviderResolver } from '../definitions.js'; -import type { FontProvider, ResolvedFontProvider } from '../types.js'; -import { resolveEntrypoint } from '../utils.js'; - -// TODO: find better name -export class RealRemoteFontProviderResolver implements RemoteFontProviderResolver { - readonly #root: URL; - readonly #modResolver: RemoteFontProviderModResolver; - - constructor({ - root, - modResolver, - }: { - root: URL; - modResolver: RemoteFontProviderModResolver; - }) { - this.#root = root; - this.#modResolver = modResolver; - } - - #validateMod({ - mod, - entrypoint, - }: { - mod: any; - entrypoint: string; - }): Pick { - // We do not throw astro errors directly to avoid duplication. Instead, we throw an error to be used as cause - try { - if (typeof mod !== 'object' || mod === null) { - throw new Error(`Expected an object for the module, but received ${typeof mod}.`); - } - - if (typeof mod.provider !== 'function') { - throw new Error(`Invalid provider export in module, expected a function.`); - } - - return { - provider: mod.provider, - }; - } catch (cause) { - throw new AstroError( - { - ...AstroErrorData.CannotLoadFontProvider, - message: AstroErrorData.CannotLoadFontProvider.message(entrypoint), - }, - { cause }, - ); - } - } - - async resolve({ entrypoint, config }: FontProvider): Promise { - const id = resolveEntrypoint(this.#root, entrypoint.toString()).href; - const mod = await this.#modResolver.resolve(id); - const { provider } = this.#validateMod({ - mod, - entrypoint: id, - }); - return { config, provider }; - } -} diff --git a/packages/astro/src/assets/fonts/infra/require-local-provider-url-resolver.ts b/packages/astro/src/assets/fonts/infra/require-local-provider-url-resolver.ts index 2184e8358297..89992b59063d 100644 --- a/packages/astro/src/assets/fonts/infra/require-local-provider-url-resolver.ts +++ b/packages/astro/src/assets/fonts/infra/require-local-provider-url-resolver.ts @@ -1,6 +1,6 @@ -import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type { LocalProviderUrlResolver } from '../definitions.js'; -import { resolveEntrypoint } from '../utils.js'; export class RequireLocalProviderUrlResolver implements LocalProviderUrlResolver { readonly #root: URL; @@ -18,10 +18,20 @@ export class RequireLocalProviderUrlResolver implements LocalProviderUrlResolver this.#intercept = intercept; } + #resolveEntrypoint(root: URL, entrypoint: string): URL { + const require = createRequire(root); + + try { + return pathToFileURL(require.resolve(entrypoint)); + } catch { + return new URL(entrypoint, root); + } + } + resolve(input: string): string { // fileURLToPath is important so that the file can be read // by createLocalUrlProxyContentResolver - const path = fileURLToPath(resolveEntrypoint(this.#root, input)); + const path = fileURLToPath(this.#resolveEntrypoint(this.#root, input)); this.#intercept?.(path); return path; } diff --git a/packages/astro/src/assets/fonts/infra/unifont-font-resolver.ts b/packages/astro/src/assets/fonts/infra/unifont-font-resolver.ts index 32da5ccf68c3..f99f2c829fa1 100644 --- a/packages/astro/src/assets/fonts/infra/unifont-font-resolver.ts +++ b/packages/astro/src/assets/fonts/infra/unifont-font-resolver.ts @@ -1,8 +1,8 @@ import type { FontFaceData, Provider } from 'unifont'; -import { createUnifont, type Unifont } from 'unifont'; +import { createUnifont, defineFontProvider, type Unifont } from 'unifont'; import { LOCAL_PROVIDER_NAME } from '../constants.js'; import type { FontResolver, Hasher, Storage } from '../definitions.js'; -import type { ResolvedFontFamily, ResolveFontOptions } from '../types.js'; +import type { FontProvider, ResolvedFontFamily, ResolveFontOptions } from '../types.js'; type NonEmptyProviders = [Provider, ...Array]; @@ -13,6 +13,20 @@ export class UnifontFontResolver implements FontResolver { this.#unifont = unifont; } + static astroToUnifontProvider(astroProvider: FontProvider): Provider { + return defineFontProvider(astroProvider.name, async (_options: any, ctx) => { + await astroProvider?.init?.(ctx); + return { + async resolveFont(familyName, options) { + return await astroProvider.resolveFont({ familyName, ...options }); + }, + async listFonts() { + return astroProvider.listFonts?.(); + }, + }; + })(astroProvider.config); + } + static extractUnifontProviders({ families, hasher, @@ -29,7 +43,7 @@ export class UnifontFontResolver implements FontResolver { continue; } - const unifontProvider = provider.provider(provider.config); + const unifontProvider = this.astroToUnifontProvider(provider); const hash = hasher.hashObject({ name: unifontProvider._name, ...provider.config, diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 823a8cf07c1b..feab898f2af8 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -12,7 +12,6 @@ import type { FontTypeExtractor, Hasher, LocalProviderUrlResolver, - RemoteFontProviderResolver, StringMatcher, SystemFallbacksProvider, UrlProxy, @@ -39,8 +38,7 @@ import { * Manages how fonts are resolved: * * - families are resolved - * - unifont providers are extracted from families - * - unifont is initialized + * - font resolver is initialized * * For each family: * - We create a URL proxy @@ -56,7 +54,6 @@ import { export async function orchestrate({ families, hasher, - remoteFontProviderResolver, localProviderUrlResolver, cssRenderer, systemFallbacksProvider, @@ -72,7 +69,6 @@ export async function orchestrate({ }: { families: Array; hasher: Hasher; - remoteFontProviderResolver: RemoteFontProviderResolver; localProviderUrlResolver: LocalProviderUrlResolver; cssRenderer: CssRenderer; systemFallbacksProvider: SystemFallbacksProvider; @@ -90,10 +86,9 @@ export async function orchestrate({ internalConsumableMap: InternalConsumableMap; consumableMap: ConsumableMap; }> { - const resolvedFamilies = await resolveFamilies({ + const resolvedFamilies = resolveFamilies({ families, hasher, - remoteFontProviderResolver, localProviderUrlResolver, }); @@ -133,7 +128,7 @@ export async function orchestrate({ // First loop: we try to merge families. This is useful for advanced cases, where eg. you want // 500, 600, 700 as normal but also 500 as italic. That requires 2 families for (const family of resolvedFamilies) { - const key = `${family.cssVariable}:${family.name}:${typeof family.provider === 'string' ? family.provider : family.provider.name!}`; + const key = `${family.cssVariable}:${family.name}:${typeof family.provider === 'string' ? family.provider : family.provider.name}`; let resolvedFamily = resolvedFamiliesMap.get(key); if (!resolvedFamily) { if ( @@ -190,21 +185,18 @@ export async function orchestrate({ }); if (family.provider === LOCAL_PROVIDER_NAME) { - const result = resolveLocalFont({ + const fonts = resolveLocalFont({ family, urlProxy, fontTypeExtractor, fontFileReader, }); // URLs are already proxied at this point so no further processing is required - resolvedFamily.fonts.push(...result.fonts); + resolvedFamily.fonts.push(...fonts); } else { const fonts = await fontResolver.resolveFont({ familyName: family.name, - // By default, unifont goes through all providers. We use a different approach where - // we specify a provider per font. Name has been set while extracting unifont providers - // from families (inside extractUnifontProviders). - provider: family.provider.name!, + provider: family.provider.name, // We do not merge the defaults, we only provide defaults as a fallback weights: family.weights ?? defaults.weights, styles: family.styles ?? defaults.styles, @@ -216,7 +208,7 @@ export async function orchestrate({ 'assets', `No data found for font family ${bold(family.name)}. Review your configuration`, ); - const availableFamilies = await fontResolver.listFonts({ provider: family.provider.name! }); + const availableFamilies = await fontResolver.listFonts({ provider: family.provider.name }); if ( availableFamilies && availableFamilies.length > 0 && diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts b/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts deleted file mode 100644 index 03b6a8464f1a..000000000000 --- a/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { providers } from 'unifont'; - -// Required type annotation because its options type is not exported -export const provider: typeof providers.adobe = providers.adobe; diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts b/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts deleted file mode 100644 index efff38505258..000000000000 --- a/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { providers } from 'unifont'; - -export const provider = providers.bunny; diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts b/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts deleted file mode 100644 index 78f67683631f..000000000000 --- a/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { providers } from 'unifont'; - -export const provider = providers.fontshare; diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts b/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts deleted file mode 100644 index 25f19cc8de7e..000000000000 --- a/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { providers } from 'unifont'; - -export const provider = providers.fontsource; diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/google.ts b/packages/astro/src/assets/fonts/providers/entrypoints/google.ts deleted file mode 100644 index 5851dea204ab..000000000000 --- a/packages/astro/src/assets/fonts/providers/entrypoints/google.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { providers } from 'unifont'; - -// Required type annotation because its options type is not exported -export const provider: typeof providers.google = providers.google; diff --git a/packages/astro/src/assets/fonts/providers/index.ts b/packages/astro/src/assets/fonts/providers/index.ts index 727c80b95dca..022c3ef038c1 100644 --- a/packages/astro/src/assets/fonts/providers/index.ts +++ b/packages/astro/src/assets/fonts/providers/index.ts @@ -1,40 +1,100 @@ -import type { providers } from 'unifont'; +import { + type AdobeProviderOptions, + type GoogleOptions, + type InitializedProvider, + providers, +} from 'unifont'; import type { FontProvider } from '../types.js'; /** [Adobe](https://fonts.adobe.com/) */ -function adobe(config: Parameters[0]): FontProvider { +function adobe(config: AdobeProviderOptions): FontProvider { + const provider = providers.adobe(config); + let initializedProvider: InitializedProvider | undefined; return { - entrypoint: 'astro/assets/fonts/providers/adobe', + name: provider._name, config, + async init(context) { + initializedProvider = await provider(context); + }, + async resolveFont({ familyName, ...rest }) { + return await initializedProvider?.resolveFont(familyName, rest); + }, + async listFonts() { + return await initializedProvider?.listFonts?.(); + }, }; } /** [Bunny](https://fonts.bunny.net/) */ function bunny(): FontProvider { + const provider = providers.bunny(); + let initializedProvider: InitializedProvider | undefined; return { - entrypoint: 'astro/assets/fonts/providers/bunny', + name: provider._name, + async init(context) { + initializedProvider = await provider(context); + }, + async resolveFont({ familyName, ...rest }) { + return await initializedProvider?.resolveFont(familyName, rest); + }, + async listFonts() { + return await initializedProvider?.listFonts?.(); + }, }; } /** [Fontshare](https://www.fontshare.com/) */ function fontshare(): FontProvider { + const provider = providers.fontshare(); + let initializedProvider: InitializedProvider | undefined; return { - entrypoint: 'astro/assets/fonts/providers/fontshare', + name: provider._name, + async init(context) { + initializedProvider = await provider(context); + }, + async resolveFont({ familyName, ...rest }) { + return await initializedProvider?.resolveFont(familyName, rest); + }, + async listFonts() { + return await initializedProvider?.listFonts?.(); + }, }; } /** [Fontsource](https://fontsource.org/) */ function fontsource(): FontProvider { + const provider = providers.fontsource(); + let initializedProvider: InitializedProvider | undefined; return { - entrypoint: 'astro/assets/fonts/providers/fontsource', + name: provider._name, + async init(context) { + initializedProvider = await provider(context); + }, + async resolveFont({ familyName, ...rest }) { + return await initializedProvider?.resolveFont(familyName, rest); + }, + async listFonts() { + return await initializedProvider?.listFonts?.(); + }, }; } /** [Google](https://fonts.google.com/) */ -function google(config?: Parameters[0]): FontProvider { +function google(config?: GoogleOptions): FontProvider { + const provider = providers.google(config); + let initializedProvider: InitializedProvider | undefined; return { - entrypoint: 'astro/assets/fonts/providers/google', + name: provider._name, config, + async init(context) { + initializedProvider = await provider(context); + }, + async resolveFont({ familyName, ...rest }) { + return await initializedProvider?.resolveFont(familyName, rest); + }, + async listFonts() { + return await initializedProvider?.listFonts?.(); + }, }; } diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 0ea2aa78b1b9..09e13f22827d 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -15,57 +15,53 @@ export function resolveLocalFont({ urlProxy, fontTypeExtractor, fontFileReader, -}: Options): { - fonts: Array; -} { - return { - fonts: family.variants.map((variant) => { - const shouldInfer = variant.weight === undefined || variant.style === undefined; +}: Options): Array { + return 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 - 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 interrupted 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; - } + // 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 + 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 interrupted 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; + } - const type = fontTypeExtractor.extract(source.url); + const type = fontTypeExtractor.extract(source.url); - return { - originalURL: source.url, - url: urlProxy.proxy({ - url: source.url, - type, - // 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, - subset: undefined, - }, - init: null, - }), - format: FONT_FORMATS.find((e) => e.type === type)?.format, - tech: source.tech, - }; - }); - return data; - }), - }; + return { + originalURL: source.url, + url: urlProxy.proxy({ + url: source.url, + type, + // 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, + subset: undefined, + }, + init: null, + }), + format: FONT_FORMATS.find((e) => e.type === type)?.format, + tech: source.tech, + }; + }); + return data; + }); } diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index c2bd084738cd..b68104786d7f 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -8,15 +8,45 @@ import type { CollectedFontForMetrics } from './core/optimize-fallbacks.js'; type Weight = z.infer; type Display = z.infer; +/** @lintignore */ +export interface FontProviderInitContext { + storage: { + getItem: { + (key: string): Promise; + (key: string, init: () => Awaitable): Promise; + }; + setItem: (key: string, value: unknown) => Awaitable; + }; +} + +type Awaitable = T | Promise; + export interface FontProvider { /** - * URL, path relative to the root or package import. + * The font provider name, used for display and deduplication. */ - entrypoint: string | URL; + name: string; /** - * Optional serializable object passed to the unifont provider. + * Optional serializable object, used for deduplication. */ config?: Record | undefined; + /** + * Optional callback, used to perform any initialization logic. + */ + init?: ((context: FontProviderInitContext) => Awaitable) | undefined; + /** + * Required callback, used to retrieve and return font face data based on the given options. + */ + resolveFont: (options: ResolveFontOptions) => Awaitable< + | { + fonts: Array; + } + | undefined + >; + /** + * Optional callback, used to return the list of available font names. + */ + listFonts?: (() => Awaitable | undefined>) | undefined; } interface RequiredFamilyAttributes { @@ -95,12 +125,6 @@ interface FamilyProperties { unicodeRange?: [string, ...Array] | undefined; } -export interface ResolvedFontProvider { - name?: string; - provider: (config?: Record) => unifont.Provider; - config?: Record; -} - type Src = | string | URL @@ -183,8 +207,7 @@ export interface RemoteFontFamily /** @lintignore somehow required by pickFontFaceProperty in utils */ export interface ResolvedRemoteFontFamily extends ResolvedFontFamilyAttributes, - Omit { - provider: ResolvedFontProvider; + Omit { weights?: Array; } @@ -217,7 +240,7 @@ export type FontFaceMetrics = Pick< export type GenericFallbackName = (typeof GENERIC_FALLBACK_NAMES)[number]; -export type Defaults = Partial< +export type Defaults = Required< Pick< ResolvedRemoteFontFamily, 'weights' | 'styles' | 'subsets' | 'fallbacks' | 'optimizedFallbacks' | 'formats' @@ -269,8 +292,8 @@ export type PreloadFilter = export interface ResolveFontOptions { familyName: string; - weights: string[] | undefined; - styles: Style[] | undefined; - subsets: string[] | undefined; - formats: FontType[] | undefined; + weights: string[]; + styles: Style[]; + subsets: string[]; + formats: FontType[]; } diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 2f80e8c2169a..f396730ac209 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -1,5 +1,3 @@ -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; import type * as unifont from 'unifont'; import { FONT_TYPES, GENERIC_FALLBACK_NAMES, LOCAL_PROVIDER_NAME } from './constants.js'; import type { CssProperties, Storage } from './definitions.js'; @@ -102,16 +100,6 @@ export function sortObjectByKey>(unordered: T): T return ordered; } -export function resolveEntrypoint(root: URL, entrypoint: string): URL { - const require = createRequire(root); - - try { - return pathToFileURL(require.resolve(entrypoint)); - } catch { - return new URL(entrypoint, root); - } -} - export function pickFontFaceProperty< T extends keyof Pick< unifont.FontFaceData, diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 45a715a6b471..6971ddc274db 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -25,18 +25,15 @@ import type { FontFetcher, FontTypeExtractor, Hasher, - RemoteFontProviderModResolver, UrlProxyContentResolver, UrlProxyHashResolver, UrlResolver, } from './definitions.js'; -import { BuildRemoteFontProviderModResolver } from './infra/build-remote-font-provider-mod-resolver.js'; import { BuildUrlProxyHashResolver } from './infra/build-url-proxy-hash-resolver.js'; import { BuildUrlResolver } from './infra/build-url-resolver.js'; import { CachedFontFetcher } from './infra/cached-font-fetcher.js'; import { CapsizeFontMetricsResolver } from './infra/capsize-font-metrics-resolver.js'; import { RealDataCollector } from './infra/data-collector.js'; -import { DevServerRemoteFontProviderModResolver } from './infra/dev-remote-font-provider-mod-resolver.js'; import { DevUrlProxyHashResolver } from './infra/dev-url-proxy-hash-resolver.js'; import { DevUrlResolver } from './infra/dev-url-resolver.js'; import { RealFontTypeExtractor } from './infra/font-type-extractor.js'; @@ -44,7 +41,6 @@ import { FontaceFontFileReader } from './infra/fontace-font-file-reader.js'; import { LevenshteinStringMatcher } from './infra/levenshtein-string-matcher.js'; import { LocalUrlProxyContentResolver } from './infra/local-url-proxy-content-resolver.js'; import { MinifiableCssRenderer } from './infra/minifiable-css-renderer.js'; -import { RealRemoteFontProviderResolver } from './infra/remote-font-provider-resolver.js'; import { RemoteUrlProxyContentResolver } from './infra/remote-url-proxy-content-resolver.js'; import { RequireLocalProviderUrlResolver } from './infra/require-local-provider-url-resolver.js'; import { RealSystemFallbacksProvider } from './infra/system-fallbacks-provider.js'; @@ -106,13 +102,11 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { async function initialize({ cacheDir, - modResolver, cssRenderer, urlResolver, createHashResolver, }: { cacheDir: URL; - modResolver: RemoteFontProviderModResolver; cssRenderer: CssRenderer; urlResolver: UrlResolver; createHashResolver: (dependencies: { @@ -124,10 +118,6 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // Dependencies. Once extracted to a dedicated vite plugin, those may be passed as // a Vite plugin option. const hasher = await XxhashHasher.create(); - const remoteFontProviderResolver = new RealRemoteFontProviderResolver({ - root, - modResolver, - }); // TODO: remove when stabilizing const pathsToWarn = new Set(); const localProviderUrlResolver = new RequireLocalProviderUrlResolver({ @@ -156,7 +146,6 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { const res = await orchestrate({ families: settings.config.experimental.fonts!, hasher, - remoteFontProviderResolver, localProviderUrlResolver, createFontResolver: async ({ families }) => await UnifontFontResolver.create({ @@ -215,7 +204,6 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { if (isBuild) { await initialize({ cacheDir: new URL(CACHE_DIR, settings.config.cacheDir), - modResolver: new BuildRemoteFontProviderModResolver(), cssRenderer: new MinifiableCssRenderer({ minify: true }), urlResolver: new BuildUrlResolver({ base: baseUrl, @@ -224,26 +212,27 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { }), createHashResolver: (dependencies) => new BuildUrlProxyHashResolver(dependencies), }); + } else { + await initialize({ + // In dev, we cache fonts data in .astro so it can be easily inspected and cleared + cacheDir: new URL(CACHE_DIR, settings.dotAstroDir), + cssRenderer: new MinifiableCssRenderer({ minify: false }), + urlResolver: new DevUrlResolver({ + base: baseUrl, + searchParams: settings.adapter?.client?.assetQueryParams ?? new URLSearchParams(), + }), + createHashResolver: (dependencies) => new DevUrlProxyHashResolver(dependencies), + }); } }, async configureServer(server) { - await initialize({ - // In dev, we cache fonts data in .astro so it can be easily inspected and cleared - cacheDir: new URL(CACHE_DIR, settings.dotAstroDir), - modResolver: new DevServerRemoteFontProviderModResolver({ server }), - cssRenderer: new MinifiableCssRenderer({ minify: false }), - urlResolver: new DevUrlResolver({ - base: baseUrl, - searchParams: settings.adapter?.client?.assetQueryParams ?? new URLSearchParams(), - }), - createHashResolver: (dependencies) => new DevUrlProxyHashResolver(dependencies), - }); - // The map is always defined at this point. Its values contains urls from remote providers - // as well as local paths for the local provider. We filter them to only keep the filepaths - const localPaths = [...fontFileDataMap!.values()] - .filter(({ url }) => isAbsolute(url)) - .map((v) => v.url); server.watcher.on('change', (path) => { + if (!fontFileDataMap) { + return; + } + const localPaths = [...fontFileDataMap.values()] + .filter(({ url }) => isAbsolute(url)) + .map((v) => v.url); if (localPaths.includes(path)) { logger.info('assets', 'Font file updated'); server.restart(); @@ -251,6 +240,12 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { }); // We do not purge the cache in case the user wants to re-use the file later on server.watcher.on('unlink', (path) => { + if (!fontFileDataMap) { + return; + } + const localPaths = [...fontFileDataMap.values()] + .filter(({ url }) => isAbsolute(url)) + .map((v) => v.url); if (localPaths.includes(path)) { logger.warn( 'assets', diff --git a/packages/astro/test/types/fonts.ts b/packages/astro/test/types/fonts.ts index f1c6fa8e86d1..d8d2976aee06 100644 --- a/packages/astro/test/types/fonts.ts +++ b/packages/astro/test/types/fonts.ts @@ -2,10 +2,15 @@ import { describe, it } from 'node:test'; import { expectTypeOf } from 'expect-type'; import type z from 'zod'; import type { + fontProviderSchema, localFontFamilySchema, remoteFontFamilySchema, } from '../../src/assets/fonts/config.js'; -import type { LocalFontFamily, RemoteFontFamily } from '../../src/assets/fonts/types.js'; +import type { + FontProvider, + LocalFontFamily, + RemoteFontFamily, +} from '../../src/assets/fonts/types.js'; describe('fonts', () => { it('LocalFontFamily type matches localFontFamilySchema', () => { @@ -15,4 +20,8 @@ describe('fonts', () => { it('RemoteFontFamily type matches remoteFontFamilySchema', () => { expectTypeOf>().toEqualTypeOf(); }); + + it('FontProvider type matches fontProviderSchema', () => { + expectTypeOf>().toEqualTypeOf(); + }); }); diff --git a/packages/astro/test/units/assets/fonts/core.test.js b/packages/astro/test/units/assets/fonts/core.test.js index e9f325a7950a..aebd498ad6f4 100644 --- a/packages/astro/test/units/assets/fonts/core.test.js +++ b/packages/astro/test/units/assets/fonts/core.test.js @@ -10,9 +10,9 @@ import { FakeFontMetricsResolver, FakeHasher, SpyUrlProxy } from './utils.js'; describe('fonts core', () => { describe('resolveFamily()', () => { - it('removes quotes correctly', async () => { + it('removes quotes correctly', () => { const hasher = new FakeHasher('xxx'); - let family = await resolveFamily({ + let family = resolveFamily({ family: { provider: 'local', name: 'Test', @@ -29,15 +29,11 @@ describe('fonts core', () => { localProviderUrlResolver: { resolve: (url) => url, }, - remoteFontProviderResolver: { - // @ts-expect-error - resolve: async () => ({}), - }, }); assert.equal(family.name, 'Test'); assert.equal(family.nameWithHash, 'Test-xxx'); - family = await resolveFamily({ + family = resolveFamily({ family: { provider: 'local', name: '"Foo bar"', @@ -54,17 +50,13 @@ describe('fonts core', () => { localProviderUrlResolver: { resolve: (url) => url, }, - remoteFontProviderResolver: { - // @ts-expect-error - resolve: async () => ({}), - }, }); assert.equal(family.name, 'Foo bar'); assert.equal(family.nameWithHash, 'Foo bar-xxx'); }); - it('resolves local variant correctly', async () => { - const family = await resolveFamily({ + it('resolves local variant correctly', () => { + const family = resolveFamily({ family: { provider: 'local', name: 'Test', @@ -81,10 +73,6 @@ describe('fonts core', () => { localProviderUrlResolver: { resolve: (url) => url + url, }, - remoteFontProviderResolver: { - // @ts-expect-error - resolve: async () => ({}), - }, }); if (family.provider === 'local') { assert.deepStrictEqual( @@ -96,36 +84,8 @@ describe('fonts core', () => { } }); - it('resolves remote providers', async () => { - const provider = () => {}; - const family = await resolveFamily({ - family: { - provider: { - entrypoint: '', - }, - name: 'Test', - cssVariable: '--test', - }, - hasher: new FakeHasher(), - localProviderUrlResolver: { - resolve: (url) => url, - }, - remoteFontProviderResolver: { - // @ts-expect-error - resolve: async () => ({ - provider, - }), - }, - }); - if (family.provider === 'local') { - assert.fail('Should be a remote provider'); - } else { - assert.deepStrictEqual(family.provider, { provider }); - } - }); - - it('dedupes properly', async () => { - let family = await resolveFamily({ + it('dedupes properly', () => { + let family = resolveFamily({ family: { provider: 'local', name: '"Foo bar"', @@ -143,16 +103,15 @@ describe('fonts core', () => { localProviderUrlResolver: { resolve: (url) => url, }, - remoteFontProviderResolver: { - // @ts-expect-error - resolve: async () => ({}), - }, }); assert.deepStrictEqual(family.fallbacks, ['foo', 'bar']); - family = await resolveFamily({ + family = resolveFamily({ family: { - provider: { entrypoint: '' }, + provider: { + name: 'xxx', + resolveFont: () => undefined, + }, name: '"Foo bar"', cssVariable: '--test', weights: [400, '400', '500', 'bold'], @@ -165,10 +124,6 @@ describe('fonts core', () => { localProviderUrlResolver: { resolve: (url) => url, }, - remoteFontProviderResolver: { - // @ts-expect-error - resolve: async () => ({}), - }, }); if (family.provider === 'local') { diff --git a/packages/astro/test/units/assets/fonts/infra.test.js b/packages/astro/test/units/assets/fonts/infra.test.js index 5c315529ef9a..c2f31fffcd89 100644 --- a/packages/astro/test/units/assets/fonts/infra.test.js +++ b/packages/astro/test/units/assets/fonts/infra.test.js @@ -1,6 +1,7 @@ // @ts-check import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { defineFontProvider } from 'unifont'; import { BuildUrlProxyHashResolver } from '../../../../dist/assets/fonts/infra/build-url-proxy-hash-resolver.js'; import { BuildUrlResolver } from '../../../../dist/assets/fonts/infra/build-url-resolver.js'; import { CachedFontFetcher } from '../../../../dist/assets/fonts/infra/cached-font-fetcher.js'; @@ -533,8 +534,16 @@ describe('fonts infra', () => { }); describe('UnifontFontResolver', () => { - const createProvider = (/** @type {string} */ name) => () => - Object.assign(() => undefined, { _name: name, _options: undefined }); + /** + * @param {string} name + * @param {any} [config] + * @returns {import('../../../../dist/index.js').FontProvider} + * */ + const createProvider = (name, config) => ({ + name, + config, + resolveFont: () => undefined, + }); describe('static extractUnifontProviders()', () => { /** @param {Array} families */ @@ -588,9 +597,7 @@ describe('fonts infra', () => { name: 'Custom', nameWithHash: 'Custom-xxx', cssVariable: '--custom', - provider: { - provider: createProvider('test'), - }, + provider: createProvider('test'), }, ]); fixture.assertProvidersLength(1); @@ -603,17 +610,13 @@ describe('fonts infra', () => { name: 'Foo', nameWithHash: 'Foo-xxx', cssVariable: '--custom', - provider: { - provider: createProvider('test'), - }, + provider: createProvider('test'), }, { name: 'Bar', nameWithHash: 'Bar-xxx', cssVariable: '--custom', - provider: { - provider: createProvider('test'), - }, + provider: createProvider('test'), }, ]); fixture.assertProvidersLength(1); @@ -626,19 +629,13 @@ describe('fonts infra', () => { name: 'Foo', nameWithHash: 'Foo-xxx', cssVariable: '--custom', - provider: { - provider: createProvider('test'), - config: { x: 'y' }, - }, + provider: createProvider('test', { x: 'y' }), }, { name: 'Bar', nameWithHash: 'Bar-xxx', cssVariable: '--custom', - provider: { - provider: createProvider('test'), - config: { x: 'y' }, - }, + provider: createProvider('test', { x: 'y' }), }, ]); fixture.assertProvidersLength(1); @@ -654,23 +651,13 @@ describe('fonts infra', () => { name: 'Foo', nameWithHash: 'Foo-xxx', cssVariable: '--custom', - provider: { - provider: createProvider('test'), - config: { - x: 'foo', - }, - }, + provider: createProvider('test', { x: 'foo' }), }, { name: 'Bar', nameWithHash: 'Bar-xxx', cssVariable: '--custom', - provider: { - provider: createProvider('test'), - config: { - x: 'bar', - }, - }, + provider: createProvider('test', { x: 'bar' }), }, ]); fixture.assertProvidersLength(2); @@ -681,6 +668,133 @@ describe('fonts infra', () => { }); }); + describe('static astroToUnifontProvider()', () => { + it('works with a minimal provider', async () => { + const providerFactory = UnifontFontResolver.astroToUnifontProvider({ + name: 'test', + resolveFont: () => ({ + fonts: [ + { + src: [{ name: 'foo' }], + }, + ], + }), + }); + assert.equal(providerFactory._name, 'test'); + const provider = await providerFactory({ storage: new SpyStorage() }); + assert.deepStrictEqual( + await provider?.resolveFont('', { + formats: [], + styles: [], + subsets: [], + weights: [], + }), + { + fonts: [ + { + src: [{ name: 'foo' }], + }, + ], + }, + ); + }); + + it('forwards the config', () => { + const providerFactory = UnifontFontResolver.astroToUnifontProvider({ + name: 'test', + config: { + foo: 'bar', + }, + resolveFont: () => undefined, + }); + assert.equal(providerFactory._name, 'test'); + assert.deepStrictEqual(providerFactory._options, { + foo: 'bar', + }); + }); + + it('handles init()', async () => { + let ran = false; + + const providerFactory = UnifontFontResolver.astroToUnifontProvider({ + name: 'test', + init: () => { + ran = true; + }, + resolveFont: () => undefined, + }); + await providerFactory({ storage: new SpyStorage() }); + assert.equal(ran, true); + }); + + it('handles listFonts()', async () => { + const providerFactory = UnifontFontResolver.astroToUnifontProvider({ + name: 'test', + resolveFont: () => undefined, + listFonts: () => ['a', 'b', 'c'], + }); + assert.equal(providerFactory._name, 'test'); + const provider = await providerFactory({ storage: new SpyStorage() }); + assert.deepStrictEqual(await provider?.listFonts?.(), ['a', 'b', 'c']); + }); + + it('handles unifont > astro > unifont', async () => { + let ran = false; + const unifontProvider = defineFontProvider('test', async () => { + ran = true; + return { + resolveFont: () => ({ + fonts: [ + { + src: [{ name: 'foo' }], + }, + ], + }), + listFonts: () => ['a', 'b', 'c'], + }; + }); + /** @returns {import('../../../../dist/index.js').FontProvider} */ + const astroProvider = () => { + const provider = unifontProvider(); + /** @type {import('unifont').InitializedProvider | undefined} */ + let initializedProvider; + return { + name: provider._name, + async init(context) { + initializedProvider = await provider(context); + }, + async resolveFont({ familyName, ...rest }) { + return await initializedProvider?.resolveFont(familyName, rest); + }, + async listFonts() { + return await initializedProvider?.listFonts?.(); + }, + }; + }; + + const providerFactory = UnifontFontResolver.astroToUnifontProvider(astroProvider()); + assert.equal(providerFactory._name, 'test'); + const provider = await providerFactory({ storage: new SpyStorage() }); + assert.equal(ran, true); + assert.deepStrictEqual( + await provider?.resolveFont('', { + formats: [], + styles: [], + subsets: [], + weights: [], + }), + { + fonts: [ + { + src: [{ name: 'foo' }], + }, + ], + }, + ); + assert.deepStrictEqual(await provider?.listFonts?.(), ['a', 'b', 'c']); + }); + }); + it('resolveFont() works', async () => { const fontResolver = await UnifontFontResolver.create({ families: [ @@ -689,17 +803,8 @@ describe('fonts infra', () => { nameWithHash: 'Foo-xxx', cssVariable: '--foo', provider: { - provider: () => - Object.assign( - () => { - return { - resolveFont: async () => { - return undefined; - }, - }; - }, - { _name: 'foo', _options: undefined }, - ), + name: 'foo', + resolveFont: () => undefined, }, }, { @@ -707,23 +812,14 @@ describe('fonts infra', () => { nameWithHash: 'Bar-xxx', cssVariable: '--bar', provider: { - provider: () => - Object.assign( - () => { - return { - resolveFont: async () => { - return { - fonts: [ - { - src: [{ name: 'Bar' }], - }, - ], - }; - }, - }; + name: 'bar', + resolveFont: () => ({ + fonts: [ + { + src: [{ name: 'Bar' }], }, - { _name: 'bar', _options: undefined }, - ), + ], + }), }, }, ], @@ -734,10 +830,10 @@ describe('fonts infra', () => { await fontResolver.resolveFont({ familyName: 'Foo', provider: 'foo-{"name":"foo"}', - weights: undefined, - styles: undefined, - subsets: undefined, - formats: undefined, + weights: [], + styles: [], + subsets: [], + formats: [], }), [], ); @@ -745,10 +841,10 @@ describe('fonts infra', () => { await fontResolver.resolveFont({ familyName: 'Bar', provider: 'bar-{"name":"bar"}', - weights: undefined, - styles: undefined, - subsets: undefined, - formats: undefined, + weights: [], + styles: [], + subsets: [], + formats: [], }), [ { @@ -766,17 +862,8 @@ describe('fonts infra', () => { nameWithHash: 'Foo-xxx', cssVariable: '--foo', provider: { - provider: () => - Object.assign( - () => { - return { - resolveFont: async () => { - return undefined; - }, - }; - }, - { _name: 'foo', _options: undefined }, - ), + name: 'foo', + resolveFont: () => undefined, }, }, { @@ -784,18 +871,9 @@ describe('fonts infra', () => { nameWithHash: 'Bar-xxx', cssVariable: '--bar', provider: { - provider: () => - Object.assign( - () => { - return { - resolveFont: async () => { - return undefined; - }, - listFonts: async () => ['a', 'b', 'c'], - }; - }, - { _name: 'bar', _options: undefined }, - ), + name: 'bar', + resolveFont: () => undefined, + listFonts: () => ['a', 'b', 'c'], }, }, ], diff --git a/packages/astro/test/units/assets/fonts/orchestrate.test.js b/packages/astro/test/units/assets/fonts/orchestrate.test.js index af90ee929a6b..092aee669f5a 100644 --- a/packages/astro/test/units/assets/fonts/orchestrate.test.js +++ b/packages/astro/test/units/assets/fonts/orchestrate.test.js @@ -2,10 +2,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { defineFontProvider } from 'unifont'; import { joinPaths } from '../../../../../internal-helpers/dist/path.js'; import { DEFAULTS } from '../../../../dist/assets/fonts/constants.js'; -import { BuildRemoteFontProviderModResolver } from '../../../../dist/assets/fonts/infra/build-remote-font-provider-mod-resolver.js'; import { BuildUrlProxyHashResolver } from '../../../../dist/assets/fonts/infra/build-url-proxy-hash-resolver.js'; import { RealDataCollector } from '../../../../dist/assets/fonts/infra/data-collector.js'; import { DevUrlResolver } from '../../../../dist/assets/fonts/infra/dev-url-resolver.js'; @@ -13,7 +11,6 @@ import { RealFontTypeExtractor } from '../../../../dist/assets/fonts/infra/font- import { FontaceFontFileReader } from '../../../../dist/assets/fonts/infra/fontace-font-file-reader.js'; import { LevenshteinStringMatcher } from '../../../../dist/assets/fonts/infra/levenshtein-string-matcher.js'; import { MinifiableCssRenderer } from '../../../../dist/assets/fonts/infra/minifiable-css-renderer.js'; -import { RealRemoteFontProviderResolver } from '../../../../dist/assets/fonts/infra/remote-font-provider-resolver.js'; import { RemoteUrlProxyContentResolver } from '../../../../dist/assets/fonts/infra/remote-url-proxy-content-resolver.js'; import { RequireLocalProviderUrlResolver } from '../../../../dist/assets/fonts/infra/require-local-provider-url-resolver.js'; import { RealSystemFallbacksProvider } from '../../../../dist/assets/fonts/infra/system-fallbacks-provider.js'; @@ -48,12 +45,8 @@ describe('fonts orchestrate()', () => { }, ], hasher, - remoteFontProviderResolver: new RealRemoteFontProviderResolver({ - root, - modResolver: new BuildRemoteFontProviderModResolver(), - }), localProviderUrlResolver: new RequireLocalProviderUrlResolver({ root }), - createFontResolver: async ({ families }) => new PassthroughFontResolver({ families, hasher }), + createFontResolver: async ({ families }) => await PassthroughFontResolver.create({ families, hasher }), cssRenderer: new MinifiableCssRenderer({ minify: true }), systemFallbacksProvider: new RealSystemFallbacksProvider(), fontMetricsResolver: new FakeFontMetricsResolver(), @@ -134,33 +127,6 @@ describe('fonts orchestrate()', () => { }); it('works with a remote provider', async () => { - const fakeUnifontProvider = defineFontProvider('test', () => { - return { - resolveFont: () => { - return { - fonts: [ - { - src: [ - { url: 'https://example.com/foo.woff2' }, - { url: 'https://example.com/foo.woff' }, - ], - weight: '400', - style: 'normal', - meta: { - init: { - method: 'POST', - }, - }, - }, - ], - }; - }, - }; - }); - const fakeAstroProvider = { - entrypoint: 'test', - }; - const root = new URL(import.meta.url); const fontTypeExtractor = new RealFontTypeExtractor(); const hasher = new FakeHasher(); @@ -169,21 +135,32 @@ describe('fonts orchestrate()', () => { { name: 'Test', cssVariable: '--test', - provider: fakeAstroProvider, + provider: { + name: 'test', + resolveFont: () => ({ + fonts: [ + { + src: [ + { url: 'https://example.com/foo.woff2' }, + { url: 'https://example.com/foo.woff' }, + ], + weight: '400', + style: 'normal', + meta: { + init: { + method: 'POST', + }, + }, + }, + ], + }), + }, fallbacks: ['serif'], }, ], hasher, - remoteFontProviderResolver: new RealRemoteFontProviderResolver({ - root, - modResolver: { - resolve: async () => ({ - provider: fakeUnifontProvider, - }), - }, - }), localProviderUrlResolver: new RequireLocalProviderUrlResolver({ root }), - createFontResolver: async ({ families }) => new PassthroughFontResolver({ families, hasher }), + createFontResolver: async ({ families }) => await PassthroughFontResolver.create({ families, hasher }), cssRenderer: new MinifiableCssRenderer({ minify: true }), systemFallbacksProvider: new RealSystemFallbacksProvider(), fontMetricsResolver: new FakeFontMetricsResolver(), @@ -268,17 +245,6 @@ describe('fonts orchestrate()', () => { }); it('warns if remote provider does not return any font data', async () => { - const fakeUnifontProvider = defineFontProvider('test', () => { - return { - resolveFont: () => { - return undefined; - }, - }; - }); - const fakeAstroProvider = { - entrypoint: 'test', - }; - const root = new URL(import.meta.url); const fontTypeExtractor = new RealFontTypeExtractor(); const hasher = new FakeHasher(); @@ -289,21 +255,16 @@ describe('fonts orchestrate()', () => { { name: 'Test', cssVariable: '--test', - provider: fakeAstroProvider, + provider: { + name: 'test', + resolveFont: () => undefined, + }, fallbacks: ['serif'], }, ], hasher, - remoteFontProviderResolver: new RealRemoteFontProviderResolver({ - root, - modResolver: { - resolve: async () => ({ - provider: fakeUnifontProvider, - }), - }, - }), localProviderUrlResolver: new RequireLocalProviderUrlResolver({ root }), - createFontResolver: async ({ families }) => new PassthroughFontResolver({ families, hasher }), + createFontResolver: async ({ families }) => await PassthroughFontResolver.create({ families, hasher }), cssRenderer: new MinifiableCssRenderer({ minify: true }), systemFallbacksProvider: new RealSystemFallbacksProvider(), fontMetricsResolver: new FakeFontMetricsResolver(), @@ -338,18 +299,6 @@ describe('fonts orchestrate()', () => { }); it('warns if remote provider does not support given font family name', async () => { - const fakeUnifontProvider = defineFontProvider('test', () => { - return { - resolveFont: () => { - return undefined; - }, - listFonts: async () => ['Testi', 'XYZ'], - }; - }); - const fakeAstroProvider = { - entrypoint: 'test', - }; - const root = new URL(import.meta.url); const fontTypeExtractor = new RealFontTypeExtractor(); const hasher = new FakeHasher(); @@ -360,21 +309,17 @@ describe('fonts orchestrate()', () => { { name: 'Test', cssVariable: '--test', - provider: fakeAstroProvider, + provider: { + name: 'test', + resolveFont: () => undefined, + listFonts: async () => ['Testi', 'XYZ'], + }, fallbacks: ['serif'], }, ], hasher, - remoteFontProviderResolver: new RealRemoteFontProviderResolver({ - root, - modResolver: { - resolve: async () => ({ - provider: fakeUnifontProvider, - }), - }, - }), localProviderUrlResolver: new RequireLocalProviderUrlResolver({ root }), - createFontResolver: async ({ families }) => new PassthroughFontResolver({ families, hasher }), + createFontResolver: async ({ families }) => await PassthroughFontResolver.create({ families, hasher }), cssRenderer: new MinifiableCssRenderer({ minify: true }), systemFallbacksProvider: new RealSystemFallbacksProvider(), fontMetricsResolver: new FakeFontMetricsResolver(), @@ -415,24 +360,18 @@ describe('fonts orchestrate()', () => { }); it('warns if conflicting unmergeable families exist', async () => { - const fakeUnifontProvider = defineFontProvider('test', () => { - return { - resolveFont: () => { - return { - fonts: [ - { - src: [ - { url: 'https://example.com/foo.woff2' }, - { url: 'https://example.com/foo.woff' }, - ], - }, + const provider = { + name: 'test', + resolveFont: () => ({ + fonts: [ + { + src: [ + { url: 'https://example.com/foo.woff2' }, + { url: 'https://example.com/foo.woff' }, ], - }; - }, - }; - }); - const fakeAstroProvider = { - entrypoint: 'test', + }, + ], + }), }; const root = new URL(import.meta.url); @@ -445,27 +384,19 @@ describe('fonts orchestrate()', () => { { name: 'Test', cssVariable: '--test', - provider: fakeAstroProvider, + provider, fallbacks: ['serif'], }, { name: 'Foo', cssVariable: '--test', - provider: fakeAstroProvider, + provider, fallbacks: ['serif'], }, ], hasher, - remoteFontProviderResolver: new RealRemoteFontProviderResolver({ - root, - modResolver: { - resolve: async () => ({ - provider: fakeUnifontProvider, - }), - }, - }), localProviderUrlResolver: new RequireLocalProviderUrlResolver({ root }), - createFontResolver: async ({ families }) => new PassthroughFontResolver({ families, hasher }), + createFontResolver: async ({ families }) => await PassthroughFontResolver.create({ families, hasher }), cssRenderer: new MinifiableCssRenderer({ minify: true }), systemFallbacksProvider: new RealSystemFallbacksProvider(), fontMetricsResolver: new FakeFontMetricsResolver(), @@ -507,24 +438,18 @@ describe('fonts orchestrate()', () => { }); it('does not if mergeable families exist', async () => { - const fakeUnifontProvider = defineFontProvider('test', () => { - return { - resolveFont: () => { - return { - fonts: [ - { - src: [ - { url: 'https://example.com/foo.woff2' }, - { url: 'https://example.com/foo.woff' }, - ], - }, + const provider = { + name: 'test', + resolveFont: () => ({ + fonts: [ + { + src: [ + { url: 'https://example.com/foo.woff2' }, + { url: 'https://example.com/foo.woff' }, ], - }; - }, - }; - }); - const fakeAstroProvider = { - entrypoint: 'test', + }, + ], + }), }; const root = new URL(import.meta.url); @@ -537,27 +462,19 @@ describe('fonts orchestrate()', () => { { name: 'Test', cssVariable: '--test', - provider: fakeAstroProvider, + provider, fallbacks: ['serif'], }, { name: 'Test', cssVariable: '--test', - provider: fakeAstroProvider, + provider, fallbacks: ['serif'], }, ], hasher, - remoteFontProviderResolver: new RealRemoteFontProviderResolver({ - root, - modResolver: { - resolve: async () => ({ - provider: fakeUnifontProvider, - }), - }, - }), localProviderUrlResolver: new RequireLocalProviderUrlResolver({ root }), - createFontResolver: async ({ families }) => new PassthroughFontResolver({ families, hasher }), + createFontResolver: async ({ families }) => await PassthroughFontResolver.create({ families, hasher }), cssRenderer: new MinifiableCssRenderer({ minify: true }), systemFallbacksProvider: new RealSystemFallbacksProvider(), fontMetricsResolver: new FakeFontMetricsResolver(), diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 5b2101b5ec5d..d635486e8bd4 100644 --- a/packages/astro/test/units/assets/fonts/providers.test.js +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -3,29 +3,18 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { RealFontTypeExtractor } from '../../../../dist/assets/fonts/infra/font-type-extractor.js'; import { FontaceFontFileReader } from '../../../../dist/assets/fonts/infra/fontace-font-file-reader.js'; -import * as adobeEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/adobe.js'; -import * as bunnyEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/bunny.js'; -import * as fontshareEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/fontshare.js'; -import * as fontsourceEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/fontsource.js'; -import * as googleEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/google.js'; import { resolveLocalFont } from '../../../../dist/assets/fonts/providers/local.js'; import { fontProviders } from '../../../../dist/config/entrypoint.js'; import { SpyUrlProxy } from './utils.js'; describe('fonts providers', () => { describe('config objects', () => { - it('references the right entrypoints', () => { - assert.equal( - fontProviders.adobe({ id: '' }).entrypoint, - 'astro/assets/fonts/providers/adobe', - ); - assert.equal(fontProviders.bunny().entrypoint, 'astro/assets/fonts/providers/bunny'); - assert.equal(fontProviders.fontshare().entrypoint, 'astro/assets/fonts/providers/fontshare'); - assert.equal( - fontProviders.fontsource().entrypoint, - 'astro/assets/fonts/providers/fontsource', - ); - assert.equal(fontProviders.google().entrypoint, 'astro/assets/fonts/providers/google'); + it('references the right names', () => { + assert.equal(fontProviders.adobe({ id: '' }).name, 'adobe'); + assert.equal(fontProviders.bunny().name, 'bunny'); + assert.equal(fontProviders.fontshare().name, 'fontshare'); + assert.equal(fontProviders.fontsource().name, 'fontsource'); + assert.equal(fontProviders.google().name, 'google'); }); it('forwards the config', () => { @@ -35,29 +24,6 @@ describe('fonts providers', () => { }); }); - it('providers are correctly exported', () => { - assert.equal( - 'provider' in adobeEntrypoint && typeof adobeEntrypoint.provider === 'function', - true, - ); - assert.equal( - 'provider' in bunnyEntrypoint && typeof bunnyEntrypoint.provider === 'function', - true, - ); - assert.equal( - 'provider' in fontshareEntrypoint && typeof fontshareEntrypoint.provider === 'function', - true, - ); - assert.equal( - 'provider' in fontsourceEntrypoint && typeof fontsourceEntrypoint.provider === 'function', - true, - ); - assert.equal( - 'provider' in googleEntrypoint && typeof googleEntrypoint.provider === 'function', - true, - ); - }); - describe('resolveLocalFont()', () => { const fontTypeExtractor = new RealFontTypeExtractor(); @@ -170,7 +136,7 @@ describe('fonts providers', () => { it('computes the format correctly', () => { const urlProxy = new SpyUrlProxy(); - const { fonts } = resolveLocalFont({ + const fonts = resolveLocalFont({ urlProxy, fontTypeExtractor, fontFileReader: new FontaceFontFileReader(), @@ -218,7 +184,7 @@ describe('fonts providers', () => { describe('properties inference', () => { it('infers properties correctly', async () => { const urlProxy = new SpyUrlProxy(); - const { fonts } = resolveLocalFont({ + const fonts = resolveLocalFont({ urlProxy, fontTypeExtractor, family: { @@ -274,7 +240,7 @@ describe('fonts providers', () => { it('respects what property should be inferred', async () => { const urlProxy = new SpyUrlProxy(); - const { fonts } = resolveLocalFont({ + const fonts = resolveLocalFont({ urlProxy, fontTypeExtractor, family: { diff --git a/packages/astro/test/units/assets/fonts/utils.js b/packages/astro/test/units/assets/fonts/utils.js index f962fc9e7ecd..246ba2c30410 100644 --- a/packages/astro/test/units/assets/fonts/utils.js +++ b/packages/astro/test/units/assets/fonts/utils.js @@ -1,7 +1,5 @@ // @ts-check -import { UnifontFontResolver } from '../../../../dist/assets/fonts/infra/unifont-font-resolver.js'; - /** * @import { Hasher, UrlProxy, FontMetricsResolver, Storage, FontResolver } from '../../../../dist/assets/fonts/definitions' */ @@ -125,52 +123,51 @@ export function markdownBold(input) { /** @implements {FontResolver} */ export class PassthroughFontResolver { - /** @type {Array} */ + /** @type {Map} */ #providers; /** - * @param {{ families: Array; hasher: Hasher }} param0 + * @private + * @param {Map} providers */ - constructor({ families, hasher }) { - this.#providers = UnifontFontResolver.extractUnifontProviders({ families, hasher }); + constructor(providers) { + this.#providers = providers; } /** - * @param {string} name + * @param {{ families: Array; hasher: Hasher }} param0 */ - async #getProvider(name) { - const providerFactory = this.#providers.find((e) => e._name === name); - if (!providerFactory) { - return undefined; + static async create({ families, hasher }) { + /** @type {Map} */ + const providers = new Map(); + for (const { provider } of families) { + if (provider === 'local') { + continue; + } + provider.name = `${provider.name}-${hasher.hashObject(provider.config ?? {})}`; + providers.set(provider.name, provider); } - return await providerFactory({ storage: new SpyStorage() }); + const storage = new SpyStorage(); + await Promise.all( + Array.from(providers.values()).map(async (provider) => { + await provider.init?.({ storage }); + }), + ); + return new PassthroughFontResolver(providers); } /** * @param {import('../../../../dist/assets/fonts/types').ResolveFontOptions & { provider: string; }} param0 */ - async resolveFont({ familyName, provider: providerName, ...options }) { - const provider = await this.#getProvider(providerName); - if (!provider) { - return []; - } - const res = await provider.resolveFont(familyName, { - weights: options.weights ?? [], - styles: options.styles ?? [], - subsets: options.subsets ?? [], - formats: options.formats ?? [], - }); + async resolveFont({ provider, ...rest }) { + const res = await this.#providers.get(provider)?.resolveFont(rest); return res?.fonts ?? []; } /** * @param {{ provider: string }} param0 */ - async listFonts({ provider: providerName }) { - const provider = await this.#getProvider(providerName); - if (!provider) { - return []; - } - return await provider.listFonts?.(); + async listFonts({ provider }) { + return await this.#providers.get(provider)?.listFonts?.(); } } diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 482dcdb98074..ffc83813e3a4 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -412,7 +412,13 @@ describe('Config Validation', () => { it('Should error on invalid css variable', async () => { let configError = await validateConfig({ experimental: { - fonts: [{ name: 'Roboto', cssVariable: 'test', provider: { entrypoint: '' } }], + fonts: [ + { + name: 'Roboto', + cssVariable: 'test', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], }, }).catch((err) => err); assert.equal(configError instanceof z.ZodError, true); @@ -425,7 +431,13 @@ describe('Config Validation', () => { configError = await validateConfig({ experimental: { - fonts: [{ name: 'Roboto', cssVariable: '-test', provider: { entrypoint: '' } }], + fonts: [ + { + name: 'Roboto', + cssVariable: '-test', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], }, }).catch((err) => err); assert.equal(configError instanceof z.ZodError, true); @@ -438,7 +450,13 @@ describe('Config Validation', () => { configError = await validateConfig({ experimental: { - fonts: [{ name: 'Roboto', cssVariable: '--test ', provider: { entrypoint: '' } }], + fonts: [ + { + name: 'Roboto', + cssVariable: '--test ', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], }, }).catch((err) => err); assert.equal(configError instanceof z.ZodError, true); @@ -451,7 +469,13 @@ describe('Config Validation', () => { configError = await validateConfig({ experimental: { - fonts: [{ name: 'Roboto', cssVariable: '--test:x', provider: { entrypoint: '' } }], + fonts: [ + { + name: 'Roboto', + cssVariable: '--test:x', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], }, }).catch((err) => err); assert.equal(configError instanceof z.ZodError, true); @@ -465,7 +489,13 @@ describe('Config Validation', () => { assert.doesNotThrow(() => validateConfig({ experimental: { - fonts: [{ name: 'Roboto', cssVariable: '--test', provider: { entrypoint: '' } }], + fonts: [ + { + name: 'Roboto', + cssVariable: '--test', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], }, }), );