@@ -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-muted-foreground: var(--muted-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
--muted-foreground: #737373;
|
--muted-foreground: #737373;
|
||||||
--primary: #171717;
|
--primary: #171717;
|
||||||
--primary-foreground: #fff;
|
--primary-foreground: #fff;
|
||||||
|
--sidebar: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
--muted-foreground: #a3a3a3;
|
--muted-foreground: #a3a3a3;
|
||||||
--primary: #f5f5f5;
|
--primary: #f5f5f5;
|
||||||
--primary-foreground: #0a0a0a;
|
--primary-foreground: #0a0a0a;
|
||||||
|
--sidebar: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -129,6 +132,23 @@ pre:not(.shiki) {
|
|||||||
font-size: 0.875em;
|
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 dual theme support */
|
||||||
.shiki,
|
.shiki,
|
||||||
.shiki span {
|
.shiki span {
|
||||||
|
|||||||
@@ -1,6 +1,156 @@
|
|||||||
import { codeToHtml } from "shiki";
|
import { codeToHtml } from "shiki";
|
||||||
import { CopyButton } from "./copy-button";
|
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 {
|
interface CodeBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
@@ -8,13 +158,16 @@ interface CodeBlockProps {
|
|||||||
|
|
||||||
export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
|
export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
|
||||||
const trimmedCode = code.trim();
|
const trimmedCode = code.trim();
|
||||||
const html = await codeToHtml(trimmedCode, {
|
const shielded = shieldPlaceholders(trimmedCode);
|
||||||
|
let html = await codeToHtml(shielded, {
|
||||||
lang,
|
lang,
|
||||||
themes: {
|
themes: {
|
||||||
light: "github-light-default",
|
light: vercelLightTheme,
|
||||||
dark: "github-dark-default",
|
dark: vercelDarkTheme,
|
||||||
},
|
},
|
||||||
|
defaultColor: false,
|
||||||
});
|
});
|
||||||
|
html = restorePlaceholders(html);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="code-block relative group">
|
<div className="code-block relative group">
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function DocsChat({
|
|||||||
// Cmd+K to open sidebar and focus prompt, Escape to close
|
// Cmd+K to open sidebar and focus prompt, Escape to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((prev) => {
|
setOpen((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
@@ -327,7 +327,7 @@ export function DocsChat({
|
|||||||
const chatPanel = (
|
const chatPanel = (
|
||||||
<>
|
<>
|
||||||
{/* Header */}
|
{/* 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>
|
<span className="text-sm font-medium">agent-browser Docs</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{showMessages && (
|
{showMessages && (
|
||||||
@@ -443,7 +443,7 @@ export function DocsChat({
|
|||||||
{/* Input bar */}
|
{/* Input bar */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
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
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -499,14 +499,14 @@ export function DocsChat({
|
|||||||
>
|
>
|
||||||
Ask AI
|
Ask AI
|
||||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||||
<span>⌘</span>K
|
<span>⌘</span>I
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop: resizable side pane -- always rendered, hidden on mobile via CSS */}
|
{/* Desktop: resizable side pane -- always rendered, hidden on mobile via CSS */}
|
||||||
<aside
|
<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 }}
|
style={{ width: desktopWidth }}
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
>
|
>
|
||||||
@@ -525,7 +525,7 @@ export function DocsChat({
|
|||||||
side="right"
|
side="right"
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
overlayClassName="bg-background!"
|
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 }}
|
style={{ backgroundColor: "var(--background)", opacity: 1 }}
|
||||||
>
|
>
|
||||||
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
import { Search } from "./search";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
return (
|
return (
|
||||||
@@ -52,6 +53,7 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-4">
|
||||||
|
<Search />
|
||||||
<a
|
<a
|
||||||
href="https://github.com/vercel-labs/agent-browser"
|
href="https://github.com/vercel-labs/agent-browser"
|
||||||
target="_blank"
|
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" />
|
<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>
|
</svg>
|
||||||
<span>20k</span>
|
<span>23k</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://www.npmjs.com/package/agent-browser"
|
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" &&
|
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",
|
"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" &&
|
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" &&
|
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",
|
"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" &&
|
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