mirror of
https://github.com/withastro/astro.git
synced 2025-03-10 23:01:26 -05:00
feat(fonts): refactor loading logic (#13353)
* feat: improve loop * feat: simplify cache * feat: extract initialize logic * fix: circular dependency * fix: circular dependency * feat: test * chore: remove comment * feat: address reviews * chore: logs * fix: normalize path * test * chore: remove logs
This commit is contained in:
parent
a84a8b5d2a
commit
d8e07435cb
11 changed files with 383 additions and 270 deletions
|
@ -1,7 +1,8 @@
|
||||||
import { GOOGLE_PROVIDER_NAME } from './providers/google.js';
|
|
||||||
import { LOCAL_PROVIDER_NAME } from './providers/local.js';
|
|
||||||
import type { ResolveFontOptions } from './types.js';
|
import type { ResolveFontOptions } from './types.js';
|
||||||
|
|
||||||
|
export const GOOGLE_PROVIDER_NAME = 'google';
|
||||||
|
export const LOCAL_PROVIDER_NAME = 'local';
|
||||||
|
|
||||||
export const BUILTIN_PROVIDERS = [GOOGLE_PROVIDER_NAME, LOCAL_PROVIDER_NAME] as const;
|
export const BUILTIN_PROVIDERS = [GOOGLE_PROVIDER_NAME, LOCAL_PROVIDER_NAME] as const;
|
||||||
|
|
||||||
export const DEFAULTS: ResolveFontOptions = {
|
export const DEFAULTS: ResolveFontOptions = {
|
||||||
|
|
148
packages/astro/src/assets/fonts/load.ts
Normal file
148
packages/astro/src/assets/fonts/load.ts
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolveLocalFont } from './providers/local.js';
|
||||||
|
import { resolveProviders, type ResolveMod } from './providers/utils.js';
|
||||||
|
import { generateFallbacksCSS, generateFontFace, proxyURL, type ProxyURLOptions } from './utils.js';
|
||||||
|
import * as unifont from 'unifont';
|
||||||
|
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
|
||||||
|
import { DEFAULTS, LOCAL_PROVIDER_NAME } from './constants.js';
|
||||||
|
import type { FontFamily, FontProvider, PreloadData } from './types.js';
|
||||||
|
import type { Storage } from 'unstorage';
|
||||||
|
|
||||||
|
interface Options
|
||||||
|
extends Pick<Parameters<typeof proxyURL>[0], 'hashString'>,
|
||||||
|
Pick<Parameters<typeof generateFallbacksCSS>[0], 'generateFontFace' | 'getMetricsForFamily'> {
|
||||||
|
root: URL;
|
||||||
|
base: string;
|
||||||
|
providers: Array<FontProvider<string>>;
|
||||||
|
families: Array<FontFamily<'local' | 'custom'>>;
|
||||||
|
storage: Storage;
|
||||||
|
hashToUrlMap: Map<string, string>;
|
||||||
|
resolvedMap: Map<string, { preloadData: PreloadData; css: string }>;
|
||||||
|
resolveMod: ResolveMod;
|
||||||
|
log: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFonts({
|
||||||
|
root,
|
||||||
|
base,
|
||||||
|
providers,
|
||||||
|
families,
|
||||||
|
storage,
|
||||||
|
hashToUrlMap,
|
||||||
|
resolvedMap,
|
||||||
|
resolveMod,
|
||||||
|
hashString,
|
||||||
|
generateFontFace: generateFallbackFontFace,
|
||||||
|
getMetricsForFamily,
|
||||||
|
log,
|
||||||
|
}: Options): Promise<void> {
|
||||||
|
const resolved = await resolveProviders({
|
||||||
|
root,
|
||||||
|
providers,
|
||||||
|
resolveMod,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resolveFont } = await unifont.createUnifont(
|
||||||
|
resolved.map((e) => e.provider(e.config)),
|
||||||
|
{ storage },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const family of families) {
|
||||||
|
const preloadData: PreloadData = [];
|
||||||
|
let css = '';
|
||||||
|
|
||||||
|
// 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 = base + hash;
|
||||||
|
if (!hashToUrlMap.has(hash)) {
|
||||||
|
hashToUrlMap.set(hash, value);
|
||||||
|
preloadData.push({ url, type });
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
let fonts: Array<unifont.FontFaceData>;
|
||||||
|
|
||||||
|
if (family.provider === LOCAL_PROVIDER_NAME) {
|
||||||
|
const result = 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 hashString(v + content);
|
||||||
|
},
|
||||||
|
collect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
// No default fallback to be used here
|
||||||
|
fallbacks: family.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],
|
||||||
|
);
|
||||||
|
|
||||||
|
fonts = result.fonts.map((font) => ({
|
||||||
|
...font,
|
||||||
|
src: font.src.map((source) =>
|
||||||
|
'name' in source
|
||||||
|
? source
|
||||||
|
: {
|
||||||
|
...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,
|
||||||
|
collect,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const data of fonts) {
|
||||||
|
css += generateFontFace(family.name, data);
|
||||||
|
}
|
||||||
|
const urls = fonts
|
||||||
|
.flatMap((font) => font.src.map((src) => ('originalURL' in src ? src.originalURL : null)))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const fallbackData = await generateFallbacksCSS({
|
||||||
|
family: family.name,
|
||||||
|
fallbacks: family.fallbacks ?? [],
|
||||||
|
fontURL: urls.at(0) ?? null,
|
||||||
|
getMetricsForFamily,
|
||||||
|
generateFontFace: generateFallbackFontFace,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fallbackData) {
|
||||||
|
css += fallbackData.css;
|
||||||
|
// TODO: generate css var
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedMap.set(family.name, { preloadData, css });
|
||||||
|
}
|
||||||
|
log('Fonts initialized');
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
|
import { GOOGLE_PROVIDER_NAME } from '../constants.js';
|
||||||
import { defineFontProvider } from './index.js';
|
import { defineFontProvider } from './index.js';
|
||||||
|
|
||||||
export const GOOGLE_PROVIDER_NAME = 'google';
|
|
||||||
|
|
||||||
// TODO: https://github.com/unjs/unifont/issues/108
|
// TODO: https://github.com/unjs/unifont/issues/108
|
||||||
// This provider downloads too many files when there's a variable font
|
// This provider downloads too many files when there's a variable font
|
||||||
// available. This is bad because it doesn't align with our default font settings
|
// available. This is bad because it doesn't align with our default font settings
|
||||||
|
|
|
@ -8,8 +8,6 @@ import { extractFontType } from '../utils.js';
|
||||||
// https://github.com/nuxt/fonts/blob/main/src/providers/local.ts
|
// https://github.com/nuxt/fonts/blob/main/src/providers/local.ts
|
||||||
// https://github.com/unjs/unifont/blob/main/src/providers/google.ts
|
// https://github.com/unjs/unifont/blob/main/src/providers/google.ts
|
||||||
|
|
||||||
export const LOCAL_PROVIDER_NAME = 'local';
|
|
||||||
|
|
||||||
type InitializedProvider = NonNullable<Awaited<ReturnType<unifont.Provider>>>;
|
type InitializedProvider = NonNullable<Awaited<ReturnType<unifont.Provider>>>;
|
||||||
|
|
||||||
type ResolveFontResult = NonNullable<Awaited<ReturnType<InitializedProvider['resolveFont']>>>;
|
type ResolveFontResult = NonNullable<Awaited<ReturnType<InitializedProvider['resolveFont']>>>;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { google } from './google.js';
|
import { google } from './google.js';
|
||||||
import type { FontProvider, ResolvedFontProvider } from '../types.js';
|
import type { FontProvider, ResolvedFontProvider } from '../types.js';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
export function resolveEntrypoint(root: URL, entrypoint: string): string {
|
export function resolveEntrypoint(root: URL, entrypoint: string): string {
|
||||||
const require = createRequire(root);
|
const require = createRequire(root);
|
||||||
|
@ -43,7 +43,7 @@ export async function resolveProviders({
|
||||||
const resolvedProviders: Array<ResolvedFontProvider> = [];
|
const resolvedProviders: Array<ResolvedFontProvider> = [];
|
||||||
|
|
||||||
for (const { name, entrypoint, config } of providers) {
|
for (const { name, entrypoint, config } of providers) {
|
||||||
const id = resolveEntrypoint(root, entrypoint.toString());
|
const id = pathToFileURL(resolveEntrypoint(root, entrypoint.toString())).href;
|
||||||
const mod = await resolveMod(id);
|
const mod = await resolveMod(id);
|
||||||
const { provider } = validateMod(mod);
|
const { provider } = validateMod(mod);
|
||||||
resolvedProviders.push({ name, config, provider });
|
resolvedProviders.push({ name, config, provider });
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type { BUILTIN_PROVIDERS, FONT_TYPES } from './constants.js';
|
import type {
|
||||||
import type { GOOGLE_PROVIDER_NAME } from './providers/google.js';
|
GOOGLE_PROVIDER_NAME,
|
||||||
import type { LOCAL_PROVIDER_NAME } from './providers/local.js';
|
LOCAL_PROVIDER_NAME,
|
||||||
|
BUILTIN_PROVIDERS,
|
||||||
|
FONT_TYPES,
|
||||||
|
} from './constants.js';
|
||||||
import type * as unifont from 'unifont';
|
import type * as unifont from 'unifont';
|
||||||
import type { resolveFontOptionsSchema } from './config.js';
|
import type { resolveFontOptionsSchema } from './config.js';
|
||||||
|
|
||||||
|
@ -44,3 +47,17 @@ export type GoogleProviderName = typeof GOOGLE_PROVIDER_NAME;
|
||||||
export type BuiltInProvider = (typeof BUILTIN_PROVIDERS)[number];
|
export type BuiltInProvider = (typeof BUILTIN_PROVIDERS)[number];
|
||||||
|
|
||||||
export type FontType = (typeof FONT_TYPES)[number];
|
export type FontType = (typeof FONT_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload data is used for links generation inside the <Font /> component
|
||||||
|
*/
|
||||||
|
export type PreloadData = Array<{
|
||||||
|
/**
|
||||||
|
* Absolute link to a font file, eg. /_astro/fonts/abc.woff
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* A font type, eg. woff2, woff, ttf...
|
||||||
|
*/
|
||||||
|
type: FontType;
|
||||||
|
}>;
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { extname } from 'node:path';
|
||||||
import { DEFAULT_FALLBACKS, FONT_TYPES } from './constants.js';
|
import { DEFAULT_FALLBACKS, FONT_TYPES } from './constants.js';
|
||||||
import type { Storage } from 'unstorage';
|
import type { Storage } from 'unstorage';
|
||||||
import type * as fontaine from 'fontaine';
|
import type * as fontaine from 'fontaine';
|
||||||
|
import type { Logger } from '../../core/logger/core.js';
|
||||||
|
|
||||||
// TODO: expose all relevant options in config
|
// TODO: expose all relevant options in config
|
||||||
// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L7-L21
|
// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L7-L21
|
||||||
|
@ -58,23 +59,20 @@ export function isFontType(str: string): str is FontType {
|
||||||
return (FONT_TYPES as Readonly<Array<string>>).includes(str);
|
return (FONT_TYPES as Readonly<Array<string>>).includes(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCache(storage: Storage) {
|
export async function cache(
|
||||||
return async function cache(
|
storage: Storage,
|
||||||
key: string,
|
key: string,
|
||||||
cb: () => Promise<Buffer>,
|
cb: () => Promise<Buffer>,
|
||||||
): Promise<{ cached: boolean; data: Buffer }> {
|
): Promise<{ cached: boolean; data: Buffer }> {
|
||||||
const existing = await storage.getItemRaw(key);
|
const existing = await storage.getItemRaw(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return { cached: true, data: existing };
|
return { cached: true, data: existing };
|
||||||
}
|
}
|
||||||
const data = await cb();
|
const data = await cb();
|
||||||
await storage.setItemRaw(key, data);
|
await storage.setItemRaw(key, data);
|
||||||
return { cached: false, data };
|
return { cached: false, data };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CacheHandler = ReturnType<typeof createCache>;
|
|
||||||
|
|
||||||
export interface ProxyURLOptions {
|
export interface ProxyURLOptions {
|
||||||
/**
|
/**
|
||||||
* The original URL
|
* The original URL
|
||||||
|
@ -179,9 +177,59 @@ export async function generateFallbacksCSS({
|
||||||
font: fallback,
|
font: fallback,
|
||||||
// TODO: support family.as
|
// TODO: support family.as
|
||||||
name: `${family} fallback: ${fallback}`,
|
name: `${family} fallback: ${fallback}`,
|
||||||
metrics: (await getMetricsForFamily(fallback, null)) ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { css, fallbacks };
|
return { css, fallbacks };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We want to show logs related to font downloading (fresh or from cache)
|
||||||
|
* However if we just use the logger as is, there are too many logs, and not
|
||||||
|
* so useful.
|
||||||
|
* This log manager allows avoiding repetitive logs:
|
||||||
|
* - If there are many downloads started at once, only one log is shown for start and end
|
||||||
|
* - If a given file has already been logged, it won't show up anymore (useful in dev)
|
||||||
|
*/
|
||||||
|
// TODO: test
|
||||||
|
export function createLogManager(logger: Logger) {
|
||||||
|
const done = new Set<string>();
|
||||||
|
const items = new Set<string>();
|
||||||
|
let id: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: (value: string) => {
|
||||||
|
if (done.has(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.size === 0 && id === null) {
|
||||||
|
logger.info('assets', 'Downloading fonts...');
|
||||||
|
}
|
||||||
|
items.add(value);
|
||||||
|
if (id) {
|
||||||
|
clearTimeout(id);
|
||||||
|
id = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: (value: string, cached: boolean) => {
|
||||||
|
if (done.has(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.delete(value);
|
||||||
|
done.add(value);
|
||||||
|
if (id) {
|
||||||
|
clearTimeout(id);
|
||||||
|
id = null;
|
||||||
|
}
|
||||||
|
id = setTimeout(() => {
|
||||||
|
let msg = 'Done';
|
||||||
|
if (cached) {
|
||||||
|
msg += ' (loaded from cache)';
|
||||||
|
}
|
||||||
|
logger.info('assets', msg);
|
||||||
|
}, 50);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -1,23 +1,13 @@
|
||||||
import type { Plugin } from 'vite';
|
import type { Plugin } from 'vite';
|
||||||
import type { AstroSettings } from '../../types/astro.js';
|
import type { AstroSettings } from '../../types/astro.js';
|
||||||
import { resolveProviders, type ResolveMod } from './providers/utils.js';
|
import type { ResolveMod } from './providers/utils.js';
|
||||||
import * as unifont from 'unifont';
|
import type { PreloadData } from './types.js';
|
||||||
import type { FontFamily, FontProvider, FontType } from './types.js';
|
|
||||||
import xxhash from 'xxhash-wasm';
|
import xxhash from 'xxhash-wasm';
|
||||||
import { isAbsolute } from 'node:path';
|
import { isAbsolute } from 'node:path';
|
||||||
import { getClientOutputDirectory } from '../../prerender/utils.js';
|
import { getClientOutputDirectory } from '../../prerender/utils.js';
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { cache, createLogManager, extractFontType } from './utils.js';
|
||||||
import {
|
import {
|
||||||
generateFontFace,
|
|
||||||
createCache,
|
|
||||||
type CacheHandler,
|
|
||||||
proxyURL,
|
|
||||||
extractFontType,
|
|
||||||
type ProxyURLOptions,
|
|
||||||
generateFallbacksCSS,
|
|
||||||
} from './utils.js';
|
|
||||||
import {
|
|
||||||
DEFAULTS,
|
|
||||||
VIRTUAL_MODULE_ID,
|
VIRTUAL_MODULE_ID,
|
||||||
RESOLVED_VIRTUAL_MODULE_ID,
|
RESOLVED_VIRTUAL_MODULE_ID,
|
||||||
URL_PREFIX,
|
URL_PREFIX,
|
||||||
|
@ -27,12 +17,13 @@ import { removeTrailingForwardSlash } from '@astrojs/internal-helpers/path';
|
||||||
import type { Logger } from '../../core/logger/core.js';
|
import type { Logger } from '../../core/logger/core.js';
|
||||||
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
|
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
|
||||||
import { createViteLoader } from '../../core/module-loader/vite.js';
|
import { createViteLoader } from '../../core/module-loader/vite.js';
|
||||||
import { resolveLocalFont, LOCAL_PROVIDER_NAME, LocalFontsWatcher } from './providers/local.js';
|
import { LocalFontsWatcher } from './providers/local.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { createStorage } from 'unstorage';
|
import { createStorage, type Storage } from 'unstorage';
|
||||||
import fsLiteDriver from 'unstorage/drivers/fs-lite';
|
import fsLiteDriver from 'unstorage/drivers/fs-lite';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import * as fontaine from 'fontaine';
|
import * as fontaine from 'fontaine';
|
||||||
|
import { loadFonts } from './load.js';
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
settings: AstroSettings;
|
settings: AstroSettings;
|
||||||
|
@ -40,71 +31,6 @@ interface Options {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Preload data is used for links generation inside the <Font /> component
|
|
||||||
*/
|
|
||||||
type PreloadData = Array<{
|
|
||||||
/**
|
|
||||||
* Absolute link to a font file, eg. /_astro/fonts/abc.woff
|
|
||||||
*/
|
|
||||||
url: string;
|
|
||||||
/**
|
|
||||||
* A font type, eg. woff2, woff, ttf...
|
|
||||||
*/
|
|
||||||
type: FontType;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We want to show logs related to font downloading (fresh or from cache)
|
|
||||||
* However if we just use the logger as is, there are too many logs, and not
|
|
||||||
* so useful.
|
|
||||||
* This log manager allows avoiding repetitive logs:
|
|
||||||
* - If there are many downloads started at once, only one log is shown for start and end
|
|
||||||
* - If a given file has already been logged, it won't show up anymore (useful in dev)
|
|
||||||
*/
|
|
||||||
// TODO: test
|
|
||||||
const createLogManager = (logger: Logger) => {
|
|
||||||
const done = new Set<string>();
|
|
||||||
const items = new Set<string>();
|
|
||||||
let id: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
add: (value: string) => {
|
|
||||||
if (done.has(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.size === 0 && id === null) {
|
|
||||||
logger.info('assets', 'Downloading fonts...');
|
|
||||||
}
|
|
||||||
items.add(value);
|
|
||||||
if (id) {
|
|
||||||
clearTimeout(id);
|
|
||||||
id = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
remove: (value: string, cached: boolean) => {
|
|
||||||
if (done.has(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.delete(value);
|
|
||||||
done.add(value);
|
|
||||||
if (id) {
|
|
||||||
clearTimeout(id);
|
|
||||||
id = null;
|
|
||||||
}
|
|
||||||
id = setTimeout(() => {
|
|
||||||
let msg = 'Done';
|
|
||||||
if (cached) {
|
|
||||||
msg += ' (loaded from cache)';
|
|
||||||
}
|
|
||||||
logger.info('assets', msg);
|
|
||||||
}, 50);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
async function fetchFont(url: string): Promise<Buffer> {
|
async function fetchFont(url: string): Promise<Buffer> {
|
||||||
try {
|
try {
|
||||||
if (isAbsolute(url)) {
|
if (isAbsolute(url)) {
|
||||||
|
@ -138,10 +64,6 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers: Array<FontProvider<string>> = settings.config.experimental.fonts.providers ?? [];
|
|
||||||
const families: Array<FontFamily<'local' | 'custom'>> = settings.config.experimental.fonts
|
|
||||||
.families as any;
|
|
||||||
|
|
||||||
// We don't need to take the trailing slash and build output configuration options
|
// We don't need to take the trailing slash and build output configuration options
|
||||||
// into account because we only serve (dev) or write (build) static assets (equivalent
|
// into account because we only serve (dev) or write (build) static assets (equivalent
|
||||||
// to trailingSlash: never)
|
// to trailingSlash: never)
|
||||||
|
@ -153,163 +75,45 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
||||||
// to download the original file, or retrieve it from cache
|
// to download the original file, or retrieve it from cache
|
||||||
let hashToUrlMap: Map<string, string> | null = null;
|
let hashToUrlMap: Map<string, string> | null = null;
|
||||||
let isBuild: boolean;
|
let isBuild: boolean;
|
||||||
let cache: CacheHandler | null = null;
|
let storage: Storage | null = null;
|
||||||
|
|
||||||
// TODO: refactor to allow testing
|
|
||||||
async function initialize({ resolveMod, base }: { resolveMod: ResolveMod; base: URL }) {
|
async function initialize({ resolveMod, base }: { resolveMod: ResolveMod; base: URL }) {
|
||||||
const { h64ToString } = await xxhash();
|
const { h64ToString } = await xxhash();
|
||||||
|
|
||||||
const resolved = await resolveProviders({
|
storage = createStorage({
|
||||||
root: settings.config.root,
|
// Types are weirly exported
|
||||||
providers,
|
|
||||||
resolveMod,
|
|
||||||
});
|
|
||||||
|
|
||||||
const storage = createStorage({
|
|
||||||
driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({
|
driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({
|
||||||
base: fileURLToPath(base),
|
base: fileURLToPath(base),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
cache = createCache(storage);
|
|
||||||
|
|
||||||
const { resolveFont } = await unifont.createUnifont(
|
|
||||||
resolved.map((e) => e.provider(e.config)),
|
|
||||||
{ storage },
|
|
||||||
);
|
|
||||||
|
|
||||||
// We initialize shared variables here and reset them in buildEnd
|
// We initialize shared variables here and reset them in buildEnd
|
||||||
// to avoid locking memory
|
// to avoid locking memory
|
||||||
resolvedMap = new Map();
|
|
||||||
hashToUrlMap = new Map();
|
hashToUrlMap = new Map();
|
||||||
const preloadData: PreloadData = [];
|
resolvedMap = new Map();
|
||||||
let css = '';
|
|
||||||
|
|
||||||
// When going through the urls/filepaths returned by providers,
|
await loadFonts({
|
||||||
// We save the hash and the associated original value so we can use
|
root: settings.config.root,
|
||||||
// it in the vite middleware during development
|
base: baseUrl,
|
||||||
const collect: ProxyURLOptions['collect'] = ({ hash, type, value }) => {
|
providers: settings.config.experimental.fonts!.providers ?? [],
|
||||||
const url = baseUrl + hash;
|
// TS is not smart enough
|
||||||
if (!hashToUrlMap!.has(hash)) {
|
families: settings.config.experimental.fonts!.families as any,
|
||||||
hashToUrlMap!.set(hash, value);
|
storage,
|
||||||
preloadData.push({ url, type });
|
hashToUrlMap,
|
||||||
}
|
resolvedMap,
|
||||||
return url;
|
resolveMod,
|
||||||
};
|
hashString: h64ToString,
|
||||||
|
getMetricsForFamily: async (name, fontURL) => {
|
||||||
// TODO: refactor to avoid repetition
|
let metrics = await fontaine.getMetricsForFamily(name);
|
||||||
for (const family of families) {
|
if (fontURL && !metrics) {
|
||||||
// Reset
|
// TODO: investigate in using capsize directly (fromBlob) to be able to cache
|
||||||
preloadData.length = 0;
|
metrics = await fontaine.readMetrics(fontURL);
|
||||||
css = '';
|
|
||||||
|
|
||||||
if (family.provider === LOCAL_PROVIDER_NAME) {
|
|
||||||
const { fonts } = 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);
|
|
||||||
}
|
}
|
||||||
const urls = fonts
|
return metrics;
|
||||||
.flatMap((font) => font.src.map((src) => ('originalURL' in src ? src.originalURL : null)))
|
},
|
||||||
.filter(Boolean);
|
generateFontFace: fontaine.generateFontFace,
|
||||||
|
log: (message) => logger.info('assets', message),
|
||||||
const fallbackData = await generateFallbacksCSS({
|
});
|
||||||
family: family.name,
|
|
||||||
fallbacks: family.fallbacks ?? [],
|
|
||||||
fontURL: urls.at(0) ?? null,
|
|
||||||
getMetricsForFamily: async (name, fontURL) => {
|
|
||||||
let metrics = await fontaine.getMetricsForFamily(name);
|
|
||||||
if (fontURL && !metrics) {
|
|
||||||
// TODO: investigate in using capsize directly (fromBlob) to be able to cache
|
|
||||||
metrics = await fontaine.readMetrics(fontURL);
|
|
||||||
}
|
|
||||||
return metrics;
|
|
||||||
},
|
|
||||||
generateFontFace: fontaine.generateFontFace,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fallbackData) {
|
|
||||||
css += fallbackData.css;
|
|
||||||
// TODO: generate css var
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const { fonts } = 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,
|
|
||||||
// No default fallback to be used here
|
|
||||||
fallbacks: family.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 fonts) {
|
|
||||||
for (const source of data.src) {
|
|
||||||
if ('name' in source) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
source.originalURL = 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const urls = fonts
|
|
||||||
.map((font) => font.src.map((src) => ('originalURL' in src ? src.originalURL : null)))
|
|
||||||
.flat()
|
|
||||||
.filter((url) => typeof url === 'string');
|
|
||||||
|
|
||||||
const fallbackData = await generateFallbacksCSS({
|
|
||||||
family: family.name,
|
|
||||||
fallbacks: family.fallbacks ?? [],
|
|
||||||
fontURL: urls.at(0) ?? null,
|
|
||||||
getMetricsForFamily: async (name, fontURL) => {
|
|
||||||
let metrics = await fontaine.getMetricsForFamily(name);
|
|
||||||
if (fontURL && !metrics) {
|
|
||||||
metrics = await fontaine.readMetrics(fontURL);
|
|
||||||
}
|
|
||||||
return metrics;
|
|
||||||
},
|
|
||||||
generateFontFace: fontaine.generateFontFace,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fallbackData) {
|
|
||||||
css += fallbackData.css;
|
|
||||||
// TODO: generate css var
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolvedMap.set(family.name, { preloadData: [...preloadData], css });
|
|
||||||
}
|
|
||||||
logger.info('assets', 'Fonts initialized');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -360,10 +164,10 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
logManager.add(hash);
|
logManager.add(hash);
|
||||||
// Cache should be defined at this point since initialize it called before registering
|
// 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,
|
// 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.
|
// no url will be matched and this line will not be reached.
|
||||||
const { cached, data } = await cache!(hash, () => fetchFont(url));
|
const { cached, data } = await cache(storage!, hash, () => fetchFont(url));
|
||||||
logManager.remove(hash, cached);
|
logManager.remove(hash, cached);
|
||||||
|
|
||||||
res.setHeader('Content-Length', data.length);
|
res.setHeader('Content-Length', data.length);
|
||||||
|
@ -394,7 +198,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
||||||
|
|
||||||
if (sync) {
|
if (sync) {
|
||||||
hashToUrlMap = null;
|
hashToUrlMap = null;
|
||||||
cache = null;
|
storage = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -412,7 +216,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(hashToUrlMap.entries()).map(async ([hash, url]) => {
|
Array.from(hashToUrlMap.entries()).map(async ([hash, url]) => {
|
||||||
logManager.add(hash);
|
logManager.add(hash);
|
||||||
const { cached, data } = await cache!(hash, () => fetchFont(url));
|
const { cached, data } = await cache(storage!, hash, () => fetchFont(url));
|
||||||
logManager.remove(hash, cached);
|
logManager.remove(hash, cached);
|
||||||
try {
|
try {
|
||||||
writeFileSync(new URL(hash, fontsDir), data);
|
writeFileSync(new URL(hash, fontsDir), data);
|
||||||
|
@ -424,7 +228,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
hashToUrlMap = null;
|
hashToUrlMap = null;
|
||||||
cache = null;
|
storage = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,10 +14,12 @@ import type { SvgRenderMode } from '../../assets/utils/svg.js';
|
||||||
import { EnvSchema } from '../../env/schema.js';
|
import { EnvSchema } from '../../env/schema.js';
|
||||||
import type { AstroUserConfig, ViteUserConfig } from '../../types/public/config.js';
|
import type { AstroUserConfig, ViteUserConfig } from '../../types/public/config.js';
|
||||||
import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../path.js';
|
import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../path.js';
|
||||||
import { BUILTIN_PROVIDERS } from '../../assets/fonts/constants.js';
|
import {
|
||||||
|
BUILTIN_PROVIDERS,
|
||||||
|
GOOGLE_PROVIDER_NAME,
|
||||||
|
LOCAL_PROVIDER_NAME,
|
||||||
|
} from '../../assets/fonts/constants.js';
|
||||||
import { resolveFontOptionsSchema } from '../../assets/fonts/config.js';
|
import { resolveFontOptionsSchema } from '../../assets/fonts/config.js';
|
||||||
import { GOOGLE_PROVIDER_NAME } from '../../assets/fonts/providers/google.js';
|
|
||||||
import { LOCAL_PROVIDER_NAME } from '../../assets/fonts/providers/local.js';
|
|
||||||
|
|
||||||
// The below types are required boilerplate to workaround a Zod issue since v3.21.2. Since that version,
|
// The below types are required boilerplate to workaround a Zod issue since v3.21.2. Since that version,
|
||||||
// Zod's compiled TypeScript would "simplify" certain values to their base representation, causing references
|
// Zod's compiled TypeScript would "simplify" certain values to their base representation, causing references
|
||||||
|
|
85
packages/astro/test/units/assets/fonts/load.test.js
Normal file
85
packages/astro/test/units/assets/fonts/load.test.js
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
// @ts-check
|
||||||
|
import { it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { loadFonts } from '../../../../dist/assets/fonts/load.js';
|
||||||
|
|
||||||
|
it('loadFonts()', async () => {
|
||||||
|
const root = new URL(import.meta.url);
|
||||||
|
const base = '/test';
|
||||||
|
/** @type {Map<string, any>} */
|
||||||
|
const store = new Map();
|
||||||
|
/** @type {import('unstorage').Storage} */
|
||||||
|
// @ts-expect-error
|
||||||
|
const storage = {
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {Promise<any | null>}
|
||||||
|
*/
|
||||||
|
getItem: async (key) => {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {Promise<any | null>}
|
||||||
|
*/
|
||||||
|
getItemRaw: async (key) => {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {any} value
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
setItemRaw: async (key, value) => {
|
||||||
|
store.set(key, value);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {any} value
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
setItem: async (key, value) => {
|
||||||
|
store.set(key, value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const hashToUrlMap = new Map();
|
||||||
|
const resolvedMap = new Map();
|
||||||
|
/** @type {Array<string>} */
|
||||||
|
const logs = [];
|
||||||
|
|
||||||
|
await loadFonts({
|
||||||
|
root,
|
||||||
|
base,
|
||||||
|
providers: [],
|
||||||
|
families: [
|
||||||
|
{
|
||||||
|
name: 'Roboto',
|
||||||
|
// @ts-expect-error we do weird typings internally for "reasons" (provider is typed as "local" | "custom") but this is valid
|
||||||
|
provider: 'google',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
storage,
|
||||||
|
hashToUrlMap,
|
||||||
|
resolvedMap,
|
||||||
|
resolveMod: async (id) => {
|
||||||
|
if (id === '/CUSTOM') {
|
||||||
|
return { provider: () => {} };
|
||||||
|
}
|
||||||
|
return await import(id);
|
||||||
|
},
|
||||||
|
hashString: (v) => Buffer.from(v).toString('base64'),
|
||||||
|
getMetricsForFamily: async () => null,
|
||||||
|
generateFontFace: () => '',
|
||||||
|
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()), ['Roboto']);
|
||||||
|
assert.deepStrictEqual(logs, ['Fonts initialized']);
|
||||||
|
});
|
|
@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
isFontType,
|
isFontType,
|
||||||
extractFontType,
|
extractFontType,
|
||||||
createCache,
|
cache as internalCache,
|
||||||
proxyURL,
|
proxyURL,
|
||||||
isGenericFontFamily,
|
isGenericFontFamily,
|
||||||
generateFallbacksCSS,
|
generateFallbacksCSS,
|
||||||
|
@ -31,12 +31,23 @@ function createSpyCache() {
|
||||||
store.set(key, value);
|
store.set(key, value);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const cache = createCache(
|
|
||||||
// @ts-expect-error we only mock the required hooks
|
|
||||||
storage,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { cache, getKeys: () => Array.from(store.keys()) };
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Parameters<typeof internalCache>[1]} key
|
||||||
|
* @param {Parameters<typeof internalCache>[2]} cb
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
cache: (key, cb) =>
|
||||||
|
internalCache(
|
||||||
|
// @ts-expect-error we only mock the required hooks
|
||||||
|
storage,
|
||||||
|
key,
|
||||||
|
cb,
|
||||||
|
),
|
||||||
|
getKeys: () => Array.from(store.keys()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -105,7 +116,7 @@ describe('fonts utils', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('createCache()', async () => {
|
it('cache()', async () => {
|
||||||
const { cache, getKeys } = createSpyCache();
|
const { cache, getKeys } = createSpyCache();
|
||||||
|
|
||||||
assert.deepStrictEqual(getKeys(), []);
|
assert.deepStrictEqual(getKeys(), []);
|
||||||
|
|
Loading…
Add table
Reference in a new issue