@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSearchIndex } from "@/lib/search-index";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase();
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json({ results: [] });
|
||||
}
|
||||
|
||||
const index = await getSearchIndex();
|
||||
const terms = q.split(/\s+/).filter(Boolean);
|
||||
|
||||
const results = index
|
||||
.map((entry) => {
|
||||
const titleLower = entry.title.toLowerCase();
|
||||
const contentLower = entry.content.toLowerCase();
|
||||
|
||||
const titleMatch = terms.every((t) => titleLower.includes(t));
|
||||
const contentMatch = terms.every((t) => contentLower.includes(t));
|
||||
|
||||
if (!titleMatch && !contentMatch) return null;
|
||||
|
||||
let snippet = "";
|
||||
if (contentMatch) {
|
||||
const firstTermIdx = Math.min(
|
||||
...terms.map((t) => {
|
||||
const idx = contentLower.indexOf(t);
|
||||
return idx === -1 ? Infinity : idx;
|
||||
}),
|
||||
);
|
||||
if (firstTermIdx !== Infinity) {
|
||||
const start = Math.max(0, firstTermIdx - 40);
|
||||
const end = Math.min(entry.content.length, firstTermIdx + 120);
|
||||
snippet =
|
||||
(start > 0 ? "..." : "") +
|
||||
entry.content.slice(start, end).replace(/\n/g, " ") +
|
||||
(end < entry.content.length ? "..." : "");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: entry.title,
|
||||
href: entry.href,
|
||||
section: entry.section,
|
||||
snippet,
|
||||
score: titleMatch ? 2 : 1,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
r,
|
||||
): r is {
|
||||
title: string;
|
||||
href: string;
|
||||
section: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
} => r !== null,
|
||||
)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20)
|
||||
.map(({ score: _, ...rest }) => rest);
|
||||
|
||||
return NextResponse.json(
|
||||
{ results },
|
||||
{ headers: { "Cache-Control": "public, max-age=60" } },
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -26,6 +27,7 @@
|
||||
--muted-foreground: #737373;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fff;
|
||||
--sidebar: #f5f5f5;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -36,6 +38,7 @@
|
||||
--muted-foreground: #a3a3a3;
|
||||
--primary: #f5f5f5;
|
||||
--primary-foreground: #0a0a0a;
|
||||
--sidebar: #171717;
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -129,6 +132,23 @@ pre:not(.shiki) {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Diff line highlighting */
|
||||
.diff-add {
|
||||
color: #00952d;
|
||||
}
|
||||
|
||||
.diff-remove {
|
||||
color: #f32e40;
|
||||
}
|
||||
|
||||
.dark .diff-add {
|
||||
color: #00ca50;
|
||||
}
|
||||
|
||||
.dark .diff-remove {
|
||||
color: #f32e40;
|
||||
}
|
||||
|
||||
/* Shiki dual theme support */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
|
||||
@@ -1,6 +1,156 @@
|
||||
import { codeToHtml } from "shiki";
|
||||
import { CopyButton } from "./copy-button";
|
||||
|
||||
const vercelDarkTheme = {
|
||||
name: "vercel-dark",
|
||||
type: "dark" as const,
|
||||
colors: {
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "#EDEDED",
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
scope: ["comment", "punctuation.definition.comment"],
|
||||
settings: { foreground: "#A1A1A1" },
|
||||
},
|
||||
{
|
||||
scope: ["string", "string.quoted", "string.template", "punctuation.definition.string"],
|
||||
settings: { foreground: "#00CA50" },
|
||||
},
|
||||
{
|
||||
scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
|
||||
settings: { foreground: "#47A8FF" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword", "storage.type", "storage.modifier"],
|
||||
settings: { foreground: "#FF4D8D" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator", "keyword.control"],
|
||||
settings: { foreground: "#FF4D8D" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.function", "support.function", "meta.function-call"],
|
||||
settings: { foreground: "#C472FB" },
|
||||
},
|
||||
{
|
||||
scope: ["variable", "variable.other"],
|
||||
settings: { foreground: "#EDEDED" },
|
||||
},
|
||||
{
|
||||
scope: ["variable.parameter"],
|
||||
settings: { foreground: "#FF9300" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
|
||||
settings: { foreground: "#FF4D8D" },
|
||||
},
|
||||
{
|
||||
scope: ["punctuation", "meta.brace", "meta.bracket"],
|
||||
settings: { foreground: "#EDEDED" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.property-name",
|
||||
"entity.name.tag.json",
|
||||
"meta.object-literal.key",
|
||||
"punctuation.support.type.property-name",
|
||||
],
|
||||
settings: { foreground: "#FF4D8D" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.other.attribute-name"],
|
||||
settings: { foreground: "#00CA50" },
|
||||
},
|
||||
{
|
||||
scope: ["support.type.primitive", "entity.name.type.primitive"],
|
||||
settings: { foreground: "#00CA50" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const vercelLightTheme = {
|
||||
name: "vercel-light",
|
||||
type: "light" as const,
|
||||
colors: {
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "#171717",
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
scope: ["comment", "punctuation.definition.comment"],
|
||||
settings: { foreground: "#6B7280" },
|
||||
},
|
||||
{
|
||||
scope: ["string", "string.quoted", "string.template", "punctuation.definition.string"],
|
||||
settings: { foreground: "#067A6E" },
|
||||
},
|
||||
{
|
||||
scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
|
||||
settings: { foreground: "#0070C0" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword", "storage.type", "storage.modifier"],
|
||||
settings: { foreground: "#D6409F" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator", "keyword.control"],
|
||||
settings: { foreground: "#D6409F" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.function", "support.function", "meta.function-call"],
|
||||
settings: { foreground: "#6E56CF" },
|
||||
},
|
||||
{
|
||||
scope: ["variable", "variable.other"],
|
||||
settings: { foreground: "#171717" },
|
||||
},
|
||||
{
|
||||
scope: ["variable.parameter"],
|
||||
settings: { foreground: "#B45309" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
|
||||
settings: { foreground: "#D6409F" },
|
||||
},
|
||||
{
|
||||
scope: ["punctuation", "meta.brace", "meta.bracket"],
|
||||
settings: { foreground: "#6B7280" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.property-name",
|
||||
"entity.name.tag.json",
|
||||
"meta.object-literal.key",
|
||||
"punctuation.support.type.property-name",
|
||||
],
|
||||
settings: { foreground: "#D6409F" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.other.attribute-name"],
|
||||
settings: { foreground: "#067A6E" },
|
||||
},
|
||||
{
|
||||
scope: ["support.type.primitive", "entity.name.type.primitive"],
|
||||
settings: { foreground: "#067A6E" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PLACEHOLDER_PREFIX = "\u200B\u200B";
|
||||
const PLACEHOLDER_SUFFIX = "\u200B\u200B";
|
||||
|
||||
function shieldPlaceholders(code: string): string {
|
||||
return code.replace(/<([\w|]+)>/g, `${PLACEHOLDER_PREFIX}$1${PLACEHOLDER_SUFFIX}`);
|
||||
}
|
||||
|
||||
function restorePlaceholders(html: string): string {
|
||||
return html.replace(
|
||||
new RegExp(`${PLACEHOLDER_PREFIX}([\\w|]+)${PLACEHOLDER_SUFFIX}`, "g"),
|
||||
"<$1>",
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
lang?: string;
|
||||
@@ -8,13 +158,16 @@ interface CodeBlockProps {
|
||||
|
||||
export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
|
||||
const trimmedCode = code.trim();
|
||||
const html = await codeToHtml(trimmedCode, {
|
||||
const shielded = shieldPlaceholders(trimmedCode);
|
||||
let html = await codeToHtml(shielded, {
|
||||
lang,
|
||||
themes: {
|
||||
light: "github-light-default",
|
||||
dark: "github-dark-default",
|
||||
light: vercelLightTheme,
|
||||
dark: vercelDarkTheme,
|
||||
},
|
||||
defaultColor: false,
|
||||
});
|
||||
html = restorePlaceholders(html);
|
||||
|
||||
return (
|
||||
<div className="code-block relative group">
|
||||
|
||||
@@ -261,7 +261,7 @@ export function DocsChat({
|
||||
// Cmd+K to open sidebar and focus prompt, Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => {
|
||||
if (!prev) {
|
||||
@@ -327,7 +327,7 @@ export function DocsChat({
|
||||
const chatPanel = (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 shrink-0">
|
||||
<span className="text-sm font-medium">agent-browser Docs</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{showMessages && (
|
||||
@@ -443,7 +443,7 @@ export function DocsChat({
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
|
||||
className="flex items-end gap-2 px-4 py-3 border-t border-border/50 shrink-0"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
@@ -499,14 +499,14 @@ export function DocsChat({
|
||||
>
|
||||
Ask AI
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||
<span>⌘</span>K
|
||||
<span>⌘</span>I
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Desktop: resizable side pane -- always rendered, hidden on mobile via CSS */}
|
||||
<aside
|
||||
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l border-border/50 bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
style={{ width: desktopWidth }}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
@@ -525,7 +525,7 @@ export function DocsChat({
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
overlayClassName="bg-background!"
|
||||
className="inset-0! w-full! h-full! max-w-none! p-0 flex flex-col"
|
||||
className="inset-0! w-full! h-full! max-w-none! border-l-0! p-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--background)", opacity: 1 }}
|
||||
>
|
||||
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { Search } from "./search";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
@@ -52,6 +53,7 @@ export function Header() {
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Search />
|
||||
<a
|
||||
href="https://github.com/vercel-labs/agent-browser"
|
||||
target="_blank"
|
||||
@@ -66,7 +68,7 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>20k</span>
|
||||
<span>23k</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/agent-browser"
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SearchResult = {
|
||||
title: string;
|
||||
href: string;
|
||||
section: string;
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
export function Search() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const navigate = useCallback(
|
||||
(href: string) => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
router.push(href);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
} else {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (!q) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setResults(data.results);
|
||||
}
|
||||
} catch {
|
||||
// aborted or network error
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [results]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && results[activeIndex]) {
|
||||
e.preventDefault();
|
||||
navigate(results[activeIndex].href);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const active = listRef.current?.querySelector("[data-active='true']");
|
||||
active?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const hasQuery = query.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="hidden sm:flex items-center gap-2 rounded-md border border-border/50 bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:border-foreground/25 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
Search docs
|
||||
<kbd className="pointer-events-none ml-1 inline-flex items-center gap-0.5 rounded border border-border/50 bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="sm:hidden flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Search docs"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent showCloseButton={false} className="gap-0 p-0 sm:max-w-lg">
|
||||
<DialogTitle className="sr-only">Search documentation</DialogTitle>
|
||||
<div className="flex items-center gap-2 border-b border-border/50 px-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search docs..."
|
||||
className="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery("")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="max-h-[min(60vh,400px)] overflow-y-auto p-2"
|
||||
>
|
||||
{loading && hasQuery ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
) : hasQuery && results.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</p>
|
||||
) : !hasQuery ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Type to search documentation...
|
||||
</p>
|
||||
) : (
|
||||
results.map((item, i) => (
|
||||
<button
|
||||
key={item.href}
|
||||
data-active={i === activeIndex}
|
||||
onClick={() => navigate(item.href)}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1 rounded-md px-3 py-2 text-left transition-colors",
|
||||
i === activeIndex
|
||||
? "bg-muted text-foreground"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{item.title}</span>
|
||||
{item.section && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{item.section}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.snippet && (
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground leading-relaxed">
|
||||
{item.snippet}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-border/50 p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Dialog, DialogPortal, DialogOverlay, DialogContent, DialogTitle };
|
||||
@@ -65,7 +65,7 @@ function SheetContent({
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r border-border/50 sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { navigation } from "./docs-navigation";
|
||||
import { mdxToCleanMarkdown } from "./mdx-to-markdown";
|
||||
|
||||
export type IndexEntry = {
|
||||
title: string;
|
||||
href: string;
|
||||
section: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
let cached: IndexEntry[] | null = null;
|
||||
|
||||
function stripMarkdown(md: string): string {
|
||||
return md
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/`[^`]+`/g, "")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function mdxFileForSlug(slug: string): string {
|
||||
const docsRoot = join(process.cwd(), "src", "app");
|
||||
if (slug === "/") {
|
||||
return join(docsRoot, "page.mdx");
|
||||
}
|
||||
const rest = slug.replace(/^\//, "");
|
||||
return join(docsRoot, ...rest.split("/"), "page.mdx");
|
||||
}
|
||||
|
||||
export async function getSearchIndex(): Promise<IndexEntry[]> {
|
||||
if (cached) return cached;
|
||||
|
||||
const entries: IndexEntry[] = [];
|
||||
|
||||
for (const section of navigation) {
|
||||
for (const item of section.items) {
|
||||
try {
|
||||
const raw = await readFile(mdxFileForSlug(item.href), "utf-8");
|
||||
const md = mdxToCleanMarkdown(raw);
|
||||
const content = stripMarkdown(md);
|
||||
entries.push({
|
||||
title: item.name,
|
||||
href: item.href,
|
||||
section: section.title ?? "",
|
||||
content,
|
||||
});
|
||||
} catch {
|
||||
entries.push({
|
||||
title: item.name,
|
||||
href: item.href,
|
||||
section: section.title ?? "",
|
||||
content: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cached = entries;
|
||||
return entries;
|
||||
}
|
||||
Reference in New Issue
Block a user