feat: build steps 7a-10 — the site is complete and reviewable at 22 pages
Steps 7a through 10 as one authorised run. Nothing deployed (D11).
7a Lighthouse returns as `lighthouse@13.4.1` + `chrome-launcher`, NOT
`@lhci/cli`. AGENTS.md §7's advisory attribution was wrong: the carriers
were @lhci/cli's own `tmp` and @puppeteer/browsers' `extract-zip`, not
Lighthouse, which audits clean. A deliberate deviation from R11's literal
trigger, recorded with what it costs. Local gate; CI has no Chrome.
7b OG card generator (satori + sharp) discharges R15 — 20 typed cards plus
per-article cards; the portrait stays on / and /about/ by Q40. Insights
plumbing: ArticleCard, Prose, the index, the article route, articleGraph,
and /'s section 7. Card copy is constrained structurally because text in a
JPEG cannot be grepped by check:claims: every headline IS its page's <h1>,
enforced by `npm run og:proof`.
7c Five drafted launch articles, draft: true / reviewedByPouya: false. An
independent compliance audit returned 76 findings and 57 unsourced
assertions; all blocking and should-fix applied.
8 /contact/, the intake form, and backend/intake/ (undeployed). Plain HTML
POST to a same-origin /api/intake with a 303 redirect, so the form works
with zero JavaScript. docs/05 records three deliberate deviations.
9 /fees/ on Q59's ruling — overtime runs from the session cap, and the
reservation point ships adjacent to the rate. One-page PDF bio discharges
R16; /bio/ is its source, so the circulated artefact stays inside the
review apparatus.
10 /legal/privacy/ and /legal/terms/, written to the backend as built. Three
of the policy's statements are derived and cannot drift.
Also: /about/'s inverse credentials band (approved at step 6); Q59 closed;
R15 and R16 discharged; and a fix to shipped copy — /practice/energy/ asserted
the absence of a regulation the source extract says must not be asserted.
Review: adversarial-reviewer, two rounds (D20/D19). Round 1 returned 16
findings including two blocking — an invisible ghost button on /fees/ at
1.00:1 that Lighthouse scored 100, and a privacy policy that named one data
processor when there are two. All 16 acted on.
Lighthouse, 22 pages, mobile: performance 99-100, accessibility 100,
best practices 100, SEO 100 on every indexable page, CLS 0.000.
AGENTS.md entry (ah) has the detail, including four of my own verification
commands that were wrong and what each of them nearly caused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
6cfe69033f
commit
210bc25a26
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Renders `/bio/` to `public/pouya-lajevardi-bio.pdf`. `npm run bio:pdf`,
|
||||
* after `npm run build`. Discharges `AGENTS.md` R16 / Q45.
|
||||
*
|
||||
* WHY A LOCAL SCRIPT AND NOT A BUILD STEP. It drives Chrome, and the Gitea
|
||||
* runner has none (`AGENTS.md` §7, Q23) — the same reason `npm run lighthouse`
|
||||
* is a local gate. A build step that cannot run in CI is a control that exists
|
||||
* on paper, which is the shape Q22 turned out to be. So the PDF is **committed**:
|
||||
* the artefact is in the repository, which is also what R14 asks for.
|
||||
*
|
||||
* ⚠️ IT IS NOT BYTE-REPRODUCIBLE, AND AN EARLIER VERSION OF THIS COMMENT SAID
|
||||
* "deterministically". Two consecutive runs produced 89,496 bytes both times and
|
||||
* DIFFERENT SHA-256 digests — Chrome stamps a `/CreationDate` into the document.
|
||||
* Measured by `adversarial-reviewer`, 2026-08-31.
|
||||
*
|
||||
* The consequence is not cosmetic: the "regenerate and re-commit the PDF" item on
|
||||
* `docs/06`'s cutover checklist therefore always produces a binary diff, so a
|
||||
* reviewer cannot tell a real content change from a no-op re-render. Do not
|
||||
* re-commit it out of habit — re-commit it when `/bio/`, §4, the rate card or the
|
||||
* print styles actually changed, and say which in the commit message.
|
||||
*
|
||||
* WHY THE PDF IS A RENDERING OF A PAGE RATHER THAN A DESIGNED DOCUMENT. R16's
|
||||
* worry was never tooling: *"a PDF circulated with an appointment proposal is
|
||||
* read once, by the reader who matters most, and never seen by a reviewer
|
||||
* again."* Rendering it from `/bio/` puts it back inside this project's review
|
||||
* apparatus — `astro check`, `check:claims` on the built HTML, the adversarial
|
||||
* review and the cutover claims pass all see every word of it, because every
|
||||
* word of it is on a page. (That is what caught `/bio/` opening with a clause
|
||||
* that scoped mediation commercial, which Q56 forbids.)
|
||||
*
|
||||
* ⚠️ IT ASSERTS ONE PAGE. A one-page bio that silently becomes two is the defect
|
||||
* this script exists to catch, and it is invisible from the source: it depends on
|
||||
* the print stylesheet, the paper size, and how much §4 has grown since anyone
|
||||
* looked. `printBackground: false` matches Chrome's own default print dialog,
|
||||
* where "Background graphics" is unchecked — `global.css` records what that did
|
||||
* to `/about/`'s inverse band when nobody checked.
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { writeFile, stat } from 'node:fs/promises';
|
||||
import { join, extname } from 'node:path';
|
||||
import * as chromeLauncher from 'chrome-launcher';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DIST = join(ROOT, 'dist');
|
||||
const OUT = join(ROOT, 'public', 'pouya-lajevardi-bio.pdf');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.avif': 'image/avif',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ico': 'image/x-icon',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
try {
|
||||
await stat(join(DIST, 'bio', 'index.html'));
|
||||
} catch {
|
||||
console.error(
|
||||
'dist/bio/index.html is missing. Run `npm run build` first — this renders ' +
|
||||
'the BUILT page, not the dev server, so what ships is what is measured.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||
const file = pathname.endsWith('/')
|
||||
? join(DIST, pathname, 'index.html')
|
||||
: join(DIST, pathname);
|
||||
const stream = createReadStream(file);
|
||||
stream.on('error', () => {
|
||||
res.writeHead(404);
|
||||
res.end('404');
|
||||
});
|
||||
stream.once('open', () => {
|
||||
res.writeHead(200, {
|
||||
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
const chrome = await chromeLauncher.launch({
|
||||
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu'],
|
||||
});
|
||||
|
||||
/** Minimal CDP client over the DevTools WebSocket. `chrome-launcher` starts the
|
||||
* browser and does not speak the protocol; adding a client library for four
|
||||
* calls would be a dependency for nothing. */
|
||||
async function cdp(port, fn) {
|
||||
const list = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
const target = list.find((t) => t.type === 'page');
|
||||
if (!target) throw new Error('no page target in Chrome');
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.addEventListener('open', resolve, { once: true });
|
||||
ws.addEventListener('error', reject, { once: true });
|
||||
});
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const events = new Map();
|
||||
ws.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id && pending.has(message.id)) {
|
||||
const { resolve, reject } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) reject(new Error(JSON.stringify(message.error)));
|
||||
else resolve(message.result);
|
||||
} else if (message.method && events.has(message.method)) {
|
||||
events.get(message.method)();
|
||||
}
|
||||
});
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const once = (method) =>
|
||||
new Promise((resolve) => events.set(method, resolve));
|
||||
try {
|
||||
return await fn({ send, once });
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
let pdfBase64;
|
||||
try {
|
||||
pdfBase64 = await cdp(chrome.port, async ({ send, once }) => {
|
||||
await send('Page.enable');
|
||||
const loaded = once('Page.loadEventFired');
|
||||
await send('Page.navigate', { url: `http://127.0.0.1:${port}/bio/` });
|
||||
await loaded;
|
||||
// The page self-hosts its fonts and `document.fonts.ready` is the only
|
||||
// reliable signal that they are laid out — a PDF printed before the serif
|
||||
// arrives is set in the fallback and looks nothing like the site.
|
||||
await send('Runtime.evaluate', {
|
||||
expression: 'document.fonts.ready',
|
||||
awaitPromise: true,
|
||||
});
|
||||
const result = await send('Page.printToPDF', {
|
||||
// Letter, because this circulates in Canada with Canadian counsel.
|
||||
paperWidth: 8.5,
|
||||
paperHeight: 11,
|
||||
marginTop: 0.55,
|
||||
marginBottom: 0.55,
|
||||
marginLeft: 0.6,
|
||||
marginRight: 0.6,
|
||||
printBackground: false,
|
||||
preferCSSPageSize: false,
|
||||
});
|
||||
return result.data;
|
||||
});
|
||||
} finally {
|
||||
chrome.kill();
|
||||
server.close();
|
||||
}
|
||||
|
||||
const pdf = Buffer.from(pdfBase64, 'base64');
|
||||
|
||||
/**
|
||||
* PAGE COUNT, ASSERTED. Counted from the PDF's own page objects rather than
|
||||
* trusting the layout — this is the whole reason the script exists rather than a
|
||||
* note telling someone to check. A one-page bio that quietly becomes two pages
|
||||
* is exactly the class of defect nobody looks for again.
|
||||
*/
|
||||
const text = pdf.toString('latin1');
|
||||
const pageCount =
|
||||
(text.match(/\/Type\s*\/Page[^s]/g) ?? []).length ||
|
||||
Number((/\/Count\s+(\d+)/.exec(text) ?? [])[1] ?? 0);
|
||||
|
||||
console.log(
|
||||
`bio:pdf — ${pdf.length.toLocaleString()} bytes, ${pageCount} page(s), Letter.`,
|
||||
);
|
||||
|
||||
if (pageCount !== 1) {
|
||||
console.error(
|
||||
`\nTHE BIO IS ${pageCount} PAGES AND MUST BE ONE.\n` +
|
||||
' It is specified as a one-page bio (docs/01 §/about/ item 7, R16), and a\n' +
|
||||
' second sheet carrying three lines is worse than a denser first one.\n' +
|
||||
' Tighten the @media print block in src/pages/bio.astro — do not widen\n' +
|
||||
' the margins here, which changes the document rather than the layout.\n' +
|
||||
' Nothing was written.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await writeFile(OUT, pdf);
|
||||
console.log(`wrote public/pouya-lajevardi-bio.pdf`);
|
||||
console.log(
|
||||
'It is COMMITTED. Regenerate and re-commit it whenever /bio/, §4, the rate ' +
|
||||
'card or the print styles change — nothing in the build does this for you.',
|
||||
);
|
||||
Reference in New Issue
Block a user