web: license as GPL alone, and give every document a Chinese form

The service was offered under MIT or Apache-2.0. It is now under the
GNU General Public License, version 3 or later, and that alone: the two
other licence files are removed and the manifest names one licence.
`apsis-rs` is still MIT or Apache-2.0; both permit redistribution
inside a GPL work, so the two may be distributed together under these
terms.

Every document now has a Chinese form. The concept set already had one
for each of its thirty-one documents; the README and the design notes
did not, and both now do.

The design notes carried two sections under the same heading and two
under another, so the second copy of each was unreachable from the
outline. They are merged into one section apiece, keeping every distinct
decision and dropping the two lead-ins that said the same thing twice.
This commit is contained in:
dev 2026-10-02 00:55:00 +08:00
parent 7d2cf4ba73
commit 0a8f00a8fa
5 changed files with 548 additions and 29 deletions

View File

@ -6,6 +6,11 @@
# other facility, including the HTTP server and the JSON codec, is written # other facility, including the HTTP server and the JSON codec, is written
# against the standard library so that the crate stays self-contained and # against the standard library so that the crate stays self-contained and
# auditable. # auditable.
#
# The licence is the GNU General Public License, version 3 or later, and
# that alone. `apsis-rs` is offered under MIT or Apache-2.0; those terms
# permit redistribution inside a GPL work, so the two may be distributed
# together under these terms.
[package] [package]
name = "apsis-book" name = "apsis-book"

View File

@ -1,5 +1,7 @@
# apsis-book # apsis-book
[简体中文](./README.zh.md) | English
The web service layer of ApsisRS. It presents the ApsisRS computational The web service layer of ApsisRS. It presents the ApsisRS computational
kernels for commercial spaceflight and satellite internet systems in a kernels for commercial spaceflight and satellite internet systems in a
browser: the concept documentation, a knowledge graph, the interface browser: the concept documentation, a knowledge graph, the interface
@ -132,7 +134,7 @@ library is built in, because that is the order a reader meets the subject:
|-------|---------| |-------|---------|
| 导论 Introduction | Scope, organisation, and how to read a page | | 导论 Introduction | Scope, organisation, and how to read a page |
| 基础层 Foundations | Units, dimensions, numerical method, error | | 基础层 Foundations | Units, dimensions, numerical method, error |
| 时间与几何 Time and geometry | Time scales, leap seconds, calendars, frames, the Earth rotation angle, the ellipsoid | | 物理层 Physics | Time scales and reference frames, which are two unrelated subjects and are both held here |
| 轨道层 Orbit | Elements, element formats, perturbations, propagators, manoeuvres | | 轨道层 Orbit | Elements, element formats, perturbations, propagators, manoeuvres |
| 射频与传播 Radio and propagation | Doppler, link budgets, the medium, interference | | 射频与传播 Radio and propagation | Doppler, link budgets, the medium, interference |
| 星座与网络 Constellation and network | Constellations, access, topology, handover, routing | | 星座与网络 Constellation and network | Constellations, access, topology, handover, routing |
@ -221,4 +223,10 @@ plain TCP requests against it.
## License ## License
MIT OR Apache-2.0. See `LICENSE-MIT` and `LICENSE-APACHE`. GNU General Public License, version 3 or later, and that alone. See
`LICENSE`.
The service depends on `apsis-rs`, which is offered under MIT or
Apache-2.0. Both of those permit redistribution inside a GPL work, so the
two are distributed together under these terms. No other licence is
offered for this service.

206
README.zh.md Normal file
View File

@ -0,0 +1,206 @@
# apsis-book
[English](./README.md) | 简体中文
ApsisRS 的 Web 服务层。它在浏览器中呈现面向商业航天与卫星互联网的
ApsisRS 计算内核:概念文档、知识图谱、从库源码中提取的接口文档,以及一组
在线工具——用户给定参数,工具在这些内核上实际计算。
服务构建为单一二进制,其中嵌入了它所服务的全部资源。运行时不读取内容目录,
也不读取前端构建产物;二进制旁边不需要存在这两者。
## 服务内容
| 板块 | 内容 | 路由前缀 |
|------|------|----------|
| 概念文档 | 关于时间尺度、参考系、元素格式、传播模型的文档,附各自的单位与适用范围 | `/api/docs` |
| 知识图谱 | 概念及其关系,以及库的 crate 结构与算法流程图 | `/api/graph` |
| 接口文档 | 服务所构建的 ApsisRS 各层的公共接口,从源码文档注释中提取 | `/api/api` |
| 在线工具 | TLE 解析、SGP4 传播、轨迹采样、时间尺度换算、坐标换算、参考系旋转、开普勒换算与霍曼转移规划 | `/api/tools` |
## 设计约束
服务遵循它所属工作区的约束。
- **一个运行时依赖。** `[dependencies]` 中只有 `apsis-rs` 一项,且是路径依赖。
HTTP 服务器、JSON 编解码器、路由器、Markdown 渲染器与图模型,全部基于标准库
编写。
- **不使用 `unsafe`。** 本 crate 不含任何 unsafe 代码块。
- **请求路径中不 panic。** 畸形请求、非法参数或计算失败都产生一个响应,而不是
panic。处理请求的工作线程会把处理函数中的 panic 捕获下来作为最后一道防线,
并返回服务器错误。
- **注释自足。** 每条注释都解释代码、算法、接口或项目本身;没有注释指向私有
文档、指令或计划。
## 构建
必须先构建前端,发布二进制才能嵌入其产物;发布构建会自动完成这一步。
每个构建出的资源,其文件名都带有内容哈希,因此替换二进制就替换了这些文件名。
浏览器因而不可能握着旧包:新包是在它从未见过的 URL 下请求的。入口页面以
`no-cache, must-revalidate` 提供,带哈希的资源则以
`public, max-age=31536000, immutable` 提供。服务不注册 service worker。
```sh
# 开发:调试构建嵌入 frontend/dist 中已有的内容,不运行前端构建。
cargo build
# 发布:build.rs 构建前端并嵌入结果。
cargo build --release
```
二进制位于 `target/release/apsis-book`。
环境变量:
| 变量 | 作用 |
|------|------|
| `APSIS_BOOK_HOST` | 绑定的网络接口,默认 `127.0.0.1` |
| `APSIS_BOOK_PORT` | 绑定的端口,默认 `8080` |
| `APSIS_BOOK_ADDRESS` | 以 `HOST:PORT` 表示的绑定地址,覆盖上面两项 |
| `APSIS_BOOK_WORKERS` | 工作线程数,默认 4 |
| `APSIS_BOOK_SKIP_FRONTEND` | 设为 `1` 时在发布构建中跳过前端构建 |
| `APSIS_BOOK_FRONTEND_DIR` | 前端目录,默认 `frontend` |
命令行选项优先于环境变量:
```sh
# 在 8080 端口绑定所有网络接口。
apsis-book --host 0.0.0.0 --port 8080
# 合并写法等价。
apsis-book --address 0.0.0.0:8080
```
`--host` 与 `--port` 可以同时给出。`--address` 不能与它们中的任何一个合用,
因为二者生效的先后顺序没有定义。主机可以是字面地址、方括号中的 IPv6 字面量,
或由操作系统解析的名字。
### 语言
界面为双语,默认以中文打开。读者用顶栏中的控件切换到英文;该选择保存在浏览器
中,对所有页面生效。
后端为每一条面向读者的字符串同时保存两种语言,因此切换无需请求即可生效。
一篇概念文档的英文写为 `content/concepts/<id>.md`,中文译为
`content/concepts/<id>.zh.md`。没有译文的文档对所有读者显示英文,并附一条说明。
## 运行
```sh
./target/release/apsis-book --address 127.0.0.1:8080
```
打开 `http://127.0.0.1:8080`。JSON 接口不依赖前端即可访问:
- `GET /health`
- `GET /api/docs`、`GET /api/docs/<id>`、`GET /api/docs/search?q=`
- `GET /api/graph`、`GET /api/graph/crates`、`GET /api/graph/flows`、`GET /api/graph/<flow>`
- `GET /api/api`、`GET /api/api/crates/<name>`、`GET /api/api/items/<name>`
- `GET /api/tools`、`GET /api/tools/<id>`、`POST /api/tools/<id>/run`
一次工具调用接受一个参数 JSON 对象,返回一个已整形的结果:
```sh
curl -s http://127.0.0.1:8080/api/tools/hohmann-transfer/run \
-H 'content-type: application/json' \
-d '{"from_radius": 6778, "to_radius": 42164}'
```
## 内容布局
内容是任何单一库所实现之物的一个超集,分为两组。
**概念 / Concepts** 描述这个领域。各层沿用库的分层,因为那是读者接触这个学科的
顺序:
| 层 | 主题 |
|----|------|
| 导论 | 范围、组织方式,以及如何阅读一页 |
| 基础层 | 单位、量纲、数值方法、误差 |
| 物理层 | 时间与坐标系是两个彼此无关的主题,故并列于此 |
| 轨道层 | 根数、元素格式、摄动、传播器、轨道机动 |
| 射频与传播 | 多普勒、链路预算、介质、干扰 |
| 星座与网络 | 星座、可见性、拓扑、切换、路由 |
| 运行层 | 遥测、跟踪、指令、规划 |
| 仿真层 | 场景、事件,以及结果的含义 |
**实现 / Implementation** 描述某一个特定的库对上述内容的实现情况,包括它如何
组织、以及它的算法从何而来。把它们分开,正是概念集保持独立的原因:一篇关于
链路预算的页面不会因为某个库变了而改变。
文档归属于它所在目录所命名的层,因此新增一篇文档就是在它所属的层下创建文件,
并在 `chapters.json` 中列出。
## 重新生成接口目录
接口文档由 ApsisRS 源码生成,而非手工撰写。源码变化时在本目录运行提取脚本,
然后重新构建:
```sh
scripts/extract_api.sh
cargo build --release
```
脚本默认读取 `../apsis-rs` 的工作区;设置 `APSIS_RS_WORKSPACE` 可指向别处的
检出。它需要 Python 3.8 或更高版本。Rust 构建不运行它,也不依赖 Python。
## 编辑内容
| 路径 | 内容 |
|------|------|
| `content/concepts/chapters.json` | 分组、各层与阅读顺序,含双语标题 |
| `content/concepts/<layer>/*.md` | 文档,每层一个目录;目录给出层,文件名给出标识 |
| `content/concepts/<layer>/*.zh.md` | 中文译文,文件名对应其所译的文件 |
| `content/graph/concepts.json` | 概念图谱 |
| `content/flows/*.json` | 算法流程图 |
| `content/api.json` | 生成的接口目录;不要手工编辑 |
| `content/api.zh.json` | crate 概要与条目文档注释的中文译文 |
文档头部声明 `title`、`summary`、`tags` 与 `prerequisites`。标识与层不由头部
声明:标识即文件名,层即目录,二者无法与目录结构相抵触。交叉引用写作
`[[id]]` 或 `[[id|label]]`,渲染为链接,前端无须整页加载即可路由。
## 目录结构
```text
apsis-book/
├── build.rs 嵌入前端构建产物与文本内容
├── Cargo.toml 一个运行时依赖:apsis-rs
├── content/ 撰写的文档、图谱与生成的目录
│ └── concepts/<layer>/ 文档,每层一个目录
├── frontend/ Svelte 与 TypeScript,由 Vite 构建
│ ├── src/lib/ API 客户端、路由器、组件、格式化
│ ├── src/pages/ 每个路由一个组件
│ └── dist/ 构建产物,由 build.rs 嵌入
├── scripts/extract_api.sh 接口目录生成器
├── src/
│ ├── api/ 路由器与 JSON 载荷
│ ├── docs/ 内容、Markdown、文档、接口目录
│ ├── embedded/ 对嵌入式前端资源的访问
│ ├── graph/ 知识图谱与流程图
│ ├── http/ 请求、响应与服务器
│ ├── tools/ 工具注册表、处理函数、结果形状
│ ├── json.rs JSON 编解码器
│ ├── main.rs 进程入口
│ └── state.rs 解析后的内容,供每个工作线程共享
└── tests/service.rs 针对构建出的二进制的端到端测试
```
## 测试
```sh
cargo test # 单元测试与端到端测试
cargo clippy --all-targets
cargo fmt --check
cd frontend && npm run check
```
端到端测试在一个临时端口上启动构建出的二进制,并以普通 TCP 请求与之通信。
## 许可协议
GNU 通用公共许可证,第 3 版或更高版本,且仅此一种。见 `LICENSE`。
本服务依赖 `apsis-rs`,后者以 MIT 或 Apache-2.0 提供。这两种协议都允许在 GPL
作品内再分发,因此二者可以按本协议一并分发。本服务不提供任何其他许可。

View File

@ -104,6 +104,23 @@ the number of documents a token appears in, so a common word contributes
less than a rare one. The index is built once at startup and never changes, less than a rare one. The index is built once at startup and never changes,
so the postings are read only and need no lock. so the postings are read only and need no lock.
A query is handled in two ways. A query that yields tokens is matched
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.
Chinese text needs more than word splitting. Latin text is split on
whitespace and punctuation, but Chinese is written without spaces, so
splitting a Chinese sentence on non-alphanumeric characters leaves one long
token that a two character query can never match. A run of Chinese
characters therefore yields one token per adjacent pair, which is the
smallest unit specific enough to be a word. A query of 坐标 matches a
sentence that contains 坐标, which a whole-run token would not. A single
Chinese character is left to the substring fallback rather than becoming a
token, because a token of one character would match nearly every document.
## Graphs ## Graphs
One graph shape serves the knowledge graph, the crate structure, and the One graph shape serves the knowledge graph, the crate structure, and the
@ -237,7 +254,7 @@ line, and the resulting behaviour would be surprising.
## Interface layout ## Interface layout
Three layout decisions keep the pages readable at any window width. These layout decisions keep the pages coherent at any window width.
A page is a **reading column**. Most pages are text, a card grid, or a form, A page is a **reading column**. Most pages are text, a card grid, or a form,
and all three read better in a centred column than across a whole wide and all three read better in a centred column than across a whole wide
@ -259,33 +276,7 @@ The **graph** is drawn on a fixed layout space that is scaled to the
viewport. A double click opens the document or the interface item a node viewport. A double click opens the document or the interface item a node
names, so a graph is also a way into the documentation. names, so a graph is also a way into the documentation.
## Search
A query is handled in two ways. A query that yields tokens is matched
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.
Chinese text needs more than word splitting. Latin text is split on
whitespace and punctuation, but Chinese is written without spaces, so
splitting a Chinese sentence on non-alphanumeric characters leaves one long
token that a two character query can never match. A run of Chinese
characters therefore yields one token per adjacent pair, which is the
smallest unit specific enough to be a word. A query of 坐标 matches a
sentence that contains 坐标, which a whole-run token would not. A single
Chinese character is left to the substring fallback rather than becoming a
token, because a token of one character would match nearly every document.
## Interface layout
Four layout decisions keep the pages coherent at any window width.
A page is a **reading column**. Most pages are text, a card grid, or a form,
and all three read better in a centred column than across a whole wide
screen. A document is wider than the column because it carries a table of
contents beside the text; the width of the pair is capped so the two stay
together. A graph is the exception and uses the whole width, because a wide
screen should show more of the graph rather than more margin.
A grid of cards has **one card size on the whole page**, not one per row. A grid of cards has **one card size on the whole page**, not one per row.

309
docs/design.zh.md Normal file
View File

@ -0,0 +1,309 @@
# apsis-book 设计说明
[English](./design.md) | 简体中文
这些说明解释服务背后的决策。它们写给读代码的人,描述的是设计,而不是计划。
## 为什么只用标准库
ApsisRS 工作区的写法,是为了让它的数值内核不必跟随一棵依赖树就能被审计。一个引入
异步运行时、Web 框架和序列化框架的 Web 服务,会在暴露内核的那一层破坏这个性质——
而那恰恰是读者最可能首先查看的一层。
代价是实在的,值得写明:
- HTTP 服务器是同步的,每条连接处理一个请求。对一个文档与工具服务来说这够用,
对流式或长轮询负载则不够。
- JSON 编解码器是在这里写的。它覆盖服务所交换的 RFC 8259 子集,并且针对畸形
输入做过测试,但它不是一个通用库。
- Markdown 渲染器覆盖内容所<E5AEB9><E68980>用的构造。它不是 CommonMark 实现。
这些各自都是有界的工作而非无止境的工作,而且每一项都小到可以一次读完——这正是
重点。
## 请求处理
一条连接被接受,读入一个请求,写出一个响应,连接关闭。服务器对每个响应都发送
`Connection: close`,这样就不需要连接状态机,也不需要 keep-alive 记账。
请求头逐字节读取。这比缓冲读<EFBFBD><EFBFBD><EFBFBD>,但正是它让请求头与请求体之间的边界精确,无需回退
缓冲:不属于请求头的字节绝不会被消费掉。读超时限制了慢速对端占用一个工作线程的
时长,而对请求头与请求体的字节上限则限制了恶意对端能让进程分配多少内存。
请求由固定规模的工作线程池处理,任务来自一个有界队列。当所有工作线程都在忙时,
被接受的连接在队列中等待,因此内存用量由线程池规模决定,而不是由连接数决定。
`Connection: close` 策略意味着工作线程绝不会在同一连接上等待第二个请求。
处理函数中的 panic 会被捕获并变成一个服务器错误响应。处理函数并不刻意 panic;
这道防线之所以存在,是因为工作线程中的 panic 否则会终止该线程,而在固定线程池下
这会悄悄降低服务能力。
## JSON
`Value` 持有 `Null`、`Bool`、`Number(f64)`、`String`、`Array` 与 `Object`。
对象保持插入顺序,这让编码输出稳定且可读。
有两项性质对安全性是重要的:
- **编码绝不产生无法解析的输出。** 非有限数没有 JSON 表示,因此被写作 `null`,
而不是 `NaN` 或 `inf`。于是产生非数值的计算不可能产生一份客户端解析失败的文档。
- **解码是有界的。** 嵌套深度受限,因此恶意文档无法通过递归耗尽栈。
字符串按 RFC 8259 转义,代理对被合并。不是合法 UTF-8 的文本被拒绝而非替换,
这样客户端就无法通过解码器夹带字节序列。
## 文档与内容
内容由 `build.rs` 以文本形式嵌入,它扫描 `content/` 并生成一张 `include_str!`
条目表。文档层在启动时解析该表一次。
一篇文档是带小型元数据头部的 Markdown。该头部被解析出标识、标题、所属章、概要、
标签与前置条件;正文被渲染成 HTML 一次,两种形式都保留。因此提供一篇文档的
代价只是一次字符串拷贝。
Markdown 渲染器转义每一个文本节点,只输出它自己生成的标记,因此文档无法注入
标记。前端中的 `{@html}` 是服务把标记交给浏览器的唯一位置,而后端保证了其中
可以出现什么。
交叉引用在解析过程中被收集,并与文档集合比对。无法解析的引用由
`Tree::missing_links` 报告,状态测试断言它对内嵌内容为空。因此一处笔误的引用
会导致测试失败,而不是变成页面中的死链。
## 搜索
索引把一个小写的词元映射到包含它的文档集合。查询以同样方式分词。词元在任意
非字母数字字符处切分,因此 `mean_motion`、`mean-motion` 与 `mean motion`
都能匹配对 `mean motion` 的查询。
打分是查询各词元的「权重乘频次项」之和,出现在标题中的词元另有加分。权重随词元
出现的文档数增加而下降,因此常见词的贡献小于罕见词。索引在启动时构建一次且不再
变化,因此倒排表是只读的,不需要锁。
一个查询分两种方式处理。能产出词元的查询与倒排表匹配;产不出词元的查询(例如
单个字符)则按子串匹配,因为单字符查询无法构成词元,否则将永远一无所获。
中文需要的比分词更多。拉丁文本按空白与标点切分,而中文书写不带空白,因此按
非字母数字字符切分一个中文句子,只会留下一个长词元,两字查询永远匹配不到。因此
一串中文字符每相邻两个字符产出一个词元,这是仍足够具体到能视作一个词的最小
单位。查询「坐标」能匹配含有「坐标」的句子,而整串词元做不到。单个中文字符留给
子串回退而不成为词元,因为一个字符的词元几乎会匹配每一篇文档。
## 图
一种图结构同时服务于知识图谱、crate 结构与算法流程图:带种类与分组的节点,以及
带标签的边。后端给出结构;布局在浏览器中计算。
布局是一个力导向仿真,初始位置由节点标识的散列导出,因此同一张图每次加载都画成
同一个样子。仿真在数据变化时跑到收敛即止,而不是做成动画,这使该组件成为其
props 的纯函数,并使图在测试或截图中可复现。
## 工具
工具是一份声明,而不是前端认识的代码。注册表为每个参数声明其类型、单位、取值
范围、默认值与帮助文本;前端据此渲染出表单。
参数值以 JSON 对象到达。`Params` 完成带类型的读取,并依据声明做校验,于是处理
函数只面对 `f64`、`i64`、`String` 与枚举选择。值既可以作为 JSON 数字也可以作为
字符串到达,因为表单控件发送的是文本;两者都被接受。落在声明范围之外的值会被
连同参数名一起拒绝,这正是表单能够高亮该字段的原因。
处理函数通过 `out` 塑造结果,它把结果固定为标量、表格与数值序列三类。前端只有
一个结果渲染器,因此新增一个工具意味着新增一份声明与一个处理函数,而前端
无需改动。
当失败源于非法输入时,错误携带参数名与一条写给表单使用者的消息。消息指明条件
与可接受的范围。它不引用内部状态,因此失败不会泄露实现的形状。
## 接口目录
接口目录由 `scripts/extract_api.sh` 从 ApsisRS 源码生成:脚本读取各 crate 目录,
收集模块文档与公共条目的文档注释和签名,写入 `content/api.json`。该文件被提交,
因此构建 Rust 二进制既不依赖 ApsisRS 的检出,也不依赖 Python。
采用提取而非手工维护,正是接口文档保持诚实的原因:库中签名的改动,在脚本重跑
之后就是生成目录的改动,而手写的描述会悄无声息地失准。
脚本在 `generated_from` 中报告它读过的 crate,服务在 `/api/api` 中返回该字段,
因此所显示内容的来源是可核查的。
译文是另一个文件 `content/api.zh.json`,以 crate 名与条目名为键。它与目录分开,
是因为目录是生成的而译文是撰写的:重新生成目录不应丢弃译文。关联条目以声明
它的类型为键,写作 `Type::name`,因为有若干类型都有关联的 `new`;裸名作为回退。
文档注释用 Rust 路径引用另一个条目,写作 `[Epoch](crate::Epoch)` 或简写
`` [`Epoch::mjd`] ``。渲染器把这类引用变成指向该条目接口页的链接。名称的解析
方式是从右沿路径回溯,取第一个已知条目的片段,因此指向变体或方法的链接会落到
声明它的枚举或 trait 上。若所有片段都未知,该引用保持为代码片段,这正是
`` [`0, 1`] `` 这样的标签不会变成一个指向空处的链接的原因。
文档注释由后端用概念文档所用的同一个渲染器渲染为 HTML,因此含有列表、表格或
代码块的注释也会被照原样画出。
## 前端与嵌入
前端是 Svelte 与 TypeScript,由 Vite 构建。构建产物是一组稳定的文件名而非带内容
哈希的文件名,因此未改动的源码产生未改动的内嵌表,增量的 Rust 构建不会重新链接。
`build.rs` 在 release 配置下运行前端构建,在 debug 配置下跳过它。这一划分是刻意
的:调试构建迭代的是后端,不应为前端构建付出代价;发布构建则应当产出无需后续
步骤的单一二进制。`APSIS_BOOK_SKIP_FRONTEND=1` 强制在发布构建中跳过。前端构建
失败会作为警告报告,并嵌入上一次成功的产物,因此在没有网络的情况下发布构建
仍能产出二进制。
当没有嵌入任何内容时,服务会提供一个内置页面,说明构建步骤并列出 JSON 路由,
因此一份尚未构建前端的新检出仍能给出有用的回应。
## 语言
界面是双语的,而后端并不知道读者选了哪种语言,因为这个选择是在浏览器中做出的,
且可以不经请求而改变。因此每一条面向读者的字符串都以两种语言保存。
有三种机制承载译文:
- [`Text`] 值持有一个中文串与一个英文串,编码为 `{"zh": ..., "en": ...}`。
工具注册表用它承载每个标签,图谱数据用它承载每个节点与边的说明,章节列表
用它承载每个章标题。
- 一篇文档以英文写一次,并以带 `.zh.md` 后缀的第二个文件翻译。读者的语言决定
取哪种形式;没有译文的文档显示英文,并在页面中附一条说明。
- 工具结果的标签在处理函数中以英文书写,再由 `src/tools/labels.rs` 中的查表
翻译。表中没有的标签是一个符号,例如 `position.x` 或 `BSTAR`,在两种语言中
都按原样显示——对一个名字而言,这正是读者所期望的。
默认语言是中文。该选择保存在 `localStorage` 中,并反映在文档元素的 `lang`
属性上。
有两类内容在两种语言中都保持英文。单位按标准所用的符号书写;接口条目保留它们
在库源码中的名字,因为译过的标识符将无法与它所记录的代码对应。
## 命令行
监听由 `--host` 与 `--port` 绑定,或由合并的 `--address` 绑定。主机可以是字面量、
IPv6 字面量,或由操作系统解析的名字;名字在绑定监听之前解析一次,因此解析失败
会被报告为配置错误,而不是绑定失败。
`--address` 不能与 `--host` 或 `--port` 合用。同时接受两者需要一个命令行并未
定义的生效顺序,其行为会令人困惑。
## 界面布局
三个布局决策让页面在任何窗口宽度下都可读。
一个页面是**阅读栏**。多数页面是文本、卡片网格或表单,三者在居中的一栏中都比
横跨整个宽屏更好读,因此内容被限制在一个阅读尺度内并居中。文档是唯一比这一栏
更宽的页面,因为它在正文旁边带一份目录;目录计入这个上限,而没有标题的文档会
去掉目录,单独使用阅读尺度。图谱朝相反方向打破这一规则:它使用整个宽度,因为宽屏
应当显示更多的图,而不是更多的留白。
**接口索引**每个 crate 只显示一行,而不是完整的模块文档。一个 crate 的概要有好几
段,把它们排成网格无法阅读;索引显示首句,其余由 crate 页面呈现。文档注释由
后端当作 Markdown 渲染,因此含有列表、表格或代码块的注释会照原样画出,前端不
必去解析它。
**图**绘制在一个固定的布局空间上,并按视口缩放。双击会打开节点所命名的文档或
接口条目,因此图也是进入文档的一条路径。
卡片网格在**整页只有一个卡片尺寸**,而不是每行一个。
网格只会让同一行的卡片等高,这还不够:每一行各自测量,于是一页由若干网格构成
时,行高会逐行变化,卡片列看上去参差不齐。因此浏览器中会测量所有网格里最高的
那张卡片,并把它应用到全部卡片上,同时用一个 `ResizeObserver` 使测量跟随列数
与语言的变化。
列数是**固定值而非有多少放多少**。用 `auto-fill` 为列定尺寸会让列宽取决于该行
有多少张卡片,于是同一页上三个一组的小节与四个一组的小节会给出不同的卡片宽度。
固定列数在整页维持同一种节奏;填不满一行的<EFBFBD><EFBFBD><EFBFBD>节把空隙留在末尾,而不是把卡片
拉宽。
一张卡片就是一列,因此它的**操作按钮位于每张卡片的最后一行**,无论上方文字
有多长。概要不被截断:统一的高度意味着概要较长的卡片只是多带几行。
卡片之间的间距**只**来自网格的 `gap`。在网格内会抵消同级外边距,因为一张带
前序兄弟的卡片否则会同时带上行间距与同级外边距。每行的第一张卡片没有前序兄弟,
因此它会紧贴而行中其余卡片下沉一个间距,看上去像错位,尽管每张卡片尺寸相同。
侧栏是**位于一个框架内的粘性元素**。框架承载底色与分隔线,使这一列画满整页;
侧栏本身是 `sticky` 并随页滚动。没有框架的话,这一列会在一个视口高度处结束,
在长页面上读起来像是被截断。
图画布采用**布局空间的宽高比**,因此绘图填满画布,而不是被加黑边塞在里面。
力导向仿真收敛为一个大致圆形的簇,而画布是宽的,因此在拟合之前会先把布局拉伸
到画布的形状;这一改动是均匀缩放,因而相对距离仍然可读。
## 锚点与大纲
标题锚点由标题文本归约而来,目录携带该锚点。有两条规则让链接落在读者预期的位置。
归约**保留每种文字的字母**。只保留 ASCII 字母意味着每个中文标题都归约到同一个
回退值,于是整份目录都指向第一节,点任何一条都滚到文档顶部。文档正文与大纲用
同一条规则归减,因此一个条目所命名的锚点是文档确实携带的。
重复出现的标题在**两侧都编号**。否则两个同文本的标题会共用一个锚点,第二个条目
会滚到第一个上。文档与大纲以同样方式为重复项编号,因此映射保持精确。
大纲携带每个标题的**层级**,于是嵌套的标题缩进在其父级之下,而不是与之并列。
折叠控件只为有子项的标题显示,因为一个什么也藏不住的控件比没有控件更糟。
## 语言覆盖
面向读者的字符串分三类,各自处理方式不同。
界面框架——导航、标题、按钮、帮助文本——是 `frontend/src/lib/i18n.ts` 中一份
手写词典,以稳定的名称为键。它覆盖图谱分组说明与接口条目种类,因此中文读者会
看到中文图例与中文分组标题。
撰写的内容——概念文档、crate 概要、条目文档——按语言各一个文件,因此后端两者
都提供,读者切换时无需再一次请求。一篇文档在每个语言文件下用同一个键声明自己
的标签,因此标签列表也随语言变化。
派生文本——卡片里用的一行概要——由后端从文档生成,渲染器会解析文档注释所用的
rustdoc 形式。`` [`Vec3`] `` 这样的简写引用会被归约为条目名;若不如此,方括号与
反引号就会原样出现在概要里。
## 缓存
服务提供的是在升级时被整体替换的单一二进制,因此缓存要回答的唯一问题是:浏览器
会不会握着一份已经不是这个二进制所嵌入的副本?
构建给出的答案是把内容哈希放进每个资源名。改动某个文件的构建同时改动了该文件的
名字,于是新包的 URL 是浏览器从未请求过的,因而不可能被缓存。服务接着声明一个
仅凭名字就该有的策略:
- 名字带哈希的文件是 `public, max-age=31536000, immutable`。该文件保留一年
且永不重新验证,因为不会再有谁请求那个名字。
- 入口页面是 `no-cache, must-revalidate`。它指名当前的包,因此不经询问就不会从
缓存提供,而这一要求既对浏览器声明,也对中间代理声明。
- 名字不带哈希的文件是 `no-cache, must-revalidate`,因为后续构建会复用那个名字。
这个判断只读名字,因此对早期构建所嵌入的文件与当前构建所嵌入的文件同样正确。
两种替代方案被否决。对每个文件使用 `no-store` 消除了这个问题,代价是每次页面
加载都重新下载整个包。对每个文件使用 `no-cache` 是正确的,但在服务没有条件请求
处理逻辑的前提下,会让浏览器重新下载本可复用的内容。两者都不如把内容编进名字
来得快。
服务不注册 service worker,也不发出 manifest,因此没有第二层缓存需要推理。
## 工具路由
工具路由有两种状态。没有工具标识时页面是索引:每个工具一张卡片,按类别分组,
每张卡片打开该工具。有标识时页面是该工具自身。
这两种状态是一<EFBFBD><EFBFBD><EFBFBD>路由而非两个页面。单一路由意味着后退按钮、共享链接与刷新,
无论读者是从索引还是从别处的链接到达,行为都一致。工具页面带有返回索引的链接,
因为索引并不在它上面。
工具页面不含列表。这正是这一划分的意义:从链接打开工具的读者看到的是表单,
而不是它被引用的那份列表,表单得以占据整个宽度。代价是比较两个工具需要经由索引
往返两次。
## 测试
单元测试与它所测试的代码相邻,覆盖解析器、编解码器、Markdown 渲染器、文档模型、
搜索排序、图谱解析、路由器以及每一个工具处理函数。
`tests/service.rs` 中的端到端测试在一个临时端口上启动构建出的二进制,并以普通
TCP 请求与之通信。它们把请求解析器、路由器、编码后的载荷、内嵌内容与工具处理
函数放在一起检验,这是任何单元测试都做不到的。
有一处断言值得单独指出:状态测试检查 `Tree::missing_links` 为空、且图谱没有悬空
的边。这两项检查把一处内容错误变成一次测试失败。