diff --git a/web/app/[locale]/docs/page.tsx b/web/app/[locale]/docs/page.tsx index e0b619535..c6cffe2cf 100644 --- a/web/app/[locale]/docs/page.tsx +++ b/web/app/[locale]/docs/page.tsx @@ -1,9 +1,4 @@ -import Link from "next/link"; -import { - getTopicsByCategory, - REPO_DOCS_BASE, - type DocTopic, -} from "@/lib/docs-map"; +import { DocsSearch } from "@/components/docs-search"; import { buildPageMetadata } from "@/lib/page-meta"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { @@ -19,94 +14,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s }); } -/* ------------------------------------------------------------------ */ -/* Locale-aware strings */ -/* ------------------------------------------------------------------ */ - -const CATEGORY_LABELS: Record = { - "getting-started": { en: "Getting started", zh: "入门" }, - "core-concepts": { en: "Core concepts", zh: "核心概念" }, - reference: { en: "Reference", zh: "参考" }, - extending: { en: "Extending", zh: "扩展" }, - operations: { en: "Operations & community", zh: "运维与社区" }, -}; - -/* ------------------------------------------------------------------ */ - -function topicHref(topic: DocTopic, locale: string): string { - if (topic.hasPage) { - return `/${locale}/docs/${topic.slug}`; - } - const src = Array.isArray(topic.repoSource) ? topic.repoSource[0] : topic.repoSource; - return `${REPO_DOCS_BASE}/${src}`; -} - -function topicSources(topic: DocTopic): string[] { - return Array.isArray(topic.repoSource) ? topic.repoSource : [topic.repoSource]; -} - -/* ------------------------------------------------------------------ */ - export default async function DocsHubPage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; - const isZh = locale === "zh"; - const byCategory = getTopicsByCategory(); - - const body = ( -
- {[...byCategory.entries()].map(([cat, topics]) => ( -
-

- {isZh ? CATEGORY_LABELS[cat]?.zh ?? cat : CATEGORY_LABELS[cat]?.en ?? cat} -

-
- {topics.map((t) => { - const href = topicHref(t, locale); - const sources = topicSources(t); - const isExternal = !t.hasPage; - return ( - -
- - {isZh ? t.label.zh : t.label.en} - - {isExternal && ( - - )} -
-

- {isZh ? t.description.zh : t.description.en} -

-
- {sources.map((s, i) => ( - - {i > 0 && ", "} - {s} - - ))} -
- - ); - })} -
-
- ))} - - {/* Parapgraph about the docs approach */} -
-

- {isZh - ? "§ 标记的条目在 CodeWhale 网站上有独立页面;↗ 标记的条目链接到 GitHub 仓库中的源文档。所有内容来源于 docs/ 目录下的 40+ 篇 Markdown 文档,通过 docs-map.ts 注册表维护。" - : "Entries marked § have dedicated pages on codewhale.net; entries marked ↗ link to source documents in the GitHub repository. All content is sourced from 40+ Markdown documents in the docs/ directory, maintained through the docs-map.ts registry."} -

-
-
- ); - - return body; + return ; } diff --git a/web/app/[locale]/faq/page.tsx b/web/app/[locale]/faq/page.tsx index f259859e4..9de158b15 100644 --- a/web/app/[locale]/faq/page.tsx +++ b/web/app/[locale]/faq/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { Seal } from "@/components/seal"; +import { FaqSearch } from "@/components/faq-search"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; @@ -732,34 +733,7 @@ export default async function FaqPage({ params }: { params: Promise<{ locale: st
-
- {items.map((item, i) => ( -
- - - {String(i + 1).padStart(2, "0")} - - {item.q} - + - -
-
- {item.a} -
- {item.sources && item.sources.length > 0 && ( -
- - {isZh ? "来源" : "Sources"}: - - {item.sources.map((s) => ( - {s} - ))} -
- )} -
-
- ))} -
+

diff --git a/web/app/globals.css b/web/app/globals.css index 59f2569b7..180f1a0a4 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -267,6 +267,31 @@ code.inline { border-radius: 2px; } + +/* ---------- search ---------- */ +.search-input { + font-family: var(--font-body), system-ui, sans-serif; + font-size: 1rem; + padding: 0.75rem 2.5rem 0.75rem 1rem; + background: var(--paper); + color: var(--ink); + border: 1px solid var(--hairline); + transition: border-color 180ms ease, box-shadow 180ms ease; +} +.search-input::placeholder { color: var(--ink-mute); } +.search-input:focus { + outline: none; + border-color: var(--indigo); + box-shadow: 0 0 0 3px var(--indigo-pale); +} + +mark.search-highlight { + background: var(--indigo-pale); + color: var(--indigo-deep); + padding: 0.02em 0.08em; + border-radius: 2px; +} + /* ---------- nav link ---------- */ .nav-link { font-family: var(--font-mono), monospace; diff --git a/web/components/docs-search.tsx b/web/components/docs-search.tsx new file mode 100644 index 000000000..6fc595b73 --- /dev/null +++ b/web/components/docs-search.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useState, useMemo, useRef, useCallback, useEffect } from "react"; +import Link from "next/link"; +import { DOC_TOPICS, REPO_DOCS_BASE, type DocTopic } from "@/lib/docs-map"; +import { docTopicHaystack } from "@/lib/search-utils"; + +/* ------------------------------------------------------------------ */ +/* Locale-aware strings */ +/* ------------------------------------------------------------------ */ + +const CATEGORY_LABELS: Record = { + "getting-started": { en: "Getting started", zh: "入门" }, + "core-concepts": { en: "Core concepts", zh: "核心概念" }, + reference: { en: "Reference", zh: "参考" }, + extending: { en: "Extending", zh: "扩展" }, + operations: { en: "Operations & community", zh: "运维与社区" }, +}; + +/* ------------------------------------------------------------------ */ +/* Link / source helpers (mirrored from the original page.tsx) */ +/* ------------------------------------------------------------------ */ + +function topicHref(topic: DocTopic, locale: string): string { + if (topic.hasPage) { + return `/${locale}/docs/${topic.slug}`; + } + const src = Array.isArray(topic.repoSource) ? topic.repoSource[0] : topic.repoSource; + return `${REPO_DOCS_BASE}/${src}`; +} + +function topicSources(topic: DocTopic): string[] { + return Array.isArray(topic.repoSource) ? topic.repoSource : [topic.repoSource]; +} + +/** + * Build a single lowercase haystack string for fuzzy matching. + * Delegates to the shared search-utils for testability. + * Includes both EN and ZH text so a user can search in either language + * regardless of the active locale. + */ +const topicHaystack = docTopicHaystack; + +/* ------------------------------------------------------------------ */ +/* Highlight helper */ +/* ------------------------------------------------------------------ */ + +function highlight(text: string, query: string): React.ReactNode { + const q = query.trim().toLowerCase(); + if (!q) return text; + const lower = text.toLowerCase(); + const idx = lower.indexOf(q); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + q.length)} + {text.slice(idx + q.length)} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Topic card */ +/* ------------------------------------------------------------------ */ + +function TopicCard({ + topic, + locale, + query, +}: { + topic: DocTopic; + locale: string; + query: string; +}) { + const isZh = locale === "zh"; + const href = topicHref(topic, locale); + const sources = topicSources(topic); + const isExternal = !topic.hasPage; + + return ( + +

+ + {highlight(isZh ? topic.label.zh : topic.label.en, query)} + + {isExternal && ( + + )} +
+

+ {highlight(isZh ? topic.description.zh : topic.description.en, query)} +

+
+ {sources.map((s, i) => ( + + {i > 0 && ", "} + {highlight(s, query)} + + ))} +
+ + ); +} + +/* ------------------------------------------------------------------ */ +/* Main component */ +/* ------------------------------------------------------------------ */ + +export function DocsSearch({ locale }: { locale: string }) { + const isZh = locale === "zh"; + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + // Precompute haystacks once. + const haystacks = useMemo(() => DOC_TOPICS.map(topicHaystack), []); + + // Filter topics by query. + const filteredTopics = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return DOC_TOPICS; + return DOC_TOPICS.filter((_, i) => haystacks[i].includes(q)); + }, [query, haystacks]); + + // Group filtered topics by category (preserve DOC_TOPICS order). + const grouped = useMemo(() => { + const map = new Map(); + for (const t of filteredTopics) { + const group = map.get(t.category) ?? []; + group.push(t); + map.set(t.category, group); + } + return map; + }, [filteredTopics]); + + // Keyboard shortcut: focus search on "/". + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { + e.preventDefault(); + inputRef.current?.focus(); + } + }, []); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + const total = DOC_TOPICS.length; + const matched = filteredTopics.length; + const hasQuery = query.trim().length > 0; + + return ( +
+ {/* Search bar */} +
+
+ setQuery(e.target.value)} + placeholder={ + isZh + ? "搜索文档…(按 / 快速聚焦)" + : "Search docs… (press / to focus)" + } + className="search-input w-full" + aria-label={isZh ? "搜索文档" : "Search documentation"} + /> + {hasQuery && ( + + )} +
+ {hasQuery && ( +
+ {matched > 0 + ? isZh + ? `${matched} / ${total} 篇文档匹配 "${query.trim()}"` + : `${matched} of ${total} docs match "${query.trim()}"` + : isZh + ? `未找到匹配 "${query.trim()}" 的文档` + : `No docs match "${query.trim()}"`} +
+ )} +
+ + {/* Results */} + {matched > 0 ? ( +
+ {[...grouped.entries()].map(([cat, topics]) => ( +
+

+ {isZh ? CATEGORY_LABELS[cat]?.zh ?? cat : CATEGORY_LABELS[cat]?.en ?? cat} +

+
+ {topics.map((t) => ( + + ))} +
+
+ ))} +
+ ) : ( +
+

+ {isZh ? "未找到结果" : "No results found"} +

+

+ {isZh + ? "尝试使用不同的关键字,或浏览 GitHub 上的完整文档。" + : "Try a different keyword, or browse the full docs on GitHub."} +

+ + {isZh ? "GitHub 文档目录 ↗" : "GitHub docs directory ↗"} + +
+ )} + + {/* Footer note (only when not searching) */} + {!hasQuery && ( +
+

+ {isZh + ? "§ 标记的条目在 CodeWhale 网站上有独立页面;↗ 标记的条目链接到 GitHub 仓库中的源文档。所有内容来源于 docs/ 目录下的 40+ 篇 Markdown 文档,通过 docs-map.ts 注册表维护。" + : "Entries marked § have dedicated pages on codewhale.net; entries marked ↗ link to source documents in the GitHub repository. All content is sourced from 40+ Markdown documents in the docs/ directory, maintained through the docs-map.ts registry."} +

+
+ )} +
+ ); +} diff --git a/web/components/faq-search.tsx b/web/components/faq-search.tsx new file mode 100644 index 000000000..bd6f89a56 --- /dev/null +++ b/web/components/faq-search.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useState, useMemo, useRef, useCallback, useEffect } from "react"; + +export interface FaqSearchItem { + q: string; + a: React.ReactNode; + sources?: string[]; +} + +/* ------------------------------------------------------------------ */ +/* Text extraction from React nodes for full-text matching */ +/* ------------------------------------------------------------------ */ + +function extractText(node: React.ReactNode): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(extractText).join(" "); + if (typeof node === "object" && "props" in node) { + const props = (node as { props?: { children?: React.ReactNode } }).props; + return props ? extractText(props.children) : ""; + } + return ""; +} + +/* ------------------------------------------------------------------ */ +/* Highlight helper */ +/* ------------------------------------------------------------------ */ + +function highlight(text: string, query: string): React.ReactNode { + const q = query.trim().toLowerCase(); + if (!q) return text; + const lower = text.toLowerCase(); + const idx = lower.indexOf(q); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + q.length)} + {text.slice(idx + q.length)} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function FaqSearch({ + items, + locale, +}: { + items: FaqSearchItem[]; + locale: string; +}) { + const isZh = locale === "zh"; + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + // Precompute haystack for each item (question + answer text + sources). + const haystacks = useMemo( + () => + items.map((item) => { + const parts = [ + item.q, + extractText(item.a), + ...(item.sources ?? []), + ]; + return parts.join(" ").toLowerCase(); + }), + [items], + ); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return items.map((item, i) => ({ item, i })); + return items + .map((item, i) => ({ item, i })) + .filter(({ i }) => haystacks[i].includes(q)); + }, [query, haystacks, items]); + + // Keyboard shortcut: focus search on "/". + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { + e.preventDefault(); + inputRef.current?.focus(); + } + }, []); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + const total = items.length; + const matched = filtered.length; + const hasQuery = query.trim().length > 0; + + return ( + <> + {/* Search bar */} +
+
+ setQuery(e.target.value)} + placeholder={ + isZh + ? "搜索常见问题…(按 / 快速聚焦)" + : "Search FAQ… (press / to focus)" + } + className="search-input w-full" + aria-label={isZh ? "搜索常见问题" : "Search FAQ"} + /> + {hasQuery && ( + + )} +
+ {hasQuery && ( +
+ {matched > 0 + ? isZh + ? `${matched} / ${total} 个问题匹配 "${query.trim()}"` + : `${matched} of ${total} questions match "${query.trim()}"` + : isZh + ? `未找到匹配 "${query.trim()}" 的问题` + : `No questions match "${query.trim()}"`} +
+ )} +
+ + {/* FAQ list */} + {matched > 0 ? ( +
+ {filtered.map(({ item, i }) => ( +
+ + + {String(i + 1).padStart(2, "0")} + + + {highlight(item.q, query)} + + + + +
+
+ {item.a} +
+ {item.sources && item.sources.length > 0 && ( +
+ + {isZh ? "来源" : "Sources"}: + + {item.sources.map((s) => ( + {s} + ))} +
+ )} +
+
+ ))} +
+ ) : ( +
+

+ {isZh ? "未找到结果" : "No results found"} +

+

+ {isZh + ? "尝试使用不同的关键字。" + : "Try a different keyword."} +

+
+ )} + + ); +} diff --git a/web/lib/facts.generated.ts b/web/lib/facts.generated.ts index 3d8eb23df..56bd3ff98 100644 --- a/web/lib/facts.generated.ts +++ b/web/lib/facts.generated.ts @@ -18,7 +18,7 @@ export interface RepoFacts { } export const FACTS: RepoFacts = { - "generatedAt": "2026-07-10T00:34:57.187Z", + "generatedAt": "2026-07-14T03:21:25.297Z", "version": "0.8.68", "crates": [ "agent", @@ -211,7 +211,7 @@ export const FACTS: RepoFacts = { ], "defaultModel": "deepseek-v4-pro", "nodeEngines": ">=18", - "toolCount": 80, + "toolCount": 85, "license": "MIT", "latestRelease": null }; diff --git a/web/lib/search-utils.test.ts b/web/lib/search-utils.test.ts new file mode 100644 index 000000000..f1f7dc71d --- /dev/null +++ b/web/lib/search-utils.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + docTopicHaystack, + filterDocTopics, + normalizeQuery, + matches, +} from "./search-utils"; +import { DOC_TOPICS } from "./docs-map"; + +describe("normalizeQuery", () => { + it("trims and lowercases", () => { + expect(normalizeQuery(" Hello World ")).toBe("hello world"); + }); + + it("returns empty string for whitespace-only input", () => { + expect(normalizeQuery(" ")).toBe(""); + }); +}); + +describe("matches", () => { + it("returns true for empty query (shows everything)", () => { + expect(matches("anything", "")).toBe(true); + expect(matches("anything", " ")).toBe(true); + }); + + it("does case-insensitive substring matching", () => { + expect(matches("Install Guide", "install")).toBe(true); + expect(matches("install guide", "INSTALL")).toBe(true); + expect(matches("Configuration", "config")).toBe(true); + }); + + it("returns false when no match", () => { + expect(matches("Install", "docker")).toBe(false); + }); +}); + +describe("docTopicHaystack", () => { + it("includes the topic id and slug", () => { + const install = DOC_TOPICS.find((t) => t.id === "install")!; + const hay = docTopicHaystack(install); + expect(hay).toContain("install"); + }); + + it("includes both EN and ZH labels", () => { + const mcp = DOC_TOPICS.find((t) => t.id === "mcp")!; + const hay = docTopicHaystack(mcp); + expect(hay).toContain("mcp"); + // ZH label is also "MCP" but description has Chinese + expect(hay).toContain("stdio"); + expect(hay).toContain("工具"); // tools in Chinese description + }); + + it("includes source file paths", () => { + const config = DOC_TOPICS.find((t) => t.id === "configuration")!; + const hay = docTopicHaystack(config); + expect(hay).toContain("docs/configuration.md"); + expect(hay).toContain("docs/legacy_paths.md"); + }); + + it("includes category name in both locales", () => { + const install = DOC_TOPICS.find((t) => t.id === "install")!; + const hay = docTopicHaystack(install); + expect(hay).toContain("getting-started"); + expect(hay).toContain("入门"); // ZH for getting-started + }); +}); + +describe("filterDocTopics", () => { + it("returns all indices when query is empty", () => { + const result = filterDocTopics(DOC_TOPICS, ""); + expect(result.length).toBe(DOC_TOPICS.length); + }); + + it("returns all indices when query is whitespace", () => { + const result = filterDocTopics(DOC_TOPICS, " "); + expect(result.length).toBe(DOC_TOPICS.length); + }); + + it("filters by English keyword", () => { + const result = filterDocTopics(DOC_TOPICS, "install"); + const ids = result.map((i) => DOC_TOPICS[i].id); + expect(ids).toContain("install"); + expect(ids.length).toBeGreaterThanOrEqual(1); + }); + + it("filters by Chinese keyword", () => { + const result = filterDocTopics(DOC_TOPICS, "沙箱"); // sandbox + const ids = result.map((i) => DOC_TOPICS[i].id); + expect(ids).toContain("sandbox"); + }); + + it("filters by source file name", () => { + const result = filterDocTopics(DOC_TOPICS, "configuration.md"); + const ids = result.map((i) => DOC_TOPICS[i].id); + expect(ids).toContain("configuration"); + }); + + it("filters by category", () => { + const result = filterDocTopics(DOC_TOPICS, "extending"); + const ids = result.map((i) => DOC_TOPICS[i].id); + // extending category includes mcp, hooks, runtime-api + expect(ids).toContain("mcp"); + expect(ids).toContain("hooks"); + expect(ids).toContain("runtime-api"); + }); + + it("is case-insensitive", () => { + const lower = filterDocTopics(DOC_TOPICS, "mcp"); + const upper = filterDocTopics(DOC_TOPICS, "MCP"); + expect(lower).toEqual(upper); + }); + + it("returns empty array for gibberish query", () => { + const result = filterDocTopics(DOC_TOPICS, "zzzzzzz_nonexistent"); + expect(result).toEqual([]); + }); + + it("matches partial keywords", () => { + const result = filterDocTopics(DOC_TOPICS, "tool"); + const ids = result.map((i) => DOC_TOPICS[i].id); + // "tool" should match "tools" topic (id contains "tool") + expect(ids).toContain("tools"); + }); + + it("matches across locales (EN query matches ZH content)", () => { + // "安装" is the ZH label for "install" + const result = filterDocTopics(DOC_TOPICS, "安装"); + const ids = result.map((i) => DOC_TOPICS[i].id); + expect(ids).toContain("install"); + }); +}); diff --git a/web/lib/search-utils.ts b/web/lib/search-utils.ts new file mode 100644 index 000000000..0428e38de --- /dev/null +++ b/web/lib/search-utils.ts @@ -0,0 +1,66 @@ +/** + * search-utils.ts — shared keyword-search utilities for docs and FAQ. + * + * Pure functions extracted from the client components so they can be unit-tested + * without a DOM. Used by DocsSearch and FaqSearch. + */ + +import type { DocTopic } from "./docs-map"; + +const CATEGORY_LABELS: Record = { + "getting-started": { en: "Getting started", zh: "入门" }, + "core-concepts": { en: "Core concepts", zh: "核心概念" }, + reference: { en: "Reference", zh: "参考" }, + extending: { en: "Extending", zh: "扩展" }, + operations: { en: "Operations & community", zh: "运维与社区" }, +}; + +/** + * Build a lowercase haystack string for a DocTopic, searching across both + * locales, source files, category name, and id/slug. + */ +export function docTopicHaystack(t: DocTopic): string { + const sources = Array.isArray(t.repoSource) ? t.repoSource : [t.repoSource]; + const parts = [ + t.id, + t.slug, + t.label.en, + t.label.zh, + t.description.en, + t.description.zh, + ...sources, + t.category, + CATEGORY_LABELS[t.category]?.en ?? "", + CATEGORY_LABELS[t.category]?.zh ?? "", + ]; + return parts.join(" ").toLowerCase(); +} + +/** + * Filter DocTopics by keyword query. Returns indices into the input array. + * Empty/whitespace query returns all indices. + */ +export function filterDocTopics(topics: DocTopic[], query: string): number[] { + const q = query.trim().toLowerCase(); + if (!q) return topics.map((_, i) => i); + return topics + .map((t, i) => ({ i, hay: docTopicHaystack(t) })) + .filter(({ hay }) => hay.includes(q)) + .map(({ i }) => i); +} + +/** + * Normalize a query for matching. + */ +export function normalizeQuery(query: string): string { + return query.trim().toLowerCase(); +} + +/** + * Check whether a query matches a haystack (case-insensitive substring). + */ +export function matches(haystack: string, query: string): boolean { + const q = normalizeQuery(query); + if (!q) return true; + return haystack.toLowerCase().includes(q); +}