feat(design): deploy default OG card (JPG/PNG), complete banner hygiene (E8), modernize CJK typography (E9), align title & hero (A2)
Deploy / build (push) Successful in 21s
Deploy / build (push) Successful in 21s
This commit is contained in:
1 parent
c1aa62ecca
commit
3f63150d51
17 files changed
+564
-42
No files matched your search
@@ -0,0 +1,140 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in new issue
Block a user