/** * Weekly newsletter cover generator. * * Uses the existing Drop #006 cover as the visual foundation: the same black / * orange palette, the urban sunset photography and the route-line motif. Only * the issue number, headline, date and copy change. Output is 1200×630 PNG. */ import { join } from 'node:path'; import sharp from 'sharp'; const SOURCE = join(process.cwd(), 'public/images/drop-006-cover.png'); function esc(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function lines(text: string, maxChars: number, maxLines = 3): string[] { const words = text.toUpperCase().split(/\s+/); const out: string[] = []; let current = ''; for (const word of words) { const next = current ? `${current} ${word}` : word; if (next.length > maxChars && current) { out.push(current); current = word; if (out.length === maxLines - 1) break; } else { current = next; } } if (current && out.length < maxLines) out.push(current); return out; } export async function generateNewsletterCover(params: { number: number; dateLabel: string; coverTitle: string; outputPath: string; }): Promise { const { number, dateLabel, coverTitle, outputPath } = params; const issue = `#${String(number).padStart(3, '0')}`; const titleLines = lines(coverTitle, 15, 3); const source = sharp(SOURCE); const meta = await source.metadata(); const width = meta.width ?? 1059; const height = meta.height ?? 537; // Take the city/sunset side of the old cover, away from the big old headline. const cropWidth = Math.max(320, Math.min(width - 640, 520)); const hero = await sharp(SOURCE) .extract({ left: Math.max(0, width - cropWidth - 20), top: Math.max(0, Math.floor(height * 0.2)), width: cropWidth, height: Math.floor(height * 0.62), }) .resize(560, 340, { fit: 'cover' }) .modulate({ brightness: 0.82, saturation: 0.9 }) .toBuffer(); const titleSvg = titleLines .map( (line, i) => `${esc(line)}`, ) .join(''); const overlay = ` SKATESPOTS NEWSLETTER REAL SPOTS REAL PEOPLE REAL SKATEBOARDING DROP ${issue} THIS WEEK ${esc(dateLabel)} ${titleSvg} SPOTS SHOPS PEOPLE MORE SPOTS skate-spots.org/newsletter `; const svgBuffer = Buffer.from(overlay); await sharp({ create: { width: 1200, height: 630, channels: 4, background: '#080808' }, }) .composite([ { input: hero, left: 620, top: 130, blend: 'over' }, { input: svgBuffer, left: 0, top: 0, blend: 'over' }, ]) .png({ quality: 92 }) .toFile(outputPath); return outputPath; } if (process.argv[1]?.endsWith('newsletter-cover.ts')) { const out = process.argv[2] ?? '/tmp/opencode/newsletter-cover.png'; await generateNewsletterCover({ number: 7, dateLabel: '28 OCTOBER 2026', coverTitle: 'THE PRIVATE CHAT GROWS UP', outputPath: out, }); console.log(out); }