@seqera/docusaurus-theme-seqera 1.0.25 → 1.0.26

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.
@@ -0,0 +1,943 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * Swizzled from docusaurus-theme-search-typesense to remove hardcoded
8
+ * group_by: 'url' so search page results match the search bar.
9
+ * Product routes are configured via themeConfig.typesense.productRoutes.
10
+ */
11
+ /* eslint-disable jsx-a11y/no-autofocus */
12
+ import React, {useEffect, useMemo, useState, useReducer, useRef} from 'react';
13
+ import clsx from 'clsx';
14
+ import algoliaSearchHelper from 'algoliasearch-helper';
15
+ import Head from '@docusaurus/Head';
16
+ import Link from '@docusaurus/Link';
17
+ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
18
+ import {
19
+ HtmlClassNameProvider,
20
+ usePluralForm,
21
+ isRegexpStringMatch,
22
+ useEvent,
23
+ // @ts-ignore
24
+ } from '@docusaurus/theme-common';
25
+ import {useSearchPage} from './useSearchPage';
26
+ import {useTitleFormatter} from './generalUtils';
27
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
28
+ import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
29
+ import Translate, {translate} from '@docusaurus/Translate';
30
+ import Layout from '@theme/Layout';
31
+ import styles from './styles.module.css';
32
+ import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter';
33
+ import type {ProductRoute} from '@seqera/docusaurus-theme-seqera';
34
+
35
+ // Non-content docusaurus_tag values to always exclude from search results.
36
+ // Verified against live Typesense facets — blog/doc-list tags have zero documents
37
+ // and are kept here defensively in case content is re-indexed with those tags.
38
+ const NON_CONTENT_TAGS = [
39
+ 'default',
40
+ 'doc_tag_doc_list',
41
+ 'blog_posts_list',
42
+ 'blog_tags_posts',
43
+ 'doc_tags_list',
44
+ 'blog_tags_list',
45
+ ];
46
+
47
+ // Custom dropdown for product/version filtering.
48
+ // A native <select> always closes and commits a value on click, making it impossible
49
+ // to let Platform Enterprise "expand" sub-options without immediately selecting it.
50
+ function FilterSelect({
51
+ value,
52
+ onChange,
53
+ options,
54
+ }: {
55
+ value: string;
56
+ onChange: (v: string) => void;
57
+ options: {
58
+ id: string;
59
+ label: string;
60
+ pluginId: string | null;
61
+ customTag: string | null;
62
+ versions: {name: string; label: string; isLast: boolean}[];
63
+ }[];
64
+ }) {
65
+ const [isOpen, setIsOpen] = useState(false);
66
+ const [expandedId, setExpandedId] = useState<string | null>(null);
67
+ const containerRef = useRef<HTMLDivElement>(null);
68
+
69
+ // Close when clicking outside
70
+ useEffect(() => {
71
+ if (!isOpen) return undefined;
72
+ function handleClickOutside(e: MouseEvent) {
73
+ if (
74
+ containerRef.current &&
75
+ !containerRef.current.contains(e.target as Node)
76
+ ) {
77
+ setIsOpen(false);
78
+ }
79
+ }
80
+ document.addEventListener('mousedown', handleClickOutside);
81
+ return () => document.removeEventListener('mousedown', handleClickOutside);
82
+ }, [isOpen]);
83
+
84
+ // Auto-expand the selected product's group when the dropdown opens
85
+ useEffect(() => {
86
+ if (!isOpen || !value) return;
87
+ const productId = value.includes('@') ? value.split('@')[0]! : value;
88
+ const product = options.find((o) => o.id === productId);
89
+ if (product && product.versions.length > 1) {
90
+ setExpandedId(productId);
91
+ }
92
+ }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
93
+
94
+ function handleSelect(newValue: string) {
95
+ onChange(newValue);
96
+ setIsOpen(false);
97
+ setExpandedId(null);
98
+ }
99
+
100
+ // Build the trigger label from the current value
101
+ const triggerLabel = (() => {
102
+ if (!value)
103
+ return translate({
104
+ id: 'theme.SearchPage.allProductsOption',
105
+ message: 'All products',
106
+ });
107
+ const [productId, versionName] = value.includes('@')
108
+ ? value.split('@')
109
+ : [value, null];
110
+ const product = options.find((o) => o.id === productId);
111
+ if (!product)
112
+ return translate({
113
+ id: 'theme.SearchPage.allProductsOption',
114
+ message: 'All products',
115
+ });
116
+ if (versionName) {
117
+ const version = product.versions.find((v) => v.name === versionName);
118
+ return `${product.label} \u2013 ${version?.label || versionName}`;
119
+ }
120
+ if (product.versions.length > 1) {
121
+ const current =
122
+ product.versions.find((v) => v.isLast) || product.versions[0]!;
123
+ return `${product.label} \u2013 Current (${current.label})`;
124
+ }
125
+ return product.label;
126
+ })();
127
+
128
+ return (
129
+ <div className={styles.filterSelectWrapper} ref={containerRef}>
130
+ <div className={clsx(styles.filterBox, isOpen && styles.filterBoxOpen)}>
131
+ <button
132
+ type="button"
133
+ className={styles.filterTrigger}
134
+ onClick={() => setIsOpen((o) => !o)}
135
+ aria-haspopup="listbox"
136
+ aria-expanded={isOpen}>
137
+ <span>{triggerLabel}</span>
138
+ <svg
139
+ aria-hidden="true"
140
+ className={clsx(
141
+ styles.filterTriggerChevron,
142
+ isOpen
143
+ ? styles.filterTriggerChevronOpen
144
+ : styles.filterTriggerChevronClosed,
145
+ )}
146
+ xmlns="http://www.w3.org/2000/svg"
147
+ viewBox="4 4 16 16"
148
+ fill="currentColor">
149
+ <path d="M11.8152 13.1989L10.0167 11.1432C9.80447 10.9013 9.97697 10.5214 10.2991 10.5214H13.8961C13.9682 10.5214 14.0388 10.5421 14.0994 10.5811C14.16 10.6201 14.2081 10.6758 14.2379 10.7414C14.2677 10.8071 14.2779 10.8799 14.2674 10.9512C14.2569 11.0226 14.226 11.0893 14.1785 11.1435L12.38 13.1985C12.3448 13.2388 12.3014 13.2711 12.2527 13.2932C12.204 13.3153 12.1511 13.3268 12.0976 13.3268C12.0441 13.3268 11.9912 13.3153 11.9425 13.2932C11.8938 13.2711 11.8504 13.2388 11.8152 13.1985V13.1989Z" />
150
+ </svg>
151
+ </button>
152
+ </div>
153
+ {isOpen && (
154
+ <ul className={styles.filterDropdown} role="listbox">
155
+ <li
156
+ role="option"
157
+ aria-selected={!value}
158
+ className={clsx(
159
+ styles.filterOption,
160
+ !value && styles.filterOptionActive,
161
+ )}
162
+ onClick={() => handleSelect('')}>
163
+ {translate({
164
+ id: 'theme.SearchPage.allProductsOption',
165
+ message: 'All products',
166
+ })}
167
+ </li>
168
+ {options.map((option) => {
169
+ if (option.versions.length > 1) {
170
+ const isExpanded = expandedId === option.id;
171
+ const isActive =
172
+ value === option.id || value.startsWith(`${option.id}@`);
173
+ const currentVersion =
174
+ option.versions.find((v) => v.isLast) || option.versions[0]!;
175
+ const olderVersions = option.versions.filter((v) => !v.isLast);
176
+ return (
177
+ <React.Fragment key={option.id}>
178
+ <li
179
+ role="option"
180
+ aria-selected={false}
181
+ aria-expanded={isExpanded}
182
+ className={clsx(
183
+ styles.filterOption,
184
+ styles.filterOptionExpandable,
185
+ isActive && styles.filterOptionActive,
186
+ )}
187
+ onClick={() =>
188
+ setExpandedId(isExpanded ? null : option.id)
189
+ }>
190
+ <span>{option.label}</span>
191
+ <svg
192
+ aria-hidden="true"
193
+ className={clsx(
194
+ styles.filterExpandChevron,
195
+ isExpanded
196
+ ? styles.filterTriggerChevronOpen
197
+ : styles.filterTriggerChevronClosed,
198
+ )}
199
+ xmlns="http://www.w3.org/2000/svg"
200
+ viewBox="4 4 16 16"
201
+ fill="currentColor">
202
+ <path d="M11.8152 13.1989L10.0167 11.1432C9.80447 10.9013 9.97697 10.5214 10.2991 10.5214H13.8961C13.9682 10.5214 14.0388 10.5421 14.0994 10.5811C14.16 10.6201 14.2081 10.6758 14.2379 10.7414C14.2677 10.8071 14.2779 10.8799 14.2674 10.9512C14.2569 11.0226 14.226 11.0893 14.1785 11.1435L12.38 13.1985C12.3448 13.2388 12.3014 13.2711 12.2527 13.2932C12.204 13.3153 12.1511 13.3268 12.0976 13.3268C12.0441 13.3268 11.9912 13.3153 11.9425 13.2932C11.8938 13.2711 11.8504 13.2388 11.8152 13.1985V13.1989Z" />
203
+ </svg>
204
+ </li>
205
+ {isExpanded && (
206
+ <>
207
+ <li
208
+ role="option"
209
+ aria-selected={value === option.id}
210
+ className={clsx(
211
+ styles.filterOption,
212
+ styles.filterSubOption,
213
+ value === option.id && styles.filterOptionActive,
214
+ )}
215
+ onClick={() => handleSelect(option.id)}>
216
+ Current ({currentVersion.label})
217
+ </li>
218
+ {olderVersions.map((v, i) => (
219
+ <li
220
+ key={i}
221
+ role="option"
222
+ aria-selected={
223
+ value === `${option.id}@${v.name}`
224
+ }
225
+ className={clsx(
226
+ styles.filterOption,
227
+ styles.filterSubOption,
228
+ value === `${option.id}@${v.name}` &&
229
+ styles.filterOptionActive,
230
+ )}
231
+ onClick={() =>
232
+ handleSelect(`${option.id}@${v.name}`)
233
+ }>
234
+ {v.label}
235
+ </li>
236
+ ))}
237
+ </>
238
+ )}
239
+ </React.Fragment>
240
+ );
241
+ }
242
+ return (
243
+ <li
244
+ key={option.id}
245
+ role="option"
246
+ aria-selected={value === option.id}
247
+ className={clsx(
248
+ styles.filterOption,
249
+ value === option.id && styles.filterOptionActive,
250
+ )}
251
+ onClick={() => handleSelect(option.id)}>
252
+ {option.label}
253
+ </li>
254
+ );
255
+ })}
256
+ </ul>
257
+ )}
258
+ </div>
259
+ );
260
+ }
261
+
262
+ // Very simple pluralization: probably good enough for now
263
+ function useDocumentsFoundPlural() {
264
+ const {selectMessage} = usePluralForm();
265
+ return (count: number) =>
266
+ selectMessage(
267
+ count,
268
+ translate(
269
+ {
270
+ id: 'theme.SearchPage.documentsFound.plurals',
271
+ description:
272
+ 'Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
273
+ message: 'One document found|{count} documents found',
274
+ },
275
+ {count},
276
+ ),
277
+ );
278
+ }
279
+
280
+ function useDocsSearchVersionsHelpers() {
281
+ const allDocsData = useAllDocsData();
282
+ // State of the version select menus / algolia facet filters
283
+ // docsPluginId -> versionName map
284
+ const [searchVersions, setSearchVersions] = useState(() =>
285
+ Object.entries(allDocsData).reduce(
286
+ (acc, [pluginId, pluginData]) => ({
287
+ ...acc,
288
+ [pluginId]: pluginData.versions[0]!.name,
289
+ }),
290
+ {} as Record<string, string>,
291
+ ),
292
+ );
293
+ // Set the value of a single select menu
294
+ const setSearchVersion = (pluginId: string, searchVersion: string) =>
295
+ setSearchVersions((s) => ({...s, [pluginId]: searchVersion}));
296
+ const versioningEnabled = Object.values(allDocsData).some(
297
+ (docsData) => docsData.versions.length > 1,
298
+ );
299
+ return {
300
+ allDocsData,
301
+ versioningEnabled,
302
+ searchVersions,
303
+ setSearchVersion,
304
+ };
305
+ }
306
+
307
+ function SearchPageContent() {
308
+ const {
309
+ siteConfig: {themeConfig},
310
+ i18n: {currentLocale},
311
+ } = useDocusaurusContext();
312
+ const {
313
+ typesense: {
314
+ typesenseCollectionName,
315
+ typesenseServerConfig,
316
+ typesenseSearchParameters,
317
+ contextualSearch,
318
+ externalUrlRegex,
319
+ productRoutes = [],
320
+ },
321
+ } = themeConfig as {
322
+ typesense: {
323
+ typesenseCollectionName: string;
324
+ typesenseServerConfig: object;
325
+ typesenseSearchParameters: Record<string, unknown>;
326
+ contextualSearch?: boolean;
327
+ externalUrlRegex?: string;
328
+ productRoutes?: ProductRoute[];
329
+ };
330
+ };
331
+
332
+ const documentsFoundPlural = useDocumentsFoundPlural();
333
+ const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
334
+
335
+ // Maps URL path prefixes to product labels using the configured productRoutes.
336
+ function getProductLabel(pathname: string): string | null {
337
+ const match = productRoutes.find(([prefix]) =>
338
+ pathname.startsWith(prefix),
339
+ );
340
+ return match ? match[1] : null;
341
+ }
342
+
343
+ // Compute tags for old versions of versioned plugins to exclude from results,
344
+ // so only the latest version of each plugin appears by default.
345
+ // The exclusion approach (rather than a whitelist) is used deliberately so that
346
+ // content accessible via URL rewrites or non-standard tags is not accidentally dropped.
347
+ const oldVersionTags = useMemo(() => {
348
+ const tags: string[] = [];
349
+ Object.entries(docsSearchVersionsHelpers.allDocsData).forEach(
350
+ ([pluginId, pluginData]) => {
351
+ if (pluginData.versions.length > 1) {
352
+ const latest =
353
+ pluginData.versions.find((v) => v.isLast) ||
354
+ pluginData.versions[0]!;
355
+ pluginData.versions
356
+ .filter((v) => v.name !== latest.name)
357
+ .forEach((v) => tags.push(`docs-${pluginId}-${v.name}`));
358
+ }
359
+ },
360
+ );
361
+ return tags;
362
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
363
+
364
+ const {searchQuery, setSearchQuery, selectedFilter, setSelectedFilter} = useSearchPage();
365
+ // inputValue tracks the live input; searchQuery only updates on submit
366
+ const [inputValue, setInputValue] = useState(searchQuery);
367
+ // Sync inputValue when searchQuery is populated from the URL on hydration
368
+ useEffect(() => {
369
+ setInputValue(searchQuery);
370
+ }, [searchQuery]);
371
+
372
+ // Products available for filtering — plugin-based products (from allDocsData) and
373
+ // rewrite-based products with a known customTag (e.g. Nextflow → docs-default-current).
374
+ // Each entry has a stable `id` used as the select option value:
375
+ // - plugin-based: id = pluginId
376
+ // - rewrite-based: id = customTag (pluginId is null)
377
+ const productOptions = useMemo(
378
+ () =>
379
+ productRoutes
380
+ .filter(
381
+ ([, , pluginId, customTag]) =>
382
+ (pluginId && docsSearchVersionsHelpers.allDocsData[pluginId]) ||
383
+ customTag,
384
+ )
385
+ .map(([, label, pluginId, customTag]) => ({
386
+ id: (pluginId || customTag)!,
387
+ label,
388
+ pluginId,
389
+ customTag,
390
+ versions:
391
+ pluginId && docsSearchVersionsHelpers.allDocsData[pluginId]
392
+ ? docsSearchVersionsHelpers.allDocsData[pluginId].versions
393
+ : [],
394
+ })),
395
+ [], // eslint-disable-line react-hooks/exhaustive-deps
396
+ );
397
+
398
+ type SearchResultItem = {
399
+ title: string;
400
+ url: string;
401
+ summary: string;
402
+ breadcrumbs: string[];
403
+ };
404
+
405
+ type SearchResultState = {
406
+ items: SearchResultItem[];
407
+ query: string | null;
408
+ totalResults: number | null;
409
+ totalPages: number | null;
410
+ lastPage: number | null;
411
+ hasMore: boolean | null;
412
+ loading: boolean | null;
413
+ };
414
+
415
+ const initialSearchResultState: SearchResultState = {
416
+ items: [],
417
+ query: null,
418
+ totalResults: null,
419
+ totalPages: null,
420
+ lastPage: null,
421
+ hasMore: null,
422
+ loading: null,
423
+ };
424
+
425
+ const [searchResultState, searchResultStateDispatcher] = useReducer(
426
+ (
427
+ prevState: SearchResultState,
428
+ data:
429
+ | {type: 'reset'}
430
+ | {type: 'loading'}
431
+ | {type: 'update'; value: SearchResultState}
432
+ | {type: 'advance'},
433
+ ): SearchResultState => {
434
+ switch (data.type) {
435
+ case 'reset': {
436
+ return initialSearchResultState;
437
+ }
438
+ case 'loading': {
439
+ return {...prevState, loading: true};
440
+ }
441
+ case 'update': {
442
+ if (searchQuery !== data.value.query) {
443
+ return prevState;
444
+ }
445
+ return {
446
+ ...data.value,
447
+ items:
448
+ data.value.lastPage === 0
449
+ ? data.value.items
450
+ : prevState.items.concat(data.value.items),
451
+ };
452
+ }
453
+ case 'advance': {
454
+ const hasMore =
455
+ (prevState.totalPages ?? 0) > (prevState.lastPage ?? 0) + 1;
456
+ return {
457
+ ...prevState,
458
+ lastPage: hasMore
459
+ ? (prevState.lastPage ?? 0) + 1
460
+ : prevState.lastPage,
461
+ hasMore,
462
+ };
463
+ }
464
+ default:
465
+ return prevState;
466
+ }
467
+ },
468
+ initialSearchResultState,
469
+ );
470
+
471
+ // Memoize the adapter and helper so they're only created once, not on every render.
472
+ // Creating a new TypesenseInstantSearchAdapter on every render causes repeated
473
+ // network activity and accumulates stale event listeners.
474
+ // eslint-disable-next-line react-hooks/exhaustive-deps
475
+ const typesenseInstantSearchAdapter = useMemo(() => {
476
+ // Parse selectedFilter: '' | 'productId' | 'productId@versionName'
477
+ const atIdx = selectedFilter.indexOf('@');
478
+ const filterId = atIdx >= 0 ? selectedFilter.slice(0, atIdx) : selectedFilter;
479
+ const filterVersion = atIdx >= 0 ? selectedFilter.slice(atIdx + 1) : null;
480
+ const filterProduct = filterId
481
+ ? productOptions.find((p) => p.id === filterId)
482
+ : null;
483
+
484
+ let filterBy: string;
485
+ if (filterProduct) {
486
+ // Specific product selected: use an explicit inclusion filter so old versions
487
+ // are not accidentally blocked by the config-level exclusion list.
488
+ // We rebuild from NON_CONTENT_TAGS rather than typesenseSearchParameters.filter_by
489
+ // because the config filter already excludes old version tags, which would
490
+ // conflict when the user intentionally selects an older version.
491
+ let inclusionTag: string;
492
+ if (filterProduct.customTag) {
493
+ // Rewrite-based product (e.g. Nextflow): use its known docusaurus_tag directly
494
+ inclusionTag = filterProduct.customTag;
495
+ } else {
496
+ const targetVersionName =
497
+ filterVersion ||
498
+ (
499
+ filterProduct.versions.find((v) => v.isLast) ||
500
+ filterProduct.versions[0]!
501
+ ).name;
502
+ inclusionTag = `docs-${filterProduct.pluginId}-${targetVersionName}`;
503
+ }
504
+ filterBy = [
505
+ `docusaurus_tag:!=[${NON_CONTENT_TAGS.join(',')}]`,
506
+ `docusaurus_tag:=[${inclusionTag}]`,
507
+ ].join(' && ');
508
+ } else {
509
+ // All products: use config filter + exclude remaining old version tags.
510
+ // Exclusion (rather than a whitelist) ensures content accessible via URL
511
+ // rewrites or non-standard tags is not accidentally dropped.
512
+ const versionExclusion =
513
+ oldVersionTags.length > 0
514
+ ? `docusaurus_tag:!=[${oldVersionTags.join(',')}]`
515
+ : null;
516
+ filterBy = [
517
+ (typesenseSearchParameters as {filter_by?: string}).filter_by,
518
+ versionExclusion,
519
+ ]
520
+ .filter(Boolean)
521
+ .join(' && ');
522
+ }
523
+
524
+ return new TypesenseInstantSearchAdapter({
525
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
526
+ server: typesenseServerConfig as any,
527
+ additionalSearchParameters: {
528
+ // Defaults matching typesense-docsearch-react (SearchBar) behaviour
529
+ query_by:
530
+ 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content',
531
+ include_fields:
532
+ 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content,anchor,url,type,id',
533
+ highlight_full_fields:
534
+ 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content',
535
+ group_by: 'url',
536
+ group_limit: 1,
537
+ sort_by: 'item_priority:desc',
538
+ snippet_threshold: 8,
539
+ highlight_affix_num_tokens: 4,
540
+ ...typesenseSearchParameters,
541
+ filter_by: filterBy,
542
+ },
543
+ });
544
+ }, [selectedFilter]); // eslint-disable-line react-hooks/exhaustive-deps
545
+
546
+ // eslint-disable-next-line react-hooks/exhaustive-deps
547
+ const algoliaHelper = useMemo(
548
+ () =>
549
+ algoliaSearchHelper(
550
+ typesenseInstantSearchAdapter.searchClient,
551
+ typesenseCollectionName,
552
+ {
553
+ hitsPerPage:
554
+ (typesenseSearchParameters as {per_page?: number}).per_page ?? 20,
555
+ advancedSyntax: true,
556
+ ...(contextualSearch && {
557
+ disjunctiveFacets: ['language', 'docusaurus_tag'],
558
+ }),
559
+ },
560
+ ),
561
+ [typesenseInstantSearchAdapter], // eslint-disable-line react-hooks/exhaustive-deps
562
+ );
563
+
564
+ useEffect(() => {
565
+ const sanitizeValue = (value: string) =>
566
+ value.replace(
567
+ /algolia-docsearch-suggestion--highlight/g,
568
+ 'search-result-match',
569
+ );
570
+
571
+ function handleResult({
572
+ results: {query, hits, page, nbHits, nbPages},
573
+ }: {
574
+ results: {
575
+ query: string;
576
+ hits: Array<{
577
+ url: string;
578
+ _highlightResult: Record<string, {value: string}>;
579
+ _snippetResult?: Record<string, {value: string}>;
580
+ hierarchy?: Record<string, string>;
581
+ [key: string]: unknown;
582
+ }>;
583
+ page: number;
584
+ nbHits: number;
585
+ nbPages: number;
586
+ };
587
+ }) {
588
+ if (query === '' || !Array.isArray(hits)) {
589
+ searchResultStateDispatcher({type: 'reset'});
590
+ return;
591
+ }
592
+ const items = hits.map((hit) => {
593
+ const {url, _highlightResult, _snippetResult: snippet = {}} = hit;
594
+ const parsedURL = new URL(url);
595
+ // Build levels using both raw and highlighted values.
596
+ // Raw values are plain text matching what the page breadcrumbs show.
597
+ // Highlighted values show which part of the hierarchy matched the query.
598
+ const levels = [0, 1, 2, 3, 4, 5, 6]
599
+ .map((lvl) => {
600
+ // Raw value: try dot-notation key first, then nested object
601
+ const raw =
602
+ (hit[`hierarchy.lvl${lvl}`] as string) ||
603
+ hit.hierarchy?.[`lvl${lvl}`] ||
604
+ '';
605
+ const h = _highlightResult[`hierarchy.lvl${lvl}`];
606
+ const highlighted = h ? sanitizeValue(h.value) : raw;
607
+ return {raw, highlighted};
608
+ })
609
+ .filter((l) => l.raw);
610
+ // Last level is the page/section title; remainder are breadcrumbs
611
+ const titleLevel = levels.pop();
612
+ const product = getProductLabel(parsedURL.pathname);
613
+ // Replace lvl0 ("Documentation") with the product label
614
+ if (product && levels.length > 0) {
615
+ levels[0] = {raw: product, highlighted: product};
616
+ } else if (product) {
617
+ levels.unshift({raw: product, highlighted: product});
618
+ }
619
+ const resultUrl = isRegexpStringMatch(externalUrlRegex, parsedURL.href)
620
+ ? parsedURL.href
621
+ : parsedURL.pathname + parsedURL.hash;
622
+ return {
623
+ title: titleLevel?.highlighted || '',
624
+ url: resultUrl,
625
+ summary: (snippet as Record<string, {value: string}>).content?.value
626
+ ? `${sanitizeValue((snippet as Record<string, {value: string}>).content!.value)}...`
627
+ : '',
628
+ // Include all levels (parent categories + current page/section)
629
+ // so the breadcrumb matches the full path shown on the page.
630
+ breadcrumbs: [...levels, ...(titleLevel ? [titleLevel] : [])].map(
631
+ (l) => l.raw,
632
+ ),
633
+ };
634
+ });
635
+ searchResultStateDispatcher({
636
+ type: 'update',
637
+ value: {
638
+ items,
639
+ query,
640
+ totalResults: nbHits,
641
+ totalPages: nbPages,
642
+ lastPage: page,
643
+ hasMore: nbPages > page + 1,
644
+ loading: false,
645
+ },
646
+ });
647
+ }
648
+
649
+ function handleError(e: Error) {
650
+ console.error(e);
651
+ }
652
+
653
+ algoliaHelper.on('result', handleResult);
654
+ // @ts-ignore — 'error' is a valid event but missing from type definitions
655
+ algoliaHelper.on('error', handleError);
656
+ return () => {
657
+ algoliaHelper.removeAllListeners('result');
658
+ algoliaHelper.removeAllListeners('error');
659
+ };
660
+ }, [algoliaHelper]); // algoliaHelper is stable (useMemo with []), so this runs once
661
+
662
+ const [loaderRef, setLoaderRef] = useState<HTMLDivElement | null>(null);
663
+ const prevY = useRef(0);
664
+ const observer = useRef(
665
+ ExecutionEnvironment.canUseIntersectionObserver &&
666
+ new IntersectionObserver(
667
+ (entries) => {
668
+ const entry = entries[0];
669
+ if (!entry) return;
670
+ const {isIntersecting, boundingClientRect: {y: currentY}} = entry;
671
+ if (isIntersecting && prevY.current > currentY) {
672
+ searchResultStateDispatcher({type: 'advance'});
673
+ }
674
+ prevY.current = currentY;
675
+ },
676
+ {threshold: 1},
677
+ ),
678
+ );
679
+
680
+ const getTitle = () =>
681
+ searchQuery
682
+ ? translate(
683
+ {
684
+ id: 'theme.SearchPage.existingResultsTitle',
685
+ message: 'Search results for "{query}"',
686
+ description: 'The search page title for non-empty query',
687
+ },
688
+ {query: searchQuery},
689
+ )
690
+ : translate({
691
+ id: 'theme.SearchPage.emptyResultsTitle',
692
+ message: 'Search the documentation',
693
+ description: 'The search page title for empty query',
694
+ });
695
+
696
+ const makeSearch = useEvent((page = 0) => {
697
+ if (contextualSearch) {
698
+ algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default');
699
+ algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale);
700
+ Object.entries(docsSearchVersionsHelpers.searchVersions).forEach(
701
+ ([pluginId, searchVersion]) => {
702
+ algoliaHelper.addDisjunctiveFacetRefinement(
703
+ 'docusaurus_tag',
704
+ `docs-${pluginId}-${searchVersion}`,
705
+ );
706
+ },
707
+ );
708
+ }
709
+ algoliaHelper.setQuery(searchQuery).setPage(page).search();
710
+ });
711
+
712
+ useEffect(() => {
713
+ if (!loaderRef) {
714
+ return undefined;
715
+ }
716
+ const currentObserver = observer.current;
717
+ if (currentObserver) {
718
+ currentObserver.observe(loaderRef);
719
+ return () => currentObserver.unobserve(loaderRef);
720
+ }
721
+ return () => true;
722
+ }, [loaderRef]);
723
+
724
+ useEffect(() => {
725
+ searchResultStateDispatcher({type: 'reset'});
726
+ if (searchQuery) {
727
+ searchResultStateDispatcher({type: 'loading'});
728
+ makeSearch();
729
+ }
730
+ }, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch, selectedFilter]);
731
+
732
+ useEffect(() => {
733
+ if (!searchResultState.lastPage || searchResultState.lastPage === 0) {
734
+ return;
735
+ }
736
+ makeSearch(searchResultState.lastPage);
737
+ }, [makeSearch, searchResultState.lastPage]);
738
+
739
+ return (
740
+ <Layout>
741
+ <Head>
742
+ <title>{useTitleFormatter(getTitle())}</title>
743
+ {/*
744
+ We should not index search pages
745
+ See https://github.com/facebook/docusaurus/pull/3233
746
+ */}
747
+ <meta property="robots" content="noindex, follow" />
748
+ </Head>
749
+
750
+ <div className="container margin-vert--lg">
751
+ <h1>{getTitle()}</h1>
752
+
753
+ <form
754
+ onSubmit={(e) => {
755
+ e.preventDefault();
756
+ setSearchQuery(inputValue);
757
+ }}>
758
+ <div className={styles.searchQueryColumn}>
759
+ <div className={styles.searchInputRow}>
760
+ <input
761
+ type="search"
762
+ name="q"
763
+ className={styles.searchQueryInput}
764
+ placeholder={translate({
765
+ id: 'theme.SearchPage.inputPlaceholder',
766
+ message: 'Type your search here',
767
+ description: 'The placeholder for search page input',
768
+ })}
769
+ aria-label={translate({
770
+ id: 'theme.SearchPage.inputLabel',
771
+ message: 'Search',
772
+ description: 'The ARIA label for search page input',
773
+ })}
774
+ onChange={(e) => setInputValue(e.target.value)}
775
+ value={inputValue}
776
+ autoComplete="off"
777
+ autoFocus
778
+ />
779
+ {productOptions.length > 0 && (
780
+ <FilterSelect
781
+ value={selectedFilter}
782
+ onChange={setSelectedFilter}
783
+ options={productOptions}
784
+ />
785
+ )}
786
+ <button
787
+ type="submit"
788
+ className={styles.searchQueryButton}
789
+ aria-label={translate({
790
+ id: 'theme.SearchPage.searchButtonLabel',
791
+ message: 'Search',
792
+ description: 'The ARIA label for the search button',
793
+ })}>
794
+ <Translate
795
+ id="theme.SearchPage.searchButton"
796
+ description="The label for the search button">
797
+ Search
798
+ </Translate>
799
+ </button>
800
+ </div>
801
+ </div>
802
+ </form>
803
+
804
+ <div className="row">
805
+ <div
806
+ className={clsx(
807
+ 'col',
808
+ 'col--8',
809
+ styles.searchResultsColumn,
810
+ )}>
811
+ {!!searchResultState.totalResults &&
812
+ documentsFoundPlural(searchResultState.totalResults)}
813
+ </div>
814
+
815
+ <div
816
+ className={clsx(
817
+ 'col',
818
+ 'col--4',
819
+ 'text--right',
820
+ styles.searchLogoColumn,
821
+ )}>
822
+ <a
823
+ target="_blank"
824
+ rel="noopener noreferrer"
825
+ href={`https://typesense.org/?utm_medium=referral&utm_content=powered_by&utm_campaign=docsearch`}
826
+ aria-label={translate({
827
+ id: 'theme.SearchPage.typesenseLabel',
828
+ message: 'Search by Typesense',
829
+ description: 'The ARIA label for Typesense mention',
830
+ })}>
831
+ <svg
832
+ fill="none"
833
+ height="21"
834
+ viewBox="0 0 141 21"
835
+ width="141"
836
+ xmlns="http://www.w3.org/2000/svg">
837
+ <clipPath id="a">
838
+ <path d="m0 0h141v21h-141z" />
839
+ </clipPath>
840
+ <g clipPath="url(#a)">
841
+ <g fill="#1035bc">
842
+ <path d="m62.0647 6.453c.0371.19.0557.37367.0557.551 0 .16467-.0186.342-.0557.532l-2.3561-.019v6.384c0 .532.2412.798.7235.798h1.41c.0866.2153.1299.4307.1299.646s-.0124.3483-.0371.399c-.569.076-1.1564.114-1.7625.114-1.1997 0-1.7995-.5257-1.7995-1.577v-6.764l-1.3172.019c-.0371-.19-.0557-.36733-.0557-.532 0-.17733.0186-.361.0557-.551l1.3172.019v-1.995c0-.342.0494-.58267.1484-.722.0989-.152.2906-.228.5751-.228h.5009l.1113.114v2.85z" />
843
+ <path d="m71.0419 6.548-2.5416 8.911c-.47 1.634-.9709 2.7867-1.5027 3.458s-1.3296 1.007-2.3932 1.007c-.5442 0-1.0452-.0823-1.5028-.247-.0371-.3547.0619-.6967.2969-1.026.3834.1393.7915.209 1.2244.209.6555 0 1.1564-.228 1.5027-.684s.6617-1.1653.9462-2.128l.0556-.19c-.3215-.0253-.5689-.1013-.742-.228-.1608-.1267-.2969-.361-.4082-.703l-2.5973-8.36c.3834-.16467.6555-.247.8163-.247.3587 0 .5999.22167.7235.665l1.4657 4.769c.0494.152.3339 1.14.8534 2.964.0247.0887.0865.133.1855.133l2.2633-8.398c.1608-.05067.3711-.076.6308-.076.2721 0 .5009.038.6864.114z" />
844
+ <path d="m74.6067 15.155v3.762c0 .342-.0495.5827-.1484.722-.0989.152-.2968.228-.5937.228h-.5009l-.1113-.114v-13.243l.1113-.114h.4824c.2968 0 .4947.08233.5936.247.1114.152.167.40533.167.76v.095c.7421-.84867 1.6264-1.273 2.653-1.273 1.0513 0 1.8428.437 2.3746 1.311.5319.86133.7978 2.05834.7978 3.591 0 .7473-.099 1.4187-.2968 2.014-.1856.5953-.4391 1.102-.7607 1.52-.3092.4053-.6679.722-1.076.95-.4082.2153-.8287.323-1.2616.323-.8534 0-1.6635-.2597-2.4303-.779zm0-6.175v4.883c.7545.57 1.4656.855 2.1335.855s1.2183-.304 1.6512-.912c.4328-.608.6493-1.5263.6493-2.755 0-.608-.0557-1.13366-.167-1.577-.0989-.456-.235-.82967-.4081-1.121-.1732-.304-.3773-.52567-.6123-.665-.2226-.152-.4638-.228-.7235-.228-.4947 0-.9647.133-1.41.399-.4452.266-.8163.63967-1.1131 1.121z" />
845
+ <path d="m89.8263 11.545h-5.7512c.0619 2.1533.8596 3.23 2.3932 3.23.8411 0 1.7378-.266 2.6901-.798.2721.2533.4391.5763.5009.969-1.0142.7093-2.152 1.064-3.4136 1.064-.6431 0-1.1935-.1203-1.6511-.361-.4576-.2533-.8349-.5953-1.1317-1.026-.2845-.4433-.4947-.9627-.6308-1.558-.136-.5953-.204-1.2477-.204-1.957 0-.722.0803-1.38067.2411-1.976.1732-.59533.4205-1.10833.7421-1.539s.705-.76633 1.1503-1.007c.4576-.24067.977-.361 1.5583-.361.569 0 1.0761.10767 1.5213.323.4576.20267.8349.48767 1.1317.855.3092.35467.5442.78533.705 1.292.1608.494.2411 1.026.2411 1.596 0 .228-.0123.4497-.0371.665-.0123.2027-.0309.399-.0556.589zm-5.7512-1.083h4.4525v-.247c0-.874-.1793-1.577-.538-2.109s-.8967-.798-1.614-.798c-.705 0-1.2554.285-1.6512.855-.3834.57-.5998 1.33633-.6493 2.299z" />
846
+ <path d="m91.7359 15.117c.0123-.2787.0865-.5827.2226-.912.1484-.342.3154-.608.5009-.798.9771.5447 1.8367.817 2.5787.817.4082 0 .7359-.0823.9833-.247.2597-.1647.3896-.3863.3896-.665 0-.4433-.3339-.798-1.0018-1.064l-1.0389-.399c-1.5584-.5827-2.3376-1.5137-2.3376-2.793 0-.456.0804-.86133.2412-1.216.1731-.36733.4081-.67767.705-.931.3092-.266.674-.46867 1.0945-.608s.8905-.209 1.41-.209c.235 0 .4947.019.7792.057.2968.038.5937.095.8905.171.2968.06333.5813.13933.8534.228s.5071.18367.705.285c0 .31667-.0619.646-.1856.988-.1236.342-.2906.59533-.5009.76-.977-.44333-1.8243-.665-2.5416-.665-.3216 0-.5751.08233-.7606.247-.1856.152-.2783.35467-.2783.608 0 .39267.3092.703.9276.931l1.1317.418c.8163.2913 1.4223.6903 1.8181 1.197.3957.5067.5936 1.0957.5936 1.767 0 .8993-.3277 1.6213-.9832 2.166-.6555.532-1.5955.798-2.8199.798-1.1998 0-2.3253-.3103-3.3765-.931z" />
847
+ <path d="m107.996 11.868h-5.121c.037.6967.192 1.2477.464 1.653.284.3927.773.589 1.466.589.717 0 1.539-.2153 2.467-.646.359.38.587.8803.686 1.501-.989.722-2.176 1.083-3.562 1.083-1.311 0-2.306-.4117-2.987-1.235-.667-.836-1.001-2.071-1.001-3.705 0-.76.086-1.444.259-2.052.174-.62067.427-1.14633.761-1.577.334-.44333.742-.78533 1.224-1.026.483-.24067 1.033-.361 1.652-.361.63 0 1.187.10133 1.669.304.483.19.891.46867 1.225.836.334.35467.581.779.742 1.273.173.494.26 1.03233.26 1.615 0 .3167-.019.6207-.056.912-.037.2787-.087.5573-.148.836zm-3.581-3.99c-.965 0-1.484.74733-1.558 2.242h3.079v-.228c0-.608-.123-1.09567-.371-1.463-.247-.36733-.631-.551-1.15-.551z" />
848
+ <path d="m118.163 9.436v4.142c0 .8107.13 1.4123.39 1.805-.396.3547-.872.532-1.429.532-.532 0-.897-.1203-1.095-.361-.197-.2533-.296-.646-.296-1.178v-4.427c0-.57-.068-.969-.204-1.197-.137-.228-.39-.342-.761-.342-.656 0-1.268.304-1.837.912v6.46c-.185.038-.383.0633-.593.076-.198.0127-.402.019-.613.019-.21 0-.42-.0063-.63-.019-.198-.0127-.39-.038-.576-.076v-9.405l.112-.133h.927c.693 0 1.126.38 1.299 1.14.903-.798 1.8-1.197 2.69-1.197.891 0 1.546.29767 1.967.893.433.58267.649 1.368.649 2.356z" />
849
+ <path d="m120.109 15.117c.012-.2787.087-.5827.223-.912.148-.342.315-.608.501-.798.977.5447 1.836.817 2.578.817.408 0 .736-.0823.984-.247.259-.1647.389-.3863.389-.665 0-.4433-.334-.798-1.002-1.064l-1.039-.399c-1.558-.5827-2.337-1.5137-2.337-2.793 0-.456.08-.86133.241-1.216.173-.36733.408-.67767.705-.931.309-.266.674-.46867 1.095-.608.42-.13933.89-.209 1.41-.209.235 0 .494.019.779.057.297.038.593.095.89.171.297.06333.582.13933.854.228s.507.18367.705.285c0 .31667-.062.646-.186.988s-.291.59533-.501.76c-.977-.44333-1.824-.665-2.541-.665-.322 0-.576.08233-.761.247-.186.152-.278.35467-.278.608 0 .39267.309.703.927.931l1.132.418c.816.2913 1.422.6903 1.818 1.197s.594 1.0957.594 1.767c0 .8993-.328 1.6213-.984 2.166-.655.532-1.595.798-2.819.798-1.2 0-2.326-.3103-3.377-.931z" />
850
+ <path d="m136.369 11.868h-5.121c.037.6967.192 1.2477.464 1.653.285.3927.773.589 1.466.589.717 0 1.54-.2153 2.467-.646.359.38.588.8803.687 1.501-.99.722-2.177 1.083-3.562 1.083-1.311 0-2.307-.4117-2.987-1.235-.668-.836-1.002-2.071-1.002-3.705 0-.76.086-1.444.26-2.052.173-.62067.426-1.14633.76-1.577.334-.44333.742-.78533 1.225-1.026.482-.24067 1.032-.361 1.651-.361.631 0 1.187.10133 1.67.304.482.19.89.46867 1.224.836.334.35467.581.779.742 1.273.173.494.26 1.03233.26 1.615 0 .3167-.019.6207-.056.912-.037.2787-.086.5573-.148.836zm-3.581-3.99c-.965 0-1.484.74733-1.558 2.242h3.079v-.228c0-.608-.123-1.09567-.371-1.463-.247-.36733-.63-.551-1.15-.551z" />
851
+ <path d="m139.245 18.442v-17.385c.186-.038.396-.057.631-.057.247 0 .476.019.686.057v17.385c-.21.038-.439.057-.686.057-.235 0-.445-.019-.631-.057z" />
852
+ </g>
853
+ <path
854
+ d="m2.648 14.604c.216.144.556.272 1.02.384s.872.168 1.224.168c.592 0 1.104-.092 1.536-.276.44-.184.772-.436.996-.756.232-.32.348-.688.348-1.104 0-.384-.08-.712-.24-.984-.16-.28-.396-.528-.708-.744-.304-.216-.708-.44-1.212-.672-.56-.256-.984-.468-1.272-.636s-.512-.352-.672-.552c-.152-.208-.228-.456-.228-.744 0-.384.156-.684.468-.9.32-.216.744-.324 1.272-.324.352 0 .648.036.888.108.248.072.52.176.816.312l.324-.732c-.28-.144-.604-.264-.972-.36-.36-.096-.732-.144-1.116-.144-.52 0-.98.092-1.38.276-.392.176-.696.42-.912.732-.208.312-.312.66-.312 1.044 0 .544.172 1.004.516 1.38.352.376.9.724 1.644 1.044.52.224.928.424 1.224.6.304.168.536.36.696.576.16.208.24.452.24.732 0 .392-.172.712-.516.96-.336.24-.816.36-1.44.36-.352 0-.712-.048-1.08-.144-.36-.104-.668-.22-.924-.348zm11.0963-2.364c0-.96-.204-1.736-.612-2.328-.4-.592-1.024-.888-1.872-.888-.56 0-1.048.132-1.46396.396-.408.256-.72.616-.936 1.08-.208.456-.312.98-.312 1.572 0 .936.26 1.684.78 2.244s1.27596.84 2.26796.84c.4 0 .764-.052 1.092-.156.328-.112.656-.26.984-.444l-.3-.696c-.36.176-.672.304-.936.384-.256.08-.54.12-.852.12-.688 0-1.2-.188-1.536-.564-.32796-.384-.50396-.904-.52796-1.56zm-4.19996-.648c.056-.544.224-.972.50396-1.284.288-.32.68-.48 1.176-.48.92 0 1.448.588 1.584 1.764zm5.84426-1.344c.288-.128.552-.224.792-.288.248-.072.544-.108.888-.108.44 0 .76.124.96.372.208.248.312.588.312 1.02v.324h-1.5c-.632 0-1.14.156-1.524.468s-.576.748-.576 1.308c0 .536.168.972.504 1.308.344.336.84.504 1.488.504.304 0 .616-.072.936-.216.32-.152.588-.328.804-.528l.12.588h.708v-4.02c0-.376-.096-.712-.288-1.008s-.46-.528-.804-.696-.736-.252-1.176-.252c-.264 0-.6.048-1.008.144-.4.096-.704.216-.912.36zm.228 3.096c0-.32.104-.588.312-.804.216-.224.512-.336.888-.336h1.536v1.32c-.216.256-.468.464-.756.624-.28.16-.576.24-.888.24-.36 0-.632-.104-.816-.312s-.276-.452-.276-.732zm6.0874-2.352c.272-.28.604-.524.996-.732.392-.216.748-.324 1.068-.324l-.228-.756c-.28 0-.604.1-.972.3s-.684.412-.948.636l-.3-.936h-.564v5.82h.948zm6.9986 2.952c-.28.144-.532.248-.756.312s-.512.096-.864.096c-.584 0-1.036-.212-1.356-.636s-.48-.976-.48-1.656c.008-.648.18-1.18.516-1.596.336-.424.792-.636 1.368-.636.336 0 .608.032.816.096.216.064.484.164.804.3l.288-.672c-.232-.152-.54-.276-.924-.372-.376-.104-.696-.156-.96-.156-.576 0-1.08.132-1.512.396-.432.256-.768.616-1.008 1.08-.232.456-.348.98-.348 1.572 0 .6.112 1.136.336 1.608.224.464.548.828.972 1.092.424.256.924.384 1.5.384.264 0 .588-.052.972-.156.384-.096.696-.22.936-.372zm4.6201-4.944c.616 0 1.072.188 1.368.564.304.368.456.864.456 1.488v3.936h-.948v-3.804c0-.432-.08-.768-.24-1.008s-.428-.36-.804-.36c-.288 0-.616.1-.984.3-.36.2-.68.452-.96.756v4.128h-.948v-8.352l.948-.12v3.396c.28-.272.612-.492.996-.66.392-.176.764-.264 1.116-.264zm8.5136.024c.864 0 1.54.284 2.028.852.496.56.744 1.304.744 2.232 0 .592-.116 1.12-.348 1.584-.224.456-.548.816-.972 1.08-.416.256-.908.384-1.476.384-.24 0-.496-.052-.768-.156-.264-.096-.512-.236-.744-.42l-.204.42h-.564v-8.352l.948-.12v2.94c.216-.144.444-.252.684-.324.24-.08.464-.12.672-.12zm0 5.328c.576 0 1.02-.208 1.332-.624s.472-.952.48-1.608c0-.688-.156-1.24-.468-1.656-.304-.424-.748-.636-1.332-.636-.288 0-.54.044-.756.132s-.42.224-.612.408v3.468c.192.168.396.296.612.384s.464.132.744.132zm5.0915 1.608c-.088.24-.224.42-.408.54-.176.128-.452.28-.828.456l.288.684c.424-.088.796-.26 1.116-.516.328-.256.56-.564.696-.924l2.568-7.02h-.96l-1.668 4.5-1.764-4.68-.84.36 2.16 5.604z"
855
+ fill="#000"
856
+ fillOpacity=".25"
857
+ />
858
+ </g>
859
+ </svg>
860
+ </a>
861
+ </div>
862
+ </div>
863
+
864
+ {searchResultState.items.length > 0 ? (
865
+ <main>
866
+ {searchResultState.items.map(({title, url, summary, breadcrumbs}, i) => (
867
+ <article key={i} className={styles.searchResultItem}>
868
+ <h2 className={styles.searchResultItemHeading}>
869
+ <Link
870
+ to={url}
871
+ dangerouslySetInnerHTML={{__html: title}}
872
+ />
873
+ </h2>
874
+
875
+ {breadcrumbs.length > 0 && (
876
+ <nav aria-label="breadcrumbs">
877
+ <ul
878
+ className={clsx(
879
+ 'breadcrumbs',
880
+ styles.searchResultItemPath,
881
+ )}>
882
+ {breadcrumbs.map((html, index) => (
883
+ <li
884
+ key={index}
885
+ className="breadcrumbs__item"
886
+ // Developer provided the HTML, so assume it's safe.
887
+ // eslint-disable-next-line react/no-danger
888
+ dangerouslySetInnerHTML={{__html: html}}
889
+ />
890
+ ))}
891
+ </ul>
892
+ </nav>
893
+ )}
894
+
895
+ {summary && (
896
+ <p
897
+ className={styles.searchResultItemSummary}
898
+ // Developer provided the HTML, so assume it's safe.
899
+ // eslint-disable-next-line react/no-danger
900
+ dangerouslySetInnerHTML={{__html: summary}}
901
+ />
902
+ )}
903
+ </article>
904
+ ))}
905
+ </main>
906
+ ) : (
907
+ [
908
+ searchQuery && !searchResultState.loading && (
909
+ <p key="no-results">
910
+ <Translate
911
+ id="theme.SearchPage.noResultsText"
912
+ description="The paragraph for empty search result">
913
+ No results were found
914
+ </Translate>
915
+ </p>
916
+ ),
917
+ !!searchResultState.loading && (
918
+ <div key="spinner" className={styles.loadingSpinner} />
919
+ ),
920
+ ]
921
+ )}
922
+
923
+ {searchResultState.hasMore && (
924
+ <div className={styles.loader} ref={setLoaderRef}>
925
+ <Translate
926
+ id="theme.SearchPage.fetchingNewResults"
927
+ description="The paragraph for fetching new search results">
928
+ Fetching new results...
929
+ </Translate>
930
+ </div>
931
+ )}
932
+ </div>
933
+ </Layout>
934
+ );
935
+ }
936
+
937
+ export default function SearchPage(): JSX.Element {
938
+ return (
939
+ <HtmlClassNameProvider className="search-page-wrapper">
940
+ <SearchPageContent />
941
+ </HtmlClassNameProvider>
942
+ );
943
+ }