diff --git a/packages/docs/src/lib/search.ts b/packages/docs/src/lib/search.ts
new file mode 100644
index 0000000..2ab063e
--- /dev/null
+++ b/packages/docs/src/lib/search.ts
@@ -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((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);
+}
diff --git a/packages/docs/src/lib/shortcut.ts b/packages/docs/src/lib/shortcut.ts
new file mode 100644
index 0000000..da42b63
--- /dev/null
+++ b/packages/docs/src/lib/shortcut.ts
@@ -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);
+}
diff --git a/packages/docs/src/lib/strings-en.ts b/packages/docs/src/lib/strings-en.ts
index 966c529..8733405 100644
--- a/packages/docs/src/lib/strings-en.ts
+++ b/packages/docs/src/lib/strings-en.ts
@@ -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",
diff --git a/packages/docs/src/lib/strings.ts b/packages/docs/src/lib/strings.ts
index 67a7d57..5a7ff7a 100644
--- a/packages/docs/src/lib/strings.ts
+++ b/packages/docs/src/lib/strings.ts
@@ -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",
diff --git a/packages/docs/src/router.tsx b/packages/docs/src/router.tsx
index f0c8b94..abedaf7 100644
--- a/packages/docs/src/router.tsx
+++ b/packages/docs/src/router.tsx
@@ -4,11 +4,12 @@
* the site works under the GitHub Pages subpath ("//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(null);
+ const searchReturnFocusRef = useRef(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 (
-
);
}
diff --git a/packages/docs/test/search.test.ts b/packages/docs/test/search.test.ts
new file mode 100644
index 0000000..c312d85
--- /dev/null
+++ b/packages/docs/test/search.test.ts
@@ -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//`.",
+ "",
+ "```ts",
+ "listTools(): Promise;",
+ 'const type = "partial_tool_call_output";',
+ "```",
+ ].join("\n");
+
+ expect(markdownToSearchText(markdown)).toBe(
+ 'Use event_msg, PENGUIN_HOME, and agent_state/skills//. listTools(): Promise; 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 ",
+ ),
+ ).toBe(
+ "Bold strong italic old context_engine foo__bar__baz partial_* model.* ~/.penguin 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` and emits `partial_tool_call_output`.",
+ ].join("\n"),
+ },
+ ];
+ const records = buildSearchRecords(codeDocs);
+
+ expect(searchRecords(records, "Promise")).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");
+ });
+});