mirror of
https://github.com/zitadel/zitadel.git
synced 2026-11-03 12:47:09 -06:00
docs: replace built in fumadocs search with custom semantic vector search (#12862)
**Description:**
Replaces Fumadocs' default client-side Orama search with our server-side
Postgres vector search backend.
**Key Changes:**
- API Proxy (app/api/search/route.ts): Added a secure proxy to the
search backend with IP rate-limiting and Edge caching.
- Custom Search UI (custom-search-dialog.tsx): Built a headless Fumadocs
search dialog featuring 300ms debouncing, request cancellation
(AbortController), and URL deduplication.
- Smart Highlighting: Added the <HighlightMatch> component to render
exact matches with Tailwind styling inside the search results.
- Providers: Hooked the new custom dialog into the RootProvider.
(cherry picked from commit ab3b44cd74)
This commit is contained in:
1 parent
a224988e4c
commit
7c87eb4c6a
3 files changed
+276
-60
No files matched your search
@@ -1,50 +1,58 @@
|
||||
import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
|
||||
import { createSearchAPI } from 'fumadocs-core/search/server';
|
||||
import { source, versionSource } from '@/lib/source';
|
||||
import { getVersionFromUrl, LATEST_VERSION } from '@/lib/versions';
|
||||
// app/api/search/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// Both loaders share one index. Every entry is tagged with its docs version so the
|
||||
// client can scope results to the version being viewed (see app/providers.tsx).
|
||||
function indexPages(loader: typeof source | typeof versionSource, getTag: (url: string) => string) {
|
||||
const tree = loader.getPageTree();
|
||||
|
||||
return loader.getPages().map((page) => ({
|
||||
id: page.url,
|
||||
url: page.url,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
structuredData: page.data.structuredData,
|
||||
// Sidebar path (e.g. "Deploy & Operate › Self-Hosted"), shown with each result.
|
||||
breadcrumbs: getBreadcrumbItems(page.url, tree)
|
||||
.map((item) => item.name)
|
||||
.filter((name): name is string => typeof name === 'string'),
|
||||
tag: getTag(page.url),
|
||||
}));
|
||||
}
|
||||
|
||||
const searchAPI = createSearchAPI('advanced', {
|
||||
// https://docs.orama.com/docs/orama-js/supported-languages
|
||||
language: 'english',
|
||||
indexes: [
|
||||
...indexPages(source, () => LATEST_VERSION),
|
||||
...indexPages(versionSource, getVersionFromUrl),
|
||||
],
|
||||
});
|
||||
|
||||
// fumadocs' built-in default. Its endpoint passes `limit: undefined` when the URL
|
||||
// has no `?limit=`, which overrides that default and returns every match (450
|
||||
// entries for "oidc").
|
||||
const DEFAULT_LIMIT = '60';
|
||||
|
||||
// Results only change with a deploy, so the CDN answers repeated queries (cached per
|
||||
// URL, i.e. per query and tag) instead of a function that may first have to rebuild
|
||||
// the index on a cold start. A static index is not an option: one version alone
|
||||
// serializes to over 20 MB of JSON.
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
if (!url.searchParams.has('limit')) url.searchParams.set('limit', DEFAULT_LIMIT);
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
const searchType = searchParams.get('type') || 'docs';
|
||||
|
||||
const response = await searchAPI.GET(new Request(url));
|
||||
response.headers.set('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate=604800');
|
||||
return response;
|
||||
if (!query || query.trim() === '') {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
const safeQuery = query.substring(0, 150).trim();
|
||||
|
||||
// Extract original client IP to pass down to the Express rate limiter
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : '127.0.0.1';
|
||||
|
||||
const baseUrl = process.env.DOCS_SEARCH_URL || 'http://localhost:8080';
|
||||
const cleanBaseUrl = baseUrl.replace(/\/+$/, '');
|
||||
|
||||
const backendUrl = new URL(`${cleanBaseUrl}/api/search/docs`);
|
||||
backendUrl.searchParams.set('q', safeQuery);
|
||||
backendUrl.searchParams.set('limit', '15');
|
||||
backendUrl.searchParams.set('type', searchType);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch(backendUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.DOCS_SEARCH_SECRET}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forwarded-For': clientIp // Forwarding IP to Express
|
||||
},
|
||||
signal: controller.signal,
|
||||
next: { revalidate: 300 }
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Search service unavailable' }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Proxy Search Error:', error.name === 'AbortError' ? 'Timeout' : error.message);
|
||||
const status = error.name === 'AbortError' ? 504 : 500;
|
||||
return NextResponse.json({ error: 'Search service connection failed' }, { status });
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,18 @@
|
||||
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import { ReactNode } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import MixpanelProvider from '@/components/mixpanel-provider';
|
||||
import PlausibleProvider from '@/components/plausible-provider';
|
||||
import { getVersionFromSlug } from '@/lib/versions';
|
||||
|
||||
// Import our new custom dialog
|
||||
import CustomSearchDialog from '@/components/custom-search-dialog';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const params = useParams();
|
||||
const slug = params?.slug as string[] | undefined;
|
||||
|
||||
return (
|
||||
<RootProvider
|
||||
search={{
|
||||
options: {
|
||||
api: '/docs/api/search',
|
||||
// Every search index entry is tagged with its docs version (see
|
||||
// app/api/search/route.ts). `defaultTag` applies that filter; `tags`
|
||||
// would only add a visible filter pill.
|
||||
defaultTag: getVersionFromSlug(slug),
|
||||
},
|
||||
// Replace Fumadocs' default Orama Dialog with our Custom Debounced Dialog
|
||||
SearchDialog: CustomSearchDialog,
|
||||
}}
|
||||
>
|
||||
<PlausibleProvider />
|
||||
@@ -30,4 +22,4 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
</MixpanelProvider>
|
||||
</RootProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, ReactNode } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import {
|
||||
SearchDialog,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogHeader,
|
||||
SearchDialogIcon,
|
||||
SearchDialogInput,
|
||||
SearchDialogList,
|
||||
SearchDialogOverlay,
|
||||
type SharedProps,
|
||||
} from 'fumadocs-ui/components/dialog/search';
|
||||
import { getVersionFromSlug } from '@/lib/versions';
|
||||
|
||||
type SearchResultItem = {
|
||||
id: string;
|
||||
content: ReactNode;
|
||||
url: string;
|
||||
type: 'page';
|
||||
};
|
||||
|
||||
function escapeRegExp(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
const HighlightMatch = ({ text, query }: { text: string; query: string }) => {
|
||||
if (!query || !text) {
|
||||
return <span className="text-xs text-muted-foreground line-clamp-2">{text}</span>;
|
||||
}
|
||||
|
||||
const safeRegex = new RegExp(`(${escapeRegExp(query)})`, 'gi');
|
||||
const parts = text.split(safeRegex);
|
||||
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-primary/20 text-primary font-semibold rounded-sm px-1 py-0.5"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CustomSearchDialog(props: SharedProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [searchType, setSearchType] = useState<'docs' | 'api'>('docs');
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// New error state to track backend failures
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const params = useParams();
|
||||
const slug = params?.slug as string[] | undefined;
|
||||
const currentVersion = getVersionFromSlug(slug);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
}, 300);
|
||||
return () => clearTimeout(handler);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery.trim()) {
|
||||
setResults([]);
|
||||
setIsLoading(false);
|
||||
setErrorMsg(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMsg(null);
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function fetchResults() {
|
||||
try {
|
||||
const searchUrl = `/docs/api/search?q=${encodeURIComponent(debouncedQuery)}&type=${searchType}&tag=${encodeURIComponent(currentVersion)}`;
|
||||
const res = await fetch(searchUrl, { signal: abortController.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) throw new Error('Too many requests. Please slow down.');
|
||||
throw new Error('Search service is currently unavailable.');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const seenUrls = new Set<string>();
|
||||
const formattedResults: SearchResultItem[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
const targetUrl = item.url;
|
||||
|
||||
if (!seenUrls.has(targetUrl)) {
|
||||
seenUrls.add(targetUrl);
|
||||
|
||||
formattedResults.push({
|
||||
id: targetUrl,
|
||||
content: (
|
||||
<div className="flex flex-col gap-1 py-0.5">
|
||||
<span className="font-medium text-foreground">
|
||||
{item.title || 'Documentation Page'}
|
||||
</span>
|
||||
<HighlightMatch text={item.description || ''} query={debouncedQuery} />
|
||||
</div>
|
||||
),
|
||||
url: targetUrl,
|
||||
type: 'page'
|
||||
});
|
||||
|
||||
if (formattedResults.length >= 15) break;
|
||||
}
|
||||
}
|
||||
|
||||
setResults(formattedResults);
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Search error:', err);
|
||||
setErrorMsg(err.message);
|
||||
setResults([]);
|
||||
}
|
||||
} finally {
|
||||
// Fix: Only clear loading if this specific request wasn't superseded/aborted
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchResults();
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [debouncedQuery, searchType, currentVersion]);
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
{...props}
|
||||
search={query}
|
||||
onSearchChange={setQuery}
|
||||
>
|
||||
<SearchDialogOverlay />
|
||||
<SearchDialogContent>
|
||||
<SearchDialogHeader className="flex flex-col gap-2 border-b pb-2">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<SearchDialogIcon />
|
||||
<SearchDialogInput placeholder={searchType === 'docs' ? 'Search documentation...' : 'Search API endpoints...'} />
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center px-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchDialogClose />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start w-full gap-1 px-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchType('docs')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${searchType === 'docs'
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
Guides & Docs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchType('api')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${searchType === 'api'
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
API Endpoints
|
||||
</button>
|
||||
</div>
|
||||
</SearchDialogHeader>
|
||||
|
||||
{results.length > 0 && <SearchDialogList items={results} />}
|
||||
|
||||
{/* Explicit Error State */}
|
||||
{errorMsg && !isLoading && (
|
||||
<div className="p-6 text-center text-sm text-red-500 font-medium">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!debouncedQuery && !errorMsg && (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
Type a query to search {searchType === 'docs' ? 'Guides & Docs' : 'API Endpoints'}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && debouncedQuery && results.length === 0 && !errorMsg && (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
No results found in {searchType === 'docs' ? 'Docs' : 'API Endpoints'} for "<span className="font-semibold text-foreground">{debouncedQuery}</span>".
|
||||
</div>
|
||||
)}
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user