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>,