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/core/src/fonts.ts b/packages/core/src/fonts.ts index 5bfda77..af8b06c 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -21,49 +21,201 @@ export interface FontConfig { style?: 'normal' | 'italic'; } -const fontCache = new Map(); +const fontBinaryCache = new Map(); +const cssCache = new Map(); -/** - * Downloads and caches a font in-memory. - * - * @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 { - 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 data = fontCache.get(url); - if (!data) { - throw new Error(`Font cache missing loaded value for ${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; } - return { name, data, weight: 400 }; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Unable to load font binary from ${url}`); + } + + const data = await response.arrayBuffer(); + fontBinaryCache.set(url, data); + return data; +} + +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. * - * @returns Default regular, bold, and black Noto Sans fonts. + * 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 { - const baseUrl = 'https://github.com/googlefonts/noto-fonts/raw/master/unhinted/ttf/NotoSans'; - return Promise.all([ - loadFont('Noto Sans', `${baseUrl}/NotoSans-Regular.ttf`), - loadFont('Noto Sans', `${baseUrl}/NotoSans-Bold.ttf`).then((font) => ({ - ...font, - weight: 700 as const - })), - loadFont('Noto Sans', `${baseUrl}/NotoSans-Black.ttf`).then((font) => ({ - ...font, - 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/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/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/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/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