From 8e89e807edaa26a0fc37b081cc6e17e5e369b797 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 21 Feb 2025 15:45:32 +0100 Subject: [PATCH] feat(fonts): better local provider (#13276) --- .../astro/src/assets/fonts/providers/local.ts | 102 +++++++---- packages/astro/src/assets/fonts/utils.ts | 47 ++--- .../src/assets/fonts/vite-plugin-fonts.ts | 84 ++++++--- .../test/units/assets/fonts/providers.test.js | 166 +++++++++++++++--- .../test/units/assets/fonts/utils.test.js | 10 +- 5 files changed, 307 insertions(+), 102 deletions(-) diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 0f5106408c..e9b4d7f761 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 type { LocalFontFamily } from '../types.js'; import { DEFAULTS } from '../constants.js'; import { fileURLToPath } from 'node:url'; -import { extractFontType, type URLProxy } from '../utils.js'; +import { extractFontType } from '../utils.js'; // https://fonts.nuxt.com/get-started/providers#local // https://github.com/nuxt/fonts/blob/main/src/providers/local.ts @@ -14,43 +14,79 @@ type InitializedProvider = NonNullable>>; type ResolveFontResult = NonNullable>>; -interface Options { - root: URL; -} - interface ResolveOptions { - proxyURL: URLProxy; + root: URL; + proxyURL: (value: string) => string; } -// TODO: dev watcher and ways to update during dev -export function createLocalProvider({ root }: Options) { - return { - resolveFont: async ( - family: LocalFontFamily, - { proxyURL }: ResolveOptions, - ): Promise => { - const fonts: ResolveFontResult['fonts'] = []; +export function resolveLocalFont( + family: LocalFontFamily, + { proxyURL, root }: ResolveOptions, +): ResolveFontResult { + const fonts: ResolveFontResult['fonts'] = []; - for (const src of family.src) { - for (const weight of src.weights ?? DEFAULTS.weights) { - for (const style of src.styles ?? DEFAULTS.styles) { - // TODO: handle fallbacks? - // TODO: handle subset - fonts.push({ - weight, - style, - src: src.paths.map((path) => ({ - url: proxyURL(fileURLToPath(new URL(path, root))), - format: extractFontType(path), - })), - }); - } - } + for (const src of family.src) { + for (const weight of src.weights ?? DEFAULTS.weights) { + for (const style of src.styles ?? DEFAULTS.styles) { + // TODO: handle fallbacks? + // TODO: handle subset + fonts.push({ + weight, + style, + src: src.paths.map((path) => ({ + url: proxyURL(fileURLToPath(new URL(path, root))), + format: extractFontType(path), + })), + }); } + } + } - return { - fonts, - }; - }, + return { + fonts, }; } + +/** + * Orchestrates local font updates and deletions during development + */ +export class LocalFontsWatcher { + /** + * Watched fonts files + */ + #paths: Array; + /** + * Action performed when a font file is updated + */ + #update: () => void; + + constructor({ paths, update }: { paths: Array; update: () => void }) { + this.#paths = paths; + this.#update = update; + } + + #matches(path: string): boolean { + return this.#paths.includes(path); + } + + /** + * Callback to call whenever a file is updated + */ + onUpdate(path: string): void { + if (!this.#matches(path)) { + return; + } + this.#update(); + } + + /** + * Callback to call whenever a file is unlinked + */ + onUnlink(path: string): void { + if (!this.#matches(path)) { + return; + } + // TODO: improve + throw new Error('File used for font deleted. Restore it or update your config'); + } +} diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts index d05beaef89..08d9436baa 100644 --- a/packages/astro/src/assets/fonts/utils.ts +++ b/packages/astro/src/assets/fonts/utils.ts @@ -74,6 +74,27 @@ export function createCache(storage: Storage) { export type CacheHandler = ReturnType; +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 @@ -83,24 +104,10 @@ export type CacheHandler = ReturnType; * - `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 createURLProxy({ - hashString, - collect, -}: { - hashString: (value: string) => string; - collect: (data: { - hash: string; - type: FontType; - value: string; - }) => string; -}) { - return function proxyURL(value: string): 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 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 type URLProxy = ReturnType; diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 32f41085f4..e19140a4c0 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -6,13 +6,14 @@ import type { FontFamily, FontProvider, FontType } from './types.js'; import xxhash from 'xxhash-wasm'; import { isAbsolute } from 'node:path'; import { getClientOutputDirectory } from '../../prerender/utils.js'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { generateFontFace, createCache, type CacheHandler, - createURLProxy, + proxyURL, extractFontType, + type ProxyURLOptions, } from './utils.js'; import { DEFAULTS, @@ -25,7 +26,7 @@ import { removeTrailingForwardSlash } from '@astrojs/internal-helpers/path'; import type { Logger } from '../../core/logger/core.js'; import { AstroError, AstroErrorData } from '../../core/errors/index.js'; import { createViteLoader } from '../../core/module-loader/vite.js'; -import { createLocalProvider, LOCAL_PROVIDER_NAME } from './providers/local.js'; +import { resolveLocalFont, LOCAL_PROVIDER_NAME, LocalFontsWatcher } from './providers/local.js'; import { readFile } from 'node:fs/promises'; import { createStorage } from 'unstorage'; import fsLiteDriver from 'unstorage/drivers/fs-lite'; @@ -152,7 +153,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { let isBuild: boolean; let cache: CacheHandler | null = null; - async function initialize({ resolveMod }: { resolveMod: ResolveMod }) { + async function initialize({ resolveMod, base }: { resolveMod: ResolveMod; base: URL }) { const { h64ToString } = await xxhash(); const resolved = await resolveProviders({ @@ -163,12 +164,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { const storage = createStorage({ driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({ - base: fileURLToPath( - // In dev, we cache fonts data in .astro so it can be easily inspected and cleared - isBuild - ? new URL(CACHE_DIR, settings.config.cacheDir) - : new URL(CACHE_DIR, settings.dotAstroDir), - ), + base: fileURLToPath(base), }), }); @@ -178,7 +174,6 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { resolved.map((e) => e.provider(e.config)), { storage }, ); - const { resolveFont: resolveLocalFont } = createLocalProvider({ root: settings.config.root }); // We initialize shared variables here and reset them in buildEnd // to avoid locking memory @@ -187,17 +182,17 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { const preloadData: PreloadData = []; let css = ''; - const proxyURL = createURLProxy({ - hashString: h64ToString, - collect: ({ hash, type, value }) => { - const url = baseUrl + hash; - if (!hashToUrlMap!.has(hash)) { - hashToUrlMap!.set(hash, value); - preloadData.push({ url, type }); - } - return url; - }, - }); + // 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 url = baseUrl + hash; + if (!hashToUrlMap!.has(hash)) { + hashToUrlMap!.set(hash, value); + preloadData.push({ url, type }); + } + return url; + }; // TODO: investigate using fontaine for fallbacks for (const family of families) { @@ -206,7 +201,26 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { css = ''; if (family.provider === LOCAL_PROVIDER_NAME) { - const { fonts, fallbacks } = await resolveLocalFont(family, { proxyURL }); + const { fonts, fallbacks } = resolveLocalFont(family, { + proxyURL: (value) => { + 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 h64ToString(v + content); + }, + collect, + }); + }, + root: settings.config.root, + }); for (const data of fonts) { css += generateFontFace(family.name, data); } @@ -230,7 +244,12 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { if ('name' in source) { continue; } - source.url = proxyURL(source.url); + source.url = proxyURL({ + value: source.url, + // We only use the url for hashing since the service returns urls with a hash already + hashString: h64ToString, + collect, + }); } // TODO: support optional as prop css += generateFontFace(family.name, data); @@ -250,6 +269,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { if (isBuild) { await initialize({ resolveMod: (id) => import(id), + base: new URL(CACHE_DIR, settings.config.cacheDir), }); } }, @@ -257,7 +277,23 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { const moduleLoader = createViteLoader(server); await initialize({ resolveMod: (id) => moduleLoader.import(id), + // In dev, we cache fonts data in .astro so it can be easily inspected and cleared + base: new URL(CACHE_DIR, settings.dotAstroDir), }); + const localFontsWatcher = new LocalFontsWatcher({ + // 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 + paths: [...hashToUrlMap!.values()].filter((url) => isAbsolute(url)), + // Whenever a local font file is updated, we restart the server so the user always has an up to date + // version of the font file + update: () => { + logger.info('assets', 'Font file updated'); + server.restart(); + }, + }); + server.watcher.on('change', (path) => localFontsWatcher.onUpdate(path)); + // We do not purge the cache in case the user wants to re-use the file later on + server.watcher.on('unlink', (path) => localFontsWatcher.onUnlink(path)); const logManager = createLogManager(logger); // Base is taken into account by default. The prefix contains a traling slash, diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 78c5714e81..8d632a7ab3 100644 --- a/packages/astro/test/units/assets/fonts/providers.test.js +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -3,11 +3,45 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { fontProviders } from '../../../../dist/config/entrypoint.js'; import { google } from '../../../../dist/assets/fonts/providers/google.js'; +import { + LocalFontsWatcher, + resolveLocalFont, +} from '../../../../dist/assets/fonts/providers/local.js'; import * as adobeEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/adobe.js'; import * as bunnyEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/bunny.js'; import * as fontshareEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/fontshare.js'; import * as fontsourceEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/fontsource.js'; import { validateMod, resolveProviders } from '../../../../dist/assets/fonts/providers/utils.js'; +import { proxyURL } from '../../../../dist/assets/fonts/utils.js'; +import { basename, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * @param {Parameters[0]} family + * @param {URL} root + */ +function resolveLocalFontSpy(family, root) { + /** @type {Array} */ + const values = []; + + const { fonts } = resolveLocalFont(family, { + proxyURL: (v) => + proxyURL({ + value: v, + hashString: (value) => basename(value, extname(value)), + collect: ({ hash, value }) => { + values.push(value); + return `/_astro/fonts/${hash}`; + }, + }), + root, + }); + + return { + fonts, + values: [...new Set(values)], + }; +} describe('fonts providers', () => { describe('config objects', () => { @@ -32,28 +66,120 @@ describe('fonts providers', () => { }); }); - describe('entrypoints', () => { - it('providers are correctly exported', () => { - assert.equal( - 'provider' in adobeEntrypoint && typeof adobeEntrypoint.provider === 'function', - true, - ); - assert.equal( - 'provider' in bunnyEntrypoint && typeof bunnyEntrypoint.provider === 'function', - true, - ); - assert.equal( - 'provider' in fontshareEntrypoint && typeof fontshareEntrypoint.provider === 'function', - true, - ); - assert.equal( - 'provider' in fontsourceEntrypoint && typeof fontsourceEntrypoint.provider === 'function', - true, - ); - }); + it('providers are correctly exported', () => { + assert.equal( + 'provider' in adobeEntrypoint && typeof adobeEntrypoint.provider === 'function', + true, + ); + assert.equal( + 'provider' in bunnyEntrypoint && typeof bunnyEntrypoint.provider === 'function', + true, + ); + assert.equal( + 'provider' in fontshareEntrypoint && typeof fontshareEntrypoint.provider === 'function', + true, + ); + assert.equal( + 'provider' in fontsourceEntrypoint && typeof fontsourceEntrypoint.provider === 'function', + true, + ); + }); + + it('resolveLocalFont()', () => { + const root = new URL(import.meta.url); + + let { fonts, values } = resolveLocalFontSpy( + { + name: 'Custom', + provider: 'local', + src: [ + { + paths: ['./src/fonts/foo.woff2', './src/fonts/foo.ttf'], + }, + ], + }, + root, + ); + + assert.deepStrictEqual(fonts, [ + { + weight: '400', + style: 'normal', + src: [ + { url: '/_astro/fonts/foo.woff2', format: 'woff2' }, + { url: '/_astro/fonts/foo.ttf', format: 'ttf' }, + ], + }, + { + weight: '400', + style: 'italic', + src: [ + { url: '/_astro/fonts/foo.woff2', format: 'woff2' }, + { url: '/_astro/fonts/foo.ttf', format: 'ttf' }, + ], + }, + ]); + assert.deepStrictEqual(values, [ + fileURLToPath(new URL('./src/fonts/foo.woff2', root)), + fileURLToPath(new URL('./src/fonts/foo.ttf', root)), + ]); + + ({ fonts, values } = resolveLocalFontSpy( + { + name: 'Custom', + provider: 'local', + src: [ + { + weights: ['600', '700'], + styles: ['oblique'], + paths: ['./src/fonts/bar.eot'], + }, + ], + }, + root, + )); + + assert.deepStrictEqual(fonts, [ + { + weight: '600', + style: 'oblique', + src: [{ url: '/_astro/fonts/bar.eot', format: 'eot' }], + }, + { + weight: '700', + style: 'oblique', + src: [{ url: '/_astro/fonts/bar.eot', format: 'eot' }], + }, + ]); + assert.deepStrictEqual(values, [fileURLToPath(new URL('./src/fonts/bar.eot', root))]); + }); + + it('LocalFontsWatcher', () => { + let updated = 0; + const watcher = new LocalFontsWatcher({ + paths: ['foo', 'bar'], + update: () => { + updated++; + }, + }); + + watcher.onUpdate('baz'); + assert.equal(updated, 0); + + watcher.onUpdate('foo'); + watcher.onUpdate('bar'); + assert.equal(updated, 2); + + assert.doesNotThrow(() => watcher.onUnlink('baz')); + try { + watcher.onUnlink('foo'); + assert.fail(); + } catch (err) { + assert.equal(err instanceof Error, true); + assert.equal(err.message, 'File used for font deleted. Restore it or update your config'); + } }); - // TODO: test local provider describe('utils', () => { it('validateMod()', () => { const provider = () => {}; diff --git a/packages/astro/test/units/assets/fonts/utils.test.js b/packages/astro/test/units/assets/fonts/utils.test.js index 6e281fa7f2..90f5704e7c 100644 --- a/packages/astro/test/units/assets/fonts/utils.test.js +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -5,7 +5,7 @@ import { isFontType, extractFontType, createCache, - createURLProxy, + proxyURL, } from '../../../../dist/assets/fonts/utils.js'; function createSpyCache() { @@ -43,16 +43,16 @@ function createSpyCache() { * @param {string} value */ function proxyURLSpy(id, value) { - /** @type {Parameters[0]['collect']>[0]} */ + /** @type {Parameters[0]} */ let collected = /** @type {any} */ (undefined); - const proxyURL = createURLProxy({ + const url = proxyURL({ + value, hashString: () => id, collect: (data) => { collected = data; return 'base/' + data.hash; }, }); - const url = proxyURL(value); return { url, @@ -122,7 +122,7 @@ describe('fonts utils', () => { assert.deepStrictEqual(getKeys(), ['foo']); }); - it('createURLProxy()', () => { + it('proxyURL()', () => { let { url, collected } = proxyURLSpy( 'foo', 'https://fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2',