igneum/site/og/render.mjs
igneum-labs a4c5b6c1e8 site: share cards and metas from one source (site/og), rendered on a build box
Every served page now carries its og: and twitter: block from site/og/pages.mjs through site/build.mjs (injected between
share markers on the hand-written pages, through meta() on the generated ones); nothing is pasted per page. New cards under
site/og/: eighteen 1200 x 630 PNGs at about 100 KB and a 1200 x 1200 square each (the second og:image for the readers
that crop square), rendered from site/og/card.html by site/og/render.mjs on build box 2 with its Playwright, never on the
Mac. The old og.png family stays on disk for cached links; nothing points at it. Titles under 60, descriptions under 110,
checked by the build. One command refreshes every card: node site/og/render.mjs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:51:03 +00:00

126 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
// Render the share cards (the link-preview lane, 8 October 2026). One command refreshes every card:
//
// node site/og/render.mjs ship site/og and site/fonts to build box 2, render there with its Playwright, bring the
// PNGs back into site/og/ (the Mac renders nothing: the standing rule of 7 October 2026)
// node site/og/render.mjs --box 1 the same on box 1
// node site/og/render.mjs --local render HERE (what the box runs; needs Playwright on the path, env.sh sets NODE_PATH)
// node site/og/render.mjs --previews DIR also render the reader mocks (WhatsApp large and square, iMessage, X, Slack) for every
// card from site/og/preview.html into DIR (not committed)
// node site/og/render.mjs --only home one card
//
// Output: site/og/<key>.png (1200 x 630) and site/og/<key>-square.png (1200 x 1200), one per CARDS row in site/og/pages.mjs.
// Checks on every render: the stack fits above the foot, the line holds at most two lines (three on the square), the wide PNG
// is under 280 KB (WhatsApp's ceiling is 300 KB). A failed check is a non-zero exit and names the card.
// Then: bump OG_VERSION in site/og/pages.mjs, `node site/build.mjs`, commit the PNGs with the pages.
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from 'node:fs';
import { createServer } from 'node:http';
import { join, dirname, basename, resolve, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
const here = dirname(fileURLToPath(import.meta.url));
const site = resolve(here, '..');
const root = resolve(site, '..');
const args = process.argv.slice(2);
const has = f => args.includes(f);
const opt = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : d; };
const ONLY = opt('--only', '');
const PREVIEWS = opt('--previews', '');
const WIDE_LIMIT = 280 * 1024;
function loadPlaywright() {
const dirs = [root, process.env.IGNEUM_PLAYWRIGHT_DIR, '/srv/builds/_bin/overlap'].filter(Boolean);
for (const d of dirs) { try { return createRequire(join(d, 'package.json'))('playwright'); } catch (e) { /* next */ } }
try { return createRequire(import.meta.url)('playwright'); } catch (e) { return null; }
}
// a static server for site/ (the template imports pages.mjs as a module, which file:// refuses)
function serve(dir) {
const types = { '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json' };
const srv = createServer((rq, rs) => {
const p = decodeURIComponent(new URL(rq.url, 'http://x').pathname);
const f = resolve(dir, '.' + p);
if (!f.startsWith(dir) || !existsSync(f) || statSync(f).isDirectory()) { rs.writeHead(404); rs.end(); return; }
rs.writeHead(200, { 'Content-Type': types[extname(f)] || 'application/octet-stream' }); rs.end(readFileSync(f));
});
return new Promise(res => srv.listen(0, '127.0.0.1', () => res({ base: `http://127.0.0.1:${srv.address().port}`, close: () => srv.close() })));
}
async function renderLocal() {
const pw = loadPlaywright();
if (!pw) { console.error('render: Playwright is not installed here (on a build box: . /srv/builds/_bin/overlap/env.sh)'); return 2; }
const { CARDS, SHARE } = await import('./pages.mjs');
const keys = Object.keys(CARDS).filter(k => !ONLY || k === ONLY);
const { base, close } = await serve(site);
const browser = await pw.chromium.launch({ args: ['--no-sandbox', '--disable-gpu', '--font-render-hinting=none'] });
const fails = [];
try {
const ctx = await browser.newContext({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
page.on('pageerror', e => console.error('page error: ' + e.message)); page.on('console', m => { if (m.type() === 'error') console.error('console: ' + m.text()); });
for (const key of keys) for (const size of ['wide', 'square']) {
await page.goto(`${base}/og/card.html?card=${key}&size=${size}`, { waitUntil: 'load' });
await page.waitForFunction(() => window.__card, null, { timeout: 15000 });
await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(150);
const info = await page.evaluate(() => window.__card);
const out = join(here, size === 'wide' ? `${key}.png` : `${key}-square.png`);
const el = await page.$('#stage'); await el.screenshot({ path: out, type: 'png', omitBackground: false });
const bytes = statSync(out).size;
const maxLines = size === 'wide' ? 2 : 3;
if (!info.fits) fails.push(`${key} ${size}: the stack does not fit above the foot`);
if (info.lines > maxLines) fails.push(`${key} ${size}: the line wraps to ${info.lines} lines (limit ${maxLines})`);
if (size === 'wide' && bytes > WIDE_LIMIT) fails.push(`${key} wide: ${Math.round(bytes / 1024)} KB, over the 280 KB ceiling`);
console.log(`${basename(out)}: ${size === 'wide' ? '1200x630' : '1200x1200'}, ${Math.round(bytes / 1024)} KB, ${info.lines} line${info.lines === 1 ? '' : 's'}${info.fits ? '' : ', DOES NOT FIT'}`);
}
if (PREVIEWS) {
const dir = resolve(PREVIEWS); mkdirSync(dir, { recursive: true });
const routes = Object.entries(SHARE).filter(([, r]) => keys.includes(r.card));
// one preview sheet per card: the route that owns it (the first route naming it)
const seen = new Set();
for (const [path, r] of routes) {
if (seen.has(r.card)) continue; seen.add(r.card);
const q = new URLSearchParams({ card: r.card, title: r.title, desc: r.desc, path }).toString();
await page.setViewportSize({ width: 1180, height: 1500 });
await page.goto(`${base}/og/preview.html?${q}`, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(250);
for (const [id, name] of [['sheet', 'sheet'], ['wa-large', 'whatsapp-large'], ['wa-small', 'whatsapp-square'], ['imessage', 'imessage'], ['x', 'x'], ['slack', 'slack']]) {
const el = await page.$('#' + id); if (!el) continue;
await el.screenshot({ path: join(dir, `${r.card}--${name}.png`), type: 'png' });
}
console.log(`previews: ${r.card} (${path}) -> ${dir}`);
}
}
} finally { await browser.close(); close(); }
if (fails.length) { console.error('render FAILED:\n' + fails.join('\n')); return 1; }
console.log(`rendered ${keys.length} cards x 2 sizes into ${here}`);
return 0;
}
// ship to a box and run there (the overlap check's shape: tools/ci/overlap-check.mjs runRemote)
function hostFile(box) { const b = join(os.homedir(), '.config', 'igneum', 'build-server'); return box === '1' ? b : b + '-' + box; }
function runRemote() {
const box = String(opt('--box', '2'));
const hf = hostFile(box); if (!existsSync(hf)) { console.error(`render: no box host file at ${hf}`); return 2; }
const host = readFileSync(hf, 'utf8').trim(); const key = join(os.homedir(), '.ssh', 'igneum_ed25519');
const wt = basename(root); const remote = `/srv/builds/_og/${wt}`;
const ssh = ['-i', key, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new'];
const sh = (cmd) => spawnSync('ssh', ssh.concat([host, cmd]), { stdio: 'inherit' });
const rs = (src, dst, extra = []) => { const r = spawnSync('rsync', ['-a', '--delete', ...extra, '-e', `ssh ${ssh.join(' ')}`, src + '/', `${host}:${dst}/`], { stdio: 'inherit' }); if (r.status !== 0) throw new Error('rsync failed for ' + src); };
sh(`mkdir -p ${remote}/site/og ${remote}/site/fonts`);
rs(join(site, 'og'), `${remote}/site/og`, ['--exclude', '*.png']);
rs(join(site, 'fonts'), `${remote}/site/fonts`);
const rargs = ['--local']; if (ONLY) rargs.push('--only', ONLY); if (PREVIEWS) rargs.push('--previews', 'previews');
console.log(`render: on box ${box} (${host}:${remote})`);
const r = sh(`cd ${remote} && . /srv/builds/_bin/overlap/env.sh && nice -n 10 node site/og/render.mjs ${rargs.map(a => `'${a}'`).join(' ')}`);
const back = (what, to) => spawnSync('rsync', ['-a', '-e', `ssh ${ssh.join(' ')}`, `${host}:${remote}/${what}`, to], { stdio: 'inherit' });
back('site/og/*.png', here + '/');
if (PREVIEWS) { const dir = resolve(PREVIEWS); mkdirSync(dir, { recursive: true }); back('previews/', dir + '/'); }
const pngs = readdirSync(here).filter(f => f.endsWith('.png'));
console.log(`render: ${pngs.length} PNGs in ${here}${PREVIEWS ? `, previews in ${resolve(PREVIEWS)}` : ''}`);
return r.status ?? 1;
}
if (has('--local')) process.exit(await renderLocal()); else process.exit(runRemote());