loopwind 0.25.6 → 0.25.7
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/app/.astro/types.d.ts +1 -0
- package/app/dist/_astro/callback.Ci5gaEfJ.css +1 -0
- package/app/dist/auth/callback/index.html +81 -0
- package/app/dist/device/index.html +70 -0
- package/app/dist/index.html +327 -0
- package/app/package-lock.json +9239 -0
- package/app/package.json +23 -0
- package/app/wrangler.toml +8 -0
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +60 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +15 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/publish.d.ts +10 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +155 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/templates.d.ts +5 -0
- package/dist/commands/templates.d.ts.map +1 -0
- package/dist/commands/templates.js +60 -0
- package/dist/commands/templates.js.map +1 -0
- package/dist/commands/unpublish.d.ts +5 -0
- package/dist/commands/unpublish.d.ts.map +1 -0
- package/dist/commands/unpublish.js +54 -0
- package/dist/commands/unpublish.js.map +1 -0
- package/dist/commands/whoami.d.ts +5 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/lib/api.d.ts +92 -0
- package/dist/lib/api.d.ts.map +1 -0
- package/dist/lib/api.js +149 -0
- package/dist/lib/api.js.map +1 -0
- package/dist/lib/auth.d.ts +41 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +89 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/bundler.d.ts +18 -0
- package/dist/lib/bundler.d.ts.map +1 -0
- package/dist/lib/bundler.js +105 -0
- package/dist/lib/bundler.js.map +1 -0
- package/dist/lib/helpers.d.ts +35 -2
- package/dist/lib/helpers.d.ts.map +1 -1
- package/dist/lib/helpers.js +91 -13
- package/dist/lib/helpers.js.map +1 -1
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +9 -0
- package/dist/lib/utils.js.map +1 -1
- package/dist/sdk/edge.d.ts +65 -0
- package/dist/sdk/edge.d.ts.map +1 -0
- package/dist/sdk/edge.js +329 -0
- package/dist/sdk/edge.js.map +1 -0
- package/dist/sdk/errors.d.ts +64 -0
- package/dist/sdk/errors.d.ts.map +1 -0
- package/dist/sdk/errors.js +94 -0
- package/dist/sdk/errors.js.map +1 -0
- package/dist/sdk/index.d.ts +29 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +30 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sdk/render.d.ts +52 -0
- package/dist/sdk/render.d.ts.map +1 -0
- package/dist/sdk/render.js +432 -0
- package/dist/sdk/render.js.map +1 -0
- package/dist/sdk/types.d.ts +185 -0
- package/dist/sdk/types.d.ts.map +1 -0
- package/dist/sdk/types.js +5 -0
- package/dist/sdk/types.js.map +1 -0
- package/dist/types/template.d.ts +18 -0
- package/dist/types/template.d.ts.map +1 -1
- package/package.json +26 -4
- package/plans/PLATFORM.md +1637 -237
- package/plans/PLATFORM_IMPLEMENTATION.md +1347 -530
- package/plans/SDK.md +797 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite +0 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-shm +0 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-wal +0 -0
- package/platform/migrations/0001_initial.sql +90 -0
- package/platform/package-lock.json +3104 -0
- package/platform/package.json +30 -0
- package/platform/wrangler.toml +43 -0
- package/tests-sdk/createRenderer.test.ts +251 -0
- package/tests-sdk/errors.test.ts +230 -0
- package/tests-sdk/render.test.ts +241 -0
- package/tests-sdk/tw.test.ts +277 -0
package/plans/SDK.md
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
# Loopwind SDK
|
|
2
|
+
|
|
3
|
+
> **Open Source**: MIT licensed. A programmatic library for rendering images and videos from JSX templates.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Loopwind ecosystem has three parts:
|
|
8
|
+
|
|
9
|
+
| Component | Description | Use Case |
|
|
10
|
+
|-----------|-------------|----------|
|
|
11
|
+
| **SDK** (this doc) | Programmatic library | Self-hosted rendering in your app |
|
|
12
|
+
| **CLI** | Command-line tool | Local development, template authoring |
|
|
13
|
+
| **Platform** | Hosted API service | Publish templates, get render URLs |
|
|
14
|
+
|
|
15
|
+
**SDK is for self-hosted rendering.** If you want hosted rendering with URLs like `api.loopwind.dev/r/abc123?title=Hello`, see [PLATFORM.md](./PLATFORM.md).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Vision
|
|
20
|
+
|
|
21
|
+
A JavaScript/TypeScript library for rendering images and videos from JSX. No config files, no folder structure - just functions.
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { render } from 'loopwind';
|
|
25
|
+
|
|
26
|
+
// Define a template - receives `tw` function as prop
|
|
27
|
+
const OGImage = ({ title, subtitle, tw }) => (
|
|
28
|
+
<div style={tw("w-full h-full flex flex-col items-center justify-center bg-gradient-to-r from-blue-500 to-purple-600")}>
|
|
29
|
+
<h1 style={tw("text-white text-6xl font-bold")}>{title}</h1>
|
|
30
|
+
<p style={tw("text-white/80 text-2xl mt-4")}>{subtitle}</p>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Render to PNG
|
|
35
|
+
const png = await render(OGImage, {
|
|
36
|
+
props: { title: 'Hello World', subtitle: 'Welcome' },
|
|
37
|
+
width: 1200,
|
|
38
|
+
height: 630,
|
|
39
|
+
format: 'png',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Write to file or return as response
|
|
43
|
+
await fs.writeFile('og.png', png);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Template Composition
|
|
49
|
+
|
|
50
|
+
Templates are just functions. Compose them like regular React components:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { render } from 'loopwind';
|
|
54
|
+
|
|
55
|
+
// Layout template
|
|
56
|
+
const Layout = ({ children, background = 'white', tw }) => (
|
|
57
|
+
<div style={{ ...tw("w-full h-full flex items-center justify-center p-12"), background }}>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Card component
|
|
63
|
+
const Card = ({ children, tw }) => (
|
|
64
|
+
<div style={tw("bg-white rounded-2xl shadow-xl p-8")}>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Composed template - tw is passed through
|
|
70
|
+
const BlogOG = ({ title, author, avatar, tw }) => (
|
|
71
|
+
<Layout background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)" tw={tw}>
|
|
72
|
+
<Card tw={tw}>
|
|
73
|
+
<div style={tw("flex items-center gap-6")}>
|
|
74
|
+
<img src={avatar} style={tw("w-16 h-16 rounded-full")} />
|
|
75
|
+
<div>
|
|
76
|
+
<h1 style={tw("text-3xl font-bold text-gray-900")}>{title}</h1>
|
|
77
|
+
<p style={tw("text-gray-600 mt-1")}>by {author}</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</Card>
|
|
81
|
+
</Layout>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Render the composed template
|
|
85
|
+
const png = await render(BlogOG, {
|
|
86
|
+
props: {
|
|
87
|
+
title: 'Building with Loopwind',
|
|
88
|
+
author: 'Jane Doe',
|
|
89
|
+
avatar: 'https://example.com/avatar.jpg',
|
|
90
|
+
},
|
|
91
|
+
width: 1200,
|
|
92
|
+
height: 630,
|
|
93
|
+
format: 'png',
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Video Rendering
|
|
100
|
+
|
|
101
|
+
Animate templates over time using the `frame` prop:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { render } from 'loopwind';
|
|
105
|
+
|
|
106
|
+
// Animated template - receives frame info
|
|
107
|
+
const AnimatedIntro = ({ title, frame, tw }) => {
|
|
108
|
+
const { progress } = frame;
|
|
109
|
+
|
|
110
|
+
// Animate based on progress (0 to 1)
|
|
111
|
+
const opacity = Math.min(progress * 2, 1);
|
|
112
|
+
const translateY = 50 * (1 - progress);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div style={tw("w-full h-full flex items-center justify-center bg-black")}>
|
|
116
|
+
<h1
|
|
117
|
+
style={{
|
|
118
|
+
...tw("text-white text-6xl font-bold"),
|
|
119
|
+
opacity,
|
|
120
|
+
transform: `translateY(${translateY}px)`,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{title}
|
|
124
|
+
</h1>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Render to video
|
|
130
|
+
const mp4 = await render(AnimatedIntro, {
|
|
131
|
+
props: { title: 'Welcome' },
|
|
132
|
+
width: 1920,
|
|
133
|
+
height: 1080,
|
|
134
|
+
format: 'mp4',
|
|
135
|
+
fps: 30,
|
|
136
|
+
duration: 3, // seconds
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## API Reference
|
|
143
|
+
|
|
144
|
+
### `render(template, options)`
|
|
145
|
+
|
|
146
|
+
Renders a template function to an image or video.
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
const result = await render(MyTemplate, {
|
|
150
|
+
// Props passed to template
|
|
151
|
+
props: { title: 'Hello', subtitle: 'World' },
|
|
152
|
+
|
|
153
|
+
// Dimensions
|
|
154
|
+
width: 1200,
|
|
155
|
+
height: 630,
|
|
156
|
+
|
|
157
|
+
// Output format
|
|
158
|
+
format: 'png', // 'png' | 'svg' | 'jpg' | 'webp' | 'mp4' | 'gif'
|
|
159
|
+
|
|
160
|
+
// Video options (only for mp4/gif)
|
|
161
|
+
fps: 30,
|
|
162
|
+
duration: 5, // seconds
|
|
163
|
+
|
|
164
|
+
// Quality
|
|
165
|
+
quality: 90, // JPEG/WebP quality (1-100)
|
|
166
|
+
crf: 23, // Video quality (0-51, lower = better)
|
|
167
|
+
|
|
168
|
+
// Fonts
|
|
169
|
+
fonts: [
|
|
170
|
+
{ name: 'Inter', data: interFontBuffer, weight: 400 },
|
|
171
|
+
{ name: 'Inter', data: interBoldBuffer, weight: 700 },
|
|
172
|
+
],
|
|
173
|
+
|
|
174
|
+
// Debug mode (shows bounding boxes)
|
|
175
|
+
debug: false,
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### `createRenderer(options)`
|
|
180
|
+
|
|
181
|
+
Create a reusable renderer with preset options (fonts, defaults):
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
import { createRenderer } from 'loopwind';
|
|
185
|
+
|
|
186
|
+
const render = createRenderer({
|
|
187
|
+
fonts: [
|
|
188
|
+
{ name: 'Inter', data: interFont, weight: 400 },
|
|
189
|
+
{ name: 'Inter', data: interBold, weight: 700 },
|
|
190
|
+
],
|
|
191
|
+
defaults: {
|
|
192
|
+
width: 1200,
|
|
193
|
+
height: 630,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Now render without specifying fonts each time
|
|
198
|
+
const png = await render(OGImage, {
|
|
199
|
+
props: { title: 'Hello' },
|
|
200
|
+
format: 'png',
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## TypeScript Types
|
|
207
|
+
|
|
208
|
+
Full type definitions for templates and rendering:
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import type {
|
|
212
|
+
Template,
|
|
213
|
+
TemplateProps,
|
|
214
|
+
RenderOptions,
|
|
215
|
+
RenderResult,
|
|
216
|
+
RendererConfig,
|
|
217
|
+
TwFunction,
|
|
218
|
+
FrameInfo,
|
|
219
|
+
} from 'loopwind';
|
|
220
|
+
|
|
221
|
+
// Template function type
|
|
222
|
+
type Template<P extends TemplateProps = TemplateProps> = (props: P & {
|
|
223
|
+
tw: TwFunction;
|
|
224
|
+
frame?: FrameInfo;
|
|
225
|
+
image?: (name: string) => string;
|
|
226
|
+
}) => JSX.Element;
|
|
227
|
+
|
|
228
|
+
// Props must be JSON-serializable
|
|
229
|
+
interface TemplateProps {
|
|
230
|
+
[key: string]: string | number | boolean | null | undefined |
|
|
231
|
+
string[] | number[] | TemplateProps | TemplateProps[];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// tw() function signature
|
|
235
|
+
type TwFunction = (classes: string) => React.CSSProperties;
|
|
236
|
+
|
|
237
|
+
// Frame info for video templates
|
|
238
|
+
interface FrameInfo {
|
|
239
|
+
index: number; // Current frame (0-based)
|
|
240
|
+
total: number; // Total frames
|
|
241
|
+
progress: number; // 0 to 1
|
|
242
|
+
time: number; // Current time in seconds
|
|
243
|
+
fps: number; // Frames per second
|
|
244
|
+
duration: number; // Total duration in seconds
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Render options
|
|
248
|
+
interface RenderOptions<P extends TemplateProps = TemplateProps> {
|
|
249
|
+
props: P;
|
|
250
|
+
width?: number; // Default: from template meta or 1200
|
|
251
|
+
height?: number; // Default: from template meta or 630
|
|
252
|
+
format?: 'png' | 'jpg' | 'webp' | 'svg' | 'mp4' | 'gif';
|
|
253
|
+
|
|
254
|
+
// Image quality (jpg, webp)
|
|
255
|
+
quality?: number; // 1-100, default: 90
|
|
256
|
+
|
|
257
|
+
// Video options (mp4, gif)
|
|
258
|
+
fps?: number; // Default: 30
|
|
259
|
+
duration?: number; // Seconds
|
|
260
|
+
crf?: number; // 0-51, default: 23
|
|
261
|
+
|
|
262
|
+
// Fonts (if not using createRenderer)
|
|
263
|
+
fonts?: FontConfig[];
|
|
264
|
+
|
|
265
|
+
// Debug mode
|
|
266
|
+
debug?: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Font configuration
|
|
270
|
+
interface FontConfig {
|
|
271
|
+
name: string;
|
|
272
|
+
data: ArrayBuffer | Buffer;
|
|
273
|
+
weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
|
|
274
|
+
style?: 'normal' | 'italic';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Renderer configuration
|
|
278
|
+
interface RendererConfig {
|
|
279
|
+
colors?: Record<string, string>;
|
|
280
|
+
fonts?: FontsConfig;
|
|
281
|
+
tokens?: TokensConfig;
|
|
282
|
+
defaults?: {
|
|
283
|
+
width?: number;
|
|
284
|
+
height?: number;
|
|
285
|
+
format?: string;
|
|
286
|
+
};
|
|
287
|
+
templates?: Record<string, Template>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Render result
|
|
291
|
+
type RenderResult = Buffer | Uint8Array;
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Using types in your templates:**
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
import type { Template, TwFunction, FrameInfo } from 'loopwind';
|
|
298
|
+
|
|
299
|
+
interface BlogOGProps {
|
|
300
|
+
title: string;
|
|
301
|
+
author: string;
|
|
302
|
+
publishedAt?: string;
|
|
303
|
+
tags?: string[];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const BlogOG: Template<BlogOGProps> = ({ title, author, publishedAt, tags, tw }) => (
|
|
307
|
+
<div style={tw("w-full h-full bg-white p-12")}>
|
|
308
|
+
<h1 style={tw("text-5xl font-bold")}>{title}</h1>
|
|
309
|
+
<p style={tw("text-xl text-gray-600 mt-4")}>by {author}</p>
|
|
310
|
+
{tags && (
|
|
311
|
+
<div style={tw("flex gap-2 mt-4")}>
|
|
312
|
+
{tags.map(tag => (
|
|
313
|
+
<span key={tag} style={tw("bg-gray-100 px-3 py-1 rounded-full text-sm")}>
|
|
314
|
+
{tag}
|
|
315
|
+
</span>
|
|
316
|
+
))}
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Error Handling
|
|
326
|
+
|
|
327
|
+
The SDK throws typed errors for different failure scenarios:
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
import { render, RenderError, ValidationError, FontError } from 'loopwind';
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const png = await render(MyTemplate, {
|
|
334
|
+
props: { title: 'Hello' },
|
|
335
|
+
format: 'png',
|
|
336
|
+
});
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error instanceof ValidationError) {
|
|
339
|
+
// Invalid props, missing required fields, etc.
|
|
340
|
+
console.error('Validation failed:', error.message);
|
|
341
|
+
console.error('Field:', error.field);
|
|
342
|
+
console.error('Expected:', error.expected);
|
|
343
|
+
} else if (error instanceof FontError) {
|
|
344
|
+
// Font loading failed
|
|
345
|
+
console.error('Font error:', error.message);
|
|
346
|
+
console.error('Font name:', error.fontName);
|
|
347
|
+
} else if (error instanceof RenderError) {
|
|
348
|
+
// Rendering failed (Satori/resvg error)
|
|
349
|
+
console.error('Render failed:', error.message);
|
|
350
|
+
console.error('Stage:', error.stage); // 'jsx' | 'svg' | 'rasterize' | 'encode'
|
|
351
|
+
} else {
|
|
352
|
+
// Unknown error
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Error types:**
|
|
359
|
+
|
|
360
|
+
| Error | When | Properties |
|
|
361
|
+
|-------|------|------------|
|
|
362
|
+
| `ValidationError` | Invalid props, dimensions, format | `field`, `expected`, `received` |
|
|
363
|
+
| `FontError` | Font file missing, corrupt, or unsupported | `fontName`, `fontPath` |
|
|
364
|
+
| `RenderError` | JSX/SVG/rasterization/encoding failed | `stage`, `cause` |
|
|
365
|
+
| `TimeoutError` | Render exceeded time limit | `timeout`, `elapsed` |
|
|
366
|
+
| `ImageFetchError` | External image failed to load | `url`, `status`, `cause` |
|
|
367
|
+
|
|
368
|
+
**Handling external image errors:**
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
import { render, ImageFetchError } from 'loopwind';
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const png = await render(CardWithImage, {
|
|
375
|
+
props: {
|
|
376
|
+
title: 'Hello',
|
|
377
|
+
imageUrl: 'https://example.com/broken.jpg' // 404
|
|
378
|
+
},
|
|
379
|
+
format: 'png',
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (error instanceof ImageFetchError) {
|
|
383
|
+
console.error(`Failed to fetch ${error.url}: ${error.status}`);
|
|
384
|
+
// Optionally render without the image
|
|
385
|
+
const fallback = await render(CardWithImage, {
|
|
386
|
+
props: { title: 'Hello', imageUrl: null },
|
|
387
|
+
format: 'png',
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**Timeouts:**
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
const png = await render(MyTemplate, {
|
|
397
|
+
props: { title: 'Hello' },
|
|
398
|
+
format: 'png',
|
|
399
|
+
timeout: 30000, // 30 seconds max
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## Template Registry (Optional)
|
|
406
|
+
|
|
407
|
+
For convenience, you can register templates by name:
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
import { createRenderer } from 'loopwind';
|
|
411
|
+
|
|
412
|
+
// Define templates
|
|
413
|
+
const templates = {
|
|
414
|
+
'og-image': ({ title, tw }) => (
|
|
415
|
+
<div style={tw("w-full h-full bg-blue-500 flex items-center justify-center")}>
|
|
416
|
+
<h1 style={tw("text-white text-6xl")}>{title}</h1>
|
|
417
|
+
</div>
|
|
418
|
+
),
|
|
419
|
+
|
|
420
|
+
'blog-card': ({ title, author, tw }) => (
|
|
421
|
+
<div style={tw("w-full h-full bg-white p-12")}>
|
|
422
|
+
<h1 style={tw("text-4xl font-bold")}>{title}</h1>
|
|
423
|
+
<p style={tw("text-gray-600")}>by {author}</p>
|
|
424
|
+
</div>
|
|
425
|
+
),
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const render = createRenderer({ templates });
|
|
429
|
+
|
|
430
|
+
// Render by name
|
|
431
|
+
const png = await render('og-image', { props: { title: 'Hello' }, format: 'png' });
|
|
432
|
+
const jpg = await render('blog-card', { props: { title: 'Post', author: 'Jane' }, format: 'jpg' });
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## Framework Integration
|
|
438
|
+
|
|
439
|
+
### Next.js API Route
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
// app/api/og/route.ts
|
|
443
|
+
import { render } from 'loopwind';
|
|
444
|
+
|
|
445
|
+
const OGImage = ({ title, tw }) => (
|
|
446
|
+
<div style={tw("w-full h-full bg-black flex items-center justify-center")}>
|
|
447
|
+
<h1 style={tw("text-white text-6xl")}>{title}</h1>
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
export async function GET(request: Request) {
|
|
452
|
+
const { searchParams } = new URL(request.url);
|
|
453
|
+
const title = searchParams.get('title') || 'Hello';
|
|
454
|
+
|
|
455
|
+
const png = await render(OGImage, {
|
|
456
|
+
props: { title },
|
|
457
|
+
width: 1200,
|
|
458
|
+
height: 630,
|
|
459
|
+
format: 'png',
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
return new Response(png, {
|
|
463
|
+
headers: { 'Content-Type': 'image/png' },
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### Express
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
import express from 'express';
|
|
472
|
+
import { render } from 'loopwind';
|
|
473
|
+
|
|
474
|
+
const OGImage = ({ title, tw }) => (
|
|
475
|
+
<div style={tw("w-full h-full bg-blue-500 flex items-center justify-center")}>
|
|
476
|
+
<h1 style={tw("text-white text-6xl")}>{title}</h1>
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const app = express();
|
|
481
|
+
|
|
482
|
+
app.get('/og', async (req, res) => {
|
|
483
|
+
const { title } = req.query;
|
|
484
|
+
|
|
485
|
+
const png = await render(OGImage, {
|
|
486
|
+
props: { title },
|
|
487
|
+
width: 1200,
|
|
488
|
+
height: 630,
|
|
489
|
+
format: 'png',
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
res.type('image/png').send(png);
|
|
493
|
+
});
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### Cloudflare Workers
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
import { render } from 'loopwind/edge';
|
|
500
|
+
|
|
501
|
+
const OGImage = ({ title, tw }) => (
|
|
502
|
+
<div style={tw("w-full h-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center")}>
|
|
503
|
+
<h1 style={tw("text-white text-6xl font-bold")}>{title}</h1>
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
export default {
|
|
508
|
+
async fetch(request: Request) {
|
|
509
|
+
const url = new URL(request.url);
|
|
510
|
+
const title = url.searchParams.get('title') || 'Hello';
|
|
511
|
+
|
|
512
|
+
const png = await render(OGImage, {
|
|
513
|
+
props: { title },
|
|
514
|
+
width: 1200,
|
|
515
|
+
height: 630,
|
|
516
|
+
format: 'png',
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
return new Response(png, {
|
|
520
|
+
headers: {
|
|
521
|
+
'Content-Type': 'image/png',
|
|
522
|
+
'Cache-Control': 'public, max-age=86400',
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## Shared Components Library
|
|
532
|
+
|
|
533
|
+
Build a library of reusable components:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
// components/base.tsx
|
|
537
|
+
export const Gradient = ({ from, to, children, tw }) => (
|
|
538
|
+
<div
|
|
539
|
+
style={{
|
|
540
|
+
...tw("w-full h-full flex items-center justify-center"),
|
|
541
|
+
background: `linear-gradient(135deg, ${from}, ${to})`,
|
|
542
|
+
}}
|
|
543
|
+
>
|
|
544
|
+
{children}
|
|
545
|
+
</div>
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
export const Card = ({ children, tw }) => (
|
|
549
|
+
<div style={tw("bg-white rounded-2xl shadow-2xl p-8")}>
|
|
550
|
+
{children}
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
export const Avatar = ({ src, tw }) => (
|
|
555
|
+
<img src={src} style={tw("w-16 h-16 rounded-full")} />
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// templates/blog-og.tsx
|
|
559
|
+
import { Gradient, Card, Avatar } from './components/base';
|
|
560
|
+
|
|
561
|
+
export const BlogOG = ({ title, author, avatar, tw }) => (
|
|
562
|
+
<Gradient from="#667eea" to="#764ba2" tw={tw}>
|
|
563
|
+
<Card tw={tw}>
|
|
564
|
+
<div style={tw("flex items-center gap-4")}>
|
|
565
|
+
<Avatar src={avatar} tw={tw} />
|
|
566
|
+
<div>
|
|
567
|
+
<h1 style={tw("text-3xl font-bold")}>{title}</h1>
|
|
568
|
+
<p style={tw("text-gray-500")}>by {author}</p>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
</Card>
|
|
572
|
+
</Gradient>
|
|
573
|
+
);
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## Configuration
|
|
579
|
+
|
|
580
|
+
Define custom colors and fonts in `createRenderer()`. Same structure as `loopwind.json`:
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
import { createRenderer } from 'loopwind';
|
|
584
|
+
|
|
585
|
+
const render = createRenderer({
|
|
586
|
+
// Custom colors for tw()
|
|
587
|
+
colors: {
|
|
588
|
+
primary: '#18181b',
|
|
589
|
+
'primary-foreground': '#fafafa',
|
|
590
|
+
secondary: '#f4f4f5',
|
|
591
|
+
'secondary-foreground': '#18181b',
|
|
592
|
+
background: '#ffffff',
|
|
593
|
+
foreground: '#09090b',
|
|
594
|
+
muted: '#f4f4f5',
|
|
595
|
+
'muted-foreground': '#71717a',
|
|
596
|
+
accent: '#f4f4f5',
|
|
597
|
+
'accent-foreground': '#18181b',
|
|
598
|
+
destructive: '#ef4444',
|
|
599
|
+
'destructive-foreground': '#fafafa',
|
|
600
|
+
border: '#e4e4e7',
|
|
601
|
+
card: '#ffffff',
|
|
602
|
+
'card-foreground': '#09090b',
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
// Fonts - simple (system fonts, uses bundled Inter for rendering)
|
|
606
|
+
fonts: {
|
|
607
|
+
sans: ['Inter', 'system-ui', 'sans-serif'],
|
|
608
|
+
mono: ['JetBrains Mono', 'monospace'],
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
// OR fonts with custom font files
|
|
612
|
+
fonts: {
|
|
613
|
+
sans: {
|
|
614
|
+
family: ['Inter', 'system-ui', 'sans-serif'],
|
|
615
|
+
files: [
|
|
616
|
+
{ data: interRegular, weight: 400 },
|
|
617
|
+
{ data: interBold, weight: 700 },
|
|
618
|
+
],
|
|
619
|
+
},
|
|
620
|
+
mono: {
|
|
621
|
+
family: ['JetBrains Mono', 'monospace'],
|
|
622
|
+
files: [
|
|
623
|
+
{ data: jetbrainsMono, weight: 400 },
|
|
624
|
+
],
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
// Design tokens
|
|
629
|
+
tokens: {
|
|
630
|
+
borderRadius: {
|
|
631
|
+
sm: '0.25rem',
|
|
632
|
+
md: '0.375rem',
|
|
633
|
+
lg: '0.5rem',
|
|
634
|
+
xl: '0.75rem',
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
// Defaults
|
|
639
|
+
defaults: {
|
|
640
|
+
width: 1200,
|
|
641
|
+
height: 630,
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Now custom colors work in tw()
|
|
646
|
+
const OGImage = ({ title, tw }) => (
|
|
647
|
+
<div style={tw("w-full h-full bg-primary flex items-center justify-center")}>
|
|
648
|
+
<h1 style={tw("text-primary-foreground text-6xl")}>{title}</h1>
|
|
649
|
+
<p style={tw("text-muted-foreground text-2xl")}>Powered by loopwind</p>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
**CLI vs SDK fonts:**
|
|
655
|
+
|
|
656
|
+
| | CLI (`loopwind.json`) | SDK (`createRenderer`) |
|
|
657
|
+
|---|---|---|
|
|
658
|
+
| Simple | `"sans": ["Inter", "sans-serif"]` | Same |
|
|
659
|
+
| With files | `{ "path": "./fonts/Inter.woff", "weight": 400 }` | `{ data: fontBuffer, weight: 400 }` |
|
|
660
|
+
|
|
661
|
+
CLI uses file paths, SDK uses binary data (ArrayBuffer). Colors and tokens are identical.
|
|
662
|
+
|
|
663
|
+
---
|
|
664
|
+
|
|
665
|
+
## The `tw()` Function
|
|
666
|
+
|
|
667
|
+
Templates receive `tw` as a prop. It converts Tailwind classes to inline styles:
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
// tw() returns a style object
|
|
671
|
+
const MyTemplate = ({ tw }) => (
|
|
672
|
+
<div style={tw("flex items-center justify-center p-4 bg-blue-500 rounded-lg")}>
|
|
673
|
+
Hello
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// Equivalent to:
|
|
678
|
+
const MyTemplate = () => (
|
|
679
|
+
<div style={{
|
|
680
|
+
display: 'flex',
|
|
681
|
+
alignItems: 'center',
|
|
682
|
+
justifyContent: 'center',
|
|
683
|
+
padding: 16,
|
|
684
|
+
backgroundColor: '#3b82f6',
|
|
685
|
+
borderRadius: 8,
|
|
686
|
+
}}>
|
|
687
|
+
Hello
|
|
688
|
+
</div>
|
|
689
|
+
);
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
**Combining with custom styles:**
|
|
693
|
+
|
|
694
|
+
```typescript
|
|
695
|
+
const AnimatedBox = ({ opacity, tw }) => (
|
|
696
|
+
<div
|
|
697
|
+
style={{
|
|
698
|
+
...tw("w-32 h-32 bg-blue-500 rounded-lg"),
|
|
699
|
+
opacity, // Custom style merged in
|
|
700
|
+
}}
|
|
701
|
+
>
|
|
702
|
+
Hello
|
|
703
|
+
</div>
|
|
704
|
+
);
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
**Dynamic classes:**
|
|
708
|
+
|
|
709
|
+
```typescript
|
|
710
|
+
const Button = ({ primary, tw }) => (
|
|
711
|
+
<div style={tw(`
|
|
712
|
+
px-6 py-3 rounded-lg font-bold
|
|
713
|
+
${primary ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800'}
|
|
714
|
+
`)}>
|
|
715
|
+
Click me
|
|
716
|
+
</div>
|
|
717
|
+
);
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
## Installation
|
|
723
|
+
|
|
724
|
+
```bash
|
|
725
|
+
npm install loopwind
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
### Requirements
|
|
729
|
+
|
|
730
|
+
- Node.js 18+
|
|
731
|
+
- For video: FFmpeg (optional, for faster encoding)
|
|
732
|
+
|
|
733
|
+
### Bundle Size
|
|
734
|
+
|
|
735
|
+
| Import | Size | Use Case |
|
|
736
|
+
|--------|------|----------|
|
|
737
|
+
| `loopwind` (full) | ~2MB | All formats including video |
|
|
738
|
+
| `loopwind/image` | ~500KB | PNG, SVG, JPG, WebP only |
|
|
739
|
+
| `loopwind/svg` | ~150KB | SVG only (no rasterization) |
|
|
740
|
+
|
|
741
|
+
---
|
|
742
|
+
|
|
743
|
+
## Comparison with Satori
|
|
744
|
+
|
|
745
|
+
Loopwind wraps Satori with additional features:
|
|
746
|
+
|
|
747
|
+
| Feature | Satori | Loopwind |
|
|
748
|
+
|---------|--------|----------|
|
|
749
|
+
| JSX → SVG | ✅ | ✅ |
|
|
750
|
+
| SVG → PNG | ❌ | ✅ (via resvg) |
|
|
751
|
+
| Tailwind via tw() | ❌ | ✅ |
|
|
752
|
+
| Video rendering | ❌ | ✅ (mp4, gif) |
|
|
753
|
+
| Animation | ❌ | ✅ (frame prop) |
|
|
754
|
+
| Font loading | Manual | Built-in helpers |
|
|
755
|
+
|
|
756
|
+
---
|
|
757
|
+
|
|
758
|
+
## Edge Runtime Support
|
|
759
|
+
|
|
760
|
+
For edge runtimes (Cloudflare Workers, Vercel Edge), use the edge-compatible build:
|
|
761
|
+
|
|
762
|
+
```typescript
|
|
763
|
+
import { render } from 'loopwind/edge';
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
**Limitations in edge:**
|
|
767
|
+
- PNG/WebP/JPG work (via WASM)
|
|
768
|
+
- Video (mp4/gif) requires Node.js runtime
|
|
769
|
+
- Bundle size ~2MB (may exceed free tier limits)
|
|
770
|
+
|
|
771
|
+
---
|
|
772
|
+
|
|
773
|
+
## SDK vs CLI vs Platform
|
|
774
|
+
|
|
775
|
+
| | SDK | CLI | Platform |
|
|
776
|
+
|---|---|---|---|
|
|
777
|
+
| **What** | npm library | Command-line tool | Hosted API service |
|
|
778
|
+
| **Install** | `npm install loopwind` | `npm install -g loopwind` | Sign up at loopwind.dev |
|
|
779
|
+
| **Use case** | Self-hosted rendering | Local development | URL-based rendering |
|
|
780
|
+
| **Templates** | Pass as functions | `.loopwind/` folder | Publish from CLI |
|
|
781
|
+
| **Config** | `createRenderer({...})` | `loopwind.json` | Stored with templates |
|
|
782
|
+
| **Rendering** | Your server | Your machine | loopwind.dev servers |
|
|
783
|
+
|
|
784
|
+
**When to use what:**
|
|
785
|
+
|
|
786
|
+
- **SDK**: You want to render images/videos in your own app (Next.js API route, Express server, etc.)
|
|
787
|
+
- **CLI**: You're authoring templates locally, testing, or rendering one-off images/videos
|
|
788
|
+
- **Platform**: You want simple render URLs (`?title=Hello`) without running your own server
|
|
789
|
+
|
|
790
|
+
**Platform uses SDK internally.** When you publish a template and call the render URL, the platform uses the SDK to do the actual rendering.
|
|
791
|
+
|
|
792
|
+
---
|
|
793
|
+
|
|
794
|
+
## Related
|
|
795
|
+
|
|
796
|
+
- [PLATFORM.md](./PLATFORM.md) - Hosted render API (publish templates, get render URLs)
|
|
797
|
+
- [loopwind CLI](../README.md) - CLI tool for local development
|