Skip to content

Commit ec5bef3

Browse files
spike(search): add MiniSearch-backed custom docs content search
1 parent 2e1bc9a commit ec5bef3

File tree

4 files changed

+150
-2
lines changed

4 files changed

+150
-2
lines changed

packages/core/eventcatalog/src/components/Search/SearchModal.tsx

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { Fragment, useState, useEffect, useMemo, useRef } from 'react';
2+
import MiniSearch from 'minisearch';
23
import { Combobox, Dialog, Transition } from '@headlessui/react';
34
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid';
45
import {
@@ -42,6 +43,7 @@ const typeIcons: any = {
4243
AsyncAPI: DocumentTextIcon,
4344
Design: Square2StackIcon,
4445
Container: CircleStackIcon,
46+
Doc: DocumentTextIcon,
4547
default: DocumentTextIcon,
4648
};
4749

@@ -61,6 +63,7 @@ const typeColors: any = {
6163
AsyncAPI: 'text-violet-500 dark:text-violet-400 bg-violet-50 dark:bg-violet-500/10 ring-violet-200 dark:ring-violet-500/30',
6264
Design: 'text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-500/10 ring-gray-200 dark:ring-gray-500/30',
6365
Container: 'text-indigo-500 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 ring-indigo-200 dark:ring-indigo-500/30',
66+
Doc: 'text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-500/10 ring-slate-200 dark:ring-slate-500/30',
6467
default: 'text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-500/10 ring-gray-200 dark:ring-gray-500/30',
6568
};
6669

@@ -118,6 +121,17 @@ interface SearchIndexPayload {
118121
items?: SearchNode[];
119122
}
120123

124+
interface DocsSearchIndexItemCompact {
125+
i: string;
126+
t: string;
127+
u: string;
128+
c: string;
129+
}
130+
131+
interface DocsSearchIndexPayload {
132+
i?: DocsSearchIndexItemCompact[];
133+
}
134+
121135
const normalizeSearchIndexPayload = (payload: SearchIndexPayload): SearchNode[] => {
122136
if (payload.i) {
123137
return payload.i.map((item) => ({
@@ -137,7 +151,9 @@ export default function SearchModal() {
137151
const [open, setOpen] = useState(false);
138152
const [activeFilter, setActiveFilter] = useState('all');
139153
const [searchNodes, setSearchNodes] = useState<SearchNode[]>([]);
154+
const [docsIndexItems, setDocsIndexItems] = useState<DocsSearchIndexItemCompact[]>([]);
140155
const [isLoadingSearchIndex, setIsLoadingSearchIndex] = useState(false);
156+
const [isLoadingDocsIndex, setIsLoadingDocsIndex] = useState(false);
141157
const [searchIndexLoadError, setSearchIndexLoadError] = useState<string | null>(null);
142158
const favorites = useStore(favoritesStore);
143159
const inputRef = useRef<HTMLInputElement>(null);
@@ -185,6 +201,33 @@ export default function SearchModal() {
185201
});
186202
}, [open, searchNodes.length, isLoadingSearchIndex]);
187203

204+
useEffect(() => {
205+
if (!open || query.trim().length < 2 || docsIndexItems.length > 0 || isLoadingDocsIndex) {
206+
return;
207+
}
208+
209+
setIsLoadingDocsIndex(true);
210+
211+
const apiUrl = buildUrl('/api/search-docs-index.json', true);
212+
213+
fetch(apiUrl)
214+
.then((response) => {
215+
if (!response.ok) {
216+
throw new Error(`Failed to fetch docs search index: ${response.status}`);
217+
}
218+
return response.json() as Promise<DocsSearchIndexPayload>;
219+
})
220+
.then((payload) => {
221+
setDocsIndexItems(payload.i || []);
222+
})
223+
.catch(() => {
224+
// non-blocking for regular search experience
225+
})
226+
.finally(() => {
227+
setIsLoadingDocsIndex(false);
228+
});
229+
}, [open, query, docsIndexItems.length, isLoadingDocsIndex]);
230+
188231
const closeModal = () => {
189232
if ((window as any).searchModalState) {
190233
(window as any).searchModalState.close();
@@ -295,6 +338,28 @@ export default function SearchModal() {
295338
return new Map(searchNodes.map((node) => [node.key, node]));
296339
}, [searchNodes]);
297340

341+
const docsMiniSearch = useMemo(() => {
342+
if (docsIndexItems.length === 0) return null;
343+
344+
const index = new MiniSearch<DocsSearchIndexItemCompact>({
345+
fields: ['t', 'c'],
346+
storeFields: ['i', 't', 'u', 'c'],
347+
searchOptions: {
348+
fuzzy: 0.2,
349+
prefix: true,
350+
},
351+
});
352+
353+
index.addAll(docsIndexItems);
354+
return index;
355+
}, [docsIndexItems]);
356+
357+
const docsSearchResults = useMemo<DocsSearchIndexItemCompact[]>(() => {
358+
if (!docsMiniSearch || query.trim().length < 2) return [];
359+
360+
return docsMiniSearch.search(query, { fuzzy: 0.2, prefix: true, boost: { t: 3, c: 1 } }).slice(0, 20) as any;
361+
}, [docsMiniSearch, query]);
362+
298363
const filteredItems = useMemo(() => {
299364
if (query === '') {
300365
// Show favorites when search is empty
@@ -324,6 +389,17 @@ export default function SearchModal() {
324389
// Start with searchable items (already filtered by query)
325390
let result = searchableItems;
326391

392+
const docsItems = docsSearchResults.map((doc: DocsSearchIndexItemCompact) => ({
393+
id: `doc:${doc.i}`,
394+
name: doc.t,
395+
url: buildUrl(doc.u),
396+
type: 'Doc',
397+
key: `doc:${doc.i}`,
398+
rawNode: {
399+
summary: doc.c.slice(0, 120),
400+
},
401+
}));
402+
327403
// Apply type filter
328404
if (activeFilter !== 'all') {
329405
if (activeFilter === 'Message') {
@@ -335,8 +411,10 @@ export default function SearchModal() {
335411
}
336412
}
337413

338-
return result.slice(0, 50); // Limit results for performance
339-
}, [searchableItems, query, activeFilter, favorites, searchNodeLookup]);
414+
const merged = activeFilter === 'all' ? [...result, ...docsItems] : result;
415+
416+
return merged.slice(0, 50); // Limit results for performance
417+
}, [searchableItems, query, activeFilter, favorites, searchNodeLookup, docsSearchResults]);
340418

341419
return (
342420
<Transition.Root
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { APIRoute } from 'astro';
2+
import { getCollection } from 'astro:content';
3+
4+
const isDev = import.meta.env.DEV;
5+
6+
interface DocsSearchItemCompact {
7+
i: string;
8+
t: string;
9+
u: string;
10+
c: string;
11+
}
12+
13+
const MAX_CONTENT_LENGTH = 5000;
14+
15+
const stripMarkdown = (value: string) => {
16+
return value
17+
.replace(/```[\s\S]*?```/g, ' ')
18+
.replace(/`[^`]*`/g, ' ')
19+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
20+
.replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
21+
.replace(/^#{1,6}\s+/gm, '')
22+
.replace(/[>*_~#-]/g, ' ')
23+
.replace(/\s+/g, ' ')
24+
.trim();
25+
};
26+
27+
const toDocsItem = (entry: any): DocsSearchItemCompact | null => {
28+
const id = entry?.id as string | undefined;
29+
if (!id) return null;
30+
31+
const body = typeof entry?.body === 'string' ? entry.body : '';
32+
const content = stripMarkdown(body).slice(0, MAX_CONTENT_LENGTH);
33+
if (!content) return null;
34+
35+
const title = (entry?.data?.title as string | undefined) || id.split('/').pop() || id;
36+
37+
return {
38+
i: id,
39+
t: title,
40+
u: `/docs/${id}`,
41+
c: content,
42+
};
43+
};
44+
45+
export const GET: APIRoute = async () => {
46+
const [customPages, pages] = await Promise.all([getCollection('customPages'), getCollection('pages')]);
47+
48+
const items = [...customPages, ...pages].map(toDocsItem).filter((item): item is DocsSearchItemCompact => Boolean(item));
49+
50+
return new Response(JSON.stringify({ i: items }), {
51+
headers: {
52+
'Content-Type': 'application/json',
53+
'Cache-Control': isDev
54+
? 'no-cache, no-store, must-revalidate'
55+
: 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
56+
Vary: 'Accept-Encoding',
57+
},
58+
});
59+
};
60+
61+
export const prerender = !isDev;

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
"lucide-react": "^0.453.0",
107107
"marked": "^15.0.6",
108108
"mdast-util-find-and-replace": "^3.0.2",
109+
"minisearch": "^7.1.2",
109110
"mermaid": "^11.12.1",
110111
"nanostores": "^1.1.0",
111112
"pako": "^2.1.0",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)