Download
120 lines 4.5 KiBJavaScriptModified 2026-10-20 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// Takes the README's screenshots of the rendered preview site. `make
// screenshots` runs it after rendering:
//
//	node preview/screenshots.mjs BIN CONFIG OUTDIR
//
// It serves the site with `BIN --config CONFIG serve`, drives headless
// Firefox over WebDriver BiDi (Node's built-in WebSocket, no packages),
// and writes one PNG per shot into OUTDIR.
import { spawn } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const [bin, config, outDir] = process.argv.slice(2);
if (!outDir) {
  console.error('usage: node preview/screenshots.mjs BIN CONFIG OUTDIR');
  process.exit(2);
}
const W = 1400, H = 900;

// Each shot is a page of the site, the theme to show it in, and an
// optional condition to wait for after the page loads.
const shots = [
  { name: 'repo', path: '/code/repo-renderer', theme: 'dark' },
  { name: 'file', path: '/code/repo-renderer/internal/queue/queue.go', theme: 'light' },
  { name: 'search', path: '/search/repo-renderer/?q=RenderRepo', theme: 'dark',
    until: `document.getElementById('status')?.getAttribute('aria-busy') === 'false' &&
            document.getElementById('results')?.children.length > 0` },
  { name: 'index', path: '/', theme: 'light' },
];

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function freePort() {
  return new Promise((res, rej) => {
    const s = createServer().once('error', rej).listen(0, '127.0.0.1', () => {
      const { port } = s.address();
      s.close(() => res(port));
    });
  });
}

// retry calls fn until it resolves, for up to about 15 seconds.
async function retry(what, fn) {
  for (let i = 0; i < 50; i++) {
    try { return await fn(); } catch { await sleep(300); }
  }
  throw new Error(`timed out waiting for ${what}`);
}

const sitePort = await freePort();
const ffPort = await freePort();
const base = `http://127.0.0.1:${sitePort}`;
const profile = mkdtempSync(join(tmpdir(), 'repo-render-shots-'));
const children = [];
const cleanup = () => {
  for (const c of children) c.kill();
  rmSync(profile, { recursive: true, force: true });
};
process.on('exit', cleanup);
process.on('SIGINT', () => process.exit(130));

children.push(spawn(bin, ['--config', config, 'serve', '--listen', `127.0.0.1:${sitePort}`],
  { stdio: ['ignore', 'ignore', 'inherit'] }));
children.push(spawn('firefox', ['--headless', '--no-remote', '--profile', profile,
  `--remote-debugging-port=${ffPort}`, `--window-size=${W},${H}`], { stdio: 'ignore' }));

await retry('the preview server', async () => {
  if (!(await fetch(base + '/')).ok) throw new Error('not ready');
});
const ws = await retry('Firefox', () => new Promise((res, rej) => {
  const s = new WebSocket(`ws://127.0.0.1:${ffPort}/session`);
  s.onopen = () => res(s);
  s.onerror = rej;
}));

let nextId = 1;
const pending = new Map();
ws.onmessage = (ev) => {
  const m = JSON.parse(ev.data);
  const p = pending.get(m.id);
  if (!p) return;
  pending.delete(m.id);
  if (m.type === 'error') p.rej(new Error(`${p.method}: ${m.error}: ${m.message}`));
  else p.res(m.result);
};
const send = (method, params = {}) => new Promise((res, rej) => {
  const id = nextId++;
  pending.set(id, { method, res, rej });
  ws.send(JSON.stringify({ id, method, params }));
});

await send('session.new', { capabilities: {} });
const { context } = await send('browsingContext.create', { type: 'tab' });
await send('browsingContext.setViewport', { context, viewport: { width: W, height: H } });
const evaluate = async (expression) => (await send('script.evaluate',
  { expression, target: { context }, awaitPromise: true })).result;

mkdirSync(outDir, { recursive: true });
for (const s of shots) {
  // Remember the theme the way the site's toggle does, so the page and
  // the file drawer's frame both pick it up.
  await send('browsingContext.navigate', { context, url: base + '/', wait: 'complete' });
  await evaluate(`localStorage.setItem('repo-render-theme', '${s.theme}')`);
  await send('browsingContext.navigate', { context, url: base + s.path, wait: 'complete' });
  if (s.until) {
    await retry(s.name, async () => {
      if (!(await evaluate(`!!(${s.until})`)).value) throw new Error('not yet');
    });
  }
  await sleep(800); // let frames and fonts settle
  const { data } = await send('browsingContext.captureScreenshot', { context });
  writeFileSync(join(outDir, `${s.name}.png`), Buffer.from(data, 'base64'));
  console.log(`wrote ${join(outDir, s.name)}.png`);
}

await send('session.end').catch(() => {});
ws.close();
process.exit(0);