Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
340 changes: 58 additions & 282 deletions src/theme/SearchBar/index.js

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions src/theme/SearchBar/searchConstants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Search-related constants and default configurations
*/

// Default search parameters
export const DEFAULT_SEARCH_PARAMS = {
clickAnalytics: true,
hitsPerPage: 3,
};

// Keyboard shortcuts
export const SEARCH_SHORTCUTS = {
SLASH: '/',
CMD_K: 'k'
};

// Kapa AI selectors and configuration
export const KAPA_CONFIG = {
SELECTORS: {
INPUT: '#kapa-ask-ai-input',
CONTAINER: '#kapa-widget-container'
},
WIDGET_CHECK_TIMEOUT: 100, // ms to wait before checking widget availability
};

// DocSearch modal configuration
export const DOCSEARCH_CONFIG = {
PRECONNECT_DOMAINS: {
getAlgoliaUrl: (appId) => `https://${appId}-dsn.algolia.net`
},
MODAL_CONTAINER_ID: 'docsearch-modal-container',
SCROLL_BEHAVIOR: {
CAPTURE_INITIAL: true,
RESTORE_ON_CLOSE: true
}
};

// Search analytics configuration
export const ANALYTICS_CONFIG = {
EVENT_NAMES: {
SEARCH_RESULT_CLICKED: 'Search Result Clicked'
},
GA_COOKIE_NAME: '_ga',
ALGOLIA_INDEX_OFFSET: 1 // Algolia indexes from 1, not 0
};

// URL processing configuration
export const URL_CONFIG = {
// TODO: temporary - all search results to english for now
FORCE_ENGLISH_RESULTS: true,
DEFAULT_LOCALE: 'en'
};

// AI conflict detection selectors
export const INPUT_FIELD_SELECTORS = [
'INPUT',
'TEXTAREA',
'#kapa-ask-ai-input',
'#kapa-widget-container',
'[contenteditable="true"]'
];

// Style constants
export const SEARCH_STYLES = {
FOOTER: {
CONTAINER: {
padding: '12px 16px',
borderTop: '1px solid var(--docsearch-modal-shadow)',
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
AI_BUTTON: {
BASE: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
padding: '12px 16px',
backgroundColor: '#5b4cfe',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
fontWeight: 600,
transition: 'all 0.2s ease',
transform: 'translateY(0)'
},
HOVER: {
backgroundColor: '#4a3dcc',
transform: 'translateY(-1px)'
}
},
SEE_ALL_LINK: {
textAlign: 'center',
fontSize: '13px',
color: 'var(--docsearch-muted-color)',
textDecoration: 'none'
}
}
};
29 changes: 29 additions & 0 deletions src/theme/SearchBar/searchHit.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Link from '@docusaurus/Link';
import { trackSearchResultClick } from './utils/searchAnalytics';

export function SearchHit({ hit, children }) {
const handleClick = () => trackSearchResultClick(hit);

// Extract multiple URL segments after /docs/ and clean them up
const segments = hit.url.split('/docs/')[1]?.split('/').filter(Boolean) || [];
const breadcrumbs = segments
.slice(0, 3) // Take first 3 segments max
.map(segment => segment.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()));

return (
<Link onClick={handleClick} to={hit.url}>
{children}
{breadcrumbs.length > 0 && (
<span style={{
fontSize: '10px',
color: '#888',
display: 'block',
lineHeight: '1',
marginBottom: '12px'
}}>
{breadcrumbs.join(' › ')}
</span>
)}
</Link>
);
}
62 changes: 62 additions & 0 deletions src/theme/SearchBar/searchResultsFooter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { useCallback } from 'react';
import Link from '@docusaurus/Link';
import { useSearchLinkCreator } from '@docusaurus/theme-common';
import Translate from '@docusaurus/Translate';
import { SEARCH_STYLES } from './searchConstants';

/**
* Footer component for search results with AI integration and "see all" link
* @param {Object} state - Current search state
* @param {Function} onClose - Function to close the search modal
*/
export function SearchResultsFooter({ state, onClose }) {
const generateSearchPageLink = useSearchLinkCreator();

const handleKapaClick = useCallback(() => {
onClose(); // Close search modal first

// Use Kapa's official API to open with query
if (typeof window !== 'undefined' && window.Kapa) {
window.Kapa('open', {
query: state.query || '',
submit: !!state.query
});
} else {
console.warn('Kapa widget not loaded');
}
}, [state.query, onClose]);

return (
<div style={SEARCH_STYLES.FOOTER.CONTAINER}>
{/* Kapa AI Button */}
<button
onClick={handleKapaClick}
style={SEARCH_STYLES.FOOTER.AI_BUTTON.BASE}
onMouseEnter={(e) => {
e.target.style.backgroundColor = SEARCH_STYLES.FOOTER.AI_BUTTON.HOVER.backgroundColor;
e.target.style.transform = SEARCH_STYLES.FOOTER.AI_BUTTON.HOVER.transform;
}}
onMouseLeave={(e) => {
e.target.style.backgroundColor = SEARCH_STYLES.FOOTER.AI_BUTTON.BASE.backgroundColor;
e.target.style.transform = SEARCH_STYLES.FOOTER.AI_BUTTON.BASE.transform;
}}
>
🤖 Ask AI{state.query ? ` about "${state.query}"` : ''}
</button>

{/* Original "See all results" link */}
<Link
to={generateSearchPageLink(state.query)}
onClick={onClose}
style={SEARCH_STYLES.FOOTER.SEE_ALL_LINK}
>
<Translate
id="theme.SearchBar.seeAll"
values={{ count: state.context.nbHits }}
>
{'See all {count} results'}
</Translate>
</Link>
</div>
);
}
60 changes: 60 additions & 0 deletions src/theme/SearchBar/utils/aiConflictHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { SEARCH_SHORTCUTS, INPUT_FIELD_SELECTORS } from '../searchConstants';

/**
* Check if the active element is an input field
* @param {Element} activeElement - The currently active DOM element
* @returns {boolean} - True if the element is an input field
*/
function isInputField(activeElement) {
if (!activeElement) return false;

return INPUT_FIELD_SELECTORS.some(selector => {
if (selector.startsWith('#') || selector.startsWith('[')) {
return activeElement.matches?.(selector) || activeElement.closest?.(selector);
}
return activeElement.tagName === selector;
}) || activeElement.contentEditable === 'true';
}

/**
* Determines if search actions should be prevented when AI is open
* @param {boolean} isAskAIOpen - Whether the AI chat is currently open
* @returns {boolean} - True if search action should be prevented
*/
export function shouldPreventSearchAction(isAskAIOpen) {
if (!isAskAIOpen) return false;

const activeElement = document.activeElement;
return !isInputField(activeElement);
}

/**
* Check if the keyboard event is a search shortcut
* @param {KeyboardEvent} event - The keyboard event
* @returns {boolean} - True if it's a search shortcut
*/
function isSearchShortcut(event) {
return (
event.key === SEARCH_SHORTCUTS.SLASH ||
(event.key === SEARCH_SHORTCUTS.CMD_K && (event.metaKey || event.ctrlKey))
);
}

/**
* Handles keyboard shortcuts when AI might be open
* @param {KeyboardEvent} event - The keyboard event
* @param {boolean} isAskAIOpen - Whether AI is open
*/
export function handleSearchKeyboardConflict(event, isAskAIOpen) {
if (!isSearchShortcut(event)) return;

if (shouldPreventSearchAction(isAskAIOpen)) {
// Special case: allow "/" in input fields
if (event.key === SEARCH_SHORTCUTS.SLASH && !shouldPreventSearchAction(isAskAIOpen)) {
event.stopImmediatePropagation();
} else {
event.preventDefault();
event.stopImmediatePropagation();
}
}
}
58 changes: 58 additions & 0 deletions src/theme/SearchBar/utils/searchAnalytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import aa from 'search-insights';
import { getGoogleAnalyticsUserIdFromBrowserCookie } from '../../../lib/google/google';

/**
* Initialize Algolia search analytics
* @param {string} appId - Algolia app ID
* @param {string} apiKey - Algolia API key
*/
export function initializeSearchAnalytics(appId, apiKey) {
if (typeof window === "undefined") return;

const userToken = getGoogleAnalyticsUserIdFromBrowserCookie('_ga');
aa('init', {
appId,
apiKey,
});
aa('setUserToken', userToken);
}

/**
* Track when a user clicks on a search result
* @param {Object} hit - The search result that was clicked
*/
export function trackSearchResultClick(hit) {
if (!hit.queryID) return;

aa('clickedObjectIDsAfterSearch', {
eventName: 'Search Result Clicked',
index: hit.__autocomplete_indexName,
queryID: hit.queryID,
objectIDs: [hit.objectID],
positions: [hit.index + 1], // algolia indexes from 1
});
}

/**
* Creates a search client wrapper that adds Docusaurus agent and intercepts queries
* @param {Object} searchClient - The original Algolia search client
* @param {string} docusaurusVersion - Version of Docusaurus
* @param {React.MutableRefObject} queryIDRef - Ref to store the current query ID
* @returns {Object} - Enhanced search client
*/
export function createEnhancedSearchClient(searchClient, docusaurusVersion, queryIDRef) {
searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion);

// Wrap the search function to intercept responses
const originalSearch = searchClient.search;
searchClient.search = async (requests) => {
const response = await originalSearch(requests);
// Extract queryID from the response
if (response.results?.length > 0 && response.results[0].queryID) {
queryIDRef.current = response.results[0].queryID;
}
return response;
};

return searchClient;
}
76 changes: 76 additions & 0 deletions src/theme/SearchBar/utils/searchConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { isRegexpStringMatch } from '@docusaurus/theme-common';
import { DEFAULT_SEARCH_PARAMS, URL_CONFIG } from '../searchConstants';

/**
* Merge facet filters from different sources
* @param {string|string[]} f1 - First set of facet filters
* @param {string|string[]} f2 - Second set of facet filters
* @returns {string[]} - Merged facet filters
*/
export function mergeFacetFilters(f1, f2) {
const normalize = (f) => (typeof f === 'string' ? [f] : f);
return [...normalize(f1), ...normalize(f2)];
}

/**
* Create search parameters configuration
* @param {Object} props - Component props
* @param {boolean} contextualSearch - Whether to use contextual search
* @param {string[]} contextualSearchFacetFilters - Contextual facet filters
* @returns {Object} - Configured search parameters
*/
export function createSearchParameters(props, contextualSearch, contextualSearchFacetFilters) {
const configFacetFilters = props.searchParameters?.facetFilters ?? [];
const facetFilters = contextualSearch
? mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters)
: configFacetFilters;

return {
...props.searchParameters,
facetFilters,
...DEFAULT_SEARCH_PARAMS,
};
}

/**
* Create navigator for handling search result clicks
* @param {Object} history - React router history object
* @param {RegExp} externalUrlRegex - Regex to match external URLs
* @returns {Object} - Navigator object
*/
export function createSearchNavigator(history, externalUrlRegex) {
return {
navigate({ itemUrl }) {
if (isRegexpStringMatch(externalUrlRegex, itemUrl)) {
window.location.href = itemUrl;
} else {
history.push(itemUrl);
}
},
};
}

/**
* Transform search result items with additional metadata
* @param {Array} items - Raw search results
* @param {Object} options - Transform options
* @param {Function} options.transformItems - Custom transform function from props
* @param {Function} options.processSearchResultUrl - URL processor function
* @param {string} options.currentLocale - Current locale
* @param {Object} options.queryIDRef - Ref containing current query ID
* @returns {Array} - Transformed search results
*/
export function transformSearchItems(items, options) {
const { transformItems, processSearchResultUrl, currentLocale, queryIDRef } = options;

const baseTransform = (items) => items.map((item, index) => ({
...item,
url: (URL_CONFIG.FORCE_ENGLISH_RESULTS && currentLocale === URL_CONFIG.DEFAULT_LOCALE)
? processSearchResultUrl(item.url)
: item.url,
index, // Adding the index property - needed for click metrics
queryID: queryIDRef.current
}));

return transformItems ? transformItems(items) : baseTransform(items);
}
Loading