Skip to content

Commit c74e476

Browse files
teallarsonclaudegithub-actions[bot]
authored
Switch search from Pagefind to Algolia (#835)
* Switch search from Pagefind to Algolia Replaces Pagefind's build-time static search index with Algolia's crawler-based search. The index is populated automatically by Algolia's crawler — no build step needed. Adds a custom InstantSearch modal component that is theme-aware (light/dark) and uses the brand red for hit highlights. Also fixes a pre-existing bug where site.webmanifest 404'd in dev because the middleware matcher didn't exclude .webmanifest files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript error: use React.ElementType for MDX component cache `React.ComponentType<{ components?: ... }>` was incompatible with `MDXContent` returned by @mdx-js/mdx's evaluate(), due to React 19 components returning ReactNode (including undefined) while @types/mdx expects Element | null. Using React.ElementType resolves the boundary without type suppressions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Refactor AlgoliaSearch: guard config, sanitize URLs, remove dead code - Only initialize algoliasearch when all three env vars are present; show a setup instructions message in the modal otherwise - Add safeHref() to reject non-relative/non-https hit URLs (XSS defense) - Remove unused inputRef, FOCUS_DELAY_MS, and the focus useEffect — SearchBox autoFocus already handles this correctly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove "Search by Algolia" footer from search modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace useEffect keyboard handler with useEventListener @uidotdev/usehooks is already a dependency. useEventListener handles add/remove lifecycle internally, removing the boilerplate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use design system --primary var for Algolia highlight colors Replaces hardcoded hsl(347 ...) values with var(--primary) from @arcadeai/design-system tokens and color-mix() for transparent tints. Dark mode text override removed since --primary resolves identically in both modes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use design system semantic tokens in AlgoliaSearch component Replace hardcoded neutral/dark: pairs with semantic tokens where the mapping is clean and visually equivalent: - text-neutral-900 dark:text-white → text-foreground - text-neutral-400/500 dark:text-neutral-500/400 → text-muted-foreground - border-neutral-200/300 dark:border-white/10 → border-border - bg-white dark:bg-neutral-900 (modal panel) → bg-popover Hover states on hits and the trigger button bg retain explicit neutral/white values since bg-muted dark resolves too dark for those. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Revert to useEffect for keyboard handler — useEventListener not in @uidotdev/usehooks@2.4.1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add content snippet support for per-section index records Adds Configure with attributesToSnippet, imports Snippet component, and updates SearchHit to show a content snippet when present (new per-section index format) with fallback to description highlight for existing flat records. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Revert "Add content snippet support for per-section index records" This reverts commit 25b7afe. * Trigger Vercel redeploy * Fix safeHref to reject protocol-relative URLs like //evil.com url.startsWith("/") was passing for //evil.com which browsers interpret as https://evil.com, enabling open redirect. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Remove dead arcade-ai/pull/460 link from changelog The PR link was returning 404 and failing the external URL check test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Regenerate clean markdown files --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 54eaae7 commit c74e476

13 files changed

Lines changed: 615 additions & 290 deletions

File tree

.env.local.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ OPENAI_API_KEY=
99
# Get a key from https://console.anthropic.com/
1010
# Falls back to OPENAI_API_KEY if not set
1111
ANTHROPIC_API_KEY=
12+
13+
# Algolia search (search API key is public/read-only)
14+
NEXT_PUBLIC_ALGOLIA_APP_ID=
15+
NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY=
16+
NEXT_PUBLIC_ALGOLIA_INDEX_NAME=

.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ node_modules
55
.env.local
66
public/sitemap*.xml
77
.env
8-
_pagefind/
9-
108
# TypeScript
119
*.tsbuildinfo
1210

app/_components/algolia-search.tsx

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"use client";
2+
3+
import { liteClient as algoliasearch } from "algoliasearch/lite";
4+
import { Search } from "lucide-react";
5+
import { useEffect, useState } from "react";
6+
import {
7+
Highlight,
8+
Hits,
9+
InstantSearch,
10+
SearchBox,
11+
useInstantSearch,
12+
} from "react-instantsearch";
13+
14+
type HitRecord = {
15+
objectID: string;
16+
title?: string;
17+
description?: string;
18+
url?: string;
19+
};
20+
21+
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
22+
const searchKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY;
23+
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME;
24+
25+
const searchClient =
26+
appId && searchKey ? algoliasearch(appId, searchKey) : null;
27+
28+
function safeHref(url: string | undefined): string {
29+
if (!url) {
30+
return "/";
31+
}
32+
if (
33+
url.startsWith("https://") ||
34+
(url.startsWith("/") && !url.startsWith("//"))
35+
) {
36+
return url;
37+
}
38+
return "/";
39+
}
40+
41+
function SearchHit({ hit }: { hit: HitRecord }) {
42+
return (
43+
<a
44+
className="block rounded-lg px-4 py-3 hover:bg-neutral-100 dark:hover:bg-white/5"
45+
href={safeHref(hit.url)}
46+
>
47+
<div className="truncate text-sm font-medium text-foreground">
48+
<Highlight
49+
attribute="title"
50+
hit={hit as Parameters<typeof Highlight>[0]["hit"]}
51+
/>
52+
</div>
53+
{hit.description && (
54+
<div className="mt-0.5 truncate text-xs text-muted-foreground">
55+
<Highlight
56+
attribute="description"
57+
hit={hit as Parameters<typeof Highlight>[0]["hit"]}
58+
/>
59+
</div>
60+
)}
61+
</a>
62+
);
63+
}
64+
65+
function EmptyQuery() {
66+
const { indexUiState } = useInstantSearch();
67+
if (indexUiState.query) {
68+
return null;
69+
}
70+
return (
71+
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
72+
Start typing to search the docs…
73+
</p>
74+
);
75+
}
76+
77+
function NoResults() {
78+
const { results } = useInstantSearch();
79+
if (!results?.query || results.nbHits > 0) {
80+
return null;
81+
}
82+
return (
83+
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
84+
No results for{" "}
85+
<strong className="text-foreground">"{results.query}"</strong>
86+
</p>
87+
);
88+
}
89+
90+
function SearchUnavailable() {
91+
return (
92+
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
93+
Add <code className="text-xs">NEXT_PUBLIC_ALGOLIA_APP_ID</code>,{" "}
94+
<code className="text-xs">NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY</code>, and{" "}
95+
<code className="text-xs">NEXT_PUBLIC_ALGOLIA_INDEX_NAME</code> to your
96+
environment to enable search.
97+
</p>
98+
);
99+
}
100+
101+
export function AlgoliaSearch() {
102+
const [isOpen, setIsOpen] = useState(false);
103+
104+
useEffect(() => {
105+
const handler = (e: KeyboardEvent) => {
106+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
107+
e.preventDefault();
108+
setIsOpen((prev) => !prev);
109+
}
110+
if (e.key === "Escape") {
111+
setIsOpen(false);
112+
}
113+
};
114+
window.addEventListener("keydown", handler);
115+
return () => window.removeEventListener("keydown", handler);
116+
}, []);
117+
118+
return (
119+
<>
120+
<button
121+
aria-label="Search docs"
122+
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"
123+
onClick={() => setIsOpen(true)}
124+
type="button"
125+
>
126+
<Search className="size-3.5 shrink-0" />
127+
<span className="flex-1 text-left">Search</span>
128+
<kbd className="flex items-center gap-0.5 rounded border border-border px-1 text-xs text-muted-foreground">
129+
<span></span>K
130+
</kbd>
131+
</button>
132+
133+
{isOpen && (
134+
<div
135+
aria-label="Search"
136+
aria-modal="true"
137+
className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[10vh]"
138+
role="dialog"
139+
>
140+
<button
141+
aria-label="Close search"
142+
className="fixed inset-0 bg-black/30 backdrop-blur-sm dark:bg-black/50"
143+
onClick={() => setIsOpen(false)}
144+
type="button"
145+
/>
146+
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-popover shadow-2xl">
147+
{searchClient && indexName ? (
148+
<InstantSearch indexName={indexName} searchClient={searchClient}>
149+
<div className="flex items-center border-b border-border px-4">
150+
<Search className="size-4 shrink-0 text-muted-foreground" />
151+
<SearchBox
152+
autoFocus
153+
classNames={{
154+
form: "flex flex-1",
155+
input:
156+
"w-full bg-transparent px-3 py-4 text-sm text-foreground placeholder:text-muted-foreground outline-none",
157+
loadingIndicator: "hidden",
158+
reset: "hidden",
159+
root: "flex-1",
160+
submit: "hidden",
161+
}}
162+
placeholder="Search docs…"
163+
/>
164+
</div>
165+
<div className="max-h-[60vh] overflow-y-auto p-2">
166+
<EmptyQuery />
167+
<NoResults />
168+
<Hits
169+
classNames={{ item: "", list: "space-y-0.5", root: "" }}
170+
hitComponent={({ hit }) => (
171+
<SearchHit hit={hit as unknown as HitRecord} />
172+
)}
173+
/>
174+
</div>
175+
</InstantSearch>
176+
) : (
177+
<SearchUnavailable />
178+
)}
179+
</div>
180+
</div>
181+
)}
182+
</>
183+
);
184+
}

app/_components/toolkit-docs/components/documentation-chunk-renderer.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,19 +112,14 @@ const MDX_COMPONENTS = {
112112
// Maximum number of MDX components to cache to prevent unbounded memory growth
113113
const MDX_CACHE_MAX_SIZE = 100;
114114

115-
const mdxCache =
116-
createMdxCache<React.ComponentType<{ components?: typeof MDX_COMPONENTS }>>(
117-
MDX_CACHE_MAX_SIZE
118-
);
115+
const mdxCache = createMdxCache<React.ElementType>(MDX_CACHE_MAX_SIZE);
119116

120117
/**
121118
* Renders MDX content from a string with custom components.
122119
*/
123120
function MdxContent({ content }: { content: string }) {
124121
const source = useMemo(() => stripMdxImports(content), [content]);
125-
const [Component, setComponent] = useState<React.ComponentType<{
126-
components?: typeof MDX_COMPONENTS;
127-
}> | null>(null);
122+
const [Component, setComponent] = useState<React.ElementType | null>(null);
128123
const [error, setError] = useState<string | null>(null);
129124

130125
useEffect(() => {

app/en/references/changelog/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ Self-hosed Arcade developers cannot be grandfathered into the old (insecure) beh
644644

645645
**Toolkits**
646646

647-
- `[bugfix - 🐛]` patching MCP Server template generator for outside the main repo ([PR #460](https://github.com/ArcadeAI/arcade-ai/pull/460))
647+
- `[bugfix - 🐛]` patching MCP Server template generator for outside the main repo
648648
- `[bugfix - 🐛]` Filter out unneeded files/directories before deploying workers ([PR #464](https://github.com/ArcadeAI/arcade-ai/pull/464))
649649

650650
**Platform and Engine**

app/globals.css

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ nav > a[aria-label="Home page"] {
145145
margin-inline-end: 3.5rem !important;
146146
}
147147

148-
nav > div:has(.nextra-search) {
148+
nav > div:has(.algolia-search-button) {
149149
order: -1;
150150
margin-inline-start: 1rem;
151151
margin-inline-end: auto;
@@ -157,6 +157,21 @@ nav > div:has(.nextra-search) {
157157
}
158158
}
159159

160+
/* Algolia search hit highlight — brand red */
161+
.ais-Highlight-highlighted,
162+
.ais-Snippet-highlighted {
163+
background: color-mix(in oklch, var(--primary) 15%, transparent);
164+
color: var(--primary);
165+
border-radius: 2px;
166+
font-style: normal;
167+
font-weight: 600;
168+
}
169+
170+
.dark .ais-Highlight-highlighted,
171+
.dark .ais-Snippet-highlighted {
172+
background: color-mix(in oklch, var(--primary) 20%, transparent);
173+
}
174+
160175
/* Override Nextra code highlight colors to use green instead of red */
161176
[data-highlighted-line] {
162177
background-color: color-mix(in oklab, #22c55e 20%, transparent) !important;

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getDictionary } from "@/_dictionaries/get-dictionary";
2+
import { AlgoliaSearch } from "@/app/_components/algolia-search";
23
import { SignupLink } from "@/app/_components/analytics";
34
import CustomLayout from "@/app/_components/custom-layout";
45
import { getDashboardUrl } from "@/app/_components/dashboard-link";
@@ -171,6 +172,7 @@ export default async function RootLayout({
171172
}
172173
nextThemes={{ defaultTheme: "dark" }}
173174
pageMap={pageMap}
175+
search={<AlgoliaSearch />}
174176
sidebar={{
175177
defaultMenuCollapseLevel: 2,
176178
autoCollapse: true,

middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,6 @@ export function middleware(request: NextRequest) {
239239

240240
export const config = {
241241
matcher: [
242-
"/((?!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).*)",
242+
"/((?!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).*)",
243243
],
244244
};

next.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { remarkGlossary } from "./lib/remark-glossary";
77
const withNextra = nextra({
88
defaultShowCopyCode: true,
99
codeHighlight: true,
10+
search: false,
1011
mdxOptions: {
1112
remarkPlugins: [
1213
[

package.json

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,16 @@
55
"type": "module",
66
"scripts": {
77
"dev": "next dev --webpack",
8-
"build": "pnpm run toolkit-markdown && next build --webpack && pnpm run custompagefind",
8+
"build": "pnpm run toolkit-markdown && next build --webpack",
99
"start": "next start",
1010
"lint": "pnpm exec ultracite check",
1111
"format": "pnpm exec ultracite fix",
1212
"prepare": "husky install",
1313
"toolkit-markdown": "pnpm dlx tsx toolkit-docs-generator/scripts/generate-toolkit-markdown.ts",
14-
"postbuild": "if [ \"$SKIP_POSTBUILD\" != \"true\" ]; then pnpm run generate:markdown && pnpm run custompagefind; fi",
14+
"postbuild": "if [ \"$SKIP_POSTBUILD\" != \"true\" ]; then pnpm run generate:markdown; fi",
1515
"generate:markdown": "pnpm dlx tsx scripts/generate-clean-markdown.ts",
1616
"translate": "pnpm dlx tsx scripts/i18n-sync/index.ts && pnpm format",
1717
"llmstxt": "pnpm dlx tsx scripts/generate-llmstxt.ts",
18-
"custompagefind": "pnpm dlx tsx scripts/pagefind.ts",
1918
"test": "vitest --run",
2019
"test:watch": "vitest --watch",
2120
"vale": "vale",
@@ -49,6 +48,7 @@
4948
"@ory/client": "1.22.7",
5049
"@theguild/remark-mermaid": "0.3.0",
5150
"@uidotdev/usehooks": "2.4.1",
51+
"algoliasearch": "^5.49.1",
5252
"chalk": "^5.6.2",
5353
"lucide-react": "0.548.0",
5454
"mdast-util-to-string": "4.0.0",
@@ -61,6 +61,7 @@
6161
"react": "19.2.3",
6262
"react-dom": "19.2.3",
6363
"react-hook-form": "7.65.0",
64+
"react-instantsearch": "^7.26.0",
6465
"react-markdown": "^10.1.0",
6566
"react-syntax-highlighter": "16.1.0",
6667
"remark-gfm": "^4.0.1",
@@ -93,12 +94,8 @@
9394
"next-validate-link": "1.6.3",
9495
"openai": "6.7.0",
9596
"ora": "9.0.0",
96-
"pagefind": "1.4.0",
9797
"picocolors": "1.1.1",
9898
"postcss": "8.5.6",
99-
"rehype-stringify": "^10.0.1",
100-
"remark": "^15.0.1",
101-
"remark-rehype": "^11.1.2",
10299
"tailwindcss": "4.1.16",
103100
"turndown": "^7.2.2",
104101
"typescript": "5.9.3",

0 commit comments

Comments
 (0)