From eba8f5b2ab24ba01dcb9f270ff73901fa972c07d Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Sun, 8 Mar 2026 10:59:33 +0900 Subject: [PATCH] docs: migrate search to Typesense and redesign docs (#8487) --- landing/.env.example | 10 +- landing/app/api/docs/search/route.ts | 180 --- landing/app/api/docs/static.json/route.ts | 7 + landing/app/docs/layout.tsx | 29 +- landing/app/docs/provider.tsx | 41 + landing/app/globals.css | 8 + landing/app/layout.tsx | 11 +- landing/components/ai-chat.tsx | 573 ++++++++ landing/components/docs/custom-sidebar.tsx | 4 +- landing/components/docs/docs-sidebar.tsx | 104 +- landing/components/floating-toolbar.tsx | 33 - .../landing/staggered-nav-files.tsx | 32 +- landing/components/search-dialog.tsx | 158 ++ landing/components/ui/textarea.tsx | 20 + landing/lib/export-search-indexes.ts | 45 + landing/package.json | 7 +- landing/scripts/sync-typesense.ts | 60 + pnpm-lock.yaml | 1307 +++++++++++++---- 18 files changed, 2095 insertions(+), 534 deletions(-) delete mode 100644 landing/app/api/docs/search/route.ts create mode 100644 landing/app/api/docs/static.json/route.ts create mode 100644 landing/app/docs/provider.tsx create mode 100644 landing/components/ai-chat.tsx delete mode 100644 landing/components/floating-toolbar.tsx create mode 100644 landing/components/search-dialog.tsx create mode 100644 landing/components/ui/textarea.tsx create mode 100644 landing/lib/export-search-indexes.ts create mode 100644 landing/scripts/sync-typesense.ts diff --git a/landing/.env.example b/landing/.env.example index a15524c574..9abf53a3f2 100644 --- a/landing/.env.example +++ b/landing/.env.example @@ -1,5 +1,13 @@ # GitHub API - community stats GITHUB_TOKEN= -# Inkeep API - docs search & AI chat +# Inkeep API - AI chat INKEEP_API_KEY= + +# Typesense - Docs search +NEXT_PUBLIC_TYPESENSE_SERVER_URL= +NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY= +TYPESENSE_ADMIN_API_KEY= + +# Next.js - public URL +NEXT_PUBLIC_URL=http://localhost:3000 diff --git a/landing/app/api/docs/search/route.ts b/landing/app/api/docs/search/route.ts deleted file mode 100644 index fd70234260..0000000000 --- a/landing/app/api/docs/search/route.ts +++ /dev/null @@ -1,180 +0,0 @@ -interface SearchResult { - id: string; - url: string; - type: "page" | "heading" | "text"; - content: string; - pageName?: string; -} - -interface InkeepDocument { - type: string; - record_type: string; - url: string; - title: string; - source?: { - type: string; - content: { type: string; text?: string }[]; - }; -} - -function toSlug(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .trim(); -} - -function toRelativePath(fullUrl: string): string { - try { - const parsed = new URL(fullUrl); - return parsed.pathname; - } catch { - return fullUrl; - } -} - -function extractHeadings(text: string): { level: number; title: string }[] { - const headings: { level: number; title: string }[] = []; - const regex = /^(#{1,6})\s+(.+)$/gm; - let match: RegExpExecArray | null; - while ((match = regex.exec(text)) !== null) { - headings.push({ - level: match[1].length, - title: match[2].trim(), - }); - } - return headings; -} - -function extractMatchingSnippet(text: string, query: string): string | null { - const terms = query.toLowerCase().split(/\s+/).filter(Boolean); - - // Find the first paragraph-like block that contains a query term - const blocks = text.split(/\n{2,}/); - for (const block of blocks) { - const blockLower = block.toLowerCase(); - // Skip headings and very short blocks - if (block.startsWith("#") || block.trim().length < 20) continue; - if (terms.some((t) => blockLower.includes(t))) { - // Return a trimmed snippet - const clean = block.replace(/[#*_`>\[\]]/g, "").trim(); - if (clean.length > 120) return `${clean.slice(0, 120)}...`; - return clean; - } - } - return null; -} - -export async function GET(request: Request) { - const url = new URL(request.url); - const query = url.searchParams.get("query"); - if (!query) return Response.json([]); - - try { - const response = await fetch("https://api.inkeep.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.INKEEP_API_KEY}`, - }, - body: JSON.stringify({ - model: "inkeep-rag", - messages: [{ role: "user", content: query }], - response_format: { type: "json_object" }, - }), - }); - - if (!response.ok) { - console.error("Inkeep RAG API error:", response.status); - return Response.json([]); - } - - const data = await response.json(); - const content = data.choices?.[0]?.message?.content; - if (!content) return Response.json([]); - - const parsed = JSON.parse(content); - const documents: InkeepDocument[] = parsed.content ?? []; - - const results: SearchResult[] = []; - const seenUrls = new Set(); - - for (const doc of documents) { - const docUrl = toRelativePath(doc.url); - - // Add page-level result (dedupe by base URL) - const baseUrl = docUrl.split("#")[0]; - if (!seenUrls.has(baseUrl)) { - seenUrls.add(baseUrl); - results.push({ - id: baseUrl, - url: baseUrl, - type: "page", - content: doc.title, - }); - } - - // Extract full text from source content blocks - const fullText = (doc.source?.content ?? []) - .filter((c) => c.type === "text" && c.text) - .map((c) => c.text!) - .join("\n\n"); - - if (!fullText) continue; - - // Extract headings and add as section results - const headings = extractHeadings(fullText); - const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean); - - for (const heading of headings) { - const headingLower = heading.title.toLowerCase(); - const matches = queryTerms.some((t) => headingLower.includes(t)); - if (!matches) continue; - - const slug = toSlug(heading.title); - const headingUrl = `${baseUrl}#${slug}`; - if (seenUrls.has(headingUrl)) continue; - seenUrls.add(headingUrl); - - results.push({ - id: headingUrl, - url: headingUrl, - type: "heading", - content: heading.title, - pageName: doc.title, - }); - } - - // Add a text snippet match if we find relevant content - const snippet = extractMatchingSnippet(fullText, query); - if (snippet) { - const snippetId = `${baseUrl}:snippet`; - if (!seenUrls.has(snippetId)) { - seenUrls.add(snippetId); - - // Link to the closest heading above the snippet, or the page - let bestAnchor = baseUrl; - for (const heading of headings) { - const slug = toSlug(heading.title); - bestAnchor = `${baseUrl}#${slug}`; - } - - results.push({ - id: snippetId, - url: bestAnchor, - type: "text", - content: snippet, - pageName: doc.title, - }); - } - } - } - - return Response.json(results.slice(0, 20)); - } catch (error) { - console.error("Search error:", error); - return Response.json([]); - } -} diff --git a/landing/app/api/docs/static.json/route.ts b/landing/app/api/docs/static.json/route.ts new file mode 100644 index 0000000000..54bea910d1 --- /dev/null +++ b/landing/app/api/docs/static.json/route.ts @@ -0,0 +1,7 @@ +import { exportSearchIndexes } from "@/lib/export-search-indexes"; + +export const revalidate = false; + +export async function GET() { + return Response.json(await exportSearchIndexes()); +} diff --git a/landing/app/docs/layout.tsx b/landing/app/docs/layout.tsx index 083ea5a7b3..f074f0ccbc 100644 --- a/landing/app/docs/layout.tsx +++ b/landing/app/docs/layout.tsx @@ -1,18 +1,20 @@ import { DocsLayout } from "fumadocs-ui/layouts/docs"; -import { RootProvider } from "fumadocs-ui/provider/next"; import type { ReactNode } from "react"; import { Suspense } from "react"; +import { AIChat, AIChatPanel, AIChatTrigger } from "@/components/ai-chat"; import { DocsSidebar } from "@/components/docs/docs-sidebar"; -import { FloatingToolbar } from "@/components/floating-toolbar"; import { source } from "@/lib/source"; +import type { PageEntry } from "./provider"; +import { DocsProvider } from "./provider"; + +const allPages: PageEntry[] = source.getPages().map((page) => ({ + name: page.data.title, + url: page.url, +})); export default function Layout({ children }: { children: ReactNode }) { return ( - + @@ -27,8 +29,17 @@ export default function Layout({ children }: { children: ReactNode }) { }} > {children} + + + + Ask AI + + + ⌘I + + + - - + ); } diff --git a/landing/app/docs/provider.tsx b/landing/app/docs/provider.tsx new file mode 100644 index 0000000000..a2684d3fc5 --- /dev/null +++ b/landing/app/docs/provider.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { RootProvider } from "fumadocs-ui/provider/next"; +import dynamic from "next/dynamic"; +import type { ReactNode } from "react"; +import { createContext, use } from "react"; + +const SearchDialog = dynamic(() => import("@/components/search-dialog"), { + ssr: false, +}); + +export interface PageEntry { + name: string; + url: string; +} + +export const PagesContext = createContext([]); + +export function usePages() { + return use(PagesContext); +} + +export function DocsProvider({ + pages, + children, +}: { + pages: PageEntry[]; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/landing/app/globals.css b/landing/app/globals.css index fb3f54bdc0..f57e028190 100644 --- a/landing/app/globals.css +++ b/landing/app/globals.css @@ -674,6 +674,14 @@ html:not([data-anchor-scrolling]) { } } +/* Hide scrollbar in AI Chat panel */ +.ai-chat-messages { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + /* Prevent scroll-chaining and hide scrollbars in TOC areas */ .docs-layout #nd-toc > div { overscroll-behavior-y: contain; diff --git a/landing/app/layout.tsx b/landing/app/layout.tsx index 0df9cf4cd1..8d78b488b9 100644 --- a/landing/app/layout.tsx +++ b/landing/app/layout.tsx @@ -5,7 +5,6 @@ import { Analytics } from "@vercel/analytics/next"; import type { Metadata } from "next"; import Script from "next/script"; import type { ReactNode } from "react"; -import { CommandMenuProvider } from "@/components/command-menu"; import { StaggeredNavFiles } from "@/components/landing/staggered-nav-files"; import { Providers } from "@/components/providers"; @@ -97,12 +96,10 @@ export default function RootLayout({ children }: { children: ReactNode }) { suppressHydrationWarning > - -
- - {children} -
-
+
+ + {children} +
diff --git a/landing/components/ai-chat.tsx b/landing/components/ai-chat.tsx new file mode 100644 index 0000000000..882a56f665 --- /dev/null +++ b/landing/components/ai-chat.tsx @@ -0,0 +1,573 @@ +"use client"; + +import type { UIMessage } from "@ai-sdk/react"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { Loader2, MessageCircleIcon, RefreshCw, Send, X } from "lucide-react"; +import type { ComponentProps, ReactNode, SyntheticEvent } from "react"; +import { + createContext, + use, + useCallback, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/utils"; +import { Markdown } from "./markdown"; +import { MessageFeedback } from "./message-feedback"; + +// ─── Context ───────────────────────────────────────────────────────────────── + +const AIChatContext = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; + chat: ReturnType; +} | null>(null); + +export function useAIChat() { + const ctx = use(AIChatContext); + if (!ctx) throw new Error("Missing "); + return ctx; +} + +function useChatContext() { + return useAIChat().chat; +} + +// ─── Root ──────────────────────────────────────────────────────────────────── + +const DEFAULT_PANEL_WIDTH = 400; +const MIN_PANEL_WIDTH = 320; +const MAX_PANEL_WIDTH = 640; + +const chatTransport = new DefaultChatTransport({ + api: "/api/docs/chat", +}); + +export function AIChat({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const chat = useChat({ + id: "ai-chat", + transport: chatTransport, + }); + + // Support ?askai= URL param + const handleAskAiParam = useEffectEvent(() => { + const params = new URLSearchParams(window.location.search); + const aiQuery = params.get("askai"); + if (aiQuery) { + setOpen(true); + void chat.sendMessage({ text: aiQuery }); + const newParams = new URLSearchParams(window.location.search); + newParams.delete("askai"); + const newUrl = newParams.toString() + ? `${window.location.pathname}?${newParams.toString()}` + : window.location.pathname; + window.history.replaceState({}, "", newUrl); + } + }); + + useEffect(() => { + handleAskAiParam(); + }, []); + + return ( + ({ chat, open, setOpen }), [chat, open])} + > + {children} + + ); +} + +// ─── Trigger ───────────────────────────────────────────────────────────────── + +export function AIChatTrigger({ + className, + ...props +}: ComponentProps<"button">) { + const { open, setOpen } = useAIChat(); + + return ( + + + ); +} + +// ─── Panel Messages ────────────────────────────────────────────────────────── + +function PanelMessages({ className, ...props }: ComponentProps<"div">) { + const { messages } = useChatContext(); + const containerRef = useRef(null); + const filtered = messages.filter((msg) => msg.role !== "system"); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + const scrollToBottom = () => { + container.scrollTo({ + top: container.scrollHeight, + behavior: "instant", + }); + }; + + // Observe size changes on all current and future children + const resizeObserver = new ResizeObserver(scrollToBottom); + const observeChildren = () => { + resizeObserver.disconnect(); + for (const child of container.children) { + resizeObserver.observe(child); + } + }; + + observeChildren(); + + // Re-attach when children are added/removed + const mutationObserver = new MutationObserver(observeChildren); + mutationObserver.observe(container, { childList: true }); + + // Prevent scroll chaining to body on desktop + const onWheel = (e: WheelEvent) => { + const { scrollTop, scrollHeight, clientHeight } = container; + const atTop = scrollTop <= 0 && e.deltaY < 0; + const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0; + if (atTop || atBottom) { + e.preventDefault(); + } + }; + container.addEventListener("wheel", onWheel, { passive: false }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + container.removeEventListener("wheel", onWheel); + }; + }, []); + + return ( +
+ {filtered.length === 0 ? ( +
+ +

Start a new chat below.

+
+ ) : ( +
+ {filtered.map((item, index) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Panel Input ───────────────────────────────────────────────────────────── + +function PanelInput() { + const { status, sendMessage, stop, setMessages, messages, regenerate } = + useChatContext(); + const [input, setInput] = useState(""); + const textareaRef = useRef(null); + const isLoading = status === "streaming" || status === "submitted"; + const inputDragRef = useRef<{ startY: number; startHeight: number } | null>( + null, + ); + const [inputMinHeight, setInputMinHeight] = useState(38); + + const adjustHeight = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + const maxH = window.innerHeight * 0.35; + el.style.height = "auto"; + el.style.height = `${Math.min(maxH, Math.max(el.scrollHeight, inputMinHeight))}px`; + }, [inputMinHeight]); + + useEffect(() => { + adjustHeight(); + }, [adjustHeight]); + + const onSubmit = (e?: SyntheticEvent) => { + e?.preventDefault(); + if (!input.trim() || isLoading) return; + void sendMessage({ text: input }); + setInput(""); + requestAnimationFrame(adjustHeight); + }; + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + inputDragRef.current = { startY: e.clientY, startHeight: inputMinHeight }; + const onMove = (ev: MouseEvent) => { + if (!inputDragRef.current) return; + const delta = inputDragRef.current.startY - ev.clientY; + setInputMinHeight( + Math.max(38, Math.min(400, inputDragRef.current.startHeight + delta)), + ); + }; + const onUp = () => { + inputDragRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.body.style.userSelect = "none"; + document.body.style.cursor = "row-resize"; + }, + [inputMinHeight], + ); + + return ( +
+ {/* Drag handle to resize input */} +
+
+
+
+
+
+