web: align the interface layout and translate the remaining captions

This commit is contained in:
dev 2026-10-01 08:46:46 +08:00
parent 994f871ff2
commit eb65230a44
32 changed files with 479 additions and 70 deletions

View File

@ -1,5 +1,6 @@
---
title: 工作区架构
tags: 架构, 分层, crate
summary: apsis-rs 的分层结构、每层的 crate 以及依赖规则。
---

View File

@ -1,5 +1,6 @@
---
title: 日历与约化儒略日
tags: 时间, 日历, 儒略日, mjd
summary: 日编号系统、外推格里高利历,以及转换算法。
---

View File

@ -1,5 +1,6 @@
---
title: 地球自转角
tags: 参考系, 地球自转, iers, ut1
summary: 惯性系与地固系之间的夹角,以及给出该值的线性表达式。
---

View File

@ -1,5 +1,6 @@
---
title: 星历采样与插值
tags: 星历, 插值, 采样, hermite
summary: 何时采样一次并多次查询轨迹,以及插值所付出的精度代价。
---

View File

@ -1,5 +1,6 @@
---
title: WGS 84 椭球与大地坐标
tags: 大地测量, wgs84, 纬度, 椭球
summary: 纬度、经度与椭球高,以及到直角坐标的闭式转换。
---

View File

@ -1,5 +1,6 @@
---
title: 开普勒轨道元素
tags: 轨道, 开普勒, 元素, 近点角
summary: 六个经典轨道元素、二体解与三种近点角。
---

View File

@ -1,5 +1,6 @@
---
title: 闰秒
tags: 时间, utc, 闰秒, iers
summary: UTC 为何存在阶跃间断,以及内嵌闰秒表如何处理它们。
---

View File

@ -1,5 +1,6 @@
---
title: 概念总览
tags: 总览, 范围
summary: ApsisRS 计算内核的覆盖范围,以及本概念文档集的组织方式。
---

View File

@ -1,5 +1,6 @@
---
title: 轨道摄动
tags: 轨道, 摄动, 阻力, 扁率, 三体
summary: 使轨道偏离椭圆的各种力,以及各模型包含其中哪些。
---

View File

@ -1,5 +1,6 @@
---
title: 传播器选择
tags: 传播, 对比, sgp4, hpop
summary: 各种传播方法的对比,以及各自的开销与精度。
---

View File

@ -1,5 +1,6 @@
---
title: 参考系
tags: 参考系, 惯性系, 地固系, 大地坐标, 旋转
summary: 惯性系与地固系、在类型中携带参考系的位置表示,以及二者之间的旋转。
---

View File

@ -1,5 +1,6 @@
---
title: SGP4 与 SDP4 传播
tags: sgp4, sdp4, 传播, 两行元素集
summary: 两行元素集所拟合的解析传播器、其两个分支,以及坐标系与单位。
---

View File

@ -1,5 +1,6 @@
---
title: 标准与来源
tags: 标准, 引用, 出处
summary: 各内核所遵循的公开标准与论文,以及引用方式。
---

View File

@ -1,5 +1,6 @@
---
title: 时间尺度
tags: 时间, utc, tai, tt, gps
summary: UTC、TAI、TT 与 GPS,以及为什么时刻只存储在单一尺度上。
---

View File

@ -1,5 +1,6 @@
---
title: 两行元素格式
tags: 两行元素集, 元素, 格式, 校验和
summary: 两行元素集的定宽列、校验和规则,以及两个容易误读的约定。
---

View File

@ -276,6 +276,54 @@ 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 **stretches every card in a row** to the height of the
tallest. A card is therefore a column, its action is pushed to the bottom,
and its summary is clamped, so the actions of a row line up and one long
paragraph cannot stretch a card past its neighbours.
The sidebar is **a sticky element inside a frame**. The frame carries the
surface and the rule so the column is drawn for the whole page; the sidebar
itself is `sticky` and scrolls. Without the frame the column would end one
viewport down and read as cut off on a long page.
The graph canvas takes the **aspect of the layout space**, so the drawing
fills it instead of being letterboxed inside it. The force simulation
settles into a roughly round cluster while the canvas is wide, so the layout
is widened to the canvas shape before it is fitted; the change is a uniform
scale, which leaves relative distances readable.
## Language coverage
A user facing string falls into three classes, and each is handled
differently.
Interface chrome — navigation, headings, buttons, help text — is a
hand written dictionary in `frontend/src/lib/i18n.ts`, keyed by a stable
name. It covers the graph group captions and the interface item kinds, so a
Chinese reader sees a Chinese legend and Chinese group headings.
Authored content — concept documents, crate summaries, item documentation —
is a separate file per language, so the backend serves both and the reader
switches without another request. A document declares its own tags in each
language file under the same key, so the tag list follows the language too.
Derived text — a one line summary used in a card — is produced by the
backend from the document, and the renderer resolves the rustdoc forms a doc
comment uses. A shortcut reference such as `[`Vec3`]` is reduced to the item
name; without that, the brackets and backticks appear verbatim in the
summary.
## Testing
Unit tests live beside the code they test and cover the parser, the codec,

View File

@ -154,6 +154,7 @@
</svelte:head>
<div class="app">
<div class="sidebar-frame">
<aside class="sidebar">
<div class="sidebar-brand">
<a href="#/">APSIS</a>
@ -192,6 +193,7 @@
{/if}
</nav>
</aside>
</div>
<div class="main">
<header class="topbar">

View File

@ -170,16 +170,22 @@ hr {
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
/* The frame carries the surface and the rule, so the column is drawn for
the whole page. The sidebar itself is sticky and scrolls; without the
frame the column would end one viewport down and look cut off on a long
page. */
.sidebar-frame {
flex: 0 0 var(--sidebar-width);
background: var(--surface);
border-right: 1px solid var(--border);
padding: 1.25rem 0 2rem;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
padding: 1.25rem 0 2rem;
}
.sidebar-brand {
@ -271,7 +277,7 @@ hr {
}
.search-box {
flex: 1 1 auto;
flex: 0 1 auto;
max-width: 34rem;
display: flex;
gap: 0.5rem;
@ -404,17 +410,61 @@ button.ghost:hover:not(:disabled) {
margin-top: 1rem;
}
/* A filter field is a control, not a paragraph, so it is given a measure
rather than the whole width of the page. */
.filter-field {
max-width: 34rem;
}
.card h3 {
margin-top: 0;
}
/* A grid of cards stretches every card in a row to the height of the
tallest. The card is a column so that its action sits at the bottom of
every card rather than floating at the height of the text above it. */
.grid.cards > .card {
display: flex;
flex-direction: column;
height: 100%;
}
.card-action {
margin-top: auto;
padding-top: 0.75rem;
align-self: flex-start;
}
/* A card is a column, so its button would otherwise stretch across the
whole card rather than sit under the text. */
.grid.cards > .card > button,
.grid.cards > .card > a {
align-self: flex-start;
}
/* The tag row follows the summary; only the action is pushed to the bottom,
so the actions of a row line up regardless of how many tags a card has. */
.card-tags {
padding-top: 0.15rem;
}
/* A summary is clamped so that one long paragraph does not stretch its card
far past the cards beside it. */
.card-clamp {
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.grid {
display: grid;
gap: 1rem;
}
.grid.cards {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
}
.grid.metrics {
@ -438,8 +488,7 @@ button.ghost:hover:not(:disabled) {
.metric .label {
color: var(--text-faint);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.03em;
}
.page-header {
@ -463,6 +512,7 @@ button.ghost:hover:not(:disabled) {
.tag {
display: inline-block;
white-space: nowrap;
background: var(--surface-raised);
border: 1px solid var(--border);
color: var(--text-muted);
@ -474,6 +524,7 @@ button.ghost:hover:not(:disabled) {
.badge {
display: inline-block;
white-space: nowrap;
border-radius: var(--radius-small);
padding: 0.05rem 0.4rem;
font-size: 0.72rem;
@ -726,7 +777,11 @@ tbody tr:hover {
.graph-canvas {
width: 100%;
height: 34rem;
/* The aspect of the layout space, so the drawing fills the canvas
instead of being letterboxed inside it. */
aspect-ratio: 2 / 1;
min-height: 20rem;
max-height: 46rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
@ -820,6 +875,19 @@ tbody tr:hover {
flex-wrap: wrap;
}
/* The items of one crate, two to a row, so that a crate with dozens of
items stays a reasonable height. */
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(28rem, 1fr));
gap: 0.6rem;
align-items: start;
}
.item-grid .api-item {
margin-bottom: 0;
}
.api-item .name {
font-family: var(--mono);
font-size: 0.95rem;
@ -957,6 +1025,35 @@ tbody tr:hover {
font-size: 0.85rem;
}
/* A tool in the tool index. The card is a link, so the whole area is the
target and the hover is the affordance. */
.tool-card {
color: var(--text);
}
.tool-card:hover {
border-color: var(--accent-strong);
background: var(--surface-raised);
text-decoration: none;
}
.tool-card h4 {
margin: 0 0 0.35rem;
font-size: 0.98rem;
color: var(--accent);
}
.tool-card p {
margin: 0;
color: var(--text-muted);
font-size: 0.86rem;
}
.tool-card.selected {
border-color: var(--accent);
background: var(--accent-soft);
}
/* A row of links that wrap, used for the item filter results. */
.chip-row {
display: flex;
@ -1061,6 +1158,18 @@ tbody tr:hover {
/* Responsive */
@media (max-width: 700px) {
/* The counters are a summary, not navigation, so they are the first
thing to go when the bar is tight. */
.topbar .badge {
display: none;
}
.topbar {
padding: 0.6rem 1rem;
}
}
@media (max-width: 960px) {
.app {
flex-direction: column;

View File

@ -37,9 +37,14 @@
/** The radius of a node marker. */
const NODE_RADIUS = 9;
/** Width and height of the layout space. */
const WIDTH = 1200;
const HEIGHT = 760;
/**
* Width and height of the layout space.
*
* The canvas takes this aspect, so the drawing fills it rather than being
* letterboxed inside it.
*/
const WIDTH = 1400;
const HEIGHT = 700;
let placements = $state<Map<string, Placement>>(new Map());
let span = $state(1);
@ -120,14 +125,84 @@
placement.y = Math.min(HEIGHT - 60, Math.max(60, placement.y));
}
}
return result;
return spreadToCanvas(result);
}
/**
* Widens a layout until it has the shape of the canvas.
*
* The simulation settles into a roughly round cluster, while the canvas is
* wide. Left alone the fit then scales by the short axis and leaves a large
* empty margin on the long one. Stretching along the long axis to match the
* canvas shape uses the whole area; the distortion is a uniform scale, so
* relative distances and the layout stay readable.
*/
function spreadToCanvas(points: Map<string, Placement>): Map<string, Placement> {
const values = [...points.values()];
if (values.length < 2) return points;
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const point of values) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
const width = maxX - minX;
const height = maxY - minY;
if (width < 1 || height < 1) return points;
const wanted = WIDTH / HEIGHT;
const current = width / height;
// Only widen a narrow layout; a layout that is already wider than the
// canvas is left alone, since stretching further would overflow it.
if (current >= wanted * 0.9) return points;
const scale = (wanted * height) / width;
if (scale > 3) return points;
const centreX = (minX + maxX) / 2;
for (const point of values) {
point.x = centreX + (point.x - centreX) * scale;
}
return points;
}
/**
* Scales and centres the view so that every node is visible with a margin.
*
* The layout occupies a fixed design space, which the force simulation
* fills only in its centre. Fitting the view to the nodes removes the
* empty margin the reader would otherwise have to pan across.
*/
function fitToNodes() {
if (placements.size === 0) {
span = 1;
offsetX = 0;
offsetY = 0;
return;
}
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const placement of placements.values()) {
minX = Math.min(minX, placement.x);
maxX = Math.max(maxX, placement.x);
minY = Math.min(minY, placement.y);
maxY = Math.max(maxY, placement.y);
}
const margin = 70;
const width = Math.max(1, maxX - minX + margin * 2);
const height = Math.max(1, maxY - minY + margin * 2);
const scale = Math.min(WIDTH / width, HEIGHT / height);
span = Math.max(0.3, Math.min(2.5, scale));
offsetX = (WIDTH - (maxX - minX) * span) / 2 - minX * span;
offsetY = (HEIGHT - (maxY - minY) * span) / 2 - minY * span;
}
$effect(() => {
placements = layout(graph);
span = 1;
offsetX = 0;
offsetY = 0;
fitToNodes();
});
/** The transform of the layout viewport. */
@ -166,16 +241,14 @@
span = next;
}
/** Resets the viewport. */
/** Returns the viewport to the fitted view. */
function reset() {
span = 1;
offsetX = 0;
offsetY = 0;
fitToNodes();
}
</script>
<div class="graph-controls">
<button class="ghost" type="button" onclick={reset}>{tr('graph.reset', lang)}</button>
<button class="ghost" type="button" onclick={reset}>{tr('graph.fit', lang)}</button>
<span class="badge">{graph.nodes.length} {tr('graph.nodes', lang)}</span>
<span class="badge">{graph.edges.length} {tr('graph.edges', lang)}</span>
<span class="badge">{tr('graph.zoom', lang)} {span.toFixed(2)}×</span>

View File

@ -27,6 +27,12 @@ export interface Text {
en: string;
}
/** A list of strings carried in both languages. */
export interface BilingualList {
zh: string[];
en: string[];
}
/** Returns `true` when the value is a supported language tag. */
export function isLanguage(value: unknown): value is Language {
return value === 'zh' || value === 'en';
@ -77,6 +83,19 @@ export function pick(value: Text | null | undefined, target?: Language): string
return value.en || value.zh;
}
/** Picks a list from a bilingual value that may be empty in one language. */
export function pickList(
value: BilingualList | null | undefined,
target?: Language,
): string[] {
if (!value) return [];
const selected = target ?? get(language);
// A list can be empty in one language and filled in the other, so the
// length is checked rather than the truthiness of the array.
if (selected === 'zh') return value.zh?.length ? value.zh : (value.en ?? []);
return value.en?.length ? value.en : (value.zh ?? []);
}
/** The interface strings, keyed by a stable name. */
const STRINGS: Record<string, Text> = {
'app.title': { zh: 'APSIS', en: 'APSIS' },
@ -172,7 +191,7 @@ const STRINGS: Record<string, Text> = {
'graph.edges': { zh: '边', en: 'edges' },
'graph.zoom': { zh: '缩放', en: 'zoom' },
'graph.reset': { zh: '复位视图', en: 'Reset view' },
'graph.fit': { zh: '适配', en: 'Fit' },
'graph.fit': { zh: '适应窗口', en: 'Fit to view' },
'graph.openDocument': { zh: '打开文档', en: 'Open the document' },
'graph.interface': { zh: '接口:', en: 'Interface:' },
'graph.crateNote': {
@ -233,6 +252,50 @@ const STRINGS: Record<string, Text> = {
'common.notFound': { zh: '不存在页面', en: 'There is no page at' },
'common.goHome': { zh: '前往总览', en: 'Go to the overview' },
'common.language': { zh: '语言', en: 'Language' },
// Group captions of the knowledge graph and the flow graphs. The keys are
// the group identifiers used by the content data.
'group.overview': { zh: '总览', en: 'Overview' },
'group.time': { zh: '时间', en: 'Time' },
'group.geometry': { zh: '坐标与几何', en: 'Geometry' },
'group.elements': { zh: '元素集', en: 'Elements' },
'group.propagation': { zh: '轨道传播', en: 'Propagation' },
'group.methods': { zh: '方法对比', en: 'Methods' },
'group.foundation': { zh: '基础层', en: 'Foundation' },
'group.physics': { zh: '物理层', en: 'Physics' },
'group.orbit': { zh: '轨道层', en: 'Orbit' },
'group.rf': { zh: '射频层', en: 'Radio' },
'group.network': { zh: '网络层', en: 'Network' },
'group.ops': { zh: '运行层', en: 'Operations' },
'group.sim': { zh: '仿真层', en: 'Simulation' },
'group.input': { zh: '输入', en: 'Input' },
'group.parse': { zh: '解析', en: 'Parse' },
'group.prepare': { zh: '准备', en: 'Prepare' },
'group.branch': { zh: '分支', en: 'Branch' },
'group.propagate': { zh: '传播', en: 'Propagate' },
'group.verify': { zh: '校验', en: 'Verify' },
'group.fields': { zh: '字段', en: 'Fields' },
'group.crosscheck': { zh: '交叉检查', en: 'Cross-check' },
'group.error': { zh: '错误', en: 'Error' },
'group.output': { zh: '输出', en: 'Output' },
'group.validate': { zh: '校验', en: 'Validate' },
'group.convert': { zh: '换算', en: 'Convert' },
'group.store': { zh: '存储', en: 'Store' },
'group.frames': { zh: '参考系', en: 'Frames' },
'group.rotate': { zh: '旋转', en: 'Rotate' },
'group.geodetic': { zh: '大地坐标', en: 'Geodetic' },
'group.general': { zh: '其他', en: 'General' },
// Item kinds of the interface documentation.
'kind.struct': { zh: '结构体', en: 'struct' },
'kind.enum': { zh: '枚举', en: 'enum' },
'kind.trait': { zh: 'trait', en: 'trait' },
'kind.fn': { zh: '函数', en: 'fn' },
'kind.const': { zh: '常量', en: 'const' },
'kind.type': { zh: '类型别名', en: 'type' },
'api.itemCount': { zh: '个接口项', en: 'items' },
'tools.choose': { zh: '共 {count} 个工具,按类别分组。', en: '{count} tools, grouped by category.' },
};
/** Returns an interface string in the given language. */
@ -249,4 +312,37 @@ export function t(key: string): string {
return get(language) === 'zh' ? entry.zh : entry.en;
}
/**
* Returns the caption of a graph group.
*
* A group that is not in the dictionary is shown as written, which keeps a
* new group in the content readable rather than blank.
*/
export function groupLabel(group: string, target?: Language): string {
const entry = STRINGS[`group.${group}`];
if (!entry) return group;
return pick(entry, target);
}
/**
* Returns the caption of an interface item kind.
*/
export function kindLabel(kind: string, target?: Language): string {
const entry = STRINGS[`kind.${kind}`];
if (!entry) return kind;
return pick(entry, target);
}
/**
* The headings that introduce a group of interface items, in the plural.
*/
export const KIND_GROUPS: Record<string, Text> = {
struct: { zh: '结构体', en: 'Structs' },
enum: { zh: '枚举', en: 'Enums' },
trait: { zh: 'trait', en: 'Traits' },
fn: { zh: '函数', en: 'Functions' },
const: { zh: '常量', en: 'Constants' },
type: { zh: '类型别名', en: 'Type aliases' },
};
export default t;

View File

@ -14,6 +14,12 @@ export interface Bilingual {
en: string;
}
/** A list of strings carried in both languages. */
export interface BilingualList {
zh: string[];
en: string[];
}
/** Service health. */
export interface Health {
status: string;
@ -42,10 +48,11 @@ export interface DocumentForm {
export interface Document {
id: string;
chapter: string;
tags: string[];
prerequisites: string[];
references: string[];
forms: DocumentForm[];
/** Tags in both languages. */
tags: BilingualList;
}
/** The document listing, grouped by chapter. */
@ -55,7 +62,7 @@ export interface DocumentIndex {
id: string;
title: Bilingual;
summary: Bilingual;
documents: { id: string; title: Bilingual; summary: Bilingual; tags: string[] }[];
documents: { id: string; title: Bilingual; summary: Bilingual; tags: BilingualList }[];
}[];
}
@ -66,6 +73,8 @@ export interface SearchHit {
title: string;
summary: string;
chapter: string;
/** The chapter title in both languages, so a result is readable. */
chapter_title: Bilingual;
score: number;
}

View File

@ -16,6 +16,7 @@
import { getJson } from '../lib/api';
import { navigate } from '../lib/router';
import { language, pick, tr } from '../lib/i18n';
import { KIND_GROUPS } from '../lib/i18n';
import Signature from '../lib/Signature.svelte';
interface Props {
@ -23,6 +24,8 @@
id: string | null;
}
type Language = import('../lib/i18n').Language;
let { index, id }: Props = $props();
/** The current language, read so that the text re-renders on a switch. */
@ -96,6 +99,17 @@
})).filter((group) => group.items.length > 0);
});
/**
* Returns the heading of a group of interface items.
*
* The heading names the kind in the reader's language, in the plural,
* because it introduces a list.
*/
function groupHeading(kind: string, language: Language): string {
const names = KIND_GROUPS[kind];
return names ? pick(names, language) : kind;
}
/** Opens an item route. */
function openItem(name: string) {
navigate(`/api/${name}`);
@ -119,20 +133,6 @@
}
}
/**
* Returns the first sentence of a crate summary.
*
* A crate summary is the module documentation, which is several
* paragraphs. The index shows one sentence per crate so that the grid
* stays readable; the full text is on the crate page.
*/
function firstSentence(summary: string): string {
const text = summary.replace(/\s+/g, ' ').trim();
if (text === '') return '';
const end = text.search(/[.!?](\s|$)/);
if (end === -1) return text.length > 160 ? `${text.slice(0, 157)}...` : text;
return text.slice(0, end + 1);
}
</script>
<div class="page-header">
@ -155,9 +155,10 @@
{#each groups as group}
<h3 id={`kind-${group.kind}`}>
{group.kind === 'fn' ? 'functions' : `${group.kind}s`}
{groupHeading(group.kind, lang)}
<span class="badge">{group.items.length}</span>
</h3>
<div class="item-grid">
{#each group.items as candidate (candidate.name + candidate.signature)}
<article class="api-item">
<header>
@ -186,6 +187,7 @@
{/if}
</article>
{/each}
</div>
{/each}
{:else if item}
<!-- One item on its own. -->
@ -230,6 +232,7 @@
<section class="card">
<h3 style="margin-top: 0">{tr('api.find', lang)}</h3>
<input
class="filter-field"
type="search"
placeholder={`${tr('api.filter', lang)} (${index.item_count})`}
bind:value={filter}
@ -256,9 +259,9 @@
<header>
<span class="name">{candidate.name}</span>
<span class="badge">{candidate.layer}</span>
<span class="badge">{candidate.items}</span>
<span class="badge">{candidate.items} {tr('api.itemCount', lang)}</span>
</header>
<p>{firstSentence(pick(candidate.summary.plain, lang))}</p>
<p class="card-clamp">{pick(candidate.summary.plain, lang)}</p>
</a>
{/each}
</div>

View File

@ -7,7 +7,7 @@
*/
import type { DocumentIndex } from '../lib/types';
import { navigate } from '../lib/router';
import { language, pick, tr } from '../lib/i18n';
import { language, pick, pickList, tr } from '../lib/i18n';
interface Props {
index: DocumentIndex | null;
@ -37,18 +37,17 @@
{#each chapter.documents as document}
<article class="card">
<h3><a href={`#/docs/${document.id}`}>{pick(document.title, lang)}</a></h3>
<p style="color: var(--text-muted); margin: 0.4rem 0 0.6rem">
<p class="card-clamp" style="color: var(--text-muted); margin: 0.4rem 0 0.6rem">
{pick(document.summary, lang)}
</p>
<div>
{#each document.tags as tag}
<div class="card-tags">
{#each pickList(document.tags, lang) as tag}
<span class="tag">{tag}</span>
{/each}
</div>
<button
class="ghost"
class="ghost card-action"
type="button"
style="margin-top: 0.75rem;"
onclick={() => navigate(`/docs/${document.id}`)}>{tr('docs.read', lang)}</button>
</article>
{/each}

View File

@ -13,7 +13,7 @@
*/
import type { Document, DocumentForm } from '../lib/types';
import { navigate } from '../lib/router';
import { language, setLanguage, tr, type Language } from '../lib/i18n';
import { language, pickList, setLanguage, tr, type Language } from '../lib/i18n';
interface Props {
document: Document | null;
@ -101,7 +101,7 @@
<p style="color: var(--text-muted); font-size: 1.05rem;">{form.summary}</p>
{/if}
<div>
{#each document.tags as tag}
{#each pickList(document.tags, lang) as tag}
<span class="tag">{tag}</span>
{/each}
{#if otherLanguage}

View File

@ -10,7 +10,7 @@
import type { FlowIndex, Graph, GraphNode } from '../lib/types';
import { getJson } from '../lib/api';
import { groupColor } from '../lib/format';
import { language, pick, tr } from '../lib/i18n';
import { groupLabel, language, pick, tr } from '../lib/i18n';
import GraphView from '../lib/GraphView.svelte';
interface Props {
@ -96,7 +96,7 @@
<div class="legend">
{#each graph.groups as group}
<span><i style={`background:${groupColor(group)}`}></i>{group}</span>
<span><i style={`background:${groupColor(group)}`}></i>{groupLabel(group, lang)}</span>
{/each}
</div>

View File

@ -10,7 +10,7 @@
import { getJson } from '../lib/api';
import { navigate } from '../lib/router';
import { groupColor } from '../lib/format';
import { language, pick, tr } from '../lib/i18n';
import { groupLabel, language, pick, tr } from '../lib/i18n';
import GraphView from '../lib/GraphView.svelte';
interface Props {
@ -92,7 +92,7 @@
<div class="legend">
{#each graph.groups as group}
<span><i style={`background:${groupColor(group)}`}></i>{group}</span>
<span><i style={`background:${groupColor(group)}`}></i>{groupLabel(group, lang)}</span>
{/each}
</div>
@ -102,7 +102,7 @@
<h3 style="margin: 0;">{pick(selectedNode.label, lang)}</h3>
<span class="badge">{selectedNode.kind}</span>
<span class="badge" style={`color:${groupColor(selectedNode.group)}`}
>{selectedNode.group}</span>
>{groupLabel(selectedNode.group, lang)}</span>
</header>
<p style="color: var(--text-muted);">{pick(selectedNode.summary, lang)}</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">

View File

@ -7,7 +7,7 @@
* that a reader can tell why it was returned.
*/
import type { SearchResponse } from '../lib/types';
import { language, tr } from '../lib/i18n';
import { language, pick, tr } from '../lib/i18n';
interface Props {
response: SearchResponse | null;
@ -62,8 +62,7 @@
<a class="result-title" href={`#/docs/${hit.id}`}>{hit.title}</a>
<p class="result-summary">{excerpt(hit.summary, query)}</p>
<div class="result-meta">
<span class="badge">{hit.chapter}</span>
<span class="badge">{hit.language}</span>
<span class="badge">{pick(hit.chapter_title, lang)}</span>
<span class="badge">{tr('search.score', lang)} {hit.score.toFixed(2)}</span>
</div>
</li>

View File

@ -52,9 +52,13 @@
const currentRegistry = registry;
const currentId = id;
if (!currentRegistry) return;
const found = currentId
? (currentRegistry.tools.find((candidate) => candidate.id === currentId) ?? null)
: null;
// With no tool named in the route the first one is shown, so the page
// opens on a working form rather than an invitation to pick.
const wanted =
(currentId
? currentRegistry.tools.find((candidate) => candidate.id === currentId)
: undefined) ?? currentRegistry.tools[0];
const found = wanted ?? null;
tool = found;
result = null;
error = null;
@ -125,18 +129,17 @@
<p class="empty">{tr('tools.loading', lang)}</p>
{:else}
{#each categories as group}
<section style="margin-bottom: 1.25rem;">
<h3 style="margin-bottom: 0.5rem;">{pick(group.category, lang)}</h3>
<div style="display: flex; flex-wrap: wrap; gap: 0.4rem;">
<section style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 0.75rem;">{pick(group.category, lang)}</h3>
<div class="grid cards">
{#each group.tools as candidate}
<a
class="card tool-card"
class:selected={id === candidate.id}
href={`#/tools/${candidate.id}`}
class="badge"
style={id === candidate.id
? 'border-color: var(--accent); color: var(--accent); padding: 0.3rem 0.7rem;'
: 'padding: 0.3rem 0.7rem;'}
>
{pick(candidate.name, lang)}
<h4>{pick(candidate.name, lang)}</h4>
<p>{pick(candidate.summary, lang)}</p>
</a>
{/each}
</div>

View File

@ -149,6 +149,7 @@ impl Router {
"summary" => hit.summary.clone(),
"language" => hit.language.tag(),
"chapter" => hit.chapter.clone(),
"chapter_title" => chapter_title(&self.state, &hit.chapter),
"score" => hit.score,
}
})
@ -197,7 +198,7 @@ impl Router {
let body = json_object! {
"id" => document.id.clone(),
"chapter" => document.chapter.clone(),
"tags" => document.tags.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(),
"tags" => tag_lists(document),
"prerequisites" => document.prerequisites.iter().map(|id| Value::from(id.as_str())).collect::<Vec<Value>>(),
"references" => document.references().iter().map(|id| Value::from(id.as_str())).collect::<Vec<Value>>(),
"forms" => forms,
@ -424,7 +425,7 @@ fn document_index(state: &AppState) -> Value {
"id" => document.id.clone(),
"title" => bilingual("title", document),
"summary" => bilingual("summary", document),
"tags" => document.tags.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(),
"tags" => tag_lists(document),
}
})
.collect();
@ -542,6 +543,22 @@ fn flow_list_json(flows: &[Graph]) -> Value {
json_object! { "flows" => items }
}
/// Encodes the tag lists of a document, one per language.
fn tag_lists(document: &crate::docs::Document) -> Value {
json_object! {
"en" => document.tags.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(),
"zh" => document.tags_zh.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(),
}
}
/// Encodes the title of a chapter, in both languages.
fn chapter_title(state: &AppState, chapter_id: &str) -> Value {
let Some(chapter) = state.docs.chapters().iter().find(|c| c.id == chapter_id) else {
return Value::string(chapter_id);
};
optional_bilingual(&chapter.title_zh, &chapter.title)
}
/// Encodes a graph as JSON.
///
/// A display label and a summary are carried in both languages when the

View File

@ -73,8 +73,10 @@ pub struct Document {
pub id: String,
/// Chapter identifier the document belongs to.
pub chapter: String,
/// Free form tags.
/// 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
@ -105,6 +107,7 @@ impl Document {
id: id.to_string(),
chapter: chapter_of(text),
tags: list_of(text, "tags"),
tags_zh: Vec::new(),
prerequisites: list_of(text, "prerequisites"),
english: variant,
chinese: None,
@ -218,6 +221,14 @@ fn strip_leading_title(body: &str, title: &str) -> String {
body.to_string()
}
/// Returns the tag list a document header declares.
///
/// 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() {

View File

@ -683,6 +683,18 @@ fn strip_inline(text: &str) -> String {
continue;
}
}
if character == '[' && characters.get(index + 1) == Some(&'`') {
// A rustdoc shortcut reference, `[`Epoch`]`, is a reference to
// another item. The plain text keeps the item name so that a
// summary still names what it is about, and the brackets never
// reach the reader.
if let Some((label, end)) = parse_shortcut_link(&characters, index) {
let name = label.rsplit("::").next().unwrap_or(&label);
output.push_str(name.trim());
index = end;
continue;
}
}
if character == '[' {
if let Some((label, _, end)) = parse_link(&characters, index) {
output.push_str(&label);
@ -809,4 +821,15 @@ mod tests {
assert!(!text.contains("[["));
assert!(!text.contains('`'));
}
#[test]
fn plain_text_resolves_a_shortcut_reference() {
// A rustdoc shortcut keeps the item name and loses the brackets,
// which otherwise appear verbatim in a one line summary.
let text = to_plain_text("See [`Vec3`] and [`Epoch::mjd`](crate::Epoch::mjd).\n");
assert!(text.contains("Vec3"));
assert!(text.contains("mjd"));
assert!(!text.contains('['), "{text}");
assert!(!text.contains('`'), "{text}");
}
}

View File

@ -73,8 +73,10 @@ pub fn load_documents() -> Vec<Document> {
let Some(translation) = DocumentVariant::parse(text) else {
continue;
};
let tags_zh = crate::docs::document::tags_of(text);
if let Some(document) = documents.get_mut(stem) {
document.chinese = Some(translation);
document.tags_zh = tags_zh;
}
}