web: fix the interface layout and add Chinese search

This commit is contained in:
dev 2026-09-27 09:45:26 +08:00
parent b7e5222bcd
commit b4c9627cbb
11 changed files with 676 additions and 167 deletions

View File

@ -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,

View File

@ -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}

View File

@ -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;

View File

@ -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' },

View File

@ -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[];
} }

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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,
} }
} }

View File

@ -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![