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>
126 lines
8.7 KiB
JavaScript
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());
|