web: fix the interface layout and add Chinese search
This commit is contained in:
parent
b434b2535c
commit
64122d21c4
|
|
@ -215,6 +215,47 @@ rather than as a bind failure.
|
||||||
would require an order of application that is not defined by the command
|
would require an order of application that is not defined by the command
|
||||||
line, and the resulting behaviour would be surprising.
|
line, and the resulting behaviour would be surprising.
|
||||||
|
|
||||||
|
## Interface layout
|
||||||
|
|
||||||
|
Three layout decisions keep the pages readable at any window width.
|
||||||
|
|
||||||
|
A page is a **reading column**. Most pages are text, a card grid, or a form,
|
||||||
|
and all three read better in a centred column than across a whole wide
|
||||||
|
screen, so the content is capped at a measure and centred. A document is the
|
||||||
|
one page that is wider than the column, because it carries a table of
|
||||||
|
contents beside the text; the table of contents is part of the cap, and a
|
||||||
|
document with no headings drops the column and uses the reading measure on
|
||||||
|
its own. A graph breaks the rule the other way: it uses the whole width,
|
||||||
|
because a wide screen should show more of the graph rather than more margin.
|
||||||
|
|
||||||
|
The **interface index** shows one line per crate rather than the full module
|
||||||
|
documentation. A crate summary is several paragraphs, and a grid of them is
|
||||||
|
unreadable; the index shows the first sentence and the crate page shows the
|
||||||
|
rest. A doc comment is rendered as Markdown by the backend, so a comment
|
||||||
|
that contains a list, a table, or a code block is drawn as one, and the
|
||||||
|
frontend does not have to parse it.
|
||||||
|
|
||||||
|
The **graph** is drawn on a fixed layout space that is scaled to the
|
||||||
|
viewport. A double click opens the document or the interface item a node
|
||||||
|
names, so a graph is also a way into the documentation.
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
A query is handled in two ways. A query that yields tokens is matched
|
||||||
|
against the postings; a query that yields no token, such as a single
|
||||||
|
character, is matched as a substring, because a one character query cannot
|
||||||
|
form a token and would otherwise always return nothing.
|
||||||
|
|
||||||
|
Chinese text needs more than word splitting. Latin text is split on
|
||||||
|
whitespace and punctuation, but Chinese is written without spaces, so
|
||||||
|
splitting a Chinese sentence on non-alphanumeric characters leaves one long
|
||||||
|
token that a two character query can never match. A run of Chinese
|
||||||
|
characters therefore yields one token per adjacent pair, which is the
|
||||||
|
smallest unit specific enough to be a word. A query of 坐标 matches a
|
||||||
|
sentence that contains 坐标, which a whole-run token would not. A single
|
||||||
|
Chinese character is left to the substring fallback rather than becoming a
|
||||||
|
token, because a token of one character would match nearly every document.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Unit tests live beside the code they test and cover the parser, the codec,
|
Unit tests live beside the code they test and cover the parser, the codec,
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@
|
||||||
>{tr('docs.otherLanguage', lang)}</button>
|
>{tr('docs.otherLanguage', lang)}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="content" class:wide={route.name === 'graph' || route.name === 'flows'} class:narrow={route.name === 'docs' && route.id !== null}>
|
<main class="content" class:doc={route.name === 'docs' && route.id !== null} class:wide={route.name === 'graph' || route.name === 'flows'}>
|
||||||
{#if status}
|
{#if status}
|
||||||
<div class="notice error"><p>{status}</p></div>
|
<div class="notice error"><p>{status}</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@
|
||||||
--radius-small: 5px;
|
--radius-small: 5px;
|
||||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||||
--max-width: 1440px;
|
|
||||||
--sidebar-width: 19rem;
|
--sidebar-width: 19rem;
|
||||||
|
--toc-width: 15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -282,15 +282,22 @@ hr {
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 1.75rem 2rem 4rem;
|
padding: 1.75rem 2.5rem 4rem;
|
||||||
max-width: var(--max-width);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content.narrow {
|
/* A document is a measure of text plus the table of contents beside it, so
|
||||||
max-width: 58rem;
|
the reading column keeps a comfortable line length while the table of
|
||||||
|
contents sits at its right. The width is capped so that the pair stays
|
||||||
|
centred on a very wide screen rather than drifting apart. */
|
||||||
|
.content.doc {
|
||||||
|
max-width: calc(62rem + var(--toc-width) + 2.5rem);
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The graph and the crate grid use the whole width; a wide screen shows
|
||||||
|
more of them rather than more margin. */
|
||||||
.content.wide {
|
.content.wide {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
@ -496,10 +503,52 @@ button.ghost:hover:not(:disabled) {
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search results */
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
padding: 0.85rem 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
margin: 0.35rem 0 0.55rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Documentation */
|
/* Documentation */
|
||||||
|
|
||||||
.doc-body {
|
.doc-body {
|
||||||
max-width: 54rem;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-body h1,
|
.doc-body h1,
|
||||||
|
|
@ -550,21 +599,29 @@ button.ghost:hover:not(:disabled) {
|
||||||
border-bottom: 1px dotted var(--accent-strong);
|
border-bottom: 1px dotted var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doc-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) var(--toc-width);
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A document with no headings gets the full width rather than an empty
|
||||||
|
column at its right. */
|
||||||
|
.doc-layout.no-toc {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
max-width: 46rem;
|
||||||
|
}
|
||||||
|
|
||||||
.doc-toc {
|
.doc-toc {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 4.5rem;
|
top: 4.5rem;
|
||||||
align-self: flex-start;
|
align-self: start;
|
||||||
width: 15rem;
|
|
||||||
flex: 0 0 15rem;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
max-height: calc(100vh - 6rem);
|
max-height: calc(100vh - 6rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
padding-left: 1rem;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
.doc-layout {
|
|
||||||
display: flex;
|
|
||||||
gap: 2.5rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tables */
|
/* Tables */
|
||||||
|
|
@ -585,11 +642,17 @@ table {
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.45rem 0.7rem;
|
padding: 0.5rem 0.8rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A table cell that holds an identifier is kept on one line so that the
|
||||||
|
column does not break a name across two lines. */
|
||||||
|
td:first-child {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -761,6 +824,140 @@ tbody tr:hover {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A doc comment rendered as Markdown. The block spacing is tightened
|
||||||
|
because the comment is a fragment rather than a document. */
|
||||||
|
.api-doc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc p {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc ul,
|
||||||
|
.api-doc ol {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc h1,
|
||||||
|
.api-doc h2,
|
||||||
|
.api-doc h3,
|
||||||
|
.api-doc h4 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.75rem 0 0.3rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc figure.code {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc figure.code figcaption {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc .table-wrap {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The interface items a tool calls */
|
||||||
|
|
||||||
|
.calls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls-label {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The interface index */
|
||||||
|
|
||||||
|
.crate-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crate-card {
|
||||||
|
display: block;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crate-card:hover {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crate-card header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crate-card .name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crate-card p {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A row of links that wrap, used for the item filter results. */
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-note {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
.kind {
|
.kind {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,9 @@ const STRINGS: Record<string, Text> = {
|
||||||
'api.noMatch': { zh: '没有匹配的项。', en: 'No item matches that filter.' },
|
'api.noMatch': { zh: '没有匹配的项。', en: 'No item matches that filter.' },
|
||||||
'api.associated': { zh: '关联项', en: 'associated' },
|
'api.associated': { zh: '关联项', en: 'associated' },
|
||||||
'api.noDoc': { zh: '该项没有文档注释。', en: 'This item has no documentation comment.' },
|
'api.noDoc': { zh: '该项没有文档注释。', en: 'This item has no documentation comment.' },
|
||||||
|
'api.detail': { zh: '详情', en: 'Detail' },
|
||||||
|
'api.noItem': { zh: '不存在接口项', en: 'There is no interface item named' },
|
||||||
|
'api.crates': { zh: 'crate', en: 'Crates' },
|
||||||
'api.loadingItem': { zh: '正在加载…', en: 'Loading…' },
|
'api.loadingItem': { zh: '正在加载…', en: 'Loading…' },
|
||||||
|
|
||||||
'tools.title': { zh: '在线工具', en: 'Online tools' },
|
'tools.title': { zh: '在线工具', en: 'Online tools' },
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@ export interface ApiCrateSummary {
|
||||||
name: string;
|
name: string;
|
||||||
layer: string;
|
layer: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
/** The module documentation, with the Markdown markup removed. */
|
||||||
|
summary_plain: string;
|
||||||
items: number;
|
items: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +121,10 @@ export interface ApiItem {
|
||||||
kind: string;
|
kind: string;
|
||||||
name: string;
|
name: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
|
/** The documentation comment as it is written in the source. */
|
||||||
doc: string;
|
doc: string;
|
||||||
|
/** The documentation comment rendered as HTML by the backend. */
|
||||||
|
doc_html: string;
|
||||||
associated: boolean;
|
associated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,6 +142,7 @@ export interface ApiCrate {
|
||||||
name: string;
|
name: string;
|
||||||
layer: string;
|
layer: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
summary_plain: string;
|
||||||
items: ApiItem[];
|
items: ApiItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,19 @@
|
||||||
/**
|
/**
|
||||||
* The interface documentation.
|
* The interface documentation.
|
||||||
*
|
*
|
||||||
|
* The page has two views. The index lists the crates with a short
|
||||||
|
* description and lets a reader filter the public items by name. A crate
|
||||||
|
* view lists the items of one crate, grouped by kind, with the signature
|
||||||
|
* and the documentation of each. An item view shows one item on its own so
|
||||||
|
* that a link from a tool or a graph node lands on a readable page.
|
||||||
|
*
|
||||||
* The catalog is extracted from the source of the library, so what is
|
* The catalog is extracted from the source of the library, so what is
|
||||||
* shown here is the interface as it is written rather than a description
|
* shown here is the interface as it is written rather than a description
|
||||||
* of it. Selecting a crate lists its items; searching filters the item
|
* of it.
|
||||||
* names across every crate.
|
|
||||||
*/
|
*/
|
||||||
import type { ApiIndex, ApiCrate, ApiItem } from '../lib/types';
|
import type { ApiIndex, ApiCrate, ApiItem } from '../lib/types';
|
||||||
import { getJson } from '../lib/api';
|
import { getJson } from '../lib/api';
|
||||||
|
import { navigate } from '../lib/router';
|
||||||
import { language, tr } from '../lib/i18n';
|
import { language, tr } from '../lib/i18n';
|
||||||
import Signature from '../lib/Signature.svelte';
|
import Signature from '../lib/Signature.svelte';
|
||||||
|
|
||||||
|
|
@ -22,45 +28,41 @@
|
||||||
/** The current language, read so that the text re-renders on a switch. */
|
/** The current language, read so that the text re-renders on a switch. */
|
||||||
let lang = $derived($language);
|
let lang = $derived($language);
|
||||||
|
|
||||||
let selectedCrate = $state<string | null>(null);
|
|
||||||
let crate = $state<ApiCrate | null>(null);
|
let crate = $state<ApiCrate | null>(null);
|
||||||
let item = $state<(ApiItem & { crate: string; layer: string }) | null>(null);
|
let item = $state<(ApiItem & { crate: string; layer: string }) | null>(null);
|
||||||
let itemError = $state<string | null>(null);
|
let notFound = $state<string | null>(null);
|
||||||
let filter = $state('');
|
let filter = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
/** Selects a route segment: either a crate or an item name. */
|
/** The kind of a member, for the heading of a group. */
|
||||||
|
const KIND_ORDER = ['struct', 'enum', 'trait', 'fn', 'const', 'type'];
|
||||||
|
|
||||||
|
/** Resolves a route segment: either a crate name or an item name. */
|
||||||
async function resolve(segment: string | null) {
|
async function resolve(segment: string | null) {
|
||||||
selectedCrate = null;
|
|
||||||
crate = null;
|
crate = null;
|
||||||
item = null;
|
item = null;
|
||||||
itemError = null;
|
notFound = null;
|
||||||
if (!segment || !index) return;
|
if (!segment || !index) return;
|
||||||
|
|
||||||
const knownCrate = index.crates.find((candidate) => candidate.name === segment);
|
if (index.crates.some((candidate) => candidate.name === segment)) {
|
||||||
if (knownCrate) {
|
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
crate = await getJson<ApiCrate>(`/api/api/crates/${segment}`);
|
crate = await getJson<ApiCrate>(`/api/api/crates/${segment}`);
|
||||||
selectedCrate = segment;
|
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownItem = index.items.find((candidate) => candidate.name === segment);
|
if (!index.items.some((candidate) => candidate.name === segment)) {
|
||||||
if (!knownItem) {
|
notFound = segment;
|
||||||
itemError = `There is no crate or interface item named ${segment}.`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const response = await getJson<ApiItem & { crate: string; layer: string }>(
|
item = await getJson<ApiItem & { crate: string; layer: string }>(
|
||||||
`/api/api/items/${segment}`,
|
`/api/api/items/${segment}`,
|
||||||
);
|
);
|
||||||
item = response;
|
|
||||||
selectedCrate = response.crate;
|
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -81,65 +83,136 @@
|
||||||
candidate.name.toLowerCase().includes(needle) ||
|
candidate.name.toLowerCase().includes(needle) ||
|
||||||
candidate.crate.toLowerCase().includes(needle),
|
candidate.crate.toLowerCase().includes(needle),
|
||||||
)
|
)
|
||||||
.slice(0, 60);
|
.slice(0, 80);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The items of the crate grouped by kind. */
|
/** The items of the crate grouped by kind. */
|
||||||
const groups = $derived.by(() => {
|
const groups = $derived.by(() => {
|
||||||
if (!crate) return [];
|
const current = crate;
|
||||||
const order = ['struct', 'enum', 'trait', 'fn', 'const', 'type'];
|
if (!current) return [];
|
||||||
const result: { kind: string; items: ApiItem[] }[] = [];
|
return KIND_ORDER.map((kind) => ({
|
||||||
for (const kind of order) {
|
kind,
|
||||||
const items = crate.items.filter((candidate) => candidate.kind === kind);
|
items: current.items.filter((candidate) => candidate.kind === kind),
|
||||||
if (items.length > 0) result.push({ kind, items });
|
})).filter((group) => group.items.length > 0);
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Opens an item route. */
|
||||||
|
function openItem(name: string) {
|
||||||
|
navigate(`/api/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first sentence of a crate summary.
|
||||||
|
*
|
||||||
|
* A crate summary is the module documentation, which is several
|
||||||
|
* paragraphs. The index shows one sentence per crate so that the grid
|
||||||
|
* stays readable; the full text is on the crate page.
|
||||||
|
*/
|
||||||
|
function firstSentence(summary: string): string {
|
||||||
|
const text = summary.replace(/\s+/g, ' ').trim();
|
||||||
|
if (text === '') return '';
|
||||||
|
const end = text.search(/[.!?](\s|$)/);
|
||||||
|
if (end === -1) return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
||||||
|
return text.slice(0, end + 1);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Interface documentation</h1>
|
<h1>{tr('api.title', lang)}</h1>
|
||||||
<p>
|
<p>{tr('api.intro', lang)}</p>
|
||||||
The public interface of the ApsisRS layers this service builds against, extracted from
|
|
||||||
the source doc comments. Signatures are shown as they are written, with the units and
|
|
||||||
the error cases stated in the documentation of each item.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !index}
|
{#if !index}
|
||||||
<p class="empty">Loading the interface catalog…</p>
|
<p class="empty">{tr('api.loading', lang)}</p>
|
||||||
{:else}
|
{:else if crate}
|
||||||
<div class="grid cards">
|
<!-- One crate, with its items grouped by kind. -->
|
||||||
{#each index.crates as candidate}
|
<p class="breadcrumb">
|
||||||
<article class="card" style={selectedCrate === candidate.name ? 'border-color: var(--accent-strong);' : ''}>
|
<a href="#/api">{tr('nav.api', lang)}</a> / {crate.name}
|
||||||
<header style="display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;">
|
<span class="badge" style="margin-left: 0.5rem">{crate.layer}</span>
|
||||||
<h3 style="margin: 0;">
|
</p>
|
||||||
<a href={`#/api/${candidate.name}`}>{candidate.name}</a>
|
<h2 style="margin-top: 0.5rem">{crate.name}</h2>
|
||||||
</h3>
|
<p style="color: var(--text-muted)">{crate.summary}</p>
|
||||||
<span class="badge">{candidate.layer}</span>
|
|
||||||
<span class="badge">{candidate.items} items</span>
|
{#each groups as group}
|
||||||
|
<h3 id={`kind-${group.kind}`}>
|
||||||
|
{group.kind === 'fn' ? 'functions' : `${group.kind}s`}
|
||||||
|
<span class="badge">{group.items.length}</span>
|
||||||
|
</h3>
|
||||||
|
{#each group.items as candidate (candidate.name + candidate.signature)}
|
||||||
|
<article class="api-item">
|
||||||
|
<header>
|
||||||
|
<a class="name" href={`#/api/${candidate.name}`}>{candidate.name}</a>
|
||||||
|
{#if candidate.associated}
|
||||||
|
<span class="badge">{tr('api.associated', lang)}</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="ghost"
|
||||||
|
type="button"
|
||||||
|
style="margin-left: auto; padding: 0.05rem 0.5rem; font-size: 0.75rem;"
|
||||||
|
onclick={() => openItem(candidate.name)}>{tr('api.detail', lang)}</button>
|
||||||
</header>
|
</header>
|
||||||
<p style="color: var(--text-muted); font-size: 0.88rem;">{candidate.summary}</p>
|
<div style="margin: 0.5rem 0">
|
||||||
|
<Signature signature={candidate.signature} name={candidate.name} />
|
||||||
|
</div>
|
||||||
|
{#if candidate.doc}
|
||||||
|
<!-- The HTML is rendered by the backend from the doc comment, and
|
||||||
|
every text node is escaped there. -->
|
||||||
|
<div class="doc-body api-doc">{@html candidate.doc_html}</div>
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{/each}
|
||||||
|
{:else if item}
|
||||||
|
<!-- One item on its own. -->
|
||||||
|
<section class="card">
|
||||||
|
<p class="breadcrumb">
|
||||||
|
<a href="#/api">{tr('nav.api', lang)}</a> /
|
||||||
|
<a href={`#/api/${item.crate}`}>{item.crate}</a> / {item.name}
|
||||||
|
</p>
|
||||||
|
<header style="display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap">
|
||||||
|
<span class="kind">{item.kind}</span>
|
||||||
|
<span style="font-family: var(--mono); color: var(--accent); font-size: 1.05rem">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div style="margin: 0.75rem 0">
|
||||||
|
<Signature signature={item.signature} name={item.name} />
|
||||||
|
</div>
|
||||||
|
{#if item.doc}
|
||||||
|
<!-- The HTML is rendered by the backend from the doc comment, and
|
||||||
|
every text node is escaped there. -->
|
||||||
|
<div class="doc-body api-doc">{@html item.doc_html}</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">{tr('api.noDoc', lang)}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<!-- The index. -->
|
||||||
|
{#if notFound}
|
||||||
|
<div class="notice error">
|
||||||
|
<p>{tr('api.noItem', lang)} <code>{notFound}</code>。</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if loading}
|
||||||
|
<p class="empty"><span class="spinner"></span> {tr('api.loadingItem', lang)}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 1.5rem;">
|
<section class="card">
|
||||||
<h3>Find an item</h3>
|
<h3 style="margin-top: 0">{tr('api.find', lang)}</h3>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Filter the {index.item_count} public items by name or crate"
|
placeholder={`${tr('api.filter', lang)} (${index.item_count})`}
|
||||||
bind:value={filter}
|
bind:value={filter}
|
||||||
/>
|
/>
|
||||||
{#if filter.trim() !== ''}
|
{#if filter.trim() !== ''}
|
||||||
{#if filtered.length === 0}
|
{#if filtered.length === 0}
|
||||||
<p class="empty">No item matches that filter.</p>
|
<p class="empty">{tr('api.noMatch', lang)}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.75rem;">
|
<div class="chip-row">
|
||||||
{#each filtered as candidate}
|
{#each filtered as candidate}
|
||||||
<a class="badge" href={`#/api/${candidate.name}`} style="font-size: 0.78rem;">
|
<a class="chip" href={`#/api/${candidate.name}`}>
|
||||||
{candidate.name}
|
{candidate.name}
|
||||||
<span style="color: var(--text-faint);"> · {candidate.crate}</span>
|
<span class="chip-note">{candidate.crate}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,62 +220,16 @@
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if loading}
|
<div class="crate-grid">
|
||||||
<p class="empty"><span class="spinner"></span> Loading…</p>
|
{#each index.crates as candidate}
|
||||||
{/if}
|
<a class="crate-card" href={`#/api/${candidate.name}`}>
|
||||||
|
<header>
|
||||||
{#if itemError}
|
<span class="name">{candidate.name}</span>
|
||||||
<div class="notice error"><p>{itemError}</p></div>
|
<span class="badge">{candidate.layer}</span>
|
||||||
{/if}
|
<span class="badge">{candidate.items}</span>
|
||||||
|
</header>
|
||||||
{#if item}
|
<p>{firstSentence(candidate.summary_plain)}</p>
|
||||||
<section class="card" style="margin-top: 1.5rem;">
|
</a>
|
||||||
<p class="breadcrumb">
|
{/each}
|
||||||
<a href="#/api">{tr('nav.api', lang)}</a> /
|
</div>
|
||||||
<a href={`#/api/${item.crate}`}>{item.crate}</a> / {item.name}
|
|
||||||
</p>
|
|
||||||
<header style="display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;">
|
|
||||||
<span class="kind">{item.kind}</span>
|
|
||||||
<span class="name" style="font-family: var(--mono); color: var(--accent); font-size: 1.05rem;">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div style="margin: 0.75rem 0;">
|
|
||||||
<Signature signature={item.signature} name={item.name} />
|
|
||||||
</div>
|
|
||||||
{#if item.doc}
|
|
||||||
<p class="doc" style="white-space: pre-wrap; color: var(--text-muted);">{item.doc}</p>
|
|
||||||
{:else}
|
|
||||||
<p class="empty">This item has no documentation comment.</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if crate}
|
|
||||||
<section style="margin-top: 1.5rem;">
|
|
||||||
<p class="breadcrumb"><a href="#/api">{tr('nav.api', lang)}</a> / {crate.name}</p>
|
|
||||||
<h2 style="margin-top: 0.5rem;">{crate.name}</h2>
|
|
||||||
<p style="color: var(--text-muted);">{crate.summary}</p>
|
|
||||||
|
|
||||||
{#each groups as group}
|
|
||||||
<h3>{group.kind === 'fn' ? 'functions' : `${group.kind}s`}</h3>
|
|
||||||
{#each group.items as candidate}
|
|
||||||
<article class="api-item">
|
|
||||||
<header>
|
|
||||||
<span class="name">{candidate.name}</span>
|
|
||||||
{#if candidate.associated}
|
|
||||||
<span class="badge">associated</span>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
<div style="margin: 0.5rem 0;">
|
|
||||||
<Signature signature={candidate.signature} name={candidate.name} />
|
|
||||||
</div>
|
|
||||||
{#if candidate.doc}
|
|
||||||
<p class="doc">{candidate.doc}</p>
|
|
||||||
{/if}
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
<p><a href="#/docs">{tr('docs.back', lang)}</a></p>
|
<p><a href="#/docs">{tr('docs.back', lang)}</a></p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="doc-layout">
|
<div class="doc-layout" class:no-toc={form.headings.length === 0}>
|
||||||
<article class="doc-body">
|
<article class="doc-body">
|
||||||
<p class="breadcrumb">
|
<p class="breadcrumb">
|
||||||
<a href="#/docs">{tr('docs.breadcrumb', lang)}</a> / {document.chapter}
|
<a href="#/docs">{tr('docs.breadcrumb', lang)}</a> / {document.chapter}
|
||||||
|
|
@ -130,11 +130,9 @@
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<aside class="doc-toc">
|
{#if form.headings.length > 0}
|
||||||
<h2 style="font-size: 0.72rem;">{tr('docs.onThisPage', lang)}</h2>
|
<aside class="doc-toc">
|
||||||
{#if form.headings.length === 0}
|
<h2 style="font-size: 0.72rem;">{tr('docs.onThisPage', lang)}</h2>
|
||||||
<p class="empty">{tr('docs.noSections', lang)}</p>
|
|
||||||
{:else}
|
|
||||||
<ul style="list-style: none; padding-left: 0;">
|
<ul style="list-style: none; padding-left: 0;">
|
||||||
{#each form.headings as heading}
|
{#each form.headings as heading}
|
||||||
<li style="margin: 0.2rem 0;">
|
<li style="margin: 0.2rem 0;">
|
||||||
|
|
@ -144,7 +142,7 @@
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
</aside>
|
||||||
</aside>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
/**
|
/**
|
||||||
* The search results page.
|
* The search results page.
|
||||||
*
|
*
|
||||||
* The query is read from the route fragment. Results link to the document
|
* The query is read from the route fragment. A result links to the
|
||||||
* that matched.
|
* document that matched, and carries a short excerpt around the match so
|
||||||
|
* that a reader can tell why it was returned.
|
||||||
*/
|
*/
|
||||||
import type { SearchResponse } from '../lib/types';
|
import type { SearchResponse } from '../lib/types';
|
||||||
import { language, tr } from '../lib/i18n';
|
import { language, tr } from '../lib/i18n';
|
||||||
|
|
@ -18,6 +19,22 @@
|
||||||
|
|
||||||
/** The current language, read so that the text re-renders on a switch. */
|
/** The current language, read so that the text re-renders on a switch. */
|
||||||
let lang = $derived($language);
|
let lang = $derived($language);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a short excerpt of a summary around the query.
|
||||||
|
*
|
||||||
|
* When the query is not in the summary, the beginning of the summary is
|
||||||
|
* returned, because the summary is still the most useful line to show.
|
||||||
|
*/
|
||||||
|
function excerpt(summary: string, needle: string, length = 200): string {
|
||||||
|
const text = summary.replace(/\s+/g, ' ').trim();
|
||||||
|
if (text.length <= length) return text;
|
||||||
|
const at = text.toLowerCase().indexOf(needle.toLowerCase());
|
||||||
|
if (at <= 0) return `${text.slice(0, length - 1)}…`;
|
||||||
|
const start = Math.max(0, at - Math.floor(length / 3));
|
||||||
|
const end = Math.min(text.length, start + length);
|
||||||
|
return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
|
@ -25,7 +42,7 @@
|
||||||
<p>
|
<p>
|
||||||
{#if query}
|
{#if query}
|
||||||
{tr('search.results', lang)} <strong>{query}</strong>{#if response} — {response.count}
|
{tr('search.results', lang)} <strong>{query}</strong>{#if response} — {response.count}
|
||||||
{tr('search.of', lang)} {response.indexed} {tr('search.documents', lang)}{/if}。
|
{tr('search.of', lang)} {response.indexed} {tr('search.documents', lang)}{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{tr('search.enter', lang)}
|
{tr('search.enter', lang)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -39,15 +56,17 @@
|
||||||
{:else if !response || response.hits.length === 0}
|
{:else if !response || response.hits.length === 0}
|
||||||
<p class="empty">{tr('search.none', lang)}</p>
|
<p class="empty">{tr('search.none', lang)}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid cards">
|
<ul class="result-list">
|
||||||
{#each response.hits as hit}
|
{#each response.hits as hit}
|
||||||
<article class="card">
|
<li class="result">
|
||||||
<h3><a href={`#/docs/${hit.id}`}>{hit.title}</a></h3>
|
<a class="result-title" href={`#/docs/${hit.id}`}>{hit.title}</a>
|
||||||
<p style="color: var(--text-muted); margin: 0.4rem 0;">{hit.summary}</p>
|
<p class="result-summary">{excerpt(hit.summary, query)}</p>
|
||||||
<span class="badge">{hit.chapter}</span>
|
<div class="result-meta">
|
||||||
<span class="badge">{hit.language}</span>
|
<span class="badge">{hit.chapter}</span>
|
||||||
<span class="badge">{tr('search.score', lang)} {hit.score.toFixed(2)}</span>
|
<span class="badge">{hit.language}</span>
|
||||||
</article>
|
<span class="badge">{tr('search.score', lang)} {hit.score.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -94,12 +94,17 @@
|
||||||
error = null;
|
error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tools grouped by category. */
|
/** The tools grouped by category, in the order the registry lists them. */
|
||||||
const categories = $derived.by(() => {
|
const categories = $derived.by(() => {
|
||||||
if (!registry) return [];
|
if (!registry) return [];
|
||||||
|
// A category is matched by its English form, which the backend uses as
|
||||||
|
// the stable key of the group. Comparing the objects by identity would
|
||||||
|
// never match, because the listing carries a copy per tool.
|
||||||
return registry.categories.map((category) => ({
|
return registry.categories.map((category) => ({
|
||||||
category,
|
category,
|
||||||
tools: registry.tools.filter((candidate) => candidate.category === category),
|
tools: registry.tools.filter(
|
||||||
|
(candidate) => candidate.category.en === category.en,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -197,12 +202,12 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if tool.api_items.length > 0}
|
{#if tool.api_items.length > 0}
|
||||||
<p style="color: var(--text-faint); font-size: 0.82rem; margin-top: 0.75rem;">
|
<div class="calls">
|
||||||
{tr('tools.calls', lang)}
|
<span class="calls-label">{tr('tools.calls', lang)}</span>
|
||||||
{#each tool.api_items as name}
|
{#each tool.api_items as name}
|
||||||
<a href={`#/api/${name}`} style="font-family: var(--mono);">{name}</a>
|
<a class="chip" href={`#/api/${name}`}>{name}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,7 @@ impl Router {
|
||||||
"name" => krate.name.clone(),
|
"name" => krate.name.clone(),
|
||||||
"layer" => krate.layer.clone(),
|
"layer" => krate.layer.clone(),
|
||||||
"summary" => krate.summary.clone(),
|
"summary" => krate.summary.clone(),
|
||||||
|
"summary_plain" => crate::docs::markdown::to_plain_text(&krate.summary),
|
||||||
"items" => krate.items.len(),
|
"items" => krate.items.len(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -279,6 +280,8 @@ impl Router {
|
||||||
"name" => item.name.clone(),
|
"name" => item.name.clone(),
|
||||||
"signature" => item.signature.clone(),
|
"signature" => item.signature.clone(),
|
||||||
"doc" => item.doc.clone(),
|
"doc" => item.doc.clone(),
|
||||||
|
"doc_html" => crate::docs::markdown::render(&item.doc),
|
||||||
|
"doc_plain" => crate::docs::markdown::to_plain_text(&item.doc),
|
||||||
"associated" => item.associated,
|
"associated" => item.associated,
|
||||||
};
|
};
|
||||||
Response::json(json::to_string(&body)).with_cache_control("no-cache")
|
Response::json(json::to_string(&body)).with_cache_control("no-cache")
|
||||||
|
|
@ -536,6 +539,7 @@ fn crate_json(krate: &crate::docs::ApiCrate) -> Value {
|
||||||
"name" => item.name.clone(),
|
"name" => item.name.clone(),
|
||||||
"signature" => item.signature.clone(),
|
"signature" => item.signature.clone(),
|
||||||
"doc" => item.doc.clone(),
|
"doc" => item.doc.clone(),
|
||||||
|
"doc_html" => crate::docs::markdown::render(&item.doc),
|
||||||
"associated" => item.associated,
|
"associated" => item.associated,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -544,6 +548,7 @@ fn crate_json(krate: &crate::docs::ApiCrate) -> Value {
|
||||||
"name" => krate.name.clone(),
|
"name" => krate.name.clone(),
|
||||||
"layer" => krate.layer.clone(),
|
"layer" => krate.layer.clone(),
|
||||||
"summary" => krate.summary.clone(),
|
"summary" => krate.summary.clone(),
|
||||||
|
"summary_plain" => crate::docs::markdown::to_plain_text(&krate.summary),
|
||||||
"items" => items,
|
"items" => items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,23 +162,62 @@ impl DocumentVariant {
|
||||||
///
|
///
|
||||||
/// Returns `None` when the `title` key is absent, because a form without
|
/// Returns `None` when the `title` key is absent, because a form without
|
||||||
/// a title cannot be listed.
|
/// a title cannot be listed.
|
||||||
|
///
|
||||||
|
/// A leading top level heading is removed when it repeats the title. The
|
||||||
|
/// page already shows the title, from the metadata, in the heading of the
|
||||||
|
/// article, so a body that opens with the same heading would show it
|
||||||
|
/// twice. A document whose first heading is a different string keeps it,
|
||||||
|
/// because then the two headings say different things.
|
||||||
pub fn parse(text: &str) -> Option<Self> {
|
pub fn parse(text: &str) -> Option<Self> {
|
||||||
let (header, body) = split_header(text);
|
let (header, body) = split_header(text);
|
||||||
let title = field_of(header, "title").unwrap_or_default();
|
let title = field_of(header, "title").unwrap_or_default();
|
||||||
if title.is_empty() {
|
if title.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let body = strip_leading_title(body, &title);
|
||||||
Some(Self {
|
Some(Self {
|
||||||
title,
|
title,
|
||||||
summary: field_of(header, "summary").unwrap_or_default(),
|
summary: field_of(header, "summary").unwrap_or_default(),
|
||||||
headings: collect_headings(body),
|
headings: collect_headings(&body),
|
||||||
references: collect_references(body),
|
references: collect_references(&body),
|
||||||
markdown: body.to_string(),
|
markdown: body.clone(),
|
||||||
html: markdown::render(body),
|
html: markdown::render(&body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes a leading `# heading` when it equals `title`.
|
||||||
|
///
|
||||||
|
/// Returns the body unchanged when it does not open with such a heading.
|
||||||
|
fn strip_leading_title(body: &str, title: &str) -> String {
|
||||||
|
let mut lines = body.lines().peekable();
|
||||||
|
// Skip leading blank lines.
|
||||||
|
while matches!(lines.peek(), Some(line) if line.trim().is_empty()) {
|
||||||
|
lines.next();
|
||||||
|
}
|
||||||
|
let Some(first) = lines.peek() else {
|
||||||
|
return body.to_string();
|
||||||
|
};
|
||||||
|
let trimmed = first.trim_end();
|
||||||
|
let hashes = trimmed
|
||||||
|
.chars()
|
||||||
|
.take_while(|character| *character == '#')
|
||||||
|
.count();
|
||||||
|
if hashes == 1 && trimmed[hashes..].starts_with(' ') {
|
||||||
|
let heading = trimmed[hashes..].trim();
|
||||||
|
if heading == title.trim() {
|
||||||
|
lines.next();
|
||||||
|
// Drop one following blank line so the body does not start with
|
||||||
|
// a gap.
|
||||||
|
if matches!(lines.peek(), Some(line) if line.trim().is_empty()) {
|
||||||
|
lines.next();
|
||||||
|
}
|
||||||
|
return lines.collect::<Vec<&str>>().join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the value of a metadata key, trimmed.
|
/// Returns the value of a metadata key, trimmed.
|
||||||
fn field_of(header: &str, key: &str) -> Option<String> {
|
fn field_of(header: &str, key: &str) -> Option<String> {
|
||||||
for line in header.lines() {
|
for line in header.lines() {
|
||||||
|
|
@ -552,6 +591,9 @@ struct SearchEntry {
|
||||||
summary: String,
|
summary: String,
|
||||||
chapter: String,
|
chapter: String,
|
||||||
title_tokens: Vec<String>,
|
title_tokens: Vec<String>,
|
||||||
|
/// The searchable text, lowercased, kept for the substring fallback that
|
||||||
|
/// a query of a single character uses.
|
||||||
|
haystack: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SearchIndex {
|
impl SearchIndex {
|
||||||
|
|
@ -582,6 +624,8 @@ impl SearchIndex {
|
||||||
summary: entry.summary.clone(),
|
summary: entry.summary.clone(),
|
||||||
chapter: entry.chapter.clone(),
|
chapter: entry.chapter.clone(),
|
||||||
title_tokens,
|
title_tokens,
|
||||||
|
haystack: format!("{} {} {}", entry.title, entry.summary, entry.text)
|
||||||
|
.to_lowercase(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -593,13 +637,22 @@ impl SearchIndex {
|
||||||
|
|
||||||
/// Searches the index and returns the best matches, ordered by score.
|
/// Searches the index and returns the best matches, ordered by score.
|
||||||
///
|
///
|
||||||
/// At most `limit` results are returned. A query with no usable token
|
/// At most `limit` results are returned. A query with no usable token and
|
||||||
/// returns nothing.
|
/// no literal match returns nothing.
|
||||||
|
///
|
||||||
|
/// A query is handled in two ways. A query that yields tokens is matched
|
||||||
|
/// against the postings. A query that yields no token, such as a single
|
||||||
|
/// character, is matched as a substring, because a one character query
|
||||||
|
/// cannot form a token and would otherwise always return nothing.
|
||||||
pub fn search(&self, query: &str, limit: usize) -> Vec<SearchHit> {
|
pub fn search(&self, query: &str, limit: usize) -> Vec<SearchHit> {
|
||||||
let tokens = tokenise(&query.to_lowercase());
|
let needle = query.trim().to_lowercase();
|
||||||
if tokens.is_empty() {
|
if needle.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
let tokens = tokenise(&needle);
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return self.search_substring(&needle, limit);
|
||||||
|
}
|
||||||
|
|
||||||
let mut scores: HashMap<usize, f64> = HashMap::new();
|
let mut scores: HashMap<usize, f64> = HashMap::new();
|
||||||
for token in &tokens {
|
for token in &tokens {
|
||||||
|
|
@ -645,6 +698,40 @@ impl SearchIndex {
|
||||||
hits
|
hits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the documents whose text contains the query literally.
|
||||||
|
///
|
||||||
|
/// Used for a query that yields no token. A match in the title scores
|
||||||
|
/// higher than a match in the body, so the ordering stays useful.
|
||||||
|
fn search_substring(&self, needle: &str, limit: usize) -> Vec<SearchHit> {
|
||||||
|
let mut hits: Vec<SearchHit> = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, entry)| entry.haystack.contains(needle))
|
||||||
|
.map(|(index, entry)| {
|
||||||
|
let title_match = entry.title.to_lowercase().contains(needle);
|
||||||
|
SearchHit {
|
||||||
|
id: entry.id.clone(),
|
||||||
|
language: entry.language,
|
||||||
|
title: entry.title.clone(),
|
||||||
|
summary: entry.summary.clone(),
|
||||||
|
chapter: entry.chapter.clone(),
|
||||||
|
score: if title_match { 2.0 } else { 1.0 } + (index as f64) * 1e-9,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
hits.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.score
|
||||||
|
.partial_cmp(&left.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then_with(|| left.id.cmp(&right.id))
|
||||||
|
});
|
||||||
|
hits.dedup_by(|left, right| left.id == right.id);
|
||||||
|
hits.truncate(limit);
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the number of indexed documents.
|
/// Returns the number of indexed documents.
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
|
|
@ -675,15 +762,68 @@ pub struct SearchHit {
|
||||||
pub score: f64,
|
pub score: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits text into lowercased alphanumeric tokens.
|
/// Splits text into lowercased tokens.
|
||||||
///
|
///
|
||||||
/// Identifiers written with underscores, hyphens, or dots are split there as
|
/// Two kinds of token are produced:
|
||||||
/// well, so `mean_motion` and `mean-motion` both match `mean motion`.
|
///
|
||||||
|
/// - A run of letters and digits becomes one token per word, with identifiers
|
||||||
|
/// written with underscores, hyphens, or dots split there as well, so
|
||||||
|
/// `mean_motion` and `mean-motion` both match a query for `mean motion`.
|
||||||
|
/// A word of one character is dropped, because it would match too much.
|
||||||
|
/// - A run of Chinese characters becomes one token per adjacent pair. Chinese
|
||||||
|
/// text is not written with spaces, so a word cannot be recovered by
|
||||||
|
/// splitting on whitespace; a pair of adjacent characters is the smallest
|
||||||
|
/// unit that is specific enough to be a word. Searching 坐标 therefore
|
||||||
|
/// matches a paragraph that contains 坐标, which a whole-run token would
|
||||||
|
/// not, because the run around it is longer.
|
||||||
fn tokenise(text: &str) -> Vec<String> {
|
fn tokenise(text: &str) -> Vec<String> {
|
||||||
text.split(|character: char| !character.is_alphanumeric())
|
let mut tokens = Vec::new();
|
||||||
.filter(|token| token.len() >= 2)
|
let mut word = String::new();
|
||||||
.map(str::to_string)
|
let mut han: Vec<char> = Vec::new();
|
||||||
.collect()
|
|
||||||
|
// Flushes the pending Latin word.
|
||||||
|
fn flush_word(word: &mut String, tokens: &mut Vec<String>) {
|
||||||
|
if word.chars().count() >= 2 {
|
||||||
|
tokens.push(word.clone());
|
||||||
|
}
|
||||||
|
word.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flushes the pending run of Chinese characters as adjacent pairs. A
|
||||||
|
// lone character produces no token; a query of one character is handled
|
||||||
|
// by the substring search instead, which is more precise than a token
|
||||||
|
// that would match every occurrence of that character.
|
||||||
|
fn flush_han(han: &mut Vec<char>, tokens: &mut Vec<String>) {
|
||||||
|
for pair in han.windows(2) {
|
||||||
|
tokens.push(pair.iter().collect());
|
||||||
|
}
|
||||||
|
han.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for character in text.chars() {
|
||||||
|
if is_han(character) {
|
||||||
|
flush_word(&mut word, &mut tokens);
|
||||||
|
han.push(character);
|
||||||
|
} else if character.is_alphanumeric() {
|
||||||
|
flush_han(&mut han, &mut tokens);
|
||||||
|
word.extend(character.to_lowercase());
|
||||||
|
} else {
|
||||||
|
flush_word(&mut word, &mut tokens);
|
||||||
|
flush_han(&mut han, &mut tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush_word(&mut word, &mut tokens);
|
||||||
|
flush_han(&mut han, &mut tokens);
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when a character is a Chinese ideograph.
|
||||||
|
///
|
||||||
|
/// The ranges cover the unified ideographs, the extension A block, and the
|
||||||
|
/// compatibility ideographs, which is everything the content uses.
|
||||||
|
fn is_han(character: char) -> bool {
|
||||||
|
matches!(character as u32,
|
||||||
|
0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -742,6 +882,33 @@ BSTAR 项。
|
||||||
assert!(document.english.html.contains("<h1"));
|
assert!(document.english.html.contains("<h1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repeated_top_level_title_is_dropped_from_the_body() {
|
||||||
|
let document = sample("sgp4");
|
||||||
|
// The sample body opens with `# SGP4`, which differs from the
|
||||||
|
// metadata title `SGP4 propagation`, so it is kept.
|
||||||
|
assert!(document.english.html.contains("<h1"));
|
||||||
|
assert!(
|
||||||
|
document
|
||||||
|
.english
|
||||||
|
.headings
|
||||||
|
.iter()
|
||||||
|
.any(|(_, text)| text == "SGP4")
|
||||||
|
);
|
||||||
|
|
||||||
|
// A body that repeats the metadata title loses the repeated heading.
|
||||||
|
let repeated = "---\ntitle: SGP4 propagation\n---\n\n# SGP4 propagation\n\nBody.\n";
|
||||||
|
let variant = DocumentVariant::parse(repeated).expect("must parse");
|
||||||
|
assert!(!variant.html.contains("<h1"));
|
||||||
|
assert!(
|
||||||
|
!variant
|
||||||
|
.headings
|
||||||
|
.iter()
|
||||||
|
.any(|(_, text)| text == "SGP4 propagation")
|
||||||
|
);
|
||||||
|
assert!(variant.html.contains("Body."));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_translation_replaces_the_language_form() {
|
fn a_translation_replaces_the_language_form() {
|
||||||
let mut document = sample("sgp4");
|
let mut document = sample("sgp4");
|
||||||
|
|
@ -806,6 +973,47 @@ BSTAR 项。
|
||||||
assert_eq!(hits[0].language, Language::English);
|
assert_eq!(hits[0].language, Language::English);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokenise_emits_words_and_han_pairs() {
|
||||||
|
assert_eq!(tokenise("mean_motion"), vec!["mean", "motion"]);
|
||||||
|
assert_eq!(tokenise("轨道传播"), vec!["轨道", "道传", "传播"]);
|
||||||
|
assert_eq!(tokenise("坐标"), vec!["坐标"]);
|
||||||
|
// A lone Chinese character produces no token; the search falls back
|
||||||
|
// to a substring match for it.
|
||||||
|
assert!(tokenise("轨").is_empty());
|
||||||
|
// A single Latin character is dropped.
|
||||||
|
assert!(tokenise("a").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chinese_query_matches_a_word_inside_a_sentence() {
|
||||||
|
let documents = vec![
|
||||||
|
Document::parse(
|
||||||
|
"a",
|
||||||
|
Language::English,
|
||||||
|
"---\ntitle: Orbits\nsummary: s\n---\n\n本条论述轨道坐标的转换。\n",
|
||||||
|
)
|
||||||
|
.expect("must parse"),
|
||||||
|
Document::parse(
|
||||||
|
"b",
|
||||||
|
Language::English,
|
||||||
|
"---\ntitle: Time\nsummary: s\n---\n\n这里讨论时间尺度。\n",
|
||||||
|
)
|
||||||
|
.expect("must parse"),
|
||||||
|
];
|
||||||
|
let tree = Tree::new(documents);
|
||||||
|
let index = tree.search_index();
|
||||||
|
// A two character query matches the pair inside the sentence.
|
||||||
|
let hits = index.search("坐标", 5);
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].id, "a");
|
||||||
|
// A one character query has no token and falls back to a substring
|
||||||
|
// match.
|
||||||
|
let hits = index.search("轨", 5);
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_ranks_title_matches_first() {
|
fn search_ranks_title_matches_first() {
|
||||||
let documents = vec![
|
let documents = vec![
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue