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:
parent
868e2ab9ed
commit
8e89e807ed
5 changed files with 307 additions and 102 deletions
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = () => {};
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Reference in a new issue