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

feat(fonts): fallbacks (#13331)

* feat(fonts): fallbacks

* feat: local

* fix: test

* feat: isGenericFontFamily test

* feat: generateFallbackCSS test

* feat: docs

* feat: simplify

* fix

* feat: improve schema

* Discard changes to examples/basics/astro.config.mjs

* feat: address reviews
This commit is contained in:
Florian Lefebvre 2025-03-03 16:34:52 +01:00 committed by GitHub
parent 08eff8cd9e
commit 4eef143486
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 532 additions and 42 deletions

View file

@ -145,6 +145,7 @@
"esbuild": "^0.25.0",
"estree-walker": "^3.0.3",
"flattie": "^1.1.1",
"fontaine": "^0.5.0",
"github-slugger": "^2.0.0",
"html-escaper": "3.0.3",
"http-cache-semantics": "^4.1.1",

View file

@ -8,7 +8,6 @@ export const DEFAULTS: ResolveFontOptions = {
weights: ['400'],
styles: ['normal', 'italic'],
subsets: ['cyrillic-ext', 'cyrillic', 'greek-ext', 'greek', 'vietnamese', 'latin-ext', 'latin'],
fallbacks: undefined,
};
export const VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/internal';
@ -19,3 +18,20 @@ export const URL_PREFIX = '/_astro/fonts/';
export const CACHE_DIR = './fonts/';
export const FONT_TYPES = ['woff2', 'woff', 'otf', 'ttf', 'eot'] as const;
// Source: https://github.com/nuxt/fonts/blob/3a3eb6dfecc472242b3011b25f3fcbae237d0acc/src/module.ts#L55-L75
export const DEFAULT_FALLBACKS: Record<string, Array<string>> = {
serif: ['Times New Roman'],
'sans-serif': ['Arial'],
monospace: ['Courier New'],
cursive: [],
fantasy: [],
'system-ui': ['BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial'],
'ui-serif': ['Times New Roman'],
'ui-sans-serif': ['Arial'],
'ui-monospace': ['Courier New'],
'ui-rounded': [],
emoji: [],
math: [],
fangsong: [],
};

View file

@ -28,15 +28,18 @@ export function resolveLocalFont(
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),
})),
src: src.paths.map((path) => {
const originalURL = fileURLToPath(new URL(path, root));
return {
originalURL,
url: proxyURL(originalURL),
format: extractFontType(path),
};
}),
});
}
}

View file

@ -25,9 +25,9 @@ interface FontFamilyAttributes extends Partial<ResolveFontOptions> {
provider: string;
}
export interface LocalFontFamily extends Pick<FontFamilyAttributes, 'name'> {
export interface LocalFontFamily extends Pick<FontFamilyAttributes, 'name' | 'fallbacks'> {
provider: LocalProviderName;
src: Array<Partial<ResolveFontOptions> & { paths: Array<string> }>;
src: Array<Partial<Omit<ResolveFontOptions, 'fallbacks'>> & { paths: Array<string> }>;
}
interface CommonFontFamily<TProvider extends string>

View file

@ -1,8 +1,9 @@
import type * as unifont from 'unifont';
import type { FontType } from './types.js';
import { extname } from 'node:path';
import { FONT_TYPES } from './constants.js';
import { DEFAULT_FALLBACKS, FONT_TYPES } from './constants.js';
import type { Storage } from 'unstorage';
import type * as fontaine from 'fontaine';
// TODO: expose all relevant options in config
// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L7-L21
@ -81,7 +82,7 @@ export interface ProxyURLOptions {
value: string;
/**
* Specifies how the hash is computed. Can be based on the value,
* a specific string for testing etc
* a specific string for testing etc
*/
hashString: (value: string) => string;
/**
@ -111,3 +112,76 @@ export function proxyURL({ value, hashString, collect }: ProxyURLOptions): strin
// Now that we collected the original url, we return our proxy so the consumer can override it
return url;
}
export function isGenericFontFamily(str: string): str is keyof typeof DEFAULT_FALLBACKS {
return Object.keys(DEFAULT_FALLBACKS).includes(str);
}
type FontFaceMetrics = Parameters<typeof fontaine.generateFontFace>[0];
/**
* Generates CSS for a given family fallbacks if possible.
*
* It works by trying to get metrics (using fontaine) of the provided font family.
* If some can be computed, they will be applied to the eligible fallbacks to match
* the original font shape as close as possible.
*/
export async function generateFallbacksCSS({
family,
fallbacks: _fallbacks,
fontURL,
getMetricsForFamily,
// eslint-disable-next-line @typescript-eslint/no-shadow
generateFontFace,
}: {
/** The family name */
family: string;
/** The family fallbacks */
fallbacks: Array<string>;
/** A remote url or local filepath to a font file. Used if metrics can't be resolved purely from the family name */
fontURL: string | null;
getMetricsForFamily: (family: string, fontURL: string | null) => Promise<null | FontFaceMetrics>;
generateFontFace: typeof fontaine.generateFontFace;
}): Promise<null | { css: string; fallbacks: Array<string> }> {
// We avoid mutating the original array
let fallbacks = [..._fallbacks];
if (fallbacks.length === 0) {
return null;
}
let css = '';
// The last element of the fallbacks is usually a generic family name (eg. serif)
const lastFallback = fallbacks[fallbacks.length - 1];
// If it's not a generic family name, we can't infer local fonts to be used as fallbacks
if (!isGenericFontFamily(lastFallback)) {
return { css, fallbacks };
}
// If it's a generic family name, we get the associated local fonts (eg. Arial)
const localFonts = DEFAULT_FALLBACKS[lastFallback];
// Some generic families do not have associated local fonts so we abort early
if (localFonts.length === 0) {
return { css, fallbacks };
}
const metrics = await getMetricsForFamily(family, fontURL);
if (!metrics) {
// If there are no metrics, we can't generate useful fallbacks
return { css, fallbacks };
}
// We prepend the fallbacks with the local fonts and we dedupe in case a local font is already provided
fallbacks = [...new Set([...localFonts, ...fallbacks])];
for (const fallback of localFonts) {
css += generateFontFace(metrics, {
font: fallback,
// TODO: support family.as
name: `${family} fallback: ${fallback}`,
metrics: (await getMetricsForFamily(fallback, null)) ?? undefined,
});
}
return { css, fallbacks };
}

View file

@ -14,6 +14,7 @@ import {
proxyURL,
extractFontType,
type ProxyURLOptions,
generateFallbacksCSS,
} from './utils.js';
import {
DEFAULTS,
@ -31,6 +32,7 @@ import { readFile } from 'node:fs/promises';
import { createStorage } from 'unstorage';
import fsLiteDriver from 'unstorage/drivers/fs-lite';
import { fileURLToPath } from 'node:url';
import * as fontaine from 'fontaine';
interface Options {
settings: AstroSettings;
@ -153,6 +155,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
let isBuild: boolean;
let cache: CacheHandler | null = null;
// TODO: refactor to allow testing
async function initialize({ resolveMod, base }: { resolveMod: ResolveMod; base: URL }) {
const { h64ToString } = await xxhash();
@ -194,19 +197,19 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
return url;
};
// TODO: investigate using fontaine for fallbacks
// TODO: refactor to avoid repetition
for (const family of families) {
// Reset
preloadData.length = 0;
css = '';
if (family.provider === LOCAL_PROVIDER_NAME) {
const { fonts, fallbacks } = resolveLocalFont(family, {
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.
// a given font file with completely different contents.
hashString: (v) => {
let content: string;
try {
@ -224,15 +227,39 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
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: 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, fallbacks } = await resolveFont(
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,
fallbacks: family.fallbacks ?? DEFAULTS.fallbacks,
// 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)
@ -244,6 +271,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
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
@ -254,6 +282,30 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
// 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 });
}
@ -285,7 +337,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
// 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
// version of the font file
update: () => {
logger.info('assets', 'Font file updated');
server.restart();
@ -356,18 +408,20 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
} catch (e) {
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e });
}
await Promise.all(
Array.from(hashToUrlMap!.entries()).map(async ([hash, url]) => {
logManager.add(hash);
const { cached, data } = await cache!(hash, () => fetchFont(url));
logManager.remove(hash, cached);
try {
writeFileSync(new URL(hash, fontsDir), data);
} catch (e) {
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e });
}
}),
);
if (hashToUrlMap) {
await Promise.all(
Array.from(hashToUrlMap.entries()).map(async ([hash, url]) => {
logManager.add(hash);
const { cached, data } = await cache!(hash, () => fetchFont(url));
logManager.remove(hash, cached);
try {
writeFileSync(new URL(hash, fontsDir), data);
} catch (e) {
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e });
}
}),
);
}
hashToUrlMap = null;
cache = null;

View file

@ -600,6 +600,8 @@ export const AstroConfigSchema = z.object({
}
return svgConfig;
}),
// TODO: properly test everything
fonts: z
.object({
providers: z
@ -635,10 +637,11 @@ export const AstroConfigSchema = z.object({
.object({
paths: z.array(z.string()).nonempty(),
})
.merge(resolveFontOptionsSchema.partial())
.merge(resolveFontOptionsSchema.omit({ fallbacks: true }).partial())
.strict(),
),
})
.merge(resolveFontOptionsSchema.pick({ fallbacks: true }).partial())
.strict(),
z
.object({

View file

@ -106,16 +106,32 @@ describe('fonts providers', () => {
weight: '400',
style: 'normal',
src: [
{ url: '/_astro/fonts/foo.woff2', format: 'woff2' },
{ url: '/_astro/fonts/foo.ttf', format: 'ttf' },
{
originalURL: fileURLToPath(new URL('./src/fonts/foo.woff2', import.meta.url)),
url: '/_astro/fonts/foo.woff2',
format: 'woff2',
},
{
originalURL: fileURLToPath(new URL('./src/fonts/foo.ttf', import.meta.url)),
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' },
{
originalURL: fileURLToPath(new URL('./src/fonts/foo.woff2', import.meta.url)),
url: '/_astro/fonts/foo.woff2',
format: 'woff2',
},
{
originalURL: fileURLToPath(new URL('./src/fonts/foo.ttf', import.meta.url)),
url: '/_astro/fonts/foo.ttf',
format: 'ttf',
},
],
},
]);
@ -143,12 +159,24 @@ describe('fonts providers', () => {
{
weight: '600',
style: 'oblique',
src: [{ url: '/_astro/fonts/bar.eot', format: 'eot' }],
src: [
{
originalURL: fileURLToPath(new URL('./src/fonts/bar.eot', import.meta.url)),
url: '/_astro/fonts/bar.eot',
format: 'eot',
},
],
},
{
weight: '700',
style: 'oblique',
src: [{ url: '/_astro/fonts/bar.eot', format: 'eot' }],
src: [
{
originalURL: fileURLToPath(new URL('./src/fonts/bar.eot', import.meta.url)),
url: '/_astro/fonts/bar.eot',
format: 'eot',
},
],
},
]);
assert.deepStrictEqual(values, [fileURLToPath(new URL('./src/fonts/bar.eot', root))]);

View file

@ -6,6 +6,8 @@ import {
extractFontType,
createCache,
proxyURL,
isGenericFontFamily,
generateFallbacksCSS,
} from '../../../../dist/assets/fonts/utils.js';
function createSpyCache() {
@ -143,4 +145,156 @@ describe('fonts utils', () => {
value: '/home/documents/project/font.ttf',
});
});
it('isGenericFontFamily()', () => {
assert.equal(isGenericFontFamily('serif'), true);
assert.equal(isGenericFontFamily('sans-serif'), true);
assert.equal(isGenericFontFamily('monospace'), true);
assert.equal(isGenericFontFamily('cursive'), true);
assert.equal(isGenericFontFamily('fantasy'), true);
assert.equal(isGenericFontFamily('system-ui'), true);
assert.equal(isGenericFontFamily('ui-serif'), true);
assert.equal(isGenericFontFamily('ui-sans-serif'), true);
assert.equal(isGenericFontFamily('ui-monospace'), true);
assert.equal(isGenericFontFamily('ui-rounded'), true);
assert.equal(isGenericFontFamily('emoji'), true);
assert.equal(isGenericFontFamily('math'), true);
assert.equal(isGenericFontFamily('fangsong'), true);
assert.equal(isGenericFontFamily(''), false);
});
describe('generateFallbacksCSS()', () => {
it('should return null if there are no fallbacks', async () => {
assert.equal(
await generateFallbacksCSS({
family: 'Roboto',
fallbacks: [],
fontURL: null,
getMetricsForFamily: async () => null,
generateFontFace: () => '',
}),
null,
);
});
it('should return fallbacks if there are no metrics', async () => {
assert.deepStrictEqual(
await generateFallbacksCSS({
family: 'Roboto',
fallbacks: ['foo'],
fontURL: null,
getMetricsForFamily: async () => null,
generateFontFace: () => '',
}),
{
css: '',
fallbacks: ['foo'],
},
);
});
it('should return fallbacks if there are metrics but no generic font family', async () => {
assert.deepStrictEqual(
await generateFallbacksCSS({
family: 'Roboto',
fallbacks: ['foo'],
fontURL: null,
getMetricsForFamily: async () => ({
ascent: 0,
descent: 0,
lineGap: 0,
unitsPerEm: 0,
xWidthAvg: 0,
}),
generateFontFace: () => '',
}),
{
css: '',
fallbacks: ['foo'],
},
);
});
it('shold return fallbacks if the generic font family does not have fonts associated', async () => {
assert.deepStrictEqual(
await generateFallbacksCSS({
family: 'Roboto',
fallbacks: ['emoji'],
fontURL: null,
getMetricsForFamily: async () => ({
ascent: 0,
descent: 0,
lineGap: 0,
unitsPerEm: 0,
xWidthAvg: 0,
}),
generateFontFace: () => '',
}),
{
css: '',
fallbacks: ['emoji'],
},
);
});
it('resolves fallbacks correctly', async () => {
assert.deepStrictEqual(
await generateFallbacksCSS({
family: 'Roboto',
fallbacks: ['foo', 'bar'],
fontURL: null,
getMetricsForFamily: async () => ({
ascent: 0,
descent: 0,
lineGap: 0,
unitsPerEm: 0,
xWidthAvg: 0,
}),
generateFontFace: (_metrics, fallback) => `[${fallback.font},${fallback.name}]`,
}),
{
css: '',
fallbacks: ['foo', 'bar'],
},
);
assert.deepStrictEqual(
await generateFallbacksCSS({
family: 'Roboto',
fallbacks: ['sans-serif', 'foo'],
fontURL: null,
getMetricsForFamily: async () => ({
ascent: 0,
descent: 0,
lineGap: 0,
unitsPerEm: 0,
xWidthAvg: 0,
}),
generateFontFace: (_metrics, fallback) => `[${fallback.font},${fallback.name}]`,
}),
{
css: '',
fallbacks: ['sans-serif', 'foo'],
},
);
assert.deepStrictEqual(
await generateFallbacksCSS({
family: 'Roboto',
fallbacks: ['foo', 'sans-serif'],
fontURL: null,
getMetricsForFamily: async () => ({
ascent: 0,
descent: 0,
lineGap: 0,
unitsPerEm: 0,
xWidthAvg: 0,
}),
generateFontFace: (_metrics, fallback) => `[${fallback.font},${fallback.name}]`,
}),
{
css: `[Arial,Roboto fallback: Arial]`,
fallbacks: ['Arial', 'foo', 'sans-serif'],
},
);
});
});
});

171
pnpm-lock.yaml generated
View file

@ -538,6 +538,9 @@ importers:
flattie:
specifier: ^1.1.1
version: 1.1.1
fontaine:
specifier: ^0.5.0
version: 0.5.0
github-slugger:
specifier: ^2.0.0
version: 2.0.0
@ -6588,6 +6591,12 @@ packages:
resolution: {integrity: sha512-v9f+ueUOKkZCDKiCm0yxKtYgYNLD9zlKarNux0NSXOvNm94QEYL3RlMpGKgD2hq44pbF2qWqEmHnCvmk56kPJw==}
engines: {node: '>=18'}
'@capsizecss/metrics@2.2.0':
resolution: {integrity: sha512-DkFIser1KbGxWyG2hhQQeCit72TnOQDx5pr9bkA7+XlIy7qv+4lYtslH3bidVxm2qkY2guAgypSIPYuQQuk70A==}
'@capsizecss/unpack@2.3.0':
resolution: {integrity: sha512-qkf9IoFIVTOkkpr8oZtCNSmubyWFCuPU4EOWO6J/rFPP5Ks2b1k1EHDSQRLwfokh6nCd7mJgBT2lhcuDCE6w4w==}
'@changesets/apply-release-plan@7.0.8':
resolution: {integrity: sha512-qjMUj4DYQ1Z6qHawsn7S71SujrExJ+nceyKKyI9iB+M5p9lCL55afuEd6uLBPRpLGWQwkwvWegDHtwHJb1UjpA==}
@ -8049,6 +8058,9 @@ packages:
svelte: ^5.0.0
vite: ^6.0.0
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tailwindcss/node@4.0.6':
resolution: {integrity: sha512-jb6E0WeSq7OQbVYcIJ6LxnZTeC4HjMvbzFBMCrQff4R50HBlo/obmYNk6V2GCUXDeqiXtvtrQgcIbT+/boB03Q==}
@ -8744,6 +8756,9 @@ packages:
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
blob-to-buffer@1.2.9:
resolution: {integrity: sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==}
body-parser@1.20.3:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@ -8765,6 +8780,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
browserslist@4.24.0:
resolution: {integrity: sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@ -8902,6 +8920,10 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@ -9004,6 +9026,9 @@ packages:
cross-argv@2.0.0:
resolution: {integrity: sha512-YIaY9TR5Nxeb8SMdtrU8asWVM4jqJDNDYlKV21LxtYcfNJhp1kEsgSa6qXwXgzN0WQWGODps0+TlGp2xQSHwOg==}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -9199,6 +9224,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@ -9667,6 +9695,12 @@ packages:
debug:
optional: true
fontaine@0.5.0:
resolution: {integrity: sha512-vPDSWKhVAfTx4hRKT777+N6Szh2pAosAuzLpbppZ6O3UdD/1m6OlHjNcC3vIbgkRTIcLjzySLHXzPeLO2rE8cA==}
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
foreground-child@3.3.0:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
engines: {node: '>=14'}
@ -10348,6 +10382,9 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-regexp@0.8.0:
resolution: {integrity: sha512-lOSLWdE156csDYwCTIGiAymOLN7Epu/TU5e/oAnISZfU6qP+pgjkE+xbVjVn3yLPKN8n1G2yIAYTAM5KRk6/ow==}
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
@ -10912,6 +10949,9 @@ packages:
package-manager-detector@0.2.8:
resolution: {integrity: sha512-ts9KSdroZisdvKMWVAVCXiKqnqNfXz4+IbrBG8/BWx/TR5le+jfenvoBuIZ6UWM9nz47W7AbD9qYfAwfWMIwzA==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@ -11431,6 +11471,10 @@ packages:
resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
regexp-tree@0.1.27:
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
hasBin: true
rehype-autolink-headings@7.1.0:
resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==}
@ -11537,6 +11581,9 @@ packages:
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
restructure@3.0.2:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
retext-latin@4.0.0:
resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==}
@ -11972,6 +12019,9 @@ packages:
resolution: {integrity: sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==}
engines: {node: '>=8'}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -12052,8 +12102,8 @@ packages:
tslib@2.1.0:
resolution: {integrity: sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==}
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
turbo-darwin-64@2.4.1:
resolution: {integrity: sha512-oos3Gz5N6ol2/7+ys0wPENhl7ZzeVKIumn2BR7X2oE5dEPxNPDMOpKBwreU9ToCxM94e+uFTzKgjcUJpBqpTHA==}
@ -12105,6 +12155,9 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
type-level-regexp@0.1.17:
resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==}
types-react-dom@19.0.0-alpha.3:
resolution: {integrity: sha512-foCg3VSAoTLKBpU6FKgtHjOzqZVo7UVXfG/JnKM8imXq/+TvSGebj+KJlAVG6H1n+hiQtqpjHc+hk5FmZOJCqw==}
@ -12165,6 +12218,12 @@ packages:
unenv@2.0.0-rc.1:
resolution: {integrity: sha512-PU5fb40H8X149s117aB4ytbORcCvlASdtF97tfls4BPIyj4PeVxvpSuy1jAptqYHqB0vb2w2sHvzM0XWcp2OKg==}
unicode-properties@1.4.1:
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@ -12242,6 +12301,10 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
unplugin@1.16.1:
resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==}
engines: {node: '>=14.0.0'}
unstorage@1.14.4:
resolution: {integrity: sha512-1SYeamwuYeQJtJ/USE1x4l17LkmQBzg7deBJ+U9qOBoHo15d1cDxG4jM31zKRgF7pG0kirZy4wVMX6WL6Zoscg==}
peerDependencies:
@ -12615,6 +12678,9 @@ packages:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
@ -13186,6 +13252,16 @@ snapshots:
dependencies:
tar: 6.2.1
'@capsizecss/metrics@2.2.0': {}
'@capsizecss/unpack@2.3.0':
dependencies:
blob-to-buffer: 1.2.9
cross-fetch: 3.2.0
fontkit: 2.0.4
transitivePeerDependencies:
- encoding
'@changesets/apply-release-plan@7.0.8':
dependencies:
'@changesets/config': 3.0.5
@ -13676,7 +13752,7 @@ snapshots:
'@emnapi/runtime@1.3.1':
dependencies:
tslib: 2.6.2
tslib: 2.8.1
optional: true
'@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)':
@ -14499,6 +14575,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
'@tailwindcss/node@4.0.6':
dependencies:
enhanced-resolve: 5.18.1
@ -15314,6 +15394,8 @@ snapshots:
blake3-wasm@2.1.5: {}
blob-to-buffer@1.2.9: {}
body-parser@1.20.3:
dependencies:
bytes: 3.1.2
@ -15357,6 +15439,10 @@ snapshots:
dependencies:
fill-range: 7.1.1
brotli@1.3.3:
dependencies:
base64-js: 1.5.1
browserslist@4.24.0:
dependencies:
caniuse-lite: 1.0.30001667
@ -15501,6 +15587,8 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clone@2.1.2: {}
clsx@2.1.1: {}
collapse-white-space@2.1.0: {}
@ -15576,6 +15664,12 @@ snapshots:
cross-argv@2.0.0: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -15725,6 +15819,8 @@ snapshots:
dependencies:
dequal: 2.0.3
dfa@1.2.0: {}
didyoumean@1.2.2: {}
diff@5.2.0: {}
@ -16234,6 +16330,30 @@ snapshots:
follow-redirects@1.15.9: {}
fontaine@0.5.0:
dependencies:
'@capsizecss/metrics': 2.2.0
'@capsizecss/unpack': 2.3.0
magic-regexp: 0.8.0
magic-string: 0.30.17
pathe: 1.1.2
ufo: 1.5.4
unplugin: 1.16.1
transitivePeerDependencies:
- encoding
fontkit@2.0.4:
dependencies:
'@swc/helpers': 0.5.15
brotli: 1.3.3
clone: 2.1.2
dfa: 1.2.0
fast-deep-equal: 3.1.3
restructure: 3.0.2
tiny-inflate: 1.0.3
unicode-properties: 1.4.1
unicode-trie: 2.0.0
foreground-child@3.3.0:
dependencies:
cross-spawn: 7.0.6
@ -17000,7 +17120,7 @@ snapshots:
lower-case@2.0.2:
dependencies:
tslib: 2.6.2
tslib: 2.8.1
lru-cache@10.4.3: {}
@ -17010,6 +17130,16 @@ snapshots:
lz-string@1.5.0: {}
magic-regexp@0.8.0:
dependencies:
estree-walker: 3.0.3
magic-string: 0.30.17
mlly: 1.7.4
regexp-tree: 0.1.27
type-level-regexp: 0.1.17
ufo: 1.5.4
unplugin: 1.16.1
magic-string@0.25.9:
dependencies:
sourcemap-codec: 1.4.8
@ -17654,7 +17784,7 @@ snapshots:
no-case@3.0.4:
dependencies:
lower-case: 2.0.2
tslib: 2.6.2
tslib: 2.8.1
node-addon-api@7.1.1:
optional: true
@ -17829,6 +17959,8 @@ snapshots:
package-manager-detector@0.2.8: {}
pako@0.2.9: {}
pako@1.0.11: {}
parent-module@1.0.1:
@ -17879,7 +18011,7 @@ snapshots:
pascal-case@3.1.2:
dependencies:
no-case: 3.0.4
tslib: 2.6.2
tslib: 2.8.1
path-browserify@1.0.1: {}
@ -18381,6 +18513,8 @@ snapshots:
'@eslint-community/regexpp': 4.12.1
refa: 0.12.1
regexp-tree@0.1.27: {}
rehype-autolink-headings@7.1.0:
dependencies:
'@types/hast': 3.0.4
@ -18571,6 +18705,8 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
restructure@3.0.2: {}
retext-latin@4.0.0:
dependencies:
'@types/nlcst': 2.0.3
@ -19145,6 +19281,8 @@ snapshots:
timestring@6.0.0: {}
tiny-inflate@1.0.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@ -19203,7 +19341,7 @@ snapshots:
tslib@2.1.0: {}
tslib@2.6.2: {}
tslib@2.8.1: {}
turbo-darwin-64@2.4.1:
optional: true
@ -19245,6 +19383,8 @@ snapshots:
media-typer: 0.3.0
mime-types: 2.1.35
type-level-regexp@0.1.17: {}
types-react-dom@19.0.0-alpha.3:
dependencies:
'@types/react': 18.3.18
@ -19307,6 +19447,16 @@ snapshots:
pathe: 1.1.2
ufo: 1.5.4
unicode-properties@1.4.1:
dependencies:
base64-js: 1.5.1
unicode-trie: 2.0.0
unicode-trie@2.0.0:
dependencies:
pako: 0.2.9
tiny-inflate: 1.0.3
unicorn-magic@0.3.0: {}
unified@11.0.5:
@ -19408,6 +19558,11 @@ snapshots:
unpipe@1.0.0: {}
unplugin@1.16.1:
dependencies:
acorn: 8.14.0
webpack-virtual-modules: 0.6.2
unstorage@1.14.4(@netlify/blobs@8.1.0):
dependencies:
anymatch: 3.1.3
@ -19760,6 +19915,8 @@ snapshots:
webidl-conversions@7.0.0: {}
webpack-virtual-modules@0.6.2: {}
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3