docs: migrate search to Typesense and redesign docs (#8487)

This commit is contained in:
Taesu
2026-03-08 12:15:02 +09:00
parent fd747c6738
commit eba8f5b2ab
18 changed files with 2095 additions and 534 deletions
+9 -1
View File
@@ -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
-180
View File
@@ -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<string>();
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([]);
}
}
@@ -0,0 +1,7 @@
import { exportSearchIndexes } from "@/lib/export-search-indexes";
export const revalidate = false;
export async function GET() {
return Response.json(await exportSearchIndexes());
}
+20 -9
View File
@@ -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 (
<RootProvider
search={{
enabled: false,
}}
>
<DocsProvider pages={allPages}>
<Suspense>
<DocsSidebar />
</Suspense>
@@ -27,8 +29,17 @@ export default function Layout({ children }: { children: ReactNode }) {
}}
>
{children}
<AIChat>
<AIChatPanel />
<AIChatTrigger>
<span className="text-sm text-muted-foreground">Ask AI</span>
<span className="h-5 w-px bg-foreground/10" />
<kbd className="inline-flex items-center gap-0.5 text-[10px] font-mono text-muted-foreground">
<span className="text-[11px]">&#8984;</span>I
</kbd>
</AIChatTrigger>
</AIChat>
</DocsLayout>
<FloatingToolbar />
</RootProvider>
</DocsProvider>
);
}
+41
View File
@@ -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<PageEntry[]>([]);
export function usePages() {
return use(PagesContext);
}
export function DocsProvider({
pages,
children,
}: {
pages: PageEntry[];
children: ReactNode;
}) {
return (
<PagesContext value={pages}>
<RootProvider
search={{
SearchDialog,
}}
>
{children}
</RootProvider>
</PagesContext>
);
}
+8
View File
@@ -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;
+4 -7
View File
@@ -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
>
<Providers>
<CommandMenuProvider>
<div className="relative min-h-dvh">
<StaggeredNavFiles />
{children}
</div>
</CommandMenuProvider>
<div className="relative min-h-dvh">
<StaggeredNavFiles />
{children}
</div>
</Providers>
<Analytics />
</body>
+573
View File
@@ -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<typeof useChat>;
} | null>(null);
export function useAIChat() {
const ctx = use(AIChatContext);
if (!ctx) throw new Error("Missing <AIChat />");
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 (
<AIChatContext
value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}
>
{children}
</AIChatContext>
);
}
// ─── Trigger ─────────────────────────────────────────────────────────────────
export function AIChatTrigger({
className,
...props
}: ComponentProps<"button">) {
const { open, setOpen } = useAIChat();
return (
<button
type="button"
data-state={open ? "open" : "closed"}
className={cn(
"fixed bottom-5 end-5 z-20 flex items-center gap-2.5 px-3.5 py-1.5 border bg-secondary/70 backdrop-blur-md shadow-lg transition-[translate,opacity]",
open && "translate-y-10 opacity-0",
className,
)}
onClick={() => setOpen(!open)}
{...props}
/>
);
}
// ─── Panel ───────────────────────────────────────────────────────────────────
export function AIChatPanel() {
const { open, setOpen } = useAIChat();
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
const [dragging, setDragging] = useState(false);
const startX = useRef(0);
const startWidth = useRef(panelWidth);
useAIChatHotKey();
// Mobile: lock body scroll when panel is open
useEffect(() => {
if (!open) return;
const mq = window.matchMedia("(max-width: 1023px)");
if (!mq.matches) return;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
if (!dragging) return;
const onMouseMove = (e: MouseEvent) => {
const delta = startX.current - e.clientX;
setPanelWidth(
Math.min(
MAX_PANEL_WIDTH,
Math.max(MIN_PANEL_WIDTH, startWidth.current + delta),
),
);
};
const onMouseUp = () => setDragging(false);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [dragging]);
if (!open) return null;
return (
<>
{/* Mobile overlay */}
<div
className="fixed inset-0 z-[200] backdrop-blur-xs bg-black/50 lg:hidden"
onClick={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
}}
/>
{/* Panel */}
<div
className={cn(
"overflow-hidden overscroll-contain z-200 bg-background text-foreground",
"[--ai-chat-width:400px]",
// Mobile
"max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:h-[80dvh] max-lg:border-t max-lg:shadow-[0_-8px_50px_0px_rgba(0,0,0,0.12)] max-lg:dark:shadow-[0_-8px_50px_0px_rgba(255,255,255,0.07)]",
// Desktop
"lg:fixed lg:top-0 lg:inset-e-0 lg:h-dvh lg:border-s lg:shadow-[-8px_0_24px_-4px_rgba(0,0,0,0.1)] lg:dark:shadow-[-8px_0_30px_-2px_rgba(0,0,0,0.7)]",
)}
style={
{
"--ai-chat-width": `${panelWidth}px`,
} as React.CSSProperties
}
>
{/* Resize handle (desktop) */}
<div
className="absolute top-0 bottom-0 left-0 w-1 cursor-col-resize hover:bg-foreground/10 active:bg-foreground/15 transition-colors hidden lg:block z-10"
onMouseDown={(e) => {
e.preventDefault();
startX.current = e.clientX;
startWidth.current = panelWidth;
setDragging(true);
}}
/>
<div className="flex flex-col size-full min-h-0 overflow-hidden p-2 pb-3 max-lg:h-full lg:p-3 lg:w-(--ai-chat-width)">
<PanelHeader />
<PanelMessages className="flex-1" />
<PanelInput />
</div>
</div>
{/* Full-screen overlay during drag */}
{dragging && <div className="fixed inset-0 z-[201] cursor-col-resize" />}
</>
);
}
// ─── Panel Header ────────────────────────────────────────────────────────────
function PanelHeader() {
const { setOpen } = useAIChat();
return (
<div className="flex items-center gap-2 border rounded-lg bg-foreground/2 p-2 mb-2">
<div className="flex-1">
<p className="text-xs font-medium mb-1">AI Chat</p>
<p className="text-[10px] text-muted-foreground">
Powered by{" "}
<a
href="https://inkeep.com"
target="_blank"
rel="noreferrer noopener"
className="underline hover:text-foreground transition-colors"
>
Inkeep AI
</a>
</p>
</div>
<button
type="button"
aria-label="Close"
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setOpen(false)}
>
<X className="size-4" />
</button>
</div>
);
}
// ─── Panel Messages ──────────────────────────────────────────────────────────
function PanelMessages({ className, ...props }: ComponentProps<"div">) {
const { messages } = useChatContext();
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={cn(
"ai-chat-messages overflow-y-auto overscroll-contain min-w-0 min-h-0 flex flex-col py-4",
className,
)}
style={{
maskImage:
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)",
}}
{...props}
>
{filtered.length === 0 ? (
<div className="size-full flex flex-col items-center justify-center text-center gap-2 text-sm text-muted-foreground/80 py-6">
<MessageCircleIcon
fill="currentColor"
stroke="none"
className="size-5"
/>
<p>Start a new chat below.</p>
</div>
) : (
<div className="flex flex-col px-1 gap-4">
{filtered.map((item, index) => (
<Message
key={item.id}
message={item}
messages={filtered}
messageIndex={index}
/>
))}
</div>
)}
</div>
);
}
// ─── Panel Input ─────────────────────────────────────────────────────────────
function PanelInput() {
const { status, sendMessage, stop, setMessages, messages, regenerate } =
useChatContext();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div className="relative">
{/* Drag handle to resize input */}
<div
onMouseDown={handleDragStart}
className="absolute -top-1.5 left-1/2 -translate-x-1/2 z-10 hidden lg:flex items-center justify-center w-8 h-3 cursor-row-resize group/drag"
>
<div className="w-8 h-[2px] rounded-full bg-foreground/20 dark:bg-white/20 group-hover/drag:bg-foreground/40 transition-colors" />
</div>
<div
className={cn(
"rounded-xl border transition-all duration-200",
"border-border/60 dark:border-white/8",
"bg-card/50 dark:bg-white/2",
"focus-within:border-foreground/15 dark:focus-within:border-white/12",
"focus-within:bg-background dark:focus-within:bg-white/3",
"focus-within:shadow-[0_0_0_1px_rgba(0,0,0,0.04)] dark:focus-within:shadow-[0_0_0_1px_rgba(255,255,255,0.03)]",
)}
>
<form onSubmit={onSubmit}>
<div className="flex items-end">
<textarea
ref={textareaRef}
id="nd-ai-input"
value={input}
onChange={(e) => {
setInput(e.target.value);
adjustHeight();
}}
onKeyDown={(e) => {
if (!e.shiftKey && e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
disabled={isLoading}
placeholder={isLoading ? "AI is answering..." : "Ask a question"}
autoFocus
rows={1}
style={{
height: Math.max(inputMinHeight, 38),
}}
className="flex-1 resize-none text-[13px] bg-transparent pl-3.5 pr-1.5 py-2.5 placeholder:text-muted-foreground focus:outline-none overflow-y-auto"
/>
<div className="shrink-0 pb-1.5 pr-1.5">
{isLoading ? (
<button
type="button"
onClick={stop}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-border text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-150 cursor-pointer"
title="Stop generating"
>
<Loader2 className="size-3.5 animate-spin" />
</button>
) : (
<button
type="submit"
disabled={input.trim().length === 0}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-full transition-all duration-150",
input.trim()
? "bg-foreground text-background hover:bg-foreground/90 cursor-pointer"
: "bg-muted/50 dark:bg-white/5 text-muted-foreground cursor-default",
)}
title="Send (Enter)"
>
<Send className="size-3.5" />
</button>
)}
</div>
</div>
</form>
</div>
<div className="flex items-center gap-1.5 p-1 empty:hidden">
{!isLoading &&
messages.length > 0 &&
messages.at(-1)?.role === "assistant" && (
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
onClick={() => regenerate()}
>
<RefreshCw className="size-3" />
Retry
</button>
)}
{messages.length > 0 && !isLoading && (
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMessages([])}
>
Clear Chat
</button>
)}
</div>
</div>
);
}
// ─── Message ─────────────────────────────────────────────────────────────────
const roleName: Record<string, string> = {
user: "you",
assistant: "BA bot",
};
function Message({
message,
messages,
messageIndex,
}: {
message: UIMessage;
messages: UIMessage[];
messageIndex: number;
}) {
const { status } = useChatContext();
const isLastAssistant =
message.role === "assistant" && messageIndex === messages.length - 1;
const isStreaming =
(status === "streaming" || status === "submitted") && isLastAssistant;
let markdown = "";
for (const part of message.parts ?? []) {
if (part.type === "text") {
markdown += part.text;
}
}
if (message.role === "assistant") {
// Remove Inkeep citation markers like (1), (2) etc.
markdown = markdown.replace(/\(\d+\)/g, "");
// Remove stray backslashes used as line breaks by Inkeep
markdown = markdown.replace(/^\s*\\\s*$/gm, "");
}
// Fix incomplete code blocks
const codeBlockCount = (markdown.match(/```/g) || []).length;
if (codeBlockCount % 2 !== 0) {
markdown += "\n```";
}
return (
<div>
<p
className={cn(
"mb-1 text-xs font-medium text-muted-foreground",
message.role === "assistant" && "text-foreground",
)}
>
{roleName[message.role] ?? "unknown"}
</p>
<div className="text-sm prose prose-sm max-w-none [&_pre]:!text-[11px]">
<Markdown text={markdown} />
</div>
{message.role === "assistant" && message.id && !isStreaming && (
<MessageFeedback
messageId={message.id}
userMessageId={
messageIndex > 0 ? messages[messageIndex - 1]?.id : undefined
}
content={markdown}
/>
)}
</div>
);
}
// ─── Hot Key ─────────────────────────────────────────────────────────────────
function useAIChatHotKey() {
const { open, setOpen } = useAIChat();
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
setOpen(false);
e.preventDefault();
}
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
setOpen(!open);
e.preventDefault();
}
});
useEffect(() => {
window.addEventListener("keydown", onKeyPress);
return () => window.removeEventListener("keydown", onKeyPress);
}, []);
}
+2 -2
View File
@@ -5,11 +5,11 @@ import type {
Item as PageTreeItem,
Separator as PageTreeSeparator,
} from "fumadocs-core/page-tree";
import { useSearchContext } from "fumadocs-ui/contexts/search";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { FC, ReactNode } from "react";
import { useState } from "react";
import { useCommandMenu } from "@/components/command-menu";
// ─── Item ───────────────────────────────────────────────────────────────────
@@ -111,7 +111,7 @@ export const CustomSeparator: FC<{ item: PageTreeSeparator }> = ({ item }) => {
// ─── Search Button (sidebar banner) ─────────────────────────────────────────
export const SearchButton: FC = () => {
const { setOpen } = useCommandMenu();
const { setOpenSearch: setOpen } = useSearchContext();
return (
<button
+71 -33
View File
@@ -1,14 +1,15 @@
"use client";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import { ChevronDownIcon, GitBranch, Search, Sparkles } from "lucide-react";
import { useSearchContext } from "fumadocs-ui/contexts/search";
import { ChevronDownIcon, GitBranch, Search } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { useCommandMenu } from "@/components/command-menu";
import type { ListItem } from "@/components/sidebar-content";
import { contents } from "@/components/sidebar-content";
import { ThemeToggle } from "@/components/theme-toggle";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
@@ -18,7 +19,7 @@ export function DocsSidebar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const branch = searchParams.get("branch");
const { setOpen, openAI } = useCommandMenu();
const { setOpenSearch } = useSearchContext();
const [currentOpen, setCurrentOpen] = useState(0);
const navRef = useRef<HTMLElement>(null);
@@ -75,30 +76,24 @@ export function DocsSidebar() {
<button
type="button"
className="flex w-full items-center gap-2 px-4 lg:px-7 py-2.5 border-b border-foreground/5 text-[12px] uppercase tracking-wider text-foreground/55 hover:text-foreground/80 transition-colors"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-2 px-4 py-2.5 border-b border-foreground/5 text-sm text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3 transition-colors"
onClick={() => setOpenSearch(true)}
>
<Search className="size-3.5 shrink-0" />
<Search className="size-4 shrink-0" />
<span className="truncate">Search</span>
<span
className="ml-auto flex items-center gap-1 shrink-0 text-foreground/55 hover:text-foreground/70 transition-colors"
onClick={(e) => {
e.stopPropagation();
openAI();
}}
onKeyDown={() => {}}
role="button"
tabIndex={-1}
>
<Sparkles className="size-3" />
<span className="text-[10px] normal-case tracking-normal">AI</span>
</span>
<kbd className="ml-auto inline-flex items-center gap-0.5 shrink-0 text-[10px] font-mono text-foreground/40 border border-foreground/10 rounded-md px-1.5 py-0.5">
<span className="text-[11px]">&#8984;</span>K
</kbd>
</button>
{/* Scrollable navigation area */}
<nav
ref={navRef}
className="flex-1 overflow-y-auto overflow-x-hidden pb-3 sidebar-scroll"
style={{
maskImage:
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 2rem), transparent 100%)",
}}
>
<MotionConfig
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
@@ -110,7 +105,6 @@ export function DocsSidebar() {
type="button"
className={cn(
"border-b border-foreground/6 w-full text-left flex gap-2 items-center px-4 py-2.5 transition-colors",
"lg:px-7",
"font-medium text-sm tracking-wider",
currentOpen === index
? "text-foreground bg-foreground/3"
@@ -152,6 +146,29 @@ export function DocsSidebar() {
</div>
</MotionConfig>
</nav>
{/* Footer: GitHub + Theme Toggle */}
<div className="flex items-center gap-1 p-2 border-t border-foreground/5 text-foreground/40">
<a
href="https://github.com/better-auth/better-auth"
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center justify-center size-8 hover:text-foreground/70 hover:bg-foreground/5 transition-colors"
aria-label="GitHub"
>
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
className="size-4"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</a>
<div className="ms-auto [&_button]:text-foreground/40 [&_button:hover]:text-foreground/70">
<ThemeToggle />
</div>
</div>
</motion.aside>
);
}
@@ -193,7 +210,7 @@ function BranchSwitcher() {
<div ref={ref} className="relative border-b border-foreground/10">
<button
type="button"
className="flex w-full items-center gap-2 px-4 lg:px-7 py-3 hover:bg-foreground/[0.02] transition-colors"
className="flex w-full items-center gap-2 px-4 py-3 hover:bg-foreground/[0.02] transition-colors"
onClick={() => setOpen((v) => !v)}
>
<GitBranch className="size-3.5 text-foreground/60 shrink-0" />
@@ -372,16 +389,37 @@ function SidebarItemWithSubpages({
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
className="overflow-hidden"
>
<div className="relative ml-7 lg:ml-11 pl-3 border-l border-foreground/20">
{item.subpages.map((subpage) => (
<SubpageLink
key={subpage.href}
href={`${subpage.href}${branchQuery}`}
active={pathname === subpage.href}
>
{subpage.title}
</SubpageLink>
))}
<div className="relative before:absolute before:left-[calc(1.75rem+0.75rem)] before:top-0 before:bottom-0 before:w-px before:bg-foreground/20">
{item.subpages.map((subpage, i) => {
if (subpage.group) {
return (
<div
key={`subgroup-${subpage.title}-${i}`}
className="flex flex-row items-center gap-2 pl-[calc(1.75rem+0.75rem+0.75rem)] pr-4 py-1.5 mt-1 first:mt-0"
>
<p className="text-[10px] text-foreground/45 uppercase tracking-wider">
{subpage.title}
</p>
<div className="grow h-px bg-border" />
</div>
);
}
if (!subpage.href) return null;
return (
<SubpageLink
key={subpage.href}
href={`${subpage.href}${branchQuery}`}
active={pathname === subpage.href}
icon={
subpage.icon ? (
<subpage.icon className="text-current" />
) : undefined
}
>
{subpage.title}
</SubpageLink>
);
})}
</div>
</motion.div>
)}
@@ -406,7 +444,7 @@ function SubpageLink({
href={href}
data-active={active || undefined}
className={cn(
"relative flex items-center pr-4 lg:pr-7 py-1 text-[13px] transition-all duration-150",
"relative flex items-center gap-1 pl-[calc(1.75rem+0.75rem+0.75rem)] pr-4 py-1 text-[13px] transition-all duration-150",
active
? "text-foreground bg-foreground/6"
: "text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3",
@@ -437,7 +475,7 @@ function SidebarLink({
href={href}
data-active={active || undefined}
className={`
relative flex items-center gap-2.5 px-4 lg:px-7 py-1 text-[14px] transition-all duration-150
relative flex items-center gap-2.5 px-4 py-1 text-[14px] transition-all duration-150
${
active
? "text-foreground bg-foreground/6"
-33
View File
@@ -1,33 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { useCommandMenu } from "@/components/command-menu";
import { ThemeToggle } from "@/components/theme-toggle";
export function FloatingToolbar() {
const { setOpen } = useCommandMenu();
return (
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.8, ease: "easeOut" }}
className="fixed bottom-5 right-5 z-[100] flex items-center border border-foreground/[0.06] bg-background/60 backdrop-blur-md"
>
<button
type="button"
onClick={() => setOpen(true)}
className="group flex items-center justify-center size-9 text-foreground/40 hover:text-foreground/70 transition-colors"
>
<kbd className="flex items-center gap-0.5 text-[10px] font-mono">
<span className="text-[11px]">&#8984;</span>K
</kbd>
<span className="sr-only">Command menu</span>
</button>
<span className="w-px h-3 bg-foreground/[0.08]" />
<div className="flex items-center justify-center size-9 [&_button]:text-foreground/40 [&_button:hover]:text-foreground/70">
<ThemeToggle />
</div>
</motion.div>
);
}
@@ -1,10 +1,11 @@
"use client";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import { ChevronDownIcon } from "lucide-react";
import { ChevronDownIcon, Search } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils";
import DarkPng from "../../public/branding/better-auth-logo-dark.png";
import WhitePng from "../../public/branding/better-auth-logo-light.png";
@@ -161,7 +162,7 @@ export function StaggeredNavFiles() {
>
<Link
href="/"
className="flex items-center gap-1 px-4 lg:px-7 py-3 transition-colors duration-150"
className="flex items-center gap-1 px-4 py-3 transition-colors duration-150"
>
<div className="flex flex-col gap-2 w-full">
<LogoContextMenu
@@ -191,11 +192,32 @@ export function StaggeredNavFiles() {
className="flex items-center gap-1 px-4 py-3 transition-colors duration-150"
>
<Logo />
<p className="select-none font-mono text-sm uppercase tracking-wider">
<p className="select-none font-mono text-base uppercase">
BETTER-AUTH.
</p>
</Link>
<div className="flex items-center">
<div className="flex items-center gap-1 pr-2">
{isDocs && (
<button
type="button"
onClick={() => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
bubbles: true,
}),
);
}}
className="flex items-center justify-center size-8 text-foreground/50 hover:text-foreground/80 transition-colors"
aria-label="Search"
>
<Search className="size-4" />
</button>
)}
<div className="flex items-center justify-center size-8 text-foreground/50 [&_button]:text-foreground/50 [&_button:hover]:text-foreground/80">
<ThemeToggle />
</div>
<button
type="button"
onClick={() => {
@@ -216,7 +238,7 @@ export function StaggeredNavFiles() {
}
}
}}
className="flex items-center justify-center px-4 py-3 text-foreground/75 dark:text-foreground/60 hover:text-foreground/85 transition-colors"
className="flex items-center justify-center size-8 text-foreground/75 dark:text-foreground/60 hover:text-foreground/85 transition-colors"
>
{mobileMenuOpen ? (
<svg
+158
View File
@@ -0,0 +1,158 @@
"use client";
import type {
SearchItemType,
SharedProps,
} from "fumadocs-ui/components/dialog/search";
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogFooter,
SearchDialogHeader,
SearchDialogInput,
SearchDialogList,
SearchDialogListItem,
SearchDialogOverlay,
useSearch,
} from "fumadocs-ui/components/dialog/search";
import { ArrowRight, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { Client } from "typesense";
import { useTypesenseSearch } from "typesense-fumadocs-adapter/client";
import { usePages } from "@/app/docs/provider";
const typesenseClient = (() => {
const url = process.env.NEXT_PUBLIC_TYPESENSE_SERVER_URL;
const apiKey = process.env.NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY;
if (!url || !apiKey) {
// Return a dummy client so the app works without Typesense configured
return new Client({
nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
apiKey: "dummy",
});
}
let serverUrl: URL;
try {
serverUrl = new URL(url);
} catch {
return new Client({
nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
apiKey: "dummy",
});
}
return new Client({
nodes: [
{
host: serverUrl.hostname,
port:
Number(serverUrl.port) ||
(serverUrl.protocol === "https:" ? 443 : 80),
protocol: serverUrl.protocol.replace(":", ""),
},
],
apiKey,
});
})();
export default function CustomSearchDialog(props: SharedProps) {
const { search, setSearch, query } = useTypesenseSearch({
typesenseCollectionName: "better-auth-docs",
client: typesenseClient!,
});
const pages = usePages();
const router = useRouter();
const pageTreeAction = useMemo<SearchItemType | undefined>(() => {
if (search.length === 0) return;
const normalized = search.toLowerCase();
for (const page of pages) {
if (!page.name.toLowerCase().includes(normalized)) continue;
return {
id: "quick-action",
type: "action",
node: (
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
<ArrowRight className="size-4" />
<p>
Jump to{" "}
<span className="font-medium text-fd-foreground">
{page.name}
</span>
</p>
</div>
),
onSelect: () => router.push(page.url),
};
}
}, [router, search, pages]);
return (
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
>
<SearchDialogOverlay className="z-200" />
<SearchDialogContent className="z-200">
<SearchDialogHeader>
<LoadingSearchIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList
items={
query.data !== "empty" || pageTreeAction
? [
...(pageTreeAction ? [pageTreeAction] : []),
...(Array.isArray(query.data) ? query.data : []),
]
: null
}
Item={({ item, onClick }) => (
<SearchDialogListItem
item={item}
onClick={onClick}
className={
item.type !== "action"
? "max-h-24 [&>div:last-child]:line-clamp-2"
: undefined
}
/>
)}
/>
<SearchDialogFooter>
<span className="text-xs text-fd-muted-foreground">
Search powered by{" "}
<a
href="https://typesense.org"
target="_blank"
rel="noreferrer noopener"
className="underline hover:text-fd-foreground transition-colors"
>
Typesense
</a>
</span>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}
function LoadingSearchIcon() {
const { isLoading } = useSearch();
return (
<Search
className={
isLoading
? "size-5 animate-pulse text-foreground duration-200"
: "size-5 text-fd-muted-foreground"
}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Textarea };
+45
View File
@@ -0,0 +1,45 @@
import { findPath } from "fumadocs-core/page-tree";
import type { DocumentRecord } from "typesense-fumadocs-adapter";
import { source } from "@/lib/source";
export async function exportSearchIndexes() {
const results: DocumentRecord[] = [];
function isBreadcrumbItem(item: unknown): item is string {
return typeof item === "string" && item.length > 0;
}
for (const page of source.getPages()) {
let breadcrumbs: string[] | undefined;
const pageTree = source.getPageTree(page.locale);
const path = findPath(
pageTree.children,
(node) => node.type === "page" && node.url === page.url,
);
if (path) {
breadcrumbs = [];
path.pop();
if (isBreadcrumbItem(pageTree.name)) {
breadcrumbs.push(pageTree.name);
}
for (const segment of path) {
if (!isBreadcrumbItem(segment.name)) continue;
breadcrumbs.push(segment.name);
}
}
const loaded = await page.data.load();
results.push({
_id: page.url,
structured: loaded.structuredData,
url: page.url,
title: page.data.title,
description: page.data.description,
breadcrumbs,
});
}
return results;
}
+6 -1
View File
@@ -7,9 +7,11 @@
"postinstall": "fumadocs-mdx",
"dev": "next dev --turbopack --port 3000",
"build": "next build",
"postbuild": "pnpm sync-typesense",
"start": "next start",
"lint": "next lint",
"lint:fix": "biome check . --fix --unsafe"
"lint:fix": "biome check . --fix --unsafe",
"sync-typesense": "node scripts/sync-typesense.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.44",
@@ -71,6 +73,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"three": "^0.183.1",
"typesense": "^3.0.2",
"typesense-fumadocs-adapter": "^0.3.0",
"unist-util-visit": "^5.1.0",
"zod": "^4.1.5"
},
@@ -81,6 +85,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.183.1",
"dotenv": "^17.3.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.7",
"typescript": "5.9.3"
+60
View File
@@ -0,0 +1,60 @@
import "dotenv/config";
import * as fs from "node:fs";
import { Client } from "typesense";
import type { DocumentRecord } from "typesense-fumadocs-adapter";
import { sync } from "typesense-fumadocs-adapter";
const url = process.env.NEXT_PUBLIC_TYPESENSE_SERVER_URL;
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
if (!url || !adminKey) {
console.log("Typesense env vars not set, skipping sync.");
process.exit(0);
}
if (!process.env.VERCEL) {
console.log("Not running on Production build, skipping sync.");
process.exit(0);
}
if (process.env.VERCEL_GIT_COMMIT_REF !== "main") {
console.log(
`Branch is "${process.env.VERCEL_GIT_COMMIT_REF}", skipping sync.`,
);
process.exit(0);
}
const filePath = ".next/server/app/api/docs/static.json.body";
if (!fs.existsSync(filePath)) {
console.log("Build output not found, run 'pnpm build' first. Skipping sync.");
process.exit(0);
}
const content = fs.readFileSync(filePath);
const records = JSON.parse(content.toString()) as DocumentRecord[];
const serverUrl = new URL(url);
const client = new Client({
nodes: [
{
host: serverUrl.hostname,
port:
Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80),
protocol: serverUrl.protocol.replace(":", ""),
},
],
apiKey: adminKey,
connectionTimeoutSeconds: 30,
});
void sync(client, {
typesenseCollectionName: "better-auth-docs",
documents: records,
})
.then(() => {
console.log(`search updated: ${records.length} records`);
})
.catch((error) => {
console.error("Failed to sync Typesense index:", error);
process.exitCode = 1;
});
+1044 -263
View File
File diff suppressed because it is too large Load Diff