- 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.
134 lines
5.6 KiB
TypeScript
134 lines
5.6 KiB
TypeScript
/**
|
||
* 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<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);
|
||
}
|