@seekora-ai/docsearch-react 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/dist/index.d.ts +395 -0
- package/dist/index.esm.js +1030 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +1044 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
2
|
+
import { useRef, useEffect, useCallback, useReducer, useState } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { SeekoraClient } from '@seekora-ai/search-sdk';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Modal component that renders via React Portal to document.body.
|
|
8
|
+
* This ensures the search overlay renders independently of its integration context,
|
|
9
|
+
* avoiding clipping/positioning issues from parent overflow, transform, or stacking contexts.
|
|
10
|
+
*/
|
|
11
|
+
function Modal({ isOpen, onClose, children }) {
|
|
12
|
+
const overlayRef = useRef(null);
|
|
13
|
+
const containerRef = useRef(null);
|
|
14
|
+
// Handle click outside to close
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const handleClickOutside = (event) => {
|
|
17
|
+
if (containerRef.current &&
|
|
18
|
+
!containerRef.current.contains(event.target)) {
|
|
19
|
+
onClose();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
if (isOpen) {
|
|
23
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
24
|
+
}
|
|
25
|
+
return () => {
|
|
26
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
27
|
+
};
|
|
28
|
+
}, [isOpen, onClose]);
|
|
29
|
+
// Lock body scroll when open
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (isOpen) {
|
|
32
|
+
const originalOverflow = document.body.style.overflow;
|
|
33
|
+
document.body.style.overflow = 'hidden';
|
|
34
|
+
return () => {
|
|
35
|
+
document.body.style.overflow = originalOverflow;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}, [isOpen]);
|
|
39
|
+
// Focus trap
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!isOpen || !containerRef.current)
|
|
42
|
+
return;
|
|
43
|
+
const container = containerRef.current;
|
|
44
|
+
const focusableElements = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
45
|
+
const firstElement = focusableElements[0];
|
|
46
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
47
|
+
const handleTabKey = (e) => {
|
|
48
|
+
if (e.key !== 'Tab')
|
|
49
|
+
return;
|
|
50
|
+
if (e.shiftKey) {
|
|
51
|
+
if (document.activeElement === firstElement) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
lastElement?.focus();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
if (document.activeElement === lastElement) {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
firstElement?.focus();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
container.addEventListener('keydown', handleTabKey);
|
|
64
|
+
return () => container.removeEventListener('keydown', handleTabKey);
|
|
65
|
+
}, [isOpen]);
|
|
66
|
+
if (!isOpen || typeof document === 'undefined')
|
|
67
|
+
return null;
|
|
68
|
+
const modalContent = (jsx("div", { ref: overlayRef, className: "seekora-docsearch-overlay", role: "dialog", "aria-modal": "true", "aria-label": "Search documentation", children: jsx("div", { ref: containerRef, className: "seekora-docsearch-container", children: children }) }));
|
|
69
|
+
// Portal to document.body so the modal renders above all site content,
|
|
70
|
+
// independent of parent overflow, transform, or stacking context.
|
|
71
|
+
return createPortal(modalContent, document.body);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function SearchBox({ value, onChange, onKeyDown, placeholder = 'Search documentation...', isLoading = false, onClear, }) {
|
|
75
|
+
const inputRef = useRef(null);
|
|
76
|
+
// Focus input when mounted
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (inputRef.current) {
|
|
79
|
+
inputRef.current.focus();
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
const handleChange = (event) => {
|
|
83
|
+
onChange(event.target.value);
|
|
84
|
+
};
|
|
85
|
+
const handleClear = () => {
|
|
86
|
+
onChange('');
|
|
87
|
+
onClear?.();
|
|
88
|
+
inputRef.current?.focus();
|
|
89
|
+
};
|
|
90
|
+
return (jsxs("div", { className: "seekora-docsearch-searchbox", children: [jsx("label", { className: "seekora-docsearch-searchbox-icon", htmlFor: "seekora-docsearch-input", children: isLoading ? (jsx("span", { className: "seekora-docsearch-spinner", "aria-hidden": "true", children: jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", children: jsx("circle", { cx: "10", cy: "10", r: "8", stroke: "currentColor", strokeWidth: "2", fill: "none", strokeDasharray: "40", strokeDashoffset: "10", children: jsx("animateTransform", { attributeName: "transform", type: "rotate", from: "0 10 10", to: "360 10 10", dur: "0.8s", repeatCount: "indefinite" }) }) }) })) : (jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: jsx("path", { d: "M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z", fill: "currentColor" }) })) }), jsx("input", { ref: inputRef, id: "seekora-docsearch-input", className: "seekora-docsearch-input", type: "text", value: value, onChange: handleChange, onKeyDown: onKeyDown, placeholder: placeholder, autoComplete: "off", autoCorrect: "off", autoCapitalize: "off", spellCheck: "false", "aria-autocomplete": "list", "aria-controls": "seekora-docsearch-results" }), value && (jsx("button", { type: "button", className: "seekora-docsearch-clear", onClick: handleClear, "aria-label": "Clear search", children: jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: jsx("path", { d: "M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z", fill: "currentColor" }) }) }))] }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Renders text with highlighted matches.
|
|
95
|
+
* Expects highlightedValue to contain <mark> tags around matches.
|
|
96
|
+
*/
|
|
97
|
+
function Highlight({ value, highlightedValue }) {
|
|
98
|
+
// If no highlighted value, render plain text
|
|
99
|
+
if (!highlightedValue) {
|
|
100
|
+
return jsx("span", { children: value });
|
|
101
|
+
}
|
|
102
|
+
// Parse the highlighted value and render with mark tags
|
|
103
|
+
// The API returns text with <mark> tags for highlighting
|
|
104
|
+
return (jsx("span", { className: "seekora-docsearch-highlight", dangerouslySetInnerHTML: { __html: sanitizeHtml(highlightedValue) } }));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Sanitize HTML to only allow <mark> tags for highlighting.
|
|
108
|
+
* This prevents XSS attacks while allowing highlighting.
|
|
109
|
+
*/
|
|
110
|
+
function sanitizeHtml(html) {
|
|
111
|
+
// First, escape all HTML
|
|
112
|
+
const escaped = html
|
|
113
|
+
.replace(/&/g, '&')
|
|
114
|
+
.replace(/</g, '<')
|
|
115
|
+
.replace(/>/g, '>')
|
|
116
|
+
.replace(/"/g, '"')
|
|
117
|
+
.replace(/'/g, ''');
|
|
118
|
+
// Then, restore only <mark> and </mark> tags
|
|
119
|
+
return escaped
|
|
120
|
+
.replace(/<mark>/g, '<mark>')
|
|
121
|
+
.replace(/<\/mark>/g, '</mark>')
|
|
122
|
+
// Also handle ais-highlight format (Algolia-style)
|
|
123
|
+
.replace(/<ais-highlight>/g, '<mark>')
|
|
124
|
+
.replace(/<\/ais-highlight>/g, '</mark>')
|
|
125
|
+
// Handle em tags too
|
|
126
|
+
.replace(/<em>/g, '<mark>')
|
|
127
|
+
.replace(/<\/em>/g, '</mark>');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Truncate content and highlight the matched portion.
|
|
131
|
+
*/
|
|
132
|
+
function truncateAroundMatch(content, maxLength = 150) {
|
|
133
|
+
// If content contains a mark tag, try to center around it
|
|
134
|
+
const markIndex = content.indexOf('<mark>');
|
|
135
|
+
if (markIndex === -1 || content.length <= maxLength) {
|
|
136
|
+
// No highlight or short enough, just truncate from start
|
|
137
|
+
if (content.length <= maxLength) {
|
|
138
|
+
return content;
|
|
139
|
+
}
|
|
140
|
+
return content.slice(0, maxLength) + '...';
|
|
141
|
+
}
|
|
142
|
+
// Center around the first match
|
|
143
|
+
const halfLength = Math.floor(maxLength / 2);
|
|
144
|
+
let start = Math.max(0, markIndex - halfLength);
|
|
145
|
+
let end = Math.min(content.length, markIndex + halfLength);
|
|
146
|
+
// Adjust to not cut in the middle of a word
|
|
147
|
+
if (start > 0) {
|
|
148
|
+
const spaceIndex = content.indexOf(' ', start);
|
|
149
|
+
if (spaceIndex !== -1 && spaceIndex < markIndex) {
|
|
150
|
+
start = spaceIndex + 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (end < content.length) {
|
|
154
|
+
const spaceIndex = content.lastIndexOf(' ', end);
|
|
155
|
+
if (spaceIndex !== -1 && spaceIndex > markIndex) {
|
|
156
|
+
end = spaceIndex;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let result = content.slice(start, end);
|
|
160
|
+
if (start > 0) {
|
|
161
|
+
result = '...' + result;
|
|
162
|
+
}
|
|
163
|
+
if (end < content.length) {
|
|
164
|
+
result = result + '...';
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function Hit({ hit, isSelected, onClick, onMouseEnter, openInNewTab, isChild, isLastChild, hierarchyType }) {
|
|
170
|
+
// Determine if this is a full hit or a suggestion
|
|
171
|
+
const isFullHit = 'objectID' in hit;
|
|
172
|
+
// Get suggestion for type checking
|
|
173
|
+
const suggestion = hit;
|
|
174
|
+
// Get the type from prop or from the suggestion itself
|
|
175
|
+
const hitType = hierarchyType || suggestion.type;
|
|
176
|
+
// For admin pages, use category and parentTitle; for docs, breadcrumb is not needed
|
|
177
|
+
// since we group by lvl0 which already shows the path
|
|
178
|
+
const breadcrumb = suggestion.parentTitle
|
|
179
|
+
? `${suggestion.category || ''} › ${suggestion.parentTitle}`.replace(/^› /, '')
|
|
180
|
+
: suggestion.category
|
|
181
|
+
? suggestion.category
|
|
182
|
+
: ''; // Don't show breadcrumb for docs - the group header already shows lvl0
|
|
183
|
+
// Get title based on the type field
|
|
184
|
+
// - type=lvl1: use lvl1 as title (page-level)
|
|
185
|
+
// - type=lvl2: use lvl2 as title (section)
|
|
186
|
+
// - type=lvl3: use lvl3 as title, etc.
|
|
187
|
+
const title = getTitleForType(hit, hitType);
|
|
188
|
+
// Get highlighted values
|
|
189
|
+
let highlightedTitle = title;
|
|
190
|
+
let highlightedContent = hit.content || suggestion.description || '';
|
|
191
|
+
if (isFullHit) {
|
|
192
|
+
const fullHit = hit;
|
|
193
|
+
if (fullHit._highlightResult) {
|
|
194
|
+
highlightedTitle = fullHit._highlightResult.title?.value || title;
|
|
195
|
+
highlightedContent = fullHit._highlightResult.content?.value || hit.content || '';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
if (suggestion.highlight) {
|
|
200
|
+
highlightedTitle = suggestion.highlight.title || title;
|
|
201
|
+
highlightedContent = suggestion.highlight.content || hit.content || suggestion.description || '';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Truncate content around match
|
|
205
|
+
const displayContent = highlightedContent
|
|
206
|
+
? truncateAroundMatch(highlightedContent, 120)
|
|
207
|
+
: '';
|
|
208
|
+
// Determine URL - use route for admin pages if url not set
|
|
209
|
+
const url = hit.url || suggestion.route || '#';
|
|
210
|
+
// Build class names
|
|
211
|
+
const classNames = ['seekora-docsearch-hit'];
|
|
212
|
+
if (isSelected)
|
|
213
|
+
classNames.push('seekora-docsearch-hit--selected');
|
|
214
|
+
if (isChild)
|
|
215
|
+
classNames.push('seekora-docsearch-hit--child');
|
|
216
|
+
if (isLastChild)
|
|
217
|
+
classNames.push('seekora-docsearch-hit--last-child');
|
|
218
|
+
return (jsxs("a", { href: url, className: classNames.join(' '), onClick: (e) => {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
onClick();
|
|
221
|
+
}, onMouseEnter: onMouseEnter, role: "option", "aria-selected": isSelected, target: openInNewTab ? '_blank' : undefined, rel: openInNewTab ? 'noopener noreferrer' : undefined, children: [isChild && (jsx("div", { className: "seekora-docsearch-hit-tree", children: jsx(TreeConnector, { isLast: isLastChild }) })), jsx("div", { className: "seekora-docsearch-hit-icon", children: jsx(HitIcon, { type: getHitTypeFromLevel(hitType) }) }), jsxs("div", { className: "seekora-docsearch-hit-content", children: [!isChild && breadcrumb && (jsx("span", { className: "seekora-docsearch-hit-breadcrumb", children: breadcrumb })), jsx("span", { className: "seekora-docsearch-hit-title", children: jsx(Highlight, { value: title, highlightedValue: highlightedTitle }) }), displayContent && (jsx("span", { className: "seekora-docsearch-hit-description", children: jsx(Highlight, { value: hit.content || '', highlightedValue: displayContent }) }))] }), jsx("div", { className: "seekora-docsearch-hit-action", children: openInNewTab ? (jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: jsx("path", { d: "M6 3H3v10h10v-3M9 3h4v4M14 2L7 9", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) : (jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: jsx("path", { d: "M6.75 3.25L11.5 8L6.75 12.75", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) })] }));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Get the title based on the type field from API.
|
|
225
|
+
* - type=lvl1: use lvl1 as title (page-level result)
|
|
226
|
+
* - type=lvl2: use lvl2 as title (section)
|
|
227
|
+
* - type=lvl3: use lvl3 as title, etc.
|
|
228
|
+
*/
|
|
229
|
+
function getTitleForType(hit, type) {
|
|
230
|
+
const hierarchy = hit.hierarchy || {};
|
|
231
|
+
if (!type) {
|
|
232
|
+
// Fallback to existing logic if no type
|
|
233
|
+
return hit.title || hierarchy.lvl1 || hierarchy.lvl0 || 'Untitled';
|
|
234
|
+
}
|
|
235
|
+
// Extract level number from type (e.g., "lvl2" -> 2)
|
|
236
|
+
const match = type.match(/^lvl(\d+)$/);
|
|
237
|
+
if (match) {
|
|
238
|
+
const level = parseInt(match[1], 10);
|
|
239
|
+
// Try to get the title from the matching hierarchy level
|
|
240
|
+
const levelKey = `lvl${level}`;
|
|
241
|
+
const levelTitle = hierarchy[levelKey];
|
|
242
|
+
if (levelTitle) {
|
|
243
|
+
return levelTitle;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Fallback
|
|
247
|
+
return hit.title || hierarchy.lvl1 || hierarchy.lvl0 || 'Untitled';
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get the icon type based on the hierarchy type from API.
|
|
251
|
+
* - type=lvl1: page icon (page-level result)
|
|
252
|
+
* - type=lvl2, lvl3, etc.: section icon (child sections)
|
|
253
|
+
*/
|
|
254
|
+
function getHitTypeFromLevel(type) {
|
|
255
|
+
if (!type) {
|
|
256
|
+
return 'page';
|
|
257
|
+
}
|
|
258
|
+
// Extract level number from type (e.g., "lvl2" -> 2)
|
|
259
|
+
const match = type.match(/^lvl(\d+)$/);
|
|
260
|
+
if (match) {
|
|
261
|
+
const level = parseInt(match[1], 10);
|
|
262
|
+
if (level === 1)
|
|
263
|
+
return 'page';
|
|
264
|
+
if (level <= 3)
|
|
265
|
+
return 'section';
|
|
266
|
+
return 'content';
|
|
267
|
+
}
|
|
268
|
+
return 'page';
|
|
269
|
+
}
|
|
270
|
+
function HitIcon({ type }) {
|
|
271
|
+
switch (type) {
|
|
272
|
+
case 'page':
|
|
273
|
+
return (jsxs("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: [jsx("path", { d: "M4.5 3.5h11a1 1 0 011 1v11a1 1 0 01-1 1h-11a1 1 0 01-1-1v-11a1 1 0 011-1z", stroke: "currentColor", strokeWidth: "1.5", fill: "none" }), jsx("path", { d: "M7 7h6M7 10h6M7 13h4", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" })] }));
|
|
274
|
+
case 'section':
|
|
275
|
+
return (jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: jsx("path", { d: "M4 5.5h12M4 10h12M4 14.5h8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }));
|
|
276
|
+
case 'content':
|
|
277
|
+
return (jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: jsx("path", { d: "M4 6h12M4 10h8M4 14h10", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Tree connector for hierarchical display
|
|
281
|
+
function TreeConnector({ isLast }) {
|
|
282
|
+
return (jsx("svg", { width: "16", height: "20", viewBox: "0 0 16 20", fill: "none", "aria-hidden": "true", className: "seekora-docsearch-hit-tree-icon", children: isLast ? (
|
|
283
|
+
// └─ Last child connector
|
|
284
|
+
jsx("path", { d: "M8 0V10H14", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" })) : (
|
|
285
|
+
// ├─ Middle child connector
|
|
286
|
+
jsxs(Fragment, { children: [jsx("path", { d: "M8 0V20", stroke: "currentColor", strokeWidth: "1.5" }), jsx("path", { d: "M8 10H14", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" })] })) }));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function Results({ hits, groupedHits, selectedIndex, onSelect, onHover, query, isLoading, error, translations = {}, sources: _sources = [], }) {
|
|
290
|
+
const listRef = useRef(null);
|
|
291
|
+
// Scroll selected item into view
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (listRef.current && hits.length > 0) {
|
|
294
|
+
const selectedItem = listRef.current.children[selectedIndex];
|
|
295
|
+
if (selectedItem) {
|
|
296
|
+
selectedItem.scrollIntoView({ block: 'nearest' });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}, [selectedIndex, hits.length]);
|
|
300
|
+
// Empty state - no query
|
|
301
|
+
if (!query) {
|
|
302
|
+
return (jsx("div", { className: "seekora-docsearch-empty", children: jsx("p", { className: "seekora-docsearch-empty-text", children: translations.searchPlaceholder || 'Type to start searching...' }) }));
|
|
303
|
+
}
|
|
304
|
+
// Loading state
|
|
305
|
+
if (isLoading && hits.length === 0) {
|
|
306
|
+
return (jsxs("div", { className: "seekora-docsearch-loading", children: [jsx("div", { className: "seekora-docsearch-loading-spinner", children: jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", "aria-hidden": "true", children: jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2", fill: "none", strokeDasharray: "50", strokeDashoffset: "15", children: jsx("animateTransform", { attributeName: "transform", type: "rotate", from: "0 12 12", to: "360 12 12", dur: "0.8s", repeatCount: "indefinite" }) }) }) }), jsx("p", { className: "seekora-docsearch-loading-text", children: translations.loadingText || 'Searching...' })] }));
|
|
307
|
+
}
|
|
308
|
+
// Error state
|
|
309
|
+
if (error) {
|
|
310
|
+
return (jsx("div", { className: "seekora-docsearch-error", children: jsx("p", { className: "seekora-docsearch-error-text", children: translations.errorText || error }) }));
|
|
311
|
+
}
|
|
312
|
+
// No results
|
|
313
|
+
if (hits.length === 0 && query) {
|
|
314
|
+
return (jsx("div", { className: "seekora-docsearch-no-results", children: jsx("p", { className: "seekora-docsearch-no-results-text", children: translations.noResultsText || `No results found for "${query}"` }) }));
|
|
315
|
+
}
|
|
316
|
+
// Use multi-source grouped hits if available, otherwise group by category
|
|
317
|
+
const displayGroups = groupedHits && groupedHits.length > 0
|
|
318
|
+
? groupedHits.map(g => ({
|
|
319
|
+
category: g.source.name,
|
|
320
|
+
sourceId: g.source.id,
|
|
321
|
+
openInNewTab: g.source.openInNewTab,
|
|
322
|
+
hits: g.items,
|
|
323
|
+
}))
|
|
324
|
+
: groupHitsByCategory(hits).map(g => ({
|
|
325
|
+
category: g.category,
|
|
326
|
+
sourceId: 'default',
|
|
327
|
+
openInNewTab: false,
|
|
328
|
+
hits: g.hits,
|
|
329
|
+
}));
|
|
330
|
+
return (jsx("div", { className: "seekora-docsearch-results", children: jsx("ul", { ref: listRef, id: "seekora-docsearch-results", className: "seekora-docsearch-results-list", role: "listbox", children: displayGroups.map((group, groupIndex) => (jsxs("li", { className: "seekora-docsearch-results-group", children: [group.category && (jsx("div", { className: "seekora-docsearch-results-group-header", children: group.category })), jsx("ul", { className: "seekora-docsearch-results-group-items", children: group.hits.map((hit, hitIndex) => {
|
|
331
|
+
const globalIndex = getGlobalIndexMulti(displayGroups, groupIndex, hitIndex);
|
|
332
|
+
const extHit = hit;
|
|
333
|
+
// Use computed isChild/isLastChild values (derived from type field)
|
|
334
|
+
const isChild = extHit.isChild === true;
|
|
335
|
+
const isLastChild = extHit.isLastChild === true;
|
|
336
|
+
// Pass the type field to Hit for icon determination
|
|
337
|
+
const hitType = extHit.type;
|
|
338
|
+
return (jsx("li", { children: jsx(Hit, { hit: hit, isSelected: globalIndex === selectedIndex, onClick: () => onSelect(hit), onMouseEnter: () => onHover(globalIndex), openInNewTab: group.openInNewTab, isChild: isChild, isLastChild: isLastChild, hierarchyType: hitType }) }, getHitKey(hit, hitIndex)));
|
|
339
|
+
}) })] }, group.sourceId + '-' + groupIndex))) }) }));
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Helper to get the hierarchy level number from a type string (e.g., "lvl1" -> 1, "lvl2" -> 2)
|
|
343
|
+
* Returns 1 as default for parent-level items
|
|
344
|
+
*/
|
|
345
|
+
function getTypeLevel(type) {
|
|
346
|
+
if (!type)
|
|
347
|
+
return 1;
|
|
348
|
+
const match = type.match(/^lvl(\d+)$/);
|
|
349
|
+
return match ? parseInt(match[1], 10) : 1;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Check if a hit is a child (type is lvl2, lvl3, etc.)
|
|
353
|
+
* type="lvl1" = parent (page-level result)
|
|
354
|
+
* type="lvl2", "lvl3", etc. = children (sections)
|
|
355
|
+
*/
|
|
356
|
+
function isChildType(type) {
|
|
357
|
+
const level = getTypeLevel(type);
|
|
358
|
+
return level >= 2;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Groups hits by hierarchy.lvl0 and marks parent/child relationships based on type field.
|
|
362
|
+
* - Groups are keyed by lvl0 (which contains the full path like "Guides > SDK > Search")
|
|
363
|
+
* - type="lvl1" = parent (page-level result)
|
|
364
|
+
* - type="lvl2", "lvl3", etc. = children (sections)
|
|
365
|
+
*/
|
|
366
|
+
function groupHitsByHierarchy(hits) {
|
|
367
|
+
const groups = new Map();
|
|
368
|
+
for (const hit of hits) {
|
|
369
|
+
// Group by lvl0 - this is the source of truth for grouping
|
|
370
|
+
const lvl0 = hit.hierarchy?.lvl0 || '';
|
|
371
|
+
if (!groups.has(lvl0)) {
|
|
372
|
+
groups.set(lvl0, []);
|
|
373
|
+
}
|
|
374
|
+
groups.get(lvl0).push(hit);
|
|
375
|
+
}
|
|
376
|
+
const result = [];
|
|
377
|
+
for (const [lvl0Key, groupHits] of groups.entries()) {
|
|
378
|
+
// Sort hits within each group: parents (lvl1) first, then children by type level
|
|
379
|
+
const sortedHits = [...groupHits].sort((a, b) => {
|
|
380
|
+
const aType = a.type;
|
|
381
|
+
const bType = b.type;
|
|
382
|
+
const aLevel = getTypeLevel(aType);
|
|
383
|
+
const bLevel = getTypeLevel(bType);
|
|
384
|
+
// Sort by level: lvl1 first, then lvl2, lvl3, etc.
|
|
385
|
+
return aLevel - bLevel;
|
|
386
|
+
});
|
|
387
|
+
// Mark isChild and isLastChild based on type field
|
|
388
|
+
const markedHits = sortedHits.map((hit, index) => {
|
|
389
|
+
const suggestion = hit;
|
|
390
|
+
const hitType = suggestion.type;
|
|
391
|
+
const isChild = isChildType(hitType);
|
|
392
|
+
// Determine if this is the last child in the group
|
|
393
|
+
// Check if this is a child and the next item is either not a child or doesn't exist
|
|
394
|
+
let isLastChild = false;
|
|
395
|
+
if (isChild) {
|
|
396
|
+
const nextHit = sortedHits[index + 1];
|
|
397
|
+
if (!nextHit || !isChildType(nextHit.type)) {
|
|
398
|
+
isLastChild = true;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
...hit,
|
|
403
|
+
isChild,
|
|
404
|
+
isLastChild,
|
|
405
|
+
};
|
|
406
|
+
});
|
|
407
|
+
result.push({
|
|
408
|
+
category: lvl0Key || null,
|
|
409
|
+
hits: markedHits,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
// Legacy function for backward compatibility
|
|
415
|
+
function groupHitsByCategory(hits) {
|
|
416
|
+
return groupHitsByHierarchy(hits);
|
|
417
|
+
}
|
|
418
|
+
function getGlobalIndexMulti(groups, groupIndex, hitIndex) {
|
|
419
|
+
let index = 0;
|
|
420
|
+
for (let i = 0; i < groupIndex; i++) {
|
|
421
|
+
index += groups[i].hits.length;
|
|
422
|
+
}
|
|
423
|
+
return index + hitIndex;
|
|
424
|
+
}
|
|
425
|
+
function getHitKey(hit, index) {
|
|
426
|
+
if ('objectID' in hit) {
|
|
427
|
+
return hit.objectID;
|
|
428
|
+
}
|
|
429
|
+
return `suggestion-${hit.url}-${index}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function Footer({ translations = {} }) {
|
|
433
|
+
return (jsxs("footer", { className: "seekora-docsearch-footer", children: [jsx("div", { className: "seekora-docsearch-footer-commands", children: jsxs("ul", { className: "seekora-docsearch-footer-commands-list", children: [jsx("li", { children: jsxs("span", { className: "seekora-docsearch-footer-command", children: [jsx("kbd", { className: "seekora-docsearch-key", children: "\u21B5" }), jsx("span", { children: "to select" })] }) }), jsx("li", { children: jsxs("span", { className: "seekora-docsearch-footer-command", children: [jsx("kbd", { className: "seekora-docsearch-key", children: "\u2191" }), jsx("kbd", { className: "seekora-docsearch-key", children: "\u2193" }), jsx("span", { children: "to navigate" })] }) }), jsx("li", { children: jsxs("span", { className: "seekora-docsearch-footer-command", children: [jsx("kbd", { className: "seekora-docsearch-key", children: "esc" }), jsx("span", { children: translations.closeText || 'to close' })] }) })] }) }), jsxs("div", { className: "seekora-docsearch-footer-logo", children: [jsx("span", { className: "seekora-docsearch-footer-logo-text", children: translations.searchByText || 'Search by' }), jsx("a", { href: "https://seekora.ai", target: "_blank", rel: "noopener noreferrer", className: "seekora-docsearch-footer-logo-link", children: jsx(SekoraLogo, {}) })] })] }));
|
|
434
|
+
}
|
|
435
|
+
function SekoraLogo() {
|
|
436
|
+
return (jsx("svg", { width: "77", height: "19", viewBox: "0 0 77 19", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-label": "Seekora", className: "seekora-docsearch-logo", children: jsx("text", { x: "0", y: "15", fontFamily: "system-ui, -apple-system, sans-serif", fontSize: "14", fontWeight: "600", fill: "currentColor", children: "Seekora" }) }));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function useKeyboard(options) {
|
|
440
|
+
const { isOpen, onOpen, onClose, onSelectNext, onSelectPrev, onEnter, disableShortcut = false, shortcutKey = 'k', } = options;
|
|
441
|
+
const handleGlobalKeyDown = useCallback((event) => {
|
|
442
|
+
// Open modal with Cmd/Ctrl + K
|
|
443
|
+
if (!disableShortcut && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === shortcutKey) {
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
if (isOpen) {
|
|
446
|
+
onClose();
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
onOpen();
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// Forward slash to open (when not in an input)
|
|
454
|
+
if (!disableShortcut && event.key === '/' && !isOpen) {
|
|
455
|
+
const target = event.target;
|
|
456
|
+
const isInput = target.tagName === 'INPUT' ||
|
|
457
|
+
target.tagName === 'TEXTAREA' ||
|
|
458
|
+
target.isContentEditable;
|
|
459
|
+
if (!isInput) {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
onOpen();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}, [isOpen, onOpen, onClose, disableShortcut, shortcutKey]);
|
|
465
|
+
const handleModalKeyDown = useCallback((event) => {
|
|
466
|
+
switch (event.key) {
|
|
467
|
+
case 'Escape':
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
onClose();
|
|
470
|
+
break;
|
|
471
|
+
case 'ArrowDown':
|
|
472
|
+
event.preventDefault();
|
|
473
|
+
onSelectNext();
|
|
474
|
+
break;
|
|
475
|
+
case 'ArrowUp':
|
|
476
|
+
event.preventDefault();
|
|
477
|
+
onSelectPrev();
|
|
478
|
+
break;
|
|
479
|
+
case 'Enter':
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
onEnter();
|
|
482
|
+
break;
|
|
483
|
+
case 'Tab':
|
|
484
|
+
// Allow Tab but prevent default to keep focus in modal
|
|
485
|
+
if (event.shiftKey) {
|
|
486
|
+
onSelectPrev();
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
onSelectNext();
|
|
490
|
+
}
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}, [onClose, onSelectNext, onSelectPrev, onEnter]);
|
|
495
|
+
// Global keyboard listener
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
document.addEventListener('keydown', handleGlobalKeyDown);
|
|
498
|
+
return () => {
|
|
499
|
+
document.removeEventListener('keydown', handleGlobalKeyDown);
|
|
500
|
+
};
|
|
501
|
+
}, [handleGlobalKeyDown]);
|
|
502
|
+
return {
|
|
503
|
+
handleModalKeyDown,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Get the keyboard shortcut display text based on platform
|
|
508
|
+
*/
|
|
509
|
+
function getShortcutText(key = 'K') {
|
|
510
|
+
if (typeof navigator === 'undefined') {
|
|
511
|
+
return `⌘${key}`;
|
|
512
|
+
}
|
|
513
|
+
const isMac = navigator.platform.toLowerCase().includes('mac');
|
|
514
|
+
return isMac ? `⌘${key}` : `Ctrl+${key}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function DocSearchButton({ onClick, placeholder = 'Search documentation...', }) {
|
|
518
|
+
const shortcutText = getShortcutText('K');
|
|
519
|
+
return (jsxs("button", { type: "button", className: "seekora-docsearch-button", onClick: onClick, "aria-label": "Search documentation", children: [jsx("span", { className: "seekora-docsearch-button-icon", children: jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: jsx("path", { d: "M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z", fill: "currentColor" }) }) }), jsx("span", { className: "seekora-docsearch-button-placeholder", children: placeholder }), jsx("span", { className: "seekora-docsearch-button-keys", children: jsx("kbd", { className: "seekora-docsearch-button-key", children: shortcutText }) })] }));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const initialState = {
|
|
523
|
+
query: '',
|
|
524
|
+
results: [],
|
|
525
|
+
suggestions: [],
|
|
526
|
+
groupedSuggestions: [],
|
|
527
|
+
isLoading: false,
|
|
528
|
+
error: null,
|
|
529
|
+
selectedIndex: 0,
|
|
530
|
+
mode: 'suggestions',
|
|
531
|
+
};
|
|
532
|
+
function reducer(state, action) {
|
|
533
|
+
switch (action.type) {
|
|
534
|
+
case 'SET_QUERY':
|
|
535
|
+
return { ...state, query: action.payload, selectedIndex: 0 };
|
|
536
|
+
case 'SET_RESULTS':
|
|
537
|
+
return { ...state, results: action.payload, mode: 'results' };
|
|
538
|
+
case 'SET_SUGGESTIONS':
|
|
539
|
+
return { ...state, suggestions: action.payload, mode: 'suggestions' };
|
|
540
|
+
case 'SET_GROUPED_SUGGESTIONS':
|
|
541
|
+
// Flatten grouped suggestions for navigation while keeping groups for display
|
|
542
|
+
const flatSuggestions = action.payload.flatMap(group => group.items.map(item => ({ ...item, _source: group.source.id })));
|
|
543
|
+
return {
|
|
544
|
+
...state,
|
|
545
|
+
groupedSuggestions: action.payload,
|
|
546
|
+
suggestions: flatSuggestions,
|
|
547
|
+
mode: 'suggestions'
|
|
548
|
+
};
|
|
549
|
+
case 'SET_LOADING':
|
|
550
|
+
return { ...state, isLoading: action.payload };
|
|
551
|
+
case 'SET_ERROR':
|
|
552
|
+
return { ...state, error: action.payload };
|
|
553
|
+
case 'SET_SELECTED_INDEX':
|
|
554
|
+
return { ...state, selectedIndex: action.payload };
|
|
555
|
+
case 'SELECT_NEXT': {
|
|
556
|
+
const items = state.mode === 'results' ? state.results : state.suggestions;
|
|
557
|
+
const maxIndex = items.length - 1;
|
|
558
|
+
return {
|
|
559
|
+
...state,
|
|
560
|
+
selectedIndex: state.selectedIndex >= maxIndex ? 0 : state.selectedIndex + 1,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
case 'SELECT_PREV': {
|
|
564
|
+
const items = state.mode === 'results' ? state.results : state.suggestions;
|
|
565
|
+
const maxIndex = items.length - 1;
|
|
566
|
+
return {
|
|
567
|
+
...state,
|
|
568
|
+
selectedIndex: state.selectedIndex <= 0 ? maxIndex : state.selectedIndex - 1,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
case 'SET_MODE':
|
|
572
|
+
return { ...state, mode: action.payload, selectedIndex: 0 };
|
|
573
|
+
case 'RESET':
|
|
574
|
+
return initialState;
|
|
575
|
+
default:
|
|
576
|
+
return state;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function useDocSearch(options) {
|
|
580
|
+
const { apiEndpoint, apiKey, sources, maxResults = 10, debounceMs = 200 } = options;
|
|
581
|
+
// Build sources array from either explicit sources or legacy single endpoint
|
|
582
|
+
const searchSources = sources || (apiEndpoint ? [{
|
|
583
|
+
id: 'default',
|
|
584
|
+
name: 'Results',
|
|
585
|
+
endpoint: apiEndpoint,
|
|
586
|
+
apiKey,
|
|
587
|
+
maxResults,
|
|
588
|
+
}] : []);
|
|
589
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
590
|
+
const abortControllersRef = useRef(new Map());
|
|
591
|
+
const debounceTimerRef = useRef(null);
|
|
592
|
+
// Default transform function for API responses
|
|
593
|
+
const defaultTransform = (data, sourceId) => {
|
|
594
|
+
const items = data.data?.suggestions || data.data?.results || data.suggestions || data.results || data.hits || [];
|
|
595
|
+
return items.map((item) => ({
|
|
596
|
+
url: item.url || item.route || '',
|
|
597
|
+
title: item.title?.replace?.(/<\/?mark>/g, '') || item.title || '',
|
|
598
|
+
content: item.content?.replace?.(/<\/?mark>/g, '')?.substring?.(0, 100) || item.description || '',
|
|
599
|
+
description: item.description || item.content?.substring?.(0, 100) || '',
|
|
600
|
+
category: item.category || item.hierarchy?.lvl0 || '',
|
|
601
|
+
hierarchy: item.hierarchy,
|
|
602
|
+
route: item.route,
|
|
603
|
+
parentTitle: item.parent_title || item.parentTitle,
|
|
604
|
+
_source: sourceId,
|
|
605
|
+
}));
|
|
606
|
+
};
|
|
607
|
+
const fetchFromSource = useCallback(async (source, query, signal) => {
|
|
608
|
+
const minLength = source.minQueryLength ?? 1;
|
|
609
|
+
if (query.length < minLength) {
|
|
610
|
+
return [];
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
const url = new URL(source.endpoint);
|
|
614
|
+
url.searchParams.set('query', query);
|
|
615
|
+
url.searchParams.set('limit', String(source.maxResults || 8));
|
|
616
|
+
const headers = {
|
|
617
|
+
'Content-Type': 'application/json',
|
|
618
|
+
};
|
|
619
|
+
if (source.apiKey) {
|
|
620
|
+
headers['X-Docs-API-Key'] = source.apiKey;
|
|
621
|
+
}
|
|
622
|
+
const response = await fetch(url.toString(), {
|
|
623
|
+
method: 'GET',
|
|
624
|
+
headers,
|
|
625
|
+
signal,
|
|
626
|
+
});
|
|
627
|
+
if (!response.ok) {
|
|
628
|
+
console.warn(`Search source ${source.id} failed: ${response.statusText}`);
|
|
629
|
+
return [];
|
|
630
|
+
}
|
|
631
|
+
const data = await response.json();
|
|
632
|
+
// Use custom transform or default
|
|
633
|
+
if (source.transformResults) {
|
|
634
|
+
return source.transformResults(data).map(item => ({ ...item, _source: source.id }));
|
|
635
|
+
}
|
|
636
|
+
return defaultTransform(data, source.id);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
640
|
+
throw error; // Re-throw abort errors
|
|
641
|
+
}
|
|
642
|
+
console.warn(`Search source ${source.id} error:`, error);
|
|
643
|
+
return [];
|
|
644
|
+
}
|
|
645
|
+
}, []);
|
|
646
|
+
const fetchSuggestions = useCallback(async (query) => {
|
|
647
|
+
if (!query.trim()) {
|
|
648
|
+
dispatch({ type: 'SET_GROUPED_SUGGESTIONS', payload: [] });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
// Cancel all previous requests
|
|
652
|
+
abortControllersRef.current.forEach(controller => controller.abort());
|
|
653
|
+
abortControllersRef.current.clear();
|
|
654
|
+
dispatch({ type: 'SET_LOADING', payload: true });
|
|
655
|
+
dispatch({ type: 'SET_ERROR', payload: null });
|
|
656
|
+
try {
|
|
657
|
+
// Fetch from all sources in parallel
|
|
658
|
+
const results = await Promise.all(searchSources.map(async (source) => {
|
|
659
|
+
const controller = new AbortController();
|
|
660
|
+
abortControllersRef.current.set(source.id, controller);
|
|
661
|
+
const items = await fetchFromSource(source, query, controller.signal);
|
|
662
|
+
return { source, items };
|
|
663
|
+
}));
|
|
664
|
+
// Filter out empty results and dispatch grouped
|
|
665
|
+
const groupedResults = results.filter(r => r.items.length > 0);
|
|
666
|
+
dispatch({ type: 'SET_GROUPED_SUGGESTIONS', payload: groupedResults });
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
670
|
+
return; // Request was cancelled
|
|
671
|
+
}
|
|
672
|
+
dispatch({
|
|
673
|
+
type: 'SET_ERROR',
|
|
674
|
+
payload: error instanceof Error ? error.message : 'Search failed',
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
dispatch({ type: 'SET_LOADING', payload: false });
|
|
679
|
+
}
|
|
680
|
+
}, [searchSources, fetchFromSource]);
|
|
681
|
+
const search = useCallback(async (query) => {
|
|
682
|
+
// For now, search behaves same as fetchSuggestions
|
|
683
|
+
await fetchSuggestions(query);
|
|
684
|
+
}, [fetchSuggestions]);
|
|
685
|
+
const setQuery = useCallback((query) => {
|
|
686
|
+
dispatch({ type: 'SET_QUERY', payload: query });
|
|
687
|
+
// Clear existing debounce timer
|
|
688
|
+
if (debounceTimerRef.current) {
|
|
689
|
+
clearTimeout(debounceTimerRef.current);
|
|
690
|
+
}
|
|
691
|
+
// Debounce the search
|
|
692
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
693
|
+
fetchSuggestions(query);
|
|
694
|
+
}, debounceMs);
|
|
695
|
+
}, [fetchSuggestions, debounceMs]);
|
|
696
|
+
const selectNext = useCallback(() => {
|
|
697
|
+
dispatch({ type: 'SELECT_NEXT' });
|
|
698
|
+
}, []);
|
|
699
|
+
const selectPrev = useCallback(() => {
|
|
700
|
+
dispatch({ type: 'SELECT_PREV' });
|
|
701
|
+
}, []);
|
|
702
|
+
const setSelectedIndex = useCallback((index) => {
|
|
703
|
+
dispatch({ type: 'SET_SELECTED_INDEX', payload: index });
|
|
704
|
+
}, []);
|
|
705
|
+
const reset = useCallback(() => {
|
|
706
|
+
abortControllersRef.current.forEach(controller => controller.abort());
|
|
707
|
+
abortControllersRef.current.clear();
|
|
708
|
+
if (debounceTimerRef.current) {
|
|
709
|
+
clearTimeout(debounceTimerRef.current);
|
|
710
|
+
}
|
|
711
|
+
dispatch({ type: 'RESET' });
|
|
712
|
+
}, []);
|
|
713
|
+
const getSelectedItem = useCallback(() => {
|
|
714
|
+
const items = state.mode === 'results' ? state.results : state.suggestions;
|
|
715
|
+
return items[state.selectedIndex] || null;
|
|
716
|
+
}, [state.mode, state.results, state.suggestions, state.selectedIndex]);
|
|
717
|
+
// Cleanup on unmount
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
return () => {
|
|
720
|
+
abortControllersRef.current.forEach(controller => controller.abort());
|
|
721
|
+
abortControllersRef.current.clear();
|
|
722
|
+
if (debounceTimerRef.current) {
|
|
723
|
+
clearTimeout(debounceTimerRef.current);
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
}, []);
|
|
727
|
+
return {
|
|
728
|
+
...state,
|
|
729
|
+
sources: searchSources,
|
|
730
|
+
setQuery,
|
|
731
|
+
search,
|
|
732
|
+
fetchSuggestions,
|
|
733
|
+
selectNext,
|
|
734
|
+
selectPrev,
|
|
735
|
+
setSelectedIndex,
|
|
736
|
+
reset,
|
|
737
|
+
getSelectedItem,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Transform Seekora SDK search results to DocSearch format
|
|
743
|
+
*/
|
|
744
|
+
function transformResults(results) {
|
|
745
|
+
return results.map((result) => {
|
|
746
|
+
// Handle different field name conventions
|
|
747
|
+
const url = result.url || result.route || result.link || '';
|
|
748
|
+
const title = result.title || result.name || '';
|
|
749
|
+
const content = result.content || result.description || result.snippet || '';
|
|
750
|
+
const description = result.description || result.content?.substring?.(0, 150) || '';
|
|
751
|
+
// Build hierarchy from available fields
|
|
752
|
+
const hierarchy = {};
|
|
753
|
+
if (result.hierarchy) {
|
|
754
|
+
hierarchy.lvl0 = result.hierarchy.lvl0;
|
|
755
|
+
hierarchy.lvl1 = result.hierarchy.lvl1;
|
|
756
|
+
hierarchy.lvl2 = result.hierarchy.lvl2;
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
// Try to extract hierarchy from category or breadcrumb fields
|
|
760
|
+
if (result.category) {
|
|
761
|
+
hierarchy.lvl0 = result.category;
|
|
762
|
+
}
|
|
763
|
+
if (result.parent_title || result.parentTitle) {
|
|
764
|
+
hierarchy.lvl1 = result.parent_title || result.parentTitle;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
url,
|
|
769
|
+
title: title?.replace?.(/<\/?mark>/g, '') || title,
|
|
770
|
+
content: content?.replace?.(/<\/?mark>/g, '')?.substring?.(0, 200) || content,
|
|
771
|
+
description: description?.replace?.(/<\/?mark>/g, '') || description,
|
|
772
|
+
category: result.category || hierarchy.lvl0 || '',
|
|
773
|
+
hierarchy,
|
|
774
|
+
route: result.route,
|
|
775
|
+
parentTitle: result.parent_title || result.parentTitle,
|
|
776
|
+
type: result.type || '',
|
|
777
|
+
anchor: result.anchor || '',
|
|
778
|
+
_source: 'seekora',
|
|
779
|
+
};
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Hook for searching using the Seekora SDK
|
|
784
|
+
*
|
|
785
|
+
* This hook provides a seamless integration with the Seekora Search SDK,
|
|
786
|
+
* handling client initialization, debouncing, and result transformation.
|
|
787
|
+
*/
|
|
788
|
+
function useSeekoraSearch(options) {
|
|
789
|
+
const { storeId, storeSecret, apiEndpoint, maxResults = 20, debounceMs = 200, analyticsTags = ['docsearch'], groupField, groupSize, } = options;
|
|
790
|
+
const [query, setQueryState] = useState('');
|
|
791
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
792
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
793
|
+
const [error, setError] = useState(null);
|
|
794
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
795
|
+
const clientRef = useRef(null);
|
|
796
|
+
const debounceTimerRef = useRef(null);
|
|
797
|
+
const abortControllerRef = useRef(null);
|
|
798
|
+
// Initialize the Seekora client
|
|
799
|
+
useEffect(() => {
|
|
800
|
+
if (!storeId) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const config = {
|
|
804
|
+
storeId,
|
|
805
|
+
readSecret: storeSecret,
|
|
806
|
+
logLevel: 'warn', // Keep logging quiet in the widget
|
|
807
|
+
};
|
|
808
|
+
// Handle endpoint configuration
|
|
809
|
+
if (apiEndpoint) {
|
|
810
|
+
if (['local', 'stage', 'production'].includes(apiEndpoint)) {
|
|
811
|
+
config.environment = apiEndpoint;
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
config.baseUrl = apiEndpoint;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
clientRef.current = new SeekoraClient(config);
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
console.error('Failed to initialize SeekoraClient:', err);
|
|
822
|
+
setError('Failed to initialize search client');
|
|
823
|
+
}
|
|
824
|
+
return () => {
|
|
825
|
+
clientRef.current = null;
|
|
826
|
+
};
|
|
827
|
+
}, [storeId, storeSecret, apiEndpoint]);
|
|
828
|
+
// Search function
|
|
829
|
+
const performSearch = useCallback(async (searchQuery) => {
|
|
830
|
+
if (!clientRef.current) {
|
|
831
|
+
setError('Search client not initialized');
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (!searchQuery.trim()) {
|
|
835
|
+
setSuggestions([]);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
// Cancel any pending request
|
|
839
|
+
if (abortControllerRef.current) {
|
|
840
|
+
abortControllerRef.current.abort();
|
|
841
|
+
}
|
|
842
|
+
abortControllerRef.current = new AbortController();
|
|
843
|
+
setIsLoading(true);
|
|
844
|
+
setError(null);
|
|
845
|
+
try {
|
|
846
|
+
const response = await clientRef.current.search(searchQuery, {
|
|
847
|
+
per_page: maxResults,
|
|
848
|
+
analytics_tags: analyticsTags,
|
|
849
|
+
// Doc search specific parameters (Algolia-style)
|
|
850
|
+
return_fields: [
|
|
851
|
+
'hierarchy.lvl0', 'hierarchy.lvl1', 'hierarchy.lvl2', 'hierarchy.lvl3',
|
|
852
|
+
'hierarchy.lvl4', 'hierarchy.lvl5', 'hierarchy.lvl6',
|
|
853
|
+
'content', 'type', 'url', 'title', 'anchor'
|
|
854
|
+
],
|
|
855
|
+
snippet_fields: [
|
|
856
|
+
'hierarchy.lvl1', 'hierarchy.lvl2', 'hierarchy.lvl3',
|
|
857
|
+
'hierarchy.lvl4', 'hierarchy.lvl5', 'hierarchy.lvl6', 'content'
|
|
858
|
+
],
|
|
859
|
+
snippet_prefix: '<mark>',
|
|
860
|
+
snippet_suffix: '</mark>',
|
|
861
|
+
include_snippets: true,
|
|
862
|
+
// Grouping for hierarchy display
|
|
863
|
+
group_field: groupField,
|
|
864
|
+
group_size: groupSize,
|
|
865
|
+
});
|
|
866
|
+
// Check if this request was aborted
|
|
867
|
+
if (abortControllerRef.current?.signal.aborted) {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const transformedResults = transformResults(response.results || []);
|
|
871
|
+
setSuggestions(transformedResults);
|
|
872
|
+
setSelectedIndex(0);
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
// Ignore abort errors
|
|
876
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
console.error('Search failed:', err);
|
|
880
|
+
setError(err instanceof Error ? err.message : 'Search failed');
|
|
881
|
+
setSuggestions([]);
|
|
882
|
+
}
|
|
883
|
+
finally {
|
|
884
|
+
setIsLoading(false);
|
|
885
|
+
}
|
|
886
|
+
}, [maxResults, analyticsTags, groupField, groupSize]);
|
|
887
|
+
// Set query with debouncing
|
|
888
|
+
const setQuery = useCallback((newQuery) => {
|
|
889
|
+
setQueryState(newQuery);
|
|
890
|
+
setSelectedIndex(0);
|
|
891
|
+
// Clear existing debounce timer
|
|
892
|
+
if (debounceTimerRef.current) {
|
|
893
|
+
clearTimeout(debounceTimerRef.current);
|
|
894
|
+
}
|
|
895
|
+
// Debounce the search
|
|
896
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
897
|
+
performSearch(newQuery);
|
|
898
|
+
}, debounceMs);
|
|
899
|
+
}, [performSearch, debounceMs]);
|
|
900
|
+
// Navigation functions
|
|
901
|
+
const selectNext = useCallback(() => {
|
|
902
|
+
setSelectedIndex((prev) => (prev >= suggestions.length - 1 ? 0 : prev + 1));
|
|
903
|
+
}, [suggestions.length]);
|
|
904
|
+
const selectPrev = useCallback(() => {
|
|
905
|
+
setSelectedIndex((prev) => (prev <= 0 ? suggestions.length - 1 : prev - 1));
|
|
906
|
+
}, [suggestions.length]);
|
|
907
|
+
// Reset function
|
|
908
|
+
const reset = useCallback(() => {
|
|
909
|
+
if (abortControllerRef.current) {
|
|
910
|
+
abortControllerRef.current.abort();
|
|
911
|
+
}
|
|
912
|
+
if (debounceTimerRef.current) {
|
|
913
|
+
clearTimeout(debounceTimerRef.current);
|
|
914
|
+
}
|
|
915
|
+
setQueryState('');
|
|
916
|
+
setSuggestions([]);
|
|
917
|
+
setIsLoading(false);
|
|
918
|
+
setError(null);
|
|
919
|
+
setSelectedIndex(0);
|
|
920
|
+
}, []);
|
|
921
|
+
// Get selected item
|
|
922
|
+
const getSelectedItem = useCallback(() => {
|
|
923
|
+
return suggestions[selectedIndex] || null;
|
|
924
|
+
}, [suggestions, selectedIndex]);
|
|
925
|
+
// Cleanup on unmount
|
|
926
|
+
useEffect(() => {
|
|
927
|
+
return () => {
|
|
928
|
+
if (abortControllerRef.current) {
|
|
929
|
+
abortControllerRef.current.abort();
|
|
930
|
+
}
|
|
931
|
+
if (debounceTimerRef.current) {
|
|
932
|
+
clearTimeout(debounceTimerRef.current);
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
}, []);
|
|
936
|
+
return {
|
|
937
|
+
query,
|
|
938
|
+
suggestions,
|
|
939
|
+
isLoading,
|
|
940
|
+
error,
|
|
941
|
+
selectedIndex,
|
|
942
|
+
setQuery,
|
|
943
|
+
selectNext,
|
|
944
|
+
selectPrev,
|
|
945
|
+
setSelectedIndex,
|
|
946
|
+
reset,
|
|
947
|
+
getSelectedItem,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function DocSearch({
|
|
952
|
+
// New Seekora SDK props
|
|
953
|
+
storeId, storeSecret, seekoraApiEndpoint,
|
|
954
|
+
// Legacy props (deprecated when using storeId)
|
|
955
|
+
apiEndpoint, apiKey, sources,
|
|
956
|
+
// indexName is accepted but not used (kept for backward compatibility)
|
|
957
|
+
placeholder = 'Search documentation...', maxResults = 10, debounceMs = 200, onSelect, onClose, translations = {}, renderButton = true, buttonComponent: ButtonComponent = DocSearchButton, initialOpen = false, disableShortcut = false, shortcutKey = 'k', }) {
|
|
958
|
+
const [isOpen, setIsOpen] = useState(initialOpen);
|
|
959
|
+
// Determine if we should use the Seekora SDK
|
|
960
|
+
const useSeekoraSDK = !!storeId;
|
|
961
|
+
// Seekora SDK-based search (when storeId is provided)
|
|
962
|
+
const seekoraSearch = useSeekoraSearch({
|
|
963
|
+
storeId: storeId || '',
|
|
964
|
+
storeSecret,
|
|
965
|
+
apiEndpoint: seekoraApiEndpoint,
|
|
966
|
+
maxResults,
|
|
967
|
+
debounceMs,
|
|
968
|
+
analyticsTags: ['docsearch'],
|
|
969
|
+
});
|
|
970
|
+
// Legacy fetch-based search (when using apiEndpoint/sources)
|
|
971
|
+
const legacySearch = useDocSearch({
|
|
972
|
+
apiEndpoint,
|
|
973
|
+
apiKey,
|
|
974
|
+
sources,
|
|
975
|
+
maxResults,
|
|
976
|
+
debounceMs,
|
|
977
|
+
});
|
|
978
|
+
// Select the appropriate search implementation
|
|
979
|
+
const { query, suggestions, isLoading, error, selectedIndex, setQuery, selectNext, selectPrev, setSelectedIndex, reset, getSelectedItem, } = useSeekoraSDK ? seekoraSearch : legacySearch;
|
|
980
|
+
// For legacy mode, get additional fields
|
|
981
|
+
const groupedSuggestions = useSeekoraSDK ? undefined : legacySearch.groupedSuggestions;
|
|
982
|
+
const results = useSeekoraSDK ? suggestions : legacySearch.results;
|
|
983
|
+
const mode = useSeekoraSDK ? 'suggestions' : legacySearch.mode;
|
|
984
|
+
const searchSources = useSeekoraSDK
|
|
985
|
+
? [{ id: 'seekora', name: 'Results', endpoint: '' }]
|
|
986
|
+
: legacySearch.sources;
|
|
987
|
+
const handleOpen = useCallback(() => {
|
|
988
|
+
setIsOpen(true);
|
|
989
|
+
}, []);
|
|
990
|
+
const handleClose = useCallback(() => {
|
|
991
|
+
setIsOpen(false);
|
|
992
|
+
reset();
|
|
993
|
+
onClose?.();
|
|
994
|
+
}, [reset, onClose]);
|
|
995
|
+
const handleSelect = useCallback((hit) => {
|
|
996
|
+
if (onSelect) {
|
|
997
|
+
onSelect(hit);
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
// Default behavior: navigate to URL
|
|
1001
|
+
window.location.href = hit.url;
|
|
1002
|
+
}
|
|
1003
|
+
handleClose();
|
|
1004
|
+
}, [onSelect, handleClose]);
|
|
1005
|
+
const handleEnter = useCallback(() => {
|
|
1006
|
+
const selectedItem = getSelectedItem();
|
|
1007
|
+
if (selectedItem) {
|
|
1008
|
+
handleSelect(selectedItem);
|
|
1009
|
+
}
|
|
1010
|
+
}, [getSelectedItem, handleSelect]);
|
|
1011
|
+
const { handleModalKeyDown } = useKeyboard({
|
|
1012
|
+
isOpen,
|
|
1013
|
+
onOpen: handleOpen,
|
|
1014
|
+
onClose: handleClose,
|
|
1015
|
+
onSelectNext: selectNext,
|
|
1016
|
+
onSelectPrev: selectPrev,
|
|
1017
|
+
onEnter: handleEnter,
|
|
1018
|
+
disableShortcut,
|
|
1019
|
+
shortcutKey,
|
|
1020
|
+
});
|
|
1021
|
+
const handleKeyDown = useCallback((event) => {
|
|
1022
|
+
handleModalKeyDown(event);
|
|
1023
|
+
}, [handleModalKeyDown]);
|
|
1024
|
+
// Get the items to display (suggestions or full results)
|
|
1025
|
+
const displayHits = mode === 'results' ? results : suggestions;
|
|
1026
|
+
return (jsxs(Fragment, { children: [renderButton && (jsx(ButtonComponent, { onClick: handleOpen, placeholder: translations.buttonText || placeholder })), jsx(Modal, { isOpen: isOpen, onClose: handleClose, children: jsxs("div", { className: "seekora-docsearch-modal", onKeyDown: handleKeyDown, children: [jsxs("header", { className: "seekora-docsearch-header", children: [jsx(SearchBox, { value: query, onChange: setQuery, onKeyDown: handleKeyDown, placeholder: placeholder, isLoading: isLoading, onClear: reset }), jsx("button", { type: "button", className: "seekora-docsearch-close", onClick: handleClose, "aria-label": "Close search", children: jsx("span", { className: "seekora-docsearch-close-text", children: "esc" }) })] }), jsx("div", { className: "seekora-docsearch-body", children: jsx(Results, { hits: displayHits, groupedHits: groupedSuggestions, selectedIndex: selectedIndex, onSelect: handleSelect, onHover: setSelectedIndex, query: query, isLoading: isLoading, error: error, translations: translations, sources: searchSources }) }), jsx(Footer, { translations: translations })] }) })] }));
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export { DocSearch, DocSearchButton, Footer, Highlight, Hit, Modal, Results, SearchBox, getShortcutText, truncateAroundMatch, useDocSearch, useKeyboard, useSeekoraSearch };
|
|
1030
|
+
//# sourceMappingURL=index.esm.js.map
|