diff --git a/content/concepts/architecture-apsis-rs.zh.md b/content/concepts/architecture-apsis-rs.zh.md index 70fd167..62f0530 100644 --- a/content/concepts/architecture-apsis-rs.zh.md +++ b/content/concepts/architecture-apsis-rs.zh.md @@ -1,5 +1,6 @@ --- title: 工作区架构 +tags: 架构, 分层, crate summary: apsis-rs 的分层结构、每层的 crate 以及依赖规则。 --- diff --git a/content/concepts/calendar-and-mjd.zh.md b/content/concepts/calendar-and-mjd.zh.md index f0239d0..d1bb899 100644 --- a/content/concepts/calendar-and-mjd.zh.md +++ b/content/concepts/calendar-and-mjd.zh.md @@ -1,5 +1,6 @@ --- title: 日历与约化儒略日 +tags: 时间, 日历, 儒略日, mjd summary: 日编号系统、外推格里高利历,以及转换算法。 --- diff --git a/content/concepts/earth-rotation-angle.zh.md b/content/concepts/earth-rotation-angle.zh.md index abce58c..41406b3 100644 --- a/content/concepts/earth-rotation-angle.zh.md +++ b/content/concepts/earth-rotation-angle.zh.md @@ -1,5 +1,6 @@ --- title: 地球自转角 +tags: 参考系, 地球自转, iers, ut1 summary: 惯性系与地固系之间的夹角,以及给出该值的线性表达式。 --- diff --git a/content/concepts/ephemeris-interpolation.zh.md b/content/concepts/ephemeris-interpolation.zh.md index b9c4924..3163863 100644 --- a/content/concepts/ephemeris-interpolation.zh.md +++ b/content/concepts/ephemeris-interpolation.zh.md @@ -1,5 +1,6 @@ --- title: 星历采样与插值 +tags: 星历, 插值, 采样, hermite summary: 何时采样一次并多次查询轨迹,以及插值所付出的精度代价。 --- diff --git a/content/concepts/geodesy-wgs84.zh.md b/content/concepts/geodesy-wgs84.zh.md index 568d05d..560e1a8 100644 --- a/content/concepts/geodesy-wgs84.zh.md +++ b/content/concepts/geodesy-wgs84.zh.md @@ -1,5 +1,6 @@ --- title: WGS 84 椭球与大地坐标 +tags: 大地测量, wgs84, 纬度, 椭球 summary: 纬度、经度与椭球高,以及到直角坐标的闭式转换。 --- diff --git a/content/concepts/keplerian-elements.zh.md b/content/concepts/keplerian-elements.zh.md index 45723ae..c09ced9 100644 --- a/content/concepts/keplerian-elements.zh.md +++ b/content/concepts/keplerian-elements.zh.md @@ -1,5 +1,6 @@ --- title: 开普勒轨道元素 +tags: 轨道, 开普勒, 元素, 近点角 summary: 六个经典轨道元素、二体解与三种近点角。 --- diff --git a/content/concepts/leap-seconds.zh.md b/content/concepts/leap-seconds.zh.md index 7d93e5a..dc8ad5c 100644 --- a/content/concepts/leap-seconds.zh.md +++ b/content/concepts/leap-seconds.zh.md @@ -1,5 +1,6 @@ --- title: 闰秒 +tags: 时间, utc, 闰秒, iers summary: UTC 为何存在阶跃间断,以及内嵌闰秒表如何处理它们。 --- diff --git a/content/concepts/overview.zh.md b/content/concepts/overview.zh.md index cba7d23..2b78133 100644 --- a/content/concepts/overview.zh.md +++ b/content/concepts/overview.zh.md @@ -1,5 +1,6 @@ --- title: 概念总览 +tags: 总览, 范围 summary: ApsisRS 计算内核的覆盖范围,以及本概念文档集的组织方式。 --- diff --git a/content/concepts/perturbations.zh.md b/content/concepts/perturbations.zh.md index eb7823b..86a59d7 100644 --- a/content/concepts/perturbations.zh.md +++ b/content/concepts/perturbations.zh.md @@ -1,5 +1,6 @@ --- title: 轨道摄动 +tags: 轨道, 摄动, 阻力, 扁率, 三体 summary: 使轨道偏离椭圆的各种力,以及各模型包含其中哪些。 --- diff --git a/content/concepts/propagator-comparison.zh.md b/content/concepts/propagator-comparison.zh.md index 9158134..05f23aa 100644 --- a/content/concepts/propagator-comparison.zh.md +++ b/content/concepts/propagator-comparison.zh.md @@ -1,5 +1,6 @@ --- title: 传播器选择 +tags: 传播, 对比, sgp4, hpop summary: 各种传播方法的对比,以及各自的开销与精度。 --- diff --git a/content/concepts/reference-frames.zh.md b/content/concepts/reference-frames.zh.md index 38f8e47..ec075a8 100644 --- a/content/concepts/reference-frames.zh.md +++ b/content/concepts/reference-frames.zh.md @@ -1,5 +1,6 @@ --- title: 参考系 +tags: 参考系, 惯性系, 地固系, 大地坐标, 旋转 summary: 惯性系与地固系、在类型中携带参考系的位置表示,以及二者之间的旋转。 --- diff --git a/content/concepts/sgp4.zh.md b/content/concepts/sgp4.zh.md index 34b4760..36cc892 100644 --- a/content/concepts/sgp4.zh.md +++ b/content/concepts/sgp4.zh.md @@ -1,5 +1,6 @@ --- title: SGP4 与 SDP4 传播 +tags: sgp4, sdp4, 传播, 两行元素集 summary: 两行元素集所拟合的解析传播器、其两个分支,以及坐标系与单位。 --- diff --git a/content/concepts/standards-and-provenance.zh.md b/content/concepts/standards-and-provenance.zh.md index 718ca58..373c944 100644 --- a/content/concepts/standards-and-provenance.zh.md +++ b/content/concepts/standards-and-provenance.zh.md @@ -1,5 +1,6 @@ --- title: 标准与来源 +tags: 标准, 引用, 出处 summary: 各内核所遵循的公开标准与论文,以及引用方式。 --- diff --git a/content/concepts/time-scales.zh.md b/content/concepts/time-scales.zh.md index a40e408..5c2cbde 100644 --- a/content/concepts/time-scales.zh.md +++ b/content/concepts/time-scales.zh.md @@ -1,5 +1,6 @@ --- title: 时间尺度 +tags: 时间, utc, tai, tt, gps summary: UTC、TAI、TT 与 GPS,以及为什么时刻只存储在单一尺度上。 --- diff --git a/content/concepts/tle-format.zh.md b/content/concepts/tle-format.zh.md index b52a200..10d3cde 100644 --- a/content/concepts/tle-format.zh.md +++ b/content/concepts/tle-format.zh.md @@ -1,5 +1,6 @@ --- title: 两行元素格式 +tags: 两行元素集, 元素, 格式, 校验和 summary: 两行元素集的定宽列、校验和规则,以及两个容易误读的约定。 --- diff --git a/docs/design.md b/docs/design.md index 988f490..aa49e11 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a1ee00e..ef7a58b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -154,6 +154,7 @@
+
diff --git a/frontend/src/app.css b/frontend/src/app.css index d4d4943..b48c591 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/lib/GraphView.svelte b/frontend/src/lib/GraphView.svelte index 6e62a0f..49a1b65 100644 --- a/frontend/src/lib/GraphView.svelte +++ b/frontend/src/lib/GraphView.svelte @@ -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>(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): Map { + 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(); }
- + {graph.nodes.length} {tr('graph.nodes', lang)} {graph.edges.length} {tr('graph.edges', lang)} {tr('graph.zoom', lang)} {span.toFixed(2)}× diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 7938c14..4d27183 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { 'app.title': { zh: 'APSIS', en: 'APSIS' }, @@ -172,7 +191,7 @@ const STRINGS: Record = { '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 = { '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 = { + 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; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 7e6d43f..b87e300 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; } diff --git a/frontend/src/pages/Api.svelte b/frontend/src/pages/Api.svelte index a7b4329..4b999a3 100644 --- a/frontend/src/pages/Api.svelte +++ b/frontend/src/pages/Api.svelte @@ -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); - }
-

{firstSentence(pick(candidate.summary.plain, lang))}

+

{pick(candidate.summary.plain, lang)}

{/each}
diff --git a/frontend/src/pages/Docs.svelte b/frontend/src/pages/Docs.svelte index 2a4a13c..5204c4c 100644 --- a/frontend/src/pages/Docs.svelte +++ b/frontend/src/pages/Docs.svelte @@ -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}

{pick(document.title, lang)}

-

+

{pick(document.summary, lang)}

-
- {#each document.tags as tag} +
+ {#each pickList(document.tags, lang) as tag} {tag} {/each}
{/each} diff --git a/frontend/src/pages/Document.svelte b/frontend/src/pages/Document.svelte index f2958f3..b87a323 100644 --- a/frontend/src/pages/Document.svelte +++ b/frontend/src/pages/Document.svelte @@ -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 @@

{form.summary}

{/if}
- {#each document.tags as tag} + {#each pickList(document.tags, lang) as tag} {tag} {/each} {#if otherLanguage} diff --git a/frontend/src/pages/Flows.svelte b/frontend/src/pages/Flows.svelte index 7e640d1..d437805 100644 --- a/frontend/src/pages/Flows.svelte +++ b/frontend/src/pages/Flows.svelte @@ -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 @@
{#each graph.groups as group} - {group} + {groupLabel(group, lang)} {/each}
diff --git a/frontend/src/pages/Graph.svelte b/frontend/src/pages/Graph.svelte index c6f5343..cfca72f 100644 --- a/frontend/src/pages/Graph.svelte +++ b/frontend/src/pages/Graph.svelte @@ -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 @@
{#each graph.groups as group} - {group} + {groupLabel(group, lang)} {/each}
@@ -102,7 +102,7 @@

{pick(selectedNode.label, lang)}

{selectedNode.kind} {selectedNode.group} + >{groupLabel(selectedNode.group, lang)}

{pick(selectedNode.summary, lang)}

diff --git a/frontend/src/pages/Search.svelte b/frontend/src/pages/Search.svelte index f62934a..3b7cbfa 100644 --- a/frontend/src/pages/Search.svelte +++ b/frontend/src/pages/Search.svelte @@ -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 @@ {hit.title}

{excerpt(hit.summary, query)}

- {hit.chapter} - {hit.language} + {pick(hit.chapter_title, lang)} {tr('search.score', lang)} {hit.score.toFixed(2)}
diff --git a/frontend/src/pages/Tools.svelte b/frontend/src/pages/Tools.svelte index 1771b0f..dab6539 100644 --- a/frontend/src/pages/Tools.svelte +++ b/frontend/src/pages/Tools.svelte @@ -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 @@

{tr('tools.loading', lang)}

{:else} {#each categories as group} -
-

{pick(group.category, lang)}

-
+
+

{pick(group.category, lang)}

+ diff --git a/src/api/mod.rs b/src/api/mod.rs index 8477bb0..db2f394 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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::>(), + "tags" => tag_lists(document), "prerequisites" => document.prerequisites.iter().map(|id| Value::from(id.as_str())).collect::>(), "references" => document.references().iter().map(|id| Value::from(id.as_str())).collect::>(), "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::>(), + "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::>(), + "zh" => document.tags_zh.iter().map(|tag| Value::from(tag.as_str())).collect::>(), + } +} + +/// 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 diff --git a/src/docs/document.rs b/src/docs/document.rs index 318bd37..ccfa197 100644 --- a/src/docs/document.rs +++ b/src/docs/document.rs @@ -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, + /// The same tags in Chinese, when the document declares them. + pub tags_zh: Vec, /// Identifier of the document this one builds on, when declared. pub prerequisites: Vec, /// The English form. Every document has one, and it is the fallback @@ -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 { + list_of(text, "tags") +} + /// Returns the value of a metadata key, trimmed. fn field_of(header: &str, key: &str) -> Option { for line in header.lines() { diff --git a/src/docs/markdown.rs b/src/docs/markdown.rs index 33cea1a..b2d97b6 100644 --- a/src/docs/markdown.rs +++ b/src/docs/markdown.rs @@ -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}"); + } } diff --git a/src/docs/mod.rs b/src/docs/mod.rs index 8a05e96..c66bf79 100644 --- a/src/docs/mod.rs +++ b/src/docs/mod.rs @@ -73,8 +73,10 @@ pub fn load_documents() -> Vec { 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; } }