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

refactor(fonts): url proxy (#13274)

This commit is contained in:
Florian Lefebvre 2025-02-20 11:14:53 +01:00 committed by GitHub
parent 8bc05a0a13
commit 287b789ec6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 118 additions and 28 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 } from '../utils.js';
import { extractFontType, type URLProxy } from '../utils.js';
// https://fonts.nuxt.com/get-started/providers#local
// https://github.com/nuxt/fonts/blob/main/src/providers/local.ts
@ -19,7 +19,7 @@ interface Options {
}
interface ResolveOptions {
proxySourceURL: (value: string) => string;
proxyURL: URLProxy;
}
// TODO: dev watcher and ways to update during dev
@ -27,7 +27,7 @@ export function createLocalProvider({ root }: Options) {
return {
resolveFont: async (
family: LocalFontFamily,
{ proxySourceURL }: ResolveOptions,
{ proxyURL }: ResolveOptions,
): Promise<ResolveFontResult> => {
const fonts: ResolveFontResult['fonts'] = [];
@ -40,7 +40,7 @@ export function createLocalProvider({ root }: Options) {
weight,
style,
src: src.paths.map((path) => ({
url: proxySourceURL(fileURLToPath(new URL(path, root))),
url: proxyURL(fileURLToPath(new URL(path, root))),
format: extractFontType(path),
})),
});

View file

@ -72,4 +72,35 @@ export function createCache(storage: Storage) {
};
}
export type CacheHandler = ReturnType<typeof createCache>;
export type CacheHandler = ReturnType<typeof createCache>;
/**
* The fonts data we receive contains urls or file paths we do no control.
* However, we will emit font files ourselves so we store the original value
* and replace it with a url we control. For example with the value "https://foo.bar/file.woff2":
* - font type is woff2
* - hash will be "<hash>.woff2"
* - `collect` will save the association of the original url and the new hash for later use
* - the returned url will be `/_astro/fonts/<hash>.woff2`
*/
export function 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 type URLProxy = ReturnType<typeof createURLProxy>;

View file

@ -4,10 +4,10 @@ import { resolveProviders, type ResolveMod } from './providers/utils.js';
import * as unifont from 'unifont';
import type { FontFamily, FontProvider, FontType } from './types.js';
import xxhash from 'xxhash-wasm';
import { extname, isAbsolute } from 'node:path';
import { isAbsolute } from 'node:path';
import { getClientOutputDirectory } from '../../prerender/utils.js';
import { mkdirSync, writeFileSync } from 'node:fs';
import { extractFontType, generateFontFace, createCache, type CacheHandler } from './utils.js';
import { generateFontFace, createCache, type CacheHandler, createURLProxy } from './utils.js';
import {
DEFAULTS,
VIRTUAL_MODULE_ID,
@ -110,7 +110,7 @@ async function fetchFont(url: string): Promise<Buffer> {
}
}
export function fonts({ settings, sync, logger }: Options): Plugin {
export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
if (!settings.config.experimental.fonts) {
// this is required because the virtual module does not exist
// when fonts are not enabled, and that prevents rollup from building
@ -172,58 +172,65 @@ export function fonts({ 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
resolvedMap = new Map();
hashToUrlMap = new Map();
const preloadData: PreloadData = [];
let css = '';
const { resolveFont: resolveLocalFont } = createLocalProvider({ root: settings.config.root });
// TODO: investigate using fontaine for fallbacks
for (const family of families) {
const preloadData: PreloadData = [];
let css = '';
function proxySourceURL(value: string) {
const hash = h64ToString(value) + extname(value);
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: extractFontType(hash) });
preloadData.push({ url, type });
}
// Now that we collected the original url, we override it with our proxy
return url;
}
},
});
// TODO: investigate using fontaine for fallbacks
for (const family of families) {
// Reset
preloadData.length = 0;
css = '';
if (family.provider === LOCAL_PROVIDER_NAME) {
const { fonts: fontsData, fallbacks } = await resolveLocalFont(family, { proxySourceURL });
for (const data of fontsData) {
const { fonts, fallbacks } = await resolveLocalFont(family, { proxyURL });
for (const data of fonts) {
css += generateFontFace(family.name, data);
}
} else {
const { fonts: fontsData, fallbacks } = await resolveFont(
const { fonts, fallbacks } = 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, fontaine goes through all providers. We use a different approach
// where we specify a provider per font (default to google)
[family.provider],
);
for (const data of fontsData) {
for (const data of fonts) {
for (const source of data.src) {
if ('name' in source) {
continue;
}
source.url = proxySourceURL(source.url);
source.url = proxyURL(source.url);
}
// TODO: support optional as prop
css += generateFontFace(family.name, data);
}
}
resolvedMap.set(family.name, { preloadData, css });
resolvedMap.set(family.name, { preloadData: [...preloadData], css });
}
logger.info('assets', 'Fonts initialized');
}
@ -299,6 +306,8 @@ export function fonts({ settings, sync, logger }: Options): Plugin {
return;
}
// TODO: properly cleanup in case of failure
const logManager = createLogManager(logger);
const dir = getClientOutputDirectory(settings);
const fontsDir = new URL('.' + baseUrl, dir);

View file

@ -19,7 +19,7 @@ import { emitESMImage } from './utils/node/emitAsset.js';
import { getProxyCode } from './utils/proxy.js';
import { makeSvgComponent } from './utils/svg.js';
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
import { fonts } from './fonts/vite-plugin-fonts.js';
import { fontsPlugin } from './fonts/vite-plugin-fonts.js';
import type { Logger } from '../core/logger/core.js';
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
@ -252,6 +252,6 @@ export default function assets({ settings, sync, logger }: Options): vite.Plugin
}
},
},
fonts({ settings, sync, logger }),
fontsPlugin({ settings, sync, logger }),
];
}

View file

@ -1,7 +1,12 @@
// @ts-check
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { isFontType, extractFontType, createCache } from '../../../../dist/assets/fonts/utils.js';
import {
isFontType,
extractFontType,
createCache,
createURLProxy,
} from '../../../../dist/assets/fonts/utils.js';
function createSpyCache() {
/** @type {Map<string, Buffer>} */
@ -32,6 +37,29 @@ function createSpyCache() {
return { cache, getKeys: () => Array.from(store.keys()) };
}
/**
*
* @param {string} id
* @param {string} value
*/
function proxyURLSpy(id, value) {
/** @type {Parameters<Parameters<typeof createURLProxy>[0]['collect']>[0]} */
let collected = /** @type {any} */ (undefined);
const proxyURL = createURLProxy({
hashString: () => id,
collect: (data) => {
collected = data;
return 'base/' + data.hash;
},
});
const url = proxyURL(value);
return {
url,
collected,
};
}
describe('fonts utils', () => {
it('isFontType()', () => {
assert.equal(isFontType('woff2'), true);
@ -93,4 +121,26 @@ describe('fonts utils', () => {
assert.deepStrictEqual(getKeys(), ['foo']);
});
it('createURLProxy()', () => {
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',
});
});
});