Finish the comments in both languages, and take the render audit in
The last three modules — the document model, the Markdown renderer, and the formula renderer — were the largest, and every file with comments now carries them in English and then in Chinese, as the library does. Three thousand eight hundred and seventy comment lines across forty-three files. The audit that renders every route is now part of the project rather than a script kept outside it, because a release needs it and the next person should not have to write it again. It is documented in the README beside the other script. Two faults of my own were found and fixed while writing the comments. A blank line between two doc comment lines detaches the second block from the item it documents; the compiler says so and it was said of ninety-three such lines, which are now empty doc lines. And a few blocks were inserted with the wrong indentation, which then tidied.
This commit is contained in:
parent
89f1b0bc9a
commit
f8954e6381
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 请求与之通信。
|
||||
|
||||
## 许可协议
|
||||
|
|
|
|||
|
|
@ -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('<mo>^</mo>') || drawn.innerHTML.includes('<mo>_</mo>'));
|
||||
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);
|
||||
|
|
@ -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<Heading>,
|
||||
/// Concept identifiers referenced by the body.
|
||||
///
|
||||
/// 正文所引用的概念标识。
|
||||
pub references: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// The same tags in Chinese, when the document declares them.
|
||||
///
|
||||
/// 同一批标签的中文,若文档声明了它们。
|
||||
pub tags_zh: Vec<String>,
|
||||
/// Identifier of the document this one builds on, when declared.
|
||||
///
|
||||
/// 本篇所依赖的文档标识,若已声明。
|
||||
pub prerequisites: Vec<String>,
|
||||
/// 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<DocumentVariant>,
|
||||
}
|
||||
|
||||
|
|
@ -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<Self> {
|
||||
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<String> {
|
||||
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<Self> {
|
||||
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<String> {
|
||||
list_of(text, "tags")
|
||||
}
|
||||
|
||||
/// Returns the value of a metadata key, trimmed.
|
||||
///
|
||||
/// 返回某个元数据键的取值,已去除首尾空白。
|
||||
fn field_of(header: &str, key: &str) -> Option<String> {
|
||||
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<String> {
|
|||
}
|
||||
|
||||
/// Returns a metadata list value, or an empty list when it is absent.
|
||||
///
|
||||
/// 返回某个元数据列表取值,缺失时返回空列表。
|
||||
fn list_of(text: &str, key: &str) -> Vec<String> {
|
||||
let (header, _) = split_header(text);
|
||||
field_of(header, key)
|
||||
|
|
@ -257,6 +362,11 @@ fn list_of(text: &str, key: &str) -> Vec<String> {
|
|||
/// 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<String> {
|
||||
value
|
||||
.split(',')
|
||||
|
|
@ -308,6 +422,9 @@ fn collect_headings(markdown: &str) -> Vec<Heading> {
|
|||
// 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<Heading> {
|
|||
}
|
||||
|
||||
/// 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<String> {
|
||||
let mut references = Vec::new();
|
||||
let characters: Vec<char> = markdown.chars().collect();
|
||||
|
|
@ -402,15 +533,31 @@ fn collect_references(markdown: &str) -> Vec<String> {
|
|||
/// 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<String>,
|
||||
/// The layers or sections of the group, in reading order.
|
||||
///
|
||||
/// 该分组的各层或各节,按阅读顺序。
|
||||
pub sections: Vec<Chapter>,
|
||||
}
|
||||
|
||||
|
|
@ -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<Section>,
|
||||
/// Chapter identifier.
|
||||
///
|
||||
/// 章标识。
|
||||
pub id: String,
|
||||
/// Display title.
|
||||
pub title: String,
|
||||
/// Display title in Chinese, when the chapter list gives one.
|
||||
pub title_zh: Option<String>,
|
||||
/// One sentence description.
|
||||
///
|
||||
/// 一句话描述。
|
||||
///
|
||||
/// 一句话描述。
|
||||
pub summary: String,
|
||||
/// One sentence description in Chinese, when the chapter list gives one.
|
||||
///
|
||||
/// 一句话中文描述,若章清单给出了一个。
|
||||
pub summary_zh: Option<String>,
|
||||
/// Document identifiers in reading order.
|
||||
///
|
||||
/// 文档标识,按阅读顺序。
|
||||
pub documents: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Document identifiers, in reading order.
|
||||
///
|
||||
/// 文档标识,按阅读顺序。
|
||||
pub documents: Vec<String>,
|
||||
}
|
||||
|
||||
/// The document collection plus its navigation order.
|
||||
///
|
||||
/// 文档集合及其导航顺序。
|
||||
#[derive(Debug)]
|
||||
pub struct Tree {
|
||||
documents: HashMap<String, Document>,
|
||||
|
|
@ -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<Document>) -> Self {
|
||||
let declared = load_groups();
|
||||
let mut map: HashMap<String, Document> = 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<String> = 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<Item = &Document> {
|
||||
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<Group> {
|
||||
let Some(text) = crate::docs::content::get("concepts/chapters.json") else {
|
||||
return Vec::new();
|
||||
|
|
@ -702,6 +924,8 @@ fn load_groups() -> Vec<Group> {
|
|||
}
|
||||
|
||||
/// Parses one group and its layers.
|
||||
///
|
||||
/// 解析一个分组及其各层。
|
||||
fn parse_group(value: &Value) -> Option<Group> {
|
||||
let id = value.get("id")?.as_str()?.to_string();
|
||||
let sections = value
|
||||
|
|
@ -727,6 +951,8 @@ fn parse_group(value: &Value) -> Option<Group> {
|
|||
}
|
||||
|
||||
/// Parses one layer of a group.
|
||||
///
|
||||
/// 解析分组的一个层。
|
||||
fn parse_section(value: &Value) -> Option<Chapter> {
|
||||
let id = value.get("id")?.as_str()?.to_string();
|
||||
Some(Chapter {
|
||||
|
|
@ -767,6 +993,8 @@ fn parse_section(value: &Value) -> Option<Chapter> {
|
|||
}
|
||||
|
||||
/// Parses one subject within a layer.
|
||||
///
|
||||
/// 解析层内的一个主题。
|
||||
fn parse_subsection(value: &Value) -> Option<Section> {
|
||||
let id = value.get("id")?.as_str()?.to_string();
|
||||
Some(Section {
|
||||
|
|
@ -800,6 +1028,8 @@ fn parse_subsection(value: &Value) -> Option<Section> {
|
|||
}
|
||||
|
||||
/// 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<SearchEntry>,
|
||||
postings: HashMap<String, Vec<(usize, u32)>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<IndexEntry>) -> Self {
|
||||
let mut postings: HashMap<String, Vec<(usize, u32)>> = 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<SearchHit> {
|
||||
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<SearchHit> {
|
||||
let mut hits: Vec<SearchHit> = 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<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut han: Vec<char> = Vec::new();
|
||||
|
||||
// Flushes the pending Latin word.
|
||||
|
||||
// 冲刷尚未结算的拉丁词。
|
||||
fn flush_word(word: &mut String, tokens: &mut Vec<String>) {
|
||||
if word.chars().count() >= 2 {
|
||||
tokens.push(word.clone());
|
||||
|
|
@ -1031,6 +1317,9 @@ pub(crate) fn tokenise(text: &str) -> Vec<String> {
|
|||
// 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<char>, tokens: &mut Vec<String>) {
|
||||
for pair in han.windows(2) {
|
||||
tokens.push(pair.iter().collect());
|
||||
|
|
@ -1059,6 +1348,10 @@ pub(crate) fn tokenise(text: &str) -> Vec<String> {
|
|||
///
|
||||
/// 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)
|
||||
|
|
|
|||
|
|
@ -131,17 +131,14 @@ pub(crate) fn normalize(latex: &str) -> Vec<Token> {
|
|||
#[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<Node>),
|
||||
/// A fraction.
|
||||
|
||||
///
|
||||
/// 一个分式。
|
||||
Fraction(Box<Node>, Box<Node>),
|
||||
/// A root.
|
||||
|
||||
///
|
||||
/// 一个根式。
|
||||
Root(Box<Node>, Option<Box<Node>>),
|
||||
/// A superscript.
|
||||
|
||||
///
|
||||
/// 一个上标。
|
||||
Superscript(Box<Node>, Box<Node>),
|
||||
/// A subscript.
|
||||
|
||||
///
|
||||
/// 一个下标。
|
||||
Subscript(Box<Node>, Box<Node>),
|
||||
/// A subscript and a superscript on one base.
|
||||
|
||||
///
|
||||
/// 同一个基上的下标与上标。
|
||||
Scripts(Box<Node>, Box<Node>, Box<Node>),
|
||||
/// A named function such as `sin`.
|
||||
|
||||
///
|
||||
/// 一个具名函数,例如 `sin`。
|
||||
Function(String),
|
||||
/// A mark written above its argument, as in `\dot\Omega`.
|
||||
|
||||
///
|
||||
/// 写在参数上方的一个符号,例如 `\dot\Omega`。
|
||||
Overscript(Box<Node>, 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<Vec<Node>>,
|
||||
|
|
|
|||
Loading…
Reference in New Issue