Barazo AppView backend
barazo.forum
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, '&')
22 .replace(/</g, '<')
23 .replace(/>/g, '>')
24 .replace(/"/g, '"')
25 .replace(/'/g, ''')
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}