The architecture and create-terminal SVGs were drawn by hand and their text overflowed. The quick start now shows the real output of npm create waybetter, rendered the way storebound renders its terminal shots; the layers stay a table.
105 lines
6.7 KiB
JavaScript
105 lines
6.7 KiB
JavaScript
#!/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/<name>.txt the cleaned output (first line "$ <command>", no ANSI, temp paths made short)
|
|
// assets/readme/term/<name>.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=<path>) 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, '<').replace(/>/g, '>');
|
|
function lineHtml(l) {
|
|
if (l.startsWith('$ ')) return `<span class="p">$</span> <span class="cmd">${esc(l.slice(2))}</span>`;
|
|
let h = esc(l);
|
|
h = h.replace(/\b(error|ERROR|failed)\b/g, m => `<span class="bad">${m}</span>`);
|
|
h = h.replace(/^(\s*)(Created|Wrote)\b/, (m, a, b) => `${a}<span class="ok">${b}</span>`);
|
|
h = h.replace(/^(\s{2,})(cd |npm |git |then )/, (m, a, b) => `${a}<span class="cmd">${b}</span>`);
|
|
return h;
|
|
}
|
|
const page = (title, lines) => `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
:root { --code-bg: #060a14; --code-ink: #e2e8f0; --code-muted: #64748b; --radius: 14px;
|
|
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -16px rgba(0, 0, 0, .7);
|
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }
|
|
html, body { margin: 0; background: #0b1120; }
|
|
body { padding: 24px; width: 960px; box-sizing: content-box; }
|
|
.term { background: var(--code-bg); color: var(--code-ink); border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--code-ink) 10%, transparent); overflow: hidden; }
|
|
.term-bar { display: flex; align-items: center; gap: 6px; padding: 10px 14px; border-bottom: 1px solid color-mix(in srgb, var(--code-ink) 10%, transparent); }
|
|
.term-bar span { width: 10px; height: 10px; border-radius: 50%; background: #334155; }
|
|
.term-bar em { margin-left: 8px; font: 12px var(--mono); color: var(--code-muted); font-style: normal; }
|
|
.term pre { margin: 0; padding: 16px 18px 20px; color: var(--code-ink); font: 13.5px/1.75 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
.p { color: #818cf8; } .cmd { color: #a5b4fc; } .c { color: var(--code-muted); }
|
|
.bad { color: #fca5a5; font-weight: 600; } .ok { color: #86efac; }
|
|
</style></head><body><div class="term"><div class="term-bar"><span></span><span></span><span></span><em>${esc(title)}</em></div><pre><code>${lines.map(lineHtml).join('\n')}</code></pre></div></body></html>`;
|
|
|
|
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 }); }
|