diff --git a/packages/docs/src/components/doc-search-dialog.tsx b/packages/docs/src/components/doc-search-dialog.tsx new file mode 100644 index 0000000..f4cf00d --- /dev/null +++ b/packages/docs/src/components/doc-search-dialog.tsx @@ -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( + + {text.slice(range.start, range.end)} + , + ); + 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; +}) { + const { locale } = useLocale(); + const navigate = useNavigate(); + const inputRef = useRef(null); + const dialogRef = useRef(null); + const activeResultRef = useRef(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 => 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) => { + 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) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (event.key !== "Tab") return; + const focusable = Array.from( + dialogRef.current?.querySelectorAll( + '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) => { + if (event.target === event.currentTarget) onClose(); + }; + + if (!open) return null; + + const activeResultId = results[activeIndex] ? `doc-search-result-${activeIndex}` : undefined; + + return ( +
+
+
+ + + 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" + /> + +
+ +
+ {!query.trim() ? ( +
+ +

{S.search.start}

+
+ ) : results.length === 0 ? ( +
+

+ {S.search.noResults} +

+

+ {S.search.noResultsHint} +

+
+ ) : ( +
    + {results.map((result, index) => { + const active = index === activeIndex; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+ {S.search.keyboardHint} +
+
+
+ ); +} diff --git a/packages/docs/src/components/icons.tsx b/packages/docs/src/components/icons.tsx index 21c1f71..b6aa40e 100644 --- a/packages/docs/src/components/icons.tsx +++ b/packages/docs/src/components/icons.tsx @@ -99,6 +99,15 @@ export function ArrowRightIcon(props: IconProps) { ); } +export function SearchIcon(props: IconProps) { + return ( + + + + + ); +} + export function GlobeIcon(props: IconProps) { return ( diff --git a/packages/docs/src/components/nav.tsx b/packages/docs/src/components/nav.tsx index 3adddb8..9cb472b 100644 --- a/packages/docs/src/components/nav.tsx +++ b/packages/docs/src/components/nav.tsx @@ -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; +}) { const pillRef = useRef(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). */} {DOCS_NAV.map((section) => (

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"); + }); +});