#!/usr/bin/env node /** * og-default-gen — regenerate the site-wide default OG card (public/og-default.png). * * WHY: the card that shipped since 2026-09-06 still carried the retired brand name * "hoelee.dev" and had no face. This is the fallback card for the homepage and the * category pages (BaseLayout default + PostList fallback), so it is the share preview * most people see when they link to the blog at all. * * DESIGN PROVENANCE: the layout in template-default.html is an outside delivery * (2026-10-17, `hoelee-og-delivery`) — fixed-size 1200x630 HTML with inline CSS and a * pixel spec. That spec is kept in-repo as `og-default-specs.md` beside this script. * A previous in-house attempt was rejected on sight by the owner, so **do not redesign * this card here** — change the template only against a new delivery. * * Usage: * node scripts/og-gen/generate-default.mjs * * Renders template-default.html with the headshot + brand mark, MEASURES the layout in * the real DOM (--dump-dom, never a screenshot read), enforces the spec's acceptance * checks, then writes public/og-default.png (1200x630). */ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import sharp from 'sharp'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..', '..'); const fileUrl = (p) => 'file:///' + p.replace(/\\/g, '/'); const photo = fileUrl(join(ROOT, 'public', 'images', 'lee-teong-hoe-640.jpg')); const logo = fileUrl(join(ROOT, 'public', 'logo-square.png')); const chrome = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; for (const [label, p] of [['photo', photo], ['logo', logo]]) { if (!existsSync(p.replace('file:///', ''))) { console.error(`missing ${label} asset: ${p}`); process.exit(1); } } const tpl = readFileSync(join(__dirname, 'template-default.html'), 'utf8') .replace('{{PHOTO_PATH}}', photo) .replace('{{LOGO_PATH}}', logo); if (/\{\{[A-Z_]+\}\}/.test(tpl)) { console.error('unsubstituted placeholder left in the template:', tpl.match(/\{\{[A-Z_]+\}\}/g)); process.exit(1); } const outDir = join(ROOT, '.og', 'gen'); mkdirSync(outDir, { recursive: true }); // --- pass 1: measure the layout in the real DOM ------------------------------- const probe = tpl.replace('', ` `); const probePath = join(outDir, 'og-default-probe.html'); writeFileSync(probePath, probe); let dom = ''; try { dom = execSync(`"${chrome}" --headless --disable-gpu --virtual-time-budget=4000 --dump-dom "${fileUrl(probePath)}"`, { stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }).toString(); } catch (e) { console.error('probe render failed:', e.message); process.exit(1); } const m = /MEASURE(\{.*?\})<\/title>/s.exec(dom); if (!m) { console.error('probe did not report — no MEASURE payload in the DOM'); process.exit(1); } const M = JSON.parse(m[1]); // The payload rode inside document.title, so the DOM serialiser escaped `&` → `&`. M.text = M.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); // --- pass 2: render the real card -------------------------------------------- const htmlPath = join(outDir, 'og-default.html'); writeFileSync(htmlPath, tpl); const shot = join(outDir, 'og-default-raw.png'); try { execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --hide-scrollbars --window-size=1200,900 --virtual-time-budget=3000 --screenshot="${shot}" "${fileUrl(htmlPath)}"`, { stdio: 'pipe' }); } catch (e) { console.error('Chrome render failed:', e.message); process.exit(1); } const finalJpg = join(ROOT, 'public', 'og-default.jpg'); await sharp(shot).extract({ left: 0, top: 0, width: 1200, height: 630 }) .jpeg({ quality: 85, mozjpeg: true }) .toFile(finalJpg); // Keep og-default.png as a backward-compatible alias / fallback const finalPng = join(ROOT, 'public', 'og-default.png'); await sharp(shot).extract({ left: 0, top: 0, width: 1200, height: 630 }).toFile(finalPng); // --- acceptance checks (spec: og-default-specs.md) ---------------------------- const lowest = Math.max(M.workstream.bottom, M.caption.bottom, M.portrait.bottom); const checks = [ ['no overflow', !(M.scrollH > M.clientH || M.scrollW > M.clientW), `scrollH ${M.scrollH} vs ${M.clientH} · scrollW ${M.scrollW} vs ${M.clientW}`], ['safe margin >= 64px', M.content.left >= 64 && 1200 - M.portrait.right >= 64, `left ${M.content.left} · right ${1200 - M.portrait.right}`], ['headline >= 48px', M.h1.fs >= 48, `${M.h1.fs}px`], ['bottom clear >= 64px', 630 - lowest >= 64, `lowest element ends at y=${lowest}`], ['retired wordmark gone', !/hoelee\.dev/i.test(M.text), 'no "hoelee.dev" in the rendered text'], ['identity lines present', /Mr Hoelee/.test(M.text) && /Lee Teong Hoe/.test(M.text) && /Full-stack Developer & DevOps Engineer/.test(M.text), 'Mr Hoelee / Lee Teong Hoe / job title'], ]; const failed = checks.filter(([, ok]) => !ok); console.log(JSON.stringify(M, null, 1)); console.log(''); for (const [name, ok, detail] of checks) console.log(`${ok ? 'PASS' : 'FAIL'} ${name} — ${detail}`); console.log(`\n✓ ${finalJpg}`); const metaJpg = await sharp(finalJpg).metadata(); console.log(` size: ${metaJpg.width}x${metaJpg.height} ${metaJpg.format} (${Math.round(statSync(finalJpg).size / 1024)} KB)`); console.log(`✓ ${finalPng}`); const meta = await sharp(finalPng).metadata(); console.log(` size: ${meta.width}x${meta.height} ${meta.format} (${Math.round(statSync(finalPng).size / 1024)} KB)`); if (failed.length) { console.error(`\n✗ ${failed.length} acceptance check(s) failed — do NOT ship this card.`); process.exit(2); }