// Renders every route in both languages and both themes, at two widths.
//
// 在两种语言与两种主题下、两种宽度下渲染每一条路由。
//
// Run it from the project root after a release build:
//
// cargo build --release
// node scripts/render_audit.mjs
//
// 在发布构建之后、于项目根目录运行。
//
// It writes one line per combination to /tmp/matrix.jsonl and reports what it
// found. A run that is interrupted keeps what it collected and skips it on
// the next run, so a long audit can be resumed.
//
// 每个组合向 /tmp/matrix.jsonl 写入一行并报告结果。被中断的一轮会保留已收集的
// 内容,下一次运行时跳过它们,因此长时间的审计可以续跑。
//
// Three things this must not do, each learned by getting it wrong.
//
// 有三件事它绝不能做,每一件都是犯错之后才明白的。
//
// It must never await a `Runtime.evaluate` that navigates: a call that
// destroys its own execution context never gets a reply, so the promise never
// settles and the run hangs until the environment kills it. The preferences
// are therefore set once per language on a page that stays put, and each
// route is reached by an ordinary navigation.
//
// 绝不能 await 一个会导航的 `Runtime.evaluate`:这样的调用会销毁自己的执行
// 上下文,回复永远不到,promise 永不结算,整轮运行挂住直到被环境杀掉。因此偏好
// 对每种语言只在一个不会移动的页面上设置一次,之后每条路由都以普通导航到达。
//
// It must not count text that the browser does not draw. A browser draws the
// text of a leaf element and drops text with no element around it; counting
// the latter made the formula check pass whatever the renderer produced.
//
// 绝不能统计浏览器不绘制的文本。浏览器绘制叶子元素内的文本,并丢弃没有元素包裹的
// 文本;统计后者会使公式检查无论在渲染器产出什么时都通过。
//
// It must not compare a formula against the source that is carried beside it
// for copying, which would compare the source with itself.
//
// 绝不能拿公式与它旁边为复制而携带的源码比对,那等于拿源码与自身比对。
import { spawn } from 'node:child_process';
import { appendFileSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
const PORT = Number(process.env.AUDIT_PORT || 10300);
const CDP = Number(process.env.AUDIT_CDP || 9600);
const OUT = process.env.AUDIT_OUT || '/tmp/matrix.jsonl';
const PROFILE = process.env.AUDIT_PROFILE || '/tmp/cprofM';
const BIN = process.env.AUDIT_BIN || 'target/release/apsis-book';
const srv = spawn(BIN,
['--host', '127.0.0.1', '--port', String(PORT)], { stdio: 'ignore' });
const chrome = spawn('/usr/bin/google-chrome',
['--headless=new', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage',
'--disable-extensions', '--disable-background-networking',
`--user-data-dir=${PROFILE}`, `--remote-debugging-port=${CDP}`,
'--hide-scrollbars', 'about:blank'], { stdio: 'ignore' });
const cleanup = () => { try { chrome.kill('SIGKILL'); } catch {} try { srv.kill('SIGKILL'); } catch {} };
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('SIGTERM', () => { cleanup(); process.exit(1); });
const wait = ms => new Promise(r => setTimeout(r, ms));
// Wait for the service, then for the browser.
for (let i = 0; i < 40; i++) {
await wait(250);
try { if ((await fetch(`http://127.0.0.1:${PORT}/health`)).ok) break; } catch {}
}
let ready = false;
for (let i = 0; i < 60 && !ready; i++) {
await wait(500);
try { ready = (await fetch(`http://127.0.0.1:${CDP}/json/version`)).ok; } catch {}
}
if (!ready) { console.log('chrome 未就绪'); cleanup(); process.exit(1); }
const target = await (await fetch(`http://127.0.0.1:${CDP}/json/new?about:blank`, { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(r => ws.onopen = r);
let id = 0; const pending = new Map();
ws.onmessage = e => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
};
const send = (method, params = {}, ms = 15000) => new Promise(resolve => {
const i = ++id;
const timer = setTimeout(() => { pending.delete(i); resolve(null); }, ms);
pending.set(i, m => { clearTimeout(timer); resolve(m); });
ws.send(JSON.stringify({ id: i, method, params }));
});
const ev = async expr => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r?.result?.result?.value;
};
const goto = async url => {
await send('Page.navigate', { url });
for (let i = 0; i < 40; i++) {
await wait(150);
if (await ev('document.readyState') === 'complete') return true;
}
return false;
};
await send('Page.enable');
await send('Runtime.enable');
// What the page shows, per route.
const REPORT = `(() => {
const LEAF = new Set(['mi','mo','mn','mtext','mspace','ms']);
const collect = node => {
let out = '';
for (const child of node.childNodes) {
if (child.nodeType === 3) {
if (LEAF.has((node.tagName || '').toLowerCase())) out += child.textContent;
} else if (child.nodeType === 1) {
out += collect(child);
}
}
return out;
};
const normalise = value => {
const BS = String.fromCharCode(92);
const raw = (value || '')
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
let src = '', i = 0;
while (i < raw.length) {
if (raw[i] === BS) {
i++;
let name = '';
while (i < raw.length && /[A-Za-z]/.test(raw[i])) { name += raw[i]; i++; }
if ((name === 'begin' || name === 'end') && raw[i] === '{') {
while (i < raw.length && raw[i] !== '}') i++;
i++;
}
continue;
}
src += raw[i]; i++;
}
return src.replace(/sqrt/g, '').replace(/[^0-9A-Za-z]/g, '');
};
const lost = [...document.querySelectorAll('.math')].map(m => {
const drawn = m.querySelector('math');
const shown = (drawn ? collect(drawn) : '').replace(/\\s+/g, '');
const source = normalise(m.getAttribute('data-tex'));
const missing = [...source].filter(c => !shown.includes(c)).join('');
// A caret or an underscore left as an operator is a script that was
// never attached. The same character inside an mtext is literal text, as
// in a variable name written with an escaped underscore, which is drawn
// as it was written and is correct.
const marker = drawn
&& (drawn.innerHTML.includes('^') || drawn.innerHTML.includes('_'));
return missing || (marker ? '^/_' : '');
}).filter(Boolean);
return JSON.stringify({
length: (document.body.innerText || '').trim().length,
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 2,
lost: lost.length,
lostWhat: lost.slice(0, 2).join('|'),
});
})()`;
const B = `http://127.0.0.1:${PORT}`;
const docs = await (await fetch(`${B}/api/docs`)).json();
const flows = (await (await fetch(`${B}/api/graph/flows`)).json()).flows;
const api = await (await fetch(`${B}/api/api`)).json();
const tools = (await (await fetch(`${B}/api/tools`)).json()).tools;
const ids = [];
for (const g of docs.groups) for (const s of g.sections) {
ids.push(...s.documents.map(d => d.id));
for (const sub of s.subsections) ids.push(...sub.documents.map(d => d.id));
}
const routes = [
'/', '/docs', '/graph', '/flows', '/api', '/tools',
'/search?q=orbit', '/search?q=%E8%BD%A8%E9%81%93', '/nowhere',
...ids.map(i => `/docs/${i}`),
...flows.map(f => `/flows/${f.id}`),
...api.crates.map(c => `/api/${c.name}`),
...tools.map(t => `/tools/${t.id}`),
];
const errors = [];
ws.addEventListener('message', e => {
const m = JSON.parse(e.data);
if (m.method === 'Runtime.exceptionThrown') {
errors.push(m.params.exceptionDetails.exception?.description?.split('\n')[0]
|| m.params.exceptionDetails.text);
}
});
if (!existsSync(OUT)) writeFileSync(OUT, '');
const done = new Set(
readFileSync(OUT, 'utf8').split('\n').filter(Boolean)
.map(l => { try { const r = JSON.parse(l); return `${r.w}|${r.lang}|${r.theme}|${r.route}`; } catch { return null; } })
.filter(Boolean),
);
let total = 0, bad = 0, skipped = 0;
for (const [w, h] of [[1440, 900], [390, 844]]) {
await send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: 1, mobile: w < 600 });
for (const lang of ['zh', 'en']) {
for (const theme of ['dark', 'light']) {
await goto(`${B}/#/`);
await ev(`localStorage.setItem('apsis-book-language','${lang}');localStorage.setItem('apsis.theme','${theme}');true`);
for (const route of routes) {
const key = `${w}|${lang}|${theme}|${route}`;
if (done.has(key)) { skipped++; continue; }
errors.length = 0;
const loaded = await goto(`${B}/#${route}`);
await wait(220);
total++;
const report = await ev(REPORT);
const d = report ? JSON.parse(report) : null;
const row = {
w, lang, theme, route, loaded,
length: d?.length ?? -1,
overflow: d?.overflow ?? null,
lost: d?.lost ?? -1,
lostWhat: d?.lostWhat ?? '',
errors: [...new Set(errors)].slice(0, 2),
};
const problem = !loaded || d === null || d.length < 120 || d.overflow || d.lost > 0 || errors.length > 0;
if (problem) bad++;
appendFileSync(OUT, JSON.stringify(row) + '\n');
}
}
}
}
console.log(`本次新增 ${total} 组 | 跳过的已完成组 ${skipped} | 有问题 ${bad}`);
ws.close(); cleanup();
process.exit(0);