diff options
author | 2025-04-28 12:08:31 +0200 | |
---|---|---|
committer | 2025-04-28 12:08:31 +0200 | |
commit | a7b2dc60ca94f42a66575feb190e8b0f36b48e7c (patch) | |
tree | 8262fcf30b2fb4852a1e7f39e100aad51f35e05b | |
parent | ab98f884f2f8639a8f385cdbc919bc829014f64d (diff) | |
download | astro-a7b2dc60ca94f42a66575feb190e8b0f36b48e7c.tar.gz astro-a7b2dc60ca94f42a66575feb190e8b0f36b48e7c.tar.zst astro-a7b2dc60ca94f42a66575feb190e8b0f36b48e7c.zip |
feat(fonts): refactor (#13653)
40 files changed, 2696 insertions, 1640 deletions
diff --git a/.changeset/slick-garlics-do.md b/.changeset/slick-garlics-do.md new file mode 100644 index 000000000..ad6f1edee --- /dev/null +++ b/.changeset/slick-garlics-do.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Reduces the amount of preloaded files for the local provider when using the experimental fonts API diff --git a/.changeset/sour-dryers-swim.md b/.changeset/sour-dryers-swim.md new file mode 100644 index 000000000..d36881b15 --- /dev/null +++ b/.changeset/sour-dryers-swim.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a case where invalid CSS was emitted when using an experimental fonts API family name containing a space
\ No newline at end of file diff --git a/packages/astro/src/assets/fonts/config.ts b/packages/astro/src/assets/fonts/config.ts index bff16165d..5ab12276e 100644 --- a/packages/astro/src/assets/fonts/config.ts +++ b/packages/astro/src/assets/fonts/config.ts @@ -58,7 +58,7 @@ const fallbacksSchema = z.object({ * ``` * - * 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), an [optimized fallback](https://developer.chrome.com/blog/font-fallbacks) using font metrics will be generated. To disable this optimization, set `optimizedFallbacks` to false. + * 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: z.array(z.string()).optional(), /** @@ -162,11 +162,10 @@ export const remoteFontFamilySchema = requiredFamilyAttributesSchema * An array of [font styles](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style). */ styles: z.array(styleSchema).nonempty().optional(), - // TODO: better link /** * @default `["cyrillic-ext", "cyrillic", "greek-ext", "greek", "vietnamese", "latin-ext", "latin"]` * - * An array of [font subsets](https://fonts.google.com/knowledge/glossary/subsetting): + * An array of [font subsets](https://knaap.dev/posts/font-subsetting/): */ subsets: z.array(z.string()).nonempty().optional(), }), diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index c7831f2b3..fbf09d767 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -1,16 +1,15 @@ -import type { FontFaceMetrics } from './metrics.js'; -import type { ResolvedRemoteFontFamily } from './types.js'; +import type { Defaults } from "./types.js"; export const LOCAL_PROVIDER_NAME = 'local'; -export const DEFAULTS = { +export const DEFAULTS: Defaults = { weights: ['400'], styles: ['normal', 'italic'], subsets: ['cyrillic-ext', 'cyrillic', 'greek-ext', 'greek', 'vietnamese', 'latin-ext', 'latin'], // Technically serif is the browser default but most websites these days use sans-serif fallbacks: ['sans-serif'], optimizedFallbacks: true, -} satisfies Partial<ResolvedRemoteFontFamily>; +}; export const VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/internal'; export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; @@ -28,74 +27,20 @@ export const FONT_FORMAT_MAP: Record<(typeof FONT_TYPES)[number], string> = { eot: 'embedded-opentype', }; -// Extracted from https://raw.githubusercontent.com/seek-oss/capsize/refs/heads/master/packages/metrics/src/entireMetricsCollection.json -export const SYSTEM_METRICS = { - 'Times New Roman': { - ascent: 1825, - descent: -443, - lineGap: 87, - unitsPerEm: 2048, - xWidthAvg: 832, - }, - Arial: { - ascent: 1854, - descent: -434, - lineGap: 67, - unitsPerEm: 2048, - xWidthAvg: 913, - }, - 'Courier New': { - ascent: 1705, - descent: -615, - lineGap: 0, - unitsPerEm: 2048, - xWidthAvg: 1229, - }, - BlinkMacSystemFont: { - ascent: 1980, - descent: -432, - lineGap: 0, - unitsPerEm: 2048, - xWidthAvg: 853, - }, - 'Segoe UI': { - ascent: 2210, - descent: -514, - lineGap: 0, - unitsPerEm: 2048, - xWidthAvg: 908, - }, - Roboto: { - ascent: 1900, - descent: -500, - lineGap: 0, - unitsPerEm: 2048, - xWidthAvg: 911, - }, - 'Helvetica Neue': { - ascent: 952, - descent: -213, - lineGap: 28, - unitsPerEm: 1000, - xWidthAvg: 450, - }, -} satisfies Record<string, FontFaceMetrics>; - -// Source: https://github.com/nuxt/fonts/blob/3a3eb6dfecc472242b3011b25f3fcbae237d0acc/src/module.ts#L55-L75 -export const DEFAULT_FALLBACKS = { - serif: ['Times New Roman'], - 'sans-serif': ['Arial'], - monospace: ['Courier New'], - cursive: [], - fantasy: [], - 'system-ui': ['BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial'], - 'ui-serif': ['Times New Roman'], - 'ui-sans-serif': ['Arial'], - 'ui-monospace': ['Courier New'], - 'ui-rounded': [], - emoji: [], - math: [], - fangsong: [], -} as const satisfies Record<string, Array<keyof typeof SYSTEM_METRICS>>; +export const GENERIC_FALLBACK_NAMES = [ + 'serif', + 'sans-serif', + 'monospace', + 'cursive', + 'fantasy', + 'system-ui', + 'ui-serif', + 'ui-sans-serif', + 'ui-monospace', + 'ui-rounded', + 'emoji', + 'math', + 'fangsong', +] as const; export const FONTS_TYPES_FILE = 'fonts.d.ts'; diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts new file mode 100644 index 000000000..4ce2decb9 --- /dev/null +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +import type { AstroFontProvider, FontType, PreloadData, ResolvedFontProvider } from './types.js'; +import type * as unifont from 'unifont'; +import type { FontFaceMetrics, GenericFallbackName } from './types.js'; +import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; + +export interface Hasher { + hashString: (input: string) => string; + hashObject: (input: Record<string, any>) => string; +} + +export interface RemoteFontProviderModResolver { + resolve: (id: string) => Promise<any>; +} + +export interface RemoteFontProviderResolver { + resolve: (provider: AstroFontProvider) => Promise<ResolvedFontProvider>; +} + +export interface LocalProviderUrlResolver { + resolve: (input: string) => string; +} + +type SingleErrorInput<TType extends string, TData extends Record<string, any>> = { + type: TType; + data: TData; + cause: unknown; +}; + +export type ErrorHandlerInput = + | SingleErrorInput< + 'cannot-load-font-provider', + { + entrypoint: string; + } + > + | SingleErrorInput<'unknown-fs-error', {}> + | SingleErrorInput<'cannot-fetch-font-file', { url: string }> + | SingleErrorInput<'cannot-extract-font-type', { url: string }>; + +export interface ErrorHandler { + handle: (input: ErrorHandlerInput) => Error; +} + +export interface UrlProxy { + proxy: (input: { + url: string; + collectPreload: boolean; + data: Partial<unifont.FontFaceData>; + }) => string; +} + +export interface UrlProxyContentResolver { + resolve: (url: string) => string; +} + +export interface DataCollector { + collect: (input: { + originalUrl: string; + hash: string; + data: Partial<unifont.FontFaceData>; + preload: PreloadData | null; + }) => void; +} + +export type CssProperties = Record<string, string | undefined>; + +export interface CssRenderer { + generateFontFace: (family: string, properties: CssProperties) => string; + generateCssVariable: (key: string, values: Array<string>) => string; +} + +export interface FontMetricsResolver { + getMetrics: (name: string, font: CollectedFontForMetrics) => Promise<FontFaceMetrics>; + generateFontFace: (input: { + metrics: FontFaceMetrics; + fallbackMetrics: FontFaceMetrics; + name: string; + font: string; + properties: CssProperties; + }) => string; +} + +export interface SystemFallbacksProvider { + getLocalFonts: (fallback: GenericFallbackName) => Array<string> | null; + getMetricsForLocalFont: (family: string) => FontFaceMetrics; +} + +export interface FontFetcher { + fetch: (hash: string, url: string) => Promise<Buffer>; +} + +export interface FontTypeExtractor { + extract: (url: string) => FontType; +} diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts new file mode 100644 index 000000000..3ae6d0393 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -0,0 +1,50 @@ +import type { CssProperties, CssRenderer } from '../definitions.js'; + +export function renderFontFace(properties: CssProperties, minify: boolean): string { + // Line feed + const lf = minify ? '' : `\n`; + // Space + const sp = minify ? '' : ' '; + + return `@font-face${sp}{${lf}${Object.entries(properties) + .filter(([, value]) => Boolean(value)) + .map(([key, value]) => `${sp}${sp}${key}:${sp}${value};`) + .join(lf)}${lf}}${lf}`; +} + +export function renderCssVariable(key: string, values: Array<string>, minify: boolean): string { + // Line feed + const lf = minify ? '' : `\n`; + // Space + const sp = minify ? '' : ' '; + + return `:root${sp}{${lf}${sp}${sp}${key}:${sp}${values.map((v) => handleValueWithSpaces(v)).join(`,${sp}`)};${lf}}${lf}`; +} + +export function withFamily(family: string, properties: CssProperties): CssProperties { + return { + 'font-family': handleValueWithSpaces(family), + ...properties, + }; +} + +const SPACE_RE = /\s/; + +/** If the value contains spaces (which would be incorrectly interpreted), we wrap it in quotes. */ +export function handleValueWithSpaces(value: string): string { + if (SPACE_RE.test(value)) { + return JSON.stringify(value); + } + return value; +} + +export function createMinifiableCssRenderer({ minify }: { minify: boolean }): CssRenderer { + return { + generateFontFace(family, properties) { + return renderFontFace(withFamily(family, properties), minify); + }, + generateCssVariable(key, values) { + return renderCssVariable(key, values, minify); + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/data-collector.ts b/packages/astro/src/assets/fonts/implementations/data-collector.ts new file mode 100644 index 000000000..6c0f3ad04 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/data-collector.ts @@ -0,0 +1,25 @@ +import type { DataCollector } from '../definitions.js'; +import type { CreateUrlProxyParams } from '../types.js'; + +export function createDataCollector({ + hasUrl, + saveUrl, + savePreload, + saveFontData, +}: Omit<CreateUrlProxyParams, 'local'>): DataCollector { + return { + collect({ originalUrl, hash, preload, data }) { + if (!hasUrl(hash)) { + saveUrl(hash, originalUrl); + if (preload) { + savePreload(preload); + } + } + saveFontData({ + hash, + url: originalUrl, + data, + }); + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/error-handler.ts b/packages/astro/src/assets/fonts/implementations/error-handler.ts new file mode 100644 index 000000000..5b911a7c0 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/error-handler.ts @@ -0,0 +1,34 @@ +import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; +import type { ErrorHandler, ErrorHandlerInput } from '../definitions.js'; + +function getProps(input: ErrorHandlerInput): ConstructorParameters<typeof AstroError>[0] { + if (input.type === 'cannot-load-font-provider') { + return { + ...AstroErrorData.CannotLoadFontProvider, + message: AstroErrorData.CannotLoadFontProvider.message(input.data.entrypoint), + }; + } else if (input.type === 'unknown-fs-error') { + return AstroErrorData.UnknownFilesystemError; + } else if (input.type === 'cannot-fetch-font-file') { + return { + ...AstroErrorData.CannotFetchFontFile, + message: AstroErrorData.CannotFetchFontFile.message(input.data.url), + }; + } else if (input.type === 'cannot-extract-font-type') { + return { + ...AstroErrorData.CannotExtractFontType, + message: AstroErrorData.CannotExtractFontType.message(input.data.url), + }; + } + input satisfies never; + // Should never happen but TS isn't happy + return AstroErrorData.UnknownError; +} + +export function createAstroErrorHandler(): ErrorHandler { + return { + handle(input) { + return new AstroError(getProps(input), { cause: input.cause }); + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/font-fetcher.ts b/packages/astro/src/assets/fonts/implementations/font-fetcher.ts new file mode 100644 index 000000000..c47c87fb6 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/font-fetcher.ts @@ -0,0 +1,41 @@ +import type { Storage } from 'unstorage'; +import type { ErrorHandler, FontFetcher } from '../definitions.js'; +import { cache } from '../utils.js'; +import { isAbsolute } from 'node:path'; + +export function createCachedFontFetcher({ + storage, + errorHandler, + fetch, + readFile, +}: { + storage: Storage; + errorHandler: ErrorHandler; + fetch: (url: string) => Promise<Response>; + readFile: (url: string) => Promise<Buffer>; +}): FontFetcher { + return { + async fetch(hash, url) { + return await cache(storage, hash, async () => { + try { + if (isAbsolute(url)) { + return await readFile(url); + } + // TODO: find a way to pass headers + // https://github.com/unjs/unifont/issues/143 + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Response was not successful, received status code ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); + } catch (cause) { + throw errorHandler.handle({ + type: 'cannot-fetch-font-file', + data: { url }, + cause, + }); + } + }); + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts new file mode 100644 index 000000000..4541479d0 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts @@ -0,0 +1,73 @@ +import { fromBuffer, type Font } from '@capsizecss/unpack'; +import type { CssRenderer, FontFetcher, FontMetricsResolver } from '../definitions.js'; +import type { FontFaceMetrics } from '../types.js'; +import { renderFontSrc } from '../utils.js'; + +// Source: https://github.com/unjs/fontaine/blob/main/src/metrics.ts +function filterRequiredMetrics({ + ascent, + descent, + lineGap, + unitsPerEm, + xWidthAvg, +}: Pick<Font, 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'>) { + return { + ascent, + descent, + lineGap, + unitsPerEm, + xWidthAvg, + }; +} + +// Source: https://github.com/unjs/fontaine/blob/f00f84032c5d5da72c8798eae4cd68d3ddfbf340/src/css.ts#L7 +function toPercentage(value: number, fractionDigits = 4) { + const percentage = value * 100; + return `${+percentage.toFixed(fractionDigits)}%`; +} + +export function createCapsizeFontMetricsResolver({ + fontFetcher, + cssRenderer, +}: { + fontFetcher: FontFetcher; + cssRenderer: CssRenderer; +}): FontMetricsResolver { + const cache: Record<string, FontFaceMetrics | null> = {}; + + return { + async getMetrics(name, { hash, url }) { + cache[name] ??= filterRequiredMetrics(await fromBuffer(await fontFetcher.fetch(hash, url))); + return cache[name]; + }, + // Source: https://github.com/unjs/fontaine/blob/f00f84032c5d5da72c8798eae4cd68d3ddfbf340/src/css.ts#L170 + generateFontFace({ + metrics, + fallbackMetrics, + name: fallbackName, + font: fallbackFontName, + properties, + }) { + // Calculate size adjust + const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm; + const fallbackFontXAvgRatio = fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm; + const sizeAdjust = preferredFontXAvgRatio / fallbackFontXAvgRatio; + + const adjustedEmSquare = metrics.unitsPerEm * sizeAdjust; + + // Calculate metric overrides for preferred font + const ascentOverride = metrics.ascent / adjustedEmSquare; + const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare; + const lineGapOverride = metrics.lineGap / adjustedEmSquare; + + return cssRenderer.generateFontFace(fallbackName, { + src: renderFontSrc([{ name: fallbackFontName }]), + 'size-adjust': toPercentage(sizeAdjust), + 'ascent-override': toPercentage(ascentOverride), + 'descent-override': toPercentage(descentOverride), + 'line-gap-override': toPercentage(lineGapOverride), + ...properties, + }); + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/font-type-extractor.ts b/packages/astro/src/assets/fonts/implementations/font-type-extractor.ts new file mode 100644 index 000000000..b61626bb7 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/font-type-extractor.ts @@ -0,0 +1,21 @@ +import { extname } from 'node:path'; +import type { ErrorHandler, FontTypeExtractor } from '../definitions.js'; +import { isFontType } from '../utils.js'; + +export function createFontTypeExtractor({ + errorHandler, +}: { errorHandler: ErrorHandler }): FontTypeExtractor { + return { + extract(url) { + const extension = extname(url).slice(1); + if (!isFontType(extension)) { + throw errorHandler.handle({ + type: 'cannot-extract-font-type', + data: { url }, + cause: `Unexpected extension, got "${extension}"`, + }); + } + return extension; + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/hasher.ts b/packages/astro/src/assets/fonts/implementations/hasher.ts new file mode 100644 index 000000000..2772284c4 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/hasher.ts @@ -0,0 +1,13 @@ +import xxhash from 'xxhash-wasm'; +import type { Hasher } from '../definitions.js'; +import { sortObjectByKey } from '../utils.js'; + +export async function createXxHasher(): Promise<Hasher> { + const { h64ToString: hashString } = await xxhash(); + return { + hashString, + hashObject(input) { + return hashString(JSON.stringify(sortObjectByKey(input))); + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts b/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts new file mode 100644 index 000000000..18738e2bb --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts @@ -0,0 +1,22 @@ +import type { LocalProviderUrlResolver } from '../definitions.js'; +import { resolveEntrypoint } from '../utils.js'; +import { fileURLToPath } from 'node:url'; + +export function createRequireLocalProviderUrlResolver({ + root, + intercept, +}: { + root: URL; + // TODO: remove when stabilizing + intercept?: (path: string) => void; +}): LocalProviderUrlResolver { + return { + resolve(input) { + // fileURLToPath is important so that the file can be read + // by createLocalUrlProxyContentResolver + const path = fileURLToPath(resolveEntrypoint(root, input)); + intercept?.(path); + return path; + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts b/packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts new file mode 100644 index 000000000..7dc3df1e7 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts @@ -0,0 +1,20 @@ +import type { ViteDevServer } from 'vite'; +import type { RemoteFontProviderModResolver } from '../definitions.js'; + +export function createBuildRemoteFontProviderModResolver(): RemoteFontProviderModResolver { + return { + resolve(id) { + return import(id); + }, + }; +} + +export function createDevServerRemoteFontProviderModResolver({ + server, +}: { server: ViteDevServer }): RemoteFontProviderModResolver { + return { + resolve(id) { + return server.ssrLoadModule(id); + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts new file mode 100644 index 000000000..f70a99fbc --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts @@ -0,0 +1,62 @@ +import type { + ErrorHandler, + RemoteFontProviderModResolver, + RemoteFontProviderResolver, +} from '../definitions.js'; +import type { ResolvedFontProvider } from '../types.js'; +import { resolveEntrypoint } from '../utils.js'; + +function validateMod({ + mod, + entrypoint, + errorHandler, +}: { mod: any; entrypoint: string; errorHandler: ErrorHandler }): Pick< + ResolvedFontProvider, + 'provider' +> { + // 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 errorHandler.handle({ + type: 'cannot-load-font-provider', + data: { + entrypoint, + }, + cause, + }); + } +} + +export function createRemoteFontProviderResolver({ + root, + modResolver, + errorHandler, +}: { + root: URL; + modResolver: RemoteFontProviderModResolver; + errorHandler: ErrorHandler; +}): RemoteFontProviderResolver { + return { + async resolve({ entrypoint, config }) { + const id = resolveEntrypoint(root, entrypoint.toString()).href; + const mod = await modResolver.resolve(id); + const { provider } = validateMod({ + mod, + entrypoint: id, + errorHandler, + }); + return { config, provider }; + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/storage.ts b/packages/astro/src/assets/fonts/implementations/storage.ts new file mode 100644 index 000000000..b9e26ebb0 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/storage.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from 'node:url'; +import { createStorage, type Storage } from 'unstorage'; +import fsLiteDriver from 'unstorage/drivers/fs-lite'; + +export function createFsStorage({ base }: { base: URL }): Storage { + return createStorage({ + // Types are weirly exported + driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({ + base: fileURLToPath(base), + }), + }); +} diff --git a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts new file mode 100644 index 000000000..3e2340c78 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts @@ -0,0 +1,79 @@ +import type { SystemFallbacksProvider } from '../definitions.js'; +import type { FontFaceMetrics, GenericFallbackName } from '../types.js'; + +// Extracted from https://raw.githubusercontent.com/seek-oss/capsize/refs/heads/master/packages/metrics/src/entireMetricsCollection.json +const SYSTEM_METRICS = { + 'Times New Roman': { + ascent: 1825, + descent: -443, + lineGap: 87, + unitsPerEm: 2048, + xWidthAvg: 832, + }, + Arial: { + ascent: 1854, + descent: -434, + lineGap: 67, + unitsPerEm: 2048, + xWidthAvg: 913, + }, + 'Courier New': { + ascent: 1705, + descent: -615, + lineGap: 0, + unitsPerEm: 2048, + xWidthAvg: 1229, + }, + BlinkMacSystemFont: { + ascent: 1980, + descent: -432, + lineGap: 0, + unitsPerEm: 2048, + xWidthAvg: 853, + }, + 'Segoe UI': { + ascent: 2210, + descent: -514, + lineGap: 0, + unitsPerEm: 2048, + xWidthAvg: 908, + }, + Roboto: { + ascent: 1900, + descent: -500, + lineGap: 0, + unitsPerEm: 2048, + xWidthAvg: 911, + }, + 'Helvetica Neue': { + ascent: 952, + descent: -213, + lineGap: 28, + unitsPerEm: 1000, + xWidthAvg: 450, + }, +} satisfies Record<string, FontFaceMetrics>; + +type FallbackName = keyof typeof SYSTEM_METRICS; + +// Source: https://github.com/nuxt/fonts/blob/3a3eb6dfecc472242b3011b25f3fcbae237d0acc/src/module.ts#L55-L75 +export const DEFAULT_FALLBACKS = { + serif: ['Times New Roman'], + 'sans-serif': ['Arial'], + monospace: ['Courier New'], + 'system-ui': ['BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial'], + 'ui-serif': ['Times New Roman'], + 'ui-sans-serif': ['Arial'], + 'ui-monospace': ['Courier New'], +} satisfies Partial<Record<GenericFallbackName, Array<FallbackName>>>; + +export function createSystemFallbacksProvider(): SystemFallbacksProvider { + return { + getLocalFonts(fallback) { + return DEFAULT_FALLBACKS[fallback as keyof typeof DEFAULT_FALLBACKS] ?? null; + }, + getMetricsForLocalFont(family) { + return SYSTEM_METRICS[family as FallbackName]; + }, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts b/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts new file mode 100644 index 000000000..2a0aa1d75 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import type { ErrorHandler, UrlProxyContentResolver } from '../definitions.js'; + +export function createLocalUrlProxyContentResolver({ + errorHandler, +}: { errorHandler: ErrorHandler }): UrlProxyContentResolver { + return { + resolve(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 + readFileSync(url, 'utf-8'); + } catch (cause) { + throw errorHandler.handle({ + type: 'unknown-fs-error', + data: {}, + cause, + }); + } + }, + }; +} + +export function createRemoteUrlProxyContentResolver(): UrlProxyContentResolver { + return { + // Passthrough, the remote provider URL is enough + resolve: (url) => url, + }; +} diff --git a/packages/astro/src/assets/fonts/implementations/url-proxy.ts b/packages/astro/src/assets/fonts/implementations/url-proxy.ts new file mode 100644 index 000000000..01cfdc40b --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/url-proxy.ts @@ -0,0 +1,38 @@ +import type { + DataCollector, + FontTypeExtractor, + Hasher, + UrlProxy, + UrlProxyContentResolver, +} from '../definitions.js'; + +export function createUrlProxy({ + base, + contentResolver, + hasher, + dataCollector, + fontTypeExtractor, +}: { + base: string; + contentResolver: UrlProxyContentResolver; + hasher: Hasher; + dataCollector: DataCollector; + fontTypeExtractor: FontTypeExtractor; +}): UrlProxy { + return { + proxy({ url: originalUrl, data, collectPreload }) { + const type = fontTypeExtractor.extract(originalUrl); + const hash = `${hasher.hashString(contentResolver.resolve(originalUrl))}.${type}`; + const url = base + hash; + + dataCollector.collect({ + originalUrl, + hash, + preload: collectPreload ? { url, type } : null, + data, + }); + + return url; + }, + }; +} diff --git a/packages/astro/src/assets/fonts/load.ts b/packages/astro/src/assets/fonts/load.ts deleted file mode 100644 index 20222f439..000000000 --- a/packages/astro/src/assets/fonts/load.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { readFileSync } from 'node:fs'; -import * as unifont from 'unifont'; -import type { Storage } from 'unstorage'; -import { AstroError, AstroErrorData } from '../../core/errors/index.js'; -import { DEFAULTS, LOCAL_PROVIDER_NAME } from './constants.js'; -import type { generateFallbackFontFace } from './metrics.js'; -import { resolveLocalFont } from './providers/local.js'; -import type { PreloadData, ResolvedFontFamily } from './types.js'; -import { - type GetMetricsForFamily, - type GetMetricsForFamilyFont, - type ProxyURLOptions, - familiesToUnifontProviders, - generateFallbacksCSS, - generateFontFace, - proxyURL, -} from './utils.js'; - -interface Options { - base: string; - families: Array<ResolvedFontFamily>; - storage: Storage; - hashToUrlMap: Map<string, string>; - resolvedMap: Map<string, { preloadData: PreloadData; css: string }>; - hashString: (value: string) => string; - log: (message: string) => void; - generateFallbackFontFace: typeof generateFallbackFontFace; - getMetricsForFamily: GetMetricsForFamily; -} - -export async function loadFonts({ - base, - families, - storage, - hashToUrlMap, - resolvedMap, - hashString, - generateFallbackFontFace, - getMetricsForFamily, - log, -}: Options): Promise<void> { - const extractedProvidersResult = familiesToUnifontProviders({ families, hashString }); - families = extractedProvidersResult.families; - const { resolveFont } = await unifont.createUnifont(extractedProvidersResult.providers, { - storage, - }); - - for (const family of families) { - const preloadData: PreloadData = []; - let css = ''; - const fallbacks = family.fallbacks ?? DEFAULTS.fallbacks; - const fallbackFontData: Array<GetMetricsForFamilyFont> = []; - - // When going through the urls/filepaths returned by providers, - // We save the hash and the associated original value so we can use - // it in the vite middleware during development - const collect: ( - parameters: Parameters<ProxyURLOptions['collect']>[0] & { - data: Partial<unifont.FontFaceData>; - }, - collectPreload: boolean, - ) => ReturnType<ProxyURLOptions['collect']> = ({ hash, type, value, data }, collectPreload) => { - const url = base + hash; - if (!hashToUrlMap.has(hash)) { - hashToUrlMap.set(hash, value); - if (collectPreload) { - preloadData.push({ url, type }); - } - } - // If a family has fallbacks, we store the first url we get that may - // be used for the fallback generation - if ( - fallbacks && - fallbacks.length > 0 && - // If the same data has already been sent for this family, we don't want to have duplicate fallbacks - // Such scenario can occur with unicode ranges - !fallbackFontData.some((f) => JSON.stringify(f.data) === JSON.stringify(data)) - ) { - fallbackFontData.push({ - hash, - url: value, - data, - }); - } - return url; - }; - - let fonts: Array<unifont.FontFaceData>; - - if (family.provider === LOCAL_PROVIDER_NAME) { - const result = resolveLocalFont({ - family, - proxyURL: ({ value, data }) => { - return proxyURL({ - value, - // We hash based on the filepath and the contents, since the user could replace - // a given font file with completely different contents. - hashString: (v) => { - let content: string; - try { - content = readFileSync(value, 'utf-8'); - } catch (e) { - throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e }); - } - return hashString(v + content); - }, - collect: (input) => collect({ ...input, data }, true), - }); - }, - }); - fonts = result.fonts; - } else { - const result = await resolveFont( - family.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, - fallbacks: family.fallbacks ?? DEFAULTS.fallbacks, - }, - // 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 familiesToUnifontProviders) - [family.provider.name!], - ); - - fonts = result.fonts - // Avoid getting too much font files - .filter((font) => - typeof font.meta?.priority === 'number' ? font.meta.priority === 0 : 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; - } - const proxied = { - ...source, - originalURL: source.url, - url: proxyURL({ - value: source.url, - // We only use the url for hashing since the service returns urls with a hash already - hashString, - // We only collect the first URL to avoid preloading fallback sources (eg. we only - // preload woff2 if woff is available) - collect: (data) => - collect( - { - ...data, - data: { - weight: font.weight, - style: font.style, - }, - }, - index === 0, - ), - }), - }; - index++; - return proxied; - }), - }; - }); - } - - for (const data of fonts) { - // User settings override the generated font settings - css += generateFontFace(family.nameWithHash, { - src: data.src, - display: - (data.display ?? family.provider === LOCAL_PROVIDER_NAME) ? undefined : family.display, - unicodeRange: - (data.unicodeRange ?? family.provider === LOCAL_PROVIDER_NAME) - ? undefined - : family.unicodeRange, - weight: data.weight, - style: data.style, - stretch: - (data.stretch ?? family.provider === LOCAL_PROVIDER_NAME) ? undefined : family.stretch, - featureSettings: - (data.featureSettings ?? family.provider === LOCAL_PROVIDER_NAME) - ? undefined - : family.featureSettings, - variationSettings: - (data.variationSettings ?? family.provider === LOCAL_PROVIDER_NAME) - ? undefined - : family.variationSettings, - }); - } - - const fallbackData = await generateFallbacksCSS({ - family, - font: fallbackFontData, - fallbacks, - metrics: - (family.optimizedFallbacks ?? DEFAULTS.optimizedFallbacks) - ? { - getMetricsForFamily, - generateFontFace: generateFallbackFontFace, - } - : null, - }); - - const cssVarValues = [family.nameWithHash]; - - if (fallbackData) { - if (fallbackData.css) { - css += fallbackData.css; - } - cssVarValues.push(...fallbackData.fallbacks); - } - - css += `:root { ${family.cssVariable}: ${cssVarValues.join(', ')}; }`; - - resolvedMap.set(family.cssVariable, { preloadData, css }); - } - log('Fonts initialized'); -} diff --git a/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts b/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts new file mode 100644 index 000000000..07759920d --- /dev/null +++ b/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts @@ -0,0 +1,46 @@ +import { LOCAL_PROVIDER_NAME } from '../constants.js'; +import type { Hasher } from '../definitions.js'; +import type { ResolvedFontFamily } from '../types.js'; +import type * as unifont from 'unifont'; + +export function extractUnifontProviders({ + families, + hasher, +}: { + families: Array<ResolvedFontFamily>; + hasher: Hasher; +}): { + families: Array<ResolvedFontFamily>; + providers: Array<unifont.Provider>; +} { + const hashes = new Set<string>(); + const providers: Array<unifont.Provider> = []; + + for (const { provider } of families) { + // The local provider logic happens outside of unifont + if (provider === LOCAL_PROVIDER_NAME) { + continue; + } + + const unifontProvider = provider.provider(provider.config); + const hash = hasher.hashObject({ + name: unifontProvider._name, + ...provider.config, + }); + // Makes sure every font uses the right instance of a given provider + // if this provider is provided several times with different options + // We have to mutate the unifont provider name because unifont deduplicates + // based on the name. + unifontProvider._name += `-${hash}`; + // We set the provider name so we can tell unifont what provider to use when + // resolving font faces + provider.name = unifontProvider._name; + + if (!hashes.has(hash)) { + hashes.add(hash); + providers.push(unifontProvider); + } + } + + return { families, providers }; +} diff --git a/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts b/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts new file mode 100644 index 000000000..4c4c6513d --- /dev/null +++ b/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts @@ -0,0 +1,46 @@ +import type * as unifont from 'unifont'; +import type { UrlProxy } from '../definitions.js'; + +export function normalizeRemoteFontFaces({ + fonts, + urlProxy, +}: { + fonts: Array<unifont.FontFaceData>; + urlProxy: UrlProxy; +}): Array<unifont.FontFaceData> { + return ( + fonts + // Avoid getting too much font files + .filter((font) => (typeof font.meta?.priority === 'number' ? font.meta.priority === 0 : 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; + } + const proxied = { + ...source, + originalURL: source.url, + url: urlProxy.proxy({ + url: 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, + }, + }), + }; + index++; + return proxied; + }), + }; + }) + ); +} diff --git a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts new file mode 100644 index 000000000..181ea9c27 --- /dev/null +++ b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts @@ -0,0 +1,80 @@ +import type { FontMetricsResolver, SystemFallbacksProvider } from '../definitions.js'; +import type { ResolvedFontFamily } from '../types.js'; +import { isGenericFontFamily, unifontFontFaceDataToProperties } from '../utils.js'; +import type * as unifont from 'unifont'; + +export interface CollectedFontForMetrics { + hash: string; + url: string; + data: Partial<unifont.FontFaceData>; +} + +export async function optimizeFallbacks({ + family, + fallbacks: _fallbacks, + collectedFonts, + enabled, + systemFallbacksProvider, + fontMetricsResolver, +}: { + family: Pick<ResolvedFontFamily, 'name' | 'nameWithHash'>; + fallbacks: Array<string>; + collectedFonts: Array<CollectedFontForMetrics>; + enabled: boolean; + systemFallbacksProvider: SystemFallbacksProvider; + fontMetricsResolver: FontMetricsResolver; +}): Promise<null | { + css: string; + fallbacks: Array<string>; +}> { + // We avoid mutating the original array + let fallbacks = [..._fallbacks]; + + if (fallbacks.length === 0 || !enabled || collectedFonts.length === 0) { + return null; + } + + // The last element of the fallbacks is usually a generic family name (eg. serif) + const lastFallback = fallbacks[fallbacks.length - 1]; + // If it's not a generic family name, we can't infer local fonts to be used as fallbacks + if (!isGenericFontFamily(lastFallback)) { + return null; + } + + // If it's a generic family name, we get the associated local fonts (eg. Arial) + const localFonts = systemFallbacksProvider.getLocalFonts(lastFallback); + // Some generic families do not have associated local fonts so we abort early + if (!localFonts || localFonts.length === 0) { + return null; + } + + // If the family is already a system font, no need to generate fallbacks + if (localFonts.includes(family.name)) { + return null; + } + + const localFontsMappings = localFonts.map((font) => ({ + font, + // We must't wrap in quote because that's handled by the CSS renderer + name: `${family.nameWithHash} fallback: ${font}`, + })); + + // We prepend the fallbacks with the local fonts and we dedupe in case a local font is already provided + fallbacks = [...localFontsMappings.map((m) => m.name), ...fallbacks]; + let css = ''; + + for (const { font, name } of localFontsMappings) { + for (const { hash, url, data } of collectedFonts) { + // We generate a fallback for each font collected, which is per weight and style + css += fontMetricsResolver.generateFontFace({ + metrics: await fontMetricsResolver.getMetrics(family.name, { hash, url, data }), + fallbackMetrics: systemFallbacksProvider.getMetricsForLocalFont(font), + font, + name, + properties: unifontFontFaceDataToProperties(data), + }); + } + } + + return { css, fallbacks }; +} diff --git a/packages/astro/src/assets/fonts/logic/resolve-families.ts b/packages/astro/src/assets/fonts/logic/resolve-families.ts new file mode 100644 index 000000000..1ee7bdb9a --- /dev/null +++ b/packages/astro/src/assets/fonts/logic/resolve-families.ts @@ -0,0 +1,99 @@ +import { LOCAL_PROVIDER_NAME } from '../constants.js'; +import type { + RemoteFontProviderResolver, + 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 async 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 + // 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, + 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({ + families, + ...dependencies +}: { families: Array<FontFamily> } & Omit<Parameters<typeof resolveFamily>[0], 'family'>): Promise< + Array<ResolvedFontFamily> +> { + return await Promise.all( + families.map((family) => + resolveFamily({ + family, + ...dependencies, + }), + ), + ); +} diff --git a/packages/astro/src/assets/fonts/metrics.ts b/packages/astro/src/assets/fonts/metrics.ts deleted file mode 100644 index 1dfa2b618..000000000 --- a/packages/astro/src/assets/fonts/metrics.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type Font, fromBuffer } from '@capsizecss/unpack'; -import { renderFontFace, renderFontSrc } from './utils.js'; - -export type FontFaceMetrics = Pick< - Font, - 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg' ->; - -const metricCache: Record<string, FontFaceMetrics | null> = {}; - -function filterRequiredMetrics({ - ascent, - descent, - lineGap, - unitsPerEm, - xWidthAvg, -}: Pick<Font, 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'>) { - return { - ascent, - descent, - lineGap, - unitsPerEm, - xWidthAvg, - }; -} - -export async function readMetrics(family: string, buffer: Buffer) { - const metrics = await fromBuffer(buffer); - - metricCache[family] = filterRequiredMetrics(metrics); - - return metricCache[family]; -} - -// See: https://github.com/seek-oss/capsize/blob/master/packages/core/src/round.ts -function toPercentage(value: number, fractionDigits = 4) { - const percentage = value * 100; - return `${+percentage.toFixed(fractionDigits)}%`; -} - -export function generateFallbackFontFace({ - metrics, - fallbackMetrics, - name: fallbackName, - font: fallbackFontName, - properties, -}: { - metrics: FontFaceMetrics; - fallbackMetrics: FontFaceMetrics; - name: string; - font: string; - properties: Record<string, string | undefined>; -}) { - // Credits to: https://github.com/seek-oss/capsize/blob/master/packages/core/src/createFontStack.ts - - // Calculate size adjust - const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm; - const fallbackFontXAvgRatio = fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm; - const sizeAdjust = preferredFontXAvgRatio / fallbackFontXAvgRatio; - - const adjustedEmSquare = metrics.unitsPerEm * sizeAdjust; - - // Calculate metric overrides for preferred font - const ascentOverride = metrics.ascent / adjustedEmSquare; - const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare; - const lineGapOverride = metrics.lineGap / adjustedEmSquare; - - return renderFontFace({ - 'font-family': fallbackName, - src: renderFontSrc([{ name: fallbackFontName }]), - 'size-adjust': toPercentage(sizeAdjust), - 'ascent-override': toPercentage(ascentOverride), - 'descent-override': toPercentage(descentOverride), - 'line-gap-override': toPercentage(lineGapOverride), - ...properties, - }); -} diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts new file mode 100644 index 000000000..8b47802d5 --- /dev/null +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -0,0 +1,202 @@ +import { LOCAL_PROVIDER_NAME } from './constants.js'; +import { resolveFamilies } from './logic/resolve-families.js'; +import { resolveLocalFont } from './providers/local.js'; +import type { CreateUrlProxyParams, Defaults, FontFamily, PreloadData } from './types.js'; +import * as unifont from 'unifont'; +import { pickFontFaceProperty, unifontFontFaceDataToProperties } from './utils.js'; +import { extractUnifontProviders } from './logic/extract-unifont-providers.js'; +import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js'; +import { optimizeFallbacks, type CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; +import type { + CssRenderer, + FontMetricsResolver, + FontTypeExtractor, + Hasher, + LocalProviderUrlResolver, + RemoteFontProviderResolver, + SystemFallbacksProvider, + UrlProxy, +} from './definitions.js'; +import type { Storage } from 'unstorage'; + +/** + * Manages how fonts are resolved: + * + * - families are resolved + * - unifont providers are extracted from families + * - unifont 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, + remoteFontProviderResolver, + localProviderUrlResolver, + storage, + cssRenderer, + systemFallbacksProvider, + fontMetricsResolver, + fontTypeExtractor, + createUrlProxy, + defaults, +}: { + families: Array<FontFamily>; + hasher: Hasher; + remoteFontProviderResolver: RemoteFontProviderResolver; + localProviderUrlResolver: LocalProviderUrlResolver; + storage: Storage; + cssRenderer: CssRenderer; + systemFallbacksProvider: SystemFallbacksProvider; + fontMetricsResolver: FontMetricsResolver; + fontTypeExtractor: FontTypeExtractor; + createUrlProxy: (params: CreateUrlProxyParams) => UrlProxy; + defaults: Defaults; +}) { + let resolvedFamilies = await resolveFamilies({ + families, + hasher, + remoteFontProviderResolver, + localProviderUrlResolver, + }); + + const extractedUnifontProvidersResult = extractUnifontProviders({ + families: resolvedFamilies, + hasher, + }); + resolvedFamilies = extractedUnifontProvidersResult.families; + const unifontProviders = extractedUnifontProvidersResult.providers; + + const { resolveFont } = await unifont.createUnifont(unifontProviders, { + storage, + }); + + /** + * Holds associations of hash and original font file URLs, so they can be + * downloaded whenever the hash is requested. + */ + const hashToUrlMap = new Map<string, string>(); + /** + * Holds associations of CSS variables and preloadData/css to be passed to the virtual module. + */ + const resolvedMap = new Map<string, { preloadData: Array<PreloadData>; css: string }>(); + + for (const family of resolvedFamilies) { + const preloadData: Array<PreloadData> = []; + let css = ''; + + /** + * Holds a list of font files to be used for optimized fallbacks generation + */ + const collectedFonts: Array<CollectedFontForMetrics> = []; + const fallbacks = family.fallbacks ?? defaults.fallbacks ?? []; + + /** + * 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) => hashToUrlMap.has(hash), + saveUrl: (hash, url) => { + hashToUrlMap.set(hash, url); + }, + savePreload: (preload) => { + preloadData.push(preload); + }, + saveFontData: (collected) => { + if ( + fallbacks && + 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. + !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. + collectedFonts.push(collected); + } + }, + }); + + let fonts: Array<unifont.FontFaceData>; + + if (family.provider === LOCAL_PROVIDER_NAME) { + const result = resolveLocalFont({ + family, + urlProxy, + fontTypeExtractor, + }); + // URLs are already proxied at this point so no further processing is required + fonts = result.fonts; + } else { + const result = await resolveFont( + family.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, + fallbacks: family.fallbacks ?? defaults.fallbacks, + }, + // 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). + [family.provider.name!], + ); + // The data returned by the remote provider contains original URLs. We proxy them. + fonts = normalizeRemoteFontFaces({ fonts: result.fonts, urlProxy }); + } + + 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 }), + }), + ); + } + + 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); + + resolvedMap.set(family.cssVariable, { preloadData, css }); + } + + return { hashToUrlMap, resolvedMap }; +} diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 72beb397d..c67a21d52 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -1,53 +1,42 @@ import type * as unifont from 'unifont'; import { FONT_FORMAT_MAP } from '../constants.js'; import type { ResolvedLocalFontFamily } from '../types.js'; -import { extractFontType } from '../utils.js'; - -// https://fonts.nuxt.com/get-started/providers#local -// https://github.com/nuxt/fonts/blob/main/src/providers/local.ts -// https://github.com/unjs/unifont/blob/main/src/providers/google.ts - -type InitializedProvider = NonNullable<Awaited<ReturnType<unifont.Provider>>>; - -type ResolveFontResult = NonNullable<Awaited<ReturnType<InitializedProvider['resolveFont']>>>; +import type { FontTypeExtractor, UrlProxy } from '../definitions.js'; interface Options { family: ResolvedLocalFontFamily; - proxyURL: (params: { value: string; data: Partial<unifont.FontFaceData> }) => string; + urlProxy: UrlProxy; + fontTypeExtractor: FontTypeExtractor; } -export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResult { - const fonts: ResolveFontResult['fonts'] = []; - - for (const variant of family.variants) { - const data: ResolveFontResult['fonts'][number] = { +export function resolveLocalFont({ family, urlProxy, fontTypeExtractor }: Options): { + fonts: Array<unifont.FontFaceData>; +} { + return { + fonts: family.variants.map((variant) => ({ weight: variant.weight, style: variant.style, - src: variant.src.map(({ url: originalURL, tech }) => { - return { - originalURL, - url: proxyURL({ - value: originalURL, - data: { - weight: variant.weight, - style: variant.style, - }, - }), - format: FONT_FORMAT_MAP[extractFontType(originalURL)], - tech, - }; - }), - }; - if (variant.display) data.display = variant.display; - if (variant.unicodeRange) data.unicodeRange = variant.unicodeRange; - if (variant.stretch) data.stretch = variant.stretch; - if (variant.featureSettings) data.featureSettings = variant.featureSettings; - if (variant.variationSettings) data.variationSettings = variant.variationSettings; - - fonts.push(data); - } - - return { - fonts, + // We proxy each source + src: variant.src.map((source, index) => ({ + originalURL: source.url, + url: urlProxy.proxy({ + url: source.url, + // We only use the first source for preloading. For example if woff2 and woff + // are available, we only keep woff2. + collectPreload: index === 0, + data: { + weight: variant.weight, + style: variant.style, + }, + }), + format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)], + tech: source.tech, + })), + display: variant.display, + unicodeRange: variant.unicodeRange, + stretch: variant.stretch, + featureSettings: variant.featureSettings, + variationSettings: variant.variationSettings, + })), }; } diff --git a/packages/astro/src/assets/fonts/providers/utils.ts b/packages/astro/src/assets/fonts/providers/utils.ts deleted file mode 100644 index 7c0bf9583..000000000 --- a/packages/astro/src/assets/fonts/providers/utils.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; -import type { AstroFontProvider, ResolvedFontProvider } from '../types.js'; -import { resolveEntrypoint } from '../utils.js'; - -export function validateMod(mod: any, entrypoint: string): Pick<ResolvedFontProvider, 'provider'> { - // 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 }, - ); - } -} - -export type ResolveMod = (id: string) => Promise<any>; - -export interface ResolveProviderOptions { - root: URL; - provider: AstroFontProvider; - resolveMod: ResolveMod; -} - -export async function resolveProvider({ - root, - provider: { entrypoint, config }, - resolveMod, -}: ResolveProviderOptions): Promise<ResolvedFontProvider> { - const id = resolveEntrypoint(root, entrypoint.toString()).href; - const mod = await resolveMod(id); - const { provider } = validateMod(mod, id); - return { config, provider }; -} diff --git a/packages/astro/src/assets/fonts/sync.ts b/packages/astro/src/assets/fonts/sync.ts index 57ed03377..a1af4dd70 100644 --- a/packages/astro/src/assets/fonts/sync.ts +++ b/packages/astro/src/assets/fonts/sync.ts @@ -1,6 +1,7 @@ import type { AstroSettings } from '../../types/astro.js'; import { FONTS_TYPES_FILE } from './constants.js'; +// TODO: investigate moving to orchestrate export function syncFonts(settings: AstroSettings): void { if (!settings.config.experimental.fonts) { return; diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index 1cac9bf75..3e3fb89b8 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -5,7 +5,9 @@ import type { localFontFamilySchema, remoteFontFamilySchema, } from './config.js'; -import type { FONT_TYPES } from './constants.js'; +import type { FONT_TYPES, GENERIC_FALLBACK_NAMES } from './constants.js'; +import type { Font } from '@capsizecss/unpack'; +import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; export type AstroFontProvider = z.infer<typeof fontProviderSchema>; @@ -34,6 +36,7 @@ export interface ResolvedLocalFontFamily type RemoteFontFamily = z.infer<typeof remoteFontFamilySchema>; +/** @lintignore somehow required by pickFontFaceProperty in utils */ export interface ResolvedRemoteFontFamily extends ResolvedFontFamilyAttributes, Omit<z.output<typeof remoteFontFamilySchema>, 'provider' | 'weights'> { @@ -49,7 +52,7 @@ export type FontType = (typeof FONT_TYPES)[number]; /** * Preload data is used for links generation inside the <Font /> component */ -export type PreloadData = Array<{ +export interface PreloadData { /** * Absolute link to a font file, eg. /_astro/fonts/abc.woff */ @@ -58,4 +61,26 @@ export type PreloadData = Array<{ * A font type, eg. woff2, woff, ttf... */ type: FontType; -}>; +} + +export type FontFaceMetrics = Pick< + Font, + 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg' +>; + +export type GenericFallbackName = (typeof GENERIC_FALLBACK_NAMES)[number]; + +export type Defaults = Partial< + Pick< + ResolvedRemoteFontFamily, + 'weights' | 'styles' | 'subsets' | 'fallbacks' | 'optimizedFallbacks' + > +>; + +export interface CreateUrlProxyParams { + local: boolean; + hasUrl: (hash: string) => boolean; + saveUrl: (hash: string, url: string) => void; + savePreload: (preload: PreloadData) => void; + saveFontData: (collected: CollectedFontForMetrics) => void; +} diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index e33354aa3..71601c11c 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -1,34 +1,17 @@ import { createRequire } from 'node:module'; -import { extname } from 'node:path'; import { pathToFileURL } from 'node:url'; import type * as unifont from 'unifont'; import type { Storage } from 'unstorage'; -import { AstroError, AstroErrorData } from '../../core/errors/index.js'; -import { DEFAULT_FALLBACKS, FONT_TYPES, LOCAL_PROVIDER_NAME, SYSTEM_METRICS } from './constants.js'; -import type { FontFaceMetrics, generateFallbackFontFace } from './metrics.js'; -import { type ResolveProviderOptions, resolveProvider } from './providers/utils.js'; -import type { - FontFamily, - FontType, - LocalFontFamily, - ResolvedFontFamily, - ResolvedLocalFontFamily, -} from './types.js'; +import { FONT_TYPES, GENERIC_FALLBACK_NAMES, LOCAL_PROVIDER_NAME } from './constants.js'; +import type { FontType, GenericFallbackName, ResolvedFontFamily } from './types.js'; +import type { CssProperties } from './definitions.js'; -export function toCSS(properties: Record<string, string | undefined>, indent = 2) { - return Object.entries(properties) - .filter(([, value]) => Boolean(value)) - .map(([key, value]) => `${' '.repeat(indent)}${key}: ${value};`) - .join('\n'); -} - -export function renderFontFace(properties: Record<string, string | undefined>) { - return `@font-face {\n\t${toCSS(properties)}\n}\n`; -} - -function unifontFontFaceDataToProperties( +/** + * Turns unifont font face data into generic CSS properties, to be consumed by the CSS renderer. + */ +export function unifontFontFaceDataToProperties( font: Partial<unifont.FontFaceData>, -): Record<string, string | undefined> { +): CssProperties { return { src: font.src ? renderFontSrc(font.src) : undefined, 'font-display': font.display ?? 'swap', @@ -41,55 +24,39 @@ function unifontFontFaceDataToProperties( }; } -export function generateFontFace(family: string, font: unifont.FontFaceData) { - return renderFontFace({ - 'font-family': family, - ...unifontFontFaceDataToProperties(font), - }); -} - -// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81 -export function renderFontSrc(sources: Exclude<unifont.FontFaceData['src'][number], string>[]) { +/** + * Turns unifont font face data src into a valid CSS property. + * Adapted from https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81 + */ +export function renderFontSrc( + sources: Exclude<unifont.FontFaceData['src'][number], string>[], +): string { return sources .map((src) => { - if ('url' in src) { - let rendered = `url("${src.url}")`; - if (src.format) { - rendered += ` format("${src.format}")`; - } - if (src.tech) { - rendered += ` tech(${src.tech})`; - } - return rendered; + if ('name' in src) { + return `local("${src.name}")`; + } + let rendered = `url("${src.url}")`; + if (src.format) { + rendered += ` format("${src.format}")`; } - return `local("${src.name}")`; + if (src.tech) { + rendered += ` tech(${src.tech})`; + } + return rendered; }) .join(', '); } const QUOTES_RE = /^["']|["']$/g; -export function withoutQuotes(str: string) { +/** + * Removes the quotes from a string. Used for family names + */ +export function withoutQuotes(str: string): string { return str.trim().replace(QUOTES_RE, ''); } -export function extractFontType(str: string): FontType { - // Extname includes a leading dot - const extension = extname(str).slice(1); - if (!isFontType(extension)) { - throw new AstroError( - { - ...AstroErrorData.CannotExtractFontType, - message: AstroErrorData.CannotExtractFontType.message(str), - }, - { - cause: `Unexpected extension, got "${extension}"`, - }, - ); - } - return extension; -} - export function isFontType(str: string): str is FontType { return (FONT_TYPES as Readonly<Array<string>>).includes(str); } @@ -108,271 +75,30 @@ export async function cache( return data; } -export interface ProxyURLOptions { - /** - * The original URL - */ - value: string; - /** - * Specifies how the hash is computed. Can be based on the value, - * a specific string for testing etc - */ - hashString: (value: string) => string; - /** - * Use the hook to save the associated value and hash, and possibly - * transform it (eg. apply a base) - */ - collect: (data: { - hash: string; - type: FontType; - value: string; - }) => string; +export function isGenericFontFamily(str: string): str is GenericFallbackName { + return (GENERIC_FALLBACK_NAMES as unknown as Array<string>).includes(str); } -/** - * The fonts data we receive contains urls or file paths we do no control. - * However, we will emit font files ourselves so we store the original value - * and replace it with a url we control. For example with the value "https://foo.bar/file.woff2": - * - font type is woff2 - * - hash will be "<hash>.woff2" - * - `collect` will save the association of the original url and the new hash for later use - * - the returned url will be `/_astro/fonts/<hash>.woff2` - */ -export function proxyURL({ value, hashString, collect }: ProxyURLOptions): string { - const type = extractFontType(value); - const hash = `${hashString(value)}.${type}`; - const url = collect({ hash, type, value }); - // Now that we collected the original url, we return our proxy so the consumer can override it - return url; -} - -export function isGenericFontFamily(str: string): str is keyof typeof DEFAULT_FALLBACKS { - return Object.keys(DEFAULT_FALLBACKS).includes(str); -} - -export type GetMetricsForFamilyFont = { - hash: string; - url: string; - data: Partial<unifont.FontFaceData>; -}; - -export type GetMetricsForFamily = ( - name: string, - /** A remote url or local filepath to a font file. Used if metrics can't be resolved purely from the family name */ - font: GetMetricsForFamilyFont, -) => Promise<FontFaceMetrics>; - -/** - * Generates CSS for a given family fallbacks if possible. - * - * It works by trying to get metrics (using capsize) of the provided font family. - * If some can be computed, they will be applied to the eligible fallbacks to match - * the original font shape as close as possible. - */ -export async function generateFallbacksCSS({ - family, - fallbacks: _fallbacks, - font: fontData, - metrics, -}: { - family: Pick<ResolvedFontFamily, 'name' | 'nameWithHash'>; - /** The family fallbacks */ - fallbacks: Array<string>; - font: Array<GetMetricsForFamilyFont>; - metrics: { - getMetricsForFamily: GetMetricsForFamily; - generateFontFace: typeof generateFallbackFontFace; - } | null; -}): Promise<null | { css?: string; fallbacks: Array<string> }> { - // We avoid mutating the original array - let fallbacks = [..._fallbacks]; - if (fallbacks.length === 0) { - return null; - } - - if (fontData.length === 0 || !metrics) { - return { fallbacks }; - } - - // The last element of the fallbacks is usually a generic family name (eg. serif) - const lastFallback = fallbacks[fallbacks.length - 1]; - // If it's not a generic family name, we can't infer local fonts to be used as fallbacks - if (!isGenericFontFamily(lastFallback)) { - return { fallbacks }; - } - - // If it's a generic family name, we get the associated local fonts (eg. Arial) - const localFonts = DEFAULT_FALLBACKS[lastFallback]; - // Some generic families do not have associated local fonts so we abort early - if (localFonts.length === 0) { - return { fallbacks }; - } - - // If the family is already a system font, no need to generate fallbacks - if ( - localFonts.includes( - // @ts-expect-error TS is not smart enough - family.name, - ) - ) { - return { fallbacks }; - } - - const localFontsMappings = localFonts.map((font) => ({ - font, - name: `"${family.nameWithHash} fallback: ${font}"`, - })); - - // We prepend the fallbacks with the local fonts and we dedupe in case a local font is already provided - fallbacks = [...new Set([...localFontsMappings.map((m) => m.name), ...fallbacks])]; - let css = ''; - - for (const { font, name } of localFontsMappings) { - for (const { hash, url, data } of fontData) { - css += metrics.generateFontFace({ - metrics: await metrics.getMetricsForFamily(family.name, { hash, url, data }), - fallbackMetrics: SYSTEM_METRICS[font], - font, - name, - properties: unifontFontFaceDataToProperties(data), - }); - } - } - - return { css, fallbacks }; -} - -function dedupe<const T extends Array<any>>(arr: T): T { +export function dedupe<const T extends Array<any>>(arr: T): T { return [...new Set(arr)] as T; } -function resolveVariants({ - variants, - resolveEntrypoint: _resolveEntrypoint, -}: { - variants: LocalFontFamily['variants']; - resolveEntrypoint: (url: string) => string; -}): ResolvedLocalFontFamily['variants'] { - return variants.map((variant) => ({ - ...variant, - weight: variant.weight.toString(), - src: variant.src.map((value) => { - const isValue = typeof value === 'string' || value instanceof URL; - const url = (isValue ? value : value.url).toString(); - const tech = isValue ? undefined : value.tech; - return { - url: _resolveEntrypoint(url), - tech, - }; - }), - })); -} - -/** - * Resolves the font family provider. If none is provided, it will infer the provider as - * one of the built-in providers and resolve it. The most important part is that if a - * provider is not provided but `src` is, then it's inferred as the local provider. - */ -export async function resolveFontFamily({ - family, - generateNameWithHash, - root, - resolveMod, - resolveLocalEntrypoint, -}: Omit<ResolveProviderOptions, 'provider'> & { - family: FontFamily; - generateNameWithHash: (family: FontFamily) => string; - resolveLocalEntrypoint: (url: string) => string; -}): Promise<ResolvedFontFamily> { - const nameWithHash = generateNameWithHash(family); - - if (family.provider === LOCAL_PROVIDER_NAME) { - return { - ...family, - nameWithHash, - variants: resolveVariants({ - variants: family.variants, - resolveEntrypoint: resolveLocalEntrypoint, - }), - fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, - }; - } - - return { - ...family, - nameWithHash, - provider: await resolveProvider({ - root, - resolveMod, - provider: family.provider, - }), - 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, - fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, - unicodeRange: family.unicodeRange ? dedupe(family.unicodeRange) : undefined, - }; -} - export function sortObjectByKey<T extends Record<string, any>>(unordered: T): T { const ordered = Object.keys(unordered) .sort() .reduce((obj, key) => { + const value = unordered[key]; // @ts-expect-error Type 'T' is generic and can only be indexed for reading. That's fine here - obj[key] = unordered[key]; + obj[key] = Array.isArray(value) + ? value.map((v) => (typeof v === 'object' && v !== null ? sortObjectByKey(v) : v)) + : typeof value === 'object' && value !== null + ? sortObjectByKey(value) + : value; return obj; }, {} as T); return ordered; } -/** - * Extracts providers from families so they can be consumed by unifont. - * It deduplicates them based on their config and provider name: - * - If several families use the same provider (by value, not by reference), we only use one provider - * - If one provider is used with different settings for 2 families, we make sure there are kept as 2 providers - */ -export function familiesToUnifontProviders({ - families, - hashString, -}: { - families: Array<ResolvedFontFamily>; - hashString: (value: string) => string; -}): { families: Array<ResolvedFontFamily>; providers: Array<unifont.Provider> } { - const hashes = new Set<string>(); - const providers: Array<unifont.Provider> = []; - - for (const { provider } of families) { - if (provider === LOCAL_PROVIDER_NAME) { - continue; - } - - const unifontProvider = provider.provider(provider.config); - const hash = hashString( - JSON.stringify( - sortObjectByKey({ - name: unifontProvider._name, - ...provider.config, - }), - ), - ); - // Makes sure every font uses the right instance of a given provider - // if this provider is provided several times with different options - // We have to mutate the unifont provider name because unifont deduplicates - // based on the name. - unifontProvider._name += `-${hash}`; - // We set the provider name so we can tell unifont what provider to use when - // resolving font faces - provider.name = unifontProvider._name; - - if (!hashes.has(hash)) { - hashes.add(hash); - providers.push(unifontProvider); - } - } - - return { families, providers }; -} - export function resolveEntrypoint(root: URL, entrypoint: string): URL { const require = createRequire(root); @@ -382,3 +108,12 @@ export function resolveEntrypoint(root: URL, entrypoint: string): URL { return new URL(entrypoint, root); } } + +export function pickFontFaceProperty< + T extends keyof Pick< + unifont.FontFaceData, + 'display' | 'unicodeRange' | 'stretch' | 'featureSettings' | 'variationSettings' + >, +>(property: T, { data, family }: { data: unifont.FontFaceData; family: ResolvedFontFamily }) { + return data[property] ?? (family.provider === LOCAL_PROVIDER_NAME ? undefined : family[property]); +} diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index c024aa5d9..4412fbe90 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -1,12 +1,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; import { isAbsolute } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { removeTrailingForwardSlash } from '@astrojs/internal-helpers/path'; -import { type Storage, createStorage } from 'unstorage'; -import fsLiteDriver from 'unstorage/drivers/fs-lite'; import type { Plugin } from 'vite'; -import xxhash from 'xxhash-wasm'; import { collectErrorMetadata } from '../../core/errors/dev/utils.js'; import { AstroError, AstroErrorData, isAstroError } from '../../core/errors/index.js'; import type { Logger } from '../../core/logger/core.js'; @@ -15,22 +10,41 @@ import { getClientOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings } from '../../types/astro.js'; import { CACHE_DIR, + DEFAULTS, RESOLVED_VIRTUAL_MODULE_ID, URL_PREFIX, VIRTUAL_MODULE_ID, } from './constants.js'; -import { loadFonts } from './load.js'; -import { generateFallbackFontFace, readMetrics } from './metrics.js'; -import type { ResolveMod } from './providers/utils.js'; -import type { PreloadData, ResolvedFontFamily } from './types.js'; +import type { PreloadData } from './types.js'; +import { orchestrate } from './orchestrate.js'; +import { createXxHasher } from './implementations/hasher.js'; +import { createAstroErrorHandler } from './implementations/error-handler.js'; +import type { + CssRenderer, + FontFetcher, + FontTypeExtractor, + RemoteFontProviderModResolver, +} from './definitions.js'; import { - cache, - extractFontType, - resolveEntrypoint, - resolveFontFamily, - sortObjectByKey, - withoutQuotes, -} from './utils.js'; + createBuildRemoteFontProviderModResolver, + createDevServerRemoteFontProviderModResolver, +} from './implementations/remote-font-provider-mod-resolver.js'; +import { createRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; +import { createRequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; +import { createFsStorage } from './implementations/storage.js'; +import { createSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; +import { createCachedFontFetcher } from './implementations/font-fetcher.js'; +import { createCapsizeFontMetricsResolver } from './implementations/font-metrics-resolver.js'; +import { createUrlProxy } from './implementations/url-proxy.js'; +import { + createLocalUrlProxyContentResolver, + createRemoteUrlProxyContentResolver, +} from './implementations/url-proxy-content-resolver.js'; +import { createDataCollector } from './implementations/data-collector.js'; +import { createMinifiableCssRenderer } from './implementations/css-renderer.js'; +import { createFontTypeExtractor } from './implementations/font-type-extractor.js'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; interface Options { settings: AstroSettings; @@ -38,29 +52,6 @@ interface Options { logger: Logger; } -async function fetchFont(url: string): Promise<Buffer> { - try { - if (isAbsolute(url)) { - return await readFile(url); - } - // TODO: find a way to pass headers - // https://github.com/unjs/unifont/issues/143 - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Response was not successful, received status code ${response.status}`); - } - return Buffer.from(await response.arrayBuffer()); - } catch (cause) { - throw new AstroError( - { - ...AstroErrorData.CannotFetchFontFile, - message: AstroErrorData.CannotFetchFontFile.message(url), - }, - { cause }, - ); - } -} - export function fontsPlugin({ settings, sync, logger }: Options): Plugin { if (!settings.config.experimental.fonts) { // This is required because the virtual module may be imported as @@ -88,80 +79,92 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // to trailingSlash: never) const baseUrl = removeTrailingForwardSlash(settings.config.base) + URL_PREFIX; - let resolvedMap: Map<string, { preloadData: PreloadData; css: string }> | null = null; + let resolvedMap: Map<string, { preloadData: Array<PreloadData>; css: string }> | null = null; // Key is `${hash}.${ext}`, value is a URL. // When a font file is requested (eg. /_astro/fonts/abc.woff), we use the hash // to download the original file, or retrieve it from cache let hashToUrlMap: Map<string, string> | null = null; let isBuild: boolean; - let storage: Storage | null = null; + let fontFetcher: FontFetcher | null = null; + let fontTypeExtractor: FontTypeExtractor | null = null; const cleanup = () => { resolvedMap = null; hashToUrlMap = null; - storage = null; + fontFetcher = null; }; - async function initialize({ resolveMod, base }: { resolveMod: ResolveMod; base: URL }) { - const { h64ToString } = await xxhash(); - - storage = createStorage({ - // Types are weirly exported - driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({ - base: fileURLToPath(base), - }), + async function initialize({ + cacheDir, + modResolver, + cssRenderer, + }: { + cacheDir: URL; + modResolver: RemoteFontProviderModResolver; + cssRenderer: CssRenderer; + }) { + const { root } = settings.config; + // Dependencies. Once extracted to a dedicated vite plugin, those may be passed as + // a Vite plugin option. + const hasher = await createXxHasher(); + const errorHandler = createAstroErrorHandler(); + const remoteFontProviderResolver = createRemoteFontProviderResolver({ + root, + modResolver, + errorHandler, }); - - // We initialize shared variables here and reset them in buildEnd - // to avoid locking memory - hashToUrlMap = new Map(); - resolvedMap = new Map(); - - const families: Array<ResolvedFontFamily> = []; - - const root = settings.config.root; + // TODO: remove when stabilizing const pathsToWarn = new Set<string>(); + const localProviderUrlResolver = createRequireLocalProviderUrlResolver({ + root, + intercept: (path) => { + if (path.startsWith(fileURLToPath(settings.config.publicDir))) { + if (pathsToWarn.has(path)) { + return; + } + pathsToWarn.add(path); + logger.warn( + 'assets', + `Found a local font file ${JSON.stringify(path)} in the \`public/\` folder. To avoid duplicated files in the build output, move this file into \`src/\``, + ); + } + }, + }); + const storage = createFsStorage({ base: cacheDir }); + const systemFallbacksProvider = createSystemFallbacksProvider(); + fontFetcher = createCachedFontFetcher({ storage, errorHandler, fetch, readFile }); + const fontMetricsResolver = createCapsizeFontMetricsResolver({ fontFetcher, cssRenderer }); + fontTypeExtractor = createFontTypeExtractor({ errorHandler }); - for (const family of settings.config.experimental.fonts!) { - families.push( - await resolveFontFamily({ - family, - root, - resolveMod, - generateNameWithHash: (_family) => - `${withoutQuotes(_family.name)}-${h64ToString(JSON.stringify(sortObjectByKey(_family)))}`, - resolveLocalEntrypoint: (url) => { - const resolvedPath = fileURLToPath(resolveEntrypoint(root, url)); - if (resolvedPath.startsWith(fileURLToPath(settings.config.publicDir))) { - pathsToWarn.add(resolvedPath); - } - return resolvedPath; - }, - }), - ); - } - - for (const path of [...pathsToWarn]) { - // TODO: remove when stabilizing - logger.warn( - 'assets', - `Found a local font file ${JSON.stringify(path)} in the \`public/\` folder. To avoid duplicated files in the build output, move this file into \`src/\``, - ); - } - - await loadFonts({ - base: baseUrl, - families, + const res = await orchestrate({ + families: settings.config.experimental.fonts!, + hasher, + remoteFontProviderResolver, + localProviderUrlResolver, storage, - hashToUrlMap, - resolvedMap, - hashString: h64ToString, - generateFallbackFontFace, - getMetricsForFamily: async (name, font) => { - return await readMetrics(name, await cache(storage!, font.hash, () => fetchFont(font.url))); + cssRenderer, + systemFallbacksProvider, + fontMetricsResolver, + fontTypeExtractor, + createUrlProxy: ({ local, ...params }) => { + const dataCollector = createDataCollector(params); + const contentResolver = local + ? createLocalUrlProxyContentResolver({ errorHandler }) + : createRemoteUrlProxyContentResolver(); + return createUrlProxy({ + base: baseUrl, + contentResolver, + hasher, + dataCollector, + fontTypeExtractor: fontTypeExtractor!, + }); }, - log: (message) => logger.info('assets', message), + defaults: DEFAULTS, }); + // We initialize shared variables here and reset them in buildEnd + // to avoid locking memory + hashToUrlMap = res.hashToUrlMap; + resolvedMap = res.resolvedMap; } return { @@ -172,16 +175,18 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { async buildStart() { if (isBuild) { await initialize({ - resolveMod: (id) => import(id), - base: new URL(CACHE_DIR, settings.config.cacheDir), + cacheDir: new URL(CACHE_DIR, settings.config.cacheDir), + modResolver: createBuildRemoteFontProviderModResolver(), + cssRenderer: createMinifiableCssRenderer({ minify: true }), }); } }, async configureServer(server) { await initialize({ - resolveMod: (id) => server.ssrLoadModule(id), // In dev, we cache fonts data in .astro so it can be easily inspected and cleared - base: new URL(CACHE_DIR, settings.dotAstroDir), + cacheDir: new URL(CACHE_DIR, settings.dotAstroDir), + modResolver: createDevServerRemoteFontProviderModResolver({ server }), + cssRenderer: createMinifiableCssRenderer({ minify: false }), }); // 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 @@ -223,10 +228,10 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // Storage should be defined at this point since initialize it called before registering // the middleware. hashToUrlMap is defined at the same time so if it's not set by now, // no url will be matched and this line will not be reached. - const data = await cache(storage!, hash, () => fetchFont(url)); + const data = await fontFetcher!.fetch(hash, url); res.setHeader('Content-Length', data.length); - res.setHeader('Content-Type', `font/${extractFontType(hash)}`); + res.setHeader('Content-Type', `font/${fontTypeExtractor!.extract(hash)}`); res.end(data); } catch (err) { @@ -272,7 +277,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { logger.info('assets', 'Copying fonts...'); await Promise.all( Array.from(hashToUrlMap.entries()).map(async ([hash, url]) => { - const data = await cache(storage!, hash, () => fetchFont(url)); + const data = await fontFetcher!.fetch(hash, url); try { writeFileSync(new URL(hash, fontsDir), data); } catch (cause) { diff --git a/packages/astro/test/fonts.test.js b/packages/astro/test/fonts.test.js index a4364aa62..9ecb4210d 100644 --- a/packages/astro/test/fonts.test.js +++ b/packages/astro/test/fonts.test.js @@ -45,7 +45,7 @@ describe('astro:fonts', () => { { name: 'Roboto', cssVariable: '--font-roboto', - provider: fontProviders.google(), + provider: fontProviders.fontsource(), }, ], }, diff --git a/packages/astro/test/units/assets/fonts/implementations.test.js b/packages/astro/test/units/assets/fonts/implementations.test.js new file mode 100644 index 000000000..b5f2d5a7c --- /dev/null +++ b/packages/astro/test/units/assets/fonts/implementations.test.js @@ -0,0 +1,291 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + handleValueWithSpaces, + renderCssVariable, + renderFontFace, + withFamily, +} from '../../../../dist/assets/fonts/implementations/css-renderer.js'; +import { createDataCollector } from '../../../../dist/assets/fonts/implementations/data-collector.js'; +import { createAstroErrorHandler } from '../../../../dist/assets/fonts/implementations/error-handler.js'; +import { createCachedFontFetcher } from '../../../../dist/assets/fonts/implementations/font-fetcher.js'; +import { createFontTypeExtractor } from '../../../../dist/assets/fonts/implementations/font-type-extractor.js'; +import { createSpyStorage, simpleErrorHandler } from './utils.js'; + +describe('fonts implementations', () => { + describe('createMinifiableCssRenderer()', () => { + describe('renderFontFace()', () => { + it('filters undefined properties properly', () => { + assert.equal(renderFontFace({ foo: 'test' }, true).includes('foo:test'), true); + assert.equal(renderFontFace({ foo: 'test', bar: undefined }, true).includes('bar'), false); + }); + + it('formats properly', () => { + assert.equal(renderFontFace({ foo: 'test' }, false), '@font-face {\n foo: test;\n}\n'); + assert.equal(renderFontFace({ foo: 'test' }, true), '@font-face{foo:test;}'); + }); + }); + + it('renderCssVariable()', () => { + assert.equal( + renderCssVariable('foo', ['bar', 'x y'], false), + ':root {\n foo: bar, "x y";\n}\n', + ); + assert.equal(renderCssVariable('foo', ['bar', 'x y'], true), ':root{foo:bar,"x y";}'); + }); + + it('withFamily()', () => { + assert.deepStrictEqual(withFamily('foo', { bar: 'baz' }), { + 'font-family': 'foo', + bar: 'baz', + }); + assert.deepStrictEqual(withFamily('x y', { bar: 'baz' }), { + 'font-family': '"x y"', + bar: 'baz', + }); + }); + + it('handleValueWithSpaces()', () => { + assert.equal(handleValueWithSpaces('foo'), 'foo'); + assert.equal(handleValueWithSpaces('x y'), '"x y"'); + }); + }); + + it('createDataCollector()', () => { + /** @type {Map<string, string>} */ + const map = new Map(); + /** @type {Array<import('../../../../dist/assets/fonts/types.js').PreloadData>} */ + const preloadData = []; + /** @type {Array<import('../../../../dist/assets/fonts/logic/optimize-fallbacks.js').CollectedFontForMetrics>} */ + const collectedFonts = []; + + const dataCollector = createDataCollector({ + hasUrl: (hash) => map.has(hash), + saveUrl: (hash, url) => { + map.set(hash, url); + }, + savePreload: (preload) => { + preloadData.push(preload); + }, + saveFontData: (collected) => { + collectedFonts.push(collected); + }, + }); + + dataCollector.collect({ hash: 'xxx', originalUrl: 'abc', preload: null, data: {} }); + dataCollector.collect({ + hash: 'yyy', + originalUrl: 'def', + preload: { type: 'woff2', url: 'def' }, + data: {}, + }); + dataCollector.collect({ hash: 'xxx', originalUrl: 'abc', preload: null, data: {} }); + + assert.deepStrictEqual( + [...map.entries()], + [ + ['xxx', 'abc'], + ['yyy', 'def'], + ], + ); + assert.deepStrictEqual(preloadData, [{ type: 'woff2', url: 'def' }]); + assert.deepStrictEqual(collectedFonts, [ + { hash: 'xxx', url: 'abc', data: {} }, + { hash: 'yyy', url: 'def', data: {} }, + { hash: 'xxx', url: 'abc', data: {} }, + ]); + }); + + it('createAstroErrorHandler()', () => { + const errorHandler = createAstroErrorHandler(); + assert.equal( + errorHandler.handle({ type: 'cannot-extract-font-type', data: { url: '' }, cause: null }) + .name, + 'CannotExtractFontType', + ); + assert.equal( + errorHandler.handle({ type: 'cannot-fetch-font-file', data: { url: '' }, cause: null }).name, + 'CannotFetchFontFile', + ); + assert.equal( + errorHandler.handle({ + type: 'cannot-load-font-provider', + data: { entrypoint: '' }, + cause: null, + }).name, + 'CannotLoadFontProvider', + ); + assert.equal( + errorHandler.handle({ type: 'unknown-fs-error', data: {}, cause: null }).name, + 'UnknownFilesystemError', + ); + + assert.equal( + errorHandler.handle({ + type: 'cannot-extract-font-type', + data: { url: '' }, + cause: 'whatever', + }).cause, + 'whatever', + ); + }); + + describe('createCachedFontFetcher()', () => { + /** + * + * @param {{ ok: boolean }} param0 + */ + function createReadFileMock({ ok }) { + /** @type {Array<string>} */ + const filesUrls = []; + return { + filesUrls, + /** @type {(url: string) => Promise<Buffer>} */ + readFile: async (url) => { + filesUrls.push(url); + if (!ok) { + throw 'fs error'; + } + return Buffer.from(''); + }, + }; + } + + /** + * + * @param {{ ok: boolean }} param0 + */ + function createFetchMock({ ok }) { + /** @type {Array<string>} */ + const fetchUrls = []; + return { + fetchUrls, + /** @type {(url: string) => Promise<Response>} */ + fetch: async (url) => { + fetchUrls.push(url); + // @ts-expect-error + return { + ok, + status: ok ? 200 : 500, + arrayBuffer: async () => new ArrayBuffer(), + }; + }, + }; + } + + it('caches work', async () => { + const { filesUrls, readFile } = createReadFileMock({ ok: true }); + const { fetchUrls, fetch } = createFetchMock({ ok: true }); + const { storage, store } = createSpyStorage(); + const fontFetcher = createCachedFontFetcher({ + storage, + errorHandler: simpleErrorHandler, + readFile, + fetch, + }); + + await fontFetcher.fetch('abc', 'def'); + await fontFetcher.fetch('foo', 'bar'); + await fontFetcher.fetch('abc', 'def'); + + assert.deepStrictEqual([...store.keys()], ['abc', 'foo']); + assert.deepStrictEqual(filesUrls, []); + assert.deepStrictEqual(fetchUrls, ['def', 'bar']); + }); + + it('reads files if path is absolute', async () => { + const { filesUrls, readFile } = createReadFileMock({ ok: true }); + const { fetchUrls, fetch } = createFetchMock({ ok: true }); + const { storage } = createSpyStorage(); + const fontFetcher = createCachedFontFetcher({ + storage, + errorHandler: simpleErrorHandler, + readFile, + fetch, + }); + + await fontFetcher.fetch('abc', '/foo/bar'); + + assert.deepStrictEqual(filesUrls, ['/foo/bar']); + assert.deepStrictEqual(fetchUrls, []); + }); + + it('fetches files if path is not absolute', async () => { + const { filesUrls, readFile } = createReadFileMock({ ok: true }); + const { fetchUrls, fetch } = createFetchMock({ ok: true }); + const { storage } = createSpyStorage(); + const fontFetcher = createCachedFontFetcher({ + storage, + errorHandler: simpleErrorHandler, + readFile, + fetch, + }); + + await fontFetcher.fetch('abc', 'https://example.com'); + + assert.deepStrictEqual(filesUrls, []); + assert.deepStrictEqual(fetchUrls, ['https://example.com']); + }); + + it('throws the right error kind', async () => { + const { readFile } = createReadFileMock({ ok: false }); + const { fetch } = createFetchMock({ ok: false }); + const { storage } = createSpyStorage(); + const fontFetcher = createCachedFontFetcher({ + storage, + errorHandler: simpleErrorHandler, + readFile, + fetch, + }); + + let error = await fontFetcher.fetch('abc', '/foo/bar').catch((err) => err); + assert.equal(error instanceof Error, true); + assert.equal(error.message, 'cannot-fetch-font-file'); + assert.equal(error.cause, 'fs error'); + + error = await fontFetcher.fetch('abc', 'https://example.com').catch((err) => err); + assert.equal(error instanceof Error, true); + assert.equal(error.message, 'cannot-fetch-font-file'); + assert.equal(error.cause instanceof Error, true); + assert.equal(error.cause.message.includes('Response was not successful'), true); + }); + }); + + // TODO: find a good way to test this + // describe('createCapsizeFontMetricsResolver()', () => {}); + + it('createFontTypeExtractor()', () => { + /** @type {Array<[string, false | string]>} */ + const data = [ + ['', false], + ['.', false], + ['test.', false], + ['https://foo.bar/file', false], + [ + 'https://fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2', + 'woff2', + ], + ['/home/documents/project/font.ttf', 'ttf'], + ]; + + const fontTypeExtractor = createFontTypeExtractor({ errorHandler: simpleErrorHandler }); + + for (const [input, check] of data) { + try { + const res = fontTypeExtractor.extract(input); + if (check) { + assert.equal(res, check); + } else { + assert.fail(`String ${JSON.stringify(input)} should not be valid`); + } + } catch (e) { + if (check) { + assert.fail(`String ${JSON.stringify(input)} should be valid`); + } else { + assert.equal(e instanceof Error, true); + } + } + } + }); +}); diff --git a/packages/astro/test/units/assets/fonts/load.test.js b/packages/astro/test/units/assets/fonts/load.test.js deleted file mode 100644 index 18ae02bce..000000000 --- a/packages/astro/test/units/assets/fonts/load.test.js +++ /dev/null @@ -1,98 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { it } from 'node:test'; -import { loadFonts } from '../../../../dist/assets/fonts/load.js'; -import { fontProviders } from '../../../../dist/assets/fonts/providers/index.js'; -import { resolveProvider } from '../../../../dist/assets/fonts/providers/utils.js'; - -it('loadFonts()', async () => { - const root = new URL(import.meta.url); - const base = '/test'; - /** @type {Map<string, any>} */ - const store = new Map(); - /** @type {import('unstorage').Storage} */ - // @ts-expect-error - const storage = { - /** - * @param {string} key - * @returns {Promise<any | null>} - */ - getItem: async (key) => { - return store.get(key) ?? null; - }, - /** - * @param {string} key - * @returns {Promise<any | null>} - */ - getItemRaw: async (key) => { - return store.get(key) ?? null; - }, - /** - * @param {string} key - * @param {any} value - * @returns {Promise<void>} - */ - setItemRaw: async (key, value) => { - store.set(key, value); - }, - /** - * @param {string} key - * @param {any} value - * @returns {Promise<void>} - */ - setItem: async (key, value) => { - store.set(key, value); - }, - }; - const hashToUrlMap = new Map(); - const resolvedMap = new Map(); - /** @type {Array<string>} */ - const logs = []; - - await loadFonts({ - base, - families: [ - { - name: 'Roboto', - nameWithHash: 'Roboto-xxx', - provider: await resolveProvider({ - root, - resolveMod: (id) => import(id), - provider: fontProviders.google(), - }), - fallbacks: ['sans-serif'], - cssVariable: '--custom', - display: 'block', - }, - ], - storage, - hashToUrlMap, - resolvedMap, - hashString: (v) => Buffer.from(v).toString('base64'), - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), - generateFallbackFontFace: () => '', - log: (message) => { - logs.push(message); - }, - }); - - assert.equal( - Array.from(store.keys()).every((key) => key.startsWith('google:')), - true, - ); - assert.equal(Array.from(hashToUrlMap.keys()).length > 0, true); - assert.deepStrictEqual(Array.from(resolvedMap.keys()), ['--custom']); - assert.deepStrictEqual(logs, ['Fonts initialized']); - const css = resolvedMap.get('--custom').css; - assert.equal( - css.includes(':root { --custom: Roboto-xxx, "Roboto-xxx fallback: Arial", sans-serif; }'), - true, - ); - assert.equal(css.includes('font-display: block'), true); -}); diff --git a/packages/astro/test/units/assets/fonts/logic.test.js b/packages/astro/test/units/assets/fonts/logic.test.js new file mode 100644 index 000000000..192acaa35 --- /dev/null +++ b/packages/astro/test/units/assets/fonts/logic.test.js @@ -0,0 +1,616 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { resolveFamily } from '../../../../dist/assets/fonts/logic/resolve-families.js'; +import { extractUnifontProviders } from '../../../../dist/assets/fonts/logic/extract-unifont-providers.js'; +import { normalizeRemoteFontFaces } from '../../../../dist/assets/fonts/logic/normalize-remote-font-faces.js'; +import { optimizeFallbacks } from '../../../../dist/assets/fonts/logic/optimize-fallbacks.js'; +import { createSystemFallbacksProvider } from '../../../../dist/assets/fonts/implementations/system-fallbacks-provider.js'; +import { createSpyUrlProxy, fakeFontMetricsResolver, fakeHasher } from './utils.js'; + +describe('fonts logic', () => { + describe('resolveFamily()', () => { + it('removes quotes correctly', async () => { + const hasher = { ...fakeHasher, hashObject: () => 'xxx' }; + let family = await resolveFamily({ + family: { + provider: 'local', + name: 'Test', + cssVariable: '--test', + variants: [ + { + weight: '400', + style: 'normal', + src: ['/'], + }, + ], + }, + hasher, + 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: { + provider: 'local', + name: '"Foo bar"', + cssVariable: '--test', + variants: [ + { + weight: '400', + style: 'normal', + src: ['/'], + }, + ], + }, + hasher, + 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({ + family: { + provider: 'local', + name: 'Test', + cssVariable: '--test', + variants: [ + { + weight: '400', + style: 'normal', + src: ['/'], + }, + ], + }, + hasher: fakeHasher, + localProviderUrlResolver: { + resolve: (url) => url + url, + }, + remoteFontProviderResolver: { + // @ts-expect-error + resolve: async () => ({}), + }, + }); + if (family.provider === 'local') { + assert.deepStrictEqual( + family.variants.map((variant) => variant.src), + [[{ url: '//', tech: undefined }]], + ); + } else { + assert.fail('Should be a local provider'); + } + }); + + it('resolves remote providers', async () => { + const provider = () => {}; + const family = await resolveFamily({ + family: { + provider: { + entrypoint: '', + }, + name: 'Test', + cssVariable: '--test', + }, + hasher: 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({ + family: { + provider: 'local', + name: '"Foo bar"', + cssVariable: '--test', + variants: [ + { + weight: '400', + style: 'normal', + src: ['/'], + }, + ], + fallbacks: ['foo', 'bar', 'foo'], + }, + hasher: fakeHasher, + localProviderUrlResolver: { + resolve: (url) => url, + }, + remoteFontProviderResolver: { + // @ts-expect-error + resolve: async () => ({}), + }, + }); + assert.deepStrictEqual(family.fallbacks, ['foo', 'bar']); + + family = await resolveFamily({ + family: { + provider: { entrypoint: '' }, + name: '"Foo bar"', + cssVariable: '--test', + weights: [400, '400', '500', 'bold'], + styles: ['normal', 'normal', 'italic'], + subsets: ['latin', 'latin'], + fallbacks: ['foo', 'bar', 'foo'], + unicodeRange: ['abc', 'def', 'abc'], + }, + hasher: fakeHasher, + localProviderUrlResolver: { + resolve: (url) => url, + }, + remoteFontProviderResolver: { + // @ts-expect-error + resolve: async () => ({}), + }, + }); + + if (family.provider === 'local') { + assert.fail('Should be a remote provider'); + } else { + assert.deepStrictEqual(family.weights, ['400', '500', 'bold']); + assert.deepStrictEqual(family.styles, ['normal', 'italic']); + assert.deepStrictEqual(family.subsets, ['latin']); + assert.deepStrictEqual(family.fallbacks, ['foo', 'bar']); + assert.deepStrictEqual(family.unicodeRange, ['abc', 'def']); + } + }); + }); + + describe('extractUnifontProviders()', () => { + const createProvider = (/** @type {string} */ name) => () => + Object.assign(() => undefined, { _name: name }); + + /** @param {Array<import('../../../../dist/assets/fonts/types.js').ResolvedFontFamily>} families */ + function createFixture(families) { + const result = extractUnifontProviders({ + families, + hasher: fakeHasher, + }); + return { + /** + * @param {number} length + */ + assertProvidersLength: (length) => { + assert.equal(result.providers.length, length); + }, + /** + * @param {Array<string>} names + */ + assertProvidersNames: (names) => { + assert.deepStrictEqual( + result.families.map((f) => + typeof f.provider === 'string' ? f.provider : f.provider.name, + ), + names, + ); + }, + }; + } + + it('skips local fonts', () => { + const fixture = createFixture([ + { + name: 'Custom', + nameWithHash: 'Custom-xxx', + cssVariable: '--custom', + provider: 'local', + variants: [ + { + src: [{ url: 'a' }], + weight: '400', + style: 'normal', + }, + ], + }, + ]); + fixture.assertProvidersLength(0); + fixture.assertProvidersNames(['local']); + }); + + it('appends a hash to the provider name', () => { + const fixture = createFixture([ + { + name: 'Custom', + nameWithHash: 'Custom-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + }, + }, + ]); + fixture.assertProvidersLength(1); + fixture.assertProvidersNames(['test-{"name":"test"}']); + }); + + it('deduplicates providers with no config', () => { + const fixture = createFixture([ + { + name: 'Foo', + nameWithHash: 'Foo-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + }, + }, + { + name: 'Bar', + nameWithHash: 'Bar-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + }, + }, + ]); + fixture.assertProvidersLength(1); + fixture.assertProvidersNames(['test-{"name":"test"}', 'test-{"name":"test"}']); + }); + + it('deduplicates providers with the same config', () => { + const fixture = createFixture([ + { + name: 'Foo', + nameWithHash: 'Foo-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + config: { x: 'y' }, + }, + }, + { + name: 'Bar', + nameWithHash: 'Bar-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + config: { x: 'y' }, + }, + }, + ]); + fixture.assertProvidersLength(1); + fixture.assertProvidersNames([ + 'test-{"name":"test","x":"y"}', + 'test-{"name":"test","x":"y"}', + ]); + }); + + it('does not deduplicate providers with different configs', () => { + const fixture = createFixture([ + { + name: 'Foo', + nameWithHash: 'Foo-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + config: { + x: 'foo', + }, + }, + }, + { + name: 'Bar', + nameWithHash: 'Bar-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + config: { + x: 'bar', + }, + }, + }, + ]); + fixture.assertProvidersLength(2); + fixture.assertProvidersNames([ + 'test-{"name":"test","x":"foo"}', + 'test-{"name":"test","x":"bar"}', + ]); + }); + }); + + describe('normalizeRemoteFontFaces()', () => { + it('filters font data based on priority', () => { + const { urlProxy } = createSpyUrlProxy(); + assert.equal(normalizeRemoteFontFaces({ fonts: [], urlProxy }).length, 0); + assert.equal( + normalizeRemoteFontFaces({ + fonts: [ + { + src: [], + }, + { + src: [], + meta: {}, + }, + { + src: [], + meta: { priority: undefined }, + }, + { + src: [], + meta: { priority: 0 }, + }, + // Will be ignored + { + src: [], + meta: { priority: 1 }, + }, + ], + urlProxy, + }).length, + 4, + ); + }); + + it('proxies URLs correctly', () => { + const { collected, urlProxy } = createSpyUrlProxy(); + normalizeRemoteFontFaces({ + urlProxy, + fonts: [ + { + weight: '400', + style: 'normal', + src: [{ url: '/' }, { url: '/ignored' }], + }, + { + weight: '500', + style: 'normal', + src: [{ url: '/2' }], + }, + ], + }); + assert.deepStrictEqual(collected, [ + { + url: '/', + collectPreload: true, + data: { weight: '400', style: 'normal' }, + }, + { + url: '/ignored', + collectPreload: false, + data: { weight: '400', style: 'normal' }, + }, + { + url: '/2', + collectPreload: true, + data: { weight: '500', style: 'normal' }, + }, + ]); + }); + + it('collect preloads correctly', () => { + const { collected, urlProxy } = createSpyUrlProxy(); + normalizeRemoteFontFaces({ + urlProxy, + fonts: [ + { + weight: '400', + style: 'normal', + src: [{ name: 'Arial' }, { url: '/' }, { url: '/ignored' }], + }, + { + weight: '500', + style: 'normal', + src: [{ url: '/2' }, { name: 'Foo' }, { url: '/also-ignored' }], + }, + ], + }); + assert.deepStrictEqual(collected, [ + { + url: '/', + collectPreload: true, + data: { weight: '400', style: 'normal' }, + }, + { + url: '/ignored', + collectPreload: false, + data: { weight: '400', style: 'normal' }, + }, + { + url: '/2', + collectPreload: true, + data: { weight: '500', style: 'normal' }, + }, + { + url: '/also-ignored', + collectPreload: false, + data: { weight: '500', style: 'normal' }, + }, + ]); + }); + }); + + describe('optimizeFallbacks()', () => { + const family = { + name: 'Test', + nameWithHash: 'Test-xxx', + }; + const systemFallbacksProvider = createSystemFallbacksProvider(); + const fontMetricsResolver = fakeFontMetricsResolver; + + it('skips if there are no fallbacks', async () => { + assert.equal( + await optimizeFallbacks({ + family, + fallbacks: [], + collectedFonts: [{ url: '', hash: '', data: {} }], + enabled: true, + systemFallbacksProvider, + fontMetricsResolver, + }), + null, + ); + }); + + it('skips if it is not enabled', async () => { + assert.equal( + await optimizeFallbacks({ + family, + fallbacks: ['foo'], + collectedFonts: [{ url: '', hash: '', data: {} }], + enabled: false, + systemFallbacksProvider, + fontMetricsResolver, + }), + null, + ); + }); + + it('skips if there are no collected fonts', async () => { + assert.equal( + await optimizeFallbacks({ + family, + fallbacks: ['foo'], + collectedFonts: [], + enabled: true, + systemFallbacksProvider, + fontMetricsResolver, + }), + null, + ); + }); + + it('skips if the last fallback is not a generic font family', async () => { + assert.equal( + await optimizeFallbacks({ + family, + fallbacks: ['foo'], + collectedFonts: [{ url: '', hash: '', data: {} }], + enabled: true, + systemFallbacksProvider, + fontMetricsResolver, + }), + null, + ); + }); + + it('skips if the last fallback does not have local fonts associated', async () => { + assert.equal( + await optimizeFallbacks({ + family, + fallbacks: ['cursive'], + collectedFonts: [{ url: '', hash: '', data: {} }], + enabled: true, + systemFallbacksProvider, + fontMetricsResolver, + }), + null, + ); + }); + + it('skips if the last fallback does not have local fonts associated', async () => { + assert.equal( + await optimizeFallbacks({ + family: { + name: 'Arial', + nameWithHash: 'Arial-xxx', + }, + fallbacks: ['sans-serif'], + collectedFonts: [{ url: '', hash: '', data: {} }], + enabled: true, + systemFallbacksProvider, + fontMetricsResolver, + }), + null, + ); + }); + + it('places optimized fallbacks at the start', async () => { + const result = await optimizeFallbacks({ + family, + fallbacks: ['foo', 'sans-serif'], + collectedFonts: [{ url: '', hash: '', data: {} }], + enabled: true, + systemFallbacksProvider, + fontMetricsResolver, + }); + assert.deepStrictEqual(result?.fallbacks, ['Test-xxx fallback: Arial', 'foo', 'sans-serif']); + }); + + it('outputs correct css', async () => { + const result = await optimizeFallbacks({ + family, + fallbacks: ['foo', 'sans-serif'], + collectedFonts: [ + { url: '', hash: '', data: { weight: '400' } }, + { url: '', hash: '', data: { weight: '500' } }, + ], + enabled: true, + systemFallbacksProvider, + fontMetricsResolver, + }); + assert.notEqual(result, null); + assert.deepStrictEqual(JSON.parse(`[${result?.css.slice(0, -1)}]`), [ + { + fallbackMetrics: { + ascent: 1854, + descent: -434, + lineGap: 67, + unitsPerEm: 2048, + xWidthAvg: 913, + }, + font: 'Arial', + metrics: { + ascent: 0, + descent: 0, + lineGap: 0, + unitsPerEm: 0, + xWidthAvg: 0, + }, + name: 'Test-xxx fallback: Arial', + properties: { + 'font-display': 'swap', + 'font-weight': '400', + }, + }, + { + fallbackMetrics: { + ascent: 1854, + descent: -434, + lineGap: 67, + unitsPerEm: 2048, + xWidthAvg: 913, + }, + font: 'Arial', + metrics: { + ascent: 0, + descent: 0, + lineGap: 0, + unitsPerEm: 0, + xWidthAvg: 0, + }, + name: 'Test-xxx fallback: Arial', + properties: { + 'font-display': 'swap', + 'font-weight': '500', + }, + }, + ]); + }); + }); +}); diff --git a/packages/astro/test/units/assets/fonts/orchestrate.test.js b/packages/astro/test/units/assets/fonts/orchestrate.test.js new file mode 100644 index 000000000..50725e291 --- /dev/null +++ b/packages/astro/test/units/assets/fonts/orchestrate.test.js @@ -0,0 +1,187 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { orchestrate } from '../../../../dist/assets/fonts/orchestrate.js'; +import { createRemoteFontProviderResolver } from '../../../../dist/assets/fonts/implementations/remote-font-provider-resolver.js'; +import { createBuildRemoteFontProviderModResolver } from '../../../../dist/assets/fonts/implementations/remote-font-provider-mod-resolver.js'; +import { createRequireLocalProviderUrlResolver } from '../../../../dist/assets/fonts/implementations/local-provider-url-resolver.js'; +import { createMinifiableCssRenderer } from '../../../../dist/assets/fonts/implementations/css-renderer.js'; +import { createSystemFallbacksProvider } from '../../../../dist/assets/fonts/implementations/system-fallbacks-provider.js'; +import { createFontTypeExtractor } from '../../../../dist/assets/fonts/implementations/font-type-extractor.js'; +import { createDataCollector } from '../../../../dist/assets/fonts/implementations/data-collector.js'; +import { createUrlProxy } from '../../../../dist/assets/fonts/implementations/url-proxy.js'; +import { createRemoteUrlProxyContentResolver } from '../../../../dist/assets/fonts/implementations/url-proxy-content-resolver.js'; +import { defineAstroFontProvider } from '../../../../dist/assets/fonts/providers/index.js'; +import { + createSpyStorage, + fakeFontMetricsResolver, + fakeHasher, + simpleErrorHandler, +} from './utils.js'; +import { DEFAULTS } from '../../../../dist/assets/fonts/constants.js'; +import { defineFontProvider } from 'unifont'; +import { fileURLToPath } from 'node:url'; + +describe('fonts orchestrate()', () => { + it('works with local fonts', async () => { + const root = new URL(import.meta.url); + const { storage } = createSpyStorage(); + const errorHandler = simpleErrorHandler; + const fontTypeExtractor = createFontTypeExtractor({ errorHandler }); + const hasher = fakeHasher; + const { hashToUrlMap, resolvedMap } = await orchestrate({ + families: [ + { + name: 'Test', + cssVariable: '--test', + provider: 'local', + variants: [ + { + weight: '400', + style: 'normal', + src: ['./my-font.woff2', './my-font.woff'], + }, + ], + }, + ], + hasher, + remoteFontProviderResolver: createRemoteFontProviderResolver({ + root, + errorHandler, + modResolver: createBuildRemoteFontProviderModResolver(), + }), + localProviderUrlResolver: createRequireLocalProviderUrlResolver({ root }), + storage, + cssRenderer: createMinifiableCssRenderer({ minify: true }), + systemFallbacksProvider: createSystemFallbacksProvider(), + fontMetricsResolver: fakeFontMetricsResolver, + fontTypeExtractor, + createUrlProxy: ({ local, ...params }) => { + const dataCollector = createDataCollector(params); + const contentResolver = createRemoteUrlProxyContentResolver(); + return createUrlProxy({ + base: '/test', + contentResolver, + hasher, + dataCollector, + fontTypeExtractor, + }); + }, + defaults: DEFAULTS, + }); + assert.deepStrictEqual( + [...hashToUrlMap.entries()], + [ + [ + fileURLToPath(new URL('my-font.woff2.woff2', root)), + fileURLToPath(new URL('my-font.woff2', root)), + ], + [ + fileURLToPath(new URL('my-font.woff.woff', root)), + fileURLToPath(new URL('my-font.woff', root)), + ], + ], + ); + assert.deepStrictEqual([...resolvedMap.keys()], ['--test']); + const entry = resolvedMap.get('--test'); + assert.deepStrictEqual(entry?.preloadData, [ + { + url: '/test' + fileURLToPath(new URL('my-font.woff2.woff2', root)), + type: 'woff2', + }, + ]); + // Uses the hash + assert.equal(entry?.css.includes('font-family:Test-'), true); + // CSS var + assert.equal(entry?.css.includes(':root{--test:Test-'), true); + // Fallback + assert.equal(entry?.css.includes('fallback: Arial"'), true); + }); + + 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', + }, + ], + }; + }, + }; + }); + const fakeAstroProvider = defineAstroFontProvider({ + entrypoint: 'test', + }); + + const root = new URL(import.meta.url); + const { storage } = createSpyStorage(); + const errorHandler = simpleErrorHandler; + const fontTypeExtractor = createFontTypeExtractor({ errorHandler }); + const hasher = fakeHasher; + const { hashToUrlMap, resolvedMap } = await orchestrate({ + families: [ + { + name: 'Test', + cssVariable: '--test', + provider: fakeAstroProvider, + fallbacks: ['serif'], + }, + ], + hasher, + remoteFontProviderResolver: createRemoteFontProviderResolver({ + root, + errorHandler, + modResolver: { + resolve: async () => ({ + provider: fakeUnifontProvider, + }), + }, + }), + localProviderUrlResolver: createRequireLocalProviderUrlResolver({ root }), + storage, + cssRenderer: createMinifiableCssRenderer({ minify: true }), + systemFallbacksProvider: createSystemFallbacksProvider(), + fontMetricsResolver: fakeFontMetricsResolver, + fontTypeExtractor, + createUrlProxy: ({ local, ...params }) => { + const dataCollector = createDataCollector(params); + const contentResolver = createRemoteUrlProxyContentResolver(); + return createUrlProxy({ + base: '', + contentResolver, + hasher, + dataCollector, + fontTypeExtractor, + }); + }, + defaults: DEFAULTS, + }); + + assert.deepStrictEqual( + [...hashToUrlMap.entries()], + [ + ['https://example.com/foo.woff2.woff2', 'https://example.com/foo.woff2'], + ['https://example.com/foo.woff.woff', 'https://example.com/foo.woff'], + ], + ); + assert.deepStrictEqual([...resolvedMap.keys()], ['--test']); + const entry = resolvedMap.get('--test'); + assert.deepStrictEqual(entry?.preloadData, [ + { url: 'https://example.com/foo.woff2.woff2', type: 'woff2' }, + ]); + // Uses the hash + assert.equal(entry?.css.includes('font-family:Test-'), true); + // CSS var + assert.equal(entry?.css.includes(':root{--test:Test-'), true); + // Fallback + assert.equal(entry?.css.includes('fallback: Times New Roman"'), true); + }); +}); diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 9478d956b..c95078bd2 100644 --- a/packages/astro/test/units/assets/fonts/providers.test.js +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -1,6 +1,5 @@ // @ts-check import assert from 'node:assert/strict'; -import { basename, extname } from 'node:path'; import { describe, it } from 'node:test'; import * as adobeEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/adobe.js'; import * as bunnyEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/bunny.js'; @@ -8,35 +7,9 @@ import * as fontshareEntrypoint from '../../../../dist/assets/fonts/providers/en 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 { resolveProvider, validateMod } from '../../../../dist/assets/fonts/providers/utils.js'; -import { proxyURL } from '../../../../dist/assets/fonts/utils.js'; +import { createFontTypeExtractor } from '../../../../dist/assets/fonts/implementations/font-type-extractor.js'; import { fontProviders } from '../../../../dist/config/entrypoint.js'; - -/** - * @param {Parameters<resolveLocalFont>[0]['family']} family - */ -function resolveLocalFontSpy(family) { - /** @type {Array<string>} */ - const values = []; - - const { fonts } = resolveLocalFont({ - family, - proxyURL: (v) => - proxyURL({ - value: v.value, - hashString: (value) => basename(value, extname(value)), - collect: ({ hash, value }) => { - values.push(value); - return `/_astro/fonts/${hash}`; - }, - }), - }); - - return { - fonts, - values: [...new Set(values)], - }; -} +import { createSpyUrlProxy, simpleErrorHandler } from './utils.js'; describe('fonts providers', () => { describe('config objects', () => { @@ -84,134 +57,98 @@ describe('fonts providers', () => { ); }); - it('resolveLocalFont()', () => { - let { fonts, values } = resolveLocalFontSpy({ - name: 'Custom', - nameWithHash: 'Custom-xxx', - cssVariable: '--custom', - provider: 'local', - variants: [ + describe('resolveLocalFont()', () => { + const fontTypeExtractor = createFontTypeExtractor({ errorHandler: simpleErrorHandler }); + + it('proxies URLs correctly', () => { + const { collected, urlProxy } = createSpyUrlProxy(); + resolveLocalFont({ + urlProxy, + fontTypeExtractor, + family: { + name: 'Test', + nameWithHash: 'Test-xxx', + cssVariable: '--test', + provider: 'local', + variants: [ + { + weight: '400', + style: 'normal', + src: [{ url: '/test.woff2' }, { url: '/ignored.woff' }], + }, + { + weight: '500', + style: 'normal', + src: [{ url: '/2.woff2' }], + }, + ], + }, + }); + assert.deepStrictEqual(collected, [ { - src: [{ url: '/src/fonts/foo.woff2' }, { url: '/src/fonts/foo.ttf' }], - weight: '400', - style: 'normal', - display: 'block', + url: '/test.woff2', + collectPreload: true, + data: { weight: '400', style: 'normal' }, }, - ], - }); - - assert.deepStrictEqual(fonts, [ - { - weight: '400', - style: 'normal', - display: 'block', - src: [ - { - originalURL: '/src/fonts/foo.woff2', - url: '/_astro/fonts/foo.woff2', - format: 'woff2', - tech: undefined, - }, - { - originalURL: '/src/fonts/foo.ttf', - url: '/_astro/fonts/foo.ttf', - format: 'truetype', - tech: undefined, - }, - ], - }, - ]); - assert.deepStrictEqual(values, ['/src/fonts/foo.woff2', '/src/fonts/foo.ttf']); - - ({ fonts, values } = resolveLocalFontSpy({ - name: 'Custom', - nameWithHash: 'Custom-xxx', - cssVariable: '--custom', - provider: 'local', - variants: [ { - src: [{ url: '/src/fonts/bar.eot', tech: 'color-SVG' }], - weight: '600', - style: 'oblique', - stretch: 'condensed', + url: '/ignored.woff', + collectPreload: false, + data: { weight: '400', style: 'normal' }, }, { - src: [{ url: '/src/fonts/bar.eot' }], - weight: '700', - style: 'oblique', - stretch: 'condensed', + url: '/2.woff2', + collectPreload: true, + data: { weight: '500', style: 'normal' }, }, - ], - })); - - assert.deepStrictEqual(fonts, [ - { - weight: '600', - style: 'oblique', - stretch: 'condensed', - src: [ - { - originalURL: '/src/fonts/bar.eot', - url: '/_astro/fonts/bar.eot', - format: 'embedded-opentype', - tech: 'color-SVG', - }, - ], - }, - { - weight: '700', - style: 'oblique', - stretch: 'condensed', - src: [ - { - originalURL: '/src/fonts/bar.eot', - url: '/_astro/fonts/bar.eot', - format: 'embedded-opentype', - tech: undefined, - }, - ], - }, - ]); - assert.deepStrictEqual(values, ['/src/fonts/bar.eot']); - }); - - describe('utils', () => { - it('validateMod()', () => { - const provider = () => {}; - - assert.deepStrictEqual(validateMod({ provider }, 'custom'), { provider }); - - const invalidMods = [{}, null, () => {}, { provider: {} }, { provider: null }]; - - for (const invalidMod of invalidMods) { - try { - validateMod(invalidMod, 'custom'); - assert.fail('This mod should not pass'); - } catch (err) { - assert.equal(err instanceof Error, true); - } - } + ]); }); - it('resolveProvider()', async () => { - const root = new URL(import.meta.url); - const provider = () => {}; - - assert.deepStrictEqual( - await resolveProvider({ - provider: { - entrypoint: 'bar', - config: { abc: 404 }, - }, - - resolveMod: async () => ({ provider }), - root, - }), + it('collect preloads correctly', () => { + const { collected, urlProxy } = createSpyUrlProxy(); + resolveLocalFont({ + urlProxy, + fontTypeExtractor, + family: { + name: 'Test', + nameWithHash: 'Test-xxx', + cssVariable: '--test', + provider: 'local', + variants: [ + { + weight: '400', + style: 'normal', + src: [{ url: '/test.woff2' }, { url: '/ignored.woff' }], + }, + { + weight: '500', + style: 'normal', + src: [{ url: '/2.woff2' }, { url: '/also-ignored.woff' }], + }, + ], + }, + }); + assert.deepStrictEqual(collected, [ { - config: { abc: 404 }, - provider, + url: '/test.woff2', + collectPreload: true, + data: { weight: '400', style: 'normal' }, }, - ); + { + url: '/ignored.woff', + collectPreload: false, + data: { weight: '400', style: 'normal' }, + }, + { + url: '/2.woff2', + collectPreload: true, + data: { weight: '500', style: 'normal' }, + }, + { + url: '/also-ignored.woff', + collectPreload: false, + data: { weight: '500', style: 'normal' }, + }, + ]); }); }); }); diff --git a/packages/astro/test/units/assets/fonts/utils.js b/packages/astro/test/units/assets/fonts/utils.js new file mode 100644 index 000000000..cfb297580 --- /dev/null +++ b/packages/astro/test/units/assets/fonts/utils.js @@ -0,0 +1,83 @@ +// @ts-check + +export function createSpyStorage() { + /** @type {Map<string, any>} */ + const store = new Map(); + /** @type {import('unstorage').Storage} */ + // @ts-expect-error + const storage = { + /** + * @param {string} key + * @returns {Promise<any | null>} + */ + getItem: async (key) => { + return store.get(key) ?? null; + }, + /** + * @param {string} key + * @returns {Promise<any | null>} + */ + getItemRaw: async (key) => { + return store.get(key) ?? null; + }, + /** + * @param {string} key + * @param {any} value + * @returns {Promise<void>} + */ + setItemRaw: async (key, value) => { + store.set(key, value); + }, + /** + * @param {string} key + * @param {any} value + * @returns {Promise<void>} + */ + setItem: async (key, value) => { + store.set(key, value); + }, + }; + + return { storage, store }; +} + +/** @type {import('../../../../dist/assets/fonts/definitions').ErrorHandler} */ +export const simpleErrorHandler = { + handle(input) { + return new Error(input.type, { cause: input.cause }); + }, +}; + +/** @type {import('../../../../dist/assets/fonts/definitions').Hasher} */ +export const fakeHasher = { + hashString: (input) => input, + hashObject: (input) => JSON.stringify(input), +}; + +export function createSpyUrlProxy() { + const collected = []; + /** @type {import('../../../../dist/assets/fonts/definitions').UrlProxy} */ + const urlProxy = { + proxy(input) { + collected.push(input); + return input.url; + }, + }; + return { collected, urlProxy }; +} + +/** @type {import('../../../../dist/assets/fonts/definitions').FontMetricsResolver} */ +export const fakeFontMetricsResolver = { + async getMetrics() { + return { + ascent: 0, + descent: 0, + lineGap: 0, + unitsPerEm: 0, + xWidthAvg: 0, + }; + }, + generateFontFace(input) { + return JSON.stringify(input, null, 2) + `,`; + }, +}; diff --git a/packages/astro/test/units/assets/fonts/utils.test.js b/packages/astro/test/units/assets/fonts/utils.test.js index 1ac51e33a..17f01215c 100644 --- a/packages/astro/test/units/assets/fonts/utils.test.js +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -1,44 +1,14 @@ // @ts-check import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { fontProviders } from '../../../../dist/assets/fonts/providers/index.js'; import { - extractFontType, - familiesToUnifontProviders, - generateFallbacksCSS, isFontType, isGenericFontFamily, - proxyURL, renderFontSrc, - resolveEntrypoint, - resolveFontFamily, - toCSS, + sortObjectByKey, + unifontFontFaceDataToProperties, } from '../../../../dist/assets/fonts/utils.js'; -/** - * - * @param {string} id - * @param {string} value - */ -function proxyURLSpy(id, value) { - /** @type {Parameters<import('../../../../dist/assets/fonts/utils.js').ProxyURLOptions['collect']>[0]} */ - let collected = /** @type {any} */ (undefined); - const url = proxyURL({ - value, - hashString: () => id, - collect: (data) => { - collected = data; - return 'base/' + data.hash; - }, - }); - - return { - url, - collected, - }; -} - describe('fonts utils', () => { it('isFontType()', () => { assert.equal(isFontType('woff2'), true); @@ -49,61 +19,6 @@ describe('fonts utils', () => { assert.equal(isFontType(''), false); }); - it('extractFontType', () => { - /** @type {Array<[string, false | string]>} */ - const data = [ - ['', false], - ['.', false], - ['test.', false], - ['https://foo.bar/file', false], - [ - 'https://fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2', - 'woff2', - ], - ['/home/documents/project/font.ttf', 'ttf'], - ]; - - for (const [input, check] of data) { - try { - const res = extractFontType(input); - if (check) { - assert.equal(res, check); - } else { - assert.fail(`String ${JSON.stringify(input)} should not be valid`); - } - } catch (e) { - if (check) { - assert.fail(`String ${JSON.stringify(input)} should be valid`); - } else { - assert.equal(e instanceof Error, true); - assert.equal(e.title, 'Cannot extract the font type from the given URL.'); - } - } - } - }); - - it('proxyURL()', () => { - let { url, collected } = proxyURLSpy( - 'foo', - 'https://fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2', - ); - assert.equal(url, 'base/foo.woff2'); - assert.deepStrictEqual(collected, { - hash: 'foo.woff2', - type: 'woff2', - value: - 'https://fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2', - }); - - ({ url, collected } = proxyURLSpy('bar', '/home/documents/project/font.ttf')); - assert.equal(url, 'base/bar.ttf'); - assert.deepStrictEqual(collected, { - hash: 'bar.ttf', - type: 'ttf', - value: '/home/documents/project/font.ttf', - }); - }); - it('isGenericFontFamily()', () => { assert.equal(isGenericFontFamily('serif'), true); assert.equal(isGenericFontFamily('sans-serif'), true); @@ -121,451 +36,128 @@ describe('fonts utils', () => { assert.equal(isGenericFontFamily(''), false); }); - describe('generateFallbacksCSS()', () => { - const METRICS_STUB = { - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }; - it('should return null if there are no fallbacks', async () => { + describe('renderFontSrc()', () => { + it('does not output tech(undefined) if key is present without value', () => { assert.equal( - await generateFallbacksCSS({ - family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: [], - font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], - metrics: { - getMetricsForFamily: async () => METRICS_STUB, - generateFontFace: () => '', - }, - }), - null, - ); - }); - - it('should return fallbacks even without automatic fallbacks generation', async () => { - assert.deepStrictEqual( - await generateFallbacksCSS({ - family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['foo'], - font: [], - metrics: null, - }), - { - fallbacks: ['foo'], - }, - ); - }); - - it('should return fallbacks if there are metrics but no generic font family', async () => { - assert.deepStrictEqual( - await generateFallbacksCSS({ - family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['foo'], - font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], - metrics: { - getMetricsForFamily: async () => METRICS_STUB, - generateFontFace: () => '', - }, - }), - { - fallbacks: ['foo'], - }, + renderFontSrc([{ url: 'test', tech: undefined }]).includes('tech(undefined)'), + false, ); }); - it('should return fallbacks if the generic font family does not have fonts associated', async () => { - assert.deepStrictEqual( - await generateFallbacksCSS({ - family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['emoji'], - font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], - metrics: { - getMetricsForFamily: async () => METRICS_STUB, - generateFontFace: () => '', - }, - }), - { - fallbacks: ['emoji'], - }, + it('wraps format in quotes', () => { + assert.equal( + renderFontSrc([{ url: 'test', format: 'woff2' }]).includes('format("woff2")'), + true, ); }); - it('should return fallbacks if the family name is a system font for the associated generic family name', async () => { - assert.deepStrictEqual( - await generateFallbacksCSS({ - family: { name: 'Arial', nameWithHash: 'Arial-xxx' }, - fallbacks: ['sans-serif'], - font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], - metrics: { - getMetricsForFamily: async () => METRICS_STUB, - generateFontFace: () => '', - }, - }), - { - fallbacks: ['sans-serif'], - }, - ); + it('does not wrap tech in quotes', () => { + assert.equal(renderFontSrc([{ url: 'test', tech: 'x' }]).includes('tech(x)'), true); }); - it('resolves fallbacks correctly', async () => { - assert.deepStrictEqual( - await generateFallbacksCSS({ - family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['foo', 'bar'], - font: [], - metrics: { - getMetricsForFamily: async () => METRICS_STUB, - generateFontFace: ({ font, name }) => `[${font},${name}]`, - }, - }), - { - fallbacks: ['foo', 'bar'], - }, - ); - assert.deepStrictEqual( - await generateFallbacksCSS({ - family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['sans-serif', 'foo'], - font: [], - metrics: { - getMetricsForFamily: async () => METRICS_STUB, - generateFontFace: ({ font, name }) => `[${font},${name}]`, - }, - }), - { - fallbacks: ['sans-serif', 'foo'], - }, - ); - assert.deepStrictEqual( - await generateFallbacksCSS({ - family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['foo', 'sans-serif'], - font: [ - { url: '/', hash: 'hash', data: { weight: '400' } }, - { url: '/', hash: 'hash', data: { weight: '500' } }, - ], - metrics: { - getMetricsForFamily: async () => METRICS_STUB, - generateFontFace: ({ font, name, properties }) => - `[${font},${name},${properties['font-weight']}]`, - }, - }), - { - css: '[Arial,"Roboto-xxx fallback: Arial",400][Arial,"Roboto-xxx fallback: Arial",500]', - fallbacks: ['"Roboto-xxx fallback: Arial"', 'foo', 'sans-serif'], - }, - ); + it('returns local if it has a name', () => { + assert.equal(renderFontSrc([{ name: 'Arial' }]), 'local("Arial")'); }); }); - describe('resolveFontFamily()', () => { - const root = new URL(import.meta.url); + it('unifontFontFaceDataToProperties()', () => { + assert.deepStrictEqual( + unifontFontFaceDataToProperties({ + display: 'auto', + unicodeRange: ['foo', 'bar'], + weight: '400', + style: 'normal', + stretch: 'condensed', + featureSettings: 'foo', + variationSettings: 'bar', + }), + { + src: undefined, + 'font-display': 'auto', + 'unicode-range': 'foo,bar', + 'font-weight': '400', + 'font-style': 'normal', + 'font-stretch': 'condensed', + 'font-feature-settings': 'foo', + 'font-variation-settings': 'bar', + }, + ); + }); - it('handles the local provider correctly', async () => { - assert.deepStrictEqual( - await resolveFontFamily({ - family: { - name: 'Custom', - cssVariable: '--custom', - provider: 'local', - variants: [ - { - src: ['a'], - weight: 400, - style: 'normal', - }, - ], - }, - resolveMod: async () => ({ provider: () => {} }), - generateNameWithHash: (family) => `${family.name}-x`, - root, - resolveLocalEntrypoint: (url) => fileURLToPath(resolveEntrypoint(root, url)), - }), - { - name: 'Custom', - nameWithHash: 'Custom-x', - cssVariable: '--custom', - provider: 'local', - fallbacks: undefined, - variants: [ + it('sortObjectByKey()', () => { + assert.equal( + JSON.stringify( + sortObjectByKey({ + b: '', + d: '', + e: [ { - src: [{ url: fileURLToPath(new URL('a', root)), tech: undefined }], - weight: '400', - style: 'normal', - }, - ], - }, - ); - assert.deepStrictEqual( - await resolveFontFamily({ - family: { - name: 'Custom', - cssVariable: '--custom', - provider: 'local', - variants: [ - { - src: ['a'], - weight: 400, - style: 'normal', + b: '', + d: '', + a: '', + c: { + b: '', + d: '', + a: '', + c: {}, }, - ], - }, - resolveMod: async () => ({ provider: () => {} }), - generateNameWithHash: (family) => `${family.name}-x`, - root, - resolveLocalEntrypoint: (url) => fileURLToPath(resolveEntrypoint(root, url)), - }), - { - name: 'Custom', - nameWithHash: 'Custom-x', - cssVariable: '--custom', - provider: 'local', - fallbacks: undefined, - variants: [ - { - src: [{ url: fileURLToPath(new URL('a', root)), tech: undefined }], - weight: '400', - style: 'normal', }, - ], - }, - ); - }); - - it('handles the google provider correctly', async () => { - let res = await resolveFontFamily({ - family: { - name: 'Custom', - cssVariable: '--custom', - provider: fontProviders.google(), - }, - resolveMod: (id) => import(id), - generateNameWithHash: (family) => `${family.name}-x`, - root, - resolveLocalEntrypoint: (url) => fileURLToPath(resolveEntrypoint(root, url)), - }); - assert.equal(res.name, 'Custom'); - // Required to make TS happy - if (res.provider !== 'local') { - const provider = res.provider.provider(res.provider.config); - assert.equal(provider._name, 'google'); - } - - res = await resolveFontFamily({ - family: { - name: 'Custom', - cssVariable: '--custom', - provider: fontProviders.google(), - }, - resolveMod: (id) => import(id), - generateNameWithHash: (family) => `${family.name}-x`, - root, - resolveLocalEntrypoint: (url) => fileURLToPath(resolveEntrypoint(root, url)), - }); - assert.equal(res.name, 'Custom'); - // Required to make TS happy - if (res.provider !== 'local') { - const provider = res.provider.provider(res.provider.config); - assert.equal(provider._name, 'google'); - } - }); - - it('handles custom providers correctly', async () => { - const res = await resolveFontFamily({ - family: { - name: 'Custom', - cssVariable: '--custom', - provider: { - entrypoint: '', - }, - }, - resolveMod: async () => ({ provider: () => Object.assign(() => {}, { _name: 'test' }) }), - generateNameWithHash: (family) => `${family.name}-x`, - root, - resolveLocalEntrypoint: (url) => fileURLToPath(resolveEntrypoint(root, url)), - }); - assert.equal(res.name, 'Custom'); - if (res.provider !== 'local') { - // Required to make TS happy - const provider = res.provider.provider(res.provider.config); - assert.equal(provider._name, 'test'); - } - }); - }); - - describe('familiesToUnifontProviders()', () => { - const createProvider = (/** @type {string} */ name) => () => - Object.assign(() => undefined, { _name: name }); - - /** @param {Array<import('../../../../dist/assets/fonts/types.js').ResolvedFontFamily>} families */ - function createFixture(families) { - const result = familiesToUnifontProviders({ - hashString: (v) => v, - families, - }); - return { - /** - * @param {number} length - */ - assertProvidersLength: (length) => { - assert.equal(result.providers.length, length); - }, - /** - * @param {Array<string>} names - */ - assertProvidersNames: (names) => { - assert.deepStrictEqual( - result.families.map((f) => - typeof f.provider === 'string' ? f.provider : f.provider.name, - ), - names, - ); - }, - }; - } - - it('skips local fonts', () => { - const fixture = createFixture([ - { - name: 'Custom', - nameWithHash: 'Custom-xxx', - cssVariable: '--custom', - provider: 'local', - variants: [ { - src: [{ url: 'a' }], - weight: '400', - style: 'normal', + b: '', + d: '', + a: '', + c: { + b: '', + d: '', + a: '', + c: {}, + }, }, ], - }, - ]); - fixture.assertProvidersLength(0); - fixture.assertProvidersNames(['local']); - }); - - it('appends a hash to the provider name', () => { - const fixture = createFixture([ - { - name: 'Custom', - nameWithHash: 'Custom-xxx', - cssVariable: '--custom', - provider: { - provider: createProvider('test'), - }, - }, - ]); - fixture.assertProvidersLength(1); - fixture.assertProvidersNames(['test-{"name":"test"}']); - }); - - it('deduplicates providers with no config', () => { - const fixture = createFixture([ - { - name: 'Foo', - nameWithHash: 'Foo-xxx', - cssVariable: '--custom', - provider: { - provider: createProvider('test'), - }, - }, - { - name: 'Bar', - nameWithHash: 'Bar-xxx', - cssVariable: '--custom', - provider: { - provider: createProvider('test'), + a: '', + c: { + b: '', + d: '', + a: '', + c: {}, }, - }, - ]); - fixture.assertProvidersLength(1); - fixture.assertProvidersNames(['test-{"name":"test"}', 'test-{"name":"test"}']); - }); - - it('deduplicates providers with the same config', () => { - const fixture = createFixture([ - { - name: 'Foo', - nameWithHash: 'Foo-xxx', - cssVariable: '--custom', - provider: { - provider: createProvider('test'), - config: { x: 'y' }, - }, - }, - { - name: 'Bar', - nameWithHash: 'Bar-xxx', - cssVariable: '--custom', - provider: { - provider: createProvider('test'), - config: { x: 'y' }, - }, - }, - ]); - fixture.assertProvidersLength(1); - fixture.assertProvidersNames([ - 'test-{"name":"test","x":"y"}', - 'test-{"name":"test","x":"y"}', - ]); - }); - - it('does not deduplicate providers with different configs', () => { - const fixture = createFixture([ - { - name: 'Foo', - nameWithHash: 'Foo-xxx', - cssVariable: '--custom', - provider: { - provider: createProvider('test'), - config: { - x: 'foo', + }), + ), + JSON.stringify({ + a: '', + b: '', + c: { + a: '', + b: '', + c: {}, + d: '', + }, + d: '', + e: [ + { + a: '', + b: '', + c: { + a: '', + b: '', + c: {}, + d: '', }, - }, - }, - { - name: 'Bar', - nameWithHash: 'Bar-xxx', - cssVariable: '--custom', - provider: { - provider: createProvider('test'), - config: { - x: 'bar', + d: '', + }, + { + a: '', + b: '', + c: { + a: '', + b: '', + c: {}, + d: '', }, + d: '', }, - }, - ]); - fixture.assertProvidersLength(2); - fixture.assertProvidersNames([ - 'test-{"name":"test","x":"foo"}', - 'test-{"name":"test","x":"bar"}', - ]); - }); - }); - - describe('renderFontSrc()', () => { - it('does not output tech(undefined) if key is present without value', () => { - assert.equal( - renderFontSrc([{ url: 'test', tech: undefined }]).includes('tech(undefined)'), - false, - ); - }); - it('wraps format in quotes', () => { - assert.equal( - renderFontSrc([{ url: 'test', format: 'woff2' }]).includes('format("woff2")'), - true, - ); - }); - it('does not wrap tech in quotes', () => { - assert.equal(renderFontSrc([{ url: 'test', tech: 'x' }]).includes('tech(x)'), true); - }); - }); - - it('toCSS', () => { - assert.deepStrictEqual(toCSS({}, 0), ''); - assert.deepStrictEqual(toCSS({ foo: 'bar' }, 0), 'foo: bar;'); - assert.deepStrictEqual(toCSS({ foo: 'bar', bar: undefined }, 0), 'foo: bar;'); + ], + }), + ); }); }); |