Barazo AppView backend barazo.forum
at main 149 lines 4.9 kB view raw
1import sharp from 'sharp' 2 3// --------------------------------------------------------------------------- 4// Constants 5// --------------------------------------------------------------------------- 6 7const OG_WIDTH = 1200 8const OG_HEIGHT = 630 9const MAX_TITLE_LINES = 3 10const MAX_CHARS_PER_LINE = 38 11 12// --------------------------------------------------------------------------- 13// Helpers (exported for testing) 14// --------------------------------------------------------------------------- 15 16/** 17 * Escape special XML characters to prevent SVG injection. 18 */ 19export function escapeXml(text: string): string { 20 return text 21 .replace(/&/g, '&amp;') 22 .replace(/</g, '&lt;') 23 .replace(/>/g, '&gt;') 24 .replace(/"/g, '&quot;') 25 .replace(/'/g, '&apos;') 26} 27 28/** 29 * Word-wrap text into lines of at most `maxCharsPerLine` characters, 30 * limited to `maxLines` total. If truncated, the last line ends with 31 * an ellipsis character. 32 */ 33export function wrapText(text: string, maxCharsPerLine: number, maxLines: number): string[] { 34 const words = text.split(/\s+/).filter((w) => w.length > 0) 35 if (words.length === 0) { 36 return [] 37 } 38 39 const lines: string[] = [] 40 let currentLine = '' 41 42 for (const word of words) { 43 if (lines.length >= maxLines) { 44 break 45 } 46 47 const testLine = currentLine ? `${currentLine} ${word}` : word 48 if (testLine.length > maxCharsPerLine && currentLine) { 49 lines.push(currentLine) 50 currentLine = word 51 } else { 52 currentLine = testLine 53 } 54 } 55 56 if (currentLine && lines.length < maxLines) { 57 lines.push(currentLine) 58 } 59 60 // Check if text was truncated 61 const joinedLength = lines.join(' ').length 62 const fullLength = words.join(' ').length 63 if (fullLength > joinedLength && lines.length > 0) { 64 const lastIdx = lines.length - 1 65 const lastLine = lines[lastIdx] ?? '' 66 if (lastLine.length > maxCharsPerLine - 1) { 67 lines[lastIdx] = lastLine.slice(0, maxCharsPerLine - 1) + '\u2026' 68 } else { 69 lines[lastIdx] = lastLine + '\u2026' 70 } 71 } 72 73 return lines 74} 75 76// --------------------------------------------------------------------------- 77// SVG generation 78// --------------------------------------------------------------------------- 79 80export interface OgImageParams { 81 title: string 82 category: string 83 communityName: string 84} 85 86/** 87 * Generate an SVG string for a cross-post OG image. 88 * 89 * Layout (1200x630): 90 * - Dark background (#1c1b22) 91 * - Category badge (cyan pill) 92 * - Community name (grey subheading) 93 * - Topic title (white, word-wrapped, max 3 lines) 94 * - Barazo branding footer 95 */ 96export function generateOgSvg(params: OgImageParams): string { 97 const titleLines = wrapText(params.title, MAX_CHARS_PER_LINE, MAX_TITLE_LINES) 98 const categoryText = escapeXml(params.category.toUpperCase()) 99 const communityText = escapeXml(params.communityName) 100 101 // Estimate category badge width (~11px per char + 32px padding) 102 const categoryWidth = String(Math.max(categoryText.length * 11 + 32, 60)) 103 const categoryTextX = String(60 + 16) 104 const categoryTextY = String(60 + 24) 105 const footerY = String(OG_HEIGHT - 40) 106 const brandingX = String(OG_WIDTH - 60) 107 const width = String(OG_WIDTH) 108 const height = String(OG_HEIGHT) 109 110 const titleSvg = titleLines 111 .map( 112 (line, i) => 113 `<text x="60" y="${String(280 + i * 60)}" font-family="sans-serif" font-size="48" font-weight="bold" fill="white">${escapeXml(line)}</text>` 114 ) 115 .join('\n ') 116 117 return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> 118 <rect width="${width}" height="${height}" fill="#1c1b22"/> 119 120 <!-- Category badge --> 121 <rect x="60" y="60" width="${categoryWidth}" height="36" rx="8" fill="#0ea5e9"/> 122 <text x="${categoryTextX}" y="${categoryTextY}" font-family="sans-serif" font-size="16" font-weight="600" fill="white" dominant-baseline="central">${categoryText}</text> 123 124 <!-- Community name --> 125 <text x="60" y="150" font-family="sans-serif" font-size="24" fill="#9ca3af">${communityText}</text> 126 127 <!-- Topic title --> 128 ${titleSvg} 129 130 <!-- Barazo branding --> 131 <text x="60" y="${footerY}" font-family="sans-serif" font-size="18" fill="#6b7280">Powered by Barazo</text> 132 <text x="${brandingX}" y="${footerY}" font-family="sans-serif" font-size="18" fill="#6b7280" text-anchor="end">barazo.forum</text> 133</svg>` 134} 135 136// --------------------------------------------------------------------------- 137// PNG generation 138// --------------------------------------------------------------------------- 139 140/** 141 * Generate a branded OG image as a PNG buffer. 142 * 143 * Produces a 1200x630 PNG suitable for use as the `thumb` in 144 * Bluesky's `app.bsky.embed.external` records. 145 */ 146export async function generateOgImage(params: OgImageParams): Promise<Buffer> { 147 const svg = generateOgSvg(params) 148 return sharp(Buffer.from(svg)).png().toBuffer() 149}