// Generate the Open Graph share cards (1200x630) in the atproto. design system — one per scope. // One-off / manual tooling, not part of `npm run build`. Regenerate all with: // node scripts/og-card.mjs // or a subset: // node scripts/og-card.mjs be eu // // Each card mirrors the homepage hero: the localness character-map of the country // (public/map/locality-map.*), full-bleed behind the //atproto. wordmark. The two accent // glyphs (// and .) and the rule take the country's own accent, so no two cards read the same // colour. There is deliberately NO bespoke avatar/logo: the wordmark + map ARE the mark. // // Renders with Playwright (a dev dep via @axe-core/playwright): the map is screenshotted from a // tiny static server rooted at public/ (its assets use absolute /map/... paths), then composited // into the card. Output downscaled 2x -> 1200x630 with sharp for crisp text. import { chromium } from 'playwright'; import sharp from 'sharp'; import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, resolve, extname } from 'node:path'; import { ACCENT_PACKS } from '../src/config/accents.ts'; import { SCOPES as SCOPES_BY_ID } from '../src/config/scopes.ts'; import { EU27, ALSO_IN_EUROPE } from '../src/data/europe-regions.ts'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); const pub = resolve(root, 'public'); const W = 1200; const H = 630; // Gruvbox dark tokens (from src/styles/global.css [data-theme='dark']). The card is always dark. const PAPER = '#282828'; const INK = '#ebdbb2'; const INKSTRONG = '#fbf1c7'; const RULE = '#504945'; // Scope list is derived from the single sources of truth: scopes.ts (which pack + cc per scope) // and accents.ts (pack -> hex). The only thing kept here is the English "in " phrase, // which mirrors src/i18n/ui.ts IN_COUNTRY[*].en (not exported; 5 low-churn strings). const IN_COUNTRY_EN = { nl: 'the Netherlands', be: 'Belgium', no: 'Norway', hu: 'Hungary', it: 'Italy', ch: 'Switzerland', eu: 'Europe', }; const SCOPES = Object.values(SCOPES_BY_ID).map((s) => ({ id: s.cc, // scope id === cc for every scope cc: s.cc, pack: s.accent, // the data-accent pack name, passed to the map (?accent=) accent: ACCENT_PACKS[s.accent].dark.accent, // card is dark -> dark accent role (slash 1 + dot + rule) accent2: ACCENT_PACKS[s.accent2].dark.accent, // second slash in: IN_COUNTRY_EN[s.cc] ?? s.cc, })); const MAP_VERSION = '9'; // keep in sync with src/components/LocalityMap.astro MAP_VERSION const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2', '.png': 'image/png', '.svg': 'image/svg+xml', '.css': 'text/css; charset=utf-8', }; // Minimal static server rooted at public/, so the map's absolute /map/... paths resolve. const server = createServer(async (req, res) => { try { const url = new URL(req.url, 'http://localhost'); const file = resolve(pub, '.' + url.pathname); if (!file.startsWith(pub)) return res.writeHead(403).end(); const body = await readFile(file); res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' }); res.end(body); } catch { res.writeHead(404).end(); } }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const port = server.address().port; const cardHtml = ({ cc, accent, accent2, tagline, mapDataUri }) => `
//atproto.${cc}
${tagline}
Meetups/Members/Tools
`; const only = new Set(process.argv.slice(2).map((s) => s.toLowerCase())); const scopes = only.size ? SCOPES.filter((s) => only.has(s.id)) : SCOPES; const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2, colorScheme: 'dark', // the map reads prefers-color-scheme when no theme is pinned }); for (const s of scopes) { // 1) Render the country character-map on its own, at card size, and grab it as a PNG. const mapPage = await ctx.newPage(); // On the .eu aggregate, colour the map by region exactly like the homepage hero: EU members bright, // the "also in Europe" set (incl. Ukraine) dim, the rest dots. National cards use the target-country // logic and need neither list. const regions = s.cc === 'eu' ? `&active=${EU27.join(',')}&lit=${ALSO_IN_EUROPE.join(',')}` : ''; const mapUrl = `http://127.0.0.1:${port}/map/locality-map.html` + `?cc=${s.cc}&accent=${s.pack}&w=${W}&h=${H}&fit=cover&motion=off&v=${MAP_VERSION}${regions}`; await mapPage.goto(mapUrl, { waitUntil: 'networkidle' }); await mapPage.waitForFunction(() => document.querySelectorAll('svg text').length > 20, { timeout: 15000, }); const mapBuf = await mapPage.screenshot({ type: 'png' }); await mapPage.close(); const mapDataUri = `data:image/png;base64,${mapBuf.toString('base64')}`; // 2) Composite it behind the wordmark + tagline. const page = await ctx.newPage(); await page.setContent( cardHtml({ cc: s.cc, accent: s.accent, accent2: s.accent2, tagline: `The AT Protocol community in ${s.in}`, mapDataUri }), { waitUntil: 'networkidle' }, ); await page.evaluate(() => document.fonts.ready); const buf = await page.screenshot({ type: 'png' }); await page.close(); const name = s.id === 'nl' ? 'og.png' : `og-${s.id}.png`; await sharp(buf).resize(W, H).png({ compressionLevel: 9 }).toFile(resolve(pub, 'brand', name)); console.log(`Wrote public/brand/${name} (${W}x${H}) — accent ${s.accent}`); } await browser.close(); server.close();