diff --git a/README.md b/README.md
index 077c871..a886348 100644
--- a/README.md
+++ b/README.md
@@ -203,6 +203,7 @@ apsis-book/
│ ├── src/pages/ one component per route
│ └── dist/ build output, embedded by build.rs
├── scripts/extract_api.sh interface catalog generator
+├── scripts/render_audit.mjs renders every route and reports what is wrong
├── src/
│ ├── api/ the router and the JSON payloads
│ ├── docs/ content, Markdown, documents, interface catalog
@@ -229,6 +230,14 @@ cargo fmt --check
cd frontend && npm run check
```
+A release is also checked by rendering the interface: `node
+scripts/render_audit.mjs` opens every route in both languages, both themes,
+and two widths, and reports a page that does not load, a page with almost no
+text, a page that scrolls sideways, or a formula that loses a character. It
+needs a Chrome and a release build, writes what it finds to
+`/tmp/matrix.jsonl`, and resumes rather than starting over if it is
+interrupted.
+
The end to end tests start the built binary on an ephemeral port and make
plain TCP requests against it.
diff --git a/README.zh.md b/README.zh.md
index ee3f3bf..be10ba1 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -179,6 +179,7 @@ apsis-book/
│ ├── src/pages/ 每个路由一个组件
│ └── dist/ 构建产物,由 build.rs 嵌入
├── scripts/extract_api.sh 接口目录生成器
+├── scripts/render_audit.mjs 渲染每条路由并报告问题
├── src/
│ ├── api/ 路由器与 JSON 载荷
│ ├── docs/ 内容、Markdown、文档、接口目录
@@ -205,6 +206,11 @@ cargo fmt --check
cd frontend && npm run check
```
+发版还会渲染界面来检查:`node scripts/render_audit.mjs` 会在两种语言、两种主题与
+两种宽度下打开每一条路由,并报告页面未加载、正文近乎空白、横向溢出,或公式丢字符
+这四类问题。它需要 Chrome 与一份发布构建,把发现写入 `/tmp/matrix.jsonl`,且被
+中断后会续跑而不是从头再来。
+
端到端测试在一个临时端口上启动构建出的二进制,并以普通 TCP 请求与之通信。
## 许可协议
diff --git a/scripts/render_audit.mjs b/scripts/render_audit.mjs
new file mode 100644
index 0000000..c5347c9
--- /dev/null
+++ b/scripts/render_audit.mjs
@@ -0,0 +1,232 @@
+// 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);
\ No newline at end of file
diff --git a/src/docs/document.rs b/src/docs/document.rs
index 057fb86..be3bbc1 100644
--- a/src/docs/document.rs
+++ b/src/docs/document.rs
@@ -12,6 +12,18 @@
//! The chapter list and the order of documents inside a chapter come from
//! `content/concepts/chapters.json`, so the navigation order is authored
//! rather than derived from file names.
+//!
+//! 文档模型、导航树与搜索索引。
+//!
+//! 一篇概念文档是一个内嵌的 Markdown 文件,带一小段元数据头部。头部由三短横线的
+//! 若干行界定,内含 `key: value` 对;文件的其余部分是 Markdown 正文。
+//!
+//! 解析在启动时进行一次。每篇文档同时保留其 Markdown 源码与渲染后的 HTML,使
+//! 服务无需再次渲染即可提供任一种。文档还记录它所引用的概念标识,服务用它报告
+//! 断掉的交叉引用。
+//!
+//! 章的清单以及章内文档的顺序来自 `content/concepts/chapters.json`,因此导航顺序
+//! 是撰写出来的,而不是从文件名推导的。
use std::collections::HashMap;
@@ -19,11 +31,17 @@ use crate::docs::markdown;
use crate::json::{self, Value};
/// The language a document variant is written in.
+///
+/// 一篇文档某一形式所用的语言。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Language {
/// Simplified Chinese, the default of the interface.
+ ///
+ /// 简体中文,界面的默认语言。
Chinese,
/// English.
+ ///
+ /// 英文。
English,
}
@@ -32,6 +50,10 @@ impl Language {
///
/// A file whose stem ends in `.zh` is Chinese; every other file is
/// English.
+ ///
+ /// 按文件名后缀返回语言。
+ ///
+ /// 主干以 `.zh` 结尾的文件是中文;其他所有文件是英文。
pub fn of_file(stem: &str) -> Self {
if stem.ends_with(".zh") {
Self::Chinese
@@ -41,6 +63,8 @@ impl Language {
}
/// Returns the short tag used in a payload.
+ ///
+ /// 返回载荷中使用的短标记。
pub const fn tag(self) -> &'static str {
match self {
Self::Chinese => "zh",
@@ -50,39 +74,75 @@ impl Language {
}
/// One language form of a concept document.
+///
+/// 一篇概念文档的某一种语言形式。
#[derive(Debug, Clone)]
pub struct DocumentVariant {
/// Display title.
+ ///
+ /// 显示标题。
+ ///
+ /// 显示标题。
+ ///
+ /// 显示标题。
+ ///
+ /// 显示标题。
pub title: String,
/// One sentence summary, shown in listings and search results.
+ ///
+ /// 一句话概要,显示在列表与搜索结果中。
pub summary: String,
/// Markdown source.
+ ///
+ /// Markdown 源码。
pub markdown: String,
/// Rendered HTML.
+ ///
+ /// 渲染后的 HTML。
pub html: String,
/// Section headings, in order.
+ ///
+ /// 各节标题,按顺序。
pub headings: Vec,
/// Concept identifiers referenced by the body.
+ ///
+ /// 正文所引用的概念标识。
pub references: Vec,
}
/// A parsed concept document, with one form per available language.
+///
+/// 一篇已解析的概念文档,每种可用语言一种形式。
#[derive(Debug, Clone)]
pub struct Document {
/// Identifier used in routes and cross references.
+ ///
+ /// 用于路由与交叉引用的标识。
pub id: String,
/// Chapter identifier the document belongs to.
+ ///
+ /// 文档所属的章标识。
pub chapter: String,
/// Free form tags in the language of the English form.
+ ///
+ /// 自由形式的标签,按英文形式所用的语言。
pub tags: Vec,
/// The same tags in Chinese, when the document declares them.
+ ///
+ /// 同一批标签的中文,若文档声明了它们。
pub tags_zh: Vec,
/// Identifier of the document this one builds on, when declared.
+ ///
+ /// 本篇所依赖的文档标识,若已声明。
pub prerequisites: Vec,
/// The English form. Every document has one, and it is the fallback
/// when a translation is absent.
+ ///
+ /// 英文形式。每篇文档都有,且在缺少译文时作为回退。
pub english: DocumentVariant,
/// The Chinese form, when a translation is embedded.
+ ///
+ /// 中文形式,若嵌入了译文。
pub chinese: Option,
}
@@ -96,11 +156,20 @@ impl Document {
///
/// Returns `None` when the mandatory `title` key is absent, because a
/// document without one cannot be listed.
+ ///
+ /// 从内嵌文本解析一篇文档。
+ ///
+ /// `id` 是该文档被寻址所用的标识,它来自文件名而不是头部,使译文能携带与原文
+ /// 相同的标识。`language` 选择元数据被读入的字段。
+ ///
+ /// 必需的 `title` 键缺失时返回 `None`,因为没有标题的文档无法被列出。
pub fn parse(id: &str, layer: &str, language: Language, text: &str) -> Option {
let variant = DocumentVariant::parse(text)?;
if language == Language::Chinese {
// A Chinese file on its own is not a document; it is merged with
// the English one by the loader.
+
+ // 单独一份中文文件不是一篇文档;它由载入器与英文那份合并。
return None;
}
Some(Self {
@@ -115,6 +184,8 @@ impl Document {
}
/// Returns the form for a language, falling back to English.
+ ///
+ /// 返回某种语言的形式,回退到英文。
pub fn variant(&self, language: Language) -> &DocumentVariant {
match language {
Language::Chinese => self.chinese.as_ref().unwrap_or(&self.english),
@@ -123,6 +194,8 @@ impl Document {
}
/// Returns `true` when the document carries a translation.
+ ///
+ /// 当文档带有译文时返回 `true`。
pub fn has(&self, language: Language) -> bool {
match language {
Language::Chinese => self.chinese.is_some(),
@@ -131,22 +204,30 @@ impl Document {
}
/// Returns the title in the given language, falling back to English.
+ ///
+ /// 以给定语言返回标题,回退到英文。
pub fn title(&self, language: Language) -> &str {
&self.variant(language).title
}
/// Returns the summary in the given language, falling back to English.
+ ///
+ /// 以给定语言返回概要,回退到英文。
pub fn summary(&self, language: Language) -> &str {
&self.variant(language).summary
}
/// Returns the plain text of a language form, used for search.
+ ///
+ /// 返回某个语言形式的纯文本,用于搜索。
pub fn plain_text(&self, language: Language) -> String {
markdown::to_plain_text(&self.variant(language).markdown)
}
/// Returns every concept identifier the document references, from
/// either language form.
+ ///
+ /// 返回文档所引用的每一个概念标识,取自任一语言形式。
pub fn references(&self) -> Vec {
let mut references = self.english.references.clone();
if let Some(chinese) = &self.chinese {
@@ -171,6 +252,14 @@ impl DocumentVariant {
/// article, so a body that opens with the same heading would show it
/// twice. A document whose first heading is a different string keeps it,
/// because then the two headings say different things.
+ ///
+ /// 解析文档的某一种语言形式。
+ ///
+ /// `title` 键缺失时返回 `None`,因为没有标题的形式无法被列出。
+ ///
+ /// 开头的顶层标题若重复标题本身则被移除。页面已经在文章标题处显示了来自元数据的
+ /// 标题,因此以同一标题开头的正文会把它显示两次。首个标题是另一串文字时文档保留
+ /// 它,因为那时两个标题说的是不同的事。
pub fn parse(text: &str) -> Option {
let (header, body) = split_header(text);
let title = field_of(header, "title").unwrap_or_default();
@@ -192,9 +281,15 @@ impl DocumentVariant {
/// Removes a leading `# heading` when it equals `title`.
///
/// Returns the body unchanged when it does not open with such a heading.
+///
+/// 当开头的 `# heading` 等于 `title` 时移除它。
+///
+/// 正文不以这样的标题开头时原样返回。
fn strip_leading_title(body: &str, title: &str) -> String {
let mut lines = body.lines().peekable();
// Skip leading blank lines.
+
+ // 跳过开头的空行。
while matches!(lines.peek(), Some(line) if line.trim().is_empty()) {
lines.next();
}
@@ -212,6 +307,8 @@ fn strip_leading_title(body: &str, title: &str) -> String {
lines.next();
// Drop one following blank line so the body does not start with
// a gap.
+
+ // 去掉随后的一个空行,使正文不以一段空隙开头。
if matches!(lines.peek(), Some(line) if line.trim().is_empty()) {
lines.next();
}
@@ -225,11 +322,17 @@ fn strip_leading_title(body: &str, title: &str) -> String {
///
/// Each language file declares its own tags under the same key, so the
/// loader reads them from the translation rather than from the original.
+///
+/// 返回文档头部所声明的标签列表。
+///
+/// 每个语言文件在同一个键下声明自己的标签,因此载入器从译文读取它们而不是从原文。
pub fn tags_of(text: &str) -> Vec {
list_of(text, "tags")
}
/// Returns the value of a metadata key, trimmed.
+///
+/// 返回某个元数据键的取值,已去除首尾空白。
fn field_of(header: &str, key: &str) -> Option {
for line in header.lines() {
let Some((name, value)) = line.split_once(':') else {
@@ -243,6 +346,8 @@ fn field_of(header: &str, key: &str) -> Option {
}
/// Returns a metadata list value, or an empty list when it is absent.
+///
+/// 返回某个元数据列表取值,缺失时返回空列表。
fn list_of(text: &str, key: &str) -> Vec {
let (header, _) = split_header(text);
field_of(header, key)
@@ -257,6 +362,11 @@ fn list_of(text: &str, key: &str) -> Vec {
/// in the directory named for its layer and the loader reads the layer from
/// there. A file that sits directly in the concept directory belongs to the
/// introduction.
+///
+/// 从载入路径返回文档所属的层。
+///
+/// 层级沿用库的层,因此文档被放在以其层命名的目录中,载入器从那里读出层。直接
+/// 位于概念目录下的文件属于导论。
pub fn layer_of(relative: &str) -> String {
match relative.split_once('/') {
Some((layer, _)) => layer.to_string(),
@@ -265,6 +375,8 @@ pub fn layer_of(relative: &str) -> String {
}
/// Splits a document into its metadata header and its body.
+///
+/// 把一篇文档拆分为元数据头部与正文。
fn split_header(text: &str) -> (&str, &str) {
let trimmed = text.trim_start_matches('\u{feff}');
let Some(rest) = trimmed.strip_prefix("---") else {
@@ -279,6 +391,8 @@ fn split_header(text: &str) -> (&str, &str) {
}
/// Splits a comma separated list.
+///
+/// 拆分一个以逗号分隔的列表。
fn split_list(value: &str) -> Vec {
value
.split(',')
@@ -308,6 +422,9 @@ fn collect_headings(markdown: &str) -> Vec {
// The anchor must match the one the renderer emits, and two
// headings can reduce to the same text, so a repeat is numbered
// the way the renderer numbers it.
+
+ // 锚点必须与渲染器输出的那个一致,而两个标题可能归约为同一文本,
+ // 因此重复项按渲染器编号的方式编号。
let base = slug(text);
let repeats = headings
.iter()
@@ -330,20 +447,32 @@ fn collect_headings(markdown: &str) -> Vec {
}
/// One heading of a document.
+///
+/// 文档的一个标题。
#[derive(Debug, Clone)]
pub struct Heading {
/// The anchor before the uniqueness suffix, used to detect a repeat.
+ ///
+ /// 加唯一性后缀之前的锚点,用于检测重复。
pub base: String,
/// The anchor the document carries for this heading.
+ ///
+ /// 文档为该标题所携带的锚点。
pub anchor: String,
/// The heading text.
+ ///
+ /// 标题文本。
pub text: String,
/// The heading level, from one to six.
+ ///
+ /// 标题层级,一到六。
pub level: u32,
}
/// Returns a URL friendly slug, matching the anchors the Markdown renderer
/// emits.
+///
+/// 返回 URL 友好的短名,与 Markdown 渲染器输出的锚点一致。
fn slug(text: &str) -> String {
let mut slug = String::with_capacity(text.len());
let mut last_dash = true;
@@ -368,6 +497,8 @@ fn slug(text: &str) -> String {
}
/// Collects the concept identifiers referenced by a Markdown body.
+///
+/// 收集某个 Markdown 正文所引用的概念标识。
fn collect_references(markdown: &str) -> Vec {
let mut references = Vec::new();
let characters: Vec = markdown.chars().collect();
@@ -402,15 +533,31 @@ fn collect_references(markdown: &str) -> Vec {
/// library does. The concepts are the larger superset; the implementation
/// exists so that a reader who wants to know what the code does can find it
/// without it being mixed into the teaching material.
+///
+/// 文档的一个顶层分组。
+///
+/// 文档以两个分组呈现:概念,描述这个领域;实现,描述某一个特定库做了什么。
+/// 概念是更大的超集;实现之所以存在,是为了让想知道代码做什么的读者能找到它,
+/// 而不必把它混进教学材料里。
#[derive(Debug, Clone)]
pub struct Group {
/// Group identifier.
+ ///
+ /// 分组标识。
pub id: String,
/// Display title.
pub title: String,
/// Display title in Chinese, when the chapter list gives one.
+ ///
+ /// 中文显示标题,若章清单给出了一个。
+ ///
+ /// 中文显示标题,若章清单给出了一个。
+ ///
+ /// 中文显示标题,若章清单给出了一个。
pub title_zh: Option,
/// The layers or sections of the group, in reading order.
+ ///
+ /// 该分组的各层或各节,按阅读顺序。
pub sections: Vec,
}
@@ -420,31 +567,54 @@ pub struct Group {
/// so that a reader is not shown one flat list of documents that have
/// nothing to do with one another. When the list is empty the layer holds its
/// documents directly.
+///
+/// 分组的一个章,也就是一个层。
+///
+/// 涵盖两个无关主题的层为每个主题声明一个子区,使读者不会看到一份把互不相干的
+/// 文档凑在一起的平铺列表。该列表为空时,层直接持有它的文档。
#[derive(Debug, Clone)]
pub struct Chapter {
/// The subjects of the layer, when it is presented as more than one.
///
/// Empty when the layer holds its documents directly.
+ ///
+ /// 该层的各主题,当它被呈现为多于一个时。
+ ///
+ /// 层直接持有其文档时为空。
pub subsections: Vec,
/// Chapter identifier.
+ ///
+ /// 章标识。
pub id: String,
/// Display title.
pub title: String,
/// Display title in Chinese, when the chapter list gives one.
pub title_zh: Option,
/// One sentence description.
+ ///
+ /// 一句话描述。
+ ///
+ /// 一句话描述。
pub summary: String,
/// One sentence description in Chinese, when the chapter list gives one.
+ ///
+ /// 一句话中文描述,若章清单给出了一个。
pub summary_zh: Option,
/// Document identifiers in reading order.
+ ///
+ /// 文档标识,按阅读顺序。
pub documents: Vec,
}
/// One subject within a layer, used when a layer covers two that are
/// unrelated.
+///
+/// 层内的一个主题,用于层涵盖两个彼此无关主题的情形。
#[derive(Debug, Clone)]
pub struct Section {
/// Section identifier.
+ ///
+ /// 子区标识。
pub id: String,
/// Display title.
pub title: String,
@@ -453,12 +623,18 @@ pub struct Section {
/// One sentence description.
pub summary: String,
/// The same description in Chinese, when the chapter list gives one.
+ ///
+ /// 同一描述的中文,若章清单给出了一个。
pub summary_zh: Option,
/// Document identifiers, in reading order.
+ ///
+ /// 文档标识,按阅读顺序。
pub documents: Vec,
}
/// The document collection plus its navigation order.
+///
+/// 文档集合及其导航顺序。
#[derive(Debug)]
pub struct Tree {
documents: HashMap,
@@ -472,11 +648,17 @@ impl Tree {
///
/// This is the order the frontend navigates and the search index weights,
/// so a caller that lists the documents needs it.
+ ///
+ /// 返回阅读顺序,即各文档按其被阅读的次序排列。
+ ///
+ /// 这是前端导航与搜索索引加权所用的顺序,因此列出文档的调用方需要它。
pub fn reading_order(&self) -> &[String] {
&self.order
}
/// Builds the tree from the embedded content.
+ ///
+ /// 从内嵌内容构建该树。
pub fn new(documents: Vec) -> Self {
let declared = load_groups();
let mut map: HashMap = HashMap::new();
@@ -488,6 +670,9 @@ impl Tree {
// appended to the group that matches, creating the layer when it is
// missing, so that a new file is visible before the list is
// updated rather than invisible.
+
+ // 层未在章清单中声明的文档被追加到相匹配的分组,并在该层缺失时创建它,
+ // 使新文件在清单更新之前就可见,而不是不可见。
let mut groups = declared;
let mut listed: Vec = groups
.iter()
@@ -518,6 +703,9 @@ impl Tree {
// where it is. Only an unlisted document is appended,
// and it goes into the first subject rather than beside
// them.
+
+ // 该层以主题呈现,因此章清单已放入某个主题的文档留在原处。
+ // 只追加未列出的文档,且放入第一个主题,而不是与各主题并列。
let already_listed = section
.subsections
.iter()
@@ -556,6 +744,8 @@ impl Tree {
// A section may name a document that does not exist; drop it so that
// navigation never advertises a missing page.
+
+ // 章可能指名一个不存在的文档;丢弃它,使导航绝不宣传一个缺失的页面。
for group in &mut groups {
for section in &mut group.sections {
section.documents.retain(|id| map.contains_key(id));
@@ -569,6 +759,10 @@ impl Tree {
// them. The layer's own list is dropped, because a document
// named in both places would be offered twice and the two
// entries would share a key, which stops the page rendering.
+
+ // 声明了主题的层把文档放在主题里。层自己的列表被丢弃,因为两处
+ // 都指名的文档会被提供两次,而两个条目会共用一个键,那会使页面
+ // 停止渲染。
if !section.subsections.is_empty() {
section.documents.clear();
}
@@ -600,26 +794,36 @@ impl Tree {
}
/// Returns a document by identifier.
+ ///
+ /// 按标识返回一篇文档。
pub fn document(&self, id: &str) -> Option<&Document> {
self.documents.get(id)
}
/// Returns the documents in navigation order.
+ ///
+ /// 按导航顺序返回各文档。
pub fn ordered(&self) -> impl Iterator- {
self.order.iter().filter_map(|id| self.documents.get(id))
}
/// Returns the groups, each holding its layers.
+ ///
+ /// 返回各分组,每个分组持有自己的各层。
pub fn groups(&self) -> &[Group] {
&self.groups
}
/// Returns the number of documents.
+ ///
+ /// 返回文档数量。
pub fn len(&self) -> usize {
self.documents.len()
}
/// Returns `true` when there are no documents.
+ ///
+ /// 没有文档时返回 `true`。
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.documents.is_empty()
@@ -631,6 +835,11 @@ impl Tree {
/// and the missing identifier. The check runs over the embedded content
/// when the service starts, so a mistyped cross reference is caught
/// rather than becoming a dead link in a page.
+ ///
+ /// 返回每一个未能解析到文档的交叉引用。
+ ///
+ /// 结果是一组「引用方文档标识」与「缺失标识」构成的二元组。该检查在服务启动时
+ /// 遍历内嵌内容,因此写错的交叉引用会被抓住,而不是变成页面中的死链。
#[allow(dead_code)]
pub fn missing_links(&self) -> Vec<(String, String)> {
let mut missing = Vec::new();
@@ -649,6 +858,11 @@ impl Tree {
/// Each language form is indexed separately, so a query in one language
/// ranks the documents that contain it in that language. A document
/// without a translation contributes only its English form.
+ ///
+ /// 在集合之上构建搜索索引。
+ ///
+ /// 每种语言形式单独建索引,因此某种语言的查询会给该语言中含有它的文档打分。
+ /// 没有译文的文档只贡献其英文形式。
pub fn search_index(&self) -> SearchIndex {
let mut entries = Vec::new();
for document in self.ordered() {
@@ -671,6 +885,8 @@ impl Tree {
}
/// Turns `orbit-model` into `Orbit model`.
+///
+/// 把 `orbit-model` 变成 `Orbit model`。
fn title_case(id: &str) -> String {
let mut text = id.replace(['-', '_'], " ");
if let Some(first) = text.chars().next() {
@@ -685,6 +901,12 @@ fn title_case(id: &str) -> String {
/// directory tree says which layer a document belongs to, so the two are
/// cross-checked rather than repeated: a document listed under one layer and
/// stored in another is a mistake the reader would not see.
+///
+/// 从 `content/concepts/chapters.json` 载入各分组及其各层。
+///
+/// 该文件是「存在什么、以何顺序」的唯一出处。目录树说明一篇文档属于哪一层,
+/// 因此两者互相校验而不是重复:列在一个层下却存放在另一个层,是读者看不到的
+/// 错误。
fn load_groups() -> Vec {
let Some(text) = crate::docs::content::get("concepts/chapters.json") else {
return Vec::new();
@@ -702,6 +924,8 @@ fn load_groups() -> Vec {
}
/// Parses one group and its layers.
+///
+/// 解析一个分组及其各层。
fn parse_group(value: &Value) -> Option {
let id = value.get("id")?.as_str()?.to_string();
let sections = value
@@ -727,6 +951,8 @@ fn parse_group(value: &Value) -> Option {
}
/// Parses one layer of a group.
+///
+/// 解析分组的一个层。
fn parse_section(value: &Value) -> Option {
let id = value.get("id")?.as_str()?.to_string();
Some(Chapter {
@@ -767,6 +993,8 @@ fn parse_section(value: &Value) -> Option {
}
/// Parses one subject within a layer.
+///
+/// 解析层内的一个主题。
fn parse_subsection(value: &Value) -> Option {
let id = value.get("id")?.as_str()?.to_string();
Some(Section {
@@ -800,6 +1028,8 @@ fn parse_subsection(value: &Value) -> Option {
}
/// One document in the search index.
+///
+/// 搜索索引中的一篇文档。
struct IndexEntry {
id: String,
language: Language,
@@ -816,12 +1046,20 @@ struct IndexEntry {
/// query tokens a document matches, with a bonus for a token that appears in
/// the title. The result is small enough that a linear scan over the
/// postings is faster than any more elaborate structure.
+///
+/// 覆盖各文档的搜索索引。
+///
+/// 索引把一个小写的词元映射到包含它的文档。查询以同样方式分词,并按一篇文档匹配
+/// 到的查询词元个数打分,出现在标题中的词元另有加分。结果规模很小,对倒排表做
+/// 线性扫描比任何更精巧的结构都快。
pub struct SearchIndex {
entries: Vec,
postings: HashMap>,
}
/// A document in the index, with the fields shown in a result.
+///
+/// 索引中的一篇文档,含结果中显示的各个字段。
struct SearchEntry {
id: String,
language: Language,
@@ -831,11 +1069,15 @@ struct SearchEntry {
title_tokens: Vec,
/// The searchable text, lowercased, kept for the substring fallback that
/// a query of a single character uses.
+ ///
+ /// 可搜索的文本,已转为小写,保留给单字符查询所用的子串回退。
haystack: String,
}
impl SearchIndex {
/// Builds an index from the entries.
+ ///
+ /// 由各条目构建索引。
fn build(entries: Vec) -> Self {
let mut postings: HashMap> = HashMap::new();
let mut search_entries = Vec::with_capacity(entries.len());
@@ -882,6 +1124,13 @@ impl SearchIndex {
/// against the postings. A query that yields no token, such as a single
/// character, is matched as a substring, because a one character query
/// cannot form a token and would otherwise always return nothing.
+ ///
+ /// 搜索索引并返回最佳匹配,按得分排序。
+ ///
+ /// 至多返回 `limit` 条结果。既无可用词元又无字面匹配的查询返回空。
+ ///
+ /// 查询分两种方式处理。能产出词元的查询与倒排表匹配。产不出词元的查询——例如
+ /// 单个字符——按子串匹配,因为单字符查询无法构成词元,否则将永远一无所获。
pub fn search(&self, query: &str, limit: usize) -> Vec {
let needle = query.trim().to_lowercase();
if needle.is_empty() {
@@ -898,6 +1147,8 @@ impl SearchIndex {
continue;
};
// A token that appears in many documents is less informative.
+
+ // 出现在许多文档中的词元信息量更小。
let weight = 1.0 / (1.0 + (postings.len() as f64).ln());
for (index, count) in postings {
let entry = &self.entries[*index];
@@ -940,6 +1191,10 @@ impl SearchIndex {
///
/// Used for a query that yields no token. A match in the title scores
/// higher than a match in the body, so the ordering stays useful.
+ ///
+ /// 返回文本字面包含该查询的文档。
+ ///
+ /// 用于产不出词元的查询。标题中的匹配得分高于正文中的匹配,使排序仍然有用。
fn search_substring(&self, needle: &str, limit: usize) -> Vec {
let mut hits: Vec = self
.entries
@@ -971,11 +1226,15 @@ impl SearchIndex {
}
/// Returns the number of indexed documents.
+ ///
+ /// 返回已建索引的文档数量。
pub fn len(&self) -> usize {
self.entries.len()
}
/// Returns `true` when the index is empty.
+ ///
+ /// 索引为空时返回 `true`。
#[allow(dead_code)]
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
@@ -984,19 +1243,33 @@ impl SearchIndex {
}
/// One search result.
+///
+/// 一条搜索结果。
#[derive(Debug, Clone)]
pub struct SearchHit {
/// Identifier of the matching document.
+ ///
+ /// 匹配文档的标识。
pub id: String,
/// Language form that matched.
+ ///
+ /// 匹配到的语言形式。
pub language: Language,
/// Title of the matching document.
+ ///
+ /// 匹配文档的标题。
pub title: String,
/// Summary of the matching document.
+ ///
+ /// 匹配文档的概要。
pub summary: String,
/// Chapter the document belongs to.
+ ///
+ /// 文档所属的章。
pub chapter: String,
/// Relevance score.
+ ///
+ /// 相关性得分。
pub score: f64,
}
@@ -1014,12 +1287,25 @@ pub struct SearchHit {
/// unit that is specific enough to be a word. Searching 坐标 therefore
/// matches a paragraph that contains 坐标, which a whole-run token would
/// not, because the run around it is longer.
+///
+/// 把文本切分为小写的词元。
+///
+/// 产生两类词元:
+///
+/// - 一串字母与数字按词产生一个词元,用下划线、连字符或点书写的标识也在那里
+/// 拆开,因此 `mean_motion` 与 `mean-motion` 都能匹配对 `mean motion` 的
+/// 查询。单个字符的词被丢弃,因为它会匹配太多。
+/// - 一串中文字符每相邻两个产生一个词元。中文书写不带空白,因此无法靠按空白
+/// 切分恢复出词;相邻的一对字符是仍足够具体到能视作一个词的最小单位。查询
+/// 坐标因此能匹配含有 坐标 的段落,而整串词元做不到,因为它周围的串更长。
pub(crate) fn tokenise(text: &str) -> Vec {
let mut tokens = Vec::new();
let mut word = String::new();
let mut han: Vec = Vec::new();
// Flushes the pending Latin word.
+
+ // 冲刷尚未结算的拉丁词。
fn flush_word(word: &mut String, tokens: &mut Vec) {
if word.chars().count() >= 2 {
tokens.push(word.clone());
@@ -1031,6 +1317,9 @@ pub(crate) fn tokenise(text: &str) -> Vec {
// lone character produces no token; a query of one character is handled
// by the substring search instead, which is more precise than a token
// that would match every occurrence of that character.
+
+ // 把尚未结算的一串中文字符按相邻对冲刷。单个字符不产生词元;单字符查询改由
+ // 子串搜索处理,那比一个会匹配该字符每一处出现的词元更精确。
fn flush_han(han: &mut Vec, tokens: &mut Vec) {
for pair in han.windows(2) {
tokens.push(pair.iter().collect());
@@ -1059,6 +1348,10 @@ pub(crate) fn tokenise(text: &str) -> Vec {
///
/// The ranges cover the unified ideographs, the extension A block, and the
/// compatibility ideographs, which is everything the content uses.
+///
+/// 当某个字符是汉字时返回 `true`。
+///
+/// 各范围覆盖统一表意文字、扩展 A 区与兼容表意文字,即内容所用的全部字符。
fn is_han(character: char) -> bool {
matches!(character as u32,
0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff)
diff --git a/src/docs/mathml.rs b/src/docs/mathml.rs
index 9bb71de..edf9007 100644
--- a/src/docs/mathml.rs
+++ b/src/docs/mathml.rs
@@ -131,17 +131,14 @@ pub(crate) fn normalize(latex: &str) -> Vec {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Token {
/// A single character.
-
///
/// 单个字符。
Symbol(char),
/// A backslash command, without the backslash.
-
///
/// 一个反斜杠命令,不含反斜杠。
Command(String),
/// An opening or closing brace; which one is taken from the position.
-
///
/// 一个开括号或闭括号;具体是哪一个由位置决定。
Group,
@@ -153,68 +150,55 @@ pub(crate) enum Token {
#[derive(Debug, Clone, PartialEq, Eq)]
enum Node {
/// Text, already escaped.
-
///
/// 文本,已转义。
Text(String),
/// A row of children.
-
///
/// 一行子节点。
Row(Vec),
/// A fraction.
-
///
/// 一个分式。
Fraction(Box, Box),
/// A root.
-
///
/// 一个根式。
Root(Box, Option>),
/// A superscript.
-
///
/// 一个上标。
Superscript(Box, Box),
/// A subscript.
-
///
/// 一个下标。
Subscript(Box, Box),
/// A subscript and a superscript on one base.
-
///
/// 同一个基上的下标与上标。
Scripts(Box, Box, Box),
/// A named function such as `sin`.
-
///
/// 一个具名函数,例如 `sin`。
Function(String),
/// A mark written above its argument, as in `\dot\Omega`.
-
///
/// 写在参数上方的一个符号,例如 `\dot\Omega`。
Overscript(Box, String),
/// Words that stand as they are written, as in `\text{km}`.
-
///
/// 按书写原样保留的词,例如 `\text{km}`。
Verbatim(String),
/// A matrix, with the delimiters its environment asks for.
-
///
/// 一个矩阵,带其环境所要求的定界符。
Matrix {
/// The opening and closing delimiters, which may be empty.
-
///
/// 开定界符与闭定界符,可以为空。
open: &'static str,
close: &'static str,
/// The cells, one row at a time.
-
///
/// 各单元格,一次一行。
rows: Vec>,