diff options
author | 2025-04-14 12:44:52 +0200 | |
---|---|---|
committer | 2025-04-14 11:44:52 +0100 | |
commit | b1fe521e2c45172b786594c50c0ca595105a6d68 (patch) | |
tree | 3f46b6d3c9ea4c80e5c81ec7695943497c7acb24 | |
parent | 762cc7f2c7450133a36f06f555b5a8bce459ab2f (diff) | |
download | astro-b1fe521e2c45172b786594c50c0ca595105a6d68.tar.gz astro-b1fe521e2c45172b786594c50c0ca595105a6d68.tar.zst astro-b1fe521e2c45172b786594c50c0ca595105a6d68.zip |
feat(fonts): experimental release (#12775)
Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
Co-authored-by: Armand Philippot <git@armand.philippot.eu>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
53 files changed, 3038 insertions, 62 deletions
diff --git a/.changeset/happy-spies-punch.md b/.changeset/happy-spies-punch.md new file mode 100644 index 000000000..b61651558 --- /dev/null +++ b/.changeset/happy-spies-punch.md @@ -0,0 +1,41 @@ +--- +'astro': minor +--- + +Adds a new, experimental Fonts API to provide first-party support for fonts in Astro. + +This experimental feature allows you to use fonts from both your file system and several built-in supported providers (e.g. Google, Fontsource, Bunny) through a unified API. Keep your site performant thanks to sensible defaults and automatic optimizations including fallback font generation. + +To enable this feature, configure an `experimental.fonts` object with one or more fonts: + +```js title="astro.config.mjs" +import { defineConfig, fontProviders } from "astro/config" + +export default defineConfig({ + experimental: { + fonts: [{ + provider: fontProviders.google(), + ` name: "Roboto", + cssVariable: "--font-roboto", + }] + } +}) +``` + +Then, add a `<Font />` component and site-wide styling in your `<head>`: + +```astro title="src/components/Head.astro" +--- +import { Font } from 'astro:assets' +--- +<Font cssVariable='--font-roboto' preload /> +<style> +body { + font-family: var(--font-roboto); +} +</style> +``` + +Visit [the experimental Fonts documentation](https://docs.astro.build/en/reference/experimental-flags/fonts/) for the full API, how to get started, and even how to build your own custom `AstroFontProvider` if we don't yet support your preferred font service. + +For a complete overview, and to give feedback on this experimental API, see the [Fonts RFC](https://github.com/withastro/roadmap/pull/1039) and help shape its future. diff --git a/package.json b/package.json index bf86b365d..11bf44115 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,9 @@ "workerd", "@biomejs/biome", "sharp" - ] + ], + "patchedDependencies": { + "unifont@0.1.7": "patches/unifont@0.1.7.patch" + } } } diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 37ab1dde0..9884d1f1d 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -2,6 +2,7 @@ /// <reference path="./types/content.d.ts" /> /// <reference path="./types/actions.d.ts" /> /// <reference path="./types/env.d.ts" /> +/// <reference path="./types/fonts.d.ts" /> interface ImportMetaEnv { /** @@ -54,6 +55,7 @@ declare module 'astro:assets' { inferRemoteSize: typeof import('./dist/assets/utils/index.js').inferRemoteSize; Image: typeof import('./components/Image.astro').default; Picture: typeof import('./components/Picture.astro').default; + Font: typeof import('./components/Font.astro').default; }; type ImgAttributes = import('./dist/type-utils.js').WithRequired< @@ -73,6 +75,7 @@ declare module 'astro:assets' { imageConfig, Image, Picture, + Font, inferRemoteSize, }: AstroAssets; } diff --git a/packages/astro/components/Font.astro b/packages/astro/components/Font.astro new file mode 100644 index 000000000..53d26ea36 --- /dev/null +++ b/packages/astro/components/Font.astro @@ -0,0 +1,32 @@ +--- +import { AstroError, AstroErrorData } from '../dist/core/errors/index.js'; + +// TODO: remove dynamic import when fonts are stabilized +const { fontsData } = await import('virtual:astro:assets/fonts/internal').catch(() => { + throw new AstroError(AstroErrorData.ExperimentalFontsNotEnabled); +}); + +interface Props { + /** The `cssVariable` registered in your Astro configuration. */ + cssVariable: import('astro:assets').FontFamily; + /** Whether it should output [preload links](https://web.dev/learn/performance/optimize-web-fonts#preload) or not. */ + preload?: boolean; +} + +const { cssVariable, preload = false } = Astro.props; +const data = fontsData.get(cssVariable); +if (!data) { + throw new AstroError({ + ...AstroErrorData.FontFamilyNotFound, + message: AstroErrorData.FontFamilyNotFound.message(cssVariable), + }); +} +--- + +<style set:html={data.css}></style> +{ + preload && + data.preloadData.map(({ url, type }) => ( + <link rel="preload" href={url} as="font" type={`font/${type}`} crossorigin /> + )) +} diff --git a/packages/astro/package.json b/packages/astro/package.json index d11dc4d74..cdbe7b93b 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -65,6 +65,7 @@ "./assets/endpoint/*": "./dist/assets/endpoint/*.js", "./assets/services/sharp": "./dist/assets/services/sharp.js", "./assets/services/noop": "./dist/assets/services/noop.js", + "./assets/fonts/providers/*": "./dist/assets/fonts/providers/entrypoints/*.js", "./loaders": "./dist/content/loaders/index.js", "./content/runtime": "./dist/content/runtime.js", "./content/runtime-assets": "./dist/content/runtime-assets.js", @@ -113,7 +114,7 @@ "test:e2e:match": "playwright test -g", "test:e2e:chrome": "playwright test", "test:e2e:firefox": "playwright test --config playwright.firefox.config.js", - "test:types": "tsc --project tsconfig.tests.json", + "test:types": "tsc --project test/types/tsconfig.json", "test:unit": "astro-scripts test \"test/units/**/*.test.js\" --teardown ./test/units/teardown.js", "test:integration": "astro-scripts test \"test/*.test.js\"" }, @@ -122,6 +123,8 @@ "@astrojs/internal-helpers": "workspace:*", "@astrojs/markdown-remark": "workspace:*", "@astrojs/telemetry": "workspace:*", + "@capsizecss/metrics": "^3.5.0", + "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.1", @@ -164,6 +167,7 @@ "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", "ultrahtml": "^1.6.0", + "unifont": "^0.1.7", "unist-util-visit": "^5.0.0", "unstorage": "^1.15.0", "vfile": "^6.0.3", diff --git a/packages/astro/src/actions/plugins.ts b/packages/astro/src/actions/plugins.ts index 6c91dd473..65ddc2235 100644 --- a/packages/astro/src/actions/plugins.ts +++ b/packages/astro/src/actions/plugins.ts @@ -4,7 +4,7 @@ import { addRollupInput } from '../core/build/add-rollup-input.js'; import type { BuildInternals } from '../core/build/internal.js'; import type { StaticBuildOptions } from '../core/build/types.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; -import { getOutputDirectory } from '../prerender/utils.js'; +import { getServerOutputDirectory } from '../prerender/utils.js'; import type { AstroSettings } from '../types/astro.js'; import { ASTRO_ACTIONS_INTERNAL_MODULE_ID, @@ -73,7 +73,7 @@ export function vitePluginActionsBuild( chunk.type !== 'asset' && chunk.facadeModuleId === RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID ) { - const outputDirectory = getOutputDirectory(opts.settings); + const outputDirectory = getServerOutputDirectory(opts.settings); internals.astroActionsEntryPoint = new URL(chunkName, outputDirectory); } } diff --git a/packages/astro/src/assets/fonts/README.md b/packages/astro/src/assets/fonts/README.md new file mode 100644 index 000000000..bed7e5ef1 --- /dev/null +++ b/packages/astro/src/assets/fonts/README.md @@ -0,0 +1,11 @@ +# fonts + +The vite plugin orchestrates the fonts logic: + +- Retrieves data from the config +- Initializes font providers +- Fetches fonts data +- In dev, serves a middleware that dynamically loads and caches fonts data +- In build, download fonts data (from cache if possible) + +The `<Font />` component is the only aspect not managed in the vite plugin, since it's exported from `astro:assets`.
\ No newline at end of file diff --git a/packages/astro/src/assets/fonts/config.ts b/packages/astro/src/assets/fonts/config.ts new file mode 100644 index 000000000..092119184 --- /dev/null +++ b/packages/astro/src/assets/fonts/config.ts @@ -0,0 +1,174 @@ +import { z } from 'zod'; +import { LOCAL_PROVIDER_NAME } from './constants.js'; + +const weightSchema = z.union([z.string(), z.number()]); +const styleSchema = z.enum(['normal', 'italic', 'oblique']); + +const familyPropertiesSchema = z.object({ + /** + * A [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight). If the associated font is a [variable font](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_fonts/Variable_fonts_guide), you can specify a range of weights: + * + * ```js + * weight: "100 900" + * ``` + */ + weight: weightSchema, + /** + * A [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style). + */ + style: styleSchema, + /** + * @default `"swap"` + * + * A [font display](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display). + */ + display: z.enum(['auto', 'block', 'swap', 'fallback', 'optional']).optional(), + /** + * A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range). + */ + unicodeRange: z.array(z.string()).nonempty().optional(), + /** + * A [font stretch](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-stretch). + */ + stretch: z.string().optional(), + /** + * Font [feature settings](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-feature-settings). + */ + featureSettings: z.string().optional(), + /** + * Font [variation settings](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-variation-settings). + */ + variationSettings: z.string().optional(), +}); + +const fallbacksSchema = z.object({ + /** + * @default `["sans-serif"]` + * + * An array of fonts to use when your chosen font is unavailable, or loading. Fallback fonts will be chosen in the order listed. The first available font will be used: + * + * ```js + * fallbacks: ["CustomFont", "serif"] + * ``` + * + * To disable fallback fonts completely, configure an empty array: + * + * ```js + * fallbacks: [] + * ``` + * + + * If the last font in the `fallbacks` array is a [generic family name](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#generic-name), an [optimized fallback](https://developer.chrome.com/blog/font-fallbacks) using font metrics will be generated. To disable this optimization, set `optimizedFallbacks` to false. + */ + fallbacks: z.array(z.string()).nonempty().optional(), + /** + * @default `true` + * + * Whether or not to enable optimized fallback generation. You may disable this default optimization to have full control over `fallbacks`. + */ + optimizedFallbacks: z.boolean().optional(), +}); + +export const requiredFamilyAttributesSchema = z.object({ + /** + * The font family name, as identified by your font provider. + */ + name: z.string(), + /** + * A valid [ident](https://developer.mozilla.org/en-US/docs/Web/CSS/ident) in the form of a CSS variable (i.e. starting with `--`). + */ + cssVariable: z.string(), +}); + +const entrypointSchema = z.union([z.string(), z.instanceof(URL)]); + +export const fontProviderSchema = z + .object({ + /** + * URL, path relative to the root or package import. + */ + entrypoint: entrypointSchema, + /** + * Optional serializable object passed to the unifont provider. + */ + config: z.record(z.string(), z.any()).optional(), + }) + .strict(); + +export const localFontFamilySchema = requiredFamilyAttributesSchema + .merge(fallbacksSchema) + .merge( + z.object({ + /** + * The source of your font files. Set to `"local"` to use local font files. + */ + provider: z.literal(LOCAL_PROVIDER_NAME), + /** + * Each variant represents a [`@font-face` declaration](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/). + */ + variants: z + .array( + familyPropertiesSchema.merge( + z + .object({ + /** + * Font [sources](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src). It can be a path relative to the root, a package import or a URL. URLs are particularly useful if you inject local fonts through an integration. + */ + src: z + .array( + z.union([ + entrypointSchema, + z.object({ url: entrypointSchema, tech: z.string().optional() }).strict(), + ]), + ) + .nonempty(), + // TODO: find a way to support subsets (through fontkit?) + }) + .strict(), + ), + ) + .nonempty(), + }), + ) + .strict(); + +export const remoteFontFamilySchema = requiredFamilyAttributesSchema + .merge( + familyPropertiesSchema.omit({ + weight: true, + style: true, + }), + ) + .merge(fallbacksSchema) + .merge( + z.object({ + /** + * The source of your font files. You can use a built-in provider or write your own custom provider. + */ + provider: fontProviderSchema, + /** + * @default `[400]` + * + * An array of [font weights](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight). If the associated font is a [variable font](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_fonts/Variable_fonts_guide), you can specify a range of weights: + * + * ```js + * weight: "100 900" + * ``` + */ + weights: z.array(weightSchema).nonempty().optional(), + /** + * @default `["normal", "italic"]` + * + * An array of [font styles](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style). + */ + styles: z.array(styleSchema).nonempty().optional(), + // TODO: better link + /** + * @default `["cyrillic-ext", "cyrillic", "greek-ext", "greek", "vietnamese", "latin-ext", "latin"]` + * + * An array of [font subsets](https://fonts.google.com/knowledge/glossary/subsetting): + */ + subsets: z.array(z.string()).nonempty().optional(), + }), + ) + .strict(); diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts new file mode 100644 index 000000000..c12d31fbd --- /dev/null +++ b/packages/astro/src/assets/fonts/constants.ts @@ -0,0 +1,40 @@ +import type { ResolvedRemoteFontFamily } from './types.js'; + +export const LOCAL_PROVIDER_NAME = 'local'; + +export const DEFAULTS = { + weights: ['400'], + styles: ['normal', 'italic'], + subsets: ['cyrillic-ext', 'cyrillic', 'greek-ext', 'greek', 'vietnamese', 'latin-ext', 'latin'], + // Technically serif is the browser default but most websites these days use sans-serif + fallbacks: ['sans-serif'], + optimizedFallbacks: true, +} satisfies Partial<ResolvedRemoteFontFamily>; + +export const VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/internal'; +export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; + +// Requires a trailing slash +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: [], +}; + +export const FONTS_TYPES_FILE = 'fonts.d.ts'; diff --git a/packages/astro/src/assets/fonts/load.ts b/packages/astro/src/assets/fonts/load.ts new file mode 100644 index 000000000..8fc97d745 --- /dev/null +++ b/packages/astro/src/assets/fonts/load.ts @@ -0,0 +1,183 @@ +import { readFileSync } from 'node:fs'; +import { resolveLocalFont } from './providers/local.js'; +import { + familiesToUnifontProviders, + generateFallbacksCSS, + generateFontFace, + proxyURL, + type GetMetricsForFamily, + type GetMetricsForFamilyFont, + 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 { PreloadData, ResolvedFontFamily } from './types.js'; +import type { Storage } from 'unstorage'; +import type { generateFallbackFontFace } from './metrics.js'; + +interface Options { + base: string; + families: Array<ResolvedFontFamily>; + storage: Storage; + hashToUrlMap: Map<string, string>; + resolvedMap: Map<string, { preloadData: PreloadData; css: string }>; + hashString: (value: string) => string; + log: (message: string) => void; + generateFallbackFontFace: typeof generateFallbackFontFace; + getMetricsForFamily: GetMetricsForFamily; +} + +export async function loadFonts({ + base, + families, + storage, + hashToUrlMap, + resolvedMap, + hashString, + generateFallbackFontFace, + getMetricsForFamily, + log, +}: Options): Promise<void> { + const extractedProvidersResult = familiesToUnifontProviders({ families, hashString }); + families = extractedProvidersResult.families; + const { resolveFont } = await unifont.createUnifont(extractedProvidersResult.providers, { + storage, + }); + + for (const family of families) { + const preloadData: PreloadData = []; + let css = ''; + let fallbackFontData: GetMetricsForFamilyFont = null; + + // 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 }); + } + // If a family has fallbacks, we store the first url we get that may + // be used for the fallback generation, if capsize doesn't have this + // family in its built-in collection + if (family.fallbacks && family.fallbacks.length > 0) { + fallbackFontData ??= { + hash, + url: value, + }; + } + 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, + }); + }, + }); + 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, + fallbacks: family.fallbacks ?? DEFAULTS.fallbacks, + }, + // By default, unifont goes through all providers. We use a different approach + // where we specify a provider per font. + // Name has been set while extracting unifont providers from families (inside familiesToUnifontProviders) + [family.provider.name!], + ); + + 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) { + // User settings override the generated font settings + css += generateFontFace(family.nameWithHash, { + src: data.src, + display: + (data.display ?? family.provider === LOCAL_PROVIDER_NAME) ? undefined : family.display, + unicodeRange: + (data.unicodeRange ?? family.provider === LOCAL_PROVIDER_NAME) + ? undefined + : family.unicodeRange, + weight: data.weight, + style: data.style, + stretch: + (data.stretch ?? family.provider === LOCAL_PROVIDER_NAME) ? undefined : family.stretch, + featureSettings: + (data.featureSettings ?? family.provider === LOCAL_PROVIDER_NAME) + ? undefined + : family.featureSettings, + variationSettings: + (data.variationSettings ?? family.provider === LOCAL_PROVIDER_NAME) + ? undefined + : family.variationSettings, + }); + } + + const fallbackData = await generateFallbacksCSS({ + family, + font: fallbackFontData, + fallbacks: family.fallbacks ?? DEFAULTS.fallbacks, + metrics: + (family.optimizedFallbacks ?? DEFAULTS.optimizedFallbacks) + ? { + getMetricsForFamily, + generateFontFace: generateFallbackFontFace, + } + : null, + }); + + const cssVarValues = [family.nameWithHash]; + + if (fallbackData) { + css += fallbackData.css; + cssVarValues.push(...fallbackData.fallbacks); + } + + css += `:root { ${family.cssVariable}: ${cssVarValues.join(', ')}; }`; + + resolvedMap.set(family.cssVariable, { preloadData, css }); + } + log('Fonts initialized'); +} diff --git a/packages/astro/src/assets/fonts/metrics.ts b/packages/astro/src/assets/fonts/metrics.ts new file mode 100644 index 000000000..fdd9ed2b2 --- /dev/null +++ b/packages/astro/src/assets/fonts/metrics.ts @@ -0,0 +1,123 @@ +// Adapted from https://github.com/unjs/fontaine/ +import { fontFamilyToCamelCase } from '@capsizecss/metrics'; +import { fromBuffer, type Font } from '@capsizecss/unpack'; + +const QUOTES_RE = /^["']|["']$/g + +const withoutQuotes = (str: string) => str.trim().replace(QUOTES_RE, ''); + +export type FontFaceMetrics = Pick< + Font, + 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg' +>; + +const metricCache: Record<string, FontFaceMetrics | null> = {}; + +function filterRequiredMetrics({ + ascent, + descent, + lineGap, + unitsPerEm, + xWidthAvg, +}: Pick<Font, 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'>) { + return { + ascent, + descent, + lineGap, + unitsPerEm, + xWidthAvg, + }; +} + +export async function getMetricsForFamily(family: string) { + family = withoutQuotes(family); + + if (family in metricCache) return metricCache[family]; + + try { + const name = fontFamilyToCamelCase(family); + const { entireMetricsCollection } = await import('@capsizecss/metrics/entireMetricsCollection'); + const metrics = entireMetricsCollection[name as keyof typeof entireMetricsCollection]; + + if (!('descent' in metrics)) { + metricCache[family] = null; + return null; + } + + const filteredMetrics = filterRequiredMetrics(metrics); + metricCache[family] = filteredMetrics; + return filteredMetrics; + } catch { + metricCache[family] = null; + return null; + } +} + +export async function readMetrics(family: string, buffer: Buffer) { + const metrics = await fromBuffer(buffer); + + metricCache[family] = filterRequiredMetrics(metrics); + + return metricCache[family]; +} + +// See: https://github.com/seek-oss/capsize/blob/master/packages/core/src/round.ts +function toPercentage(value: number, fractionDigits = 4) { + const percentage = value * 100; + return `${+percentage.toFixed(fractionDigits)}%`; +} + +function toCSS(properties: Record<string, any>, indent = 2) { + return Object.entries(properties) + .map(([key, value]) => `${' '.repeat(indent)}${key}: ${value};`) + .join('\n'); +} + +export function generateFallbackFontFace( + metrics: FontFaceMetrics, + fallback: { + name: string; + font: string; + metrics?: FontFaceMetrics; + [key: string]: any; + }, +) { + const { + name: fallbackName, + font: fallbackFontName, + metrics: fallbackMetrics, + ...properties + } = fallback; + + // Credits to: https://github.com/seek-oss/capsize/blob/master/packages/core/src/createFontStack.ts + + // Calculate size adjust + const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm; + const fallbackFontXAvgRatio = fallbackMetrics + ? fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm + : 1; + + const sizeAdjust = + fallbackMetrics && preferredFontXAvgRatio && fallbackFontXAvgRatio + ? preferredFontXAvgRatio / fallbackFontXAvgRatio + : 1; + + const adjustedEmSquare = metrics.unitsPerEm * sizeAdjust; + + // Calculate metric overrides for preferred font + const ascentOverride = metrics.ascent / adjustedEmSquare; + const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare; + const lineGapOverride = metrics.lineGap / adjustedEmSquare; + + const declaration = { + 'font-family': JSON.stringify(fallbackName), + src: `local(${JSON.stringify(fallbackFontName)})`, + 'size-adjust': toPercentage(sizeAdjust), + 'ascent-override': toPercentage(ascentOverride), + 'descent-override': toPercentage(descentOverride), + 'line-gap-override': toPercentage(lineGapOverride), + ...properties, + }; + + return `@font-face {\n${toCSS(declaration)}\n}\n`; +} diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts b/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts new file mode 100644 index 000000000..03b6a8464 --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts @@ -0,0 +1,4 @@ +import { providers } from 'unifont'; + +// Required type annotation because its options type is not exported +export const provider: typeof providers.adobe = providers.adobe; diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts b/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts new file mode 100644 index 000000000..efff38505 --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts @@ -0,0 +1,3 @@ +import { providers } from 'unifont'; + +export const provider = providers.bunny; diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts b/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts new file mode 100644 index 000000000..78f676836 --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts @@ -0,0 +1,3 @@ +import { providers } from 'unifont'; + +export const provider = providers.fontshare; diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts b/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts new file mode 100644 index 000000000..25f19cc8d --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts @@ -0,0 +1,3 @@ +import { providers } from 'unifont'; + +export const provider = providers.fontsource; diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/google.ts b/packages/astro/src/assets/fonts/providers/entrypoints/google.ts new file mode 100644 index 000000000..5851dea20 --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/entrypoints/google.ts @@ -0,0 +1,4 @@ +import { providers } from 'unifont'; + +// Required type annotation because its options type is not exported +export const provider: typeof providers.google = providers.google; diff --git a/packages/astro/src/assets/fonts/providers/index.ts b/packages/astro/src/assets/fonts/providers/index.ts new file mode 100644 index 000000000..3d7dcfdc1 --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/index.ts @@ -0,0 +1,62 @@ +import type { providers } from 'unifont'; +import type { AstroFontProvider } from '../types.js'; + +/** [Adobe](https://fonts.adobe.com/) */ +function adobe(config: Parameters<typeof providers.adobe>[0]) { + return defineAstroFontProvider({ + entrypoint: 'astro/assets/fonts/providers/adobe', + config, + }); +} + +/** [Bunny](https://fonts.bunny.net/) */ +function bunny() { + return defineAstroFontProvider({ + entrypoint: 'astro/assets/fonts/providers/bunny', + }); +} + +/** [Fontshare](https://www.fontshare.com/) */ +function fontshare() { + return defineAstroFontProvider({ + entrypoint: 'astro/assets/fonts/providers/fontshare', + }); +} + +/** [Fontsource](https://fontsource.org/) */ +function fontsource() { + return defineAstroFontProvider({ + entrypoint: 'astro/assets/fonts/providers/fontsource', + }); +} + +// TODO: https://github.com/unjs/unifont/issues/108. Once resolved, remove the unifont patch +// 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 +/** [Google](https://fonts.google.com/) */ +function google() { + return defineAstroFontProvider({ + entrypoint: 'astro/assets/fonts/providers/google', + }); +} + +/** + * Astro re-exports most [unifont](https://github.com/unjs/unifont/) providers: + * - [Adobe](https://fonts.adobe.com/) + * - [Bunny](https://fonts.bunny.net/) + * - [Fontshare](https://www.fontshare.com/) + * - [Fontsource](https://fontsource.org/) + * - [Google](https://fonts.google.com/) + */ +export const fontProviders = { + adobe, + bunny, + fontshare, + fontsource, + google, +}; + +/** A type helper for defining Astro font providers config objects */ +export function defineAstroFontProvider(provider: AstroFontProvider) { + return provider; +} diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts new file mode 100644 index 000000000..80f500e75 --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -0,0 +1,46 @@ +import type * as unifont from 'unifont'; +import type { ResolvedLocalFontFamily } from '../types.js'; +import { extractFontType } from '../utils.js'; + +// https://fonts.nuxt.com/get-started/providers#local +// https://github.com/nuxt/fonts/blob/main/src/providers/local.ts +// https://github.com/unjs/unifont/blob/main/src/providers/google.ts + +type InitializedProvider = NonNullable<Awaited<ReturnType<unifont.Provider>>>; + +type ResolveFontResult = NonNullable<Awaited<ReturnType<InitializedProvider['resolveFont']>>>; + +interface Options { + family: ResolvedLocalFontFamily; + proxyURL: (value: string) => string; +} + +export function resolveLocalFont({ family, proxyURL }: Options): ResolveFontResult { + const fonts: ResolveFontResult['fonts'] = []; + + for (const variant of family.variants) { + const data: ResolveFontResult['fonts'][number] = { + weight: variant.weight, + style: variant.style, + src: variant.src.map(({ url: originalURL, tech }) => { + return { + originalURL, + url: proxyURL(originalURL), + format: extractFontType(originalURL), + tech, + }; + }), + }; + if (variant.display) data.display = variant.display; + if (variant.unicodeRange) data.unicodeRange = variant.unicodeRange; + if (variant.stretch) data.stretch = variant.stretch; + if (variant.featureSettings) data.featureSettings = variant.featureSettings; + if (variant.variationSettings) data.variationSettings = variant.variationSettings; + + fonts.push(data); + } + + return { + fonts, + }; +} diff --git a/packages/astro/src/assets/fonts/providers/utils.ts b/packages/astro/src/assets/fonts/providers/utils.ts new file mode 100644 index 000000000..5502b1fff --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/utils.ts @@ -0,0 +1,47 @@ +import type { AstroFontProvider, ResolvedFontProvider } from '../types.js'; +import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; +import { resolveEntrypoint } from '../utils.js'; + +export function validateMod(mod: any, entrypoint: string): Pick<ResolvedFontProvider, 'provider'> { + // We do not throw astro errors directly to avoid duplication. Instead, we throw an error to be used as cause + try { + if (typeof mod !== 'object' || mod === null) { + throw new Error(`Expected an object for the module, but received ${typeof mod}.`); + } + + if (typeof mod.provider !== 'function') { + throw new Error(`Invalid provider export in module, expected a function.`); + } + + return { + provider: mod.provider, + }; + } catch (cause) { + throw new AstroError( + { + ...AstroErrorData.CannotLoadFontProvider, + message: AstroErrorData.CannotLoadFontProvider.message(entrypoint), + }, + { cause }, + ); + } +} + +export type ResolveMod = (id: string) => Promise<any>; + +export interface ResolveProviderOptions { + root: URL; + provider: AstroFontProvider; + resolveMod: ResolveMod; +} + +export async function resolveProvider({ + root, + provider: { entrypoint, config }, + resolveMod, +}: ResolveProviderOptions): Promise<ResolvedFontProvider> { + const id = resolveEntrypoint(root, entrypoint.toString()).href; + const mod = await resolveMod(id); + const { provider } = validateMod(mod, id); + return { config, provider }; +} diff --git a/packages/astro/src/assets/fonts/sync.ts b/packages/astro/src/assets/fonts/sync.ts new file mode 100644 index 000000000..57ed03377 --- /dev/null +++ b/packages/astro/src/assets/fonts/sync.ts @@ -0,0 +1,17 @@ +import type { AstroSettings } from '../../types/astro.js'; +import { FONTS_TYPES_FILE } from './constants.js'; + +export function syncFonts(settings: AstroSettings): void { + if (!settings.config.experimental.fonts) { + return; + } + + settings.injectedTypes.push({ + filename: FONTS_TYPES_FILE, + content: `declare module 'astro:assets' { + /** @internal */ + export type FontFamily = (${JSON.stringify(settings.config.experimental.fonts.map((family) => family.cssVariable))})[number]; +} +`, + }); +} diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts new file mode 100644 index 000000000..f8dcf1948 --- /dev/null +++ b/packages/astro/src/assets/fonts/types.ts @@ -0,0 +1,61 @@ +import type { z } from 'zod'; +import type { FONT_TYPES } from './constants.js'; +import type * as unifont from 'unifont'; +import type { + remoteFontFamilySchema, + fontProviderSchema, + localFontFamilySchema, +} from './config.js'; + +export type AstroFontProvider = z.infer<typeof fontProviderSchema>; + +export interface ResolvedFontProvider { + name?: string; + provider: (config?: Record<string, any>) => unifont.Provider; + config?: Record<string, any>; +} + +export type LocalFontFamily = z.infer<typeof localFontFamilySchema>; + +interface ResolvedFontFamilyAttributes { + nameWithHash: string; +} + +export interface ResolvedLocalFontFamily + extends ResolvedFontFamilyAttributes, + Omit<LocalFontFamily, 'variants'> { + variants: Array< + Omit<LocalFontFamily['variants'][number], 'weight' | 'src'> & { + weight: string; + src: Array<{ url: string; tech?: string }>; + } + >; +} + +type RemoteFontFamily = z.infer<typeof remoteFontFamilySchema>; + +export interface ResolvedRemoteFontFamily + extends ResolvedFontFamilyAttributes, + Omit<z.output<typeof remoteFontFamilySchema>, 'provider' | 'weights'> { + provider: ResolvedFontProvider; + weights?: Array<string>; +} + +export type FontFamily = LocalFontFamily | RemoteFontFamily; +export type ResolvedFontFamily = ResolvedLocalFontFamily | ResolvedRemoteFontFamily; + +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; +}>; diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts new file mode 100644 index 000000000..54e5e263f --- /dev/null +++ b/packages/astro/src/assets/fonts/utils.ts @@ -0,0 +1,338 @@ +import type * as unifont from 'unifont'; +import type { + FontFamily, + FontType, + LocalFontFamily, + ResolvedFontFamily, + ResolvedLocalFontFamily, +} from './types.js'; +import { extname } from 'node:path'; +import { DEFAULT_FALLBACKS, FONT_TYPES, LOCAL_PROVIDER_NAME } from './constants.js'; +import type { Storage } from 'unstorage'; +import type { FontFaceMetrics, generateFallbackFontFace } from './metrics.js'; +import { AstroError, AstroErrorData } from '../../core/errors/index.js'; +import { resolveProvider, type ResolveProviderOptions } from './providers/utils.js'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L7-L21 +export function generateFontFace(family: string, font: unifont.FontFaceData) { + return [ + '@font-face {', + ` font-family: ${family};`, + ` src: ${renderFontSrc(font.src)};`, + ` font-display: ${font.display ?? 'swap'};`, + font.unicodeRange && ` unicode-range: ${font.unicodeRange};`, + font.weight && + ` font-weight: ${Array.isArray(font.weight) ? font.weight.join(' ') : font.weight};`, + font.style && ` font-style: ${font.style};`, + font.stretch && ` font-stretch: ${font.stretch};`, + font.featureSettings && ` font-feature-settings: ${font.featureSettings};`, + font.variationSettings && ` font-variation-settings: ${font.variationSettings};`, + `}`, + ] + .filter(Boolean) + .join('\n'); +} + +// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81 +function renderFontSrc(sources: Exclude<unifont.FontFaceData['src'][number], string>[]) { + return sources + .map((src) => { + if ('url' in src) { + let rendered = `url("${src.url}")`; + for (const key of ['format', 'tech'] as const) { + if (key in src) { + rendered += ` ${key}(${src[key]})`; + } + } + return rendered; + } + return `local("${src.name}")`; + }) + .join(', '); +} + +export function extractFontType(str: string): FontType { + // Extname includes a leading dot + const extension = extname(str).slice(1); + if (!isFontType(extension)) { + throw new AstroError(AstroErrorData.CannotExtractFontType, { + cause: `Unexpected extension, got "${extension}"`, + }); + } + return extension; +} + +export function isFontType(str: string): str is FontType { + return (FONT_TYPES as Readonly<Array<string>>).includes(str); +} + +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 interface ProxyURLOptions { + /** + * The original URL + */ + value: string; + /** + * Specifies how the hash is computed. Can be based on the value, + * a specific string for testing etc + */ + hashString: (value: string) => string; + /** + * Use the hook to save the associated value and hash, and possibly + * transform it (eg. apply a base) + */ + collect: (data: { + hash: string; + type: FontType; + value: string; + }) => string; +} + +/** + * The fonts data we receive contains urls or file paths we do no control. + * However, we will emit font files ourselves so we store the original value + * and replace it with a url we control. For example with the value "https://foo.bar/file.woff2": + * - font type is woff2 + * - hash will be "<hash>.woff2" + * - `collect` will save the association of the original url and the new hash for later use + * - the returned url will be `/_astro/fonts/<hash>.woff2` + */ +export function proxyURL({ value, hashString, collect }: ProxyURLOptions): string { + const type = extractFontType(value); + const hash = `${hashString(value)}.${type}`; + const url = collect({ hash, type, value }); + // Now that we collected the original url, we return our proxy so the consumer can override it + return url; +} + +export function isGenericFontFamily(str: string): str is keyof typeof DEFAULT_FALLBACKS { + return Object.keys(DEFAULT_FALLBACKS).includes(str); +} + +export type GetMetricsForFamilyFont = { + hash: string; + url: string; +} | null; + +export type GetMetricsForFamily = ( + name: string, + /** A remote url or local filepath to a font file. Used if metrics can't be resolved purely from the family name */ + font: GetMetricsForFamilyFont, +) => Promise<FontFaceMetrics | null>; + +/** + * Generates CSS for a given family fallbacks if possible. + * + * It works by trying to get metrics (using capsize) 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, + font: fontData, + metrics, +}: { + family: Pick<ResolvedFontFamily, 'name' | 'nameWithHash'>; + /** The family fallbacks */ + fallbacks: Array<string>; + font: GetMetricsForFamilyFont; + metrics: { + getMetricsForFamily: GetMetricsForFamily; + generateFontFace: typeof generateFallbackFontFace; + } | null; +}): Promise<null | { css: string; fallbacks: Array<string> }> { + // We avoid mutating the original array + let fallbacks = [..._fallbacks]; + if (fallbacks.length === 0) { + return null; + } + + let css = ''; + + if (!metrics) { + return { css, fallbacks }; + } + + // 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 foundMetrics = await metrics.getMetricsForFamily(family.name, fontData); + if (!foundMetrics) { + // If there are no metrics, we can't generate useful fallbacks + return { css, fallbacks }; + } + + const localFontsMappings = localFonts.map((font) => ({ + font, + name: `"${family.nameWithHash} fallback: ${font}"`, + })); + + // We prepend the fallbacks with the local fonts and we dedupe in case a local font is already provided + fallbacks = [...new Set([...localFontsMappings.map((m) => m.name), ...fallbacks])]; + + for (const { font, name } of localFontsMappings) { + css += metrics.generateFontFace(foundMetrics, { font, name }); + } + + return { css, fallbacks }; +} + +function dedupe<const T extends Array<any>>(arr: T): T { + return [...new Set(arr)] as T; +} + +function resolveVariants({ + variants, + root, +}: { variants: LocalFontFamily['variants']; root: URL }): ResolvedLocalFontFamily['variants'] { + return variants.map((variant) => ({ + ...variant, + weight: variant.weight.toString(), + src: variant.src.map((value) => { + const isValue = typeof value === 'string' || value instanceof URL; + const url = (isValue ? value : value.url).toString(); + const tech = isValue ? undefined : value.tech; + return { + url: fileURLToPath(resolveEntrypoint(root, url)), + tech, + }; + }), + })); +} + +/** + * Resolves the font family provider. If none is provided, it will infer the provider as + * one of the built-in providers and resolve it. The most important part is that if a + * provider is not provided but `src` is, then it's inferred as the local provider. + */ +export async function resolveFontFamily({ + family, + generateNameWithHash, + root, + resolveMod, +}: Omit<ResolveProviderOptions, 'provider'> & { + family: FontFamily; + generateNameWithHash: (family: FontFamily) => string; +}): Promise<ResolvedFontFamily> { + const nameWithHash = generateNameWithHash(family); + + if (family.provider === LOCAL_PROVIDER_NAME) { + return { + ...family, + nameWithHash, + variants: resolveVariants({ variants: family.variants, root }), + fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, + }; + } + + return { + ...family, + nameWithHash, + provider: await resolveProvider({ + root, + resolveMod, + provider: family.provider, + }), + weights: family.weights ? dedupe(family.weights.map((weight) => weight.toString())) : undefined, + styles: family.styles ? dedupe(family.styles) : undefined, + subsets: family.subsets ? dedupe(family.subsets) : undefined, + fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined, + unicodeRange: family.unicodeRange ? dedupe(family.unicodeRange) : undefined, + }; +} + +export function sortObjectByKey<T extends Record<string, any>>(unordered: T): T { + const ordered = Object.keys(unordered) + .sort() + .reduce((obj, key) => { + // @ts-expect-error Type 'T' is generic and can only be indexed for reading. That's fine here + obj[key] = unordered[key]; + return obj; + }, {} as T); + return ordered; +} + +/** + * Extracts providers from families so they can be consumed by unifont. + * It deduplicates them based on their config and provider name: + * - If several families use the same provider (by value, not by reference), we only use one provider + * - If one provider is used with different settings for 2 families, we make sure there are kept as 2 providers + */ +export function familiesToUnifontProviders({ + families, + hashString, +}: { + families: Array<ResolvedFontFamily>; + hashString: (value: string) => string; +}): { families: Array<ResolvedFontFamily>; providers: Array<unifont.Provider> } { + const hashes = new Set<string>(); + const providers: Array<unifont.Provider> = []; + + for (const { provider } of families) { + if (provider === LOCAL_PROVIDER_NAME) { + continue; + } + + const unifontProvider = provider.provider(provider.config); + const hash = hashString( + JSON.stringify( + sortObjectByKey({ + name: unifontProvider._name, + ...provider.config, + }), + ), + ); + if (hashes.has(hash)) { + continue; + } + // Makes sure every font uses the right instance of a given provider + // if this provider is provided several times with different options + // We have to mutate the unifont provider name because unifont deduplicates + // based on the name. + unifontProvider._name += `-${hash}`; + // We set the provider name so we can tell unifont what provider to use when + // resolving font faces + provider.name = unifontProvider._name; + hashes.add(hash); + providers.push(unifontProvider); + } + + return { families, providers }; +} + +export function resolveEntrypoint(root: URL, entrypoint: string): URL { + const require = createRequire(root); + + try { + return pathToFileURL(require.resolve(entrypoint)); + } catch { + return new URL(entrypoint, root); + } +} diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts new file mode 100644 index 000000000..608e8fe3a --- /dev/null +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -0,0 +1,266 @@ +import type { Plugin } from 'vite'; +import type { AstroSettings } from '../../types/astro.js'; +import type { ResolveMod } from './providers/utils.js'; +import type { PreloadData, ResolvedFontFamily } from './types.js'; +import xxhash from 'xxhash-wasm'; +import { isAbsolute } from 'node:path'; +import { getClientOutputDirectory } from '../../prerender/utils.js'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { cache, extractFontType, resolveFontFamily, sortObjectByKey } from './utils.js'; +import { + VIRTUAL_MODULE_ID, + RESOLVED_VIRTUAL_MODULE_ID, + URL_PREFIX, + CACHE_DIR, +} from './constants.js'; +import { removeTrailingForwardSlash } from '@astrojs/internal-helpers/path'; +import type { Logger } from '../../core/logger/core.js'; +import { AstroError, AstroErrorData, isAstroError } from '../../core/errors/index.js'; +import { readFile } from 'node:fs/promises'; +import { createStorage, type Storage } from 'unstorage'; +import fsLiteDriver from 'unstorage/drivers/fs-lite'; +import { fileURLToPath } from 'node:url'; +import { loadFonts } from './load.js'; +import { generateFallbackFontFace, getMetricsForFamily, readMetrics } from './metrics.js'; +import { formatErrorMessage } from '../../core/messages.js'; +import { collectErrorMetadata } from '../../core/errors/dev/utils.js'; + +interface Options { + settings: AstroSettings; + sync: boolean; + logger: Logger; +} + +async function fetchFont(url: string): Promise<Buffer> { + try { + if (isAbsolute(url)) { + return await readFile(url); + } + // TODO: find a way to pass headers + // https://github.com/unjs/unifont/issues/143 + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Response was not successful, received status code ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); + } catch (cause) { + throw new AstroError( + { + ...AstroErrorData.CannotFetchFontFile, + message: AstroErrorData.CannotFetchFontFile.message(url), + }, + { cause }, + ); + } +} + +export function fontsPlugin({ settings, sync, logger }: Options): Plugin { + if (!settings.config.experimental.fonts) { + // this is required because the virtual module does not exist + // when fonts are not enabled, and that prevents rollup from building + // TODO: remove once fonts are stabilized + return { + name: 'astro:fonts:fallback', + config() { + return { + build: { + rollupOptions: { + external: [VIRTUAL_MODULE_ID], + }, + }, + }; + }, + }; + } + + // 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) + const baseUrl = removeTrailingForwardSlash(settings.config.base) + URL_PREFIX; + + let resolvedMap: Map<string, { preloadData: PreloadData; css: string }> | null = null; + // Key is `${hash}.${ext}`, value is a URL. + // When a font file is requested (eg. /_astro/fonts/abc.woff), we use the hash + // to download the original file, or retrieve it from cache + let hashToUrlMap: Map<string, string> | null = null; + let isBuild: boolean; + let storage: Storage | null = null; + + const cleanup = () => { + resolvedMap = null; + hashToUrlMap = null; + storage = null; + }; + + async function initialize({ resolveMod, base }: { resolveMod: ResolveMod; base: URL }) { + const { h64ToString } = await xxhash(); + + storage = createStorage({ + // Types are weirly exported + driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({ + base: fileURLToPath(base), + }), + }); + + // We initialize shared variables here and reset them in buildEnd + // to avoid locking memory + hashToUrlMap = new Map(); + resolvedMap = new Map(); + + const families: Array<ResolvedFontFamily> = []; + + for (const family of settings.config.experimental.fonts!) { + families.push( + await resolveFontFamily({ + family, + root: settings.config.root, + resolveMod, + generateNameWithHash: (_family) => + `${_family.name}-${h64ToString(JSON.stringify(sortObjectByKey(_family)))}`, + }), + ); + } + + await loadFonts({ + base: baseUrl, + families, + storage, + hashToUrlMap, + resolvedMap, + hashString: h64ToString, + generateFallbackFontFace, + getMetricsForFamily: async (name, font) => { + let metrics = await getMetricsForFamily(name); + if (font && !metrics) { + const { data } = await cache(storage!, font.hash, () => fetchFont(font.url)); + metrics = await readMetrics(name, data); + } + return metrics; + }, + log: (message) => logger.info('assets', message), + }); + } + + return { + name: 'astro:fonts', + config(_, { command }) { + isBuild = command === 'build'; + }, + async buildStart() { + if (isBuild) { + await initialize({ + resolveMod: (id) => import(id), + base: new URL(CACHE_DIR, settings.config.cacheDir), + }); + } + }, + async configureServer(server) { + await initialize({ + resolveMod: (id) => server.ssrLoadModule(id), + // In dev, we cache fonts data in .astro so it can be easily inspected and cleared + base: new URL(CACHE_DIR, settings.dotAstroDir), + }); + // The map is always defined at this point. Its values contains urls from remote providers + // as well as local paths for the local provider. We filter them to only keep the filepaths + const localPaths = [...hashToUrlMap!.values()].filter((url) => isAbsolute(url)); + server.watcher.on('change', (path) => { + if (localPaths.includes(path)) { + logger.info('assets', 'Font file updated'); + server.restart(); + } + }); + // We do not purge the cache in case the user wants to re-use the file later on + server.watcher.on('unlink', (path) => { + if (localPaths.includes(path)) { + logger.warn( + 'assets', + `The font file ${JSON.stringify(path)} referenced in your config has been deleted. Restore the file or remove this font from your configuration if it is no longer needed.`, + ); + } + }); + + // Base is taken into account by default. The prefix contains a traling slash, + // so it matches correctly any hash, eg. /_astro/fonts/abc.woff => abc.woff + server.middlewares.use(URL_PREFIX, async (req, res, next) => { + if (!req.url) { + return next(); + } + const hash = req.url.slice(1); + const url = hashToUrlMap?.get(hash); + if (!url) { + return next(); + } + // We don't want the request to be cached in dev because we cache it already internally, + // and it makes it easier to debug without needing hard refreshes + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', 0); + + try { + // 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 { data } = await cache(storage!, hash, () => fetchFont(url)); + + res.setHeader('Content-Length', data.length); + res.setHeader('Content-Type', `font/${extractFontType(hash)}`); + + res.end(data); + } catch (err) { + logger.error('assets', 'Cannot download font file'); + if (isAstroError(err)) { + logger.error( + 'SKIP_FORMAT', + formatErrorMessage(collectErrorMetadata(err), logger.level() === 'debug'), + ); + } + res.statusCode = 500; + res.end(); + } + }); + }, + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID; + } + }, + load(id, opts) { + if (id === RESOLVED_VIRTUAL_MODULE_ID && opts?.ssr) { + return { + code: `export const fontsData = new Map(${JSON.stringify(Array.from(resolvedMap?.entries() ?? []))})`, + }; + } + }, + async buildEnd() { + if (sync || settings.config.experimental.fonts!.length === 0) { + cleanup(); + return; + } + + try { + const dir = getClientOutputDirectory(settings); + const fontsDir = new URL('.' + baseUrl, dir); + try { + mkdirSync(fontsDir, { recursive: true }); + } catch (cause) { + throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause }); + } + if (hashToUrlMap) { + logger.info('assets', 'Copying fonts...'); + await Promise.all( + Array.from(hashToUrlMap.entries()).map(async ([hash, url]) => { + const { data } = await cache(storage!, hash, () => fetchFont(url)); + try { + writeFileSync(new URL(hash, fontsDir), data); + } catch (cause) { + throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause }); + } + }), + ); + } + } finally { + cleanup(); + } + }, + }; +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index e647515e4..97de05efa 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -20,6 +20,8 @@ import { emitESMImage } from './utils/node/emitAsset.js'; import { getProxyCode } from './utils/proxy.js'; import { makeSvgComponent } from './utils/svg.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; +import { fontsPlugin } from './fonts/vite-plugin-fonts.js'; +import type { Logger } from '../core/logger/core.js'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; @@ -91,10 +93,14 @@ const addStaticImageFactory = ( }; }; -export default function assets({ - fs, - settings, -}: { fs: typeof fsMod; settings: AstroSettings }): vite.Plugin[] { +interface Options { + settings: AstroSettings; + sync: boolean; + logger: Logger; + fs: typeof fsMod; +} + +export default function assets({ fs, settings, sync, logger }: Options): vite.Plugin[] { let resolvedConfig: vite.ResolvedConfig; let shouldEmitFile = false; let isBuild = false; @@ -127,6 +133,7 @@ export default function assets({ import { getImage as getImageInternal } from "astro/assets"; export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro"; export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro"; + export { default as Font } from "astro/components/Font.astro"; export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js"; export const imageConfig = ${JSON.stringify({ ...settings.config.image, experimentalResponsiveImages: settings.config.experimental.responsiveImages })}; @@ -251,5 +258,6 @@ export default function assets({ } }, }, + fontsPlugin({ settings, sync, logger }), ]; } diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts index 35290abde..a1301662e 100644 --- a/packages/astro/src/config/entrypoint.ts +++ b/packages/astro/src/config/entrypoint.ts @@ -7,6 +7,8 @@ export { defineConfig, getViteConfig } from './index.js'; export { envField } from '../env/config.js'; export { mergeConfig } from '../core/config/merge.js'; export { validateConfig } from '../core/config/validate.js'; +export { fontProviders, defineAstroFontProvider } from '../assets/fonts/providers/index.js'; +export type { AstroFontProvider as FontProvider } from '../assets/fonts/types.js'; /** * Return the configuration needed to use the Sharp-based image service diff --git a/packages/astro/src/config/index.ts b/packages/astro/src/config/index.ts index 593f60f30..766f8ddf0 100644 --- a/packages/astro/src/config/index.ts +++ b/packages/astro/src/config/index.ts @@ -7,6 +7,7 @@ import type { SessionDriverName, } from '../types/public/config.js'; import { createDevelopmentManifest } from '../vite-plugin-astro-server/plugin.js'; +import type { FontFamily } from '../assets/fonts/types.js'; /** * See the full Astro Configuration API Documentation @@ -15,7 +16,8 @@ import { createDevelopmentManifest } from '../vite-plugin-astro-server/plugin.js export function defineConfig< const TLocales extends Locales = never, const TDriver extends SessionDriverName = never, ->(config: AstroUserConfig<TLocales, TDriver>) { + const TFontFamilies extends FontFamily[] = never, +>(config: AstroUserConfig<TLocales, TDriver, TFontFamilies>) { return config; } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index f612ec7fa..2313ca01a 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -18,7 +18,7 @@ import { } from '../../core/path.js'; import { toFallbackType, toRoutingStrategy } from '../../i18n/utils.js'; import { runHookBuildGenerated } from '../../integrations/hooks.js'; -import { getOutputDirectory } from '../../prerender/utils.js'; +import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings, ComponentInstance } from '../../types/astro.js'; import type { GetStaticPathsItem, MiddlewareHandler } from '../../types/public/common.js'; import type { AstroConfig } from '../../types/public/config.js'; @@ -58,7 +58,7 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil if (ssr) { manifest = await BuildPipeline.retrieveManifest(options.settings, internals); } else { - const baseDirectory = getOutputDirectory(options.settings); + const baseDirectory = getServerOutputDirectory(options.settings); const renderersEntryUrl = new URL('renderers.mjs', baseDirectory); const renderers = await import(renderersEntryUrl.toString()); const middleware: MiddlewareHandler = internals.middlewareEntryPoint diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index 70be64fdf..996b6fc69 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -1,4 +1,4 @@ -import { getOutputDirectory } from '../../prerender/utils.js'; +import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings, ComponentInstance } from '../../types/astro.js'; import type { RewritePayload } from '../../types/public/common.js'; import type { @@ -115,7 +115,7 @@ export class BuildPipeline extends Pipeline { settings: AstroSettings, internals: BuildInternals, ): Promise<SSRManifest> { - const baseDirectory = getOutputDirectory(settings); + const baseDirectory = getServerOutputDirectory(settings); const manifestEntryUrl = new URL( `${internals.manifestFileName}?time=${Date.now()}`, baseDirectory, diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 9e903696c..955acf7d8 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -9,7 +9,7 @@ import { type BuildInternals, createBuildInternals } from '../../core/build/inte import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js'; import { appendForwardSlash, prependForwardSlash } from '../../core/path.js'; import { runHookBuildSetup } from '../../integrations/hooks.js'; -import { getOutputDirectory } from '../../prerender/utils.js'; +import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { RouteData } from '../../types/public/internal.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { routeIsRedirect } from '../redirects/index.js'; @@ -142,7 +142,7 @@ async function ssrBuild( ) { const { allPages, settings, viteConfig } = opts; const ssr = settings.buildOutput === 'server'; - const out = getOutputDirectory(settings); + const out = getServerOutputDirectory(settings); const routes = Object.values(allPages).flatMap((pageData) => pageData.route); const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input); const viteBuildConfig: vite.InlineConfig = { diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts index b6b313254..76894fb81 100644 --- a/packages/astro/src/core/build/util.ts +++ b/packages/astro/src/core/build/util.ts @@ -67,3 +67,4 @@ export function viteBuildReturnToRollupOutputs( } return result; } + diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 427b5137d..c067de212 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -8,6 +8,7 @@ import type { import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark'; import { type BuiltinTheme, bundledThemes } from 'shiki'; import { z } from 'zod'; +import { remoteFontFamilySchema, localFontFamilySchema } from '../../../assets/fonts/config.js'; import { EnvSchema } from '../../../env/schema.js'; import type { AstroUserConfig, ViteUserConfig } from '../../../types/public/config.js'; @@ -471,6 +472,7 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.preserveScriptOrder), + fonts: z.array(z.union([localFontFamilySchema, remoteFontFamilySchema])).optional(), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`, diff --git a/packages/astro/src/core/config/schemas/refined.ts b/packages/astro/src/core/config/schemas/refined.ts index 25e058cf4..c14e18f12 100644 --- a/packages/astro/src/core/config/schemas/refined.ts +++ b/packages/astro/src/core/config/schemas/refined.ts @@ -186,4 +186,21 @@ export const AstroConfigRefinedSchema = z.custom<AstroConfig>().superRefine((con path: ['experimental', 'responsiveImages'], }); } + + if (config.experimental.fonts && config.experimental.fonts.length > 0) { + for (let i = 0; i < config.experimental.fonts.length; i++) { + const { cssVariable } = config.experimental.fonts[i]; + + // Checks if the name starts with --, doesn't include a space nor a colon. + // We are not trying to recreate the full CSS spec about indents: + // https://developer.mozilla.org/en-US/docs/Web/CSS/ident + if (!cssVariable.startsWith('--') || cssVariable.includes(' ') || cssVariable.includes(':')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `**cssVariable** property "${cssVariable}" contains invalid characters for CSS variable generation. It must start with -- and be a valid indent: https://developer.mozilla.org/en-US/docs/Web/CSS/ident.`, + path: ['fonts', i, 'cssVariable'], + }); + } + } + } }); diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index e79324538..cf831319b 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -164,7 +164,7 @@ export async function createVite( astroContentAssetPropagationPlugin({ settings }), vitePluginMiddleware({ settings }), vitePluginSSRManifest(), - astroAssetsPlugin({ fs, settings }), + astroAssetsPlugin({ fs, settings, sync, logger }), astroPrefetch({ settings }), astroTransitions({ settings }), astroDevToolbar({ settings, logger }), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index e859c395a..9e8366dbf 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1295,6 +1295,73 @@ export const UnknownFilesystemError = { /** * @docs + * @description + * Cannot extract the font type from the given URL. + */ +export const CannotExtractFontType = { + name: 'CannotExtractFontType', + title: 'Cannot extract the font type from the given URL.', + hint: 'Open an issue at https://github.com/withastro/astro/issues.', +} satisfies ErrorData; + +/** + * @docs + * @description + * Cannot fetch the given font file + * @message + * An error occured while fetching font file from the given URL. + */ +export const CannotFetchFontFile = { + name: 'CannotFetchFontFile', + title: 'Cannot fetch the given font file.', + message: (url: string) => `An error occurred while fetching the font file from ${url}`, + hint: 'This is often caused by connectivity issues. If the error persists, open an issue at https://github.com/withastro/astro/issues.', +} satisfies ErrorData; + +/** + * @docs + * @description + * Cannot load font provider + * @message + * Astro is unable to load the given font provider. Open an issue on the corresponding provider's repository. + */ +export const CannotLoadFontProvider = { + name: 'CannotLoadFontProvider', + title: 'Cannot load font provider', + message: (entrypoint: string) => `An error occured while loading the "${entrypoint}" provider.`, + hint: 'This is an issue with the font provider. Please open an issue on their repository.', +} satisfies ErrorData; + +/** + * @docs + * @description + * Font component is used but experimental fonts have not been registered in the config. + */ +export const ExperimentalFontsNotEnabled = { + name: 'ExperimentalFontsNotEnabled', + title: 'Experimental fonts are not enabled', + message: + 'The Font component is used but experimental fonts have not been registered in the config.', + hint: 'Check that you have enabled experimental fonts and also configured your preferred fonts.', +} satisfies ErrorData; + +/** + * @docs + * @description + * Font family not found + * @message + * No data was found for the family passed to the Font component. + */ +export const FontFamilyNotFound = { + name: 'FontFamilyNotFound', + title: 'Font family not found', + message: (family: string) => + `No data was found for the \`"${family}"\` family passed to the \`<Font>\` component.`, + hint: 'This is often caused by a typo. Check that your Font component is using a `cssVariable` specified in your config.', +} satisfies ErrorData; + +/** + * @docs * @kind heading * @name CSS Errors */ diff --git a/packages/astro/src/core/middleware/vite-plugin.ts b/packages/astro/src/core/middleware/vite-plugin.ts index 19cb87bc4..b24131a39 100644 --- a/packages/astro/src/core/middleware/vite-plugin.ts +++ b/packages/astro/src/core/middleware/vite-plugin.ts @@ -1,5 +1,5 @@ import type { Plugin as VitePlugin } from 'vite'; -import { getOutputDirectory } from '../../prerender/utils.js'; +import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings } from '../../types/astro.js'; import { addRollupInput } from '../build/add-rollup-input.js'; import type { BuildInternals } from '../build/internal.js'; @@ -112,7 +112,7 @@ export function vitePluginMiddlewareBuild( writeBundle(_, bundle) { for (const [chunkName, chunk] of Object.entries(bundle)) { if (chunk.type !== 'asset' && chunk.facadeModuleId === MIDDLEWARE_MODULE_ID) { - const outputDirectory = getOutputDirectory(opts.settings); + const outputDirectory = getServerOutputDirectory(opts.settings); internals.middlewareEntryPoint = new URL(chunkName, outputDirectory); } } diff --git a/packages/astro/src/core/sync/index.ts b/packages/astro/src/core/sync/index.ts index 9c3ee053f..1b2708681 100644 --- a/packages/astro/src/core/sync/index.ts +++ b/packages/astro/src/core/sync/index.ts @@ -34,6 +34,7 @@ import type { Logger } from '../logger/core.js'; import { createRoutesList } from '../routing/index.js'; import { ensureProcessNodeEnv } from '../util.js'; import { normalizePath } from '../viteUtils.js'; +import { syncFonts } from '../../assets/fonts/sync.js'; export type SyncOptions = { mode: string; @@ -181,6 +182,7 @@ export async function syncInternal({ } } syncAstroEnv(settings); + syncFonts(settings); writeInjectedTypes(settings, fs); logger.info('types', `Generated ${dim(getTimeStat(timerStart, performance.now()))}`); diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index cdf4eb80b..7a558f660 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -34,6 +34,7 @@ import type { } from '../types/public/integrations.js'; import type { RouteData } from '../types/public/internal.js'; import { validateSupportedFeatures } from './features-validation.js'; +import { getClientOutputDirectory } from '../prerender/utils.js'; async function withTakingALongTimeMsg<T>({ name, @@ -605,8 +606,7 @@ type RunHookBuildDone = { }; export async function runHookBuildDone({ settings, pages, routes, logger }: RunHookBuildDone) { - const dir = - settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir; + const dir = getClientOutputDirectory(settings); await fsMod.promises.mkdir(dir, { recursive: true }); const integrationRoutes = routes.map(toIntegrationRouteData); diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts index 06ddc09ef..7c2dd8fcf 100644 --- a/packages/astro/src/prerender/utils.ts +++ b/packages/astro/src/prerender/utils.ts @@ -9,10 +9,15 @@ export function getPrerenderDefault(config: AstroConfig) { /** * Returns the correct output directory of the SSR build based on the configuration */ -export function getOutputDirectory(settings: AstroSettings): URL { - if (settings.buildOutput === 'server') { - return settings.config.build.server; - } else { - return getOutDirWithinCwd(settings.config.outDir); - } +export function getServerOutputDirectory(settings: AstroSettings): URL { + return settings.buildOutput === 'server' + ? settings.config.build.server + : getOutDirWithinCwd(settings.config.outDir); +} + +/** + * Returns the correct output directory of the client build based on the configuration + */ +export function getClientOutputDirectory(settings: AstroSettings): URL { + return settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir; } diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 7b7bad1aa..fea7da407 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -17,8 +17,12 @@ import type { AstroCookieSetOptions } from '../../core/cookies/cookies.js'; import type { Logger, LoggerLevel } from '../../core/logger/core.js'; import type { EnvSchema } from '../../env/schema.js'; import type { AstroIntegration } from './integrations.js'; +import type { FontFamily, AstroFontProvider } from '../../assets/fonts/types.js'; + export type Locales = (string | { codes: [string, ...string[]]; path: string })[]; +export type { AstroFontProvider as FontProvider }; + type NormalizeLocales<T extends Locales> = { [K in keyof T]: T[K] extends string ? T[K] @@ -184,6 +188,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig { */ export interface AstroUserConfig< TLocales extends Locales = never, TSession extends SessionDriverName = never, + TFontFamilies extends FontFamily[] = never, > { /** * @docs @@ -2208,6 +2213,25 @@ export interface ViteUserConfig extends OriginalViteUserConfig { svg?: boolean; /** + * + * @name experimental.fonts + * @type {FontFamily[]} + * @version 5.7 + * @description + * + * This experimental feature allows you to use fonts from your filesystem and various providers + * (eg. Google, Fontsource, Bunny...) through a unified, fully customizable and type-safe API. + * + * Web fonts can impact page performance at both load time and rendering time. This feature provides + * automatic [optimization](https://web.dev/learn/performance/optimize-web-fonts) by creating + * preload links and optimized fallbacks. This API includes opinionated defaults to keep your sites lightweight and performant (e.g. minimal font files downloaded) while allowing for extensive customization so you can opt in to greater control. + * + * For a complete overview, and to give feedback on this experimental API, + * see the [Fonts RFC](https://github.com/withastro/roadmap/pull/1039). + */ + fonts?: [TFontFamilies] extends [never] ? FontFamily[] : TFontFamilies; + + /** * @name experimental.headingIdCompat * @type {boolean} * @default `false` diff --git a/packages/astro/test/fixtures/fonts/astro.config.mjs b/packages/astro/test/fixtures/fonts/astro.config.mjs new file mode 100644 index 000000000..882e6515a --- /dev/null +++ b/packages/astro/test/fixtures/fonts/astro.config.mjs @@ -0,0 +1,4 @@ +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/fonts/package.json b/packages/astro/test/fixtures/fonts/package.json new file mode 100644 index 000000000..873d15586 --- /dev/null +++ b/packages/astro/test/fixtures/fonts/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/fonts", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/fonts/src/pages/index.astro b/packages/astro/test/fixtures/fonts/src/pages/index.astro new file mode 100644 index 000000000..22d92f7f8 --- /dev/null +++ b/packages/astro/test/fixtures/fonts/src/pages/index.astro @@ -0,0 +1,5 @@ +--- +import { Font } from 'astro:assets' +--- + +<Font cssVariable='--font-roboto' />
\ No newline at end of file diff --git a/packages/astro/test/fixtures/fonts/src/pages/preload.astro b/packages/astro/test/fixtures/fonts/src/pages/preload.astro new file mode 100644 index 000000000..40f525baa --- /dev/null +++ b/packages/astro/test/fixtures/fonts/src/pages/preload.astro @@ -0,0 +1,5 @@ +--- +import { Font } from 'astro:assets' +--- + +<Font cssVariable='--font-roboto' preload />
\ No newline at end of file diff --git a/packages/astro/test/fonts.test.js b/packages/astro/test/fonts.test.js new file mode 100644 index 000000000..4e8916a0d --- /dev/null +++ b/packages/astro/test/fonts.test.js @@ -0,0 +1,94 @@ +// @ts-check +import { after, before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; +import assert from 'node:assert/strict'; +import * as cheerio from 'cheerio'; +import { fontProviders } from 'astro/config' + +describe('astro:fonts', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + /** @type {import('./test-utils.js').DevServer} */ + let devServer; + + describe('<Font /> component', () => { + // TODO: remove once fonts are stabilized + describe('Fonts are not enabled', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/fonts/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Throws an error if fonts are not enabled', async () => { + const res = await fixture.fetch('/'); + const body = await res.text(); + assert.equal( + body.includes('<script type="module" src="/@vite/client">'), + true, + 'Body does not include Vite error overlay script', + ); + }); + }); + + describe('Fonts are enabled', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/fonts/', + experimental: { + fonts: [ + { + name: 'Roboto', + cssVariable: '--font-roboto', + provider: fontProviders.google(), + }, + ], + }, + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Includes styles', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + assert.equal(html.includes('<style>'), true); + }); + + it('Includes links when preloading', async () => { + const res = await fixture.fetch('/preload'); + const html = await res.text(); + assert.equal(html.includes('<link rel="preload"'), true); + }); + + it('Has correct headers in dev', async () => { + let res = await fixture.fetch('/preload'); + const html = await res.text(); + const $ = cheerio.load(html); + const href = $('link[rel=preload][type^=font/woff2]').attr('href'); + + if (!href) { + assert.fail(); + } + + const headers = await fixture.fetch(href).then((r) => r.headers); + assert.equal(headers.has('Content-Length'), true); + assert.equal(headers.get('Content-Type'), 'font/woff2'); + assert.equal( + headers.get('Cache-Control'), + 'no-store, no-cache, must-revalidate, max-age=0', + ); + assert.equal(headers.get('Pragma'), 'no-cache'); + assert.equal(headers.get('Expires'), '0'); + }); + }); + }); +}); diff --git a/packages/astro/test/types/define-config.ts b/packages/astro/test/types/define-config.ts index 7d68ae035..16a0e1090 100644 --- a/packages/astro/test/types/define-config.ts +++ b/packages/astro/test/types/define-config.ts @@ -2,40 +2,140 @@ import { describe, it } from 'node:test'; import { expectTypeOf } from 'expect-type'; import { defineConfig } from '../../dist/config/index.js'; import type { AstroUserConfig } from '../../dist/types/public/index.js'; +import type { FontFamily, AstroFontProvider } from '../../dist/assets/fonts/types.js'; +import { defineAstroFontProvider } from '../../dist/config/entrypoint.js'; + +function assertType<T>(data: T, cb: (data: NoInfer<T>) => void) { + cb(data); +} describe('defineConfig()', () => { - it('Infers generics correctly', () => { - const config_0 = defineConfig({}); - expectTypeOf(config_0).toEqualTypeOf<AstroUserConfig<never>>(); - expectTypeOf(config_0.i18n!.defaultLocale).toEqualTypeOf<string>(); - - const config_1 = defineConfig({ - i18n: { - locales: ['en'], - defaultLocale: 'en', - }, + it('Infers i18n generics correctly', () => { + assertType(defineConfig({}), (config) => { + expectTypeOf(config).toEqualTypeOf<AstroUserConfig<never, never, never>>(); + expectTypeOf(config.i18n!.defaultLocale).toEqualTypeOf<string>(); }); - expectTypeOf(config_1).toEqualTypeOf<AstroUserConfig<['en']>>(); - expectTypeOf(config_1.i18n!.defaultLocale).toEqualTypeOf<'en'>(); - const config_2 = defineConfig({ - i18n: { - locales: ['en', 'fr'], - defaultLocale: 'fr', + assertType( + defineConfig({ + i18n: { + locales: ['en'], + defaultLocale: 'en', + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf<AstroUserConfig<['en'], never, never>>(); + expectTypeOf(config.i18n!.defaultLocale).toEqualTypeOf<'en'>(); }, - }); - expectTypeOf(config_2).toEqualTypeOf<AstroUserConfig<['en', 'fr']>>(); - expectTypeOf(config_2.i18n!.defaultLocale).toEqualTypeOf<'en' | 'fr'>(); + ); - const config_3 = defineConfig({ - i18n: { - locales: ['en', { path: 'french', codes: ['fr', 'fr-FR'] }], - defaultLocale: 'en', + assertType( + defineConfig({ + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'fr', + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf<AstroUserConfig<['en', 'fr'], never, never>>(); + expectTypeOf(config.i18n!.defaultLocale).toEqualTypeOf<'en' | 'fr'>(); }, + ); + + assertType( + defineConfig({ + i18n: { + locales: ['en', { path: 'french', codes: ['fr', 'fr-FR'] }], + defaultLocale: 'en', + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf< + AstroUserConfig< + ['en', { readonly path: 'french'; readonly codes: ['fr', 'fr-FR'] }], + never, + never + > + >(); + expectTypeOf(config.i18n!.defaultLocale).toEqualTypeOf<'en' | 'fr' | 'fr-FR'>(); + }, + ); + }); + + it('Infers fonts generics correctly', () => { + assertType(defineConfig({}), (config) => { + expectTypeOf(config).toEqualTypeOf<AstroUserConfig<never, never, never>>(); + expectTypeOf(config.experimental!.fonts!).toEqualTypeOf<FontFamily[]>(); }); - expectTypeOf(config_3).toEqualTypeOf< - AstroUserConfig<['en', { readonly path: 'french'; readonly codes: ['fr', 'fr-FR'] }]> - >(); - expectTypeOf(config_3.i18n!.defaultLocale).toEqualTypeOf<'en' | 'fr' | 'fr-FR'>(); + + assertType( + defineConfig({ + experimental: { + fonts: [], + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf<AstroUserConfig<never, never, []>>(); + expectTypeOf(config.experimental!.fonts!).toEqualTypeOf<[]>(); + }, + ); + + const provider = defineAstroFontProvider({ entrypoint: '' }); + + assertType( + defineConfig({ + experimental: { + fonts: [ + { + name: 'bar', + cssVariable: '--font-bar', + provider: 'local', + variants: [{ src: [''], weight: 400, style: 'normal' }], + }, + { name: 'baz', cssVariable: '--font-baz', provider }, + ], + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf< + AstroUserConfig< + never, + never, + [ + { + readonly name: 'bar'; + readonly cssVariable: '--font-bar'; + readonly provider: 'local'; + readonly variants: [ + { readonly src: ['']; readonly weight: 400; readonly style: 'normal' }, + ]; + }, + { + readonly name: 'baz'; + readonly cssVariable: '--font-baz'; + readonly provider: AstroFontProvider; + }, + ] + > + >(); + expectTypeOf(config.experimental!.fonts!).toEqualTypeOf< + [ + { + readonly name: 'bar'; + readonly cssVariable: '--font-bar'; + readonly provider: 'local'; + readonly variants: [ + { readonly src: ['']; readonly weight: 400; readonly style: 'normal' }, + ]; + }, + { + readonly name: 'baz'; + readonly cssVariable: '--font-baz'; + readonly provider: AstroFontProvider; + }, + ] + >(); + }, + ); }); }); diff --git a/packages/astro/tsconfig.tests.json b/packages/astro/test/types/tsconfig.json index 1984bc4fe..cc320a61e 100644 --- a/packages/astro/tsconfig.tests.json +++ b/packages/astro/test/types/tsconfig.json @@ -1,6 +1,5 @@ { - "extends": "../../tsconfig.base.json", - "include": ["test/types"], + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "allowJs": true, "emitDeclarationOnly": false, diff --git a/packages/astro/test/units/assets/fonts/load.test.js b/packages/astro/test/units/assets/fonts/load.test.js new file mode 100644 index 000000000..7cb016758 --- /dev/null +++ b/packages/astro/test/units/assets/fonts/load.test.js @@ -0,0 +1,98 @@ +// @ts-check +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadFonts } from '../../../../dist/assets/fonts/load.js'; +import { resolveProvider } from '../../../../dist/assets/fonts/providers/utils.js'; +import { fontProviders } from '../../../../dist/assets/fonts/providers/index.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({ + base, + families: [ + { + name: 'Roboto', + nameWithHash: 'Roboto-xxx', + provider: await resolveProvider({ + root, + resolveMod: (id) => import(id), + provider: fontProviders.google(), + }), + fallbacks: ['sans-serif'], + cssVariable: '--custom', + display: 'block', + }, + ], + storage, + hashToUrlMap, + resolvedMap, + hashString: (v) => Buffer.from(v).toString('base64'), + getMetricsForFamily: async () => ({ + ascent: 0, + descent: 0, + lineGap: 0, + unitsPerEm: 0, + xWidthAvg: 0, + }), + generateFallbackFontFace: () => '', + 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()), ['--custom']); + assert.deepStrictEqual(logs, ['Fonts initialized']); + const css = resolvedMap.get('--custom').css; + assert.equal( + css.includes(':root { --custom: Roboto-xxx, "Roboto-xxx fallback: Arial", sans-serif; }'), + true, + ); + assert.equal(css.includes('font-display: block'), true); +}); diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js new file mode 100644 index 000000000..b4f7ad16b --- /dev/null +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -0,0 +1,217 @@ +// @ts-check +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fontProviders } from '../../../../dist/config/entrypoint.js'; +import { resolveLocalFont } from '../../../../dist/assets/fonts/providers/local.js'; +import * as adobeEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/adobe.js'; +import * as bunnyEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/bunny.js'; +import * as fontshareEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/fontshare.js'; +import * as fontsourceEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/fontsource.js'; +import * as googleEntrypoint from '../../../../dist/assets/fonts/providers/entrypoints/google.js'; +import { validateMod, resolveProvider } from '../../../../dist/assets/fonts/providers/utils.js'; +import { proxyURL } from '../../../../dist/assets/fonts/utils.js'; +import { basename, extname } from 'node:path'; + +/** + * @param {Parameters<resolveLocalFont>[0]['family']} family + */ +function resolveLocalFontSpy(family) { + /** @type {Array<string>} */ + const values = []; + + const { fonts } = resolveLocalFont({ + family, + proxyURL: (v) => + proxyURL({ + value: v, + hashString: (value) => basename(value, extname(value)), + collect: ({ hash, value }) => { + values.push(value); + return `/_astro/fonts/${hash}`; + }, + }), + }); + + return { + fonts, + values: [...new Set(values)], + }; +} + +describe('fonts providers', () => { + describe('config objects', () => { + it('references the right entrypoints', () => { + assert.equal( + fontProviders.adobe({ id: '' }).entrypoint, + 'astro/assets/fonts/providers/adobe', + ); + assert.equal(fontProviders.bunny().entrypoint, 'astro/assets/fonts/providers/bunny'); + assert.equal(fontProviders.fontshare().entrypoint, 'astro/assets/fonts/providers/fontshare'); + assert.equal( + fontProviders.fontsource().entrypoint, + 'astro/assets/fonts/providers/fontsource', + ); + assert.equal(fontProviders.google().entrypoint, 'astro/assets/fonts/providers/google'); + }); + + it('forwards the config', () => { + assert.deepStrictEqual(fontProviders.adobe({ id: 'foo' }).config, { + id: 'foo', + }); + }); + }); + + it('providers are correctly exported', () => { + assert.equal( + 'provider' in adobeEntrypoint && typeof adobeEntrypoint.provider === 'function', + true, + ); + assert.equal( + 'provider' in bunnyEntrypoint && typeof bunnyEntrypoint.provider === 'function', + true, + ); + assert.equal( + 'provider' in fontshareEntrypoint && typeof fontshareEntrypoint.provider === 'function', + true, + ); + assert.equal( + 'provider' in fontsourceEntrypoint && typeof fontsourceEntrypoint.provider === 'function', + true, + ); + assert.equal( + 'provider' in googleEntrypoint && typeof googleEntrypoint.provider === 'function', + true, + ); + }); + + it('resolveLocalFont()', () => { + let { fonts, values } = resolveLocalFontSpy({ + name: 'Custom', + nameWithHash: 'Custom-xxx', + cssVariable: '--custom', + provider: 'local', + variants: [ + { + src: [{ url: '/src/fonts/foo.woff2' }, { url: '/src/fonts/foo.ttf' }], + weight: '400', + style: 'normal', + display: 'block', + }, + ], + }); + + assert.deepStrictEqual(fonts, [ + { + weight: '400', + style: 'normal', + display: 'block', + src: [ + { + originalURL: '/src/fonts/foo.woff2', + url: '/_astro/fonts/foo.woff2', + format: 'woff2', + tech: undefined, + }, + { + originalURL: '/src/fonts/foo.ttf', + url: '/_astro/fonts/foo.ttf', + format: 'ttf', + tech: undefined, + }, + ], + }, + ]); + assert.deepStrictEqual(values, ['/src/fonts/foo.woff2', '/src/fonts/foo.ttf']); + + ({ fonts, values } = resolveLocalFontSpy({ + name: 'Custom', + nameWithHash: 'Custom-xxx', + cssVariable: '--custom', + provider: 'local', + variants: [ + { + src: [{ url: '/src/fonts/bar.eot', tech: 'color-SVG' }], + weight: '600', + style: 'oblique', + stretch: 'condensed', + }, + { + src: [{ url: '/src/fonts/bar.eot' }], + weight: '700', + style: 'oblique', + stretch: 'condensed', + }, + ], + })); + + assert.deepStrictEqual(fonts, [ + { + weight: '600', + style: 'oblique', + stretch: 'condensed', + src: [ + { + originalURL: '/src/fonts/bar.eot', + url: '/_astro/fonts/bar.eot', + format: 'eot', + tech: 'color-SVG', + }, + ], + }, + { + weight: '700', + style: 'oblique', + stretch: 'condensed', + src: [ + { + originalURL: '/src/fonts/bar.eot', + url: '/_astro/fonts/bar.eot', + format: 'eot', + tech: undefined, + }, + ], + }, + ]); + assert.deepStrictEqual(values, ['/src/fonts/bar.eot']); + }); + + describe('utils', () => { + it('validateMod()', () => { + const provider = () => {}; + + assert.deepStrictEqual(validateMod({ provider }, 'custom'), { provider }); + + const invalidMods = [{}, null, () => {}, { provider: {} }, { provider: null }]; + + for (const invalidMod of invalidMods) { + try { + validateMod(invalidMod, 'custom'); + assert.fail('This mod should not pass'); + } catch (err) { + assert.equal(err instanceof Error, true); + } + } + }); + + it('resolveProvider()', async () => { + const root = new URL(import.meta.url); + const provider = () => {}; + + assert.deepStrictEqual( + await resolveProvider({ + provider: { + entrypoint: 'bar', + config: { abc: 404 }, + }, + + resolveMod: async () => ({ provider }), + root, + }), + { + config: { abc: 404 }, + provider, + }, + ); + }); + }); +}); diff --git a/packages/astro/test/units/assets/fonts/utils.test.js b/packages/astro/test/units/assets/fonts/utils.test.js new file mode 100644 index 000000000..97a677167 --- /dev/null +++ b/packages/astro/test/units/assets/fonts/utils.test.js @@ -0,0 +1,600 @@ +// @ts-check +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isFontType, + extractFontType, + cache as internalCache, + proxyURL, + isGenericFontFamily, + generateFallbacksCSS, + resolveFontFamily, + familiesToUnifontProviders, +} from '../../../../dist/assets/fonts/utils.js'; +import { fileURLToPath } from 'node:url'; +import { fontProviders } from '../../../../dist/assets/fonts/providers/index.js'; + +function createSpyCache() { + /** @type {Map<string, Buffer>} */ + const store = new Map(); + + const storage = { + /** + * @param {string} key + * @returns {Promise<Buffer | null>} + */ + getItemRaw: async (key) => { + return store.get(key) ?? null; + }, + /** + * @param {string} key + * @param {Buffer} value + * @returns {Promise<void>} + */ + setItemRaw: async (key, value) => { + store.set(key, value); + }, + }; + + 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()), + }; +} + +/** + * + * @param {string} id + * @param {string} value + */ +function proxyURLSpy(id, value) { + /** @type {Parameters<import('../../../../dist/assets/fonts/utils.js').ProxyURLOptions['collect']>[0]} */ + let collected = /** @type {any} */ (undefined); + const url = proxyURL({ + value, + hashString: () => id, + collect: (data) => { + collected = data; + return 'base/' + data.hash; + }, + }); + + return { + url, + collected, + }; +} + +describe('fonts utils', () => { + it('isFontType()', () => { + assert.equal(isFontType('woff2'), true); + assert.equal(isFontType('woff'), true); + assert.equal(isFontType('otf'), true); + assert.equal(isFontType('ttf'), true); + assert.equal(isFontType('eot'), true); + assert.equal(isFontType(''), false); + }); + + it('extractFontType', () => { + /** @type {Array<[string, false | string]>} */ + const data = [ + ['', false], + ['.', false], + ['test.', false], + ['https://foo.bar/file', false], + [ + 'https://fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2', + 'woff2', + ], + ['/home/documents/project/font.ttf', 'ttf'], + ]; + + for (const [input, check] of data) { + try { + const res = extractFontType(input); + if (check) { + assert.equal(res, check); + } else { + assert.fail(`String ${JSON.stringify(input)} should not be valid`); + } + } catch (e) { + if (check) { + assert.fail(`String ${JSON.stringify(input)} should be valid`); + } else { + assert.equal(e instanceof Error, true); + assert.equal(e.title, 'Cannot extract the font type from the given URL.'); + } + } + } + }); + + it('cache()', async () => { + const { cache, getKeys } = createSpyCache(); + + assert.deepStrictEqual(getKeys(), []); + + let buffer = Buffer.from('foo'); + let res = await cache('foo', async () => buffer); + assert.equal(res.cached, false); + assert.equal(res.data, buffer); + + assert.deepStrictEqual(getKeys(), ['foo']); + + res = await cache('foo', async () => buffer); + assert.equal(res.cached, true); + assert.equal(res.data, buffer); + + assert.deepStrictEqual(getKeys(), ['foo']); + }); + + it('proxyURL()', () => { + let { url, collected } = proxyURLSpy( + 'foo', + 'https://fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2', + ); + assert.equal(url, 'base/foo.woff2'); + assert.deepStrictEqual(collected, { + hash: 'foo.woff2', + type: 'woff2', + value: + 'https://fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2', + }); + + ({ url, collected } = proxyURLSpy('bar', '/home/documents/project/font.ttf')); + assert.equal(url, 'base/bar.ttf'); + assert.deepStrictEqual(collected, { + hash: 'bar.ttf', + type: 'ttf', + value: '/home/documents/project/font.ttf', + }); + }); + + 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: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, + fallbacks: [], + font: null, + metrics: { + getMetricsForFamily: async () => null, + generateFontFace: () => '', + }, + }), + null, + ); + }); + + it('should return fallbacks even without automatic fallbacks generation', async () => { + assert.deepStrictEqual( + await generateFallbacksCSS({ + family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, + fallbacks: ['foo'], + font: null, + metrics: null, + }), + { + css: '', + fallbacks: ['foo'], + }, + ); + }); + + it('should return fallbacks if there are no metrics', async () => { + assert.deepStrictEqual( + await generateFallbacksCSS({ + family: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, + fallbacks: ['foo'], + font: null, + metrics: { + 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: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, + fallbacks: ['foo'], + font: null, + metrics: { + 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: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, + fallbacks: ['emoji'], + font: null, + metrics: { + 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: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, + fallbacks: ['foo', 'bar'], + font: null, + metrics: { + 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: { name: 'Roboto', nameWithHash: 'Roboto-xxx' }, + fallbacks: ['sans-serif', 'foo'], + font: null, + metrics: { + getMetricsForFamily: async () => ({ + ascent: 0, + descent: 0, + lineGap: 0, + unitsPerEm: 0, + xWidthAvg: 0, + }), + generateFontFace: (_metrics, fallback) => `[${fallback.font},${fallback.name}]`, + }, + }), + { + css: '', + fallbacks: ['sans-serif', 'foo'], + }, + ); + }); + }); + + describe('resolveFontFamily()', () => { + const root = new URL(import.meta.url); + + it('handles the local provider correctly', async () => { + assert.deepStrictEqual( + await resolveFontFamily({ + family: { + name: 'Custom', + cssVariable: '--custom', + provider: 'local', + variants: [ + { + src: ['a'], + weight: 400, + style: 'normal', + }, + ], + }, + resolveMod: async () => ({ provider: () => {} }), + generateNameWithHash: (family) => `${family.name}-x`, + root, + }), + { + name: 'Custom', + nameWithHash: 'Custom-x', + cssVariable: '--custom', + provider: 'local', + fallbacks: undefined, + variants: [ + { + src: [{ url: fileURLToPath(new URL('a', root)), tech: undefined }], + weight: '400', + style: 'normal', + }, + ], + }, + ); + assert.deepStrictEqual( + await resolveFontFamily({ + family: { + name: 'Custom', + cssVariable: '--custom', + provider: 'local', + variants: [ + { + src: ['a'], + weight: 400, + style: 'normal', + }, + ], + }, + resolveMod: async () => ({ provider: () => {} }), + generateNameWithHash: (family) => `${family.name}-x`, + root, + }), + { + name: 'Custom', + nameWithHash: 'Custom-x', + cssVariable: '--custom', + provider: 'local', + fallbacks: undefined, + variants: [ + { + src: [{ url: fileURLToPath(new URL('a', root)), tech: undefined }], + weight: '400', + style: 'normal', + }, + ], + }, + ); + }); + + it('handles the google provider correctly', async () => { + let res = await resolveFontFamily({ + family: { + name: 'Custom', + cssVariable: '--custom', + provider: fontProviders.google(), + }, + resolveMod: (id) => import(id), + generateNameWithHash: (family) => `${family.name}-x`, + root, + }); + assert.equal(res.name, 'Custom'); + // Required to make TS happy + if (res.provider !== 'local') { + const provider = res.provider.provider(res.provider.config); + assert.equal(provider._name, 'google'); + } + + res = await resolveFontFamily({ + family: { + name: 'Custom', + cssVariable: '--custom', + provider: fontProviders.google(), + }, + resolveMod: (id) => import(id), + generateNameWithHash: (family) => `${family.name}-x`, + root, + }); + assert.equal(res.name, 'Custom'); + // Required to make TS happy + if (res.provider !== 'local') { + const provider = res.provider.provider(res.provider.config); + assert.equal(provider._name, 'google'); + } + }); + + it('handles custom providers correctly', async () => { + const res = await resolveFontFamily({ + family: { + name: 'Custom', + cssVariable: '--custom', + provider: { + entrypoint: '', + }, + }, + resolveMod: async () => ({ provider: () => Object.assign(() => {}, { _name: 'test' }) }), + generateNameWithHash: (family) => `${family.name}-x`, + root, + }); + assert.equal(res.name, 'Custom'); + if (res.provider !== 'local') { + // Required to make TS happy + const provider = res.provider.provider(res.provider.config); + assert.equal(provider._name, 'test'); + } + }); + }); + + describe('familiesToUnifontProviders()', () => { + const createProvider = (/** @type {string} */ name) => () => + Object.assign(() => undefined, { _name: name }); + + /** @param {Array<import('../../../../dist/assets/fonts/types.js').ResolvedFontFamily>} families */ + function createFixture(families) { + const result = familiesToUnifontProviders({ + hashString: (v) => v, + families, + }); + return { + /** + * @param {number} length + */ + assertProvidersLength: (length) => { + assert.equal(result.providers.length, length); + }, + /** + * @param {Array<string | undefined>} names + */ + assertProvidersNames: (names) => { + assert.deepStrictEqual( + result.families.map((f) => + typeof f.provider === 'string' ? f.provider : f.provider.name, + ), + names, + ); + }, + }; + } + + it('skips local fonts', () => { + const fixture = createFixture([ + { + name: 'Custom', + nameWithHash: 'Custom-xxx', + cssVariable: '--custom', + provider: 'local', + variants: [ + { + src: [{ url: 'a' }], + weight: '400', + style: 'normal', + }, + ], + }, + ]); + fixture.assertProvidersLength(0); + fixture.assertProvidersNames(['local']); + }); + + it('appends a hash to the provider name', () => { + const fixture = createFixture([ + { + name: 'Custom', + nameWithHash: 'Custom-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + }, + }, + ]); + fixture.assertProvidersLength(1); + fixture.assertProvidersNames(['test-{"name":"test"}']); + }); + + it('deduplicates providers with no config', () => { + const fixture = createFixture([ + { + name: 'Foo', + nameWithHash: 'Foo-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + }, + }, + { + name: 'Bar', + nameWithHash: 'Bar-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + }, + }, + ]); + fixture.assertProvidersLength(1); + fixture.assertProvidersNames(['test-{"name":"test"}', undefined]); + }); + + it('deduplicates providers with the same config', () => { + const fixture = createFixture([ + { + name: 'Foo', + nameWithHash: 'Foo-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + config: { x: 'y' }, + }, + }, + { + name: 'Bar', + nameWithHash: 'Bar-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + config: { x: 'y' }, + }, + }, + ]); + fixture.assertProvidersLength(1); + fixture.assertProvidersNames(['test-{"name":"test","x":"y"}', undefined]); + }); + + it('does not deduplicate providers with different configs', () => { + const fixture = createFixture([ + { + name: 'Foo', + nameWithHash: 'Foo-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + config: { + x: 'foo', + }, + }, + }, + { + name: 'Bar', + nameWithHash: 'Bar-xxx', + cssVariable: '--custom', + provider: { + provider: createProvider('test'), + config: { + x: 'bar', + }, + }, + }, + ]); + fixture.assertProvidersLength(2); + fixture.assertProvidersNames([ + 'test-{"name":"test","x":"foo"}', + 'test-{"name":"test","x":"bar"}', + ]); + }); + }); +}); diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 81c3bc5fc..cee3de345 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -387,4 +387,78 @@ describe('Config Validation', () => { ); }); }); + + describe('fonts', () => { + it('Should allow empty fonts', () => { + assert.doesNotThrow(() => + validateConfig({ + experimental: { + fonts: [], + }, + }), + ); + }); + + it('Should error on invalid css variable', async () => { + let configError = await validateConfig({ + experimental: { + fonts: [{ name: 'Roboto', cssVariable: 'test', provider: { entrypoint: '' } }], + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message.includes( + 'contains invalid characters for CSS variable generation', + ), + true, + ); + + configError = await validateConfig({ + experimental: { + fonts: [{ name: 'Roboto', cssVariable: '-test', provider: { entrypoint: '' } }], + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message.includes( + 'contains invalid characters for CSS variable generation', + ), + true, + ); + + configError = await validateConfig({ + experimental: { + fonts: [{ name: 'Roboto', cssVariable: '--test ', provider: { entrypoint: '' } }], + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message.includes( + 'contains invalid characters for CSS variable generation', + ), + true, + ); + + configError = await validateConfig({ + experimental: { + fonts: [{ name: 'Roboto', cssVariable: '--test:x', provider: { entrypoint: '' } }], + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message.includes( + 'contains invalid characters for CSS variable generation', + ), + true, + ); + + assert.doesNotThrow(() => + validateConfig({ + experimental: { + fonts: [{ name: 'Roboto', cssVariable: '--test', provider: { entrypoint: '' } }], + }, + }), + ); + }); + }); }); diff --git a/packages/astro/types/fonts.d.ts b/packages/astro/types/fonts.d.ts new file mode 100644 index 000000000..f78805b3c --- /dev/null +++ b/packages/astro/types/fonts.d.ts @@ -0,0 +1,4 @@ +declare module 'astro:assets' { + /** @internal Run `astro dev` or `astro sync` to generate high fidelity types */ + export type FontFamily = string; +} diff --git a/patches/unifont@0.1.7.patch b/patches/unifont@0.1.7.patch new file mode 100644 index 000000000..625a623d9 --- /dev/null +++ b/patches/unifont@0.1.7.patch @@ -0,0 +1,22 @@ +diff --git a/dist/index.js b/dist/index.js +index a8d7acc843a641e4797d69b897fc66fbddbd31a9..93c3e63a1a238505ccf8f6b12767a2acdcb0d183 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -383,7 +383,7 @@ const fontsource = defineFontProvider("fontsource", async (_options, ctx) => { + const fontFaceData = []; + for (const subset of subsets) { + for (const style of styles) { +- if (font.variable) { ++ if (options.weights.some(weight => weight.includes(" ")) && font.variable) { + try { + const variableAxes = await ctx.storage.getItem(`fontsource:${font.family}-axes.json`, () => fontAPI(`/variable/${font.id}`, { responseType: "json" })); + if (variableAxes && variableAxes.axes.wght) { +@@ -441,7 +441,7 @@ const google = defineFontProvider("google", async (_options = {}, ctx) => { + async function getFontDetails(family, options) { + const font = googleFonts.find((font2) => font2.family === family); + const styles = [...new Set(options.styles.map((i) => styleMap[i]))].sort(); +- const variableWeight = font.axes.find((a) => a.tag === "wght"); ++ const variableWeight = options.weights.some(weight => weight.includes(" ")) && font.axes.find((a) => a.tag === "wght"); + const weights = variableWeight ? [`${variableWeight.min}..${variableWeight.max}`] : options.weights.filter((weight) => weight in font.fonts); + if (weights.length === 0 || styles.length === 0) + return []; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f981d49d..1eca4cdfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: false excludeLinksFromLockfile: false +patchedDependencies: + unifont@0.1.7: + hash: 8015962152377f03e7f61d7d6a3511d95131453e45fd8afb4c4e10c337306488 + path: patches/unifont@0.1.7.patch + importers: .: @@ -472,6 +477,12 @@ importers: '@astrojs/telemetry': specifier: workspace:* version: link:../telemetry + '@capsizecss/metrics': + specifier: ^3.5.0 + version: 3.5.0 + '@capsizecss/unpack': + specifier: ^2.4.0 + version: 2.4.0 '@oslojs/encoding': specifier: ^1.1.0 version: 1.1.0 @@ -598,6 +609,9 @@ importers: ultrahtml: specifier: ^1.6.0 version: 1.6.0 + unifont: + specifier: ^0.1.7 + version: 0.1.7(patch_hash=8015962152377f03e7f61d7d6a3511d95131453e45fd8afb4c4e10c337306488) unist-util-visit: specifier: ^5.0.0 version: 5.0.0 @@ -3086,6 +3100,12 @@ importers: specifier: ^3.5.13 version: 3.5.13(typescript@5.8.3) + packages/astro/test/fixtures/fonts: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/fontsource-package: dependencies: '@fontsource/monofett': @@ -6566,6 +6586,12 @@ packages: resolution: {integrity: sha512-v9f+ueUOKkZCDKiCm0yxKtYgYNLD9zlKarNux0NSXOvNm94QEYL3RlMpGKgD2hq44pbF2qWqEmHnCvmk56kPJw==} engines: {node: '>=18'} + '@capsizecss/metrics@3.5.0': + resolution: {integrity: sha512-Ju2I/Qn3c1OaU8FgeW4Tc22D4C9NwyVfKzNmzst59bvxBjPoLYNZMqFYn+HvCtn4MpXwiaDtCE8fNuQLpdi9yA==} + + '@capsizecss/unpack@2.4.0': + resolution: {integrity: sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q==} + '@changesets/apply-release-plan@7.0.10': resolution: {integrity: sha512-wNyeIJ3yDsVspYvHnEz1xQDq18D9ifed3lI+wxRQRK4pArUcuHgCTrHv0QRnnwjhVCQACxZ+CBih3wgOct6UXw==} @@ -7905,6 +7931,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.1.3': resolution: {integrity: sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==} @@ -8590,6 +8619,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} @@ -8611,6 +8643,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.4: resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -8740,6 +8775,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'} @@ -8839,6 +8878,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'} @@ -8881,6 +8923,10 @@ packages: resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.1.0: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} @@ -9019,6 +9065,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==} + diff@5.2.0: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} @@ -9472,6 +9521,9 @@ packages: debug: optional: true + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} @@ -10200,6 +10252,9 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -10545,6 +10600,9 @@ packages: ofetch@1.4.1: resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} + ohash@1.1.6: + resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -10649,6 +10707,9 @@ packages: package-manager-detector@1.1.0: resolution: {integrity: sha512-Y8f9qUlBzW8qauJjd/eu6jlpJZsuPJm2ZAV0cDVd420o4EdpH5RPdoCv+60/TdJflGatr4sDfpAL6ArWZbM5tA==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -11197,6 +11258,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==} @@ -11587,6 +11651,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==} @@ -11656,8 +11723,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.5.0: resolution: {integrity: sha512-fP1hhI9zY8hv0idym3hAaXdPi80TLovmGmgZFocVAykFtOxF+GlfIgM/l4iLAV9ObIO4SUXPVWHeBZQQ+Hpjag==} @@ -11766,6 +11833,12 @@ packages: unenv@2.0.0-rc.15: resolution: {integrity: sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA==} + 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'} @@ -11773,6 +11846,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unifont@0.1.7: + resolution: {integrity: sha512-UyN6r/TUyl69iW/jhXaCtuwA6bP9ZSLhVViwgP8LH9EHRGk5FyIMDxvClqD5z2BV6MI9GMATzd0dyLqFxKkUmQ==} + unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -12743,6 +12819,16 @@ snapshots: dependencies: tar: 6.2.1 + '@capsizecss/metrics@3.5.0': {} + + '@capsizecss/unpack@2.4.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.10': dependencies: '@changesets/config': 3.1.1 @@ -13239,7 +13325,7 @@ snapshots: '@emnapi/runtime@1.3.1': dependencies: - tslib: 2.6.2 + tslib: 2.8.1 optional: true '@esbuild/aix-ppc64@0.24.2': @@ -13990,6 +14076,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.1.3': dependencies: enhanced-resolve: 5.18.1 @@ -14794,6 +14884,8 @@ snapshots: blake3-wasm@2.1.5: {} + blob-to-buffer@1.2.9: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -14837,6 +14929,10 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + browserslist@4.24.4: dependencies: caniuse-lite: 1.0.30001702 @@ -14967,6 +15063,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: {} @@ -15040,6 +15138,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 @@ -15088,6 +15192,11 @@ snapshots: mdn-data: 2.0.30 source-map-js: 1.2.1 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + css-what@6.1.0: {} cssdb@8.2.3: {} @@ -15174,6 +15283,8 @@ snapshots: dependencies: dequal: 2.0.3 + dfa@1.2.0: {} + diff@5.2.0: {} dir-glob@3.0.1: @@ -15650,6 +15761,18 @@ snapshots: follow-redirects@1.15.9: {} + 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 @@ -16339,7 +16462,7 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.6.2 + tslib: 2.8.1 lru-cache@10.4.3: {} @@ -16579,6 +16702,8 @@ snapshots: mdn-data@2.0.30: {} + mdn-data@2.12.2: {} + media-typer@0.3.0: {} merge-anything@5.1.7: @@ -16974,7 +17099,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 @@ -17048,6 +17173,8 @@ snapshots: node-fetch-native: 1.6.6 ufo: 1.5.4 + ohash@1.1.6: {} + ohash@2.0.11: {} on-finished@2.4.1: @@ -17150,6 +17277,8 @@ snapshots: package-manager-detector@1.1.0: {} + pako@0.2.9: {} + pako@1.0.11: {} parent-module@1.0.1: @@ -17200,7 +17329,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: {} @@ -17815,6 +17944,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 @@ -18319,6 +18450,8 @@ snapshots: timestring@6.0.0: {} + tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -18364,7 +18497,7 @@ snapshots: tslib@2.1.0: {} - tslib@2.6.2: {} + tslib@2.8.1: {} turbo-darwin-64@2.5.0: optional: true @@ -18460,6 +18593,16 @@ snapshots: pathe: 2.0.3 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: @@ -18472,6 +18615,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unifont@0.1.7(patch_hash=8015962152377f03e7f61d7d6a3511d95131453e45fd8afb4c4e10c337306488): + dependencies: + css-tree: 3.1.0 + ohash: 1.1.6 + unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3 |