#!/usr/bin/env node // Terminal shots for the README: runs real `create-waybetter` commands in a throwaway folder and saves, per shot, // assets/readme/term/.txt the cleaned output (first line "$ ", no ANSI, temp paths made short) // assets/readme/term/.webp that text drawn as a terminal (dark), rendered by headless Google Chrome and // encoded with cwebp (the smaller of lossy q80 and lossless) // Fails when a command exits non-zero, its output lacks its expected marker, or Chrome / cwebp hang. // node tools/readme-term-shots.mjs all shots // node tools/readme-term-shots.mjs --text only the .txt files (no browser) // node tools/readme-term-shots.mjs app only these shots // Needs Google Chrome (or CHROME=) and cwebp on PATH for the images. import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const CLI = join(ROOT, 'packages', 'create-waybetter', 'bin', 'create-waybetter.js'); const OUT = join(ROOT, 'assets', 'readme', 'term'); const CHROME = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const TIMEOUT_MS = 60_000; const log = m => console.log(`${new Date().toISOString().slice(11, 19)} [term-shots] ${m}`); const SHOTS = [ { name: 'create-app', title: 'npm create waybetter', marker: 'Created a wayBetter app', shown: 'npm create waybetter app kickapp -- --site kick.com --name "wayBetter for Kick" --yes', argv: ['app', 'kickapp', '--site', 'kick.com', '--name', 'wayBetter for Kick', '--yes'] }, { name: 'create-plugin', title: 'npm create waybetter', marker: 'hide-trends', shown: 'npm create waybetter plugin -- --name "Hide Trends"', argv: ['plugin', '--name', 'Hide Trends'] }, ]; const esc = s => s.replace(/&/g, '&').replace(//g, '>'); function lineHtml(l) { if (l.startsWith('$ ')) return `$ ${esc(l.slice(2))}`; let h = esc(l); h = h.replace(/\b(error|ERROR|failed)\b/g, m => `${m}`); h = h.replace(/^(\s*)(Created|Wrote)\b/, (m, a, b) => `${a}${b}`); h = h.replace(/^(\s{2,})(cd |npm |git |then )/, (m, a, b) => `${a}${b}`); return h; } const page = (title, lines) => `
${esc(title)}
${lines.map(lineHtml).join('\n')}
`; function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: TIMEOUT_MS, ...opts }); if (r.error) throw new Error(`${cmd}: ${r.error.message}`); return r; } function shoot(shot, work) { const r = run(process.execPath, [CLI, ...shot.argv], { cwd: work, input: '', env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' } }); const out = `${r.stdout}${r.stderr}`.replace(/\x1b\[[0-9;]*m/g, '').replaceAll(work + '/', '').replaceAll(work, '.').trimEnd(); if (r.status !== 0) throw new Error(`${shot.name}: exit ${r.status}\n${out}`); if (!out.includes(shot.marker)) throw new Error(`${shot.name}: output lacks "${shot.marker}"\n${out}`); return [`$ ${shot.shown}`, ...out.split('\n')]; } function render(shot, lines) { const tmp = mkdtempSync(join(tmpdir(), 'wb-term-')); try { const html = join(tmp, 'page.html'), png = join(tmp, 'shot.png'); writeFileSync(html, page(shot.title, lines)); // Height: bar + padding + lines; a little spare, the page background is the frame. const height = Math.ceil(24 * 2 + 38 + 36 + lines.length * 13.5 * 1.75 + 8); run(CHROME, ['--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=2', `--window-size=1008,${height}`, `--screenshot=${png}`, `file://${html}`]); if (!existsSync(png)) throw new Error(`${shot.name}: Chrome wrote no screenshot`); const best = ['q80', 'lossless'].map(mode => { const file = join(tmp, `${mode}.webp`); const args = mode === 'q80' ? ['-q', '80', '-m', '6'] : ['-lossless', '-z', '9']; const c = run('cwebp', ['-quiet', ...args, '-resize', '1600', '0', png, '-o', file]); if (c.status !== 0) throw new Error(`cwebp ${mode}: ${c.stderr}`); return { file, bytes: statSync(file).size }; }).reduce((a, b) => (b.bytes < a.bytes ? b : a)); writeFileSync(join(OUT, `${shot.name}.webp`), readFileSync(best.file)); return best.bytes; } finally { rmSync(tmp, { recursive: true, force: true }); } } const args = process.argv.slice(2); const textOnly = args.includes('--text'); const only = args.filter(a => !a.startsWith('--')); const shots = only.length ? SHOTS.filter(s => only.includes(s.name)) : SHOTS; if (only.length && shots.length !== only.length) throw new Error(`unknown shot(s); known: ${SHOTS.map(s => s.name).join(', ')}`); mkdirSync(OUT, { recursive: true }); const work = mkdtempSync(join(tmpdir(), 'wb-readme-')); try { for (const shot of shots) { const lines = shoot(shot, work); writeFileSync(join(OUT, `${shot.name}.txt`), lines.join('\n') + '\n'); log(`${shot.name}: ${lines.length} lines${textOnly ? '' : `, ${Math.round(render(shot, lines) / 1024)} KB webp`}`); } } finally { rmSync(work, { recursive: true, force: true }); }