feat(docs): add document search (#147)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user