From 895c6d3a3a1712d96273e32251ec086aadeeb438 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 11:00:09 +0800 Subject: [PATCH] web: fix the outline anchors, level the outline, and fold the sidebar chapters --- content/concepts/keplerian-elements.zh.md | 4 +- docs/design.md | 21 ++++ frontend/src/App.svelte | 40 +++++-- frontend/src/app.css | 137 +++++++++++++++++++++- frontend/src/lib/i18n.ts | 3 + frontend/src/lib/types.ts | 10 +- frontend/src/lib/uniformCards.ts | 13 +- frontend/src/pages/Document.svelte | 75 ++++++++++-- frontend/src/pages/Tools.svelte | 21 +++- src/api/mod.rs | 8 +- src/docs/document.rs | 54 +++++++-- src/docs/markdown.rs | 44 ++++++- 12 files changed, 382 insertions(+), 48 deletions(-) diff --git a/content/concepts/keplerian-elements.zh.md b/content/concepts/keplerian-elements.zh.md index c09ced9..48bf559 100644 --- a/content/concepts/keplerian-elements.zh.md +++ b/content/concepts/keplerian-elements.zh.md @@ -19,7 +19,7 @@ summary: 六个经典轨道元素、二体解与三种近点角。 | 近地点幅角 | `omega` | 轨道面内从升交点到近地点的角度 | rad | | 真近点角 | `nu` | 从近地点到卫星的角度 | rad | -前两个给出形状与大小,接下来两个给出轨道面的朝向,最后两个给出近地点在轨道面内的位置与卫星沿轨道的位�置。 +前两个给出形状与大小,接下来两个给出轨道面的朝向,最后两个给出近地点在轨道面内的位置与卫星沿轨道的位置。 轨道是以地心为一个焦点的椭圆,即开普勒第一定律。离地球最近的点是近地点,最远的是远地点。 @@ -110,7 +110,7 @@ M = E - e sin(E) | 赤道元素 | 状态矢量 | 二体 | 是 | 否 | | 时间网格上的位置 | 任意传播器 | 任意 | 是 | 插值,见 [[ephemeris-interpolation]] | -赤道元素避免了经典元素在倾角为零和偏心率�为零时的奇异。此处使用经典形式,因为两行元素格式与已发表文献都采用这种形式,且地球轨道几乎总有非零倾角。 +赤道元素避免了经典元素在倾角为零和偏心率为零时的奇异。此处使用经典形式,因为两行元素格式与已发表文献都采用这种形式,且地球轨道几乎总有非零倾角。 ## 来源 diff --git a/docs/design.md b/docs/design.md index b69ccbd..f6c86ad 100644 --- a/docs/design.md +++ b/docs/design.md @@ -325,6 +325,27 @@ 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. +## Anchors and outlines + +A heading anchor is reduced from the heading text, and a table of contents +carries that anchor. Two rules make the link land where the reader expects. + +The reduction **keeps the letters of every script**. Keeping only the ASCII +ones meant every Chinese heading reduced to the same fallback, so the whole +table of contents pointed at the first section and clicking an entry always +scrolled to the top of the document. The document text and the outline are +reduced by the same rule, so an entry names an anchor the document carries. + +A repeated heading is **numbered on both sides**. Two headings with the same +text would otherwise share one anchor, and the second entry would scroll to +the first. The document and the outline number the repeat the same way, so +the mapping stays exact. + +An outline carries the **level** of each heading, so a nested heading is +indented under its parent rather than listed beside it. A fold control is +shown only for a heading that has a child, because a control that hides +nothing is worse than no control. + ## Language coverage A user facing string falls into three classes, and each is handled diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index ef7a58b..7954eec 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -45,6 +45,16 @@ let searchLoading = $state(false); let status = $state(null); + /** The chapters whose documents are hidden in the sidebar. */ + let collapsed = $state([]); + + /** Folds a chapter, or unfolds it when it is already folded. */ + function toggleChapter(id: string) { + collapsed = collapsed.includes(id) + ? collapsed.filter((entry) => entry !== id) + : [...collapsed, id]; + } + /** Loads the content every page shares. */ async function loadShared() { try { @@ -178,15 +188,27 @@ {#if index} {:else} -
+

{pick(tool.name, lang)}

{pick(tool.category, lang)} diff --git a/src/api/mod.rs b/src/api/mod.rs index dc0e414..7b3c45e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -191,7 +191,13 @@ impl Router { "headings" => variant .headings .iter() - .map(|(anchor, text)| json_object! { "anchor" => anchor.clone(), "text" => text.clone() }) + .map(|heading| { + json_object! { + "anchor" => heading.anchor.clone(), + "text" => heading.text.clone(), + "level" => heading.level as f64, + } + }) .collect::>(), }); } diff --git a/src/docs/document.rs b/src/docs/document.rs index ccfa197..b347825 100644 --- a/src/docs/document.rs +++ b/src/docs/document.rs @@ -60,8 +60,8 @@ pub struct DocumentVariant { pub markdown: String, /// Rendered HTML. pub html: String, - /// Section headings, in order, as pairs of anchor identifier and text. - pub headings: Vec<(String, String)>, + /// Section headings, in order. + pub headings: Vec, /// Concept identifiers referenced by the body. pub references: Vec, } @@ -279,9 +279,8 @@ fn split_list(value: &str) -> Vec { .collect() } -/// Collects the headings of a Markdown body as pairs of anchor and text. -fn collect_headings(markdown: &str) -> Vec<(String, String)> { - let mut headings = Vec::new(); +fn collect_headings(markdown: &str) -> Vec { + let mut headings: Vec = Vec::new(); let mut in_code = false; for line in markdown.lines() { let trimmed = line.trim_end(); @@ -298,20 +297,53 @@ fn collect_headings(markdown: &str) -> Vec<(String, String)> { .count(); if (1..=6).contains(&hashes) && trimmed[hashes..].starts_with(' ') { let text = trimmed[hashes..].trim(); - headings.push((slug(text), text.to_string())); + // 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() + .filter(|heading| heading.base == base) + .count(); + let anchor = if repeats == 0 { + base.clone() + } else { + format!("{base}-{}", repeats + 1) + }; + headings.push(Heading { + base, + anchor, + text: text.to_string(), + level: hashes as u32, + }); } } headings } +/// 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. fn slug(text: &str) -> String { let mut slug = String::with_capacity(text.len()); let mut last_dash = true; for character in text.chars() { - if character.is_ascii_alphanumeric() { - slug.push(character.to_ascii_lowercase()); + if character.is_alphanumeric() { + for lowered in character.to_lowercase() { + slug.push(lowered); + } last_dash = false; } else if !last_dash { slug.push('-'); @@ -889,7 +921,7 @@ BSTAR 项。 assert_eq!(document.prerequisites, vec!["tle"]); assert_eq!(document.references(), vec!["tle"]); assert_eq!(document.english.headings.len(), 2); - assert_eq!(document.english.headings[1].0, "drag"); + assert_eq!(document.english.headings[1].anchor, "drag"); assert!(document.english.html.contains(" String { /// shortcut link whose name is not in the set is left as a code span, so a /// doc comment can write `[`0, 1`]` without producing a link to nothing. pub fn render_with(markdown: &str, items: Option<&std::collections::HashSet>) -> String { + // Anchors must be unique within the document: two headings that reduce + // to the same text would otherwise share one anchor, and the second + // entry of a table of contents would scroll to the first. + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); let lines: Vec<&str> = markdown.lines().collect(); let mut output = String::with_capacity(markdown.len() + markdown.len() / 2); let mut index = 0; @@ -70,7 +74,17 @@ pub fn render_with(markdown: &str, items: Option<&std::collections::HashSet{}#\n", escape(&id), @@ -609,8 +623,14 @@ fn slug(text: &str) -> String { let mut slug = String::with_capacity(text.len()); let mut last_dash = true; for character in text.chars() { - if character.is_ascii_alphanumeric() { - slug.push(character.to_ascii_lowercase()); + // Letters of any script are kept, so a Chinese heading keeps its own + // characters instead of collapsing to the fallback. Keeping only the + // ASCII ones gave every Chinese heading the same anchor, so the + // table of contents pointed every entry at the first section. + if character.is_alphanumeric() { + for lowered in character.to_lowercase() { + slug.push(lowered); + } last_dash = false; } else if !last_dash { slug.push('-'); @@ -791,6 +811,24 @@ mod tests { assert!(!html.contains("data-api"), "{html}"); } + #[test] + fn a_chinese_heading_keeps_its_own_anchor() { + // Only ASCII letters were kept, so every Chinese heading reduced to + // the fallback and the whole table of contents pointed at the first + // section. + let html = render("# 时间尺度\n\n正文。\n\n# 单位约定\n\n更多。\n"); + assert!(html.contains("id=\"时间尺度\""), "{html}"); + assert!(html.contains("id=\"单位约定\""), "{html}"); + assert!(!html.contains("id=\"section\""), "{html}"); + } + + #[test] + fn a_repeated_heading_gets_a_distinct_anchor() { + let html = render("# 说明\n\n甲。\n\n# 说明\n\n乙。\n"); + assert!(html.contains("id=\"说明\""), "{html}"); + assert!(html.contains("id=\"说明-2\""), "{html}"); + } + #[test] fn renders_concept_links() { let html = render("See [[sgp4]] and [[tle|the element set]].\n");