colors + search for docs (#927)

* colors

* search
This commit is contained in:
Chris Tate
2026-03-19 01:50:19 -05:00
committed by GitHub
parent 0749ad667e
commit 06b3b94493
9 changed files with 678 additions and 11 deletions
+69
View File
@@ -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" } },
);
}
+20
View File
@@ -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 {
+156 -3
View File
@@ -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"),
"&lt;$1&gt;",
);
}
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">
+6 -6
View File
@@ -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>&#8984;</span>K
<span>&#8984;</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>
+3 -1
View File
@@ -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"
+265
View File
@@ -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>&#8984;</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>
</>
);
}
+92
View File
@@ -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 };
+1 -1
View File
@@ -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" &&
+66
View File
@@ -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;
}