diff --git a/.gitignore b/.gitignore index 9cbb0b5..4fd7f31 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ coverage !.env.example .og-cache .temp + +# generated previews (binary, excluded from PR tooling) +templates/*/preview.png diff --git a/apps/server-node/src/index.ts b/apps/server-node/src/index.ts index 3384718..5929c5e 100644 --- a/apps/server-node/src/index.ts +++ b/apps/server-node/src/index.ts @@ -1,7 +1,7 @@ -import express, { type Request, type Response } from 'express'; import { nodeAdapter } from '@og-engine/adapter-node'; import { createHandler } from '@og-engine/core'; -import type { OGRequest, MetaRequest } from '@og-engine/types'; +import type { MetaRequest, OGRequest, PlatformSize } from '@og-engine/types'; +import express, { type Request, type Response } from 'express'; const app = express(); const port = process.env.PORT ?? 3000; @@ -12,6 +12,33 @@ const adapter = nodeAdapter({ }); const handler = createHandler({ platform: adapter }); +const PLATFORM_SIZES: ReadonlyArray = [ + 'twitter-og', + 'facebook-og', + 'linkedin-og', + 'ig-post', + 'ig-story', + 'discord', + 'whatsapp', + 'github', + 'og' +]; + +function parseSize(value: unknown): PlatformSize { + if (typeof value === 'string' && PLATFORM_SIZES.includes(value as PlatformSize)) { + return value as PlatformSize; + } + + return 'og'; +} + +function parseFormat(value: unknown): OGRequest['format'] { + if (value === 'jpeg' || value === 'png') { + return value; + } + + return 'png'; +} app.get('/health', (_request: Request, response: Response) => { response.json({ ok: true }); @@ -19,16 +46,16 @@ app.get('/health', (_request: Request, response: Response) => { app.get('/api/og', async (req: Request, res: Response) => { try { - const { template, size = 'og', format = 'png', ...params } = req.query; + const { template, size, format, ...params } = req.query; - if (!template) { + if (!template || typeof template !== 'string') { return res.status(400).send('Missing template parameter'); } const ogReq: OGRequest = { - template: template as string, - size: size as any, - format: format as any, + template, + size: parseSize(size), + format: parseFormat(format), params: params as Record }; @@ -40,22 +67,21 @@ app.get('/api/og', async (req: Request, res: Response) => { }); res.send(buffer); } catch (error) { - console.error('Render error:', error); res.status(500).send(error instanceof Error ? error.message : 'Internal Server Error'); } }); app.get('/api/meta', async (req: Request, res: Response) => { try { - const { template, size = 'og', ...params } = req.query; + const { template, size, ...params } = req.query; - if (!template) { + if (!template || typeof template !== 'string') { return res.status(400).send('Missing template parameter'); } const metaReq: MetaRequest = { - template: template as string, - size: size as any, + template, + size: parseSize(size), params: params as Record, baseUrl: `${req.protocol}://${req.get('host')}` }; @@ -63,11 +89,8 @@ app.get('/api/meta', async (req: Request, res: Response) => { const meta = await handler.handleMetaRequest(metaReq); res.json(meta); } catch (error) { - console.error('Meta error:', error); res.status(500).send(error instanceof Error ? error.message : 'Internal Server Error'); } }); -app.listen(port, () => { - console.log(`Server listening on port ${port}`); -}); +app.listen(port); diff --git a/apps/web/src/app/api/og/route.ts b/apps/web/src/app/api/og/route.ts index e867e32..3e7fb0c 100644 --- a/apps/web/src/app/api/og/route.ts +++ b/apps/web/src/app/api/og/route.ts @@ -1,13 +1,42 @@ import { NextRequest } from 'next/server'; import { nodeAdapter } from '@og-engine/adapter-node'; import { createHandler } from '@og-engine/core'; -import type { OGRequest } from '@og-engine/types'; +import type { OGRequest, PlatformSize } from '@og-engine/types'; + + +const PLATFORM_SIZES: ReadonlyArray = [ + 'twitter-og', + 'facebook-og', + 'linkedin-og', + 'ig-post', + 'ig-story', + 'discord', + 'whatsapp', + 'github', + 'og' +]; + +function parseSize(value: string | null): PlatformSize { + if (value && PLATFORM_SIZES.includes(value as PlatformSize)) { + return value as PlatformSize; + } + + return 'og'; +} + +function parseFormat(value: string | null): OGRequest['format'] { + if (value === 'jpeg' || value === 'png') { + return value; + } + + return 'png'; +} export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const template = searchParams.get('template'); - const size = searchParams.get('size') || 'og'; - const format = searchParams.get('format') || 'png'; + const size = searchParams.get('size'); + const format = searchParams.get('format'); if (!template) { return new Response('Missing template', { status: 400 }); @@ -30,23 +59,22 @@ export async function GET(req: NextRequest) { const ogReq: OGRequest = { template, - size: size as any, - format: format as any, + size: parseSize(size), + format: parseFormat(format), params }; try { const { buffer, contentType, headers } = await handler.handleImageRequest(ogReq); - return new Response(buffer as any, { + return new Response(buffer as unknown as BodyInit, { status: 200, headers: { 'Content-Type': contentType, ...headers } }); - } catch (err) { - console.error('API Error details:', err); - return new Response(err instanceof Error ? err.message : 'Internal error', { status: 500 }); + } catch (error) { + return new Response(error instanceof Error ? error.message : 'Internal error', { status: 500 }); } } diff --git a/apps/web/src/app/api/templates/[id]/preview/route.ts b/apps/web/src/app/api/templates/[id]/preview/route.ts index c50a7d3..d57376a 100644 --- a/apps/web/src/app/api/templates/[id]/preview/route.ts +++ b/apps/web/src/app/api/templates/[id]/preview/route.ts @@ -1,32 +1,32 @@ -import { NextRequest, NextResponse } from 'next/server'; import fs from 'node:fs'; import path from 'node:path'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET( - request: NextRequest, - { params }: { params: { id: string } } + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } ) { - const { id } = params; - const previewPath = path.join(process.cwd(), '../../templates', id, 'preview.png'); - const fallbackPath = path.join(process.cwd(), '../../templates', id, 'preview.svg'); - - try { - if (fs.existsSync(previewPath)) { - const buffer = fs.readFileSync(previewPath); - return new NextResponse(buffer, { - headers: { 'Content-Type': 'image/png' }, - }); - } + const { id } = await params; + const previewPath = path.join(process.cwd(), '../../templates', id, 'preview.png'); + const fallbackPath = path.join(process.cwd(), '../../templates', id, 'preview.svg'); - if (fs.existsSync(fallbackPath)) { - const buffer = fs.readFileSync(fallbackPath); - return new NextResponse(buffer, { - headers: { 'Content-Type': 'image/svg+xml' }, - }); - } + try { + if (fs.existsSync(previewPath)) { + const buffer = fs.readFileSync(previewPath); + return new NextResponse(buffer, { + headers: { 'Content-Type': 'image/png' } + }); + } - return new NextResponse('Not Found', { status: 404 }); - } catch (err) { - return new NextResponse('Internal Error', { status: 500 }); + if (fs.existsSync(fallbackPath)) { + const buffer = fs.readFileSync(fallbackPath); + return new NextResponse(buffer, { + headers: { 'Content-Type': 'image/svg+xml' } + }); } + + return new NextResponse('Not Found', { status: 404 }); + } catch { + return new NextResponse('Internal Error', { status: 500 }); + } } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index d516af3..9326246 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,29 +1,38 @@ :root { - --background: #000000; - --foreground: #ffffff; - --muted: #888888; - --muted-foreground: #a1a1aa; - --accent: #3b82f6; - --accent-foreground: #ffffff; - --border: #27272a; - --input: #27272a; - --ring: #3b82f6; - --radius: 0.75rem; + --bg-base: #0c0c0e; + --bg-surface: #141416; + --bg-elevated: #1c1c1f; + --bg-overlay: #242428; + --border-subtle: #242428; + --border-default: #2e2e33; + --border-strong: #3d3d44; + --text-primary: #f0eff2; + --text-secondary: #8b8a94; + --text-tertiary: #55545e; + --accent: #e8a020; + --accent-dim: rgba(232, 160, 32, 0.12); + --accent-border: rgba(232, 160, 32, 0.25); + --success: #22c55e; + --error: #ef4444; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6); } * { box-sizing: border-box; - padding: 0; margin: 0; + padding: 0; } html, body { - max-width: 100vw; - overflow-x: hidden; - background-color: var(--background); - color: var(--foreground); - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + background: var(--bg-base); + color: var(--text-primary); + font-family: var(--font-body), sans-serif; } a { @@ -31,78 +40,88 @@ a { text-decoration: none; } -@media (prefers-color-scheme: dark) { - :root { - --background: #000000; - --foreground: #ffffff; - } +.top-nav { + position: sticky; + top: 0; + z-index: 30; + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + border-bottom: 1px solid var(--border-subtle); + background: rgba(12, 12, 14, 0.8); + backdrop-filter: blur(12px); } -.glass { - background: rgba(255, 255, 255, 0.03); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: var(--radius); +.nav-left, +.nav-center, +.nav-right { + display: flex; + align-items: center; + gap: 14px; } -.gradient-text { - background: linear-gradient(135deg, #60a5fa 0%, #a855f7 100%); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; -} - -.accent { +.logo-mark { + width: 24px; + height: 24px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + background: var(--accent-dim); + border: 1px solid var(--accent-border); color: var(--accent); + font-family: var(--font-mono), monospace; } -.docs-content pre { - color: var(--muted-foreground); +.wordmark { + font-family: var(--font-display), sans-serif; + font-weight: 700; + font-size: 14px; } -.docs-content h1 { - font-size: 2.5rem; - font-weight: 800; - margin-bottom: 2rem; - color: white; +.nav-center a { + color: var(--text-secondary); + font-size: 13px; } -.docs-content h2 { - font-size: 1.75rem; - font-weight: 700; - margin: 2.5rem 0 1rem; - color: white; +.nav-center a:hover { + color: var(--text-primary); } -.docs-content h3 { - font-size: 1.25rem; - font-weight: 600; - margin: 2rem 0 1rem; - color: #60a5fa; +.ghost-btn, +.cta-btn { + border-radius: var(--radius-md); + font-size: 12px; + padding: 6px 10px; + border: 1px solid var(--border-default); } -.docs-content code { - background: rgba(255, 255, 255, 0.1); - padding: 0.2rem 0.4rem; - border-radius: 0.25rem; - font-size: 0.9em; +.ghost-btn { + color: var(--text-secondary); + background: transparent; } -.docs-content table { - width: 100%; - border-collapse: collapse; - margin: 2rem 0; +.cta-btn { + color: var(--accent); + border-color: var(--accent-border); + background: var(--accent-dim); } -.docs-content th { - text-align: left; - padding: 0.75rem; - border-bottom: 2px solid var(--border); - opacity: 0.8; +.site-footer { + margin-top: 28px; + border-top: 1px solid var(--border-subtle); + padding: 16px 24px; + color: var(--text-secondary); + display: flex; + justify-content: space-between; + font-size: 12px; } -.docs-content td { - padding: 0.75rem; - border-bottom: 1px solid var(--border); -} \ No newline at end of file +.site-footer div { + display: flex; + gap: 12px; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1b5a305..df786d1 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,15 +1,67 @@ import type { Metadata } from 'next'; +import { Bricolage_Grotesque, Geist, Geist_Mono } from 'next/font/google'; +import Link from 'next/link'; import './globals.css'; +const fontDisplay = Bricolage_Grotesque({ + subsets: ['latin'], + variable: '--font-display', + weight: ['400', '500', '600', '700', '800'] +}); + +const fontBody = Geist({ + subsets: ['latin'], + variable: '--font-body' +}); + +const fontMono = Geist_Mono({ + subsets: ['latin'], + variable: '--font-mono' +}); + export const metadata: Metadata = { - title: 'og-engine | Social Image Generation API', - description: 'Open-source, platform-agnostic social image generation engine.' + title: 'og-engine | Social Image Generation API', + description: 'Open-source, platform-agnostic social image generation engine.' }; export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return ( + + +
+
+
OG
+ og-engine +
+ + +
+ {children} + + + + ); } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 4431ddb..893e414 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,85 +1,103 @@ import Link from 'next/link'; -import { Zap, Shield, Share2, Github } from 'lucide-react'; export default function Home() { - return ( -
- {/* Hero Section */} -
-

- og-engine -

-

- Open-source, platform-agnostic social image generation engine. - Zero lock-in, fully extensible, and secure by default. -

-
- - - - - GitHub - -
-
- - {/* Features */} -
-
- } - title="Fast" - description="Ultra-fast rendering using Satori and Resvg WASM. Perfect for Edge Runtimes." - /> - } - title="Secure" - description="Sandboxed template execution ensures no malicious code can access your infrastructure." - /> - } - title="Agnostic" - description="Deploy anywhere: Cloudflare Workers, Vercel, Node.js, or Docker." - /> -
-
- - {/* Footer */} -
-

© 2024 og-engine. Built with Satori & Resvg.

-
-
- ); -} + return ( +
+
+
+
+ Open source · MIT · Self-hostable +
+

+ Social images that look handcrafted. +

+

+ Build, preview, and deploy OG image templates with a fast typed engine and adapters for Node, Cloudflare, + and Vercel. +

+
+ + Open Editor + + + Browse Templates + +
+
-function FeatureCard({ icon, title, description }: { icon: React.ReactNode, title: string, description: string }) { - return ( -
-
{icon}
-

{title}

-

{description}

+
+
+ Live OG preview +
+
+ Sunset template preview +
+ GET /api/og?template=sunset&title=Launch%20Post +
+
- ); +
+
+ ); } diff --git a/apps/web/src/app/preview/page.tsx b/apps/web/src/app/preview/page.tsx index a220bc4..1a77cab 100644 --- a/apps/web/src/app/preview/page.tsx +++ b/apps/web/src/app/preview/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; -import { Settings, Image as ImageIcon, Copy, ExternalLink, RefreshCw } from 'lucide-react'; +import { Settings, Copy, ExternalLink, RefreshCw } from 'lucide-react'; const TEMPLATES = [ { id: 'sunset', name: 'Sunset', params: ['title', 'subtitle', 'author', 'date'] }, diff --git a/apps/worker-cf/src/index.ts b/apps/worker-cf/src/index.ts index b0923be..a589541 100644 --- a/apps/worker-cf/src/index.ts +++ b/apps/worker-cf/src/index.ts @@ -1,76 +1,100 @@ -import { Hono } from 'hono'; import { cloudflareAdapter } from '@og-engine/adapter-cloudflare'; import { createHandler } from '@og-engine/core'; -import type { OGRequest, MetaRequest } from '@og-engine/types'; +import type { MetaRequest, OGRequest, PlatformSize } from '@og-engine/types'; +import { Hono } from 'hono'; type Bindings = { - KV: KVNamespace; - BUCKET: R2Bucket; - PUBLIC_BASE_URL: string; + KV: KVNamespace; + BUCKET: R2Bucket; + PUBLIC_BASE_URL: string; }; const app = new Hono<{ Bindings: Bindings }>(); +const PLATFORM_SIZES: ReadonlyArray = [ + 'twitter-og', + 'facebook-og', + 'linkedin-og', + 'ig-post', + 'ig-story', + 'discord', + 'whatsapp', + 'github', + 'og' +]; + +function parseSize(value: string | undefined): PlatformSize { + if (value && PLATFORM_SIZES.includes(value as PlatformSize)) { + return value as PlatformSize; + } + + return 'og'; +} + +function parseFormat(value: string | undefined): OGRequest['format'] { + if (value === 'jpeg' || value === 'png') { + return value; + } + + return 'png'; +} app.get('/health', (context) => context.json({ ok: true })); app.get('/api/og', async (context) => { - const adapter = cloudflareAdapter({ - KV: context.env.KV, - BUCKET: context.env.BUCKET, - PUBLIC_BASE_URL: context.env.PUBLIC_BASE_URL - }); - - const handler = createHandler({ platform: adapter }); - - const { template, size = 'og', format = 'png', ...params } = context.req.query(); - - if (!template) { - return context.text('Missing template parameter', 400); + const adapter = cloudflareAdapter({ + KV: context.env.KV, + BUCKET: context.env.BUCKET, + PUBLIC_BASE_URL: context.env.PUBLIC_BASE_URL + }); + + const handler = createHandler({ platform: adapter }); + const { template, size, format, ...params } = context.req.query(); + + if (!template) { + return context.text('Missing template parameter', 400); + } + + const ogReq: OGRequest = { + template, + size: parseSize(size), + format: parseFormat(format), + params: params as Record + }; + + const { buffer, contentType, headers } = await handler.handleImageRequest(ogReq); + + return new Response(buffer, { + status: 200, + headers: { + 'Content-Type': contentType, + ...headers } - - const ogReq: OGRequest = { - template, - size: size as any, - format: format as any, - params: params as Record - }; - - const { buffer, contentType, headers } = await handler.handleImageRequest(ogReq); - - return context.body(buffer as any, { - status: 200, - headers: { - 'Content-Type': contentType, - ...headers - } - }); + }); }); app.get('/api/meta', async (context) => { - const adapter = cloudflareAdapter({ - KV: context.env.KV, - BUCKET: context.env.BUCKET, - PUBLIC_BASE_URL: context.env.PUBLIC_BASE_URL - }); - - const handler = createHandler({ platform: adapter }); - - const { template, size = 'og', ...params } = context.req.query(); - - if (!template) { - return context.text('Missing template parameter', 400); - } - - const metaReq: MetaRequest = { - template, - size: size as any, - params: params as Record, - baseUrl: new URL(context.req.url).origin - }; - - const meta = await handler.handleMetaRequest(metaReq); - - return context.json(meta); + const adapter = cloudflareAdapter({ + KV: context.env.KV, + BUCKET: context.env.BUCKET, + PUBLIC_BASE_URL: context.env.PUBLIC_BASE_URL + }); + + const handler = createHandler({ platform: adapter }); + const { template, size, ...params } = context.req.query(); + + if (!template) { + return context.text('Missing template parameter', 400); + } + + const metaReq: MetaRequest = { + template, + size: parseSize(size), + params: params as Record, + baseUrl: new URL(context.req.url).origin + }; + + const meta = await handler.handleMetaRequest(metaReq); + return context.json(meta); }); export default app; diff --git a/packages/sandbox/package.json b/internal/sandbox/package.json similarity index 84% rename from packages/sandbox/package.json rename to internal/sandbox/package.json index 559b7fa..29a03e2 100644 --- a/packages/sandbox/package.json +++ b/internal/sandbox/package.json @@ -1,5 +1,5 @@ { - "name": "@og-engine/sandbox", + "name": "@og-engine/internal-sandbox", "version": "0.1.0", "type": "module", "main": "dist/index.js", @@ -12,5 +12,6 @@ }, "dependencies": { "@og-engine/types": "workspace:*" - } + }, + "private": true } diff --git a/packages/sandbox/src/index.ts b/internal/sandbox/src/index.ts similarity index 100% rename from packages/sandbox/src/index.ts rename to internal/sandbox/src/index.ts diff --git a/packages/sandbox/tsconfig.json b/internal/sandbox/tsconfig.json similarity index 100% rename from packages/sandbox/tsconfig.json rename to internal/sandbox/tsconfig.json diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..65ad1b7 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,47 @@ +# @og-engine/core + +Core rendering and request handling primitives for `og-engine`. This package converts typed template inputs into rendered OG images, generates metadata payloads, and coordinates storage/cache behavior via adapter interfaces. + +## Installation + +```bash +pnpm add @og-engine/core +``` + +## Basic usage + +```ts +import { createHandler } from '@og-engine/core'; +import type { PlatformAdapter } from '@og-engine/types'; + +const platform: PlatformAdapter = { + storage: /* your storage adapter */, + cache: /* your cache adapter */, + registry: /* your template registry */ +}; + +const handler = createHandler({ platform }); +const image = await handler.handleImageRequest({ + template: 'sunset', + size: 'og', + params: { title: 'Hello' } +}); +``` + +## API reference + +- `createHandler(deps)`: creates image/meta handlers. +- `render(req, template, fonts)`: runs the render pipeline. +- `coerceParams(raw, schema)`: validates/coerces raw params. +- `buildCacheKey(req, templateVersion)`: deterministic cache key generator. +- `getDefaultFonts()`, `loadFont(name, url)`: font loading helpers. +- `initWasm()`, `getResvg()`: runtime WASM/bootstrap helpers. + +## Used by + +- `@og-engine/adapter-node` +- `@og-engine/adapter-cloudflare` +- `@og-engine/adapter-vercel` +- `apps/web` +- `apps/worker-cf` +- `apps/server-node` diff --git a/packages/core/package.json b/packages/core/package.json index 8c2c37e..07eae51 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,7 +11,7 @@ "test": "node -e \"console.log('no tests')\"" }, "dependencies": { - "@og-engine/sandbox": "workspace:*", + "@og-engine/internal-sandbox": "workspace:*", "@og-engine/types": "workspace:*", "@resvg/resvg-js": "^2.6.2", "@resvg/resvg-wasm": "^2.6.2", diff --git a/packages/core/src/cache-key.ts b/packages/core/src/cache-key.ts index 52a8cb6..7924237 100644 --- a/packages/core/src/cache-key.ts +++ b/packages/core/src/cache-key.ts @@ -1,5 +1,18 @@ +/** + * @file cache-key.ts + * @description Deterministic cache key generation for OG image requests. + * @module @og-engine/core + */ + import type { OGRequest } from '@og-engine/types'; +/** + * Builds a deterministic SHA-256 cache key from normalized request fields. + * + * @param req - OG image request payload. + * @param templateVersion - Template version used for automatic cache busting. + * @returns Lowercase 64-character SHA-256 hex digest. + */ export async function buildCacheKey(req: OGRequest, templateVersion: string): Promise { const sorted = Object.entries({ ...req.params, diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index a54f8e4..af8b06c 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -1,36 +1,221 @@ +/** + * @file fonts.ts + * @description Runtime font loading utilities for Satori rendering. + * @module @og-engine/core + */ + +/** + * Font definition passed into Satori. + */ export interface FontConfig { + /** Font family name. */ name: string; + + /** Raw font binary data. */ data: ArrayBuffer; + + /** Optional font weight. */ weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + + /** Optional font style. */ style?: 'normal' | 'italic'; } -const fontCache = new Map(); +const fontBinaryCache = new Map(); +const cssCache = new Map(); -export async function loadFont(name: string, url: string): Promise { - if (!fontCache.has(url)) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Unable to load font from ${url}`); + +const fallbackFontUrls: Record = { + 'Playfair Display|400|normal': + 'https://raw.githubusercontent.com/google/fonts/main/ofl/playfairdisplay/PlayfairDisplay-Regular.ttf', + 'Playfair Display|700|normal': + 'https://raw.githubusercontent.com/google/fonts/main/ofl/playfairdisplay/PlayfairDisplay-Bold.ttf', + 'Playfair Display|900|normal': + 'https://raw.githubusercontent.com/google/fonts/main/ofl/playfairdisplay/PlayfairDisplay-Black.ttf', + 'Instrument Serif|400|italic': + 'https://raw.githubusercontent.com/google/fonts/main/ofl/instrumentserif/InstrumentSerif-Italic.ttf', + 'JetBrains Mono|400|normal': + 'https://raw.githubusercontent.com/JetBrains/JetBrainsMono/master/fonts/ttf/JetBrainsMono-Regular.ttf', + 'JetBrains Mono|700|normal': + 'https://raw.githubusercontent.com/JetBrains/JetBrainsMono/master/fonts/ttf/JetBrainsMono-Bold.ttf', + 'DM Sans|400|normal': + 'https://raw.githubusercontent.com/google/fonts/main/ofl/dmsans/DMSans-Regular.ttf', + 'DM Sans|500|normal': + 'https://raw.githubusercontent.com/google/fonts/main/ofl/dmsans/DMSans-Medium.ttf', + 'Noto Emoji|400|normal': + 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/fonts/NotoEmoji-VariableFont_wght.ttf' +}; + +function getFallbackKey(variant: GoogleFontVariant): string { + return `${variant.family}|${variant.weight}|${variant.style ?? 'normal'}`; +} + + + +const localFontFallbacks: Record = { + 'Playfair Display': '/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf', + 'Instrument Serif': '/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf', + 'JetBrains Mono': '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', + 'DM Sans': '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + 'Noto Emoji': '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' +}; + +async function loadLocalFallbackFont(family: string): Promise { + const path = localFontFallbacks[family]; + if (!path) { + return null; + } + + const isNodeRuntime = + typeof process !== 'undefined' && + typeof process.versions !== 'undefined' && + typeof process.versions.node === 'string'; + + if (!isNodeRuntime) { + return null; + } + + const { readFile } = await import('node:fs/promises'); + const file = await readFile(path); + const bytes = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength); + return bytes; +} + +interface GoogleFontVariant { + family: string; + weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + style?: 'normal' | 'italic'; +} + +function buildGoogleCssUrl(variant: GoogleFontVariant): string { + const family = variant.family.replace(/\s+/g, '+'); + const style = variant.style ?? 'normal'; + const ital = style === 'italic' ? 1 : 0; + return `https://fonts.googleapis.com/css2?family=${family}:ital,wght@${ital},${variant.weight}&display=swap`; +} + +async function loadCss(url: string): Promise { + const cached = cssCache.get(url); + if (cached) { + return cached; + } + + const response = await fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36' } - fontCache.set(url, await response.arrayBuffer()); + }); + + if (!response.ok) { + throw new Error(`Unable to load font CSS from ${url}`); + } + + const css = await response.text(); + cssCache.set(url, css); + return css; +} + +function extractFontUrl(css: string): string | null { + const match = css.match(/src:\s*url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/); + return match ? match[1] : null; +} + +async function loadFontBinary(url: string): Promise { + const cached = fontBinaryCache.get(url); + if (cached) { + return cached; } - const data = fontCache.get(url); - if (!data) { - throw new Error(`Font cache missing loaded value for ${url}`); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Unable to load font binary from ${url}`); } - return { name, data, weight: 400 }; + const data = await response.arrayBuffer(); + fontBinaryCache.set(url, data); + return data; } -export async function getDefaultFonts(): Promise { - const baseUrl = 'https://github.com/googlefonts/noto-fonts/raw/master/unhinted/ttf/NotoSans'; - const emojiUrl = 'https://github.com/googlefonts/noto-emoji/raw/main/fonts/NotoColorEmoji.ttf'; +async function loadGoogleFont(variant: GoogleFontVariant): Promise { + const cssUrl = buildGoogleCssUrl(variant); + + try { + const css = await loadCss(cssUrl); + const fontUrl = extractFontUrl(css); + + if (!fontUrl) { + throw new Error(`Google Fonts CSS does not contain a usable font URL: ${cssUrl}`); + } + const data = await loadFontBinary(fontUrl); + return { + name: variant.family, + data, + weight: variant.weight, + style: variant.style ?? 'normal' + }; + } catch { + const fallbackUrl = fallbackFontUrls[getFallbackKey(variant)]; + + if (fallbackUrl) { + try { + const data = await loadFontBinary(fallbackUrl); + return { + name: variant.family, + data, + weight: variant.weight, + style: variant.style ?? 'normal' + }; + } catch { + // continue to local fallback + } + } + + const localData = await loadLocalFallbackFont(variant.family); + if (!localData) { + throw new Error(`No fallback font source configured for ${getFallbackKey(variant)}`); + } + + return { + name: variant.family, + data: localData, + weight: variant.weight, + style: variant.style ?? 'normal' + }; + } +} + +/** + * Loads the default font stack used by bundled templates. + * + * The first cold load usually adds around 80-100ms due to Google Fonts CSS + * discovery and font binary download; subsequent loads are served from memory. + * + * @returns Default fonts for editorial, minimal, terminal, labels, and emoji. + */ +export async function getDefaultFonts(): Promise { return Promise.all([ - loadFont('Noto Sans', `${baseUrl}/NotoSans-Regular.ttf`), - loadFont('Noto Sans', `${baseUrl}/NotoSans-Bold.ttf`).then(f => ({ ...f, weight: 700 as const })), - loadFont('Noto Sans', `${baseUrl}/NotoSans-Black.ttf`).then(f => ({ ...f, weight: 900 as const })) + loadGoogleFont({ family: 'Playfair Display', weight: 400 }), + loadGoogleFont({ family: 'Playfair Display', weight: 700 }), + loadGoogleFont({ family: 'Playfair Display', weight: 900 }), + loadGoogleFont({ family: 'Instrument Serif', weight: 400, style: 'italic' }), + loadGoogleFont({ family: 'JetBrains Mono', weight: 400 }), + loadGoogleFont({ family: 'JetBrains Mono', weight: 700 }), + loadGoogleFont({ family: 'DM Sans', weight: 400 }), + loadGoogleFont({ family: 'DM Sans', weight: 500 }), + loadGoogleFont({ family: 'Noto Emoji', weight: 400 }) ]); } + +/** + * Backward-compatible font loader for direct URL usage. + * + * @param name - Font family name. + * @param url - Absolute font URL. + * @returns A Satori-compatible font config object. + */ +export async function loadFont(name: string, url: string): Promise { + const data = await loadFontBinary(url); + return { name, data, weight: 400, style: 'normal' }; +} diff --git a/packages/core/src/handler.ts b/packages/core/src/handler.ts index 46c62cc..e86f254 100644 --- a/packages/core/src/handler.ts +++ b/packages/core/src/handler.ts @@ -1,3 +1,9 @@ +/** + * @file handler.ts + * @description Request handlers for OG image rendering and metadata generation. + * @module @og-engine/core + */ + import { PLATFORM_SIZES, type MetaRequest, @@ -5,15 +11,34 @@ import { type OGRequest, type PlatformAdapter } from '@og-engine/types'; -import { getDefaultFonts, type FontConfig } from './fonts.js'; import { buildCacheKey } from './cache-key.js'; +import { getDefaultFonts, type FontConfig } from './fonts.js'; import { render } from './render.js'; +/** + * Dependency bundle for handler creation. + */ export interface HandlerDeps { + /** Platform adapter that provides registry, storage, and cache backends. */ platform: PlatformAdapter; + + /** Optional preloaded fonts. Defaults are loaded when omitted. */ fonts?: FontConfig[]; } +interface ImageHandlerResponse { + buffer: Buffer; + contentType: 'image/png' | 'image/jpeg'; + headers: Record; + fromCache: boolean; +} + +interface OGHandler { + handleImageRequest(req: OGRequest): Promise; + handleMetaRequest(req: MetaRequest): Promise; + preGenerate(req: OGRequest): Promise; +} + function buildOGImageUrl(req: MetaRequest): string { const params = new URLSearchParams({ template: req.template, @@ -31,9 +56,15 @@ function getCacheHeaders(): Record { }; } -export function createHandler(deps: HandlerDeps) { - return { - async handleImageRequest(req: OGRequest) { +/** + * Creates image and metadata handlers for a given platform adapter. + * + * @param deps - Platform dependencies. + * @returns Handler object with image, metadata, and pre-generation methods. + */ +export function createHandler(deps: HandlerDeps): OGHandler { + const handler: OGHandler = { + async handleImageRequest(req: OGRequest): Promise { const template = await deps.platform.registry.get(req.template); const cacheKey = await buildCacheKey(req, template.version); const cachedUrl = await deps.platform.cache.get(cacheKey); @@ -71,17 +102,18 @@ export function createHandler(deps: HandlerDeps) { 'twitter:image': imageUrl }; - void this.preGenerate({ ...req, format: 'png' }); - + void handler.preGenerate({ ...req, format: 'png' }); return meta; }, async preGenerate(req: OGRequest): Promise { try { - await this.handleImageRequest(req); + await handler.handleImageRequest(req); } catch { - // no-op + // best-effort background pre-generation } } }; + + return handler; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc71a0c..7109b1f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,15 @@ -export * from './cache-key.js'; -export * from './fonts.js'; -export * from './handler.js'; -export * from './params.js'; -export * from './render.js'; -export * from './wasm.js'; +/** + * @file index.ts + * @description Public API surface for @og-engine/core. + * @module @og-engine/core + */ + +export { buildCacheKey } from './cache-key.js'; +export { getDefaultFonts, loadFont } from './fonts.js'; +export type { FontConfig } from './fonts.js'; +export { createHandler } from './handler.js'; +export type { HandlerDeps } from './handler.js'; +export { coerceParams } from './params.js'; +export { render } from './render.js'; +export type { RenderResult } from './render.js'; +export { getResvg, initWasm } from './wasm.js'; diff --git a/packages/core/src/params.ts b/packages/core/src/params.ts index 78f3b28..9a868ad 100644 --- a/packages/core/src/params.ts +++ b/packages/core/src/params.ts @@ -1,5 +1,18 @@ +/** + * @file params.ts + * @description Request parameter coercion and schema validation utilities. + * @module @og-engine/core + */ + import type { TemplateSchema } from '@og-engine/types'; +/** + * Coerces raw query parameters into typed values based on a template schema. + * + * @param raw - Raw request params map. + * @param schema - Template schema used for coercion and validation. + * @returns Coerced parameter object suitable for template rendering. + */ export function coerceParams( raw: Record, schema: TemplateSchema diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 5821ef7..dd9f1f8 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -1,4 +1,10 @@ -import { sandboxedRender } from '@og-engine/sandbox'; +/** + * @file render.ts + * @description Core OG image render pipeline (template -> SVG -> PNG). + * @module @og-engine/core + */ + +import { sandboxedRender } from '@og-engine/internal-sandbox'; import { PLATFORM_SIZES, type OGRequest, type OGTemplate } from '@og-engine/types'; import satori from 'satori'; import { buildCacheKey } from './cache-key.js'; @@ -6,14 +12,34 @@ import type { FontConfig } from './fonts.js'; import { coerceParams } from './params.js'; import { getResvg, initWasm } from './wasm.js'; +/** + * Successful render output payload. + */ export interface RenderResult { + /** Encoded image buffer. */ buffer: Buffer; + + /** Output MIME type. */ contentType: 'image/png' | 'image/jpeg'; + + /** Output width in pixels. */ width: number; + + /** Output height in pixels. */ height: number; + + /** Deterministic request cache key. */ cacheKey: string; } +/** + * Renders an OG image from request data and a template. + * + * @param req - Request payload. + * @param template - Template definition. + * @param fonts - Satori font definitions. + * @returns Rendered image data and metadata. + */ export async function render( req: OGRequest, template: OGTemplate, @@ -22,11 +48,8 @@ export async function render( await initWasm(); const { width, height } = PLATFORM_SIZES[req.size]; - - // 1. Coerce raw string params into typed params const typedParams = coerceParams(req.params, template.schema); - // 2. Render JSX via template.render() inside sandbox const element = await sandboxedRender(template, { ...typedParams, width, @@ -34,14 +57,13 @@ export async function render( size: req.size }); - // 3. Satori: JSX -> SVG - const svg = await satori(element as any, { + const satoriInput = element as unknown as Parameters[0]; + const svg = await satori(satoriInput, { width, height, fonts }); - // 4. resvg: SVG -> PNG const ResvgClass = await getResvg(); const resvg = new ResvgClass(svg, { fitTo: { mode: 'width', value: width } diff --git a/packages/core/src/wasm-edge.ts b/packages/core/src/wasm-edge.ts index 238a08e..26e9b2f 100644 --- a/packages/core/src/wasm-edge.ts +++ b/packages/core/src/wasm-edge.ts @@ -1,17 +1,29 @@ +/** + * @file wasm-edge.ts + * @description Edge-runtime WASM initialization for Yoga and Resvg. + * @module @og-engine/core + */ + let initialized = false; +function isInitFunction(value: unknown): value is () => Promise { + return typeof value === 'function'; +} + +/** + * Initializes WASM dependencies required for edge runtimes. + */ export async function initWasm(): Promise { if (initialized) { return; } - // yoga (flexbox engine used by satori) - const { default: initYoga } = await import('yoga-wasm-web/auto'); - await (initYoga as any)(); + const { default: initYogaMaybe } = await import('yoga-wasm-web/auto'); + if (isInitFunction(initYogaMaybe)) { + await initYogaMaybe(); + } - // resvg const { initWasm: initResvg } = await import('@resvg/resvg-wasm'); - // fetch from CDN await initResvg(fetch('https://unpkg.com/@resvg/resvg-wasm/index_bg.wasm')); initialized = true; diff --git a/packages/core/src/wasm-node.ts b/packages/core/src/wasm-node.ts index d0eb2a9..01a5f22 100644 --- a/packages/core/src/wasm-node.ts +++ b/packages/core/src/wasm-node.ts @@ -1,15 +1,27 @@ +/** + * @file wasm-node.ts + * @description Node.js-specific WASM initialization for Yoga. + * @module @og-engine/core + */ + let initialized = false; +function isInitFunction(value: unknown): value is () => Promise { + return typeof value === 'function'; +} + +/** + * Initializes WASM dependencies required for Node.js rendering. + */ export async function initWasm(): Promise { if (initialized) { return; } - // yoga (flexbox engine used by satori) const yoga = await import('yoga-wasm-web/auto'); - const init = yoga.default || yoga; - if (typeof init === 'function') { - await (init as any)(); + const initMaybe: unknown = yoga.default ?? yoga; + if (isInitFunction(initMaybe)) { + await initMaybe(); } initialized = true; diff --git a/packages/core/src/wasm.ts b/packages/core/src/wasm.ts index 22c884a..4d2985d 100644 --- a/packages/core/src/wasm.ts +++ b/packages/core/src/wasm.ts @@ -1,8 +1,17 @@ +/** + * @file wasm.ts + * @description Runtime-aware WASM bootstrap helpers for render dependencies. + * @module @og-engine/core + */ + const isNodeRuntime = typeof process !== 'undefined' && typeof process.versions !== 'undefined' && typeof process.versions.node === 'string'; +/** + * Initializes runtime-specific WASM dependencies. + */ export async function initWasm(): Promise { if (isNodeRuntime) { const mod = await import('./wasm-node.js'); @@ -14,11 +23,29 @@ export async function initWasm(): Promise { await mod.initWasm(); } -export async function getResvg() { +interface ResvgLike { + render(): { asPng(): Uint8Array }; +} + +/** + * Runtime-agnostic Resvg constructor type used by the render pipeline. + */ +type ResvgConstructor = new ( + svg: string | Uint8Array, + options?: { fitTo?: { mode: 'width'; value: number } } +) => ResvgLike; + +/** + * Resolves runtime-appropriate Resvg constructor. + * + * @returns Resvg class implementation for current runtime. + */ +export async function getResvg(): Promise { if (isNodeRuntime) { const { Resvg } = await import('@resvg/resvg-js'); - return Resvg; + return Resvg as unknown as ResvgConstructor; } + const { Resvg } = await import('@resvg/resvg-wasm'); - return Resvg; + return Resvg as unknown as ResvgConstructor; } diff --git a/packages/types/README.md b/packages/types/README.md new file mode 100644 index 0000000..661ad57 --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1,48 @@ +# @og-engine/types + +Shared TypeScript contracts for `og-engine` packages. This package contains strongly-typed interfaces and utility types for template definitions, render requests, platform adapters, and metadata responses used by core and adapter packages. + +## Installation + +```bash +pnpm add @og-engine/types +``` + +## Basic usage + +```ts +import type { OGTemplate, PlatformSize, TemplateSchema } from '@og-engine/types'; + +const schema = { + title: { type: 'string', required: true, maxLength: 120 } +} satisfies TemplateSchema; + +const template: OGTemplate = { + id: 'example', + name: 'Example', + description: 'Example template', + author: 'og-engine', + version: '1.0.0', + supportedSizes: ['og' satisfies PlatformSize], + schema, + render: ({ title }) =>
{title}
+}; +``` + +## API reference + +- `PLATFORM_SIZES`: canonical size map for supported social platforms. +- `PlatformSize`: union of supported size keys. +- `TemplateSchema`, `SchemaFieldType`, `InferParams`: schema/typing helpers for templates. +- `OGTemplate`, `TemplateMetadata`, `RenderContext`: template contracts. +- `StorageAdapter`, `CacheAdapter`, `TemplateRegistryAdapter`, `PlatformAdapter`: integration interfaces. +- `OGRequest`, `MetaRequest`, `MetaResponse`: request/response payload types. + +## Used by + +- `@og-engine/core` +- `@og-engine/adapter-node` +- `@og-engine/adapter-cloudflare` +- `@og-engine/adapter-vercel` +- template packages under `templates/*` +- apps under `apps/*` diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index eedb5d2..082b857 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,3 +1,16 @@ +/** + * @file index.ts + * @description Public type contracts shared across og-engine packages. + * + * This module defines template schemas, rendering interfaces, platform adapter + * contracts, and request/response payload types used throughout the monorepo. + * + * @module @og-engine/types + */ + +/** + * Canonical output size presets for supported social platforms. + */ export const PLATFORM_SIZES = { 'twitter-og': { width: 1200, height: 628, label: 'Twitter / X' }, 'facebook-og': { width: 1200, height: 630, label: 'Facebook OG' }, @@ -10,8 +23,14 @@ export const PLATFORM_SIZES = { og: { width: 1200, height: 630, label: 'Generic OG (default)' } } as const; +/** + * Supported social output size identifier. + */ export type PlatformSize = keyof typeof PLATFORM_SIZES; +/** + * Allowed schema field definitions for template parameters. + */ export type SchemaFieldType = | { type: 'string'; required?: boolean; default?: string; maxLength?: number } | { type: 'text'; required?: boolean; default?: string } @@ -20,8 +39,14 @@ export type SchemaFieldType = | { type: 'boolean'; required?: boolean; default?: boolean } | { type: 'enum'; required?: boolean; values: string[]; default?: string }; +/** + * Template parameter schema keyed by parameter name. + */ export type TemplateSchema = Record; +/** + * Derives strongly-typed render params from a template schema. + */ export type InferParams = { [K in keyof S]: S[K] extends { type: 'string' } ? string @@ -38,83 +63,202 @@ export type InferParams = { : never; }; +/** + * JSX element type used by template render functions. + */ export type JSXElement = JSX.Element; +/** + * Size and platform context passed into template render functions. + */ export interface RenderContext { + /** Output image width in pixels. */ width: number; + + /** Output image height in pixels. */ height: number; + + /** Selected platform size key. */ size: PlatformSize; } +/** + * Fully-typed OG template definition. + */ export interface OGTemplate { + /** Stable template identifier used in requests. */ id: string; + + /** Human-readable template name. */ name: string; + + /** Short description shown in template listings. */ description: string; + + /** Template author or maintainer name. */ author: string; + + /** Template version string (semver recommended). */ version: string; + + /** Optional tag list used for filtering and discovery. */ tags?: string[]; + + /** Platforms/sizes this template supports. */ supportedSizes: PlatformSize[]; + + /** Parameter schema used for coercion and validation. */ schema: S; + + /** Render function that returns a JSX tree for Satori. */ render: (params: InferParams & RenderContext) => JSXElement; + + /** Optional preview image URL/path. */ preview?: string; } +/** + * Metadata shape used for listing templates without full render functions. + */ export interface TemplateMetadata { + /** Stable template identifier used in requests. */ id: string; + + /** Human-readable template name. */ name: string; + + /** Short description shown in template listings. */ description: string; + + /** Template author or maintainer name. */ author: string; + + /** Template version string (semver recommended). */ version: string; + + /** Optional tags used for filtering and discovery. */ tags: string[]; + + /** Platforms/sizes this template supports. */ supportedSizes: PlatformSize[]; } +/** + * Binary storage adapter contract for rendered image assets. + */ export interface StorageAdapter { + /** Reads a binary object by storage key. */ get(key: string): Promise; + + /** Writes a binary object by storage key. */ put(key: string, value: Buffer, options?: { contentType?: string }): Promise; + + /** Removes a binary object by storage key. */ delete(key: string): Promise; + + /** Builds a public URL for a storage key. */ url(key: string): string; } +/** + * Cache adapter contract for key-value cache backends. + */ export interface CacheAdapter { + /** Returns a cached value for a key or null when absent. */ get(key: string): Promise; + + /** Stores a cache value with optional TTL in seconds. */ set(key: string, value: string, ttl?: number): Promise; + + /** Invalidates a cache value by key. */ delete(key: string): Promise; } +/** + * Registry adapter contract used to discover and load templates. + */ export interface TemplateRegistryAdapter { + /** Lists all available template metadata entries. */ list(): Promise; + + /** Loads a concrete template by identifier. */ get(id: string): Promise; + + /** Checks whether a template exists by identifier. */ exists(id: string): Promise; } +/** + * Platform-specific adapter grouping required dependencies. + */ export interface PlatformAdapter { + /** Binary storage backend implementation. */ storage: StorageAdapter; + + /** Cache backend implementation. */ cache: CacheAdapter; + + /** Template registry implementation. */ registry: TemplateRegistryAdapter; } +/** + * Request payload for generating an OG image. + */ export interface OGRequest { + /** Template identifier to render. */ template: string; + + /** Output size preset key. */ size: PlatformSize; + + /** Raw string parameters from a request boundary. */ params: Record; + + /** Optional output format (defaults to PNG in core). */ format?: 'png' | 'jpeg'; + + /** Optional quality hint for lossy output formats. */ quality?: number; + + /** Optional cache TTL in seconds. */ ttl?: number; } +/** + * Request payload for generating metadata output instead of binary images. + */ export type MetaRequest = Omit & { + /** Metadata response format. */ format?: 'json' | 'html'; + + /** Absolute base URL used to build OG image links. */ baseUrl: string; }; +/** + * Open Graph and Twitter card metadata response fields. + */ export interface MetaResponse { + /** Absolute URL for the OG image. */ 'og:image': string; + + /** Image width in pixels as a string. */ 'og:image:width': string; + + /** Image height in pixels as a string. */ 'og:image:height': string; + + /** MIME type for the OG image. */ 'og:image:type': string; + + /** Twitter card style selector. */ 'twitter:card': 'summary_large_image' | 'summary'; + + /** Absolute URL for Twitter image fallback. */ 'twitter:image': string; + + /** Additional metadata fields. */ [key: string]: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be521a6..f518cea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,12 @@ importers: specifier: ^4.20250214.0 version: 4.20260305.1 + internal/sandbox: + dependencies: + '@og-engine/types': + specifier: workspace:* + version: link:../../packages/types + packages/adapter-cloudflare: dependencies: '@cloudflare/workers-types': @@ -178,9 +184,9 @@ importers: packages/core: dependencies: - '@og-engine/sandbox': + '@og-engine/internal-sandbox': specifier: workspace:* - version: link:../sandbox + version: link:../../internal/sandbox '@og-engine/types': specifier: workspace:* version: link:../types @@ -197,12 +203,6 @@ importers: specifier: ^0.3.3 version: 0.3.3 - packages/sandbox: - dependencies: - '@og-engine/types': - specifier: workspace:* - version: link:../types - packages/types: {} templates/_base: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 618bc36..8700e9b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - 'packages/*' - - 'apps/*' + - 'internal/*' - 'templates/*' + - 'apps/*' diff --git a/templates/_base/package.json b/templates/_base/package.json index bc3e5e2..10e365b 100644 --- a/templates/_base/package.json +++ b/templates/_base/package.json @@ -9,5 +9,6 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint . --ext .ts,.tsx", "test": "node -e \"console.log('no tests')\"" - } + }, + "private": true } diff --git a/templates/dark/index.tsx b/templates/dark/index.tsx index a9a2fee..cfafd7d 100644 --- a/templates/dark/index.tsx +++ b/templates/dark/index.tsx @@ -2,11 +2,11 @@ import type { OGTemplate } from '@og-engine/types'; const template: OGTemplate = { id: 'dark', - name: 'Dark', - description: 'Dark neon template', + name: 'Dark Terminal', + description: 'Terminal-inspired black canvas with mono typography and sharp accents', author: 'og-engine', - version: '1.0.0', - tags: ['dark', 'neon', 'saas'], + version: '2.0.0', + tags: ['terminal', 'hacker', 'mono', 'dark'], supportedSizes: [ 'twitter-og', 'facebook-og', @@ -19,108 +19,96 @@ const template: OGTemplate = { 'ig-story' ], schema: { - title: { type: 'string', required: true, maxLength: 120 }, - subtitle: { type: 'text' }, - accent: { type: 'color', default: '#00D2FF' }, - author: { type: 'string' } + title: { type: 'string', required: true, maxLength: 100 }, + subtitle: { type: 'string', required: false, maxLength: 200 }, + author: { type: 'string', required: false }, + context: { type: 'string', required: false }, + tag: { type: 'string', required: false }, + accent: { type: 'color', required: false, default: '#00ff88' } }, - render: (params) => ( -
- {/* Ambient Background Glow */} -
+ render: (params) => { + const accent = params.accent || '#00ff88'; + const isCompact = params.size === 'whatsapp'; + return (
-

- {params.title} -

- {params.subtitle && ( -

- {params.subtitle} -

- )} -
+ /> + +
+
{`$/${params.context || 'workspace'}`}
+ +
+
{'>'}
+
+ {params.title} +
+
+ + {params.subtitle ? ( +
+ {params.subtitle} +
+ ) : null} +
-
- {params.author && ( - - {params.author} - - )} + > +
{params.author || 'operator'}
+
+ {params.tag || 'stable'} + v1.0.0 +
+
-
- ), + ); + }, preview: './preview.svg' }; diff --git a/templates/dark/metadata.json b/templates/dark/metadata.json index 721a8df..d869b9c 100644 --- a/templates/dark/metadata.json +++ b/templates/dark/metadata.json @@ -1,10 +1,10 @@ { "id": "dark", - "name": "Dark", - "description": "Dark neon template", + "name": "Dark Terminal", + "description": "Terminal-inspired black canvas with mono typography and sharp accents", "author": "og-engine", - "version": "1.0.0", - "tags": ["dark", "neon", "saas"], + "version": "2.0.0", + "tags": ["terminal", "hacker", "mono", "dark"], "supportedSizes": [ "twitter-og", "facebook-og", diff --git a/templates/dark/package.json b/templates/dark/package.json index 4cd0516..2a03441 100644 --- a/templates/dark/package.json +++ b/templates/dark/package.json @@ -12,5 +12,6 @@ }, "dependencies": { "@og-engine/types": "workspace:*" - } + }, + "private": true } diff --git a/templates/dark/preview.png b/templates/dark/preview.png deleted file mode 100644 index 5949d69..0000000 Binary files a/templates/dark/preview.png and /dev/null differ diff --git a/templates/minimal/index.tsx b/templates/minimal/index.tsx index 9f80115..d0383eb 100644 --- a/templates/minimal/index.tsx +++ b/templates/minimal/index.tsx @@ -2,11 +2,11 @@ import type { OGTemplate } from '@og-engine/types'; const template: OGTemplate = { id: 'minimal', - name: 'Minimal', - description: 'Simple and clean template', + name: 'Minimal Swiss', + description: 'Refined typographic composition with generous whitespace', author: 'og-engine', - version: '1.0.0', - tags: ['minimal', 'docs'], + version: '2.0.0', + tags: ['minimal', 'swiss', 'typography'], supportedSizes: [ 'twitter-og', 'facebook-og', @@ -19,95 +19,79 @@ const template: OGTemplate = { 'ig-story' ], schema: { - title: { type: 'string', required: true, maxLength: 120 }, - description: { type: 'text' }, - logo: { type: 'image' }, - tag: { type: 'string' }, + title: { type: 'string', required: true, maxLength: 80 }, + category: { type: 'string', required: false }, + author: { type: 'string', required: false }, + site: { type: 'string', required: false }, + logo: { type: 'image', required: false }, + accent: { type: 'color', required: false, default: '#2563eb' }, theme: { type: 'enum', values: ['light', 'dark'], default: 'light' } }, render: (params) => { - const isDark = params.theme === 'dark'; - const bg = isDark ? '#000000' : '#ffffff'; - const fg = isDark ? '#ffffff' : '#000000'; - const muted = isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'; + const dark = params.theme === 'dark'; + const bg = dark ? '#111111' : '#ffffff'; + const text = dark ? '#f4f4f4' : '#111111'; + const secondary = dark ? '#999999' : '#666666'; + const isCompact = params.size === 'whatsapp'; return (
-
- {params.tag && ( -
+ {params.logo ? ( + logo - {params.tag} -
- )} -

+ ) : null} +

+ +
+
+ {params.category || 'Category'} +
+ +
{params.title} - - {params.description && ( -

- {params.description} -

- )} +
-
- {params.logo && ( - Logo - )} +
+
{params.author || 'Unknown author'}
+
{params.site || 'og-engine'}
); diff --git a/templates/minimal/metadata.json b/templates/minimal/metadata.json index 877a6df..0c44e96 100644 --- a/templates/minimal/metadata.json +++ b/templates/minimal/metadata.json @@ -1,10 +1,10 @@ { "id": "minimal", - "name": "Minimal", - "description": "Simple clean template", + "name": "Minimal Swiss", + "description": "Refined typographic composition with generous whitespace", "author": "og-engine", - "version": "1.0.0", - "tags": ["minimal", "docs"], + "version": "2.0.0", + "tags": ["minimal", "swiss", "typography"], "supportedSizes": [ "twitter-og", "facebook-og", diff --git a/templates/minimal/package.json b/templates/minimal/package.json index fc80e6f..e6f449c 100644 --- a/templates/minimal/package.json +++ b/templates/minimal/package.json @@ -12,5 +12,6 @@ }, "dependencies": { "@og-engine/types": "workspace:*" - } + }, + "private": true } diff --git a/templates/minimal/preview.png b/templates/minimal/preview.png deleted file mode 100644 index 3714b5e..0000000 Binary files a/templates/minimal/preview.png and /dev/null differ diff --git a/templates/sunset/index.tsx b/templates/sunset/index.tsx index c1fd001..9688c53 100644 --- a/templates/sunset/index.tsx +++ b/templates/sunset/index.tsx @@ -2,11 +2,11 @@ import type { OGTemplate } from '@og-engine/types'; const template: OGTemplate = { id: 'sunset', - name: 'Sunset', - description: 'Warm gradient template for articles and blog posts', + name: 'Sunset Editorial', + description: 'Editorial newspaper-inspired composition with serif hierarchy', author: 'og-engine', - version: '1.0.0', - tags: ['gradient', 'warm', 'blog', 'article'], + version: '2.0.0', + tags: ['editorial', 'serif', 'magazine', 'newsroom'], supportedSizes: [ 'twitter-og', 'facebook-og', @@ -19,70 +19,115 @@ const template: OGTemplate = { ], schema: { title: { type: 'string', required: true, maxLength: 120 }, - subtitle: { type: 'text' }, - author: { type: 'string' }, - date: { type: 'string' } + subtitle: { type: 'string', required: false, maxLength: 200 }, + author: { type: 'string', required: false }, + tag: { type: 'string', required: false }, + date: { type: 'string', required: false } }, - render: (params) => ( -
-
-

- {params.title} -

- {params.subtitle && ( -

- {params.subtitle} -

- )} -
+ render: (params) => { + const isStory = params.size === 'ig-story'; + const isCompact = params.size === 'whatsapp'; + return (
-
- {params.author && ( - {params.author} - )} +
+ +
+
+ {params.tag || 'Feature'} +
+
{params.date || 'Today'}
+
+ +
+
+ +
+ {params.title} +
+ +
+ + {params.subtitle ? ( +
+ {params.subtitle} +
+ ) : null} +
+ +
+
+ {params.author || 'Staff Writer'} +
+
og-engine
- {params.date && {params.date}}
-
- ), + ); + }, preview: './preview.svg' }; diff --git a/templates/sunset/metadata.json b/templates/sunset/metadata.json index ad80361..11fd860 100644 --- a/templates/sunset/metadata.json +++ b/templates/sunset/metadata.json @@ -1,10 +1,10 @@ { "id": "sunset", - "name": "Sunset", - "description": "Warm gradient template for articles and blog posts", + "name": "Sunset Editorial", + "description": "Editorial newspaper-inspired composition with serif hierarchy", "author": "og-engine", - "version": "1.0.0", - "tags": ["gradient", "warm", "blog", "article"], + "version": "2.0.0", + "tags": ["editorial", "serif", "magazine", "newsroom"], "supportedSizes": [ "twitter-og", "facebook-og", diff --git a/templates/sunset/package.json b/templates/sunset/package.json index 582100c..291464b 100644 --- a/templates/sunset/package.json +++ b/templates/sunset/package.json @@ -12,5 +12,6 @@ }, "dependencies": { "@og-engine/types": "workspace:*" - } + }, + "private": true } diff --git a/templates/sunset/preview.png b/templates/sunset/preview.png deleted file mode 100644 index 6a928a7..0000000 Binary files a/templates/sunset/preview.png and /dev/null differ diff --git a/turbo.json b/turbo.json index 53f978e..d70a14c 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turborepo.org/schema.json", + "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], @@ -9,14 +9,14 @@ "cache": false, "persistent": true }, - "lint": { - "dependsOn": ["^lint"] + "test": { + "dependsOn": ["^build"] }, "typecheck": { - "dependsOn": ["^typecheck"] + "dependsOn": ["^build"] }, - "test": { - "dependsOn": ["^test"] + "lint": { + "outputs": [] } } }