From 71f782069ea0dc7f91a7b349acbf9a26d6b4f0e1 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 16 Apr 2025 15:23:12 +0200 Subject: [PATCH 01/68] feat(fonts): generate fallbacks for all faces --- .changeset/major-beds-press.md | 5 + packages/astro/src/assets/fonts/load.ts | 35 ++++-- packages/astro/src/assets/fonts/metrics.ts | 4 +- .../astro/src/assets/fonts/providers/local.ts | 10 +- packages/astro/src/assets/fonts/utils.ts | 60 ++++++---- .../test/units/assets/fonts/load.test.js | 2 +- .../test/units/assets/fonts/providers.test.js | 4 +- .../test/units/assets/fonts/utils.test.js | 104 +++++++++--------- 8 files changed, 130 insertions(+), 94 deletions(-) create mode 100644 .changeset/major-beds-press.md diff --git a/.changeset/major-beds-press.md b/.changeset/major-beds-press.md new file mode 100644 index 000000000000..13dda9899cc7 --- /dev/null +++ b/.changeset/major-beds-press.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +The experimental fonts API now generates optimized fallbacks for every weight and style diff --git a/packages/astro/src/assets/fonts/load.ts b/packages/astro/src/assets/fonts/load.ts index 3992ccf398c7..7c8d7d6bfae1 100644 --- a/packages/astro/src/assets/fonts/load.ts +++ b/packages/astro/src/assets/fonts/load.ts @@ -48,12 +48,17 @@ export async function loadFonts({ for (const family of families) { const preloadData: PreloadData = []; let css = ''; - let fallbackFontData: GetMetricsForFamilyFont | null = null; + const fallbacks = family.fallbacks ?? DEFAULTS.fallbacks; + const fallbackFontData: Array = []; // 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: ProxyURLOptions['collect'] = ({ hash, type, value }) => { + const collect: ( + parameters: Parameters[0] & { + data: Partial; + }, + ) => ReturnType = ({ hash, type, value, data }) => { const url = base + hash; if (!hashToUrlMap.has(hash)) { hashToUrlMap.set(hash, value); @@ -62,11 +67,12 @@ export async function loadFonts({ // If a family has fallbacks, we store the first url we get that may // be used for the fallback generation, if capsize doesn't have this // family in its built-in collection - if (family.fallbacks && family.fallbacks.length > 0) { - fallbackFontData ??= { + if (fallbacks && fallbacks.length > 0) { + fallbackFontData.push({ hash, url: value, - }; + data, + }); } return url; }; @@ -76,7 +82,7 @@ export async function loadFonts({ if (family.provider === LOCAL_PROVIDER_NAME) { const result = resolveLocalFont({ family, - proxyURL: (value) => { + proxyURL: ({ value, data }) => { return proxyURL({ value, // We hash based on the filepath and the contents, since the user could replace @@ -90,7 +96,7 @@ export async function loadFonts({ } return hashString(v + content); }, - collect, + collect: (input) => collect({ ...input, data }), }); }, }); @@ -133,7 +139,14 @@ export async function loadFonts({ value: source.url, // We only use the url for hashing since the service returns urls with a hash already hashString, - collect, + collect: (data) => + collect({ + ...data, + data: { + weight: font.weight, + style: font.style, + }, + }), }), }, ), @@ -168,7 +181,7 @@ export async function loadFonts({ const fallbackData = await generateFallbacksCSS({ family, font: fallbackFontData, - fallbacks: family.fallbacks ?? DEFAULTS.fallbacks, + fallbacks, metrics: (family.optimizedFallbacks ?? DEFAULTS.optimizedFallbacks) ? { @@ -181,7 +194,9 @@ export async function loadFonts({ const cssVarValues = [family.nameWithHash]; if (fallbackData) { - css += fallbackData.css; + if (fallbackData.css) { + css += fallbackData.css; + } cssVarValues.push(...fallbackData.fallbacks); } diff --git a/packages/astro/src/assets/fonts/metrics.ts b/packages/astro/src/assets/fonts/metrics.ts index 0ab6f3fa424b..1dfa2b6184f8 100644 --- a/packages/astro/src/assets/fonts/metrics.ts +++ b/packages/astro/src/assets/fonts/metrics.ts @@ -43,13 +43,13 @@ export function generateFallbackFontFace({ fallbackMetrics, name: fallbackName, font: fallbackFontName, - properties = {}, + properties, }: { metrics: FontFaceMetrics; fallbackMetrics: FontFaceMetrics; name: string; font: string; - properties?: Record; + properties: Record; }) { // Credits to: https://github.com/seek-oss/capsize/blob/master/packages/core/src/createFontStack.ts diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 80f500e75805..739fbdd42e0e 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -12,7 +12,7 @@ type ResolveFontResult = NonNullable string; + proxyURL: (params: { value: string; data: Partial }) => string; } export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResult { @@ -25,7 +25,13 @@ export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResu src: variant.src.map(({ url: originalURL, tech }) => { return { originalURL, - url: proxyURL(originalURL), + url: proxyURL({ + value: originalURL, + data: { + weight: variant.weight, + style: variant.style, + }, + }), format: extractFontType(originalURL), tech, }; diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 6c76406c7096..4987b9947d5e 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -26,10 +26,11 @@ export function renderFontFace(properties: Record) { return `@font-face {\n\t${toCSS(properties)}\n}\n`; } -export function generateFontFace(family: string, font: unifont.FontFaceData) { - return renderFontFace({ - 'font-family': family, - src: renderFontSrc(font.src), +export function unifontFontFaceDataToProperties( + font: Partial, +): Record { + return { + src: font.src ? renderFontSrc(font.src) : undefined, 'font-display': font.display ?? 'swap', 'unicode-range': font.unicodeRange?.join(','), 'font-weight': Array.isArray(font.weight) ? font.weight.join(' ') : font.weight?.toString(), @@ -37,6 +38,13 @@ export function generateFontFace(family: string, font: unifont.FontFaceData) { 'font-stretch': font.stretch, 'font-feature-settings': font.featureSettings, 'font-variation-settings': font.variationSettings, + }; +} + +export function generateFontFace(family: string, font: unifont.FontFaceData) { + return renderFontFace({ + 'font-family': family, + ...unifontFontFaceDataToProperties(font), }); } @@ -145,6 +153,7 @@ export function isGenericFontFamily(str: string): str is keyof typeof DEFAULT_FA export type GetMetricsForFamilyFont = { hash: string; url: string; + data: Partial; }; export type GetMetricsForFamily = ( @@ -169,42 +178,44 @@ export async function generateFallbacksCSS({ family: Pick; /** The family fallbacks */ fallbacks: Array; - font: GetMetricsForFamilyFont | null; + font: Array; metrics: { getMetricsForFamily: GetMetricsForFamily; generateFontFace: typeof generateFallbackFontFace; } | null; -}): Promise }> { +}): Promise }> { // We avoid mutating the original array let fallbacks = [..._fallbacks]; if (fallbacks.length === 0) { return null; } - let css = ''; - - if (!fontData || !metrics) { - return { css, fallbacks }; + 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 { css, fallbacks }; + 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 { css, fallbacks }; + return { fallbacks }; } - const foundMetrics = await metrics.getMetricsForFamily(family.name, fontData); - if (!foundMetrics) { - // If there are no metrics, we can't generate useful fallbacks - return { css, 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) => ({ @@ -214,15 +225,18 @@ export async function generateFallbacksCSS({ // 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) { - css += metrics.generateFontFace({ - metrics: foundMetrics, - fallbackMetrics: SYSTEM_METRICS[font], - font, - name, - // TODO: forward some properties once we generate one fallback per font face data - }); + 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 }; diff --git a/packages/astro/test/units/assets/fonts/load.test.js b/packages/astro/test/units/assets/fonts/load.test.js index 857d1b732f4b..18ae02bcea2c 100644 --- a/packages/astro/test/units/assets/fonts/load.test.js +++ b/packages/astro/test/units/assets/fonts/load.test.js @@ -1,5 +1,5 @@ -import assert from 'node:assert/strict'; // @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'; diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 18c5945ebe6a..2f82ae81694b 100644 --- a/packages/astro/test/units/assets/fonts/providers.test.js +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -1,6 +1,6 @@ +// @ts-check import assert from 'node:assert/strict'; import { basename, extname } from 'node:path'; -// @ts-check 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'; @@ -23,7 +23,7 @@ function resolveLocalFontSpy(family) { family, proxyURL: (v) => proxyURL({ - value: v, + value: v.value, hashString: (value) => basename(value, extname(value)), collect: ({ hash, value }) => { values.push(value); diff --git a/packages/astro/test/units/assets/fonts/utils.test.js b/packages/astro/test/units/assets/fonts/utils.test.js index d41d80ba4bce..561415abf683 100644 --- a/packages/astro/test/units/assets/fonts/utils.test.js +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -1,5 +1,5 @@ -import assert from 'node:assert/strict'; // @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'; @@ -121,14 +121,21 @@ describe('fonts utils', () => { }); 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 () => { assert.equal( await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: [], - font: null, + font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], metrics: { - getMetricsForFamily: async () => null, + getMetricsForFamily: async () => METRICS_STUB, generateFontFace: () => '', }, }), @@ -141,78 +148,62 @@ describe('fonts utils', () => { await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: ['foo'], - font: null, + font: [], metrics: null, }), { - css: '', fallbacks: ['foo'], }, ); }); - it('should return fallbacks if there are no metrics', async () => { + 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: null, + font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], metrics: { - getMetricsForFamily: async () => null, + getMetricsForFamily: async () => METRICS_STUB, generateFontFace: () => '', }, }), { - css: '', fallbacks: ['foo'], }, ); }); - it('should return fallbacks if there are metrics but no generic font family', async () => { + 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: ['foo'], - font: null, + fallbacks: ['emoji'], + font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], metrics: { - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), + getMetricsForFamily: async () => METRICS_STUB, generateFontFace: () => '', }, }), { - css: '', - fallbacks: ['foo'], + fallbacks: ['emoji'], }, ); }); - it('shold return fallbacks if the generic font family does not have fonts associated', async () => { + 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: 'Roboto', nameWithHash: 'Roboto-xxx' }, - fallbacks: ['emoji'], - font: null, + family: { name: 'Arial', nameWithHash: 'Arial-xxx' }, + fallbacks: ['sans-serif'], + font: [{ url: '/', hash: 'hash', data: { weight: '400' } }], metrics: { - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), + getMetricsForFamily: async () => METRICS_STUB, generateFontFace: () => '', }, }), { - css: '', - fallbacks: ['emoji'], + fallbacks: ['sans-serif'], }, ); }); @@ -222,20 +213,13 @@ describe('fonts utils', () => { await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: ['foo', 'bar'], - font: null, + font: [], metrics: { - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), - generateFontFace: (_metrics, fallback) => `[${fallback.font},${fallback.name}]`, + getMetricsForFamily: async () => METRICS_STUB, + generateFontFace: ({ font, name }) => `[${font},${name}]`, }, }), { - css: '', fallbacks: ['foo', 'bar'], }, ); @@ -243,23 +227,35 @@ describe('fonts utils', () => { await generateFallbacksCSS({ family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, fallbacks: ['sans-serif', 'foo'], - font: null, + font: [], metrics: { - getMetricsForFamily: async () => ({ - ascent: 0, - descent: 0, - lineGap: 0, - unitsPerEm: 0, - xWidthAvg: 0, - }), - generateFontFace: (_metrics, fallback) => `[${fallback.font},${fallback.name}]`, + getMetricsForFamily: async () => METRICS_STUB, + generateFontFace: ({ font, name }) => `[${font},${name}]`, }, }), { - css: '', 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'], + }, + ); }); }); From 5d89da8af8515de972a30943836a0cda2ace14bc Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 16 Apr 2025 15:30:00 +0200 Subject: [PATCH 02/68] fix: avoid duplicates --- packages/astro/src/assets/fonts/load.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/astro/src/assets/fonts/load.ts b/packages/astro/src/assets/fonts/load.ts index 7c8d7d6bfae1..4e6bf3bb247d 100644 --- a/packages/astro/src/assets/fonts/load.ts +++ b/packages/astro/src/assets/fonts/load.ts @@ -67,7 +67,13 @@ export async function loadFonts({ // If a family has fallbacks, we store the first url we get that may // be used for the fallback generation, if capsize doesn't have this // family in its built-in collection - if (fallbacks && fallbacks.length > 0) { + 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) === JSON.stringify(data)) + ) { fallbackFontData.push({ hash, url: value, From 1feaf67b83924ba27892376d78ccabf7d108fb40 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 17:42:58 +0200 Subject: [PATCH 03/68] feat: experiment --- .../astro/src/assets/fonts/definitions.ts | 13 +++ .../astro/src/assets/fonts/implementations.ts | 39 ++++++++ packages/astro/src/assets/fonts/logic.ts | 95 +++++++++++++++++++ packages/astro/src/assets/fonts/test.ts | 14 +++ 4 files changed, 161 insertions(+) create mode 100644 packages/astro/src/assets/fonts/definitions.ts create mode 100644 packages/astro/src/assets/fonts/implementations.ts create mode 100644 packages/astro/src/assets/fonts/logic.ts create mode 100644 packages/astro/src/assets/fonts/test.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts new file mode 100644 index 000000000000..9ed6698daf13 --- /dev/null +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -0,0 +1,13 @@ +import type { AstroFontProvider, ResolvedFontProvider } from './types.js'; + +export interface Hasher { + hash: (input: string) => string; +} + +export interface RemoteFontProviderResolver { + resolve: (input: AstroFontProvider) => Promise; +} + +export interface LocalProviderUrlResolver { + resolve: (input: string) => string; +} diff --git a/packages/astro/src/assets/fonts/implementations.ts b/packages/astro/src/assets/fonts/implementations.ts new file mode 100644 index 000000000000..5eccc9196706 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations.ts @@ -0,0 +1,39 @@ +import xxhash from 'xxhash-wasm'; +import type { Hasher, RemoteFontProviderResolver } from './definitions.js'; +import type { AstroFontProvider, ResolvedFontProvider } from './types.js'; +import { resolveEntrypoint } from './utils.js'; +import { validateMod } from './providers/utils.js'; + +export class XxHasher implements Hasher { + private constructor(public hash: Hasher['hash']) {} + + static async create() { + const { h64ToString } = await xxhash(); + return new XxHasher(h64ToString); + } +} + +export class BuildRemoteFontProviderResolver implements RemoteFontProviderResolver { + constructor(public root: URL) {} + + async resolve({ entrypoint, config }: AstroFontProvider): Promise { + const id = resolveEntrypoint(this.root, entrypoint.toString()).href; + const mod = await import(id); + // TODO: allow customizing thrown + const { provider } = validateMod(mod, id); + return { config, provider }; + } +} + +export class DevServerRemoteFontProviderResolver implements RemoteFontProviderResolver { + constructor(public root: URL) {} + + async resolve({ entrypoint, config }: AstroFontProvider): Promise { + const id = resolveEntrypoint(this.root, entrypoint.toString()).href; + // TODO: loadSsrModule + const mod = await import(id); + // TODO: allow customizing thrown + const { provider } = validateMod(mod, id); + return { config, provider }; + } +} diff --git a/packages/astro/src/assets/fonts/logic.ts b/packages/astro/src/assets/fonts/logic.ts new file mode 100644 index 000000000000..a50028659faa --- /dev/null +++ b/packages/astro/src/assets/fonts/logic.ts @@ -0,0 +1,95 @@ +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 { sortObjectByKey, withoutQuotes } from './utils.js'; + +function dedupe>(arr: T): T { + return [...new Set(arr)] as T; +} + +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) => { + 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, + }; + }), + })); +} + +export async function resolveFamily({ + family, + hasher, + remoteFontProviderResolver, + localProviderUrlResolver, +}: { + family: FontFamily; + hasher: Hasher; + remoteFontProviderResolver: RemoteFontProviderResolver; + localProviderUrlResolver: LocalProviderUrlResolver; +}): Promise { + // TODO: handle spaces + // TODO: recursive sort, may need to be a dependency + const nameWithHash = `${withoutQuotes(family.name)}-${hasher.hash(JSON.stringify(sortObjectByKey(family)))}`; + + if (family.provider === LOCAL_PROVIDER_NAME) { + return { + ...family, + nameWithHash, + variants: resolveVariants({ variants: family.variants, localProviderUrlResolver }), + fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, + }; + } + + return { + ...family, + nameWithHash, + provider: await remoteFontProviderResolver.resolve(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 async function resolveFamilies({ + families, + ...dependencies +}: { families: Array } & Omit[0], 'family'>): Promise< + Array +> { + const resolvedFamilies: Array = []; + + for (const family of families) { + resolvedFamilies.push( + await resolveFamily({ + family, + ...dependencies, + }), + ); + } + + return resolvedFamilies; +} diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts new file mode 100644 index 000000000000..4584197dd992 --- /dev/null +++ b/packages/astro/src/assets/fonts/test.ts @@ -0,0 +1,14 @@ +import { XxHasher } from './implementations.js'; +import { resolveFamilies } from './logic.js'; +import type { FontFamily } from './types.js'; + +export async function main({ families }: { families: Array }) { + const hasher = await XxHasher.create(); + + const resolvedFamilies = await resolveFamilies({ + families, + hasher, + remoteFontProviderResolver, + localProviderUrlResolver, + }); +} From 4d6836721021d4ed8cd378e7aadfdaf45bc604e6 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 17:47:32 +0200 Subject: [PATCH 04/68] feat: experiment --- .../astro/src/assets/fonts/implementations.ts | 24 +++++++++++++++---- packages/astro/src/assets/fonts/test.ts | 10 ++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/astro/src/assets/fonts/implementations.ts b/packages/astro/src/assets/fonts/implementations.ts index 5eccc9196706..704410b59acd 100644 --- a/packages/astro/src/assets/fonts/implementations.ts +++ b/packages/astro/src/assets/fonts/implementations.ts @@ -1,8 +1,14 @@ import xxhash from 'xxhash-wasm'; -import type { Hasher, RemoteFontProviderResolver } from './definitions.js'; +import type { + Hasher, + LocalProviderUrlResolver, + RemoteFontProviderResolver, +} from './definitions.js'; import type { AstroFontProvider, ResolvedFontProvider } from './types.js'; import { resolveEntrypoint } from './utils.js'; import { validateMod } from './providers/utils.js'; +import type { ViteDevServer } from 'vite'; +import { fileURLToPath } from 'node:url'; export class XxHasher implements Hasher { private constructor(public hash: Hasher['hash']) {} @@ -26,14 +32,24 @@ export class BuildRemoteFontProviderResolver implements RemoteFontProviderResolv } export class DevServerRemoteFontProviderResolver implements RemoteFontProviderResolver { - constructor(public root: URL) {} + constructor( + public root: URL, + public server: ViteDevServer, + ) {} async resolve({ entrypoint, config }: AstroFontProvider): Promise { const id = resolveEntrypoint(this.root, entrypoint.toString()).href; - // TODO: loadSsrModule - const mod = await import(id); + const mod = await this.server.ssrLoadModule(id); // TODO: allow customizing thrown const { provider } = validateMod(mod, id); return { config, provider }; } } + +export class RequireLocalProviderUrlResolver implements LocalProviderUrlResolver { + constructor(public root: URL) {} + + resolve(input: string): string { + return fileURLToPath(resolveEntrypoint(this.root, input)); + } +} diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index 4584197dd992..f894ae08efd5 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -1,9 +1,15 @@ -import { XxHasher } from './implementations.js'; +import { + BuildRemoteFontProviderResolver, + RequireLocalProviderUrlResolver, + XxHasher, +} from './implementations.js'; import { resolveFamilies } from './logic.js'; import type { FontFamily } from './types.js'; -export async function main({ families }: { families: Array }) { +export async function main({ families, root }: { families: Array; root: URL }) { const hasher = await XxHasher.create(); + const remoteFontProviderResolver = new BuildRemoteFontProviderResolver(root); + const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); const resolvedFamilies = await resolveFamilies({ families, From 58aa0c15758c8c811b62464fc348520a8f37d0bb Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 17:51:24 +0200 Subject: [PATCH 05/68] feat: experiment --- packages/astro/src/assets/fonts/logic.ts | 47 ++++++++++++++++++++++++ packages/astro/src/assets/fonts/test.ts | 11 +++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/astro/src/assets/fonts/logic.ts b/packages/astro/src/assets/fonts/logic.ts index a50028659faa..23993a637cf7 100644 --- a/packages/astro/src/assets/fonts/logic.ts +++ b/packages/astro/src/assets/fonts/logic.ts @@ -11,6 +11,7 @@ import type { ResolvedLocalFontFamily, } from './types.js'; import { sortObjectByKey, withoutQuotes } from './utils.js'; +import type * as unifont from 'unifont'; function dedupe>(arr: T): T { return [...new Set(arr)] as T; @@ -93,3 +94,49 @@ export async function resolveFamilies({ return resolvedFamilies; } + +export function extractUnifontProviders({ + families, + hasher, +}: { + families: Array; + hasher: Hasher; +}): { + families: Array; + providers: Array; +} { + const hashes = new Set(); + const providers: Array = []; + + for (const { provider } of families) { + if (provider === LOCAL_PROVIDER_NAME) { + continue; + } + + const unifontProvider = provider.provider(provider.config); + const hash = hasher.hash( + // TODO: Probably need to be extracted to the hasher, eg. hashObject() + 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 }; +} diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index f894ae08efd5..360def4c7677 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -3,7 +3,7 @@ import { RequireLocalProviderUrlResolver, XxHasher, } from './implementations.js'; -import { resolveFamilies } from './logic.js'; +import { extractUnifontProviders, resolveFamilies } from './logic.js'; import type { FontFamily } from './types.js'; export async function main({ families, root }: { families: Array; root: URL }) { @@ -11,10 +11,17 @@ export async function main({ families, root }: { families: Array; ro const remoteFontProviderResolver = new BuildRemoteFontProviderResolver(root); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); - const resolvedFamilies = await resolveFamilies({ + let resolvedFamilies = await resolveFamilies({ families, hasher, remoteFontProviderResolver, localProviderUrlResolver, }); + + const extractedUnifontProvidersResult = extractUnifontProviders({ + families: resolvedFamilies, + hasher, + }); + resolvedFamilies = extractedUnifontProvidersResult.families; + const unifontProviders = extractedUnifontProvidersResult.providers; } From 693a2b6377ee1b4e7abb18c8ee746f8f3c728218 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 17:56:03 +0200 Subject: [PATCH 06/68] feat: experiment --- packages/astro/src/assets/fonts/definitions.ts | 3 ++- .../astro/src/assets/fonts/implementations.ts | 17 +++++++++++------ packages/astro/src/assets/fonts/logic.ts | 18 ++++++------------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 9ed6698daf13..63631b58339a 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -1,7 +1,8 @@ import type { AstroFontProvider, ResolvedFontProvider } from './types.js'; export interface Hasher { - hash: (input: string) => string; + hashString: (input: string) => string; + hashObject: (input: Record) => string; } export interface RemoteFontProviderResolver { diff --git a/packages/astro/src/assets/fonts/implementations.ts b/packages/astro/src/assets/fonts/implementations.ts index 704410b59acd..499397b5b329 100644 --- a/packages/astro/src/assets/fonts/implementations.ts +++ b/packages/astro/src/assets/fonts/implementations.ts @@ -5,22 +5,27 @@ import type { RemoteFontProviderResolver, } from './definitions.js'; import type { AstroFontProvider, ResolvedFontProvider } from './types.js'; -import { resolveEntrypoint } from './utils.js'; +import { resolveEntrypoint, sortObjectByKey } from './utils.js'; import { validateMod } from './providers/utils.js'; import type { ViteDevServer } from 'vite'; import { fileURLToPath } from 'node:url'; export class XxHasher implements Hasher { - private constructor(public hash: Hasher['hash']) {} + private constructor(public hashString: Hasher['hashString']) {} static async create() { const { h64ToString } = await xxhash(); return new XxHasher(h64ToString); } + + hashObject(input: Record): string { + // TODO: needs to be deep + return this.hashString(JSON.stringify(sortObjectByKey(input))); + } } export class BuildRemoteFontProviderResolver implements RemoteFontProviderResolver { - constructor(public root: URL) {} + constructor(private root: URL) {} async resolve({ entrypoint, config }: AstroFontProvider): Promise { const id = resolveEntrypoint(this.root, entrypoint.toString()).href; @@ -33,8 +38,8 @@ export class BuildRemoteFontProviderResolver implements RemoteFontProviderResolv export class DevServerRemoteFontProviderResolver implements RemoteFontProviderResolver { constructor( - public root: URL, - public server: ViteDevServer, + private root: URL, + private server: ViteDevServer, ) {} async resolve({ entrypoint, config }: AstroFontProvider): Promise { @@ -47,7 +52,7 @@ export class DevServerRemoteFontProviderResolver implements RemoteFontProviderRe } export class RequireLocalProviderUrlResolver implements LocalProviderUrlResolver { - constructor(public root: URL) {} + constructor(private root: URL) {} resolve(input: string): string { return fileURLToPath(resolveEntrypoint(this.root, input)); diff --git a/packages/astro/src/assets/fonts/logic.ts b/packages/astro/src/assets/fonts/logic.ts index 23993a637cf7..969ff862c753 100644 --- a/packages/astro/src/assets/fonts/logic.ts +++ b/packages/astro/src/assets/fonts/logic.ts @@ -10,7 +10,7 @@ import type { ResolvedFontFamily, ResolvedLocalFontFamily, } from './types.js'; -import { sortObjectByKey, withoutQuotes } from './utils.js'; +import { withoutQuotes } from './utils.js'; import type * as unifont from 'unifont'; function dedupe>(arr: T): T { @@ -51,8 +51,7 @@ export async function resolveFamily({ localProviderUrlResolver: LocalProviderUrlResolver; }): Promise { // TODO: handle spaces - // TODO: recursive sort, may need to be a dependency - const nameWithHash = `${withoutQuotes(family.name)}-${hasher.hash(JSON.stringify(sortObjectByKey(family)))}`; + const nameWithHash = `${withoutQuotes(family.name)}-${hasher.hashObject(family)}`; if (family.provider === LOCAL_PROVIDER_NAME) { return { @@ -114,15 +113,10 @@ export function extractUnifontProviders({ } const unifontProvider = provider.provider(provider.config); - const hash = hasher.hash( - // TODO: Probably need to be extracted to the hasher, eg. hashObject() - JSON.stringify( - sortObjectByKey({ - name: unifontProvider._name, - ...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 From 194516408211d94700cac08e1aaac0a0c8d51d96 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 18:12:32 +0200 Subject: [PATCH 07/68] feat: experiment --- .../astro/src/assets/fonts/definitions.ts | 17 +++++ .../astro/src/assets/fonts/implementations.ts | 60 --------------- .../fonts/implementations/error-handler.ts | 21 ++++++ .../assets/fonts/implementations/hasher.ts | 17 +++++ .../local-provider-url-resolver.ts | 11 +++ .../remote-font-provider-resolver.ts | 73 +++++++++++++++++++ packages/astro/src/assets/fonts/test.ts | 12 +-- 7 files changed, 145 insertions(+), 66 deletions(-) delete mode 100644 packages/astro/src/assets/fonts/implementations.ts create mode 100644 packages/astro/src/assets/fonts/implementations/error-handler.ts create mode 100644 packages/astro/src/assets/fonts/implementations/hasher.ts create mode 100644 packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts create mode 100644 packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 63631b58339a..6ab3e169a3b4 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -12,3 +12,20 @@ export interface RemoteFontProviderResolver { export interface LocalProviderUrlResolver { resolve: (input: string) => string; } + +type SingleErrorInput> = { + type: TType; + data: TData; + cause: unknown +}; + +export type ErrorHandlerInput = SingleErrorInput< + 'cannot-load-font-provider', + { + entrypoint: string; + } + >; + +export interface ErrorHandler { + handle: (input: ErrorHandlerInput) => Error; +} diff --git a/packages/astro/src/assets/fonts/implementations.ts b/packages/astro/src/assets/fonts/implementations.ts deleted file mode 100644 index 499397b5b329..000000000000 --- a/packages/astro/src/assets/fonts/implementations.ts +++ /dev/null @@ -1,60 +0,0 @@ -import xxhash from 'xxhash-wasm'; -import type { - Hasher, - LocalProviderUrlResolver, - RemoteFontProviderResolver, -} from './definitions.js'; -import type { AstroFontProvider, ResolvedFontProvider } from './types.js'; -import { resolveEntrypoint, sortObjectByKey } from './utils.js'; -import { validateMod } from './providers/utils.js'; -import type { ViteDevServer } from 'vite'; -import { fileURLToPath } from 'node:url'; - -export class XxHasher implements Hasher { - private constructor(public hashString: Hasher['hashString']) {} - - static async create() { - const { h64ToString } = await xxhash(); - return new XxHasher(h64ToString); - } - - hashObject(input: Record): string { - // TODO: needs to be deep - return this.hashString(JSON.stringify(sortObjectByKey(input))); - } -} - -export class BuildRemoteFontProviderResolver implements RemoteFontProviderResolver { - constructor(private root: URL) {} - - async resolve({ entrypoint, config }: AstroFontProvider): Promise { - const id = resolveEntrypoint(this.root, entrypoint.toString()).href; - const mod = await import(id); - // TODO: allow customizing thrown - const { provider } = validateMod(mod, id); - return { config, provider }; - } -} - -export class DevServerRemoteFontProviderResolver implements RemoteFontProviderResolver { - constructor( - private root: URL, - private server: ViteDevServer, - ) {} - - async resolve({ entrypoint, config }: AstroFontProvider): Promise { - const id = resolveEntrypoint(this.root, entrypoint.toString()).href; - const mod = await this.server.ssrLoadModule(id); - // TODO: allow customizing thrown - const { provider } = validateMod(mod, id); - return { config, provider }; - } -} - -export class RequireLocalProviderUrlResolver implements LocalProviderUrlResolver { - constructor(private root: URL) {} - - resolve(input: string): string { - return fileURLToPath(resolveEntrypoint(this.root, input)); - } -} 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 000000000000..0d794483df93 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/error-handler.ts @@ -0,0 +1,21 @@ +import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; +import type { ErrorHandler, ErrorHandlerInput } from '../definitions.js'; + +export class AstroErrorHandler implements ErrorHandler { + private getProps( + input: Pick, + ): ConstructorParameters[0] { + if (input.type === 'cannot-load-font-provider') { + return { + ...AstroErrorData.CannotLoadFontProvider, + message: AstroErrorData.CannotLoadFontProvider.message(input.data.entrypoint), + }; + } + input.type satisfies never; + throw new Error('Should never happen'); + } + + handle({ type, data, cause }: ErrorHandlerInput): Error { + return new AstroError(this.getProps({ type, data }), { cause }); + } +} 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 000000000000..0800c4978694 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/hasher.ts @@ -0,0 +1,17 @@ +import xxhash from 'xxhash-wasm'; +import type { Hasher } from '../definitions.js'; +import { sortObjectByKey } from '../utils.js'; + +export class XxHasher implements Hasher { + private constructor(public hashString: Hasher['hashString']) {} + + static async create() { + const { h64ToString } = await xxhash(); + return new XxHasher(h64ToString); + } + + hashObject(input: Record): string { + // TODO: needs to be deep + return this.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 000000000000..3b0d9d1b2e6d --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts @@ -0,0 +1,11 @@ +import type { LocalProviderUrlResolver } from '../definitions.js'; +import { resolveEntrypoint } from '../utils.js'; +import { fileURLToPath } from 'node:url'; + +export class RequireLocalProviderUrlResolver implements LocalProviderUrlResolver { + constructor(private root: URL) {} + + resolve(input: string): string { + return fileURLToPath(resolveEntrypoint(this.root, input)); + } +} 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 000000000000..d6f4aa491b68 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts @@ -0,0 +1,73 @@ +import type { ErrorHandler, RemoteFontProviderResolver } from '../definitions.js'; +import type { AstroFontProvider, ResolvedFontProvider } from '../types.js'; +import { resolveEntrypoint } from '../utils.js'; +import type { ViteDevServer } from 'vite'; + +export 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 class BuildRemoteFontProviderResolver implements RemoteFontProviderResolver { + constructor( + private root: URL, + private errorHandler: ErrorHandler, + ) {} + + async resolve({ entrypoint, config }: AstroFontProvider): Promise { + const id = resolveEntrypoint(this.root, entrypoint.toString()).href; + const mod = await import(id); + const { provider } = validateMod({ + mod, + entrypoint: id, + errorHandler: this.errorHandler, + }); + return { config, provider }; + } +} + +export class DevServerRemoteFontProviderResolver implements RemoteFontProviderResolver { + constructor( + private root: URL, + private server: ViteDevServer, + private errorHandler: ErrorHandler, + ) {} + + async resolve({ entrypoint, config }: AstroFontProvider): Promise { + const id = resolveEntrypoint(this.root, entrypoint.toString()).href; + const mod = await this.server.ssrLoadModule(id); + const { provider } = validateMod({ + mod, + entrypoint: id, + errorHandler: this.errorHandler, + }); + return { config, provider }; + } +} diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index 360def4c7677..37c4719f8c2e 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -1,14 +1,14 @@ -import { - BuildRemoteFontProviderResolver, - RequireLocalProviderUrlResolver, - XxHasher, -} from './implementations.js'; +import { AstroErrorHandler } from './implementations/error-handler.js'; +import { XxHasher } from './implementations/hasher.js'; +import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; +import { BuildRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { extractUnifontProviders, resolveFamilies } from './logic.js'; import type { FontFamily } from './types.js'; export async function main({ families, root }: { families: Array; root: URL }) { const hasher = await XxHasher.create(); - const remoteFontProviderResolver = new BuildRemoteFontProviderResolver(root); + const errorHandler = new AstroErrorHandler(); + const remoteFontProviderResolver = new BuildRemoteFontProviderResolver(root, errorHandler); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); let resolvedFamilies = await resolveFamilies({ From 35d670c3491f11adf4a92b25160f2fe6428ef02c Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 18:17:51 +0200 Subject: [PATCH 08/68] feat: experiment --- .../src/assets/fonts/implementations/storage.ts | 14 ++++++++++++++ packages/astro/src/assets/fonts/test.ts | 14 +++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 packages/astro/src/assets/fonts/implementations/storage.ts 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 000000000000..fafec83602ec --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/storage.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url'; +import { createStorage, type Storage } from 'unstorage'; +import fsLiteDriver from 'unstorage/drivers/fs-lite'; + +export class FsStorage { + static create(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/test.ts b/packages/astro/src/assets/fonts/test.ts index 37c4719f8c2e..3c4090653c9c 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -2,14 +2,21 @@ import { AstroErrorHandler } from './implementations/error-handler.js'; import { XxHasher } from './implementations/hasher.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; import { BuildRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; +import { FsStorage } from './implementations/storage.js'; import { extractUnifontProviders, resolveFamilies } from './logic.js'; import type { FontFamily } from './types.js'; +import * as unifont from 'unifont'; -export async function main({ families, root }: { families: Array; root: URL }) { +export async function main({ + families, + root, + cacheDir, +}: { families: Array; root: URL; cacheDir: URL }) { const hasher = await XxHasher.create(); const errorHandler = new AstroErrorHandler(); const remoteFontProviderResolver = new BuildRemoteFontProviderResolver(root, errorHandler); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); + const storage = FsStorage.create(cacheDir); let resolvedFamilies = await resolveFamilies({ families, @@ -24,4 +31,9 @@ export async function main({ families, root }: { families: Array; ro }); resolvedFamilies = extractedUnifontProvidersResult.families; const unifontProviders = extractedUnifontProvidersResult.providers; + + // Probably not worth making a dependency + const { resolveFont } = await unifont.createUnifont(unifontProviders, { + storage, + }); } From 3d42f0e0ca3e6c65cea1ef2d904feb6af3639759 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 18:27:08 +0200 Subject: [PATCH 09/68] feat: experiment --- packages/astro/src/assets/fonts/test.ts | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index 3c4090653c9c..83916f68dcd7 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -1,3 +1,4 @@ +import { LOCAL_PROVIDER_NAME } from './constants.js'; import { AstroErrorHandler } from './implementations/error-handler.js'; import { XxHasher } from './implementations/hasher.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; @@ -7,11 +8,13 @@ import { extractUnifontProviders, resolveFamilies } from './logic.js'; import type { FontFamily } from './types.js'; import * as unifont from 'unifont'; +// TODO: logs everywhere! export async function main({ families, root, cacheDir, }: { families: Array; root: URL; cacheDir: URL }) { + // Dependencies const hasher = await XxHasher.create(); const errorHandler = new AstroErrorHandler(); const remoteFontProviderResolver = new BuildRemoteFontProviderResolver(root, errorHandler); @@ -36,4 +39,38 @@ export async function main({ const { resolveFont } = await unifont.createUnifont(unifontProviders, { storage, }); + + for (const family of resolvedFamilies) { + // TODO: data for collection + + let fonts: Array; + + if (family.provider === LOCAL_PROVIDER_NAME) { + const result = resolveLocalFont(); + fonts = result.fonts; + } else { + const result = await resolveFont( + family.name, + // We do not merge the defaults, we only provide defaults as a fallback + // TODO: defaults should be customizable + { + 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 = normalizeRemoteFontFaces(result.fonts); + } + + // TODO: generate CSS font faces + // TODO: generate fallback css + // TODO: generate CSS variables + // TODO: add data to internal data structures + } + // TODO: return data } From d7a8b46cc26c8ea001506c46d94a60caeaa0c98f Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 19:24:37 +0200 Subject: [PATCH 10/68] feat: experiment --- .../astro/src/assets/fonts/providers/local.ts | 7 +-- packages/astro/src/assets/fonts/test.ts | 51 +++++++++++++++++-- packages/astro/src/assets/fonts/utils.ts | 4 ++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 29c1f393579a..4883a45e99db 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -13,7 +13,7 @@ type ResolveFontResult = NonNullable string; + proxyURL: (input: { originalUrl: string; collectPreload: boolean }) => string; } export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResult { @@ -23,10 +23,11 @@ export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResu const data: ResolveFontResult['fonts'][number] = { weight: variant.weight, style: variant.style, - src: variant.src.map(({ url: originalURL, tech }) => { + src: variant.src.map(({ url: originalURL, tech }, index) => { return { originalURL, - url: proxyURL(originalURL), + // TODO: explain + url: proxyURL({ originalUrl: originalURL, collectPreload: index === 0 }), format: FONT_FORMAT_MAP[extractFontType(originalURL)], tech, }; diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index 83916f68dcd7..32c28334aba1 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -1,19 +1,27 @@ -import { LOCAL_PROVIDER_NAME } from './constants.js'; +import { DEFAULTS, LOCAL_PROVIDER_NAME } from './constants.js'; import { AstroErrorHandler } from './implementations/error-handler.js'; import { XxHasher } from './implementations/hasher.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; import { BuildRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { FsStorage } from './implementations/storage.js'; import { extractUnifontProviders, resolveFamilies } from './logic.js'; -import type { FontFamily } from './types.js'; +import { resolveLocalFont } from './providers/local.js'; +import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; +import { extractFontType, hashWithExtension, type GetMetricsForFamilyFont } from './utils.js'; // TODO: logs everywhere! export async function main({ families, root, cacheDir, -}: { families: Array; root: URL; cacheDir: URL }) { + base, +}: { + families: Array; + root: URL; + cacheDir: URL; + base: string; +}) { // Dependencies const hasher = await XxHasher.create(); const errorHandler = new AstroErrorHandler(); @@ -40,13 +48,46 @@ export async function main({ storage, }); + // TODO: might be optimized? + const hashToUrlMap = new Map(); + const resolvedMap = new Map(); + for (const family of resolvedFamilies) { - // TODO: data for collection + // TODO: might be refactored + const preloadData: PreloadData = []; + let css = ''; + let fallbackFontData: GetMetricsForFamilyFont | null = null; let fonts: Array; if (family.provider === LOCAL_PROVIDER_NAME) { - const result = resolveLocalFont(); + const result = resolveLocalFont({ + family, + proxyURL: ({ originalUrl, collectPreload }) => { + const type = extractFontType(originalUrl); + const hash = hashWithExtension({ hash: hasher.hashString(originalUrl), type }); + const url = base + hash; + + if (!hashToUrlMap.has(hash)) { + hashToUrlMap.set(hash, originalUrl); + 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 capsize doesn't have this + // family in its built-in collection + if (family.fallbacks && family.fallbacks.length > 0) { + fallbackFontData ??= { + hash, + url: originalUrl, + }; + } + + return url; + }, + }); fonts = result.fonts; } else { const result = await resolveFont( diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index a43af9f0f464..126b02e6ea27 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -138,6 +138,10 @@ export function proxyURL({ value, hashString, collect }: ProxyURLOptions): strin return url; } +export function hashWithExtension({ hash, type }: { hash: string; type: FontType }): string { + return `${hash}.${type}`; +} + export function isGenericFontFamily(str: string): str is keyof typeof DEFAULT_FALLBACKS { return Object.keys(DEFAULT_FALLBACKS).includes(str); } From b6cfd5443cb375b98c545dbf160e517f2e8aec9a Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 19:32:36 +0200 Subject: [PATCH 11/68] feat: experiment --- .../remote-font-provider-resolver.ts | 45 +++++++++++++------ packages/astro/src/assets/fonts/logic.ts | 38 ++++++++++++++++ .../astro/src/assets/fonts/providers/local.ts | 1 + packages/astro/src/assets/fonts/test.ts | 14 +++++- 4 files changed, 82 insertions(+), 16 deletions(-) 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 index d6f4aa491b68..4dfaf6937a8e 100644 --- a/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts @@ -35,21 +35,40 @@ export function validateMod({ } } +export async function resolve({ + provider: { entrypoint, config }, + resolveMod, + root, + errorHandler, +}: { + provider: AstroFontProvider; + resolveMod: (id: string) => Promise; + root: URL; + errorHandler: ErrorHandler; +}): Promise { + const id = resolveEntrypoint(root, entrypoint.toString()).href; + const mod = await resolveMod(id); + const { provider } = validateMod({ + mod, + entrypoint: id, + errorHandler, + }); + return { config, provider }; +} + export class BuildRemoteFontProviderResolver implements RemoteFontProviderResolver { constructor( private root: URL, private errorHandler: ErrorHandler, ) {} - async resolve({ entrypoint, config }: AstroFontProvider): Promise { - const id = resolveEntrypoint(this.root, entrypoint.toString()).href; - const mod = await import(id); - const { provider } = validateMod({ - mod, - entrypoint: id, + async resolve(provider: AstroFontProvider): Promise { + return await resolve({ + provider, + resolveMod: (id) => import(id), + root: this.root, errorHandler: this.errorHandler, }); - return { config, provider }; } } @@ -60,14 +79,12 @@ export class DevServerRemoteFontProviderResolver implements RemoteFontProviderRe private errorHandler: ErrorHandler, ) {} - async resolve({ entrypoint, config }: AstroFontProvider): Promise { - const id = resolveEntrypoint(this.root, entrypoint.toString()).href; - const mod = await this.server.ssrLoadModule(id); - const { provider } = validateMod({ - mod, - entrypoint: id, + async resolve(provider: AstroFontProvider): Promise { + return await resolve({ + provider, + resolveMod: (id) => this.server.ssrLoadModule(id), + root: this.root, errorHandler: this.errorHandler, }); - return { config, provider }; } } diff --git a/packages/astro/src/assets/fonts/logic.ts b/packages/astro/src/assets/fonts/logic.ts index 969ff862c753..b43d37e39a7f 100644 --- a/packages/astro/src/assets/fonts/logic.ts +++ b/packages/astro/src/assets/fonts/logic.ts @@ -134,3 +134,41 @@ export function extractUnifontProviders({ return { families, providers }; } + +export function normalizeRemoteFontFaces( + fonts: Array, +): Array { + 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: 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, index === 0), + }), + }; + index++; + return proxied; + }), + }; + }) + ); +} diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 4883a45e99db..65606141d8b0 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -13,6 +13,7 @@ type ResolveFontResult = NonNullable string; } diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index 32c28334aba1..cb3d78b358aa 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -4,11 +4,12 @@ import { XxHasher } from './implementations/hasher.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; import { BuildRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { FsStorage } from './implementations/storage.js'; -import { extractUnifontProviders, resolveFamilies } from './logic.js'; +import { extractUnifontProviders, normalizeRemoteFontFaces, resolveFamilies } from './logic.js'; import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; import { extractFontType, hashWithExtension, type GetMetricsForFamilyFont } from './utils.js'; +import { readFileSync } from 'node:fs'; // TODO: logs everywhere! export async function main({ @@ -64,8 +65,17 @@ export async function main({ const result = resolveLocalFont({ family, proxyURL: ({ originalUrl, collectPreload }) => { + let content: string; + try { + // TODO: dependency + content = readFileSync(originalUrl, 'utf-8'); + } catch (cause) { + // TODO: errorHandler + throw 'test'; + } + const type = extractFontType(originalUrl); - const hash = hashWithExtension({ hash: hasher.hashString(originalUrl), type }); + const hash = hashWithExtension({ hash: hasher.hashString(originalUrl + content), type }); const url = base + hash; if (!hashToUrlMap.has(hash)) { From ea56a480703e52fec1868d4c32910bf9b99a3e62 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 19:46:22 +0200 Subject: [PATCH 12/68] feat: experiment --- .../astro/src/assets/fonts/definitions.ts | 21 ++++-- .../remote-font-provider-mod-resolver.ts | 16 +++++ .../remote-font-provider-resolver.ts | 65 ++++++------------- packages/astro/src/assets/fonts/logic.ts | 8 ++- packages/astro/src/assets/fonts/test.ts | 7 +- 5 files changed, 61 insertions(+), 56 deletions(-) create mode 100644 packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 6ab3e169a3b4..79a60bfed05d 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -5,8 +5,15 @@ export interface Hasher { hashObject: (input: Record) => string; } +export interface RemoteFontProviderModResolver { + resolve: (id: string) => Promise; +} + export interface RemoteFontProviderResolver { - resolve: (input: AstroFontProvider) => Promise; + resolve: (input: { + provider: AstroFontProvider; + modResolver: RemoteFontProviderModResolver; + }) => Promise; } export interface LocalProviderUrlResolver { @@ -16,15 +23,15 @@ export interface LocalProviderUrlResolver { type SingleErrorInput> = { type: TType; data: TData; - cause: unknown + cause: unknown; }; export type ErrorHandlerInput = SingleErrorInput< - 'cannot-load-font-provider', - { - entrypoint: string; - } - >; + 'cannot-load-font-provider', + { + entrypoint: string; + } +>; export interface ErrorHandler { handle: (input: ErrorHandlerInput) => Error; 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 000000000000..16c220ebb38f --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts @@ -0,0 +1,16 @@ +import type { ViteDevServer } from 'vite'; +import type { RemoteFontProviderModResolver } from '../definitions.js'; + +export class BuildRemoteFontProviderModResolver implements RemoteFontProviderModResolver { + async resolve(id: string): Promise { + return await import(id); + } +} + +export class DevServerRemoteFontProviderModResolver implements RemoteFontProviderModResolver { + constructor(private server: ViteDevServer) {} + + async resolve(id: string): Promise { + return this.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 index 4dfaf6937a8e..ec4805ec4bc4 100644 --- a/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts @@ -1,7 +1,10 @@ -import type { ErrorHandler, RemoteFontProviderResolver } from '../definitions.js'; +import type { + ErrorHandler, + RemoteFontProviderModResolver, + RemoteFontProviderResolver, +} from '../definitions.js'; import type { AstroFontProvider, ResolvedFontProvider } from '../types.js'; import { resolveEntrypoint } from '../utils.js'; -import type { ViteDevServer } from 'vite'; export function validateMod({ mod, @@ -35,56 +38,26 @@ export function validateMod({ } } -export async function resolve({ - provider: { entrypoint, config }, - resolveMod, - root, - errorHandler, -}: { - provider: AstroFontProvider; - resolveMod: (id: string) => Promise; - root: URL; - errorHandler: ErrorHandler; -}): Promise { - const id = resolveEntrypoint(root, entrypoint.toString()).href; - const mod = await resolveMod(id); - const { provider } = validateMod({ - mod, - entrypoint: id, - errorHandler, - }); - return { config, provider }; -} - -export class BuildRemoteFontProviderResolver implements RemoteFontProviderResolver { - constructor( - private root: URL, - private errorHandler: ErrorHandler, - ) {} - - async resolve(provider: AstroFontProvider): Promise { - return await resolve({ - provider, - resolveMod: (id) => import(id), - root: this.root, - errorHandler: this.errorHandler, - }); - } -} - -export class DevServerRemoteFontProviderResolver implements RemoteFontProviderResolver { +export class RealRemoteFontProviderResolver implements RemoteFontProviderResolver { constructor( private root: URL, - private server: ViteDevServer, private errorHandler: ErrorHandler, ) {} - async resolve(provider: AstroFontProvider): Promise { - return await resolve({ - provider, - resolveMod: (id) => this.server.ssrLoadModule(id), - root: this.root, + async resolve({ + provider: { entrypoint, config }, + modResolver, + }: { + provider: AstroFontProvider; + modResolver: RemoteFontProviderModResolver; + }): Promise { + const id = resolveEntrypoint(this.root, entrypoint.toString()).href; + const mod = await modResolver.resolve(id); + const { provider } = validateMod({ + mod, + entrypoint: id, errorHandler: this.errorHandler, }); + return { config, provider }; } } diff --git a/packages/astro/src/assets/fonts/logic.ts b/packages/astro/src/assets/fonts/logic.ts index b43d37e39a7f..0f4863db1eb3 100644 --- a/packages/astro/src/assets/fonts/logic.ts +++ b/packages/astro/src/assets/fonts/logic.ts @@ -3,6 +3,7 @@ import type { RemoteFontProviderResolver, Hasher, LocalProviderUrlResolver, + RemoteFontProviderModResolver, } from './definitions.js'; import type { FontFamily, @@ -43,11 +44,13 @@ export async function resolveFamily({ family, hasher, remoteFontProviderResolver, + modResolver, localProviderUrlResolver, }: { family: FontFamily; hasher: Hasher; remoteFontProviderResolver: RemoteFontProviderResolver; + modResolver: RemoteFontProviderModResolver; localProviderUrlResolver: LocalProviderUrlResolver; }): Promise { // TODO: handle spaces @@ -65,7 +68,10 @@ export async function resolveFamily({ return { ...family, nameWithHash, - provider: await remoteFontProviderResolver.resolve(family.provider), + provider: await remoteFontProviderResolver.resolve({ + provider: family.provider, + modResolver, + }), 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, diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index cb3d78b358aa..a99b82f225b7 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -2,7 +2,7 @@ import { DEFAULTS, LOCAL_PROVIDER_NAME } from './constants.js'; import { AstroErrorHandler } from './implementations/error-handler.js'; import { XxHasher } from './implementations/hasher.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; -import { BuildRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; +import { RealRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { FsStorage } from './implementations/storage.js'; import { extractUnifontProviders, normalizeRemoteFontFaces, resolveFamilies } from './logic.js'; import { resolveLocalFont } from './providers/local.js'; @@ -10,6 +10,7 @@ import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; import { extractFontType, hashWithExtension, type GetMetricsForFamilyFont } from './utils.js'; import { readFileSync } from 'node:fs'; +import { BuildRemoteFontProviderModResolver } from './implementations/remote-font-provider-mod-resolver.js'; // TODO: logs everywhere! export async function main({ @@ -26,7 +27,8 @@ export async function main({ // Dependencies const hasher = await XxHasher.create(); const errorHandler = new AstroErrorHandler(); - const remoteFontProviderResolver = new BuildRemoteFontProviderResolver(root, errorHandler); + const remoteFontProviderResolver = new RealRemoteFontProviderResolver(root, errorHandler); + const modResolver = new BuildRemoteFontProviderModResolver(); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); const storage = FsStorage.create(cacheDir); @@ -34,6 +36,7 @@ export async function main({ families, hasher, remoteFontProviderResolver, + modResolver, localProviderUrlResolver, }); From caf9539df2be45c1182eb4b7c5ce05d05c3bc4ef Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 19:48:38 +0200 Subject: [PATCH 13/68] feat: experiment --- packages/astro/src/assets/fonts/test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index a99b82f225b7..d864ad3ad86a 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -67,6 +67,8 @@ export async function main({ if (family.provider === LOCAL_PROVIDER_NAME) { const result = resolveLocalFont({ family, + // TODO: UrlProxy + // TODO: UrlProxyContentResolver proxyURL: ({ originalUrl, collectPreload }) => { let content: string; try { From ba76199d49ed5e8662c8409d77caa74488b09f01 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 21:51:36 +0200 Subject: [PATCH 14/68] feat: experiment --- .../astro/src/assets/fonts/definitions.ts | 40 +++++++++++++++---- .../fonts/implementations/data-collector.ts | 38 ++++++++++++++++++ .../url-proxy-content-resolver.ts | 24 +++++++++++ .../assets/fonts/implementations/url-proxy.ts | 32 +++++++++++++++ packages/astro/src/assets/fonts/logic.ts | 6 +-- .../astro/src/assets/fonts/providers/local.ts | 29 +++++++++++--- packages/astro/src/assets/fonts/test.ts | 17 ++++++-- packages/astro/src/assets/fonts/utils.ts | 6 +-- 8 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 packages/astro/src/assets/fonts/implementations/data-collector.ts create mode 100644 packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts create mode 100644 packages/astro/src/assets/fonts/implementations/url-proxy.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 79a60bfed05d..53b8674e9885 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -1,4 +1,5 @@ -import type { AstroFontProvider, ResolvedFontProvider } from './types.js'; +/* eslint-disable @typescript-eslint/no-empty-object-type */ +import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './types.js'; export interface Hasher { hashString: (input: string) => string; @@ -26,13 +27,38 @@ type SingleErrorInput> = cause: unknown; }; -export type ErrorHandlerInput = SingleErrorInput< - 'cannot-load-font-provider', - { - entrypoint: string; - } ->; +export type ErrorHandlerInput = + | SingleErrorInput< + 'cannot-load-font-provider', + { + entrypoint: string; + } + > + | SingleErrorInput<'unknown-fs-error', {}>; export interface ErrorHandler { handle: (input: ErrorHandlerInput) => Error; } + +export interface UrlProxy { + proxy: (input: { + url: string; + collectPreload: boolean; + contentResolver: UrlProxyContentResolver; + hasher: Hasher; + base: string; + dataCollector: DataCollector; + }) => string; +} + +export interface UrlProxyContentResolver { + resolve: (url: string) => string; +} + +export interface DataCollector { + collect: (input: { + originalUrl: string; + hash: string; + preload: PreloadData[number] | null; + }) => void; +} 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 000000000000..04af7f8a2f1f --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/data-collector.ts @@ -0,0 +1,38 @@ +import type { DataCollector } from '../definitions.js'; +import type { PreloadData } from '../types.js'; +import type { GetMetricsForFamilyFont } from '../utils.js'; + +export class RealDataCollector implements DataCollector { + constructor( + private hashToUrlMap: Map, + private preloadData: PreloadData = [], + private fallbackFontData: { value: GetMetricsForFamilyFont | null }, + private fallbacks: Array, + ) {} + + collect({ + originalUrl, + hash, + preload, + }: { + originalUrl: string; + hash: string; + preload: PreloadData[number] | null; + }): void { + if (!this.hashToUrlMap.has(hash)) { + this.hashToUrlMap.set(hash, originalUrl); + if (preload) { + this.preloadData.push(preload); + } + } + if (this.fallbacks && this.fallbacks.length > 0) { + // If a family has fallbacks, we store the first url we get that may + // be used for the fallback generation, if capsize doesn't have this + // family in its built-in collection + this.fallbackFontData.value ??= { + hash, + url: originalUrl, + }; + } + } +} 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 000000000000..f53e467c1d18 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import type { ErrorHandler, UrlProxyContentResolver } from '../definitions.js'; + +export class LocalUrlProxyContentResolver implements UrlProxyContentResolver { + constructor(private errorHandler: ErrorHandler) {} + + resolve(url: string): string { + try { + return url + readFileSync(url, 'utf-8'); + } catch (cause) { + throw this.errorHandler.handle({ + type: 'unknown-fs-error', + data: {}, + cause, + }); + } + } +} + +export class RemoteUrlProxyContentResolver implements UrlProxyContentResolver { + resolve(url: string): string { + return 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 000000000000..03f8e6d5e256 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/url-proxy.ts @@ -0,0 +1,32 @@ +import type { DataCollector, Hasher, UrlProxy, UrlProxyContentResolver } from '../definitions.js'; +import { extractFontType } from '../utils.js'; + +export class RealUrlProxy implements UrlProxy { + proxy({ + url: originalUrl, + collectPreload, + contentResolver, + hasher, + base, + dataCollector, + }: { + url: string; + collectPreload: boolean; + contentResolver: UrlProxyContentResolver; + hasher: Hasher; + base: string; + dataCollector: DataCollector; + }): string { + const type = extractFontType(originalUrl); + const hash = `${hasher.hashString(contentResolver.resolve(originalUrl))}.${type}`; + const url = base + hash; + + dataCollector.collect({ + originalUrl, + hash, + preload: collectPreload ? { url, type } : null, + }); + + return url; + } +} diff --git a/packages/astro/src/assets/fonts/logic.ts b/packages/astro/src/assets/fonts/logic.ts index 0f4863db1eb3..fb25fb9c06bc 100644 --- a/packages/astro/src/assets/fonts/logic.ts +++ b/packages/astro/src/assets/fonts/logic.ts @@ -11,13 +11,9 @@ import type { ResolvedFontFamily, ResolvedLocalFontFamily, } from './types.js'; -import { withoutQuotes } from './utils.js'; +import { dedupe, withoutQuotes } from './utils.js'; import type * as unifont from 'unifont'; -function dedupe>(arr: T): T { - return [...new Set(arr)] as T; -} - function resolveVariants({ variants, localProviderUrlResolver, diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 65606141d8b0..82db0a0b4269 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -2,6 +2,7 @@ import type * as unifont from 'unifont'; import { FONT_FORMAT_MAP } from '../constants.js'; import type { ResolvedLocalFontFamily } from '../types.js'; import { extractFontType } from '../utils.js'; +import type { DataCollector, Hasher, UrlProxy, UrlProxyContentResolver } from '../definitions.js'; // https://fonts.nuxt.com/get-started/providers#local // https://github.com/nuxt/fonts/blob/main/src/providers/local.ts @@ -13,11 +14,21 @@ type ResolveFontResult = NonNullable string; + urlProxy: UrlProxy; + contentResolver: UrlProxyContentResolver; + base: string; + dataCollector: DataCollector; + hasher: Hasher; } -export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResult { +export function resolveLocalFont({ + family, + urlProxy, + contentResolver, + base, + dataCollector, + hasher, +}: Options): ResolveFontResult { const fonts: ResolveFontResult['fonts'] = []; for (const variant of family.variants) { @@ -27,8 +38,16 @@ export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResu src: variant.src.map(({ url: originalURL, tech }, index) => { return { originalURL, - // TODO: explain - url: proxyURL({ originalUrl: originalURL, collectPreload: index === 0 }), + url: urlProxy.proxy({ + base, + // TODO: explain + collectPreload: index === 0, + contentResolver, + hasher, + url: originalURL, + dataCollector, + }), + // url: proxyURL({ originalUrl: originalURL, collectPreload: index === 0 }), format: FONT_FORMAT_MAP[extractFontType(originalURL)], tech, }; diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index d864ad3ad86a..fa197802ecc2 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -8,9 +8,11 @@ import { extractUnifontProviders, normalizeRemoteFontFaces, resolveFamilies } fr import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; -import { extractFontType, hashWithExtension, type GetMetricsForFamilyFont } from './utils.js'; +import { extractFontType, type GetMetricsForFamilyFont } from './utils.js'; import { readFileSync } from 'node:fs'; import { BuildRemoteFontProviderModResolver } from './implementations/remote-font-provider-mod-resolver.js'; +import { RealUrlProxy } from './implementations/url-proxy.js'; +import { RealDataCollector } from './implementations/data-collector.js'; // TODO: logs everywhere! export async function main({ @@ -31,6 +33,7 @@ export async function main({ const modResolver = new BuildRemoteFontProviderModResolver(); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); const storage = FsStorage.create(cacheDir); + const urlProxy = new RealUrlProxy(); let resolvedFamilies = await resolveFamilies({ families, @@ -60,7 +63,15 @@ export async function main({ // TODO: might be refactored const preloadData: PreloadData = []; let css = ''; - let fallbackFontData: GetMetricsForFamilyFont | null = null; + const fallbackFontData: { value: GetMetricsForFamilyFont | null } = { value: null }; + const fallbacks = family.fallbacks ?? DEFAULTS.fallbacks; + + const dataCollector = new RealDataCollector( + hashToUrlMap, + preloadData, + fallbackFontData, + fallbacks, + ); let fonts: Array; @@ -94,7 +105,7 @@ export async function main({ // be used for the fallback generation, if capsize doesn't have this // family in its built-in collection if (family.fallbacks && family.fallbacks.length > 0) { - fallbackFontData ??= { + fallbackFontData.value ??= { hash, url: originalUrl, }; diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 126b02e6ea27..ac3e0db85f58 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -138,10 +138,6 @@ export function proxyURL({ value, hashString, collect }: ProxyURLOptions): strin return url; } -export function hashWithExtension({ hash, type }: { hash: string; type: FontType }): string { - return `${hash}.${type}`; -} - export function isGenericFontFamily(str: string): str is keyof typeof DEFAULT_FALLBACKS { return Object.keys(DEFAULT_FALLBACKS).includes(str); } @@ -232,7 +228,7 @@ export async function generateFallbacksCSS({ return { css, fallbacks }; } -function dedupe>(arr: T): T { +export function dedupe>(arr: T): T { return [...new Set(arr)] as T; } From d6af00408c7aa060419c4b7f4e717616c81ecc63 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 17 Apr 2025 22:00:47 +0200 Subject: [PATCH 15/68] feat: experiment --- .../astro/src/assets/fonts/definitions.ts | 9 +-- .../remote-font-provider-resolver.ts | 11 +--- .../assets/fonts/implementations/url-proxy.ts | 21 ++++--- packages/astro/src/assets/fonts/logic.ts | 27 ++++----- .../astro/src/assets/fonts/providers/local.ts | 30 +++------- packages/astro/src/assets/fonts/test.ts | 58 ++++++------------- 6 files changed, 50 insertions(+), 106 deletions(-) diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 53b8674e9885..3d3d5f7ac48b 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -11,10 +11,7 @@ export interface RemoteFontProviderModResolver { } export interface RemoteFontProviderResolver { - resolve: (input: { - provider: AstroFontProvider; - modResolver: RemoteFontProviderModResolver; - }) => Promise; + resolve: (provider: AstroFontProvider) => Promise; } export interface LocalProviderUrlResolver { @@ -44,10 +41,6 @@ export interface UrlProxy { proxy: (input: { url: string; collectPreload: boolean; - contentResolver: UrlProxyContentResolver; - hasher: Hasher; - base: string; - dataCollector: DataCollector; }) => string; } 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 index ec4805ec4bc4..df7045e38ce5 100644 --- a/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts @@ -41,18 +41,13 @@ export function validateMod({ export class RealRemoteFontProviderResolver implements RemoteFontProviderResolver { constructor( private root: URL, + private modResolver: RemoteFontProviderModResolver, private errorHandler: ErrorHandler, ) {} - async resolve({ - provider: { entrypoint, config }, - modResolver, - }: { - provider: AstroFontProvider; - modResolver: RemoteFontProviderModResolver; - }): Promise { + async resolve({ entrypoint, config }: AstroFontProvider): Promise { const id = resolveEntrypoint(this.root, entrypoint.toString()).href; - const mod = await modResolver.resolve(id); + const mod = await this.modResolver.resolve(id); const { provider } = validateMod({ mod, entrypoint: id, diff --git a/packages/astro/src/assets/fonts/implementations/url-proxy.ts b/packages/astro/src/assets/fonts/implementations/url-proxy.ts index 03f8e6d5e256..d29f65bcf360 100644 --- a/packages/astro/src/assets/fonts/implementations/url-proxy.ts +++ b/packages/astro/src/assets/fonts/implementations/url-proxy.ts @@ -2,26 +2,25 @@ import type { DataCollector, Hasher, UrlProxy, UrlProxyContentResolver } from '. import { extractFontType } from '../utils.js'; export class RealUrlProxy implements UrlProxy { + constructor( + private base: string, + private contentResolver: UrlProxyContentResolver, + private hasher: Hasher, + private dataCollector: DataCollector, + ) {} + proxy({ url: originalUrl, collectPreload, - contentResolver, - hasher, - base, - dataCollector, }: { url: string; collectPreload: boolean; - contentResolver: UrlProxyContentResolver; - hasher: Hasher; - base: string; - dataCollector: DataCollector; }): string { const type = extractFontType(originalUrl); - const hash = `${hasher.hashString(contentResolver.resolve(originalUrl))}.${type}`; - const url = base + hash; + const hash = `${this.hasher.hashString(this.contentResolver.resolve(originalUrl))}.${type}`; + const url = this.base + hash; - dataCollector.collect({ + this.dataCollector.collect({ originalUrl, hash, preload: collectPreload ? { url, type } : null, diff --git a/packages/astro/src/assets/fonts/logic.ts b/packages/astro/src/assets/fonts/logic.ts index fb25fb9c06bc..803f2cb11ce6 100644 --- a/packages/astro/src/assets/fonts/logic.ts +++ b/packages/astro/src/assets/fonts/logic.ts @@ -3,7 +3,7 @@ import type { RemoteFontProviderResolver, Hasher, LocalProviderUrlResolver, - RemoteFontProviderModResolver, + UrlProxy, } from './definitions.js'; import type { FontFamily, @@ -40,13 +40,11 @@ export async function resolveFamily({ family, hasher, remoteFontProviderResolver, - modResolver, localProviderUrlResolver, }: { family: FontFamily; hasher: Hasher; remoteFontProviderResolver: RemoteFontProviderResolver; - modResolver: RemoteFontProviderModResolver; localProviderUrlResolver: LocalProviderUrlResolver; }): Promise { // TODO: handle spaces @@ -64,10 +62,7 @@ export async function resolveFamily({ return { ...family, nameWithHash, - provider: await remoteFontProviderResolver.resolve({ - provider: family.provider, - modResolver, - }), + provider: await remoteFontProviderResolver.resolve(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, @@ -137,9 +132,13 @@ export function extractUnifontProviders({ return { families, providers }; } -export function normalizeRemoteFontFaces( - fonts: Array, -): Array { +export function normalizeRemoteFontFaces({ + fonts, + urlProxy, +}: { + fonts: Array; + urlProxy: UrlProxy; +}): Array { return ( fonts // Avoid getting too much font files @@ -158,13 +157,11 @@ export function normalizeRemoteFontFaces( 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, + 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) - collect: (data) => collect(data, index === 0), + collectPreload: index === 0, }), }; index++; diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 82db0a0b4269..db2139f2fe0e 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -2,7 +2,7 @@ import type * as unifont from 'unifont'; import { FONT_FORMAT_MAP } from '../constants.js'; import type { ResolvedLocalFontFamily } from '../types.js'; import { extractFontType } from '../utils.js'; -import type { DataCollector, Hasher, UrlProxy, UrlProxyContentResolver } from '../definitions.js'; +import type { UrlProxy } from '../definitions.js'; // https://fonts.nuxt.com/get-started/providers#local // https://github.com/nuxt/fonts/blob/main/src/providers/local.ts @@ -15,41 +15,25 @@ type ResolveFontResult = NonNullable { + src: variant.src.map((source, index) => { return { - originalURL, + originalURL: source.url, url: urlProxy.proxy({ - base, + url: source.url, // TODO: explain collectPreload: index === 0, - contentResolver, - hasher, - url: originalURL, - dataCollector, }), - // url: proxyURL({ originalUrl: originalURL, collectPreload: index === 0 }), - format: FONT_FORMAT_MAP[extractFontType(originalURL)], - tech, + format: FONT_FORMAT_MAP[extractFontType(source.url)], + tech: source.tech, }; }), }; diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index fa197802ecc2..595e7873c68c 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -8,11 +8,14 @@ import { extractUnifontProviders, normalizeRemoteFontFaces, resolveFamilies } fr import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; -import { extractFontType, type GetMetricsForFamilyFont } from './utils.js'; -import { readFileSync } from 'node:fs'; +import type { GetMetricsForFamilyFont } from './utils.js'; import { BuildRemoteFontProviderModResolver } from './implementations/remote-font-provider-mod-resolver.js'; import { RealUrlProxy } from './implementations/url-proxy.js'; import { RealDataCollector } from './implementations/data-collector.js'; +import { + LocalUrlProxyContentResolver, + RemoteUrlProxyContentResolver, +} from './implementations/url-proxy-content-resolver.js'; // TODO: logs everywhere! export async function main({ @@ -29,17 +32,19 @@ export async function main({ // Dependencies const hasher = await XxHasher.create(); const errorHandler = new AstroErrorHandler(); - const remoteFontProviderResolver = new RealRemoteFontProviderResolver(root, errorHandler); const modResolver = new BuildRemoteFontProviderModResolver(); + const remoteFontProviderResolver = new RealRemoteFontProviderResolver( + root, + modResolver, + errorHandler, + ); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); const storage = FsStorage.create(cacheDir); - const urlProxy = new RealUrlProxy(); let resolvedFamilies = await resolveFamilies({ families, hasher, remoteFontProviderResolver, - modResolver, localProviderUrlResolver, }); @@ -72,47 +77,18 @@ export async function main({ fallbackFontData, fallbacks, ); + const contentResolver = + family.provider === LOCAL_PROVIDER_NAME + ? new LocalUrlProxyContentResolver(errorHandler) + : new RemoteUrlProxyContentResolver(); + const urlProxy = new RealUrlProxy(base, contentResolver, hasher, dataCollector); let fonts: Array; if (family.provider === LOCAL_PROVIDER_NAME) { const result = resolveLocalFont({ family, - // TODO: UrlProxy - // TODO: UrlProxyContentResolver - proxyURL: ({ originalUrl, collectPreload }) => { - let content: string; - try { - // TODO: dependency - content = readFileSync(originalUrl, 'utf-8'); - } catch (cause) { - // TODO: errorHandler - throw 'test'; - } - - const type = extractFontType(originalUrl); - const hash = hashWithExtension({ hash: hasher.hashString(originalUrl + content), type }); - const url = base + hash; - - if (!hashToUrlMap.has(hash)) { - hashToUrlMap.set(hash, originalUrl); - 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 capsize doesn't have this - // family in its built-in collection - if (family.fallbacks && family.fallbacks.length > 0) { - fallbackFontData.value ??= { - hash, - url: originalUrl, - }; - } - - return url; - }, + urlProxy, }); fonts = result.fonts; } else { @@ -131,7 +107,7 @@ export async function main({ // Name has been set while extracting unifont providers from families (inside familiesToUnifontProviders) [family.provider.name!], ); - fonts = normalizeRemoteFontFaces(result.fonts); + fonts = normalizeRemoteFontFaces({ fonts: result.fonts, urlProxy }); } // TODO: generate CSS font faces From d2d573a46b5236a18b0d87fad2c4530f52521c5a Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 11:18:48 +0200 Subject: [PATCH 16/68] feat: experiment --- packages/astro/src/assets/fonts/logic.ts | 173 ------------------ .../fonts/logic/extract-unifont-providers.ts | 45 +++++ .../logic/normalize-remote-font-faces.ts | 42 +++++ .../assets/fonts/logic/resolve-families.ts | 90 +++++++++ packages/astro/src/assets/fonts/test.ts | 4 +- 5 files changed, 180 insertions(+), 174 deletions(-) delete mode 100644 packages/astro/src/assets/fonts/logic.ts create mode 100644 packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts create mode 100644 packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts create mode 100644 packages/astro/src/assets/fonts/logic/resolve-families.ts diff --git a/packages/astro/src/assets/fonts/logic.ts b/packages/astro/src/assets/fonts/logic.ts deleted file mode 100644 index 803f2cb11ce6..000000000000 --- a/packages/astro/src/assets/fonts/logic.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { LOCAL_PROVIDER_NAME } from './constants.js'; -import type { - RemoteFontProviderResolver, - Hasher, - LocalProviderUrlResolver, - UrlProxy, -} from './definitions.js'; -import type { - FontFamily, - LocalFontFamily, - ResolvedFontFamily, - ResolvedLocalFontFamily, -} from './types.js'; -import { dedupe, withoutQuotes } from './utils.js'; -import type * as unifont from 'unifont'; - -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) => { - 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, - }; - }), - })); -} - -export async function resolveFamily({ - family, - hasher, - remoteFontProviderResolver, - localProviderUrlResolver, -}: { - family: FontFamily; - hasher: Hasher; - remoteFontProviderResolver: RemoteFontProviderResolver; - localProviderUrlResolver: LocalProviderUrlResolver; -}): Promise { - // TODO: handle spaces - const nameWithHash = `${withoutQuotes(family.name)}-${hasher.hashObject(family)}`; - - if (family.provider === LOCAL_PROVIDER_NAME) { - return { - ...family, - nameWithHash, - variants: resolveVariants({ variants: family.variants, localProviderUrlResolver }), - fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, - }; - } - - return { - ...family, - nameWithHash, - provider: await remoteFontProviderResolver.resolve(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 async function resolveFamilies({ - families, - ...dependencies -}: { families: Array } & Omit[0], 'family'>): Promise< - Array -> { - const resolvedFamilies: Array = []; - - for (const family of families) { - resolvedFamilies.push( - await resolveFamily({ - family, - ...dependencies, - }), - ); - } - - return resolvedFamilies; -} - -export function extractUnifontProviders({ - families, - hasher, -}: { - families: Array; - hasher: Hasher; -}): { - families: Array; - providers: Array; -} { - const hashes = new Set(); - const providers: Array = []; - - for (const { provider } of families) { - 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 }; -} - -export function normalizeRemoteFontFaces({ - fonts, - urlProxy, -}: { - fonts: Array; - urlProxy: UrlProxy; -}): Array { - 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, - }), - }; - index++; - return proxied; - }), - }; - }) - ); -} 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 000000000000..6be81a80fcda --- /dev/null +++ b/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts @@ -0,0 +1,45 @@ +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; + hasher: Hasher; +}): { + families: Array; + providers: Array; +} { + const hashes = new Set(); + const providers: Array = []; + + for (const { provider } of families) { + 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 000000000000..91029322c577 --- /dev/null +++ b/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts @@ -0,0 +1,42 @@ +import type * as unifont from 'unifont'; +import type { UrlProxy } from '../definitions.js'; + +export function normalizeRemoteFontFaces({ + fonts, + urlProxy, +}: { + fonts: Array; + urlProxy: UrlProxy; +}): Array { + 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, + }), + }; + index++; + return proxied; + }), + }; + }) + ); +} 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 000000000000..3db2dd1f6d9d --- /dev/null +++ b/packages/astro/src/assets/fonts/logic/resolve-families.ts @@ -0,0 +1,90 @@ +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) => { + 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, + }; + }), + })); +} + +export async function resolveFamily({ + family, + hasher, + remoteFontProviderResolver, + localProviderUrlResolver, +}: { + family: FontFamily; + hasher: Hasher; + remoteFontProviderResolver: RemoteFontProviderResolver; + localProviderUrlResolver: LocalProviderUrlResolver; +}): Promise { + // TODO: handle spaces + const nameWithHash = `${withoutQuotes(family.name)}-${hasher.hashObject(family)}`; + + if (family.provider === LOCAL_PROVIDER_NAME) { + return { + ...family, + nameWithHash, + variants: resolveVariants({ variants: family.variants, localProviderUrlResolver }), + fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, + }; + } + + return { + ...family, + nameWithHash, + provider: await remoteFontProviderResolver.resolve(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 async function resolveFamilies({ + families, + ...dependencies +}: { families: Array } & Omit[0], 'family'>): Promise< + Array +> { + const resolvedFamilies: Array = []; + + for (const family of families) { + resolvedFamilies.push( + await resolveFamily({ + family, + ...dependencies, + }), + ); + } + + return resolvedFamilies; +} diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index 595e7873c68c..d7aa928b589f 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -4,7 +4,7 @@ import { XxHasher } from './implementations/hasher.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; import { RealRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { FsStorage } from './implementations/storage.js'; -import { extractUnifontProviders, normalizeRemoteFontFaces, resolveFamilies } from './logic.js'; +import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; @@ -16,6 +16,8 @@ import { LocalUrlProxyContentResolver, RemoteUrlProxyContentResolver, } from './implementations/url-proxy-content-resolver.js'; +import { extractUnifontProviders } from './logic/extract-unifont-providers.js'; +import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js'; // TODO: logs everywhere! export async function main({ From 5151bc27ec5439dc54d6ead71da25bbdc9daf8dd Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 11:34:42 +0200 Subject: [PATCH 17/68] feat: experiment --- .../astro/src/assets/fonts/definitions.ts | 6 ++++++ .../fonts/implementations/css-renderer.ts | 15 ++++++++++++++ packages/astro/src/assets/fonts/test.ts | 20 +++++++++++++++++-- packages/astro/src/assets/fonts/utils.ts | 9 +++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 packages/astro/src/assets/fonts/implementations/css-renderer.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 3d3d5f7ac48b..613ea7a7d321 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './types.js'; +import type * as unifont from 'unifont'; export interface Hasher { hashString: (input: string) => string; @@ -55,3 +56,8 @@ export interface DataCollector { preload: PreloadData[number] | null; }) => void; } + +export interface CssRenderer { + generateFontFace: (family: string, font: unifont.FontFaceData) => string; + generateCssVariable: (key: string, values: Array) => string; +} 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 000000000000..93fe345c015f --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -0,0 +1,15 @@ +import type { FontFaceData } from 'unifont'; +import type { CssRenderer } from '../definitions.js'; +import { generateFontFace } from '../utils.js'; + +// TODO: better impl +export class PrettyCssRenderer implements CssRenderer { + generateFontFace(family: string, font: FontFaceData): string { + return generateFontFace(family, font); + } + generateCssVariable(key: string, values: Array): string { + return `:root { ${key}: ${values.join(', ')}; }`; + } +} + +// TODO: MinifiedCssRenderer \ No newline at end of file diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index d7aa928b589f..7ccf93f914a4 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -8,7 +8,7 @@ import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; -import type { GetMetricsForFamilyFont } from './utils.js'; +import { pickFontFaceProperty, type GetMetricsForFamilyFont } from './utils.js'; import { BuildRemoteFontProviderModResolver } from './implementations/remote-font-provider-mod-resolver.js'; import { RealUrlProxy } from './implementations/url-proxy.js'; import { RealDataCollector } from './implementations/data-collector.js'; @@ -18,6 +18,7 @@ import { } from './implementations/url-proxy-content-resolver.js'; import { extractUnifontProviders } from './logic/extract-unifont-providers.js'; import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js'; +import { PrettyCssRenderer } from './implementations/css-renderer.js'; // TODO: logs everywhere! export async function main({ @@ -31,6 +32,7 @@ export async function main({ cacheDir: URL; base: string; }) { + // TODO: dependencies will have to be args // Dependencies const hasher = await XxHasher.create(); const errorHandler = new AstroErrorHandler(); @@ -42,6 +44,7 @@ export async function main({ ); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); const storage = FsStorage.create(cacheDir); + const cssRenderer = new PrettyCssRenderer(); let resolvedFamilies = await resolveFamilies({ families, @@ -112,7 +115,20 @@ export async function main({ fonts = normalizeRemoteFontFaces({ fonts: result.fonts, urlProxy }); } - // TODO: generate CSS font faces + for (const data of fonts) { + css += cssRenderer.generateFontFace(family.nameWithHash, { + src: data.src, + weight: data.weight, + style: data.style, + // User settings override the generated font settings + 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 }), + }); + } + // TODO: generate fallback css // TODO: generate CSS variables // TODO: add data to internal data structures diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index ac3e0db85f58..9eefcfb80ffd 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -360,3 +360,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]); +} From c240c7d56806ffe9f1eea4933f68f6baa0723f0a Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 11:55:15 +0200 Subject: [PATCH 18/68] feat: experiment --- packages/astro/src/assets/fonts/definitions.ts | 7 +++++++ .../src/assets/fonts/logic/optimize-fallbacks.ts | 12 ++++++++++++ packages/astro/src/assets/fonts/test.ts | 1 + 3 files changed, 20 insertions(+) create mode 100644 packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 613ea7a7d321..28e7298f8daf 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './types.js'; import type * as unifont from 'unifont'; +import type { GetMetricsForFamily } from './utils.js'; export interface Hasher { hashString: (input: string) => string; @@ -61,3 +62,9 @@ export interface CssRenderer { generateFontFace: (family: string, font: unifont.FontFaceData) => string; generateCssVariable: (key: string, values: Array) => string; } + +export interface FontMetricsResolver { + // TODO: do not keep type like this + getMetrics: GetMetricsForFamily; + generateFontFace: any +} 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 000000000000..c35106568ef5 --- /dev/null +++ b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts @@ -0,0 +1,12 @@ +import type { ResolvedFontFamily } from '../types.js'; +import type { GetMetricsForFamilyFont } from '../utils.js'; + +export async function optimizeFallbacks({}: { + family: Pick; + fallbacks: Array; + // TODO: type is pretty bad + font: GetMetricsForFamilyFont | null; +}): Promise; +}> {} diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index 7ccf93f914a4..408ff6459e0c 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -130,6 +130,7 @@ export async function main({ } // TODO: generate fallback css + // logic fn + deps // TODO: generate CSS variables // TODO: add data to internal data structures } From 4e8b2c0bc1d905bcab58f418237be567f0d19396 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 13:25:00 +0200 Subject: [PATCH 19/68] feat: experiment --- packages/astro/src/assets/fonts/definitions.ts | 4 +++- .../fonts/implementations/data-collector.ts | 18 ++++++++++++++---- .../assets/fonts/implementations/url-proxy.ts | 4 ++++ .../fonts/logic/normalize-remote-font-faces.ts | 4 ++++ .../astro/src/assets/fonts/providers/local.ts | 11 ++++------- packages/astro/src/assets/fonts/test.ts | 2 +- 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 28e7298f8daf..c062bfcde550 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -43,6 +43,7 @@ export interface UrlProxy { proxy: (input: { url: string; collectPreload: boolean; + data: Partial; }) => string; } @@ -54,6 +55,7 @@ export interface DataCollector { collect: (input: { originalUrl: string; hash: string; + data: Partial; preload: PreloadData[number] | null; }) => void; } @@ -66,5 +68,5 @@ export interface CssRenderer { export interface FontMetricsResolver { // TODO: do not keep type like this getMetrics: GetMetricsForFamily; - generateFontFace: any + generateFontFace: any; } diff --git a/packages/astro/src/assets/fonts/implementations/data-collector.ts b/packages/astro/src/assets/fonts/implementations/data-collector.ts index 04af7f8a2f1f..dfb3c61aada1 100644 --- a/packages/astro/src/assets/fonts/implementations/data-collector.ts +++ b/packages/astro/src/assets/fonts/implementations/data-collector.ts @@ -1,12 +1,13 @@ import type { DataCollector } from '../definitions.js'; import type { PreloadData } from '../types.js'; import type { GetMetricsForFamilyFont } from '../utils.js'; +import type * as unifont from 'unifont'; export class RealDataCollector implements DataCollector { constructor( private hashToUrlMap: Map, private preloadData: PreloadData = [], - private fallbackFontData: { value: GetMetricsForFamilyFont | null }, + private fallbackFontData: Array, private fallbacks: Array, ) {} @@ -14,10 +15,12 @@ export class RealDataCollector implements DataCollector { originalUrl, hash, preload, + data, }: { originalUrl: string; hash: string; preload: PreloadData[number] | null; + data: Partial; }): void { if (!this.hashToUrlMap.has(hash)) { this.hashToUrlMap.set(hash, originalUrl); @@ -25,14 +28,21 @@ export class RealDataCollector implements DataCollector { this.preloadData.push(preload); } } - if (this.fallbacks && this.fallbacks.length > 0) { + if ( + this.fallbacks && + this.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 + !this.fallbackFontData.some((f) => JSON.stringify(f.data) === JSON.stringify(data)) + ) { // If a family has fallbacks, we store the first url we get that may // be used for the fallback generation, if capsize doesn't have this // family in its built-in collection - this.fallbackFontData.value ??= { + this.fallbackFontData.push({ hash, url: originalUrl, - }; + data, + }); } } } diff --git a/packages/astro/src/assets/fonts/implementations/url-proxy.ts b/packages/astro/src/assets/fonts/implementations/url-proxy.ts index d29f65bcf360..37c13f8ff473 100644 --- a/packages/astro/src/assets/fonts/implementations/url-proxy.ts +++ b/packages/astro/src/assets/fonts/implementations/url-proxy.ts @@ -1,3 +1,4 @@ +import type * as unifont from 'unifont'; import type { DataCollector, Hasher, UrlProxy, UrlProxyContentResolver } from '../definitions.js'; import { extractFontType } from '../utils.js'; @@ -11,9 +12,11 @@ export class RealUrlProxy implements UrlProxy { proxy({ url: originalUrl, + data, collectPreload, }: { url: string; + data: Partial; collectPreload: boolean; }): string { const type = extractFontType(originalUrl); @@ -24,6 +27,7 @@ export class RealUrlProxy implements UrlProxy { originalUrl, hash, preload: collectPreload ? { url, type } : null, + data, }); return url; 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 index 91029322c577..4c4c6513d167 100644 --- a/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts +++ b/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts @@ -31,6 +31,10 @@ export function normalizeRemoteFontFaces({ // 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++; diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index c0325c5c63d6..6f220b2a78bc 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -31,14 +31,11 @@ export function resolveLocalFont({ family, urlProxy }: Options): ResolveFontResu url: source.url, // TODO: explain collectPreload: index === 0, + data: { + weight: variant.weight, + style: variant.style, + }, }), - // url: proxyURL({ - // value: originalURL, - // data: { - // weight: variant.weight, - // style: variant.style, - // }, - // }), format: FONT_FORMAT_MAP[extractFontType(source.url)], tech: source.tech, }; diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/test.ts index 408ff6459e0c..8159fc22d7ad 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -73,7 +73,7 @@ export async function main({ // TODO: might be refactored const preloadData: PreloadData = []; let css = ''; - const fallbackFontData: { value: GetMetricsForFamilyFont | null } = { value: null }; + const fallbackFontData: Array = []; const fallbacks = family.fallbacks ?? DEFAULTS.fallbacks; const dataCollector = new RealDataCollector( From cc58199a4525e5268ec04ba50f979c6389ae4a0a Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 14:01:33 +0200 Subject: [PATCH 20/68] feat: experiment --- .../astro/src/assets/fonts/definitions.ts | 21 ++++- .../fonts/implementations/error-handler.ts | 20 +++-- .../fonts/implementations/font-fetcher.ts | 35 ++++++++ .../implementations/font-metrics-resolver.ts | 23 +++++ .../system-fallbacks-provider.ts | 83 +++++++++++++++++++ .../assets/fonts/logic/optimize-fallbacks.ts | 72 +++++++++++++++- packages/astro/src/assets/fonts/test.ts | 35 ++++++-- 7 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 packages/astro/src/assets/fonts/implementations/font-fetcher.ts create mode 100644 packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts create mode 100644 packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index c062bfcde550..f822ca3d3279 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -2,6 +2,7 @@ import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './types.js'; import type * as unifont from 'unifont'; import type { GetMetricsForFamily } from './utils.js'; +import type { FontFaceMetrics } from './metrics.js'; export interface Hasher { hashString: (input: string) => string; @@ -33,7 +34,8 @@ export type ErrorHandlerInput = entrypoint: string; } > - | SingleErrorInput<'unknown-fs-error', {}>; + | SingleErrorInput<'unknown-fs-error', {}> + | SingleErrorInput<'cannot-fetch-font-file', { url: string }>; export interface ErrorHandler { handle: (input: ErrorHandlerInput) => Error; @@ -68,5 +70,20 @@ export interface CssRenderer { export interface FontMetricsResolver { // TODO: do not keep type like this getMetrics: GetMetricsForFamily; - generateFontFace: any; + generateFontFace: (input: { + metrics: FontFaceMetrics; + fallbackMetrics: FontFaceMetrics; + name: string; + font: string; + properties: Record; + }) => string; +} + +export interface SystemFallbacksProvider { + getLocalFonts: (fallback: string) => Array | null; + getMetricsForLocalFont: (family: string) => FontFaceMetrics; +} + +export interface FontFetcher { + fetch: (hash: string, url: string) => Promise; } diff --git a/packages/astro/src/assets/fonts/implementations/error-handler.ts b/packages/astro/src/assets/fonts/implementations/error-handler.ts index 0d794483df93..b81ab77643e8 100644 --- a/packages/astro/src/assets/fonts/implementations/error-handler.ts +++ b/packages/astro/src/assets/fonts/implementations/error-handler.ts @@ -2,20 +2,26 @@ import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; import type { ErrorHandler, ErrorHandlerInput } from '../definitions.js'; export class AstroErrorHandler implements ErrorHandler { - private getProps( - input: Pick, - ): ConstructorParameters[0] { + private getProps(input: ErrorHandlerInput): ConstructorParameters[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), + }; } - input.type satisfies never; - throw new Error('Should never happen'); + input satisfies never; + // Should never happen + return AstroErrorData.UnknownError; } - handle({ type, data, cause }: ErrorHandlerInput): Error { - return new AstroError(this.getProps({ type, data }), { cause }); + handle(input: ErrorHandlerInput): Error { + return new AstroError(this.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 000000000000..d1ddd6a5ea53 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/font-fetcher.ts @@ -0,0 +1,35 @@ +import type { Storage } from 'unstorage'; +import type { ErrorHandler, FontFetcher } from '../definitions.js'; +import { cache } from '../utils.js'; +import { isAbsolute } from 'node:path'; +import { readFile } from 'node:fs/promises'; + +export class CachedFontFetcher implements FontFetcher { + constructor( + private storage: Storage, + private errorHandler: ErrorHandler, + ) {} + + async fetch(hash: string, url: string): Promise { + return await cache(this.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 this.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 000000000000..2b35608f5850 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts @@ -0,0 +1,23 @@ +import type { FontFetcher, FontMetricsResolver } from '../definitions.js'; +import { generateFallbackFontFace, readMetrics, type FontFaceMetrics } from '../metrics.js'; +import type { GetMetricsForFamilyFont } from '../utils.js'; + +// TODO: better impl +export class RealFontMetricsResolver implements FontMetricsResolver { + constructor(private fontFetcher: FontFetcher) {} + + async getMetrics(name: string, { hash, url }: GetMetricsForFamilyFont): Promise { + return await readMetrics(name, await this.fontFetcher.fetch(hash, url)); + } + + generateFontFace(input: { + metrics: FontFaceMetrics; + fallbackMetrics: FontFaceMetrics; + name: string; + font: string; + properties: Record; + }): string { + // TODO: use cssRenderer + return generateFallbackFontFace(input); + } +} 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 000000000000..c080abe59165 --- /dev/null +++ b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts @@ -0,0 +1,83 @@ +import type { SystemFallbacksProvider } from '../definitions.js'; +import type { FontFaceMetrics } from '../metrics.js'; + +// 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; + +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'], + // 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>; + +export class RealSystemFallbacksProvider implements SystemFallbacksProvider { + getLocalFonts(fallback: string): Array | null { + return DEFAULT_FALLBACKS[fallback as keyof typeof DEFAULT_FALLBACKS] ?? null; + } + getMetricsForLocalFont(family: string): FontFaceMetrics { + return SYSTEM_METRICS[family as FallbackName]; + } +} diff --git a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts index c35106568ef5..b951e21403cd 100644 --- a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts +++ b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts @@ -1,12 +1,76 @@ +import type { FontMetricsResolver, SystemFallbacksProvider } from '../definitions.js'; import type { ResolvedFontFamily } from '../types.js'; -import type { GetMetricsForFamilyFont } from '../utils.js'; +import { + isGenericFontFamily, + unifontFontFaceDataToProperties, + type GetMetricsForFamilyFont, +} from '../utils.js'; -export async function optimizeFallbacks({}: { +export async function optimizeFallbacks({ + family, + fallbacks: _fallbacks, + fontData, + enabled, + systemFallbacksProvider, + fontMetricsResolver, +}: { family: Pick; fallbacks: Array; // TODO: type is pretty bad - font: GetMetricsForFamilyFont | null; + fontData: Array; + enabled: boolean; + systemFallbacksProvider: SystemFallbacksProvider; + fontMetricsResolver: FontMetricsResolver; }): Promise; -}> {} +}> { + // We avoid mutating the original array + let fallbacks = [..._fallbacks]; + + if (fallbacks.length === 0 || !enabled || fontData.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, + 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 += 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/test.ts b/packages/astro/src/assets/fonts/test.ts index 8159fc22d7ad..33a8315e1de6 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/test.ts @@ -19,6 +19,10 @@ import { import { extractUnifontProviders } from './logic/extract-unifont-providers.js'; import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js'; import { PrettyCssRenderer } from './implementations/css-renderer.js'; +import { optimizeFallbacks } from './logic/optimize-fallbacks.js'; +import { RealSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; +import { CachedFontFetcher } from './implementations/font-fetcher.js'; +import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; // TODO: logs everywhere! export async function main({ @@ -45,6 +49,9 @@ export async function main({ const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); const storage = FsStorage.create(cacheDir); const cssRenderer = new PrettyCssRenderer(); + const systemFallbacksProvider = new RealSystemFallbacksProvider(); + const fontFetcher = new CachedFontFetcher(storage, errorHandler); + const fontMetricsResolver = new RealFontMetricsResolver(fontFetcher); let resolvedFamilies = await resolveFamilies({ families, @@ -129,10 +136,28 @@ export async function main({ }); } - // TODO: generate fallback css - // logic fn + deps - // TODO: generate CSS variables - // TODO: add data to internal data structures + const cssVarValues = [family.nameWithHash]; + const optimizeFallbacksResult = await optimizeFallbacks({ + family, + fallbacks, + fontData: fallbackFontData, + enabled: family.optimizedFallbacks ?? DEFAULTS.optimizedFallbacks, + systemFallbacksProvider, + fontMetricsResolver, + }); + + if (optimizeFallbacksResult) { + css += optimizeFallbacksResult.css; + cssVarValues.push(...optimizeFallbacksResult.fallbacks); + } else { + // Nothing to optimize, pass as is + cssVarValues.push(...fallbacks); + } + + css += cssRenderer.generateCssVariable(family.cssVariable, cssVarValues); + + resolvedMap.set(family.cssVariable, { preloadData, css }); } - // TODO: return data + + return { hashToUrlMap, resolvedMap }; } From fd4faf4a3f63d30ee83961884f94aaef26bbb43e Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 14:19:54 +0200 Subject: [PATCH 21/68] feat: experiment --- packages/astro/src/assets/fonts/logic/resolve-families.ts | 1 + packages/astro/src/assets/fonts/{test.ts => orchestrate.ts} | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename packages/astro/src/assets/fonts/{test.ts => orchestrate.ts} (99%) diff --git a/packages/astro/src/assets/fonts/logic/resolve-families.ts b/packages/astro/src/assets/fonts/logic/resolve-families.ts index 3db2dd1f6d9d..a3e1317f37aa 100644 --- a/packages/astro/src/assets/fonts/logic/resolve-families.ts +++ b/packages/astro/src/assets/fonts/logic/resolve-families.ts @@ -60,6 +60,7 @@ export async function resolveFamily({ return { ...family, nameWithHash, + // TODO: this will be an Astro specific aspect eventually provider: await remoteFontProviderResolver.resolve(family.provider), weights: family.weights ? dedupe(family.weights.map((weight) => weight.toString())) : undefined, styles: family.styles ? dedupe(family.styles) : undefined, diff --git a/packages/astro/src/assets/fonts/test.ts b/packages/astro/src/assets/fonts/orchestrate.ts similarity index 99% rename from packages/astro/src/assets/fonts/test.ts rename to packages/astro/src/assets/fonts/orchestrate.ts index 33a8315e1de6..f36411e78be7 100644 --- a/packages/astro/src/assets/fonts/test.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -25,7 +25,7 @@ import { CachedFontFetcher } from './implementations/font-fetcher.js'; import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; // TODO: logs everywhere! -export async function main({ +export async function orchestrate({ families, root, cacheDir, From 2ab5ce2f61a162fb7049dcb1f1da6c72c3b12991 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 14:36:07 +0200 Subject: [PATCH 22/68] feat: experiment --- .../astro/src/assets/fonts/orchestrate.ts | 13 ++++---- .../src/assets/fonts/vite-plugin-fonts.ts | 30 ++++++++++++++++++- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index f36411e78be7..e3b15a40131e 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -1,6 +1,4 @@ import { DEFAULTS, LOCAL_PROVIDER_NAME } from './constants.js'; -import { AstroErrorHandler } from './implementations/error-handler.js'; -import { XxHasher } from './implementations/hasher.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; import { RealRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { FsStorage } from './implementations/storage.js'; @@ -23,23 +21,22 @@ import { optimizeFallbacks } from './logic/optimize-fallbacks.js'; import { RealSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; import { CachedFontFetcher } from './implementations/font-fetcher.js'; import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; +import type { ErrorHandler, Hasher } from './definitions.js'; // TODO: logs everywhere! export async function orchestrate({ families, - root, cacheDir, base, + hasher, + errorHandler, }: { families: Array; - root: URL; cacheDir: URL; base: string; + hasher: Hasher; + errorHandler: ErrorHandler; }) { - // TODO: dependencies will have to be args - // Dependencies - const hasher = await XxHasher.create(); - const errorHandler = new AstroErrorHandler(); const modResolver = new BuildRemoteFontProviderModResolver(); const remoteFontProviderResolver = new RealRemoteFontProviderResolver( root, diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index e6ea231e67cd..e5e8496edcc5 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -30,6 +30,14 @@ import { sortObjectByKey, withoutQuotes, } from './utils.js'; +import { orchestrate } from './orchestrate.js'; +import { XxHasher } from './implementations/hasher.js'; +import { AstroErrorHandler } from './implementations/error-handler.js'; +import type { RemoteFontProviderModResolver } from './definitions.js'; +import { + BuildRemoteFontProviderModResolver, + DevServerRemoteFontProviderModResolver, +} from './implementations/remote-font-provider-mod-resolver.js'; interface Options { settings: AstroSettings; @@ -98,7 +106,11 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { storage = null; }; - async function initialize({ resolveMod, base }: { resolveMod: ResolveMod; base: URL }) { + async function initialize({ + resolveMod, + base, + modResolver, + }: { resolveMod: ResolveMod; base: URL; modResolver: RemoteFontProviderModResolver }) { const { h64ToString } = await xxhash(); storage = createStorage({ @@ -140,6 +152,20 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { }, log: (message) => logger.info('assets', message), }); + + // Dependencies + const { root } = settings.config; + const hasher = await XxHasher.create(); + const errorHandler = new AstroErrorHandler(); + + // TODO: renames needed + const res = await orchestrate({ + families: settings.config.experimental.fonts!, + base: baseUrl, + cacheDir: base, + hasher, + errorHandler, + }); } return { @@ -152,6 +178,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { await initialize({ resolveMod: (id) => import(id), base: new URL(CACHE_DIR, settings.config.cacheDir), + modResolver: new BuildRemoteFontProviderModResolver(), }); } }, @@ -160,6 +187,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { 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), + modResolver: new DevServerRemoteFontProviderModResolver(server), }); // 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 From 8f88ca0229c5151e7b95761876daddb0cd84e2b4 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 15:07:47 +0200 Subject: [PATCH 23/68] feat: experiment --- .../astro/src/assets/fonts/orchestrate.ts | 21 +++++++++---------- .../src/assets/fonts/vite-plugin-fonts.ts | 10 +++++++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index e3b15a40131e..9f42a8414bdf 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -1,13 +1,10 @@ import { DEFAULTS, LOCAL_PROVIDER_NAME } from './constants.js'; -import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; -import { RealRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { FsStorage } from './implementations/storage.js'; import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; import { pickFontFaceProperty, type GetMetricsForFamilyFont } from './utils.js'; -import { BuildRemoteFontProviderModResolver } from './implementations/remote-font-provider-mod-resolver.js'; import { RealUrlProxy } from './implementations/url-proxy.js'; import { RealDataCollector } from './implementations/data-collector.js'; import { @@ -21,7 +18,12 @@ import { optimizeFallbacks } from './logic/optimize-fallbacks.js'; import { RealSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; import { CachedFontFetcher } from './implementations/font-fetcher.js'; import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; -import type { ErrorHandler, Hasher } from './definitions.js'; +import type { + ErrorHandler, + Hasher, + LocalProviderUrlResolver, + RemoteFontProviderResolver, +} from './definitions.js'; // TODO: logs everywhere! export async function orchestrate({ @@ -30,20 +32,17 @@ export async function orchestrate({ base, hasher, errorHandler, + remoteFontProviderResolver, + localProviderUrlResolver, }: { families: Array; cacheDir: URL; base: string; hasher: Hasher; errorHandler: ErrorHandler; + remoteFontProviderResolver: RemoteFontProviderResolver; + localProviderUrlResolver: LocalProviderUrlResolver; }) { - const modResolver = new BuildRemoteFontProviderModResolver(); - const remoteFontProviderResolver = new RealRemoteFontProviderResolver( - root, - modResolver, - errorHandler, - ); - const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); const storage = FsStorage.create(cacheDir); const cssRenderer = new PrettyCssRenderer(); const systemFallbacksProvider = new RealSystemFallbacksProvider(); diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index e5e8496edcc5..d0a53fb9635c 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -38,6 +38,8 @@ import { BuildRemoteFontProviderModResolver, DevServerRemoteFontProviderModResolver, } from './implementations/remote-font-provider-mod-resolver.js'; +import { RealRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; +import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; interface Options { settings: AstroSettings; @@ -157,6 +159,12 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { const { root } = settings.config; const hasher = await XxHasher.create(); const errorHandler = new AstroErrorHandler(); + const remoteFontProviderResolver = new RealRemoteFontProviderResolver( + root, + modResolver, + errorHandler, + ); + const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); // TODO: renames needed const res = await orchestrate({ @@ -165,6 +173,8 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { cacheDir: base, hasher, errorHandler, + remoteFontProviderResolver, + localProviderUrlResolver, }); } From 77140e689fae948dbf777133cf5be0d3145a4afe Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 15:19:23 +0200 Subject: [PATCH 24/68] feat: experiment --- .../astro/src/assets/fonts/orchestrate.ts | 23 ++++++++++--------- .../src/assets/fonts/vite-plugin-fonts.ts | 16 +++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 9f42a8414bdf..4f222a1f8171 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -1,5 +1,4 @@ import { DEFAULTS, LOCAL_PROVIDER_NAME } from './constants.js'; -import { FsStorage } from './implementations/storage.js'; import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData } from './types.js'; @@ -13,17 +12,17 @@ import { } from './implementations/url-proxy-content-resolver.js'; import { extractUnifontProviders } from './logic/extract-unifont-providers.js'; import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js'; -import { PrettyCssRenderer } from './implementations/css-renderer.js'; import { optimizeFallbacks } from './logic/optimize-fallbacks.js'; -import { RealSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; -import { CachedFontFetcher } from './implementations/font-fetcher.js'; -import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; import type { + CssRenderer, ErrorHandler, + FontMetricsResolver, Hasher, LocalProviderUrlResolver, RemoteFontProviderResolver, + SystemFallbacksProvider, } from './definitions.js'; +import type { Storage } from 'unstorage'; // TODO: logs everywhere! export async function orchestrate({ @@ -34,6 +33,10 @@ export async function orchestrate({ errorHandler, remoteFontProviderResolver, localProviderUrlResolver, + storage, + cssRenderer, + systemFallbacksProvider, + fontMetricsResolver, }: { families: Array; cacheDir: URL; @@ -42,13 +45,11 @@ export async function orchestrate({ errorHandler: ErrorHandler; remoteFontProviderResolver: RemoteFontProviderResolver; localProviderUrlResolver: LocalProviderUrlResolver; + storage: Storage; + cssRenderer: CssRenderer; + systemFallbacksProvider: SystemFallbacksProvider; + fontMetricsResolver: FontMetricsResolver; }) { - const storage = FsStorage.create(cacheDir); - const cssRenderer = new PrettyCssRenderer(); - const systemFallbacksProvider = new RealSystemFallbacksProvider(); - const fontFetcher = new CachedFontFetcher(storage, errorHandler); - const fontMetricsResolver = new RealFontMetricsResolver(fontFetcher); - let resolvedFamilies = await resolveFamilies({ families, hasher, diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index d0a53fb9635c..1ee613c81d31 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -40,6 +40,11 @@ import { } from './implementations/remote-font-provider-mod-resolver.js'; import { RealRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; +import { FsStorage } from './implementations/storage.js'; +import { PrettyCssRenderer } from './implementations/css-renderer.js'; +import { RealSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; +import { CachedFontFetcher } from './implementations/font-fetcher.js'; +import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; interface Options { settings: AstroSettings; @@ -165,6 +170,11 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { errorHandler, ); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); + const storage = FsStorage.create(base); + const cssRenderer = new PrettyCssRenderer(); + const systemFallbacksProvider = new RealSystemFallbacksProvider(); + const fontFetcher = new CachedFontFetcher(storage, errorHandler); + const fontMetricsResolver = new RealFontMetricsResolver(fontFetcher); // TODO: renames needed const res = await orchestrate({ @@ -175,7 +185,13 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { errorHandler, remoteFontProviderResolver, localProviderUrlResolver, + storage, + cssRenderer, + systemFallbacksProvider, + fontMetricsResolver, }); + hashToUrlMap = res.hashToUrlMap; + resolvedMap = res.resolvedMap; } return { From ffed015f51dbf3a578ab6cda4ee5dce460e7e924 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 15:28:04 +0200 Subject: [PATCH 25/68] feat: experiment --- .../astro/src/assets/fonts/orchestrate.ts | 30 +++++++------------ .../src/assets/fonts/vite-plugin-fonts.ts | 16 ++++++++-- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 4f222a1f8171..3cfd7cf7532f 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -4,51 +4,45 @@ import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; import { pickFontFaceProperty, type GetMetricsForFamilyFont } from './utils.js'; -import { RealUrlProxy } from './implementations/url-proxy.js'; -import { RealDataCollector } from './implementations/data-collector.js'; -import { - LocalUrlProxyContentResolver, - RemoteUrlProxyContentResolver, -} from './implementations/url-proxy-content-resolver.js'; +import type { RealDataCollector } from './implementations/data-collector.js'; import { extractUnifontProviders } from './logic/extract-unifont-providers.js'; import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js'; import { optimizeFallbacks } from './logic/optimize-fallbacks.js'; import type { CssRenderer, - ErrorHandler, FontMetricsResolver, Hasher, LocalProviderUrlResolver, RemoteFontProviderResolver, SystemFallbacksProvider, + UrlProxy, } from './definitions.js'; import type { Storage } from 'unstorage'; // TODO: logs everywhere! export async function orchestrate({ families, - cacheDir, - base, hasher, - errorHandler, remoteFontProviderResolver, localProviderUrlResolver, storage, cssRenderer, systemFallbacksProvider, fontMetricsResolver, + createUrlProxy, }: { families: Array; - cacheDir: URL; - base: string; hasher: Hasher; - errorHandler: ErrorHandler; remoteFontProviderResolver: RemoteFontProviderResolver; localProviderUrlResolver: LocalProviderUrlResolver; storage: Storage; cssRenderer: CssRenderer; systemFallbacksProvider: SystemFallbacksProvider; fontMetricsResolver: FontMetricsResolver; + createUrlProxy: ( + local: boolean, + ...collectorArgs: ConstructorParameters + ) => UrlProxy; }) { let resolvedFamilies = await resolveFamilies({ families, @@ -64,7 +58,6 @@ export async function orchestrate({ resolvedFamilies = extractedUnifontProvidersResult.families; const unifontProviders = extractedUnifontProvidersResult.providers; - // Probably not worth making a dependency const { resolveFont } = await unifont.createUnifont(unifontProviders, { storage, }); @@ -80,17 +73,14 @@ export async function orchestrate({ const fallbackFontData: Array = []; const fallbacks = family.fallbacks ?? DEFAULTS.fallbacks; - const dataCollector = new RealDataCollector( + // Must be cleaned to less rely on internal structures + const urlProxy = createUrlProxy( + family.provider === LOCAL_PROVIDER_NAME, hashToUrlMap, preloadData, fallbackFontData, fallbacks, ); - const contentResolver = - family.provider === LOCAL_PROVIDER_NAME - ? new LocalUrlProxyContentResolver(errorHandler) - : new RemoteUrlProxyContentResolver(); - const urlProxy = new RealUrlProxy(base, contentResolver, hasher, dataCollector); let fonts: Array; diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 1ee613c81d31..a37cf392ff8c 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -45,6 +45,12 @@ import { PrettyCssRenderer } from './implementations/css-renderer.js'; import { RealSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; import { CachedFontFetcher } from './implementations/font-fetcher.js'; import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; +import { RealUrlProxy } from './implementations/url-proxy.js'; +import { + LocalUrlProxyContentResolver, + RemoteUrlProxyContentResolver, +} from './implementations/url-proxy-content-resolver.js'; +import { RealDataCollector } from './implementations/data-collector.js'; interface Options { settings: AstroSettings; @@ -179,16 +185,20 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // TODO: renames needed const res = await orchestrate({ families: settings.config.experimental.fonts!, - base: baseUrl, - cacheDir: base, hasher, - errorHandler, remoteFontProviderResolver, localProviderUrlResolver, storage, cssRenderer, systemFallbacksProvider, fontMetricsResolver, + createUrlProxy: (local, ...collectorArgs) => { + const dataCollector = new RealDataCollector(...collectorArgs); + const contentResolver = local + ? new LocalUrlProxyContentResolver(errorHandler) + : new RemoteUrlProxyContentResolver(); + return new RealUrlProxy(baseUrl, contentResolver, hasher, dataCollector); + }, }); hashToUrlMap = res.hashToUrlMap; resolvedMap = res.resolvedMap; From 01191d0293ae818e63b7224bb51c956f68585709 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 15:33:52 +0200 Subject: [PATCH 26/68] feat: experiment --- packages/astro/src/assets/fonts/constants.ts | 6 ++--- .../astro/src/assets/fonts/orchestrate.ts | 25 +++++++++++++------ .../src/assets/fonts/vite-plugin-fonts.ts | 2 ++ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index c7831f2b3230..74ea4777249b 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -1,16 +1,16 @@ import type { FontFaceMetrics } from './metrics.js'; -import type { ResolvedRemoteFontFamily } from './types.js'; +import type { Defaults } from './orchestrate.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; +}; export const VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/internal'; export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 3cfd7cf7532f..1e20bf42dd31 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -1,7 +1,7 @@ -import { DEFAULTS, LOCAL_PROVIDER_NAME } from './constants.js'; +import { LOCAL_PROVIDER_NAME } from './constants.js'; import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; -import type { FontFamily, PreloadData } from './types.js'; +import type { FontFamily, PreloadData, ResolvedRemoteFontFamily } from './types.js'; import * as unifont from 'unifont'; import { pickFontFaceProperty, type GetMetricsForFamilyFont } from './utils.js'; import type { RealDataCollector } from './implementations/data-collector.js'; @@ -19,6 +19,13 @@ import type { } from './definitions.js'; import type { Storage } from 'unstorage'; +export type Defaults = Partial< + Pick< + ResolvedRemoteFontFamily, + 'weights' | 'styles' | 'subsets' | 'fallbacks' | 'optimizedFallbacks' + > +>; + // TODO: logs everywhere! export async function orchestrate({ families, @@ -30,6 +37,7 @@ export async function orchestrate({ systemFallbacksProvider, fontMetricsResolver, createUrlProxy, + defaults, }: { families: Array; hasher: Hasher; @@ -43,6 +51,7 @@ export async function orchestrate({ local: boolean, ...collectorArgs: ConstructorParameters ) => UrlProxy; + defaults: Defaults; }) { let resolvedFamilies = await resolveFamilies({ families, @@ -71,7 +80,7 @@ export async function orchestrate({ const preloadData: PreloadData = []; let css = ''; const fallbackFontData: Array = []; - const fallbacks = family.fallbacks ?? DEFAULTS.fallbacks; + const fallbacks = family.fallbacks ?? defaults.fallbacks ?? []; // Must be cleaned to less rely on internal structures const urlProxy = createUrlProxy( @@ -96,10 +105,10 @@ export async function orchestrate({ // We do not merge the defaults, we only provide defaults as a fallback // TODO: defaults should be customizable { - weights: family.weights ?? DEFAULTS.weights, - styles: family.styles ?? DEFAULTS.styles, - subsets: family.subsets ?? DEFAULTS.subsets, - fallbacks: family.fallbacks ?? DEFAULTS.fallbacks, + 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. @@ -128,7 +137,7 @@ export async function orchestrate({ family, fallbacks, fontData: fallbackFontData, - enabled: family.optimizedFallbacks ?? DEFAULTS.optimizedFallbacks, + enabled: family.optimizedFallbacks ?? defaults.optimizedFallbacks ?? false, systemFallbacksProvider, fontMetricsResolver, }); diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index a37cf392ff8c..849174183a22 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -15,6 +15,7 @@ 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, @@ -199,6 +200,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { : new RemoteUrlProxyContentResolver(); return new RealUrlProxy(baseUrl, contentResolver, hasher, dataCollector); }, + defaults: DEFAULTS, }); hashToUrlMap = res.hashToUrlMap; resolvedMap = res.resolvedMap; From 2afd050e79330510e30e3b42a8c9ad85f58b97f7 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 15:39:14 +0200 Subject: [PATCH 27/68] feat: experiment --- .../astro/src/assets/fonts/orchestrate.ts | 1 - .../src/assets/fonts/vite-plugin-fonts.ts | 114 +++--------------- 2 files changed, 18 insertions(+), 97 deletions(-) diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 1e20bf42dd31..03340145a5bb 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -103,7 +103,6 @@ export async function orchestrate({ const result = await resolveFont( family.name, // We do not merge the defaults, we only provide defaults as a fallback - // TODO: defaults should be customizable { weights: family.weights ?? defaults.weights, styles: family.styles ?? defaults.styles, diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 849174183a22..6f215522992d 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'; @@ -20,21 +15,12 @@ import { 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 { - cache, - extractFontType, - resolveFontFamily, - sortObjectByKey, - withoutQuotes, -} from './utils.js'; +import type { PreloadData } from './types.js'; +import { extractFontType } from './utils.js'; import { orchestrate } from './orchestrate.js'; import { XxHasher } from './implementations/hasher.js'; import { AstroErrorHandler } from './implementations/error-handler.js'; -import type { RemoteFontProviderModResolver } from './definitions.js'; +import type { FontFetcher, RemoteFontProviderModResolver } from './definitions.js'; import { BuildRemoteFontProviderModResolver, DevServerRemoteFontProviderModResolver, @@ -59,29 +45,6 @@ interface Options { logger: Logger; } -async function fetchFont(url: string): Promise { - 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 does not exist @@ -112,61 +75,21 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // to download the original file, or retrieve it from cache let hashToUrlMap: Map | null = null; let isBuild: boolean; - let storage: Storage | null = null; + let fontFetcher: FontFetcher | null = null; const cleanup = () => { resolvedMap = null; hashToUrlMap = null; - storage = null; + fontFetcher = null; }; async function initialize({ - resolveMod, - base, + cacheDir, modResolver, - }: { resolveMod: ResolveMod; base: URL; modResolver: RemoteFontProviderModResolver }) { - const { h64ToString } = await xxhash(); - - storage = createStorage({ - // Types are weirly exported - driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({ - base: fileURLToPath(base), - }), - }); - - // We initialize shared variables here and reset them in buildEnd - // to avoid locking memory - hashToUrlMap = new Map(); - resolvedMap = new Map(); - - const families: Array = []; - - for (const family of settings.config.experimental.fonts!) { - families.push( - await resolveFontFamily({ - family, - root: settings.config.root, - resolveMod, - generateNameWithHash: (_family) => - `${withoutQuotes(_family.name)}-${h64ToString(JSON.stringify(sortObjectByKey(_family)))}`, - }), - ); - } - - await loadFonts({ - base: baseUrl, - families, - storage, - hashToUrlMap, - resolvedMap, - hashString: h64ToString, - generateFallbackFontFace, - getMetricsForFamily: async (name, font) => { - return await readMetrics(name, await cache(storage!, font.hash, () => fetchFont(font.url))); - }, - log: (message) => logger.info('assets', message), - }); - + }: { + cacheDir: URL; + modResolver: RemoteFontProviderModResolver; + }) { // Dependencies const { root } = settings.config; const hasher = await XxHasher.create(); @@ -177,13 +100,12 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { errorHandler, ); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); - const storage = FsStorage.create(base); + const storage = FsStorage.create(cacheDir); const cssRenderer = new PrettyCssRenderer(); const systemFallbacksProvider = new RealSystemFallbacksProvider(); - const fontFetcher = new CachedFontFetcher(storage, errorHandler); + fontFetcher = new CachedFontFetcher(storage, errorHandler); const fontMetricsResolver = new RealFontMetricsResolver(fontFetcher); - // TODO: renames needed const res = await orchestrate({ families: settings.config.experimental.fonts!, hasher, @@ -202,6 +124,8 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { }, defaults: DEFAULTS, }); + // We initialize shared variables here and reset them in buildEnd + // to avoid locking memory hashToUrlMap = res.hashToUrlMap; resolvedMap = res.resolvedMap; } @@ -214,17 +138,15 @@ 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: new BuildRemoteFontProviderModResolver(), }); } }, 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: new DevServerRemoteFontProviderModResolver(server), }); // The map is always defined at this point. Its values contains urls from remote providers @@ -267,7 +189,7 @@ 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)}`); @@ -316,7 +238,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) { From a103ad93ca825aef274c8c95116d803547e73adc Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 15:51:09 +0200 Subject: [PATCH 28/68] feat: experiment --- packages/astro/src/assets/fonts/constants.ts | 88 ++---- .../astro/src/assets/fonts/definitions.ts | 3 +- .../system-fallbacks-provider.ts | 11 +- packages/astro/src/assets/fonts/load.ts | 225 ---------------- packages/astro/src/assets/fonts/sync.ts | 1 + packages/astro/src/assets/fonts/utils.ts | 251 +----------------- 6 files changed, 36 insertions(+), 543 deletions(-) delete mode 100644 packages/astro/src/assets/fonts/load.ts diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index 74ea4777249b..2d77ae82c0a1 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -1,4 +1,3 @@ -import type { FontFaceMetrics } from './metrics.js'; import type { Defaults } from './orchestrate.js'; export const LOCAL_PROVIDER_NAME = 'local'; @@ -28,74 +27,23 @@ 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; - -// 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>; +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; + +// TODO: move type +export type GenericFallbackName = (typeof GENERIC_FALLBACK_NAMES)[number]; 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 index f822ca3d3279..0d3ca1f0f509 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -3,6 +3,7 @@ import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './typ import type * as unifont from 'unifont'; import type { GetMetricsForFamily } from './utils.js'; import type { FontFaceMetrics } from './metrics.js'; +import type { GenericFallbackName } from './constants.js'; export interface Hasher { hashString: (input: string) => string; @@ -80,7 +81,7 @@ export interface FontMetricsResolver { } export interface SystemFallbacksProvider { - getLocalFonts: (fallback: string) => Array | null; + getLocalFonts: (fallback: GenericFallbackName) => Array | null; getMetricsForLocalFont: (family: string) => FontFaceMetrics; } diff --git a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts index c080abe59165..cce9a1f89948 100644 --- a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts +++ b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts @@ -1,3 +1,4 @@ +import type { GenericFallbackName } from '../constants.js'; import type { SystemFallbacksProvider } from '../definitions.js'; import type { FontFaceMetrics } from '../metrics.js'; @@ -61,20 +62,14 @@ 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>; +} satisfies Partial>>; export class RealSystemFallbacksProvider implements SystemFallbacksProvider { - getLocalFonts(fallback: string): Array | null { + getLocalFonts(fallback: GenericFallbackName): Array | null { return DEFAULT_FALLBACKS[fallback as keyof typeof DEFAULT_FALLBACKS] ?? null; } getMetricsForLocalFont(family: string): FontFaceMetrics { diff --git a/packages/astro/src/assets/fonts/load.ts b/packages/astro/src/assets/fonts/load.ts deleted file mode 100644 index 20222f439624..000000000000 --- 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; - storage: Storage; - hashToUrlMap: Map; - resolvedMap: Map; - 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 { - 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 = []; - - // 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[0] & { - data: Partial; - }, - collectPreload: boolean, - ) => ReturnType = ({ 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; - - 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/sync.ts b/packages/astro/src/assets/fonts/sync.ts index 57ed03377124..a1af4dd701e6 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/utils.ts b/packages/astro/src/assets/fonts/utils.ts index f84d76cb47e0..425490736369 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -1,19 +1,17 @@ import { createRequire } from 'node:module'; import { extname } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +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, + type GenericFallbackName, + LOCAL_PROVIDER_NAME, +} from './constants.js'; +import type { FontFaceMetrics } from './metrics.js'; +import type { FontType, ResolvedFontFamily } from './types.js'; export function toCSS(properties: Record, indent = 2) { return Object.entries(properties) @@ -108,48 +106,11 @@ 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; -} - -/** - * 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 ".woff2" - * - `collect` will save the association of the original url and the new hash for later use - * - the returned url will be `/_astro/fonts/.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 function isGenericFontFamily(str: string): str is GenericFallbackName { + return (GENERIC_FALLBACK_NAMES as unknown as Array).includes(str); } +// TODO: types should not live here export type GetMetricsForFamilyFont = { hash: string; url: string; @@ -162,150 +123,10 @@ export type GetMetricsForFamily = ( font: GetMetricsForFamilyFont, ) => Promise; -/** - * 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; - /** The family fallbacks */ - fallbacks: Array; - font: Array; - metrics: { - getMetricsForFamily: GetMetricsForFamily; - generateFontFace: typeof generateFallbackFontFace; - } | null; -}): Promise }> { - // 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 }; -} - export function dedupe>(arr: T): T { return [...new Set(arr)] as T; } -function resolveVariants({ - variants, - root, -}: { variants: LocalFontFamily['variants']; root: URL }): 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: fileURLToPath(resolveEntrypoint(root, 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, -}: Omit & { - family: FontFamily; - generateNameWithHash: (family: FontFamily) => string; -}): Promise { - const nameWithHash = generateNameWithHash(family); - - if (family.provider === LOCAL_PROVIDER_NAME) { - return { - ...family, - nameWithHash, - variants: resolveVariants({ variants: family.variants, root }), - 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>(unordered: T): T { const ordered = Object.keys(unordered) .sort() @@ -317,54 +138,6 @@ export function sortObjectByKey>(unordered: T): 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; - hashString: (value: string) => string; -}): { families: Array; providers: Array } { - const hashes = new Set(); - const providers: Array = []; - - 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); From a6e7155014c593b07e73bbfd780e0bc8294604c2 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 16:23:08 +0200 Subject: [PATCH 29/68] feat: experiment --- .../astro/src/assets/fonts/definitions.ts | 4 +- .../fonts/implementations/css-renderer.ts | 12 +-- .../implementations/font-metrics-resolver.ts | 72 ++++++++++++++--- .../system-fallbacks-provider.ts | 2 +- packages/astro/src/assets/fonts/metrics.ts | 77 ------------------- .../astro/src/assets/fonts/orchestrate.ts | 31 +++++--- packages/astro/src/assets/fonts/types.ts | 6 ++ packages/astro/src/assets/fonts/utils.ts | 9 +-- .../src/assets/fonts/vite-plugin-fonts.ts | 2 +- 9 files changed, 100 insertions(+), 115 deletions(-) delete mode 100644 packages/astro/src/assets/fonts/metrics.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 0d3ca1f0f509..da9d133efab2 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -2,7 +2,7 @@ import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './types.js'; import type * as unifont from 'unifont'; import type { GetMetricsForFamily } from './utils.js'; -import type { FontFaceMetrics } from './metrics.js'; +import type { FontFaceMetrics } from './types.js'; import type { GenericFallbackName } from './constants.js'; export interface Hasher { @@ -64,7 +64,7 @@ export interface DataCollector { } export interface CssRenderer { - generateFontFace: (family: string, font: unifont.FontFaceData) => string; + generateFontFace: (family: string, properties: Record) => string; generateCssVariable: (key: string, values: Array) => string; } diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts index 93fe345c015f..6d543a9501ab 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -1,15 +1,17 @@ -import type { FontFaceData } from 'unifont'; import type { CssRenderer } from '../definitions.js'; -import { generateFontFace } from '../utils.js'; +import { renderFontFace } from '../utils.js'; // TODO: better impl export class PrettyCssRenderer implements CssRenderer { - generateFontFace(family: string, font: FontFaceData): string { - return generateFontFace(family, font); + generateFontFace(family: string, properties: Record): string { + return renderFontFace({ + 'font-family': family, + ...properties, + }); } generateCssVariable(key: string, values: Array): string { return `:root { ${key}: ${values.join(', ')}; }`; } } -// TODO: MinifiedCssRenderer \ No newline at end of file +// TODO: MinifiedCssRenderer diff --git a/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts index 2b35608f5850..1073c976bdb2 100644 --- a/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts @@ -1,23 +1,77 @@ -import type { FontFetcher, FontMetricsResolver } from '../definitions.js'; -import { generateFallbackFontFace, readMetrics, type FontFaceMetrics } from '../metrics.js'; -import type { GetMetricsForFamilyFont } from '../utils.js'; +import { fromBuffer, type Font } from '@capsizecss/unpack'; +import type { CssRenderer, FontFetcher, FontMetricsResolver } from '../definitions.js'; +import type { FontFaceMetrics } from '../types.js'; +import { renderFontSrc, type GetMetricsForFamilyFont } from '../utils.js'; + +function filterRequiredMetrics({ + ascent, + descent, + lineGap, + unitsPerEm, + xWidthAvg, +}: Pick) { + return { + ascent, + descent, + lineGap, + unitsPerEm, + xWidthAvg, + }; +} + +// 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)}%`; +} -// TODO: better impl export class RealFontMetricsResolver implements FontMetricsResolver { - constructor(private fontFetcher: FontFetcher) {} + private cache: Record = {}; + + constructor( + private fontFetcher: FontFetcher, + private cssRenderer: CssRenderer, + ) {} async getMetrics(name: string, { hash, url }: GetMetricsForFamilyFont): Promise { - return await readMetrics(name, await this.fontFetcher.fetch(hash, url)); + this.cache[name] ??= filterRequiredMetrics( + await fromBuffer(await this.fontFetcher.fetch(hash, url)), + ); + return this.cache[name]; } - generateFontFace(input: { + generateFontFace({ + metrics, + fallbackMetrics, + name: fallbackName, + font: fallbackFontName, + properties, + }: { metrics: FontFaceMetrics; fallbackMetrics: FontFaceMetrics; name: string; font: string; properties: Record; }): string { - // TODO: use cssRenderer - return generateFallbackFontFace(input); + // 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 this.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/system-fallbacks-provider.ts b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts index cce9a1f89948..a30994bcd0f9 100644 --- a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts +++ b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts @@ -1,6 +1,6 @@ import type { GenericFallbackName } from '../constants.js'; import type { SystemFallbacksProvider } from '../definitions.js'; -import type { FontFaceMetrics } from '../metrics.js'; +import type { FontFaceMetrics } from '../types.js'; // Extracted from https://raw.githubusercontent.com/seek-oss/capsize/refs/heads/master/packages/metrics/src/entireMetricsCollection.json export const SYSTEM_METRICS = { diff --git a/packages/astro/src/assets/fonts/metrics.ts b/packages/astro/src/assets/fonts/metrics.ts deleted file mode 100644 index 1dfa2b6184f8..000000000000 --- 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 = {}; - -function filterRequiredMetrics({ - ascent, - descent, - lineGap, - unitsPerEm, - xWidthAvg, -}: Pick) { - 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; -}) { - // 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 index 03340145a5bb..3e76d05703fb 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -3,7 +3,11 @@ import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData, ResolvedRemoteFontFamily } from './types.js'; import * as unifont from 'unifont'; -import { pickFontFaceProperty, type GetMetricsForFamilyFont } from './utils.js'; +import { + pickFontFaceProperty, + unifontFontFaceDataToProperties, + type GetMetricsForFamilyFont, +} from './utils.js'; import type { RealDataCollector } from './implementations/data-collector.js'; import { extractUnifontProviders } from './logic/extract-unifont-providers.js'; import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js'; @@ -118,17 +122,20 @@ export async function orchestrate({ } for (const data of fonts) { - css += cssRenderer.generateFontFace(family.nameWithHash, { - src: data.src, - weight: data.weight, - style: data.style, - // User settings override the generated font settings - 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 }), - }); + css += cssRenderer.generateFontFace( + family.nameWithHash, + unifontFontFaceDataToProperties({ + src: data.src, + weight: data.weight, + style: data.style, + // User settings override the generated font settings + 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]; diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index 1cac9bf753e5..29d7a064bf19 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -6,6 +6,7 @@ import type { remoteFontFamilySchema, } from './config.js'; import type { FONT_TYPES } from './constants.js'; +import type { Font } from '@capsizecss/unpack'; export type AstroFontProvider = z.infer; @@ -59,3 +60,8 @@ export type PreloadData = Array<{ */ type: FontType; }>; + +export type FontFaceMetrics = Pick< + Font, + 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg' +>; diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 425490736369..dc01723dcc66 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -10,7 +10,7 @@ import { type GenericFallbackName, LOCAL_PROVIDER_NAME, } from './constants.js'; -import type { FontFaceMetrics } from './metrics.js'; +import type { FontFaceMetrics } from './types.js'; import type { FontType, ResolvedFontFamily } from './types.js'; export function toCSS(properties: Record, indent = 2) { @@ -39,13 +39,6 @@ export 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[]) { return sources diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 6f215522992d..96b8d8e5c87c 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -104,7 +104,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { const cssRenderer = new PrettyCssRenderer(); const systemFallbacksProvider = new RealSystemFallbacksProvider(); fontFetcher = new CachedFontFetcher(storage, errorHandler); - const fontMetricsResolver = new RealFontMetricsResolver(fontFetcher); + const fontMetricsResolver = new RealFontMetricsResolver(fontFetcher, cssRenderer); const res = await orchestrate({ families: settings.config.experimental.fonts!, From b5476ba59d2c33768244471292a0327f681ee1fc Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 16:30:29 +0200 Subject: [PATCH 30/68] feat: experiment --- .../fonts/implementations/css-renderer.ts | 54 ++++++++++++++++--- .../src/assets/fonts/vite-plugin-fonts.ts | 9 ++-- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts index 6d543a9501ab..65f0fd19313c 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -1,17 +1,55 @@ import type { CssRenderer } from '../definitions.js'; -import { renderFontFace } from '../utils.js'; -// TODO: better impl +export function renderFontFace( + properties: Record, + minify: boolean, +): string { + // Line return + const lr = minify ? '' : `\n`; + // Space + const sp = minify ? '' : ' '; + + return `@font-face${sp}{${lr}${Object.entries(properties) + .filter(([, value]) => Boolean(value)) + .map(([key, value]) => `${sp}${sp}${key}:${sp}${value};`) + .join(lr)}${lr}}${lr}`; +} + +export function renderCssVariable(key: string, values: Array, minify: boolean): string { + // Line return + const lr = minify ? '' : `\n`; + // Space + const sp = minify ? '' : ' '; + + return `:root${sp}{${lr}${sp}${sp}${key}:${sp}${values.join(`,${sp}`)};${lr}}${lr}`; +} + export class PrettyCssRenderer implements CssRenderer { generateFontFace(family: string, properties: Record): string { - return renderFontFace({ - 'font-family': family, - ...properties, - }); + return renderFontFace( + { + 'font-family': family, + ...properties, + }, + false, + ); } generateCssVariable(key: string, values: Array): string { - return `:root { ${key}: ${values.join(', ')}; }`; + return renderCssVariable(key, values, false); } } -// TODO: MinifiedCssRenderer +export class MinifiedCssRenderer implements CssRenderer { + generateFontFace(family: string, properties: Record): string { + return renderFontFace( + { + 'font-family': family, + ...properties, + }, + true, + ); + } + generateCssVariable(key: string, values: Array): string { + return renderCssVariable(key, values, true); + } +} diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 96b8d8e5c87c..a888ea1c3d0d 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -20,7 +20,7 @@ import { extractFontType } from './utils.js'; import { orchestrate } from './orchestrate.js'; import { XxHasher } from './implementations/hasher.js'; import { AstroErrorHandler } from './implementations/error-handler.js'; -import type { FontFetcher, RemoteFontProviderModResolver } from './definitions.js'; +import type { CssRenderer, FontFetcher, RemoteFontProviderModResolver } from './definitions.js'; import { BuildRemoteFontProviderModResolver, DevServerRemoteFontProviderModResolver, @@ -28,7 +28,6 @@ import { import { RealRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; import { FsStorage } from './implementations/storage.js'; -import { PrettyCssRenderer } from './implementations/css-renderer.js'; import { RealSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; import { CachedFontFetcher } from './implementations/font-fetcher.js'; import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; @@ -38,6 +37,7 @@ import { RemoteUrlProxyContentResolver, } from './implementations/url-proxy-content-resolver.js'; import { RealDataCollector } from './implementations/data-collector.js'; +import { MinifiedCssRenderer, PrettyCssRenderer } from './implementations/css-renderer.js'; interface Options { settings: AstroSettings; @@ -86,9 +86,11 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { async function initialize({ cacheDir, modResolver, + cssRenderer, }: { cacheDir: URL; modResolver: RemoteFontProviderModResolver; + cssRenderer: CssRenderer; }) { // Dependencies const { root } = settings.config; @@ -101,7 +103,6 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { ); const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); const storage = FsStorage.create(cacheDir); - const cssRenderer = new PrettyCssRenderer(); const systemFallbacksProvider = new RealSystemFallbacksProvider(); fontFetcher = new CachedFontFetcher(storage, errorHandler); const fontMetricsResolver = new RealFontMetricsResolver(fontFetcher, cssRenderer); @@ -140,6 +141,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { await initialize({ cacheDir: new URL(CACHE_DIR, settings.config.cacheDir), modResolver: new BuildRemoteFontProviderModResolver(), + cssRenderer: new MinifiedCssRenderer(), }); } }, @@ -148,6 +150,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // In dev, we cache fonts data in .astro so it can be easily inspected and cleared cacheDir: new URL(CACHE_DIR, settings.dotAstroDir), modResolver: new DevServerRemoteFontProviderModResolver(server), + cssRenderer: new PrettyCssRenderer(), }); // 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 From 6822ef7bae2de20746b2ff38f242184e0e3f9417 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 16:31:05 +0200 Subject: [PATCH 31/68] feat: experiment --- packages/astro/src/assets/fonts/utils.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index dc01723dcc66..10e2f5ca2d9c 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -13,17 +13,6 @@ import { import type { FontFaceMetrics } from './types.js'; import type { FontType, ResolvedFontFamily } from './types.js'; -export function toCSS(properties: Record, indent = 2) { - return Object.entries(properties) - .filter(([, value]) => Boolean(value)) - .map(([key, value]) => `${' '.repeat(indent)}${key}: ${value};`) - .join('\n'); -} - -export function renderFontFace(properties: Record) { - return `@font-face {\n\t${toCSS(properties)}\n}\n`; -} - export function unifontFontFaceDataToProperties( font: Partial, ): Record { From 1e1ac844013c5aa8bcf8cf89497a3ea20a69df5c Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 16:34:26 +0200 Subject: [PATCH 32/68] feat: deep sort --- packages/astro/src/assets/fonts/implementations/hasher.ts | 1 - packages/astro/src/assets/fonts/utils.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/astro/src/assets/fonts/implementations/hasher.ts b/packages/astro/src/assets/fonts/implementations/hasher.ts index 0800c4978694..129a941e98e1 100644 --- a/packages/astro/src/assets/fonts/implementations/hasher.ts +++ b/packages/astro/src/assets/fonts/implementations/hasher.ts @@ -11,7 +11,6 @@ export class XxHasher implements Hasher { } hashObject(input: Record): string { - // TODO: needs to be deep return this.hashString(JSON.stringify(sortObjectByKey(input))); } } diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 10e2f5ca2d9c..a5a48c2dd45a 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -113,8 +113,9 @@ export function sortObjectByKey>(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] = typeof value === 'object' && value !== null ? sortObjectByKey(value) : value; return obj; }, {} as T); return ordered; From 6c2b4eaae71fb7cd2145b641d905b831ceff3b6b Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 16:39:59 +0200 Subject: [PATCH 33/68] feat: move stuff around --- .../astro/src/assets/fonts/definitions.ts | 5 ++--- .../fonts/implementations/data-collector.ts | 4 ++-- .../implementations/font-metrics-resolver.ts | 5 +++-- .../assets/fonts/logic/optimize-fallbacks.ts | 22 ++++++++++--------- .../astro/src/assets/fonts/orchestrate.ts | 13 +++++------ packages/astro/src/assets/fonts/utils.ts | 14 ------------ 6 files changed, 24 insertions(+), 39 deletions(-) diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index da9d133efab2..29f7ccafbfa6 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './types.js'; import type * as unifont from 'unifont'; -import type { GetMetricsForFamily } from './utils.js'; import type { FontFaceMetrics } from './types.js'; import type { GenericFallbackName } from './constants.js'; +import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; export interface Hasher { hashString: (input: string) => string; @@ -69,8 +69,7 @@ export interface CssRenderer { } export interface FontMetricsResolver { - // TODO: do not keep type like this - getMetrics: GetMetricsForFamily; + getMetrics: (name: string, font: CollectedFontForMetrics) => Promise; generateFontFace: (input: { metrics: FontFaceMetrics; fallbackMetrics: FontFaceMetrics; diff --git a/packages/astro/src/assets/fonts/implementations/data-collector.ts b/packages/astro/src/assets/fonts/implementations/data-collector.ts index 245e32df6fa1..06a85a607d24 100644 --- a/packages/astro/src/assets/fonts/implementations/data-collector.ts +++ b/packages/astro/src/assets/fonts/implementations/data-collector.ts @@ -1,13 +1,13 @@ import type { DataCollector } from '../definitions.js'; +import type { CollectedFontForMetrics } from '../logic/optimize-fallbacks.js'; import type { PreloadData } from '../types.js'; -import type { GetMetricsForFamilyFont } from '../utils.js'; import type * as unifont from 'unifont'; export class RealDataCollector implements DataCollector { constructor( private hashToUrlMap: Map, private preloadData: PreloadData = [], - private fallbackFontData: Array, + private fallbackFontData: Array, private fallbacks: Array, ) {} diff --git a/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts index 1073c976bdb2..1605edcaae81 100644 --- a/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts @@ -1,7 +1,8 @@ import { fromBuffer, type Font } from '@capsizecss/unpack'; import type { CssRenderer, FontFetcher, FontMetricsResolver } from '../definitions.js'; import type { FontFaceMetrics } from '../types.js'; -import { renderFontSrc, type GetMetricsForFamilyFont } from '../utils.js'; +import { renderFontSrc } from '../utils.js'; +import type { CollectedFontForMetrics } from '../logic/optimize-fallbacks.js'; function filterRequiredMetrics({ ascent, @@ -33,7 +34,7 @@ export class RealFontMetricsResolver implements FontMetricsResolver { private cssRenderer: CssRenderer, ) {} - async getMetrics(name: string, { hash, url }: GetMetricsForFamilyFont): Promise { + async getMetrics(name: string, { hash, url }: CollectedFontForMetrics): Promise { this.cache[name] ??= filterRequiredMetrics( await fromBuffer(await this.fontFetcher.fetch(hash, url)), ); diff --git a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts index b951e21403cd..911ee4238094 100644 --- a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts +++ b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts @@ -1,23 +1,25 @@ import type { FontMetricsResolver, SystemFallbacksProvider } from '../definitions.js'; import type { ResolvedFontFamily } from '../types.js'; -import { - isGenericFontFamily, - unifontFontFaceDataToProperties, - type GetMetricsForFamilyFont, -} from '../utils.js'; +import { isGenericFontFamily, unifontFontFaceDataToProperties } from '../utils.js'; +import type * as unifont from 'unifont'; + +export interface CollectedFontForMetrics { + hash: string; + url: string; + data: Partial; +} export async function optimizeFallbacks({ family, fallbacks: _fallbacks, - fontData, + collectedFonts, enabled, systemFallbacksProvider, fontMetricsResolver, }: { family: Pick; fallbacks: Array; - // TODO: type is pretty bad - fontData: Array; + collectedFonts: Array; enabled: boolean; systemFallbacksProvider: SystemFallbacksProvider; fontMetricsResolver: FontMetricsResolver; @@ -28,7 +30,7 @@ export async function optimizeFallbacks({ // We avoid mutating the original array let fallbacks = [..._fallbacks]; - if (fallbacks.length === 0 || !enabled || fontData.length === 0) { + if (fallbacks.length === 0 || !enabled || collectedFonts.length === 0) { return null; } @@ -61,7 +63,7 @@ export async function optimizeFallbacks({ let css = ''; for (const { font, name } of localFontsMappings) { - for (const { hash, url, data } of fontData) { + for (const { hash, url, data } of collectedFonts) { css += fontMetricsResolver.generateFontFace({ metrics: await fontMetricsResolver.getMetrics(family.name, { hash, url, data }), fallbackMetrics: systemFallbacksProvider.getMetricsForLocalFont(font), diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 3e76d05703fb..189da508bb37 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -3,15 +3,11 @@ import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; import type { FontFamily, PreloadData, ResolvedRemoteFontFamily } from './types.js'; import * as unifont from 'unifont'; -import { - pickFontFaceProperty, - unifontFontFaceDataToProperties, - type GetMetricsForFamilyFont, -} from './utils.js'; +import { pickFontFaceProperty, unifontFontFaceDataToProperties } from './utils.js'; import type { RealDataCollector } from './implementations/data-collector.js'; import { extractUnifontProviders } from './logic/extract-unifont-providers.js'; import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js'; -import { optimizeFallbacks } from './logic/optimize-fallbacks.js'; +import { optimizeFallbacks, type CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; import type { CssRenderer, FontMetricsResolver, @@ -83,7 +79,8 @@ export async function orchestrate({ // TODO: might be refactored const preloadData: PreloadData = []; let css = ''; - const fallbackFontData: Array = []; + // TODO: rename to something like collectedFonts + const fallbackFontData: Array = []; const fallbacks = family.fallbacks ?? defaults.fallbacks ?? []; // Must be cleaned to less rely on internal structures @@ -142,7 +139,7 @@ export async function orchestrate({ const optimizeFallbacksResult = await optimizeFallbacks({ family, fallbacks, - fontData: fallbackFontData, + collectedFonts: fallbackFontData, enabled: family.optimizedFallbacks ?? defaults.optimizedFallbacks ?? false, systemFallbacksProvider, fontMetricsResolver, diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index a5a48c2dd45a..eaeff94b5d93 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -10,7 +10,6 @@ import { type GenericFallbackName, LOCAL_PROVIDER_NAME, } from './constants.js'; -import type { FontFaceMetrics } from './types.js'; import type { FontType, ResolvedFontFamily } from './types.js'; export function unifontFontFaceDataToProperties( @@ -92,19 +91,6 @@ export function isGenericFontFamily(str: string): str is GenericFallbackName { return (GENERIC_FALLBACK_NAMES as unknown as Array).includes(str); } -// TODO: types should not live here -export type GetMetricsForFamilyFont = { - hash: string; - url: string; - data: Partial; -}; - -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; - export function dedupe>(arr: T): T { return [...new Set(arr)] as T; } From 6ce6f3cc0e6914e9a9a6c09047daafd408780f1c Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 16:52:38 +0200 Subject: [PATCH 34/68] feat: handle space in family name --- .../fonts/implementations/css-renderer.ts | 43 ++++++++++--------- .../assets/fonts/logic/resolve-families.ts | 8 ++-- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts index 65f0fd19313c..41180b81ead8 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -1,9 +1,8 @@ import type { CssRenderer } from '../definitions.js'; -export function renderFontFace( - properties: Record, - minify: boolean, -): string { +type Properties = Record; + +export function renderFontFace(properties: Properties, minify: boolean): string { // Line return const lr = minify ? '' : `\n`; // Space @@ -24,15 +23,25 @@ export function renderCssVariable(key: string, values: Array, minify: bo return `:root${sp}{${lr}${sp}${sp}${key}:${sp}${values.join(`,${sp}`)};${lr}}${lr}`; } +export function withFamily(family: string, properties: Properties): Properties { + return { + 'font-family': handleValueWithSpaces(family), + ...properties, + }; +} + +const SPACE_RE = /\s/; + +export function handleValueWithSpaces(value: string): string { + if (SPACE_RE.test(value)) { + return JSON.stringify(value); + } + return value; +} + export class PrettyCssRenderer implements CssRenderer { - generateFontFace(family: string, properties: Record): string { - return renderFontFace( - { - 'font-family': family, - ...properties, - }, - false, - ); + generateFontFace(family: string, properties: Properties): string { + return renderFontFace(withFamily(family, properties), false); } generateCssVariable(key: string, values: Array): string { return renderCssVariable(key, values, false); @@ -40,14 +49,8 @@ export class PrettyCssRenderer implements CssRenderer { } export class MinifiedCssRenderer implements CssRenderer { - generateFontFace(family: string, properties: Record): string { - return renderFontFace( - { - 'font-family': family, - ...properties, - }, - true, - ); + generateFontFace(family: string, properties: Properties): string { + return renderFontFace(withFamily(family, properties), true); } generateCssVariable(key: string, values: Array): string { return renderCssVariable(key, values, true); diff --git a/packages/astro/src/assets/fonts/logic/resolve-families.ts b/packages/astro/src/assets/fonts/logic/resolve-families.ts index a3e1317f37aa..b6342a36e9d8 100644 --- a/packages/astro/src/assets/fonts/logic/resolve-families.ts +++ b/packages/astro/src/assets/fonts/logic/resolve-families.ts @@ -45,12 +45,13 @@ export async function resolveFamily({ remoteFontProviderResolver: RemoteFontProviderResolver; localProviderUrlResolver: LocalProviderUrlResolver; }): Promise { - // TODO: handle spaces - const nameWithHash = `${withoutQuotes(family.name)}-${hasher.hashObject(family)}`; + const name = withoutQuotes(family.name); + 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, @@ -59,8 +60,9 @@ export async function resolveFamily({ return { ...family, + name, nameWithHash, - // TODO: this will be an Astro specific aspect eventually + // TODO: this will only be Astro specific eventually provider: await remoteFontProviderResolver.resolve(family.provider), weights: family.weights ? dedupe(family.weights.map((weight) => weight.toString())) : undefined, styles: family.styles ? dedupe(family.styles) : undefined, From 001fc6aa8d13551b9a4c5fe29829b376dbba41f2 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 16:57:02 +0200 Subject: [PATCH 35/68] feat: jsdocs --- packages/astro/src/assets/fonts/config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/astro/src/assets/fonts/config.ts b/packages/astro/src/assets/fonts/config.ts index 1f837862a459..0d5a374f3b77 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()).nonempty().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(), }), From 8b437dbfedb31eb425d5ba0ddc231a9df5f3000e Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 16:58:16 +0200 Subject: [PATCH 36/68] feat: move type --- packages/astro/src/assets/fonts/constants.ts | 3 --- packages/astro/src/assets/fonts/definitions.ts | 3 +-- .../fonts/implementations/system-fallbacks-provider.ts | 3 +-- packages/astro/src/assets/fonts/types.ts | 4 +++- packages/astro/src/assets/fonts/utils.ts | 9 ++------- 5 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index 2d77ae82c0a1..14b7efc4d2f8 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -43,7 +43,4 @@ export const GENERIC_FALLBACK_NAMES = [ 'fangsong', ] as const; -// TODO: move type -export type GenericFallbackName = (typeof GENERIC_FALLBACK_NAMES)[number]; - 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 index 29f7ccafbfa6..a9279a3d5c95 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './types.js'; import type * as unifont from 'unifont'; -import type { FontFaceMetrics } from './types.js'; -import type { GenericFallbackName } from './constants.js'; +import type { FontFaceMetrics, GenericFallbackName } from './types.js'; import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; export interface Hasher { diff --git a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts index a30994bcd0f9..2533149c4df5 100644 --- a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts +++ b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts @@ -1,6 +1,5 @@ -import type { GenericFallbackName } from '../constants.js'; import type { SystemFallbacksProvider } from '../definitions.js'; -import type { FontFaceMetrics } from '../types.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 export const SYSTEM_METRICS = { diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index 29d7a064bf19..9df1e09ac899 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -5,7 +5,7 @@ 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'; export type AstroFontProvider = z.infer; @@ -65,3 +65,5 @@ export type FontFaceMetrics = Pick< Font, 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg' >; + +export type GenericFallbackName = (typeof GENERIC_FALLBACK_NAMES)[number]; \ No newline at end of file diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index eaeff94b5d93..340229c4cea5 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -4,13 +4,8 @@ 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 { - FONT_TYPES, - GENERIC_FALLBACK_NAMES, - type GenericFallbackName, - LOCAL_PROVIDER_NAME, -} from './constants.js'; -import type { FontType, ResolvedFontFamily } from './types.js'; +import { FONT_TYPES, GENERIC_FALLBACK_NAMES, LOCAL_PROVIDER_NAME } from './constants.js'; +import type { FontType, GenericFallbackName, ResolvedFontFamily } from './types.js'; export function unifontFontFaceDataToProperties( font: Partial, From 426877675521906972b6a2d4c756fbfe3b3d79e6 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 17:02:29 +0200 Subject: [PATCH 37/68] chore: comments --- packages/astro/src/assets/fonts/constants.ts | 2 +- packages/astro/src/assets/fonts/orchestrate.ts | 14 ++++---------- packages/astro/src/assets/fonts/types.ts | 9 ++++++++- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index 14b7efc4d2f8..fbf09d76797e 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -1,4 +1,4 @@ -import type { Defaults } from './orchestrate.js'; +import type { Defaults } from "./types.js"; export const LOCAL_PROVIDER_NAME = 'local'; diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 189da508bb37..0d7bee430890 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -1,7 +1,7 @@ import { LOCAL_PROVIDER_NAME } from './constants.js'; import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; -import type { FontFamily, PreloadData, ResolvedRemoteFontFamily } from './types.js'; +import type { Defaults, FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; import { pickFontFaceProperty, unifontFontFaceDataToProperties } from './utils.js'; import type { RealDataCollector } from './implementations/data-collector.js'; @@ -19,12 +19,7 @@ import type { } from './definitions.js'; import type { Storage } from 'unstorage'; -export type Defaults = Partial< - Pick< - ResolvedRemoteFontFamily, - 'weights' | 'styles' | 'subsets' | 'fallbacks' | 'optimizedFallbacks' - > ->; +// TODO: comment/document everything everywhere // TODO: logs everywhere! export async function orchestrate({ @@ -49,6 +44,7 @@ export async function orchestrate({ fontMetricsResolver: FontMetricsResolver; createUrlProxy: ( local: boolean, + // TODO: needs a better shape, should not rely on an implementation ...collectorArgs: ConstructorParameters ) => UrlProxy; defaults: Defaults; @@ -71,19 +67,17 @@ export async function orchestrate({ storage, }); - // TODO: might be optimized? const hashToUrlMap = new Map(); const resolvedMap = new Map(); for (const family of resolvedFamilies) { - // TODO: might be refactored const preloadData: PreloadData = []; let css = ''; + // TODO: rename to something like collectedFonts const fallbackFontData: Array = []; const fallbacks = family.fallbacks ?? defaults.fallbacks ?? []; - // Must be cleaned to less rely on internal structures const urlProxy = createUrlProxy( family.provider === LOCAL_PROVIDER_NAME, hashToUrlMap, diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index 9df1e09ac899..f910a63f979b 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -66,4 +66,11 @@ export type FontFaceMetrics = Pick< 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg' >; -export type GenericFallbackName = (typeof GENERIC_FALLBACK_NAMES)[number]; \ No newline at end of file +export type GenericFallbackName = (typeof GENERIC_FALLBACK_NAMES)[number]; + +export type Defaults = Partial< + Pick< + ResolvedRemoteFontFamily, + 'weights' | 'styles' | 'subsets' | 'fallbacks' | 'optimizedFallbacks' + > +>; From 0a74196a91790c46b68d18e9dce6b6ec0683031f Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 18 Apr 2025 17:04:14 +0200 Subject: [PATCH 38/68] feat: simplify --- .../astro/src/assets/fonts/providers/local.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 6f220b2a78bc..0d1eb8f08301 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -4,24 +4,18 @@ import type { ResolvedLocalFontFamily } from '../types.js'; import { extractFontType } from '../utils.js'; import type { UrlProxy } from '../definitions.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>>; - -type ResolveFontResult = NonNullable>>; - interface Options { family: ResolvedLocalFontFamily; urlProxy: UrlProxy; } -export function resolveLocalFont({ family, urlProxy }: Options): ResolveFontResult { - const fonts: ResolveFontResult['fonts'] = []; +export function resolveLocalFont({ family, urlProxy }: Options): { + fonts: Array; +} { + const fonts: Array = []; for (const variant of family.variants) { - const data: ResolveFontResult['fonts'][number] = { + const data: unifont.FontFaceData = { weight: variant.weight, style: variant.style, src: variant.src.map((source, index) => { From edeb949d5dec828512e1fb6aefcff3839ee4cd14 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 09:06:14 +0200 Subject: [PATCH 39/68] feat: refactor --- .../assets/fonts/implementations/css-renderer.ts | 15 ++++----------- packages/astro/src/assets/fonts/utils.ts | 15 --------------- .../astro/src/assets/fonts/vite-plugin-fonts.ts | 6 +++--- 3 files changed, 7 insertions(+), 29 deletions(-) diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts index 41180b81ead8..dde2218c9bc2 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -39,20 +39,13 @@ export function handleValueWithSpaces(value: string): string { return value; } -export class PrettyCssRenderer implements CssRenderer { - generateFontFace(family: string, properties: Properties): string { - return renderFontFace(withFamily(family, properties), false); - } - generateCssVariable(key: string, values: Array): string { - return renderCssVariable(key, values, false); - } -} +export class MinifiableCssRenderer implements CssRenderer { + constructor(private minify: boolean) {} -export class MinifiedCssRenderer implements CssRenderer { generateFontFace(family: string, properties: Properties): string { - return renderFontFace(withFamily(family, properties), true); + return renderFontFace(withFamily(family, properties), this.minify); } generateCssVariable(key: string, values: Array): string { - return renderCssVariable(key, values, true); + return renderCssVariable(key, values, this.minify); } } diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index aa2b9df4c030..885e3193a37f 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -7,21 +7,6 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js'; import { FONT_TYPES, GENERIC_FALLBACK_NAMES, LOCAL_PROVIDER_NAME } from './constants.js'; import type { FontType, GenericFallbackName, ResolvedFontFamily } from './types.js'; -function unifontFontFaceDataToProperties( - font: Partial, -): Record { - return { - src: font.src ? renderFontSrc(font.src) : undefined, - 'font-display': font.display ?? 'swap', - 'unicode-range': font.unicodeRange?.join(','), - 'font-weight': Array.isArray(font.weight) ? font.weight.join(' ') : font.weight?.toString(), - 'font-style': font.style, - 'font-stretch': font.stretch, - 'font-feature-settings': font.featureSettings, - 'font-variation-settings': font.variationSettings, - }; -} - // Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81 export function renderFontSrc(sources: Exclude[]) { return sources diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index e3a90b154b16..331cac184b67 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -37,7 +37,7 @@ import { RemoteUrlProxyContentResolver, } from './implementations/url-proxy-content-resolver.js'; import { RealDataCollector } from './implementations/data-collector.js'; -import { MinifiedCssRenderer, PrettyCssRenderer } from './implementations/css-renderer.js'; +import { MinifiableCssRenderer } from './implementations/css-renderer.js'; interface Options { settings: AstroSettings; @@ -144,7 +144,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { await initialize({ cacheDir: new URL(CACHE_DIR, settings.config.cacheDir), modResolver: new BuildRemoteFontProviderModResolver(), - cssRenderer: new MinifiedCssRenderer(), + cssRenderer: new MinifiableCssRenderer(true), }); } }, @@ -153,7 +153,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // In dev, we cache fonts data in .astro so it can be easily inspected and cleared cacheDir: new URL(CACHE_DIR, settings.dotAstroDir), modResolver: new DevServerRemoteFontProviderModResolver(server), - cssRenderer: new PrettyCssRenderer(), + cssRenderer: new MinifiableCssRenderer(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 From 07e0e65b6ca9af4193548088e2f6656595b5f880 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 09:20:10 +0200 Subject: [PATCH 40/68] feat: refactor create url proxy --- .../fonts/implementations/data-collector.ts | 36 +++++---------- .../astro/src/assets/fonts/orchestrate.ts | 44 ++++++++++++------- packages/astro/src/assets/fonts/types.ts | 10 +++++ packages/astro/src/assets/fonts/utils.ts | 15 +++++++ .../src/assets/fonts/vite-plugin-fonts.ts | 4 +- 5 files changed, 64 insertions(+), 45 deletions(-) diff --git a/packages/astro/src/assets/fonts/implementations/data-collector.ts b/packages/astro/src/assets/fonts/implementations/data-collector.ts index 06a85a607d24..69925bcde2ca 100644 --- a/packages/astro/src/assets/fonts/implementations/data-collector.ts +++ b/packages/astro/src/assets/fonts/implementations/data-collector.ts @@ -1,15 +1,9 @@ import type { DataCollector } from '../definitions.js'; -import type { CollectedFontForMetrics } from '../logic/optimize-fallbacks.js'; -import type { PreloadData } from '../types.js'; +import type { CreateUrlProxyParams, PreloadData } from '../types.js'; import type * as unifont from 'unifont'; export class RealDataCollector implements DataCollector { - constructor( - private hashToUrlMap: Map, - private preloadData: PreloadData = [], - private fallbackFontData: Array, - private fallbacks: Array, - ) {} + constructor(private params: Omit) {} collect({ originalUrl, @@ -22,26 +16,16 @@ export class RealDataCollector implements DataCollector { preload: PreloadData[number] | null; data: Partial; }): void { - if (!this.hashToUrlMap.has(hash)) { - this.hashToUrlMap.set(hash, originalUrl); + if (!this.params.hasUrl(hash)) { + this.params.saveUrl(hash, originalUrl); if (preload) { - this.preloadData.push(preload); + this.params.savePreload(preload); } } - if ( - this.fallbacks && - this.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 - !this.fallbackFontData.some((f) => JSON.stringify(f.data) === JSON.stringify(data)) - ) { - // If a family has fallbacks, we store the first url we get that may - // be used for the fallback generation - this.fallbackFontData.push({ - hash, - url: originalUrl, - data, - }); - } + this.params.saveFontData({ + hash, + url: originalUrl, + data, + }); } } diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 0d7bee430890..9e2ac172154f 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -1,10 +1,9 @@ import { LOCAL_PROVIDER_NAME } from './constants.js'; import { resolveFamilies } from './logic/resolve-families.js'; import { resolveLocalFont } from './providers/local.js'; -import type { Defaults, FontFamily, PreloadData } from './types.js'; +import type { CreateUrlProxyParams, Defaults, FontFamily, PreloadData } from './types.js'; import * as unifont from 'unifont'; import { pickFontFaceProperty, unifontFontFaceDataToProperties } from './utils.js'; -import type { RealDataCollector } from './implementations/data-collector.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'; @@ -42,11 +41,7 @@ export async function orchestrate({ cssRenderer: CssRenderer; systemFallbacksProvider: SystemFallbacksProvider; fontMetricsResolver: FontMetricsResolver; - createUrlProxy: ( - local: boolean, - // TODO: needs a better shape, should not rely on an implementation - ...collectorArgs: ConstructorParameters - ) => UrlProxy; + createUrlProxy: (params: CreateUrlProxyParams) => UrlProxy; defaults: Defaults; }) { let resolvedFamilies = await resolveFamilies({ @@ -74,17 +69,32 @@ export async function orchestrate({ const preloadData: PreloadData = []; let css = ''; - // TODO: rename to something like collectedFonts - const fallbackFontData: Array = []; + const collectedFonts: Array = []; const fallbacks = family.fallbacks ?? defaults.fallbacks ?? []; - const urlProxy = createUrlProxy( - family.provider === LOCAL_PROVIDER_NAME, - hashToUrlMap, - preloadData, - fallbackFontData, - fallbacks, - ); + 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 duplicate 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; @@ -133,7 +143,7 @@ export async function orchestrate({ const optimizeFallbacksResult = await optimizeFallbacks({ family, fallbacks, - collectedFonts: fallbackFontData, + collectedFonts, enabled: family.optimizedFallbacks ?? defaults.optimizedFallbacks ?? false, systemFallbacksProvider, fontMetricsResolver, diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index f910a63f979b..205c457fac56 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -7,6 +7,7 @@ import type { } from './config.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; @@ -74,3 +75,12 @@ export type Defaults = Partial< 'weights' | 'styles' | 'subsets' | 'fallbacks' | 'optimizedFallbacks' > >; + +export interface CreateUrlProxyParams { + local: boolean; + hasUrl: (hash: string) => boolean; + saveUrl: (hash: string, url: string) => void; + // TODO: improve type + savePreload: (preload: PreloadData[number]) => void; + saveFontData: (collected: CollectedFontForMetrics) => void; +} diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 885e3193a37f..340229c4cea5 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -7,6 +7,21 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js'; import { FONT_TYPES, GENERIC_FALLBACK_NAMES, LOCAL_PROVIDER_NAME } from './constants.js'; import type { FontType, GenericFallbackName, ResolvedFontFamily } from './types.js'; +export function unifontFontFaceDataToProperties( + font: Partial, +): Record { + return { + src: font.src ? renderFontSrc(font.src) : undefined, + 'font-display': font.display ?? 'swap', + 'unicode-range': font.unicodeRange?.join(','), + 'font-weight': Array.isArray(font.weight) ? font.weight.join(' ') : font.weight?.toString(), + 'font-style': font.style, + 'font-stretch': font.stretch, + 'font-feature-settings': font.featureSettings, + 'font-variation-settings': font.variationSettings, + }; +} + // Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81 export function renderFontSrc(sources: Exclude[]) { return sources diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 331cac184b67..d627ea6f2bca 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -119,8 +119,8 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { cssRenderer, systemFallbacksProvider, fontMetricsResolver, - createUrlProxy: (local, ...collectorArgs) => { - const dataCollector = new RealDataCollector(...collectorArgs); + createUrlProxy: ({ local, ...params }) => { + const dataCollector = new RealDataCollector(params); const contentResolver = local ? new LocalUrlProxyContentResolver(errorHandler) : new RemoteUrlProxyContentResolver(); From 5c146ae60e663e2a55caf9a67a65eddcd996b690 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 09:22:36 +0200 Subject: [PATCH 41/68] feat: improve type --- packages/astro/src/assets/fonts/definitions.ts | 4 +++- .../src/assets/fonts/implementations/data-collector.ts | 2 +- packages/astro/src/assets/fonts/orchestrate.ts | 4 ++-- packages/astro/src/assets/fonts/types.ts | 7 +++---- packages/astro/src/assets/fonts/vite-plugin-fonts.ts | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index a9279a3d5c95..2013dded236f 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -4,6 +4,8 @@ import type * as unifont from 'unifont'; import type { FontFaceMetrics, GenericFallbackName } from './types.js'; import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; +// TODO: rework to extract complex types (maybe get rid of classes?) + export interface Hasher { hashString: (input: string) => string; hashObject: (input: Record) => string; @@ -58,7 +60,7 @@ export interface DataCollector { originalUrl: string; hash: string; data: Partial; - preload: PreloadData[number] | null; + preload: PreloadData | null; }) => void; } diff --git a/packages/astro/src/assets/fonts/implementations/data-collector.ts b/packages/astro/src/assets/fonts/implementations/data-collector.ts index 69925bcde2ca..b844cbf66bba 100644 --- a/packages/astro/src/assets/fonts/implementations/data-collector.ts +++ b/packages/astro/src/assets/fonts/implementations/data-collector.ts @@ -13,7 +13,7 @@ export class RealDataCollector implements DataCollector { }: { originalUrl: string; hash: string; - preload: PreloadData[number] | null; + preload: PreloadData | null; data: Partial; }): void { if (!this.params.hasUrl(hash)) { diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 9e2ac172154f..45f92c3fcb0b 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -63,10 +63,10 @@ export async function orchestrate({ }); const hashToUrlMap = new Map(); - const resolvedMap = new Map(); + const resolvedMap = new Map; css: string }>(); for (const family of resolvedFamilies) { - const preloadData: PreloadData = []; + const preloadData: Array = []; let css = ''; const collectedFonts: Array = []; diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index 205c457fac56..a35a4151ba0e 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -51,7 +51,7 @@ export type FontType = (typeof FONT_TYPES)[number]; /** * Preload data is used for links generation inside the component */ -export type PreloadData = Array<{ +export interface PreloadData { /** * Absolute link to a font file, eg. /_astro/fonts/abc.woff */ @@ -60,7 +60,7 @@ export type PreloadData = Array<{ * A font type, eg. woff2, woff, ttf... */ type: FontType; -}>; +} export type FontFaceMetrics = Pick< Font, @@ -80,7 +80,6 @@ export interface CreateUrlProxyParams { local: boolean; hasUrl: (hash: string) => boolean; saveUrl: (hash: string, url: string) => void; - // TODO: improve type - savePreload: (preload: PreloadData[number]) => void; + savePreload: (preload: PreloadData) => void; saveFontData: (collected: CollectedFontForMetrics) => void; } diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index d627ea6f2bca..ed5ab99ff5f2 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -72,7 +72,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // to trailingSlash: never) const baseUrl = removeTrailingForwardSlash(settings.config.base) + URL_PREFIX; - let resolvedMap: Map | null = null; + let resolvedMap: Map; 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 From 51d59ca956feb4edab187bee095976a5dc2b0604 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 09:44:39 +0200 Subject: [PATCH 42/68] feat: use functions instead of classes --- .../astro/src/assets/fonts/definitions.ts | 2 - .../fonts/implementations/css-renderer.ts | 18 ++-- .../fonts/implementations/data-collector.ts | 48 +++++------ .../fonts/implementations/error-handler.ts | 44 +++++----- .../fonts/implementations/font-fetcher.ts | 54 ++++++------ .../implementations/font-metrics-resolver.ts | 85 +++++++++---------- .../assets/fonts/implementations/hasher.ts | 19 ++--- .../local-provider-url-resolver.ts | 14 +-- .../remote-font-provider-mod-resolver.ts | 24 +++--- .../remote-font-provider-resolver.ts | 40 +++++---- .../assets/fonts/implementations/storage.ts | 16 ++-- .../system-fallbacks-provider.ts | 16 ++-- .../url-proxy-content-resolver.ts | 38 +++++---- .../assets/fonts/implementations/url-proxy.ts | 54 ++++++------ .../src/assets/fonts/vite-plugin-fonts.ts | 64 +++++++------- 15 files changed, 264 insertions(+), 272 deletions(-) diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 2013dded236f..266bb9fe8f66 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -4,8 +4,6 @@ import type * as unifont from 'unifont'; import type { FontFaceMetrics, GenericFallbackName } from './types.js'; import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js'; -// TODO: rework to extract complex types (maybe get rid of classes?) - export interface Hasher { hashString: (input: string) => string; hashObject: (input: Record) => string; diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts index dde2218c9bc2..82d68e1ca23f 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -39,13 +39,13 @@ export function handleValueWithSpaces(value: string): string { return value; } -export class MinifiableCssRenderer implements CssRenderer { - constructor(private minify: boolean) {} - - generateFontFace(family: string, properties: Properties): string { - return renderFontFace(withFamily(family, properties), this.minify); - } - generateCssVariable(key: string, values: Array): string { - return renderCssVariable(key, values, this.minify); - } +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 index b844cbf66bba..6c0f3ad040b8 100644 --- a/packages/astro/src/assets/fonts/implementations/data-collector.ts +++ b/packages/astro/src/assets/fonts/implementations/data-collector.ts @@ -1,31 +1,25 @@ import type { DataCollector } from '../definitions.js'; -import type { CreateUrlProxyParams, PreloadData } from '../types.js'; -import type * as unifont from 'unifont'; +import type { CreateUrlProxyParams } from '../types.js'; -export class RealDataCollector implements DataCollector { - constructor(private params: Omit) {} - - collect({ - originalUrl, - hash, - preload, - data, - }: { - originalUrl: string; - hash: string; - preload: PreloadData | null; - data: Partial; - }): void { - if (!this.params.hasUrl(hash)) { - this.params.saveUrl(hash, originalUrl); - if (preload) { - this.params.savePreload(preload); +export function createDataCollector({ + hasUrl, + saveUrl, + savePreload, + saveFontData, +}: Omit): DataCollector { + return { + collect({ originalUrl, hash, preload, data }) { + if (!hasUrl(hash)) { + saveUrl(hash, originalUrl); + if (preload) { + savePreload(preload); + } } - } - this.params.saveFontData({ - hash, - url: originalUrl, - data, - }); - } + 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 index b81ab77643e8..b5034599b3d3 100644 --- a/packages/astro/src/assets/fonts/implementations/error-handler.ts +++ b/packages/astro/src/assets/fonts/implementations/error-handler.ts @@ -1,27 +1,29 @@ import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; import type { ErrorHandler, ErrorHandlerInput } from '../definitions.js'; -export class AstroErrorHandler implements ErrorHandler { - private getProps(input: ErrorHandlerInput): ConstructorParameters[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), - }; - } - input satisfies never; - // Should never happen - return AstroErrorData.UnknownError; +function getProps(input: ErrorHandlerInput): ConstructorParameters[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), + }; } + input satisfies never; + // Should never happen + return AstroErrorData.UnknownError; +} - handle(input: ErrorHandlerInput): Error { - return new AstroError(this.getProps(input), { cause: input.cause }); - } +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 index d1ddd6a5ea53..b5179440f021 100644 --- a/packages/astro/src/assets/fonts/implementations/font-fetcher.ts +++ b/packages/astro/src/assets/fonts/implementations/font-fetcher.ts @@ -4,32 +4,32 @@ import { cache } from '../utils.js'; import { isAbsolute } from 'node:path'; import { readFile } from 'node:fs/promises'; -export class CachedFontFetcher implements FontFetcher { - constructor( - private storage: Storage, - private errorHandler: ErrorHandler, - ) {} - - async fetch(hash: string, url: string): Promise { - return await cache(this.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}`); +export function createCachedFontFetcher({ + storage, + errorHandler, +}: { storage: Storage; errorHandler: ErrorHandler }): 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, + }); } - return Buffer.from(await response.arrayBuffer()); - } catch (cause) { - throw this.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 index 1605edcaae81..825a20adfcb4 100644 --- a/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts @@ -2,7 +2,6 @@ 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'; -import type { CollectedFontForMetrics } from '../logic/optimize-fallbacks.js'; function filterRequiredMetrics({ ascent, @@ -26,53 +25,47 @@ function toPercentage(value: number, fractionDigits = 4) { return `${+percentage.toFixed(fractionDigits)}%`; } -export class RealFontMetricsResolver implements FontMetricsResolver { - private cache: Record = {}; +export function createCapsizeFontMetricsResolver({ + fontFetcher, + cssRenderer, +}: { + fontFetcher: FontFetcher; + cssRenderer: CssRenderer; +}): FontMetricsResolver { + const cache: Record = {}; - constructor( - private fontFetcher: FontFetcher, - private cssRenderer: CssRenderer, - ) {} - - async getMetrics(name: string, { hash, url }: CollectedFontForMetrics): Promise { - this.cache[name] ??= filterRequiredMetrics( - await fromBuffer(await this.fontFetcher.fetch(hash, url)), - ); - return this.cache[name]; - } - - generateFontFace({ - metrics, - fallbackMetrics, - name: fallbackName, - font: fallbackFontName, - properties, - }: { - metrics: FontFaceMetrics; - fallbackMetrics: FontFaceMetrics; - name: string; - font: string; - properties: Record; - }): string { - // Calculate size adjust - const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm; - const fallbackFontXAvgRatio = fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm; - const sizeAdjust = preferredFontXAvgRatio / fallbackFontXAvgRatio; + return { + async getMetrics(name, { hash, url }) { + cache[name] ??= filterRequiredMetrics(await fromBuffer(await fontFetcher.fetch(hash, url))); + return cache[name]; + }, + 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; + 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; + // Calculate metric overrides for preferred font + const ascentOverride = metrics.ascent / adjustedEmSquare; + const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare; + const lineGapOverride = metrics.lineGap / adjustedEmSquare; - return this.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, - }); - } + 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/hasher.ts b/packages/astro/src/assets/fonts/implementations/hasher.ts index 129a941e98e1..2772284c4f79 100644 --- a/packages/astro/src/assets/fonts/implementations/hasher.ts +++ b/packages/astro/src/assets/fonts/implementations/hasher.ts @@ -2,15 +2,12 @@ import xxhash from 'xxhash-wasm'; import type { Hasher } from '../definitions.js'; import { sortObjectByKey } from '../utils.js'; -export class XxHasher implements Hasher { - private constructor(public hashString: Hasher['hashString']) {} - - static async create() { - const { h64ToString } = await xxhash(); - return new XxHasher(h64ToString); - } - - hashObject(input: Record): string { - return this.hashString(JSON.stringify(sortObjectByKey(input))); - } +export async function createXxHasher(): Promise { + 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 index 3b0d9d1b2e6d..8661fd77653f 100644 --- a/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts @@ -2,10 +2,12 @@ import type { LocalProviderUrlResolver } from '../definitions.js'; import { resolveEntrypoint } from '../utils.js'; import { fileURLToPath } from 'node:url'; -export class RequireLocalProviderUrlResolver implements LocalProviderUrlResolver { - constructor(private root: URL) {} - - resolve(input: string): string { - return fileURLToPath(resolveEntrypoint(this.root, input)); - } +export function createRequireLocalProviderUrlResolver({ + root, +}: { root: URL }): LocalProviderUrlResolver { + return { + resolve(input) { + return fileURLToPath(resolveEntrypoint(root, input)); + }, + }; } 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 index 16c220ebb38f..7dc3df1e7fdd 100644 --- 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 @@ -1,16 +1,20 @@ import type { ViteDevServer } from 'vite'; import type { RemoteFontProviderModResolver } from '../definitions.js'; -export class BuildRemoteFontProviderModResolver implements RemoteFontProviderModResolver { - async resolve(id: string): Promise { - return await import(id); - } +export function createBuildRemoteFontProviderModResolver(): RemoteFontProviderModResolver { + return { + resolve(id) { + return import(id); + }, + }; } -export class DevServerRemoteFontProviderModResolver implements RemoteFontProviderModResolver { - constructor(private server: ViteDevServer) {} - - async resolve(id: string): Promise { - return this.server.ssrLoadModule(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 index df7045e38ce5..e9b20d1ad21c 100644 --- a/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts @@ -3,7 +3,7 @@ import type { RemoteFontProviderModResolver, RemoteFontProviderResolver, } from '../definitions.js'; -import type { AstroFontProvider, ResolvedFontProvider } from '../types.js'; +import type { ResolvedFontProvider } from '../types.js'; import { resolveEntrypoint } from '../utils.js'; export function validateMod({ @@ -38,21 +38,25 @@ export function validateMod({ } } -export class RealRemoteFontProviderResolver implements RemoteFontProviderResolver { - constructor( - private root: URL, - private modResolver: RemoteFontProviderModResolver, - private errorHandler: ErrorHandler, - ) {} - - async resolve({ entrypoint, config }: AstroFontProvider): Promise { - const id = resolveEntrypoint(this.root, entrypoint.toString()).href; - const mod = await this.modResolver.resolve(id); - const { provider } = validateMod({ - mod, - entrypoint: id, - errorHandler: this.errorHandler, - }); - return { config, provider }; - } +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 index fafec83602ec..b9e26ebb0f76 100644 --- a/packages/astro/src/assets/fonts/implementations/storage.ts +++ b/packages/astro/src/assets/fonts/implementations/storage.ts @@ -2,13 +2,11 @@ import { fileURLToPath } from 'node:url'; import { createStorage, type Storage } from 'unstorage'; import fsLiteDriver from 'unstorage/drivers/fs-lite'; -export class FsStorage { - static create(base: URL): Storage { - return createStorage({ - // Types are weirly exported - driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({ - base: fileURLToPath(base), - }), - }); - } +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 index 2533149c4df5..e6812a43f0cb 100644 --- a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts +++ b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts @@ -67,11 +67,13 @@ export const DEFAULT_FALLBACKS = { 'ui-monospace': ['Courier New'], } satisfies Partial>>; -export class RealSystemFallbacksProvider implements SystemFallbacksProvider { - getLocalFonts(fallback: GenericFallbackName): Array | null { - return DEFAULT_FALLBACKS[fallback as keyof typeof DEFAULT_FALLBACKS] ?? null; - } - getMetricsForLocalFont(family: string): FontFaceMetrics { - return SYSTEM_METRICS[family as 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 index f53e467c1d18..416b18747181 100644 --- a/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts @@ -1,24 +1,26 @@ import { readFileSync } from 'node:fs'; import type { ErrorHandler, UrlProxyContentResolver } from '../definitions.js'; -export class LocalUrlProxyContentResolver implements UrlProxyContentResolver { - constructor(private errorHandler: ErrorHandler) {} - - resolve(url: string): string { - try { - return url + readFileSync(url, 'utf-8'); - } catch (cause) { - throw this.errorHandler.handle({ - type: 'unknown-fs-error', - data: {}, - cause, - }); - } - } +export function createLocalUrlProxyContentResolver({ + errorHandler, +}: { errorHandler: ErrorHandler }): UrlProxyContentResolver { + return { + resolve(url) { + try { + return url + readFileSync(url, 'utf-8'); + } catch (cause) { + throw errorHandler.handle({ + type: 'unknown-fs-error', + data: {}, + cause, + }); + } + }, + }; } -export class RemoteUrlProxyContentResolver implements UrlProxyContentResolver { - resolve(url: string): string { - return url; - } +export function createRemoteUrlProxyContentResolver(): UrlProxyContentResolver { + return { + 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 index 37c13f8ff473..01485d7e11c9 100644 --- a/packages/astro/src/assets/fonts/implementations/url-proxy.ts +++ b/packages/astro/src/assets/fonts/implementations/url-proxy.ts @@ -1,35 +1,31 @@ -import type * as unifont from 'unifont'; import type { DataCollector, Hasher, UrlProxy, UrlProxyContentResolver } from '../definitions.js'; import { extractFontType } from '../utils.js'; -export class RealUrlProxy implements UrlProxy { - constructor( - private base: string, - private contentResolver: UrlProxyContentResolver, - private hasher: Hasher, - private dataCollector: DataCollector, - ) {} +export function createUrlProxy({ + base, + contentResolver, + hasher, + dataCollector, +}: { + base: string; + contentResolver: UrlProxyContentResolver; + hasher: Hasher; + dataCollector: DataCollector; +}): UrlProxy { + return { + proxy({ url: originalUrl, data, collectPreload }) { + const type = extractFontType(originalUrl); + const hash = `${hasher.hashString(contentResolver.resolve(originalUrl))}.${type}`; + const url = base + hash; - proxy({ - url: originalUrl, - data, - collectPreload, - }: { - url: string; - data: Partial; - collectPreload: boolean; - }): string { - const type = extractFontType(originalUrl); - const hash = `${this.hasher.hashString(this.contentResolver.resolve(originalUrl))}.${type}`; - const url = this.base + hash; + dataCollector.collect({ + originalUrl, + hash, + preload: collectPreload ? { url, type } : null, + data, + }); - this.dataCollector.collect({ - originalUrl, - hash, - preload: collectPreload ? { url, type } : null, - data, - }); - - return url; - } + return url; + }, + }; } diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index ed5ab99ff5f2..277d0fc2bc56 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -18,26 +18,26 @@ import { import type { PreloadData } from './types.js'; import { extractFontType } from './utils.js'; import { orchestrate } from './orchestrate.js'; -import { XxHasher } from './implementations/hasher.js'; -import { AstroErrorHandler } from './implementations/error-handler.js'; +import { createXxHasher } from './implementations/hasher.js'; +import { createAstroErrorHandler } from './implementations/error-handler.js'; import type { CssRenderer, FontFetcher, RemoteFontProviderModResolver } from './definitions.js'; import { - BuildRemoteFontProviderModResolver, - DevServerRemoteFontProviderModResolver, + createBuildRemoteFontProviderModResolver, + createDevServerRemoteFontProviderModResolver, } from './implementations/remote-font-provider-mod-resolver.js'; -import { RealRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js'; -import { RequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js'; -import { FsStorage } from './implementations/storage.js'; -import { RealSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js'; -import { CachedFontFetcher } from './implementations/font-fetcher.js'; -import { RealFontMetricsResolver } from './implementations/font-metrics-resolver.js'; -import { RealUrlProxy } from './implementations/url-proxy.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 { - LocalUrlProxyContentResolver, - RemoteUrlProxyContentResolver, + createLocalUrlProxyContentResolver, + createRemoteUrlProxyContentResolver, } from './implementations/url-proxy-content-resolver.js'; -import { RealDataCollector } from './implementations/data-collector.js'; -import { MinifiableCssRenderer } from './implementations/css-renderer.js'; +import { createDataCollector } from './implementations/data-collector.js'; +import { createMinifiableCssRenderer } from './implementations/css-renderer.js'; interface Options { settings: AstroSettings; @@ -97,18 +97,18 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { }) { // Dependencies const { root } = settings.config; - const hasher = await XxHasher.create(); - const errorHandler = new AstroErrorHandler(); - const remoteFontProviderResolver = new RealRemoteFontProviderResolver( + const hasher = await createXxHasher(); + const errorHandler = createAstroErrorHandler(); + const remoteFontProviderResolver = createRemoteFontProviderResolver({ root, modResolver, errorHandler, - ); - const localProviderUrlResolver = new RequireLocalProviderUrlResolver(root); - const storage = FsStorage.create(cacheDir); - const systemFallbacksProvider = new RealSystemFallbacksProvider(); - fontFetcher = new CachedFontFetcher(storage, errorHandler); - const fontMetricsResolver = new RealFontMetricsResolver(fontFetcher, cssRenderer); + }); + const localProviderUrlResolver = createRequireLocalProviderUrlResolver({ root }); + const storage = createFsStorage({ base: cacheDir }); + const systemFallbacksProvider = createSystemFallbacksProvider(); + fontFetcher = createCachedFontFetcher({ storage, errorHandler }); + const fontMetricsResolver = createCapsizeFontMetricsResolver({ fontFetcher, cssRenderer }); const res = await orchestrate({ families: settings.config.experimental.fonts!, @@ -120,11 +120,11 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { systemFallbacksProvider, fontMetricsResolver, createUrlProxy: ({ local, ...params }) => { - const dataCollector = new RealDataCollector(params); + const dataCollector = createDataCollector(params); const contentResolver = local - ? new LocalUrlProxyContentResolver(errorHandler) - : new RemoteUrlProxyContentResolver(); - return new RealUrlProxy(baseUrl, contentResolver, hasher, dataCollector); + ? createLocalUrlProxyContentResolver({ errorHandler }) + : createRemoteUrlProxyContentResolver(); + return createUrlProxy({ base: baseUrl, contentResolver, hasher, dataCollector }); }, defaults: DEFAULTS, }); @@ -143,8 +143,8 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { if (isBuild) { await initialize({ cacheDir: new URL(CACHE_DIR, settings.config.cacheDir), - modResolver: new BuildRemoteFontProviderModResolver(), - cssRenderer: new MinifiableCssRenderer(true), + modResolver: createBuildRemoteFontProviderModResolver(), + cssRenderer: createMinifiableCssRenderer({ minify: true }), }); } }, @@ -152,8 +152,8 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { await initialize({ // In dev, we cache fonts data in .astro so it can be easily inspected and cleared cacheDir: new URL(CACHE_DIR, settings.dotAstroDir), - modResolver: new DevServerRemoteFontProviderModResolver(server), - cssRenderer: new MinifiableCssRenderer(false), + 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 From 837cd9444d13d25b6031c813add709168561f39d Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 10:00:18 +0200 Subject: [PATCH 43/68] chore: comment --- .../astro/src/assets/fonts/orchestrate.ts | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 45f92c3fcb0b..ba9ab1890cb1 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -20,7 +20,24 @@ import type { Storage } from 'unstorage'; // TODO: comment/document everything everywhere -// TODO: logs everywhere! +/** + * 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, @@ -62,16 +79,30 @@ export async function orchestrate({ storage, }); + /** + * Holds associations of hash and original font file URLs, so they can be + * downloaded whenever the hash is requested. + */ const hashToUrlMap = new Map(); + /** + * Holds associations of CSS variables and preloadData/css to be passed to the virtual module. + */ const resolvedMap = new Map; css: string }>(); for (const family of resolvedFamilies) { const preloadData: Array = []; let css = ''; + /** + * Holds a list of font files to be used for optimized fallbacks generation + */ const collectedFonts: Array = []; 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), @@ -85,12 +116,12 @@ export async function orchestrate({ 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 + // 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 + // be used for the fallback generation. collectedFonts.push(collected); } }, @@ -103,6 +134,7 @@ export async function orchestrate({ family, urlProxy, }); + // URLs are already proxied at this point so no further processing is required fonts = result.fonts; } else { const result = await resolveFont( @@ -114,11 +146,12 @@ export async function orchestrate({ 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) + // 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 }); } @@ -129,7 +162,8 @@ export async function orchestrate({ src: data.src, weight: data.weight, style: data.style, - // User settings override the generated font settings + // 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 }), @@ -153,7 +187,7 @@ export async function orchestrate({ css += optimizeFallbacksResult.css; cssVarValues.push(...optimizeFallbacksResult.fallbacks); } else { - // Nothing to optimize, pass as is + // If there are no optimized fallbacks, we pass the provided fallbacks as is. cssVarValues.push(...fallbacks); } From c6e22de5d83853debd7ad7b92e04b76b91a065ca Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 10:16:29 +0200 Subject: [PATCH 44/68] feat: refactor --- .../astro/src/assets/fonts/definitions.ts | 15 +++++-- .../fonts/implementations/css-renderer.ts | 8 ++-- .../fonts/implementations/error-handler.ts | 5 +++ .../implementations/font-type-extractor.ts | 21 ++++++++++ .../assets/fonts/implementations/url-proxy.ts | 13 ++++-- .../astro/src/assets/fonts/orchestrate.ts | 6 ++- .../astro/src/assets/fonts/providers/local.ts | 8 ++-- .../astro/src/assets/fonts/providers/utils.ts | 2 + packages/astro/src/assets/fonts/utils.ts | 41 ++++++++----------- .../src/assets/fonts/vite-plugin-fonts.ts | 25 ++++++++--- 10 files changed, 99 insertions(+), 45 deletions(-) create mode 100644 packages/astro/src/assets/fonts/implementations/font-type-extractor.ts diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 266bb9fe8f66..4ce2decb9121 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ -import type { AstroFontProvider, PreloadData, ResolvedFontProvider } from './types.js'; +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'; @@ -35,7 +35,8 @@ export type ErrorHandlerInput = } > | SingleErrorInput<'unknown-fs-error', {}> - | SingleErrorInput<'cannot-fetch-font-file', { url: string }>; + | SingleErrorInput<'cannot-fetch-font-file', { url: string }> + | SingleErrorInput<'cannot-extract-font-type', { url: string }>; export interface ErrorHandler { handle: (input: ErrorHandlerInput) => Error; @@ -62,8 +63,10 @@ export interface DataCollector { }) => void; } +export type CssProperties = Record; + export interface CssRenderer { - generateFontFace: (family: string, properties: Record) => string; + generateFontFace: (family: string, properties: CssProperties) => string; generateCssVariable: (key: string, values: Array) => string; } @@ -74,7 +77,7 @@ export interface FontMetricsResolver { fallbackMetrics: FontFaceMetrics; name: string; font: string; - properties: Record; + properties: CssProperties; }) => string; } @@ -86,3 +89,7 @@ export interface SystemFallbacksProvider { export interface FontFetcher { fetch: (hash: string, url: string) => Promise; } + +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 index 82d68e1ca23f..fcb97fdafbc2 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -1,8 +1,6 @@ -import type { CssRenderer } from '../definitions.js'; +import type { CssProperties, CssRenderer } from '../definitions.js'; -type Properties = Record; - -export function renderFontFace(properties: Properties, minify: boolean): string { +export function renderFontFace(properties: CssProperties, minify: boolean): string { // Line return const lr = minify ? '' : `\n`; // Space @@ -23,7 +21,7 @@ export function renderCssVariable(key: string, values: Array, minify: bo return `:root${sp}{${lr}${sp}${sp}${key}:${sp}${values.join(`,${sp}`)};${lr}}${lr}`; } -export function withFamily(family: string, properties: Properties): Properties { +export function withFamily(family: string, properties: CssProperties): CssProperties { return { 'font-family': handleValueWithSpaces(family), ...properties, diff --git a/packages/astro/src/assets/fonts/implementations/error-handler.ts b/packages/astro/src/assets/fonts/implementations/error-handler.ts index b5034599b3d3..e67ca00d760a 100644 --- a/packages/astro/src/assets/fonts/implementations/error-handler.ts +++ b/packages/astro/src/assets/fonts/implementations/error-handler.ts @@ -14,6 +14,11 @@ function getProps(input: ErrorHandlerInput): ConstructorParameters UrlProxy; defaults: Defaults; }) { @@ -133,6 +136,7 @@ export async function orchestrate({ const result = resolveLocalFont({ family, urlProxy, + fontTypeExtractor, }); // URLs are already proxied at this point so no further processing is required fonts = result.fonts; @@ -163,7 +167,7 @@ export async function orchestrate({ 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. + // 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 }), diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 0d1eb8f08301..47bd5ada7c91 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -1,15 +1,15 @@ import type * as unifont from 'unifont'; import { FONT_FORMAT_MAP } from '../constants.js'; import type { ResolvedLocalFontFamily } from '../types.js'; -import { extractFontType } from '../utils.js'; -import type { UrlProxy } from '../definitions.js'; +import type { FontTypeExtractor, UrlProxy } from '../definitions.js'; interface Options { family: ResolvedLocalFontFamily; urlProxy: UrlProxy; + fontTypeExtractor: FontTypeExtractor; } -export function resolveLocalFont({ family, urlProxy }: Options): { +export function resolveLocalFont({ family, urlProxy, fontTypeExtractor }: Options): { fonts: Array; } { const fonts: Array = []; @@ -30,7 +30,7 @@ export function resolveLocalFont({ family, urlProxy }: Options): { style: variant.style, }, }), - format: FONT_FORMAT_MAP[extractFontType(source.url)], + format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)], tech: source.tech, }; }), diff --git a/packages/astro/src/assets/fonts/providers/utils.ts b/packages/astro/src/assets/fonts/providers/utils.ts index 7c0bf958362c..da846e4919fb 100644 --- a/packages/astro/src/assets/fonts/providers/utils.ts +++ b/packages/astro/src/assets/fonts/providers/utils.ts @@ -17,6 +17,8 @@ export function validateMod(mod: any, entrypoint: string): Pick, -): Record { +): CssProperties { return { src: font.src ? renderFontSrc(font.src) : undefined, 'font-display': font.display ?? 'swap', @@ -22,8 +26,13 @@ export function unifontFontFaceDataToProperties( }; } -// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81 -export function renderFontSrc(sources: Exclude[]) { +/** + * 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[], +): string { return sources .map((src) => { if ('url' in src) { @@ -43,27 +52,13 @@ export function renderFontSrc(sources: Exclude>).includes(str); } diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 277d0fc2bc56..1b0ab5ecf0ae 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -16,11 +16,15 @@ import { VIRTUAL_MODULE_ID, } from './constants.js'; import type { PreloadData } from './types.js'; -import { extractFontType } from './utils.js'; import { orchestrate } from './orchestrate.js'; import { createXxHasher } from './implementations/hasher.js'; import { createAstroErrorHandler } from './implementations/error-handler.js'; -import type { CssRenderer, FontFetcher, RemoteFontProviderModResolver } from './definitions.js'; +import type { + CssRenderer, + FontFetcher, + FontTypeExtractor, + RemoteFontProviderModResolver, +} from './definitions.js'; import { createBuildRemoteFontProviderModResolver, createDevServerRemoteFontProviderModResolver, @@ -38,6 +42,7 @@ import { } 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'; interface Options { settings: AstroSettings; @@ -79,6 +84,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { let hashToUrlMap: Map | null = null; let isBuild: boolean; let fontFetcher: FontFetcher | null = null; + let fontTypeExtractor: FontTypeExtractor | null = null; const cleanup = () => { resolvedMap = null; @@ -95,8 +101,9 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { modResolver: RemoteFontProviderModResolver; cssRenderer: CssRenderer; }) { - // Dependencies 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({ @@ -109,6 +116,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { const systemFallbacksProvider = createSystemFallbacksProvider(); fontFetcher = createCachedFontFetcher({ storage, errorHandler }); const fontMetricsResolver = createCapsizeFontMetricsResolver({ fontFetcher, cssRenderer }); + fontTypeExtractor = createFontTypeExtractor({ errorHandler }); const res = await orchestrate({ families: settings.config.experimental.fonts!, @@ -119,12 +127,19 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { cssRenderer, systemFallbacksProvider, fontMetricsResolver, + fontTypeExtractor, createUrlProxy: ({ local, ...params }) => { const dataCollector = createDataCollector(params); const contentResolver = local ? createLocalUrlProxyContentResolver({ errorHandler }) : createRemoteUrlProxyContentResolver(); - return createUrlProxy({ base: baseUrl, contentResolver, hasher, dataCollector }); + return createUrlProxy({ + base: baseUrl, + contentResolver, + hasher, + dataCollector, + fontTypeExtractor: fontTypeExtractor!, + }); }, defaults: DEFAULTS, }); @@ -198,7 +213,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { 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) { From 2f873265cf167b3a30b4081c13af25ef897099a1 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 10:18:25 +0200 Subject: [PATCH 45/68] chore: clean --- .../astro/src/assets/fonts/providers/utils.ts | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 packages/astro/src/assets/fonts/providers/utils.ts 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 da846e4919fb..000000000000 --- a/packages/astro/src/assets/fonts/providers/utils.ts +++ /dev/null @@ -1,49 +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 { - // 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) { - // No need to make it part of the errorHandler because it's Astro specific - // TODO: should validateMod be run no matter what? - throw new AstroError( - { - ...AstroErrorData.CannotLoadFontProvider, - message: AstroErrorData.CannotLoadFontProvider.message(entrypoint), - }, - { cause }, - ); - } -} - -export type ResolveMod = (id: string) => Promise; - -export interface ResolveProviderOptions { - root: URL; - provider: AstroFontProvider; - resolveMod: ResolveMod; -} - -export async function resolveProvider({ - root, - provider: { entrypoint, config }, - resolveMod, -}: ResolveProviderOptions): Promise { - const id = resolveEntrypoint(root, entrypoint.toString()).href; - const mod = await resolveMod(id); - const { provider } = validateMod(mod, id); - return { config, provider }; -} From 3d92628a82c8cc90852c89da29de7ae7c758ad49 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 10:23:09 +0200 Subject: [PATCH 46/68] feat: refactor --- .../astro/src/assets/fonts/providers/local.ts | 56 ++++++++----------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 47bd5ada7c91..c67a21d52990 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -12,39 +12,31 @@ interface Options { export function resolveLocalFont({ family, urlProxy, fontTypeExtractor }: Options): { fonts: Array; } { - const fonts: Array = []; - - for (const variant of family.variants) { - const data: unifont.FontFaceData = { + return { + fonts: family.variants.map((variant) => ({ weight: variant.weight, style: variant.style, - src: variant.src.map((source, index) => { - return { - originalURL: source.url, - url: urlProxy.proxy({ - url: source.url, - // TODO: explain - collectPreload: index === 0, - data: { - weight: variant.weight, - style: variant.style, - }, - }), - format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)], - tech: source.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, + })), }; } From 957215ce95bf0005eb87b69726b2fa3fa3ea81f8 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 10:39:24 +0200 Subject: [PATCH 47/68] chore: comment --- .../fonts/implementations/css-renderer.ts | 1 + .../fonts/implementations/error-handler.ts | 2 +- .../implementations/font-metrics-resolver.ts | 4 ++- .../local-provider-url-resolver.ts | 2 ++ .../url-proxy-content-resolver.ts | 4 +++ .../fonts/logic/extract-unifont-providers.ts | 1 + .../assets/fonts/logic/optimize-fallbacks.ts | 1 + .../assets/fonts/logic/resolve-families.ts | 28 +++++++++++-------- .../astro/src/assets/fonts/orchestrate.ts | 2 -- packages/astro/src/assets/fonts/utils.ts | 2 -- 10 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts index fcb97fdafbc2..e29decd2900b 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -30,6 +30,7 @@ export function withFamily(family: string, properties: CssProperties): CssProper 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); diff --git a/packages/astro/src/assets/fonts/implementations/error-handler.ts b/packages/astro/src/assets/fonts/implementations/error-handler.ts index e67ca00d760a..5b911a7c0959 100644 --- a/packages/astro/src/assets/fonts/implementations/error-handler.ts +++ b/packages/astro/src/assets/fonts/implementations/error-handler.ts @@ -21,7 +21,7 @@ function getProps(input: ErrorHandlerInput): ConstructorParameters url, }; } diff --git a/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts b/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts index 6be81a80fcda..07759920d4fa 100644 --- a/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts +++ b/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts @@ -17,6 +17,7 @@ export function extractUnifontProviders({ const providers: Array = []; for (const { provider } of families) { + // The local provider logic happens outside of unifont if (provider === LOCAL_PROVIDER_NAME) { continue; } diff --git a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts index 911ee4238094..192116944f22 100644 --- a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts +++ b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts @@ -64,6 +64,7 @@ export async function optimizeFallbacks({ 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), diff --git a/packages/astro/src/assets/fonts/logic/resolve-families.ts b/packages/astro/src/assets/fonts/logic/resolve-families.ts index b6342a36e9d8..1ee7bdb9a2ed 100644 --- a/packages/astro/src/assets/fonts/logic/resolve-families.ts +++ b/packages/astro/src/assets/fonts/logic/resolve-families.ts @@ -23,6 +23,7 @@ function resolveVariants({ ...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; @@ -34,6 +35,9 @@ function resolveVariants({ })); } +/** + * Dedupes properties if applicable and resolves entrypoints. + */ export async function resolveFamily({ family, hasher, @@ -45,7 +49,10 @@ export async function resolveFamily({ remoteFontProviderResolver: RemoteFontProviderResolver; localProviderUrlResolver: LocalProviderUrlResolver; }): Promise { + // 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) { @@ -62,32 +69,31 @@ export async function resolveFamily({ ...family, name, nameWithHash, - // TODO: this will only be Astro specific eventually - provider: await remoteFontProviderResolver.resolve(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, + // 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 } & Omit[0], 'family'>): Promise< Array > { - const resolvedFamilies: Array = []; - - for (const family of families) { - resolvedFamilies.push( - await resolveFamily({ + return await Promise.all( + families.map((family) => + resolveFamily({ family, ...dependencies, }), - ); - } - - return resolvedFamilies; + ), + ); } diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 5159fd22ebd3..8b47802d57da 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -19,8 +19,6 @@ import type { } from './definitions.js'; import type { Storage } from 'unstorage'; -// TODO: comment/document everything everywhere - /** * Manages how fonts are resolved: * diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 29c66363b0e2..cf69b8969af7 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -6,8 +6,6 @@ import { FONT_TYPES, GENERIC_FALLBACK_NAMES, LOCAL_PROVIDER_NAME } from './const import type { FontType, GenericFallbackName, ResolvedFontFamily } from './types.js'; import type { CssProperties } from './definitions.js'; -// TODO: check for errors - /** * Turns unifont font face data into generic CSS properties, to be consumed by the CSS renderer. */ From 3da9498805361fe544bdb8962da617419b21d561 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 11:01:21 +0200 Subject: [PATCH 48/68] feat: work on test --- .../fonts/implementations/css-renderer.ts | 2 +- .../assets/fonts/implementations.test.js | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/astro/test/units/assets/fonts/implementations.test.js diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts index e29decd2900b..6ed0a860a24a 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -18,7 +18,7 @@ export function renderCssVariable(key: string, values: Array, minify: bo // Space const sp = minify ? '' : ' '; - return `:root${sp}{${lr}${sp}${sp}${key}:${sp}${values.join(`,${sp}`)};${lr}}${lr}`; + return `:root${sp}{${lr}${sp}${sp}${key}:${sp}${values.map((v) => handleValueWithSpaces(v)).join(`,${sp}`)};${lr}}${lr}`; } export function withFamily(family: string, properties: CssProperties): CssProperties { 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 000000000000..1404dc0c37f3 --- /dev/null +++ b/packages/astro/test/units/assets/fonts/implementations.test.js @@ -0,0 +1,51 @@ +// @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'; + +describe('astro 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"'); + }); + }); + + // TODO: cover more implementations +}); From bd10d34fdcaf05b88c685a9c5e7fb8d089eaab18 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 11:10:19 +0200 Subject: [PATCH 49/68] feat: work on test --- .../assets/fonts/implementations.test.js | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/astro/test/units/assets/fonts/implementations.test.js b/packages/astro/test/units/assets/fonts/implementations.test.js index 1404dc0c37f3..b0e1bf6e4c18 100644 --- a/packages/astro/test/units/assets/fonts/implementations.test.js +++ b/packages/astro/test/units/assets/fonts/implementations.test.js @@ -7,6 +7,7 @@ import { renderFontFace, withFamily, } from '../../../../dist/assets/fonts/implementations/css-renderer.js'; +import { createDataCollector } from '../../../../dist/assets/fonts/implementations/data-collector.js'; describe('astro fonts implementations', () => { describe('createMinifiableCssRenderer()', () => { @@ -47,5 +48,50 @@ describe('astro fonts implementations', () => { }); }); + it('createDataCollector()', () => { + /** @type {Map} */ + const map = new Map(); + /** @type {Array} */ + const preloadData = []; + /** @type {Array} */ + 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: {} }, + ]); + }); + // TODO: cover more implementations }); From ed2c282d95b0d80a37b30ee4505046637b193a71 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 11:14:06 +0200 Subject: [PATCH 50/68] feat: work on test --- .../assets/fonts/implementations.test.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/astro/test/units/assets/fonts/implementations.test.js b/packages/astro/test/units/assets/fonts/implementations.test.js index b0e1bf6e4c18..e6995e0c6ab5 100644 --- a/packages/astro/test/units/assets/fonts/implementations.test.js +++ b/packages/astro/test/units/assets/fonts/implementations.test.js @@ -8,6 +8,7 @@ import { 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'; describe('astro fonts implementations', () => { describe('createMinifiableCssRenderer()', () => { @@ -93,5 +94,39 @@ describe('astro fonts implementations', () => { ]); }); + 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', + ); + }); + // TODO: cover more implementations }); From 9517bc313676f2f1ec330daaf9f0b17a9f9a1edd Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 12:10:33 +0200 Subject: [PATCH 51/68] feat: work on test --- .../fonts/implementations/font-fetcher.ts | 10 +- .../src/assets/fonts/vite-plugin-fonts.ts | 3 +- .../assets/fonts/implementations.test.js | 123 ++++++++++++++++++ .../astro/test/units/assets/fonts/utils.js | 49 +++++++ 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 packages/astro/test/units/assets/fonts/utils.js diff --git a/packages/astro/src/assets/fonts/implementations/font-fetcher.ts b/packages/astro/src/assets/fonts/implementations/font-fetcher.ts index b5179440f021..c47c87fb686b 100644 --- a/packages/astro/src/assets/fonts/implementations/font-fetcher.ts +++ b/packages/astro/src/assets/fonts/implementations/font-fetcher.ts @@ -2,12 +2,18 @@ import type { Storage } from 'unstorage'; import type { ErrorHandler, FontFetcher } from '../definitions.js'; import { cache } from '../utils.js'; import { isAbsolute } from 'node:path'; -import { readFile } from 'node:fs/promises'; export function createCachedFontFetcher({ storage, errorHandler, -}: { storage: Storage; errorHandler: ErrorHandler }): FontFetcher { + fetch, + readFile, +}: { + storage: Storage; + errorHandler: ErrorHandler; + fetch: (url: string) => Promise; + readFile: (url: string) => Promise; +}): FontFetcher { return { async fetch(hash, url) { return await cache(storage, hash, async () => { diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 1b0ab5ecf0ae..47c1c0ea93da 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -43,6 +43,7 @@ import { 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'; interface Options { settings: AstroSettings; @@ -114,7 +115,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { const localProviderUrlResolver = createRequireLocalProviderUrlResolver({ root }); const storage = createFsStorage({ base: cacheDir }); const systemFallbacksProvider = createSystemFallbacksProvider(); - fontFetcher = createCachedFontFetcher({ storage, errorHandler }); + fontFetcher = createCachedFontFetcher({ storage, errorHandler, fetch, readFile }); const fontMetricsResolver = createCapsizeFontMetricsResolver({ fontFetcher, cssRenderer }); fontTypeExtractor = createFontTypeExtractor({ errorHandler }); diff --git a/packages/astro/test/units/assets/fonts/implementations.test.js b/packages/astro/test/units/assets/fonts/implementations.test.js index e6995e0c6ab5..0c92ddd70cce 100644 --- a/packages/astro/test/units/assets/fonts/implementations.test.js +++ b/packages/astro/test/units/assets/fonts/implementations.test.js @@ -9,6 +9,8 @@ import { } 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 { createSpyStorage, simpleErrorHandler } from './utils.js'; describe('astro fonts implementations', () => { describe('createMinifiableCssRenderer()', () => { @@ -128,5 +130,126 @@ describe('astro fonts implementations', () => { ); }); + describe('createCachedFontFetcher()', () => { + /** + * + * @param {{ ok: boolean }} param0 + */ + function createReadFileMock({ ok }) { + /** @type {Array} */ + const filesUrls = []; + return { + filesUrls, + /** @type {(url: string) => Promise} */ + readFile: async (url) => { + filesUrls.push(url); + if (!ok) { + throw 'fs error'; + } + return Buffer.from(''); + }, + }; + } + + /** + * + * @param {{ ok: boolean }} param0 + */ + function createFetchMock({ ok }) { + /** @type {Array} */ + const fetchUrls = []; + return { + fetchUrls, + /** @type {(url: string) => Promise} */ + 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: cover more implementations }); 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 000000000000..ba5fc67560a3 --- /dev/null +++ b/packages/astro/test/units/assets/fonts/utils.js @@ -0,0 +1,49 @@ +// @ts-check + +export function createSpyStorage() { + /** @type {Map} */ + const store = new Map(); + /** @type {import('unstorage').Storage} */ + // @ts-expect-error + const storage = { + /** + * @param {string} key + * @returns {Promise} + */ + getItem: async (key) => { + return store.get(key) ?? null; + }, + /** + * @param {string} key + * @returns {Promise} + */ + getItemRaw: async (key) => { + return store.get(key) ?? null; + }, + /** + * @param {string} key + * @param {any} value + * @returns {Promise} + */ + setItemRaw: async (key, value) => { + store.set(key, value); + }, + /** + * @param {string} key + * @param {any} value + * @returns {Promise} + */ + 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 }); + }, +}; From 3b2af11ff8a109e1d59ab422e15784a5319799ad Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 15:35:42 +0200 Subject: [PATCH 52/68] feat: work on test --- .../assets/fonts/implementations.test.js | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/astro/test/units/assets/fonts/implementations.test.js b/packages/astro/test/units/assets/fonts/implementations.test.js index 0c92ddd70cce..16ada6cd4805 100644 --- a/packages/astro/test/units/assets/fonts/implementations.test.js +++ b/packages/astro/test/units/assets/fonts/implementations.test.js @@ -10,6 +10,7 @@ import { 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('astro fonts implementations', () => { @@ -251,5 +252,42 @@ describe('astro fonts implementations', () => { }); }); + // 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); + } + } + } + }); + // TODO: cover more implementations }); From fb8410950b46fce1c28fdd58f42320ba9f0370ba Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 15:37:58 +0200 Subject: [PATCH 53/68] feat: work on test --- .../fonts/implementations/remote-font-provider-resolver.ts | 2 +- .../assets/fonts/implementations/system-fallbacks-provider.ts | 2 +- packages/astro/src/assets/fonts/types.ts | 2 +- packages/astro/test/units/assets/fonts/implementations.test.js | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) 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 index e9b20d1ad21c..f70a99fbc910 100644 --- a/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts @@ -6,7 +6,7 @@ import type { import type { ResolvedFontProvider } from '../types.js'; import { resolveEntrypoint } from '../utils.js'; -export function validateMod({ +function validateMod({ mod, entrypoint, errorHandler, diff --git a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts index e6812a43f0cb..3e2340c78bd7 100644 --- a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts +++ b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts @@ -2,7 +2,7 @@ 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 -export const SYSTEM_METRICS = { +const SYSTEM_METRICS = { 'Times New Roman': { ascent: 1825, descent: -443, diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index a35a4151ba0e..044c1354f8b7 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -36,7 +36,7 @@ export interface ResolvedLocalFontFamily type RemoteFontFamily = z.infer; -export interface ResolvedRemoteFontFamily +interface ResolvedRemoteFontFamily extends ResolvedFontFamilyAttributes, Omit, 'provider' | 'weights'> { provider: ResolvedFontProvider; diff --git a/packages/astro/test/units/assets/fonts/implementations.test.js b/packages/astro/test/units/assets/fonts/implementations.test.js index 16ada6cd4805..c2aca2a64e2e 100644 --- a/packages/astro/test/units/assets/fonts/implementations.test.js +++ b/packages/astro/test/units/assets/fonts/implementations.test.js @@ -288,6 +288,4 @@ describe('astro fonts implementations', () => { } } }); - - // TODO: cover more implementations }); From 30c528f2355bbba2768425828d17b52e887f8325 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 17:17:12 +0200 Subject: [PATCH 54/68] feat: work on test --- .../test/units/assets/fonts/logic.test.js | 69 +++++++++++++++++++ .../astro/test/units/assets/fonts/utils.js | 6 ++ 2 files changed, 75 insertions(+) create mode 100644 packages/astro/test/units/assets/fonts/logic.test.js 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 000000000000..bfa9e1a9438b --- /dev/null +++ b/packages/astro/test/units/assets/fonts/logic.test.js @@ -0,0 +1,69 @@ +// @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 { fakeHasher } from './utils.js'; + +describe('astro fonts logic', () => { + describe('resolveFamily()', () => { + // TODO: check local provider + // TODO: check deduping + // TODO: check remote provider + 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 () => ({ + provider: () => {}, + }), + }, + }); + 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 () => ({ + provider: () => {}, + }), + }, + }); + assert.equal(family.name, 'Foo bar'); + assert.equal(family.nameWithHash, 'Foo bar-xxx'); + }); + }); +}); diff --git a/packages/astro/test/units/assets/fonts/utils.js b/packages/astro/test/units/assets/fonts/utils.js index ba5fc67560a3..0cbf81741055 100644 --- a/packages/astro/test/units/assets/fonts/utils.js +++ b/packages/astro/test/units/assets/fonts/utils.js @@ -47,3 +47,9 @@ export const simpleErrorHandler = { 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), +}; From 9cf802584b339bcbd4d5f3a0088dc77ead8dfa71 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 17:35:27 +0200 Subject: [PATCH 55/68] feat: work on test --- .../test/units/assets/fonts/logic.test.js | 130 ++++++++++++++++-- 1 file changed, 121 insertions(+), 9 deletions(-) diff --git a/packages/astro/test/units/assets/fonts/logic.test.js b/packages/astro/test/units/assets/fonts/logic.test.js index bfa9e1a9438b..3cad46584f6b 100644 --- a/packages/astro/test/units/assets/fonts/logic.test.js +++ b/packages/astro/test/units/assets/fonts/logic.test.js @@ -6,9 +6,6 @@ import { fakeHasher } from './utils.js'; describe('astro fonts logic', () => { describe('resolveFamily()', () => { - // TODO: check local provider - // TODO: check deduping - // TODO: check remote provider it('removes quotes correctly', async () => { const hasher = { ...fakeHasher, hashObject: () => 'xxx' }; let family = await resolveFamily({ @@ -30,9 +27,7 @@ describe('astro fonts logic', () => { }, remoteFontProviderResolver: { // @ts-expect-error - resolve: async () => ({ - provider: () => {}, - }), + resolve: async () => ({}), }, }); assert.equal(family.name, 'Test'); @@ -57,13 +52,130 @@ describe('astro fonts logic', () => { }, remoteFontProviderResolver: { // @ts-expect-error - resolve: async () => ({ - provider: () => {}, - }), + 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']); + } + }); }); }); From ab2a1ee0443a1a14762455931f5d1f34e2bd1ba0 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 17:37:56 +0200 Subject: [PATCH 56/68] feat: work on test --- .../test/units/assets/fonts/logic.test.js | 151 ++++++++++++++ .../test/units/assets/fonts/utils.test.js | 185 ------------------ 2 files changed, 151 insertions(+), 185 deletions(-) diff --git a/packages/astro/test/units/assets/fonts/logic.test.js b/packages/astro/test/units/assets/fonts/logic.test.js index 3cad46584f6b..054e7dde63f9 100644 --- a/packages/astro/test/units/assets/fonts/logic.test.js +++ b/packages/astro/test/units/assets/fonts/logic.test.js @@ -2,6 +2,7 @@ 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 { fakeHasher } from './utils.js'; describe('astro fonts logic', () => { @@ -178,4 +179,154 @@ describe('astro fonts logic', () => { } }); }); + + describe('extractUnifontProviders()', () => { + const createProvider = (/** @type {string} */ name) => () => + Object.assign(() => undefined, { _name: name }); + + /** @param {Array} families */ + function createFixture(families) { + const result = extractUnifontProviders({ + families, + hasher: fakeHasher, + }); + return { + /** + * @param {number} length + */ + assertProvidersLength: (length) => { + assert.equal(result.providers.length, length); + }, + /** + * @param {Array} 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"}', + ]); + }); + }); }); diff --git a/packages/astro/test/units/assets/fonts/utils.test.js b/packages/astro/test/units/assets/fonts/utils.test.js index 7e2eccdba3c6..cd31e8fdda64 100644 --- a/packages/astro/test/units/assets/fonts/utils.test.js +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -4,8 +4,6 @@ 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, @@ -48,39 +46,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', @@ -389,156 +354,6 @@ describe('fonts utils', () => { }); }); - describe('familiesToUnifontProviders()', () => { - const createProvider = (/** @type {string} */ name) => () => - Object.assign(() => undefined, { _name: name }); - - /** @param {Array} 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} 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('renderFontSrc()', () => { it('does not output tech(undefined) if key is present without value', () => { assert.equal( From 3e9a7e1eeff58ac28ab52e046c0ae91ca3386cdb Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 17:58:20 +0200 Subject: [PATCH 57/68] feat: work on test --- .../test/units/assets/fonts/logic.test.js | 115 +++++++++++++++++- .../test/units/assets/fonts/providers.test.js | 1 + .../astro/test/units/assets/fonts/utils.js | 12 ++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/astro/test/units/assets/fonts/logic.test.js b/packages/astro/test/units/assets/fonts/logic.test.js index 054e7dde63f9..9378dc1f54db 100644 --- a/packages/astro/test/units/assets/fonts/logic.test.js +++ b/packages/astro/test/units/assets/fonts/logic.test.js @@ -3,7 +3,8 @@ 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 { fakeHasher } from './utils.js'; +import { normalizeRemoteFontFaces } from '../../../../dist/assets/fonts/logic/normalize-remote-font-faces.js'; +import { createSpyUrlProxy, fakeHasher } from './utils.js'; describe('astro fonts logic', () => { describe('resolveFamily()', () => { @@ -329,4 +330,116 @@ describe('astro fonts logic', () => { ]); }); }); + + 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' }, + }, + ]); + }); + }); }); diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 9478d956bded..18bec35d5d4c 100644 --- a/packages/astro/test/units/assets/fonts/providers.test.js +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -84,6 +84,7 @@ describe('fonts providers', () => { ); }); + // TODO: check preloading it('resolveLocalFont()', () => { let { fonts, values } = resolveLocalFontSpy({ name: 'Custom', diff --git a/packages/astro/test/units/assets/fonts/utils.js b/packages/astro/test/units/assets/fonts/utils.js index 0cbf81741055..dd162aafd363 100644 --- a/packages/astro/test/units/assets/fonts/utils.js +++ b/packages/astro/test/units/assets/fonts/utils.js @@ -53,3 +53,15 @@ 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 }; +} From 6bbf14beebacc13abeab10e867f96439cb926695 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Thu, 24 Apr 2025 18:42:01 +0200 Subject: [PATCH 58/68] feat: work on test --- .../assets/fonts/logic/optimize-fallbacks.ts | 5 +- packages/astro/src/assets/fonts/types.ts | 3 +- .../test/units/assets/fonts/logic.test.js | 173 +++++++++++++++++- .../astro/test/units/assets/fonts/utils.js | 16 ++ 4 files changed, 193 insertions(+), 4 deletions(-) diff --git a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts index 192116944f22..181ea9c27006 100644 --- a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts +++ b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts @@ -55,11 +55,12 @@ export async function optimizeFallbacks({ const localFontsMappings = localFonts.map((font) => ({ font, - name: `"${family.nameWithHash} fallback: ${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 = [...new Set([...localFontsMappings.map((m) => m.name), ...fallbacks])]; + fallbacks = [...localFontsMappings.map((m) => m.name), ...fallbacks]; let css = ''; for (const { font, name } of localFontsMappings) { diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index 044c1354f8b7..3e3fb89b8ef0 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -36,7 +36,8 @@ export interface ResolvedLocalFontFamily type RemoteFontFamily = z.infer; -interface ResolvedRemoteFontFamily +/** @lintignore somehow required by pickFontFaceProperty in utils */ +export interface ResolvedRemoteFontFamily extends ResolvedFontFamilyAttributes, Omit, 'provider' | 'weights'> { provider: ResolvedFontProvider; diff --git a/packages/astro/test/units/assets/fonts/logic.test.js b/packages/astro/test/units/assets/fonts/logic.test.js index 9378dc1f54db..dc898b5d39ee 100644 --- a/packages/astro/test/units/assets/fonts/logic.test.js +++ b/packages/astro/test/units/assets/fonts/logic.test.js @@ -4,7 +4,9 @@ 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 { createSpyUrlProxy, fakeHasher } from './utils.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('astro fonts logic', () => { describe('resolveFamily()', () => { @@ -442,4 +444,173 @@ describe('astro fonts logic', () => { ]); }); }); + + 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/utils.js b/packages/astro/test/units/assets/fonts/utils.js index dd162aafd363..cfb2975807de 100644 --- a/packages/astro/test/units/assets/fonts/utils.js +++ b/packages/astro/test/units/assets/fonts/utils.js @@ -65,3 +65,19 @@ export function createSpyUrlProxy() { }; 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) + `,`; + }, +}; From 6f60e6d66fefc03fd949aadbe58866c2fd0167a2 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 25 Apr 2025 11:59:53 +0200 Subject: [PATCH 59/68] feat: work on test --- packages/astro/src/assets/fonts/utils.ts | 20 +- .../assets/fonts/implementations.test.js | 2 +- .../test/units/assets/fonts/logic.test.js | 2 +- .../test/units/assets/fonts/utils.test.js | 383 +++--------------- 4 files changed, 71 insertions(+), 336 deletions(-) diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index cf69b8969af7..9dc16a8e16b1 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -33,17 +33,17 @@ export function renderFontSrc( ): 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}")`; } - return `local("${src.name}")`; + let rendered = `url("${src.url}")`; + if (src.format) { + rendered += ` format("${src.format}")`; + } + if (src.tech) { + rendered += ` tech(${src.tech})`; + } + return rendered; }) .join(', '); } diff --git a/packages/astro/test/units/assets/fonts/implementations.test.js b/packages/astro/test/units/assets/fonts/implementations.test.js index c2aca2a64e2e..b5f2d5a7c4c1 100644 --- a/packages/astro/test/units/assets/fonts/implementations.test.js +++ b/packages/astro/test/units/assets/fonts/implementations.test.js @@ -13,7 +13,7 @@ import { createCachedFontFetcher } from '../../../../dist/assets/fonts/implement import { createFontTypeExtractor } from '../../../../dist/assets/fonts/implementations/font-type-extractor.js'; import { createSpyStorage, simpleErrorHandler } from './utils.js'; -describe('astro fonts implementations', () => { +describe('fonts implementations', () => { describe('createMinifiableCssRenderer()', () => { describe('renderFontFace()', () => { it('filters undefined properties properly', () => { diff --git a/packages/astro/test/units/assets/fonts/logic.test.js b/packages/astro/test/units/assets/fonts/logic.test.js index dc898b5d39ee..192acaa35d94 100644 --- a/packages/astro/test/units/assets/fonts/logic.test.js +++ b/packages/astro/test/units/assets/fonts/logic.test.js @@ -8,7 +8,7 @@ import { optimizeFallbacks } from '../../../../dist/assets/fonts/logic/optimize- import { createSystemFallbacksProvider } from '../../../../dist/assets/fonts/implementations/system-fallbacks-provider.js'; import { createSpyUrlProxy, fakeFontMetricsResolver, fakeHasher } from './utils.js'; -describe('astro fonts logic', () => { +describe('fonts logic', () => { describe('resolveFamily()', () => { it('removes quotes correctly', async () => { const hasher = { ...fakeHasher, hashObject: () => 'xxx' }; diff --git a/packages/astro/test/units/assets/fonts/utils.test.js b/packages/astro/test/units/assets/fonts/utils.test.js index cd31e8fdda64..6a16905aa9bd 100644 --- a/packages/astro/test/units/assets/fonts/utils.test.js +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -1,41 +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 { - generateFallbacksCSS, isFontType, isGenericFontFamily, - proxyURL, renderFontSrc, - resolveFontFamily, - toCSS, + sortObjectByKey, + unifontFontFaceDataToProperties, } from '../../../../dist/assets/fonts/utils.js'; -/** - * - * @param {string} id - * @param {string} value - */ -function proxyURLSpy(id, value) { - /** @type {Parameters[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); @@ -46,28 +19,6 @@ describe('fonts utils', () => { assert.equal(isFontType(''), false); }); - 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); @@ -85,275 +36,6 @@ 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 () => { - 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'], - }, - ); - }); - - 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('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('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'], - }, - ); - }); - }); - - describe('resolveFontFamily()', () => { - const root = new URL(import.meta.url); - - 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, - }), - { - 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', - }, - ], - }, - ); - 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, - }), - { - 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, - }); - 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, - }); - 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, - }); - 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('renderFontSrc()', () => { it('does not output tech(undefined) if key is present without value', () => { assert.equal( @@ -361,20 +43,73 @@ describe('fonts utils', () => { 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('returns local if it has a name', () => { + assert.equal(renderFontSrc([{ name: 'Arial' }]), 'local("Arial")'); + }); + }); + + 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('toCSS', () => { - assert.deepStrictEqual(toCSS({}, 0), ''); - assert.deepStrictEqual(toCSS({ foo: 'bar' }, 0), 'foo: bar;'); - assert.deepStrictEqual(toCSS({ foo: 'bar', bar: undefined }, 0), 'foo: bar;'); + it('sortObjectByKey()', () => { + assert.equal( + JSON.stringify( + sortObjectByKey({ + b: '', + d: '', + a: '', + c: { + b: '', + d: '', + a: '', + c: {}, + }, + }), + ), + JSON.stringify({ + a: '', + b: '', + c: { + a: '', + b: '', + c: {}, + d: '', + }, + d: '', + }), + ); }); }); From c461fade02fd90fb0a1061e24605f0965a0abce3 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 25 Apr 2025 12:37:29 +0200 Subject: [PATCH 60/68] feat: work on test --- .../units/assets/fonts/orchestrate.test.js | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/astro/test/units/assets/fonts/orchestrate.test.js 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 000000000000..99e0d979d303 --- /dev/null +++ b/packages/astro/test/units/assets/fonts/orchestrate.test.js @@ -0,0 +1,91 @@ +// @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 { + createSpyStorage, + fakeFontMetricsResolver, + fakeHasher, + simpleErrorHandler, +} from './utils.js'; +import { DEFAULTS } from '../../../../dist/assets/fonts/constants.js'; + +describe('fonts orchestrate()', () => { + it('works with local fonts', async () => { + const root = new URL('file:///foo/bar/'); + 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()], + [ + ['/foo/bar/my-font.woff2.woff2', '/foo/bar/my-font.woff2'], + ['/foo/bar/my-font.woff.woff', '/foo/bar/my-font.woff'], + ], + ); + assert.deepStrictEqual([...resolvedMap.keys()], ['--test']); + const entry = resolvedMap.get('--test'); + assert.deepStrictEqual(entry?.preloadData, [ + { url: '/test/foo/bar/my-font.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: Arial"'), true); + }); + + // TODO: fake provider + it('works with a remote provider', () => {}); +}); From 2ce8b6b4097e2f185b127f3c5e4498f32497cd03 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 25 Apr 2025 14:48:04 +0200 Subject: [PATCH 61/68] feat: work on test --- .../units/assets/fonts/orchestrate.test.js | 90 ++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/astro/test/units/assets/fonts/orchestrate.test.js b/packages/astro/test/units/assets/fonts/orchestrate.test.js index 99e0d979d303..7e195283eb1a 100644 --- a/packages/astro/test/units/assets/fonts/orchestrate.test.js +++ b/packages/astro/test/units/assets/fonts/orchestrate.test.js @@ -11,6 +11,7 @@ import { createFontTypeExtractor } from '../../../../dist/assets/fonts/implement 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, @@ -18,6 +19,7 @@ import { simpleErrorHandler, } from './utils.js'; import { DEFAULTS } from '../../../../dist/assets/fonts/constants.js'; +import { defineFontProvider } from 'unifont'; describe('fonts orchestrate()', () => { it('works with local fonts', async () => { @@ -86,6 +88,90 @@ describe('fonts orchestrate()', () => { assert.equal(entry?.css.includes('fallback: Arial"'), true); }); - // TODO: fake provider - it('works with a remote provider', () => {}); + 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('file:///foo/bar/'); + 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); + }); }); From d7e89f6c0393d75ec8f8a15b3dcc3b0888caecdb Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 25 Apr 2025 15:09:54 +0200 Subject: [PATCH 62/68] feat: optimize fonts --- packages/astro/src/assets/fonts/utils.ts | 6 +- .../test/units/assets/fonts/load.test.js | 98 -------- .../test/units/assets/fonts/providers.test.js | 232 +++++++----------- .../test/units/assets/fonts/utils.test.js | 48 ++++ 4 files changed, 137 insertions(+), 247 deletions(-) delete mode 100644 packages/astro/test/units/assets/fonts/load.test.js diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index 9dc16a8e16b1..71601c11c911 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -89,7 +89,11 @@ export function sortObjectByKey>(unordered: T): T .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] = typeof value === 'object' && value !== null ? sortObjectByKey(value) : value; + 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; 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 18ae02bcea2c..000000000000 --- 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} */ - const store = new Map(); - /** @type {import('unstorage').Storage} */ - // @ts-expect-error - const storage = { - /** - * @param {string} key - * @returns {Promise} - */ - getItem: async (key) => { - return store.get(key) ?? null; - }, - /** - * @param {string} key - * @returns {Promise} - */ - getItemRaw: async (key) => { - return store.get(key) ?? null; - }, - /** - * @param {string} key - * @param {any} value - * @returns {Promise} - */ - setItemRaw: async (key, value) => { - store.set(key, value); - }, - /** - * @param {string} key - * @param {any} value - * @returns {Promise} - */ - setItem: async (key, value) => { - store.set(key, value); - }, - }; - const hashToUrlMap = new Map(); - const resolvedMap = new Map(); - /** @type {Array} */ - 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/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 18bec35d5d4c..c95078bd2bfd 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[0]['family']} family - */ -function resolveLocalFontSpy(family) { - /** @type {Array} */ - 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,135 +57,98 @@ describe('fonts providers', () => { ); }); - // TODO: check preloading - 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.test.js b/packages/astro/test/units/assets/fonts/utils.test.js index 6a16905aa9bd..17f01215cda7 100644 --- a/packages/astro/test/units/assets/fonts/utils.test.js +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -90,6 +90,30 @@ describe('fonts utils', () => { sortObjectByKey({ b: '', d: '', + e: [ + { + b: '', + d: '', + a: '', + c: { + b: '', + d: '', + a: '', + c: {}, + }, + }, + { + b: '', + d: '', + a: '', + c: { + b: '', + d: '', + a: '', + c: {}, + }, + }, + ], a: '', c: { b: '', @@ -109,6 +133,30 @@ describe('fonts utils', () => { d: '', }, d: '', + e: [ + { + a: '', + b: '', + c: { + a: '', + b: '', + c: {}, + d: '', + }, + d: '', + }, + { + a: '', + b: '', + c: { + a: '', + b: '', + c: {}, + d: '', + }, + d: '', + }, + ], }), ); }); From 895c58a02ae86576ed4b96a3cf4a05772c7881ee Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 25 Apr 2025 15:22:26 +0200 Subject: [PATCH 63/68] feat: public warn --- .../local-provider-url-resolver.ts | 11 +++++++++-- .../src/assets/fonts/vite-plugin-fonts.ts | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) 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 index b9af48ab7bd3..18738e2bbd54 100644 --- a/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts +++ b/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts @@ -4,12 +4,19 @@ import { fileURLToPath } from 'node:url'; export function createRequireLocalProviderUrlResolver({ root, -}: { root: URL }): LocalProviderUrlResolver { + 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 - return fileURLToPath(resolveEntrypoint(root, input)); + const path = fileURLToPath(resolveEntrypoint(root, input)); + intercept?.(path); + return path; }, }; } diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 47c1c0ea93da..4412fbe908f9 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -44,6 +44,7 @@ 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; @@ -112,7 +113,23 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { modResolver, errorHandler, }); - const localProviderUrlResolver = createRequireLocalProviderUrlResolver({ root }); + // TODO: remove when stabilizing + const pathsToWarn = new Set(); + 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 }); From 518ae7cce4ee6ef26c1ed7d99bf9c065cd859dc8 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 25 Apr 2025 15:26:00 +0200 Subject: [PATCH 64/68] chore: changeset --- .changeset/slick-garlics-do.md | 5 +++++ .changeset/sour-dryers-swim.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/slick-garlics-do.md create mode 100644 .changeset/sour-dryers-swim.md diff --git a/.changeset/slick-garlics-do.md b/.changeset/slick-garlics-do.md new file mode 100644 index 000000000000..ad6f1edeeaca --- /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 000000000000..d36881b15503 --- /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 From c86448381296e50b2a5379e6349ff1b80223f9a2 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 25 Apr 2025 15:45:24 +0200 Subject: [PATCH 65/68] fix: test --- .../units/assets/fonts/orchestrate.test.js | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/astro/test/units/assets/fonts/orchestrate.test.js b/packages/astro/test/units/assets/fonts/orchestrate.test.js index 7e195283eb1a..50725e2912d6 100644 --- a/packages/astro/test/units/assets/fonts/orchestrate.test.js +++ b/packages/astro/test/units/assets/fonts/orchestrate.test.js @@ -20,10 +20,11 @@ import { } 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('file:///foo/bar/'); + const root = new URL(import.meta.url); const { storage } = createSpyStorage(); const errorHandler = simpleErrorHandler; const fontTypeExtractor = createFontTypeExtractor({ errorHandler }); @@ -71,14 +72,23 @@ describe('fonts orchestrate()', () => { assert.deepStrictEqual( [...hashToUrlMap.entries()], [ - ['/foo/bar/my-font.woff2.woff2', '/foo/bar/my-font.woff2'], - ['/foo/bar/my-font.woff.woff', '/foo/bar/my-font.woff'], + [ + 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/foo/bar/my-font.woff2.woff2', type: 'woff2' }, + { + url: '/test' + fileURLToPath(new URL('my-font.woff2.woff2', root)), + type: 'woff2', + }, ]); // Uses the hash assert.equal(entry?.css.includes('font-family:Test-'), true); @@ -111,7 +121,7 @@ describe('fonts orchestrate()', () => { entrypoint: 'test', }); - const root = new URL('file:///foo/bar/'); + const root = new URL(import.meta.url); const { storage } = createSpyStorage(); const errorHandler = simpleErrorHandler; const fontTypeExtractor = createFontTypeExtractor({ errorHandler }); From 7e6a9d186cd148e9b01aa30acfb75d3110ccd799 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Mon, 28 Apr 2025 10:54:19 +0200 Subject: [PATCH 66/68] feedback --- .../assets/fonts/implementations/css-renderer.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts index 6ed0a860a24a..3ae6d0393715 100644 --- a/packages/astro/src/assets/fonts/implementations/css-renderer.ts +++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts @@ -1,24 +1,24 @@ import type { CssProperties, CssRenderer } from '../definitions.js'; export function renderFontFace(properties: CssProperties, minify: boolean): string { - // Line return - const lr = minify ? '' : `\n`; + // Line feed + const lf = minify ? '' : `\n`; // Space const sp = minify ? '' : ' '; - return `@font-face${sp}{${lr}${Object.entries(properties) + return `@font-face${sp}{${lf}${Object.entries(properties) .filter(([, value]) => Boolean(value)) .map(([key, value]) => `${sp}${sp}${key}:${sp}${value};`) - .join(lr)}${lr}}${lr}`; + .join(lf)}${lf}}${lf}`; } export function renderCssVariable(key: string, values: Array, minify: boolean): string { - // Line return - const lr = minify ? '' : `\n`; + // Line feed + const lf = minify ? '' : `\n`; // Space const sp = minify ? '' : ' '; - return `:root${sp}{${lr}${sp}${sp}${key}:${sp}${values.map((v) => handleValueWithSpaces(v)).join(`,${sp}`)};${lr}}${lr}`; + 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 { From bcbfb7740348ffe4f09256c4bb078ee4e9b7acda Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Mon, 28 Apr 2025 11:15:43 +0200 Subject: [PATCH 67/68] fix: test --- .../astro/test/fixtures/fonts/astro.config.mjs | 14 ++++++++++++-- packages/astro/test/fonts.test.js | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/astro/test/fixtures/fonts/astro.config.mjs b/packages/astro/test/fixtures/fonts/astro.config.mjs index 882e6515a67e..f7fc41493a00 100644 --- a/packages/astro/test/fixtures/fonts/astro.config.mjs +++ b/packages/astro/test/fixtures/fonts/astro.config.mjs @@ -1,4 +1,14 @@ -import { defineConfig } from 'astro/config'; +import { defineConfig, fontProviders } from 'astro/config'; // https://astro.build/config -export default defineConfig({}); +export default defineConfig({ + experimental: { + fonts: [ + { + name: 'Roboto', + cssVariable: '--font-roboto', + provider: fontProviders.fontsource(), + }, + ], + }, +}); diff --git a/packages/astro/test/fonts.test.js b/packages/astro/test/fonts.test.js index a4364aa62a78..9ecb4210dd13 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(), }, ], }, From 41b8c3719b93e9aec4f503efefb84a8ae313afd5 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Mon, 28 Apr 2025 11:41:39 +0200 Subject: [PATCH 68/68] Discard changes to packages/astro/test/fixtures/fonts/astro.config.mjs --- .../astro/test/fixtures/fonts/astro.config.mjs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/astro/test/fixtures/fonts/astro.config.mjs b/packages/astro/test/fixtures/fonts/astro.config.mjs index f7fc41493a00..882e6515a67e 100644 --- a/packages/astro/test/fixtures/fonts/astro.config.mjs +++ b/packages/astro/test/fixtures/fonts/astro.config.mjs @@ -1,14 +1,4 @@ -import { defineConfig, fontProviders } from 'astro/config'; +import { defineConfig } from 'astro/config'; // https://astro.build/config -export default defineConfig({ - experimental: { - fonts: [ - { - name: 'Roboto', - cssVariable: '--font-roboto', - provider: fontProviders.fontsource(), - }, - ], - }, -}); +export default defineConfig({});