import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { Search as SearchIcon } from '@/components/icons'; import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { highlightTerms, type SearchResult } from '@/lib/search'; import type { ResolvedSearchConfig } from '@/lib/search/config'; import { resolveSearchProvider, type SearchProvider } from '@/lib/search/provider'; import { getPageFrontmatter, getScopeByPathname } from '@/lib/site-config'; import type { SearchPosition, SiteModel, ThemeLabels } from '@/lib/types'; import { cn } from '@/lib/utils'; /** Enough results to make the list scroll; the dialog caps its own height. */ const RESULT_LIMIT = 30; /** * Renders a snippet, turning provider-supplied `` tags into highlight * elements. Snippets are parsed — never injected as HTML — so any other * markup in the text renders literally. */ function renderSnippet(snippet: string) { const parts = snippet.split(/(.*?)<\/mark>/g); if (parts.length === 1) { return snippet; } return parts.map((part, index) => index % 2 ? ( // biome-ignore lint/suspicious/noArrayIndexKey: parts are positional and never reorder. {part} ) : ( part ), ); } function renderWithQueryHighlight(text: string, query: string) { const terms = query.trim().split(/\s+/).filter(Boolean); if (!terms.length) return text; return renderSnippet(highlightTerms(text, terms)); } /** * Provider-neutral search dialog. The selected provider and its index or * client are loaded on demand, so search stays out of the initial bundle. */ export function Search({ config, labels, className, }: { config: ResolvedSearchConfig; labels: ThemeLabels; /** Extra classes for the trigger button (e.g. to stretch it in a column). */ className?: string; }) { const navigate = useNavigate(); const { pathname } = useLocation(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); const provider = useRef | null>(null); const requestId = useRef(0); const loadProvider = useCallback(() => { if (!provider.current) { provider.current = resolveSearchProvider(config.provider, config.options) .then(resolved => { if (resolved.fellBack) { console.warn( `[shiso] Unknown search provider "${config.provider}" — using the built-in local provider.`, ); } return resolved.provider; }) .catch(error => { provider.current = null; throw error; }); } return provider.current; }, [config.options, config.provider]); const runQuery = useCallback( async (value: string) => { const currentRequest = ++requestId.current; setQuery(value); if (!value.trim()) { setResults([]); setStatus('idle'); return; } setResults([]); setStatus('loading'); try { const activeProvider = await loadProvider(); // Search stays inside the version/language scope being browsed. // Providers that predate the context argument simply ignore it. const scope = getScopeByPathname(pathname); const nextResults = await activeProvider.search(value, RESULT_LIMIT, { scopeId: scope.id, language: scope.language, version: scope.version, }); if (currentRequest === requestId.current) { setResults(nextResults); setStatus('ready'); } } catch (error) { if (currentRequest === requestId.current) { console.error('[shiso] Search provider failed:', error); setResults([]); setStatus('error'); } } }, [loadProvider, pathname], ); const openDialog = useCallback(() => { if (!config.enabled) { return; } setOpen(true); void loadProvider().catch(error => { console.error('[shiso] Search provider failed to load:', error); }); }, [config.enabled, loadProvider]); const closeDialog = useCallback(() => { requestId.current += 1; setOpen(false); setQuery(''); setResults([]); setStatus('idle'); }, []); // Global shortcut configured by docs.json. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ( config.enabled && config.shortcut && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === config.shortcut ) { event.preventDefault(); openDialog(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [config.enabled, config.shortcut, openDialog]); const select = (result: SearchResult) => { closeDialog(); navigate(result.url); }; if (!config.enabled) { return null; } const hasQuery = !!query.trim(); return ( (nextOpen ? openDialog() : closeDialog())}> } > {config.prompt} {config.shortcut ? ( {config.shortcutLabel} ) : null} {labels.searchTitle} { if (event.key === 'Home' || event.key === 'End') { event.stopPropagation(); } }} /> {hasQuery ? ( {status === 'loading' ? labels.searching : status === 'error' ? labels.searchUnavailable : labels.noResults} ) : null} {hasQuery && results.length ? ( {results.map(result => ( select(result)} >
{renderWithQueryHighlight(result.page, query)} {result.heading ? ( <> {' › '} {renderWithQueryHighlight(result.heading, query)} ) : null}
{result.snippet && (
{renderSnippet(result.snippet)}
)}
))}
) : null}
); } /** * Renders the search control only when `search.position` targets this slot. * Layout components drop one of these into each position they support; the * per-page `search: false` frontmatter flag is honored here as well. */ export function SearchSlot({ site, position, className, }: { site: SiteModel; position: SearchPosition; className?: string; }) { const { pathname } = useLocation(); if (!site.search.enabled || site.search.position !== position) { return null; } if (getPageFrontmatter(pathname)?.search === false) { return null; } return ; }