loopwind 0.25.6 → 0.25.8

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.
Files changed (90) hide show
  1. package/app/.astro/types.d.ts +1 -0
  2. package/app/dist/_astro/callback.Ci5gaEfJ.css +1 -0
  3. package/app/dist/auth/callback/index.html +81 -0
  4. package/app/dist/device/index.html +70 -0
  5. package/app/dist/index.html +327 -0
  6. package/app/package-lock.json +9239 -0
  7. package/app/package.json +23 -0
  8. package/app/wrangler.toml +8 -0
  9. package/dist/cli.js +54 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/login.d.ts +5 -0
  12. package/dist/commands/login.d.ts.map +1 -0
  13. package/dist/commands/login.js +60 -0
  14. package/dist/commands/login.js.map +1 -0
  15. package/dist/commands/logout.d.ts +5 -0
  16. package/dist/commands/logout.d.ts.map +1 -0
  17. package/dist/commands/logout.js +15 -0
  18. package/dist/commands/logout.js.map +1 -0
  19. package/dist/commands/publish.d.ts +10 -0
  20. package/dist/commands/publish.d.ts.map +1 -0
  21. package/dist/commands/publish.js +155 -0
  22. package/dist/commands/publish.js.map +1 -0
  23. package/dist/commands/templates.d.ts +5 -0
  24. package/dist/commands/templates.d.ts.map +1 -0
  25. package/dist/commands/templates.js +60 -0
  26. package/dist/commands/templates.js.map +1 -0
  27. package/dist/commands/unpublish.d.ts +5 -0
  28. package/dist/commands/unpublish.d.ts.map +1 -0
  29. package/dist/commands/unpublish.js +54 -0
  30. package/dist/commands/unpublish.js.map +1 -0
  31. package/dist/commands/whoami.d.ts +5 -0
  32. package/dist/commands/whoami.d.ts.map +1 -0
  33. package/dist/commands/whoami.js +30 -0
  34. package/dist/commands/whoami.js.map +1 -0
  35. package/dist/lib/api.d.ts +92 -0
  36. package/dist/lib/api.d.ts.map +1 -0
  37. package/dist/lib/api.js +149 -0
  38. package/dist/lib/api.js.map +1 -0
  39. package/dist/lib/auth.d.ts +41 -0
  40. package/dist/lib/auth.d.ts.map +1 -0
  41. package/dist/lib/auth.js +89 -0
  42. package/dist/lib/auth.js.map +1 -0
  43. package/dist/lib/bundler.d.ts +18 -0
  44. package/dist/lib/bundler.d.ts.map +1 -0
  45. package/dist/lib/bundler.js +105 -0
  46. package/dist/lib/bundler.js.map +1 -0
  47. package/dist/lib/helpers.d.ts +35 -2
  48. package/dist/lib/helpers.d.ts.map +1 -1
  49. package/dist/lib/helpers.js +91 -13
  50. package/dist/lib/helpers.js.map +1 -1
  51. package/dist/lib/utils.d.ts.map +1 -1
  52. package/dist/lib/utils.js +9 -0
  53. package/dist/lib/utils.js.map +1 -1
  54. package/dist/sdk/edge.d.ts +65 -0
  55. package/dist/sdk/edge.d.ts.map +1 -0
  56. package/dist/sdk/edge.js +359 -0
  57. package/dist/sdk/edge.js.map +1 -0
  58. package/dist/sdk/errors.d.ts +64 -0
  59. package/dist/sdk/errors.d.ts.map +1 -0
  60. package/dist/sdk/errors.js +94 -0
  61. package/dist/sdk/errors.js.map +1 -0
  62. package/dist/sdk/index.d.ts +29 -0
  63. package/dist/sdk/index.d.ts.map +1 -0
  64. package/dist/sdk/index.js +30 -0
  65. package/dist/sdk/index.js.map +1 -0
  66. package/dist/sdk/render.d.ts +52 -0
  67. package/dist/sdk/render.d.ts.map +1 -0
  68. package/dist/sdk/render.js +432 -0
  69. package/dist/sdk/render.js.map +1 -0
  70. package/dist/sdk/types.d.ts +185 -0
  71. package/dist/sdk/types.d.ts.map +1 -0
  72. package/dist/sdk/types.js +5 -0
  73. package/dist/sdk/types.js.map +1 -0
  74. package/dist/types/template.d.ts +18 -0
  75. package/dist/types/template.d.ts.map +1 -1
  76. package/package.json +27 -4
  77. package/plans/PLATFORM.md +1637 -237
  78. package/plans/PLATFORM_IMPLEMENTATION.md +1347 -530
  79. package/plans/SDK.md +797 -0
  80. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite +0 -0
  81. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-shm +0 -0
  82. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-wal +0 -0
  83. package/platform/migrations/0001_initial.sql +90 -0
  84. package/platform/package-lock.json +3253 -0
  85. package/platform/package.json +30 -0
  86. package/platform/wrangler.toml +43 -0
  87. package/tests-sdk/createRenderer.test.ts +251 -0
  88. package/tests-sdk/errors.test.ts +230 -0
  89. package/tests-sdk/render.test.ts +241 -0
  90. 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