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';
|
||||
|
||||
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 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';
|
||||
|
||||
export const GOOGLE_PROVIDER_NAME = 'google';
|
||||
|
||||
// TODO: https://github.com/unjs/unifont/issues/108
|
||||
// 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
|
||||
|
|
|
@ -8,8 +8,6 @@ import { extractFontType } from '../utils.js';
|
|||
// https://github.com/nuxt/fonts/blob/main/src/providers/local.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 ResolveFontResult = NonNullable<Awaited<ReturnType<InitializedProvider['resolveFont']>>>;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { createRequire } from 'node:module';
|
||||
import { google } from './google.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 {
|
||||
const require = createRequire(root);
|
||||
|
@ -43,7 +43,7 @@ export async function resolveProviders({
|
|||
const resolvedProviders: Array<ResolvedFontProvider> = [];
|
||||
|
||||
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 { provider } = validateMod(mod);
|
||||
resolvedProviders.push({ name, config, provider });
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import type { z } from 'zod';
|
||||
import type { BUILTIN_PROVIDERS, FONT_TYPES } from './constants.js';
|
||||
import type { GOOGLE_PROVIDER_NAME } from './providers/google.js';
|
||||
import type { LOCAL_PROVIDER_NAME } from './providers/local.js';
|
||||
import type {
|
||||
GOOGLE_PROVIDER_NAME,
|
||||
LOCAL_PROVIDER_NAME,
|
||||
BUILTIN_PROVIDERS,
|
||||
FONT_TYPES,
|
||||
} from './constants.js';
|
||||
import type * as unifont from 'unifont';
|
||||
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 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 type { Storage } from 'unstorage';
|
||||
import type * as fontaine from 'fontaine';
|
||||
import type { Logger } from '../../core/logger/core.js';
|
||||
|
||||
// TODO: expose all relevant options in config
|
||||
// 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);
|
||||
}
|
||||
|
||||
export function createCache(storage: Storage) {
|
||||
return async function cache(
|
||||
key: string,
|
||||
cb: () => Promise<Buffer>,
|
||||
): Promise<{ cached: boolean; data: Buffer }> {
|
||||
const existing = await storage.getItemRaw(key);
|
||||
if (existing) {
|
||||
return { cached: true, data: existing };
|
||||
}
|
||||
const data = await cb();
|
||||
await storage.setItemRaw(key, data);
|
||||
return { cached: false, data };
|
||||
};
|
||||
export async function cache(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
cb: () => Promise<Buffer>,
|
||||
): Promise<{ cached: boolean; data: Buffer }> {
|
||||
const existing = await storage.getItemRaw(key);
|
||||
if (existing) {
|
||||
return { cached: true, data: existing };
|
||||
}
|
||||
const data = await cb();
|
||||
await storage.setItemRaw(key, data);
|
||||
return { cached: false, data };
|
||||
}
|
||||
|
||||
export type CacheHandler = ReturnType<typeof createCache>;
|
||||
|
||||
export interface ProxyURLOptions {
|
||||
/**
|
||||
* The original URL
|
||||
|
@ -179,9 +177,59 @@ export async function generateFallbacksCSS({
|
|||
font: fallback,
|
||||
// TODO: support family.as
|
||||
name: `${family} fallback: ${fallback}`,
|
||||
metrics: (await getMetricsForFamily(fallback, null)) ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
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 { AstroSettings } from '../../types/astro.js';
|
||||
import { resolveProviders, type ResolveMod } from './providers/utils.js';
|
||||
import * as unifont from 'unifont';
|
||||
import type { FontFamily, FontProvider, FontType } from './types.js';
|
||||
import type { ResolveMod } from './providers/utils.js';
|
||||
import type { PreloadData } from './types.js';
|
||||
import xxhash from 'xxhash-wasm';
|
||||
import { isAbsolute } from 'node:path';
|
||||
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 {
|
||||
generateFontFace,
|
||||
createCache,
|
||||
type CacheHandler,
|
||||
proxyURL,
|
||||
extractFontType,
|
||||
type ProxyURLOptions,
|
||||
generateFallbacksCSS,
|
||||
} from './utils.js';
|
||||
import {
|
||||
DEFAULTS,
|
||||
VIRTUAL_MODULE_ID,
|
||||
RESOLVED_VIRTUAL_MODULE_ID,
|
||||
URL_PREFIX,
|
||||
|
@ -27,12 +17,13 @@ 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 { resolveLocalFont, LOCAL_PROVIDER_NAME, LocalFontsWatcher } from './providers/local.js';
|
||||
import { LocalFontsWatcher } from './providers/local.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createStorage } from 'unstorage';
|
||||
import { createStorage, type Storage } from 'unstorage';
|
||||
import fsLiteDriver from 'unstorage/drivers/fs-lite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fontaine from 'fontaine';
|
||||
import { loadFonts } from './load.js';
|
||||
|
||||
interface Options {
|
||||
settings: AstroSettings;
|
||||
|
@ -40,71 +31,6 @@ interface Options {
|
|||
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> {
|
||||
try {
|
||||
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
|
||||
// into account because we only serve (dev) or write (build) static assets (equivalent
|
||||
// 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
|
||||
let hashToUrlMap: Map<string, string> | null = null;
|
||||
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 }) {
|
||||
const { h64ToString } = await xxhash();
|
||||
|
||||
const resolved = await resolveProviders({
|
||||
root: settings.config.root,
|
||||
providers,
|
||||
resolveMod,
|
||||
});
|
||||
|
||||
const storage = createStorage({
|
||||
storage = createStorage({
|
||||
// Types are weirly exported
|
||||
driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({
|
||||
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
|
||||
// to avoid locking memory
|
||||
resolvedMap = new Map();
|
||||
hashToUrlMap = new Map();
|
||||
const preloadData: PreloadData = [];
|
||||
let css = '';
|
||||
resolvedMap = new Map();
|
||||
|
||||
// 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: refactor to avoid repetition
|
||||
for (const family of families) {
|
||||
// Reset
|
||||
preloadData.length = 0;
|
||||
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);
|
||||
await loadFonts({
|
||||
root: settings.config.root,
|
||||
base: baseUrl,
|
||||
providers: settings.config.experimental.fonts!.providers ?? [],
|
||||
// TS is not smart enough
|
||||
families: settings.config.experimental.fonts!.families as any,
|
||||
storage,
|
||||
hashToUrlMap,
|
||||
resolvedMap,
|
||||
resolveMod,
|
||||
hashString: h64ToString,
|
||||
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);
|
||||
}
|
||||
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: 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 metrics;
|
||||
},
|
||||
generateFontFace: fontaine.generateFontFace,
|
||||
log: (message) => logger.info('assets', message),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -360,10 +164,10 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
|||
return next();
|
||||
}
|
||||
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,
|
||||
// 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);
|
||||
|
||||
res.setHeader('Content-Length', data.length);
|
||||
|
@ -394,7 +198,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
|||
|
||||
if (sync) {
|
||||
hashToUrlMap = null;
|
||||
cache = null;
|
||||
storage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -412,7 +216,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
|||
await Promise.all(
|
||||
Array.from(hashToUrlMap.entries()).map(async ([hash, url]) => {
|
||||
logManager.add(hash);
|
||||
const { cached, data } = await cache!(hash, () => fetchFont(url));
|
||||
const { cached, data } = await cache(storage!, hash, () => fetchFont(url));
|
||||
logManager.remove(hash, cached);
|
||||
try {
|
||||
writeFileSync(new URL(hash, fontsDir), data);
|
||||
|
@ -424,7 +228,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
|||
}
|
||||
|
||||
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 type { AstroUserConfig, ViteUserConfig } from '../../types/public/config.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 { 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,
|
||||
// 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 {
|
||||
isFontType,
|
||||
extractFontType,
|
||||
createCache,
|
||||
cache as internalCache,
|
||||
proxyURL,
|
||||
isGenericFontFamily,
|
||||
generateFallbacksCSS,
|
||||
|
@ -31,12 +31,23 @@ function createSpyCache() {
|
|||
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();
|
||||
|
||||
assert.deepStrictEqual(getKeys(), []);
|
||||
|
|
Loading…
Add table
Reference in a new issue