Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ OPENAI_API_KEY=
# Get a key from https://console.anthropic.com/
# Falls back to OPENAI_API_KEY if not set
ANTHROPIC_API_KEY=

# Algolia search (search API key is public/read-only)
NEXT_PUBLIC_ALGOLIA_APP_ID=
NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY=
NEXT_PUBLIC_ALGOLIA_INDEX_NAME=
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ node_modules
.env.local
public/sitemap*.xml
.env
_pagefind/

# TypeScript
*.tsbuildinfo

Expand Down
181 changes: 181 additions & 0 deletions app/_components/algolia-search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"use client";

import { liteClient as algoliasearch } from "algoliasearch/lite";
import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import {
Highlight,
Hits,
InstantSearch,
SearchBox,
useInstantSearch,
} from "react-instantsearch";

type HitRecord = {
objectID: string;
title?: string;
description?: string;
url?: string;
};

const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
const searchKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY;
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME;

const searchClient =
appId && searchKey ? algoliasearch(appId, searchKey) : null;

function safeHref(url: string | undefined): string {
if (!url) {
return "/";
}
if (url.startsWith("/") || url.startsWith("https://")) {
return url;
}
return "/";
}

function SearchHit({ hit }: { hit: HitRecord }) {
return (
<a
className="block rounded-lg px-4 py-3 hover:bg-neutral-100 dark:hover:bg-white/5"
href={safeHref(hit.url)}
>
<div className="truncate text-sm font-medium text-foreground">
<Highlight
attribute="title"
hit={hit as Parameters<typeof Highlight>[0]["hit"]}
/>
</div>
{hit.description && (
<div className="mt-0.5 truncate text-xs text-muted-foreground">
<Highlight
attribute="description"
hit={hit as Parameters<typeof Highlight>[0]["hit"]}
/>
</div>
)}
</a>
);
}

function EmptyQuery() {
const { indexUiState } = useInstantSearch();
if (indexUiState.query) {
return null;
}
return (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Start typing to search the docs…
</p>
);
}

function NoResults() {
const { results } = useInstantSearch();
if (!results?.query || results.nbHits > 0) {
return null;
}
return (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No results for{" "}
<strong className="text-foreground">"{results.query}"</strong>
</p>
);
}

function SearchUnavailable() {
return (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Add <code className="text-xs">NEXT_PUBLIC_ALGOLIA_APP_ID</code>,{" "}
<code className="text-xs">NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY</code>, and{" "}
<code className="text-xs">NEXT_PUBLIC_ALGOLIA_INDEX_NAME</code> to your
environment to enable search.
</p>
);
}

export function AlgoliaSearch() {
const [isOpen, setIsOpen] = useState(false);

useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setIsOpen((prev) => !prev);
}
if (e.key === "Escape") {
setIsOpen(false);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
Comment on lines +101 to +113
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be neat to either upgrade @uidotdev/usehooks to a verstion that has useEventListener or use https://tanstack.com/hotkeys/latest (never tried it but tanstack is usually legit)

but for now, a useEffect is the best i've got


return (
<>
<button
aria-label="Search docs"
className="algolia-search-button flex w-56 items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-neutral-200 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10"
onClick={() => setIsOpen(true)}
type="button"
>
<Search className="size-3.5 shrink-0" />
<span className="flex-1 text-left">Search</span>
<kbd className="flex items-center gap-0.5 rounded border border-border px-1 text-xs text-muted-foreground">
<span>⌘</span>K
</kbd>
</button>

{isOpen && (
<div
aria-label="Search"
aria-modal="true"
className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[10vh]"
role="dialog"
>
<button
aria-label="Close search"
className="fixed inset-0 bg-black/30 backdrop-blur-sm dark:bg-black/50"
onClick={() => setIsOpen(false)}
type="button"
/>
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-popover shadow-2xl">
{searchClient && indexName ? (
<InstantSearch indexName={indexName} searchClient={searchClient}>
<div className="flex items-center border-b border-border px-4">
<Search className="size-4 shrink-0 text-muted-foreground" />
<SearchBox
autoFocus
classNames={{
form: "flex flex-1",
input:
"w-full bg-transparent px-3 py-4 text-sm text-foreground placeholder:text-muted-foreground outline-none",
loadingIndicator: "hidden",
reset: "hidden",
root: "flex-1",
submit: "hidden",
}}
placeholder="Search docs…"
/>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2">
<EmptyQuery />
<NoResults />
<Hits
classNames={{ item: "", list: "space-y-0.5", root: "" }}
hitComponent={({ hit }) => (
<SearchHit hit={hit as unknown as HitRecord} />
)}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hits component always renders alongside empty query message

Medium Severity

The Hits component renders unconditionally, but Algolia returns results for empty queries by default. When the search modal opens, users see the "Start typing to search the docs…" message from EmptyQuery and a list of search results from Hits at the same time. The Hits component needs to be conditionally hidden when no query has been entered.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

@teallarson teallarson Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine? I like the way this shows top docs pages and also prompts for input when empty. Open to other opinions.

</div>
</InstantSearch>
) : (
<SearchUnavailable />
)}
</div>
</div>
)}
</>
);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not related but docs weren't building right on my branch until i made this change.

Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,14 @@ const MDX_COMPONENTS = {
// Maximum number of MDX components to cache to prevent unbounded memory growth
const MDX_CACHE_MAX_SIZE = 100;

const mdxCache =
createMdxCache<React.ComponentType<{ components?: typeof MDX_COMPONENTS }>>(
MDX_CACHE_MAX_SIZE
);
const mdxCache = createMdxCache<React.ElementType>(MDX_CACHE_MAX_SIZE);

/**
* Renders MDX content from a string with custom components.
*/
function MdxContent({ content }: { content: string }) {
const source = useMemo(() => stripMdxImports(content), [content]);
const [Component, setComponent] = useState<React.ComponentType<{
components?: typeof MDX_COMPONENTS;
}> | null>(null);
const [Component, setComponent] = useState<React.ElementType | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
Expand Down
17 changes: 16 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ nav > a[aria-label="Home page"] {
margin-inline-end: 3.5rem !important;
}

nav > div:has(.nextra-search) {
nav > div:has(.algolia-search-button) {
order: -1;
margin-inline-start: 1rem;
margin-inline-end: auto;
Expand All @@ -157,6 +157,21 @@ nav > div:has(.nextra-search) {
}
}

/* Algolia search hit highlight — brand red */
.ais-Highlight-highlighted,
.ais-Snippet-highlighted {
background: color-mix(in oklch, var(--primary) 15%, transparent);
color: var(--primary);
border-radius: 2px;
font-style: normal;
font-weight: 600;
}

.dark .ais-Highlight-highlighted,
.dark .ais-Snippet-highlighted {
background: color-mix(in oklch, var(--primary) 20%, transparent);
}

/* Override Nextra code highlight colors to use green instead of red */
[data-highlighted-line] {
background-color: color-mix(in oklab, #22c55e 20%, transparent) !important;
Expand Down
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getDictionary } from "@/_dictionaries/get-dictionary";
import { AlgoliaSearch } from "@/app/_components/algolia-search";
import { SignupLink } from "@/app/_components/analytics";
import CustomLayout from "@/app/_components/custom-layout";
import { getDashboardUrl } from "@/app/_components/dashboard-link";
Expand Down Expand Up @@ -171,6 +172,7 @@ export default async function RootLayout({
}
nextThemes={{ defaultTheme: "dark" }}
pageMap={pageMap}
search={<AlgoliaSearch />}
sidebar={{
defaultMenuCollapseLevel: 2,
autoCollapse: true,
Expand Down
2 changes: 1 addition & 1 deletion middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,6 @@ export function middleware(request: NextRequest) {

export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|manifest|_pagefind|public|.*.svg|.*.png|.*.jpg|.*.jpeg|.*.gif|.*.webp|.*.ico|.*.css|.*.js|.*.woff|.*.woff2|.*.ttf|.*.eot|.*.otf|.*.pdf|.*.txt|.*.xml|.*.json|.*.py|.*.mp4).*)",
"/((?!api|_next/static|_next/image|favicon.ico|manifest|public|.*.svg|.*.png|.*.jpg|.*.jpeg|.*.gif|.*.webp|.*.ico|.*.webmanifest|.*.css|.*.js|.*.woff|.*.woff2|.*.ttf|.*.eot|.*.otf|.*.pdf|.*.txt|.*.xml|.*.json|.*.py|.*.mp4).*)",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a bunch of 404s locally from this. Idk if I should have just ignored them. This fixed it.

],
};
1 change: 1 addition & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { remarkGlossary } from "./lib/remark-glossary";
const withNextra = nextra({
defaultShowCopyCode: true,
codeHighlight: true,
search: false,
mdxOptions: {
remarkPlugins: [
[
Expand Down
11 changes: 4 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@
"type": "module",
"scripts": {
"dev": "next dev --webpack",
"build": "pnpm run toolkit-markdown && next build --webpack && pnpm run custompagefind",
"build": "pnpm run toolkit-markdown && next build --webpack",
"start": "next start",
"lint": "pnpm exec ultracite check",
"format": "pnpm exec ultracite fix",
"prepare": "husky install",
"toolkit-markdown": "pnpm dlx tsx toolkit-docs-generator/scripts/generate-toolkit-markdown.ts",
"postbuild": "if [ \"$SKIP_POSTBUILD\" != \"true\" ]; then pnpm run generate:markdown && pnpm run custompagefind; fi",
"postbuild": "if [ \"$SKIP_POSTBUILD\" != \"true\" ]; then pnpm run generate:markdown; fi",
"generate:markdown": "pnpm dlx tsx scripts/generate-clean-markdown.ts",
"translate": "pnpm dlx tsx scripts/i18n-sync/index.ts && pnpm format",
"llmstxt": "pnpm dlx tsx scripts/generate-llmstxt.ts",
"custompagefind": "pnpm dlx tsx scripts/pagefind.ts",
"test": "vitest --run",
"test:watch": "vitest --watch",
"vale": "vale",
Expand Down Expand Up @@ -49,6 +48,7 @@
"@ory/client": "1.22.7",
"@theguild/remark-mermaid": "0.3.0",
"@uidotdev/usehooks": "2.4.1",
"algoliasearch": "^5.49.1",
"chalk": "^5.6.2",
"lucide-react": "0.548.0",
"mdast-util-to-string": "4.0.0",
Expand All @@ -61,6 +61,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "7.65.0",
"react-instantsearch": "^7.26.0",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"react-markdown": "^10.1.0",
"react-syntax-highlighter": "16.1.0",
"remark-gfm": "^4.0.1",
Expand Down Expand Up @@ -93,12 +94,8 @@
"next-validate-link": "1.6.3",
"openai": "6.7.0",
"ora": "9.0.0",
"pagefind": "1.4.0",
"picocolors": "1.1.1",
"postcss": "8.5.6",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
"remark-rehype": "^11.1.2",
"tailwindcss": "4.1.16",
"turndown": "^7.2.2",
"typescript": "5.9.3",
Expand Down
Loading