0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-10 23:01:26 -05:00

feat(fonts): better local provider (#13276)

This commit is contained in:
Florian Lefebvre 2025-02-21 15:45:32 +01:00 committed by GitHub
parent 868e2ab9ed
commit 8e89e807ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 307 additions and 102 deletions

View file

@ -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<Awaited<ReturnType<unifont.Provider>>>;
type ResolveFontResult = NonNullable<Awaited<ReturnType<InitializedProvider['resolveFont']>>>;
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<ResolveFontResult> => {
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<string>;
/**
* Action performed when a font file is updated
*/
#update: () => void;
constructor({ paths, update }: { paths: Array<string>; 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');
}
}

View file

@ -74,6 +74,27 @@ export function createCache(storage: Storage) {
export type CacheHandler = ReturnType<typeof createCache>;
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<typeof createCache>;
* - `collect` will save the association of the original url and the new hash for later use
* - the returned url will be `/_astro/fonts/<hash>.woff2`
*/
export function 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<typeof createURLProxy>;

View file

@ -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,

View file

@ -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<typeof resolveLocalFont>[0]} family
* @param {URL} root
*/
function resolveLocalFontSpy(family, root) {
/** @type {Array<string>} */
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 = () => {};

View file

@ -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<Parameters<typeof createURLProxy>[0]['collect']>[0]} */
/** @type {Parameters<import('../../../../dist/assets/fonts/utils.js').ProxyURLOptions['collect']>[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',