@ethercorps/sveltekit-og 4.3.1-next.3 → 4.4.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -193
- package/dist/client/assets/NotoSans-Bold.ttf +0 -0
- package/dist/client/assets/NotoSans-Regular.ttf +0 -0
- package/dist/client/component.d.ts +19 -0
- package/dist/client/component.js +32 -0
- package/dist/client/create.d.ts +7 -0
- package/dist/client/create.js +8 -0
- package/dist/client/engines/satori.d.ts +4 -0
- package/dist/client/engines/satori.js +81 -0
- package/dist/client/engines/takumi.d.ts +7 -0
- package/dist/client/engines/takumi.js +9 -0
- package/dist/client/fonts.d.ts +2 -0
- package/dist/client/fonts.js +36 -0
- package/dist/client/image-response.d.ts +13 -0
- package/dist/client/image-response.js +88 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +6 -0
- package/dist/client/render.d.ts +7 -0
- package/dist/client/render.js +37 -0
- package/dist/client/types.d.ts +14 -0
- package/dist/client/types.js +1 -0
- package/dist/fonts.d.ts +4 -4
- package/dist/fonts.js +17 -13
- package/dist/helpers/create.d.ts +7 -4
- package/dist/helpers/create.js +39 -28
- package/dist/helpers/defaults.d.ts +4 -4
- package/dist/helpers/defaults.js +28 -18
- package/dist/helpers/emoji.d.ts +1 -1
- package/dist/helpers/emoji.js +31 -21
- package/dist/helpers/error-handler.d.ts +38 -0
- package/dist/helpers/error-handler.js +76 -0
- package/dist/helpers/logger.d.ts +7 -0
- package/dist/helpers/logger.js +21 -0
- package/dist/helpers/response.d.ts +30 -0
- package/dist/helpers/response.js +52 -0
- package/dist/helpers/to-html.d.ts +10 -0
- package/dist/helpers/to-html.js +10 -0
- package/dist/helpers/toJSX.d.ts +3 -3
- package/dist/helpers/toJSX.js +9 -7
- package/dist/helpers/utils.d.ts +2 -0
- package/dist/helpers/utils.js +25 -0
- package/dist/image-response.d.ts +2 -2
- package/dist/image-response.js +13 -21
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.js +44 -12
- package/dist/providers/instances.d.ts +3 -3
- package/dist/providers/instances.js +43 -15
- package/dist/providers/resvg/edge.d.ts +1 -1
- package/dist/providers/resvg/edge.js +10 -6
- package/dist/providers/resvg/node.d.ts +1 -1
- package/dist/providers/resvg/node.js +18 -9
- package/dist/providers/satori/edge.d.ts +6 -0
- package/dist/providers/satori/edge.js +12 -0
- package/dist/providers/satori/node.d.ts +1 -1
- package/dist/providers/satori/node.js +4 -4
- package/dist/takumi/fonts.d.ts +16 -0
- package/dist/takumi/fonts.js +15 -0
- package/dist/takumi/image-response.d.ts +6 -0
- package/dist/takumi/image-response.js +27 -0
- package/dist/takumi/index.d.ts +5 -0
- package/dist/takumi/index.js +4 -0
- package/dist/takumi/render.d.ts +4 -0
- package/dist/takumi/render.js +23 -0
- package/dist/takumi/renderer.d.ts +7 -0
- package/dist/takumi/renderer.js +39 -0
- package/dist/takumi/types.d.ts +64 -0
- package/dist/takumi/types.js +1 -0
- package/dist/types.d.ts +10 -14
- package/package.json +76 -21
package/README.md
CHANGED
|
@@ -4,225 +4,67 @@
|
|
|
4
4
|
|
|
5
5
|
# SvelteKit Open Graph Image Generation
|
|
6
6
|
|
|
7
|
-
Dynamically generate Open Graph images from an HTML+CSS template or Svelte component
|
|
7
|
+
Dynamically generate Open Graph images from an HTML+CSS template or Svelte component. No headless browser required.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- With `sveltekit-og@4`, we have [official documentation](https://sveltekit-og.dev).
|
|
9
|
+
Pick the rendering engine that fits your needs:
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
- **Satori** (default) — HTML → SVG → PNG, based on [Satori](https://github.com/vercel/satori#documentation).
|
|
12
|
+
- **[Takumi](https://takumi.kane.tw)** — a Rust/WASM engine with more output formats (`webp`, `jpeg`, `ico`, `svg`, …) and a built-in font, available from `@ethercorps/sveltekit-og/takumi` (v4.3.0+).
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
pnpm install @ethercorps/sveltekit-og
|
|
16
|
-
```
|
|
14
|
+
## Table of Contents
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
- [SvelteKit Open Graph Image Generation](#sveltekit-open-graph-image-generation)
|
|
17
|
+
- [Table of Contents](#table-of-contents)
|
|
18
|
+
- [Docs](#docs)
|
|
19
|
+
- [Installation](#installation)
|
|
20
|
+
- [Usage](#usage)
|
|
21
|
+
- [Examples](#examples)
|
|
22
|
+
- [Contributing](#contributing)
|
|
23
|
+
- [Changelog](#changelog)
|
|
24
|
+
- [License](#license)
|
|
25
|
+
- [Acknowledgements](#acknowledgements)
|
|
26
|
+
- [Authors](#authors)
|
|
27
|
+
- [Contributors](#contributors)
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- Add vite plugin
|
|
23
|
-
|
|
24
|
-
```typescript title="vite.cofig.js"
|
|
25
|
-
import { sveltekit } from '@sveltejs/kit/vite';
|
|
26
|
-
import { sveltekitOG } from '@ethercorps/sveltekit-og/plugin';
|
|
27
|
-
const config = {
|
|
28
|
-
plugins: [sveltekit(), sveltekitOG()]
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export default config;
|
|
32
|
-
```
|
|
29
|
+
## Docs
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
- Add `rollupWasm` to `build.rollupOptions.plugins` in `vite.cofig.js` file.
|
|
36
|
-
- For more information, check [docs](https://sveltekit-og.dev/docs/getting-started)
|
|
37
|
-
|
|
38
|
-
```ts title="vite.cofig.js"
|
|
39
|
-
import { sveltekit } from '@sveltejs/kit/vite';
|
|
40
|
-
import { defineConfig } from 'vitest/config';
|
|
41
|
-
import { rollupWasm } from '@ethercorps/sveltekit-og/plugin';
|
|
42
|
-
|
|
43
|
-
export default defineConfig({
|
|
44
|
-
plugins: [sveltekit()],
|
|
45
|
-
build: {
|
|
46
|
-
rollupOptions: {
|
|
47
|
-
plugins: [rollupWasm()],
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
```
|
|
31
|
+
For more detailed information and advanced usage, please refer to the [official documentation](https://sveltekit-og.dev).
|
|
52
32
|
|
|
53
|
-
|
|
54
|
-
- Check node runtime [docs](https://sveltekit-og.dev/docs/runtime/node)
|
|
55
|
-
|
|
56
|
-
```ts title="vite.cofig.js"
|
|
57
|
-
import { sveltekit } from '@sveltejs/kit/vite';
|
|
58
|
-
import { defineConfig } from 'vitest/config';
|
|
59
|
-
import { rollupWasm } from '@ethercorps/sveltekit-og/plugin';
|
|
60
|
-
|
|
61
|
-
export default defineConfig({
|
|
62
|
-
plugins: [sveltekit()],
|
|
63
|
-
build: {
|
|
64
|
-
rollupOptions: {
|
|
65
|
-
plugins: [
|
|
66
|
-
rollupWasm({ esmImport: false })
|
|
67
|
-
],
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
```
|
|
33
|
+
## Installation
|
|
72
34
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
```typescript
|
|
76
|
-
// src/routes/og/+server.ts
|
|
77
|
-
import { ImageResponse } from '@ethercorps/sveltekit-og';
|
|
78
|
-
import { RequestHandler } from './$types';
|
|
79
|
-
|
|
80
|
-
const template = `
|
|
81
|
-
<div tw="bg-gray-50 flex w-full h-full items-center justify-center">
|
|
82
|
-
<div tw="flex flex-col md:flex-row w-full py-12 px-4 md:items-center justify-between p-8">
|
|
83
|
-
<h2 tw="flex flex-col text-3xl sm:text-4xl font-bold tracking-tight text-gray-900 text-left">
|
|
84
|
-
<span>Ready to dive in?</span>
|
|
85
|
-
<span tw="text-indigo-600">Start your free trial today.</span>
|
|
86
|
-
</h2>
|
|
87
|
-
<div tw="mt-8 flex md:mt-0">
|
|
88
|
-
<div tw="flex rounded-md shadow">
|
|
89
|
-
<a href="#" tw="flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-5 py-3 text-base font-medium text-white">Get started</a>
|
|
90
|
-
</div>
|
|
91
|
-
<div tw="ml-3 flex rounded-md shadow">
|
|
92
|
-
<a href="#" tw="flex items-center justify-center rounded-md border border-transparent bg-white px-5 py-3 text-base font-medium text-indigo-600">Learn more</a>
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
</div>
|
|
97
|
-
`;
|
|
98
|
-
|
|
99
|
-
const fontFile = await fetch('https://og-playground.vercel.app/inter-latin-ext-400-normal.woff');
|
|
100
|
-
const fontData: ArrayBuffer = await fontFile.arrayBuffer();
|
|
101
|
-
|
|
102
|
-
export const GET: RequestHandler = async () => {
|
|
103
|
-
return await new ImageResponse(template, {
|
|
104
|
-
height: 630,
|
|
105
|
-
width: 1200,
|
|
106
|
-
fonts: [
|
|
107
|
-
{
|
|
108
|
-
name: 'Inter Latin',
|
|
109
|
-
data: fontData,
|
|
110
|
-
weight: 400
|
|
111
|
-
}
|
|
112
|
-
]
|
|
113
|
-
});
|
|
114
|
-
};
|
|
35
|
+
```bash
|
|
36
|
+
pnpm install @ethercorps/sveltekit-og
|
|
115
37
|
```
|
|
116
38
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
## Example Output
|
|
120
|
-
|
|
121
|
-

|
|
122
|
-
|
|
123
|
-
## Headers
|
|
124
|
-
|
|
125
|
-
When run in development, image headers contain `cache-control: no-cache, no-store`. In production, image headers contain `'cache-control': 'public, immutable, no-transform, max-age=31536000'`, which caches the image for 1 year. In both cases, the `'content-type': 'image/png'` is used.
|
|
126
|
-
|
|
127
|
-
## Styling
|
|
128
|
-
|
|
129
|
-
Notice that our example uses TailwindCSS classes (e.g. `tw="bg-gray-50"`). Alternatively, your HTML can contain style attributes using any of [the subset of CSS supported by Satori](https://github.com/vercel/satori#css).
|
|
130
|
-
|
|
131
|
-
Satori supports only a subset of HTML and CSS. For full details, see [Satori’s documentation](https://github.com/vercel/satori#documentation). Notably, Satori only supports flex-based layouts.
|
|
132
|
-
|
|
133
|
-
## Fonts
|
|
39
|
+
## Usage
|
|
134
40
|
|
|
135
|
-
|
|
41
|
+
For detailed usage instructions, please see the [Getting Started](https://sveltekit-og.dev/docs/getting-started) section of our documentation.
|
|
136
42
|
|
|
137
|
-
|
|
43
|
+
Prefer the Takumi engine — more output formats and a built-in font? See the [Takumi Engine](https://sveltekit-og.dev/docs/usage/takumi) guide.
|
|
138
44
|
|
|
139
45
|
## Examples
|
|
140
46
|
|
|
141
|
-
-
|
|
142
|
-
-
|
|
143
|
-
|
|
144
|
-
## API Reference
|
|
145
|
-
|
|
146
|
-
The package exposes an `ImageResponse` constructors, with the following options available:
|
|
147
|
-
|
|
148
|
-
```typescript
|
|
149
|
-
import {ImageResponse} from '@ethercorps/sveltekit-og'
|
|
150
|
-
import {SvelteComponent} from "svelte";
|
|
151
|
-
|
|
152
|
-
ImageResponse(
|
|
153
|
-
element : string | Component,
|
|
154
|
-
options : {
|
|
155
|
-
width ? : number = 1200
|
|
156
|
-
height ? : number = 630,
|
|
157
|
-
backgroundColor ? : string = "#fff"
|
|
158
|
-
fonts ? : {
|
|
159
|
-
name: string,
|
|
160
|
-
data: ArrayBuffer,
|
|
161
|
-
weight: number,
|
|
162
|
-
style: 'normal' | 'italic'
|
|
163
|
-
}[]
|
|
164
|
-
debug ? : boolean = false
|
|
165
|
-
// Options that will be passed to the HTTP response
|
|
166
|
-
status ? : number = 200
|
|
167
|
-
statusText ? : string
|
|
168
|
-
headers ? : Record<string, string>
|
|
169
|
-
},
|
|
170
|
-
// Component props if components.
|
|
171
|
-
ComponentProps<Component>
|
|
172
|
-
)
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
## Changelog
|
|
176
|
-
|
|
177
|
-
### v4.0.0 (Breaking Changes)
|
|
178
|
-
|
|
179
|
-
> Just install @ethercorps/sveltekit-og
|
|
180
|
-
|
|
181
|
-
> Support for NodeJS, Deno, Cloudflare Pages, Cloudflare Workers, Vercel and Netlify.
|
|
182
|
-
|
|
183
|
-
> No support for Bun tried and failed.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
### v3.0.0 (Breaking Changes)
|
|
47
|
+
- **ImageResponse**: [_source_](/src/routes/+server.ts) · [_demo_](https://vercel.sveltekit-og.dev)
|
|
48
|
+
- **Component Rendering**: [_source_](/src/routes/sc/+server.ts) · [_demo_](https://vercel.sveltekit-og.dev/sc)
|
|
187
49
|
|
|
188
|
-
|
|
189
|
-
> No wasm as of now, only support for nodejs based runtime.
|
|
50
|
+
## Contributing
|
|
190
51
|
|
|
191
|
-
|
|
52
|
+
Contributions are welcome! Please read our [contributing guidelines](CONTRIBUTING.md) to get started.
|
|
192
53
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
```
|
|
196
|
-
npm i @resvg/resvg-js
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
```
|
|
200
|
-
npm i satori
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
> From now on their will be no issues related to build, and soon this library going to have its own documentation.
|
|
204
|
-
|
|
205
|
-
### v1.2.2 Update (Breaking Change)
|
|
206
|
-
|
|
207
|
-
- We don't provide access to satori from `@ethercorps/sveltekit-og`.
|
|
208
|
-
|
|
209
|
-
### v1.0.0 Update (Breaking Changes)
|
|
54
|
+
## Changelog
|
|
210
55
|
|
|
211
|
-
|
|
212
|
-
Now you can use `{ toReactElement }` with `"@ethercorps/sveltekit-og"` like:
|
|
56
|
+
All notable changes to this project are documented in the [changelog](CHANGELOG.md).
|
|
213
57
|
|
|
214
|
-
|
|
215
|
-
- Removed `@resvg/resvg-wasm` with `@resvg/resvg-js` because of internal errors.
|
|
216
|
-
- Removed `satori-html` because now we have `toReactElement` out of the box with svelte compiler.
|
|
217
|
-
> If you find a problem related to undefined a please check [_vite.config.js_](/vite.config.ts) and add ` define: { _a: 'undefined' } in config.`
|
|
58
|
+
## License
|
|
218
59
|
|
|
219
|
-
|
|
60
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
220
61
|
|
|
221
62
|
## Acknowledgements
|
|
222
63
|
|
|
223
|
-
This project
|
|
64
|
+
This project would not be possible without the following projects:
|
|
224
65
|
|
|
225
66
|
- [Satori & @vercel/og](https://github.com/vercel/satori)
|
|
67
|
+
- [Takumi](https://takumi.kane.tw)
|
|
226
68
|
- [Noto by Google Fonts](https://fonts.google.com/noto)
|
|
227
69
|
- [fineshopdesign](https://github.com/fineshopdesign/cf-wasm)
|
|
228
70
|
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Component } from "svelte";
|
|
2
|
+
/**
|
|
3
|
+
* Render a Svelte component to an HTML string in the browser. The server path uses
|
|
4
|
+
* `svelte/server`'s `render`, which isn't reliable client-side, so here we mount the
|
|
5
|
+
* component instead.
|
|
6
|
+
*
|
|
7
|
+
* Mounted inside a detached shadow root: the host is never added to the document, so
|
|
8
|
+
* page CSS can't cascade into the component (nothing from the page leaks into the
|
|
9
|
+
* rendered image), and the component's own scoped styles stay in the shadow root
|
|
10
|
+
* instead of piling up in the page's <head> on every render.
|
|
11
|
+
*
|
|
12
|
+
* Loaded lazily by render.js, only on the main thread and only when a component is
|
|
13
|
+
* passed — string-only users and workers never ship this (or Svelte's mount runtime).
|
|
14
|
+
*
|
|
15
|
+
* ponytail: captures innerHTML only — inline styles come through, but scoped /
|
|
16
|
+
* `css="injected"` styles won't (they're separate nodes an engine ignores). Use
|
|
17
|
+
* inline styles (or the `stylesheets`/tailwind options) for client component rendering.
|
|
18
|
+
*/
|
|
19
|
+
export declare function componentToHtml(component: Component<any>, props: Record<string, unknown>): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mount, unmount, flushSync } from "svelte";
|
|
2
|
+
/**
|
|
3
|
+
* Render a Svelte component to an HTML string in the browser. The server path uses
|
|
4
|
+
* `svelte/server`'s `render`, which isn't reliable client-side, so here we mount the
|
|
5
|
+
* component instead.
|
|
6
|
+
*
|
|
7
|
+
* Mounted inside a detached shadow root: the host is never added to the document, so
|
|
8
|
+
* page CSS can't cascade into the component (nothing from the page leaks into the
|
|
9
|
+
* rendered image), and the component's own scoped styles stay in the shadow root
|
|
10
|
+
* instead of piling up in the page's <head> on every render.
|
|
11
|
+
*
|
|
12
|
+
* Loaded lazily by render.js, only on the main thread and only when a component is
|
|
13
|
+
* passed — string-only users and workers never ship this (or Svelte's mount runtime).
|
|
14
|
+
*
|
|
15
|
+
* ponytail: captures innerHTML only — inline styles come through, but scoped /
|
|
16
|
+
* `css="injected"` styles won't (they're separate nodes an engine ignores). Use
|
|
17
|
+
* inline styles (or the `stylesheets`/tailwind options) for client component rendering.
|
|
18
|
+
*/
|
|
19
|
+
export function componentToHtml(component, props) {
|
|
20
|
+
const host = document.createElement("div");
|
|
21
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
22
|
+
const target = document.createElement("div");
|
|
23
|
+
shadow.appendChild(target);
|
|
24
|
+
const instance = mount(component, { target, props });
|
|
25
|
+
try {
|
|
26
|
+
flushSync();
|
|
27
|
+
return target.innerHTML;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
unmount(instance);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Component, ComponentProps } from "svelte";
|
|
2
|
+
import type { ClientImageResponseOptions } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Same API as the client `ImageResponse`, as a plain function. Returns a
|
|
5
|
+
* `Response` carrying the rendered image bytes.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createImage<T extends string | Component<any>>(element: T, options?: ClientImageResponseOptions, props?: T extends Component<any> ? ComponentProps<T> : never): Response;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ImageResponse } from "./image-response.js";
|
|
2
|
+
/**
|
|
3
|
+
* Same API as the client `ImageResponse`, as a plain function. Returns a
|
|
4
|
+
* `Response` carrying the rendered image bytes.
|
|
5
|
+
*/
|
|
6
|
+
export function createImage(element, options, props) {
|
|
7
|
+
return new ImageResponse(element, options, props);
|
|
8
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ImageOptions } from "../../types.js";
|
|
2
|
+
/** Engine entry: HTML string in, svg string or png bytes out. Fonts are required here —
|
|
3
|
+
* render.js fills in the bundled defaults when the caller passes none. */
|
|
4
|
+
export declare function render(html: string, options: ImageOptions): Promise<Uint8Array | string>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import _satori, { init as initSatoriWasm } from "satori/standalone";
|
|
2
|
+
import { Resvg as _Resvg, initWasm as initResvgWasm } from "@resvg/resvg-wasm";
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
4
|
+
// @ts-ignore - Vite resolves ?url to the emitted asset path
|
|
5
|
+
import yogaUrl from "satori/yoga.wasm?url";
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
7
|
+
// @ts-ignore - Vite resolves ?url to the emitted asset path
|
|
8
|
+
import resvgWasmUrl from "@resvg/resvg-wasm/index_bg.wasm?url";
|
|
9
|
+
import { loadDynamicAsset } from "../../helpers/emoji.js";
|
|
10
|
+
import { DEFAULT_WIDTH } from "../../helpers/defaults.js";
|
|
11
|
+
import { createVNode } from "../../helpers/toJSX.js";
|
|
12
|
+
import { createLogger } from "../../helpers/logger.js";
|
|
13
|
+
import { handleAsync, ErrorCodes } from "../../helpers/error-handler.js";
|
|
14
|
+
/*
|
|
15
|
+
* Browser/worker Satori + ReSVG engine. Loaded lazily by render.js so takumi users
|
|
16
|
+
* never download it. The wasm comes from same-origin bundler-emitted assets
|
|
17
|
+
* (Vite ?url + fetch(new URL(...))): no CORS, works in browsers and workers alike.
|
|
18
|
+
* Kept out of the server providers so neither bundle pulls in the other's runtime.
|
|
19
|
+
*
|
|
20
|
+
* Each init is memoized; a rejected init clears the cache so the next call retries.
|
|
21
|
+
*/
|
|
22
|
+
let satoriPromise;
|
|
23
|
+
let resvgPromise;
|
|
24
|
+
function useSatori() {
|
|
25
|
+
satoriPromise ??= (async () => {
|
|
26
|
+
const bytes = await fetch(new URL(yogaUrl, import.meta.url)).then((r) => r.arrayBuffer());
|
|
27
|
+
await initSatoriWasm(await WebAssembly.compile(bytes));
|
|
28
|
+
return _satori;
|
|
29
|
+
})().catch((error) => {
|
|
30
|
+
satoriPromise = undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
return satoriPromise;
|
|
34
|
+
}
|
|
35
|
+
function useResvg() {
|
|
36
|
+
resvgPromise ??= (async () => {
|
|
37
|
+
await initResvgWasm(fetch(new URL(resvgWasmUrl, import.meta.url)));
|
|
38
|
+
return _Resvg;
|
|
39
|
+
})().catch((error) => {
|
|
40
|
+
resvgPromise = undefined;
|
|
41
|
+
throw error;
|
|
42
|
+
});
|
|
43
|
+
return resvgPromise;
|
|
44
|
+
}
|
|
45
|
+
/** Engine entry: HTML string in, svg string or png bytes out. Fonts are required here —
|
|
46
|
+
* render.js fills in the bundled defaults when the caller passes none. */
|
|
47
|
+
export function render(html, options) {
|
|
48
|
+
return options.format === "svg" ? createSvg(html, options) : createPng(html, options);
|
|
49
|
+
}
|
|
50
|
+
async function createSvg(html, options) {
|
|
51
|
+
const log = createLogger(options.debug ?? false);
|
|
52
|
+
const vnodes = createVNode(html);
|
|
53
|
+
const satori = await handleAsync(() => useSatori(), ErrorCodes.SATORI_INIT_FAILED, "Failed to initialize Satori");
|
|
54
|
+
const satoriOptions = { ...options };
|
|
55
|
+
satoriOptions.loadAdditionalAsset = loadDynamicAsset({
|
|
56
|
+
emoji: options.emoji,
|
|
57
|
+
});
|
|
58
|
+
log.debug("Generating SVG with Satori (client)");
|
|
59
|
+
return handleAsync(() => satori(vnodes, satoriOptions), ErrorCodes.SATORI_RENDER_FAILED, "Failed to render SVG with Satori");
|
|
60
|
+
}
|
|
61
|
+
async function createPng(html, options) {
|
|
62
|
+
const log = createLogger(options.debug ?? false);
|
|
63
|
+
const svg = await createSvg(html, options);
|
|
64
|
+
const Resvg = await handleAsync(() => useResvg(), ErrorCodes.RESVG_INIT_FAILED, "Failed to initialize ReSVG");
|
|
65
|
+
const resvg_options = {
|
|
66
|
+
fitTo: { mode: "width", value: options.width || DEFAULT_WIDTH },
|
|
67
|
+
};
|
|
68
|
+
log.debug("Rendering PNG with ReSVG (client)");
|
|
69
|
+
return handleAsync(async () => {
|
|
70
|
+
// free the wasm-backed objects so repeated client renders don't grow wasm memory
|
|
71
|
+
const resvg = new Resvg(svg, resvg_options);
|
|
72
|
+
const rendered = resvg.render();
|
|
73
|
+
try {
|
|
74
|
+
return rendered.asPng();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
rendered.free();
|
|
78
|
+
resvg.free();
|
|
79
|
+
}
|
|
80
|
+
}, ErrorCodes.RESVG_RENDER_FAILED, "Failed to render PNG with ReSVG");
|
|
81
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TakumiImageOptions } from "../../takumi/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Takumi engine boundary for the client. Exists only so render.js can `import()` it
|
|
4
|
+
* lazily (own Vite chunk); the actual render is the shared takumi path. Takumi's wasm
|
|
5
|
+
* embeds a sans-serif font, so no default fonts are injected here.
|
|
6
|
+
*/
|
|
7
|
+
export declare function render(html: string, options: TakumiImageOptions): Promise<Uint8Array | string>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createTakumiImage } from "../../takumi/render.js";
|
|
2
|
+
/**
|
|
3
|
+
* Takumi engine boundary for the client. Exists only so render.js can `import()` it
|
|
4
|
+
* lazily (own Vite chunk); the actual render is the shared takumi path. Takumi's wasm
|
|
5
|
+
* embeds a sans-serif font, so no default fonts are injected here.
|
|
6
|
+
*/
|
|
7
|
+
export function render(html, options) {
|
|
8
|
+
return createTakumiImage(html, options);
|
|
9
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { handleAsync, ErrorCodes } from "../helpers/error-handler.js";
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
3
|
+
// @ts-ignore - Vite resolves ?url to the emitted asset path
|
|
4
|
+
import regularUrl from "./assets/NotoSans-Regular.ttf?url";
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
6
|
+
// @ts-ignore - Vite resolves ?url to the emitted asset path
|
|
7
|
+
import boldUrl from "./assets/NotoSans-Bold.ttf?url";
|
|
8
|
+
/*
|
|
9
|
+
* Bundled fallback fonts for the client satori engine. The server falls back to a
|
|
10
|
+
* CDN that doesn't send CORS headers, so browsers can't use it; these ship with the
|
|
11
|
+
* package and are served same-origin from the consumer's own build output.
|
|
12
|
+
*
|
|
13
|
+
* Loaded once and memoized; a rejected load clears the memo so the next render retries.
|
|
14
|
+
* Takumi doesn't need this — its wasm embeds a sans-serif.
|
|
15
|
+
*/
|
|
16
|
+
let fontsPromise;
|
|
17
|
+
export function defaultClientFonts() {
|
|
18
|
+
fontsPromise ??= load().catch((error) => {
|
|
19
|
+
fontsPromise = undefined;
|
|
20
|
+
throw error;
|
|
21
|
+
});
|
|
22
|
+
return fontsPromise;
|
|
23
|
+
}
|
|
24
|
+
async function fetchFont(url) {
|
|
25
|
+
const res = await fetch(new URL(url, import.meta.url));
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
throw new Error(`${res.status} fetching ${url}`);
|
|
28
|
+
return res.arrayBuffer();
|
|
29
|
+
}
|
|
30
|
+
async function load() {
|
|
31
|
+
const [regular, bold] = await handleAsync(() => Promise.all([fetchFont(regularUrl), fetchFont(boldUrl)]), ErrorCodes.FONT_LOAD_FAILED, "Failed to load the bundled default fonts");
|
|
32
|
+
return [
|
|
33
|
+
{ name: "Noto Sans", data: regular, weight: 400, style: "normal" },
|
|
34
|
+
{ name: "Noto Sans", data: bold, weight: 700, style: "normal" },
|
|
35
|
+
];
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Component, ComponentProps } from "svelte";
|
|
2
|
+
import type { ClientImageResponseOptions } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Client-side OG image, rendered in the browser (or a worker) with the engine
|
|
5
|
+
* you pick via `options.engine` ("takumi" | "satori", defaults to "takumi").
|
|
6
|
+
* Extends `Response`, so consume it with `URL.createObjectURL(await res.blob())`.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ImageResponse<T extends string | Component<any>> extends Response {
|
|
9
|
+
constructor(element: T, options?: ClientImageResponseOptions, props?: T extends Component<any> ? ComponentProps<T> : never);
|
|
10
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
11
|
+
blob(): Promise<Blob>;
|
|
12
|
+
text(): Promise<string>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createClientImage } from "./render.js";
|
|
2
|
+
import { buildImageResponse, CONTENT_TYPES } from "../helpers/response.js";
|
|
3
|
+
import { ImageResponseError, ErrorCodes } from "../helpers/error-handler.js";
|
|
4
|
+
import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "../helpers/defaults.js";
|
|
5
|
+
const DEFAULT_OPTIONS = {
|
|
6
|
+
engine: "takumi",
|
|
7
|
+
width: DEFAULT_WIDTH,
|
|
8
|
+
height: DEFAULT_HEIGHT,
|
|
9
|
+
format: "png",
|
|
10
|
+
emoji: "twemoji",
|
|
11
|
+
debug: false,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Client-side OG image, rendered in the browser (or a worker) with the engine
|
|
15
|
+
* you pick via `options.engine` ("takumi" | "satori", defaults to "takumi").
|
|
16
|
+
* Extends `Response`, so consume it with `URL.createObjectURL(await res.blob())`.
|
|
17
|
+
*/
|
|
18
|
+
export class ImageResponse extends Response {
|
|
19
|
+
constructor(element, options, props) {
|
|
20
|
+
const merged = { ...DEFAULT_OPTIONS, ...options };
|
|
21
|
+
const engine = (merged.engine ?? "takumi");
|
|
22
|
+
// Satori only emits png or svg; any other raster format renders as png, so pin it
|
|
23
|
+
// to png here too — otherwise the Content-Type would mislabel png bytes (e.g. webp).
|
|
24
|
+
const format = (engine === "satori" && merged.format !== "svg" ? "png" : (merged.format ?? "png"));
|
|
25
|
+
const opts = { ...merged, format };
|
|
26
|
+
// response-only fields live on both engine option shapes; read them off the raw input
|
|
27
|
+
const resp = (options ?? {});
|
|
28
|
+
const { body, init } = buildImageResponse(() => createClientImage(element, opts, props), {
|
|
29
|
+
label: format.toUpperCase(),
|
|
30
|
+
contentType: CONTENT_TYPES[format],
|
|
31
|
+
debug: merged.debug ?? false,
|
|
32
|
+
headers: resp.headers,
|
|
33
|
+
status: resp.status,
|
|
34
|
+
statusText: resp.statusText,
|
|
35
|
+
});
|
|
36
|
+
super(body, init);
|
|
37
|
+
}
|
|
38
|
+
/*
|
|
39
|
+
* Browsers turn an errored body stream into a bare `TypeError: Failed to fetch`
|
|
40
|
+
* when read through Response's own arrayBuffer()/blob()/text(), which drops the
|
|
41
|
+
* ImageResponseError (and its .code). Reading the stream directly keeps the
|
|
42
|
+
* original error, so override the three readers users actually call.
|
|
43
|
+
*/
|
|
44
|
+
async arrayBuffer() {
|
|
45
|
+
const reader = this.body.getReader();
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let size = 0;
|
|
48
|
+
for (;;) {
|
|
49
|
+
let step;
|
|
50
|
+
try {
|
|
51
|
+
step = await reader.read();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw unwrapRenderError(error);
|
|
55
|
+
}
|
|
56
|
+
if (step.done)
|
|
57
|
+
break;
|
|
58
|
+
chunks.push(step.value);
|
|
59
|
+
size += step.value.byteLength;
|
|
60
|
+
}
|
|
61
|
+
const out = new Uint8Array(size);
|
|
62
|
+
let offset = 0;
|
|
63
|
+
for (const chunk of chunks) {
|
|
64
|
+
out.set(chunk, offset);
|
|
65
|
+
offset += chunk.byteLength;
|
|
66
|
+
}
|
|
67
|
+
return out.buffer;
|
|
68
|
+
}
|
|
69
|
+
async blob() {
|
|
70
|
+
return new Blob([await this.arrayBuffer()], { type: this.headers.get("Content-Type") ?? "" });
|
|
71
|
+
}
|
|
72
|
+
async text() {
|
|
73
|
+
return new TextDecoder().decode(await this.arrayBuffer());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The shared body builder wraps every failure as UNKNOWN_ERROR "Failed to generate X"
|
|
78
|
+
* with the real error in `originalError`. Surface the inner one so callers can check
|
|
79
|
+
* `error.code` (e.g. COMPONENT_IN_WORKER, FONT_LOAD_FAILED).
|
|
80
|
+
* ponytail: unwrap here rather than change the shared helper; fold into the v3 realignment.
|
|
81
|
+
*/
|
|
82
|
+
function unwrapRenderError(error) {
|
|
83
|
+
return error instanceof ImageResponseError &&
|
|
84
|
+
error.code === ErrorCodes.UNKNOWN_ERROR &&
|
|
85
|
+
error.originalError instanceof ImageResponseError
|
|
86
|
+
? error.originalError
|
|
87
|
+
: error;
|
|
88
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ImageResponse } from "./image-response.js";
|
|
2
|
+
export { createImage } from "./create.js";
|
|
3
|
+
// CustomFont works anywhere (you supply the bytes); resolveFonts turns it into the
|
|
4
|
+
// shape satori needs. GoogleFont/loadGoogleFont are deliberately not re-exported: a
|
|
5
|
+
// browser can't set the User-Agent, so Google serves woff2 and the loader rejects it.
|
|
6
|
+
export { CustomFont, resolveFonts } from "../fonts.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Component } from "svelte";
|
|
2
|
+
import type { ClientImageResponseOptions } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Dispatch to the chosen engine, both running in the browser/worker. Response-only
|
|
5
|
+
* keys are stripped so they don't leak into the engine options.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createClientImage(element: string | Component<any>, options: ClientImageResponseOptions, props?: Record<string, unknown>): Promise<Uint8Array | string>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ImageResponseError, ErrorCodes } from "../helpers/error-handler.js";
|
|
2
|
+
/** Components need the DOM to mount; workers have none. */
|
|
3
|
+
const hasDocument = () => typeof document !== "undefined";
|
|
4
|
+
async function toHtml(element, props) {
|
|
5
|
+
if (typeof element === "string")
|
|
6
|
+
return element;
|
|
7
|
+
if (!hasDocument()) {
|
|
8
|
+
throw new ImageResponseError("Svelte components can't be rendered in a worker (no document). Pass an HTML string instead.", ErrorCodes.COMPONENT_IN_WORKER);
|
|
9
|
+
}
|
|
10
|
+
// lazy: only main-thread component users download the mounter + Svelte's mount runtime
|
|
11
|
+
const { componentToHtml } = await import("./component.js");
|
|
12
|
+
return componentToHtml(element, props ?? {});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Dispatch to the chosen engine, both running in the browser/worker. Response-only
|
|
16
|
+
* keys are stripped so they don't leak into the engine options.
|
|
17
|
+
*/
|
|
18
|
+
export async function createClientImage(element, options, props) {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
20
|
+
const { engine = "takumi", status, statusText, headers, ...imageOptions } = options;
|
|
21
|
+
// components are rendered to HTML here (browser mount), so engines only ever see a string
|
|
22
|
+
const html = await toHtml(element, props);
|
|
23
|
+
// lazy: one Vite chunk per engine, so the other engine's JS + wasm are never fetched
|
|
24
|
+
if (engine === "satori") {
|
|
25
|
+
const satoriOptions = imageOptions;
|
|
26
|
+
// satori has no built-in font; use the bundled ones unless the caller passed some.
|
|
27
|
+
// Lives here (not in the engine) so it's unit-testable in node, where the engine can't run.
|
|
28
|
+
if (!satoriOptions.fonts?.length) {
|
|
29
|
+
const { defaultClientFonts } = await import("./fonts.js");
|
|
30
|
+
satoriOptions.fonts = await defaultClientFonts();
|
|
31
|
+
}
|
|
32
|
+
const { render } = await import("./engines/satori.js");
|
|
33
|
+
return render(html, satoriOptions);
|
|
34
|
+
}
|
|
35
|
+
const { render } = await import("./engines/takumi.js");
|
|
36
|
+
return render(html, imageOptions);
|
|
37
|
+
}
|