feat(docs): add document search (#147)

This commit is contained in:
Laodouuu
2026-07-31 20:36:34 +08:00
committed by GitHub
parent 4db89cbe3f
commit 5308002d72
10 changed files with 1040 additions and 7 deletions
@@ -0,0 +1,277 @@
/**
* Global docs search dialog: Ctrl/Cmd+K opens it, results update locally as the user
* types, and every result navigates to the matching page heading. The input retains
* focus while arrow keys move the active result, following command-palette behavior.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
RefObject,
} from "react";
import { useNavigate } from "react-router";
import { S } from "../lib/strings";
import { useLocale } from "../state/locale";
import { getDoc } from "../lib/docs";
import { DOC_SLUGS, HOME_SLUG } from "../lib/nav";
import { buildSearchRecords, findSearchMatchRanges, searchRecords } from "../lib/search";
import { ArrowRightIcon, SearchIcon, XIcon } from "./icons";
function HighlightedText({ text, query }: { text: string; query: string }) {
const ranges = findSearchMatchRanges(text, query);
if (ranges.length === 0) return text;
const parts = [];
let offset = 0;
for (const range of ranges) {
if (range.start > offset) parts.push(text.slice(offset, range.start));
parts.push(
<mark
key={`${range.start}:${range.end}`}
className="bg-transparent text-brand-700 dark:text-brand-300"
>
{text.slice(range.start, range.end)}
</mark>,
);
offset = range.end;
}
if (offset < text.length) parts.push(text.slice(offset));
return parts;
}
export function DocSearchDialog({
open,
onClose,
returnFocusRef,
}: {
open: boolean;
onClose: () => void;
returnFocusRef: RefObject<HTMLElement | null>;
}) {
const { locale } = useLocale();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const activeResultRef = useRef<HTMLButtonElement | null>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const records = useMemo(
() =>
buildSearchRecords(
DOC_SLUGS.map((slug) => getDoc(slug, locale)).filter(
(doc): doc is NonNullable<typeof doc> => doc !== undefined,
),
),
[locale],
);
const results = useMemo(() => searchRecords(records, query), [records, query]);
useEffect(() => {
setActiveIndex(0);
}, [query]);
useEffect(() => {
activeResultRef.current?.scrollIntoView({ block: "nearest" });
}, [activeIndex, results]);
useEffect(() => {
if (!open) {
setQuery("");
const returnFocus = returnFocusRef.current;
returnFocusRef.current = null;
if (returnFocus?.isConnected) returnFocus.focus();
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => {
cancelAnimationFrame(frame);
document.body.style.overflow = previousOverflow;
};
}, [open, returnFocusRef]);
const openResult = (index: number) => {
const result = results[index];
if (!result) return;
const page = result.record.slug === HOME_SLUG ? "/" : `/${result.record.slug}`;
const target = result.record.anchor ? `${page}#${result.record.anchor}` : page;
navigate(target);
onClose();
};
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown" && results.length > 0) {
event.preventDefault();
setActiveIndex((index) => (index + 1) % results.length);
return;
}
if (event.key === "ArrowUp" && results.length > 0) {
event.preventDefault();
setActiveIndex((index) => (index - 1 + results.length) % results.length);
return;
}
if (event.key === "Enter" && results.length > 0) {
event.preventDefault();
openResult(activeIndex);
}
};
const onDialogKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
'input, button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
) ?? [],
);
if (focusable.length === 0) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const onBackdropMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) onClose();
};
if (!open) return null;
const activeResultId = results[activeIndex] ? `doc-search-result-${activeIndex}` : undefined;
return (
<div
className="anim-fade fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-3 pt-[10vh] sm:px-6 sm:pt-[14vh]"
onMouseDown={onBackdropMouseDown}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="doc-search-title"
className="anim-rise flex max-h-[min(38rem,80vh)] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-950"
onKeyDown={onDialogKeyDown}
>
<div className="flex min-h-14 items-center gap-3 border-b border-gray-200 px-4 dark:border-gray-800">
<SearchIcon className="h-5 w-5 shrink-0 text-gray-400" />
<label id="doc-search-title" htmlFor="doc-search-input" className="sr-only">
{S.search.label}
</label>
<input
ref={inputRef}
id="doc-search-input"
type="text"
inputMode="search"
enterKeyHint="search"
spellCheck={false}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onInputKeyDown}
placeholder={S.search.placeholder}
autoComplete="off"
role="combobox"
aria-expanded="true"
aria-autocomplete="list"
aria-controls="doc-search-results"
aria-activedescendant={activeResultId}
className="min-w-0 flex-1 bg-transparent py-4 text-base text-gray-900 outline-none placeholder:text-gray-400 dark:text-gray-100 dark:placeholder:text-gray-500"
/>
<button
type="button"
onClick={onClose}
aria-label={S.search.close}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
<XIcon className="h-5 w-5" />
</button>
</div>
<div className="min-h-32 overflow-y-auto p-2">
{!query.trim() ? (
<div className="flex min-h-28 flex-col items-center justify-center px-5 text-center">
<SearchIcon className="h-6 w-6 text-gray-300 dark:text-gray-600" />
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{S.search.start}</p>
</div>
) : results.length === 0 ? (
<div className="flex min-h-28 flex-col items-center justify-center px-5 text-center">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{S.search.noResults}
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{S.search.noResultsHint}
</p>
</div>
) : (
<ul id="doc-search-results" role="listbox" aria-label={S.search.results}>
{results.map((result, index) => {
const active = index === activeIndex;
return (
<li key={`${result.record.slug}:${result.record.anchor || "page"}:${index}`}>
<button
ref={active ? activeResultRef : undefined}
id={`doc-search-result-${index}`}
type="button"
role="option"
aria-selected={active}
onMouseEnter={() => setActiveIndex(index)}
onFocus={() => setActiveIndex(index)}
onClick={() => openResult(index)}
className={`flex min-h-16 w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ${
active
? "bg-brand-50 text-gray-950 dark:bg-gray-800 dark:text-white"
: "text-gray-800 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-900"
}`}
>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate text-sm font-medium">
<HighlightedText
text={result.record.heading || result.record.pageTitle}
query={query}
/>
</span>
{result.record.heading && (
<span className="truncate text-xs text-gray-500 dark:text-gray-400">
<HighlightedText text={result.record.pageTitle} query={query} />
</span>
)}
</span>
{result.snippet && (
<span className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
<HighlightedText text={result.snippet} query={query} />
</span>
)}
</span>
<ArrowRightIcon
className={`h-4 w-4 shrink-0 ${active ? "text-brand-600 dark:text-brand-300" : "text-gray-300 dark:text-gray-600"}`}
/>
</button>
</li>
);
})}
</ul>
)}
</div>
<div className="hidden border-t border-gray-200 px-4 py-2 text-xs text-gray-500 sm:block dark:border-gray-800 dark:text-gray-400">
{S.search.keyboardHint}
</div>
</div>
</div>
);
}
+9
View File
@@ -99,6 +99,15 @@ export function ArrowRightIcon(props: IconProps) {
);
}
export function SearchIcon(props: IconProps) {
return (
<Icon {...props}>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</Icon>
);
}
export function GlobeIcon(props: IconProps) {
return (
<Icon {...props}>
+11 -2
View File
@@ -12,7 +12,7 @@
* site-prefs.ts); keep the two files aligned so the navbars render identically.
*/
import { useRef } from "react";
import type { MouseEvent } from "react";
import type { MouseEvent, RefObject } from "react";
import { Link } from "react-router";
import { S } from "../lib/strings";
import { REPO_URL, SITE_URL } from "../lib/links";
@@ -30,7 +30,15 @@ const SECTION_IDS = [
"features",
] as const;
export function Nav({ menuOpen, onToggleMenu }: { menuOpen: boolean; onToggleMenu: () => void }) {
export function Nav({
menuOpen,
onToggleMenu,
menuButtonRef,
}: {
menuOpen: boolean;
onToggleMenu: () => void;
menuButtonRef?: RefObject<HTMLButtonElement | null>;
}) {
const pillRef = useRef<HTMLSpanElement | null>(null);
const pillVisible = useRef(false);
@@ -142,6 +150,7 @@ export function Nav({ menuOpen, onToggleMenu }: { menuOpen: boolean; onToggleMen
{/* Sidebar toggle: same slot and styling as the landing nav's menu button, but it
opens the docs sidebar and hides at lg (the sidebar's own breakpoint). */}
<button
ref={menuButtonRef}
type="button"
onClick={onToggleMenu}
aria-label={menuOpen ? S.nav.closeMenu : S.nav.openMenu}
+20 -1
View File
@@ -8,14 +8,33 @@ import { S } from "../lib/strings";
import { useLocale } from "../state/locale";
import { DOCS_NAV, HOME_SLUG } from "../lib/nav";
import { docTitle } from "../lib/docs";
import { currentSearchShortcutLabel } from "../lib/shortcut";
import { SearchIcon } from "./icons";
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
export function Sidebar({
onNavigate,
onOpenSearch,
}: {
onNavigate?: () => void;
onOpenSearch: (trigger: HTMLButtonElement) => void;
}) {
const { locale } = useLocale();
const { pathname } = useLocation();
const activeSlug = pathname.replace(/^\/|\/$/g, "") || HOME_SLUG;
return (
<nav aria-label="Docs" className="text-sm">
<button
type="button"
onClick={(event) => onOpenSearch(event.currentTarget)}
className="mb-6 flex min-h-11 w-full items-center gap-2 rounded-lg border border-gray-200 px-3 text-left text-gray-500 transition-colors hover:border-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-800 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-gray-900 dark:hover:text-gray-100"
>
<SearchIcon className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{S.search.open}</span>
<kbd className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] leading-none text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
{currentSearchShortcutLabel()}
</kbd>
</button>
{DOCS_NAV.map((section) => (
<div key={section.id} className="mb-6">
<p className="mb-2 text-xs font-semibold tracking-wide text-gray-400 uppercase dark:text-gray-500">
+374
View File
@@ -0,0 +1,374 @@
/**
* Dependency-free docs search. The Markdown is already bundled by Vite, so the
* current content set is small enough to split into heading-sized records and scan
* in memory. Keeping the index section-based lets a result deep-link to the relevant
* heading instead of only opening the top of a long page.
*/
import type { DocPage } from "./docs";
import { slugifyHeading } from "./toc";
export interface SearchRecord {
slug: string;
anchor: string;
pageTitle: string;
heading: string;
text: string;
normalizedTitle: string;
normalizedHeading: string;
normalizedText: string;
}
export interface SearchResult {
record: SearchRecord;
score: number;
snippet: string;
}
export interface SearchMatchRange {
start: number;
end: number;
}
interface NormalizedSourceMap {
value: string;
sourceStarts: number[];
sourceEnds: number[];
}
interface SearchSection {
heading: string;
anchor: string;
lines: string[];
}
const SEARCH_GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
/** Unicode-compatible normalization shared by indexing and querying. */
export function normalizeSearchText(value: string): string {
return value.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
}
/**
* Normalize text while retaining the source offsets for every normalized UTF-16
* code unit. Segmenting by grapheme keeps compositions such as e + combining acute
* together, and the pending-space handling mirrors normalizeSearchText exactly.
*/
function normalizeSearchTextWithSourceMap(text: string): NormalizedSourceMap {
let value = "";
const sourceStarts: number[] = [];
const sourceEnds: number[] = [];
let pendingSpace: SearchMatchRange | null = null;
const segments = SEARCH_GRAPHEME_SEGMENTER.segment(text);
const append = (normalized: string, start: number, end: number) => {
value += normalized;
for (let index = 0; index < normalized.length; index += 1) {
sourceStarts.push(start);
sourceEnds.push(end);
}
};
for (const { segment, index: sourceStart } of segments) {
const sourceEnd = sourceStart + segment.length;
const normalizedSegment = segment.normalize("NFKC").toLowerCase();
for (const character of normalizedSegment) {
if (/\s/u.test(character)) {
if (value) {
pendingSpace ??= { start: sourceStart, end: sourceEnd };
pendingSpace.end = sourceEnd;
}
continue;
}
if (pendingSpace) {
append(" ", pendingSpace.start, pendingSpace.end);
pendingSpace = null;
}
append(character, sourceStart, sourceEnd);
}
}
return { value, sourceStarts, sourceEnds };
}
/**
* Remove only paired Markdown emphasis/strikethrough delimiters. Unmatched marker
* characters can be part of documented identifiers or paths and must remain
* searchable (for example partial_*, model.*, and ~/.penguin).
*/
function stripMarkdownFormatting(text: string): string {
return text
.replace(/(\*\*|~~)(?=\S)([^\n]*?\S)\1/g, "$2")
.replace(/(^|[^\p{L}\p{N}])__(?=\S)([^\n]*?\S)__(?=$|[^\p{L}\p{N}])/gu, "$1$2")
.replace(/(^|[^\p{L}\p{N}*_])([*_])(?=\S)([^*_\n]*?\S)\2(?=$|[^\p{L}\p{N}*_])/gu, "$1$3");
}
/** Flatten actual table rows without removing literal pipes from ordinary prose. */
function stripMarkdownTableSyntax(text: string): string {
return text
.split("\n")
.map((line) => {
const trimmed = line.trim();
if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return line;
const cells = trimmed.slice(1, -1).split("|");
if (cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell))) return "";
return cells.join(" ");
})
.join("\n");
}
/**
* Locate normalized query terms in the original string while retaining source
* offsets. A grapheme-aware map keeps highlights correct when NFKC changes width,
* case, or combines multiple source code points into one displayed character.
*/
export function findSearchMatchRanges(text: string, rawQuery: string): SearchMatchRange[] {
const terms = normalizeSearchText(rawQuery).split(" ").filter(Boolean);
if (terms.length === 0 || !text) return [];
const { value: normalized, sourceStarts, sourceEnds } = normalizeSearchTextWithSourceMap(text);
const ranges: SearchMatchRange[] = [];
for (const term of terms) {
let from = 0;
while (from < normalized.length) {
const match = normalized.indexOf(term, from);
if (match < 0) break;
const start = sourceStarts[match];
const end = sourceEnds[match + term.length - 1];
if (start !== undefined && end !== undefined) ranges.push({ start, end });
from = match + Math.max(1, term.length);
}
}
return ranges
.sort((a, b) => a.start - b.start || a.end - b.end)
.reduce<SearchMatchRange[]>((merged, range) => {
const previous = merged[merged.length - 1];
if (previous && range.start <= previous.end) {
previous.end = Math.max(previous.end, range.end);
} else {
merged.push({ ...range });
}
return merged;
}, []);
}
/**
* Markdown -> readable search text. Link labels and code remain searchable, while
* formatting punctuation, destinations, and fence markers are discarded.
*/
export function markdownToSearchText(markdown: string): string {
const codeSegments: string[] = [];
const protectCode = (code: string): string => {
const index = codeSegments.push(code.replace(/\s+/g, " ").trim()) - 1;
return `\uE000${index}\uE001`;
};
const lines = markdown.split("\n");
const protectedLines: string[] = [];
let fence = "";
let fencedCode: string[] = [];
for (const line of lines) {
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1] ?? "";
if (!fence && marker) {
fence = marker;
fencedCode = [];
continue;
}
if (fence) {
if (marker && marker[0] === fence[0] && marker.length >= fence.length) {
protectedLines.push(` ${protectCode(fencedCode.join("\n"))} `);
fence = "";
fencedCode = [];
} else {
fencedCode.push(line);
}
continue;
}
protectedLines.push(line);
}
if (fence) protectedLines.push(` ${protectCode(fencedCode.join("\n"))} `);
const visibleText = protectedLines
.join("\n")
.replace(/(`+)([\s\S]*?)\1/g, (_match, _ticks: string, code: string) => protectCode(code))
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/<(https?:\/\/[^>]+)>/g, "$1")
.replace(/^\s{0,3}(?:#{1,6}|>|[-+*]|\d+[.)])\s+/gm, "");
return stripMarkdownTableSyntax(stripMarkdownFormatting(visibleText))
.replace(/\s+/g, " ")
.trim()
.replace(/\uE000(\d+)\uE001/g, (_match, rawIndex: string) => {
return codeSegments[Number(rawIndex)] ?? "";
});
}
function splitSections(body: string): SearchSection[] {
const sections: SearchSection[] = [{ heading: "", anchor: "", lines: [] }];
let current = sections[0]!;
let fenceMarker = "";
for (const line of body.split("\n")) {
const fence = /^\s*(```|~~~)/.exec(line)?.[1] ?? "";
if (fence) {
if (!fenceMarker) fenceMarker = fence;
else if (fence === fenceMarker) fenceMarker = "";
current.lines.push(line);
continue;
}
if (!fenceMarker) {
const heading = /^(#{2,3})\s+(.+?)\s*$/.exec(line);
if (heading) {
const text = markdownToSearchText(heading[2]!);
current = {
heading: text,
anchor: slugifyHeading(text),
lines: [],
};
sections.push(current);
continue;
}
}
current.lines.push(line);
}
return sections;
}
export function buildSearchRecords(docs: readonly DocPage[]): SearchRecord[] {
return docs.flatMap((doc) =>
splitSections(doc.body)
.map((section) => {
const body = markdownToSearchText(section.lines.join("\n"));
return {
slug: doc.slug,
anchor: section.anchor,
pageTitle: doc.title,
heading: section.heading,
text: section.anchor === "" ? [doc.description, body].filter(Boolean).join(" ") : body,
};
})
.map((section) => ({
...section,
normalizedTitle: normalizeSearchText(section.pageTitle),
normalizedHeading: normalizeSearchText(section.heading),
normalizedText: normalizeSearchText(section.text),
})),
);
}
function firstMatchIndex(text: string, query: string, terms: readonly string[]): number {
const phrase = text.indexOf(query);
if (phrase >= 0) return phrase;
let first = -1;
for (const term of terms) {
const index = text.indexOf(term);
if (index >= 0 && (first < 0 || index < first)) first = index;
}
return first;
}
function graphemeBoundaryAtOrBefore(text: string, offset: number): number {
let boundary = 0;
for (const { index } of SEARCH_GRAPHEME_SEGMENTER.segment(text)) {
if (index > offset) break;
boundary = index;
}
return boundary;
}
function graphemeBoundaryAtSnippetEnd(text: string, start: number, maxLength: number): number {
const limit = start + maxLength;
let end = start;
for (const { segment, index } of SEARCH_GRAPHEME_SEGMENTER.segment(text)) {
if (index < start) continue;
const next = index + segment.length;
if (next > limit) return end === start ? next : end;
end = next;
}
return text.length;
}
export function createSearchSnippet(text: string, query: string, maxLength = 150): string {
if (text.length <= maxLength) return text;
const { value: normalized, sourceStarts } = normalizeSearchTextWithSourceMap(text);
const normalizedQuery = normalizeSearchText(query);
const terms = normalizedQuery.split(" ").filter(Boolean);
const match = firstMatchIndex(normalized, normalizedQuery, terms);
const matchSourceStart = match < 0 ? 0 : (sourceStarts[match] ?? 0);
const preferredStart = Math.max(0, matchSourceStart - Math.floor(maxLength / 3));
const start = graphemeBoundaryAtOrBefore(text, preferredStart);
const end = graphemeBoundaryAtSnippetEnd(text, start, maxLength);
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
}
function scoreRecord(record: SearchRecord, query: string, terms: readonly string[]): number | null {
const combined = `${record.normalizedTitle} ${record.normalizedHeading} ${record.normalizedText}`;
if (!terms.every((term) => combined.includes(term))) return null;
const content = `${record.normalizedHeading} ${record.normalizedText}`;
// A page-title-only query should yield the page result, not every section in it.
if (record.heading && !terms.some((term) => content.includes(term))) return null;
let score = 0;
const isPageResult = record.heading === "";
if (record.normalizedTitle === query) score += isPageResult ? 240 : 40;
else if (record.normalizedTitle.startsWith(query)) score += isPageResult ? 150 : 25;
else if (record.normalizedTitle.includes(query)) score += isPageResult ? 110 : 15;
if (record.normalizedHeading === query) score += 200;
else if (record.normalizedHeading.startsWith(query)) score += 130;
else if (record.normalizedHeading.includes(query)) score += 90;
if (record.normalizedText.includes(query)) score += 45;
for (const term of terms) {
if (record.normalizedTitle.includes(term)) score += isPageResult ? 24 : 6;
if (record.normalizedHeading.includes(term)) score += 30;
if (record.normalizedText.includes(term)) score += 8;
}
const firstContentMatch = firstMatchIndex(content, query, terms);
if (firstContentMatch >= 0) score += Math.max(0, 12 - firstContentMatch / 100);
return score;
}
export function searchRecords(
records: readonly SearchRecord[],
rawQuery: string,
limit = 10,
): SearchResult[] {
const query = normalizeSearchText(rawQuery);
if (!query) return [];
const terms = query.split(" ").filter(Boolean);
return records
.map((record, order) => {
const score = scoreRecord(record, query, terms);
if (score === null) return null;
return {
record,
score,
snippet: createSearchSnippet(record.text, query),
order,
};
})
.filter(
(
result,
): result is SearchResult & {
order: number;
} => result !== null,
)
.sort((a, b) => b.score - a.score || a.order - b.order)
.slice(0, limit)
.map(({ order: _order, ...result }) => result);
}
+11
View File
@@ -0,0 +1,11 @@
/** Platform-specific label for the global documentation search shortcut. */
export function getSearchShortcutLabel(platform: string | undefined): string {
return platform && /(Mac|iPhone|iPad|iPod)/i.test(platform) ? "⌘ K" : "Ctrl K";
}
/** Read the current browser platform without making module evaluation browser-only. */
export function currentSearchShortcutLabel(): string {
const platform =
typeof navigator === "undefined" ? undefined : navigator.platform || navigator.userAgent;
return getSearchShortcutLabel(platform);
}
+12
View File
@@ -50,6 +50,18 @@ export const en: Strings = {
backHome: "Back to docs home",
},
search: {
open: "Search docs",
label: "Search docs",
placeholder: "Search titles, sections, and content",
start: "Enter a keyword to search all docs in the current language",
noResults: "No matching documentation",
noResultsHint: "Try a shorter or different search term",
results: "Search results",
close: "Close search",
keyboardHint: "Use the arrow keys to select, then press Enter to open",
},
footer: {
repo: "GitHub repository",
license: "Apache-2.0 License",
+12
View File
@@ -56,6 +56,18 @@ export const zh = {
backHome: "返回文档首页",
},
search: {
open: "搜索文档",
label: "搜索文档",
placeholder: "搜索标题、章节和正文",
start: "输入关键词搜索当前语言的全部文档",
noResults: "没有找到相关文档",
noResultsHint: "请尝试更短或不同的关键词",
results: "搜索结果",
close: "关闭搜索",
keyboardHint: "使用上下方向键选择,按回车打开",
},
footer: {
repo: "GitHub 仓库",
license: "Apache-2.0 License",
+44 -4
View File
@@ -4,11 +4,12 @@
* the site works under the GitHub Pages subpath ("/<repo>/docs/"); scroll restores to
* top on route change (hash targets excluded).
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { BrowserRouter, Outlet, Route, Routes, useLocation } from "react-router";
import { Nav } from "./components/nav";
import { Sidebar } from "./components/sidebar";
import { Footer } from "./components/footer";
import { DocSearchDialog } from "./components/doc-search-dialog";
import { DocPage } from "./pages/doc-page";
/**
@@ -22,6 +23,36 @@ let handledLocationKey = "";
function Layout() {
const { pathname, hash, key } = useLocation();
const [menuOpen, setMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
const searchReturnFocusRef = useRef<HTMLElement | null>(null);
const openSearch = useCallback(
(trigger?: HTMLElement) => {
const activeElement =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
searchReturnFocusRef.current = menuOpen ? menuButtonRef.current : (trigger ?? activeElement);
setMenuOpen(false);
setSearchOpen(true);
},
[menuOpen],
);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key.toLowerCase() === "k" &&
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
!event.shiftKey
) {
event.preventDefault();
if (!searchOpen) openSearch();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [openSearch, searchOpen]);
// Genuine route change: jump to top; with a hash scroll to the target once it is
// in the DOM. Also close the mobile sidebar.
@@ -42,18 +73,22 @@ function Layout() {
return (
<div className="flex min-h-full flex-col">
<Nav menuOpen={menuOpen} onToggleMenu={() => setMenuOpen((v) => !v)} />
<Nav
menuOpen={menuOpen}
onToggleMenu={() => setMenuOpen((v) => !v)}
menuButtonRef={menuButtonRef}
/>
{menuOpen && (
<div className="anim-fade fixed inset-x-0 top-14 bottom-0 z-30 overflow-y-auto border-t border-gray-200 bg-white px-6 py-6 lg:hidden dark:border-gray-800 dark:bg-gray-950">
<Sidebar onNavigate={() => setMenuOpen(false)} />
<Sidebar onNavigate={() => setMenuOpen(false)} onOpenSearch={openSearch} />
</div>
)}
<div className="mx-auto w-full max-w-7xl flex-1 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]">
<aside className="hidden border-r border-gray-200 lg:block dark:border-gray-800">
<div className="sticky top-14 max-h-[calc(100vh-3.5rem)] overflow-y-auto px-4 py-8 pl-6">
<Sidebar />
<Sidebar onOpenSearch={openSearch} />
</div>
</aside>
<main className="min-w-0">
@@ -62,6 +97,11 @@ function Layout() {
</div>
<Footer />
<DocSearchDialog
open={searchOpen}
onClose={() => setSearchOpen(false)}
returnFocusRef={searchReturnFocusRef}
/>
</div>
);
}
+270
View File
@@ -0,0 +1,270 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { DocPage } from "../src/lib/docs";
import { parseFrontmatter } from "../src/lib/frontmatter";
import { DOC_SLUGS } from "../src/lib/nav";
import {
buildSearchRecords,
createSearchSnippet,
findSearchMatchRanges,
markdownToSearchText,
normalizeSearchText,
searchRecords,
} from "../src/lib/search";
import { getSearchShortcutLabel } from "../src/lib/shortcut";
const docs: DocPage[] = [
{
slug: "introduction",
lang: "zh",
title: "产品介绍",
description: "",
body: [
"PenguinHarness 是一个开源的 AI Agent Harness。",
"",
"## 产品组成",
"",
"Web App 提供多 Session 对话和 Trace 观测。",
"",
"### 模型网关",
"",
"统一接入在线与本地模型。",
].join("\n"),
},
{
slug: "server-api",
lang: "en",
title: "Server API",
description: "",
body: ["## Predict", "", "Call `client.predict` to stream an agent response."].join("\n"),
},
{
slug: "configuration",
lang: "en",
title: "Configuration",
description: "Environment variables and configuration files.",
body: ["## Vault", "", "Store secrets outside the project."].join("\n"),
},
];
const contentDir = join(__dirname, "..", "content");
const contentFiles = ["zh", "en"].flatMap((lang) => DOC_SLUGS.map((slug) => `${slug}.${lang}.md`));
describe("docs search", () => {
it("normalizes width, case, and whitespace", () => {
expect(normalizeSearchText(" PENGUIN Harness ")).toBe("penguin harness");
});
it("maps normalized matches back to their original display text", () => {
const text = "使用 Client.Predict 调用本地模型";
const ranges = findSearchMatchRanges(text, "client.predict 模型");
expect(ranges.map(({ start, end }) => text.slice(start, end))).toEqual([
"Client.Predict",
"模型",
]);
});
it("uses locale-independent case folding for English documentation", () => {
expect(normalizeSearchText("INTERFACES INPUT")).toBe("interfaces input");
});
it("maps composed queries to decomposed source graphemes", () => {
const text = "Cafe\u0301 configuration";
const ranges = findSearchMatchRanges(text, "café");
expect(ranges.map(({ start, end }) => text.slice(start, end))).toEqual(["Cafe\u0301"]);
});
it("merges overlapping highlight terms", () => {
expect(findSearchMatchRanges("PenguinHarness", "penguin penguinharness")).toEqual([
{ start: 0, end: 14 },
]);
});
it("keeps link labels and inline code while removing Markdown syntax", () => {
expect(markdownToSearchText("Use [`client.predict`](/api) with **streaming**.")).toBe(
"Use client.predict with streaming.",
);
});
it("preserves identifiers and angle-bracket placeholders inside code", () => {
const markdown = [
"Use `event_msg`, `PENGUIN_HOME`, and `agent_state/skills/<name>/`.",
"",
"```ts",
"listTools(): Promise<ToolDefinition[]>;",
'const type = "partial_tool_call_output";',
"```",
].join("\n");
expect(markdownToSearchText(markdown)).toBe(
'Use event_msg, PENGUIN_HOME, and agent_state/skills/<name>/. listTools(): Promise<ToolDefinition[]>; const type = "partial_tool_call_output";',
);
});
it("removes paired formatting without changing literal identifier punctuation", () => {
expect(
markdownToSearchText(
"**Bold** __strong__ _italic_ ~~old~~ context_engine foo__bar__baz partial_* model.* ~/.penguin <name> <https://example.com>",
),
).toBe(
"Bold strong italic old context_engine foo__bar__baz partial_* model.* ~/.penguin <name> https://example.com",
);
});
it("flattens Markdown tables without changing literal pipes in prose", () => {
expect(
markdownToSearchText(
["Keep A|B searchable.", "", "| Name | Value |", "| --- | --- |", "| alpha | beta |"].join(
"\n",
),
),
).toBe("Keep A|B searchable. Name Value alpha beta");
});
it("finds documented identifiers by their exact spelling", () => {
const codeDocs: DocPage[] = [
{
slug: "interfaces",
lang: "en",
title: "Interfaces",
description: "",
body: [
"## Environment",
"",
"Returns `Promise<ToolDefinition[]>` and emits `partial_tool_call_output`.",
].join("\n"),
},
];
const records = buildSearchRecords(codeDocs);
expect(searchRecords(records, "Promise<ToolDefinition[]>")).toHaveLength(1);
expect(searchRecords(records, "partial_tool_call_output")).toHaveLength(1);
});
it("finds identifier headings from the real OmniMessage documentation", () => {
for (const lang of ["en", "zh"] as const) {
const { meta, body } = parseFrontmatter(
readFileSync(join(contentDir, `omni-message.${lang}.md`), "utf8"),
);
const records = buildSearchRecords([
{
slug: "omni-message",
lang,
title: meta.title ?? "OmniMessage",
description: meta.description ?? "",
body,
},
]);
for (const heading of ["session_meta", "model_msg", "event_msg", "stop_reason"]) {
expect(
searchRecords(records, heading, 50).some((result) =>
result.record.heading.startsWith(heading),
),
`${heading} should resolve to its real ${lang} heading`,
).toBe(true);
}
}
});
it("preserves visible underscore identifiers across every documentation file", () => {
for (const file of contentFiles) {
const { body } = parseFrontmatter(readFileSync(join(contentDir, file), "utf8"));
const visibleMarkdown = body
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
const identifiers = new Set(
visibleMarkdown.match(/[\p{L}\p{N}]+(?:_[\p{L}\p{N}*]+)+/gu) ?? [],
);
const searchText = markdownToSearchText(body);
for (const identifier of identifiers) {
expect(searchText, `${file} should preserve ${identifier}`).toContain(identifier);
}
}
});
it("maps normalized snippet offsets back to grapheme-safe source boundaries", () => {
const text = `${"e\u0301".repeat(100)} TARGET value`;
const snippet = createSearchSnippet(text, "target");
const visibleSnippet = snippet.replace(/^…/, "");
expect(snippet).toContain("TARGET");
expect(visibleSnippet).not.toMatch(/^\u0301/u);
expect(findSearchMatchRanges(snippet, "target")).not.toHaveLength(0);
});
it("builds page and heading records with deep-link anchors", () => {
const records = buildSearchRecords(docs);
expect(records).toEqual(
expect.arrayContaining([
expect.objectContaining({
slug: "introduction",
anchor: "",
pageTitle: "产品介绍",
}),
expect.objectContaining({
slug: "introduction",
anchor: "产品组成",
heading: "产品组成",
}),
expect.objectContaining({
slug: "introduction",
anchor: "模型网关",
heading: "模型网关",
}),
]),
);
});
it("finds Chinese section content and ranks the matching section first", () => {
const results = searchRecords(buildSearchRecords(docs), "本地模型");
expect(results[0]?.record).toMatchObject({
slug: "introduction",
anchor: "模型网关",
});
});
it("finds punctuation-bearing API names inside inline code", () => {
const results = searchRecords(buildSearchRecords(docs), "client.predict");
expect(results[0]?.record).toMatchObject({
slug: "server-api",
anchor: "predict",
});
});
it("returns only the page record for a page-title-only query", () => {
const results = searchRecords(buildSearchRecords(docs), "产品介绍");
expect(results).toHaveLength(1);
expect(results[0]?.record.anchor).toBe("");
});
it("keeps a searchable page result when a document starts with a heading", () => {
const results = searchRecords(buildSearchRecords(docs), "Configuration");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
record: {
slug: "configuration",
anchor: "",
},
});
});
it("returns no results for a blank or unmatched query", () => {
const records = buildSearchRecords(docs);
expect(searchRecords(records, " ")).toEqual([]);
expect(searchRecords(records, "does-not-exist")).toEqual([]);
});
});
describe("docs search shortcut", () => {
it("uses the Command key label on Apple platforms", () => {
expect(getSearchShortcutLabel("MacIntel")).toBe("⌘ K");
expect(getSearchShortcutLabel("iPad")).toBe("⌘ K");
});
it("uses the Control key label on non-Apple platforms", () => {
expect(getSearchShortcutLabel("Win32")).toBe("Ctrl K");
expect(getSearchShortcutLabel("Linux x86_64")).toBe("Ctrl K");
});
});