Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,8 @@
"@modelcontextprotocol/sdk": "^1.26.0",
"@react-three/fiber": "^9.6.1",
"@vercel/analytics": "^2.0.1",
"cmdk": "^1.1.1",
"fuse.js": "^7.4.2",
"ioredis": "^5.11.1",
"js-yaml": "^4.1.1",
"lucide-react": "^1.11.0",
Expand Down
454 changes: 454 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

15 changes: 11 additions & 4 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@ import { Analytics } from "@vercel/analytics/next";
import "./globals.css";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
import { SearchProvider } from "@/components/search/search-provider";
import { buildSearchIndex } from "@/lib/search/buildIndex";
import { SITE } from "@/data/site";
import { safeJsonLd } from "@/lib/jsonld";

Expand DownExpand Up@@ -140,9 +142,12 @@ const ORG_JSONLD = {
],
};

export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
// Index is built once per server runtime (memoised via React `cache`),
// shipped as JSON to the client provider. ~400 docs, ~25-40 KB raw.
const searchItems = await buildSearchIndex();
return (
<html
lang="en"
Expand DownExpand Up@@ -187,9 +192,11 @@ export default function RootLayout({
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<SiteHeader />
<main id="main-content" className="flex-1 w-full max-w-full overflow-x-clip min-w-0">{children}</main>
<SiteFooter />
<SearchProvider items={searchItems}>
<SiteHeader />
<main id="main-content" className="flex-1 w-full max-w-full overflow-x-clip min-w-0">{children}</main>
<SiteFooter />
</SearchProvider>
{process.env.VERCEL_ENV === "production" && <Analytics />}
</body>
</html>
Expand Down
200 changes: 200 additions & 0 deletions src/components/search/search-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
"use client";

import { Command } from "cmdk";
import Fuse from "fuse.js";
import { Search, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearch } from "@/components/search/search-provider";
import type { SearchItem, SearchKind } from "@/lib/search/types";

const KIND_ORDER: SearchKind[] = [
"Benchmark",
"Product",
"Compare",
"Alternative",
"Answer",
"Chain",
"Page",
];

/**
* Hardcoded "popular benches" shown when the query is empty. No
* analytics involved, just an editorial pick of high-traffic specs
* that map to known SEO winners. Update by hand when traffic shifts.
*/
const POPULAR_BENCH_SLUGS = [
"pm-data-freshness",
"aggregator-head-lag",
"l1-finality",
"rpc-capabilities",
];

export default function SearchDialog() {
const { items, close: onClose } = useSearch();
const router = useRouter();
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);

// One Fuse instance per dialog mount. The full corpus is ~400 docs
// so build cost is sub-millisecond, no need to memoise across mounts.
const fuse = useMemo(
() =>
new Fuse(items, {
keys: [
{ name: "title", weight: 0.6 },
{ name: "tags", weight: 0.3 },
{ name: "description", weight: 0.1 },
],
threshold: 0.35,
minMatchCharLength: 2,
ignoreLocation: true,
}),
[items],
);

// Body scroll lock + ESC handler, same pattern as report-section-modal.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [onClose]);

useEffect(() => {
// cmdk autofocuses its own input, but giving the ref-driven focus
// a tick of priority avoids a flash where typing the first letter
// gets eaten by the trigger button's focus.
const t = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(t);
}, []);

const results = useMemo<SearchItem[]>(() => {
const q = query.trim();
if (!q) {
const popular = POPULAR_BENCH_SLUGS
.map((slug) => items.find((it) => it.id === `bench:${slug}`))
.filter((it): it is SearchItem => Boolean(it));
return popular;
}
return fuse.search(q, { limit: 8 }).map((r) => r.item);
}, [query, fuse, items]);

const grouped = useMemo(() => {
const map = new Map<SearchKind, SearchItem[]>();
for (const it of results) {
const list = map.get(it.kind) ?? [];
list.push(it);
map.set(it.kind, list);
}
return KIND_ORDER
.map((kind) => ({ kind, list: map.get(kind) ?? [] }))
.filter((g) => g.list.length > 0);
}, [results]);

function go(url: string) {
onClose();
router.push(url);
}

const trimmed = query.trim();
const showEmpty = trimmed.length > 0 && results.length === 0;
const headerLabel = trimmed.length === 0 ? "Popular benchmarks" : null;

return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink/40 backdrop-blur-sm p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Search"
>
<div
className="relative w-full max-w-xl mx-auto mt-[10vh] rounded-md border border-rule bg-paper shadow-2xl font-sans"
onClick={(e) => e.stopPropagation()}
>
<Command
label="Site search"
shouldFilter={false}
className="flex flex-col"
>
<div className="flex items-center gap-2.5 border-b border-rule px-4 py-3">
<Search size={15} className="text-ink-faint shrink-0" aria-hidden />
<Command.Input
ref={inputRef}
value={query}
onValueChange={setQuery}
placeholder="Search benchmarks, products, chains, answers…"
className="flex-1 bg-transparent text-sm text-ink placeholder:text-ink-faint outline-none border-0"
/>
<button
type="button"
onClick={onClose}
aria-label="Close search"
className="text-ink-muted hover:text-ink transition-colors"
>
<X size={16} strokeWidth={2} />
</button>
</div>

<Command.List className="max-h-[60vh] overflow-y-auto py-2">
{headerLabel && (
<div className="px-4 pt-1 pb-2 text-[10px] font-medium uppercase tracking-[0.18em] text-ink-faint">
{headerLabel}
</div>
)}

{showEmpty && (
<Command.Empty className="px-4 py-6 text-sm text-ink-muted leading-relaxed">
No results for &quot;{trimmed}&quot;. Try searching by bench name,
provider, chain, or keyword.
</Command.Empty>
)}

{grouped.map(({ kind, list }) => (
<Command.Group
key={kind}
heading={trimmed.length > 0 ? kind : undefined}
className="px-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.18em] [&_[cmdk-group-heading]]:text-ink-faint"
>
{list.map((it) => (
<Command.Item
key={it.id}
value={`${it.kind}|${it.title}|${it.id}`}
onSelect={() => go(it.url)}
className="flex items-start gap-3 rounded px-2 py-2 cursor-pointer text-sm aria-selected:bg-paper-soft"
>
<span className="mt-[3px] inline-flex items-center justify-center min-w-[64px] shrink-0 rounded border border-rule px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-ink-muted">
{it.kind}
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-ink truncate">
{it.title}
</span>
{it.description && (
<span className="block text-xs text-ink-muted truncate">
{it.description}
</span>
)}
</span>
</Command.Item>
))}
</Command.Group>
))}
</Command.List>

<div className="flex items-center justify-between border-t border-rule px-4 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-ink-faint">
<span>Enter to go</span>
<span>Esc to close</span>
</div>
</Command>
</div>
</div>
);
}
82 changes: 82 additions & 0 deletions src/components/search/search-provider.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"use client";

import dynamic from "next/dynamic";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { SearchItem } from "@/lib/search/types";

type Ctx = {
open: () => void;
close: () => void;
isOpen: boolean;
items: SearchItem[];
};

const SearchCtx = createContext<Ctx | null>(null);

/**
* Lazy-load the dialog (and with it, cmdk + Fuse.js) on the first open.
* Keeps the dialog out of the initial JS payload so the header trigger
* is cheap to ship sitewide.
*/
const SearchDialog = dynamic(() => import("@/components/search/search-dialog"), {
ssr: false,
});

type ProviderProps = {
items: SearchItem[];
children: React.ReactNode;
};

export function SearchProvider({ items, children }: ProviderProps) {
const [isOpen, setIsOpen] = useState(false);

const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);

// Global keyboard shortcuts. Cmd+K (mac) / Ctrl+K (win/linux) toggle
// the dialog. `/` opens it the way GitHub does, but only when the
// active element is not already an input, so typing `/` in a search
// box on a bench page doesn't fire twice.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setIsOpen((v) => !v);
return;
}
if (e.key === "/" && !isOpen) {
const t = e.target as HTMLElement | null;
const tag = t?.tagName;
const editable = t?.isContentEditable;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable) return;
e.preventDefault();
setIsOpen(true);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isOpen]);

const value = useMemo<Ctx>(
() => ({ open, close, isOpen, items }),
[open, close, isOpen, items],
);

return (
<SearchCtx.Provider value={value}>
{children}
{isOpen && <SearchDialog />}
</SearchCtx.Provider>
);
}

export function useSearch(): Ctx {
const ctx = useContext(SearchCtx);
if (!ctx) {
// Soft fallback: when something tries to open the dialog outside
// the provider (shouldn't happen, but cheap to guard) we make the
// trigger inert rather than crash the page.
return { open: () => {}, close: () => {}, isOpen: false, items: [] };
}
return ctx;
}
61 changes: 61 additions & 0 deletions src/components/search/search-trigger.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
"use client";

import { Search } from "lucide-react";
import { useSyncExternalStore } from "react";
import { useSearch } from "@/components/search/search-provider";

const emptySubscribe = () => () => {};

function readIsMac(): boolean {
if (typeof navigator === "undefined") return false;
const p = navigator.platform || navigator.userAgent;
return /Mac|iPhone|iPad|iPod/i.test(p);
}

type Props = {
/**
* - `desktop`: inline pill-ish button next to "GitHub" / theme toggle
* in the desktop nav. Hidden below md.
* - `mobile`: 44×44 icon-only square sized to the same min-tap target
* as the hamburger. Hidden at md+.
*/
variant: "desktop" | "mobile";
};

export function SearchTrigger({ variant }: Props) {
const { open } = useSearch();
// Read once on the client. SSR snapshot is `false` so we render the
// Ctrl variant; the client snapshot replaces it on hydration if the
// platform actually is macOS. Avoids the set-state-in-effect lint
// and stays consistent with how ThemeToggle reads the DOM.
const isMac = useSyncExternalStore(emptySubscribe, readIsMac, () => false);

if (variant === "mobile") {
return (
<button
type="button"
onClick={open}
aria-label="Open search"
className="md:hidden inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-ink-muted hover:text-ink transition-colors"
>
<Search size={20} aria-hidden />
</button>
);
}

const shortcut = isMac ? "⌘K" : "Ctrl K";
return (
<button
type="button"
onClick={open}
aria-label="Open search"
className="inline-flex items-center gap-1.5 text-ink-muted hover:text-ink transition-colors"
>
<Search size={15} aria-hidden />
<span>Search</span>
<span className="ml-1 inline-flex items-center rounded border border-rule px-1 py-[1px] text-[10px] font-medium uppercase tracking-[0.12em] text-ink-faint tabular">
{shortcut}
</span>
</button>
);
}
Loading
Loading