Skip to content

Commit 53beb62

Browse files
lennessyyprasek
authored andcommitted
feat: add SDK language filter to algolia search (temporalio#4154)
* feat: algolia filter draft * make pretty * add dark mode * implement keyboard controls * restore functions * enhance results grouping * fix anchor pixels * fix blurry anchor problem * fix anchor issues * components refactor and fix hit heading level issue * add search page * add collapsible filter * small bug fixes * a11y improvements
1 parent f46f7cb commit 53beb62

15 files changed

Lines changed: 4152 additions & 2290 deletions

docusaurus.config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,19 @@ module.exports = async function createConfigAsync() {
221221
// contextualSearch: true, // Optional; if you have different version of docs etc (v1 and v2), doesn't display dup results
222222
appId: 'T5D6KNJCQS', // Optional, if you run the DocSearch crawler on your own
223223
// algoliaOptions: {}, // Optional, if provided by Algolia
224+
searchPagePath: false, // Disable default search page - using custom implementation at src/pages/search.tsx
224225
insights: true,
226+
searchParameters: {
227+
attributesToRetrieve: [
228+
'hierarchy',
229+
'content',
230+
'anchor',
231+
'url',
232+
'url_without_anchor',
233+
'type',
234+
'sdk_language',
235+
],
236+
},
225237
},
226238
},
227239
presets: [

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,19 @@
6060
"@mdx-js/react": "^3.0.0",
6161
"@types/react": "^19.1.0",
6262
"@types/react-dom": "^19.1.0",
63-
"js-yaml": "^4.1.1",
6463
"algoliasearch": "^5.40.0",
6564
"chart.js": "^4.4.1",
6665
"clsx": "^1.1.1",
6766
"comlink": "^4.4.2",
6867
"concurrently": "^8.2.2",
6968
"docusaurus-plugin-llms": "^0.2.0",
7069
"docusaurus-pushfeedback": "^1.0.3",
70+
"js-yaml": "^4.1.1",
7171
"path": "^0.12.7",
7272
"react": "^19.1.0",
7373
"react-dom": "^19.1.0",
7474
"react-icons": "^5.5.0",
75+
"react-instantsearch": "^7.22.1",
7576
"react-markdown": "^10.1.0",
7677
"react-player": "^2.6.0",
7778
"rehype-katex": "7",
@@ -85,9 +86,9 @@
8586
},
8687
"devDependencies": {
8788
"@playwright/test": "^1.55.1",
88-
"dprint": "^0.45.0",
8989
"@types/node": "^24.5.1",
9090
"@types/yaml": "^1.9.7",
91+
"dprint": "^0.45.0",
9192
"eslint": "^7.32.0",
9293
"eslint-plugin-react": "^7.23.2",
9394
"husky": "^9.1.7",

src/pages/search.tsx

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import React, { useEffect, useState, useRef, useCallback } from 'react';
2+
import Layout from '@theme/Layout';
3+
import { useLocation, useHistory } from '@docusaurus/router';
4+
import {
5+
InstantSearch,
6+
useInfiniteHits,
7+
useSearchBox,
8+
useStats,
9+
Highlight,
10+
Configure,
11+
} from 'react-instantsearch';
12+
import { liteClient as algoliasearch } from 'algoliasearch/lite';
13+
import { SDK_LANGUAGES, getInitialLanguageFilter, SDK_LANGUAGE_STORAGE_KEY } from '../theme/SearchBar/SDKLanguageFilter';
14+
import '../theme/SearchBar/styles.css';
15+
16+
const ALGOLIA_APP_ID = 'T5D6KNJCQS';
17+
const ALGOLIA_API_KEY = '4a2fa646f476d7756a7cdc599b625bec';
18+
const ALGOLIA_INDEX_NAME = 'temporal';
19+
20+
const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY);
21+
22+
// Get the appropriate hierarchy attribute based on hit type
23+
function getHierarchyAttribute(hit: any): string {
24+
if (hit.type && hit.type.startsWith('lvl')) {
25+
return `hierarchy.${hit.type}`;
26+
}
27+
const levels = ['lvl6', 'lvl5', 'lvl4', 'lvl3', 'lvl2', 'lvl1'];
28+
for (const level of levels) {
29+
if (hit.hierarchy?.[level]) {
30+
return `hierarchy.${level}`;
31+
}
32+
}
33+
return 'hierarchy.lvl1';
34+
}
35+
36+
// Build breadcrumb path from hierarchy
37+
function getBreadcrumbPath(hit: any): string[] {
38+
const path: string[] = [];
39+
const hierarchyAttr = getHierarchyAttribute(hit);
40+
const currentLevel = hierarchyAttr.replace('hierarchy.', '');
41+
const levelNum = parseInt(currentLevel.replace('lvl', ''), 10);
42+
43+
// Add levels from lvl0 up to (but not including) the current level
44+
for (let i = 0; i < levelNum; i++) {
45+
const levelKey = `lvl${i}`;
46+
if (hit.hierarchy?.[levelKey]) {
47+
path.push(hit.hierarchy[levelKey]);
48+
}
49+
}
50+
51+
return path;
52+
}
53+
54+
function SearchResultItem({ hit }: { hit: any }) {
55+
const history = useHistory();
56+
const hierarchyAttr = getHierarchyAttribute(hit);
57+
const breadcrumbs = getBreadcrumbPath(hit);
58+
59+
const handleClick = (e: React.MouseEvent) => {
60+
e.preventDefault();
61+
const fullUrl = hit.url || hit.objectID;
62+
try {
63+
const url = new URL(fullUrl, window.location.origin);
64+
history.push(url.pathname + url.hash);
65+
} catch {
66+
history.push(fullUrl);
67+
}
68+
};
69+
70+
return (
71+
<a
72+
href={hit.url || hit.objectID}
73+
onClick={handleClick}
74+
className="search-page-result"
75+
>
76+
<div className="search-page-result-title">
77+
<Highlight attribute={hierarchyAttr} hit={hit} />
78+
</div>
79+
{breadcrumbs.length > 0 && (
80+
<div className="search-page-result-breadcrumb">
81+
{breadcrumbs.map((crumb, index) => (
82+
<React.Fragment key={index}>
83+
{index > 0 && <span className="search-page-breadcrumb-separator"></span>}
84+
<span>{crumb}</span>
85+
</React.Fragment>
86+
))}
87+
</div>
88+
)}
89+
</a>
90+
);
91+
}
92+
93+
function SearchResultsList() {
94+
const { items, isLastPage, showMore } = useInfiniteHits();
95+
const { query } = useSearchBox();
96+
const { nbHits } = useStats();
97+
const sentinelRef = useRef<HTMLDivElement>(null);
98+
99+
// Infinite scroll using Intersection Observer
100+
useEffect(() => {
101+
const sentinel = sentinelRef.current;
102+
if (!sentinel) return;
103+
104+
const observer = new IntersectionObserver(
105+
(entries) => {
106+
entries.forEach((entry) => {
107+
if (entry.isIntersecting && !isLastPage) {
108+
showMore();
109+
}
110+
});
111+
},
112+
{ rootMargin: '100px' }
113+
);
114+
115+
observer.observe(sentinel);
116+
return () => observer.disconnect();
117+
}, [isLastPage, showMore]);
118+
119+
if (!query) {
120+
return (
121+
<div className="search-page-empty">
122+
Enter a search term to find documentation
123+
</div>
124+
);
125+
}
126+
127+
if (items.length === 0) {
128+
return (
129+
<div className="search-page-no-results">
130+
No results found for "{query}"
131+
</div>
132+
);
133+
}
134+
135+
return (
136+
<>
137+
<div className="search-page-stats">
138+
{nbHits.toLocaleString()} documents found
139+
</div>
140+
<div className="search-page-results">
141+
{items.map((hit: any) => (
142+
<SearchResultItem key={hit.objectID} hit={hit} />
143+
))}
144+
</div>
145+
{/* Sentinel element for infinite scroll */}
146+
<div ref={sentinelRef} className="search-page-sentinel">
147+
{!isLastPage && (
148+
<div className="search-page-loading">Loading more results...</div>
149+
)}
150+
</div>
151+
</>
152+
);
153+
}
154+
155+
function SearchInput() {
156+
const { query, refine } = useSearchBox();
157+
const location = useLocation();
158+
const history = useHistory();
159+
const [inputValue, setInputValue] = useState(query);
160+
161+
// Sync URL query param with search
162+
useEffect(() => {
163+
const params = new URLSearchParams(location.search);
164+
const urlQuery = params.get('q') || '';
165+
if (urlQuery !== query) {
166+
refine(urlQuery);
167+
setInputValue(urlQuery);
168+
}
169+
}, [location.search]);
170+
171+
// Update URL when query changes
172+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
173+
const newQuery = e.target.value;
174+
setInputValue(newQuery);
175+
refine(newQuery);
176+
177+
const params = new URLSearchParams(location.search);
178+
if (newQuery) {
179+
params.set('q', newQuery);
180+
} else {
181+
params.delete('q');
182+
}
183+
history.replace({ search: params.toString() });
184+
};
185+
186+
return (
187+
<div className="search-page-input-wrapper">
188+
<input
189+
type="search"
190+
value={inputValue}
191+
onChange={handleInputChange}
192+
placeholder="Search documentation..."
193+
className="search-page-input"
194+
autoFocus
195+
/>
196+
</div>
197+
);
198+
}
199+
200+
function LanguageFilter({ selectedLanguages, onLanguageChange }: {
201+
selectedLanguages: string[];
202+
onLanguageChange: (languages: string[]) => void;
203+
}) {
204+
const toggleLanguage = (langId: string) => {
205+
const newSelection = selectedLanguages.includes(langId)
206+
? selectedLanguages.filter((id) => id !== langId)
207+
: [...selectedLanguages, langId];
208+
onLanguageChange(newSelection);
209+
};
210+
211+
const clearAll = () => {
212+
onLanguageChange([]);
213+
};
214+
215+
const selectedLabels = selectedLanguages
216+
.map(id => SDK_LANGUAGES.find(lang => lang.id === id)?.label)
217+
.filter(Boolean)
218+
.join(', ');
219+
220+
return (
221+
<div className="search-page-language-filter">
222+
<div className="search-page-language-filter-header">
223+
<span className="search-page-language-filter-title">Filter by SDK</span>
224+
{selectedLanguages.length > 0 && (
225+
<button
226+
className="search-page-language-filter-clear"
227+
onClick={clearAll}
228+
type="button"
229+
>
230+
Clear all
231+
</button>
232+
)}
233+
</div>
234+
<div className="search-page-language-filter-options">
235+
{SDK_LANGUAGES.map((lang) => (
236+
<label key={lang.id} className="search-page-language-filter-option">
237+
<input
238+
type="checkbox"
239+
checked={selectedLanguages.includes(lang.id)}
240+
onChange={() => toggleLanguage(lang.id)}
241+
/>
242+
<span>{lang.label}</span>
243+
</label>
244+
))}
245+
</div>
246+
{selectedLanguages.length > 0 && (
247+
<div className="search-page-language-filter-note">
248+
Showing {selectedLabels} and language-agnostic content
249+
</div>
250+
)}
251+
</div>
252+
);
253+
}
254+
255+
function SearchPageContent() {
256+
const location = useLocation();
257+
const params = new URLSearchParams(location.search);
258+
const initialQuery = params.get('q') || '';
259+
260+
const [selectedLanguages, setSelectedLanguages] = useState<string[]>(() => getInitialLanguageFilter());
261+
262+
const handleLanguageChange = (languages: string[]) => {
263+
setSelectedLanguages(languages);
264+
try {
265+
localStorage.setItem(SDK_LANGUAGE_STORAGE_KEY, JSON.stringify(languages));
266+
} catch (e) {
267+
console.error('Failed to save language filter:', e);
268+
}
269+
};
270+
271+
// Build facet filters based on selected languages
272+
const facetFilters = selectedLanguages.length > 0
273+
? SDK_LANGUAGES
274+
.filter(lang => !selectedLanguages.includes(lang.id))
275+
.map(lang => `sdk_language:-${lang.id}`)
276+
: undefined;
277+
278+
return (
279+
<div className="search-page-container">
280+
<h1 className="search-page-title">
281+
Search results{initialQuery && <> for "{initialQuery}"</>}
282+
</h1>
283+
<InstantSearch
284+
searchClient={searchClient}
285+
indexName={ALGOLIA_INDEX_NAME}
286+
initialUiState={{
287+
[ALGOLIA_INDEX_NAME]: {
288+
query: initialQuery,
289+
},
290+
}}
291+
>
292+
<Configure
293+
hitsPerPage={50}
294+
facetFilters={facetFilters}
295+
attributesToRetrieve={[
296+
'hierarchy',
297+
'content',
298+
'anchor',
299+
'url',
300+
'url_without_anchor',
301+
'type',
302+
'sdk_language',
303+
]}
304+
/>
305+
<SearchInput />
306+
<LanguageFilter
307+
selectedLanguages={selectedLanguages}
308+
onLanguageChange={handleLanguageChange}
309+
/>
310+
<SearchResultsList />
311+
</InstantSearch>
312+
</div>
313+
);
314+
}
315+
316+
export default function SearchPage() {
317+
return (
318+
<Layout title="Search" description="Search Temporal documentation">
319+
<SearchPageContent />
320+
</Layout>
321+
);
322+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react';
2+
import { useSearchBox } from 'react-instantsearch';
3+
4+
export function ClearButton() {
5+
const { query, refine } = useSearchBox();
6+
7+
if (!query) return null;
8+
9+
return (
10+
<button
11+
className="custom-search-clear"
12+
onClick={() => refine('')}
13+
type="button"
14+
>
15+
Clear the query
16+
</button>
17+
);
18+
}

0 commit comments

Comments
 (0)