-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(fonts): experimental_getFontFileURL() #16302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
florian-lefebvre
wants to merge
34
commits into
main
Choose a base branch
from
feat/fonts-experimental-get-font-buffer
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 26 commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
ec68468
feat(fonts): experimental_getFontBuffer()
florian-lefebvre 331649b
fix
florian-lefebvre 7b8fafd
test core
florian-lefebvre fce29f3
async
florian-lefebvre 56f7192
simplify
florian-lefebvre 6d3f0f3
test
florian-lefebvre 575df21
infra test
florian-lefebvre 327062e
fix
florian-lefebvre 3b65458
error
florian-lefebvre 198fff4
chore: format
florian-lefebvre 64e124d
changeset
florian-lefebvre 12a1ab2
fix
florian-lefebvre 2b10a1d
Apply suggestions from code review
florian-lefebvre fc89dba
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre a1f5cf7
update
florian-lefebvre 04e7396
reduce diff
florian-lefebvre 1cf050a
fetch
florian-lefebvre 1aca8eb
document
florian-lefebvre 6cb9425
use full address
florian-lefebvre 4add883
simplify
florian-lefebvre 6fd545e
chore: format
florian-lefebvre c23f70e
error handling
florian-lefebvre a3212db
feat: stronger validation
florian-lefebvre 767a616
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre b335b91
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre f5482f5
fix: lint
florian-lefebvre 20aeb80
feedback
florian-lefebvre 0aaf5f8
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre c8c2e22
rename
florian-lefebvre 3e99ee7
changeset
florian-lefebvre 85de653
improve changeset
florian-lefebvre 2518318
Update packages/astro/src/core/errors/errors-data.ts
florian-lefebvre 164e270
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre d5e6f45
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| --- | ||
| 'astro': minor | ||
| --- | ||
|
|
||
| Adds a new `experimental_getFontBuffer()` method to retrieve font file buffers when using the Fonts API | ||
|
|
||
| The `fontData` object exported from `astro:assets` was introduced to provide low-level access to font family data for advanced usage. One of the goals of this API was to be able to retrieve buffers using URLs. However, it turned out to be impractical, especially during prerendering. | ||
|
|
||
| Astro now exports a new `experimental_getFontBuffer()` helper function from `astro:assets` to retrieve font file buffers from URL obtained via `fontData`. For example, when using [satori](https://github.com/vercel/satori) to generate Open Graph images: | ||
|
|
||
| ```diff | ||
| // src/pages/og.png.ts | ||
|
|
||
| import type { APIRoute } from "astro"; | ||
| -import { fontData } from "astro:assets"; | ||
| +import { fontData, experimental_getFontBuffer } from "astro:assets"; | ||
| -import { outDir } from "astro:config/server"; | ||
| -import { readFile } from "node:fs/promises"; | ||
| import satori from "satori"; | ||
| import { html } from "satori-html"; | ||
| import sharp from "sharp"; | ||
|
|
||
| export const GET: APIRoute = async (context) => { | ||
| const fontPath = fontData["--font-roboto"][0]?.src[0]?.url; | ||
|
|
||
| if (fontPath === undefined) { | ||
| throw new Error("Cannot find the font path."); | ||
| } | ||
|
|
||
| - const data = import.meta.env.DEV | ||
| - ? await fetch(new URL(fontPath, context.url.origin)).then(async (res) => res.arrayBuffer()) | ||
| - : await readFile(new URL(`.${fontPath}`, outDir)); | ||
| + const data = await experimental_getFontBuffer(fontPath, context.url) | ||
|
|
||
| const svg = await satori( | ||
| html`<div style="color: black;">hello, world</div>`, | ||
| { | ||
| width: 600, | ||
| height: 400, | ||
| fonts: [ | ||
| { | ||
| name: "Roboto", | ||
| data, | ||
| weight: 400, | ||
| style: "normal", | ||
| }, | ||
| ], | ||
| }, | ||
| ); | ||
|
|
||
| const pngBuffer = await sharp(Buffer.from(svg)) | ||
| .resize(600, 400) | ||
| .png() | ||
| .toBuffer(); | ||
|
|
||
| return new Response(new Uint8Array(pngBuffer), { | ||
| headers: { | ||
| "Content-Type": "image/png", | ||
| }, | ||
| }); | ||
| }; | ||
| ``` | ||
|
|
||
| See the [Fonts API documentation](https://docs.astro.build/en/guides/fonts/#accessing-font-data-programmatically) for more information. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 22 additions & 0 deletions
22
packages/astro/src/assets/fonts/core/create-get-font-buffer.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; | ||
| import type { RuntimeFontFetcher } from './../definitions.js'; | ||
|
|
||
| export function createGetFontBuffer(runtimeFontFetcher: RuntimeFontFetcher) { | ||
| return async function getFontBuffer(url: string, requestUrl?: URL): Promise<ArrayBuffer> { | ||
| try { | ||
| const buffer = await runtimeFontFetcher.fetch(url, requestUrl); | ||
| if (buffer === null) { | ||
| throw new Error('Not found'); | ||
| } | ||
| return buffer; | ||
| } catch (cause) { | ||
| throw new AstroError( | ||
| { | ||
| ...AstroErrorData.FontBufferNotFound, | ||
| message: AstroErrorData.FontBufferNotFound.message(url), | ||
| }, | ||
| { cause }, | ||
| ); | ||
| } | ||
| }; | ||
| } |
82 changes: 82 additions & 0 deletions
82
packages/astro/src/assets/fonts/core/font-file-middleware.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import type { AstroLogger } from '../../../core/logger/core.js'; | ||
| import type { FontFetcher, FontTypeExtractor } from '../definitions.js'; | ||
| import type { FontFileById } from '../types.js'; | ||
| import { isAstroError } from '../../../core/errors/errors.js'; | ||
| import { formatErrorMessage } from '../../../core/messages/runtime.js'; | ||
| import { collectErrorMetadata } from '../../../core/errors/dev/utils.js'; | ||
| import type { ServerResponse } from 'node:http'; | ||
|
|
||
| interface MinimalResponse { | ||
| setHeader: (name: string, value: string) => void; | ||
| end: (buffer?: Buffer) => void; | ||
| setStatusCode: (statusCode: number) => void; | ||
| } | ||
|
|
||
| interface Options { | ||
| url: string | undefined; | ||
| response: MinimalResponse; | ||
| next: () => void; | ||
| fontFetcher: FontFetcher | null; | ||
| fontTypeExtractor: FontTypeExtractor | null; | ||
| logger: AstroLogger; | ||
| fontFileById: FontFileById | null; | ||
| } | ||
|
|
||
| export function resToMinimalResponse(res: ServerResponse): MinimalResponse { | ||
| return { | ||
| setHeader: (...args) => res.setHeader(...args), | ||
| end: (...args) => res.end(...args), | ||
| setStatusCode: (statusCode) => { | ||
| res.statusCode = statusCode; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export async function fontFileMiddleware({ | ||
| url: _url, | ||
| response, | ||
| next, | ||
| fontFetcher, | ||
| fontTypeExtractor, | ||
| logger, | ||
| fontFileById, | ||
| }: Options): Promise<void> { | ||
| if (!fontFetcher || !fontTypeExtractor || !fontFileById) { | ||
| logger.debug('assets', 'Fonts dependencies should be initialized by now, skipping middleware.'); | ||
| return next(); | ||
| } | ||
| if (!_url) { | ||
| return next(); | ||
| } | ||
| const url = new URL(_url, 'http://localhost'); | ||
| const fontId = url.pathname.slice(1); | ||
| const fontData = fontFileById.get(fontId); | ||
| if (!fontData) { | ||
| 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 | ||
| response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); | ||
| response.setHeader('Pragma', 'no-cache'); | ||
| response.setHeader('Expires', '0'); | ||
|
|
||
| try { | ||
| const buffer = await fontFetcher.fetch({ id: fontId, ...fontData }); | ||
|
|
||
| response.setHeader('Content-Length', buffer.byteLength.toString()); | ||
| response.setHeader('Content-Type', `font/${fontTypeExtractor.extract(fontId)}`); | ||
|
|
||
| response.setStatusCode(200); | ||
| response.end(buffer); | ||
| } catch (err) { | ||
| logger.error('assets', 'Cannot download font file'); | ||
| if (isAstroError(err)) { | ||
| logger.error( | ||
| 'SKIP_FORMAT', | ||
| formatErrorMessage(collectErrorMetadata(err), logger.level() === 'debug'), | ||
| ); | ||
| } | ||
| response.setStatusCode(500); | ||
| response.end(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
49 changes: 49 additions & 0 deletions
49
packages/astro/src/assets/fonts/infra/remote-runtime-font-fetcher.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import type { AddressInfo } from 'node:net'; | ||
| import type { RuntimeFontFetcher } from '../definitions.js'; | ||
|
|
||
| /** | ||
| * In development, font files are served through a Vite middleware. | ||
| * During prerendering, a temporary Node HTTP server is started to | ||
| * serve font files. | ||
| * | ||
| * We send request to the provided server address. `requestUrl` on | ||
| * `fetch` is not implemented because we have the information from | ||
| * within the Vite plugin already. | ||
| */ | ||
| export class RemoteRuntimeFontFetcher implements RuntimeFontFetcher { | ||
| #urls: Set<string>; | ||
| #address: AddressInfo | null; | ||
| #fetch: typeof globalThis.fetch; | ||
|
|
||
| constructor({ | ||
| urls, | ||
| address, | ||
| fetch, | ||
| }: { | ||
| urls: Set<string>; | ||
| address: AddressInfo | null; | ||
| fetch: typeof globalThis.fetch; | ||
| }) { | ||
| this.#urls = urls; | ||
| this.#address = address; | ||
| this.#fetch = fetch; | ||
| } | ||
|
|
||
| async fetch(url: string): Promise<ArrayBuffer | null> { | ||
| if (!this.#urls.has(url)) { | ||
| return null; | ||
| } | ||
| if (!this.#address) { | ||
| throw new Error('Server address unavailable, this should not happen. Open an issue.'); | ||
| } | ||
| // assetsPrefix | ||
| if (!url.startsWith('/')) { | ||
| url = new URL(url).pathname; | ||
| } | ||
| const host = | ||
| this.#address.family === 'IPv6' ? `[${this.#address.address}]` : this.#address.address; | ||
| return this.#fetch(`http://${host}:${this.#address.port}${url}`).then((res) => | ||
| res.arrayBuffer(), | ||
| ); | ||
| } | ||
| } |
38 changes: 38 additions & 0 deletions
38
packages/astro/src/assets/fonts/infra/ssr-runtime-font-fetcher.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import type { RuntimeFontFetcher } from '../definitions.js'; | ||
| import { MissingGetFontBufferRequestUrl } from '../../../core/errors/errors-data.js'; | ||
| import { AstroError } from '../../../core/errors/errors.js'; | ||
|
|
||
| /** | ||
| * During SSR, we don't know ahead of time where the server is located. | ||
| * We rely on `requestUrl` (provided by the user) to construct the URL. | ||
| */ | ||
| export class SsrRuntimeFontFetcher implements RuntimeFontFetcher { | ||
| #urls: Set<string>; | ||
| #fetch: typeof globalThis.fetch; | ||
|
|
||
| constructor({ | ||
| urls, | ||
| fetch, | ||
| }: { | ||
| urls: Set<string>; | ||
| fetch: typeof globalThis.fetch; | ||
| }) { | ||
| this.#urls = urls; | ||
| this.#fetch = fetch; | ||
| } | ||
|
|
||
| async fetch(url: string, requestUrl: URL | undefined): Promise<ArrayBuffer | null> { | ||
| if (!this.#urls.has(url)) { | ||
| return null; | ||
| } | ||
| // assetsPrefix | ||
| if (!url.startsWith('/')) { | ||
| return this.#fetch(url).then((res) => res.arrayBuffer()); | ||
| } | ||
| // We need the request URL to call the current server | ||
| if (!requestUrl) { | ||
| throw new AstroError(MissingGetFontBufferRequestUrl); | ||
| } | ||
| return this.#fetch(`${requestUrl.origin}${url}`).then((res) => res.arrayBuffer()); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,6 @@ | ||
| import { fontDataByCssVariable } from 'virtual:astro:assets/fonts/internal'; | ||
| import { runtimeFontFetcher } from 'virtual:astro:assets/fonts/runtime/font-fetcher'; | ||
| import { createGetFontBuffer } from './core/create-get-font-buffer.js'; | ||
|
|
||
| export const fontData = fontDataByCssVariable; | ||
| export const experimental_getFontBuffer = createGetFontBuffer(runtimeFontFetcher); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you need to extract the URL like this, what is the benefit of the
experimental_getFontBufferfunction vs. just calling fetch() yourself?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this point specifically, the purpose of
getFontBuffer()is to avoid:Because, on the user side,
fetch()would only work if you're in dev mode or if there is an existing live website. If your website is not live yet, the build fails and you have to read the file locally.But, this example doesn't work for all use cases. Depending on the build output, users have to use
outDirorbuild.client(with the Node adapter at least) to resolve the right path. ThegetFontBuffer()helper is meant to simplify that FWIU.However, yeah, this doesn't solve all the "DX issues".
I mean
fontData["--font-roboto"][0]?.src[0]?.urlis not nice. That's said, this is hard to know what the user wants. With multiple fonts and/or a multiple weights you might end up with something likefontData["--font-inter"].find((font) => font.style === "normal" && font.weight === "400")?.src[0]?.url;.Perhaps it's possible to add options to filter on behalf of the user? But
getFontBuffer()then do two things and may require renaming.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes again the problem this API is solving is access to font files during the build, which is one of the main usecases. And for this
fetch()is the ideal solutionHaving a great DX around how to access the URL to pass to
getFontBuffer()is a different topic on its own I think. I would like this PR to be low-level and then we can think about improving the DX (this was discussed on Discord a few months back) in another PR. We're quite free to experiment with it while it's in experimental