diff --git a/packages/astro/components/Font.astro b/packages/astro/components/Font.astro index 40d1ed6942d3..13f05a44c803 100644 --- a/packages/astro/components/Font.astro +++ b/packages/astro/components/Font.astro @@ -4,8 +4,8 @@ import { filterPreloads } from 'astro/assets/fonts/runtime'; import { AstroError, AstroErrorData } from '../dist/core/errors/index.js'; // TODO: remove check when fonts are stabilized -const { internalConsumableMap } = mod; -if (!internalConsumableMap) { +const { componentDataByCssVariable } = mod; +if (!componentDataByCssVariable) { throw new AstroError(AstroErrorData.ExperimentalFontsNotEnabled); } @@ -17,7 +17,7 @@ interface Props { } const { cssVariable, preload = false } = Astro.props as Props; -const data = internalConsumableMap.get(cssVariable); +const data = componentDataByCssVariable.get(cssVariable); if (!data) { throw new AstroError({ ...AstroErrorData.FontFamilyNotFound, @@ -25,7 +25,7 @@ if (!data) { }); } -const filteredPreloadData = filterPreloads(data.preloadData, preload); +const filteredPreloadData = filterPreloads(data.preloads, preload); --- diff --git a/packages/astro/dev-only.d.ts b/packages/astro/dev-only.d.ts index 94f4dbb89cbd..dd7d43618921 100644 --- a/packages/astro/dev-only.d.ts +++ b/packages/astro/dev-only.d.ts @@ -7,8 +7,8 @@ declare module 'virtual:astro:env/internal' { } declare module 'virtual:astro:assets/fonts/internal' { - export const internalConsumableMap: import('./src/assets/fonts/types.js').InternalConsumableMap; - export const consumableMap: import('./src/assets/fonts/types.js').ConsumableMap; + export const componentDataByCssVariable: import('./src/assets/fonts/types.js').ComponentDataByCssVariable; + export const fontDataByCssVariable: import('./src/assets/fonts/types.js').FontDataByCssVariable; } declare module 'virtual:astro:adapter-config/client' { diff --git a/packages/astro/src/assets/fonts/README.md b/packages/astro/src/assets/fonts/README.md index 7326407f6fbb..f95ba8eb8327 100644 --- a/packages/astro/src/assets/fonts/README.md +++ b/packages/astro/src/assets/fonts/README.md @@ -3,7 +3,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 resolves font families (eg. deduplication) - 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 diff --git a/packages/astro/src/assets/fonts/config.ts b/packages/astro/src/assets/fonts/config.ts index 88925e1e2f7e..fcfa1ca1e867 100644 --- a/packages/astro/src/assets/fonts/config.ts +++ b/packages/astro/src/assets/fonts/config.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { FONT_TYPES, LOCAL_PROVIDER_NAME } from './constants.js'; +import { FONT_TYPES } from './constants.js'; import type { FontProvider } from './types.js'; export const weightSchema = z.union([z.string(), z.number()]); @@ -26,34 +26,6 @@ const requiredFamilyAttributesSchema = z.object({ cssVariable: z.string(), }); -const entrypointSchema = z.union([z.string(), z.instanceof(URL)]); - -export const localFontFamilySchema = z - .object({ - ...requiredFamilyAttributesSchema.shape, - ...fallbacksSchema.shape, - provider: z.literal(LOCAL_PROVIDER_NAME), - variants: z - .array( - z - .object({ - ...familyPropertiesSchema.shape, - src: z - .array( - z.union([ - entrypointSchema, - z.object({ url: entrypointSchema, tech: z.string().optional() }).strict(), - ]), - ) - .nonempty(), - // TODO: find a way to support subsets (through fontkit?) - }) - .strict(), - ) - .nonempty(), - }) - .strict(); - export const fontProviderSchema = z .object({ name: z.string(), @@ -64,7 +36,7 @@ export const fontProviderSchema = z }) .strict(); -export const remoteFontFamilySchema = z +export const fontFamilySchema = z .object({ ...requiredFamilyAttributesSchema.shape, ...fallbacksSchema.shape, diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index 110fd102fd5c..0ee81c01ef4d 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -1,7 +1,5 @@ import type { Defaults, FontType } from './types.js'; -export const LOCAL_PROVIDER_NAME = 'local'; - export const DEFAULTS: Defaults = { weights: ['400'], styles: ['normal', 'italic'], diff --git a/packages/astro/src/assets/fonts/core/collect-component-data.ts b/packages/astro/src/assets/fonts/core/collect-component-data.ts new file mode 100644 index 000000000000..a70e73abf57e --- /dev/null +++ b/packages/astro/src/assets/fonts/core/collect-component-data.ts @@ -0,0 +1,72 @@ +import type { CssRenderer } from '../definitions.js'; +import type { + Collaborator, + ComponentDataByCssVariable, + Defaults, + FontFamilyAssets, +} from '../types.js'; +import { unifontFontFaceDataToProperties } from '../utils.js'; +import type { optimizeFallbacks as _optimizeFallbacks } from './optimize-fallbacks.js'; + +export async function collectComponentData({ + fontFamilyAssets, + cssRenderer, + defaults, + optimizeFallbacks, +}: { + fontFamilyAssets: Array; + cssRenderer: CssRenderer; + defaults: Pick; + optimizeFallbacks: Collaborator< + typeof _optimizeFallbacks, + 'family' | 'fallbacks' | 'collectedFonts' + >; +}) { + const componentDataByCssVariable: ComponentDataByCssVariable = new Map(); + + for (const { family, fonts, collectedFontsForMetricsByUniqueKey, preloads } of fontFamilyAssets) { + let css = ''; + + for (const data of fonts) { + css += cssRenderer.generateFontFace( + family.uniqueName, + unifontFontFaceDataToProperties({ + src: data.src, + weight: data.weight, + style: data.style, + // User settings override the generated font settings + display: data.display ?? family.display, + unicodeRange: data.unicodeRange ?? family.unicodeRange, + stretch: data.stretch ?? family.stretch, + featureSettings: data.featureSettings ?? family.featureSettings, + variationSettings: data.variationSettings ?? family.variationSettings, + }), + ); + } + + const fallbacks = family.fallbacks ?? defaults.fallbacks; + const cssVarValues = [family.uniqueName]; + const optimizeFallbacksResult = + (family.optimizedFallbacks ?? defaults.optimizedFallbacks) + ? await optimizeFallbacks({ + family, + fallbacks, + collectedFonts: Array.from(collectedFontsForMetricsByUniqueKey.values()), + }) + : null; + + if (optimizeFallbacksResult) { + css += optimizeFallbacksResult.css; + cssVarValues.push(...optimizeFallbacksResult.fallbacks); + } else { + // If there are no optimized fallbacks, we pass the provided fallbacks as is. + cssVarValues.push(...fallbacks); + } + + css += cssRenderer.generateCssVariable(family.cssVariable, cssVarValues); + + componentDataByCssVariable.set(family.cssVariable, { preloads, css }); + } + + return componentDataByCssVariable; +} diff --git a/packages/astro/src/assets/fonts/core/collect-font-assets-from-faces.ts b/packages/astro/src/assets/fonts/core/collect-font-assets-from-faces.ts new file mode 100644 index 000000000000..db2056007ad6 --- /dev/null +++ b/packages/astro/src/assets/fonts/core/collect-font-assets-from-faces.ts @@ -0,0 +1,96 @@ +import type * as unifont from 'unifont'; +import { FONT_FORMATS } from '../constants.js'; +import type { FontFileIdGenerator, Hasher } from '../definitions.js'; +import type { Defaults, FontFileById, PreloadData, ResolvedFontFamily } from '../types.js'; +import { renderFontWeight } from '../utils.js'; +import type { CollectedFontForMetrics } from './optimize-fallbacks.js'; + +export function collectFontAssetsFromFaces({ + fonts, + fontFileIdGenerator, + family, + fontFilesIds, + collectedFontsIds, + hasher, + defaults, +}: { + fonts: Array; + fontFileIdGenerator: FontFileIdGenerator; + family: Pick; + fontFilesIds: Set; + collectedFontsIds: Set; + hasher: Hasher; + defaults: Pick; +}) { + const fontFileById: FontFileById = new Map(); + const collectedFontsForMetricsByUniqueKey = new Map(); + const preloads: Array = []; + + for (const font of fonts) { + // The index keeps track of encountered URLs. We can't use a regular for loop + // below because it may contain sources without urls, which would prevent preloading completely + let index = 0; + for (const source of font.src) { + if ('name' in source) { + continue; + } + const format = FONT_FORMATS.find((e) => e.format === source.format)!; + const originalUrl = source.originalURL!; + const id = fontFileIdGenerator.generate({ + cssVariable: family.cssVariable, + font, + originalUrl, + type: format.type, + }); + + if (!fontFilesIds.has(id) && !fontFileById.has(id)) { + fontFileById.set(id, { url: source.url, init: font.meta?.init }); + // We only collect the first URL to avoid preloading fallback sources (eg. we only + // preload woff2 if woff is available) + if (index === 0) { + preloads.push({ + style: font.style, + subset: font.meta?.subset, + type: format.type, + url: source.url, + weight: renderFontWeight(font.weight), + }); + } + } + + const collected: CollectedFontForMetrics = { + hash: id, + url: originalUrl, + init: font.meta?.init, + data: { + weight: font.weight, + style: font.style, + meta: { + subset: font.meta?.subset, + }, + }, + }; + const collectedKey = hasher.hashObject(collected.data); + const fallbacks = family.fallbacks ?? defaults.fallbacks; + if ( + fallbacks.length > 0 && + // If the same data has already been sent for this family, we don't want to have + // duplicated fallbacks. Such scenario can occur with unicode ranges. + !collectedFontsIds.has(collectedKey) && + !collectedFontsForMetricsByUniqueKey.has(collectedKey) + ) { + // If a family has fallbacks, we store the first url we get that may + // be used for the fallback generation. + collectedFontsForMetricsByUniqueKey.set(collectedKey, collected); + } + + index++; + } + } + + return { + fontFileById, + preloads, + collectedFontsForMetricsByUniqueKey, + }; +} diff --git a/packages/astro/src/assets/fonts/core/collect-font-data.ts b/packages/astro/src/assets/fonts/core/collect-font-data.ts new file mode 100644 index 000000000000..689805409255 --- /dev/null +++ b/packages/astro/src/assets/fonts/core/collect-font-data.ts @@ -0,0 +1,31 @@ +import type { FontData, FontDataByCssVariable, FontFamilyAssets } from '../types.js'; +import { renderFontWeight } from '../utils.js'; + +export function collectFontData( + fontFamilyAssets: Array< + Pick & { family: Pick } + >, +) { + const fontDataByCssVariable: FontDataByCssVariable = new Map(); + + for (const { family, fonts } of fontFamilyAssets) { + const fontData: Array = []; + for (const data of fonts) { + fontData.push({ + weight: renderFontWeight(data.weight), + style: data.style, + src: data.src + .filter((src) => 'url' in src) + .map((src) => ({ + url: src.url, + format: src.format, + tech: src.tech, + })), + }); + } + + fontDataByCssVariable.set(family.cssVariable, fontData); + } + + return fontDataByCssVariable; +} diff --git a/packages/astro/src/assets/fonts/core/compute-font-families-assets.ts b/packages/astro/src/assets/fonts/core/compute-font-families-assets.ts new file mode 100644 index 000000000000..6164656e06a0 --- /dev/null +++ b/packages/astro/src/assets/fonts/core/compute-font-families-assets.ts @@ -0,0 +1,116 @@ +import type { Logger } from '../../../core/logger/core.js'; +import type { FontResolver, StringMatcher } from '../definitions.js'; +import type { + Collaborator, + Defaults, + FontFamilyAssetsByUniqueKey, + FontFileById, + ResolvedFontFamily, +} from '../types.js'; +import type { collectFontAssetsFromFaces as _collectFontAssetsFromFaces } from './collect-font-assets-from-faces.js'; +import type { filterAndTransformFontFaces as _filterAndTransformFontFaces } from './filter-and-transform-font-faces.js'; +import type { getOrCreateFontFamilyAssets as _getOrCreateFontFamilyAssets } from './get-or-create-font-family-assets.js'; + +export async function computeFontFamiliesAssets({ + resolvedFamilies, + fontResolver, + logger, + bold, + defaults, + stringMatcher, + getOrCreateFontFamilyAssets, + collectFontAssetsFromFaces, + filterAndTransformFontFaces, +}: { + resolvedFamilies: Array; + fontResolver: FontResolver; + logger: Logger; + bold: (input: string) => string; + defaults: Defaults; + stringMatcher: StringMatcher; + getOrCreateFontFamilyAssets: Collaborator< + typeof _getOrCreateFontFamilyAssets, + 'family' | 'fontFamilyAssetsByUniqueKey' + >; + filterAndTransformFontFaces: Collaborator< + typeof _filterAndTransformFontFaces, + 'family' | 'fonts' + >; + collectFontAssetsFromFaces: Collaborator< + typeof _collectFontAssetsFromFaces, + 'family' | 'fonts' | 'collectedFontsIds' | 'fontFilesIds' + >; +}) { + /** + * Holds family data by a key, to allow merging families + */ + const fontFamilyAssetsByUniqueKey: FontFamilyAssetsByUniqueKey = new Map(); + + /** + * Holds associations of hash and original font file URLs, so they can be + * downloaded whenever the hash is requested. + */ + const fontFileById: FontFileById = new Map(); + + // 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 fontAssets = getOrCreateFontFamilyAssets({ + fontFamilyAssetsByUniqueKey, + family, + }); + + const _fonts = await fontResolver.resolveFont({ + familyName: family.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, + subsets: family.subsets ?? defaults.subsets, + formats: family.formats ?? defaults.formats, + options: family.options, + }); + if (_fonts.length === 0) { + logger.warn( + 'assets', + `No data found for font family ${bold(family.name)}. Review your configuration`, + ); + const availableFamilies = await fontResolver.listFonts({ provider: family.provider.name }); + if ( + availableFamilies && + availableFamilies.length > 0 && + !availableFamilies.includes(family.name) + ) { + logger.warn( + 'assets', + `${bold(family.name)} font family cannot be retrieved by the provider. Did you mean ${bold(stringMatcher.getClosestMatch(family.name, availableFamilies))}?`, + ); + } + continue; + } + // The data returned by the provider contains original URLs. We proxy them. + // TODO: dedupe? + fontAssets.fonts.push( + ...filterAndTransformFontFaces({ + fonts: _fonts, + family, + }), + ); + + const result = collectFontAssetsFromFaces({ + fonts: fontAssets.fonts, + family, + fontFilesIds: new Set(fontFileById.keys()), + collectedFontsIds: new Set(fontAssets.collectedFontsForMetricsByUniqueKey.keys()), + }); + for (const [key, value] of result.fontFileById.entries()) { + fontFileById.set(key, value); + } + for (const [key, value] of result.collectedFontsForMetricsByUniqueKey.entries()) { + fontAssets.collectedFontsForMetricsByUniqueKey.set(key, value); + } + fontAssets.preloads.push(...result.preloads); + } + + return { fontFamilyAssets: Array.from(fontFamilyAssetsByUniqueKey.values()), fontFileById }; +} diff --git a/packages/astro/src/assets/fonts/core/filter-and-transform-font-faces.ts b/packages/astro/src/assets/fonts/core/filter-and-transform-font-faces.ts new file mode 100644 index 000000000000..6bd915de9f91 --- /dev/null +++ b/packages/astro/src/assets/fonts/core/filter-and-transform-font-faces.ts @@ -0,0 +1,55 @@ +import type * as unifont from 'unifont'; +import { FONT_FORMATS } from '../constants.js'; +import type { FontFileIdGenerator, FontTypeExtractor, UrlResolver } from '../definitions.js'; +import type { ResolvedFontFamily } from '../types.js'; + +export function filterAndTransformFontFaces({ + fonts, + fontTypeExtractor, + fontFileIdGenerator, + urlResolver, + family, +}: { + fonts: Array; + fontTypeExtractor: FontTypeExtractor; + fontFileIdGenerator: FontFileIdGenerator; + urlResolver: UrlResolver; + family: Pick; +}) { + return ( + fonts + // Avoid getting too much font files + .filter((font) => (typeof font.meta?.priority === 'number' ? font.meta.priority <= 1 : true)) + // Collect URLs + .map((font) => ({ + ...font, + src: font.src.map((source) => { + if ('name' in source) { + return source; + } + // We handle protocol relative URLs here, otherwise they're considered absolute by the font + // fetcher which will try to read them from the file system + const originalUrl = source.url.startsWith('//') ? `https:${source.url}` : source.url; + let format = FONT_FORMATS.find((e) => e.format === source.format); + if (!format) { + format = FONT_FORMATS.find((e) => e.type === fontTypeExtractor.extract(source.url))!; + } + const id = fontFileIdGenerator.generate({ + cssVariable: family.cssVariable, + font, + originalUrl, + type: format.type, + }); + const url = urlResolver.resolve(id); + + const newSource: unifont.RemoteFontSource = { + originalURL: originalUrl, + url, + format: format.format, + tech: source.tech, + }; + return newSource; + }), + })) + ); +} diff --git a/packages/astro/src/assets/fonts/core/get-or-create-font-family-assets.ts b/packages/astro/src/assets/fonts/core/get-or-create-font-family-assets.ts new file mode 100644 index 000000000000..589a8c4671c5 --- /dev/null +++ b/packages/astro/src/assets/fonts/core/get-or-create-font-family-assets.ts @@ -0,0 +1,41 @@ +import type { Logger } from '../../../core/logger/core.js'; +import type { FontFamilyAssetsByUniqueKey, ResolvedFontFamily } from '../types.js'; + +export function getOrCreateFontFamilyAssets({ + fontFamilyAssetsByUniqueKey, + logger, + bold, + family, +}: { + fontFamilyAssetsByUniqueKey: FontFamilyAssetsByUniqueKey; + logger: Logger; + bold: (input: string) => string; + family: ResolvedFontFamily; +}) { + const key = `${family.cssVariable}:${family.name}:${family.provider.name}`; + let fontAssets = fontFamilyAssetsByUniqueKey.get(key); + if (!fontAssets) { + if ( + Array.from(fontFamilyAssetsByUniqueKey.keys()).find((k) => + k.startsWith(`${family.cssVariable}:`), + ) + ) { + logger.warn( + 'assets', + `Several font families have been registered for the ${bold(family.cssVariable)} cssVariable but they do not share the same name and provider.`, + ); + logger.warn( + 'assets', + 'These families will not be merged together. The last occurrence will override previous families for this cssVariable. Review your Astro configuration.', + ); + } + fontAssets = { + family, + fonts: [], + collectedFontsForMetricsByUniqueKey: new Map(), + preloads: [], + }; + fontFamilyAssetsByUniqueKey.set(key, fontAssets); + } + return fontAssets; +} diff --git a/packages/astro/src/assets/fonts/core/normalize-remote-font-faces.ts b/packages/astro/src/assets/fonts/core/normalize-remote-font-faces.ts deleted file mode 100644 index 818104396ea6..000000000000 --- a/packages/astro/src/assets/fonts/core/normalize-remote-font-faces.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type * as unifont from 'unifont'; -import { FONT_FORMATS } from '../constants.js'; -import type { FontTypeExtractor, UrlProxy } from '../definitions.js'; - -export function normalizeRemoteFontFaces({ - fonts, - urlProxy, - fontTypeExtractor, -}: { - fonts: Array; - urlProxy: UrlProxy; - fontTypeExtractor: FontTypeExtractor; -}): Array { - return ( - fonts - // Avoid getting too much font files - .filter((font) => (typeof font.meta?.priority === 'number' ? font.meta.priority <= 1 : true)) - // Collect URLs - .map((font) => { - // The index keeps track of encountered URLs. We can't use the index on font.src.map - // below because it may contain sources without urls, which would prevent preloading completely - let index = 0; - return { - ...font, - src: font.src.map((source) => { - if ('name' in source) { - return source; - } - // We handle protocol relative URLs here, otherwise they're considered absolute by the font - // fetcher which will try to read them from the file system - const url = source.url.startsWith('//') ? `https:${source.url}` : source.url; - const proxied = { - ...source, - originalURL: url, - url: urlProxy.proxy({ - url, - type: - FONT_FORMATS.find((e) => e.format === source.format)?.type ?? - fontTypeExtractor.extract(source.url), - // We only collect the first URL to avoid preloading fallback sources (eg. we only - // preload woff2 if woff is available) - collectPreload: index === 0, - data: { - weight: font.weight, - style: font.style, - subset: font.meta?.subset, - }, - init: font.meta?.init ?? null, - }), - }; - index++; - return proxied; - }), - }; - }) - ); -} diff --git a/packages/astro/src/assets/fonts/core/optimize-fallbacks.ts b/packages/astro/src/assets/fonts/core/optimize-fallbacks.ts index 5e97680524b0..a706a19d281e 100644 --- a/packages/astro/src/assets/fonts/core/optimize-fallbacks.ts +++ b/packages/astro/src/assets/fonts/core/optimize-fallbacks.ts @@ -11,14 +11,12 @@ export async function optimizeFallbacks({ family, fallbacks: _fallbacks, collectedFonts, - enabled, systemFallbacksProvider, fontMetricsResolver, }: { - family: Pick; + family: Pick; fallbacks: Array; collectedFonts: Array; - enabled: boolean; systemFallbacksProvider: SystemFallbacksProvider; fontMetricsResolver: FontMetricsResolver; }): Promise ({ font, // We mustn't wrap in quote because that's handled by the CSS renderer - name: `${family.nameWithHash} fallback: ${font}`, + name: `${family.uniqueName} fallback: ${font}`, })); // We prepend the fallbacks with the local fonts and we dedupe in case a local font is already provided diff --git a/packages/astro/src/assets/fonts/core/resolve-families.ts b/packages/astro/src/assets/fonts/core/resolve-families.ts deleted file mode 100644 index 8e6141fbfa07..000000000000 --- a/packages/astro/src/assets/fonts/core/resolve-families.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { LOCAL_PROVIDER_NAME } from '../constants.js'; -import type { Hasher, LocalProviderUrlResolver } from '../definitions.js'; -import type { - FontFamily, - LocalFontFamily, - ResolvedFontFamily, - ResolvedLocalFontFamily, -} from '../types.js'; -import { dedupe, withoutQuotes } from '../utils.js'; - -function resolveVariants({ - variants, - localProviderUrlResolver, -}: { - variants: LocalFontFamily['variants']; - localProviderUrlResolver: LocalProviderUrlResolver; -}): ResolvedLocalFontFamily['variants'] { - return variants.map((variant) => ({ - ...variant, - 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; - const url = (isValue ? value : value.url).toString(); - const tech = isValue ? undefined : value.tech; - return { - url: localProviderUrlResolver.resolve(url), - tech, - }; - }), - })); -} - -/** - * Dedupes properties if applicable and resolves entrypoints. - */ -export function resolveFamily({ - family, - hasher, - localProviderUrlResolver, -}: { - family: FontFamily; - hasher: Hasher; - localProviderUrlResolver: LocalProviderUrlResolver; -}): 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 - // this value contains a space. - const nameWithHash = `${name}-${hasher.hashObject(family)}`; - - if (family.provider === LOCAL_PROVIDER_NAME) { - return { - ...family, - name, - nameWithHash, - variants: resolveVariants({ variants: family.variants, localProviderUrlResolver }), - fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, - }; - } - - return { - ...family, - name, - nameWithHash, - weights: family.weights ? dedupe(family.weights.map((weight) => weight.toString())) : undefined, - styles: family.styles ? dedupe(family.styles) : undefined, - subsets: family.subsets ? dedupe(family.subsets) : undefined, - formats: family.formats ? dedupe(family.formats) : undefined, - fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, - unicodeRange: family.unicodeRange ? dedupe(family.unicodeRange) : undefined, - }; -} - -/** - * A function for convenience. The actual logic lives in resolveFamily - */ -export function resolveFamilies({ - families, - ...dependencies -}: { families: Array } & Omit< - Parameters[0], - 'family' ->): Array { - return families.map((family) => - resolveFamily({ - family, - ...dependencies, - }), - ); -} diff --git a/packages/astro/src/assets/fonts/core/resolve-family.ts b/packages/astro/src/assets/fonts/core/resolve-family.ts new file mode 100644 index 000000000000..e1e5e41b017a --- /dev/null +++ b/packages/astro/src/assets/fonts/core/resolve-family.ts @@ -0,0 +1,28 @@ +import type { Hasher } from '../definitions.js'; +import type { FontFamily, ResolvedFontFamily } from '../types.js'; +import { dedupe, withoutQuotes } from '../utils.js'; + +export function resolveFamily({ + family, + hasher, +}: { + family: FontFamily; + hasher: Hasher; +}): ResolvedFontFamily { + // We remove quotes from the name so they can be properly resolved by providers. + const name = withoutQuotes(family.name); + + return { + ...family, + name, + // This will be used in CSS font faces. Quotes are added by the CSS renderer if + // this value contains a space. + uniqueName: `${name}-${hasher.hashObject(family)}`, + weights: family.weights ? dedupe(family.weights.map((weight) => weight.toString())) : undefined, + styles: family.styles ? dedupe(family.styles) : undefined, + subsets: family.subsets ? dedupe(family.subsets) : undefined, + formats: family.formats ? dedupe(family.formats) : undefined, + fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, + unicodeRange: family.unicodeRange ? dedupe(family.unicodeRange) : undefined, + }; +} diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index e80e5e262f94..00c0cb68fc8b 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -1,11 +1,11 @@ import type * as unifont from 'unifont'; import type { CollectedFontForMetrics } from './core/optimize-fallbacks.js'; import type { + CssProperties, FontFaceMetrics, FontFileData, FontType, GenericFallbackName, - PreloadData, ResolveFontOptions, Style, } from './types.js'; @@ -15,46 +15,15 @@ export interface Hasher { hashObject: (input: Record) => string; } -export interface LocalProviderUrlResolver { - resolve: (input: string) => string; -} - -export interface ProxyData { - weight: unifont.FontFaceData['weight']; - style: unifont.FontFaceData['style']; - subset: NonNullable['subset']; -} - -export interface UrlProxy { - proxy: ( - input: Pick & { - type: FontType; - collectPreload: boolean; - data: ProxyData; - }, - ) => string; -} - export interface UrlResolver { resolve: (hash: string) => string; readonly cspResources: Array; } -export interface UrlProxyContentResolver { +export interface FontFileContentResolver { resolve: (url: string) => string; } -export interface DataCollector { - collect: ( - input: FontFileData & { - data: ProxyData; - preload: PreloadData | null; - }, - ) => void; -} - -export type CssProperties = Record; - export interface CssRenderer { generateFontFace: (family: string, properties: CssProperties) => string; generateCssVariable: (key: string, values: Array) => string; @@ -91,12 +60,12 @@ export interface FontFileReader { }; } -export interface UrlProxyHashResolver { - resolve: (input: { +export interface FontFileIdGenerator { + generate: (input: { originalUrl: string; type: FontType; cssVariable: string; - data: ProxyData; + font: unifont.FontFaceData; }) => string; } diff --git a/packages/astro/src/assets/fonts/infra/build-font-file-id-generator.ts b/packages/astro/src/assets/fonts/infra/build-font-file-id-generator.ts new file mode 100644 index 000000000000..0d445e29e422 --- /dev/null +++ b/packages/astro/src/assets/fonts/infra/build-font-file-id-generator.ts @@ -0,0 +1,22 @@ +import type { FontFileContentResolver, FontFileIdGenerator, Hasher } from '../definitions.js'; +import type { FontType } from '../types.js'; + +export class BuildFontFileIdGenerator implements FontFileIdGenerator { + readonly #hasher: Hasher; + readonly #contentResolver: FontFileContentResolver; + + constructor({ + hasher, + contentResolver, + }: { + hasher: Hasher; + contentResolver: FontFileContentResolver; + }) { + this.#hasher = hasher; + this.#contentResolver = contentResolver; + } + + generate({ originalUrl, type }: { originalUrl: string; type: FontType }): string { + return `${this.#hasher.hashString(this.#contentResolver.resolve(originalUrl))}.${type}`; + } +} diff --git a/packages/astro/src/assets/fonts/infra/build-url-proxy-hash-resolver.ts b/packages/astro/src/assets/fonts/infra/build-url-proxy-hash-resolver.ts deleted file mode 100644 index 6012bac16d41..000000000000 --- a/packages/astro/src/assets/fonts/infra/build-url-proxy-hash-resolver.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { - Hasher, - ProxyData, - UrlProxyContentResolver, - UrlProxyHashResolver, -} from '../definitions.js'; -import type { FontType } from '../types.js'; - -export class BuildUrlProxyHashResolver implements UrlProxyHashResolver { - readonly #hasher: Hasher; - readonly #contentResolver: UrlProxyContentResolver; - - constructor({ - hasher, - contentResolver, - }: { - hasher: Hasher; - contentResolver: UrlProxyContentResolver; - }) { - this.#hasher = hasher; - this.#contentResolver = contentResolver; - } - - resolve({ - originalUrl, - type, - }: { - originalUrl: string; - type: FontType; - cssVariable: string; - data: ProxyData; - }): string { - return `${this.#hasher.hashString(this.#contentResolver.resolve(originalUrl))}.${type}`; - } -} diff --git a/packages/astro/src/assets/fonts/infra/cached-font-fetcher.ts b/packages/astro/src/assets/fonts/infra/cached-font-fetcher.ts index afaee2ff72ed..a7b8a5c9e507 100644 --- a/packages/astro/src/assets/fonts/infra/cached-font-fetcher.ts +++ b/packages/astro/src/assets/fonts/infra/cached-font-fetcher.ts @@ -2,7 +2,6 @@ import { isAbsolute } from 'node:path'; import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; import type { FontFetcher, Storage } from '../definitions.js'; import type { FontFileData } from '../types.js'; -import { cache } from '../utils.js'; export class CachedFontFetcher implements FontFetcher { readonly #storage: Storage; @@ -23,8 +22,18 @@ export class CachedFontFetcher implements FontFetcher { this.#readFile = readFile; } + async #cache(storage: Storage, key: string, cb: () => Promise): Promise { + const existing = await storage.getItemRaw(key); + if (existing) { + return existing; + } + const data = await cb(); + await storage.setItemRaw(key, data); + return data; + } + async fetch({ hash, url, init }: FontFileData): Promise { - return await cache(this.#storage, hash, async () => { + return await this.#cache(this.#storage, hash, async () => { try { if (isAbsolute(url)) { return await this.#readFile(url); diff --git a/packages/astro/src/assets/fonts/infra/capsize-font-metrics-resolver.ts b/packages/astro/src/assets/fonts/infra/capsize-font-metrics-resolver.ts index 9a27a2bf499a..f3f5eae49019 100644 --- a/packages/astro/src/assets/fonts/infra/capsize-font-metrics-resolver.ts +++ b/packages/astro/src/assets/fonts/infra/capsize-font-metrics-resolver.ts @@ -1,12 +1,7 @@ import { type Font, fromBuffer } from '@capsizecss/unpack'; import type { CollectedFontForMetrics } from '../core/optimize-fallbacks.js'; -import type { - CssProperties, - CssRenderer, - FontFetcher, - FontMetricsResolver, -} from '../definitions.js'; -import type { FontFaceMetrics } from '../types.js'; +import type { CssRenderer, FontFetcher, FontMetricsResolver } from '../definitions.js'; +import type { CssProperties, FontFaceMetrics } from '../types.js'; import { renderFontSrc } from '../utils.js'; // Source: https://github.com/unjs/fontaine/blob/main/src/metrics.ts diff --git a/packages/astro/src/assets/fonts/infra/data-collector.ts b/packages/astro/src/assets/fonts/infra/data-collector.ts deleted file mode 100644 index decef6fc9eec..000000000000 --- a/packages/astro/src/assets/fonts/infra/data-collector.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { DataCollector, ProxyData } from '../definitions.js'; -import type { CreateUrlProxyParams, FontFileData, PreloadData } from '../types.js'; - -// TODO: investigate converting to core logic -export class RealDataCollector implements DataCollector { - readonly #hasUrl: CreateUrlProxyParams['hasUrl']; - readonly #saveUrl: CreateUrlProxyParams['saveUrl']; - readonly #savePreload: CreateUrlProxyParams['savePreload']; - readonly #saveFontData: CreateUrlProxyParams['saveFontData']; - - constructor({ - hasUrl, - saveUrl, - savePreload, - saveFontData, - }: Pick) { - this.#hasUrl = hasUrl; - this.#saveUrl = saveUrl; - this.#savePreload = savePreload; - this.#saveFontData = saveFontData; - } - - collect({ - hash, - url, - init, - preload, - data, - }: FontFileData & { data: ProxyData; preload: PreloadData | null }): void { - if (!this.#hasUrl(hash)) { - this.#saveUrl({ hash, url, init }); - if (preload) { - this.#savePreload(preload); - } - } - this.#saveFontData({ hash, url, data, init }); - } -} diff --git a/packages/astro/src/assets/fonts/infra/dev-url-proxy-hash-resolver.ts b/packages/astro/src/assets/fonts/infra/dev-font-file-id-generator.ts similarity index 57% rename from packages/astro/src/assets/fonts/infra/dev-url-proxy-hash-resolver.ts rename to packages/astro/src/assets/fonts/infra/dev-font-file-id-generator.ts index b94ac60e3ba8..96b58d49bdc4 100644 --- a/packages/astro/src/assets/fonts/infra/dev-url-proxy-hash-resolver.ts +++ b/packages/astro/src/assets/fonts/infra/dev-font-file-id-generator.ts @@ -1,29 +1,23 @@ -import type { - Hasher, - ProxyData, - UrlProxyContentResolver, - UrlProxyHashResolver, -} from '../definitions.js'; +import type * as unifont from 'unifont'; +import type { FontFileContentResolver, FontFileIdGenerator, Hasher } from '../definitions.js'; import type { FontType } from '../types.js'; -export class DevUrlProxyHashResolver implements UrlProxyHashResolver { +export class DevFontFileIdGenerator implements FontFileIdGenerator { readonly #hasher: Hasher; - readonly #contentResolver: UrlProxyContentResolver; + readonly #contentResolver: FontFileContentResolver; constructor({ hasher, contentResolver, }: { hasher: Hasher; - contentResolver: UrlProxyContentResolver; + contentResolver: FontFileContentResolver; }) { this.#hasher = hasher; this.#contentResolver = contentResolver; } - #formatWeight( - weight: Parameters[0]['data']['weight'], - ): string | undefined { + #formatWeight(weight: unifont.FontFaceData['weight']): string | undefined { if (Array.isArray(weight)) { return weight.join('-'); } @@ -33,22 +27,22 @@ export class DevUrlProxyHashResolver implements UrlProxyHashResolver { return weight?.replace(/\s+/g, '-'); } - resolve({ + generate({ cssVariable, - data, originalUrl, type, + font, }: { originalUrl: string; type: FontType; cssVariable: string; - data: ProxyData; + font: unifont.FontFaceData; }): string { return [ cssVariable.slice(2), - this.#formatWeight(data.weight), - data.style, - data.subset, + this.#formatWeight(font.weight), + font.style, + font.meta?.subset, `${this.#hasher.hashString(this.#contentResolver.resolve(originalUrl))}.${type}`, ] .filter(Boolean) diff --git a/packages/astro/src/assets/fonts/infra/fs-font-file-content-resolver.ts b/packages/astro/src/assets/fonts/infra/fs-font-file-content-resolver.ts new file mode 100644 index 000000000000..338478c362bf --- /dev/null +++ b/packages/astro/src/assets/fonts/infra/fs-font-file-content-resolver.ts @@ -0,0 +1,28 @@ +import { isAbsolute } from 'node:path'; +import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; +import type { FontFileContentResolver } from '../definitions.js'; + +type ReadFileSync = (path: string) => string; + +export class FsFontFileContentResolver implements FontFileContentResolver { + #readFileSync: ReadFileSync; + + constructor({ readFileSync }: { readFileSync: ReadFileSync }) { + this.#readFileSync = readFileSync; + } + + resolve(url: string): string { + if (!isAbsolute(url)) { + // HTTP URLs are enough + return url; + } + try { + // We use the url and the file content for the hash generation because: + // - The URL is not hashed unlike remote providers + // - A font file can renamed and swapped so we would incorrectly cache it + return url + this.#readFileSync(url); + } catch (cause) { + throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause }); + } + } +} diff --git a/packages/astro/src/assets/fonts/infra/local-url-proxy-content-resolver.ts b/packages/astro/src/assets/fonts/infra/local-url-proxy-content-resolver.ts deleted file mode 100644 index 0cac797ab161..000000000000 --- a/packages/astro/src/assets/fonts/infra/local-url-proxy-content-resolver.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; -import type { UrlProxyContentResolver } from '../definitions.js'; - -export class LocalUrlProxyContentResolver implements UrlProxyContentResolver { - resolve(url: string): string { - try { - // We use the url and the file content for the hash generation because: - // - The URL is not hashed unlike remote providers - // - A font file can renamed and swapped so we would incorrectly cache it - return url + readFileSync(url, 'utf-8'); - } catch (cause) { - throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause }); - } - } -} diff --git a/packages/astro/src/assets/fonts/infra/minifiable-css-renderer.ts b/packages/astro/src/assets/fonts/infra/minifiable-css-renderer.ts index 0b9cedb3a671..24f2d6ac8a23 100644 --- a/packages/astro/src/assets/fonts/infra/minifiable-css-renderer.ts +++ b/packages/astro/src/assets/fonts/infra/minifiable-css-renderer.ts @@ -1,4 +1,5 @@ -import type { CssProperties, CssRenderer } from '../definitions.js'; +import type { CssRenderer } from '../definitions.js'; +import type { CssProperties } from '../types.js'; // TODO: consider making these public methods diff --git a/packages/astro/src/assets/fonts/infra/font-type-extractor.ts b/packages/astro/src/assets/fonts/infra/node-font-type-extractor.ts similarity index 87% rename from packages/astro/src/assets/fonts/infra/font-type-extractor.ts rename to packages/astro/src/assets/fonts/infra/node-font-type-extractor.ts index 201f816f30de..6867fcf143e2 100644 --- a/packages/astro/src/assets/fonts/infra/font-type-extractor.ts +++ b/packages/astro/src/assets/fonts/infra/node-font-type-extractor.ts @@ -4,8 +4,7 @@ import type { FontTypeExtractor } from '../definitions.js'; import type { FontType } from '../types.js'; import { isFontType } from '../utils.js'; -// TODO: find better name -export class RealFontTypeExtractor implements FontTypeExtractor { +export class NodeFontTypeExtractor implements FontTypeExtractor { extract(url: string): FontType { const extension = extname(url).slice(1); if (!isFontType(extension)) { diff --git a/packages/astro/src/assets/fonts/infra/remote-url-proxy-content-resolver.ts b/packages/astro/src/assets/fonts/infra/remote-url-proxy-content-resolver.ts deleted file mode 100644 index 885b36e2ed3d..000000000000 --- a/packages/astro/src/assets/fonts/infra/remote-url-proxy-content-resolver.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { UrlProxyContentResolver } from '../definitions.js'; - -export class RemoteUrlProxyContentResolver implements UrlProxyContentResolver { - // Passthrough, the remote provider URL is enough - resolve(url: string): string { - return url; - } -} 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 deleted file mode 100644 index 89992b59063d..000000000000 --- a/packages/astro/src/assets/fonts/infra/require-local-provider-url-resolver.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createRequire } from 'node:module'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import type { LocalProviderUrlResolver } from '../definitions.js'; - -export class RequireLocalProviderUrlResolver implements LocalProviderUrlResolver { - readonly #root: URL; - // TODO: remove when stabilizing - readonly #intercept: ((path: string) => void) | undefined; - - constructor({ - root, - intercept, - }: { - root: URL; - intercept?: ((path: string) => void) | undefined; - }) { - this.#root = root; - 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(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 d93b646513dc..5a190dfb145c 100644 --- a/packages/astro/src/assets/fonts/infra/unifont-font-resolver.ts +++ b/packages/astro/src/assets/fonts/infra/unifont-font-resolver.ts @@ -1,6 +1,5 @@ import type { FontFaceData, Provider } 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 { FontProvider, ResolvedFontFamily, ResolveFontOptions } from '../types.js'; @@ -16,9 +15,9 @@ export class UnifontFontResolver implements FontResolver { this.#unifont = unifont; } - static astroToUnifontProvider(astroProvider: FontProvider): Provider { + static astroToUnifontProvider(astroProvider: FontProvider, root: URL): Provider { return defineFontProvider(astroProvider.name, async (_options: any, ctx) => { - await astroProvider?.init?.(ctx); + await astroProvider?.init?.({ ...ctx, root }); return { async resolveFont(familyName, { options, ...rest }) { return await astroProvider.resolveFont({ familyName, options, ...rest }); @@ -33,20 +32,17 @@ export class UnifontFontResolver implements FontResolver { static extractUnifontProviders({ families, hasher, + root, }: { families: Array; hasher: Hasher; + root: URL; }) { const hashes = new Set(); const providers: Array = []; for (const { provider } of families) { - // The local provider logic happens outside of unifont - if (provider === LOCAL_PROVIDER_NAME) { - continue; - } - - const unifontProvider = this.astroToUnifontProvider(provider); + const unifontProvider = this.astroToUnifontProvider(provider, root); const hash = hasher.hashObject({ name: unifontProvider._name, ...provider.config, @@ -75,13 +71,15 @@ export class UnifontFontResolver implements FontResolver { families, hasher, storage, + root, }: { families: Array; hasher: Hasher; storage: Storage; + root: URL; }) { return new UnifontFontResolver({ - unifont: await createUnifont(this.extractUnifontProviders({ families, hasher }), { + unifont: await createUnifont(this.extractUnifontProviders({ families, hasher, root }), { storage, // TODO: consider enabling, would require new astro errors throwOnError: false, diff --git a/packages/astro/src/assets/fonts/infra/url-proxy.ts b/packages/astro/src/assets/fonts/infra/url-proxy.ts deleted file mode 100644 index 21853364ed3a..000000000000 --- a/packages/astro/src/assets/fonts/infra/url-proxy.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - DataCollector, - ProxyData, - UrlProxy, - UrlProxyHashResolver, - UrlResolver, -} from '../definitions.js'; -import type { FontFileData, FontType } from '../types.js'; -import { renderFontWeight } from '../utils.js'; - -// TODO: find a better name -export class RealUrlProxy implements UrlProxy { - readonly #hashResolver: UrlProxyHashResolver; - readonly #dataCollector: DataCollector; - readonly #urlResolver: UrlResolver; - readonly #cssVariable: string; - - constructor({ - hashResolver, - dataCollector, - urlResolver, - cssVariable, - }: { - hashResolver: UrlProxyHashResolver; - dataCollector: DataCollector; - urlResolver: UrlResolver; - cssVariable: string; - }) { - this.#hashResolver = hashResolver; - this.#dataCollector = dataCollector; - this.#urlResolver = urlResolver; - this.#cssVariable = cssVariable; - } - - proxy({ - url: originalUrl, - type, - data, - collectPreload, - init, - }: Pick & { - type: FontType; - collectPreload: boolean; - data: ProxyData; - }): string { - const hash = this.#hashResolver.resolve({ - cssVariable: this.#cssVariable, - data, - originalUrl, - type, - }); - const url = this.#urlResolver.resolve(hash); - - this.#dataCollector.collect({ - url: originalUrl, - hash, - preload: collectPreload - ? { - url, - type, - weight: renderFontWeight(data.weight), - style: data.style, - subset: data.subset, - } - : null, - data, - init, - }); - - return url; - } -} diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts deleted file mode 100644 index 04e1d5653ad1..000000000000 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ /dev/null @@ -1,295 +0,0 @@ -import type * as unifont from 'unifont'; -import type { Logger } from '../../core/logger/core.js'; -import { LOCAL_PROVIDER_NAME } from './constants.js'; -import { normalizeRemoteFontFaces } from './core/normalize-remote-font-faces.js'; -import { type CollectedFontForMetrics, optimizeFallbacks } from './core/optimize-fallbacks.js'; -import { resolveFamilies } from './core/resolve-families.js'; -import type { - CssRenderer, - FontFileReader, - FontMetricsResolver, - FontResolver, - FontTypeExtractor, - Hasher, - LocalProviderUrlResolver, - StringMatcher, - SystemFallbacksProvider, - UrlProxy, -} from './definitions.js'; -import { resolveLocalFont } from './providers/local.js'; -import type { - ConsumableMap, - CreateUrlProxyParams, - Defaults, - FontData, - FontFamily, - FontFileDataMap, - InternalConsumableMap, - PreloadData, - ResolvedFontFamily, -} from './types.js'; -import { - pickFontFaceProperty, - renderFontWeight, - unifontFontFaceDataToProperties, -} from './utils.js'; - -/** - * Manages how fonts are resolved: - * - * - families are resolved - * - font resolver is initialized - * - * For each family: - * - We create a URL proxy - * - We resolve the font and normalize the result - * - * For each resolved font: - * - We generate the CSS font face - * - We generate optimized fallbacks if applicable - * - We generate CSS variables - * - * Once that's done, the collected data is returned - */ -export async function orchestrate({ - families, - hasher, - localProviderUrlResolver, - cssRenderer, - systemFallbacksProvider, - fontMetricsResolver, - fontTypeExtractor, - fontFileReader, - logger, - createUrlProxy, - defaults, - bold, - stringMatcher, - createFontResolver, -}: { - families: Array; - hasher: Hasher; - localProviderUrlResolver: LocalProviderUrlResolver; - cssRenderer: CssRenderer; - systemFallbacksProvider: SystemFallbacksProvider; - fontMetricsResolver: FontMetricsResolver; - fontTypeExtractor: FontTypeExtractor; - fontFileReader: FontFileReader; - logger: Logger; - createUrlProxy: (params: CreateUrlProxyParams) => UrlProxy; - defaults: Defaults; - bold: (input: string) => string; - stringMatcher: StringMatcher; - createFontResolver: (params: { families: Array }) => Promise; -}): Promise<{ - fontFileDataMap: FontFileDataMap; - internalConsumableMap: InternalConsumableMap; - consumableMap: ConsumableMap; -}> { - const resolvedFamilies = resolveFamilies({ - families, - hasher, - localProviderUrlResolver, - }); - - const fontResolver = await createFontResolver({ families: resolvedFamilies }); - - /** - * Holds associations of hash and original font file URLs, so they can be - * downloaded whenever the hash is requested. - */ - const fontFileDataMap: FontFileDataMap = new Map(); - /** - * Holds associations of CSS variables and preloadData/css to be passed to the internal virtual module. - */ - const internalConsumableMap: InternalConsumableMap = new Map(); - /** - * Holds associations of CSS variables and font data to be exposed via virtual module. - */ - const consumableMap: ConsumableMap = new Map(); - - /** - * Holds family data by a key, to allow merging families - */ - const resolvedFamiliesMap = new Map< - string, - { - family: ResolvedFontFamily; - fonts: Array; - fallbacks: Array; - /** - * Holds a list of font files to be used for optimized fallbacks generation - */ - collectedFonts: Array; - preloadData: Array; - } - >(); - - // 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}`; - let resolvedFamily = resolvedFamiliesMap.get(key); - if (!resolvedFamily) { - if ( - Array.from(resolvedFamiliesMap.keys()).find((k) => k.startsWith(`${family.cssVariable}:`)) - ) { - logger.warn( - 'assets', - `Several font families have been registered for the ${bold(family.cssVariable)} cssVariable but they do not share the same name and provider.`, - ); - logger.warn( - 'assets', - 'These families will not be merged together. The last occurrence will override previous families for this cssVariable. Review your Astro configuration.', - ); - } - resolvedFamily = { - family, - fonts: [], - fallbacks: family.fallbacks ?? defaults.fallbacks ?? [], - collectedFonts: [], - preloadData: [], - }; - resolvedFamiliesMap.set(key, resolvedFamily); - } - - /** - * Allows collecting and transforming original URLs from providers, so the Vite - * plugin has control over URLs. - */ - const urlProxy = createUrlProxy({ - local: family.provider === LOCAL_PROVIDER_NAME, - hasUrl: (hash) => fontFileDataMap.has(hash), - saveUrl: ({ hash, url, init }) => { - fontFileDataMap.set(hash, { url, init }); - }, - savePreload: (preload) => { - resolvedFamily.preloadData.push(preload); - }, - saveFontData: (collected) => { - if ( - resolvedFamily.fallbacks && - resolvedFamily.fallbacks.length > 0 && - // If the same data has already been sent for this family, we don't want to have - // duplicated fallbacks. Such scenario can occur with unicode ranges. - !resolvedFamily.collectedFonts.some( - (f) => JSON.stringify(f.data) === JSON.stringify(collected.data), - ) - ) { - // If a family has fallbacks, we store the first url we get that may - // be used for the fallback generation. - resolvedFamily.collectedFonts.push(collected); - } - }, - cssVariable: family.cssVariable, - }); - - if (family.provider === LOCAL_PROVIDER_NAME) { - const fonts = resolveLocalFont({ - family, - urlProxy, - fontTypeExtractor, - fontFileReader, - }); - // URLs are already proxied at this point so no further processing is required - resolvedFamily.fonts.push(...fonts); - } else { - const fonts = await fontResolver.resolveFont({ - familyName: family.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, - subsets: family.subsets ?? defaults.subsets, - formats: family.formats ?? defaults.formats, - options: family.options, - }); - if (fonts.length === 0) { - logger.warn( - 'assets', - `No data found for font family ${bold(family.name)}. Review your configuration`, - ); - const availableFamilies = await fontResolver.listFonts({ provider: family.provider.name }); - if ( - availableFamilies && - availableFamilies.length > 0 && - !availableFamilies.includes(family.name) - ) { - logger.warn( - 'assets', - `${bold(family.name)} font family cannot be retrieved by the provider. Did you mean ${bold(stringMatcher.getClosestMatch(family.name, availableFamilies))}?`, - ); - } - } - // The data returned by the remote provider contains original URLs. We proxy them. - resolvedFamily.fonts = normalizeRemoteFontFaces({ fonts, urlProxy, fontTypeExtractor }); - } - } - - // We know about all the families, let's generate css, fallbacks and more - for (const { - family, - fonts, - fallbacks, - collectedFonts, - preloadData, - } of resolvedFamiliesMap.values()) { - const consumableMapValue: Array = []; - let css = ''; - - for (const data of fonts) { - css += cssRenderer.generateFontFace( - family.nameWithHash, - unifontFontFaceDataToProperties({ - src: data.src, - weight: data.weight, - style: data.style, - // User settings override the generated font settings. We use a helper function - // because local and remote providers store this data in different places. - display: pickFontFaceProperty('display', { data, family }), - unicodeRange: pickFontFaceProperty('unicodeRange', { data, family }), - stretch: pickFontFaceProperty('stretch', { data, family }), - featureSettings: pickFontFaceProperty('featureSettings', { data, family }), - variationSettings: pickFontFaceProperty('variationSettings', { data, family }), - }), - ); - - consumableMapValue.push({ - weight: renderFontWeight(data.weight), - style: data.style, - src: data.src - .filter((src) => 'url' in src) - .map((src) => ({ - url: src.url, - format: src.format, - tech: src.tech, - })), - }); - } - - const cssVarValues = [family.nameWithHash]; - const optimizeFallbacksResult = await optimizeFallbacks({ - family, - fallbacks, - collectedFonts, - enabled: family.optimizedFallbacks ?? defaults.optimizedFallbacks ?? false, - systemFallbacksProvider, - fontMetricsResolver, - }); - - if (optimizeFallbacksResult) { - css += optimizeFallbacksResult.css; - cssVarValues.push(...optimizeFallbacksResult.fallbacks); - } else { - // If there are no optimized fallbacks, we pass the provided fallbacks as is. - cssVarValues.push(...fallbacks); - } - - css += cssRenderer.generateCssVariable(family.cssVariable, cssVarValues); - - internalConsumableMap.set(family.cssVariable, { preloadData, css }); - consumableMap.set(family.cssVariable, consumableMapValue); - } - - return { fontFileDataMap, internalConsumableMap, consumableMap }; -} diff --git a/packages/astro/src/assets/fonts/providers/index.ts b/packages/astro/src/assets/fonts/providers/index.ts index f9707adf91a4..c5134a8e5a81 100644 --- a/packages/astro/src/assets/fonts/providers/index.ts +++ b/packages/astro/src/assets/fonts/providers/index.ts @@ -5,7 +5,9 @@ import { type InitializedProvider, providers, } from 'unifont'; +import { FontaceFontFileReader } from '../infra/fontace-font-file-reader.js'; import type { FontProvider } from '../types.js'; +import { type LocalFamilyOptions, LocalFontProvider } from './local.js'; /** [Adobe](https://fonts.adobe.com/) */ function adobe(config: AdobeProviderOptions): FontProvider { @@ -116,14 +118,22 @@ function googleicons(): FontProvider { }; } +/** TODO: */ +function local(): FontProvider { + return new LocalFontProvider({ + fontFileReader: new FontaceFontFileReader(), + }); +} + /** - * Astro re-exports most [unifont](https://github.com/unjs/unifont/) providers: + * Astro exports a few built-in providers: * - [Adobe](https://fonts.adobe.com/) * - [Bunny](https://fonts.bunny.net/) * - [Fontshare](https://www.fontshare.com/) * - [Fontsource](https://fontsource.org/) * - [Google](https://fonts.google.com/) * - [Google Icons](https://fonts.google.com/icons) + * - Local */ export const fontProviders = { adobe, @@ -132,4 +142,5 @@ export const fontProviders = { fontsource, google, googleicons, + local, }; diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 09e13f22827d..8478d1adefc9 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -1,67 +1,134 @@ +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type * as unifont from 'unifont'; -import { FONT_FORMATS } from '../constants.js'; -import type { FontFileReader, FontTypeExtractor, UrlProxy } from '../definitions.js'; -import type { ResolvedLocalFontFamily } from '../types.js'; +import type { FontFileReader } from '../definitions.js'; +import type { + FamilyProperties, + FontProvider, + FontProviderInitContext, + ResolveFontOptions, + Style, + Weight, +} from '../types.js'; -interface Options { - family: ResolvedLocalFontFamily; - urlProxy: UrlProxy; - fontTypeExtractor: FontTypeExtractor; - fontFileReader: FontFileReader; +interface NormalizedSource { + url: string; + tech: string | undefined; } -export function resolveLocalFont({ - family, - urlProxy, - fontTypeExtractor, - fontFileReader, -}: Options): Array { - return family.variants.map((variant) => { - const shouldInfer = variant.weight === undefined || variant.style === undefined; +type RawSource = + | string + | URL + | { + url: string | URL; + tech?: string | 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, +interface Variant extends FamilyProperties { + /** + * Font [sources](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src). It can be a path relative to the root, a package import or a URL. URLs are particularly useful if you inject local fonts through an integration. + */ + src: [RawSource, ...Array]; + /** + * A [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight). If the associated font is a [variable font](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_fonts/Variable_fonts_guide), you can specify a range of weights: + * + * ```js + * weight: "100 900" + * ``` + */ + weight?: Weight | undefined; + /** + * A [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style). + */ + style?: Style | undefined; +} + +export interface LocalFamilyOptions { + /** + * Each variant represents a [`@font-face` declaration](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/). + */ + variants: [Variant, ...Array]; +} + +export class LocalFontProvider implements FontProvider { + name = 'local'; + config?: Record | undefined; + + #fontFileReader: FontFileReader; + #root: URL | undefined; + + constructor({ + fontFileReader, + }: { + fontFileReader: FontFileReader; + }) { + this.config = undefined; + this.#fontFileReader = fontFileReader; + this.#root = undefined; + } + + init(context: Pick): void { + this.#root = context.root; + } + + #resolveEntrypoint(root: URL, entrypoint: string): URL { + const require = createRequire(root); + + try { + return pathToFileURL(require.resolve(entrypoint)); + } catch { + return new URL(entrypoint, root); + } + } + + #normalizeSource(value: RawSource): NormalizedSource { + const isValue = typeof value === 'string' || value instanceof URL; + const url = (isValue ? value : value.url).toString(); + const tech = isValue ? undefined : value.tech; + return { + url: fileURLToPath(this.#resolveEntrypoint(this.#root ?? new URL(import.meta.url), url)), + tech, }; - // 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; - } + } + + resolveFont(options: ResolveFontOptions): { + fonts: Array; + } { + return { + fonts: + options.options?.variants.map((variant) => { + const shouldInfer = variant.weight === undefined || variant.style === undefined; - const type = fontTypeExtractor.extract(source.url); + // 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((rawSource, index) => { + const source = this.#normalizeSource(rawSource); + // 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 = this.#fontFileReader.extract({ + family: options.familyName, + 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, - 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 source; + }); + return data; + }) ?? [], + }; + } } diff --git a/packages/astro/src/assets/fonts/runtime.ts b/packages/astro/src/assets/fonts/runtime.ts index d0d3943eeb3d..4f66e6c57ee5 100644 --- a/packages/astro/src/assets/fonts/runtime.ts +++ b/packages/astro/src/assets/fonts/runtime.ts @@ -1,13 +1,17 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js'; -import type { ConsumableMap, PreloadData, PreloadFilter } from './types.js'; +import type { FontDataByCssVariable, PreloadData, PreloadFilter } from './types.js'; -export function createGetFontData({ consumableMap }: { consumableMap?: ConsumableMap }) { +export function createGetFontData({ + fontDataByCssVariable, +}: { + fontDataByCssVariable?: FontDataByCssVariable; +}) { return function getFontData(cssVariable: string) { // TODO: remove once fonts are stabilized - if (!consumableMap) { + if (!fontDataByCssVariable) { throw new AstroError(AstroErrorData.ExperimentalFontsNotEnabled); } - const data = consumableMap.get(cssVariable); + const data = fontDataByCssVariable.get(cssVariable); if (!data) { throw new AstroError({ ...AstroErrorData.FontFamilyNotFound, diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index 9e9c3dc35385..96312fa95b8b 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -2,13 +2,12 @@ import type { Font } from '@capsizecss/unpack'; import type * as unifont from 'unifont'; import type { z } from 'zod'; import type { displaySchema, styleSchema, weightSchema } from './config.js'; -import type { FONT_TYPES, GENERIC_FALLBACK_NAMES, LOCAL_PROVIDER_NAME } from './constants.js'; +import type { FONT_TYPES, GENERIC_FALLBACK_NAMES } from './constants.js'; import type { CollectedFontForMetrics } from './core/optimize-fallbacks.js'; -type Weight = z.infer; +export type Weight = z.infer; type Display = z.infer; -/** @lintignore */ export interface FontProviderInitContext { storage: { getItem: { @@ -17,6 +16,7 @@ export interface FontProviderInitContext { }; setItem: (key: string, value: unknown) => Awaitable; }; + root: URL; } type Awaitable = T | Promise; @@ -51,58 +51,7 @@ export interface FontProvider< listFonts?: (() => Awaitable | undefined>) | undefined; } -interface RequiredFamilyAttributes { - /** - * The font family name, as identified by your font provider. - */ - name: string; - /** - * A valid [ident](https://developer.mozilla.org/en-US/docs/Web/CSS/ident) in the form of a CSS variable (i.e. starting with `--`). - */ - cssVariable: string; -} - -interface Fallbacks { - /** - * @default `["sans-serif"]` - * - * An array of fonts to use when your chosen font is unavailable, or loading. Fallback fonts will be chosen in the order listed. The first available font will be used: - * - * ```js - * fallbacks: ["CustomFont", "serif"] - * ``` - * - * To disable fallback fonts completely, configure an empty array: - * - * ```js - * fallbacks: [] - * ``` - * - - * If the last font in the `fallbacks` array is a [generic family name](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#generic-name), Astro will attempt to generate [optimized fallbacks](https://developer.chrome.com/blog/font-fallbacks) using font metrics will be generated. To disable this optimization, set `optimizedFallbacks` to false. - */ - fallbacks?: Array | undefined; - /** - * @default `true` - * - * Whether or not to enable optimized fallback generation. You may disable this default optimization to have full control over `fallbacks`. - */ - optimizedFallbacks?: boolean | undefined; -} - -interface FamilyProperties { - /** - * A [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight). If the associated font is a [variable font](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_fonts/Variable_fonts_guide), you can specify a range of weights: - * - * ```js - * weight: "100 900" - * ``` - */ - weight?: Weight | undefined; - /** - * A [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style). - */ - style?: Style | undefined; +export interface FamilyProperties { /** * @default `"swap"` * @@ -127,47 +76,6 @@ interface FamilyProperties { unicodeRange?: [string, ...Array] | undefined; } -type Src = - | string - | URL - | { - url: string | URL; - tech?: string | undefined; - }; - -interface Variant extends FamilyProperties { - /** - * Font [sources](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src). It can be a path relative to the root, a package import or a URL. URLs are particularly useful if you inject local fonts through an integration. - */ - src: [Src, ...Array]; -} - -export interface LocalFontFamily extends RequiredFamilyAttributes, Fallbacks { - /** - * The source of your font files. Set to `"local"` to use local font files. - */ - provider: typeof LOCAL_PROVIDER_NAME; - /** - * Each variant represents a [`@font-face` declaration](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/). - */ - variants: [Variant, ...Array]; -} - -interface ResolvedFontFamilyAttributes { - nameWithHash: string; -} - -export interface ResolvedLocalFontFamily - extends ResolvedFontFamilyAttributes, - Omit { - variants: Array< - Omit & { - weight?: string; - src: Array<{ url: string; tech?: string }>; - } - >; -} - type WithOptions = TFontProvider extends FontProvider< infer TFamilyOptions > @@ -198,57 +106,80 @@ type WithOptions = TFontProvider extends Fon options?: undefined; }; -export type RemoteFontFamily = - RequiredFamilyAttributes & - Omit & - Fallbacks & - WithOptions> & { - /** - * The source of your font files. You can use a built-in provider or write your own custom provider. - */ - provider: TFontProvider; - /** - * @default `[400]` - * - * An array of [font weights](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight). If the associated font is a [variable font](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_fonts/Variable_fonts_guide), you can specify a range of weights: - * - * ```js - * weight: "100 900" - * ``` - */ - weights?: [Weight, ...Array] | undefined; - /** - * @default `["normal", "italic"]` - * - * An array of [font styles](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style). - */ - styles?: [Style, ...Array