From 9f800ddc2060851b116a5aee761341e64cdc29b6 Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:59:13 +0000 Subject: [PATCH 01/35] feat(void): add next/og-compatible ImageResponse in void/og Add `void/og`, a thin layer over Takumi (`takumi-js`) that renders JSX or an HTML string to an image `Response` with the same arguments as `next/og`. On workerd, Takumi's Wasm loads through the Cloudflare plugin's compiled-module imports in dev and builds; Node.js and Bun use Takumi's native renderer. The wrapper adds what `next/og` callers expect: a 1200x630 default size, the `cache-control` header (no-store during development), `debug`, and Noto fallback fonts from Google Fonts for scripts the built-in Geist cannot draw. Latin text stays on Geist, and a failed font download renders without that font instead of failing the image. On the Node.js, Bun and Deno targets the build fails with a clear message when the app cannot resolve `takumi-js` itself, instead of bundling the native addon so every render fails at runtime. Apps that do not import `void/og` bundle nothing extra. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- .github/scripts/packed-consumer.mjs | 31 ++- docs/.vitepress/config.ts | 1 + docs/guide/og-images.md | 184 +++++++++++++++ docs/guide/pages-routing/head.md | 2 +- docs/integrations/nodejs-bun-deno.md | 4 + docs/reference/api.md | 1 + packages/void/AGENTS.md | 10 + packages/void/package.json | 6 + packages/void/skills/void/SKILL.md | 1 + packages/void/src/installed-packages.ts | 2 +- packages/void/src/plugin-node-target.ts | 33 ++- packages/void/src/runtime/og.ts | 174 ++++++++++++++ .../void/test/integration/og-image.test.ts | 220 ++++++++++++++++++ packages/void/test/unit/og.test.ts | 197 ++++++++++++++++ packages/void/vite.config.ts | 1 + pnpm-lock.yaml | 153 ++++++++++++ test/integration-timings.ts | 1 + 17 files changed, 1014 insertions(+), 7 deletions(-) create mode 100644 docs/guide/og-images.md create mode 100644 packages/void/src/runtime/og.ts create mode 100644 packages/void/test/integration/og-image.test.ts create mode 100644 packages/void/test/unit/og.test.ts diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index 4e36188d2..5210c8ec6 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -116,6 +116,22 @@ async function run(command, args, cwd, env = environment) { } } +const packedImageRoute = `import { ImageResponse } from 'void/og'; +export const GET = () => new ImageResponse('
Packed
'); +`; + +/** Fetches the void/og route and checks for a 1200x630 PNG. */ +async function assertPackedImage(url, failure) { + const response = await fetch(url); + assert.equal(response.status, 200, failure()); + assert.equal(response.headers.get('content-type'), 'image/png', failure()); + // A render error arrives after the status, as a failed body. + const bytes = new Uint8Array(await response.arrayBuffer()); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + assert.deepEqual([...bytes.subarray(1, 4)], [80, 78, 71], failure()); + assert.deepEqual([view.getUint32(16), view.getUint32(20)], [1200, 630], failure()); +} + const packages = []; for (const entry of readdirSync(join(root, 'packages'), { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -143,7 +159,8 @@ writeFileSync( environment.npm_config_registry = registry.origin; environment.NPM_CONFIG_REGISTRY = registry.origin; environment.PNPM_CONFIG_REGISTRY = registry.origin; -const version = packages.find((pkg) => pkg.manifest.name === 'void').manifest.version; +const voidManifest = packages.find((pkg) => pkg.manifest.name === 'void').manifest; +const version = voidManifest.version; const managers = process.env.VOID_PACKED_PM ? [process.env.VOID_PACKED_PM] : ['npm', 'pnpm']; assert(managers.every((manager) => ['npm', 'pnpm'].includes(manager))); const frameworks = process.env.VOID_PACKED_FRAMEWORK @@ -329,6 +346,8 @@ try { join(fixture, 'routes/api/packed-email.ts'), `import { defineHandler } from 'void';\nimport { sendEmail } from 'void/email';\nexport const GET = defineHandler(async () => { return sendEmail({ from: 'packed@example.com', to: 'inbox@example.com', subject: 'Packed', text: 'ok' }); });\n`, ); + // void/og imports its renderer's Wasm, and React only when the app has it. + writeFileSync(join(fixture, 'routes/api/packed-og.ts'), packedImageRoute); mkdirSync(join(fixture, 'public'), { recursive: true }); writeFileSync(join(fixture, 'public/packed.txt'), label); const vite = join( @@ -421,6 +440,7 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E } else { assert.equal(email.error?.code, 'BINDING_MISSING', emailBody); } + await assertPackedImage(`${base}/api/packed-og`, task.failure); if (full && framework === 'react') { const markdown = await fetch(`${base}/markdown`); assert.equal(markdown.status, 200, task.failure()); @@ -463,9 +483,11 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E if (framework === 'react') { // The Node target bundles the installed void into its build, so a ValidationError from // void/handler is the class that page actions check, as in dev. + // void/og renders with the app's own takumi-js on Node.js, as its guide says. + const takumi = `takumi-js@${voidManifest.dependencies['takumi-js']}`; await run( manager, - manager === 'npm' ? ['install', '@hono/node-server'] : ['add', '@hono/node-server'], + [manager === 'npm' ? 'install' : 'add', '@hono/node-server', takumi], fixture, ); writeFileSync( @@ -474,6 +496,8 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E ); // KV and email are Cloudflare bindings. rmSync(join(fixture, 'routes'), { recursive: true }); + mkdirSync(join(fixture, 'routes/api'), { recursive: true }); + writeFileSync(join(fixture, 'routes/api/packed-og.ts'), packedImageRoute); writeFileSync( join(fixture, 'pages/form.tsx'), 'export default function Form() {\n return

Form

;\n}\n', @@ -507,12 +531,13 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E const body = await response.text(); assert.equal(response.status, 200, `${body}\n${task.failure()}`); assert(body.includes('Email already taken'), body); + await assertPackedImage(`${base}/api/packed-og`, task.failure); } finally { await task.stop(); } } console.log( - `Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`, + `Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, image, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`, ); } catch (error) { failures.push({ fixture: label, error: String(error.stack ?? error) }); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 3139a0f97..09433c619 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -335,6 +335,7 @@ export default extendConfig( }, { text: 'Custom SSR', link: '/guide/ssr' }, { text: 'SSG', link: '/guide/ssg' }, + { text: 'Open Graph Images', link: '/guide/og-images' }, ], }, { diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md new file mode 100644 index 000000000..592049c8b --- /dev/null +++ b/docs/guide/og-images.md @@ -0,0 +1,184 @@ +--- +outline: deep +--- + +# Open Graph Images + +Use `ImageResponse` from `void/og` to generate Open Graph and social preview images in a route handler. It takes the same arguments as `ImageResponse` from `next/og` and draws JSX or HTML to a PNG with [Takumi](https://takumi.kane.tw/docs/), without a headless browser. + +## Create an image route + +Route files are TypeScript, so put the image's JSX in a `.tsx` module: + +```tsx +// src/og/post-card.tsx +export function postCard(post: { title: string; author: string }) { + return ( +
+
{post.title}
+
{post.author}
+
+ ); +} +``` + +Then return an `ImageResponse` from a route: + +```ts +// routes/og/[slug].ts +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { postCard } from '../../src/og/post-card'; +import { getPost } from '../../src/posts'; + +export const GET = defineHandler(async (c) => { + const post = await getPost(c.req.param('slug')); + if (!post) { + return c.notFound(); + } + return new ImageResponse(postCard(post)); +}); +``` + +`GET /og/hello-world` now returns a 1200 × 630 PNG. + +JSX in image modules compiles with React, so your app needs `react`. Elements also accept Tailwind classes in a `tw` prop, such as `
`. + +### Without React + +Pass an HTML string instead of JSX, and escape the values you insert: + +```ts +// routes/og/[slug].ts +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { getPost } from '../../src/posts'; + +const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); + +export const GET = defineHandler(async (c) => { + const post = await getPost(c.req.param('slug')); + if (!post) { + return c.notFound(); + } + return new ImageResponse( + `
+ ${escapeHtml(post.title)} +
`, + ); +}); +``` + +## Options + +The second argument sets the image and the response: + +| Option | Default | Description | +| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- | +| `width`, `height` | `1200`, `630` | Image size in pixels. | +| `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). | +| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, or `fluentFlat`. | +| `debug` | `false` | Draws a border around every box. | +| `format` | `'png'` | Image format: `png`, `jpeg`, or `webp`. | +| `status`, `statusText`, `headers` | | The response status and headers. | + +Takumi's other render options, such as `devicePixelRatio`, are passed through. See [Takumi's reference](https://takumi.kane.tw/docs/reference). + +Responses have these headers unless you set them in `headers`: + +```http +Content-Type: image/png +Cache-Control: public, immutable, no-transform, max-age=31536000 +``` + +During `vp dev`, `Cache-Control` is `no-cache, no-store`. + +Emoji and remote `` sources are downloaded while the image renders. + +## Fonts + +Text uses Geist unless you pass `fonts`. When the text includes a script that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, or Greek, Void downloads the matching Noto Sans font from Google Fonts while rendering. If the download fails, the image renders without those characters. + +To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data, as in `next/og`: + +```ts +return new ImageResponse(postCard(post), { + fonts: [ + 'https://example.com/fonts/Inter-Bold.woff2', + { name: 'Inter', data: interRegular, weight: 400, style: 'normal' }, + ], +}); +``` + +Fonts can be TTF, OTF, WOFF, or WOFF2 files. + +## Add the image to a page + +Point an `og:image` tag at the route from your page's [`head()`](./pages-routing/head.md): + +```ts +// pages/posts/[slug].server.ts +import { defineHead } from 'void'; + +export const head = defineHead((c, props) => { + return { + title: props.post.title, + meta: [ + { property: 'og:image', content: new URL(`/og/${props.post.slug}`, c.req.url).href }, + { name: 'twitter:card', content: 'summary_large_image' }, + ], + }; +}); +``` + +## Cache generated images + +Each request renders the image again, which uses several milliseconds of CPU time, more for large images. [Workers Free allows 10 ms per request](../reference/config.md#worker). Cache image routes with [revalidation](./edge/revalidation.md): + +```json +{ + "routing": { + "revalidate": { + "/og/*": 86400 + } + } +} +``` + +Void never caches paths under `/api`, so serve images from another path, such as `/og/...`. The cache ignores query strings, so put values in the path, or list the query parameters in [`routing.revalidateQueryAllowlist`](../reference/config.md#routing-revalidatequeryallowlist). + +## Coming from `next/og` + +Change the import: + +```ts +import { ImageResponse } from 'next/og'; // [!code --] +import { ImageResponse } from 'void/og'; // [!code ++] +``` + +Then: + +- Move the JSX from `route.tsx` into a `.tsx` module and return the response from a route file, as shown above. +- Read the request from Hono's context: `c.req.url`, `c.req.param()`, and `c.req.query()`. +- Void draws images with Takumi rather than Satori, so layout can differ slightly. See [Takumi vs. Satori](https://takumi.kane.tw/docs/comparison-to-satori). + +## Node.js and Bun + +On the [Node.js and Bun targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's dependencies: + +```sh +npm install takumi-js +``` + +The build then leaves Takumi's native renderer to the runtime. Without it, the build fails and names this step. diff --git a/docs/guide/pages-routing/head.md b/docs/guide/pages-routing/head.md index d53f44ef2..455b433b3 100644 --- a/docs/guide/pages-routing/head.md +++ b/docs/guide/pages-routing/head.md @@ -38,7 +38,7 @@ export const head = defineHead((c, props) => { }); ``` -`head()` runs server-side only, after the loader resolves. +`head()` runs server-side only, after the loader resolves. To generate the image an `og:image` tag points to, see [Open Graph Images](../og-images.md). ## Site defaults diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 13952b812..24426cea1 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -103,6 +103,10 @@ Supply server values through your host's environment and read them with `process For browser code, use Vite's `import.meta.env.VITE_*` values. These are embedded at build time and must not contain secrets. +### Open Graph images + +On Node.js and Bun, [`void/og`](../guide/og-images.md) needs `takumi-js` in your app's `dependencies`. See [Node.js and Bun](../guide/og-images.md#node-js-and-bun). + ### No Cloudflare bindings The following imports are **not available** with a non-CF target and produce a compile-time error: diff --git a/docs/reference/api.md b/docs/reference/api.md index b5460f654..78744286b 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -49,6 +49,7 @@ Import Void's app-facing APIs from these paths: | `void/live` | `defineLiveStream`, SSE topic fanout runtime types. Server-only. | | `void/live/client` | `connectLiveStream`, browser helper for one SSE connection plus POST subscribe/unsubscribe control. | | `void/response` | `convertReturnValue` | +| `void/og` | `ImageResponse` and `ImageResponseOptions`. Renders JSX or HTML to a 1200 × 630 PNG `Response`, with the same arguments as `next/og`. See [Open Graph Images](../guide/og-images.md). | | `void/validator` | `runValidation`, `ValidatorSlots`, `HandlerInput` | | `void/drizzle-zod` | Re-exports [`drizzle-zod`](https://orm.drizzle.team/docs/zod) for schema-derived Zod validators for Drizzle tables | | `void/drizzle-valibot` | Re-exports [`drizzle-valibot`](https://orm.drizzle.team/docs/valibot) for schema-derived Valibot validators for Drizzle tables | diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 3842f09dc..42336ad60 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -157,6 +157,16 @@ Vite/CLI dependency graph. `void/remote` stays exported only for semver. Generated code imports `#self/remote`. Remove the subpath in the next major version. +`void/og` is a thin `next/og`-compatible layer over `takumi-js`. In workerd, +Takumi resolves its Wasm backend, whose `.wasm` import the Cloudflare plugin +loads as a compiled module in dev and builds. Node and Bun load Takumi's native +addon, which a bundle cannot carry, so `nodeTargetPlugin` fails a build whose +app root cannot resolve `takumi-js` instead of bundling it. Takumi's JSX helper +imports `react` as an optional peer; Vite stubs it in apps without React, and +Takumi catches the failed import. The wrapper converts the element once, asks +Google Fonts for Noto families by the scripts in its text, and drops their Latin +subsets so Latin text keeps Takumi's built-in Geist. + Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). ### Route Filename Parsing Rules diff --git a/packages/void/package.json b/packages/void/package.json index a4d147d78..7166a58b4 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -337,6 +337,11 @@ "import": "./dist/runtime/limits.mjs", "require": "./dist/runtime/limits.mjs" }, + "./og": { + "types": "./dist/runtime/og.d.mts", + "import": "./dist/runtime/og.mjs", + "require": "./dist/runtime/og.mjs" + }, "./deploy": { "types": "./dist/deploy.d.mts", "import": "./dist/deploy.mjs" @@ -386,6 +391,7 @@ "postal-mime": "catalog:", "proper-lockfile": "^4.1.2", "smol-toml": "catalog:", + "takumi-js": "^2.16.2", "terminal-link": "^5.0.0", "yaml": "^2.9.1" }, diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index edc6e4134..d346ee524 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -51,6 +51,7 @@ Use `void deploy --skip-migrations` when database migrations run separately; it | Platform AI logging policy | `docs/reference/cli/platform-users.md#ai-gateway-logging` | | Durable State and WebSockets | `docs/guide/durable-state.md`, `docs/guide/websockets.md` | | Server-sent events and live streams | `docs/guide/sse.md`, `docs/guide/live.md` | +| Open Graph and social images | `docs/guide/og-images.md`, `docs/guide/edge/revalidation.md` | | Sandboxes | `docs/guide/sandboxes.md` | | Email | `docs/guide/email.md`, then the matching guide under `docs/guide/email/` | | KV / storage / queues / cron jobs | `docs/guide/kv.md`, `docs/guide/storage.md`, `docs/guide/queues.md`, `docs/guide/jobs.md` | diff --git a/packages/void/src/installed-packages.ts b/packages/void/src/installed-packages.ts index bf9f41f96..9ea6cdb84 100644 --- a/packages/void/src/installed-packages.ts +++ b/packages/void/src/installed-packages.ts @@ -26,7 +26,7 @@ function readManifest(dir: string): PackageManifest | undefined { type PnpApi = { resolveToUnqualified(request: string, issuer: string): string | null }; /** The directory Node loads `name` from for code in `fromDir`. Ignores `exports`. */ -function findInstalledPackage(name: string, fromDir: string): string | undefined { +export function findInstalledPackage(name: string, fromDir: string): string | undefined { // A Yarn Plug'n'Play install has no node_modules. Under `yarn`, its runtime adds // `findPnpApi` to `node:module` and serves the packages' files from its archives. const pnp = (module as { findPnpApi?: (path: string) => PnpApi | null }).findPnpApi?.(fromDir); diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index 70abc4075..9c43f7c6c 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url'; import mime from 'mime'; import { dirname, isAbsolute, join, relative, resolve } from 'pathslash'; import { isFileServingAllowed, type Plugin } from 'vite'; -import { findVoidDependentPackages } from './installed-packages.ts'; +import { findInstalledPackage, findVoidDependentPackages } from './installed-packages.ts'; import { readProjectPaths } from './project-paths.ts'; /** Imports that require CF bindings and are not available in Node.js mode. */ @@ -322,5 +322,34 @@ export function nodeTargetPlugin( }, }; - return [guardPlugin, cloudflareModulesPlugin]; + // void/og renders with takumi-js, which loads a native addon that a bundle cannot carry. A build + // leaves a package that resolves from the app root to the runtime. With pnpm, void's own + // dependency does not, so it would be bundled and every image would fail to render. + const imageRendererPlugin: Plugin = { + name: 'void:node-target-image-renderer', + apply: 'build', + enforce: 'pre', + resolveId: { + filter: { id: /^takumi-js(?:\/|$)/ }, + handler() { + const root = this.environment.config.root; + if (findInstalledPackage('takumi-js', root)) { + return; + } + throw new Error( + `void/og needs takumi-js in your app's dependencies with target '${target}', so the ` + + `build leaves its native renderer to ${RUNTIME_NAMES[target]}. Install it, for example ` + + 'with `npm install takumi-js`.', + ); + }, + }, + }; + + return [guardPlugin, cloudflareModulesPlugin, imageRendererPlugin]; } + +const RUNTIME_NAMES: Record = { + node: 'Node.js', + bun: 'Bun', + deno: 'Deno', +}; diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts new file mode 100644 index 000000000..65e4f53cf --- /dev/null +++ b/packages/void/src/runtime/og.ts @@ -0,0 +1,174 @@ +/** + * `void/og` — a `next/og`-compatible `ImageResponse` rendered by Takumi. + * + * Takumi does the rendering (Wasm on workerd, its native addon on Node). This + * module only adds what `next/og` callers expect on top: a 1200×630 default + * size, the `cache-control` header, `debug`, and Noto fallback fonts for + * scripts that Takumi's built-in Geist (Latin only) cannot draw. + */ +import { render, type Node, type RenderInput, type RenderOptions } from 'takumi-js'; +import { collectCodepoints, googleFonts, type FontSubset } from 'takumi-js/helpers'; +import { fromHtml } from 'takumi-js/helpers/html'; +import { fromJsx } from 'takumi-js/helpers/jsx'; + +export type ImageResponseOptions = RenderOptions & + ResponseInit & { + /** Draws a border around every box, like `next/og`'s `debug`. */ + debug?: boolean; + }; + +const CONTENT_TYPES = { + png: 'image/png', + jpeg: 'image/jpeg', + webp: 'image/webp', + ico: 'image/x-icon', + raw: 'application/octet-stream', +} as const; + +// Google Fonts families for text the default font cannot draw, matched by +// script, like the fonts `next/og` downloads. Han text uses the Japanese or +// Korean family when kana or Hangul shows the language. +const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ + [/[\p{sc=Hiragana}\p{sc=Katakana}]/u, 'Noto Sans JP'], + [/\p{sc=Hangul}/u, 'Noto Sans KR'], + [/\p{sc=Han}/u, 'Noto Sans SC'], + [/\p{sc=Thai}/u, 'Noto Sans Thai'], + [/\p{sc=Arabic}/u, 'Noto Sans Arabic'], + [/\p{sc=Hebrew}/u, 'Noto Sans Hebrew'], + [/\p{sc=Devanagari}/u, 'Noto Sans Devanagari'], + [/\p{sc=Bengali}/u, 'Noto Sans Bengali'], + [/\p{sc=Tamil}/u, 'Noto Sans Tamil'], + [/\p{sc=Telugu}/u, 'Noto Sans Telugu'], + [/\p{sc=Kannada}/u, 'Noto Sans Kannada'], + [/\p{sc=Malayalam}/u, 'Noto Sans Malayalam'], + [/[\p{sc=Cyrillic}\p{sc=Greek}\u1E00-\u1EFF]/u, 'Noto Sans'], +]; + +/** + * A `Response` whose body is the image rendered from `element`: React JSX, an + * HTML string, or a Takumi node tree. Defaults match `next/og`: 1200×630 PNG, + * cached for a year outside development. + */ +export class ImageResponse extends Response { + constructor(element: RenderInput, options: ImageResponseOptions = {}) { + const { status, statusText, headers, debug, ...renderOptions } = options; + const responseHeaders = new Headers(headers); + if (!responseHeaders.has('content-type')) { + responseHeaders.set('content-type', CONTENT_TYPES[renderOptions.format ?? 'png']); + } + if (!responseHeaders.has('cache-control')) { + responseHeaders.set( + 'cache-control', + isDevelopment() + ? 'no-cache, no-store' + : 'public, immutable, no-transform, max-age=31536000', + ); + } + const body = new ReadableStream({ + async start(controller) { + try { + const image = await renderImage(element, { + width: 1200, + height: 630, + drawDebugBorder: debug, + ...renderOptions, + }); + controller.enqueue(image); + controller.close(); + } catch (error) { + console.error('void/og: failed to render the image.', error); + controller.error(error); + } + }, + }); + super(body, { status, statusText, headers: responseHeaders }); + } +} + +class FallbackFontError extends Error {} + +async function renderImage(element: RenderInput, options: RenderOptions): Promise { + // Convert once, here: the fallback fonts depend on the final text, and + // function components must not run twice. + const { node, css } = await toNode(element, options.jsx); + const renderNode = (fonts?: RenderOptions['fonts']) => + render(node, { + ...options, + ...(css.length > 0 && { css: [...toList(options.css), ...css] }), + ...(fonts && { fonts }), + }); + const fallback = await fallbackFonts(node); + if (fallback.length === 0) { + return renderNode(); + } + try { + return await renderNode([...((await options.fonts) ?? []), ...fallback]); + } catch (error) { + // Like `next/og`, a font that fails to download leaves its glyphs missing + // rather than failing the image. + if (!(error instanceof FallbackFontError)) { + throw error; + } + console.warn(`${error.message} Rendering without it.`, error.cause); + return renderNode(); + } +} + +function toNode(element: RenderInput, jsx: RenderOptions['jsx']) { + if (typeof element === 'string') { + return fromHtml(element); + } + if (isTakumiNode(element)) { + return { node: element, css: [] }; + } + return fromJsx(element, jsx); +} + +function isTakumiNode(element: unknown): element is Node { + const type = (element as { type?: unknown } | null)?.type; + return type === 'container' || type === 'text' || type === 'image'; +} + +function toList(value: T | ReadonlyArray | undefined): Array { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? [...value] : [value as T]; +} + +async function fallbackFonts(node: Node): Promise> { + const text = Array.from(collectCodepoints(node), (codepoint) => + String.fromCodePoint(codepoint), + ).join(''); + let families = FALLBACK_FONTS.filter(([script]) => script.test(text)).map(([, family]) => family); + if (families.includes('Noto Sans JP') || families.includes('Noto Sans KR')) { + families = families.filter((family) => family !== 'Noto Sans SC'); + } + if (families.length === 0) { + return []; + } + let subsets: Array; + try { + subsets = await googleFonts(families); + } catch (error) { + console.warn(`void/og: could not load ${families.join(', ')} from Google Fonts.`, error); + return []; + } + return ( + subsets + // Registered fonts come before Geist, so keep their Latin subsets out: + // Latin text stays on the default font, as it does with `next/og`. + .filter((subset) => !subset.unicodeRange.some(([start, end]) => start <= 0x41 && 0x41 <= end)) + .map((subset) => ({ + ...subset, + data: () => + subset.data().catch((cause: unknown) => { + throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause }); + }), + })) + ); +} + +function isDevelopment(): boolean { + return (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true; +} diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts new file mode 100644 index 000000000..aa0f98892 --- /dev/null +++ b/packages/void/test/integration/og-image.test.ts @@ -0,0 +1,220 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import spawn from 'cross-spawn'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { killChild, startServer, waitForServer } from '../../../../test/integration-helpers.ts'; +import { collectPrerenderWorkerModules } from '../../src/prerender.ts'; + +const repository = join(import.meta.dirname, '..', '..', '..', '..'); +const DEV_HEADER = 'no-cache, no-store'; +const BUILD_HEADER = 'public, immutable, no-transform, max-age=31536000'; +// Vitest sets NODE_ENV=test, which would make Vite build in development mode. +const { NODE_ENV: _nodeEnv, ...userEnv } = process.env; + +const OG_ROUTE = `import { ImageResponse } from 'void/og'; +export const GET = () => new ImageResponse('
Hello
'); +`; +const CARD_ROUTE = `import { ImageResponse } from 'void/og'; +import { card } from '../../src/card'; +export const GET = () => new ImageResponse(card('Hello from JSX')); +`; +const CARD_COMPONENT = `export function card(title: string) { + return
{title}
; +} +`; + +const fixtures: Array = []; + +/** An app inside the workspace, so Vite serves the linked packages, outside its package globs. */ +function createApp( + files: Record, + links: Record, + config = `{ cloudflare: { name: 'og-fixture', compatibility_date: '2026-07-01' } }`, +): string { + const parent = join(repository, 'playground-local', '.test-fixtures'); + mkdirSync(parent, { recursive: true }); + const root = mkdtempSync(join(parent, 'test-og-')); + fixtures.push(root); + const packages = { 'vite-plus': 'node_modules/vite-plus', void: 'packages/void', ...links }; + for (const [name, source] of Object.entries(packages)) { + mkdirSync(dirname(join(root, 'node_modules', name)), { recursive: true }); + symlinkSync( + realpathSync(join(repository, source)), + join(root, 'node_modules', name), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } + const all: Record = { + 'package.json': JSON.stringify({ name: `og-${root.slice(-6)}`, private: true, type: 'module' }), + 'vite.config.ts': `import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; +export default defineConfig({ plugins: [voidPlugin()] }); +`, + 'void.config.ts': `export default ${config};\n`, + ...files, + }; + for (const [path, source] of Object.entries(all)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), source); + } + return root; +} + +function build(root: string) { + const result = spawn.sync('vp', ['build'], { + cwd: root, + encoding: 'utf8', + timeout: 60_000, + maxBuffer: 10 * 1024 * 1024, + env: { ...userEnv, VOID_INSPECTOR_PORT: 'false' }, + }); + expect(result.status, `${result.stdout ?? ''}${result.stderr ?? ''}`).toBe(0); +} + +async function expectImage(response: Response, cacheControl: string) { + expect(response.status, response.ok ? undefined : await response.text()).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(response.headers.get('cache-control')).toBe(cacheControl); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect([...bytes.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + expect({ width: view.getUint32(16), height: view.getUint32(20) }).toEqual({ + width: 1200, + height: 630, + }); +} + +function builtFiles(root: string): Array { + return readdirSync(join(root, 'dist/ssr'), { recursive: true }).map(String); +} + +afterAll(() => { + for (const root of fixtures) { + rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + } +}); + +describe('void/og in a Cloudflare app', () => { + let root: string; + beforeAll(() => { + root = createApp( + { + 'routes/api/og.ts': OG_ROUTE, + 'routes/api/card.ts': CARD_ROUTE, + 'src/card.tsx': CARD_COMPONENT, + }, + { react: 'playground/react-pages/node_modules/react' }, + ); + }); + + describe('dev server', () => { + let child: ReturnType | undefined; + afterAll(async () => { + if (child) { + await killChild(child); + } + }); + + it('renders HTML and JSX in workerd', async () => { + const server = await startServer((port) => { + child = spawn( + 'vp', + ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...userEnv, VOID_INSPECTOR_PORT: 'false' }, + }, + ); + return child; + }, 30_000); + // The first import of takumi-js reloads the optimized dependencies once. + await waitForServer(`${server.base}/api/og`, 30_000, { + expectStatus: 200, + diagnostics: server.output, + }); + await expectImage(await fetch(`${server.base}/api/og`), DEV_HEADER); + await expectImage(await fetch(`${server.base}/api/card`), DEV_HEADER); + }); + }); + + describe('build', () => { + let runtime: Miniflare | undefined; + afterAll(async () => { + await runtime?.dispose(); + }); + + it('renders HTML and JSX in the built Worker', async () => { + build(root); + const output = join(root, 'dist/ssr'); + const config = JSON.parse(readFileSync(join(output, 'wrangler.json'), 'utf8')); + const modules = collectPrerenderWorkerModules(output, config.main); + expect(modules.filter((module) => module.type === 'CompiledWasm')).toHaveLength(1); + runtime = new Miniflare( + convertV4MiniflareOptions({ + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + modules, + modulesRoot: output, + }), + ); + await expectImage(await runtime.dispatchFetch('http://localhost/api/og'), BUILD_HEADER); + await expectImage(await runtime.dispatchFetch('http://localhost/api/card'), BUILD_HEADER); + }); + }); +}); + +describe('a Cloudflare app without void/og', () => { + it('bundles no image renderer', () => { + const root = createApp({ 'routes/api/hello.ts': "export const GET = () => 'hello';\n" }, {}); + build(root); + const files = builtFiles(root); + expect(files.filter((file) => file.endsWith('.wasm'))).toEqual([]); + const code = files + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(join(root, 'dist/ssr', file), 'utf8')) + .join('\n'); + expect(code).not.toContain('takumi'); + }); +}); + +describe('void/og on the Node.js target', () => { + let child: ReturnType | undefined; + afterAll(async () => { + if (child) { + await killChild(child); + } + }); + + it("renders with the app's takumi-js", async () => { + const root = createApp( + { 'routes/api/og.ts': OG_ROUTE }, + { + 'takumi-js': 'packages/void/node_modules/takumi-js', + '@hono/node-server': 'playground/node-app/node_modules/@hono/node-server', + }, + `{ target: 'node' }`, + ); + build(root); + expect(builtFiles(root).filter((file) => file.endsWith('.node'))).toEqual([]); + const server = await startServer((port) => { + child = spawn('node', ['dist/ssr/index.js'], { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...userEnv, PORT: String(port) }, + }); + return child; + }, 20_000); + await expectImage(await fetch(`${server.base}/api/og`), BUILD_HEADER); + }); +}); diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts new file mode 100644 index 000000000..fed454240 --- /dev/null +++ b/packages/void/test/unit/og.test.ts @@ -0,0 +1,197 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { nodeTargetPlugin } from '../../src/plugin-node-target.ts'; +import { ImageResponse } from '../../src/runtime/og.ts'; + +const card = '
Hello
'; + +async function pngSize(response: Response) { + const bytes = new Uint8Array(await response.arrayBuffer()); + expect([...bytes.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; +} + +// A css2 answer with a Latin subset and one that covers the Japanese test text. +const NOTO_SANS_JP_CSS = `/* [0] */ +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/noto-sans-jp/kana.woff2) format('woff2'); + unicode-range: U+3040-309F, U+4E16, U+754C; +} +/* latin */ +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/noto-sans-jp/latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131; +} +`; + +function stubFetch(handler: (url: string) => Response | Promise) { + const requests: Array = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + requests.push(url); + return handler(url); + }), + ); + return requests; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('ImageResponse', () => { + it('renders a 1200x630 PNG by default', async () => { + const response = new ImageResponse(card); + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + }); + + it('renders React-style elements and runs each function component once', async () => { + const Title = vi.fn(({ text }: { text: string }) => ({ + type: 'span', + props: { children: text }, + })); + const response = new ImageResponse( + { + type: 'div', + props: { style: { display: 'flex' }, children: { type: Title, props: { text: 'Hello' } } }, + }, + { width: 600, height: 315 }, + ); + expect(await pngSize(response)).toEqual({ width: 600, height: 315 }); + expect(Title).toHaveBeenCalledOnce(); + }); + + // Builds cache images for a year: test/integration/og-image.test.ts. + it('does not cache images during development', () => { + expect(new ImageResponse(card).headers.get('cache-control')).toBe('no-cache, no-store'); + }); + + it('keeps the status and headers it is given', () => { + const response = new ImageResponse(card, { + status: 201, + statusText: 'Created', + headers: { 'cache-control': 'public, max-age=60', 'x-image': 'card' }, + }); + expect(response.status).toBe(201); + expect(response.statusText).toBe('Created'); + expect(response.headers.get('cache-control')).toBe('public, max-age=60'); + expect(response.headers.get('x-image')).toBe('card'); + expect(response.headers.get('content-type')).toBe('image/png'); + }); + + it('labels other formats with their content type', async () => { + const response = new ImageResponse(card, { format: 'jpeg' }); + expect(response.headers.get('content-type')).toBe('image/jpeg'); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect([...bytes.subarray(0, 2)]).toEqual([0xff, 0xd8]); + }); + + it('draws box borders with debug', async () => { + const plain = new Uint8Array(await new ImageResponse(card).arrayBuffer()); + const debug = new Uint8Array(await new ImageResponse(card, { debug: true }).arrayBuffer()); + expect(debug).not.toEqual(plain); + }); + + it('errors the body when the image cannot render', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const response = new ImageResponse(card, { fonts: [new Uint8Array([1, 2, 3])] }); + await expect(response.arrayBuffer()).rejects.toThrow(); + expect(logged).toHaveBeenCalledWith('void/og: failed to render the image.', expect.anything()); + }); +}); + +describe('ImageResponse fallback fonts', () => { + it('requests no font for Latin text', async () => { + const requests = stubFetch(() => new Response(null, { status: 500 })); + await new ImageResponse(card).arrayBuffer(); + expect(requests).toEqual([]); + }); + + it('downloads only the non-Latin subsets of the Japanese family', async () => { + const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch((url) => + url.startsWith('https://fonts.googleapis.com/') + ? new Response(NOTO_SANS_JP_CSS, { headers: { 'content-type': 'text/css' } }) + : new Response(null, { status: 404 }), + ); + const response = new ImageResponse('
Hello こんにちは世界
'); + // The font file is missing, so the image renders without it. + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + expect(requests).toHaveLength(2); + const css = new URL(requests[0]!); + expect(css.origin + css.pathname).toBe('https://fonts.googleapis.com/css2'); + expect(css.searchParams.getAll('family')).toEqual(['Noto Sans JP:wght@400']); + expect(requests[1]).toBe('https://fonts.gstatic.com/noto-sans-jp/kana.woff2'); + expect(warned).toHaveBeenCalledWith( + 'void/og: could not download Noto Sans JP. Rendering without it.', + expect.anything(), + ); + }); + + it('uses the Simplified Chinese family for Han text without kana or Hangul', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 503 })); + await new ImageResponse('
你好世界
').arrayBuffer(); + expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Noto Sans SC:wght@400']); + }); + + it('renders without fallback fonts when Google Fonts cannot be reached', async () => { + const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + const response = new ImageResponse('
안녕하세요
'); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + expect(warned).toHaveBeenCalledWith( + 'void/og: could not load Noto Sans KR from Google Fonts.', + expect.anything(), + ); + }); +}); + +describe('void/og on Node.js targets', () => { + let root: string | undefined; + afterEach(() => { + if (root) { + rmSync(root, { recursive: true, force: true }); + root = undefined; + } + }); + + function resolveRenderer() { + const plugin = nodeTargetPlugin('node').find( + (candidate) => candidate.name === 'void:node-target-image-renderer', + )!; + const resolveId = plugin.resolveId as { handler: (this: unknown, id: string) => unknown }; + return resolveId.handler.call({ environment: { config: { root } } }, 'takumi-js/helpers'); + } + + it('fails the build when the app cannot load takumi-js itself', () => { + root = mkdtempSync(join(tmpdir(), 'void-og-node-')); + expect(resolveRenderer).toThrow( + "void/og needs takumi-js in your app's dependencies with target 'node'", + ); + }); + + it("leaves takumi-js to Node.js when it is in the app's dependencies", () => { + root = mkdtempSync(join(tmpdir(), 'void-og-node-')); + mkdirSync(join(root, 'node_modules', 'takumi-js'), { recursive: true }); + writeFileSync(join(root, 'node_modules', 'takumi-js', 'package.json'), '{"name":"takumi-js"}'); + expect(resolveRenderer()).toBeUndefined(); + }); +}); diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index 71c0c6b37..78e8ba60b 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -115,6 +115,7 @@ export default defineConfig({ 'src/runtime/log.ts', 'src/runtime/ai.ts', 'src/runtime/limits.ts', + 'src/runtime/og.ts', 'src/runtime/email.ts', 'src/runtime/email-protocol.ts', 'src/runtime/email/testing.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d370b14b5..df447edc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -873,6 +873,9 @@ importers: smol-toml: specifier: 'catalog:' version: 1.9.1 + takumi-js: + specifier: ^2.16.2 + version: 2.20.0(csstype@3.2.3)(react@19.3.1) terminal-link: specifier: ^5.0.0 version: 5.0.0 @@ -5402,6 +5405,88 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@takumi-rs/core-darwin-arm64@2.20.0': + resolution: {integrity: sha512-wescbJ8pm0fBN8uXxjiZUDqN7JkdDDmxt8Gq1pSrpRCuGxvAnoj3agMMhHtJ5Ty+BufkIwSvV2cBXRLM4wyYZw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@takumi-rs/core-darwin-x64@2.20.0': + resolution: {integrity: sha512-idCQC8PLuuDFQ4c2YEx142Q1WM+GbrR3RqNxuJay5MxfCXcZ42dv+96xGcgs0U2AWOk1GkY/vTY4y/Om7O/QHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@takumi-rs/core-linux-arm64-gnu@2.20.0': + resolution: {integrity: sha512-TeqwOXlVdjAoSoOVQVoJOER4upQnh7nX6QJnYqVDlbByiSkMhqYULXc7I83NqzkL3U/fbT7/oASNeGWAI2axBA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@takumi-rs/core-linux-arm64-musl@2.20.0': + resolution: {integrity: sha512-gOcZxdGq7ebhqRN1Ue3FYJV7jKFG8yG99Opiky13viXXH8hkOde+OKUMe2kqWRH42j7p5EhBPj2qmdUBXXQBCQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@takumi-rs/core-linux-x64-gnu@2.20.0': + resolution: {integrity: sha512-MwIdLqyMUUz+6wuxkGJZyEO9alZODuDqe826HAG2+RErwpSUwfqtD2pHPWG9bnWkr7qpVeziFut6Ng68lYzcvQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@takumi-rs/core-linux-x64-musl@2.20.0': + resolution: {integrity: sha512-bM9QEwOuKg0+im1M+XjownCCdN+zHKQXGblDttpKuoa2QEd4F9t0JJcQMBSjLFucC2sf+rGicAmayMsQRxwgfg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@takumi-rs/core-win32-arm64-msvc@2.20.0': + resolution: {integrity: sha512-VwGj5dsODmHYV09P3AFhvcHL3hk8cS3Y0PYYHxFQtopwcWTsDN522+siotYL1lXZaEag9FoMJOuqiuZsVGgR3w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@takumi-rs/core-win32-x64-msvc@2.20.0': + resolution: {integrity: sha512-3am5JIowXAETxUHwQh8MONCagATpN4jFiXISKT2djvceJxrriPZKwWNjB7zTUzZmVLYTbslMrG7x2+U1wPndYg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@takumi-rs/core@2.20.0': + resolution: {integrity: sha512-28uryNYSyuXZWv5uBfJiWDrRXVdAunkU0sZiB7KG2WgLej64pUNL6jZSTQa+gBXfVu+3DdCz58OnpONBO6EUqQ==} + engines: {node: '>=18'} + peerDependencies: + csstype: '*' + peerDependenciesMeta: + csstype: + optional: true + + '@takumi-rs/helpers@2.20.0': + resolution: {integrity: sha512-efdeG4Ujsw0IEYXhq3lUN1UiKhK6f3tak7VSMrCEWSvumFs0q5moBHSLdsR6aJhPNsYei21Ea6Rjtv94/qdyjg==} + engines: {node: '>=18'} + peerDependencies: + preact: ^10.0.0 || ^11.0.0 + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + preact: + optional: true + react: + optional: true + + '@takumi-rs/wasm@2.20.0': + resolution: {integrity: sha512-2Xfu55Ye8gmJMXQEDun/5zl/78LyCXKUjP503alH1tG0OyotkkWpZbgW8iWGuxxf4IJvp9kIaO+NLGbq4B6J8A==} + engines: {node: '>=20.19'} + peerDependencies: + csstype: '*' + peerDependenciesMeta: + csstype: + optional: true + '@tanstack/history@1.162.5': resolution: {integrity: sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==} engines: {node: '>=20.19'} @@ -9688,6 +9773,10 @@ packages: tailwindcss@4.3.5: resolution: {integrity: sha512-FcQk9BuffmEHJP2G0XmB2SplU1aLTI7j07Kunk+U74Ny+XiWOtzXz9aujFZ/avntw1e2cOv/3PkgzflQ7gX2vQ==} + takumi-js@2.20.0: + resolution: {integrity: sha512-WnqocRxTlQcDeA6Q0Awcz3+diSF3UGA2hmIL3pLsqiGXZHpeeRVpVad97EO33sYiMT6+L2kMya28EVGJbjCyfg==} + engines: {node: '>=20.19'} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -13541,6 +13630,60 @@ snapshots: tailwindcss: 4.3.5 vite: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' + '@takumi-rs/core-darwin-arm64@2.20.0': + optional: true + + '@takumi-rs/core-darwin-x64@2.20.0': + optional: true + + '@takumi-rs/core-linux-arm64-gnu@2.20.0': + optional: true + + '@takumi-rs/core-linux-arm64-musl@2.20.0': + optional: true + + '@takumi-rs/core-linux-x64-gnu@2.20.0': + optional: true + + '@takumi-rs/core-linux-x64-musl@2.20.0': + optional: true + + '@takumi-rs/core-win32-arm64-msvc@2.20.0': + optional: true + + '@takumi-rs/core-win32-x64-msvc@2.20.0': + optional: true + + '@takumi-rs/core@2.20.0(csstype@3.2.3)(react@19.3.1)': + dependencies: + '@takumi-rs/helpers': 2.20.0(react@19.3.1) + optionalDependencies: + '@takumi-rs/core-darwin-arm64': 2.20.0 + '@takumi-rs/core-darwin-x64': 2.20.0 + '@takumi-rs/core-linux-arm64-gnu': 2.20.0 + '@takumi-rs/core-linux-arm64-musl': 2.20.0 + '@takumi-rs/core-linux-x64-gnu': 2.20.0 + '@takumi-rs/core-linux-x64-musl': 2.20.0 + '@takumi-rs/core-win32-arm64-msvc': 2.20.0 + '@takumi-rs/core-win32-x64-msvc': 2.20.0 + csstype: 3.2.3 + transitivePeerDependencies: + - preact + - react + + '@takumi-rs/helpers@2.20.0(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + + '@takumi-rs/wasm@2.20.0(csstype@3.2.3)(react@19.3.1)': + dependencies: + '@takumi-rs/helpers': 2.20.0(react@19.3.1) + optionalDependencies: + csstype: 3.2.3 + transitivePeerDependencies: + - preact + - react + '@tanstack/history@1.162.5': {} '@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': @@ -18415,6 +18558,16 @@ snapshots: tailwindcss@4.3.5: {} + takumi-js@2.20.0(csstype@3.2.3)(react@19.3.1): + dependencies: + '@takumi-rs/core': 2.20.0(csstype@3.2.3)(react@19.3.1) + '@takumi-rs/helpers': 2.20.0(react@19.3.1) + '@takumi-rs/wasm': 2.20.0(csstype@3.2.3)(react@19.3.1) + transitivePeerDependencies: + - csstype + - preact + - react + tapable@2.3.3: {} tar-stream@3.2.2(bare-buffer@3.7.1): diff --git a/test/integration-timings.ts b/test/integration-timings.ts index ef386ccae..d0539cd52 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -29,6 +29,7 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/node-static.test.ts': 13667, 'packages/void/test/integration/node-target.test.ts': 10833, 'packages/void/test/integration/nuxt.test.ts': 43000, + 'packages/void/test/integration/og-image.test.ts': 22000, 'packages/void/test/integration/operator-cli.test.ts': 4910, 'packages/void/test/integration/pages-target-assets.test.ts': 10966, 'packages/void/test/integration/pg-api-test.test.ts': 11000, From 2d901ca2e97d3fef163c83a700c65fe0c600693e Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:01:30 +0000 Subject: [PATCH 02/35] test(void): type the Miniflare runtime in the void/og integration test Miniflare types its own Response without Cloudflare's cf property, which failed the package typecheck. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- packages/void/test/integration/og-image.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts index aa0f98892..9e9ff44f0 100644 --- a/packages/void/test/integration/og-image.test.ts +++ b/packages/void/test/integration/og-image.test.ts @@ -33,6 +33,12 @@ const CARD_COMPONENT = `export function card(title: string) { } `; +// Miniflare types its own Response, without Cloudflare's `cf`. +type Runtime = { + dispatchFetch(input: string): Promise; + dispose(): Promise; +}; + const fixtures: Array = []; /** An app inside the workspace, so Vite serves the linked packages, outside its package globs. */ @@ -149,7 +155,7 @@ describe('void/og in a Cloudflare app', () => { }); describe('build', () => { - let runtime: Miniflare | undefined; + let runtime: Runtime | undefined; afterAll(async () => { await runtime?.dispose(); }); @@ -167,7 +173,7 @@ describe('void/og in a Cloudflare app', () => { modules, modulesRoot: output, }), - ); + ) as unknown as Runtime; await expectImage(await runtime.dispatchFetch('http://localhost/api/og'), BUILD_HEADER); await expectImage(await runtime.dispatchFetch('http://localhost/api/card'), BUILD_HEADER); }); From 9e2d6d11c646b39894793bb33cdd6f3a419965a4 Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:39:54 +0000 Subject: [PATCH 03/35] fix(void): draw CJK and accented Latin text in void/og images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Fonts puts ASCII into the CJK subsets that hold the most common characters, such as 日, 中 and 하. void/og dropped every subset that covered "A" to keep Latin text on the default font, so those characters rendered as missing glyphs. Skip only each family's Latin subset, and take Latin-1 out of the others. The built-in font also lacks Latin Extended-A and -B, so Polish, Czech, Turkish and similar text now loads Noto Sans as well. Fold the deprecated `stylesheets` option into `css` when the element brings its own styles, since takumi rejects both together. On the Node.js, Bun and Deno targets, the build now also fails when the app's takumi-js is outside the range void declares, and the error names the version to install. The docs give realistic CPU times, cover Deno, and say JSX works in React apps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 12 ++--- docs/integrations/nodejs-bun-deno.md | 2 +- packages/void/AGENTS.md | 17 +++--- packages/void/src/plugin-node-target.ts | 56 +++++++++++++++---- packages/void/src/runtime/og.ts | 46 ++++++++++------ packages/void/test/unit/og.test.ts | 71 ++++++++++++++++++++----- 6 files changed, 153 insertions(+), 51 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 592049c8b..38073aaff 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -53,7 +53,7 @@ export const GET = defineHandler(async (c) => { `GET /og/hello-world` now returns a 1200 × 630 PNG. -JSX in image modules compiles with React, so your app needs `react`. Elements also accept Tailwind classes in a `tw` prop, such as `
`. +JSX works in React apps. In Vue, Svelte, and Solid apps, pass an HTML string instead, as shown in [Without React](#without-react). Elements also accept Tailwind classes in a `tw` prop, such as `
`. ### Without React @@ -108,7 +108,7 @@ Emoji and remote `` sources are downloaded while the image renders. ## Fonts -Text uses Geist unless you pass `fonts`. When the text includes a script that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, or Greek, Void downloads the matching Noto Sans font from Google Fonts while rendering. If the download fails, the image renders without those characters. +Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, or accented letters like Ł and ş, Void downloads the matching Noto Sans font from Google Fonts while rendering. If the download fails, the image renders without those characters. To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data, as in `next/og`: @@ -144,7 +144,7 @@ export const head = defineHead((c, props) => { ## Cache generated images -Each request renders the image again, which uses several milliseconds of CPU time, more for large images. [Workers Free allows 10 ms per request](../reference/config.md#worker). Cache image routes with [revalidation](./edge/revalidation.md): +Each request renders the image again. A 1200 × 630 image takes around 10 ms of CPU time, more for large or text-heavy images, and the first render in a new Worker instance takes around 100 ms. [Workers Free allows 10 ms per request](../reference/config.md#worker) and fails requests from a Worker that keeps exceeding it, so cache image routes with [revalidation](./edge/revalidation.md): ```json { @@ -173,12 +173,12 @@ Then: - Read the request from Hono's context: `c.req.url`, `c.req.param()`, and `c.req.query()`. - Void draws images with Takumi rather than Satori, so layout can differ slightly. See [Takumi vs. Satori](https://takumi.kane.tw/docs/comparison-to-satori). -## Node.js and Bun +## Node.js, Bun, and Deno -On the [Node.js and Bun targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's dependencies: +On the [Node.js, Bun, and Deno targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's `dependencies`, not `devDependencies`, so production installs include it: ```sh npm install takumi-js ``` -The build then leaves Takumi's native renderer to the runtime. Without it, the build fails and names this step. +If the build cannot find a version of `takumi-js` that Void supports, it fails and prints the command to install one. diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 24426cea1..818650eb6 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -105,7 +105,7 @@ For browser code, use Vite's `import.meta.env.VITE_*` values. These are embedded ### Open Graph images -On Node.js and Bun, [`void/og`](../guide/og-images.md) needs `takumi-js` in your app's `dependencies`. See [Node.js and Bun](../guide/og-images.md#node-js-and-bun). +[`void/og`](../guide/og-images.md) needs `takumi-js` in your app's `dependencies`. See [Node.js, Bun, and Deno](../guide/og-images.md#node-js-bun-and-deno). ### No Cloudflare bindings diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 42336ad60..2634247a6 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -159,13 +159,16 @@ Vite/CLI dependency graph. `void/og` is a thin `next/og`-compatible layer over `takumi-js`. In workerd, Takumi resolves its Wasm backend, whose `.wasm` import the Cloudflare plugin -loads as a compiled module in dev and builds. Node and Bun load Takumi's native -addon, which a bundle cannot carry, so `nodeTargetPlugin` fails a build whose -app root cannot resolve `takumi-js` instead of bundling it. Takumi's JSX helper -imports `react` as an optional peer; Vite stubs it in apps without React, and -Takumi catches the failed import. The wrapper converts the element once, asks -Google Fonts for Noto families by the scripts in its text, and drops their Latin -subsets so Latin text keeps Takumi's built-in Geist. +loads as a compiled module in dev and builds. On Node.js, Bun and Deno, Takumi +loads its renderer from files next to its code (a native addon on Node.js and +Bun), which a bundle cannot carry, so `nodeTargetPlugin` fails a build unless +the app root resolves a `takumi-js` in the range void declares. Takumi's JSX +helper imports `react` as an optional peer; Vite stubs it in apps without +React, and Takumi catches the failed import. The wrapper converts the element +once and asks Google Fonts for Noto families by the scripts in its text. It +skips each family's Latin subset and takes Latin-1 out of the other subsets, +because Google mixes ASCII into the CJK subsets with the most common +characters, so Latin text keeps Takumi's built-in Geist. Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index 9c43f7c6c..5da77d89e 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -1,6 +1,7 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { findPackageJSON } from 'node:module'; import { pathToFileURL } from 'node:url'; import mime from 'mime'; import { dirname, isAbsolute, join, relative, resolve } from 'pathslash'; @@ -322,9 +323,10 @@ export function nodeTargetPlugin( }, }; - // void/og renders with takumi-js, which loads a native addon that a bundle cannot carry. A build - // leaves a package that resolves from the app root to the runtime. With pnpm, void's own - // dependency does not, so it would be bundled and every image would fail to render. + // void/og renders with takumi-js, which loads its renderer from files next to its own code (a + // native addon on Node.js and Bun) that a bundle cannot carry. A build leaves a package that + // resolves from the app root to the runtime, so the app must install a version that void/og + // supports. With pnpm, void's own copy does not resolve from the root and would be bundled. const imageRendererPlugin: Plugin = { name: 'void:node-target-image-renderer', apply: 'build', @@ -332,14 +334,17 @@ export function nodeTargetPlugin( resolveId: { filter: { id: /^takumi-js(?:\/|$)/ }, handler() { - const root = this.environment.config.root; - if (findInstalledPackage('takumi-js', root)) { + const range = readTakumiRange(); + const installed = findInstalledPackage('takumi-js', this.environment.config.root); + const version = installed && readPackageVersion(installed); + if (version && satisfiesCaret(version, range)) { return; } + const found = version ? ` The app has takumi-js ${version}.` : ''; throw new Error( - `void/og needs takumi-js in your app's dependencies with target '${target}', so the ` + - `build leaves its native renderer to ${RUNTIME_NAMES[target]}. Install it, for example ` + - 'with `npm install takumi-js`.', + `void/og needs takumi-js@${range} in your app's dependencies with target '${target}', so ` + + `the build leaves it to ${RUNTIME_NAMES[target]}.${found} Install it, for example with ` + + `\`npm install takumi-js@${range}\`.`, ); }, }, @@ -353,3 +358,36 @@ const RUNTIME_NAMES: Record = { bun: 'Bun', deno: 'Deno', }; + +/** The takumi-js range void/og is built and tested against, from void's package.json. */ +function readTakumiRange(): string { + const manifest = findPackageJSON(import.meta.url); + return readPackageManifest(manifest!).dependencies!['takumi-js']!; +} + +function readPackageVersion(dir: string): string | undefined { + return readPackageManifest(join(dir, 'package.json')).version; +} + +function readPackageManifest(path: string): { + version?: string; + dependencies?: Record; +} { + return JSON.parse(readFileSync(path, 'utf8')); +} + +/** Whether `version` is in the caret `range` (a major version above 0, as takumi-js uses). */ +function satisfiesCaret(version: string, range: string): boolean { + const parse = (value: string) => /^\^?(\d+)\.(\d+)\.(\d+)/.exec(value)?.slice(1).map(Number); + const actual = parse(version); + const minimum = parse(range); + if (!actual || !minimum || actual[0] !== minimum[0]) { + return false; + } + for (const part of [1, 2]) { + if (actual[part] !== minimum[part]) { + return actual[part]! > minimum[part]!; + } + } + return true; +} diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index 65e4f53cf..d96a2874e 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -4,7 +4,7 @@ * Takumi does the rendering (Wasm on workerd, its native addon on Node). This * module only adds what `next/og` callers expect on top: a 1200×630 default * size, the `cache-control` header, `debug`, and Noto fallback fonts for - * scripts that Takumi's built-in Geist (Latin only) cannot draw. + * text that Takumi's built-in Geist (ASCII and Latin-1) cannot draw. */ import { render, type Node, type RenderInput, type RenderOptions } from 'takumi-js'; import { collectCodepoints, googleFonts, type FontSubset } from 'takumi-js/helpers'; @@ -41,7 +41,7 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ [/\p{sc=Telugu}/u, 'Noto Sans Telugu'], [/\p{sc=Kannada}/u, 'Noto Sans Kannada'], [/\p{sc=Malayalam}/u, 'Noto Sans Malayalam'], - [/[\p{sc=Cyrillic}\p{sc=Greek}\u1E00-\u1EFF]/u, 'Noto Sans'], + [/[\p{sc=Cyrillic}\p{sc=Greek}\u0100-\u024F\u1E00-\u1EFF]/u, 'Noto Sans'], ]; /** @@ -94,7 +94,11 @@ async function renderImage(element: RenderInput, options: RenderOptions): Promis const renderNode = (fonts?: RenderOptions['fonts']) => render(node, { ...options, - ...(css.length > 0 && { css: [...toList(options.css), ...css] }), + // takumi rejects `css` beside its deprecated `stylesheets`, so fold them. + ...(css.length > 0 && { + css: [...toList(options.css ?? options.stylesheets), ...css], + stylesheets: undefined, + }), ...(fonts && { fonts }), }); const fallback = await fallbackFonts(node); @@ -154,19 +158,29 @@ async function fallbackFonts(node: Node): Promise> { console.warn(`void/og: could not load ${families.join(', ')} from Google Fonts.`, error); return []; } - return ( - subsets - // Registered fonts come before Geist, so keep their Latin subsets out: - // Latin text stays on the default font, as it does with `next/og`. - .filter((subset) => !subset.unicodeRange.some(([start, end]) => start <= 0x41 && 0x41 <= end)) - .map((subset) => ({ - ...subset, - data: () => - subset.data().catch((cause: unknown) => { - throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause }); - }), - })) - ); + // Registered fonts come before Geist, so keep ASCII and Latin-1 on the + // default font, as `next/og` does: skip each family's Latin subset, and take + // Latin-1 out of the others, which can mix ASCII with common CJK characters. + return subsets.flatMap((subset) => { + if (subset.unicodeRange.some(([start, end]) => start <= 0x20 && 0x7e <= end)) { + return []; + } + const unicodeRange = subset.unicodeRange + .filter(([, end]) => end > 0xff) + .map(([start, end]): [number, number] => [Math.max(start, 0x100), end]); + // An empty range covers everything, so only drop a subset that had ranges. + if (unicodeRange.length === 0 && subset.unicodeRange.length > 0) { + return []; + } + return { + ...subset, + unicodeRange, + data: () => + subset.data().catch((cause: unknown) => { + throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause }); + }), + }; + }); } function isDevelopment(): boolean { diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index fed454240..a1cdd50b6 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import voidManifest from '../../package.json' with { type: 'json' }; import { nodeTargetPlugin } from '../../src/plugin-node-target.ts'; import { ImageResponse } from '../../src/runtime/og.ts'; @@ -14,7 +15,8 @@ async function pngSize(response: Response) { return { width: view.getUint32(16), height: view.getUint32(20) }; } -// A css2 answer with a Latin subset and one that covers the Japanese test text. +// A css2 answer shaped like Google Fonts': a numbered subset can mix ASCII with +// common CJK characters, and the Latin subset covers Latin-1. const NOTO_SANS_JP_CSS = `/* [0] */ @font-face { font-family: 'Noto Sans JP'; @@ -23,13 +25,21 @@ const NOTO_SANS_JP_CSS = `/* [0] */ src: url(https://fonts.gstatic.com/noto-sans-jp/kana.woff2) format('woff2'); unicode-range: U+3040-309F, U+4E16, U+754C; } +/* [118] */ +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/noto-sans-jp/common.woff2) format('woff2'); + unicode-range: U+20-22, U+27-2A, U+2C-38, U+41-47, U+61-7B, U+3001, U+65E5; +} /* latin */ @font-face { font-family: 'Noto Sans JP'; font-style: normal; font-weight: 400; src: url(https://fonts.gstatic.com/noto-sans-jp/latin.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131; + unicode-range: U+0000-00FF, U+0131, U+2000-206F; } `; @@ -107,6 +117,16 @@ describe('ImageResponse', () => { expect(debug).not.toEqual(plain); }); + it('accepts the deprecated stylesheets option beside a style element', async () => { + // takumi warns that the option is deprecated. + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const response = new ImageResponse( + '
Hi
', + { stylesheets: ['.card { color: #fff }'] }, + ); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + }); + it('errors the body when the image cannot render', async () => { const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); const response = new ImageResponse(card, { fonts: [new Uint8Array([1, 2, 3])] }); @@ -122,27 +142,37 @@ describe('ImageResponse fallback fonts', () => { expect(requests).toEqual([]); }); - it('downloads only the non-Latin subsets of the Japanese family', async () => { + it('keeps Latin text off the Japanese family', async () => { const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch((url) => url.startsWith('https://fonts.googleapis.com/') ? new Response(NOTO_SANS_JP_CSS, { headers: { 'content-type': 'text/css' } }) : new Response(null, { status: 404 }), ); - const response = new ImageResponse('
Hello こんにちは世界
'); - // The font file is missing, so the image renders without it. + const response = new ImageResponse('
Hello こんにちは日本
'); + // The font files are missing, so the image renders without them. expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); - expect(requests).toHaveLength(2); const css = new URL(requests[0]!); expect(css.origin + css.pathname).toBe('https://fonts.googleapis.com/css2'); expect(css.searchParams.getAll('family')).toEqual(['Noto Sans JP:wght@400']); - expect(requests[1]).toBe('https://fonts.gstatic.com/noto-sans-jp/kana.woff2'); + // The subset that mixes ASCII with 日 still serves 日; the Latin subset is never fetched. + expect(requests.slice(1).sort()).toEqual([ + 'https://fonts.gstatic.com/noto-sans-jp/common.woff2', + 'https://fonts.gstatic.com/noto-sans-jp/kana.woff2', + ]); expect(warned).toHaveBeenCalledWith( 'void/og: could not download Noto Sans JP. Rendering without it.', expect.anything(), ); }); + it('uses Noto Sans for accented Latin letters that Geist lacks', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 503 })); + await new ImageResponse('
Łódź
').arrayBuffer(); + expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Noto Sans:wght@400']); + }); + it('uses the Simplified Chinese family for Han text without kana or Hangul', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch(() => new Response(null, { status: 503 })); @@ -181,17 +211,34 @@ describe('void/og on Node.js targets', () => { return resolveId.handler.call({ environment: { config: { root } } }, 'takumi-js/helpers'); } + // The range void/og declares; the app must install a version in it. + const range = voidManifest.dependencies['takumi-js']; + + function installTakumi(version: string) { + root ??= mkdtempSync(join(tmpdir(), 'void-og-node-')); + mkdirSync(join(root, 'node_modules', 'takumi-js'), { recursive: true }); + writeFileSync( + join(root, 'node_modules', 'takumi-js', 'package.json'), + JSON.stringify({ name: 'takumi-js', version }), + ); + } + it('fails the build when the app cannot load takumi-js itself', () => { root = mkdtempSync(join(tmpdir(), 'void-og-node-')); expect(resolveRenderer).toThrow( - "void/og needs takumi-js in your app's dependencies with target 'node'", + `void/og needs takumi-js@${range} in your app's dependencies with target 'node'`, ); }); - it("leaves takumi-js to Node.js when it is in the app's dependencies", () => { - root = mkdtempSync(join(tmpdir(), 'void-og-node-')); - mkdirSync(join(root, 'node_modules', 'takumi-js'), { recursive: true }); - writeFileSync(join(root, 'node_modules', 'takumi-js', 'package.json'), '{"name":"takumi-js"}'); + it('fails the build when the app has a takumi-js that void/og does not support', () => { + installTakumi('2.15.2'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.15.2.'); + installTakumi('3.0.0'); + expect(resolveRenderer).toThrow('The app has takumi-js 3.0.0.'); + }); + + it("leaves a supported takumi-js in the app's dependencies to Node.js", () => { + installTakumi(range.replace(/^\^/, '')); expect(resolveRenderer()).toBeUndefined(); }); }); From bf1087246a7ed1d9c49a67465a65615e807074b1 Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:37:31 +0000 Subject: [PATCH 04/35] Cite upstream sources in void/og and isolate fonts per image - Comment where each behavior comes from in @vercel/og 1.0.3, Satori 0.33.5 and Next.js 16.4.2, and where void/og differs. - Always pass Takumi a font list. Without one it used every font registered in the process, so an earlier image's fallback fonts changed the glyphs of later Latin-only images. - Check the takumi-js version on Node.js targets only for void's own imports, so an app can use another takumi-js version itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- packages/void/src/plugin-node-target.ts | 12 +++-- packages/void/src/runtime/og.ts | 62 ++++++++++++++++++------- packages/void/test/unit/og.test.ts | 35 ++++++++++++-- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index 5da77d89e..b0f3d50b2 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -333,7 +333,11 @@ export function nodeTargetPlugin( enforce: 'pre', resolveId: { filter: { id: /^takumi-js(?:\/|$)/ }, - handler() { + handler(_id, importer) { + // An app may also use takumi-js itself, at any version. + if (!importer?.startsWith(`${VOID_PACKAGE_DIR}/`)) { + return; + } const range = readTakumiRange(); const installed = findInstalledPackage('takumi-js', this.environment.config.root); const version = installed && readPackageVersion(installed); @@ -359,10 +363,12 @@ const RUNTIME_NAMES: Record = { deno: 'Deno', }; +const VOID_MANIFEST = findPackageJSON(import.meta.url)!; +const VOID_PACKAGE_DIR = dirname(VOID_MANIFEST); + /** The takumi-js range void/og is built and tested against, from void's package.json. */ function readTakumiRange(): string { - const manifest = findPackageJSON(import.meta.url); - return readPackageManifest(manifest!).dependencies!['takumi-js']!; + return readPackageManifest(VOID_MANIFEST).dependencies!['takumi-js']!; } function readPackageVersion(dir: string): string | undefined { diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index d96a2874e..aae85348b 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -1,10 +1,16 @@ /** - * `void/og` — a `next/og`-compatible `ImageResponse` rendered by Takumi. + * `void/og` — the `ImageResponse` API of `next/og` and `@vercel/og`, rendered + * by Takumi instead of Satori and resvg. * * Takumi does the rendering (Wasm on workerd, its native addon on Node). This - * module only adds what `next/og` callers expect on top: a 1200×630 default - * size, the `cache-control` header, `debug`, and Noto fallback fonts for - * text that Takumi's built-in Geist (ASCII and Latin-1) cannot draw. + * module adds what `@vercel/og` does around its renderer: a 1200×630 default + * size, the response headers, `debug`, and Noto fallback fonts for text that + * Takumi's built-in Geist cannot draw. Comments cite the upstream code: + * - @vercel/og 1.0.3, published only as a bundle whose `// src/…` sections + * name its source files: + * https://cdn.jsdelivr.net/npm/@vercel/og@1.0.3/dist/index.edge.js + * - Satori 0.33.5, the version @vercel/og 1.0.3 bundles. + * - Next.js 16.4.2, whose `next/og` wraps a copy of @vercel/og. */ import { render, type Node, type RenderInput, type RenderOptions } from 'takumi-js'; import { collectCodepoints, googleFonts, type FontSubset } from 'takumi-js/helpers'; @@ -13,7 +19,11 @@ import { fromJsx } from 'takumi-js/helpers/jsx'; export type ImageResponseOptions = RenderOptions & ResponseInit & { - /** Draws a border around every box, like `next/og`'s `debug`. */ + /** + * Draws a border around every box, as `debug` does in Satori, though the lines + * look different: + * https://github.com/vercel/satori/blob/0.33.5/src/builder/rect.ts#L213-L225 + */ debug?: boolean; }; @@ -25,9 +35,16 @@ const CONTENT_TYPES = { raw: 'application/octet-stream', } as const; -// Google Fonts families for text the default font cannot draw, matched by -// script, like the fonts `next/og` downloads. Han text uses the Japanese or -// Korean family when kana or Hangul shows the language. +// Google Fonts families for text the default font cannot draw. Satori hands +// @vercel/og the characters no loaded font has, with the languages their script +// suggests, and @vercel/og downloads each language's Noto family: +// - https://github.com/vercel/satori/blob/0.33.5/src/text/index.ts#L103-L115 +// - https://github.com/vercel/satori/blob/0.33.5/src/language.ts#L24-L45 +// - @vercel/og `// src/language/index.ts`: languageFontMap +// Takumi has no such hook, so these families are matched by script before the +// render. Han text uses the Japanese or Korean family when kana or Hangul shows +// the language, and Simplified Chinese otherwise, where Satori tries Japanese +// first. @vercel/og also has Traditional Chinese, Symbols and Math families. const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ [/[\p{sc=Hiragana}\p{sc=Katakana}]/u, 'Noto Sans JP'], [/\p{sc=Hangul}/u, 'Noto Sans KR'], @@ -46,12 +63,16 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ /** * A `Response` whose body is the image rendered from `element`: React JSX, an - * HTML string, or a Takumi node tree. Defaults match `next/og`: 1200×630 PNG, - * cached for a year outside development. + * HTML string, or a Takumi node tree. Like `@vercel/og`, it renders a 1200×630 + * PNG by default and lets it be cached for a year outside development. */ export class ImageResponse extends Response { constructor(element: RenderInput, options: ImageResponseOptions = {}) { const { status, statusText, headers, debug, ...renderOptions } = options; + // @vercel/og `// src/index.edge.ts`: ImageResponse sets these two headers + // unless the caller does. Next.js 16 sends `public, max-age=0, must-revalidate` + // outside development instead: + // https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L76-L86 const responseHeaders = new Headers(headers); if (!responseHeaders.has('content-type')) { responseHeaders.set('content-type', CONTENT_TYPES[renderOptions.format ?? 'png']); @@ -64,9 +85,12 @@ export class ImageResponse extends Response { : 'public, immutable, no-transform, max-age=31536000', ); } + // Render while the body streams, as @vercel/og does, so a failure errors the + // body: https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L49-L72 const body = new ReadableStream({ async start(controller) { try { + // @vercel/og `// src/og.ts`: render() defaults to 1200×630 without debug. const image = await renderImage(element, { width: 1200, height: 630, @@ -99,7 +123,10 @@ async function renderImage(element: RenderInput, options: RenderOptions): Promis css: [...toList(options.css ?? options.stylesheets), ...css], stylesheets: undefined, }), - ...(fonts && { fonts }), + // Takumi uses every font registered in the process when it gets no list, + // which includes earlier images' fallback fonts: + // https://github.com/kane50613/takumi/blob/takumi@2.16.2/takumi-helpers/src/renderer.ts#L271-L282 + fonts: fonts ?? options.fonts ?? [], }); const fallback = await fallbackFonts(node); if (fallback.length === 0) { @@ -108,8 +135,8 @@ async function renderImage(element: RenderInput, options: RenderOptions): Promis try { return await renderNode([...((await options.fonts) ?? []), ...fallback]); } catch (error) { - // Like `next/og`, a font that fails to download leaves its glyphs missing - // rather than failing the image. + // A font that fails to download leaves its glyphs missing rather than + // failing the image, as in @vercel/og `// src/og.ts`: loadDynamicAsset. if (!(error instanceof FallbackFontError)) { throw error; } @@ -158,9 +185,12 @@ async function fallbackFonts(node: Node): Promise> { console.warn(`void/og: could not load ${families.join(', ')} from Google Fonts.`, error); return []; } - // Registered fonts come before Geist, so keep ASCII and Latin-1 on the - // default font, as `next/og` does: skip each family's Latin subset, and take - // Latin-1 out of the others, which can mix ASCII with common CJK characters. + // Satori draws each character with the first loaded font that has it, and + // fallback fonts come after the default one, so Latin text keeps the default + // font: https://github.com/vercel/satori/blob/0.33.5/src/font.ts#L601-L679 + // Takumi puts registered fonts before its built-in Geist, which covers ASCII + // and Latin-1 but µ, so skip each family's Latin subset and take Latin-1 out + // of the others, which can mix ASCII with common CJK characters. return subsets.flatMap((subset) => { if (subset.unicodeRange.some(([start, end]) => start <= 0x20 && 0x7e <= end)) { return []; diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index a1cdd50b6..f2633cb47 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -1,11 +1,19 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { toSlash } from 'pathslash'; +import { render } from 'takumi-js'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import voidManifest from '../../package.json' with { type: 'json' }; import { nodeTargetPlugin } from '../../src/plugin-node-target.ts'; import { ImageResponse } from '../../src/runtime/og.ts'; +vi.mock('takumi-js', async (importOriginal) => { + const takumi = await importOriginal(); + return { ...takumi, render: vi.fn(takumi.render) }; +}); + const card = '
Hello
'; async function pngSize(response: Response) { @@ -142,6 +150,14 @@ describe('ImageResponse fallback fonts', () => { expect(requests).toEqual([]); }); + it('renders each image with only its own fonts', async () => { + vi.mocked(render).mockClear(); + await new ImageResponse(card).arrayBuffer(); + // Without a font list, takumi uses every font registered in the process, + // such as the fallback fonts of an earlier image. + expect(vi.mocked(render).mock.calls.map(([, options]) => options?.fonts)).toEqual([[]]); + }); + it('keeps Latin text off the Japanese family', async () => { const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch((url) => @@ -203,12 +219,20 @@ describe('void/og on Node.js targets', () => { } }); - function resolveRenderer() { + function resolveRenderer( + importer = toSlash(fileURLToPath(new URL('../../src/runtime/og.ts', import.meta.url))), + ) { const plugin = nodeTargetPlugin('node').find( (candidate) => candidate.name === 'void:node-target-image-renderer', )!; - const resolveId = plugin.resolveId as { handler: (this: unknown, id: string) => unknown }; - return resolveId.handler.call({ environment: { config: { root } } }, 'takumi-js/helpers'); + const resolveId = plugin.resolveId as { + handler: (this: unknown, id: string, importer: string) => unknown; + }; + return resolveId.handler.call( + { environment: { config: { root } } }, + 'takumi-js/helpers', + importer, + ); } // The range void/og declares; the app must install a version in it. @@ -241,4 +265,9 @@ describe('void/og on Node.js targets', () => { installTakumi(range.replace(/^\^/, '')); expect(resolveRenderer()).toBeUndefined(); }); + + it('leaves takumi-js that the app imports itself alone', () => { + installTakumi('3.0.0'); + expect(resolveRenderer(toSlash(join(root!, 'src', 'image.ts')))).toBeUndefined(); + }); }); From f01c06fd3975736e62c6309e0d275e343c26a7a8 Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:48:01 +0000 Subject: [PATCH 05/35] Fix void/og review findings - Load void under Bun again: read void's package.json by path, not `findPackageJSON`, which Bun's `node:module` lacks. - Check the app's takumi-js for void's own imports only, following symlinks, and warn in the dev server instead of failing the build. - Draw Han text as Japanese like next/og unless kana, Hangul or `lang` say otherwise; add Traditional Chinese, math and symbol fallbacks. - Register fallback fonts under private names, so an app's own copy of the family does not change later images. - Keep only the fallback subsets the text uses, extract emoji before choosing fonts, and fetch images once when the render runs twice. - Keep the default size for `width: undefined`, accept Satori's font `lang`, fold `css` with `stylesheets`, and leave render errors to the runtime's report of the failed body. - Docs: Workers Free CPU numbers, WOFF2 only, Next.js 16 cache header. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 19 ++- packages/void/AGENTS.md | 11 +- packages/void/src/plugin-node-target.ts | 41 +++++-- packages/void/src/runtime/og.ts | 148 +++++++++++++++++------- packages/void/test/unit/og.test.ts | 99 +++++++++++++--- 5 files changed, 241 insertions(+), 77 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 38073aaff..a42e2d6a5 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -89,6 +89,7 @@ The second argument sets the image and the response: | `width`, `height` | `1200`, `630` | Image size in pixels. | | `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). | | `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, or `fluentFlat`. | +| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | | `debug` | `false` | Draws a border around every box. | | `format` | `'png'` | Image format: `png`, `jpeg`, or `webp`. | | `status`, `statusText`, `headers` | | The response status and headers. | @@ -102,13 +103,19 @@ Content-Type: image/png Cache-Control: public, immutable, no-transform, max-age=31536000 ``` -During `vp dev`, `Cache-Control` is `no-cache, no-store`. +During `vp dev`, `Cache-Control` is `no-cache, no-store`. Images served from the [revalidation cache](#cache-generated-images) carry Void's cache headers instead. -Emoji and remote `` sources are downloaded while the image renders. +Emoji and remote `` sources are downloaded while the image renders. An emoji that the chosen style does not have fails the image; `noto` has no flags. ## Fonts -Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, or accented letters like Ł and ş, Void downloads the matching Noto Sans font from Google Fonts while rendering. If the download fails, the image renders without those characters. +Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching Noto Sans font from Google Fonts while rendering. If the download fails, those characters are drawn as boxes. + +Chinese characters are drawn in the Japanese style, as in `next/og`, unless the text has Korean or Chinese around them, or `lang` names the language: + +```ts +return new ImageResponse(`
${title}
`); +``` To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data, as in `next/og`: @@ -121,7 +128,7 @@ return new ImageResponse(postCard(post), { }); ``` -Fonts can be TTF, OTF, WOFF, or WOFF2 files. +Fonts can be TTF, OTF, or WOFF2 files. ## Add the image to a page @@ -144,7 +151,7 @@ export const head = defineHead((c, props) => { ## Cache generated images -Each request renders the image again. A 1200 × 630 image takes around 10 ms of CPU time, more for large or text-heavy images, and the first render in a new Worker instance takes around 100 ms. [Workers Free allows 10 ms per request](../reference/config.md#worker) and fails requests from a Worker that keeps exceeding it, so cache image routes with [revalidation](./edge/revalidation.md): +Each request renders the image again. A simple 1200 × 630 card takes under 10 ms of CPU time, one with gradients, bold text, or emoji 30 to 100 ms, and the first render in a new Worker instance about 200 ms more. [Workers Free allows 10 ms per request](../reference/config.md#worker) and fails requests from a Worker that keeps exceeding it, so on Workers Free, cache image routes with [revalidation](./edge/revalidation.md): ```json { @@ -172,6 +179,8 @@ Then: - Move the JSX from `route.tsx` into a `.tsx` module and return the response from a route file, as shown above. - Read the request from Hono's context: `c.req.url`, `c.req.param()`, and `c.req.query()`. - Void draws images with Takumi rather than Satori, so layout can differ slightly. See [Takumi vs. Satori](https://takumi.kane.tw/docs/comparison-to-satori). +- Next.js 16 sends `Cache-Control: public, max-age=0, must-revalidate`; Void lets clients cache the image for a year, like `@vercel/og`. Pass `headers` to change that. +- A font's `lang` is accepted but does not choose the font for a language. Use the `lang` option or attribute for Chinese characters instead. ## Node.js, Bun, and Deno diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 2634247a6..d8442cfb1 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -157,18 +157,17 @@ Vite/CLI dependency graph. `void/remote` stays exported only for semver. Generated code imports `#self/remote`. Remove the subpath in the next major version. -`void/og` is a thin `next/og`-compatible layer over `takumi-js`. In workerd, +`void/og` is a thin `next/og`-compatible layer over `takumi-js`; its source +cites the @vercel/og, Satori and Next.js code each behavior follows. In workerd, Takumi resolves its Wasm backend, whose `.wasm` import the Cloudflare plugin loads as a compiled module in dev and builds. On Node.js, Bun and Deno, Takumi loads its renderer from files next to its code (a native addon on Node.js and Bun), which a bundle cannot carry, so `nodeTargetPlugin` fails a build unless the app root resolves a `takumi-js` in the range void declares. Takumi's JSX helper imports `react` as an optional peer; Vite stubs it in apps without -React, and Takumi catches the failed import. The wrapper converts the element -once and asks Google Fonts for Noto families by the scripts in its text. It -skips each family's Latin subset and takes Latin-1 out of the other subsets, -because Google mixes ASCII into the CJK subsets with the most common -characters, so Latin text keeps Takumi's built-in Geist. +React, and Takumi catches the failed import. Fallback fonts come from Google +Fonts by the scripts in the text, under private family names, with Latin-1 +taken out so Latin text keeps Takumi's built-in Geist. Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index b0f3d50b2..003024d1d 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -1,10 +1,9 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { findPackageJSON } from 'node:module'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import mime from 'mime'; -import { dirname, isAbsolute, join, relative, resolve } from 'pathslash'; +import { dirname, isAbsolute, join, relative, resolve, toSlash } from 'pathslash'; import { isFileServingAllowed, type Plugin } from 'vite'; import { findInstalledPackage, findVoidDependentPackages } from './installed-packages.ts'; import { readProjectPaths } from './project-paths.ts'; @@ -327,15 +326,16 @@ export function nodeTargetPlugin( // native addon on Node.js and Bun) that a bundle cannot carry. A build leaves a package that // resolves from the app root to the runtime, so the app must install a version that void/og // supports. With pnpm, void's own copy does not resolve from the root and would be bundled. + // The dev server resolves it from void, so there the check only warns. + let warned = false; const imageRendererPlugin: Plugin = { name: 'void:node-target-image-renderer', - apply: 'build', enforce: 'pre', resolveId: { filter: { id: /^takumi-js(?:\/|$)/ }, handler(_id, importer) { // An app may also use takumi-js itself, at any version. - if (!importer?.startsWith(`${VOID_PACKAGE_DIR}/`)) { + if (!importer || !realPath(importer).startsWith(`${VOID_PACKAGE_DIR}/`)) { return; } const range = readTakumiRange(); @@ -345,11 +345,17 @@ export function nodeTargetPlugin( return; } const found = version ? ` The app has takumi-js ${version}.` : ''; - throw new Error( + const message = `void/og needs takumi-js@${range} in your app's dependencies with target '${target}', so ` + - `the build leaves it to ${RUNTIME_NAMES[target]}.${found} Install it, for example with ` + - `\`npm install takumi-js@${range}\`.`, - ); + `the build leaves it to ${RUNTIME_NAMES[target]}.${found} Install it, for example with ` + + `\`npm install takumi-js@${range}\`.`; + if (this.environment.config.command === 'build') { + throw new Error(message); + } + if (!warned) { + warned = true; + this.warn(message); + } }, }, }; @@ -363,9 +369,19 @@ const RUNTIME_NAMES: Record = { deno: 'Deno', }; -const VOID_MANIFEST = findPackageJSON(import.meta.url)!; +// This module is one directory below the package root in src/ and in dist/. +const VOID_MANIFEST = fileURLToPath(new URL('../package.json', import.meta.url)); const VOID_PACKAGE_DIR = dirname(VOID_MANIFEST); +/** Vite resolves symlinks in importer paths unless `resolve.preserveSymlinks` is set. */ +function realPath(path: string): string { + try { + return toSlash(realpathSync(path)); + } catch { + return path; + } +} + /** The takumi-js range void/og is built and tested against, from void's package.json. */ function readTakumiRange(): string { return readPackageManifest(VOID_MANIFEST).dependencies!['takumi-js']!; @@ -384,7 +400,8 @@ function readPackageManifest(path: string): { /** Whether `version` is in the caret `range` (a major version above 0, as takumi-js uses). */ function satisfiesCaret(version: string, range: string): boolean { - const parse = (value: string) => /^\^?(\d+)\.(\d+)\.(\d+)/.exec(value)?.slice(1).map(Number); + const parse = (value: string) => /^\^?(\d+)\.(\d+)\.(\d+)$/.exec(value)?.slice(1).map(Number); + // A prerelease is outside a caret range. const actual = parse(version); const minimum = parse(range); if (!actual || !minimum || actual[0] !== minimum[0]) { diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index aae85348b..052a1fdc8 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -12,12 +12,33 @@ * - Satori 0.33.5, the version @vercel/og 1.0.3 bundles. * - Next.js 16.4.2, whose `next/og` wraps a copy of @vercel/og. */ -import { render, type Node, type RenderInput, type RenderOptions } from 'takumi-js'; -import { collectCodepoints, googleFonts, type FontSubset } from 'takumi-js/helpers'; +import { + render, + type FontDetails, + type FontLoader, + type Node, + type RenderInput, + type RenderOptions, +} from 'takumi-js'; +import { collectCodepoints, googleFonts, subsetFonts, type FontSubset } from 'takumi-js/helpers'; +import { extractEmojis } from 'takumi-js/helpers/emoji'; import { fromHtml } from 'takumi-js/helpers/html'; import { fromJsx } from 'takumi-js/helpers/jsx'; -export type ImageResponseOptions = RenderOptions & +/** Takumi's options without `fonts`; a plain `Omit` would merge its format variants. */ +type RenderOptionsWithoutFonts = RenderOptions extends infer T + ? T extends object + ? Omit + : never + : never; + +/** + * A font as Takumi loads it, or as `next/og` types it, with Satori's `lang`: + * https://github.com/vercel/satori/blob/0.33.5/src/font.ts#L137-L143 + */ +type Font = FontLoader | (FontDetails & { lang?: string }); + +export type ImageResponseOptions = RenderOptionsWithoutFonts & ResponseInit & { /** * Draws a border around every box, as `debug` does in Satori, though the lines @@ -25,6 +46,13 @@ export type ImageResponseOptions = RenderOptions & * https://github.com/vercel/satori/blob/0.33.5/src/builder/rect.ts#L213-L225 */ debug?: boolean; + /** + * Fonts to draw text with. A font's `lang`, which Satori reads to prefer the + * font for that language, is ignored: Takumi takes the first font with the + * glyph. The `lang` option, or attribute on the root element, chooses the + * CJK fallback font instead. + */ + fonts?: Array | Promise>; }; const CONTENT_TYPES = { @@ -42,13 +70,10 @@ const CONTENT_TYPES = { // - https://github.com/vercel/satori/blob/0.33.5/src/language.ts#L24-L45 // - @vercel/og `// src/language/index.ts`: languageFontMap // Takumi has no such hook, so these families are matched by script before the -// render. Han text uses the Japanese or Korean family when kana or Hangul shows -// the language, and Simplified Chinese otherwise, where Satori tries Japanese -// first. @vercel/og also has Traditional Chinese, Symbols and Math families. +// render. `cjkFamily` settles the first one. const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ - [/[\p{sc=Hiragana}\p{sc=Katakana}]/u, 'Noto Sans JP'], + [/[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana} -〿＀-￯]/u, 'Noto Sans JP'], [/\p{sc=Hangul}/u, 'Noto Sans KR'], - [/\p{sc=Han}/u, 'Noto Sans SC'], [/\p{sc=Thai}/u, 'Noto Sans Thai'], [/\p{sc=Arabic}/u, 'Noto Sans Arabic'], [/\p{sc=Hebrew}/u, 'Noto Sans Hebrew'], @@ -58,7 +83,9 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ [/\p{sc=Telugu}/u, 'Noto Sans Telugu'], [/\p{sc=Kannada}/u, 'Noto Sans Kannada'], [/\p{sc=Malayalam}/u, 'Noto Sans Malayalam'], - [/[\p{sc=Cyrillic}\p{sc=Greek}\u0100-\u024F\u1E00-\u1EFF]/u, 'Noto Sans'], + [/[\p{sc=Cyrillic}\p{sc=Greek}Ā-ɏḀ-ỿ]/u, 'Noto Sans'], + [/\p{Sm}/u, 'Noto Sans Math'], + [/\p{So}/u, 'Noto Sans Symbols 2'], ]; /** @@ -69,9 +96,9 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ export class ImageResponse extends Response { constructor(element: RenderInput, options: ImageResponseOptions = {}) { const { status, statusText, headers, debug, ...renderOptions } = options; - // @vercel/og `// src/index.edge.ts`: ImageResponse sets these two headers - // unless the caller does. Next.js 16 sends `public, max-age=0, must-revalidate` - // outside development instead: + // next/og sets these two headers unless the caller's `headers` has them + // (@vercel/og itself spreads the caller's object over its defaults). Next.js + // 16 sends `public, max-age=0, must-revalidate` outside development instead: // https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L76-L86 const responseHeaders = new Headers(headers); if (!responseHeaders.has('content-type')) { @@ -86,21 +113,21 @@ export class ImageResponse extends Response { ); } // Render while the body streams, as @vercel/og does, so a failure errors the - // body: https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L49-L72 + // body, which the runtime reports: + // https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L49-L72 const body = new ReadableStream({ async start(controller) { try { // @vercel/og `// src/og.ts`: render() defaults to 1200×630 without debug. const image = await renderImage(element, { - width: 1200, - height: 630, - drawDebugBorder: debug, ...renderOptions, + width: renderOptions.width ?? 1200, + height: renderOptions.height ?? 630, + drawDebugBorder: renderOptions.drawDebugBorder ?? debug, }); controller.enqueue(image); controller.close(); } catch (error) { - console.error('void/og: failed to render the image.', error); controller.error(error); } }, @@ -109,31 +136,40 @@ export class ImageResponse extends Response { } } +type RenderImageOptions = RenderOptionsWithoutFonts & { fonts?: ImageResponseOptions['fonts'] }; + class FallbackFontError extends Error {} -async function renderImage(element: RenderInput, options: RenderOptions): Promise { +async function renderImage(element: RenderInput, options: RenderImageOptions): Promise { // Convert once, here: the fallback fonts depend on the final text, and - // function components must not run twice. - const { node, css } = await toNode(element, options.jsx); - const renderNode = (fonts?: RenderOptions['fonts']) => + // function components must not run twice. Emoji become images first, as in + // the render, so their code points ask for no symbol font. + const { node: tree, css } = await toNode(element, options.jsx); + const node = + options.emoji === 'from-font' ? tree : extractEmojis(tree, options.emoji ?? 'twemoji'); + // Remote images and emoji are fetched once, even when the render runs twice. + const fetchCache = new Map>(); + const renderNode = (fonts: Array) => render(node, { ...options, // takumi rejects `css` beside its deprecated `stylesheets`, so fold them. - ...(css.length > 0 && { - css: [...toList(options.css ?? options.stylesheets), ...css], - stylesheets: undefined, - }), + css: [...toList(options.css), ...toList(options.stylesheets), ...css], + stylesheets: undefined, + images: Array.isArray(options.images) + ? { sources: options.images, fetchCache } + : { fetchCache, ...options.images }, // Takumi uses every font registered in the process when it gets no list, // which includes earlier images' fallback fonts: // https://github.com/kane50613/takumi/blob/takumi@2.16.2/takumi-helpers/src/renderer.ts#L271-L282 - fonts: fonts ?? options.fonts ?? [], + fonts, }); - const fallback = await fallbackFonts(node); + const fallback = await fallbackFonts(node, options); + const fonts = ((await options.fonts) ?? []).map(withoutLang); if (fallback.length === 0) { - return renderNode(); + return renderNode(fonts); } try { - return await renderNode([...((await options.fonts) ?? []), ...fallback]); + return await renderNode([...fonts, ...fallback]); } catch (error) { // A font that fails to download leaves its glyphs missing rather than // failing the image, as in @vercel/og `// src/og.ts`: loadDynamicAsset. @@ -141,7 +177,7 @@ async function renderImage(element: RenderInput, options: RenderOptions): Promis throw error; } console.warn(`${error.message} Rendering without it.`, error.cause); - return renderNode(); + return renderNode(fonts); } } @@ -167,22 +203,49 @@ function toList(value: T | ReadonlyArray | undefined): Array { return Array.isArray(value) ? [...value] : [value as T]; } -async function fallbackFonts(node: Node): Promise> { +function withoutLang(font: Font): FontLoader { + if (typeof font === 'object' && 'lang' in font) { + const { lang: _lang, ...rest } = font; + return rest; + } + return font; +} + +// Satori's ja-JP code takes Han and comes first, so Han text is Japanese there +// unless an element's `lang` moves another language first: +// https://github.com/vercel/satori/blob/0.33.5/src/language.ts#L57-L84 +function cjkFamily(text: string, lang = ''): string { + if (/[\p{sc=Hiragana}\p{sc=Katakana}]/u.test(text)) { + return 'Noto Sans JP'; + } + const tag = lang.toLowerCase(); + if (tag.startsWith('ko') || /\p{sc=Hangul}/u.test(text)) { + return 'Noto Sans KR'; + } + if (tag.startsWith('zh')) { + return /hant|-tw|-hk|-mo/.test(tag) ? 'Noto Sans TC' : 'Noto Sans SC'; + } + return 'Noto Sans JP'; +} + +async function fallbackFonts(node: Node, options: RenderImageOptions): Promise> { const text = Array.from(collectCodepoints(node), (codepoint) => String.fromCodePoint(codepoint), ).join(''); - let families = FALLBACK_FONTS.filter(([script]) => script.test(text)).map(([, family]) => family); - if (families.includes('Noto Sans JP') || families.includes('Noto Sans KR')) { - families = families.filter((family) => family !== 'Noto Sans SC'); - } - if (families.length === 0) { + const families = new Set( + FALLBACK_FONTS.filter(([script]) => script.test(text)).map(([, family]) => + family === 'Noto Sans JP' ? cjkFamily(text, options.lang ?? node.lang) : family, + ), + ); + if (families.size === 0) { return []; } + options.signal?.throwIfAborted(); let subsets: Array; try { - subsets = await googleFonts(families); + subsets = await googleFonts([...families]); } catch (error) { - console.warn(`void/og: could not load ${families.join(', ')} from Google Fonts.`, error); + console.warn(`void/og: could not load ${[...families].join(', ')} from Google Fonts.`, error); return []; } // Satori draws each character with the first loaded font that has it, and @@ -191,7 +254,7 @@ async function fallbackFonts(node: Node): Promise> { // Takumi puts registered fonts before its built-in Geist, which covers ASCII // and Latin-1 but µ, so skip each family's Latin subset and take Latin-1 out // of the others, which can mix ASCII with common CJK characters. - return subsets.flatMap((subset) => { + const stripped = subsets.flatMap((subset) => { if (subset.unicodeRange.some(([start, end]) => start <= 0x20 && 0x7e <= end)) { return []; } @@ -205,12 +268,19 @@ async function fallbackFonts(node: Node): Promise> { return { ...subset, unicodeRange, + // Private names: Takumi keeps fonts per process, so the app's own copy + // of the family, Latin glyphs included, must not join this one. + name: `void/og ${subset.name}`, + key: `void/og ${subset.key}`, data: () => subset.data().catch((cause: unknown) => { throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause }); }), }; }); + // Takumi downloads the subsets of its list-marker sample as well, so keep + // only those the text uses. + return subsetFonts({ fonts: stripped, source: node }); } function isDevelopment(): boolean { diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index f2633cb47..3f04da55c 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -33,6 +33,14 @@ const NOTO_SANS_JP_CSS = `/* [0] */ src: url(https://fonts.gstatic.com/noto-sans-jp/kana.woff2) format('woff2'); unicode-range: U+3040-309F, U+4E16, U+754C; } +/* [5] */ +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/noto-sans-jp/unused.woff2) format('woff2'); + unicode-range: U+4E00-4E0F; +} /* [118] */ @font-face { font-family: 'Noto Sans JP'; @@ -119,27 +127,31 @@ describe('ImageResponse', () => { expect([...bytes.subarray(0, 2)]).toEqual([0xff, 0xd8]); }); + it('keeps the default size for an undefined width or height', async () => { + const response = new ImageResponse(card, { width: undefined, height: undefined }); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + }); + it('draws box borders with debug', async () => { const plain = new Uint8Array(await new ImageResponse(card).arrayBuffer()); const debug = new Uint8Array(await new ImageResponse(card, { debug: true }).arrayBuffer()); expect(debug).not.toEqual(plain); }); - it('accepts the deprecated stylesheets option beside a style element', async () => { - // takumi warns that the option is deprecated. - vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('accepts css and the deprecated stylesheets option beside a style element', async () => { const response = new ImageResponse( '
Hi
', - { stylesheets: ['.card { color: #fff }'] }, + { css: '.card { padding: 8px }', stylesheets: ['.card { color: #fff }'] }, ); expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); }); it('errors the body when the image cannot render', async () => { - const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); - const response = new ImageResponse(card, { fonts: [new Uint8Array([1, 2, 3])] }); + // A next/og font entry may carry Satori's `lang`. + const response = new ImageResponse(card, { + fonts: [{ name: 'Broken', data: new Uint8Array([1, 2, 3]), weight: 400, lang: 'ja-JP' }], + }); await expect(response.arrayBuffer()).rejects.toThrow(); - expect(logged).toHaveBeenCalledWith('void/og: failed to render the image.', expect.anything()); }); }); @@ -171,7 +183,8 @@ describe('ImageResponse fallback fonts', () => { const css = new URL(requests[0]!); expect(css.origin + css.pathname).toBe('https://fonts.googleapis.com/css2'); expect(css.searchParams.getAll('family')).toEqual(['Noto Sans JP:wght@400']); - // The subset that mixes ASCII with 日 still serves 日; the Latin subset is never fetched. + // The subset that mixes ASCII with 日 still serves 日; the Latin subset and + // the subset the text does not use are never fetched. expect(requests.slice(1).sort()).toEqual([ 'https://fonts.gstatic.com/noto-sans-jp/common.woff2', 'https://fonts.gstatic.com/noto-sans-jp/kana.woff2', @@ -189,11 +202,44 @@ describe('ImageResponse fallback fonts', () => { expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Noto Sans:wght@400']); }); - it('uses the Simplified Chinese family for Han text without kana or Hangul', async () => { + it('draws Han text as Japanese unless lang says otherwise, like next/og', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch(() => new Response(null, { status: 503 })); - await new ImageResponse('
你好世界
').arrayBuffer(); - expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Noto Sans SC:wght@400']); + const family = async (html: string, lang?: string) => { + requests.length = 0; + await new ImageResponse(html, { lang }).arrayBuffer(); + return new URL(requests[0]!).searchParams.getAll('family'); + }; + expect(await family('
東京都
')).toEqual([ + 'Noto Sans JP:wght@400', + ]); + expect(await family('
東京都
', 'zh-TW')).toEqual([ + 'Noto Sans TC:wght@400', + ]); + expect(await family('
东京都
')).toEqual([ + 'Noto Sans SC:wght@400', + ]); + expect(await family('
漢字 한국어
')).toEqual([ + 'Noto Sans KR:wght@400', + ]); + }); + + it('uses the symbol and math families for characters Geist lacks', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch((url) => + url.endsWith('.svg') + ? new Response('', { + headers: { 'content-type': 'image/svg+xml' }, + }) + : new Response(null, { status: 503 }), + ); + await new ImageResponse('
A → B ★ 🎉
').arrayBuffer(); + // The emoji is an image, so it asks for no font. + const fonts = requests.filter((url) => url.startsWith('https://fonts.googleapis.com/')); + expect(new URL(fonts[0]!).searchParams.getAll('family')).toEqual([ + 'Noto Sans Math:wght@400', + 'Noto Sans Symbols 2:wght@400', + ]); }); it('renders without fallback fonts when Google Fonts cannot be reached', async () => { @@ -219,21 +265,27 @@ describe('void/og on Node.js targets', () => { } }); + function rendererPlugin() { + return nodeTargetPlugin('node').find( + (candidate) => candidate.name === 'void:node-target-image-renderer', + )!; + } + function resolveRenderer( importer = toSlash(fileURLToPath(new URL('../../src/runtime/og.ts', import.meta.url))), + plugin = rendererPlugin(), ) { - const plugin = nodeTargetPlugin('node').find( - (candidate) => candidate.name === 'void:node-target-image-renderer', - )!; const resolveId = plugin.resolveId as { handler: (this: unknown, id: string, importer: string) => unknown; }; return resolveId.handler.call( - { environment: { config: { root } } }, + { environment: { config: { root, command } }, warn }, 'takumi-js/helpers', importer, ); } + let command = 'build'; + const warn = vi.fn(); // The range void/og declares; the app must install a version in it. const range = voidManifest.dependencies['takumi-js']; @@ -259,6 +311,23 @@ describe('void/og on Node.js targets', () => { expect(resolveRenderer).toThrow('The app has takumi-js 2.15.2.'); installTakumi('3.0.0'); expect(resolveRenderer).toThrow('The app has takumi-js 3.0.0.'); + installTakumi('2.17.0-beta.1'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.17.0-beta.1.'); + }); + + it('warns once in the dev server instead', () => { + root = mkdtempSync(join(tmpdir(), 'void-og-node-')); + const plugin = rendererPlugin(); + command = 'serve'; + warn.mockClear(); + try { + expect(resolveRenderer(undefined, plugin)).toBeUndefined(); + expect(resolveRenderer(undefined, plugin)).toBeUndefined(); + } finally { + command = 'build'; + } + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]![0]).toContain(`npm install takumi-js@${range}`); }); it("leaves a supported takumi-js in the app's dependencies to Node.js", () => { From c87fc1f27d28e230452d444d3e075648ba6847af Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:52:58 +0000 Subject: [PATCH 06/35] Cover void/og's documented failure paths and fix the guide - Integration: the Node target build fails without takumi-js and prints the install command; a Cloudflare app without React renders HTML. - Unit: emoji render as images without a font, a font's `lang` is dropped before takumi sees it, and no case reaches the network. The Google Fonts stubs answer 404, which takumi does not retry. - Guide: `from-font` and `ico` in the options table, the root-only `lang` attribute, the Props type in the head() example, and that passing `fonts` does not stop the Noto download. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 26 ++++--- .../void/test/integration/og-image.test.ts | 70 +++++++++++++------ packages/void/test/unit/og.test.ts | 35 ++++++++-- test/integration-timings.ts | 2 +- 4 files changed, 93 insertions(+), 40 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index a42e2d6a5..d7a38a262 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -84,15 +84,15 @@ export const GET = defineHandler(async (c) => { The second argument sets the image and the response: -| Option | Default | Description | -| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- | -| `width`, `height` | `1200`, `630` | Image size in pixels. | -| `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). | -| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, or `fluentFlat`. | -| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | -| `debug` | `false` | Draws a border around every box. | -| `format` | `'png'` | Image format: `png`, `jpeg`, or `webp`. | -| `status`, `statusText`, `headers` | | The response status and headers. | +| Option | Default | Description | +| --------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `width`, `height` | `1200`, `630` | Image size in pixels. | +| `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). | +| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, `fluentFlat`, or `from-font` to draw emoji with the fonts. | +| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | +| `debug` | `false` | Draws a border around every box. | +| `format` | `'png'` | Image format: `png`, `jpeg`, `webp`, or `ico`. | +| `status`, `statusText`, `headers` | | The response status and headers. | Takumi's other render options, such as `devicePixelRatio`, are passed through. See [Takumi's reference](https://takumi.kane.tw/docs/reference). @@ -109,9 +109,9 @@ Emoji and remote `` sources are downloaded while the image renders. An emoj ## Fonts -Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching Noto Sans font from Google Fonts while rendering. If the download fails, those characters are drawn as boxes. +Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching Noto Sans font from Google Fonts while rendering, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. -Chinese characters are drawn in the Japanese style, as in `next/og`, unless the text has Korean or Chinese around them, or `lang` names the language: +Chinese characters are drawn in the Japanese style, as in `next/og`, unless the text has Korean around them, or the `lang` option or the root element's `lang` attribute names the language: ```ts return new ImageResponse(`
${title}
`); @@ -138,6 +138,10 @@ Point an `og:image` tag at the route from your page's [`head()`](./pages-routing // pages/posts/[slug].server.ts import { defineHead } from 'void'; +export interface Props { + post: { title: string; slug: string }; +} + export const head = defineHead((c, props) => { return { title: props.post.title, diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts index 9e9ff44f0..94cef1eb3 100644 --- a/packages/void/test/integration/og-image.test.ts +++ b/packages/void/test/integration/og-image.test.ts @@ -76,17 +76,41 @@ export default defineConfig({ plugins: [voidPlugin()] }); return root; } -function build(root: string) { - const result = spawn.sync('vp', ['build'], { +function runBuild(root: string) { + return spawn.sync('vp', ['build'], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 10 * 1024 * 1024, env: { ...userEnv, VOID_INSPECTOR_PORT: 'false' }, }); +} + +function build(root: string) { + const result = runBuild(root); expect(result.status, `${result.stdout ?? ''}${result.stderr ?? ''}`).toBe(0); } +async function renderBuilt(root: string, path: string, cacheControl: string) { + const output = join(root, 'dist/ssr'); + const config = JSON.parse(readFileSync(join(output, 'wrangler.json'), 'utf8')); + const modules = collectPrerenderWorkerModules(output, config.main); + expect(modules.filter((module) => module.type === 'CompiledWasm')).toHaveLength(1); + const runtime = new Miniflare( + convertV4MiniflareOptions({ + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + modules, + modulesRoot: output, + }), + ) as unknown as Runtime; + try { + await expectImage(await runtime.dispatchFetch(`http://localhost${path}`), cacheControl); + } finally { + await runtime.dispose(); + } +} + async function expectImage(response: Response, cacheControl: string) { expect(response.status, response.ok ? undefined : await response.text()).toBe(200); expect(response.headers.get('content-type')).toBe('image/png'); @@ -155,31 +179,23 @@ describe('void/og in a Cloudflare app', () => { }); describe('build', () => { - let runtime: Runtime | undefined; - afterAll(async () => { - await runtime?.dispose(); - }); - it('renders HTML and JSX in the built Worker', async () => { build(root); - const output = join(root, 'dist/ssr'); - const config = JSON.parse(readFileSync(join(output, 'wrangler.json'), 'utf8')); - const modules = collectPrerenderWorkerModules(output, config.main); - expect(modules.filter((module) => module.type === 'CompiledWasm')).toHaveLength(1); - runtime = new Miniflare( - convertV4MiniflareOptions({ - compatibilityDate: config.compatibility_date, - compatibilityFlags: config.compatibility_flags, - modules, - modulesRoot: output, - }), - ) as unknown as Runtime; - await expectImage(await runtime.dispatchFetch('http://localhost/api/og'), BUILD_HEADER); - await expectImage(await runtime.dispatchFetch('http://localhost/api/card'), BUILD_HEADER); + await renderBuilt(root, '/api/og', BUILD_HEADER); + await renderBuilt(root, '/api/card', BUILD_HEADER); }); }); }); +describe('void/og in a Cloudflare app without React', () => { + it('renders HTML in the built Worker', async () => { + // Takumi's JSX helper imports React as an optional peer; the HTML route needs none. + const root = createApp({ 'routes/api/og.ts': OG_ROUTE }, {}); + build(root); + await renderBuilt(root, '/api/og', BUILD_HEADER); + }); +}); + describe('a Cloudflare app without void/og', () => { it('bundles no image renderer', () => { const root = createApp({ 'routes/api/hello.ts': "export const GET = () => 'hello';\n" }, {}); @@ -223,4 +239,16 @@ describe('void/og on the Node.js target', () => { }, 20_000); await expectImage(await fetch(`${server.base}/api/og`), BUILD_HEADER); }); + + it('fails the build without takumi-js', () => { + const root = createApp( + { 'routes/api/og.ts': OG_ROUTE }, + { '@hono/node-server': 'playground/node-app/node_modules/@hono/node-server' }, + `{ target: 'node' }`, + ); + const result = runBuild(root); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('void/og needs takumi-js@^'); + expect(result.stderr).toContain('npm install takumi-js@^'); + }); }); diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 3f04da55c..10d89bdeb 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { toSlash } from 'pathslash'; import { render } from 'takumi-js'; -import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import voidManifest from '../../package.json' with { type: 'json' }; import { nodeTargetPlugin } from '../../src/plugin-node-target.ts'; import { ImageResponse } from '../../src/runtime/og.ts'; @@ -72,6 +72,13 @@ function stubFetch(handler: (url: string) => Response | Promise) { return requests; } +// Only the cases below that stub fetch may reach the network. +beforeEach(() => { + stubFetch(() => { + throw new Error('unexpected fetch'); + }); +}); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -147,17 +154,21 @@ describe('ImageResponse', () => { }); it('errors the body when the image cannot render', async () => { - // A next/og font entry may carry Satori's `lang`. + const data = new Uint8Array([1, 2, 3]); + // A next/og font entry may carry Satori's `lang`, which takumi does not take. const response = new ImageResponse(card, { - fonts: [{ name: 'Broken', data: new Uint8Array([1, 2, 3]), weight: 400, lang: 'ja-JP' }], + fonts: [{ name: 'Broken', data, weight: 400, lang: 'ja-JP' }], }); await expect(response.arrayBuffer()).rejects.toThrow(); + expect(vi.mocked(render).mock.lastCall?.[1]?.fonts).toEqual([ + { name: 'Broken', data, weight: 400 }, + ]); }); }); describe('ImageResponse fallback fonts', () => { it('requests no font for Latin text', async () => { - const requests = stubFetch(() => new Response(null, { status: 500 })); + const requests = stubFetch(() => new Response(null, { status: 404 })); await new ImageResponse(card).arrayBuffer(); expect(requests).toEqual([]); }); @@ -197,14 +208,14 @@ describe('ImageResponse fallback fonts', () => { it('uses Noto Sans for accented Latin letters that Geist lacks', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - const requests = stubFetch(() => new Response(null, { status: 503 })); + const requests = stubFetch(() => new Response(null, { status: 404 })); await new ImageResponse('
Łódź
').arrayBuffer(); expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Noto Sans:wght@400']); }); it('draws Han text as Japanese unless lang says otherwise, like next/og', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - const requests = stubFetch(() => new Response(null, { status: 503 })); + const requests = stubFetch(() => new Response(null, { status: 404 })); const family = async (html: string, lang?: string) => { requests.length = 0; await new ImageResponse(html, { lang }).arrayBuffer(); @@ -224,6 +235,16 @@ describe('ImageResponse fallback fonts', () => { ]); }); + it('draws emoji as images, without a font', async () => { + const requests = stubFetch(() => new Response('')); + expect(await pngSize(new ImageResponse('
🎉
'))).toEqual({ + width: 1200, + height: 630, + }); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatch(/^https:\/\/cdn\.jsdelivr\.net\/.*\.svg$/); + }); + it('uses the symbol and math families for characters Geist lacks', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch((url) => @@ -231,7 +252,7 @@ describe('ImageResponse fallback fonts', () => { ? new Response('', { headers: { 'content-type': 'image/svg+xml' }, }) - : new Response(null, { status: 503 }), + : new Response(null, { status: 404 }), ); await new ImageResponse('
A → B ★ 🎉
').arrayBuffer(); // The emoji is an image, so it asks for no font. diff --git a/test/integration-timings.ts b/test/integration-timings.ts index d0539cd52..1b9b458eb 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -29,7 +29,7 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/node-static.test.ts': 13667, 'packages/void/test/integration/node-target.test.ts': 10833, 'packages/void/test/integration/nuxt.test.ts': 43000, - 'packages/void/test/integration/og-image.test.ts': 22000, + 'packages/void/test/integration/og-image.test.ts': 40000, 'packages/void/test/integration/operator-cli.test.ts': 4910, 'packages/void/test/integration/pages-target-assets.test.ts': 10966, 'packages/void/test/integration/pg-api-test.test.ts': 11000, From d5fa8e9c92ba585f2fa61bf75d255702d4544ba3 Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:31:47 +0000 Subject: [PATCH 07/35] Keep Latin text on Geist and cache emoji in void/og MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A Google Fonts face without a unicode-range, such as Noto Sans Math, covered every character and drew the Latin text of any image with a math symbol. Treat it as a whole font above Latin-1. - Detect fallback scripts only among characters the built-in Geist lacks, so `C++`, `©` or typographic quotes make no request, and µ gets a font. Accented Latin and Cyrillic come from Geist's own Google Fonts subsets; `Noto Sans Symbols` and `Noto Sans` cover number forms, currency, Greek and combining marks, as @vercel/og's `unknown` does. - Request each family's CSS separately so takumi's cache serves it, and forward `images.fetch` and `images.timeout` to the font downloads. - Key a font given as bytes by its content: Takumi keeps every registered font, so one read per request would grow without bound. - Keep emoji images for the life of the isolate, as @vercel/og does. - Send `public, max-age=0, must-revalidate` outside development, as Next.js 16 does: a failed render is still a complete response, which a year-long cache would keep. Log the failure again. - Drop Takumi's deprecated `stylesheets` from the public options, and read package manifests through installed-packages.ts. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 8 +- packages/void/src/installed-packages.ts | 3 +- packages/void/src/plugin-node-target.ts | 32 ++- packages/void/src/runtime/og.ts | 194 ++++++++++++------ .../void/test/integration/og-image.test.ts | 2 +- packages/void/test/unit/og.test.ts | 76 +++++-- 6 files changed, 215 insertions(+), 100 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index d7a38a262..0474ed42b 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -92,6 +92,7 @@ The second argument sets the image and the response: | `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | | `debug` | `false` | Draws a border around every box. | | `format` | `'png'` | Image format: `png`, `jpeg`, `webp`, or `ico`. | +| `images` | | Limits for remote `` sources: `allowUrl`, `maxBytes`, and `timeout`. Set them when the markup includes request values. | | `status`, `statusText`, `headers` | | The response status and headers. | Takumi's other render options, such as `devicePixelRatio`, are passed through. See [Takumi's reference](https://takumi.kane.tw/docs/reference). @@ -100,16 +101,16 @@ Responses have these headers unless you set them in `headers`: ```http Content-Type: image/png -Cache-Control: public, immutable, no-transform, max-age=31536000 +Cache-Control: public, max-age=0, must-revalidate ``` -During `vp dev`, `Cache-Control` is `no-cache, no-store`. Images served from the [revalidation cache](#cache-generated-images) carry Void's cache headers instead. +During `vp dev`, `Cache-Control` is `no-cache, no-store`. Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy. Emoji and remote `` sources are downloaded while the image renders. An emoji that the chosen style does not have fails the image; `noto` has no flags. ## Fonts -Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching Noto Sans font from Google Fonts while rendering, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. +Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching font from Google Fonts while rendering, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. Chinese characters are drawn in the Japanese style, as in `next/og`, unless the text has Korean around them, or the `lang` option or the root element's `lang` attribute names the language: @@ -183,7 +184,6 @@ Then: - Move the JSX from `route.tsx` into a `.tsx` module and return the response from a route file, as shown above. - Read the request from Hono's context: `c.req.url`, `c.req.param()`, and `c.req.query()`. - Void draws images with Takumi rather than Satori, so layout can differ slightly. See [Takumi vs. Satori](https://takumi.kane.tw/docs/comparison-to-satori). -- Next.js 16 sends `Cache-Control: public, max-age=0, must-revalidate`; Void lets clients cache the image for a year, like `@vercel/og`. Pass `headers` to change that. - A font's `lang` is accepted but does not choose the font for a language. Use the `lang` option or attribute for Chinese characters instead. ## Node.js, Bun, and Deno diff --git a/packages/void/src/installed-packages.ts b/packages/void/src/installed-packages.ts index 9ea6cdb84..1bec474c2 100644 --- a/packages/void/src/installed-packages.ts +++ b/packages/void/src/installed-packages.ts @@ -5,6 +5,7 @@ import { parseSync } from 'vite'; import { staticModuleReferences } from './shared/module-references.ts'; type PackageManifest = { + readonly version?: string; readonly type?: string; readonly module?: unknown; readonly main?: unknown; @@ -15,7 +16,7 @@ type PackageManifest = { readonly peerDependencies?: Record; }; -function readManifest(dir: string): PackageManifest | undefined { +export function readManifest(dir: string): PackageManifest | undefined { try { return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as PackageManifest; } catch { diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index 003024d1d..a5ee6383d 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -1,11 +1,15 @@ -import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { fileURLToPath, pathToFileURL } from 'node:url'; import mime from 'mime'; import { dirname, isAbsolute, join, relative, resolve, toSlash } from 'pathslash'; import { isFileServingAllowed, type Plugin } from 'vite'; -import { findInstalledPackage, findVoidDependentPackages } from './installed-packages.ts'; +import { + findInstalledPackage, + findVoidDependentPackages, + readManifest, +} from './installed-packages.ts'; import { readProjectPaths } from './project-paths.ts'; /** Imports that require CF bindings and are not available in Node.js mode. */ @@ -340,7 +344,7 @@ export function nodeTargetPlugin( } const range = readTakumiRange(); const installed = findInstalledPackage('takumi-js', this.environment.config.root); - const version = installed && readPackageVersion(installed); + const version = installed && readManifest(installed)?.version; if (version && satisfiesCaret(version, range)) { return; } @@ -370,8 +374,7 @@ const RUNTIME_NAMES: Record = { }; // This module is one directory below the package root in src/ and in dist/. -const VOID_MANIFEST = fileURLToPath(new URL('../package.json', import.meta.url)); -const VOID_PACKAGE_DIR = dirname(VOID_MANIFEST); +const VOID_PACKAGE_DIR = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); /** Vite resolves symlinks in importer paths unless `resolve.preserveSymlinks` is set. */ function realPath(path: string): string { @@ -384,18 +387,13 @@ function realPath(path: string): string { /** The takumi-js range void/og is built and tested against, from void's package.json. */ function readTakumiRange(): string { - return readPackageManifest(VOID_MANIFEST).dependencies!['takumi-js']!; -} - -function readPackageVersion(dir: string): string | undefined { - return readPackageManifest(join(dir, 'package.json')).version; -} - -function readPackageManifest(path: string): { - version?: string; - dependencies?: Record; -} { - return JSON.parse(readFileSync(path, 'utf8')); + const range = readManifest(VOID_PACKAGE_DIR)?.dependencies?.['takumi-js']; + if (!range || !/^\^\d+\.\d+\.\d+$/.test(range)) { + throw new Error( + `void/og: expected a caret takumi-js range in void's package.json, got ${range}.`, + ); + } + return range; } /** Whether `version` is in the caret `range` (a major version above 0, as takumi-js uses). */ diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index 052a1fdc8..eec06f837 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -4,8 +4,8 @@ * * Takumi does the rendering (Wasm on workerd, its native addon on Node). This * module adds what `@vercel/og` does around its renderer: a 1200×630 default - * size, the response headers, `debug`, and Noto fallback fonts for text that - * Takumi's built-in Geist cannot draw. Comments cite the upstream code: + * size, the response headers, `debug`, and Google Fonts fallbacks for text + * that Takumi's built-in Geist cannot draw. Comments cite the upstream code: * - @vercel/og 1.0.3, published only as a bundle whose `// src/…` sections * name its source files: * https://cdn.jsdelivr.net/npm/@vercel/og@1.0.3/dist/index.edge.js @@ -20,15 +20,21 @@ import { type RenderInput, type RenderOptions, } from 'takumi-js'; -import { collectCodepoints, googleFonts, subsetFonts, type FontSubset } from 'takumi-js/helpers'; +import { + collectCodepoints, + googleFonts, + subsetFonts, + type FontSubset, + type ImageFetchCache, +} from 'takumi-js/helpers'; import { extractEmojis } from 'takumi-js/helpers/emoji'; import { fromHtml } from 'takumi-js/helpers/html'; import { fromJsx } from 'takumi-js/helpers/jsx'; /** Takumi's options without `fonts`; a plain `Omit` would merge its format variants. */ -type RenderOptionsWithoutFonts = RenderOptions extends infer T +type TakumiOptions = RenderOptions extends infer T ? T extends object - ? Omit + ? Omit : never : never; @@ -38,7 +44,7 @@ type RenderOptionsWithoutFonts = RenderOptions extends infer T */ type Font = FontLoader | (FontDetails & { lang?: string }); -export type ImageResponseOptions = RenderOptionsWithoutFonts & +export type ImageResponseOptions = TakumiOptions & ResponseInit & { /** * Draws a border around every box, as `debug` does in Satori, though the lines @@ -70,7 +76,8 @@ const CONTENT_TYPES = { // - https://github.com/vercel/satori/blob/0.33.5/src/language.ts#L24-L45 // - @vercel/og `// src/language/index.ts`: languageFontMap // Takumi has no such hook, so these families are matched by script before the -// render. `cjkFamily` settles the first one. +// render. `cjkFamily` settles the first one; `Noto Sans` takes the rest, as +// @vercel/og's `unknown` does. const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ [/[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana} -〿＀-￯]/u, 'Noto Sans JP'], [/\p{sc=Hangul}/u, 'Noto Sans KR'], @@ -83,22 +90,37 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ [/\p{sc=Telugu}/u, 'Noto Sans Telugu'], [/\p{sc=Kannada}/u, 'Noto Sans Kannada'], [/\p{sc=Malayalam}/u, 'Noto Sans Malayalam'], - [/[\p{sc=Cyrillic}\p{sc=Greek}Ā-ɏḀ-ỿ]/u, 'Noto Sans'], + // The default font's own subsets, so a word stays in one typeface. + [/[\p{sc=Cyrillic}µĀ-ɏḀ-ỿ]/u, 'Geist'], [/\p{Sm}/u, 'Noto Sans Math'], - [/\p{So}/u, 'Noto Sans Symbols 2'], + [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols'], + [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols 2'], ]; +// What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found +// by rendering each code point with takumi-js 2.16: some accented letters, +// spaces and format characters, quotes, dashes, € ™ − ↑ ↓. +const BUILT_IN = + /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FEFF}]/u; + +// Emoji come from jsDelivr by code point, so keep them for the life of the +// isolate, as @vercel/og does (`// src/og.ts`: assetCache); other images are +// fetched for each render. +const EMOJI_ORIGIN = 'https://cdn.jsdelivr.net/'; +const emojiCache = new Map>(); + /** * A `Response` whose body is the image rendered from `element`: React JSX, an - * HTML string, or a Takumi node tree. Like `@vercel/og`, it renders a 1200×630 - * PNG by default and lets it be cached for a year outside development. + * HTML string, or a Takumi node tree. Like `next/og`, it renders a 1200×630 PNG + * by default, which clients revalidate outside development. */ export class ImageResponse extends Response { constructor(element: RenderInput, options: ImageResponseOptions = {}) { const { status, statusText, headers, debug, ...renderOptions } = options; // next/og sets these two headers unless the caller's `headers` has them - // (@vercel/og itself spreads the caller's object over its defaults). Next.js - // 16 sends `public, max-age=0, must-revalidate` outside development instead: + // (@vercel/og itself spreads the caller's object over its defaults). A + // failed render still ends as a complete response, so the image is not + // cached for the year @vercel/og allows: // https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L76-L86 const responseHeaders = new Headers(headers); if (!responseHeaders.has('content-type')) { @@ -107,14 +129,11 @@ export class ImageResponse extends Response { if (!responseHeaders.has('cache-control')) { responseHeaders.set( 'cache-control', - isDevelopment() - ? 'no-cache, no-store' - : 'public, immutable, no-transform, max-age=31536000', + isDevelopment() ? 'no-cache, no-store' : 'public, max-age=0, must-revalidate', ); } // Render while the body streams, as @vercel/og does, so a failure errors the - // body, which the runtime reports: - // https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L49-L72 + // body: https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L49-L72 const body = new ReadableStream({ async start(controller) { try { @@ -128,6 +147,7 @@ export class ImageResponse extends Response { controller.enqueue(image); controller.close(); } catch (error) { + console.error('void/og: failed to render the image.', error); controller.error(error); } }, @@ -136,7 +156,7 @@ export class ImageResponse extends Response { } } -type RenderImageOptions = RenderOptionsWithoutFonts & { fonts?: ImageResponseOptions['fonts'] }; +type RenderImageOptions = TakumiOptions & { fonts?: ImageResponseOptions['fonts'] }; class FallbackFontError extends Error {} @@ -147,24 +167,21 @@ async function renderImage(element: RenderInput, options: RenderImageOptions): P const { node: tree, css } = await toNode(element, options.jsx); const node = options.emoji === 'from-font' ? tree : extractEmojis(tree, options.emoji ?? 'twemoji'); - // Remote images and emoji are fetched once, even when the render runs twice. - const fetchCache = new Map>(); + const images = Array.isArray(options.images) ? { sources: options.images } : options.images; const renderNode = (fonts: Array) => render(node, { ...options, - // takumi rejects `css` beside its deprecated `stylesheets`, so fold them. - css: [...toList(options.css), ...toList(options.stylesheets), ...css], - stylesheets: undefined, - images: Array.isArray(options.images) - ? { sources: options.images, fetchCache } - : { fetchCache, ...options.images }, + css: [...toList(options.css), ...css], + images: { fetchCache: imageCache(), ...images }, // Takumi uses every font registered in the process when it gets no list, // which includes earlier images' fallback fonts: // https://github.com/kane50613/takumi/blob/takumi@2.16.2/takumi-helpers/src/renderer.ts#L271-L282 fonts, }); - const fallback = await fallbackFonts(node, options); - const fonts = ((await options.fonts) ?? []).map(withoutLang); + const [fonts, fallback] = await Promise.all([ + Promise.all(((await options.fonts) ?? []).map(keyedFont)), + fallbackFonts(node, options, images), + ]); if (fallback.length === 0) { return renderNode(fonts); } @@ -203,12 +220,55 @@ function toList(value: T | ReadonlyArray | undefined): Array { return Array.isArray(value) ? [...value] : [value as T]; } -function withoutLang(font: Font): FontLoader { - if (typeof font === 'object' && 'lang' in font) { - const { lang: _lang, ...rest } = font; - return rest; +/** Images fetched once per render, and emoji once per isolate. */ +function imageCache(): ImageFetchCache { + const cache = new Map>(); + return { + get: (url) => cache.get(url) ?? emojiCache.get(url), + set(url, data) { + cache.set(url, data); + if (url.startsWith(EMOJI_ORIGIN)) { + if (emojiCache.size >= 256) { + emojiCache.delete(emojiCache.keys().next().value!); + } + emojiCache.set(url, data); + } + }, + delete(url) { + cache.delete(url); + emojiCache.delete(url); + }, + }; +} + +function hasBytes(font: Font): font is FontDetails & { lang?: string } { + return ( + typeof font === 'object' && + 'data' in font && + (font.data instanceof ArrayBuffer || font.data instanceof Uint8Array) + ); +} + +// Takumi keeps every registered font for the life of the process and tells +// them apart by buffer identity, so a font read anew for each request, as in +// next/og's examples, would be registered again every time. Key it by content. +async function keyedFont(font: Font): Promise { + if (!hasBytes(font)) { + return font; } - return font; + const { lang: _lang, data, ...details } = font; + const digest = await crypto.subtle.digest( + 'SHA-256', + data instanceof ArrayBuffer ? data : new Uint8Array(data), + ); + const hash = Array.from(new Uint8Array(digest, 0, 8), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + return { + ...details, + key: `void/og ${details.name ?? ''}:${details.weight ?? ''}:${details.style ?? ''}:${hash}`, + data: () => data, + }; } // Satori's ja-JP code takes Han and comes first, so Han text is Japanese there @@ -228,41 +288,57 @@ function cjkFamily(text: string, lang = ''): string { return 'Noto Sans JP'; } -async function fallbackFonts(node: Node, options: RenderImageOptions): Promise> { - const text = Array.from(collectCodepoints(node), (codepoint) => - String.fromCodePoint(codepoint), - ).join(''); +async function fallbackFonts( + node: Node, + options: RenderImageOptions, + images: Exclude>, +): Promise> { + const missing = Array.from(collectCodepoints(node)) + .filter((codepoint) => codepoint === 0xb5 || codepoint > 0xff) + .map((codepoint) => String.fromCodePoint(codepoint)) + .filter((character) => !BUILT_IN.test(character)); const families = new Set( - FALLBACK_FONTS.filter(([script]) => script.test(text)).map(([, family]) => - family === 'Noto Sans JP' ? cjkFamily(text, options.lang ?? node.lang) : family, + FALLBACK_FONTS.filter(([script]) => missing.some((character) => script.test(character))).map( + ([, family]) => + family === 'Noto Sans JP' ? cjkFamily(missing.join(''), options.lang ?? node.lang) : family, ), ); + if (missing.some((character) => !FALLBACK_FONTS.some(([script]) => script.test(character)))) { + families.add('Noto Sans'); + } if (families.size === 0) { return []; } options.signal?.throwIfAborted(); - let subsets: Array; - try { - subsets = await googleFonts([...families]); - } catch (error) { - console.warn(`void/og: could not load ${[...families].join(', ')} from Google Fonts.`, error); - return []; - } + // One request per family: takumi caches the CSS by URL, and a family's + // subsets are keyed alike whichever image asked for them. + const subsets = await Promise.all( + [...families].map(async (family) => { + try { + return await googleFonts({ + families: [family], + fetch: images?.fetch, + timeout: images?.timeout, + }); + } catch (error) { + console.warn(`void/og: could not load ${family} from Google Fonts.`, error); + return []; + } + }), + ); // Satori draws each character with the first loaded font that has it, and // fallback fonts come after the default one, so Latin text keeps the default // font: https://github.com/vercel/satori/blob/0.33.5/src/font.ts#L601-L679 - // Takumi puts registered fonts before its built-in Geist, which covers ASCII - // and Latin-1 but µ, so skip each family's Latin subset and take Latin-1 out - // of the others, which can mix ASCII with common CJK characters. - const stripped = subsets.flatMap((subset) => { - if (subset.unicodeRange.some(([start, end]) => start <= 0x20 && 0x7e <= end)) { - return []; - } - const unicodeRange = subset.unicodeRange - .filter(([, end]) => end > 0xff) - .map(([start, end]): [number, number] => [Math.max(start, 0x100), end]); - // An empty range covers everything, so only drop a subset that had ranges. - if (unicodeRange.length === 0 && subset.unicodeRange.length > 0) { + // Takumi puts registered fonts before its built-in Geist, so take ASCII and + // Latin-1 but µ out of every subset. A subset without ranges is a whole font. + const stripped = subsets.flat().flatMap((subset) => { + const unicodeRange = ( + subset.unicodeRange.length > 0 ? subset.unicodeRange : [[0x100, 0x10ffff] as const] + ).flatMap(([start, end]): Array<[number, number]> => [ + ...(start <= 0xb5 && 0xb5 <= end ? [[0xb5, 0xb5] as [number, number]] : []), + ...(end > 0xff ? [[Math.max(start, 0x100), end] as [number, number]] : []), + ]); + if (unicodeRange.length === 0) { return []; } return { diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts index 94cef1eb3..6fe09c8d5 100644 --- a/packages/void/test/integration/og-image.test.ts +++ b/packages/void/test/integration/og-image.test.ts @@ -17,7 +17,7 @@ import { collectPrerenderWorkerModules } from '../../src/prerender.ts'; const repository = join(import.meta.dirname, '..', '..', '..', '..'); const DEV_HEADER = 'no-cache, no-store'; -const BUILD_HEADER = 'public, immutable, no-transform, max-age=31536000'; +const BUILD_HEADER = 'public, max-age=0, must-revalidate'; // Vitest sets NODE_ENV=test, which would make Vite build in development mode. const { NODE_ENV: _nodeEnv, ...userEnv } = process.env; diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 10d89bdeb..51be2dcfc 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -145,14 +145,29 @@ describe('ImageResponse', () => { expect(debug).not.toEqual(plain); }); - it('accepts css and the deprecated stylesheets option beside a style element', async () => { + it('accepts css beside a style element', async () => { const response = new ImageResponse( '
Hi
', - { css: '.card { padding: 8px }', stylesheets: ['.card { color: #fff }'] }, + { css: '.card { padding: 8px }' }, ); expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); }); + it('keys a font by its bytes, so a font read for each request registers once', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const fontKey = async (data: Uint8Array) => { + await new ImageResponse(card, { fonts: [{ name: 'Broken', data }] }) + .arrayBuffer() + .catch(() => {}); + const fonts = vi.mocked(render).mock.lastCall?.[1]?.fonts as Array<{ key: string }>; + return fonts[0]!.key; + }; + const key = await fontKey(new Uint8Array([1, 2, 3])); + expect(key).toMatch(/^void\/og Broken:::[0-9a-f]{16}$/); + expect(await fontKey(new Uint8Array([1, 2, 3]))).toBe(key); + expect(await fontKey(new Uint8Array([1, 2, 4]))).not.toBe(key); + }); + it('errors the body when the image cannot render', async () => { const data = new Uint8Array([1, 2, 3]); // A next/og font entry may carry Satori's `lang`, which takumi does not take. @@ -160,16 +175,18 @@ describe('ImageResponse', () => { fonts: [{ name: 'Broken', data, weight: 400, lang: 'ja-JP' }], }); await expect(response.arrayBuffer()).rejects.toThrow(); - expect(vi.mocked(render).mock.lastCall?.[1]?.fonts).toEqual([ - { name: 'Broken', data, weight: 400 }, - ]); + const fonts = vi.mocked(render).mock.lastCall?.[1]?.fonts as Array; + expect(fonts[0]).not.toHaveProperty('lang'); }); }); describe('ImageResponse fallback fonts', () => { - it('requests no font for Latin text', async () => { + it('requests no font for text the built-in font draws', async () => { const requests = stubFetch(() => new Response(null, { status: 404 })); await new ImageResponse(card).arrayBuffer(); + await new ImageResponse( + '
C++ © 2026 “quotes” — … • ™ €
', + ).arrayBuffer(); expect(requests).toEqual([]); }); @@ -206,11 +223,29 @@ describe('ImageResponse fallback fonts', () => { ); }); - it('uses Noto Sans for accented Latin letters that Geist lacks', async () => { + it("uses Geist's own subsets for letters the built-in Geist lacks", async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch(() => new Response(null, { status: 404 })); - await new ImageResponse('
Łódź
').arrayBuffer(); - expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Noto Sans:wght@400']); + await new ImageResponse('
Łódź 5µs
').arrayBuffer(); + expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Geist:wght@400']); + }); + + it('takes a whole font for a subset without ranges, beyond Latin-1', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch((url) => + url.startsWith('https://fonts.googleapis.com/') + ? new Response( + `@font-face { font-family: 'Noto Sans Math'; src: url(https://fonts.gstatic.com/math.woff2) format('woff2'); }`, + { headers: { 'content-type': 'text/css' } }, + ) + : new Response(null, { status: 404 }), + ); + await new ImageResponse('
A → B
').arrayBuffer(); + expect(requests.slice(1)).toEqual(['https://fonts.gstatic.com/math.woff2']); + const [, options] = vi.mocked(render).mock.calls.at(-2)!; + expect(options?.fonts).toMatchObject([ + { name: 'void/og Noto Sans Math', unicodeRange: [[0x100, 0x10ffff]] }, + ]); }); it('draws Han text as Japanese unless lang says otherwise, like next/og', async () => { @@ -235,14 +270,14 @@ describe('ImageResponse fallback fonts', () => { ]); }); - it('draws emoji as images, without a font', async () => { + it('draws emoji as images, fetched once per isolate, without a font', async () => { const requests = stubFetch(() => new Response('')); - expect(await pngSize(new ImageResponse('
🎉
'))).toEqual({ - width: 1200, - height: 630, - }); + const emoji = '
🪩
'; + expect(await pngSize(new ImageResponse(emoji))).toEqual({ width: 1200, height: 630 }); expect(requests).toHaveLength(1); expect(requests[0]).toMatch(/^https:\/\/cdn\.jsdelivr\.net\/.*\.svg$/); + await new ImageResponse(emoji).arrayBuffer(); + expect(requests).toHaveLength(1); }); it('uses the symbol and math families for characters Geist lacks', async () => { @@ -254,12 +289,17 @@ describe('ImageResponse fallback fonts', () => { }) : new Response(null, { status: 404 }), ); - await new ImageResponse('
A → B ★ 🎉
').arrayBuffer(); - // The emoji is an image, so it asks for no font. - const fonts = requests.filter((url) => url.startsWith('https://fonts.googleapis.com/')); - expect(new URL(fonts[0]!).searchParams.getAll('family')).toEqual([ + await new ImageResponse('
A → B ★ ₹ 🎉
').arrayBuffer(); + // The emoji is an image, so it asks for no font. ₹ has no row and goes to Noto Sans. + const families = requests + .filter((url) => url.startsWith('https://fonts.googleapis.com/')) + .flatMap((url) => new URL(url).searchParams.getAll('family')) + .sort(); + expect(families).toEqual([ 'Noto Sans Math:wght@400', 'Noto Sans Symbols 2:wght@400', + 'Noto Sans Symbols:wght@400', + 'Noto Sans:wght@400', ]); }); From 0d0aae3ae73cc47bb1ece024449b210ac9091dad Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:53:32 +0000 Subject: [PATCH 08/35] Fetch images once per void/og render and hash each font buffer once - The retry without a failed fallback font fetched remote images again: share one image cache between both attempts. - Hash a font buffer once per buffer, not per request, so a font kept at module scope costs no CPU. - Count the combining marks and variation selectors the built-in Geist draws, so NFD text asks for no font. - Guide: WOFF loads on Node.js and Bun. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 2 +- packages/void/src/runtime/og.ts | 36 ++++++++++++++++++++---------- packages/void/test/unit/og.test.ts | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 0474ed42b..35a0cbeb4 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -129,7 +129,7 @@ return new ImageResponse(postCard(post), { }); ``` -Fonts can be TTF, OTF, or WOFF2 files. +Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too. ## Add the image to a page diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index eec06f837..c194f2359 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -98,10 +98,14 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ ]; // What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found -// by rendering each code point with takumi-js 2.16: some accented letters, -// spaces and format characters, quotes, dashes, € ™ − ↑ ↓. +// by rendering each code point with takumi-js 2.16: some accented letters and +// combining marks, spaces, format characters and variation selectors, quotes, +// dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing: no served subset +// has them. +/* eslint-disable no-misleading-character-class -- the marks and selectors are listed on their own */ const BUILT_IN = - /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FEFF}]/u; + /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{304}\u{308}\u{34F}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FE00}-\u{FE0F}\u{FEFF}]/u; +/* eslint-enable no-misleading-character-class */ // Emoji come from jsDelivr by code point, so keep them for the life of the // isolate, as @vercel/og does (`// src/og.ts`: assetCache); other images are @@ -168,11 +172,13 @@ async function renderImage(element: RenderInput, options: RenderImageOptions): P const node = options.emoji === 'from-font' ? tree : extractEmojis(tree, options.emoji ?? 'twemoji'); const images = Array.isArray(options.images) ? { sources: options.images } : options.images; + // One cache for both attempts below, so a retry fetches no image again. + const fetchCache = imageCache(); const renderNode = (fonts: Array) => render(node, { ...options, css: [...toList(options.css), ...css], - images: { fetchCache: imageCache(), ...images }, + images: { fetchCache, ...images }, // Takumi uses every font registered in the process when it gets no list, // which includes earlier images' fallback fonts: // https://github.com/kane50613/takumi/blob/takumi@2.16.2/takumi-helpers/src/renderer.ts#L271-L282 @@ -249,6 +255,8 @@ function hasBytes(font: Font): font is FontDetails & { lang?: string } { ); } +const fontHashes = new WeakMap>(); + // Takumi keeps every registered font for the life of the process and tells // them apart by buffer identity, so a font read anew for each request, as in // next/og's examples, would be registered again every time. Key it by content. @@ -257,16 +265,20 @@ async function keyedFont(font: Font): Promise { return font; } const { lang: _lang, data, ...details } = font; - const digest = await crypto.subtle.digest( - 'SHA-256', - data instanceof ArrayBuffer ? data : new Uint8Array(data), - ); - const hash = Array.from(new Uint8Array(digest, 0, 8), (byte) => - byte.toString(16).padStart(2, '0'), - ).join(''); + let hash = fontHashes.get(data); + if (!hash) { + hash = crypto.subtle + .digest('SHA-256', data instanceof ArrayBuffer ? data : new Uint8Array(data)) + .then((digest) => + Array.from(new Uint8Array(digest, 0, 8), (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ), + ); + fontHashes.set(data, hash); + } return { ...details, - key: `void/og ${details.name ?? ''}:${details.weight ?? ''}:${details.style ?? ''}:${hash}`, + key: `void/og ${details.name ?? ''}:${details.weight ?? ''}:${details.style ?? ''}:${await hash}`, data: () => data, }; } diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 51be2dcfc..28043042b 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -109,7 +109,7 @@ describe('ImageResponse', () => { expect(Title).toHaveBeenCalledOnce(); }); - // Builds cache images for a year: test/integration/og-image.test.ts. + // Builds send `public, max-age=0, must-revalidate`: test/integration/og-image.test.ts. it('does not cache images during development', () => { expect(new ImageResponse(card).headers.get('cache-control')).toBe('no-cache, no-store'); }); From e1bedec686f8c946d09ee1fad9bc787a0c2dbb1d Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:40:32 +0000 Subject: [PATCH 09/35] Show void/og in a .tsx route now that routes accept JSX Main added .tsx and .jsx route files (#561), so the guide no longer needs a separate module for the image's JSX: its example is one `routes/og/[slug].tsx` route, and the next/og migration note moves `app/api/og/route.tsx` to a route outside /api so it can be cached. The integration test renders its JSX card from a .tsx route in dev and in the built Worker. The guide says "local development" rather than naming a dev command, as main's docs now do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 40 +++++++------------ .../void/test/integration/og-image.test.ts | 10 ++--- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 35a0cbeb4..5cf149d49 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -8,12 +8,20 @@ Use `ImageResponse` from `void/og` to generate Open Graph and social preview ima ## Create an image route -Route files are TypeScript, so put the image's JSX in a `.tsx` module: +Return an `ImageResponse` from a [`.tsx` route](./server-routing.md#jsx-handlers): ```tsx -// src/og/post-card.tsx -export function postCard(post: { title: string; author: string }) { - return ( +// routes/og/[slug].tsx +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { getPost } from '../../src/posts'; + +export const GET = defineHandler(async (c) => { + const post = await getPost(c.req.param('slug')); + if (!post) { + return c.notFound(); + } + return new ImageResponse(
{post.title}
{post.author}
-
+ , ); -} -``` - -Then return an `ImageResponse` from a route: - -```ts -// routes/og/[slug].ts -import { defineHandler } from 'void'; -import { ImageResponse } from 'void/og'; -import { postCard } from '../../src/og/post-card'; -import { getPost } from '../../src/posts'; - -export const GET = defineHandler(async (c) => { - const post = await getPost(c.req.param('slug')); - if (!post) { - return c.notFound(); - } - return new ImageResponse(postCard(post)); }); ``` @@ -104,7 +94,7 @@ Content-Type: image/png Cache-Control: public, max-age=0, must-revalidate ``` -During `vp dev`, `Cache-Control` is `no-cache, no-store`. Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy. +During local development, `Cache-Control` is `no-cache, no-store`. Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy. Emoji and remote `` sources are downloaded while the image renders. An emoji that the chosen style does not have fails the image; `noto` has no flags. @@ -181,7 +171,7 @@ import { ImageResponse } from 'void/og'; // [!code ++] Then: -- Move the JSX from `route.tsx` into a `.tsx` module and return the response from a route file, as shown above. +- Move `app/api/og/route.tsx` to a route file outside `/api`, such as `routes/og.tsx`, so its images can be [cached](#cache-generated-images). - Read the request from Hono's context: `c.req.url`, `c.req.param()`, and `c.req.query()`. - Void draws images with Takumi rather than Satori, so layout can differ slightly. See [Takumi vs. Satori](https://takumi.kane.tw/docs/comparison-to-satori). - A font's `lang` is accepted but does not choose the font for a language. Use the `lang` option or attribute for Chinese characters instead. diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts index 6fe09c8d5..0ae86342c 100644 --- a/packages/void/test/integration/og-image.test.ts +++ b/packages/void/test/integration/og-image.test.ts @@ -24,13 +24,12 @@ const { NODE_ENV: _nodeEnv, ...userEnv } = process.env; const OG_ROUTE = `import { ImageResponse } from 'void/og'; export const GET = () => new ImageResponse('
Hello
'); `; +// A .tsx route, as the guide shows. const CARD_ROUTE = `import { ImageResponse } from 'void/og'; -import { card } from '../../src/card'; -export const GET = () => new ImageResponse(card('Hello from JSX')); -`; -const CARD_COMPONENT = `export function card(title: string) { +function Card({ title }: { title: string }) { return
{title}
; } +export const GET = () => new ImageResponse(); `; // Miniflare types its own Response, without Cloudflare's `cf`. @@ -140,8 +139,7 @@ describe('void/og in a Cloudflare app', () => { root = createApp( { 'routes/api/og.ts': OG_ROUTE, - 'routes/api/card.ts': CARD_ROUTE, - 'src/card.tsx': CARD_COMPONENT, + 'routes/api/card.tsx': CARD_ROUTE, }, { react: 'playground/react-pages/node_modules/react' }, ); From 443fc9c58db4d09abdefc6401441e0a5c121f013 Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:03:51 +0000 Subject: [PATCH 10/35] Move void/og to takumi-js ~2.18.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit void declared takumi-js ^2.16.2, so a fresh install already resolved 2.19.0, which the repository never tested. 2.19.0 loads only the first font whose unicode-range covers a character and draws the whole word as boxes when that font lacks the glyph: with Japanese in the image, Noto Sans JP's latin-ext subset claims Ł and "Łódź" renders as four boxes, and Noto Sans Symbols claims ❤ without having it. 2.16.2, 2.17.1 and 2.18.2 render both correctly. Test and ship 2.18.2, the newest release that does, and keep fresh installs on it with ~2.18.2. The Node.js build check reads ~ ranges and rejects 2.19.0, and the guide's install command names the range. 2.18.1 added images.throwOnError: the guide now says an emoji the chosen style lacks, or an image that fails to download, fails the image unless it is false, and a unit test covers both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 6 +- packages/void/AGENTS.md | 6 +- packages/void/package.json | 2 +- packages/void/src/plugin-node-target.ts | 22 ++-- packages/void/src/runtime/og.ts | 4 +- .../void/test/integration/og-image.test.ts | 4 +- packages/void/test/unit/og.test.ts | 14 ++- pnpm-lock.yaml | 102 +++++++++--------- 8 files changed, 91 insertions(+), 69 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 5cf149d49..795651713 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -82,7 +82,7 @@ The second argument sets the image and the response: | `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | | `debug` | `false` | Draws a border around every box. | | `format` | `'png'` | Image format: `png`, `jpeg`, `webp`, or `ico`. | -| `images` | | Limits for remote `` sources: `allowUrl`, `maxBytes`, and `timeout`. Set them when the markup includes request values. | +| `images` | | Limits for remote images: `allowUrl`, `maxBytes`, and `timeout`. Set them when the markup includes request values. | | `status`, `statusText`, `headers` | | The response status and headers. | Takumi's other render options, such as `devicePixelRatio`, are passed through. See [Takumi's reference](https://takumi.kane.tw/docs/reference). @@ -96,7 +96,7 @@ Cache-Control: public, max-age=0, must-revalidate During local development, `Cache-Control` is `no-cache, no-store`. Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy. -Emoji and remote `` sources are downloaded while the image renders. An emoji that the chosen style does not have fails the image; `noto` has no flags. +Emoji and remote images are downloaded while the image renders. An emoji that the chosen style does not have, or an image that fails to download, fails the image unless you pass `images: { throwOnError: false }`; `noto` has no flags. ## Fonts @@ -181,7 +181,7 @@ Then: On the [Node.js, Bun, and Deno targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's `dependencies`, not `devDependencies`, so production installs include it: ```sh -npm install takumi-js +npm install takumi-js@~2.18.2 ``` If the build cannot find a version of `takumi-js` that Void supports, it fails and prints the command to install one. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index d8442cfb1..63e0c1de2 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -167,7 +167,11 @@ the app root resolves a `takumi-js` in the range void declares. Takumi's JSX helper imports `react` as an optional peer; Vite stubs it in apps without React, and Takumi catches the failed import. Fallback fonts come from Google Fonts by the scripts in the text, under private family names, with Latin-1 -taken out so Latin text keeps Takumi's built-in Geist. +taken out so Latin text keeps Takumi's built-in Geist. takumi-js stays on +`~2.18`: 2.19.0 loads only the first font whose `unicode-range` covers a +character, and draws the whole word as boxes when that font lacks the glyph +(Noto Sans JP's latin-ext subset has no Ł). Raise the range, and the install +command in `docs/guide/og-images.md`, once Takumi fixes it. Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). diff --git a/packages/void/package.json b/packages/void/package.json index 7166a58b4..4d18a2622 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -391,7 +391,7 @@ "postal-mime": "catalog:", "proper-lockfile": "^4.1.2", "smol-toml": "catalog:", - "takumi-js": "^2.16.2", + "takumi-js": "~2.18.2", "terminal-link": "^5.0.0", "yaml": "^2.9.1" }, diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index a5ee6383d..00a312883 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -345,7 +345,7 @@ export function nodeTargetPlugin( const range = readTakumiRange(); const installed = findInstalledPackage('takumi-js', this.environment.config.root); const version = installed && readManifest(installed)?.version; - if (version && satisfiesCaret(version, range)) { + if (version && satisfiesRange(version, range)) { return; } const found = version ? ` The app has takumi-js ${version}.` : ''; @@ -388,21 +388,27 @@ function realPath(path: string): string { /** The takumi-js range void/og is built and tested against, from void's package.json. */ function readTakumiRange(): string { const range = readManifest(VOID_PACKAGE_DIR)?.dependencies?.['takumi-js']; - if (!range || !/^\^\d+\.\d+\.\d+$/.test(range)) { + if (!range || !/^[~^]\d+\.\d+\.\d+$/.test(range)) { throw new Error( - `void/og: expected a caret takumi-js range in void's package.json, got ${range}.`, + `void/og: expected a ^ or ~ takumi-js range in void's package.json, got ${range}.`, ); } return range; } -/** Whether `version` is in the caret `range` (a major version above 0, as takumi-js uses). */ -function satisfiesCaret(version: string, range: string): boolean { - const parse = (value: string) => /^\^?(\d+)\.(\d+)\.(\d+)$/.exec(value)?.slice(1).map(Number); - // A prerelease is outside a caret range. +/** Whether `version` is in the `^` or `~` `range` (a major version above 0, as takumi-js uses). */ +function satisfiesRange(version: string, range: string): boolean { + const parse = (value: string) => /^[~^]?(\d+)\.(\d+)\.(\d+)$/.exec(value)?.slice(1).map(Number); + // A prerelease is outside the range. const actual = parse(version); const minimum = parse(range); - if (!actual || !minimum || actual[0] !== minimum[0]) { + // `~` also fixes the minor version. + const fixed = range.startsWith('~') ? 2 : 1; + if ( + !actual || + !minimum || + actual.slice(0, fixed).some((part, index) => part !== minimum[index]) + ) { return false; } for (const part of [1, 2]) { diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index c194f2359..ed5fcc434 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -98,7 +98,7 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ ]; // What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found -// by rendering each code point with takumi-js 2.16: some accented letters and +// by rendering each code point with takumi-js 2.18: some accented letters and // combining marks, spaces, format characters and variation selectors, quotes, // dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing: no served subset // has them. @@ -181,7 +181,7 @@ async function renderImage(element: RenderInput, options: RenderImageOptions): P images: { fetchCache, ...images }, // Takumi uses every font registered in the process when it gets no list, // which includes earlier images' fallback fonts: - // https://github.com/kane50613/takumi/blob/takumi@2.16.2/takumi-helpers/src/renderer.ts#L271-L282 + // https://github.com/kane50613/takumi/blob/takumi@2.18.2/takumi-helpers/src/renderer.ts#L280-L308 fonts, }); const [fonts, fallback] = await Promise.all([ diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts index 0ae86342c..e140fa920 100644 --- a/packages/void/test/integration/og-image.test.ts +++ b/packages/void/test/integration/og-image.test.ts @@ -246,7 +246,7 @@ describe('void/og on the Node.js target', () => { ); const result = runBuild(root); expect(result.status).not.toBe(0); - expect(result.stderr).toContain('void/og needs takumi-js@^'); - expect(result.stderr).toContain('npm install takumi-js@^'); + expect(result.stderr).toContain('void/og needs takumi-js@'); + expect(result.stderr).toContain('npm install takumi-js@'); }); }); diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 28043042b..ee2910b19 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -280,6 +280,15 @@ describe('ImageResponse fallback fonts', () => { expect(requests).toHaveLength(1); }); + it('fails on an emoji that cannot download, unless told to leave it out', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + stubFetch(() => new Response(null, { status: 404 })); + const emoji = '
🪅
'; + await expect(new ImageResponse(emoji).arrayBuffer()).rejects.toThrow(); + const response = new ImageResponse(emoji, { images: { throwOnError: false } }); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + }); + it('uses the symbol and math families for characters Geist lacks', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch((url) => @@ -374,6 +383,9 @@ describe('void/og on Node.js targets', () => { expect(resolveRenderer).toThrow('The app has takumi-js 3.0.0.'); installTakumi('2.17.0-beta.1'); expect(resolveRenderer).toThrow('The app has takumi-js 2.17.0-beta.1.'); + // takumi-js 2.19.0 draws boxes for text its first matching font lacks. + installTakumi('2.19.0'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.19.0.'); }); it('warns once in the dev server instead', () => { @@ -392,7 +404,7 @@ describe('void/og on Node.js targets', () => { }); it("leaves a supported takumi-js in the app's dependencies to Node.js", () => { - installTakumi(range.replace(/^\^/, '')); + installTakumi(range.replace(/^[~^]/, '')); expect(resolveRenderer()).toBeUndefined(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df447edc6..391fa58c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -874,8 +874,8 @@ importers: specifier: 'catalog:' version: 1.9.1 takumi-js: - specifier: ^2.16.2 - version: 2.20.0(csstype@3.2.3)(react@19.3.1) + specifier: ~2.18.2 + version: 2.18.2(csstype@3.2.3)(react@19.3.1) terminal-link: specifier: ^5.0.0 version: 5.0.0 @@ -5405,60 +5405,60 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@takumi-rs/core-darwin-arm64@2.20.0': - resolution: {integrity: sha512-wescbJ8pm0fBN8uXxjiZUDqN7JkdDDmxt8Gq1pSrpRCuGxvAnoj3agMMhHtJ5Ty+BufkIwSvV2cBXRLM4wyYZw==} + '@takumi-rs/core-darwin-arm64@2.18.2': + resolution: {integrity: sha512-7JjXUdT1ywBGghWAR8nim7emczQRsHV4TkLspITL3a853AahtNIwLTMMb/uK0KVAPwQR0plFEqzVU23iAzRBWQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@takumi-rs/core-darwin-x64@2.20.0': - resolution: {integrity: sha512-idCQC8PLuuDFQ4c2YEx142Q1WM+GbrR3RqNxuJay5MxfCXcZ42dv+96xGcgs0U2AWOk1GkY/vTY4y/Om7O/QHA==} + '@takumi-rs/core-darwin-x64@2.18.2': + resolution: {integrity: sha512-YwExtQ81c91GLVKDnECruM5bM41a8flnXtQ/KTMSVovX74xzUZCe98XJjcyvTDdksF+bGMG9aqsmwX833HiMQw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@takumi-rs/core-linux-arm64-gnu@2.20.0': - resolution: {integrity: sha512-TeqwOXlVdjAoSoOVQVoJOER4upQnh7nX6QJnYqVDlbByiSkMhqYULXc7I83NqzkL3U/fbT7/oASNeGWAI2axBA==} + '@takumi-rs/core-linux-arm64-gnu@2.18.2': + resolution: {integrity: sha512-NQcTa0mfIGAuGSLiTyJSTWn85uSj9Y7+adp+2yIaV89SZ1/thYg7HwGHMwpHQyYCZ2mTFWOwlh264nqYO1RvLw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] libc: [glibc] - '@takumi-rs/core-linux-arm64-musl@2.20.0': - resolution: {integrity: sha512-gOcZxdGq7ebhqRN1Ue3FYJV7jKFG8yG99Opiky13viXXH8hkOde+OKUMe2kqWRH42j7p5EhBPj2qmdUBXXQBCQ==} + '@takumi-rs/core-linux-arm64-musl@2.18.2': + resolution: {integrity: sha512-5oaEkOZfyVolD6KxQ77aFeSHI1W7W8Oai4W0vBGzXyyXVLeONfWZkBFWPpXO0ojnPsKa2JVGL5mKFqRiwKAEQg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] libc: [musl] - '@takumi-rs/core-linux-x64-gnu@2.20.0': - resolution: {integrity: sha512-MwIdLqyMUUz+6wuxkGJZyEO9alZODuDqe826HAG2+RErwpSUwfqtD2pHPWG9bnWkr7qpVeziFut6Ng68lYzcvQ==} + '@takumi-rs/core-linux-x64-gnu@2.18.2': + resolution: {integrity: sha512-UG95eda/4ySoJqV1QGl7ykH7AMKySvSkpafSVuIHefzu6fiQbyCOE9GRx/BWx0wKIE3ozQXteIP3ydnHQPJBJw==} engines: {node: '>=18'} cpu: [x64] os: [linux] libc: [glibc] - '@takumi-rs/core-linux-x64-musl@2.20.0': - resolution: {integrity: sha512-bM9QEwOuKg0+im1M+XjownCCdN+zHKQXGblDttpKuoa2QEd4F9t0JJcQMBSjLFucC2sf+rGicAmayMsQRxwgfg==} + '@takumi-rs/core-linux-x64-musl@2.18.2': + resolution: {integrity: sha512-eTufsMdBMk8tvER1hBfh2RkE2Qv6ov05r9UEIXFDwZLVlXoHr1DbEp/k8Ze1a1DdLpZJizVQ9BKFKBUk8+SWnQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] libc: [musl] - '@takumi-rs/core-win32-arm64-msvc@2.20.0': - resolution: {integrity: sha512-VwGj5dsODmHYV09P3AFhvcHL3hk8cS3Y0PYYHxFQtopwcWTsDN522+siotYL1lXZaEag9FoMJOuqiuZsVGgR3w==} + '@takumi-rs/core-win32-arm64-msvc@2.18.2': + resolution: {integrity: sha512-6C6ONbCg65smpkYRik9o3shON/8B8OP3VFpYXvSVHiW59boI+9SQKmOysTQBrZpSlUG5ZPAyoJHCfcFR5nawWA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@takumi-rs/core-win32-x64-msvc@2.20.0': - resolution: {integrity: sha512-3am5JIowXAETxUHwQh8MONCagATpN4jFiXISKT2djvceJxrriPZKwWNjB7zTUzZmVLYTbslMrG7x2+U1wPndYg==} + '@takumi-rs/core-win32-x64-msvc@2.18.2': + resolution: {integrity: sha512-ui0aYjmhx9vsNlNc2ktLwZsk308132HwaQyp6OR3mQ40/svfnASJDxMyTdmh9O1Vv0ynph9idoj4eEh/0YQAjw==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@takumi-rs/core@2.20.0': - resolution: {integrity: sha512-28uryNYSyuXZWv5uBfJiWDrRXVdAunkU0sZiB7KG2WgLej64pUNL6jZSTQa+gBXfVu+3DdCz58OnpONBO6EUqQ==} + '@takumi-rs/core@2.18.2': + resolution: {integrity: sha512-9g+oXNTAKLp6ZliXiQ0BkMXVPrXgaX34wjY7jO9osxbgFtBi3QLhnrDMyKFpSvXuOcbjdSCi1LkJ0YzN95bMQw==} engines: {node: '>=18'} peerDependencies: csstype: '*' @@ -5466,8 +5466,8 @@ packages: csstype: optional: true - '@takumi-rs/helpers@2.20.0': - resolution: {integrity: sha512-efdeG4Ujsw0IEYXhq3lUN1UiKhK6f3tak7VSMrCEWSvumFs0q5moBHSLdsR6aJhPNsYei21Ea6Rjtv94/qdyjg==} + '@takumi-rs/helpers@2.18.2': + resolution: {integrity: sha512-HHVdYmGxLos+Jn67mYU64gc+nW372zP8pF6zCnLzn3JMbGtcX5jV7a0N5uZseXkGrfGR07tsdWQbTkpO4Ms+WA==} engines: {node: '>=18'} peerDependencies: preact: ^10.0.0 || ^11.0.0 @@ -5478,8 +5478,8 @@ packages: react: optional: true - '@takumi-rs/wasm@2.20.0': - resolution: {integrity: sha512-2Xfu55Ye8gmJMXQEDun/5zl/78LyCXKUjP503alH1tG0OyotkkWpZbgW8iWGuxxf4IJvp9kIaO+NLGbq4B6J8A==} + '@takumi-rs/wasm@2.18.2': + resolution: {integrity: sha512-Ap8rA5TRcfLRpKagM3NK0JGKo4TO0DrTI7fAFpKM5R04yIVvibT8Qq/FqFcYBqWdlVxfxwzdMAdn/Glxkx9WAA==} engines: {node: '>=20.19'} peerDependencies: csstype: '*' @@ -9773,8 +9773,8 @@ packages: tailwindcss@4.3.5: resolution: {integrity: sha512-FcQk9BuffmEHJP2G0XmB2SplU1aLTI7j07Kunk+U74Ny+XiWOtzXz9aujFZ/avntw1e2cOv/3PkgzflQ7gX2vQ==} - takumi-js@2.20.0: - resolution: {integrity: sha512-WnqocRxTlQcDeA6Q0Awcz3+diSF3UGA2hmIL3pLsqiGXZHpeeRVpVad97EO33sYiMT6+L2kMya28EVGJbjCyfg==} + takumi-js@2.18.2: + resolution: {integrity: sha512-FFl1sctUFyMgoVghDxZYDPOcxIKYmdZodkc1w+ozrRl2mW3OYlqWklyGR1c3zHoMFlqDZO170/v51ZmV/38JXw==} engines: {node: '>=20.19'} tapable@2.3.3: @@ -13630,54 +13630,54 @@ snapshots: tailwindcss: 4.3.5 vite: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' - '@takumi-rs/core-darwin-arm64@2.20.0': + '@takumi-rs/core-darwin-arm64@2.18.2': optional: true - '@takumi-rs/core-darwin-x64@2.20.0': + '@takumi-rs/core-darwin-x64@2.18.2': optional: true - '@takumi-rs/core-linux-arm64-gnu@2.20.0': + '@takumi-rs/core-linux-arm64-gnu@2.18.2': optional: true - '@takumi-rs/core-linux-arm64-musl@2.20.0': + '@takumi-rs/core-linux-arm64-musl@2.18.2': optional: true - '@takumi-rs/core-linux-x64-gnu@2.20.0': + '@takumi-rs/core-linux-x64-gnu@2.18.2': optional: true - '@takumi-rs/core-linux-x64-musl@2.20.0': + '@takumi-rs/core-linux-x64-musl@2.18.2': optional: true - '@takumi-rs/core-win32-arm64-msvc@2.20.0': + '@takumi-rs/core-win32-arm64-msvc@2.18.2': optional: true - '@takumi-rs/core-win32-x64-msvc@2.20.0': + '@takumi-rs/core-win32-x64-msvc@2.18.2': optional: true - '@takumi-rs/core@2.20.0(csstype@3.2.3)(react@19.3.1)': + '@takumi-rs/core@2.18.2(csstype@3.2.3)(react@19.3.1)': dependencies: - '@takumi-rs/helpers': 2.20.0(react@19.3.1) + '@takumi-rs/helpers': 2.18.2(react@19.3.1) optionalDependencies: - '@takumi-rs/core-darwin-arm64': 2.20.0 - '@takumi-rs/core-darwin-x64': 2.20.0 - '@takumi-rs/core-linux-arm64-gnu': 2.20.0 - '@takumi-rs/core-linux-arm64-musl': 2.20.0 - '@takumi-rs/core-linux-x64-gnu': 2.20.0 - '@takumi-rs/core-linux-x64-musl': 2.20.0 - '@takumi-rs/core-win32-arm64-msvc': 2.20.0 - '@takumi-rs/core-win32-x64-msvc': 2.20.0 + '@takumi-rs/core-darwin-arm64': 2.18.2 + '@takumi-rs/core-darwin-x64': 2.18.2 + '@takumi-rs/core-linux-arm64-gnu': 2.18.2 + '@takumi-rs/core-linux-arm64-musl': 2.18.2 + '@takumi-rs/core-linux-x64-gnu': 2.18.2 + '@takumi-rs/core-linux-x64-musl': 2.18.2 + '@takumi-rs/core-win32-arm64-msvc': 2.18.2 + '@takumi-rs/core-win32-x64-msvc': 2.18.2 csstype: 3.2.3 transitivePeerDependencies: - preact - react - '@takumi-rs/helpers@2.20.0(react@19.3.1)': + '@takumi-rs/helpers@2.18.2(react@19.3.1)': optionalDependencies: react: 19.3.1 - '@takumi-rs/wasm@2.20.0(csstype@3.2.3)(react@19.3.1)': + '@takumi-rs/wasm@2.18.2(csstype@3.2.3)(react@19.3.1)': dependencies: - '@takumi-rs/helpers': 2.20.0(react@19.3.1) + '@takumi-rs/helpers': 2.18.2(react@19.3.1) optionalDependencies: csstype: 3.2.3 transitivePeerDependencies: @@ -18558,11 +18558,11 @@ snapshots: tailwindcss@4.3.5: {} - takumi-js@2.20.0(csstype@3.2.3)(react@19.3.1): + takumi-js@2.18.2(csstype@3.2.3)(react@19.3.1): dependencies: - '@takumi-rs/core': 2.20.0(csstype@3.2.3)(react@19.3.1) - '@takumi-rs/helpers': 2.20.0(react@19.3.1) - '@takumi-rs/wasm': 2.20.0(csstype@3.2.3)(react@19.3.1) + '@takumi-rs/core': 2.18.2(csstype@3.2.3)(react@19.3.1) + '@takumi-rs/helpers': 2.18.2(react@19.3.1) + '@takumi-rs/wasm': 2.18.2(csstype@3.2.3)(react@19.3.1) transitivePeerDependencies: - csstype - preact From 9ffc276854e56619a29fa48930548365c0bb54ac Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:23:34 +0000 Subject: [PATCH 11/35] Test the ~ range in void/og's Node.js check and note how to verify a fix The guard tests now reject a patch below the minimum and a prerelease inside the ~2.18 window, and accept a later patch. The guide says what `throwOnError: false` does, and the maintainer note records that reordering the fallback families or dropping unicode-range does not avoid takumi-js 2.19.0's bug, and that a fix must be checked in a fresh process. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 2 +- packages/void/AGENTS.md | 6 ++++-- packages/void/test/unit/og.test.ts | 8 ++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 795651713..248caad69 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -96,7 +96,7 @@ Cache-Control: public, max-age=0, must-revalidate During local development, `Cache-Control` is `no-cache, no-store`. Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy. -Emoji and remote images are downloaded while the image renders. An emoji that the chosen style does not have, or an image that fails to download, fails the image unless you pass `images: { throwOnError: false }`; `noto` has no flags. +Emoji and remote images are downloaded while the image renders. An emoji that the chosen style does not have, or an image that fails to download, fails the image unless you pass `images: { throwOnError: false }`, which leaves it out; `noto` has no flags. ## Fonts diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 63e0c1de2..eb9165229 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -170,8 +170,10 @@ Fonts by the scripts in the text, under private family names, with Latin-1 taken out so Latin text keeps Takumi's built-in Geist. takumi-js stays on `~2.18`: 2.19.0 loads only the first font whose `unicode-range` covers a character, and draws the whole word as boxes when that font lacks the glyph -(Noto Sans JP's latin-ext subset has no Ł). Raise the range, and the install -command in `docs/guide/og-images.md`, once Takumi fixes it. +(Noto Sans JP's latin-ext subset has no Ł). Reordering the families or dropping +`unicode-range` does not avoid it. Check a fix in a fresh process, as 2.19.0 +falls back to faces an earlier render loaded, then raise the range and the +install command in `docs/guide/og-images.md`. Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index ee2910b19..dbdbb79fa 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -381,8 +381,10 @@ describe('void/og on Node.js targets', () => { expect(resolveRenderer).toThrow('The app has takumi-js 2.15.2.'); installTakumi('3.0.0'); expect(resolveRenderer).toThrow('The app has takumi-js 3.0.0.'); - installTakumi('2.17.0-beta.1'); - expect(resolveRenderer).toThrow('The app has takumi-js 2.17.0-beta.1.'); + installTakumi('2.18.1'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.18.1.'); + installTakumi('2.18.3-beta.1'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.18.3-beta.1.'); // takumi-js 2.19.0 draws boxes for text its first matching font lacks. installTakumi('2.19.0'); expect(resolveRenderer).toThrow('The app has takumi-js 2.19.0.'); @@ -406,6 +408,8 @@ describe('void/og on Node.js targets', () => { it("leaves a supported takumi-js in the app's dependencies to Node.js", () => { installTakumi(range.replace(/^[~^]/, '')); expect(resolveRenderer()).toBeUndefined(); + installTakumi('2.18.9'); + expect(resolveRenderer()).toBeUndefined(); }); it('leaves takumi-js that the app imports itself alone', () => { From aa979e1bec7ef7d186232c157a29b2be3f42e655 Mon Sep 17 00:00:00 2001 From: Futa Ogawa <3258736+ogawa0071@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:39:34 +0000 Subject: [PATCH 12/35] Use measured CPU figures in the void/og guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on V8 with takumi-js 2.18.2's Wasm build, a simple card takes 6–17 ms of CPU rather than under 10 ms, a richer one 31–81 ms, and the first render 120–150 ms. "Under 10 ms" suggested a simple card fits the Workers Free limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT --- docs/guide/og-images.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 248caad69..99387e27b 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -146,7 +146,7 @@ export const head = defineHead((c, props) => { ## Cache generated images -Each request renders the image again. A simple 1200 × 630 card takes under 10 ms of CPU time, one with gradients, bold text, or emoji 30 to 100 ms, and the first render in a new Worker instance about 200 ms more. [Workers Free allows 10 ms per request](../reference/config.md#worker) and fails requests from a Worker that keeps exceeding it, so on Workers Free, cache image routes with [revalidation](./edge/revalidation.md): +Each request renders the image again. A simple 1200 × 630 card takes around 10 ms of CPU time, one with gradients, bold text, or emoji 30 to 80 ms, and the first render in a new Worker instance more than 100 ms. [Workers Free allows 10 ms per request](../reference/config.md#worker) and fails requests from a Worker that keeps exceeding it, so on Workers Free, cache image routes with [revalidation](./edge/revalidation.md): ```json { From 6d1c6bd427e5c209b0a7eff47b5c4e896d808d2f Mon Sep 17 00:00:00 2001 From: Yangshun Date: Wed, 11 Nov 2026 21:55:36 +0800 Subject: [PATCH 13/35] fix(void): find void's package.json without assuming the module's depth The void/og Node.js check read `../package.json` relative to the plugin module, which only holds while the bundler emits it one directory below the package root. Walk up to the nearest package.json named `void`, and do it lazily so a lookup failure cannot break loading the plugin. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/installed-packages.ts | 1 + packages/void/src/plugin-node-target.ts | 27 +++++++++++++++++++++---- packages/void/test/unit/og.test.ts | 16 ++++++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/void/src/installed-packages.ts b/packages/void/src/installed-packages.ts index 1bec474c2..656ff5c39 100644 --- a/packages/void/src/installed-packages.ts +++ b/packages/void/src/installed-packages.ts @@ -5,6 +5,7 @@ import { parseSync } from 'vite'; import { staticModuleReferences } from './shared/module-references.ts'; type PackageManifest = { + readonly name?: string; readonly version?: string; readonly type?: string; readonly module?: unknown; diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index 00a312883..734c51738 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -339,7 +339,7 @@ export function nodeTargetPlugin( filter: { id: /^takumi-js(?:\/|$)/ }, handler(_id, importer) { // An app may also use takumi-js itself, at any version. - if (!importer || !realPath(importer).startsWith(`${VOID_PACKAGE_DIR}/`)) { + if (!importer || !realPath(importer).startsWith(`${getVoidPackageDir()}/`)) { return; } const range = readTakumiRange(); @@ -373,8 +373,27 @@ const RUNTIME_NAMES: Record = { deno: 'Deno', }; -// This module is one directory below the package root in src/ and in dist/. -const VOID_PACKAGE_DIR = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +let voidPackageDir: string | undefined; + +function getVoidPackageDir(): string { + return (voidPackageDir ??= findVoidPackageDir(toSlash(fileURLToPath(import.meta.url)))); +} + +/** + * The `void` package root above `file`. Walks up rather than assuming a depth, so the + * lookup holds wherever the bundler places this module in `dist/` (Bun's `node:module` + * has no `findPackageJSON`). + */ +export function findVoidPackageDir(file: string): string { + for (let dir = dirname(file); ; dir = dirname(dir)) { + if (readManifest(dir)?.name === 'void') { + return dir; + } + if (dirname(dir) === dir) { + throw new Error(`void/og: could not find void's package.json above ${file}.`); + } + } +} /** Vite resolves symlinks in importer paths unless `resolve.preserveSymlinks` is set. */ function realPath(path: string): string { @@ -387,7 +406,7 @@ function realPath(path: string): string { /** The takumi-js range void/og is built and tested against, from void's package.json. */ function readTakumiRange(): string { - const range = readManifest(VOID_PACKAGE_DIR)?.dependencies?.['takumi-js']; + const range = readManifest(getVoidPackageDir())?.dependencies?.['takumi-js']; if (!range || !/^[~^]\d+\.\d+\.\d+$/.test(range)) { throw new Error( `void/og: expected a ^ or ~ takumi-js range in void's package.json, got ${range}.`, diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index dbdbb79fa..aef5dcd7b 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -6,7 +6,7 @@ import { toSlash } from 'pathslash'; import { render } from 'takumi-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import voidManifest from '../../package.json' with { type: 'json' }; -import { nodeTargetPlugin } from '../../src/plugin-node-target.ts'; +import { findVoidPackageDir, nodeTargetPlugin } from '../../src/plugin-node-target.ts'; import { ImageResponse } from '../../src/runtime/og.ts'; vi.mock('takumi-js', async (importOriginal) => { @@ -416,4 +416,18 @@ describe('void/og on Node.js targets', () => { installTakumi('3.0.0'); expect(resolveRenderer(toSlash(join(root!, 'src', 'image.ts')))).toBeUndefined(); }); + + it("finds void's package.json wherever the bundler places the plugin", () => { + root = toSlash(mkdtempSync(join(tmpdir(), 'void-og-node-'))); + const voidDir = `${root}/node_modules/void`; + mkdirSync(`${voidDir}/dist/chunks`, { recursive: true }); + writeFileSync(`${voidDir}/package.json`, JSON.stringify({ name: 'void' })); + // A package.json without void's name, such as a nested `type` marker, is skipped. + writeFileSync(`${voidDir}/dist/package.json`, JSON.stringify({ type: 'module' })); + expect(findVoidPackageDir(`${voidDir}/dist/index.mjs`)).toBe(voidDir); + expect(findVoidPackageDir(`${voidDir}/dist/chunks/plugin-abc.mjs`)).toBe(voidDir); + expect(() => findVoidPackageDir(`${root}/elsewhere/index.mjs`)).toThrow( + "could not find void's package.json", + ); + }); }); From 3b8663f83ce5bd99f8b9f320568aed07a3c6bd85 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Wed, 11 Nov 2026 21:56:10 +0800 Subject: [PATCH 14/35] docs(og): note the renderer's Worker size on Cloudflare void/og adds Takumi's Wasm renderer, about 1.7 MB compressed, to the Worker. Say how that fits Workers Free's 3 MB compressed limit. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 99387e27b..545a9043f 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -160,6 +160,10 @@ Each request renders the image again. A simple 1200 × 630 card takes around 10 Void never caches paths under `/api`, so serve images from another path, such as `/og/...`. The cache ignores query strings, so put values in the path, or list the query parameters in [`routing.revalidateQueryAllowlist`](../reference/config.md#routing-revalidatequeryallowlist). +## Worker size + +On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4 MB, or 1.7 MB compressed. Cloudflare limits a Worker to 3 MB compressed on Workers Free and 10 MB on Workers Paid (see [Worker size](https://developers.cloudflare.com/workers/platform/limits/#worker-size)), so on Workers Free the rest of your app has about 1.3 MB. Apps that do not import `void/og` do not include the renderer. + ## Coming from `next/og` Change the import: From 753bbc53889f07d306a45f3b570b8c8f4c44c5a9 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 07:45:12 +0800 Subject: [PATCH 15/35] fix(og): let lang choose the Chinese, Japanese or Korean font before kana Any kana in an image chose Noto Sans JP before `lang` was read, so a Traditional Chinese title with a katakana loanword was drawn in the Japanese style. An explicit `lang` now decides first; kana and Hangul decide only without one. Google's Noto Sans SC, TC and KR include kana, so the kana still draw. The guide notes that one style covers all Han text in an image. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 4 +++- packages/void/src/runtime/og.ts | 20 +++++++++++++++++--- packages/void/test/unit/og.test.ts | 10 ++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 545a9043f..73579b59b 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -102,12 +102,14 @@ Emoji and remote images are downloaded while the image renders. An emoji that th Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching font from Google Fonts while rendering, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. -Chinese characters are drawn in the Japanese style, as in `next/og`, unless the text has Korean around them, or the `lang` option or the root element's `lang` attribute names the language: +Chinese characters are drawn in the Japanese style, as in `next/og`, unless the `lang` option or the root element's `lang` attribute names the language, or the text has Korean around them. A `lang` wins over the text, so a Chinese title with a katakana word keeps the Chinese style: ```ts return new ImageResponse(`
${title}
`); ``` +One style applies to all Chinese characters in an image, so an image cannot mix Japanese and Chinese styles through `lang` on inner elements. + To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data, as in `next/og`: ```ts diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index ed5fcc434..ca9ca0c31 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -286,17 +286,31 @@ async function keyedFont(font: Font): Promise { // Satori's ja-JP code takes Han and comes first, so Han text is Japanese there // unless an element's `lang` moves another language first: // https://github.com/vercel/satori/blob/0.33.5/src/language.ts#L57-L84 +// Takumi takes the first font with a glyph and ignores `lang`, so one family +// draws all Han text in an image, chosen from the `lang` option or the root +// element's `lang`. function cjkFamily(text: string, lang = ''): string { - if (/[\p{sc=Hiragana}\p{sc=Katakana}]/u.test(text)) { + // An explicit language wins over the scripts in the text: a Traditional + // Chinese title with a katakana loanword (タワー) is still Chinese. Google's + // Noto Sans SC, TC and KR all include kana, so no character is left without + // a glyph. + const tag = lang.toLowerCase(); + if (tag.startsWith('ja')) { return 'Noto Sans JP'; } - const tag = lang.toLowerCase(); - if (tag.startsWith('ko') || /\p{sc=Hangul}/u.test(text)) { + if (tag.startsWith('ko')) { return 'Noto Sans KR'; } if (tag.startsWith('zh')) { return /hant|-tw|-hk|-mo/.test(tag) ? 'Noto Sans TC' : 'Noto Sans SC'; } + // Without one, kana marks Japanese and Hangul marks Korean, as in Satori. + if (/[\p{sc=Hiragana}\p{sc=Katakana}]/u.test(text)) { + return 'Noto Sans JP'; + } + if (/\p{sc=Hangul}/u.test(text)) { + return 'Noto Sans KR'; + } return 'Noto Sans JP'; } diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index aef5dcd7b..d20fd17b5 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -268,6 +268,16 @@ describe('ImageResponse fallback fonts', () => { expect(await family('
漢字 한국어
')).toEqual([ 'Noto Sans KR:wght@400', ]); + // Kana alone marks Japanese only when no `lang` names the language. + expect(await family('
東京タワー
')).toEqual([ + 'Noto Sans JP:wght@400', + ]); + expect(await family('
東京タワー
')).toEqual([ + 'Noto Sans TC:wght@400', + ]); + expect(await family('
咖啡コーヒー
', 'zh-CN')).toEqual([ + 'Noto Sans SC:wght@400', + ]); }); it('draws emoji as images, fetched once per isolate, without a font', async () => { From 5c77568bf6f0f3a35fdfb967536e47ee40deaa12 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 07:51:52 +0800 Subject: [PATCH 16/35] fix(og): drop only the fallback font files that fail to download When any fallback font file failed, the image was rendered again with no fallback fonts at all, so a missing symbol font also dropped the Japanese one. Each image now records the files that fail, warns once per file, and retries without only those. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/runtime/og.ts | 36 +++++++++++++++------ packages/void/test/unit/og.test.ts | 50 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index ca9ca0c31..324914ec7 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -184,23 +184,34 @@ async function renderImage(element: RenderInput, options: RenderImageOptions): P // https://github.com/kane50613/takumi/blob/takumi@2.18.2/takumi-helpers/src/renderer.ts#L280-L308 fonts, }); + // Keys of the fallback files that failed to download during this image. + const failed = new Set(); const [fonts, fallback] = await Promise.all([ Promise.all(((await options.fonts) ?? []).map(keyedFont)), - fallbackFonts(node, options, images), + fallbackFonts(node, options, images, failed), ]); if (fallback.length === 0) { return renderNode(fonts); } - try { - return await renderNode([...fonts, ...fallback]); - } catch (error) { - // A font that fails to download leaves its glyphs missing rather than - // failing the image, as in @vercel/og `// src/og.ts`: loadDynamicAsset. - if (!(error instanceof FallbackFontError)) { - throw error; + // A font that fails to download leaves its glyphs missing rather than + // failing the image, as in @vercel/og `// src/og.ts`: loadDynamicAsset. + // Takumi downloads the files together and rejects the render on the first + // failure, so retry without the files that failed, and only those: a flaky + // symbol font must not also drop the Japanese one. Files that did download + // stay registered in Takumi, so a retry fetches none of them again. + let remaining = fallback; + for (;;) { + try { + return await renderNode([...fonts, ...remaining]); + } catch (error) { + if (!(error instanceof FallbackFontError)) { + throw error; + } + const rest = remaining.filter((subset) => !failed.has(subset.key)); + // Each retry must drop a file, so the loop ends. A failure this image + // did not record would retry forever; drop every fallback font instead. + remaining = rest.length < remaining.length ? rest : []; } - console.warn(`${error.message} Rendering without it.`, error.cause); - return renderNode(fonts); } } @@ -318,6 +329,7 @@ async function fallbackFonts( node: Node, options: RenderImageOptions, images: Exclude>, + failed: Set, ): Promise> { const missing = Array.from(collectCodepoints(node)) .filter((codepoint) => codepoint === 0xb5 || codepoint > 0xff) @@ -376,6 +388,10 @@ async function fallbackFonts( key: `void/og ${subset.key}`, data: () => subset.data().catch((cause: unknown) => { + // Record and report each failed file here: when several fail at + // once, Takumi's rejection carries only the first. + failed.add(`void/og ${subset.key}`); + console.warn(`void/og: could not download ${subset.name}. Rendering without it.`, cause); throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause }); }), }; diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index d20fd17b5..5ab96ac5b 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -322,6 +322,56 @@ describe('ImageResponse fallback fonts', () => { ]); }); + it('leaves out only the fallback files that fail to download', async () => { + const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // One subset per family. The Japanese file downloads; both symbol files are missing. + const requests = stubFetch((url) => { + if (url.startsWith('https://fonts.googleapis.com/')) { + const family = new URL(url).searchParams.get('family')!.split(':')[0]!; + const range = family === 'Noto Sans JP' ? 'U+65E5, U+672C' : 'U+2605'; + const file = `https://fonts.gstatic.com/${family.replaceAll(' ', '-')}.woff2`; + return new Response( + `@font-face { font-family: '${family}'; src: url(${file}) format('woff2'); unicode-range: ${range}; }`, + { headers: { 'content-type': 'text/css' } }, + ); + } + return url.endsWith('Noto-Sans-JP.woff2') + ? new Response(new Uint8Array([0, 1, 0, 0])) + : new Response(null, { status: 404 }); + }); + // Stand in for Takumi's font loading, one file at a time, so the order of + // failures is fixed; the bytes above are not a real font to draw with. + const original = vi.mocked(render).getMockImplementation(); + vi.mocked(render).mockClear(); + vi.mocked(render).mockImplementation(async (_node, options) => { + for (const font of (await options?.fonts) ?? []) { + if (typeof font === 'object' && 'data' in font && typeof font.data === 'function') { + await font.data(); + } + } + return new Uint8Array(); + }); + try { + await new ImageResponse('
日本 ★
').arrayBuffer(); + } finally { + vi.mocked(render).mockImplementation(original!); + } + // Noto Sans JP, Symbols and Symbols 2. Each failure drops that file only, + // so the Japanese font stays, where the image used to lose every fallback. + const attempts = vi + .mocked(render) + .mock.calls.map(([, options]) => + ((options?.fonts ?? []) as Array<{ name?: string }>).map((font) => font.name), + ); + expect(attempts.map((fonts) => fonts.length)).toEqual([3, 2, 1]); + expect(attempts.at(-1)).toEqual(['void/og Noto Sans JP']); + // A file that failed is not requested again. + expect( + requests.filter((url) => url.includes('Symbols')).filter((url) => url.endsWith('.woff2')), + ).toHaveLength(2); + expect(warned).toHaveBeenCalledTimes(2); + }); + it('renders without fallback fonts when Google Fonts cannot be reached', async () => { const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); stubFetch(() => { From 59b61dfb22d595503200d224acb06e57335c8df3 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 07:52:22 +0800 Subject: [PATCH 17/35] fix(og): draw bold fallback text with the fonts' weight axis Fallback fonts were requested from Google Fonts at weight 400 only. For variable families (Noto Sans JP, KR, SC, TC, the Indic, Thai, Arabic and Hebrew families, Noto Sans, Symbols and Geist) Takumi then pinned the face to 400, so bold Chinese, Japanese or Korean text was only thickened and 700 looked the same as 900. Request those families as 100..900, which Google serves as a weight range that Takumi leaves on the font's axis. Noto Sans Math and Noto Sans Symbols 2 have no weight axis and reject a range, so they stay at 400. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/runtime/og.ts | 14 +++++++- packages/void/test/unit/og.test.ts | 51 +++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index 324914ec7..ed4c4163d 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -97,6 +97,16 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols 2'], ]; +// The fallback families Google Fonts serves only at weight 400. Every other +// family above is a variable font with a 100–900 weight axis, and is asked +// for as that range: Google then serves `font-weight: 100 900`, Takumi leaves +// the face without a fixed weight, and `font-weight: 700` drives the axis. +// Asked for at a single 400, a variable face is pinned to 400 and bold text is +// only thickened, so 700 and 900 look the same. Asking for a range from these +// two fails the whole request with HTTP 400, which would lose the family. +// Found by asking css2 for each family with `wght@100..900`. +const SINGLE_WEIGHT_FAMILIES = new Set(['Noto Sans Math', 'Noto Sans Symbols 2']); + // What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found // by rendering each code point with takumi-js 2.18: some accented letters and // combining marks, spaces, format characters and variation selectors, quotes, @@ -354,7 +364,9 @@ async function fallbackFonts( [...families].map(async (family) => { try { return await googleFonts({ - families: [family], + families: [ + SINGLE_WEIGHT_FAMILIES.has(family) ? family : { name: family, weight: '100..900' }, + ], fetch: images?.fetch, timeout: images?.timeout, }); diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 5ab96ac5b..4eaf4959f 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -210,7 +210,7 @@ describe('ImageResponse fallback fonts', () => { expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); const css = new URL(requests[0]!); expect(css.origin + css.pathname).toBe('https://fonts.googleapis.com/css2'); - expect(css.searchParams.getAll('family')).toEqual(['Noto Sans JP:wght@400']); + expect(css.searchParams.getAll('family')).toEqual(['Noto Sans JP:wght@100..900']); // The subset that mixes ASCII with 日 still serves 日; the Latin subset and // the subset the text does not use are never fetched. expect(requests.slice(1).sort()).toEqual([ @@ -227,7 +227,36 @@ describe('ImageResponse fallback fonts', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch(() => new Response(null, { status: 404 })); await new ImageResponse('
Łódź 5µs
').arrayBuffer(); - expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Geist:wght@400']); + expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Geist:wght@100..900']); + }); + + it('leaves variable fallback faces free to draw bold', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Google answers a weight range with `font-weight: 100 900`, and a single + // weight with that weight. + stubFetch((url) => { + if (!url.startsWith('https://fonts.googleapis.com/')) { + return new Response(null, { status: 404 }); + } + const [family, weight] = new URL(url).searchParams.get('family')!.split(':wght@'); + const range = family === 'Noto Sans JP' ? 'U+65E5' : 'U+2192'; + return new Response( + `@font-face { font-family: '${family}'; font-weight: ${weight!.replace('..', ' ')}; src: url(https://fonts.gstatic.com/${weight}.woff2) format('woff2'); unicode-range: ${range}; }`, + { headers: { 'content-type': 'text/css' } }, + ); + }); + vi.mocked(render).mockClear(); + await new ImageResponse('
日 → B
').arrayBuffer(); + const [, options] = vi.mocked(render).mock.calls[0]!; + const weights = Object.fromEntries( + ((options?.fonts ?? []) as Array<{ name: string; weight?: number }>).map((font) => [ + font.name, + font.weight, + ]), + ); + // A face without a fixed weight follows `font-weight` along its axis; one + // pinned to 400 is only thickened for bold text. + expect(weights).toEqual({ 'void/og Noto Sans JP': undefined, 'void/og Noto Sans Math': 400 }); }); it('takes a whole font for a subset without ranges, beyond Latin-1', async () => { @@ -257,26 +286,26 @@ describe('ImageResponse fallback fonts', () => { return new URL(requests[0]!).searchParams.getAll('family'); }; expect(await family('
東京都
')).toEqual([ - 'Noto Sans JP:wght@400', + 'Noto Sans JP:wght@100..900', ]); expect(await family('
東京都
', 'zh-TW')).toEqual([ - 'Noto Sans TC:wght@400', + 'Noto Sans TC:wght@100..900', ]); expect(await family('
东京都
')).toEqual([ - 'Noto Sans SC:wght@400', + 'Noto Sans SC:wght@100..900', ]); expect(await family('
漢字 한국어
')).toEqual([ - 'Noto Sans KR:wght@400', + 'Noto Sans KR:wght@100..900', ]); // Kana alone marks Japanese only when no `lang` names the language. expect(await family('
東京タワー
')).toEqual([ - 'Noto Sans JP:wght@400', + 'Noto Sans JP:wght@100..900', ]); expect(await family('
東京タワー
')).toEqual([ - 'Noto Sans TC:wght@400', + 'Noto Sans TC:wght@100..900', ]); expect(await family('
咖啡コーヒー
', 'zh-CN')).toEqual([ - 'Noto Sans SC:wght@400', + 'Noto Sans SC:wght@100..900', ]); }); @@ -317,8 +346,8 @@ describe('ImageResponse fallback fonts', () => { expect(families).toEqual([ 'Noto Sans Math:wght@400', 'Noto Sans Symbols 2:wght@400', - 'Noto Sans Symbols:wght@400', - 'Noto Sans:wght@400', + 'Noto Sans Symbols:wght@100..900', + 'Noto Sans:wght@100..900', ]); }); From de27bcfcd5a9de07a21fe9c0d3f311198e438446 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 07:58:10 +0800 Subject: [PATCH 18/35] docs(og): describe void/og on its own Drop the comparisons with other libraries from the Open Graph guide and the API reference, and the migration section. Keep its note that a font entry's `lang` does not choose the font, under Fonts. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 24 ++++-------------------- docs/reference/api.md | 2 +- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 73579b59b..d7b2455fe 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -4,7 +4,7 @@ outline: deep # Open Graph Images -Use `ImageResponse` from `void/og` to generate Open Graph and social preview images in a route handler. It takes the same arguments as `ImageResponse` from `next/og` and draws JSX or HTML to a PNG with [Takumi](https://takumi.kane.tw/docs/), without a headless browser. +Use `ImageResponse` from `void/og` to generate Open Graph and social preview images in a route handler. It draws JSX or HTML to a PNG with [Takumi](https://takumi.kane.tw/docs/), without a headless browser. ## Create an image route @@ -102,7 +102,7 @@ Emoji and remote images are downloaded while the image renders. An emoji that th Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching font from Google Fonts while rendering, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. -Chinese characters are drawn in the Japanese style, as in `next/og`, unless the `lang` option or the root element's `lang` attribute names the language, or the text has Korean around them. A `lang` wins over the text, so a Chinese title with a katakana word keeps the Chinese style: +Chinese characters are drawn in the Japanese style unless the `lang` option or the root element's `lang` attribute names the language, or the text has Korean around them. A `lang` wins over the text, so a Chinese title with a katakana word keeps the Chinese style: ```ts return new ImageResponse(`
${title}
`); @@ -110,7 +110,7 @@ return new ImageResponse(`
${title}
One style applies to all Chinese characters in an image, so an image cannot mix Japanese and Chinese styles through `lang` on inner elements. -To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data, as in `next/og`: +To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data: ```ts return new ImageResponse(postCard(post), { @@ -121,7 +121,7 @@ return new ImageResponse(postCard(post), { }); ``` -Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too. +Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too. A font entry's `lang` is accepted but does not choose the font for a language; use the `lang` option or attribute for Chinese characters instead. ## Add the image to a page @@ -166,22 +166,6 @@ Void never caches paths under `/api`, so serve images from another path, such as On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4 MB, or 1.7 MB compressed. Cloudflare limits a Worker to 3 MB compressed on Workers Free and 10 MB on Workers Paid (see [Worker size](https://developers.cloudflare.com/workers/platform/limits/#worker-size)), so on Workers Free the rest of your app has about 1.3 MB. Apps that do not import `void/og` do not include the renderer. -## Coming from `next/og` - -Change the import: - -```ts -import { ImageResponse } from 'next/og'; // [!code --] -import { ImageResponse } from 'void/og'; // [!code ++] -``` - -Then: - -- Move `app/api/og/route.tsx` to a route file outside `/api`, such as `routes/og.tsx`, so its images can be [cached](#cache-generated-images). -- Read the request from Hono's context: `c.req.url`, `c.req.param()`, and `c.req.query()`. -- Void draws images with Takumi rather than Satori, so layout can differ slightly. See [Takumi vs. Satori](https://takumi.kane.tw/docs/comparison-to-satori). -- A font's `lang` is accepted but does not choose the font for a language. Use the `lang` option or attribute for Chinese characters instead. - ## Node.js, Bun, and Deno On the [Node.js, Bun, and Deno targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's `dependencies`, not `devDependencies`, so production installs include it: diff --git a/docs/reference/api.md b/docs/reference/api.md index 78744286b..29e097bf6 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -49,7 +49,7 @@ Import Void's app-facing APIs from these paths: | `void/live` | `defineLiveStream`, SSE topic fanout runtime types. Server-only. | | `void/live/client` | `connectLiveStream`, browser helper for one SSE connection plus POST subscribe/unsubscribe control. | | `void/response` | `convertReturnValue` | -| `void/og` | `ImageResponse` and `ImageResponseOptions`. Renders JSX or HTML to a 1200 × 630 PNG `Response`, with the same arguments as `next/og`. See [Open Graph Images](../guide/og-images.md). | +| `void/og` | `ImageResponse` and `ImageResponseOptions`. Renders JSX or HTML to a 1200 × 630 PNG `Response`. See [Open Graph Images](../guide/og-images.md). | | `void/validator` | `runValidation`, `ValidatorSlots`, `HandlerInput` | | `void/drizzle-zod` | Re-exports [`drizzle-zod`](https://orm.drizzle.team/docs/zod) for schema-derived Zod validators for Drizzle tables | | `void/drizzle-valibot` | Re-exports [`drizzle-valibot`](https://orm.drizzle.team/docs/valibot) for schema-derived Valibot validators for Drizzle tables | From 49b35f9b87f5451fdaca7bccbefa9e9f51888aaf Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 10:04:17 +0800 Subject: [PATCH 19/35] chore(og): upgrade takumi-js to 2.20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.20.0 fixes the 2.19.0 font fallback regression: Japanese next to "Łódź", symbols, mixed scripts, bold Japanese and twemoji render pixel-identical to 2.18.2 with both the native and Wasm backends, and the built-in Geist draws the same code points. The Wasm grows from 1.7 MB to 1.9 MB compressed. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 4 +- packages/void/AGENTS.md | 15 +++-- packages/void/package.json | 2 +- packages/void/src/runtime/og.ts | 10 +-- packages/void/test/unit/og.test.ts | 10 +-- pnpm-lock.yaml | 102 ++++++++++++++--------------- 6 files changed, 72 insertions(+), 71 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index d7b2455fe..f5fecc07e 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -164,14 +164,14 @@ Void never caches paths under `/api`, so serve images from another path, such as ## Worker size -On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4 MB, or 1.7 MB compressed. Cloudflare limits a Worker to 3 MB compressed on Workers Free and 10 MB on Workers Paid (see [Worker size](https://developers.cloudflare.com/workers/platform/limits/#worker-size)), so on Workers Free the rest of your app has about 1.3 MB. Apps that do not import `void/og` do not include the renderer. +On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4.3 MB, or 1.9 MB compressed. Cloudflare limits a Worker to 3 MB compressed on Workers Free and 10 MB on Workers Paid (see [Worker size](https://developers.cloudflare.com/workers/platform/limits/#worker-size)), so on Workers Free the rest of your app has about 1.1 MB. Apps that do not import `void/og` do not include the renderer. ## Node.js, Bun, and Deno On the [Node.js, Bun, and Deno targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's `dependencies`, not `devDependencies`, so production installs include it: ```sh -npm install takumi-js@~2.18.2 +npm install takumi-js@~2.20.0 ``` If the build cannot find a version of `takumi-js` that Void supports, it fails and prints the command to install one. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index eb9165229..39a854d5e 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -167,13 +167,14 @@ the app root resolves a `takumi-js` in the range void declares. Takumi's JSX helper imports `react` as an optional peer; Vite stubs it in apps without React, and Takumi catches the failed import. Fallback fonts come from Google Fonts by the scripts in the text, under private family names, with Latin-1 -taken out so Latin text keeps Takumi's built-in Geist. takumi-js stays on -`~2.18`: 2.19.0 loads only the first font whose `unicode-range` covers a -character, and draws the whole word as boxes when that font lacks the glyph -(Noto Sans JP's latin-ext subset has no Ł). Reordering the families or dropping -`unicode-range` does not avoid it. Check a fix in a fresh process, as 2.19.0 -falls back to faces an earlier render loaded, then raise the range and the -install command in `docs/guide/og-images.md`. +taken out so Latin text keeps Takumi's built-in Geist. takumi-js is on +`~2.20`. Avoid 2.19.0: it loads only the first font whose `unicode-range` covers +a character, and draws the whole word as boxes when that font lacks the glyph +(Noto Sans JP's latin-ext subset has no Ł). Before raising the range, render +Japanese next to "Łódź" with the new version in a fresh process, as 2.19.0 falls +back to faces an earlier render loaded, and check that the built-in Geist still +draws the code points `BUILT_IN` lists. Then raise the range, the install +command and the Worker size in `docs/guide/og-images.md`. Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). diff --git a/packages/void/package.json b/packages/void/package.json index 4d18a2622..8fbb26270 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -391,7 +391,7 @@ "postal-mime": "catalog:", "proper-lockfile": "^4.1.2", "smol-toml": "catalog:", - "takumi-js": "~2.18.2", + "takumi-js": "~2.20.0", "terminal-link": "^5.0.0", "yaml": "^2.9.1" }, diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index ed4c4163d..705e76cb9 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -108,10 +108,10 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ const SINGLE_WEIGHT_FAMILIES = new Set(['Noto Sans Math', 'Noto Sans Symbols 2']); // What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found -// by rendering each code point with takumi-js 2.18: some accented letters and -// combining marks, spaces, format characters and variation selectors, quotes, -// dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing: no served subset -// has them. +// by rendering each code point with takumi-js 2.18 (2.20 draws the same): some +// accented letters and combining marks, spaces, format characters and variation +// selectors, quotes, dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing: +// no served subset has them. /* eslint-disable no-misleading-character-class -- the marks and selectors are listed on their own */ const BUILT_IN = /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{304}\u{308}\u{34F}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FE00}-\u{FE0F}\u{FEFF}]/u; @@ -191,7 +191,7 @@ async function renderImage(element: RenderInput, options: RenderImageOptions): P images: { fetchCache, ...images }, // Takumi uses every font registered in the process when it gets no list, // which includes earlier images' fallback fonts: - // https://github.com/kane50613/takumi/blob/takumi@2.18.2/takumi-helpers/src/renderer.ts#L280-L308 + // https://github.com/kane50613/takumi/blob/takumi@2.20.0/takumi-helpers/src/renderer.ts#L384-L412 fonts, }); // Keys of the fallback files that failed to download during this image. diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 4eaf4959f..a7f292d71 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -470,10 +470,10 @@ describe('void/og on Node.js targets', () => { expect(resolveRenderer).toThrow('The app has takumi-js 2.15.2.'); installTakumi('3.0.0'); expect(resolveRenderer).toThrow('The app has takumi-js 3.0.0.'); - installTakumi('2.18.1'); - expect(resolveRenderer).toThrow('The app has takumi-js 2.18.1.'); - installTakumi('2.18.3-beta.1'); - expect(resolveRenderer).toThrow('The app has takumi-js 2.18.3-beta.1.'); + installTakumi('2.18.2'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.18.2.'); + installTakumi('2.20.1-beta.1'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.20.1-beta.1.'); // takumi-js 2.19.0 draws boxes for text its first matching font lacks. installTakumi('2.19.0'); expect(resolveRenderer).toThrow('The app has takumi-js 2.19.0.'); @@ -497,7 +497,7 @@ describe('void/og on Node.js targets', () => { it("leaves a supported takumi-js in the app's dependencies to Node.js", () => { installTakumi(range.replace(/^[~^]/, '')); expect(resolveRenderer()).toBeUndefined(); - installTakumi('2.18.9'); + installTakumi('2.20.9'); expect(resolveRenderer()).toBeUndefined(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 391fa58c8..8a38e77df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -874,8 +874,8 @@ importers: specifier: 'catalog:' version: 1.9.1 takumi-js: - specifier: ~2.18.2 - version: 2.18.2(csstype@3.2.3)(react@19.3.1) + specifier: ~2.20.0 + version: 2.20.0(csstype@3.2.3)(react@19.3.1) terminal-link: specifier: ^5.0.0 version: 5.0.0 @@ -5405,60 +5405,60 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@takumi-rs/core-darwin-arm64@2.18.2': - resolution: {integrity: sha512-7JjXUdT1ywBGghWAR8nim7emczQRsHV4TkLspITL3a853AahtNIwLTMMb/uK0KVAPwQR0plFEqzVU23iAzRBWQ==} + '@takumi-rs/core-darwin-arm64@2.20.0': + resolution: {integrity: sha512-wescbJ8pm0fBN8uXxjiZUDqN7JkdDDmxt8Gq1pSrpRCuGxvAnoj3agMMhHtJ5Ty+BufkIwSvV2cBXRLM4wyYZw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@takumi-rs/core-darwin-x64@2.18.2': - resolution: {integrity: sha512-YwExtQ81c91GLVKDnECruM5bM41a8flnXtQ/KTMSVovX74xzUZCe98XJjcyvTDdksF+bGMG9aqsmwX833HiMQw==} + '@takumi-rs/core-darwin-x64@2.20.0': + resolution: {integrity: sha512-idCQC8PLuuDFQ4c2YEx142Q1WM+GbrR3RqNxuJay5MxfCXcZ42dv+96xGcgs0U2AWOk1GkY/vTY4y/Om7O/QHA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@takumi-rs/core-linux-arm64-gnu@2.18.2': - resolution: {integrity: sha512-NQcTa0mfIGAuGSLiTyJSTWn85uSj9Y7+adp+2yIaV89SZ1/thYg7HwGHMwpHQyYCZ2mTFWOwlh264nqYO1RvLw==} + '@takumi-rs/core-linux-arm64-gnu@2.20.0': + resolution: {integrity: sha512-TeqwOXlVdjAoSoOVQVoJOER4upQnh7nX6QJnYqVDlbByiSkMhqYULXc7I83NqzkL3U/fbT7/oASNeGWAI2axBA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] libc: [glibc] - '@takumi-rs/core-linux-arm64-musl@2.18.2': - resolution: {integrity: sha512-5oaEkOZfyVolD6KxQ77aFeSHI1W7W8Oai4W0vBGzXyyXVLeONfWZkBFWPpXO0ojnPsKa2JVGL5mKFqRiwKAEQg==} + '@takumi-rs/core-linux-arm64-musl@2.20.0': + resolution: {integrity: sha512-gOcZxdGq7ebhqRN1Ue3FYJV7jKFG8yG99Opiky13viXXH8hkOde+OKUMe2kqWRH42j7p5EhBPj2qmdUBXXQBCQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] libc: [musl] - '@takumi-rs/core-linux-x64-gnu@2.18.2': - resolution: {integrity: sha512-UG95eda/4ySoJqV1QGl7ykH7AMKySvSkpafSVuIHefzu6fiQbyCOE9GRx/BWx0wKIE3ozQXteIP3ydnHQPJBJw==} + '@takumi-rs/core-linux-x64-gnu@2.20.0': + resolution: {integrity: sha512-MwIdLqyMUUz+6wuxkGJZyEO9alZODuDqe826HAG2+RErwpSUwfqtD2pHPWG9bnWkr7qpVeziFut6Ng68lYzcvQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] libc: [glibc] - '@takumi-rs/core-linux-x64-musl@2.18.2': - resolution: {integrity: sha512-eTufsMdBMk8tvER1hBfh2RkE2Qv6ov05r9UEIXFDwZLVlXoHr1DbEp/k8Ze1a1DdLpZJizVQ9BKFKBUk8+SWnQ==} + '@takumi-rs/core-linux-x64-musl@2.20.0': + resolution: {integrity: sha512-bM9QEwOuKg0+im1M+XjownCCdN+zHKQXGblDttpKuoa2QEd4F9t0JJcQMBSjLFucC2sf+rGicAmayMsQRxwgfg==} engines: {node: '>=18'} cpu: [x64] os: [linux] libc: [musl] - '@takumi-rs/core-win32-arm64-msvc@2.18.2': - resolution: {integrity: sha512-6C6ONbCg65smpkYRik9o3shON/8B8OP3VFpYXvSVHiW59boI+9SQKmOysTQBrZpSlUG5ZPAyoJHCfcFR5nawWA==} + '@takumi-rs/core-win32-arm64-msvc@2.20.0': + resolution: {integrity: sha512-VwGj5dsODmHYV09P3AFhvcHL3hk8cS3Y0PYYHxFQtopwcWTsDN522+siotYL1lXZaEag9FoMJOuqiuZsVGgR3w==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@takumi-rs/core-win32-x64-msvc@2.18.2': - resolution: {integrity: sha512-ui0aYjmhx9vsNlNc2ktLwZsk308132HwaQyp6OR3mQ40/svfnASJDxMyTdmh9O1Vv0ynph9idoj4eEh/0YQAjw==} + '@takumi-rs/core-win32-x64-msvc@2.20.0': + resolution: {integrity: sha512-3am5JIowXAETxUHwQh8MONCagATpN4jFiXISKT2djvceJxrriPZKwWNjB7zTUzZmVLYTbslMrG7x2+U1wPndYg==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@takumi-rs/core@2.18.2': - resolution: {integrity: sha512-9g+oXNTAKLp6ZliXiQ0BkMXVPrXgaX34wjY7jO9osxbgFtBi3QLhnrDMyKFpSvXuOcbjdSCi1LkJ0YzN95bMQw==} + '@takumi-rs/core@2.20.0': + resolution: {integrity: sha512-28uryNYSyuXZWv5uBfJiWDrRXVdAunkU0sZiB7KG2WgLej64pUNL6jZSTQa+gBXfVu+3DdCz58OnpONBO6EUqQ==} engines: {node: '>=18'} peerDependencies: csstype: '*' @@ -5466,8 +5466,8 @@ packages: csstype: optional: true - '@takumi-rs/helpers@2.18.2': - resolution: {integrity: sha512-HHVdYmGxLos+Jn67mYU64gc+nW372zP8pF6zCnLzn3JMbGtcX5jV7a0N5uZseXkGrfGR07tsdWQbTkpO4Ms+WA==} + '@takumi-rs/helpers@2.20.0': + resolution: {integrity: sha512-efdeG4Ujsw0IEYXhq3lUN1UiKhK6f3tak7VSMrCEWSvumFs0q5moBHSLdsR6aJhPNsYei21Ea6Rjtv94/qdyjg==} engines: {node: '>=18'} peerDependencies: preact: ^10.0.0 || ^11.0.0 @@ -5478,8 +5478,8 @@ packages: react: optional: true - '@takumi-rs/wasm@2.18.2': - resolution: {integrity: sha512-Ap8rA5TRcfLRpKagM3NK0JGKo4TO0DrTI7fAFpKM5R04yIVvibT8Qq/FqFcYBqWdlVxfxwzdMAdn/Glxkx9WAA==} + '@takumi-rs/wasm@2.20.0': + resolution: {integrity: sha512-2Xfu55Ye8gmJMXQEDun/5zl/78LyCXKUjP503alH1tG0OyotkkWpZbgW8iWGuxxf4IJvp9kIaO+NLGbq4B6J8A==} engines: {node: '>=20.19'} peerDependencies: csstype: '*' @@ -9773,8 +9773,8 @@ packages: tailwindcss@4.3.5: resolution: {integrity: sha512-FcQk9BuffmEHJP2G0XmB2SplU1aLTI7j07Kunk+U74Ny+XiWOtzXz9aujFZ/avntw1e2cOv/3PkgzflQ7gX2vQ==} - takumi-js@2.18.2: - resolution: {integrity: sha512-FFl1sctUFyMgoVghDxZYDPOcxIKYmdZodkc1w+ozrRl2mW3OYlqWklyGR1c3zHoMFlqDZO170/v51ZmV/38JXw==} + takumi-js@2.20.0: + resolution: {integrity: sha512-WnqocRxTlQcDeA6Q0Awcz3+diSF3UGA2hmIL3pLsqiGXZHpeeRVpVad97EO33sYiMT6+L2kMya28EVGJbjCyfg==} engines: {node: '>=20.19'} tapable@2.3.3: @@ -13630,54 +13630,54 @@ snapshots: tailwindcss: 4.3.5 vite: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' - '@takumi-rs/core-darwin-arm64@2.18.2': + '@takumi-rs/core-darwin-arm64@2.20.0': optional: true - '@takumi-rs/core-darwin-x64@2.18.2': + '@takumi-rs/core-darwin-x64@2.20.0': optional: true - '@takumi-rs/core-linux-arm64-gnu@2.18.2': + '@takumi-rs/core-linux-arm64-gnu@2.20.0': optional: true - '@takumi-rs/core-linux-arm64-musl@2.18.2': + '@takumi-rs/core-linux-arm64-musl@2.20.0': optional: true - '@takumi-rs/core-linux-x64-gnu@2.18.2': + '@takumi-rs/core-linux-x64-gnu@2.20.0': optional: true - '@takumi-rs/core-linux-x64-musl@2.18.2': + '@takumi-rs/core-linux-x64-musl@2.20.0': optional: true - '@takumi-rs/core-win32-arm64-msvc@2.18.2': + '@takumi-rs/core-win32-arm64-msvc@2.20.0': optional: true - '@takumi-rs/core-win32-x64-msvc@2.18.2': + '@takumi-rs/core-win32-x64-msvc@2.20.0': optional: true - '@takumi-rs/core@2.18.2(csstype@3.2.3)(react@19.3.1)': + '@takumi-rs/core@2.20.0(csstype@3.2.3)(react@19.3.1)': dependencies: - '@takumi-rs/helpers': 2.18.2(react@19.3.1) + '@takumi-rs/helpers': 2.20.0(react@19.3.1) optionalDependencies: - '@takumi-rs/core-darwin-arm64': 2.18.2 - '@takumi-rs/core-darwin-x64': 2.18.2 - '@takumi-rs/core-linux-arm64-gnu': 2.18.2 - '@takumi-rs/core-linux-arm64-musl': 2.18.2 - '@takumi-rs/core-linux-x64-gnu': 2.18.2 - '@takumi-rs/core-linux-x64-musl': 2.18.2 - '@takumi-rs/core-win32-arm64-msvc': 2.18.2 - '@takumi-rs/core-win32-x64-msvc': 2.18.2 + '@takumi-rs/core-darwin-arm64': 2.20.0 + '@takumi-rs/core-darwin-x64': 2.20.0 + '@takumi-rs/core-linux-arm64-gnu': 2.20.0 + '@takumi-rs/core-linux-arm64-musl': 2.20.0 + '@takumi-rs/core-linux-x64-gnu': 2.20.0 + '@takumi-rs/core-linux-x64-musl': 2.20.0 + '@takumi-rs/core-win32-arm64-msvc': 2.20.0 + '@takumi-rs/core-win32-x64-msvc': 2.20.0 csstype: 3.2.3 transitivePeerDependencies: - preact - react - '@takumi-rs/helpers@2.18.2(react@19.3.1)': + '@takumi-rs/helpers@2.20.0(react@19.3.1)': optionalDependencies: react: 19.3.1 - '@takumi-rs/wasm@2.18.2(csstype@3.2.3)(react@19.3.1)': + '@takumi-rs/wasm@2.20.0(csstype@3.2.3)(react@19.3.1)': dependencies: - '@takumi-rs/helpers': 2.18.2(react@19.3.1) + '@takumi-rs/helpers': 2.20.0(react@19.3.1) optionalDependencies: csstype: 3.2.3 transitivePeerDependencies: @@ -18558,11 +18558,11 @@ snapshots: tailwindcss@4.3.5: {} - takumi-js@2.18.2(csstype@3.2.3)(react@19.3.1): + takumi-js@2.20.0(csstype@3.2.3)(react@19.3.1): dependencies: - '@takumi-rs/core': 2.18.2(csstype@3.2.3)(react@19.3.1) - '@takumi-rs/helpers': 2.18.2(react@19.3.1) - '@takumi-rs/wasm': 2.18.2(csstype@3.2.3)(react@19.3.1) + '@takumi-rs/core': 2.20.0(csstype@3.2.3)(react@19.3.1) + '@takumi-rs/helpers': 2.20.0(react@19.3.1) + '@takumi-rs/wasm': 2.20.0(csstype@3.2.3)(react@19.3.1) transitivePeerDependencies: - csstype - preact From d8218daf888fc3da870f05c78995c851090c68ec Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 10:14:59 +0800 Subject: [PATCH 20/35] refactor(og): declare ImageResponseOptions instead of passing Takumi's through ImageResponseOptions now lists width, height, debug, fonts, emoji, lang, images (allowUrl, maxBytes, timeout, throwOnError) and the ResponseInit fields, and og.ts maps each one to Takumi's render options. A Takumi upgrade no longer changes the API, and backend options such as renderer, module and fontFamilies no longer bypass the per-image font handling. A fonts entry is a font file's URL or { name, data, weight, style, lang }. format, devicePixelRatio, css, emoji: 'from-font' and Takumi's other options are no longer accepted, and options outside the list do not reach Takumi at runtime. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 21 ++-- packages/void/AGENTS.md | 5 +- packages/void/src/runtime/og.ts | 155 ++++++++++++++--------------- packages/void/test/unit/og.test.ts | 40 ++++++-- 4 files changed, 120 insertions(+), 101 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index f5fecc07e..b632ef322 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -74,18 +74,15 @@ export const GET = defineHandler(async (c) => { The second argument sets the image and the response: -| Option | Default | Description | -| --------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `width`, `height` | `1200`, `630` | Image size in pixels. | -| `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). | -| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, `fluentFlat`, or `from-font` to draw emoji with the fonts. | -| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | -| `debug` | `false` | Draws a border around every box. | -| `format` | `'png'` | Image format: `png`, `jpeg`, `webp`, or `ico`. | -| `images` | | Limits for remote images: `allowUrl`, `maxBytes`, and `timeout`. Set them when the markup includes request values. | -| `status`, `statusText`, `headers` | | The response status and headers. | - -Takumi's other render options, such as `devicePixelRatio`, are passed through. See [Takumi's reference](https://takumi.kane.tw/docs/reference). +| Option | Default | Description | +| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `width`, `height` | `1200`, `630` | Image size in pixels. | +| `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). | +| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, or `fluentFlat`. | +| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | +| `debug` | `false` | Draws a border around every box. | +| `images` | | Limits for remote images and emoji: `allowUrl`, `maxBytes`, `timeout`, and `throwOnError`. Set them when the markup includes request values. | +| `status`, `statusText`, `headers` | | The response status and headers. | Responses have these headers unless you set them in `headers`: diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 39a854d5e..6fc38ae5b 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -158,7 +158,10 @@ Vite/CLI dependency graph. `#self/remote`. Remove the subpath in the next major version. `void/og` is a thin `next/og`-compatible layer over `takumi-js`; its source -cites the @vercel/og, Satori and Next.js code each behavior follows. In workerd, +cites the @vercel/og, Satori and Next.js code each behavior follows. +`ImageResponseOptions` is declared in `og.ts` and mapped to Takumi's render +options one by one. Do not +pass Takumi's options through: a Takumi upgrade must not change the API. In workerd, Takumi resolves its Wasm backend, whose `.wasm` import the Cloudflare plugin loads as a compiled module in dev and builds. On Node.js, Bun and Deno, Takumi loads its renderer from files next to its code (a native addon on Node.js and diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index 705e76cb9..bb48102d0 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -12,14 +12,7 @@ * - Satori 0.33.5, the version @vercel/og 1.0.3 bundles. * - Next.js 16.4.2, whose `next/og` wraps a copy of @vercel/og. */ -import { - render, - type FontDetails, - type FontLoader, - type Node, - type RenderInput, - type RenderOptions, -} from 'takumi-js'; +import { render, type FontLoader, type Node, type RenderInput } from 'takumi-js'; import { collectCodepoints, googleFonts, @@ -31,43 +24,56 @@ import { extractEmojis } from 'takumi-js/helpers/emoji'; import { fromHtml } from 'takumi-js/helpers/html'; import { fromJsx } from 'takumi-js/helpers/jsx'; -/** Takumi's options without `fonts`; a plain `Omit` would merge its format variants. */ -type TakumiOptions = RenderOptions extends infer T - ? T extends object - ? Omit - : never - : never; +/** A font file's URL, or a font's bytes with its name, weight and style. */ +type Font = + | string + | { + name: string; + data: ArrayBuffer | Uint8Array; + weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + style?: 'normal' | 'italic'; + /** + * Accepted and ignored: text uses the first font that has the glyph. The + * `lang` option, or attribute on the root element, chooses the font for + * Chinese characters instead. + */ + lang?: string; + }; /** - * A font as Takumi loads it, or as `next/og` types it, with Satori's `lang`: - * https://github.com/vercel/satori/blob/0.33.5/src/font.ts#L137-L143 + * Void declares these options rather than passing Takumi's render options + * through, so a Takumi upgrade does not change this API. */ -type Font = FontLoader | (FontDetails & { lang?: string }); - -export type ImageResponseOptions = TakumiOptions & - ResponseInit & { - /** - * Draws a border around every box, as `debug` does in Satori, though the lines - * look different: - * https://github.com/vercel/satori/blob/0.33.5/src/builder/rect.ts#L213-L225 - */ - debug?: boolean; - /** - * Fonts to draw text with. A font's `lang`, which Satori reads to prefer the - * font for that language, is ignored: Takumi takes the first font with the - * glyph. The `lang` option, or attribute on the root element, chooses the - * CJK fallback font instead. - */ - fonts?: Array | Promise>; +export interface ImageResponseOptions extends ResponseInit { + /** Image width in pixels. Defaults to 1200. */ + width?: number; + /** Image height in pixels. Defaults to 630. */ + height?: number; + /** Draws a border around every box. */ + debug?: boolean; + /** Fonts to draw text with, before the built-in Geist. */ + fonts?: Array; + /** The emoji images to draw. Defaults to `twemoji`. */ + emoji?: 'twemoji' | 'blobmoji' | 'noto' | 'openmoji' | 'fluent' | 'fluentFlat'; + /** + * The language of the text, such as `ja` or `zh-TW`, which chooses the style + * of Chinese characters. Defaults to the root element's `lang` attribute. + */ + lang?: string; + /** Limits for downloading the images and emoji in the markup. */ + images?: { + /** Returns false for a URL the image must not download. */ + allowUrl?: (url: string) => boolean; + /** The largest image to download, in bytes. */ + maxBytes?: number; + /** Milliseconds to wait for each download. */ + timeout?: number; + /** Set to false to leave out an image or emoji that fails to download. */ + throwOnError?: boolean; }; +} -const CONTENT_TYPES = { - png: 'image/png', - jpeg: 'image/jpeg', - webp: 'image/webp', - ico: 'image/x-icon', - raw: 'application/octet-stream', -} as const; +type ImageOptions = Omit; // Google Fonts families for text the default font cannot draw. Satori hands // @vercel/og the characters no loaded font has, with the languages their script @@ -112,10 +118,10 @@ const SINGLE_WEIGHT_FAMILIES = new Set(['Noto Sans Math', 'Noto Sans Symbols 2'] // accented letters and combining marks, spaces, format characters and variation // selectors, quotes, dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing: // no served subset has them. -/* eslint-disable no-misleading-character-class -- the marks and selectors are listed on their own */ +/* oxlint-disable no-misleading-character-class -- the marks and selectors are listed on their own */ const BUILT_IN = /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{304}\u{308}\u{34F}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FE00}-\u{FE0F}\u{FEFF}]/u; -/* eslint-enable no-misleading-character-class */ +/* oxlint-enable no-misleading-character-class */ // Emoji come from jsDelivr by code point, so keep them for the life of the // isolate, as @vercel/og does (`// src/og.ts`: assetCache); other images are @@ -130,7 +136,7 @@ const emojiCache = new Map>(); */ export class ImageResponse extends Response { constructor(element: RenderInput, options: ImageResponseOptions = {}) { - const { status, statusText, headers, debug, ...renderOptions } = options; + const { status, statusText, headers, ...imageOptions } = options; // next/og sets these two headers unless the caller's `headers` has them // (@vercel/og itself spreads the caller's object over its defaults). A // failed render still ends as a complete response, so the image is not @@ -138,7 +144,7 @@ export class ImageResponse extends Response { // https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L76-L86 const responseHeaders = new Headers(headers); if (!responseHeaders.has('content-type')) { - responseHeaders.set('content-type', CONTENT_TYPES[renderOptions.format ?? 'png']); + responseHeaders.set('content-type', 'image/png'); } if (!responseHeaders.has('cache-control')) { responseHeaders.set( @@ -151,13 +157,7 @@ export class ImageResponse extends Response { const body = new ReadableStream({ async start(controller) { try { - // @vercel/og `// src/og.ts`: render() defaults to 1200×630 without debug. - const image = await renderImage(element, { - ...renderOptions, - width: renderOptions.width ?? 1200, - height: renderOptions.height ?? 630, - drawDebugBorder: renderOptions.drawDebugBorder ?? debug, - }); + const image = await renderImage(element, imageOptions); controller.enqueue(image); controller.close(); } catch (error) { @@ -170,25 +170,29 @@ export class ImageResponse extends Response { } } -type RenderImageOptions = TakumiOptions & { fonts?: ImageResponseOptions['fonts'] }; - class FallbackFontError extends Error {} -async function renderImage(element: RenderInput, options: RenderImageOptions): Promise { +async function renderImage(element: RenderInput, options: ImageOptions): Promise { // Convert once, here: the fallback fonts depend on the final text, and // function components must not run twice. Emoji become images first, as in // the render, so their code points ask for no symbol font. - const { node: tree, css } = await toNode(element, options.jsx); - const node = - options.emoji === 'from-font' ? tree : extractEmojis(tree, options.emoji ?? 'twemoji'); - const images = Array.isArray(options.images) ? { sources: options.images } : options.images; + const { node: tree, css } = await toNode(element); + const emoji = options.emoji ?? 'twemoji'; + const node = extractEmojis(tree, emoji); + // Only the documented limits reach Takumi, which takes more image options. + const { allowUrl, maxBytes, timeout, throwOnError } = options.images ?? {}; // One cache for both attempts below, so a retry fetches no image again. const fetchCache = imageCache(); const renderNode = (fonts: Array) => render(node, { - ...options, - css: [...toList(options.css), ...css], - images: { fetchCache, ...images }, + // @vercel/og `// src/og.ts`: render() defaults to 1200×630 without debug. + width: options.width ?? 1200, + height: options.height ?? 630, + drawDebugBorder: options.debug, + lang: options.lang, + emoji, + css, + images: { fetchCache, allowUrl, maxBytes, timeout, throwOnError }, // Takumi uses every font registered in the process when it gets no list, // which includes earlier images' fallback fonts: // https://github.com/kane50613/takumi/blob/takumi@2.20.0/takumi-helpers/src/renderer.ts#L384-L412 @@ -197,8 +201,8 @@ async function renderImage(element: RenderInput, options: RenderImageOptions): P // Keys of the fallback files that failed to download during this image. const failed = new Set(); const [fonts, fallback] = await Promise.all([ - Promise.all(((await options.fonts) ?? []).map(keyedFont)), - fallbackFonts(node, options, images, failed), + Promise.all((options.fonts ?? []).map(keyedFont)), + fallbackFonts(node, options.lang ?? node.lang, timeout, failed), ]); if (fallback.length === 0) { return renderNode(fonts); @@ -225,14 +229,14 @@ async function renderImage(element: RenderInput, options: RenderImageOptions): P } } -function toNode(element: RenderInput, jsx: RenderOptions['jsx']) { +function toNode(element: RenderInput) { if (typeof element === 'string') { return fromHtml(element); } if (isTakumiNode(element)) { return { node: element, css: [] }; } - return fromJsx(element, jsx); + return fromJsx(element); } function isTakumiNode(element: unknown): element is Node { @@ -240,13 +244,6 @@ function isTakumiNode(element: unknown): element is Node { return type === 'container' || type === 'text' || type === 'image'; } -function toList(value: T | ReadonlyArray | undefined): Array { - if (value === undefined) { - return []; - } - return Array.isArray(value) ? [...value] : [value as T]; -} - /** Images fetched once per render, and emoji once per isolate. */ function imageCache(): ImageFetchCache { const cache = new Map>(); @@ -268,10 +265,9 @@ function imageCache(): ImageFetchCache { }; } -function hasBytes(font: Font): font is FontDetails & { lang?: string } { +function hasBytes(font: Font): font is Exclude { return ( typeof font === 'object' && - 'data' in font && (font.data instanceof ArrayBuffer || font.data instanceof Uint8Array) ); } @@ -337,8 +333,8 @@ function cjkFamily(text: string, lang = ''): string { async function fallbackFonts( node: Node, - options: RenderImageOptions, - images: Exclude>, + lang: string | undefined, + timeout: number | undefined, failed: Set, ): Promise> { const missing = Array.from(collectCodepoints(node)) @@ -347,8 +343,7 @@ async function fallbackFonts( .filter((character) => !BUILT_IN.test(character)); const families = new Set( FALLBACK_FONTS.filter(([script]) => missing.some((character) => script.test(character))).map( - ([, family]) => - family === 'Noto Sans JP' ? cjkFamily(missing.join(''), options.lang ?? node.lang) : family, + ([, family]) => (family === 'Noto Sans JP' ? cjkFamily(missing.join(''), lang) : family), ), ); if (missing.some((character) => !FALLBACK_FONTS.some(([script]) => script.test(character)))) { @@ -357,7 +352,6 @@ async function fallbackFonts( if (families.size === 0) { return []; } - options.signal?.throwIfAborted(); // One request per family: takumi caches the CSS by URL, and a family's // subsets are keyed alike whichever image asked for them. const subsets = await Promise.all( @@ -367,8 +361,7 @@ async function fallbackFonts( families: [ SINGLE_WEIGHT_FAMILIES.has(family) ? family : { name: family, weight: '100..900' }, ], - fetch: images?.fetch, - timeout: images?.timeout, + timeout, }); } catch (error) { console.warn(`void/og: could not load ${family} from Google Fonts.`, error); diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index a7f292d71..85c5d0220 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -127,11 +127,37 @@ describe('ImageResponse', () => { expect(response.headers.get('content-type')).toBe('image/png'); }); - it('labels other formats with their content type', async () => { - const response = new ImageResponse(card, { format: 'jpeg' }); - expect(response.headers.get('content-type')).toBe('image/jpeg'); - const bytes = new Uint8Array(await response.arrayBuffer()); - expect([...bytes.subarray(0, 2)]).toEqual([0xff, 0xd8]); + it('passes Takumi none of its options that ImageResponseOptions leaves out', async () => { + const response = new ImageResponse(card, { + emoji: 'noto', + lang: 'ja', + // Takumi takes these, but void/og does not. + format: 'jpeg', + devicePixelRatio: 2, + drawDebugBorder: true, + images: { maxBytes: 1024, throwOnError: false, fetch: () => {} }, + } as never); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + const options = vi.mocked(render).mock.lastCall![1]!; + expect(Object.keys(options).sort()).toEqual([ + 'css', + 'drawDebugBorder', + 'emoji', + 'fonts', + 'height', + 'images', + 'lang', + 'width', + ]); + expect(options).toMatchObject({ drawDebugBorder: undefined, emoji: 'noto', lang: 'ja' }); + expect(Object.keys(options.images!).sort()).toEqual([ + 'allowUrl', + 'fetchCache', + 'maxBytes', + 'throwOnError', + 'timeout', + ]); }); it('keeps the default size for an undefined width or height', async () => { @@ -145,12 +171,12 @@ describe('ImageResponse', () => { expect(debug).not.toEqual(plain); }); - it('accepts css beside a style element', async () => { + it('applies a style element in the markup', async () => { const response = new ImageResponse( '
Hi
', - { css: '.card { padding: 8px }' }, ); expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + expect(vi.mocked(render).mock.lastCall![1]!.css).toEqual(['.card { background: #000 }']); }); it('keys a font by its bytes, so a font read for each request registers once', async () => { From b6488696e71d5ab076e6d1774950481b1207bdd7 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 10:18:57 +0800 Subject: [PATCH 21/35] docs(og): explain void/og behavior without citing other libraries Co-Authored-By: Claude Opus 5.5 --- packages/void/AGENTS.md | 3 +- packages/void/src/runtime/og.ts | 75 +++++++++++------------------- packages/void/test/unit/og.test.ts | 4 +- 3 files changed, 31 insertions(+), 51 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 6fc38ae5b..b668bfb09 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -157,8 +157,7 @@ Vite/CLI dependency graph. `void/remote` stays exported only for semver. Generated code imports `#self/remote`. Remove the subpath in the next major version. -`void/og` is a thin `next/og`-compatible layer over `takumi-js`; its source -cites the @vercel/og, Satori and Next.js code each behavior follows. +`void/og` is a thin `ImageResponse` layer over `takumi-js`. `ImageResponseOptions` is declared in `og.ts` and mapped to Takumi's render options one by one. Do not pass Takumi's options through: a Takumi upgrade must not change the API. In workerd, diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index bb48102d0..3370b772a 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -1,16 +1,11 @@ /** - * `void/og` — the `ImageResponse` API of `next/og` and `@vercel/og`, rendered - * by Takumi instead of Satori and resvg. + * `void/og` — an `ImageResponse` that renders JSX or HTML to an image with + * Takumi. * * Takumi does the rendering (Wasm on workerd, its native addon on Node). This - * module adds what `@vercel/og` does around its renderer: a 1200×630 default - * size, the response headers, `debug`, and Google Fonts fallbacks for text - * that Takumi's built-in Geist cannot draw. Comments cite the upstream code: - * - @vercel/og 1.0.3, published only as a bundle whose `// src/…` sections - * name its source files: - * https://cdn.jsdelivr.net/npm/@vercel/og@1.0.3/dist/index.edge.js - * - Satori 0.33.5, the version @vercel/og 1.0.3 bundles. - * - Next.js 16.4.2, whose `next/og` wraps a copy of @vercel/og. + * module adds what a route handler needs around it: a 1200×630 default size, + * the response headers, `debug`, and Google Fonts fallbacks for text that + * Takumi's built-in Geist cannot draw. */ import { render, type FontLoader, type Node, type RenderInput } from 'takumi-js'; import { @@ -75,15 +70,10 @@ export interface ImageResponseOptions extends ResponseInit { type ImageOptions = Omit; -// Google Fonts families for text the default font cannot draw. Satori hands -// @vercel/og the characters no loaded font has, with the languages their script -// suggests, and @vercel/og downloads each language's Noto family: -// - https://github.com/vercel/satori/blob/0.33.5/src/text/index.ts#L103-L115 -// - https://github.com/vercel/satori/blob/0.33.5/src/language.ts#L24-L45 -// - @vercel/og `// src/language/index.ts`: languageFontMap -// Takumi has no such hook, so these families are matched by script before the -// render. `cjkFamily` settles the first one; `Noto Sans` takes the rest, as -// @vercel/og's `unknown` does. +// Google Fonts families for text the default font cannot draw: each script's +// Noto family. Takumi has no hook for characters that no font has, so these +// families are matched by script before the render. `cjkFamily` settles the +// first one; `Noto Sans` takes any other text. const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ [/[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana} -〿＀-￯]/u, 'Noto Sans JP'], [/\p{sc=Hangul}/u, 'Noto Sans KR'], @@ -123,25 +113,22 @@ const BUILT_IN = /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{304}\u{308}\u{34F}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FE00}-\u{FE0F}\u{FEFF}]/u; /* oxlint-enable no-misleading-character-class */ -// Emoji come from jsDelivr by code point, so keep them for the life of the -// isolate, as @vercel/og does (`// src/og.ts`: assetCache); other images are -// fetched for each render. +// Emoji come from jsDelivr by code point and never change, so keep them for the +// life of the isolate; other images are fetched for each render. const EMOJI_ORIGIN = 'https://cdn.jsdelivr.net/'; const emojiCache = new Map>(); /** * A `Response` whose body is the image rendered from `element`: React JSX, an - * HTML string, or a Takumi node tree. Like `next/og`, it renders a 1200×630 PNG - * by default, which clients revalidate outside development. + * HTML string, or a Takumi node tree. It renders a 1200×630 PNG by default, + * which clients revalidate outside development. */ export class ImageResponse extends Response { constructor(element: RenderInput, options: ImageResponseOptions = {}) { const { status, statusText, headers, ...imageOptions } = options; - // next/og sets these two headers unless the caller's `headers` has them - // (@vercel/og itself spreads the caller's object over its defaults). A - // failed render still ends as a complete response, so the image is not - // cached for the year @vercel/og allows: - // https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L76-L86 + // Set these two headers unless the caller's `headers` has them. A failed + // render still ends as a complete response, so clients revalidate the + // image rather than cache it for long. const responseHeaders = new Headers(headers); if (!responseHeaders.has('content-type')) { responseHeaders.set('content-type', 'image/png'); @@ -152,8 +139,7 @@ export class ImageResponse extends Response { isDevelopment() ? 'no-cache, no-store' : 'public, max-age=0, must-revalidate', ); } - // Render while the body streams, as @vercel/og does, so a failure errors the - // body: https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/og/image-response.ts#L49-L72 + // Render while the body streams, so a failure errors the body. const body = new ReadableStream({ async start(controller) { try { @@ -185,7 +171,6 @@ async function renderImage(element: RenderInput, options: ImageOptions): Promise const fetchCache = imageCache(); const renderNode = (fonts: Array) => render(node, { - // @vercel/og `// src/og.ts`: render() defaults to 1200×630 without debug. width: options.width ?? 1200, height: options.height ?? 630, drawDebugBorder: options.debug, @@ -208,7 +193,7 @@ async function renderImage(element: RenderInput, options: ImageOptions): Promise return renderNode(fonts); } // A font that fails to download leaves its glyphs missing rather than - // failing the image, as in @vercel/og `// src/og.ts`: loadDynamicAsset. + // failing the image. // Takumi downloads the files together and rejects the render on the first // failure, so retry without the files that failed, and only those: a flaky // symbol font must not also drop the Japanese one. Files that did download @@ -275,8 +260,8 @@ function hasBytes(font: Font): font is Exclude { const fontHashes = new WeakMap>(); // Takumi keeps every registered font for the life of the process and tells -// them apart by buffer identity, so a font read anew for each request, as in -// next/og's examples, would be registered again every time. Key it by content. +// them apart by buffer identity, so a font read anew for each request would be +// registered again every time. Key it by content. async function keyedFont(font: Font): Promise { if (!hasBytes(font)) { return font; @@ -300,12 +285,9 @@ async function keyedFont(font: Font): Promise { }; } -// Satori's ja-JP code takes Han and comes first, so Han text is Japanese there -// unless an element's `lang` moves another language first: -// https://github.com/vercel/satori/blob/0.33.5/src/language.ts#L57-L84 -// Takumi takes the first font with a glyph and ignores `lang`, so one family -// draws all Han text in an image, chosen from the `lang` option or the root -// element's `lang`. +// Han text is drawn as Japanese unless a language says otherwise. Takumi takes +// the first font with a glyph and ignores `lang`, so one family draws all Han +// text in an image, chosen from the `lang` option or the root element's `lang`. function cjkFamily(text: string, lang = ''): string { // An explicit language wins over the scripts in the text: a Traditional // Chinese title with a katakana loanword (タワー) is still Chinese. Google's @@ -321,7 +303,7 @@ function cjkFamily(text: string, lang = ''): string { if (tag.startsWith('zh')) { return /hant|-tw|-hk|-mo/.test(tag) ? 'Noto Sans TC' : 'Noto Sans SC'; } - // Without one, kana marks Japanese and Hangul marks Korean, as in Satori. + // Without one, kana marks Japanese and Hangul marks Korean. if (/[\p{sc=Hiragana}\p{sc=Katakana}]/u.test(text)) { return 'Noto Sans JP'; } @@ -369,11 +351,10 @@ async function fallbackFonts( } }), ); - // Satori draws each character with the first loaded font that has it, and - // fallback fonts come after the default one, so Latin text keeps the default - // font: https://github.com/vercel/satori/blob/0.33.5/src/font.ts#L601-L679 - // Takumi puts registered fonts before its built-in Geist, so take ASCII and - // Latin-1 but µ out of every subset. A subset without ranges is a whole font. + // Latin text should keep the default font, but Takumi draws each character + // with the first font that has it and puts registered fonts before its + // built-in Geist, so take ASCII and Latin-1 but µ out of every subset. A + // subset without ranges is a whole font. const stripped = subsets.flat().flatMap((subset) => { const unicodeRange = ( subset.unicodeRange.length > 0 ? subset.unicodeRange : [[0x100, 0x10ffff] as const] diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 85c5d0220..931f4c25b 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -196,7 +196,7 @@ describe('ImageResponse', () => { it('errors the body when the image cannot render', async () => { const data = new Uint8Array([1, 2, 3]); - // A next/og font entry may carry Satori's `lang`, which takumi does not take. + // A font entry may carry `lang`, which takumi does not take. const response = new ImageResponse(card, { fonts: [{ name: 'Broken', data, weight: 400, lang: 'ja-JP' }], }); @@ -303,7 +303,7 @@ describe('ImageResponse fallback fonts', () => { ]); }); - it('draws Han text as Japanese unless lang says otherwise, like next/og', async () => { + it('draws Han text as Japanese unless lang says otherwise', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch(() => new Response(null, { status: 404 })); const family = async (html: string, lang?: string) => { From b4aeb795046db849eb3bb9f81dd0c7e53843a811 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 10:26:19 +0800 Subject: [PATCH 22/35] fix(og): accept any font weight from 1 to 1000 A font's weight fixes the weight it draws, so a variable font set to 550 or a static Book (350) face needs a weight between the 100 steps. Leaving it out keeps a variable font free for each element's fontWeight. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 2 ++ packages/void/src/runtime/og.ts | 7 ++++++- packages/void/test/unit/og.test.ts | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index b632ef322..ed2d3b267 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -118,6 +118,8 @@ return new ImageResponse(postCard(post), { }); ``` +A `weight` from 1 to 1000 makes the font draw only that weight. Leave it out for a variable font, so each element's `fontWeight` sets the weight, including values between the usual steps such as `550`. + Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too. A font entry's `lang` is accepted but does not choose the font for a language; use the `lang` option or attribute for Chinese characters instead. ## Add the image to a page diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index 3370b772a..8786f8026 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -25,7 +25,12 @@ type Font = | { name: string; data: ArrayBuffer | Uint8Array; - weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + /** + * The weight the font draws, from 1 to 1000. Leave it out for a variable + * font, so each element's `fontWeight` sets the weight; with it, the font + * draws only this weight. + */ + weight?: number; style?: 'normal' | 'italic'; /** * Accepted and ignored: text uses the first font that has the glyph. The diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 931f4c25b..fb2d0b0a6 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -194,6 +194,27 @@ describe('ImageResponse', () => { expect(await fontKey(new Uint8Array([1, 2, 4]))).not.toBe(key); }); + it('passes any font weight to Takumi, and none when it is left out', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const data = new Uint8Array([1, 2, 3]); + await new ImageResponse(card, { + fonts: [ + { name: 'Medium', data, weight: 550 }, + { name: 'Variable', data }, + ], + }) + .arrayBuffer() + .catch(() => {}); + const fonts = vi.mocked(render).mock.lastCall?.[1]?.fonts as Array<{ + name: string; + weight?: number; + }>; + expect(fonts.map(({ name, weight }) => [name, weight])).toEqual([ + ['Medium', 550], + ['Variable', undefined], + ]); + }); + it('errors the body when the image cannot render', async () => { const data = new Uint8Array([1, 2, 3]); // A font entry may carry `lang`, which takumi does not take. From 376b1bd61b15dbd998d385183baea8f1fb4f774e Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 10:54:16 +0800 Subject: [PATCH 23/35] =?UTF-8?q?fix(og):=20draw=20=E3=83=BB=20and=20?= =?UTF-8?q?=E3=83=BC=20with=20the=20Japanese=20fallback=20family?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unicode files these marks under the Common script, so they matched no script's family and fell through to Noto Sans, which cost almost every Japanese image an extra Google Fonts request. Match by script extension. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/runtime/og.ts | 4 +++- packages/void/test/unit/og.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index 8786f8026..b44d573e6 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -80,7 +80,9 @@ type ImageOptions = Omit; // families are matched by script before the render. `cjkFamily` settles the // first one; `Noto Sans` takes any other text. const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ - [/[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana} -〿＀-￯]/u, 'Noto Sans JP'], + // Script extensions, so marks such as ・ and ー, which Unicode files under the + // Common script, stay with the family that draws the kana around them. + [/[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana} -〿＀-￯]/u, 'Noto Sans JP'], [/\p{sc=Hangul}/u, 'Noto Sans KR'], [/\p{sc=Thai}/u, 'Noto Sans Thai'], [/\p{sc=Arabic}/u, 'Noto Sans Arabic'], diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index fb2d0b0a6..2a34d920e 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -277,6 +277,15 @@ describe('ImageResponse fallback fonts', () => { expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Geist:wght@100..900']); }); + it('draws the marks ・ and ー with the Japanese family', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + await new ImageResponse('
東京・ラーメン
').arrayBuffer(); + expect(requests.flatMap((url) => new URL(url).searchParams.getAll('family'))).toEqual([ + 'Noto Sans JP:wght@100..900', + ]); + }); + it('leaves variable fallback faces free to draw bold', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); // Google answers a weight range with `font-weight: 100 900`, and a single From 4121679a4e6e46333c2441f3d55700a243aa13d8 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 10:56:41 +0800 Subject: [PATCH 24/35] refactor(og): move font handling into og-fonts.ts og.ts keeps the response, its options and the render; og-fonts.ts holds the font type, keying of the app's fonts and the Google Fonts fallbacks. Co-Authored-By: Claude Opus 5.5 --- packages/void/AGENTS.md | 2 +- packages/void/src/runtime/og-fonts.ts | 218 ++++++++++++++++++++++++++ packages/void/src/runtime/og.ts | 214 +------------------------ 3 files changed, 221 insertions(+), 213 deletions(-) create mode 100644 packages/void/src/runtime/og-fonts.ts diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index b668bfb09..6ef106881 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -175,7 +175,7 @@ a character, and draws the whole word as boxes when that font lacks the glyph (Noto Sans JP's latin-ext subset has no Ł). Before raising the range, render Japanese next to "Łódź" with the new version in a fresh process, as 2.19.0 falls back to faces an earlier render loaded, and check that the built-in Geist still -draws the code points `BUILT_IN` lists. Then raise the range, the install +draws the code points `BUILT_IN` lists in `og-fonts.ts`. Then raise the range, the install command and the Worker size in `docs/guide/og-images.md`. Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). diff --git a/packages/void/src/runtime/og-fonts.ts b/packages/void/src/runtime/og-fonts.ts new file mode 100644 index 000000000..16ca46ede --- /dev/null +++ b/packages/void/src/runtime/og-fonts.ts @@ -0,0 +1,218 @@ +/** + * Fonts for `void/og`: the app's own fonts, keyed so each registers with + * Takumi once, and Google Fonts fallbacks for text that Takumi's built-in Geist + * cannot draw. + */ +import type { FontLoader, Node } from 'takumi-js'; +import { collectCodepoints, googleFonts, subsetFonts, type FontSubset } from 'takumi-js/helpers'; + +/** A font file's URL, or a font's bytes with its name, weight and style. */ +export type Font = + | string + | { + name: string; + data: ArrayBuffer | Uint8Array; + /** + * The weight the font draws, from 1 to 1000. Leave it out for a variable + * font, so each element's `fontWeight` sets the weight; with it, the font + * draws only this weight. + */ + weight?: number; + style?: 'normal' | 'italic'; + /** + * Accepted and ignored: text uses the first font that has the glyph. The + * `lang` option, or attribute on the root element, chooses the font for + * Chinese characters instead. + */ + lang?: string; + }; + +/** A fallback font file that failed to download. */ +export class FallbackFontError extends Error {} + +// Google Fonts families for text the default font cannot draw: each script's +// Noto family. Takumi has no hook for characters that no font has, so these +// families are matched by script before the render. `cjkFamily` settles the +// first one; `Noto Sans` takes any other text. +const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ + // Script extensions, so marks such as ・ and ー, which Unicode files under the + // Common script, stay with the family that draws the kana around them. + [/[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana} -〿＀-￯]/u, 'Noto Sans JP'], + [/\p{sc=Hangul}/u, 'Noto Sans KR'], + [/\p{sc=Thai}/u, 'Noto Sans Thai'], + [/\p{sc=Arabic}/u, 'Noto Sans Arabic'], + [/\p{sc=Hebrew}/u, 'Noto Sans Hebrew'], + [/\p{sc=Devanagari}/u, 'Noto Sans Devanagari'], + [/\p{sc=Bengali}/u, 'Noto Sans Bengali'], + [/\p{sc=Tamil}/u, 'Noto Sans Tamil'], + [/\p{sc=Telugu}/u, 'Noto Sans Telugu'], + [/\p{sc=Kannada}/u, 'Noto Sans Kannada'], + [/\p{sc=Malayalam}/u, 'Noto Sans Malayalam'], + // The default font's own subsets, so a word stays in one typeface. + [/[\p{sc=Cyrillic}µĀ-ɏḀ-ỿ]/u, 'Geist'], + [/\p{Sm}/u, 'Noto Sans Math'], + [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols'], + [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols 2'], +]; + +// The fallback families Google Fonts serves only at weight 400. Every other +// family above is a variable font with a 100–900 weight axis, and is asked +// for as that range: Google then serves `font-weight: 100 900`, Takumi leaves +// the face without a fixed weight, and `font-weight: 700` drives the axis. +// Asked for at a single 400, a variable face is pinned to 400 and bold text is +// only thickened, so 700 and 900 look the same. Asking for a range from these +// two fails the whole request with HTTP 400, which would lose the family. +// Found by asking css2 for each family with `wght@100..900`. +const SINGLE_WEIGHT_FAMILIES = new Set(['Noto Sans Math', 'Noto Sans Symbols 2']); + +// What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found +// by rendering each code point with takumi-js 2.18 (2.20 draws the same): some +// accented letters and combining marks, spaces, format characters and variation +// selectors, quotes, dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing: +// no served subset has them. +/* oxlint-disable no-misleading-character-class -- the marks and selectors are listed on their own */ +const BUILT_IN = + /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{304}\u{308}\u{34F}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FE00}-\u{FE0F}\u{FEFF}]/u; +/* oxlint-enable no-misleading-character-class */ + +function hasBytes(font: Font): font is Exclude { + return ( + typeof font === 'object' && + (font.data instanceof ArrayBuffer || font.data instanceof Uint8Array) + ); +} + +const fontHashes = new WeakMap>(); + +// Takumi keeps every registered font for the life of the process and tells +// them apart by buffer identity, so a font read anew for each request would be +// registered again every time. Key it by content. +export async function keyedFont(font: Font): Promise { + if (!hasBytes(font)) { + return font; + } + const { lang: _lang, data, ...details } = font; + let hash = fontHashes.get(data); + if (!hash) { + hash = crypto.subtle + .digest('SHA-256', data instanceof ArrayBuffer ? data : new Uint8Array(data)) + .then((digest) => + Array.from(new Uint8Array(digest, 0, 8), (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ), + ); + fontHashes.set(data, hash); + } + return { + ...details, + key: `void/og ${details.name ?? ''}:${details.weight ?? ''}:${details.style ?? ''}:${await hash}`, + data: () => data, + }; +} + +// Han text is drawn as Japanese unless a language says otherwise. Takumi takes +// the first font with a glyph and ignores `lang`, so one family draws all Han +// text in an image, chosen from the `lang` option or the root element's `lang`. +function cjkFamily(text: string, lang = ''): string { + // An explicit language wins over the scripts in the text: a Traditional + // Chinese title with a katakana loanword (タワー) is still Chinese. Google's + // Noto Sans SC, TC and KR all include kana, so no character is left without + // a glyph. + const tag = lang.toLowerCase(); + if (tag.startsWith('ja')) { + return 'Noto Sans JP'; + } + if (tag.startsWith('ko')) { + return 'Noto Sans KR'; + } + if (tag.startsWith('zh')) { + return /hant|-tw|-hk|-mo/.test(tag) ? 'Noto Sans TC' : 'Noto Sans SC'; + } + // Without one, kana marks Japanese and Hangul marks Korean. + if (/[\p{sc=Hiragana}\p{sc=Katakana}]/u.test(text)) { + return 'Noto Sans JP'; + } + if (/\p{sc=Hangul}/u.test(text)) { + return 'Noto Sans KR'; + } + return 'Noto Sans JP'; +} + +/** + * The Google Fonts subsets that draw the characters in `node` that the + * built-in Geist cannot. A file that fails to download adds its key to + * `failed` and rejects with a `FallbackFontError`. + */ +export async function fallbackFonts( + node: Node, + lang: string | undefined, + timeout: number | undefined, + failed: Set, +): Promise> { + const missing = Array.from(collectCodepoints(node)) + .filter((codepoint) => codepoint === 0xb5 || codepoint > 0xff) + .map((codepoint) => String.fromCodePoint(codepoint)) + .filter((character) => !BUILT_IN.test(character)); + const families = new Set( + FALLBACK_FONTS.filter(([script]) => missing.some((character) => script.test(character))).map( + ([, family]) => (family === 'Noto Sans JP' ? cjkFamily(missing.join(''), lang) : family), + ), + ); + if (missing.some((character) => !FALLBACK_FONTS.some(([script]) => script.test(character)))) { + families.add('Noto Sans'); + } + if (families.size === 0) { + return []; + } + // One request per family: takumi caches the CSS by URL, and a family's + // subsets are keyed alike whichever image asked for them. + const subsets = await Promise.all( + [...families].map(async (family) => { + try { + return await googleFonts({ + families: [ + SINGLE_WEIGHT_FAMILIES.has(family) ? family : { name: family, weight: '100..900' }, + ], + timeout, + }); + } catch (error) { + console.warn(`void/og: could not load ${family} from Google Fonts.`, error); + return []; + } + }), + ); + // Latin text should keep the default font, but Takumi draws each character + // with the first font that has it and puts registered fonts before its + // built-in Geist, so take ASCII and Latin-1 but µ out of every subset. A + // subset without ranges is a whole font. + const stripped = subsets.flat().flatMap((subset) => { + const unicodeRange = ( + subset.unicodeRange.length > 0 ? subset.unicodeRange : [[0x100, 0x10ffff] as const] + ).flatMap(([start, end]): Array<[number, number]> => [ + ...(start <= 0xb5 && 0xb5 <= end ? [[0xb5, 0xb5] as [number, number]] : []), + ...(end > 0xff ? [[Math.max(start, 0x100), end] as [number, number]] : []), + ]); + if (unicodeRange.length === 0) { + return []; + } + return { + ...subset, + unicodeRange, + // Private names: Takumi keeps fonts per process, so the app's own copy + // of the family, Latin glyphs included, must not join this one. + name: `void/og ${subset.name}`, + key: `void/og ${subset.key}`, + data: () => + subset.data().catch((cause: unknown) => { + // Record and report each failed file here: when several fail at + // once, Takumi's rejection carries only the first. + failed.add(`void/og ${subset.key}`); + console.warn(`void/og: could not download ${subset.name}. Rendering without it.`, cause); + throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause }); + }), + }; + }); + // Takumi downloads the subsets of its list-marker sample as well, so keep + // only those the text uses. + return subsetFonts({ fonts: stripped, source: node }); +} diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index b44d573e6..e05f4955d 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -8,37 +8,11 @@ * Takumi's built-in Geist cannot draw. */ import { render, type FontLoader, type Node, type RenderInput } from 'takumi-js'; -import { - collectCodepoints, - googleFonts, - subsetFonts, - type FontSubset, - type ImageFetchCache, -} from 'takumi-js/helpers'; +import type { ImageFetchCache } from 'takumi-js/helpers'; import { extractEmojis } from 'takumi-js/helpers/emoji'; import { fromHtml } from 'takumi-js/helpers/html'; import { fromJsx } from 'takumi-js/helpers/jsx'; - -/** A font file's URL, or a font's bytes with its name, weight and style. */ -type Font = - | string - | { - name: string; - data: ArrayBuffer | Uint8Array; - /** - * The weight the font draws, from 1 to 1000. Leave it out for a variable - * font, so each element's `fontWeight` sets the weight; with it, the font - * draws only this weight. - */ - weight?: number; - style?: 'normal' | 'italic'; - /** - * Accepted and ignored: text uses the first font that has the glyph. The - * `lang` option, or attribute on the root element, chooses the font for - * Chinese characters instead. - */ - lang?: string; - }; +import { FallbackFontError, fallbackFonts, keyedFont, type Font } from './og-fonts.ts'; /** * Void declares these options rather than passing Takumi's render options @@ -75,51 +49,6 @@ export interface ImageResponseOptions extends ResponseInit { type ImageOptions = Omit; -// Google Fonts families for text the default font cannot draw: each script's -// Noto family. Takumi has no hook for characters that no font has, so these -// families are matched by script before the render. `cjkFamily` settles the -// first one; `Noto Sans` takes any other text. -const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ - // Script extensions, so marks such as ・ and ー, which Unicode files under the - // Common script, stay with the family that draws the kana around them. - [/[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana} -〿＀-￯]/u, 'Noto Sans JP'], - [/\p{sc=Hangul}/u, 'Noto Sans KR'], - [/\p{sc=Thai}/u, 'Noto Sans Thai'], - [/\p{sc=Arabic}/u, 'Noto Sans Arabic'], - [/\p{sc=Hebrew}/u, 'Noto Sans Hebrew'], - [/\p{sc=Devanagari}/u, 'Noto Sans Devanagari'], - [/\p{sc=Bengali}/u, 'Noto Sans Bengali'], - [/\p{sc=Tamil}/u, 'Noto Sans Tamil'], - [/\p{sc=Telugu}/u, 'Noto Sans Telugu'], - [/\p{sc=Kannada}/u, 'Noto Sans Kannada'], - [/\p{sc=Malayalam}/u, 'Noto Sans Malayalam'], - // The default font's own subsets, so a word stays in one typeface. - [/[\p{sc=Cyrillic}µĀ-ɏḀ-ỿ]/u, 'Geist'], - [/\p{Sm}/u, 'Noto Sans Math'], - [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols'], - [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols 2'], -]; - -// The fallback families Google Fonts serves only at weight 400. Every other -// family above is a variable font with a 100–900 weight axis, and is asked -// for as that range: Google then serves `font-weight: 100 900`, Takumi leaves -// the face without a fixed weight, and `font-weight: 700` drives the axis. -// Asked for at a single 400, a variable face is pinned to 400 and bold text is -// only thickened, so 700 and 900 look the same. Asking for a range from these -// two fails the whole request with HTTP 400, which would lose the family. -// Found by asking css2 for each family with `wght@100..900`. -const SINGLE_WEIGHT_FAMILIES = new Set(['Noto Sans Math', 'Noto Sans Symbols 2']); - -// What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found -// by rendering each code point with takumi-js 2.18 (2.20 draws the same): some -// accented letters and combining marks, spaces, format characters and variation -// selectors, quotes, dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing: -// no served subset has them. -/* oxlint-disable no-misleading-character-class -- the marks and selectors are listed on their own */ -const BUILT_IN = - /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{304}\u{308}\u{34F}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FE00}-\u{FE0F}\u{FEFF}]/u; -/* oxlint-enable no-misleading-character-class */ - // Emoji come from jsDelivr by code point and never change, so keep them for the // life of the isolate; other images are fetched for each render. const EMOJI_ORIGIN = 'https://cdn.jsdelivr.net/'; @@ -163,8 +92,6 @@ export class ImageResponse extends Response { } } -class FallbackFontError extends Error {} - async function renderImage(element: RenderInput, options: ImageOptions): Promise { // Convert once, here: the fallback fonts depend on the final text, and // function components must not run twice. Emoji become images first, as in @@ -257,143 +184,6 @@ function imageCache(): ImageFetchCache { }; } -function hasBytes(font: Font): font is Exclude { - return ( - typeof font === 'object' && - (font.data instanceof ArrayBuffer || font.data instanceof Uint8Array) - ); -} - -const fontHashes = new WeakMap>(); - -// Takumi keeps every registered font for the life of the process and tells -// them apart by buffer identity, so a font read anew for each request would be -// registered again every time. Key it by content. -async function keyedFont(font: Font): Promise { - if (!hasBytes(font)) { - return font; - } - const { lang: _lang, data, ...details } = font; - let hash = fontHashes.get(data); - if (!hash) { - hash = crypto.subtle - .digest('SHA-256', data instanceof ArrayBuffer ? data : new Uint8Array(data)) - .then((digest) => - Array.from(new Uint8Array(digest, 0, 8), (byte) => byte.toString(16).padStart(2, '0')).join( - '', - ), - ); - fontHashes.set(data, hash); - } - return { - ...details, - key: `void/og ${details.name ?? ''}:${details.weight ?? ''}:${details.style ?? ''}:${await hash}`, - data: () => data, - }; -} - -// Han text is drawn as Japanese unless a language says otherwise. Takumi takes -// the first font with a glyph and ignores `lang`, so one family draws all Han -// text in an image, chosen from the `lang` option or the root element's `lang`. -function cjkFamily(text: string, lang = ''): string { - // An explicit language wins over the scripts in the text: a Traditional - // Chinese title with a katakana loanword (タワー) is still Chinese. Google's - // Noto Sans SC, TC and KR all include kana, so no character is left without - // a glyph. - const tag = lang.toLowerCase(); - if (tag.startsWith('ja')) { - return 'Noto Sans JP'; - } - if (tag.startsWith('ko')) { - return 'Noto Sans KR'; - } - if (tag.startsWith('zh')) { - return /hant|-tw|-hk|-mo/.test(tag) ? 'Noto Sans TC' : 'Noto Sans SC'; - } - // Without one, kana marks Japanese and Hangul marks Korean. - if (/[\p{sc=Hiragana}\p{sc=Katakana}]/u.test(text)) { - return 'Noto Sans JP'; - } - if (/\p{sc=Hangul}/u.test(text)) { - return 'Noto Sans KR'; - } - return 'Noto Sans JP'; -} - -async function fallbackFonts( - node: Node, - lang: string | undefined, - timeout: number | undefined, - failed: Set, -): Promise> { - const missing = Array.from(collectCodepoints(node)) - .filter((codepoint) => codepoint === 0xb5 || codepoint > 0xff) - .map((codepoint) => String.fromCodePoint(codepoint)) - .filter((character) => !BUILT_IN.test(character)); - const families = new Set( - FALLBACK_FONTS.filter(([script]) => missing.some((character) => script.test(character))).map( - ([, family]) => (family === 'Noto Sans JP' ? cjkFamily(missing.join(''), lang) : family), - ), - ); - if (missing.some((character) => !FALLBACK_FONTS.some(([script]) => script.test(character)))) { - families.add('Noto Sans'); - } - if (families.size === 0) { - return []; - } - // One request per family: takumi caches the CSS by URL, and a family's - // subsets are keyed alike whichever image asked for them. - const subsets = await Promise.all( - [...families].map(async (family) => { - try { - return await googleFonts({ - families: [ - SINGLE_WEIGHT_FAMILIES.has(family) ? family : { name: family, weight: '100..900' }, - ], - timeout, - }); - } catch (error) { - console.warn(`void/og: could not load ${family} from Google Fonts.`, error); - return []; - } - }), - ); - // Latin text should keep the default font, but Takumi draws each character - // with the first font that has it and puts registered fonts before its - // built-in Geist, so take ASCII and Latin-1 but µ out of every subset. A - // subset without ranges is a whole font. - const stripped = subsets.flat().flatMap((subset) => { - const unicodeRange = ( - subset.unicodeRange.length > 0 ? subset.unicodeRange : [[0x100, 0x10ffff] as const] - ).flatMap(([start, end]): Array<[number, number]> => [ - ...(start <= 0xb5 && 0xb5 <= end ? [[0xb5, 0xb5] as [number, number]] : []), - ...(end > 0xff ? [[Math.max(start, 0x100), end] as [number, number]] : []), - ]); - if (unicodeRange.length === 0) { - return []; - } - return { - ...subset, - unicodeRange, - // Private names: Takumi keeps fonts per process, so the app's own copy - // of the family, Latin glyphs included, must not join this one. - name: `void/og ${subset.name}`, - key: `void/og ${subset.key}`, - data: () => - subset.data().catch((cause: unknown) => { - // Record and report each failed file here: when several fail at - // once, Takumi's rejection carries only the first. - failed.add(`void/og ${subset.key}`); - console.warn(`void/og: could not download ${subset.name}. Rendering without it.`, cause); - throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause }); - }), - }; - }); - // Takumi downloads the subsets of its list-marker sample as well, so keep - // only those the text uses. - return subsetFonts({ fonts: stripped, source: node }); -} - function isDevelopment(): boolean { return (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true; } From d55e7e8d68d7a0634c25df399526b7390595edaa Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 11:08:17 +0800 Subject: [PATCH 25/35] perf(og): ask Google Fonts for only the characters in the image With a `text` query, Google answers with one file of just those characters and the whole weight axis. A 45-character Japanese title now takes 2 requests and one 26 KB file instead of 21 requests and 480 KB. Text too long for one query still asks for the range files. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/runtime/og-fonts.ts | 39 ++++++++++++++++++++------- packages/void/test/unit/og.test.ts | 27 +++++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/void/src/runtime/og-fonts.ts b/packages/void/src/runtime/og-fonts.ts index 16ca46ede..2c0253103 100644 --- a/packages/void/src/runtime/og-fonts.ts +++ b/packages/void/src/runtime/og-fonts.ts @@ -65,6 +65,20 @@ const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ // Found by asking css2 for each family with `wght@100..900`. const SINGLE_WEIGHT_FAMILIES = new Set(['Noto Sans Math', 'Noto Sans Symbols 2']); +// Google Fonts answers a `text` parameter with one file holding just those +// characters, with the whole weight axis. Without it, a family comes as files +// split by character range: a Japanese title needs about 20 of Noto Sans JP's +// 124, 480 KB in all, against one 26 KB file. Past a query of about 7,500 +// characters (some 800 kanji), Google returns every range file instead, and +// longer queries fail, so longer text asks for the range files. +const GOOGLE_FONTS_CSS = 'https://fonts.googleapis.com/css2'; +const MAX_TEXT_QUERY = 6000; + +function cssUrl(characters: Array): string | undefined { + const text = encodeURIComponent([...new Set(characters)].sort().join('')); + return text.length <= MAX_TEXT_QUERY ? `${GOOGLE_FONTS_CSS}?text=${text}` : undefined; +} + // What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found // by rendering each code point with takumi-js 2.18 (2.20 draws the same): some // accented letters and combining marks, spaces, format characters and variation @@ -153,26 +167,33 @@ export async function fallbackFonts( .filter((codepoint) => codepoint === 0xb5 || codepoint > 0xff) .map((codepoint) => String.fromCodePoint(codepoint)) .filter((character) => !BUILT_IN.test(character)); - const families = new Set( - FALLBACK_FONTS.filter(([script]) => missing.some((character) => script.test(character))).map( - ([, family]) => (family === 'Noto Sans JP' ? cjkFamily(missing.join(''), lang) : family), - ), + // The characters each family is asked to draw. + const families = new Map>(); + for (const [script, name] of FALLBACK_FONTS) { + const characters = missing.filter((character) => script.test(character)); + if (characters.length > 0) { + const family = name === 'Noto Sans JP' ? cjkFamily(missing.join(''), lang) : name; + families.set(family, characters); + } + } + const unmatched = missing.filter( + (character) => !FALLBACK_FONTS.some(([script]) => script.test(character)), ); - if (missing.some((character) => !FALLBACK_FONTS.some(([script]) => script.test(character)))) { - families.add('Noto Sans'); + if (unmatched.length > 0) { + families.set('Noto Sans', unmatched); } if (families.size === 0) { return []; } - // One request per family: takumi caches the CSS by URL, and a family's - // subsets are keyed alike whichever image asked for them. + // One request per family: takumi caches the CSS by URL. const subsets = await Promise.all( - [...families].map(async (family) => { + [...families].map(async ([family, characters]) => { try { return await googleFonts({ families: [ SINGLE_WEIGHT_FAMILIES.has(family) ? family : { name: family, weight: '100..900' }, ], + baseUrl: cssUrl(characters), timeout, }); } catch (error) { diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 2a34d920e..945456e4d 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -277,6 +277,33 @@ describe('ImageResponse fallback fonts', () => { expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Geist:wght@100..900']); }); + it('asks each family for only the characters it draws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + await new ImageResponse('
本日 ★ 日本語 ★
').arrayBuffer(); + const texts = Object.fromEntries( + requests.map((url) => { + const { searchParams } = new URL(url); + return [searchParams.get('family'), searchParams.get('text')]; + }), + ); + expect(texts).toEqual({ + 'Noto Sans JP:wght@100..900': '日本語', + 'Noto Sans Symbols:wght@100..900': '★', + 'Noto Sans Symbols 2:wght@400': '★', + }); + }); + + it('asks for the whole family when the text is too long for one request', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + const kanji = Array.from({ length: 700 }, (_, i) => String.fromCodePoint(0x4e00 + i)).join(''); + await new ImageResponse(`
${kanji}
`).arrayBuffer(); + const css = new URL(requests[0]!); + expect(css.searchParams.getAll('family')).toEqual(['Noto Sans JP:wght@100..900']); + expect(css.searchParams.has('text')).toBe(false); + }); + it('draws the marks ・ and ー with the Japanese family', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch(() => new Response(null, { status: 404 })); From 64d88ec9f03a7ff282d9e5ae39f55e1c5979a021 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 12:39:02 +0800 Subject: [PATCH 26/35] feat(og): add fallbackFonts to turn off Google Fonts downloads Each image with new text downloads a small font, and a server keeps it until it restarts. The guide now says so, and shows how to pass the fonts an app needs with fallbackFonts: false to download none. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 18 +++++++++++++++++- packages/void/src/runtime/og.ts | 9 ++++++++- packages/void/test/unit/og.test.ts | 15 +++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index ed2d3b267..72c060905 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -78,6 +78,7 @@ The second argument sets the image and the response: | --------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `width`, `height` | `1200`, `630` | Image size in pixels. | | `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). | +| `fallbackFonts` | `true` | Set to `false` to download no fonts from Google Fonts. See [Control font downloads](#control-font-downloads). | | `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, or `fluentFlat`. | | `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | | `debug` | `false` | Draws a border around every box. | @@ -97,7 +98,7 @@ Emoji and remote images are downloaded while the image renders. An emoji that th ## Fonts -Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching font from Google Fonts while rendering, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. +Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching font from Google Fonts while rendering, with only the characters the image uses, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. Chinese characters are drawn in the Japanese style unless the `lang` option or the root element's `lang` attribute names the language, or the text has Korean around them. A `lang` wins over the text, so a Chinese title with a katakana word keeps the Chinese style: @@ -122,6 +123,21 @@ A `weight` from 1 to 1000 makes the font draw only that weight. Leave it out for Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too. A font entry's `lang` is accepted but does not choose the font for a language; use the `lang` option or attribute for Chinese characters instead. +### Control font downloads + +Each server keeps the fonts it downloads in memory until it restarts. Every image with new text downloads a small font made for that text, such as a 26 KB file for a Japanese title, so the memory of a long-running Node.js, Bun, or Deno server grows with the number of different titles it renders. On Cloudflare Workers, the downloads also count toward the [subrequest limit](https://developers.cloudflare.com/workers/platform/limits/#subrequests) and add latency to the first render. + +To decide which fonts load and when, pass the fonts your images need in `fonts` and set `fallbackFonts: false`. Void then downloads no fonts, and characters that neither your fonts nor Geist can draw are drawn as boxes: + +```ts +return new ImageResponse(postCard(post), { + fonts: [{ name: 'Noto Sans JP', data: notoSansJp }], + fallbackFonts: false, +}); +``` + +A font passed as data loads once per server, however many images use it. + ## Add the image to a page Point an `og:image` tag at the route from your page's [`head()`](./pages-routing/head.md): diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index e05f4955d..dfac6324d 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -27,6 +27,11 @@ export interface ImageResponseOptions extends ResponseInit { debug?: boolean; /** Fonts to draw text with, before the built-in Geist. */ fonts?: Array; + /** + * Set to false to download no fonts from Google Fonts for characters that + * the built-in Geist cannot draw, leaving them to `fonts`. Defaults to true. + */ + fallbackFonts?: boolean; /** The emoji images to draw. Defaults to `twemoji`. */ emoji?: 'twemoji' | 'blobmoji' | 'noto' | 'openmoji' | 'fluent' | 'fluentFlat'; /** @@ -121,7 +126,9 @@ async function renderImage(element: RenderInput, options: ImageOptions): Promise const failed = new Set(); const [fonts, fallback] = await Promise.all([ Promise.all((options.fonts ?? []).map(keyedFont)), - fallbackFonts(node, options.lang ?? node.lang, timeout, failed), + options.fallbackFonts === false + ? [] + : fallbackFonts(node, options.lang ?? node.lang, timeout, failed), ]); if (fallback.length === 0) { return renderNode(fonts); diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 945456e4d..6a198a379 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -277,6 +277,21 @@ describe('ImageResponse fallback fonts', () => { expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Geist:wght@100..900']); }); + it('downloads no fallback font when fallbackFonts is false', async () => { + const requests = stubFetch(() => new Response(null, { status: 404 })); + vi.mocked(render).mockClear(); + const data = new Uint8Array([1, 2, 3]); + await new ImageResponse('
日本語 ★ Łódź
', { + fonts: [{ name: 'App', data }], + fallbackFonts: false, + }) + .arrayBuffer() + .catch(() => {}); + expect(requests).toEqual([]); + const fonts = vi.mocked(render).mock.lastCall?.[1]?.fonts as Array<{ name: string }>; + expect(fonts.map(({ name }) => name)).toEqual(['App']); + }); + it('asks each family for only the characters it draws', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch(() => new Response(null, { status: 404 })); From 47d2bfa9b6e1881c9b0d58ea6fa252352ef0768c Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 12:45:33 +0800 Subject: [PATCH 27/35] docs(og): note font memory growth in the Node.js, Bun and Deno section Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 72c060905..d6a83ff3e 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -190,3 +190,5 @@ npm install takumi-js@~2.20.0 ``` If the build cannot find a version of `takumi-js` that Void supports, it fails and prints the command to install one. + +The server keeps every font it downloads for text that Geist cannot draw until it restarts, so its memory grows with each image that has new text. If your images show many different titles in other scripts, pass your own fonts with `fallbackFonts: false`; see [Control font downloads](#control-font-downloads). From 70f1d83222162baf7aa679f0fbadb315fd4f91d7 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 12:57:05 +0800 Subject: [PATCH 28/35] fix(og): send one Cache-Control header in development and builds With max-age=0 and no validator, clients refetch the image on each use, so the dev server needs no `no-cache, no-store` of its own. This drops the runtime check for development, which read a flag that is also true in builds made with NODE_ENV=development. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 2 +- packages/void/src/runtime/og.ts | 14 ++++---------- packages/void/test/integration/og-image.test.ts | 15 +++++++-------- packages/void/test/unit/og.test.ts | 7 ++++--- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index d6a83ff3e..ad11fb7b4 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -92,7 +92,7 @@ Content-Type: image/png Cache-Control: public, max-age=0, must-revalidate ``` -During local development, `Cache-Control` is `no-cache, no-store`. Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy. +Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy. Emoji and remote images are downloaded while the image renders. An emoji that the chosen style does not have, or an image that fails to download, fails the image unless you pass `images: { throwOnError: false }`, which leaves it out; `noto` has no flags. diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index dfac6324d..83b9ed492 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -62,23 +62,21 @@ const emojiCache = new Map>(); /** * A `Response` whose body is the image rendered from `element`: React JSX, an * HTML string, or a Takumi node tree. It renders a 1200×630 PNG by default, - * which clients revalidate outside development. + * which clients revalidate on each use. */ export class ImageResponse extends Response { constructor(element: RenderInput, options: ImageResponseOptions = {}) { const { status, statusText, headers, ...imageOptions } = options; // Set these two headers unless the caller's `headers` has them. A failed // render still ends as a complete response, so clients revalidate the - // image rather than cache it for long. + // image rather than cache it. With no validator, revalidating fetches it + // again, so the dev server needs no header of its own. const responseHeaders = new Headers(headers); if (!responseHeaders.has('content-type')) { responseHeaders.set('content-type', 'image/png'); } if (!responseHeaders.has('cache-control')) { - responseHeaders.set( - 'cache-control', - isDevelopment() ? 'no-cache, no-store' : 'public, max-age=0, must-revalidate', - ); + responseHeaders.set('cache-control', 'public, max-age=0, must-revalidate'); } // Render while the body streams, so a failure errors the body. const body = new ReadableStream({ @@ -190,7 +188,3 @@ function imageCache(): ImageFetchCache { }, }; } - -function isDevelopment(): boolean { - return (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true; -} diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts index e140fa920..c21ced4b9 100644 --- a/packages/void/test/integration/og-image.test.ts +++ b/packages/void/test/integration/og-image.test.ts @@ -16,8 +16,7 @@ import { killChild, startServer, waitForServer } from '../../../../test/integrat import { collectPrerenderWorkerModules } from '../../src/prerender.ts'; const repository = join(import.meta.dirname, '..', '..', '..', '..'); -const DEV_HEADER = 'no-cache, no-store'; -const BUILD_HEADER = 'public, max-age=0, must-revalidate'; +const CACHE_HEADER = 'public, max-age=0, must-revalidate'; // Vitest sets NODE_ENV=test, which would make Vite build in development mode. const { NODE_ENV: _nodeEnv, ...userEnv } = process.env; @@ -171,16 +170,16 @@ describe('void/og in a Cloudflare app', () => { expectStatus: 200, diagnostics: server.output, }); - await expectImage(await fetch(`${server.base}/api/og`), DEV_HEADER); - await expectImage(await fetch(`${server.base}/api/card`), DEV_HEADER); + await expectImage(await fetch(`${server.base}/api/og`), CACHE_HEADER); + await expectImage(await fetch(`${server.base}/api/card`), CACHE_HEADER); }); }); describe('build', () => { it('renders HTML and JSX in the built Worker', async () => { build(root); - await renderBuilt(root, '/api/og', BUILD_HEADER); - await renderBuilt(root, '/api/card', BUILD_HEADER); + await renderBuilt(root, '/api/og', CACHE_HEADER); + await renderBuilt(root, '/api/card', CACHE_HEADER); }); }); }); @@ -190,7 +189,7 @@ describe('void/og in a Cloudflare app without React', () => { // Takumi's JSX helper imports React as an optional peer; the HTML route needs none. const root = createApp({ 'routes/api/og.ts': OG_ROUTE }, {}); build(root); - await renderBuilt(root, '/api/og', BUILD_HEADER); + await renderBuilt(root, '/api/og', CACHE_HEADER); }); }); @@ -235,7 +234,7 @@ describe('void/og on the Node.js target', () => { }); return child; }, 20_000); - await expectImage(await fetch(`${server.base}/api/og`), BUILD_HEADER); + await expectImage(await fetch(`${server.base}/api/og`), CACHE_HEADER); }); it('fails the build without takumi-js', () => { diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index 6a198a379..eff3a73a8 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -109,9 +109,10 @@ describe('ImageResponse', () => { expect(Title).toHaveBeenCalledOnce(); }); - // Builds send `public, max-age=0, must-revalidate`: test/integration/og-image.test.ts. - it('does not cache images during development', () => { - expect(new ImageResponse(card).headers.get('cache-control')).toBe('no-cache, no-store'); + it('tells clients to revalidate the image on each use', () => { + expect(new ImageResponse(card).headers.get('cache-control')).toBe( + 'public, max-age=0, must-revalidate', + ); }); it('keeps the status and headers it is given', () => { From 192bd4082620fffc54f4545e151fb7288f9a60d8 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 12:59:49 +0800 Subject: [PATCH 29/35] docs(og): trim the void/og notes in the package AGENTS.md Keep what maintainers act on: the declared options, the Node.js, Bun and Deno install check, and the Takumi upgrade steps. The font and Wasm details already live in the source comments. Co-Authored-By: Claude Opus 5.5 --- packages/void/AGENTS.md | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 6ef106881..8225ded4a 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -157,26 +157,17 @@ Vite/CLI dependency graph. `void/remote` stays exported only for semver. Generated code imports `#self/remote`. Remove the subpath in the next major version. -`void/og` is a thin `ImageResponse` layer over `takumi-js`. -`ImageResponseOptions` is declared in `og.ts` and mapped to Takumi's render -options one by one. Do not -pass Takumi's options through: a Takumi upgrade must not change the API. In workerd, -Takumi resolves its Wasm backend, whose `.wasm` import the Cloudflare plugin -loads as a compiled module in dev and builds. On Node.js, Bun and Deno, Takumi -loads its renderer from files next to its code (a native addon on Node.js and -Bun), which a bundle cannot carry, so `nodeTargetPlugin` fails a build unless -the app root resolves a `takumi-js` in the range void declares. Takumi's JSX -helper imports `react` as an optional peer; Vite stubs it in apps without -React, and Takumi catches the failed import. Fallback fonts come from Google -Fonts by the scripts in the text, under private family names, with Latin-1 -taken out so Latin text keeps Takumi's built-in Geist. takumi-js is on -`~2.20`. Avoid 2.19.0: it loads only the first font whose `unicode-range` covers -a character, and draws the whole word as boxes when that font lacks the glyph -(Noto Sans JP's latin-ext subset has no Ł). Before raising the range, render -Japanese next to "Łódź" with the new version in a fresh process, as 2.19.0 falls -back to faces an earlier render loaded, and check that the built-in Geist still -draws the code points `BUILT_IN` lists in `og-fonts.ts`. Then raise the range, the install -command and the Worker size in `docs/guide/og-images.md`. +`void/og` is a thin `ImageResponse` layer over `takumi-js`. Keep +`ImageResponseOptions` declared in `og.ts` and map each option to Takumi: +passing Takumi's options through would let a Takumi upgrade change the API. +On Node.js, Bun and Deno, Takumi loads its renderer from files next to its +code, which a bundle cannot carry, so `nodeTargetPlugin` fails a build unless +the app resolves a `takumi-js` in void's range. takumi-js is on `~2.20`; skip +2.19.0, which draws a word as boxes when the first font covering a character +lacks its glyph. Before raising the range, render Japanese next to "Łódź" in +a fresh process and check that Geist still draws `BUILT_IN` in `og-fonts.ts`, +then update the range, the install command and the Worker size in +`docs/guide/og-images.md`. Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). From a07c95deb2c7df6eb6ad13c4470e3669dbd2bbec Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 13:28:35 +0800 Subject: [PATCH 30/35] docs(og): describe the renderer's size without Cloudflare's limits Cloudflare now limits a Worker to 64 MiB uncompressed on every plan and has no compressed limit, so the guide no longer counts down from 3 MB. It keeps what the renderer costs: 4.3 MB more to upload and to start. Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index ad11fb7b4..a8347a74b 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -179,7 +179,7 @@ Void never caches paths under `/api`, so serve images from another path, such as ## Worker size -On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4.3 MB, or 1.9 MB compressed. Cloudflare limits a Worker to 3 MB compressed on Workers Free and 10 MB on Workers Paid (see [Worker size](https://developers.cloudflare.com/workers/platform/limits/#worker-size)), so on Workers Free the rest of your app has about 1.1 MB. Apps that do not import `void/og` do not include the renderer. +On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4.3 MB, or 1.9 MB compressed. A larger Worker takes longer to upload on each deploy and to start in a new instance. Apps that do not import `void/og` do not include the renderer. ## Node.js, Bun, and Deno From 18388cb5a308a5ca3ec725da4641e6be6923d2d8 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 14:38:53 +0800 Subject: [PATCH 31/35] fix(og): keep Han characters when Korean text also has Hangul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text with Hangul draws Han characters with Noto Sans KR, so both scripts asked for the same family, and the Hangul request replaced the Han one. "日本語・中文・한국어" drew its Han characters, and the numbers beside them, as boxes. Ask the family for both. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/runtime/og-fonts.ts | 3 ++- packages/void/test/unit/og.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/void/src/runtime/og-fonts.ts b/packages/void/src/runtime/og-fonts.ts index 2c0253103..5a164d06e 100644 --- a/packages/void/src/runtime/og-fonts.ts +++ b/packages/void/src/runtime/og-fonts.ts @@ -172,8 +172,9 @@ export async function fallbackFonts( for (const [script, name] of FALLBACK_FONTS) { const characters = missing.filter((character) => script.test(character)); if (characters.length > 0) { + // Han text can go to the same family as Hangul, so add to it. const family = name === 'Noto Sans JP' ? cjkFamily(missing.join(''), lang) : name; - families.set(family, characters); + families.set(family, [...(families.get(family) ?? []), ...characters]); } } const unmatched = missing.filter( diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts index eff3a73a8..d041b1d63 100644 --- a/packages/void/test/unit/og.test.ts +++ b/packages/void/test/unit/og.test.ts @@ -310,6 +310,18 @@ describe('ImageResponse fallback fonts', () => { }); }); + it('asks the Korean family for both Han and Hangul in Korean text', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + await new ImageResponse('
中文・한국어
').arrayBuffer(); + expect( + requests.map((url) => { + const { searchParams } = new URL(url); + return [searchParams.get('family'), searchParams.get('text')]; + }), + ).toEqual([['Noto Sans KR:wght@100..900', [...'中文・한국어'].sort().join('')]]); + }); + it('asks for the whole family when the text is too long for one request', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const requests = stubFetch(() => new Response(null, { status: 404 })); From 461ee03072c89bc1768b0bcb9ede545dbff56680 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 14:38:55 +0800 Subject: [PATCH 32/35] docs(og): load the example font from Google Fonts Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index a8347a74b..139b930f1 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -113,7 +113,7 @@ To use your own fonts, pass `fonts`. An entry is either a font URL or an object ```ts return new ImageResponse(postCard(post), { fonts: [ - 'https://example.com/fonts/Inter-Bold.woff2', + 'https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hiA.woff2', { name: 'Inter', data: interRegular, weight: 400, style: 'normal' }, ], }); From 187616061d3e138640d14eaa99c09021efde719b Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 12 Nov 2026 14:47:10 +0800 Subject: [PATCH 33/35] docs(og): say which fonts draw text and name the Noto fallback Co-Authored-By: Claude Opus 5.5 --- docs/guide/og-images.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md index 139b930f1..9ec11b410 100644 --- a/docs/guide/og-images.md +++ b/docs/guide/og-images.md @@ -98,7 +98,9 @@ Emoji and remote images are downloaded while the image renders. An emoji that th ## Fonts -Text uses Geist unless you pass `fonts`. When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads the matching font from Google Fonts while rendering, with only the characters the image uses, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. +Text uses Geist unless you pass `fonts`. Your fonts draw every character they have, and Geist draws only the characters they lack. + +When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads a font that has them from Google Fonts while rendering: the matching [Noto](https://fonts.google.com/noto) font, such as Noto Sans JP for Japanese, or Geist's own Cyrillic and accented letters. It asks for only the characters the image uses, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. Chinese characters are drawn in the Japanese style unless the `lang` option or the root element's `lang` attribute names the language, or the text has Korean around them. A `lang` wins over the text, so a Chinese title with a katakana word keeps the Chinese style: From af9934cc70f1d719cfdb24b5bab1e2bbd6731904 Mon Sep 17 00:00:00 2001 From: Sasha Koss Date: Thu, 12 Nov 2026 16:50:10 +0800 Subject: [PATCH 34/35] chore: add ImageResponse playground --- playground/image-response/README.md | 9 + playground/image-response/package.json | 25 ++ .../pages/examples/[variant].server.ts | 38 +++ .../pages/examples/[variant].tsx | 168 +++++++++++++ playground/image-response/pages/index.tsx | 36 +++ playground/image-response/pages/layout.tsx | 18 ++ playground/image-response/pages/styles.css | 101 ++++++++ .../routes/og/article/[slug].tsx | 18 ++ playground/image-response/routes/og/emoji.tsx | 21 ++ playground/image-response/routes/og/fonts.tsx | 30 +++ playground/image-response/routes/og/html.ts | 15 ++ .../image-response/routes/og/languages.tsx | 18 ++ .../image-response/routes/og/layout.tsx | 23 ++ .../image-response/routes/og/remote.tsx | 28 +++ .../image-response/routes/og/square.tsx | 15 ++ playground/image-response/src/card.tsx | 21 ++ playground/image-response/src/examples.ts | 231 ++++++++++++++++++ playground/image-response/src/fonts.ts | 32 +++ playground/image-response/src/posts.ts | 12 + playground/image-response/src/source-link.tsx | 14 ++ playground/image-response/tsconfig.json | 18 ++ playground/image-response/vite.config.ts | 10 + playground/image-response/void.config.ts | 9 + playground/image-response/void.lock.json | 8 + pnpm-lock.yaml | 34 +++ 25 files changed, 952 insertions(+) create mode 100644 playground/image-response/README.md create mode 100644 playground/image-response/package.json create mode 100644 playground/image-response/pages/examples/[variant].server.ts create mode 100644 playground/image-response/pages/examples/[variant].tsx create mode 100644 playground/image-response/pages/index.tsx create mode 100644 playground/image-response/pages/layout.tsx create mode 100644 playground/image-response/pages/styles.css create mode 100644 playground/image-response/routes/og/article/[slug].tsx create mode 100644 playground/image-response/routes/og/emoji.tsx create mode 100644 playground/image-response/routes/og/fonts.tsx create mode 100644 playground/image-response/routes/og/html.ts create mode 100644 playground/image-response/routes/og/languages.tsx create mode 100644 playground/image-response/routes/og/layout.tsx create mode 100644 playground/image-response/routes/og/remote.tsx create mode 100644 playground/image-response/routes/og/square.tsx create mode 100644 playground/image-response/src/card.tsx create mode 100644 playground/image-response/src/examples.ts create mode 100644 playground/image-response/src/fonts.ts create mode 100644 playground/image-response/src/posts.ts create mode 100644 playground/image-response/src/source-link.tsx create mode 100644 playground/image-response/tsconfig.json create mode 100644 playground/image-response/vite.config.ts create mode 100644 playground/image-response/void.config.ts create mode 100644 playground/image-response/void.lock.json diff --git a/playground/image-response/README.md b/playground/image-response/README.md new file mode 100644 index 000000000..8043c92af --- /dev/null +++ b/playground/image-response/README.md @@ -0,0 +1,9 @@ +# ImageResponse Playground + +Demo of `ImageResponse` from `void/og`. + +To start dev server, run the repository root: + +```sh +vp run --filter ./playground/image-response dev +``` diff --git a/playground/image-response/package.json b/playground/image-response/package.json new file mode 100644 index 000000000..249380f0d --- /dev/null +++ b/playground/image-response/package.json @@ -0,0 +1,25 @@ +{ + "name": "playground-image-response", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "dev": "vp dev", + "preview": "vp preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@void/react": "workspace:*", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "void": "workspace:*" + } +} diff --git a/playground/image-response/pages/examples/[variant].server.ts b/playground/image-response/pages/examples/[variant].server.ts new file mode 100644 index 000000000..2c24c5aec --- /dev/null +++ b/playground/image-response/pages/examples/[variant].server.ts @@ -0,0 +1,38 @@ +import { defineHandler, defineHead } from 'void'; +import { getExample, imagePath } from '../../src/examples.ts'; + +export interface Props { + variant: string; + values: Record; + imageUrl: string; +} + +export const loader = defineHandler((c) => { + const example = getExample(c.req.param('variant') ?? ''); + if (!example) { + return c.notFound(); + } + const values = Object.fromEntries( + example.controls.map((control) => { + const requested = c.req.query(control.name) ?? control.value; + const value = + control.options && !control.options.some((option) => option.value === requested) + ? control.value + : requested.slice(0, 200); + return [control.name, value]; + }), + ); + return { variant: example.id, values, imageUrl: imagePath(example.id, values) } satisfies Props; +}); + +export const head = defineHead((c, props) => { + const example = getExample(props.variant)!; + return { + title: `${example.title} | ImageResponse Playground`, + meta: [ + { property: 'og:title', content: example.title }, + { property: 'og:image', content: new URL(props.imageUrl, c.req.url).href }, + { name: 'twitter:card', content: 'summary_large_image' }, + ], + }; +}); diff --git a/playground/image-response/pages/examples/[variant].tsx b/playground/image-response/pages/examples/[variant].tsx new file mode 100644 index 000000000..46fe47eff --- /dev/null +++ b/playground/image-response/pages/examples/[variant].tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react'; +import { getExample } from '../../src/examples.ts'; +import { SourceLink } from '../../src/source-link.tsx'; +import type { Props } from './[variant].server.ts'; + +function Preview({ imageUrl, title }: { imageUrl: string; title: string }) { + const [imageSrc, setImageSrc] = useState(); + const [size, setSize] = useState('Loading image…'); + const [responseInfo, setResponseInfo] = useState('Fetching image…'); + + useEffect(() => { + const controller = new AbortController(); + let objectUrl: string | undefined; + + async function load() { + let info = ''; + try { + const response = await fetch(imageUrl, { cache: 'no-store', signal: controller.signal }); + info = [ + `HTTP ${response.status}`, + ...Array.from(response.headers, ([k, v]) => `${k}: ${v}`), + ].join('\n'); + setResponseInfo(info); + const bytes = await response.arrayBuffer(); + if (controller.signal.aborted) { + return; + } + info += `\n\nBody: ${bytes.byteLength.toLocaleString()} bytes`; + const png = new DataView(bytes); + if ( + !response.ok || + bytes.byteLength < 24 || + png.getUint32(0) !== 0x89504e47 || + png.getUint32(4) !== 0x0d0a1a0a + ) { + info += `\n\n${new TextDecoder().decode(bytes.slice(0, 2000))}`; + throw new Error( + response.ok ? 'The response body is not a PNG.' : `HTTP ${response.status}`, + ); + } + info += `\nPNG: ${png.getUint32(16)} × ${png.getUint32(20)}`; + objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); + setImageSrc(objectUrl); + setResponseInfo(info); + } catch (error) { + if (controller.signal.aborted) { + return; + } + setSize('Image failed to load. See the response below and server log.'); + setResponseInfo(`${info}\n\nImage request/body failed: ${String(error)}`.trim()); + } + } + + void load(); + return () => { + controller.abort(); + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [imageUrl]); + + return ( +
+
+ {imageSrc && ( + {title} + setSize( + `${event.currentTarget.naturalWidth} × ${event.currentTarget.naturalHeight} PNG`, + ) + } + onError={() => + setSize('Image could not be decoded. See the response below and server log.') + } + /> + )} +
+

{size}

+

+ Open PNG |{' '} + + Download PNG + +

+

+ {imageUrl} +

+

Response

+
{responseInfo}
+
+ ); +} + +export default function ExamplePage({ variant, values, imageUrl }: Props) { + const example = getExample(variant)!; + return ( + <> +

{example.title}

+

{example.description}

+

+ +

+ {variant === 'article' && ( +

+ |{' '} + Missing article (404) +

+ )} +
{example.code}
+ {example.network && ( +

+ Network: {example.network} +

+ )} + {example.controls.length > 0 && ( +
+ {example.controls.map((control) => ( + + ))} +
+ )} +
+ +
+

Expected

+
    + {example.checks.map((check) => ( +
  • {check}
  • + ))} +
+

All images are 1200 × 630 unless the demo explicitly sets a different size.

+

+ (loader and page head) +

+
+
+ + ); +} diff --git a/playground/image-response/pages/index.tsx b/playground/image-response/pages/index.tsx new file mode 100644 index 000000000..b70d9a335 --- /dev/null +++ b/playground/image-response/pages/index.tsx @@ -0,0 +1,36 @@ +import { Link } from '@void/react'; +import { examples } from '../src/examples.ts'; +import { SourceLink } from '../src/source-link.tsx'; + +export default function Home() { + return ( + <> +

ImageResponse Playground

+

+ Eight distinct uses of ImageResponse from void/og. Each example + has a live preview, source links, and expected results. Options that exercise the same use + case stay together on one page. +

+

+ Start with the JSX or HTML examples for an offline baseline. Fonts, multilingual text, + emoji, and remote images also exercise server-side downloads. Use the Network panel and + server log alongside the previews. +

+
+ {examples.map((example) => ( +
+

+ {example.title} +

+

{example.description}

+ +
{example.code}
+

+ Open demo +

+
+ ))} +
+ + ); +} diff --git a/playground/image-response/pages/layout.tsx b/playground/image-response/pages/layout.tsx new file mode 100644 index 000000000..54310bee8 --- /dev/null +++ b/playground/image-response/pages/layout.tsx @@ -0,0 +1,18 @@ +import { Link } from '@void/react'; +import type { ReactNode } from 'react'; +import './styles.css'; + +export default function Layout({ children }: { children: ReactNode }) { + return ( +
+
+ +
+
+
{children}
+
+ ); +} diff --git a/playground/image-response/pages/styles.css b/playground/image-response/pages/styles.css new file mode 100644 index 000000000..2a9159a6e --- /dev/null +++ b/playground/image-response/pages/styles.css @@ -0,0 +1,101 @@ +:root { + font-family: system-ui, sans-serif; + color: #0f172a; + background: #f8fafc; + line-height: 1.5; +} + +body { + margin: 0; +} +a { + color: #1d4ed8; +} +button, +input, +select { + font: inherit; +} +button { + cursor: pointer; +} +.example-container { + max-width: 100rem; + margin-inline: auto; + padding: 1.5rem; +} +p { + max-width: 80ch; +} +pre { + padding: 1rem; + background: #e2e8f0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +code { + overflow-wrap: anywhere; +} +.examples { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem 2rem; +} +.example { + min-width: 0; + border-bottom: 1px solid #cbd5e1; + padding-bottom: 1rem; +} +.example h2 { + font-size: 1.2rem; +} +.controls { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 1rem; + margin-block: 1.5rem; +} +.controls label { + display: grid; + gap: 0.25rem; +} +.controls input { + width: min(30rem, 75vw); +} +.demo { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr); + gap: 2rem; +} +.preview { + background: #e2e8f0; + border: 1px solid #cbd5e1; + padding: 0.5rem; +} +.preview img { + display: block; + max-width: 100%; + height: auto; + margin-inline: auto; +} +.notice { + padding: 0.75rem; + background: #fef3c7; +} +li + li { + margin-top: 0.75rem; +} + +@media (max-width: 75rem) { + .examples { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 50rem) { + .examples, + .demo { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/playground/image-response/routes/og/article/[slug].tsx b/playground/image-response/routes/og/article/[slug].tsx new file mode 100644 index 000000000..05b8ee329 --- /dev/null +++ b/playground/image-response/routes/og/article/[slug].tsx @@ -0,0 +1,18 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { Card } from '../../../src/card.tsx'; +import { getPost } from '../../../src/posts.ts'; + +export const GET = defineHandler((c) => { + const post = getPost(c.req.param('slug') ?? ''); + if (!post) { + return c.notFound(); + } + return new ImageResponse( + +
{post.category}
+
{post.title}
+
{post.author}
+
, + ); +}); diff --git a/playground/image-response/routes/og/emoji.tsx b/playground/image-response/routes/og/emoji.tsx new file mode 100644 index 000000000..57d1342d4 --- /dev/null +++ b/playground/image-response/routes/og/emoji.tsx @@ -0,0 +1,21 @@ +import { defineHandler } from 'void'; +import { ImageResponse, type ImageResponseOptions } from 'void/og'; +import { Card } from '../../src/card.tsx'; + +const styles = ['twemoji', 'blobmoji', 'noto', 'openmoji', 'fluent', 'fluentFlat'] as const; + +export const GET = defineHandler((c) => { + const requested = c.req.query('emoji'); + const emoji: ImageResponseOptions['emoji'] = + styles.find((style) => style === requested) ?? 'twemoji'; + return new ImageResponse( + +
EMOJI / {emoji}
+
Ship something great 🚀
+
+ {c.req.query('sample') === 'joined' ? '👩‍💻 🎉 🌍 ❤️' : '😀 🎉 🌍 ❤️'} +
+
, + { emoji, images: { timeout: 5000 } }, + ); +}); diff --git a/playground/image-response/routes/og/fonts.tsx b/playground/image-response/routes/og/fonts.tsx new file mode 100644 index 000000000..ab4cd8fc6 --- /dev/null +++ b/playground/image-response/routes/og/fonts.tsx @@ -0,0 +1,30 @@ +import { defineHandler } from 'void'; +import { ImageResponse, type ImageResponseOptions } from 'void/og'; +import { Card } from '../../src/card.tsx'; +import { fontPresets } from '../../src/fonts.ts'; + +export const GET = defineHandler(async (c) => { + const font = fontPresets.find((preset) => preset.id === c.req.query('font')) ?? fontPresets[0]!; + let fonts: ImageResponseOptions['fonts'] = [font.url]; + if (c.req.query('mode') === 'data') { + const response = await fetch(font.url); + if (!response.ok) { + return c.text('Could not download the demo font.', 502); + } + // No weight: the variable font follows each element's fontWeight. + fonts = [{ name: font.name, data: await response.arrayBuffer() }]; + } + return new ImageResponse( + +
+ {font.name.toUpperCase()} / VARIABLE WEIGHTS +
+ {[400, 550, 700].map((weight) => ( +
+ {weight} — Build with Void +
+ ))} +
, + { fonts, fallbackFonts: false }, + ); +}); diff --git a/playground/image-response/routes/og/html.ts b/playground/image-response/routes/og/html.ts new file mode 100644 index 000000000..c696e6ec8 --- /dev/null +++ b/playground/image-response/routes/og/html.ts @@ -0,0 +1,15 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; + +const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); + +export const GET = defineHandler((c) => { + const title = (c.req.query('title') ?? 'Hello without JSX').slice(0, 200); + return new ImageResponse(` +
+
HTML string, not React
+
${escapeHtml(title)}
+
+ `); +}); diff --git a/playground/image-response/routes/og/languages.tsx b/playground/image-response/routes/og/languages.tsx new file mode 100644 index 000000000..918fcc986 --- /dev/null +++ b/playground/image-response/routes/og/languages.tsx @@ -0,0 +1,18 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { Card } from '../../src/card.tsx'; + +export const GET = defineHandler((c) => { + const requested = c.req.query('lang') ?? 'ja'; + const lang = ['ja', 'zh-CN', 'zh-TW', 'ko'].includes(requested) ? requested : 'ja'; + return new ImageResponse( + +
GEIST + FALLBACK FONTS / {lang}
+
Hello, Łódź! → ★
+
骨 直 令 / 世界 / タワー
+
مرحبا بالعالم
+
สวัสดีชาวโลก
+
, + { lang, fallbackFonts: c.req.query('fallback') !== 'off' }, + ); +}); diff --git a/playground/image-response/routes/og/layout.tsx b/playground/image-response/routes/og/layout.tsx new file mode 100644 index 000000000..e99f04fd6 --- /dev/null +++ b/playground/image-response/routes/og/layout.tsx @@ -0,0 +1,23 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; + +export const GET = defineHandler( + (c) => + new ImageResponse( +
+
+
VOID / LAYOUT
+
Small markup. Big preview.
+
Tailwind classes inside the image.
+
+
+
Vite
+
Void
+
+
, + { debug: c.req.query('debug') === 'on' }, + ), +); diff --git a/playground/image-response/routes/og/remote.tsx b/playground/image-response/routes/og/remote.tsx new file mode 100644 index 000000000..e14ef090f --- /dev/null +++ b/playground/image-response/routes/og/remote.tsx @@ -0,0 +1,28 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { Card } from '../../src/card.tsx'; + +// Fixed input: this demo never accepts arbitrary remote URLs from a request. +const avatar = 'https://avatars.githubusercontent.com/u/65625612?s=256'; + +export const GET = defineHandler((c) => { + const mode = c.req.query('mode') ?? 'allow'; + return new ImageResponse( + +
REMOTE IMAGE / {mode}
+
+ +
An image from the web.
+
+
The text survives a lenient image failure.
+
, + { + images: { + allowUrl: (url) => url === avatar && (mode === 'allow' || mode === 'limit'), + maxBytes: mode === 'limit' ? 64 : 1024 * 1024, + timeout: 5000, + throwOnError: mode === 'allow' || mode === 'fail', + }, + }, + ); +}); diff --git a/playground/image-response/routes/og/square.tsx b/playground/image-response/routes/og/square.tsx new file mode 100644 index 000000000..950f66d66 --- /dev/null +++ b/playground/image-response/routes/og/square.tsx @@ -0,0 +1,15 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { Card } from '../../src/card.tsx'; + +export const GET = defineHandler( + () => + new ImageResponse( + +
A DIFFERENT FORMAT
+
Think outside the rectangle.
+
600 x 600
+
, + { width: 600, height: 600, headers: { 'Cache-Control': 'no-store', 'X-OG-Demo': 'square' } }, + ), +); diff --git a/playground/image-response/src/card.tsx b/playground/image-response/src/card.tsx new file mode 100644 index 000000000..60df3b596 --- /dev/null +++ b/playground/image-response/src/card.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +export function Card({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/playground/image-response/src/examples.ts b/playground/image-response/src/examples.ts new file mode 100644 index 000000000..5d8dc2126 --- /dev/null +++ b/playground/image-response/src/examples.ts @@ -0,0 +1,231 @@ +import { fontPresets } from './fonts.ts'; + +export interface Control { + name: string; + label: string; + value: string; + options?: Array<{ value: string; label: string }>; +} + +interface Example { + id: string; + title: string; + description: string; + file: string; + code: string; + checks: Array; + controls: Array; + network?: string; +} + +export const examples: Array = [ + { + id: 'article', + title: 'JSX article card', + description: 'A reusable JSX component, route data, and an absolute og:image in the page head.', + file: 'routes/og/article/[slug].tsx', + code: 'return new ImageResponse(…{post.title}…);', + checks: [ + 'The PNG is 1200 × 630 with Geist text and a byline.', + 'Choose the long title to check wrapping. Open the missing article URL to check its 404.', + 'View page source for og:image and twitter:card; the image URL includes the selected slug.', + ], + controls: [ + { + name: 'slug', + label: 'Article', + value: 'hello', + options: [ + { value: 'hello', label: 'Short title' }, + { value: 'long', label: 'Long title' }, + ], + }, + ], + }, + { + id: 'html', + title: 'HTML with request input', + description: 'Framework-independent markup with an escaped title from the query string.', + file: 'routes/og/html.ts', + code: 'new ImageResponse(`
${escapeHtml(title)}
`);', + checks: [ + 'The title appears literally, including , &, and quotes; it must not become markup.', + 'Edit the title, then leave the field or press Enter. A long title should wrap without leaving the card.', + ], + controls: [{ name: 'title', label: 'Title', value: 'Void is not HTML & "friends"' }], + }, + { + id: 'layout', + title: 'Tailwind and debug borders', + description: 'Utility classes, a gradient, and a two-column layout without a CSS build step.', + file: 'routes/og/layout.tsx', + code: 'new ImageResponse(
…
, { debug });', + checks: [ + 'The card has two columns, rounded boxes, and a gradient background.', + 'Enable debug to see borders around the layout boxes; disable it to compare.', + ], + controls: [ + { + name: 'debug', + label: 'Debug borders', + value: 'off', + options: [ + { value: 'off', label: 'Off' }, + { value: 'on', label: 'On' }, + ], + }, + ], + }, + { + id: 'square', + title: 'Square image and response headers', + description: 'A 600 × 600 social card with an explicit cache policy and a custom header.', + file: 'routes/og/square.tsx', + code: "new ImageResponse(card, { width: 600, height: 600, headers: { 'Cache-Control': 'no-store', 'X-OG-Demo': 'square' } });", + checks: [ + 'The actual PNG dimensions are 600 × 600, not just a square browser crop.', + 'The response shows Content-Type image/png, Cache-Control no-store, and X-OG-Demo square.', + ], + controls: [], + }, + { + id: 'fonts', + title: 'Custom variable font', + description: + 'Compare five variable typefaces by URL or font bytes, with multiple weights in each image.', + file: 'routes/og/fonts.tsx', + code: 'new ImageResponse(card, { fonts: [font.url], fallbackFonts: false });', + network: 'Downloads the selected font from fonts.gstatic.com. Both modes need internet access.', + checks: [ + 'The 400, 550, and 700 rows get progressively heavier without boxes or clipping.', + 'Switch typefaces to compare clean sans-serif, slab serif, editorial serif, monospace, and handwriting styles.', + 'URL and font-data modes should look the same. No weight is pinned on the variable font entry.', + ], + controls: [ + { + name: 'font', + label: 'Typeface', + value: 'inter', + options: fontPresets.map((font) => ({ value: font.id, label: font.label })), + }, + { + name: 'mode', + label: 'Font source', + value: 'url', + options: [ + { value: 'url', label: 'Font URL' }, + { value: 'data', label: 'Font bytes' }, + ], + }, + ], + }, + { + id: 'languages', + title: 'Multilingual fallback fonts', + description: 'Mixed scripts, accented Latin, symbols, and language-specific Chinese glyphs.', + file: 'routes/og/languages.tsx', + code: "new ImageResponse(card, { lang: 'zh-TW', fallbackFonts: true });", + network: + 'Automatic fallback downloads fonts from Google Fonts. The first render can be slower.', + checks: [ + 'With fallback enabled, Japanese, Łódź, Arabic, Thai, and symbols should not be boxes.', + 'Switch the language to compare the same Han characters in Japanese, Simplified Chinese, Traditional Chinese, and Korean styles.', + 'Disable fallback to see missing glyphs while basic Latin remains readable; switch it back on to compare.', + ], + controls: [ + { + name: 'lang', + label: 'Han glyph style', + value: 'ja', + options: [ + { value: 'ja', label: 'Japanese' }, + { value: 'zh-CN', label: 'Simplified Chinese' }, + { value: 'zh-TW', label: 'Traditional Chinese' }, + { value: 'ko', label: 'Korean' }, + ], + }, + { + name: 'fallback', + label: 'Automatic fonts', + value: 'on', + options: [ + { value: 'on', label: 'Enabled' }, + { value: 'off', label: 'Disabled (expect missing glyphs)' }, + ], + }, + ], + }, + { + id: 'emoji', + title: 'Emoji styles', + description: 'Compare the six emoji sets, with a separate sample for joined emoji.', + file: 'routes/og/emoji.tsx', + code: "new ImageResponse(card, { emoji: 'twemoji' });", + network: 'Downloads emoji images from jsDelivr; repeat renders can reuse them.', + checks: [ + 'Switch styles: the emoji artwork changes, but the text and layout stay the same.', + 'Choose the joined sample: the technologist should be one emoji, not separate person and laptop images.', + 'An emoji missing from the chosen set fails the image. Compare another set and inspect the server log for the failed download URL.', + ], + controls: [ + { + name: 'emoji', + label: 'Emoji set', + value: 'twemoji', + options: ['twemoji', 'blobmoji', 'noto', 'openmoji', 'fluent', 'fluentFlat'].map( + (value) => ({ value, label: value }), + ), + }, + { + name: 'sample', + label: 'Sample', + value: 'common', + options: [ + { value: 'common', label: 'Common emoji' }, + { value: 'joined', label: 'Joined emoji (availability varies)' }, + ], + }, + ], + }, + { + id: 'remote', + title: 'Remote image policies', + description: + 'A fixed remote avatar with allow-listing, size limits, and strict or lenient failures.', + file: 'routes/og/remote.tsx', + code: 'new ImageResponse(card, { images: { allowUrl, maxBytes, timeout: 5000, throwOnError } });', + network: + 'The allowed image downloads from avatars.githubusercontent.com. No arbitrary URL input.', + checks: [ + 'Allowed: the Vite avatar appears next to the text.', + 'Blocked / omit and size limit / omit: the card still renders without the avatar.', + 'Blocked / fail: the image intentionally fails. The response reports the failure; check the server log too.', + 'In dev, an intentional failure may open the Vite error overlay. Dismiss it to use the page again.', + ], + controls: [ + { + name: 'mode', + label: 'Image policy', + value: 'allow', + options: [ + { value: 'allow', label: 'Allowed' }, + { value: 'omit', label: 'Blocked / omit' }, + { value: 'limit', label: 'Size limit / omit' }, + { value: 'fail', label: 'Blocked / fail' }, + ], + }, + ], + }, +]; + +export function getExample(id: string) { + return examples.find((example) => example.id === id); +} + +export function imagePath(id: string, values: Record) { + if (id === 'article') { + return `/og/article/${encodeURIComponent(values.slug ?? 'hello')}`; + } + const query = new URLSearchParams(values).toString(); + return `/og/${id}${query ? `?${query}` : ''}`; +} diff --git a/playground/image-response/src/fonts.ts b/playground/image-response/src/fonts.ts new file mode 100644 index 000000000..eb2ee047d --- /dev/null +++ b/playground/image-response/src/fonts.ts @@ -0,0 +1,32 @@ +export const fontPresets = [ + { + id: 'inter', + name: 'Inter', + label: 'Inter — clean sans-serif', + url: 'https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2', + }, + { + id: 'roboto-slab', + name: 'Roboto Slab', + label: 'Roboto Slab — sturdy slab serif', + url: 'https://fonts.gstatic.com/s/robotoslab/v36/BngMUXZYTXPIvIBgJJSb6ufN5qWr4xCC.woff2', + }, + { + id: 'playfair-display', + name: 'Playfair Display', + label: 'Playfair Display — editorial serif', + url: 'https://fonts.gstatic.com/s/playfairdisplay/v40/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgEM86xQ.woff2', + }, + { + id: 'jetbrains-mono', + name: 'JetBrains Mono', + label: 'JetBrains Mono — developer monospace', + url: 'https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbV2o-flEEny0FZhsfKu5WU4xD7OwGtT0rU.woff2', + }, + { + id: 'caveat', + name: 'Caveat', + label: 'Caveat — casual handwriting', + url: 'https://fonts.gstatic.com/s/caveat/v23/Wnz6HAc5bAfYB2Q7ZjYYiAzcPA.woff2', + }, +]; diff --git a/playground/image-response/src/posts.ts b/playground/image-response/src/posts.ts new file mode 100644 index 000000000..b6c12464f --- /dev/null +++ b/playground/image-response/src/posts.ts @@ -0,0 +1,12 @@ +export const posts = { + hello: { title: 'Hello from Void', author: 'The Void team', category: 'Getting started' }, + long: { + title: 'Build full-stack applications on Cloudflare Workers with Vite and Void', + author: 'The Void team', + category: 'A longer title', + }, +}; + +export function getPost(slug: string) { + return Object.entries(posts).find(([key]) => key === slug)?.[1]; +} diff --git a/playground/image-response/src/source-link.tsx b/playground/image-response/src/source-link.tsx new file mode 100644 index 000000000..988189881 --- /dev/null +++ b/playground/image-response/src/source-link.tsx @@ -0,0 +1,14 @@ +declare const __IMAGE_RESPONSE_SOURCE_ROOT__: string; + +export function SourceLink({ file }: { file: string }) { + const source = new URL( + file.split('/').map(encodeURIComponent).join('/'), + __IMAGE_RESPONSE_SOURCE_ROOT__, + ); + const host = source.host ? `//${source.host}` : ''; + return ( + + {file} + + ); +} diff --git a/playground/image-response/tsconfig.json b/playground/image-response/tsconfig.json new file mode 100644 index 000000000..0ef417fe5 --- /dev/null +++ b/playground/image-response/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./.void/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["pages", "routes", "src", "*.config.ts"] +} diff --git a/playground/image-response/vite.config.ts b/playground/image-response/vite.config.ts new file mode 100644 index 000000000..ab6c2f623 --- /dev/null +++ b/playground/image-response/vite.config.ts @@ -0,0 +1,10 @@ +import { voidReact } from '@void/react/plugin'; +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + define: { + __IMAGE_RESPONSE_SOURCE_ROOT__: JSON.stringify(new URL('./', import.meta.url).href), + }, + plugins: [voidPlugin(), voidReact()], +}); diff --git a/playground/image-response/void.config.ts b/playground/image-response/void.config.ts new file mode 100644 index 000000000..fd88a7db9 --- /dev/null +++ b/playground/image-response/void.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'void/config'; + +export default defineConfig({ + head: { + title: 'ImageResponse Playground', + htmlAttrs: { lang: 'en' }, + meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }], + }, +}); diff --git a/playground/image-response/void.lock.json b/playground/image-response/void.lock.json new file mode 100644 index 000000000..393006e90 --- /dev/null +++ b/playground/image-response/void.lock.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "source": {}, + "resolved": { + "compatibility_date": "2026-02-24" + }, + "text": "{\n \"compatibility_date\": \"2026-02-24\"\n}\n" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a38e77df..05eab89fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1360,6 +1360,40 @@ importers: specifier: workspace:* version: link:../../packages/void + playground/image-response: + dependencies: + hono: + specifier: 'catalog:' + version: 4.13.18 + react: + specifier: 'catalog:' + version: 19.3.1 + react-dom: + specifier: 'catalog:' + version: 19.3.1(react@19.3.1) + devDependencies: + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@void/react': + specifier: workspace:* + version: link:../../packages/react + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' + vite-plus: + specifier: 'catalog:' + version: 1.6.0(@astrojs/compiler-rs@0.5.3)(@types/node@26.6.6)(@vitest/ui@5.1.0)(esbuild@0.28.2)(happy-dom@21.0.4)(jiti@2.7.0)(playwright@1.65.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1) + void: + specifier: workspace:* + version: link:../../packages/void + playground/kitchen-sink: dependencies: hono: From 5776825c989f9e25c00300335f8b01699a74cd56 Mon Sep 17 00:00:00 2001 From: Sasha Koss Date: Thu, 12 Nov 2026 17:00:54 +0800 Subject: [PATCH 35/35] chore: expose `renderImage`'s options type --- packages/void/src/runtime/og.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts index 83b9ed492..008d33762 100644 --- a/packages/void/src/runtime/og.ts +++ b/packages/void/src/runtime/og.ts @@ -15,10 +15,9 @@ import { fromJsx } from 'takumi-js/helpers/jsx'; import { FallbackFontError, fallbackFonts, keyedFont, type Font } from './og-fonts.ts'; /** - * Void declares these options rather than passing Takumi's render options - * through, so a Takumi upgrade does not change this API. + * {@link renderImage} function options. */ -export interface ImageResponseOptions extends ResponseInit { +export interface RenderImageOptions { /** Image width in pixels. Defaults to 1200. */ width?: number; /** Image height in pixels. Defaults to 630. */ @@ -51,8 +50,11 @@ export interface ImageResponseOptions extends ResponseInit { throwOnError?: boolean; }; } - -type ImageOptions = Omit; +/** + * Void declares these options rather than passing Takumi's render options + * through, so a Takumi upgrade does not change this API. + */ +export interface ImageResponseOptions extends ResponseInit, RenderImageOptions {} // Emoji come from jsDelivr by code point and never change, so keep them for the // life of the isolate; other images are fetched for each render. @@ -95,7 +97,7 @@ export class ImageResponse extends Response { } } -async function renderImage(element: RenderInput, options: ImageOptions): Promise { +async function renderImage(element: RenderInput, options: RenderImageOptions): Promise { // Convert once, here: the fallback fonts depend on the final text, and // function components must not run twice. Emoji become images first, as in // the render, so their code points ask for no symbol font.