summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Florian Lefebvre <contact@florian-lefebvre.dev> 2025-04-28 12:08:31 +0200
committerGravatar GitHub <noreply@github.com> 2025-04-28 12:08:31 +0200
commita7b2dc60ca94f42a66575feb190e8b0f36b48e7c (patch)
tree8262fcf30b2fb4852a1e7f39e100aad51f35e05b
parentab98f884f2f8639a8f385cdbc919bc829014f64d (diff)
downloadastro-a7b2dc60ca94f42a66575feb190e8b0f36b48e7c.tar.gz
astro-a7b2dc60ca94f42a66575feb190e8b0f36b48e7c.tar.zst
astro-a7b2dc60ca94f42a66575feb190e8b0f36b48e7c.zip
feat(fonts): refactor (#13653)
-rw-r--r--.changeset/slick-garlics-do.md5
-rw-r--r--.changeset/sour-dryers-swim.md5
-rw-r--r--packages/astro/src/assets/fonts/config.ts5
-rw-r--r--packages/astro/src/assets/fonts/constants.ts91
-rw-r--r--packages/astro/src/assets/fonts/definitions.ts95
-rw-r--r--packages/astro/src/assets/fonts/implementations/css-renderer.ts50
-rw-r--r--packages/astro/src/assets/fonts/implementations/data-collector.ts25
-rw-r--r--packages/astro/src/assets/fonts/implementations/error-handler.ts34
-rw-r--r--packages/astro/src/assets/fonts/implementations/font-fetcher.ts41
-rw-r--r--packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts73
-rw-r--r--packages/astro/src/assets/fonts/implementations/font-type-extractor.ts21
-rw-r--r--packages/astro/src/assets/fonts/implementations/hasher.ts13
-rw-r--r--packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts22
-rw-r--r--packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts20
-rw-r--r--packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts62
-rw-r--r--packages/astro/src/assets/fonts/implementations/storage.ts12
-rw-r--r--packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts79
-rw-r--r--packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts30
-rw-r--r--packages/astro/src/assets/fonts/implementations/url-proxy.ts38
-rw-r--r--packages/astro/src/assets/fonts/load.ts225
-rw-r--r--packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts46
-rw-r--r--packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts46
-rw-r--r--packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts80
-rw-r--r--packages/astro/src/assets/fonts/logic/resolve-families.ts99
-rw-r--r--packages/astro/src/assets/fonts/metrics.ts77
-rw-r--r--packages/astro/src/assets/fonts/orchestrate.ts202
-rw-r--r--packages/astro/src/assets/fonts/providers/local.ts71
-rw-r--r--packages/astro/src/assets/fonts/providers/utils.ts47
-rw-r--r--packages/astro/src/assets/fonts/sync.ts1
-rw-r--r--packages/astro/src/assets/fonts/types.ts31
-rw-r--r--packages/astro/src/assets/fonts/utils.ts359
-rw-r--r--packages/astro/src/assets/fonts/vite-plugin-fonts.ts211
-rw-r--r--packages/astro/test/fonts.test.js2
-rw-r--r--packages/astro/test/units/assets/fonts/implementations.test.js291
-rw-r--r--packages/astro/test/units/assets/fonts/load.test.js98
-rw-r--r--packages/astro/test/units/assets/fonts/logic.test.js616
-rw-r--r--packages/astro/test/units/assets/fonts/orchestrate.test.js187
-rw-r--r--packages/astro/test/units/assets/fonts/providers.test.js231
-rw-r--r--packages/astro/test/units/assets/fonts/utils.js83
-rw-r--r--packages/astro/test/units/assets/fonts/utils.test.js612
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;');
+ ],
+ }),
+ );
});
});