From f504946a139302d510acac7846d3eed51e3936c4 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/12] 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 ca9e6762d..6cd9b25f3 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()) { @@ -134,7 +150,8 @@ const registry = await startPackedRegistry(packages); 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 @@ -320,6 +337,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( @@ -396,6 +415,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()); @@ -438,9 +458,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( @@ -449,6 +471,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', @@ -482,12 +506,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+' : '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+' : '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 fdb5d1c82..3e1f9ceb1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -261,6 +261,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 a8d572a10..b3c3310e5 100644 --- a/docs/guide/pages-routing/head.md +++ b/docs/guide/pages-routing/head.md @@ -35,7 +35,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 0d992023f..e8d108016 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -102,6 +102,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 3347e2cd6..3e9ca5c2c 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -48,6 +48,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 7f8a2ec70..0f9c2d64f 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -144,6 +144,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 8b76fa47c..7f1587362 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -333,6 +333,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" @@ -378,6 +383,7 @@ "pg": "^8.23.1", "postal-mime": "catalog:", "proper-lockfile": "^4.1.2", + "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 3b380b312..5549bd2ff 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -45,6 +45,7 @@ the app's existing saved destination, including when the target project is new. | AI inference (Workers AI, providers) | `docs/guide/ai.md` | | 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 eefdcf6ac..c76f12eee 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { pathToFileURL } from 'node:url'; import { dirname, join, relative } from 'pathslash'; import 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. */ @@ -260,5 +260,34 @@ export function nodeTargetPlugin(target: NodeTarget): Array { }, }; - 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 d38a383ba..8d2bbc30a 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -113,6 +113,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 a2795df76..2edd734de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -714,6 +714,9 @@ importers: proper-lockfile: specifier: ^4.1.2 version: 4.1.2 + takumi-js: + specifier: ^2.16.2 + version: 2.16.2(csstype@3.2.3)(react@19.3.0) terminal-link: specifier: ^5.0.0 version: 5.0.0 @@ -5339,6 +5342,88 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@takumi-rs/core-darwin-arm64@2.16.2': + resolution: {integrity: sha512-4bTzrfKNKVvM7r09pGV4ySUYkPrIW9IInOoOsNlcUKL8znMqfaM5cxie6VL0Hn94wHFIiAK9cDQEZwEe0SLMPg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@takumi-rs/core-darwin-x64@2.16.2': + resolution: {integrity: sha512-FanSHh0FtV1mDKvJy/KVHOA9loFO1kF2PNfdUuScv8jZWtLoUOSImbCwTomaePnNIeFY7jBjVREj2xhMenHz2w==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@takumi-rs/core-linux-arm64-gnu@2.16.2': + resolution: {integrity: sha512-aXxQ6F7UTZgKzqyPNypZ96V+XQvOFtPH9LX0/AnOO7dxbB8n3/JdSocwaJn/XUHicrar+k+VTv4CQ6Z9xx0RDQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@takumi-rs/core-linux-arm64-musl@2.16.2': + resolution: {integrity: sha512-U8aB9ISUmkFtuC/n9B3zunUbFLxf/oOqDUYNwI05ukMxGrV87UbbQKaYmhRdvLAqWJacz2EdhMDr+Padjly5GA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@takumi-rs/core-linux-x64-gnu@2.16.2': + resolution: {integrity: sha512-aYjF4E+9Dw5jLAFMCcvzEFeWtouX7YYc550pwQ8aj6U98qTtj6aoSPEv2mtxoDKe+QDkQ1yfqB90+vgNp0uCjA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@takumi-rs/core-linux-x64-musl@2.16.2': + resolution: {integrity: sha512-Nz04aGC7x1Qm313VCnFHBXzXRDgyfgsOK6KHZt/jBo8ZEOqaC+jSJvVNMNesSGCsFRzNMOlIdb0UuW21aYEutQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@takumi-rs/core-win32-arm64-msvc@2.16.2': + resolution: {integrity: sha512-u+LIuDwTMji4KD2s7GC1/g7H7L0Yf4djFdu6jOKNXg4sVrDv7ZG0ovoDKMyXGDGHSVuoypvkLMCZO4U/Q042Zg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@takumi-rs/core-win32-x64-msvc@2.16.2': + resolution: {integrity: sha512-za4ZORo4/NCn96b1JQh9wxuJ6t84Vzs7oIfwSjWd5Sw7S2xfX4DzP+U+uY+7NU8v7XYH78aqyzhEL8FkoNc9vA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@takumi-rs/core@2.16.2': + resolution: {integrity: sha512-0CfFW08JP23zjNb5dmXel48UXOEK01wXxCvDkAWzJ0Bs4MA7m2NLL2UoFX+HP7tIfYeEO/3uyRjJv0jXbP4IMA==} + engines: {node: '>=18'} + peerDependencies: + csstype: '*' + peerDependenciesMeta: + csstype: + optional: true + + '@takumi-rs/helpers@2.16.2': + resolution: {integrity: sha512-GmVDykCVTWhN7hVXO5eVhFvLkzX7HrAp/a5xMGX/IJjblR7Hxb5TP0OqwmtZUIkoRLhtjIl6Mlchag0NuBJGBw==} + 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.16.2': + resolution: {integrity: sha512-HaLYmKkTvyzCv53/YH7Vh/UBzy0LJc3OxrgoKwAQovEGbLBPXi8iqBnjJrst8ZjAVPVrTKe/YT/DUXt/yb5GLg==} + 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'} @@ -10015,6 +10100,10 @@ packages: tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + takumi-js@2.16.2: + resolution: {integrity: sha512-HzYqPb3DRQrnBPf4GIbmA8b2HDvK6pqv9qkzJ3hjdSpxDAqrGqHf3T9ryRyFwADxqsBT7AHNzdfyCe84M4zcMg==} + engines: {node: '>=20.19'} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -14036,6 +14125,60 @@ snapshots: tailwindcss: 4.3.3 vite: '@voidzero-dev/vite-plus-core@1.2.0(@types/node@26.6.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.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)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1)' + '@takumi-rs/core-darwin-arm64@2.16.2': + optional: true + + '@takumi-rs/core-darwin-x64@2.16.2': + optional: true + + '@takumi-rs/core-linux-arm64-gnu@2.16.2': + optional: true + + '@takumi-rs/core-linux-arm64-musl@2.16.2': + optional: true + + '@takumi-rs/core-linux-x64-gnu@2.16.2': + optional: true + + '@takumi-rs/core-linux-x64-musl@2.16.2': + optional: true + + '@takumi-rs/core-win32-arm64-msvc@2.16.2': + optional: true + + '@takumi-rs/core-win32-x64-msvc@2.16.2': + optional: true + + '@takumi-rs/core@2.16.2(csstype@3.2.3)(react@19.3.0)': + dependencies: + '@takumi-rs/helpers': 2.16.2(react@19.3.0) + optionalDependencies: + '@takumi-rs/core-darwin-arm64': 2.16.2 + '@takumi-rs/core-darwin-x64': 2.16.2 + '@takumi-rs/core-linux-arm64-gnu': 2.16.2 + '@takumi-rs/core-linux-arm64-musl': 2.16.2 + '@takumi-rs/core-linux-x64-gnu': 2.16.2 + '@takumi-rs/core-linux-x64-musl': 2.16.2 + '@takumi-rs/core-win32-arm64-msvc': 2.16.2 + '@takumi-rs/core-win32-x64-msvc': 2.16.2 + csstype: 3.2.3 + transitivePeerDependencies: + - preact + - react + + '@takumi-rs/helpers@2.16.2(react@19.3.0)': + optionalDependencies: + react: 19.3.0 + + '@takumi-rs/wasm@2.16.2(csstype@3.2.3)(react@19.3.0)': + dependencies: + '@takumi-rs/helpers': 2.16.2(react@19.3.0) + optionalDependencies: + csstype: 3.2.3 + transitivePeerDependencies: + - preact + - react + '@tanstack/history@1.162.5': {} '@tanstack/react-router@1.170.44(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': @@ -20087,6 +20230,16 @@ snapshots: tailwindcss@4.3.3: {} + takumi-js@2.16.2(csstype@3.2.3)(react@19.3.0): + dependencies: + '@takumi-rs/core': 2.16.2(csstype@3.2.3)(react@19.3.0) + '@takumi-rs/helpers': 2.16.2(react@19.3.0) + '@takumi-rs/wasm': 2.16.2(csstype@3.2.3)(react@19.3.0) + transitivePeerDependencies: + - csstype + - preact + - react + tapable@2.3.3: {} tar-stream@3.2.0: diff --git a/test/integration-timings.ts b/test/integration-timings.ts index 4b952f41e..d377d9640 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -27,6 +27,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 2bd5cbbe768bb44c540a07bc510999b7fcf5835a 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/12] 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 e82328614dfac9fa58c447f6870ca3f0e63a38af 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/12] 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 e8d108016..364af95f7 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -104,7 +104,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 0f9c2d64f..4ae780cc3 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -146,13 +146,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 c76f12eee..2e1b16a53 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -1,5 +1,6 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { findPackageJSON } from 'node:module'; import { pathToFileURL } from 'node:url'; import { dirname, join, relative } from 'pathslash'; import type { Plugin } from 'vite'; @@ -260,9 +261,10 @@ export function nodeTargetPlugin(target: NodeTarget): Array { }, }; - // 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', @@ -270,14 +272,17 @@ export function nodeTargetPlugin(target: NodeTarget): Array { 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}\`.`, ); }, }, @@ -291,3 +296,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 edb41e36f1de539257a7cd7d9443a2ae95ad66bf 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/12] 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 2e1b16a53..e3c5c0eb5 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -271,7 +271,11 @@ export function nodeTargetPlugin(target: NodeTarget): Array { 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); @@ -297,10 +301,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 6b60d3e833685fa6993bf775e1af2d98923728d9 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/12] 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 4ae780cc3..7c42bf73e 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -144,18 +144,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 e3c5c0eb5..1e7cb5953 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -1,8 +1,7 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { findPackageJSON } from 'node:module'; -import { pathToFileURL } from 'node:url'; -import { dirname, join, relative } from 'pathslash'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join, relative, toSlash } from 'pathslash'; import type { Plugin } from 'vite'; import { findInstalledPackage, findVoidDependentPackages } from './installed-packages.ts'; import { readProjectPaths } from './project-paths.ts'; @@ -265,15 +264,16 @@ export function nodeTargetPlugin(target: NodeTarget): Array { // 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(); @@ -283,11 +283,17 @@ export function nodeTargetPlugin(target: NodeTarget): Array { 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); + } }, }, }; @@ -301,9 +307,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']!; @@ -322,7 +338,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 e345397fc1448fa4c1eeb7de77fcfd524a02a3d6 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/12] 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 d377d9640..218942e9e 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -27,7 +27,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 d9b5ac4ef7b0cfa83b41728c438cd119345c85a5 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/12] 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 1e7cb5953..f0ca4bfbc 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -1,9 +1,13 @@ -import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join, relative, toSlash } from 'pathslash'; import 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. */ @@ -278,7 +282,7 @@ export function nodeTargetPlugin(target: NodeTarget): Array { } 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; } @@ -308,8 +312,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 { @@ -322,18 +325,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 f751416f5a06bdcf4046c59e798e7887ce4d6a39 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/12] 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 d4cc099ade4f4b59e492283e4c575e6524dcb584 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/12] 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 ccde9c6f03cdbeb5368f8a56d5ab49d700cba76a 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/12] 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 aa25a4d3d..679685da3 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -159,7 +159,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 9b23187d3..0c7791829 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -389,7 +389,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 188a3d6a6..76d7d28fe 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -286,7 +286,7 @@ export function nodeTargetPlugin(target: NodeTarget): Array { 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}.` : ''; @@ -329,21 +329,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 1bdd08e1b..1fb148e92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -841,8 +841,8 @@ importers: specifier: 'catalog:' version: 1.9.1 takumi-js: - specifier: ^2.16.2 - version: 2.16.2(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 @@ -5314,60 +5314,60 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@takumi-rs/core-darwin-arm64@2.16.2': - resolution: {integrity: sha512-4bTzrfKNKVvM7r09pGV4ySUYkPrIW9IInOoOsNlcUKL8znMqfaM5cxie6VL0Hn94wHFIiAK9cDQEZwEe0SLMPg==} + '@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.16.2': - resolution: {integrity: sha512-FanSHh0FtV1mDKvJy/KVHOA9loFO1kF2PNfdUuScv8jZWtLoUOSImbCwTomaePnNIeFY7jBjVREj2xhMenHz2w==} + '@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.16.2': - resolution: {integrity: sha512-aXxQ6F7UTZgKzqyPNypZ96V+XQvOFtPH9LX0/AnOO7dxbB8n3/JdSocwaJn/XUHicrar+k+VTv4CQ6Z9xx0RDQ==} + '@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.16.2': - resolution: {integrity: sha512-U8aB9ISUmkFtuC/n9B3zunUbFLxf/oOqDUYNwI05ukMxGrV87UbbQKaYmhRdvLAqWJacz2EdhMDr+Padjly5GA==} + '@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.16.2': - resolution: {integrity: sha512-aYjF4E+9Dw5jLAFMCcvzEFeWtouX7YYc550pwQ8aj6U98qTtj6aoSPEv2mtxoDKe+QDkQ1yfqB90+vgNp0uCjA==} + '@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.16.2': - resolution: {integrity: sha512-Nz04aGC7x1Qm313VCnFHBXzXRDgyfgsOK6KHZt/jBo8ZEOqaC+jSJvVNMNesSGCsFRzNMOlIdb0UuW21aYEutQ==} + '@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.16.2': - resolution: {integrity: sha512-u+LIuDwTMji4KD2s7GC1/g7H7L0Yf4djFdu6jOKNXg4sVrDv7ZG0ovoDKMyXGDGHSVuoypvkLMCZO4U/Q042Zg==} + '@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.16.2': - resolution: {integrity: sha512-za4ZORo4/NCn96b1JQh9wxuJ6t84Vzs7oIfwSjWd5Sw7S2xfX4DzP+U+uY+7NU8v7XYH78aqyzhEL8FkoNc9vA==} + '@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.16.2': - resolution: {integrity: sha512-0CfFW08JP23zjNb5dmXel48UXOEK01wXxCvDkAWzJ0Bs4MA7m2NLL2UoFX+HP7tIfYeEO/3uyRjJv0jXbP4IMA==} + '@takumi-rs/core@2.18.2': + resolution: {integrity: sha512-9g+oXNTAKLp6ZliXiQ0BkMXVPrXgaX34wjY7jO9osxbgFtBi3QLhnrDMyKFpSvXuOcbjdSCi1LkJ0YzN95bMQw==} engines: {node: '>=18'} peerDependencies: csstype: '*' @@ -5375,8 +5375,8 @@ packages: csstype: optional: true - '@takumi-rs/helpers@2.16.2': - resolution: {integrity: sha512-GmVDykCVTWhN7hVXO5eVhFvLkzX7HrAp/a5xMGX/IJjblR7Hxb5TP0OqwmtZUIkoRLhtjIl6Mlchag0NuBJGBw==} + '@takumi-rs/helpers@2.18.2': + resolution: {integrity: sha512-HHVdYmGxLos+Jn67mYU64gc+nW372zP8pF6zCnLzn3JMbGtcX5jV7a0N5uZseXkGrfGR07tsdWQbTkpO4Ms+WA==} engines: {node: '>=18'} peerDependencies: preact: ^10.0.0 || ^11.0.0 @@ -5387,8 +5387,8 @@ packages: react: optional: true - '@takumi-rs/wasm@2.16.2': - resolution: {integrity: sha512-HaLYmKkTvyzCv53/YH7Vh/UBzy0LJc3OxrgoKwAQovEGbLBPXi8iqBnjJrst8ZjAVPVrTKe/YT/DUXt/yb5GLg==} + '@takumi-rs/wasm@2.18.2': + resolution: {integrity: sha512-Ap8rA5TRcfLRpKagM3NK0JGKo4TO0DrTI7fAFpKM5R04yIVvibT8Qq/FqFcYBqWdlVxfxwzdMAdn/Glxkx9WAA==} engines: {node: '>=20.19'} peerDependencies: csstype: '*' @@ -9610,8 +9610,8 @@ packages: tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} - takumi-js@2.16.2: - resolution: {integrity: sha512-HzYqPb3DRQrnBPf4GIbmA8b2HDvK6pqv9qkzJ3hjdSpxDAqrGqHf3T9ryRyFwADxqsBT7AHNzdfyCe84M4zcMg==} + takumi-js@2.18.2: + resolution: {integrity: sha512-FFl1sctUFyMgoVghDxZYDPOcxIKYmdZodkc1w+ozrRl2mW3OYlqWklyGR1c3zHoMFlqDZO170/v51ZmV/38JXw==} engines: {node: '>=20.19'} tapable@2.3.3: @@ -13406,54 +13406,54 @@ snapshots: tailwindcss: 4.3.3 vite: '@voidzero-dev/vite-plus-core@1.4.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.16.2': + '@takumi-rs/core-darwin-arm64@2.18.2': optional: true - '@takumi-rs/core-darwin-x64@2.16.2': + '@takumi-rs/core-darwin-x64@2.18.2': optional: true - '@takumi-rs/core-linux-arm64-gnu@2.16.2': + '@takumi-rs/core-linux-arm64-gnu@2.18.2': optional: true - '@takumi-rs/core-linux-arm64-musl@2.16.2': + '@takumi-rs/core-linux-arm64-musl@2.18.2': optional: true - '@takumi-rs/core-linux-x64-gnu@2.16.2': + '@takumi-rs/core-linux-x64-gnu@2.18.2': optional: true - '@takumi-rs/core-linux-x64-musl@2.16.2': + '@takumi-rs/core-linux-x64-musl@2.18.2': optional: true - '@takumi-rs/core-win32-arm64-msvc@2.16.2': + '@takumi-rs/core-win32-arm64-msvc@2.18.2': optional: true - '@takumi-rs/core-win32-x64-msvc@2.16.2': + '@takumi-rs/core-win32-x64-msvc@2.18.2': optional: true - '@takumi-rs/core@2.16.2(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.16.2(react@19.3.1) + '@takumi-rs/helpers': 2.18.2(react@19.3.1) optionalDependencies: - '@takumi-rs/core-darwin-arm64': 2.16.2 - '@takumi-rs/core-darwin-x64': 2.16.2 - '@takumi-rs/core-linux-arm64-gnu': 2.16.2 - '@takumi-rs/core-linux-arm64-musl': 2.16.2 - '@takumi-rs/core-linux-x64-gnu': 2.16.2 - '@takumi-rs/core-linux-x64-musl': 2.16.2 - '@takumi-rs/core-win32-arm64-msvc': 2.16.2 - '@takumi-rs/core-win32-x64-msvc': 2.16.2 + '@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.16.2(react@19.3.1)': + '@takumi-rs/helpers@2.18.2(react@19.3.1)': optionalDependencies: react: 19.3.1 - '@takumi-rs/wasm@2.16.2(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.16.2(react@19.3.1) + '@takumi-rs/helpers': 2.18.2(react@19.3.1) optionalDependencies: csstype: 3.2.3 transitivePeerDependencies: @@ -18132,11 +18132,11 @@ snapshots: tailwindcss@4.3.3: {} - takumi-js@2.16.2(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.16.2(csstype@3.2.3)(react@19.3.1) - '@takumi-rs/helpers': 2.16.2(react@19.3.1) - '@takumi-rs/wasm': 2.16.2(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 85654e88b399bf3490db2af4f5e808522df5d9cf 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/12] 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 679685da3..391e9c104 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -162,8 +162,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 fe5c8023a3d318163776b54dc1543ebdbcb3a834 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/12] 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 {