og.mjs (7045B)
1 /** 2 * Social card — `npm run og`. 3 * 4 * Renders public/og.png (1200×630) from the SVG template below, using the 5 * site's own faces from src/fonts via a private fontconfig so the card is the 6 * site (Archivo display, DM Mono labels) rather than whatever the build box 7 * happens to have installed. The PNG is committed and served as a static 8 * asset — it is NOT rendered at build time on purpose: Vercel's image has 9 * different fonts, and a card that changes shape per environment is worse 10 * than one that changes only when someone re-runs this. 11 * 12 * Re-run after the dataset changes so the counts on the card stay honest. 13 */ 14 import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from 'node:fs'; 15 import { spawnSync } from 'node:child_process'; 16 import { fileURLToPath } from 'node:url'; 17 import { dirname, join, resolve } from 'node:path'; 18 import { tmpdir } from 'node:os'; 19 20 const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); 21 const FONTS = join(ROOT, 'src', 'fonts'); 22 const facets = JSON.parse(readFileSync(join(ROOT, 'src/data/facets.json'), 'utf8')); 23 const c = facets.counts; 24 const n = (x) => Number(x || 0).toLocaleString('en-US'); 25 26 // A private fontconfig that sees only the site's faces. sharp's bundled 27 // FreeType registers WOFF2 (fc-list sees them) but cannot rasterise their 28 // glyphs — every run came out as tofu — so decompress to TTF first. Needs 29 // `woff2_decompress` (package "woff2") or fonttools+brotli on PATH; both are 30 // tried. Must be in place before sharp (libvips → pango → fontconfig) loads. 31 const fcDir = join(tmpdir(), 'daemonbins-og-fc'); 32 const ttfDir = join(fcDir, 'ttf'); 33 mkdirSync(join(fcDir, 'cache'), { recursive: true }); 34 mkdirSync(ttfDir, { recursive: true }); 35 function decompress(woff2, ttf) { 36 let r = spawnSync('woff2_decompress', [woff2], { stdio: 'ignore' }); 37 if (r.status === 0) { 38 // woff2_decompress writes <name>.ttf beside the input; move it. 39 const beside = woff2.replace(/\.woff2$/, '.ttf'); 40 if (existsSync(beside)) { writeFileSync(ttf, readFileSync(beside)); spawnSync('rm', ['-f', beside]); return true; } 41 } 42 r = spawnSync('fonttools', ['ttLib.woff2', 'decompress', '-o', ttf, woff2], { stdio: 'ignore' }); 43 return r.status === 0 && existsSync(ttf); 44 } 45 let converted = 0; 46 for (const f of readdirSync(FONTS).filter((f) => f.endsWith('.woff2'))) { 47 const ttf = join(ttfDir, f.replace(/\.woff2$/, '.ttf')); 48 if (existsSync(ttf) || decompress(join(FONTS, f), ttf)) converted++; 49 } 50 if (!converted) { 51 console.warn('og: custom WOFF2 faces unavailable; using the system fallback fonts.'); 52 } else { 53 writeFileSync(join(fcDir, 'fonts.conf'), `<?xml version="1.0"?> 54 <!DOCTYPE fontconfig SYSTEM "fonts.dtd"> 55 <fontconfig> 56 <dir>${ttfDir}</dir> 57 <cachedir>${join(fcDir, 'cache')}</cachedir> 58 </fontconfig> 59 `); 60 process.env.FONTCONFIG_FILE = join(fcDir, 'fonts.conf'); 61 } 62 const sharp = (await import('sharp')).default; 63 64 // Rosé Pine (night) — the card is always the dark plate, like the code blocks. 65 const P = { 66 base: '#191724', surface: '#1f1d2e', overlay: '#26233a', 67 text: '#e0def4', subtle: '#908caa', muted: '#6e6a86', 68 love: '#eb6f92', rose: '#ebbcba', gold: '#f6c177', iris: '#c4a7e7', foam: '#9ccfd8', pine: '#3e8fb0', 69 }; 70 // Noto Sans Display is the masthead wordmark face (tokens.css --font-wordmark); 71 // the Archivo subsets do not survive the WOFF2→TTF trip, so the card uses the 72 // wordmark face for all display text. 73 const DISPLAY = "'Noto Sans Display', 'Archivo', 'Liberation Sans', sans-serif"; 74 const MONO = "'DM Mono', 'JetBrains Mono', 'Liberation Mono', monospace"; 75 76 const decks = [ 77 { tag: 'GTFOBins', acc: P.foam, n: c.bySource?.GTFOBins }, 78 { tag: 'LOLBAS', acc: P.iris, n: c.bySource?.LOLBAS }, 79 { tag: 'WADComs', acc: P.gold, n: c.bySource?.WADComs }, 80 { tag: 'LOOBins', acc: P.rose, n: c.bySource?.LOOBins }, 81 { tag: 'DÆMON', acc: P.love, n: c.bySource?.DAEMON }, 82 ]; 83 84 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630"> 85 <defs> 86 <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"> 87 <path d="M40 0H0V40" fill="none" stroke="${P.text}" stroke-opacity="0.045" stroke-width="1"/> 88 </pattern> 89 <linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"> 90 <stop offset="0" stop-color="${P.base}"/> 91 <stop offset="1" stop-color="${P.surface}"/> 92 </linearGradient> 93 </defs> 94 <rect width="1200" height="630" fill="url(#wash)"/> 95 <rect width="1200" height="630" fill="url(#grid)"/> 96 <!-- signal bands: the site's streak field, frozen --> 97 <g opacity="0.55"> 98 <rect x="0" y="86" width="1200" height="2" fill="${P.iris}" opacity="0.35"/> 99 <rect x="180" y="118" width="760" height="1" fill="${P.foam}" opacity="0.5"/> 100 <rect x="0" y="540" width="1200" height="1" fill="${P.love}" opacity="0.35"/> 101 <rect x="420" y="566" width="780" height="2" fill="${P.gold}" opacity="0.3"/> 102 </g> 103 <!-- masthead --> 104 <g font-family="${MONO}" font-size="22" fill="${P.subtle}" letter-spacing="4"> 105 <text x="80" y="150">^: 00 · LIVING OFF THE LAND</text> 106 <text x="1120" y="150" text-anchor="end">lotl.daemon-sec.xyz</text> 107 </g> 108 <!-- title --> 109 <g font-family="${DISPLAY}" font-weight="700"> 110 <text x="76" y="300" fill="${P.text}" font-weight="800" font-size="150" letter-spacing="-7">D<tspan fill="${P.love}">Æ</tspan>MONBins</text> 111 </g> 112 <text x="80" y="368" fill="${P.subtle}" font-family="${DISPLAY}" font-weight="600" font-size="44" letter-spacing="-1">the Off-the-Land Almanac</text> 113 <text x="1120" y="366" text-anchor="end" fill="${P.text}" font-family="${MONO}" font-size="26" letter-spacing="1">${n(c.techniques)} techniques · ${n(c.tools)} tools</text> 114 <text x="80" y="420" fill="${P.muted}" font-family="${MONO}" font-size="22" letter-spacing="0.5">GTFOBins × LOLBAS × WADComs × LOOBins — merged, resynced, extended · MITRE ATT&CK mapped</text> 115 <!-- the five collections --> 116 <g font-family="${MONO}"> 117 ${decks.map((d, i) => { 118 const x = 55 + i * 220; 119 return `<rect x="${x}" y="470" width="194" height="4" fill="${d.acc}"/> 120 <text x="${x}" y="522" fill="${P.text}" font-family="${DISPLAY}" font-weight="700" font-size="46" letter-spacing="-2">${n(d.n)}</text> 121 <text x="${x}" y="556" fill="${d.acc}" font-size="20" letter-spacing="3">${d.tag.toUpperCase()}</text>`; 122 }).join('\n ')} 123 </g> 124 </svg> 125 `; 126 127 const outSvg = join(ROOT, 'public', 'og.svg'); 128 const outPng = join(ROOT, 'public', 'og.png'); 129 writeFileSync(outSvg, svg); 130 // librsvg maps the SVG's px to 72 dpi; rasterise at 2× and downsample to the 131 // exact 1200×630 so type edges stay crisp on retina previews. 132 const png = await sharp(Buffer.from(svg), { density: 144 }) 133 .resize(1200, 630, { fit: 'fill', kernel: 'lanczos3' }) 134 .png({ compressionLevel: 9, palette: false }) 135 .toBuffer(); 136 writeFileSync(outPng, png); 137 const meta = await sharp(png).metadata(); 138 console.log(`wrote public/og.svg (${svg.length} B) and public/og.png (${png.length} B, ${meta.width}×${meta.height})`);