From 3587475b14ce0e349d8c7c86af3df89b9537b1a6 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 13 Jan 2026 09:15:48 +0100 Subject: [PATCH 1/6] feat(fonts): getFontBuffer() --- packages/astro/client.d.ts | 2 + packages/astro/dev-only.d.ts | 1 + packages/astro/src/assets/fonts/constants.ts | 2 + packages/astro/src/assets/fonts/runtime.ts | 30 +++++++++++- packages/astro/src/assets/fonts/types.ts | 2 + .../src/assets/fonts/vite-plugin-fonts.ts | 46 +++++++++++++++++-- .../astro/src/assets/vite-plugin-assets.ts | 3 +- 7 files changed, 80 insertions(+), 6 deletions(-) diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 6023bac8b1a9..0dd13d0433f1 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -57,6 +57,7 @@ declare module 'astro:assets' { getFontData: ( cssVariable: import('astro:assets').CssVariable, ) => Array; + getFontBuffer: (url: string) => Promise; }; type ImgAttributes = import('./dist/type-utils.js').WithRequired< @@ -79,6 +80,7 @@ declare module 'astro:assets' { Font, inferRemoteSize, getFontData, + getFontBuffer }: AstroAssets; } diff --git a/packages/astro/dev-only.d.ts b/packages/astro/dev-only.d.ts index 94f4dbb89cbd..97cd8e241146 100644 --- a/packages/astro/dev-only.d.ts +++ b/packages/astro/dev-only.d.ts @@ -9,6 +9,7 @@ declare module 'virtual:astro:env/internal' { declare module 'virtual:astro:assets/fonts/internal' { export const internalConsumableMap: import('./src/assets/fonts/types.js').InternalConsumableMap; export const consumableMap: import('./src/assets/fonts/types.js').ConsumableMap; + export const bufferImports: import('./src/assets/fonts/types.js').BufferImports; } declare module 'virtual:astro:adapter-config/client' { diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index 110fd102fd5c..b9e2f478f073 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -15,6 +15,8 @@ export const DEFAULTS: Defaults = { /** Used to serialize data, to be used by public APIs */ export const VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/internal'; export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; +export const BUFFER_VIRTUAL_MODULE_ID_PREFIX = 'virtual:astro:assets/fonts/file/'; +export const RESOLVED_BUFFER_VIRTUAL_MODULE_ID_PREFIX = '\0' + BUFFER_VIRTUAL_MODULE_ID_PREFIX; export const ASSETS_DIR = 'fonts'; export const CACHE_DIR = './fonts/'; diff --git a/packages/astro/src/assets/fonts/runtime.ts b/packages/astro/src/assets/fonts/runtime.ts index d0d3943eeb3d..2bd5c30b0f1d 100644 --- a/packages/astro/src/assets/fonts/runtime.ts +++ b/packages/astro/src/assets/fonts/runtime.ts @@ -1,5 +1,5 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js'; -import type { ConsumableMap, PreloadData, PreloadFilter } from './types.js'; +import type { BufferImports, ConsumableMap, PreloadData, PreloadFilter } from './types.js'; export function createGetFontData({ consumableMap }: { consumableMap?: ConsumableMap }) { return function getFontData(cssVariable: string) { @@ -18,6 +18,34 @@ export function createGetFontData({ consumableMap }: { consumableMap?: Consumabl }; } +// TODO: astro errors +export function createGetFontBuffer({ bufferImports }: { bufferImports?: BufferImports }) { + return async function getFontBuffer(url: string) { + // TODO: remove once fonts are stabilized + if (!bufferImports) { + throw new AstroError(AstroErrorData.ExperimentalFontsNotEnabled); + } + const hash = url.split('/').pop(); + if (!hash) { + throw new Error('no buffer found'); + } + const fn = bufferImports[hash]; + if (!fn) { + throw new Error('no buffer found'); + } + let mod; + try { + mod = await fn(); + } catch { + throw new Error('no buffer found'); + } + if (!mod?.default) { + throw new Error('no buffer found'); + } + return mod.default; + }; +} + export function filterPreloads( data: Array, preload: PreloadFilter, diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index b68104786d7f..b241b6a02ffb 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -297,3 +297,5 @@ export interface ResolveFontOptions { subsets: string[]; formats: FontType[]; } + +export type BufferImports = Record Promise<{ default: Buffer | null }>>; diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 6971ddc274db..383886027450 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -15,8 +15,10 @@ import { getClientOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings } from '../../types/astro.js'; import { ASSETS_DIR, + BUFFER_VIRTUAL_MODULE_ID_PREFIX, CACHE_DIR, DEFAULTS, + RESOLVED_BUFFER_VIRTUAL_MODULE_ID_PREFIX, RESOLVED_VIRTUAL_MODULE_ID, VIRTUAL_MODULE_ID, } from './constants.js'; @@ -273,12 +275,12 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { // 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 fontFetcher!.fetch({ hash, ...associatedData }); + const buffer = await fontFetcher!.fetch({ hash, ...associatedData }); - res.setHeader('Content-Length', data.length); + res.setHeader('Content-Length', buffer.length); res.setHeader('Content-Type', `font/${fontTypeExtractor!.extract(hash)}`); - res.end(data); + res.end(buffer); } catch (err) { logger.error('assets', 'Cannot download font file'); if (isAstroError(err)) { @@ -296,16 +298,52 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { if (id === VIRTUAL_MODULE_ID) { return RESOLVED_VIRTUAL_MODULE_ID; } + if (id.startsWith(BUFFER_VIRTUAL_MODULE_ID_PREFIX)) { + return `\0` + id; + } }, - load(id) { + async load(id) { if (id === RESOLVED_VIRTUAL_MODULE_ID) { return { code: ` export const internalConsumableMap = new Map(${JSON.stringify(Array.from(internalConsumableMap?.entries() ?? []))}); export const consumableMap = new Map(${JSON.stringify(Array.from(consumableMap?.entries() ?? []))}); + export const bufferImports = {${[...(fontFileDataMap?.keys() ?? [])].map((key) => `"${key}": () => import("virtual:astro:assets/fonts/file/${key}")`).join(',')}}; `, }; } + if (id.startsWith(RESOLVED_BUFFER_VIRTUAL_MODULE_ID_PREFIX)) { + const hash = id.slice(RESOLVED_BUFFER_VIRTUAL_MODULE_ID_PREFIX.length); + const associatedData = fontFileDataMap?.get(hash); + if (!associatedData) { + return { + code: `export default null;`, + }; + } + + 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 buffer = await fontFetcher!.fetch({ hash, ...associatedData }); + + const bytes = Array.from(buffer); + return { + code: `export default Uint8Array.from(${JSON.stringify(bytes)});`, + }; + } catch (err) { + logger.error('assets', 'Cannot download font file'); + if (isAstroError(err)) { + logger.error( + 'SKIP_FORMAT', + formatErrorMessage(collectErrorMetadata(err), logger.level() === 'debug'), + ); + } + return { + code: `export default null;`, + }; + } + } }, async buildEnd() { if (sync || settings.config.experimental.fonts!.length === 0) { diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index d241398c8fc6..b7ff8a594682 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -150,7 +150,7 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl export { default as Font } from "astro/components/Font.astro"; import * as fontsMod from 'virtual:astro:assets/fonts/internal'; - import { createGetFontData } from "astro/assets/fonts/runtime"; + import { createGetFontData, createGetFontBuffer } from "astro/assets/fonts/runtime"; export const viteFSConfig = ${JSON.stringify(resolvedConfig.server.fs ?? {})}; @@ -191,6 +191,7 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl export const getImage = async (options) => await getImageInternal(options, imageConfig); export const getFontData = createGetFontData(fontsMod); + export const getFontBuffer = createGetFontBuffer(fontsMod); `, }; } From 42bbc5d1d6fdd126d4e106022ffdcdb8470152d9 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 13 Jan 2026 09:29:04 +0100 Subject: [PATCH 2/6] feat: astro error --- packages/astro/src/assets/fonts/runtime.ts | 21 ++++++++++++++----- packages/astro/src/core/errors/errors-data.ts | 15 +++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/astro/src/assets/fonts/runtime.ts b/packages/astro/src/assets/fonts/runtime.ts index 2bd5c30b0f1d..9a2b02a4acb6 100644 --- a/packages/astro/src/assets/fonts/runtime.ts +++ b/packages/astro/src/assets/fonts/runtime.ts @@ -18,7 +18,6 @@ export function createGetFontData({ consumableMap }: { consumableMap?: Consumabl }; } -// TODO: astro errors export function createGetFontBuffer({ bufferImports }: { bufferImports?: BufferImports }) { return async function getFontBuffer(url: string) { // TODO: remove once fonts are stabilized @@ -27,20 +26,32 @@ export function createGetFontBuffer({ bufferImports }: { bufferImports?: BufferI } const hash = url.split('/').pop(); if (!hash) { - throw new Error('no buffer found'); + throw new AstroError({ + ...AstroErrorData.FontBufferNotFound, + message: AstroErrorData.FontBufferNotFound.message(url), + }); } const fn = bufferImports[hash]; if (!fn) { - throw new Error('no buffer found'); + throw new AstroError({ + ...AstroErrorData.FontBufferNotFound, + message: AstroErrorData.FontBufferNotFound.message(url), + }); } let mod; try { mod = await fn(); } catch { - throw new Error('no buffer found'); + throw new AstroError({ + ...AstroErrorData.FontBufferNotFound, + message: AstroErrorData.FontBufferNotFound.message(url), + }); } if (!mod?.default) { - throw new Error('no buffer found'); + throw new AstroError({ + ...AstroErrorData.FontBufferNotFound, + message: AstroErrorData.FontBufferNotFound.message(url), + }); } return mod.default; }; diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 7cf54958a749..b12dfac15e18 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1401,6 +1401,21 @@ export const FontFamilyNotFound = { hint: 'This is often caused by a typo. Check that the `` component or `getFontData()` function are using a `cssVariable` specified in your config.', } satisfies ErrorData; +/** + * @docs + * @description + * Font buffer not found + * @message + * No buffer was found for the URL passed to the `getFontBuffer()` function. + */ +export const FontBufferNotFound = { + name: 'FontBufferNotFound', + title: 'Font buffer not found', + message: (url: string) => + `No buffer was found for the \`"${url}"\` passed to the \`getFontBuffer()\` function.`, + hint: 'Make sure you pass a valid URL, obtained via the \`getFontData()\` function.', +} satisfies ErrorData; + /** * @docs * @description From 24c493a091fb815e98a2a73871a0815d37aa663b Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 13 Jan 2026 09:43:44 +0100 Subject: [PATCH 3/6] unit test --- packages/astro/src/assets/fonts/runtime.ts | 9 +--- .../test/units/assets/fonts/runtime.test.js | 49 ++++++++++++++++++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/astro/src/assets/fonts/runtime.ts b/packages/astro/src/assets/fonts/runtime.ts index 9a2b02a4acb6..f5566ab6eb00 100644 --- a/packages/astro/src/assets/fonts/runtime.ts +++ b/packages/astro/src/assets/fonts/runtime.ts @@ -24,13 +24,8 @@ export function createGetFontBuffer({ bufferImports }: { bufferImports?: BufferI if (!bufferImports) { throw new AstroError(AstroErrorData.ExperimentalFontsNotEnabled); } - const hash = url.split('/').pop(); - if (!hash) { - throw new AstroError({ - ...AstroErrorData.FontBufferNotFound, - message: AstroErrorData.FontBufferNotFound.message(url), - }); - } + // Should always be able to split but we default to a hash that will always fail + const hash = url.split('/').pop() ?? ''; const fn = bufferImports[hash]; if (!fn) { throw new AstroError({ diff --git a/packages/astro/test/units/assets/fonts/runtime.test.js b/packages/astro/test/units/assets/fonts/runtime.test.js index 16a164558346..ee8cd29ee285 100644 --- a/packages/astro/test/units/assets/fonts/runtime.test.js +++ b/packages/astro/test/units/assets/fonts/runtime.test.js @@ -1,7 +1,7 @@ // @ts-check import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { filterPreloads } from '../../../../dist/assets/fonts/runtime.js'; +import { createGetFontBuffer, filterPreloads } from '../../../../dist/assets/fonts/runtime.js'; describe('fonts runtime', () => { describe('filterPreloads()', () => { @@ -158,4 +158,51 @@ describe('fonts runtime', () => { ); }); }); + + describe('createGetFontBuffer()', () => { + it('throws if there is are no bufferImports', async () => { + assert.rejects(() => createGetFontBuffer({ bufferImports: undefined })('foo')); + }); + + it('throws if hash cannot be found in buffer imports', async () => { + assert.rejects(() => + createGetFontBuffer({ + bufferImports: { + bar: async () => ({ default: Buffer.alloc(4) }), + }, + })('foo'), + ); + }); + + it('throws if import fails', async () => { + assert.rejects(() => + createGetFontBuffer({ + bufferImports: { + foo: async () => { + throw new Error('unexpected'); + }, + }, + })('foo'), + ); + }); + + it('throws if import result is not a buffer', async () => { + assert.rejects(() => + createGetFontBuffer({ + bufferImports: { + foo: async () => ({ default: null }), + }, + })('foo'), + ); + }); + + it('works', async () => { + const result = await createGetFontBuffer({ + bufferImports: { + foo: async () => ({ default: Buffer.alloc(4) }), + }, + })('foo'); + assert.equal(result instanceof Buffer, true); + }); + }); }); From ff083587a3d1f8827be3103db497ab407c5f5afd Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 13 Jan 2026 10:10:35 +0100 Subject: [PATCH 4/6] ssr tests --- packages/astro/client.d.ts | 2 +- packages/astro/test/fonts.test.js | 120 ++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 0dd13d0433f1..0b634d5abc46 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -80,7 +80,7 @@ declare module 'astro:assets' { Font, inferRemoteSize, getFontData, - getFontBuffer + getFontBuffer, }: AstroAssets; } diff --git a/packages/astro/test/fonts.test.js b/packages/astro/test/fonts.test.js index 70f711875c19..ee4e25fd88ba 100644 --- a/packages/astro/test/fonts.test.js +++ b/packages/astro/test/fonts.test.js @@ -4,6 +4,7 @@ import { readdir } from 'node:fs/promises'; import { describe, it } from 'node:test'; import { fontProviders } from 'astro/config'; import * as cheerio from 'cheerio'; +import testAdapter from './test-adapter.js'; import { loadFixture } from './test-utils.js'; /** @@ -25,6 +26,7 @@ async function createDevFixture(inlineConfig) { }, }; } + /** * @param {Omit} inlineConfig */ @@ -37,6 +39,34 @@ async function createBuildFixture(inlineConfig) { }; } +/** + * @param {Omit} inlineConfig + */ +async function createSsrFixture(inlineConfig) { + const fixture = await loadFixture({ + root: './fixtures/fonts/', + output: 'server', + adapter: testAdapter(), + ...inlineConfig, + }); + await fixture.build({}); + const app = await fixture.loadTestAdapterApp(); + + return { + fixture, + app, + /** + * @param {string} url + */ + fetch: async (url) => { + const request = new Request(`http://example.com${url}`); + const response = await app.render(request); + const html = await response.text(); + return html; + }, + }; +} + describe('astro fonts', () => { describe('dev', () => { it('Includes styles', async () => { @@ -317,4 +347,94 @@ describe('astro fonts', () => { assert.equal(parsed[0].src[0].url.startsWith('/_astro/fonts/'), true); }); }); + + describe('ssr', () => { + it('Includes styles', async () => { + const fixture = await createSsrFixture({ + experimental: { + fonts: [ + { + name: 'Poppins', + cssVariable: '--font-test', + provider: fontProviders.fontsource(), + weights: [400, 500], + }, + ], + }, + }); + const html = await fixture.fetch('/'); + const $ = cheerio.load(html); + assert.equal(html.includes('