diff --git a/.changeset/dirty-pears-repeat.md b/.changeset/dirty-pears-repeat.md
new file mode 100644
index 000000000000..cdf8ef1071b1
--- /dev/null
+++ b/.changeset/dirty-pears-repeat.md
@@ -0,0 +1,45 @@
+---
+'astro': patch
+---
+
+Adds a new `getFontBuffer()` method to retrieve font file buffers when using the experimental Fonts API
+
+The `getFontData()` helper function from `astro:assets` was introduced in 5.14.0 to provide access to font family data for use outside of Astro. One of the goals of this API was to be able to retrieve buffers using URLs.
+
+However, it turned out to be impactical and even impossible during prerendering.
+
+Astro now exports a new `getFontBuffer()` helper function from `astro:assets` to retrieve font file buffers from URL returned by `getFontData()`. For example, using [satori](https://github.com/vercel/satori) to generate OpenGraph images:
+
+```diff
+// src/pages/og.png.ts
+
+import type{ APIRoute } from "astro"
+-import { getFontData } from "astro:assets"
++import { getFontData, getFontBuffer } from "astro:assets"
+import satori from "satori"
+
+export const GET: APIRoute = (context) => {
+ const data = getFontData("--font-roboto")
+
+ const svg = await satori(
+
hello, world
,
+ {
+ width: 600,
+ height: 400,
+ fonts: [
+ {
+ name: "Roboto",
+- data: await fetch(new URL(data[0].src[0].url, context.url.origin)).then(res => res.arrayBuffer()),
++ data: await getFontBuffer(data[0].src[0].url),
+ weight: 400,
+ style: "normal",
+ },
+ ],
+ },
+ )
+
+ // ...
+}
+```
+
+See the [experimental Fonts API documentation](https://docs.astro.build/en/reference/experimental-flags/fonts/#accessing-font-data-programmatically) for more information.
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index 6023bac8b1a9..0b634d5abc46 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..f5566ab6eb00 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,40 @@ export function createGetFontData({ consumableMap }: { consumableMap?: Consumabl
};
}
+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);
+ }
+ // 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({
+ ...AstroErrorData.FontBufferNotFound,
+ message: AstroErrorData.FontBufferNotFound.message(url),
+ });
+ }
+ let mod;
+ try {
+ mod = await fn();
+ } catch {
+ throw new AstroError({
+ ...AstroErrorData.FontBufferNotFound,
+ message: AstroErrorData.FontBufferNotFound.message(url),
+ });
+ }
+ if (!mod?.default) {
+ throw new AstroError({
+ ...AstroErrorData.FontBufferNotFound,
+ message: AstroErrorData.FontBufferNotFound.message(url),
+ });
+ }
+ 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);
`,
};
}
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
diff --git a/packages/astro/test/fixtures/fonts/src/pages/get-font-buffer.astro b/packages/astro/test/fixtures/fonts/src/pages/get-font-buffer.astro
new file mode 100644
index 000000000000..5121d5cb5d18
--- /dev/null
+++ b/packages/astro/test/fixtures/fonts/src/pages/get-font-buffer.astro
@@ -0,0 +1,7 @@
+---
+import { getFontBuffer, getFontData } from 'astro:assets'
+
+const buffer = await getFontBuffer(getFontData('--font-test')[0].src[0].url)
+---
+
+{buffer.length}
\ No newline at end of file
diff --git a/packages/astro/test/fonts.test.js b/packages/astro/test/fonts.test.js
index 70f711875c19..73a318f2cf76 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 () => {
@@ -200,6 +230,30 @@ describe('astro fonts', () => {
assert.equal(parsed[0].src[0].url.startsWith('/_astro/fonts/'), true);
});
});
+
+ it('Exposes buffer in getFontBuffer()', async () => {
+ const { fixture, run } = await createDevFixture({
+ experimental: {
+ fonts: [
+ {
+ name: 'Poppins',
+ cssVariable: '--font-test',
+ provider: fontProviders.fontsource(),
+ },
+ ],
+ },
+ });
+ await run(async () => {
+ const res = await fixture.fetch('/get-font-buffer');
+ const html = await res.text();
+ const $ = cheerio.load(html);
+ const length = $('#length').html();
+ if (!length) {
+ assert.fail();
+ }
+ assert.equal(length === '0', false);
+ });
+ });
});
describe('build', () => {
@@ -316,5 +370,138 @@ describe('astro fonts', () => {
assert.equal(parsed.length > 0, true);
assert.equal(parsed[0].src[0].url.startsWith('/_astro/fonts/'), true);
});
+
+ it('Exposes buffer in getFontBuffer()', async () => {
+ const { fixture } = await createBuildFixture({
+ experimental: {
+ fonts: [
+ {
+ name: 'Poppins',
+ cssVariable: '--font-test',
+ provider: fontProviders.fontsource(),
+ },
+ ],
+ },
+ });
+ const html = await fixture.readFile('/get-font-buffer/index.html');
+ const $ = cheerio.load(html);
+ const length = $('#length').html();
+ if (!length) {
+ assert.fail();
+ }
+ assert.equal(length === '0', false);
+ });
+ });
+
+ 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('