SkateSpots/scripts/newsletter-cover.ts
SkateSpots f029b5a516 feat(newsletter): weekly generated drafts, covers, and admin preview
- Add a browser-safe editorial generator with rotating satire and shipped highlights.
- Generate a fresh 1200x630 cover from the Drop #006 foundation and upload it to Blossom.
- Publish weekly drafts as NIP-78; add the admin Load draft/Preview composer flow and banner.
- Add the Monday user timer (draft by default) and a manual newsletter:weekly script.
- Remove static/live issue duplicates; translate the new admin strings to all 24 locales.
2026-10-28 09:42:44 +07:00

134 lines
5.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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<string> {
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) =>
`<text x="56" y="${272 + i * 76}" font-family="DejaVu Sans, sans-serif" font-size="62" font-weight="900" fill="#ffffff">${esc(line)}</text>`,
)
.join('');
const overlay = `
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#080808" stop-opacity="1"/>
<stop offset="0.58" stop-color="#080808" stop-opacity="0.35"/>
<stop offset="1" stop-color="#080808" stop-opacity="0.08"/>
</linearGradient>
<linearGradient id="bottom" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#080808" stop-opacity="0"/>
<stop offset="1" stop-color="#080808" stop-opacity="0.92"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#fade)"/>
<rect x="560" y="120" width="600" height="380" fill="url(#bottom)"/>
<g font-family="DejaVu Sans, sans-serif">
<text x="56" y="62" font-size="13" font-weight="700" letter-spacing="4" fill="#ff5a1f">SKATESPOTS</text>
<text x="56" y="88" font-size="30" font-weight="900" fill="#ffffff">NEWSLETTER</text>
<text x="760" y="62" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">REAL SPOTS</text>
<text x="760" y="82" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">REAL PEOPLE</text>
<text x="760" y="102" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">REAL SKATEBOARDING</text>
<rect x="978" y="42" width="168" height="40" rx="20" fill="none" stroke="#ff5a1f" stroke-width="2"/>
<text x="1062" y="68" text-anchor="middle" font-size="14" font-weight="900" fill="#ff5a1f">DROP ${issue}</text>
<text x="56" y="160" font-size="12" font-weight="700" letter-spacing="4" fill="#ff5a1f">THIS WEEK</text>
<text x="56" y="190" font-size="13" font-weight="700" fill="#a3a3a3">${esc(dateLabel)}</text>
${titleSvg}
<line x1="56" y1="510" x2="520" y2="510" stroke="#ff5a1f" stroke-width="3"/>
<text x="56" y="548" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">SPOTS</text>
<text x="160" y="548" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">SHOPS</text>
<text x="270" y="548" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">PEOPLE</text>
<text x="390" y="548" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">MORE SPOTS</text>
<polyline points="58,530 115,530 170,500 235,515 300,490 375,505 445,470 510,480" fill="none" stroke="#ff5a1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="58" cy="530" r="6" fill="#ff5a1f"/>
<circle cx="510" cy="480" r="6" fill="#ff5a1f"/>
<text x="1144" y="590" text-anchor="end" font-size="11" font-weight="700" fill="#737373">skate-spots.org/newsletter</text>
</g>
</svg>`;
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);
}