web: fix the outline anchors, level the outline, and fold the sidebar chapters

This commit is contained in:
dev 2026-10-01 11:00:09 +08:00
parent bc662bc492
commit b4ed846f73
12 changed files with 382 additions and 48 deletions

View File

@ -19,7 +19,7 @@ summary: 六个经典轨道元素、二体解与三种近点角。
| 近地点幅角 | `omega` | 轨道面内从升交点到近地点的角度 | rad | | 近地点幅角 | `omega` | 轨道面内从升交点到近地点的角度 | rad |
| 真近点角 | `nu` | 从近地点到卫星的角度 | rad | | 真近点角 | `nu` | 从近地点到卫星的角度 | rad |
前两个给出形状与大小,接下来两个给出轨道面的朝向,最后两个给出近地点在轨道面内的位置与卫星沿轨道的位<EFBFBD>置。 前两个给出形状与大小,接下来两个给出轨道面的朝向,最后两个给出近地点在轨道面内的位置与卫星沿轨道的位置。
轨道是以地心为一个焦点的椭圆,即开普勒第一定律。离地球最近的点是近地点,最远的是远地点。 轨道是以地心为一个焦点的椭圆,即开普勒第一定律。离地球最近的点是近地点,最远的是远地点。
@ -110,7 +110,7 @@ M = E - e sin(E)
| 赤道元素 | 状态矢量 | 二体 | 是 | 否 | | 赤道元素 | 状态矢量 | 二体 | 是 | 否 |
| 时间网格上的位置 | 任意传播器 | 任意 | 是 | 插值,见 [[ephemeris-interpolation]] | | 时间网格上的位置 | 任意传播器 | 任意 | 是 | 插值,见 [[ephemeris-interpolation]] |
赤道元素避免了经典元素在倾角为零和偏心率<EFBFBD>为零时的奇异。此处使用经典形式,因为两行元素格式与已发表文献都采用这种形式,且地球轨道几乎总有非零倾角。 赤道元素避免了经典元素在倾角为零和偏心率为零时的奇异。此处使用经典形式,因为两行元素格式与已发表文献都采用这种形式,且地球轨道几乎总有非零倾角。
## 来源 ## 来源

View File

@ -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 is widened to the canvas shape before it is fitted; the change is a uniform
scale, which leaves relative distances readable. 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 ## Language coverage
A user facing string falls into three classes, and each is handled A user facing string falls into three classes, and each is handled

View File

@ -45,6 +45,16 @@
let searchLoading = $state(false); let searchLoading = $state(false);
let status = $state<string | null>(null); let status = $state<string | null>(null);
/** The chapters whose documents are hidden in the sidebar. */
let collapsed = $state<string[]>([]);
/** 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. */ /** Loads the content every page shares. */
async function loadShared() { async function loadShared() {
try { try {
@ -178,15 +188,27 @@
{#if index} {#if index}
<div class="sidebar-section"> <div class="sidebar-section">
<h2>{tr('nav.documents', lang)}</h2> <h2>{tr('nav.documents', lang)}</h2>
{#each index.chapters as chapter} {#each index.chapters as chapter (chapter.id)}
<div class="sidebar-chapter"> <div class="sidebar-chapter" class:collapsed={collapsed.includes(chapter.id)}>
<div class="sidebar-chapter-title">{pick(chapter.title, lang)}</div> <div class="sidebar-chapter-head">
<a class="sidebar-chapter-title" href="#/docs" title="{pick(chapter.title, lang)}">
{pick(chapter.title, lang)}
</a>
<button
class="sidebar-chapter-toggle"
aria-expanded={!collapsed.includes(chapter.id)}
aria-label="{tr('nav.toggleChapter', lang)}: {pick(chapter.title, lang)}"
onclick={() => toggleChapter(chapter.id)}
>▾</button>
</div>
{#if !collapsed.includes(chapter.id)}
{#each chapter.documents as entry} {#each chapter.documents as entry}
<a <a
class="item" class="item"
class:active={route.name === 'docs' && route.id === entry.id} class:active={route.name === 'docs' && route.id === entry.id}
href={`#/docs/${entry.id}`}>{pick(entry.title, lang)}</a> href={`#/docs/${entry.id}`}>{pick(entry.title, lang)}</a>
{/each} {/each}
{/if}
</div> </div>
{/each} {/each}
</div> </div>

View File

@ -227,14 +227,60 @@ hr {
} }
.sidebar-chapter { .sidebar-chapter {
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
}
/* The chapter head is a row: the title is a link to the chapter listing
and the chevron folds the group. They sit apart so the two do not look
like one control. */
.sidebar-chapter-head {
display: flex;
align-items: center;
gap: 0.1rem;
margin-bottom: 0.15rem;
padding-left: 0.35rem;
border-left: 2px solid var(--border-strong);
} }
.sidebar-chapter-title { .sidebar-chapter-title {
font-size: 0.82rem; flex: 1 1 auto;
color: var(--text-muted); font-size: 0.78rem;
margin: 0 0 0.2rem 0.5rem; color: var(--text);
font-weight: 600; margin: 0;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-chapter-title:hover {
color: var(--accent);
text-decoration: none;
}
.sidebar-chapter-toggle {
flex: 0 0 auto;
width: 1.1rem;
height: 1.1rem;
padding: 0;
border: none;
background: none;
color: var(--text-faint);
font-size: 0.65rem;
line-height: 1;
cursor: pointer;
transition: transform 0.12s ease;
}
.sidebar-chapter.collapsed .sidebar-chapter-toggle {
transform: rotate(-90deg);
}
.sidebar-chapter-toggle:hover {
color: var(--accent);
background: none;
} }
.sidebar a.item { .sidebar a.item {
@ -1061,11 +1107,33 @@ tbody tr:hover {
} }
.tool-card h4 { .tool-card h4 {
display: flex;
align-items: baseline;
gap: 0.35rem;
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
font-size: 0.98rem; font-size: 0.98rem;
color: var(--accent); color: var(--accent);
} }
/* The chevron marks the card as an entry point to a page of its own, which
is what a reader cannot otherwise tell from a heading. */
.tool-go {
color: var(--text-faint);
font-size: 1.05rem;
line-height: 1;
transition: transform 0.12s ease, color 0.12s ease;
}
.tool-card:hover .tool-go {
color: var(--accent);
transform: translateX(2px);
}
/* The form a tool opens, given room to breathe under the index. */
.tool-form {
scroll-margin-top: 4.5rem;
}
.tool-card p { .tool-card p {
margin: 0; margin: 0;
color: var(--text-muted); color: var(--text-muted);
@ -1077,6 +1145,65 @@ tbody tr:hover {
background: var(--accent-soft); background: var(--accent-soft);
} }
/* The table of contents beside a document. */
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
/* A deeper heading is indented, so the levels of the document are visible
without reading the text. */
.toc-item {
display: flex;
align-items: baseline;
gap: 0.15rem;
margin: 0.1rem 0;
}
.toc-item.level-3 {
padding-left: 0.85rem;
}
.toc-item.level-4 {
padding-left: 1.7rem;
}
.toc-item.level-5,
.toc-item.level-6 {
padding-left: 2.55rem;
}
.toc-toggle {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
padding: 0;
border: none;
background: none;
color: var(--text-faint);
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
transition: transform 0.12s ease;
}
.toc-toggle.collapsed {
transform: rotate(-90deg);
}
.toc-toggle:hover {
color: var(--accent);
background: none;
}
.toc-link {
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.4;
}
/* A row of links that wrap, used for the item filter results. */ /* A row of links that wrap, used for the item filter results. */
.chip-row { .chip-row {
display: flex; display: flex;

View File

@ -108,6 +108,7 @@ const STRINGS: Record<string, Text> = {
'nav.api': { zh: '接口文档', en: 'Interface' }, 'nav.api': { zh: '接口文档', en: 'Interface' },
'nav.tools': { zh: '在线工具', en: 'Online tools' }, 'nav.tools': { zh: '在线工具', en: 'Online tools' },
'nav.documents': { zh: '文档', en: 'Documents' }, 'nav.documents': { zh: '文档', en: 'Documents' },
'nav.toggleChapter': { zh: '展开或折叠该章', en: 'Expand or collapse the chapter' },
'search.placeholder': { zh: '搜索文档', en: 'Search the documentation' }, 'search.placeholder': { zh: '搜索文档', en: 'Search the documentation' },
'search.button': { zh: '搜索', en: 'Search' }, 'search.button': { zh: '搜索', en: 'Search' },
'search.title': { zh: '搜索', en: 'Search' }, 'search.title': { zh: '搜索', en: 'Search' },
@ -171,6 +172,7 @@ const STRINGS: Record<string, Text> = {
'docs.back': { zh: '返回列表', en: 'Back to the listing' }, 'docs.back': { zh: '返回列表', en: 'Back to the listing' },
'docs.readFirst': { zh: '先读:', en: 'Read first:' }, 'docs.readFirst': { zh: '先读:', en: 'Read first:' },
'docs.onThisPage': { zh: '本页目录', en: 'On this page' }, 'docs.onThisPage': { zh: '本页目录', en: 'On this page' },
'docs.toggle': { zh: '展开或折叠该节', en: 'Expand or collapse the section' },
'docs.noSections': { zh: '无小节。', en: 'No sections.' }, 'docs.noSections': { zh: '无小节。', en: 'No sections.' },
'docs.translationMissing': { 'docs.translationMissing': {
zh: '本文档暂无中文版本,显示英文原文。', zh: '本文档暂无中文版本,显示英文原文。',
@ -295,6 +297,7 @@ const STRINGS: Record<string, Text> = {
'kind.type': { zh: '类型别名', en: 'type' }, 'kind.type': { zh: '类型别名', en: 'type' },
'api.itemCount': { zh: '个接口项', en: 'items' }, 'api.itemCount': { zh: '个接口项', en: 'items' },
'tools.open': { zh: '打开', en: 'Open' },
'tools.choose': { zh: '共 {count} 个工具,按类别分组。', en: '{count} tools, grouped by category.' }, 'tools.choose': { zh: '共 {count} 个工具,按类别分组。', en: '{count} tools, grouped by category.' },
}; };

View File

@ -34,6 +34,13 @@ export interface Health {
frontend_embedded: boolean; frontend_embedded: boolean;
} }
/** One section heading of a document. */
export interface DocumentHeading {
anchor: string;
text: string;
level: number;
}
/** One language form of a document. */ /** One language form of a document. */
export interface DocumentForm { export interface DocumentForm {
language: 'zh' | 'en'; language: 'zh' | 'en';
@ -41,7 +48,8 @@ export interface DocumentForm {
summary: string; summary: string;
html: string; html: string;
markdown: string; markdown: string;
headings: { anchor: string; text: string }[]; /** Section headings, each with the anchor, text, and level. */
headings: { anchor: string; text: string; level: number }[];
} }
/** One document, with a rendered form per embedded language. */ /** One document, with a rendered form per embedded language. */

View File

@ -12,6 +12,15 @@
* height is a property of the rendering, not of the content. * height is a property of the rendering, not of the content.
*/ */
/**
* The cards the measurement covers.
*
* The interface page groups its items under a kind and lays them out in its
* own grid, so both grids take part; a card in either is made the height of
* the tallest.
*/
const CARD_SELECTOR = '.grid.cards > .card, .item-grid > .api-item';
/** /**
* Returns the height of the tallest card under `root`, in pixels, or `0` when * Returns the height of the tallest card under `root`, in pixels, or `0` when
* there is no card. * there is no card.
@ -20,7 +29,7 @@ export function tallestCardHeight(root: ParentNode): number {
let tallest = 0; let tallest = 0;
// The card is laid out at its natural height here, because the uniform // The card is laid out at its natural height here, because the uniform
// height is not applied yet. // height is not applied yet.
for (const card of root.querySelectorAll<HTMLElement>('.grid.cards > .card')) { for (const card of root.querySelectorAll<HTMLElement>(CARD_SELECTOR)) {
tallest = Math.max(tallest, card.getBoundingClientRect().height); tallest = Math.max(tallest, card.getBoundingClientRect().height);
} }
return Math.ceil(tallest); return Math.ceil(tallest);
@ -31,7 +40,7 @@ export function tallestCardHeight(root: ParentNode): number {
* inline height when there is no card. * inline height when there is no card.
*/ */
export function applyUniformHeight(root: ParentNode): void { export function applyUniformHeight(root: ParentNode): void {
const cards = [...root.querySelectorAll<HTMLElement>('.grid.cards > .card')]; const cards = [...root.querySelectorAll<HTMLElement>(CARD_SELECTOR)];
if (cards.length === 0) { if (cards.length === 0) {
return; return;
} }

View File

@ -11,7 +11,7 @@
* the reader's language is shown, with the English form as the fallback * the reader's language is shown, with the English form as the fallback
* when a translation is absent. * when a translation is absent.
*/ */
import type { Document, DocumentForm } from '../lib/types'; import type { Document, DocumentForm, DocumentHeading } from '../lib/types';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { language, pickList, setLanguage, tr, type Language } from '../lib/i18n'; import { language, pickList, setLanguage, tr, type Language } from '../lib/i18n';
@ -38,6 +38,47 @@
!!document && !!form && form.language !== lang, !!document && !!form && form.language !== lang,
); );
/** Which top level sections of the table of contents are expanded. */
let openSections = $state<Record<string, boolean>>({});
/**
* Returns `true` when a heading has a child under it.
*
* A fold control is only shown for a heading that actually has children,
* so a flat document does not carry controls that do nothing.
*/
function hasChildren(heading: DocumentHeading, index: number): boolean {
const next = form?.headings[index + 1];
return next !== undefined && next.level > heading.level;
}
/**
* Returns `true` when a heading is visible in the table of contents.
*
* A heading is visible when it is a top level entry, when its section is
* expanded, or when it is the first child of a section so that a section
* never looks empty while it is collapsed.
*/
function isVisible(heading: DocumentHeading, index: number): boolean {
if (heading.level <= 2) return true;
const parent = form?.headings
.slice(0, index)
.reverse()
.find((candidate) => candidate.level <= 2);
if (!parent) return true;
const open = openSections[parent.anchor];
if (open !== false) return true;
// Keep the first child visible so a collapsed section still shows where
// it starts.
const firstChild = form?.headings.findIndex((candidate) => candidate.level > 2);
return firstChild === index;
}
/** Flips a section open or closed. */
function toggleSection(anchor: string) {
openSections = { ...openSections, [anchor]: openSections[anchor] === false };
}
/** True when a form in the other language is available to switch to. */ /** True when a form in the other language is available to switch to. */
const otherLanguage = $derived.by((): Language | null => { const otherLanguage = $derived.by((): Language | null => {
if (!document || !form) return null; if (!document || !form) return null;
@ -143,17 +184,27 @@
{#if form.headings.length > 0} {#if form.headings.length > 0}
<aside class="doc-toc"> <aside class="doc-toc">
<h2 style="font-size: 0.72rem;">{tr('docs.onThisPage', lang)}</h2> <h2 style="font-size: 0.72rem;">{tr('docs.onThisPage', lang)}</h2>
<ul style="list-style: none; padding-left: 0;"> <ul class="toc-list">
{#each form.headings as heading} {#each form.headings as heading, index (heading.anchor)}
<li style="margin: 0.2rem 0;"> {#if isVisible(heading, index)}
<li class="toc-item level-{heading.level}">
{#if hasChildren(heading, index)}
<button
class="toc-toggle"
class:collapsed={openSections[heading.anchor] === false}
aria-expanded={openSections[heading.anchor] !== false}
aria-label="{tr('docs.toggle', lang)}: {heading.text}"
onclick={() => toggleSection(heading.anchor)}>▾</button>
{/if}
<a <a
href={`#${heading.anchor}`} class="toc-link"
style="color: var(--text-muted); font-size: 0.85rem;" href="#{heading.anchor}"
onclick={(event) => { onclick={(event) => {
event.preventDefault(); event.preventDefault();
scrollToSection(heading.anchor); scrollToSection(heading.anchor);
}}>{heading.text}</a> }}>{heading.text}</a>
</li> </li>
{/if}
{/each} {/each}
</ul> </ul>
</aside> </aside>

View File

@ -74,6 +74,19 @@
values = defaultsFor(found); values = defaultsFor(found);
}); });
/**
* Moves the form into view after a tool is chosen.
*
* The index and the form share the page, so a reader who picks a tool from
* the list would otherwise stay on the index and not see the form. This
* runs only when the route names a tool, which is what a click does.
*/
let form = $state<HTMLElement | null>(null);
$effect(() => {
if (!id || !form) return;
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
/** Runs the selected tool. */ /** Runs the selected tool. */
async function run() { async function run() {
if (!tool) return; if (!tool) return;
@ -147,8 +160,12 @@
class="card tool-card" class="card tool-card"
class:selected={id === candidate.id} class:selected={id === candidate.id}
href={`#/tools/${candidate.id}`} href={`#/tools/${candidate.id}`}
aria-label="{tr('tools.open', lang)}: {pick(candidate.name, lang)}"
> >
<h4>{pick(candidate.name, lang)}</h4> <h4>
{pick(candidate.name, lang)}
<span class="tool-go" aria-hidden="true">›</span>
</h4>
<p>{pick(candidate.summary, lang)}</p> <p>{pick(candidate.summary, lang)}</p>
</a> </a>
{/each} {/each}
@ -161,7 +178,7 @@
<p>{tr('tools.select', lang)}</p> <p>{tr('tools.select', lang)}</p>
</div> </div>
{:else} {:else}
<section class="card" style="margin-top: 1.5rem;"> <section class="card tool-form" bind:this={form} style="margin-top: 1.5rem;">
<header style="display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;"> <header style="display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;">
<h2 style="margin: 0; border: none;">{pick(tool.name, lang)}</h2> <h2 style="margin: 0; border: none;">{pick(tool.name, lang)}</h2>
<span class="badge">{pick(tool.category, lang)}</span> <span class="badge">{pick(tool.category, lang)}</span>

View File

@ -191,7 +191,13 @@ impl Router {
"headings" => variant "headings" => variant
.headings .headings
.iter() .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::<Vec<Value>>(), .collect::<Vec<Value>>(),
}); });
} }

View File

@ -60,8 +60,8 @@ pub struct DocumentVariant {
pub markdown: String, pub markdown: String,
/// Rendered HTML. /// Rendered HTML.
pub html: String, pub html: String,
/// Section headings, in order, as pairs of anchor identifier and text. /// Section headings, in order.
pub headings: Vec<(String, String)>, pub headings: Vec<Heading>,
/// Concept identifiers referenced by the body. /// Concept identifiers referenced by the body.
pub references: Vec<String>, pub references: Vec<String>,
} }
@ -279,9 +279,8 @@ fn split_list(value: &str) -> Vec<String> {
.collect() .collect()
} }
/// Collects the headings of a Markdown body as pairs of anchor and text. fn collect_headings(markdown: &str) -> Vec<Heading> {
fn collect_headings(markdown: &str) -> Vec<(String, String)> { let mut headings: Vec<Heading> = Vec::new();
let mut headings = Vec::new();
let mut in_code = false; let mut in_code = false;
for line in markdown.lines() { for line in markdown.lines() {
let trimmed = line.trim_end(); let trimmed = line.trim_end();
@ -298,20 +297,53 @@ fn collect_headings(markdown: &str) -> Vec<(String, String)> {
.count(); .count();
if (1..=6).contains(&hashes) && trimmed[hashes..].starts_with(' ') { if (1..=6).contains(&hashes) && trimmed[hashes..].starts_with(' ') {
let text = trimmed[hashes..].trim(); 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 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 /// Returns a URL friendly slug, matching the anchors the Markdown renderer
/// emits. /// emits.
fn slug(text: &str) -> String { fn slug(text: &str) -> String {
let mut slug = String::with_capacity(text.len()); let mut slug = String::with_capacity(text.len());
let mut last_dash = true; let mut last_dash = true;
for character in text.chars() { for character in text.chars() {
if character.is_ascii_alphanumeric() { if character.is_alphanumeric() {
slug.push(character.to_ascii_lowercase()); for lowered in character.to_lowercase() {
slug.push(lowered);
}
last_dash = false; last_dash = false;
} else if !last_dash { } else if !last_dash {
slug.push('-'); slug.push('-');
@ -889,7 +921,7 @@ BSTAR 项。
assert_eq!(document.prerequisites, vec!["tle"]); assert_eq!(document.prerequisites, vec!["tle"]);
assert_eq!(document.references(), vec!["tle"]); assert_eq!(document.references(), vec!["tle"]);
assert_eq!(document.english.headings.len(), 2); 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("<h1")); assert!(document.english.html.contains("<h1"));
} }
@ -904,7 +936,7 @@ BSTAR 项。
.english .english
.headings .headings
.iter() .iter()
.any(|(_, text)| text == "SGP4") .any(|heading| heading.text == "SGP4")
); );
// A body that repeats the metadata title loses the repeated heading. // A body that repeats the metadata title loses the repeated heading.
@ -915,7 +947,7 @@ BSTAR 项。
!variant !variant
.headings .headings
.iter() .iter()
.any(|(_, text)| text == "SGP4 propagation") .any(|heading| heading.text == "SGP4 propagation")
); );
assert!(variant.html.contains("Body.")); assert!(variant.html.contains("Body."));
} }

View File

@ -42,6 +42,10 @@ pub fn render(markdown: &str) -> String {
/// shortcut link whose name is not in the set is left as a code span, so a /// 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. /// doc comment can write `[`0, 1`]` without producing a link to nothing.
pub fn render_with(markdown: &str, items: Option<&std::collections::HashSet<String>>) -> String { pub fn render_with(markdown: &str, items: Option<&std::collections::HashSet<String>>) -> 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<String, usize> = std::collections::HashMap::new();
let lines: Vec<&str> = markdown.lines().collect(); let lines: Vec<&str> = markdown.lines().collect();
let mut output = String::with_capacity(markdown.len() + markdown.len() / 2); let mut output = String::with_capacity(markdown.len() + markdown.len() / 2);
let mut index = 0; let mut index = 0;
@ -70,7 +74,17 @@ pub fn render_with(markdown: &str, items: Option<&std::collections::HashSet<Stri
if let Some(level) = heading_level(trimmed) { if let Some(level) = heading_level(trimmed) {
let text = trimmed[level..].trim(); let text = trimmed[level..].trim();
let id = slug(text); // Two headings that reduce to the same text must not share one
// anchor, or every later entry of a table of contents scrolls
// to the first of them.
let base = slug(text);
let count = seen.entry(base.clone()).or_insert(0);
*count += 1;
let id = if *count == 1 {
base
} else {
format!("{base}-{count}")
};
output.push_str(&format!( output.push_str(&format!(
"<h{level} id=\"{}\">{}<a class=\"anchor\" href=\"#{id}\" aria-hidden=\"true\">#</a></h{level}>\n", "<h{level} id=\"{}\">{}<a class=\"anchor\" href=\"#{id}\" aria-hidden=\"true\">#</a></h{level}>\n",
escape(&id), escape(&id),
@ -609,8 +623,14 @@ fn slug(text: &str) -> String {
let mut slug = String::with_capacity(text.len()); let mut slug = String::with_capacity(text.len());
let mut last_dash = true; let mut last_dash = true;
for character in text.chars() { for character in text.chars() {
if character.is_ascii_alphanumeric() { // Letters of any script are kept, so a Chinese heading keeps its own
slug.push(character.to_ascii_lowercase()); // 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; last_dash = false;
} else if !last_dash { } else if !last_dash {
slug.push('-'); slug.push('-');
@ -791,6 +811,24 @@ mod tests {
assert!(!html.contains("data-api"), "{html}"); 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] #[test]
fn renders_concept_links() { fn renders_concept_links() {
let html = render("See [[sgp4]] and [[tle|the element set]].\n"); let html = render("See [[sgp4]] and [[tle|the element set]].\n");