From e7d892d80a11f1aae780365eb40e2a2007afcdd2 Mon Sep 17 00:00:00 2001 From: Andrei Sirbu Date: Fri, 30 Jan 2026 18:44:55 +0200 Subject: [PATCH 01/17] testing the new algolia search --- src/theme/SearchBar/index.js | 356 +++++++++++++++++++++++++++++++++ src/theme/SearchBar/styles.css | 14 ++ 2 files changed, 370 insertions(+) create mode 100644 src/theme/SearchBar/index.js create mode 100644 src/theme/SearchBar/styles.css diff --git a/src/theme/SearchBar/index.js b/src/theme/SearchBar/index.js new file mode 100644 index 0000000000..b2163ba21f --- /dev/null +++ b/src/theme/SearchBar/index.js @@ -0,0 +1,356 @@ +import React, {useCallback, useMemo, useRef, useState, useEffect} from 'react'; +import {createPortal} from 'react-dom'; +import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import {useHistory} from '@docusaurus/router'; +import { + isRegexpStringMatch, + useSearchLinkCreator, +} from '@docusaurus/theme-common'; +import { + useAlgoliaContextualFacetFilters, + useSearchResultUrlProcessor, +} from '@docusaurus/theme-search-algolia/client'; +import Translate from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import translations from '@theme/SearchTranslations'; + +let DocSearchModal = null; + +function importDocSearchModalIfNeeded() { + if (DocSearchModal) { + return Promise.resolve(); + } + return Promise.all([ + import('@docsearch/react/modal'), + import('@docsearch/react/style'), + import('./styles.css'), + ]).then(([{DocSearchModal: Modal}]) => { + DocSearchModal = Modal; + }); +} + +function useNavigator({externalUrlRegex}) { + const history = useHistory(); + const [navigator] = useState(() => { + return { + navigate(params) { + if (isRegexpStringMatch(externalUrlRegex, params.itemUrl)) { + window.location.href = params.itemUrl; + } else { + history.push(params.itemUrl); + } + }, + }; + }); + return navigator; +} + +function useTransformSearchClient() { + const { + siteMetadata: {docusaurusVersion}, + } = useDocusaurusContext(); + return useCallback( + (searchClient) => { + searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion); + return searchClient; + }, + [docusaurusVersion], + ); +} + +function useTransformItems(props) { + const processSearchResultUrl = useSearchResultUrlProcessor(); + const [transformItems] = useState(() => { + return (items) => + props.transformItems + ? props.transformItems(items) + : items.map((item) => ({ + ...item, + url: processSearchResultUrl(item.url), + })); + }); + return transformItems; +} + +function useResultsFooterComponent({closeModal}) { + return useMemo( + () => + ({state}) => + , + [closeModal], + ); +} + +function Hit({hit, children}) { + return {children}; +} + +function ResultsFooter({state, onClose}) { + const createSearchLink = useSearchLinkCreator(); + return ( + + + {'See all {count} results'} + + + ); +} + +function useSearchParameters({contextualSearch, productFacetFilters = [], ...props}) { + function mergeFacetFilters(f1, f2) { + const normalize = (f) => (typeof f === 'string' ? [f] : f); + return [...normalize(f1), ...normalize(f2)]; + } + + const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters(); + + const configFacetFilters = props.searchParameters?.facetFilters ?? []; + const combinedConfigFacetFilters = + productFacetFilters.length > 0 + ? mergeFacetFilters(configFacetFilters, productFacetFilters) + : configFacetFilters; + + const facetFilters = contextualSearch + ? mergeFacetFilters(contextualSearchFacetFilters, combinedConfigFacetFilters) + : combinedConfigFacetFilters; + + return { + ...props.searchParameters, + facetFilters, + }; +} + +// Values MUST match Algolia facet values exactly +const PRODUCT_OPTIONS = [ + {label: 'All products', value: '__all__'}, + {label: 'Netwrix Auditor', value: 'Auditor'}, + {label: 'Netwrix Privilege Secure', value: 'Privilege Secure'}, + {label: 'Netwrix Password Secure', value: 'Password Secure'}, + {label: 'Netwrix Change Tracker', value: 'Change Tracker'}, + {label: 'Netwrix Endpoint Policy Manager', value: 'Endpoint Policy Manager'}, + {label: 'Netwrix Endpoint Protector', value: 'Endpoint Protector'}, + {label: 'Netwrix Access Analyzer', value: 'Access Analyzer'}, + {label: 'Netwrix Data Classification', value: 'Data Classification'}, + {label: 'Netwrix 1Secure', value: '1Secure'}, + {label: 'Netwrix Threat Manager', value: 'Threat Manager'}, + {label: 'Netwrix Threat Prevention', value: 'Threat Prevention'}, + {label: 'Recovery for Active Directory', value: 'Recovery for Active Directory'}, + {label: 'PingCastle', value: 'PingCastle'}, + {label: 'Access Information Center', value: 'Access Information Center'}, + {label: 'Activity Monitor', value: 'Activity Monitor'}, + {label: 'Password Policy Enforcer', value: 'Password Policy Enforcer'}, + {label: 'Password Reset', value: 'Password Reset'}, +]; + +function ProductSelect({value, onChange}) { + return ( + + ); +} + +function DocSearch({externalUrlRegex, onModalOpen, ...props}) { + const navigator = useNavigator({externalUrlRegex}); + const searchParameters = useSearchParameters({...props}); + const transformItems = useTransformItems(props); + const transformSearchClient = useTransformSearchClient(); + const searchContainer = useRef(null); + const searchButtonRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(undefined); + + const prepareSearchContainer = useCallback(() => { + if (!searchContainer.current) { + const divElement = document.createElement('div'); + searchContainer.current = divElement; + document.body.insertBefore(divElement, document.body.firstChild); + } + }, []); + + const openModal = useCallback(() => { + prepareSearchContainer(); + importDocSearchModalIfNeeded().then(() => { + setIsOpen(true); + // Let React render the modal, then caller can locate DOM nodes + setTimeout(() => onModalOpen?.(), 0); + }); + }, [prepareSearchContainer, onModalOpen]); + + const closeModal = useCallback(() => { + setIsOpen(false); + searchButtonRef.current?.focus(); + setInitialQuery(undefined); + }, []); + + const handleInput = useCallback( + (event) => { + if (event.key === 'f' && (event.metaKey || event.ctrlKey)) { + return; + } + event.preventDefault(); + setInitialQuery(event.key); + openModal(); + }, + [openModal], + ); + + const resultsFooterComponent = useResultsFooterComponent({closeModal}); + + useDocSearchKeyboardEvents({ + isOpen, + onOpen: openModal, + onClose: closeModal, + onInput: handleInput, + searchButtonRef, + }); + + return ( + <> + + + + + + + {isOpen && + DocSearchModal && + searchContainer.current && + createPortal( + , + searchContainer.current, + )} + + ); +} + +export default function SearchBar() { + const {siteConfig} = useDocusaurusContext(); + + const [product, setProduct] = useState(() => { + if (typeof window === 'undefined') return '__all__'; + return localStorage.getItem('docs_product_filter') || '__all__'; + }); + + const productFacetFilters = useMemo(() => { + if (!product || product === '__all__') return []; + return [`product_name:${product}`]; + }, [product]); + + const onChangeProduct = (e) => { + const next = e.target.value; + setProduct(next); + + if (typeof window !== 'undefined') { + localStorage.setItem('docs_product_filter', next); + } + + // Trigger DocSearch to refresh results without closing the modal: + // Find the modal's input and dispatch an input event with the same value. + setTimeout(() => { + const input = + document.querySelector('.DocSearch-Input') || + document.querySelector('input[type="search"]'); + + if (input) { + const value = input.value; + // Re-set same value and dispatch input event to retrigger search + input.value = value; + input.dispatchEvent(new Event('input', {bubbles: true})); + } + }, 0); + }; + + + // This is where we will portal the - {PRODUCT_OPTIONS.map((opt) => ( - - ))} - +
+ + {isOpen && ( +
+ {options.map((opt) => { + const isSelected = opt.value === '__all__' + ? selectedValues.length === 0 + : selectedValues.includes(opt.value); + + return ( + + ); + })} +
+ )} +
); } -function DocSearch({externalUrlRegex, onModalOpen, ...props}) { - const navigator = useNavigator({externalUrlRegex}); +function DocSearch({externalUrlRegex, onModalOpen, selectedProducts, ...props}) { + const navigator = useNavigator({externalUrlRegex, selectedProducts}); const searchParameters = useSearchParameters({...props}); const transformItems = useTransformItems(props); const transformSearchClient = useTransformSearchClient(); @@ -213,7 +353,10 @@ function DocSearch({externalUrlRegex, onModalOpen, ...props}) { [openModal], ); - const resultsFooterComponent = useResultsFooterComponent({closeModal}); + const resultsFooterComponent = useResultsFooterComponent({ + closeModal, + selectedProducts, + }); useDocSearchKeyboardEvents({ isOpen, @@ -269,42 +412,84 @@ function DocSearch({externalUrlRegex, onModalOpen, ...props}) { export default function SearchBar() { const {siteConfig} = useDocusaurusContext(); - const [product, setProduct] = useState(() => { - if (typeof window === 'undefined') return '__all__'; - return localStorage.getItem('docs_product_filter') || '__all__'; + // Store the current search query to preserve it across filter changes + const searchQueryRef = useRef(''); + + // Multi-select state for products + const [selectedProducts, setSelectedProducts] = useState(() => { + if (typeof window === 'undefined') return []; + const saved = localStorage.getItem('docs_product_filter'); + try { + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } }); + // Generate facetFilters for products const productFacetFilters = useMemo(() => { - if (!product || product === '__all__') return []; - return [`product_name:${product}`]; - }, [product]); - - const onChangeProduct = (e) => { - const next = e.target.value; - setProduct(next); + const filters = []; - if (typeof window !== 'undefined') { - localStorage.setItem('docs_product_filter', next); + // Add product filters (OR logic - any of the selected products) + if (selectedProducts.length > 0) { + const productFilters = selectedProducts.map(p => `product_name:${p}`); + filters.push(productFilters); // Array within array = OR logic } - // Trigger DocSearch to refresh results without closing the modal: - // Find the modal's input and dispatch an input event with the same value. - setTimeout(() => { + return filters; + }, [selectedProducts]); + + // Keep track of the search input value + useEffect(() => { + const interval = setInterval(() => { const input = document.querySelector('.DocSearch-Input') || document.querySelector('input[type="search"]'); - if (input) { - const value = input.value; - // Re-set same value and dispatch input event to retrigger search - input.value = value; - input.dispatchEvent(new Event('input', {bubbles: true})); + searchQueryRef.current = input.value; } - }, 0); - }; + }, 100); + return () => clearInterval(interval); + }, []); - // This is where we will portal the { + const values = Array.from(e.target.selectedOptions, option => option.value); + onChange(values); + }} + style={{ + width: '100%', + minHeight: '120px', + padding: '8px', + borderRadius: '4px', + border: '1px solid var(--ifm-color-emphasis-300)', + }} + > + {options.map((opt) => ( + + ))} + + + Hold Ctrl/Cmd to select multiple + + + ); +} + +function useDocumentsFoundPlural() { + const {selectMessage} = usePluralForm(); + return (count) => + selectMessage( + count, + translate( + { + id: 'theme.SearchPage.documentsFound.plurals', + message: 'One document found|{count} documents found', + }, + {count}, + ), + ); +} + +function SearchPageContent() { + const {i18n: {currentLocale}} = useDocusaurusContext(); + const {algolia: {appId, apiKey, indexName}} = useAlgoliaThemeConfig(); + const processSearchResultUrl = useSearchResultUrlProcessor(); + const documentsFoundPlural = useDocumentsFoundPlural(); + const location = useLocation(); + const history = useHistory(); + + // Parse URL parameters + const urlParams = new URLSearchParams(location.search); + const queryFromUrl = urlParams.get('q') || ''; + const productsFromUrl = urlParams.get('products')?.split(',').filter(Boolean) || []; + + const [searchQuery, setSearchQuery] = useState(queryFromUrl); + const [selectedProducts, setSelectedProducts] = useState(productsFromUrl); + + // Update state when URL changes (e.g., when navigating from search modal) + useEffect(() => { + const urlParams = new URLSearchParams(location.search); + const newQuery = urlParams.get('q') || ''; + const newProducts = urlParams.get('products')?.split(',').filter(Boolean) || []; + + setSearchQuery(newQuery); + setSelectedProducts(newProducts); + }, [location.search]); + + const initialSearchResultState = { + items: [], + query: null, + totalResults: null, + totalPages: null, + lastPage: null, + hasMore: null, + loading: null, + }; + + const [searchResultState, searchResultStateDispatcher] = useReducer( + (prevState, data) => { + switch (data.type) { + case 'reset': + return initialSearchResultState; + case 'loading': + return {...prevState, loading: true}; + case 'update': + if (searchQuery !== data.value.query) { + return prevState; + } + return { + ...data.value, + items: + data.value.lastPage === 0 + ? data.value.items + : prevState.items.concat(data.value.items), + }; + case 'advance': + const hasMore = prevState.totalPages > prevState.lastPage + 1; + return { + ...prevState, + lastPage: hasMore ? prevState.lastPage + 1 : prevState.lastPage, + hasMore, + }; + default: + return prevState; + } + }, + initialSearchResultState, + ); + + const algoliaClient = useMemo(() => liteClient(appId, apiKey), [appId, apiKey]); + const algoliaHelper = useMemo( + () => + algoliaSearchHelper(algoliaClient, indexName, { + hitsPerPage: 15, + advancedSyntax: true, + disjunctiveFacets: ['language'], // Only language facet exists in index + }), + [algoliaClient, indexName], + ); + + algoliaHelper.on('result', ({results: {query, hits, page, nbHits, nbPages, facets}}) => { + if (query === '' || !Array.isArray(hits)) { + searchResultStateDispatcher({type: 'reset'}); + return; + } + + + const sanitizeValue = (value) => + value.replace(/algolia-docsearch-suggestion--highlight/g, 'search-result-match'); + + // Extract product and version from URL and filter client-side + const allItems = hits.map(({url, _highlightResult: {hierarchy}, _snippetResult: snippet = {}, product_name}) => { + const titles = Object.keys(hierarchy).map((key) => sanitizeValue(hierarchy[key].value)); + const breadcrumbs = [...titles]; + const title = titles.pop(); + + // Extract product and version from URL path like /docs/auditor/10.8/... + let product = product_name; + let version = null; + let productId = null; + + // Try to match: /docs/{product}/{version}/ or /docs/kb/{product}/ + const urlMatch = url.match(/\/docs\/(?:kb\/)?([^/]+)(?:\/([^/]+))?/); + if (urlMatch) { + productId = urlMatch[1]; + const versionPart = urlMatch[2]; + + // Map product ID to display name + const productConfig = PRODUCTS.find(p => p.id === productId); + if (productConfig) { + product = productConfig.name; + + // Convert version from URL format (10_8) to display format (10.8) + if (versionPart && versionPart !== 'kb') { + version = versionPart.replace(/_/g, '.'); + } + } + } + + // Fallback: try to extract product from breadcrumbs + if (!product && breadcrumbs.length > 0) { + product = breadcrumbs[0].replace(/<[^>]*>/g, ''); + } + if (!product) { + product = 'Unknown'; + } + + return { + title, + url: processSearchResultUrl(url), + summary: snippet.content ? `${sanitizeValue(snippet.content.value)}...` : '', + breadcrumbs, + product, + version, + productId, + originalUrl: url, + }; + }); + + // Algolia handles filtering via facetFilters, so no client-side filtering needed + const items = allItems; + + searchResultStateDispatcher({ + type: 'update', + value: { + items, + query, + totalResults: nbHits, + totalPages: nbPages, + lastPage: page, + hasMore: nbPages > page + 1, + loading: false, + }, + }); + }); + + const [loaderRef, setLoaderRef] = useState(null); + const prevY = useRef(0); + const observer = useRef( + ExecutionEnvironment.canUseIntersectionObserver && + new IntersectionObserver( + (entries) => { + const {isIntersecting, boundingClientRect: {y: currentY}} = entries[0]; + if (isIntersecting && prevY.current > currentY) { + searchResultStateDispatcher({type: 'advance'}); + } + prevY.current = currentY; + }, + {threshold: 1}, + ), + ); + + const makeSearch = useCallback( + (page = 0) => { + // Build facetFilters with product filters (matching SearchBar logic) + const facetFilters = [`language:${currentLocale}`]; + + // Add product filters (OR logic - any of the selected products) + const hasProductFilter = selectedProducts.length > 0 && !selectedProducts.includes('__all__'); + if (hasProductFilter) { + const productFilters = selectedProducts.map(p => `product_name:${p}`); + facetFilters.push(productFilters); // Array within array = OR logic + } + + algoliaHelper + .setQuery(searchQuery) + .setQueryParameter('facetFilters', facetFilters) + .setPage(page) + .search(); + }, + [searchQuery, algoliaHelper, currentLocale, selectedProducts], + ); + + // Update URL when filters change + const prevFiltersRef = useRef({searchQuery: '', selectedProducts: []}); + + useEffect(() => { + // Only update URL if filters actually changed + const prev = prevFiltersRef.current; + if ( + prev.searchQuery !== searchQuery || + JSON.stringify(prev.selectedProducts) !== JSON.stringify(selectedProducts) + ) { + const params = new URLSearchParams(); + if (searchQuery) params.set('q', searchQuery); + if (selectedProducts.length > 0) params.set('products', selectedProducts.join(',')); + + history.replace({search: params.toString()}); + + prevFiltersRef.current = {searchQuery, selectedProducts}; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, selectedProducts]); + + useEffect(() => { + if (!loaderRef) return undefined; + const currentObserver = observer.current; + if (currentObserver) { + currentObserver.observe(loaderRef); + return () => currentObserver.unobserve(loaderRef); + } + return () => true; + }, [loaderRef]); + + useEffect(() => { + searchResultStateDispatcher({type: 'reset'}); + if (searchQuery) { + searchResultStateDispatcher({type: 'loading'}); + setTimeout(() => makeSearch(), 300); + } + }, [searchQuery, selectedProducts, makeSearch]); + + useEffect(() => { + if (!searchResultState.lastPage || searchResultState.lastPage === 0) { + return; + } + makeSearch(searchResultState.lastPage); + }, [makeSearch, searchResultState.lastPage]); + + const pageTitle = searchQuery + ? `Search results for "${searchQuery}"` + : 'Search the documentation'; + + return ( + + + + + + +
+ {pageTitle} + +
+
+ setSearchQuery(e.target.value)} + value={searchQuery} + autoComplete="off" + autoFocus + style={{ + width: '100%', + padding: '14px 16px', + fontSize: '16px', + borderRadius: '8px', + border: '2px solid var(--ifm-color-emphasis-300)', + marginBottom: '0', + transition: 'border-color 0.2s', + }} + onFocus={(e) => { + e.target.style.borderColor = 'var(--ifm-color-primary)'; + e.target.style.outline = 'none'; + }} + onBlur={(e) => { + e.target.style.borderColor = 'var(--ifm-color-emphasis-300)'; + }} + /> +
+
+ +
+
+ + {!!searchResultState.totalResults && ( +
+ {documentsFoundPlural(searchResultState.totalResults)} +
+ )} + + {searchResultState.items.length > 0 ? ( +
+ {(() => { + // Group results by product + const groupedByProduct = searchResultState.items.reduce((acc, item) => { + const product = item.product || 'Unknown'; + if (!acc[product]) acc[product] = []; + acc[product].push(item); + return acc; + }, {}); + + // Sort products alphabetically + const sortedProducts = Object.keys(groupedByProduct).sort(); + + return sortedProducts.map((product) => ( +
+ + {product} ({groupedByProduct[product].length} results) + + + {groupedByProduct[product].map(({title, url, summary, breadcrumbs}, i) => ( +
+ + + + + {breadcrumbs.length > 0 && ( + + )} + + {summary && ( +

+ )} +

+ ))} +
+ )); + })()} +
+ ) : ( + [ + searchQuery && !searchResultState.loading && ( +

+ No results were found +

+ ), + !!searchResultState.loading && ( +
+ Loading... +
+ ), + ] + )} + + {searchResultState.hasMore && ( +
+ Fetching new results... +
+ )} +
+
+ ); +} + +export default function SearchPage() { + return ( + + + + ); +} diff --git a/src/theme/SearchPage/styles.module.css b/src/theme/SearchPage/styles.module.css new file mode 100644 index 0000000000..649c45478c --- /dev/null +++ b/src/theme/SearchPage/styles.module.css @@ -0,0 +1,41 @@ +/* Custom styles for search page */ + +.searchQueryColumn { + flex-grow: 1; +} + +.searchResultsColumn { + margin-top: 1rem; +} + +.searchResultItem { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.searchResultItemHeading { + font-size: 1.25rem; + margin-bottom: 0.5rem; +} + +.searchResultItemPath { + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.searchResultItemSummary { + color: var(--ifm-color-emphasis-700); + margin: 0; +} + +.loadingSpinner { + text-align: center; + padding: 2rem; +} + +.loader { + text-align: center; + padding: 1rem; + color: var(--ifm-color-emphasis-600); +} From 6cee1405d6924a2480d7141bc723deba8f44caf1 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Mon, 2 Mar 2026 14:25:08 -0500 Subject: [PATCH 03/17] Add back to top button on search results page - Floating button appears when scrolled down more than 300px - Smooth scroll animation to return to top - Positioned in bottom-right corner with hover effects --- src/theme/SearchPage/index.js | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 03109f7913..7b700b90b1 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -90,6 +90,9 @@ function SearchPageContent() { const location = useLocation(); const history = useHistory(); + // Back to top button visibility + const [showBackToTop, setShowBackToTop] = useState(false); + // Parse URL parameters const urlParams = new URLSearchParams(location.search); const queryFromUrl = urlParams.get('q') || ''; @@ -320,6 +323,23 @@ function SearchPageContent() { makeSearch(searchResultState.lastPage); }, [makeSearch, searchResultState.lastPage]); + // Show/hide back to top button based on scroll position + useEffect(() => { + const handleScroll = () => { + setShowBackToTop(window.scrollY > 300); + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }; + const pageTitle = searchQuery ? `Search results for "${searchQuery}"` : 'Search the documentation'; @@ -492,6 +512,44 @@ function SearchPageContent() { )} + + {/* Back to top button */} + {showBackToTop && ( + + )} ); } From 691e506fe9d30e0df52cdb959fe2a2953d7c6455 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Mon, 2 Mar 2026 15:16:24 -0500 Subject: [PATCH 04/17] Fix HTML sanitization security vulnerability Replace fragile regex with safe DOM-based HTML stripping to prevent potential HTML injection from incomplete tag fragments. Resolves CodeQL security warning. --- src/theme/SearchPage/index.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 7b700b90b1..d340bb37e9 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -19,6 +19,20 @@ import Heading from '@theme/Heading'; import {PRODUCTS} from '../../config/products'; import styles from './styles.module.css'; +// Safely strip HTML tags to plain text +function stripHtmlTagsToText(input) { + if (!input) { + return ''; + } + if (ExecutionEnvironment.canUseDOM) { + const container = document.createElement('div'); + container.innerHTML = input; + return container.textContent || container.innerText || ''; + } + // Fallback for non-DOM environments (SSR): basic tag removal + return input.replace(/<[^>]*>/g, ''); +} + // Generate product options from PRODUCTS config const PRODUCT_OPTIONS = [ {label: 'All products', value: '__all__'}, @@ -205,7 +219,7 @@ function SearchPageContent() { // Fallback: try to extract product from breadcrumbs if (!product && breadcrumbs.length > 0) { - product = breadcrumbs[0].replace(/<[^>]*>/g, ''); + product = stripHtmlTagsToText(breadcrumbs[0]); } if (!product) { product = 'Unknown'; From 6f8850df52d5b6e52481a5f0278ff8b833d7761c Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Mon, 2 Mar 2026 15:55:04 -0500 Subject: [PATCH 05/17] Add numbered results list and dynamic result counters - Add sequential numbering to search results across all products - Update product headings to show result range (e.g., 'showing 1-15 of 103 results') - Range updates dynamically as user scrolls and loads more results - Improves navigation through large result sets --- src/theme/SearchPage/index.js | 80 ++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index d340bb37e9..fff2bf8db6 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -436,40 +436,60 @@ function SearchPageContent() { // Sort products alphabetically const sortedProducts = Object.keys(groupedByProduct).sort(); - return sortedProducts.map((product) => ( -
- - {product} ({groupedByProduct[product].length} results) - - - {groupedByProduct[product].map(({title, url, summary, breadcrumbs}, i) => ( -
{ + const productResults = groupedByProduct[product]; + const startNum = resultNumber + 1; + const endNum = resultNumber + productResults.length; + const totalForProduct = productResults.length; + + return ( +
+ - + + {productResults.map(({title, url, summary, breadcrumbs}, i) => { + resultNumber++; + return ( +
- - + + + {resultNumber}. + + + {breadcrumbs.length > 0 && (
- ))} + ); + })}
- )); + ); + }); })()} ) : ( From ce65d1a36aaebaf151f198591e5bbb9be605f6a0 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Tue, 3 Mar 2026 00:26:24 -0500 Subject: [PATCH 06/17] Harden SSR HTML sanitization to prevent injection attacks - Use iterative tag removal to handle nested/malformed tags - Remove remaining angle brackets to prevent partial tag injection - Addresses CodeQL security warning: js/incomplete-multi-character-sanitization --- src/theme/SearchPage/index.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index fff2bf8db6..5cbffbe328 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -29,8 +29,16 @@ function stripHtmlTagsToText(input) { container.innerHTML = input; return container.textContent || container.innerText || ''; } - // Fallback for non-DOM environments (SSR): basic tag removal - return input.replace(/<[^>]*>/g, ''); + // Fallback for non-DOM environments (SSR): robust iterative tag removal + let previous = input; + let current = input.replace(/<[^>]*>/g, ''); + // Repeat until no further tag-like patterns are found + while (current !== previous) { + previous = current; + current = current.replace(/<[^>]*>/g, ''); + } + // Remove any remaining angle brackets to prevent partial tag injection + return current.replace(/[<>]/g, ''); } // Generate product options from PRODUCTS config From 09c4a723b5507be2c1bb93827614e7184ea7dbd1 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Tue, 3 Mar 2026 02:42:56 -0500 Subject: [PATCH 07/17] Replace infinite scroll with pagination controls and enhance navigation - Replace IntersectionObserver infinite scroll with Previous/Next pagination buttons - Add page jump input field allowing users to type and jump to any page - Add results-per-page selector with options: 25, 50, 100, 150, 200 (default: 25) - Add jump to bottom button that appears when viewing top of results - Update result counters to show "X results on this page" instead of global totals - Add automatic scroll-to-top when changing pages - Add URL persistence for page number and resultsPerPage settings - Fix browser back button to restore previous search state and position - Fix pagination navigation issues when resultsPerPage exceeds default --- src/theme/SearchPage/index.js | 435 +++++++++++++++++++++++++++------- 1 file changed, 346 insertions(+), 89 deletions(-) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 5cbffbe328..887220b3c3 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -114,23 +114,55 @@ function SearchPageContent() { // Back to top button visibility const [showBackToTop, setShowBackToTop] = useState(false); + const [showJumpToBottom, setShowJumpToBottom] = useState(false); + + // Page jump input state + const [pageInputValue, setPageInputValue] = useState(''); + const [pageInputFocused, setPageInputFocused] = useState(false); // Parse URL parameters const urlParams = new URLSearchParams(location.search); const queryFromUrl = urlParams.get('q') || ''; const productsFromUrl = urlParams.get('products')?.split(',').filter(Boolean) || []; + const resultsPerPageFromUrl = parseInt(urlParams.get('resultsPerPage'), 10) || 25; + const pageFromUrl = parseInt(urlParams.get('page'), 10) || 1; const [searchQuery, setSearchQuery] = useState(queryFromUrl); const [selectedProducts, setSelectedProducts] = useState(productsFromUrl); + const [resultsPerPage, setResultsPerPage] = useState(resultsPerPageFromUrl); + + // Track if we're restoring from URL (e.g., browser back button) + const restoringFromUrl = useRef(false); + const targetPageRef = useRef(null); + const isInternalNavigation = useRef(false); - // Update state when URL changes (e.g., when navigating from search modal) + // Update state when URL changes (e.g., when navigating from search modal or browser back) useEffect(() => { + // Skip if this was an internal navigation (we triggered the URL change ourselves) + if (isInternalNavigation.current) { + isInternalNavigation.current = false; + return; + } + + // External navigation (browser back/forward, or coming from search modal) const urlParams = new URLSearchParams(location.search); const newQuery = urlParams.get('q') || ''; const newProducts = urlParams.get('products')?.split(',').filter(Boolean) || []; + const newResultsPerPage = parseInt(urlParams.get('resultsPerPage'), 10) || 25; + const newPage = parseInt(urlParams.get('page'), 10) || 1; setSearchQuery(newQuery); setSelectedProducts(newProducts); + setResultsPerPage(newResultsPerPage); + + // Store target page for restoration + if (newPage > 1) { + restoringFromUrl.current = true; + targetPageRef.current = newPage - 1; + } else { + restoringFromUrl.current = false; + targetPageRef.current = null; + } }, [location.search]); const initialSearchResultState = { @@ -156,17 +188,25 @@ function SearchPageContent() { } return { ...data.value, - items: - data.value.lastPage === 0 - ? data.value.items - : prevState.items.concat(data.value.items), + items: data.value.items, // Show only current page + }; + case 'nextPage': + if (!prevState.hasMore || prevState.loading) { + return prevState; + } + return { + ...prevState, + lastPage: prevState.lastPage + 1, + loading: true, }; - case 'advance': - const hasMore = prevState.totalPages > prevState.lastPage + 1; + case 'prevPage': + if (prevState.lastPage === 0 || prevState.loading) { + return prevState; + } return { ...prevState, - lastPage: hasMore ? prevState.lastPage + 1 : prevState.lastPage, - hasMore, + lastPage: prevState.lastPage - 1, + loading: true, }; default: return prevState; @@ -179,11 +219,11 @@ function SearchPageContent() { const algoliaHelper = useMemo( () => algoliaSearchHelper(algoliaClient, indexName, { - hitsPerPage: 15, + hitsPerPage: resultsPerPage, advancedSyntax: true, disjunctiveFacets: ['language'], // Only language facet exists in index }), - [algoliaClient, indexName], + [algoliaClient, indexName, resultsPerPage], ); algoliaHelper.on('result', ({results: {query, hits, page, nbHits, nbPages, facets}}) => { @@ -262,21 +302,7 @@ function SearchPageContent() { }); }); - const [loaderRef, setLoaderRef] = useState(null); - const prevY = useRef(0); - const observer = useRef( - ExecutionEnvironment.canUseIntersectionObserver && - new IntersectionObserver( - (entries) => { - const {isIntersecting, boundingClientRect: {y: currentY}} = entries[0]; - if (isIntersecting && prevY.current > currentY) { - searchResultStateDispatcher({type: 'advance'}); - } - prevY.current = currentY; - }, - {threshold: 1}, - ), - ); + // Pagination is now controlled by Previous/Next buttons instead of infinite scroll const makeSearch = useCallback( (page = 0) => { @@ -299,61 +325,94 @@ function SearchPageContent() { [searchQuery, algoliaHelper, currentLocale, selectedProducts], ); - // Update URL when filters change - const prevFiltersRef = useRef({searchQuery: '', selectedProducts: []}); + // Update URL when filters or pagination change + const prevFiltersRef = useRef({searchQuery: '', selectedProducts: [], resultsPerPage: 25, page: 1}); useEffect(() => { - // Only update URL if filters actually changed + // Only update URL if values actually changed const prev = prevFiltersRef.current; + const currentPage = (searchResultState.lastPage || 0) + 1; + if ( prev.searchQuery !== searchQuery || - JSON.stringify(prev.selectedProducts) !== JSON.stringify(selectedProducts) + JSON.stringify(prev.selectedProducts) !== JSON.stringify(selectedProducts) || + prev.resultsPerPage !== resultsPerPage || + prev.page !== currentPage ) { const params = new URLSearchParams(); if (searchQuery) params.set('q', searchQuery); if (selectedProducts.length > 0) params.set('products', selectedProducts.join(',')); + if (resultsPerPage !== 25) params.set('resultsPerPage', String(resultsPerPage)); + if (currentPage > 1) params.set('page', String(currentPage)); + // Mark this as an internal navigation so location.search effect ignores it + isInternalNavigation.current = true; history.replace({search: params.toString()}); - prevFiltersRef.current = {searchQuery, selectedProducts}; + prevFiltersRef.current = {searchQuery, selectedProducts, resultsPerPage, page: currentPage}; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchQuery, selectedProducts]); + }, [searchQuery, selectedProducts, resultsPerPage, searchResultState.lastPage]); - useEffect(() => { - if (!loaderRef) return undefined; - const currentObserver = observer.current; - if (currentObserver) { - currentObserver.observe(loaderRef); - return () => currentObserver.unobserve(loaderRef); - } - return () => true; - }, [loaderRef]); + // IntersectionObserver removed - using pagination buttons instead useEffect(() => { searchResultStateDispatcher({type: 'reset'}); if (searchQuery) { searchResultStateDispatcher({type: 'loading'}); - setTimeout(() => makeSearch(), 300); + // If restoring from URL, use the target page; otherwise start at page 0 + const startPage = (restoringFromUrl.current && targetPageRef.current !== null) + ? targetPageRef.current + : 0; + setTimeout(() => { + makeSearch(startPage); + // Clear restoration flags after search + restoringFromUrl.current = false; + targetPageRef.current = null; + }, 300); } - }, [searchQuery, selectedProducts, makeSearch]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, selectedProducts, resultsPerPage]); useEffect(() => { - if (!searchResultState.lastPage || searchResultState.lastPage === 0) { + // Only trigger pagination search if lastPage has been set by nextPage/prevPage actions + // (not during initial render where lastPage is null) + if (searchResultState.lastPage === null) { return; } makeSearch(searchResultState.lastPage); - }, [makeSearch, searchResultState.lastPage]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchResultState.lastPage]); + + // Scroll to top when page changes (except initial load) + const isInitialLoad = useRef(true); + useEffect(() => { + if (isInitialLoad.current) { + isInitialLoad.current = false; + return; + } + if (searchResultState.items.length > 0 && !searchResultState.loading) { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } + }, [searchResultState.lastPage, searchResultState.items.length, searchResultState.loading]); - // Show/hide back to top button based on scroll position + // Show/hide back to top and jump to bottom buttons based on scroll position useEffect(() => { const handleScroll = () => { - setShowBackToTop(window.scrollY > 300); + const scrolledDown = window.scrollY > 300; + const nearBottom = (window.innerHeight + window.scrollY) >= document.documentElement.scrollHeight - 300; + + setShowBackToTop(scrolledDown); + setShowJumpToBottom(!scrolledDown && !nearBottom && searchResultState.items.length > 0); }; + handleScroll(); // Check initial state window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); - }, []); + }, [searchResultState.items.length]); const scrollToTop = () => { window.scrollTo({ @@ -362,6 +421,13 @@ function SearchPageContent() { }); }; + const scrollToBottom = () => { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth' + }); + }; + const pageTitle = searchQuery ? `Search results for "${searchQuery}"` : 'Search the documentation'; @@ -376,42 +442,68 @@ function SearchPageContent() {
{pageTitle} -
-
- setSearchQuery(e.target.value)} - value={searchQuery} - autoComplete="off" - autoFocus - style={{ - width: '100%', - padding: '14px 16px', - fontSize: '16px', - borderRadius: '8px', - border: '2px solid var(--ifm-color-emphasis-300)', - marginBottom: '0', - transition: 'border-color 0.2s', - }} - onFocus={(e) => { - e.target.style.borderColor = 'var(--ifm-color-primary)'; - e.target.style.outline = 'none'; - }} - onBlur={(e) => { - e.target.style.borderColor = 'var(--ifm-color-emphasis-300)'; - }} - /> +
+
+
+ setSearchQuery(e.target.value)} + value={searchQuery} + autoComplete="off" + autoFocus + style={{ + width: '100%', + padding: '14px 16px', + fontSize: '16px', + borderRadius: '8px', + border: '2px solid var(--ifm-color-emphasis-300)', + marginBottom: '0', + transition: 'border-color 0.2s', + }} + onFocus={(e) => { + e.target.style.borderColor = 'var(--ifm-color-primary)'; + e.target.style.outline = 'none'; + }} + onBlur={(e) => { + e.target.style.borderColor = 'var(--ifm-color-emphasis-300)'; + }} + /> +
+
+ + +
-
- +
+
+ +
@@ -444,8 +536,9 @@ function SearchPageContent() { // Sort products alphabetically const sortedProducts = Object.keys(groupedByProduct).sort(); - // Global counter for numbering results across all products - let resultNumber = 0; + // Global counter for numbering results across all products on this page + // Start from (page * resultsPerPage) + 1 + let resultNumber = (searchResultState.lastPage || 0) * resultsPerPage; return sortedProducts.map((product) => { const productResults = groupedByProduct[product]; @@ -465,7 +558,7 @@ function SearchPageContent() { color: 'var(--ifm-color-primary)', }} > - {product} (showing {startNum}-{endNum} of {totalForProduct} results) + {product} ({productResults.length} {productResults.length === 1 ? 'result' : 'results'} on this page) {productResults.map(({title, url, summary, breadcrumbs}, i) => { @@ -550,9 +643,135 @@ function SearchPageContent() { ] )} - {searchResultState.hasMore && ( -
- Fetching new results... + {/* Pagination controls */} + {searchResultState.items.length > 0 && ( +
+ + +
+ + Page + + { + const value = e.target.value.replace(/\D/g, ''); + setPageInputValue(value); + }} + onFocus={(e) => { + setPageInputFocused(true); + setPageInputValue(String(searchResultState.lastPage + 1)); + setTimeout(() => e.target.select(), 0); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const pageNum = parseInt(pageInputValue, 10); + if (pageNum >= 1 && pageNum <= searchResultState.totalPages) { + searchResultStateDispatcher({ + type: 'update', + value: { + ...searchResultState, + lastPage: pageNum - 1, + loading: true, + } + }); + } + setPageInputValue(''); + setPageInputFocused(false); + e.target.blur(); + } else if (e.key === 'Escape') { + setPageInputValue(''); + setPageInputFocused(false); + e.target.blur(); + } + }} + onBlur={() => { + setPageInputValue(''); + setPageInputFocused(false); + }} + style={{ + width: '60px', + padding: '6px 8px', + fontSize: '16px', + fontWeight: '500', + border: '1px solid var(--ifm-color-emphasis-300)', + borderRadius: '4px', + textAlign: 'center', + }} + /> + + of {searchResultState.totalPages} + +
+ +
)}
@@ -594,6 +813,44 @@ function SearchPageContent() { ↑ )} + + {/* Jump to bottom button */} + {showJumpToBottom && ( + + )} ); } From cf6fe5b17fb4235e2c5694f45196e2b206e1ca39 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Tue, 3 Mar 2026 11:06:00 -0500 Subject: [PATCH 08/17] Add CodeQL suppression comment for SSR HTML sanitization The code is safe because it uses two mitigation strategies: 1. Iterative loop that repeats replacement until no changes occur 2. Final removal of all angle brackets to prevent any tag fragments This addresses the false positive CodeQL warning. --- src/theme/SearchPage/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 887220b3c3..922810904b 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -30,6 +30,10 @@ function stripHtmlTagsToText(input) { return container.textContent || container.innerText || ''; } // Fallback for non-DOM environments (SSR): robust iterative tag removal + // CodeQL: Safe - implements both recommended mitigation strategies: + // 1. Iterative replacement loop until no changes occur (see while loop below) + // 2. Final removal of ALL angle brackets to prevent any tag fragments (see return statement) + // lgtm[js/incomplete-multi-character-sanitization] let previous = input; let current = input.replace(/<[^>]*>/g, ''); // Repeat until no further tag-like patterns are found From 6de2375c8226a0a37736f2d60e100971c656c1f4 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Tue, 3 Mar 2026 11:19:18 -0500 Subject: [PATCH 09/17] Try inline suppression comment for CodeQL warning Moving the suppression directive to the same line as the flagged code, as this format is sometimes required by static analysis tools. --- src/theme/SearchPage/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 922810904b..8afad73653 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -33,9 +33,8 @@ function stripHtmlTagsToText(input) { // CodeQL: Safe - implements both recommended mitigation strategies: // 1. Iterative replacement loop until no changes occur (see while loop below) // 2. Final removal of ALL angle brackets to prevent any tag fragments (see return statement) - // lgtm[js/incomplete-multi-character-sanitization] let previous = input; - let current = input.replace(/<[^>]*>/g, ''); + let current = input.replace(/<[^>]*>/g, ''); // lgtm[js/incomplete-multi-character-sanitization] // Repeat until no further tag-like patterns are found while (current !== previous) { previous = current; From 46d9f5d468d80fa017f0046d6f6b8d4bd25dc7ab Mon Sep 17 00:00:00 2001 From: Hil-Ram-NWX Date: Tue, 3 Mar 2026 11:36:50 -0500 Subject: [PATCH 10/17] Potential fix for code scanning alert no. 55: Incomplete multi-character sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/theme/SearchPage/index.js | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 8afad73653..1b9d53f66c 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -29,19 +29,9 @@ function stripHtmlTagsToText(input) { container.innerHTML = input; return container.textContent || container.innerText || ''; } - // Fallback for non-DOM environments (SSR): robust iterative tag removal - // CodeQL: Safe - implements both recommended mitigation strategies: - // 1. Iterative replacement loop until no changes occur (see while loop below) - // 2. Final removal of ALL angle brackets to prevent any tag fragments (see return statement) - let previous = input; - let current = input.replace(/<[^>]*>/g, ''); // lgtm[js/incomplete-multi-character-sanitization] - // Repeat until no further tag-like patterns are found - while (current !== previous) { - previous = current; - current = current.replace(/<[^>]*>/g, ''); - } - // Remove any remaining angle brackets to prevent partial tag injection - return current.replace(/[<>]/g, ''); + // Fallback for non-DOM environments (SSR): remove all angle brackets to prevent tags + // This avoids multi-character tag patterns and ensures no HTML elements can be formed. + return input.replace(/[<>]/g, ''); } // Generate product options from PRODUCTS config From ca66a878af53902228ebd630ba85a2f8e31c0780 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Tue, 3 Mar 2026 12:02:51 -0500 Subject: [PATCH 11/17] Align jump button arrows consistently with circle edges - Add overflow: hidden to both back-to-top and jump-to-bottom buttons - Adjust down arrow translateY from -6px to -10px - Ensures both arrow lines touch circle edge with no gaps or overflow --- src/theme/SearchPage/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 8afad73653..15cc41d93f 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -803,6 +803,7 @@ function SearchPageContent() { display: 'flex', alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; @@ -841,17 +842,18 @@ function SearchPageContent() { display: 'flex', alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', }} onMouseEnter={(e) => { - e.currentTarget.style.transform = 'scale(1.1) translateY(-6px)'; + e.currentTarget.style.transform = 'scale(1.1) translateY(-10px)'; e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.2)'; }} onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(-6px)'; + e.currentTarget.style.transform = 'translateY(-10px)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; }} > - + )} From 6daaf816eea809dc222a55ee964afc249550316f Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:39:47 -0500 Subject: [PATCH 12/17] Layout and style updates for "Results per page" dropdown --- .gitignore | 1 + kb_allowlist.json | 3 --- package-lock.json | 31 ------------------------------- src/theme/SearchPage/index.js | 16 ++++++++++++---- 4 files changed, 13 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index dd90024f53..e6819c2fe1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # Generated files .docusaurus +.playwright-mcp .cache-loader build claude_logs diff --git a/kb_allowlist.json b/kb_allowlist.json index 817c8c4088..0ce970bb8f 100644 --- a/kb_allowlist.json +++ b/kb_allowlist.json @@ -32,9 +32,6 @@ "11.0", "11.1" ], - "endpointpolicymanager": [ - "current" - ], "endpointprotector": [ "current" ], diff --git a/package-lock.json b/package-lock.json index 9bf5fc9af2..9fe815d919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -174,7 +174,6 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.34.1.tgz", "integrity": "sha512-bt5hC9vvjaKvdvsgzfXJ42Sl3qjQqoi/FD8V7HOQgtNFhwSauZOlgLwFoUiw67sM+r7ehF7QDk5WRDgY7fAkIg==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.34.1", "@algolia/requester-browser-xhr": "5.34.1", @@ -335,7 +334,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2202,7 +2200,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2225,7 +2222,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2306,7 +2302,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2670,7 +2665,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3438,7 +3432,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/faster/-/faster-3.8.1.tgz", "integrity": "sha512-XYrj3qnTm+o2d5ih5drCq9s63GJoM8vZ26WbLG5FZhURsNxTSXgHJcx11Qo7nWPUStCQkuqk1HvItzscCUnd4A==", "license": "MIT", - "peer": true, "dependencies": { "@docusaurus/types": "3.8.1", "@rspack/core": "^1.3.15", @@ -3591,7 +3584,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz", "integrity": "sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA==", "license": "MIT", - "peer": true, "dependencies": { "@docusaurus/core": "3.8.1", "@docusaurus/logger": "3.8.1", @@ -4259,7 +4251,6 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -4763,7 +4754,6 @@ "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.4.10.tgz", "integrity": "sha512-eK3H328pihiM1323OlaClKJ9WlqgGBZpcR5AqFoWsG0KD01tKCJOeZEgtCY6paRLrsQrEJwBrLntkG0fE7WNGg==", "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.17.0", "@rspack/binding": "1.4.10", @@ -5005,7 +4995,6 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5110,7 +5099,6 @@ "integrity": "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" @@ -6084,7 +6072,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6416,7 +6403,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6494,7 +6480,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6540,7 +6525,6 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.34.1.tgz", "integrity": "sha512-s70HlfBgswgEdmCYkUJG8i/ULYhbkk8N9+N8JsWUwszcp7eauPEr5tIX4BY0qDGeKWQ/qZvmt4mxwTusYY23sg==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-abtesting": "5.34.1", "@algolia/client-analytics": "5.34.1", @@ -7032,7 +7016,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -7316,7 +7299,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -8244,7 +8226,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8576,7 +8557,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.1.tgz", "integrity": "sha512-dbeqFTLYEwlFg7UGtcZhCCG/2WayX72zK3Sq323CEX29CY81tYfVhw1MIdduCtpstB0cTOhJswWlM/OEB3Xp+Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -8998,7 +8978,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -10326,7 +10305,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15376,7 +15354,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -16093,7 +16070,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17009,7 +16985,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -17814,7 +17789,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -17827,7 +17801,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -17884,7 +17857,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" }, @@ -17913,7 +17885,6 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -20865,7 +20836,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -21222,7 +21192,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.2.tgz", "integrity": "sha512-QaNKAvGCDRh3wW1dsDjeMdDXwZm2vqq3zn6Pvq4rHOEOGSaUMgOOjG2Y9ZbIGzpfkJk9ZYTHpDqgDfeBDcnLaw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index f029e548ba..7e85fdc2e5 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -438,6 +438,9 @@ function SearchPageContent() {
+ setResultsPerPage(Number(e.target.value))} style={{ width: '100%', - padding: '8px', - borderRadius: '4px', - border: '1px solid var(--ifm-color-emphasis-300)', - fontSize: '14px', + padding: '14px 44px 14px 16px', + fontSize: '16px', + borderRadius: '8px', + border: '2px solid var(--ifm-color-emphasis-300)', + transition: 'border-color 0.2s', + appearance: 'none', + backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'12\' height=\'8\' viewBox=\'0 0 12 8\'%3E%3Cpath d=\'M1 1l5 5 5-5\' stroke=\'%23666\' stroke-width=\'2\' fill=\'none\' stroke-linecap=\'round\'/%3E%3C/svg%3E")', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'right 14px center', }} > From e511eba307fd5d8a23a2d740a823de1a6beb525e Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:00:42 -0500 Subject: [PATCH 13/17] Layout and style updates for "Results per page" dropdown - Convert product filter to checkboxes - Add selection count to product filters - Update style and location of bottom-right navigation button - Add .claude to .gitignore --- .gitignore | 1 + src/theme/SearchPage/index.js | 220 +++++++++++++++++++--------------- 2 files changed, 122 insertions(+), 99 deletions(-) diff --git a/.gitignore b/.gitignore index e6819c2fe1..ebb4447229 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # Generated files .docusaurus .playwright-mcp +.claude .cache-loader build claude_logs diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 7e85fdc2e5..67ca01172e 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -47,37 +47,114 @@ const PRODUCT_OPTIONS = [ return a.label.localeCompare(b.label); }); -// Simple multi-select component +// Checkbox-based multi-select component function MultiSelect({label, options, selectedValues, onChange}) { + // options[0] is the "All products" sentinel — skip it for the checkbox list + const productOptions = options.filter(o => o.value !== '__all__'); + const noneSelected = selectedValues.length === 1 && selectedValues[0] === '__none__'; + const allSelected = selectedValues.length === 0 || selectedValues.includes('__all__'); + const someSelected = !allSelected && !noneSelected && selectedValues.length > 0; + + const selectAllRef = useRef(null); + + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = someSelected; + if (!someSelected) selectAllRef.current.indeterminate = false; + } + }, [someSelected]); + + function handleSelectAll() { + if (allSelected && !someSelected) { + onChange(['__none__']); // All checked → uncheck all + } else { + onChange([]); // Indeterminate or none → check all + } + } + + function handleOption(value, checked) { + // Resolve current explicit selection + const current = allSelected + ? productOptions.map(o => o.value) // [] means all + : noneSelected + ? [] // __none__ means none + : selectedValues.filter(v => v !== '__all__'); + if (checked) { + const next = current.concat(value); + onChange(next.length === productOptions.length ? [] : next); + } else { + onChange(current.filter(v => v !== value)); + } + } + + const containerStyle = { + width: '100%', + border: '2px solid var(--ifm-color-emphasis-300)', + borderRadius: '8px', + maxHeight: '220px', + overflowY: 'auto', + background: 'var(--ifm-background-color)', + }; + + const rowStyle = { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '8px 14px', + cursor: 'pointer', + fontSize: '15px', + userSelect: 'none', + }; + + const checkboxStyle = { + width: '16px', + height: '16px', + flexShrink: 0, + cursor: 'pointer', + accentColor: 'var(--ifm-color-primary)', + }; + + const dividerStyle = { + height: '1px', + background: 'var(--ifm-color-emphasis-200)', + margin: '0', + }; + return (
- - - Hold Ctrl/Cmd to select multiple - +
+ {/* Select-all row */} + +
+ {productOptions.map((opt) => { + const checked = !noneSelected && (allSelected || selectedValues.includes(opt.value)); + return ( + + ); + })} +
); } @@ -537,15 +614,8 @@ function SearchPageContent() { // Sort products alphabetically const sortedProducts = Object.keys(groupedByProduct).sort(); - // Global counter for numbering results across all products on this page - // Start from (page * resultsPerPage) + 1 - let resultNumber = (searchResultState.lastPage || 0) * resultsPerPage; - return sortedProducts.map((product) => { const productResults = groupedByProduct[product]; - const startNum = resultNumber + 1; - const endNum = resultNumber + productResults.length; - const totalForProduct = productResults.length; return (
@@ -563,7 +633,6 @@ function SearchPageContent() { {productResults.map(({title, url, summary, breadcrumbs}, i) => { - resultNumber++; return (
- - {resultNumber}. - @@ -777,81 +837,43 @@ function SearchPageContent() { )}
- {/* Back to top button */} - {showBackToTop && ( + {/* Back to top / jump to bottom buttons — mutually exclusive, same position */} + {(showBackToTop || showJumpToBottom) && ( - )} - - {/* Jump to bottom button */} - {showJumpToBottom && ( - )} From 5857a525883544986433c6f71edbfa72d2c9f7af Mon Sep 17 00:00:00 2001 From: Dan Piazza <220388267+DanPiazza-Netwrix@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:29:43 -0500 Subject: [PATCH 14/17] Move product filters to left sidebar - Page elements now static as search results are scrolled through - Minor style changes to the paging buttons for search results --- src/theme/SearchPage/index.js | 245 +++++++++++++++++++--------------- 1 file changed, 134 insertions(+), 111 deletions(-) diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index 67ca01172e..cb5edd4dfe 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -91,18 +91,20 @@ function MultiSelect({label, options, selectedValues, onChange}) { width: '100%', border: '2px solid var(--ifm-color-emphasis-300)', borderRadius: '8px', - maxHeight: '220px', overflowY: 'auto', background: 'var(--ifm-background-color)', + flex: 1, + minHeight: 0, }; const rowStyle = { display: 'flex', alignItems: 'center', - gap: '10px', - padding: '8px 14px', + gap: '8px', + padding: '4px 10px', cursor: 'pointer', - fontSize: '15px', + fontSize: '13px', + lineHeight: '1.3', userSelect: 'none', }; @@ -121,8 +123,8 @@ function MultiSelect({label, options, selectedValues, onChange}) { }; return ( -
-