141 lines
6.6 KiB
JavaScript
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 `&` → `&`.
|
|
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);
|
|
}
|