web: fix the outline anchors, level the outline, and fold the sidebar chapters
This commit is contained in:
parent
e3fe48e352
commit
895c6d3a3a
|
|
@ -19,7 +19,7 @@ summary: 六个经典轨道元素、二体解与三种近点角。
|
|||
| 近地点幅角 | `omega` | 轨道面内从升交点到近地点的角度 | rad |
|
||||
| 真近点角 | `nu` | 从近地点到卫星的角度 | rad |
|
||||
|
||||
前两个给出形状与大小,接下来两个给出轨道面的朝向,最后两个给出近地点在轨道面内的位置与卫星沿轨道的位<EFBFBD>置。
|
||||
前两个给出形状与大小,接下来两个给出轨道面的朝向,最后两个给出近地点在轨道面内的位置与卫星沿轨道的位置。
|
||||
|
||||
轨道是以地心为一个焦点的椭圆,即开普勒第一定律。离地球最近的点是近地点,最远的是远地点。
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ M = E - e sin(E)
|
|||
| 赤道元素 | 状态矢量 | 二体 | 是 | 否 |
|
||||
| 时间网格上的位置 | 任意传播器 | 任意 | 是 | 插值,见 [[ephemeris-interpolation]] |
|
||||
|
||||
赤道元素避免了经典元素在倾角为零和偏心率<EFBFBD>为零时的奇异。此处使用经典形式,因为两行元素格式与已发表文献都采用这种形式,且地球轨道几乎总有非零倾角。
|
||||
赤道元素避免了经典元素在倾角为零和偏心率为零时的奇异。此处使用经典形式,因为两行元素格式与已发表文献都采用这种形式,且地球轨道几乎总有非零倾角。
|
||||
|
||||
## 来源
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,16 @@
|
|||
let searchLoading = $state(false);
|
||||
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. */
|
||||
async function loadShared() {
|
||||
try {
|
||||
|
|
@ -178,15 +188,27 @@
|
|||
{#if index}
|
||||
<div class="sidebar-section">
|
||||
<h2>{tr('nav.documents', lang)}</h2>
|
||||
{#each index.chapters as chapter}
|
||||
<div class="sidebar-chapter">
|
||||
<div class="sidebar-chapter-title">{pick(chapter.title, lang)}</div>
|
||||
{#each index.chapters as chapter (chapter.id)}
|
||||
<div class="sidebar-chapter" class:collapsed={collapsed.includes(chapter.id)}>
|
||||
<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}
|
||||
<a
|
||||
class="item"
|
||||
class:active={route.name === 'docs' && route.id === entry.id}
|
||||
href={`#/docs/${entry.id}`}>{pick(entry.title, lang)}</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -227,14 +227,60 @@ hr {
|
|||
}
|
||||
|
||||
.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 {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.2rem 0.5rem;
|
||||
font-weight: 600;
|
||||
flex: 1 1 auto;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
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 {
|
||||
|
|
@ -1061,11 +1107,33 @@ tbody tr:hover {
|
|||
}
|
||||
|
||||
.tool-card h4 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.98rem;
|
||||
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 {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
|
|
@ -1077,6 +1145,65 @@ tbody tr:hover {
|
|||
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. */
|
||||
.chip-row {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ const STRINGS: Record<string, Text> = {
|
|||
'nav.api': { zh: '接口文档', en: 'Interface' },
|
||||
'nav.tools': { zh: '在线工具', en: 'Online tools' },
|
||||
'nav.documents': { zh: '文档', en: 'Documents' },
|
||||
'nav.toggleChapter': { zh: '展开或折叠该章', en: 'Expand or collapse the chapter' },
|
||||
'search.placeholder': { zh: '搜索文档', en: 'Search the documentation' },
|
||||
'search.button': { 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.readFirst': { zh: '先读:', en: 'Read first:' },
|
||||
'docs.onThisPage': { zh: '本页目录', en: 'On this page' },
|
||||
'docs.toggle': { zh: '展开或折叠该节', en: 'Expand or collapse the section' },
|
||||
'docs.noSections': { zh: '无小节。', en: 'No sections.' },
|
||||
'docs.translationMissing': {
|
||||
zh: '本文档暂无中文版本,显示英文原文。',
|
||||
|
|
@ -295,6 +297,7 @@ const STRINGS: Record<string, Text> = {
|
|||
'kind.type': { zh: '类型别名', en: 'type' },
|
||||
|
||||
'api.itemCount': { zh: '个接口项', en: 'items' },
|
||||
'tools.open': { zh: '打开', en: 'Open' },
|
||||
'tools.choose': { zh: '共 {count} 个工具,按类别分组。', en: '{count} tools, grouped by category.' },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ export interface Health {
|
|||
frontend_embedded: boolean;
|
||||
}
|
||||
|
||||
/** One section heading of a document. */
|
||||
export interface DocumentHeading {
|
||||
anchor: string;
|
||||
text: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
/** One language form of a document. */
|
||||
export interface DocumentForm {
|
||||
language: 'zh' | 'en';
|
||||
|
|
@ -41,7 +48,8 @@ export interface DocumentForm {
|
|||
summary: string;
|
||||
html: 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. */
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@
|
|||
* 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
|
||||
* there is no card.
|
||||
|
|
@ -20,7 +29,7 @@ export function tallestCardHeight(root: ParentNode): number {
|
|||
let tallest = 0;
|
||||
// The card is laid out at its natural height here, because the uniform
|
||||
// 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);
|
||||
}
|
||||
return Math.ceil(tallest);
|
||||
|
|
@ -31,7 +40,7 @@ export function tallestCardHeight(root: ParentNode): number {
|
|||
* inline height when there is no card.
|
||||
*/
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
* the reader's language is shown, with the English form as the fallback
|
||||
* 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 { language, pickList, setLanguage, tr, type Language } from '../lib/i18n';
|
||||
|
||||
|
|
@ -38,6 +38,47 @@
|
|||
!!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. */
|
||||
const otherLanguage = $derived.by((): Language | null => {
|
||||
if (!document || !form) return null;
|
||||
|
|
@ -143,17 +184,27 @@
|
|||
{#if form.headings.length > 0}
|
||||
<aside class="doc-toc">
|
||||
<h2 style="font-size: 0.72rem;">{tr('docs.onThisPage', lang)}</h2>
|
||||
<ul style="list-style: none; padding-left: 0;">
|
||||
{#each form.headings as heading}
|
||||
<li style="margin: 0.2rem 0;">
|
||||
<ul class="toc-list">
|
||||
{#each form.headings as heading, index (heading.anchor)}
|
||||
{#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
|
||||
href={`#${heading.anchor}`}
|
||||
style="color: var(--text-muted); font-size: 0.85rem;"
|
||||
class="toc-link"
|
||||
href="#{heading.anchor}"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
scrollToSection(heading.anchor);
|
||||
}}>{heading.text}</a>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,19 @@
|
|||
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. */
|
||||
async function run() {
|
||||
if (!tool) return;
|
||||
|
|
@ -147,8 +160,12 @@
|
|||
class="card tool-card"
|
||||
class:selected={id === 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>
|
||||
</a>
|
||||
{/each}
|
||||
|
|
@ -161,7 +178,7 @@
|
|||
<p>{tr('tools.select', lang)}</p>
|
||||
</div>
|
||||
{: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;">
|
||||
<h2 style="margin: 0; border: none;">{pick(tool.name, lang)}</h2>
|
||||
<span class="badge">{pick(tool.category, lang)}</span>
|
||||
|
|
|
|||
|
|
@ -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::<Vec<Value>>(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Heading>,
|
||||
/// Concept identifiers referenced by the body.
|
||||
pub references: Vec<String>,
|
||||
}
|
||||
|
|
@ -279,9 +279,8 @@ fn split_list(value: &str) -> Vec<String> {
|
|||
.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<Heading> {
|
||||
let mut headings: Vec<Heading> = 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("<h1"));
|
||||
}
|
||||
|
||||
|
|
@ -904,7 +936,7 @@ BSTAR 项。
|
|||
.english
|
||||
.headings
|
||||
.iter()
|
||||
.any(|(_, text)| text == "SGP4")
|
||||
.any(|heading| heading.text == "SGP4")
|
||||
);
|
||||
|
||||
// A body that repeats the metadata title loses the repeated heading.
|
||||
|
|
@ -915,7 +947,7 @@ BSTAR 项。
|
|||
!variant
|
||||
.headings
|
||||
.iter()
|
||||
.any(|(_, text)| text == "SGP4 propagation")
|
||||
.any(|heading| heading.text == "SGP4 propagation")
|
||||
);
|
||||
assert!(variant.html.contains("Body."));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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 {
|
||||
// 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 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<Stri
|
|||
|
||||
if let Some(level) = heading_level(trimmed) {
|
||||
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!(
|
||||
"<h{level} id=\"{}\">{}<a class=\"anchor\" href=\"#{id}\" aria-hidden=\"true\">#</a></h{level}>\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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue