Something went wrong. Try again.
Central platform for European atproto.<cc> country community websites atproto.eu
community atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181// Generate the Open Graph share cards (1200x630) in the atproto.<cc> 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.<cc> 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 <country>" 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 }) => `<!doctype html><html><head><meta charset="utf-8"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Next:wght@400;600&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"><style> * { margin: 0; box-sizing: border-box; } html, body { width: ${W}px; height: ${H}px; } body { background: ${PAPER}; color: ${INK}; font-family: 'Atkinson Hyperlegible Next', sans-serif; position: relative; overflow: hidden; } /* The localness character-map, full-bleed. The country sits right-aligned (the renderer shifts it ~2 columns from the right), leaving the left for the wordmark + tagline. */ .map { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } /* Scrim: keep the left-hand text legible while the country stays readable on the right. */ .scrim { position: absolute; inset: 0; background: linear-gradient(90deg, ${PAPER} 0%, ${PAPER} 30%, rgba(40,40,40,0.72) 52%, rgba(40,40,40,0) 78%); } .content { position: absolute; inset: 0; padding: 88px 96px; display: flex; flex-direction: column; justify-content: space-between; } .wordmark { font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 108px; letter-spacing: -0.01em; color: ${INKSTRONG}; line-height: 1; } .wordmark .s1 { color: ${accent}; } .wordmark .s2 { color: ${accent2}; margin-left: 0.05em; } .tagline { font-size: 46px; font-weight: 400; color: ${INK}; max-width: 760px; line-height: 1.25; } .rule { height: 6px; width: 128px; background: ${accent}; border-radius: 3px; margin-bottom: 40px; } .foot { font-family: 'JetBrains Mono', monospace; font-weight: 500; font-size: 26px; letter-spacing: 0.08em; color: ${INK}; opacity: 0.85; text-transform: uppercase; } .foot .sep { color: ${RULE}; padding: 0 14px; }</style></head><body> <img class="map" src="${mapDataUri}" alt=""> <div class="scrim"></div> <div class="content"> <div><div class="wordmark"><span class="s1">/</span><span class="s2">/</span>atproto.${cc}</div></div> <div> <div class="rule"></div> <div class="tagline">${tagline}</div> </div> <div class="foot">Meetups<span class="sep">/</span>Members<span class="sep">/</span>Tools</div> </div></body></html>`;
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();