web: fix the outline anchors, level the outline, and fold the sidebar chapters
This commit is contained in:
parent
bc662bc492
commit
b4ed846f73
|
|
@ -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>为零时的奇异。此处使用经典形式,因为两行元素格式与已发表文献都采用这种形式,且地球轨道几乎总有非零倾角。
|
赤道元素避免了经典元素在倾角为零和偏心率为零时的奇异。此处使用经典形式,因为两行元素格式与已发表文献都采用这种形式,且地球轨道几乎总有非零倾角。
|
||||||
|
|
||||||
## 来源
|
## 来源
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
{#each chapter.documents as entry}
|
<a class="sidebar-chapter-title" href="#/docs" title="{pick(chapter.title, lang)}">
|
||||||
<a
|
{pick(chapter.title, lang)}
|
||||||
class="item"
|
</a>
|
||||||
class:active={route.name === 'docs' && route.id === entry.id}
|
<button
|
||||||
href={`#/docs/${entry.id}`}>{pick(entry.title, lang)}</a>
|
class="sidebar-chapter-toggle"
|
||||||
{/each}
|
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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
<a
|
<li class="toc-item level-{heading.level}">
|
||||||
href={`#${heading.anchor}`}
|
{#if hasChildren(heading, index)}
|
||||||
style="color: var(--text-muted); font-size: 0.85rem;"
|
<button
|
||||||
onclick={(event) => {
|
class="toc-toggle"
|
||||||
event.preventDefault();
|
class:collapsed={openSections[heading.anchor] === false}
|
||||||
scrollToSection(heading.anchor);
|
aria-expanded={openSections[heading.anchor] !== false}
|
||||||
}}>{heading.text}</a>
|
aria-label="{tr('docs.toggle', lang)}: {heading.text}"
|
||||||
</li>
|
onclick={() => toggleSection(heading.anchor)}>▾</button>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
class="toc-link"
|
||||||
|
href="#{heading.anchor}"
|
||||||
|
onclick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollToSection(heading.anchor);
|
||||||
|
}}>{heading.text}</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>>(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue