Created
February 12, 2026 04:49
-
-
Save davext/1cf6e0125273e26cbaeacdc07261643f to your computer and use it in GitHub Desktop.
Cloudflare Worker Dynamic Open Graph Generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Hono } from "hono"; | |
| import { ImageResponse } from "workers-og"; | |
| const app = new Hono(); | |
| /** | |
| * Brand / product configuration | |
| * Change these values (or wire them to env vars) to re-brand the OG generator. | |
| */ | |
| const BRAND_PREFIX = "Blooio"; | |
| const DEFAULT_TITLE = "iMessage API for Developers"; | |
| const LOGO_URL = "https://bucket.blooio.com/assets/Logo/Blooio-dark-light-v4.png"; | |
| /** | |
| * OG image configuration | |
| */ | |
| const OG_WIDTH = 1200; | |
| const OG_HEIGHT = 630; | |
| /** | |
| * Module-level cache so we don’t fetch + base64-encode the logo on every request. | |
| */ | |
| let cachedLogoDataUrl: string | null = null; | |
| /** | |
| * Helper: adjust font size based on title length | |
| */ | |
| function getFontSize(titleLength: number): string { | |
| if (titleLength > 80) return "48px"; | |
| if (titleLength > 60) return "56px"; | |
| if (titleLength > 40) return "64px"; | |
| return "72px"; | |
| } | |
| /** | |
| * Helper: split text into multiple lines when too long (word-wrapping at maxLength). | |
| */ | |
| function formatTitle(text: string, maxLength = 50): string { | |
| if (text.length <= maxLength) return text; | |
| const words = text.split(" "); | |
| const lines: string[] = []; | |
| let currentLine = ""; | |
| for (const word of words) { | |
| const next = (currentLine + " " + word).trim(); | |
| if (next.length <= maxLength) { | |
| currentLine = next; | |
| } else { | |
| if (currentLine) lines.push(currentLine); | |
| currentLine = word; | |
| } | |
| } | |
| if (currentLine) lines.push(currentLine); | |
| return lines.join("\n"); | |
| } | |
| /** | |
| * Convert ArrayBuffer -> base64 in a worker-safe way (no Buffer). | |
| * Chunked to avoid argument size limits. | |
| */ | |
| function arrayBufferToBase64(buf: ArrayBuffer): string { | |
| const bytes = new Uint8Array(buf); | |
| const chunkSize = 0x8000; // 32KB | |
| let binary = ""; | |
| for (let i = 0; i < bytes.length; i += chunkSize) { | |
| const chunk = bytes.subarray(i, i + chunkSize); | |
| binary += String.fromCharCode(...chunk); | |
| } | |
| return btoa(binary); | |
| } | |
| /** | |
| * Fetch + cache the logo as a data URL (or return null if it fails). | |
| */ | |
| async function getLogoDataUrl(): Promise<string | null> { | |
| if (cachedLogoDataUrl) return cachedLogoDataUrl; | |
| try { | |
| const res = await fetch(LOGO_URL); | |
| if (!res.ok) return null; | |
| const buf = await res.arrayBuffer(); | |
| const base64 = arrayBufferToBase64(buf); | |
| cachedLogoDataUrl = `data:image/png;base64,${base64}`; | |
| return cachedLogoDataUrl; | |
| } catch (err) { | |
| console.error("Failed to fetch logo:", err); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Small HTML escaping helpers for /preview endpoint | |
| */ | |
| function escapeHtml(text: string): string { | |
| return text | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">"); | |
| } | |
| function escapeHtmlAttr(text: string): string { | |
| return escapeHtml(text).replace(/"/g, """); | |
| } | |
| function withBrandPrefix(title: string): string { | |
| // Maintain prior behavior: always prepend "<BRAND_PREFIX> - " to the title | |
| return `${BRAND_PREFIX} - ${title}`; | |
| } | |
| app.get("/", async (c) => { | |
| try { | |
| // Query parameters (support long + short aliases) | |
| const rawTitle = | |
| c.req.query("title") || c.req.query("t") || DEFAULT_TITLE; | |
| const description = c.req.query("description") || c.req.query("d") || ""; | |
| const title = withBrandPrefix(rawTitle); | |
| const fontSize = getFontSize(title.length); | |
| const formattedTitle = formatTitle(title); | |
| const formattedDescription = description ? formatTitle(description, 80) : ""; | |
| const logoDataUrl = await getLogoDataUrl(); | |
| const html = { | |
| type: "div", | |
| props: { | |
| style: { | |
| width: "100%", | |
| height: "100%", | |
| display: "flex", | |
| flexDirection: "column", | |
| justifyContent: "space-between", | |
| alignItems: "flex-start", | |
| padding: "80px", | |
| background: | |
| "linear-gradient(135deg, #020617 0%, #0c1844 50%, #1e3a8a 100%)", | |
| fontFamily: "Inter, system-ui, -apple-system, sans-serif", | |
| }, | |
| children: [ | |
| // Logo | |
| logoDataUrl | |
| ? { | |
| type: "div", | |
| props: { | |
| style: { | |
| display: "flex", | |
| alignItems: "center", | |
| marginBottom: "60px", | |
| }, | |
| children: { | |
| type: "img", | |
| props: { | |
| src: logoDataUrl, | |
| width: 280, | |
| height: 80, | |
| style: { | |
| objectFit: "contain", | |
| filter: "drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1))", | |
| }, | |
| }, | |
| }, | |
| }, | |
| } | |
| : null, | |
| // Title + Description | |
| { | |
| type: "div", | |
| props: { | |
| style: { | |
| display: "flex", | |
| flexDirection: "column", | |
| justifyContent: "center", | |
| flex: 1, | |
| gap: "24px", | |
| }, | |
| children: [ | |
| { | |
| type: "h1", | |
| props: { | |
| style: { | |
| fontSize, | |
| fontWeight: "bold", | |
| color: "white", | |
| lineHeight: 1.2, | |
| margin: 0, | |
| textShadow: "0 4px 12px rgba(0, 0, 0, 0.2)", | |
| whiteSpace: "pre-wrap", | |
| maxWidth: "1000px", | |
| }, | |
| children: formattedTitle, | |
| }, | |
| }, | |
| description | |
| ? { | |
| type: "p", | |
| props: { | |
| style: { | |
| fontSize: "32px", | |
| fontWeight: "normal", | |
| color: "rgba(255, 255, 255, 0.85)", | |
| lineHeight: 1.4, | |
| margin: 0, | |
| textShadow: "0 2px 8px rgba(0, 0, 0, 0.2)", | |
| whiteSpace: "pre-wrap", | |
| maxWidth: "1000px", | |
| }, | |
| children: formattedDescription, | |
| }, | |
| } | |
| : null, | |
| ].filter(Boolean), | |
| }, | |
| }, | |
| ].filter(Boolean), | |
| }, | |
| }; | |
| return new ImageResponse(html, { | |
| width: OG_WIDTH, | |
| height: OG_HEIGHT, | |
| headers: { | |
| "Content-Type": "image/png", | |
| "Cache-Control": "public, max-age=31536000, immutable", | |
| }, | |
| }); | |
| } catch (error) { | |
| console.error("Error generating OG image:", error); | |
| return c.text("Error generating image", 500); | |
| } | |
| }); | |
| // Health check | |
| app.get("/health", (c) => c.json({ status: "ok" })); | |
| // Preview page | |
| app.get("/preview", (c) => { | |
| const title = c.req.query("title") || DEFAULT_TITLE; | |
| const description = c.req.query("description") || ""; | |
| const imageUrl = `/?title=${encodeURIComponent(title)}&description=${encodeURIComponent( | |
| description | |
| )}`; | |
| const safeTitleAttr = escapeHtmlAttr(title); | |
| const safeDescText = escapeHtml(description); | |
| return c.html(`<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>OG Image Preview</title> | |
| <style> | |
| body { | |
| font-family: system-ui, -apple-system, sans-serif; | |
| max-width: 1200px; | |
| margin: 50px auto; | |
| padding: 20px; | |
| } | |
| img { | |
| width: 100%; | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .controls { margin-bottom: 20px; } | |
| input, textarea { | |
| width: 100%; | |
| padding: 10px; | |
| font-size: 16px; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| margin-bottom: 10px; | |
| font-family: system-ui, -apple-system, sans-serif; | |
| } | |
| textarea { resize: vertical; min-height: 80px; } | |
| label { | |
| display: block; | |
| font-weight: 600; | |
| margin-bottom: 5px; | |
| color: #333; | |
| } | |
| button { | |
| margin-top: 10px; | |
| padding: 10px 20px; | |
| background: #1e3a8a; | |
| color: white; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 16px; | |
| } | |
| button:hover { background: #1e40af; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>OG Image Preview</h1> | |
| <div class="controls"> | |
| <label for="titleInput">Title</label> | |
| <input | |
| type="text" | |
| id="titleInput" | |
| placeholder="Enter title..." | |
| value="${safeTitleAttr}" | |
| /> | |
| <label for="descriptionInput">Description (optional)</label> | |
| <textarea | |
| id="descriptionInput" | |
| placeholder="Enter description..." | |
| >${safeDescText}</textarea> | |
| <button onclick="updatePreview()">Update Preview</button> | |
| </div> | |
| <img src="${imageUrl}" alt="OG Image Preview" /> | |
| <script> | |
| function updatePreview() { | |
| const title = document.getElementById('titleInput').value; | |
| const description = document.getElementById('descriptionInput').value; | |
| window.location.href = | |
| '/preview?title=' + encodeURIComponent(title) + | |
| '&description=' + encodeURIComponent(description); | |
| } | |
| document.getElementById('titleInput').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') updatePreview(); | |
| }); | |
| </script> | |
| </body> | |
| </html>`); | |
| }); | |
| export default app; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment