Files
hoelee-blog/scripts/og-gen/generate-default.mjs
T

141 lines
6.6 KiB
JavaScript

#!/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('</body>', `
<script>
window.addEventListener('load', () => {
const r = (s) => { const e = document.querySelector(s); if (!e) return null;
const b = e.getBoundingClientRect();
return { top: Math.round(b.top), bottom: Math.round(b.bottom), left: Math.round(b.left),
right: Math.round(b.right), w: Math.round(b.width), h: Math.round(b.height),
fs: Math.round(parseFloat(getComputedStyle(e).fontSize)) }; };
const body = document.body;
document.title = 'MEASURE' + JSON.stringify({
scrollH: body.scrollHeight, clientH: body.clientHeight,
scrollW: body.scrollWidth, clientW: body.clientWidth,
header: r('.header'), h1: r('h1'), content: r('.left-content'),
portrait: r('.portrait-frame'), workstream: r('.workstream'), caption: r('.portrait-caption'),
text: body.innerText.slice(0, 400),
});
});
</script>
</body>`);
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 `&` → `&amp;`.
M.text = M.text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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);
}