import sharp from 'sharp'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OG_WIDTH = 1200
const OG_HEIGHT = 630
const MAX_TITLE_LINES = 3
const MAX_CHARS_PER_LINE = 38
// ---------------------------------------------------------------------------
// Helpers (exported for testing)
// ---------------------------------------------------------------------------
/**
* Escape special XML characters to prevent SVG injection.
*/
export function escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
/**
* Word-wrap text into lines of at most `maxCharsPerLine` characters,
* limited to `maxLines` total. If truncated, the last line ends with
* an ellipsis character.
*/
export function wrapText(text: string, maxCharsPerLine: number, maxLines: number): string[] {
const words = text.split(/\s+/).filter((w) => w.length > 0)
if (words.length === 0) {
return []
}
const lines: string[] = []
let currentLine = ''
for (const word of words) {
if (lines.length >= maxLines) {
break
}
const testLine = currentLine ? `${currentLine} ${word}` : word
if (testLine.length > maxCharsPerLine && currentLine) {
lines.push(currentLine)
currentLine = word
} else {
currentLine = testLine
}
}
if (currentLine && lines.length < maxLines) {
lines.push(currentLine)
}
// Check if text was truncated
const joinedLength = lines.join(' ').length
const fullLength = words.join(' ').length
if (fullLength > joinedLength && lines.length > 0) {
const lastIdx = lines.length - 1
const lastLine = lines[lastIdx] ?? ''
if (lastLine.length > maxCharsPerLine - 1) {
lines[lastIdx] = lastLine.slice(0, maxCharsPerLine - 1) + '\u2026'
} else {
lines[lastIdx] = lastLine + '\u2026'
}
}
return lines
}
// ---------------------------------------------------------------------------
// SVG generation
// ---------------------------------------------------------------------------
export interface OgImageParams {
title: string
category: string
communityName: string
}
/**
* Generate an SVG string for a cross-post OG image.
*
* Layout (1200x630):
* - Dark background (#1c1b22)
* - Category badge (cyan pill)
* - Community name (grey subheading)
* - Topic title (white, word-wrapped, max 3 lines)
* - Barazo branding footer
*/
export function generateOgSvg(params: OgImageParams): string {
const titleLines = wrapText(params.title, MAX_CHARS_PER_LINE, MAX_TITLE_LINES)
const categoryText = escapeXml(params.category.toUpperCase())
const communityText = escapeXml(params.communityName)
// Estimate category badge width (~11px per char + 32px padding)
const categoryWidth = String(Math.max(categoryText.length * 11 + 32, 60))
const categoryTextX = String(60 + 16)
const categoryTextY = String(60 + 24)
const footerY = String(OG_HEIGHT - 40)
const brandingX = String(OG_WIDTH - 60)
const width = String(OG_WIDTH)
const height = String(OG_HEIGHT)
const titleSvg = titleLines
.map(
(line, i) =>
`${escapeXml(line)}`
)
.join('\n ')
return ``
}
// ---------------------------------------------------------------------------
// PNG generation
// ---------------------------------------------------------------------------
/**
* Generate a branded OG image as a PNG buffer.
*
* Produces a 1200x630 PNG suitable for use as the `thumb` in
* Bluesky's `app.bsky.embed.external` records.
*/
export async function generateOgImage(params: OgImageParams): Promise {
const svg = generateOgSvg(params)
return sharp(Buffer.from(svg)).png().toBuffer()
}