Skip to content

Commit d18e9a4

Browse files
authored
Merge pull request #4301 from ClickHouse/search-2
adding url breadcrumbs to search
2 parents 5b61c21 + 4e71923 commit d18e9a4

File tree

8 files changed

+527
-282
lines changed

8 files changed

+527
-282
lines changed

src/theme/SearchBar/index.js

Lines changed: 58 additions & 282 deletions
Large diffs are not rendered by default.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Search-related constants and default configurations
3+
*/
4+
5+
// Default search parameters
6+
export const DEFAULT_SEARCH_PARAMS = {
7+
clickAnalytics: true,
8+
hitsPerPage: 3,
9+
};
10+
11+
// Keyboard shortcuts
12+
export const SEARCH_SHORTCUTS = {
13+
SLASH: '/',
14+
CMD_K: 'k'
15+
};
16+
17+
// Kapa AI selectors and configuration
18+
export const KAPA_CONFIG = {
19+
SELECTORS: {
20+
INPUT: '#kapa-ask-ai-input',
21+
CONTAINER: '#kapa-widget-container'
22+
},
23+
WIDGET_CHECK_TIMEOUT: 100, // ms to wait before checking widget availability
24+
};
25+
26+
// DocSearch modal configuration
27+
export const DOCSEARCH_CONFIG = {
28+
PRECONNECT_DOMAINS: {
29+
getAlgoliaUrl: (appId) => `https://${appId}-dsn.algolia.net`
30+
},
31+
MODAL_CONTAINER_ID: 'docsearch-modal-container',
32+
SCROLL_BEHAVIOR: {
33+
CAPTURE_INITIAL: true,
34+
RESTORE_ON_CLOSE: true
35+
}
36+
};
37+
38+
// Search analytics configuration
39+
export const ANALYTICS_CONFIG = {
40+
EVENT_NAMES: {
41+
SEARCH_RESULT_CLICKED: 'Search Result Clicked'
42+
},
43+
GA_COOKIE_NAME: '_ga',
44+
ALGOLIA_INDEX_OFFSET: 1 // Algolia indexes from 1, not 0
45+
};
46+
47+
// URL processing configuration
48+
export const URL_CONFIG = {
49+
// TODO: temporary - all search results to english for now
50+
FORCE_ENGLISH_RESULTS: true,
51+
DEFAULT_LOCALE: 'en'
52+
};
53+
54+
// AI conflict detection selectors
55+
export const INPUT_FIELD_SELECTORS = [
56+
'INPUT',
57+
'TEXTAREA',
58+
'#kapa-ask-ai-input',
59+
'#kapa-widget-container',
60+
'[contenteditable="true"]'
61+
];
62+
63+
// Style constants
64+
export const SEARCH_STYLES = {
65+
FOOTER: {
66+
CONTAINER: {
67+
padding: '12px 16px',
68+
borderTop: '1px solid var(--docsearch-modal-shadow)',
69+
display: 'flex',
70+
flexDirection: 'column',
71+
gap: '8px'
72+
},
73+
AI_BUTTON: {
74+
BASE: {
75+
display: 'flex',
76+
alignItems: 'center',
77+
justifyContent: 'center',
78+
width: '100%',
79+
padding: '12px 16px',
80+
backgroundColor: '#5b4cfe',
81+
color: 'white',
82+
border: 'none',
83+
borderRadius: '6px',
84+
fontSize: '14px',
85+
cursor: 'pointer',
86+
fontWeight: 600,
87+
transition: 'all 0.2s ease',
88+
transform: 'translateY(0)'
89+
},
90+
HOVER: {
91+
backgroundColor: '#4a3dcc',
92+
transform: 'translateY(-1px)'
93+
}
94+
},
95+
SEE_ALL_LINK: {
96+
textAlign: 'center',
97+
fontSize: '13px',
98+
color: 'var(--docsearch-muted-color)',
99+
textDecoration: 'none'
100+
}
101+
}
102+
};

src/theme/SearchBar/searchHit.jsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Link from '@docusaurus/Link';
2+
import { trackSearchResultClick } from './utils/searchAnalytics';
3+
4+
export function SearchHit({ hit, children }) {
5+
const handleClick = () => trackSearchResultClick(hit);
6+
7+
// Extract multiple URL segments after /docs/ and clean them up
8+
const segments = hit.url.split('/docs/')[1]?.split('/').filter(Boolean) || [];
9+
const breadcrumbs = segments
10+
.slice(0, 3) // Take first 3 segments max
11+
.map(segment => segment.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()));
12+
13+
return (
14+
<Link onClick={handleClick} to={hit.url}>
15+
{children}
16+
{breadcrumbs.length > 0 && (
17+
<span style={{
18+
fontSize: '10px',
19+
color: '#888',
20+
display: 'block',
21+
lineHeight: '1',
22+
marginBottom: '12px'
23+
}}>
24+
{breadcrumbs.join(' › ')}
25+
</span>
26+
)}
27+
</Link>
28+
);
29+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { useCallback } from 'react';
2+
import Link from '@docusaurus/Link';
3+
import { useSearchLinkCreator } from '@docusaurus/theme-common';
4+
import Translate from '@docusaurus/Translate';
5+
import { SEARCH_STYLES } from './searchConstants';
6+
7+
/**
8+
* Footer component for search results with AI integration and "see all" link
9+
* @param {Object} state - Current search state
10+
* @param {Function} onClose - Function to close the search modal
11+
*/
12+
export function SearchResultsFooter({ state, onClose }) {
13+
const generateSearchPageLink = useSearchLinkCreator();
14+
15+
const handleKapaClick = useCallback(() => {
16+
onClose(); // Close search modal first
17+
18+
// Use Kapa's official API to open with query
19+
if (typeof window !== 'undefined' && window.Kapa) {
20+
window.Kapa('open', {
21+
query: state.query || '',
22+
submit: !!state.query
23+
});
24+
} else {
25+
console.warn('Kapa widget not loaded');
26+
}
27+
}, [state.query, onClose]);
28+
29+
return (
30+
<div style={SEARCH_STYLES.FOOTER.CONTAINER}>
31+
{/* Kapa AI Button */}
32+
<button
33+
onClick={handleKapaClick}
34+
style={SEARCH_STYLES.FOOTER.AI_BUTTON.BASE}
35+
onMouseEnter={(e) => {
36+
e.target.style.backgroundColor = SEARCH_STYLES.FOOTER.AI_BUTTON.HOVER.backgroundColor;
37+
e.target.style.transform = SEARCH_STYLES.FOOTER.AI_BUTTON.HOVER.transform;
38+
}}
39+
onMouseLeave={(e) => {
40+
e.target.style.backgroundColor = SEARCH_STYLES.FOOTER.AI_BUTTON.BASE.backgroundColor;
41+
e.target.style.transform = SEARCH_STYLES.FOOTER.AI_BUTTON.BASE.transform;
42+
}}
43+
>
44+
🤖 Ask AI{state.query ? ` about "${state.query}"` : ''}
45+
</button>
46+
47+
{/* Original "See all results" link */}
48+
<Link
49+
to={generateSearchPageLink(state.query)}
50+
onClick={onClose}
51+
style={SEARCH_STYLES.FOOTER.SEE_ALL_LINK}
52+
>
53+
<Translate
54+
id="theme.SearchBar.seeAll"
55+
values={{ count: state.context.nbHits }}
56+
>
57+
{'See all {count} results'}
58+
</Translate>
59+
</Link>
60+
</div>
61+
);
62+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { SEARCH_SHORTCUTS, INPUT_FIELD_SELECTORS } from '../searchConstants';
2+
3+
/**
4+
* Check if the active element is an input field
5+
* @param {Element} activeElement - The currently active DOM element
6+
* @returns {boolean} - True if the element is an input field
7+
*/
8+
function isInputField(activeElement) {
9+
if (!activeElement) return false;
10+
11+
return INPUT_FIELD_SELECTORS.some(selector => {
12+
if (selector.startsWith('#') || selector.startsWith('[')) {
13+
return activeElement.matches?.(selector) || activeElement.closest?.(selector);
14+
}
15+
return activeElement.tagName === selector;
16+
}) || activeElement.contentEditable === 'true';
17+
}
18+
19+
/**
20+
* Determines if search actions should be prevented when AI is open
21+
* @param {boolean} isAskAIOpen - Whether the AI chat is currently open
22+
* @returns {boolean} - True if search action should be prevented
23+
*/
24+
export function shouldPreventSearchAction(isAskAIOpen) {
25+
if (!isAskAIOpen) return false;
26+
27+
const activeElement = document.activeElement;
28+
return !isInputField(activeElement);
29+
}
30+
31+
/**
32+
* Check if the keyboard event is a search shortcut
33+
* @param {KeyboardEvent} event - The keyboard event
34+
* @returns {boolean} - True if it's a search shortcut
35+
*/
36+
function isSearchShortcut(event) {
37+
return (
38+
event.key === SEARCH_SHORTCUTS.SLASH ||
39+
(event.key === SEARCH_SHORTCUTS.CMD_K && (event.metaKey || event.ctrlKey))
40+
);
41+
}
42+
43+
/**
44+
* Handles keyboard shortcuts when AI might be open
45+
* @param {KeyboardEvent} event - The keyboard event
46+
* @param {boolean} isAskAIOpen - Whether AI is open
47+
*/
48+
export function handleSearchKeyboardConflict(event, isAskAIOpen) {
49+
if (!isSearchShortcut(event)) return;
50+
51+
if (shouldPreventSearchAction(isAskAIOpen)) {
52+
// Special case: allow "/" in input fields
53+
if (event.key === SEARCH_SHORTCUTS.SLASH && !shouldPreventSearchAction(isAskAIOpen)) {
54+
event.stopImmediatePropagation();
55+
} else {
56+
event.preventDefault();
57+
event.stopImmediatePropagation();
58+
}
59+
}
60+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import aa from 'search-insights';
2+
import { getGoogleAnalyticsUserIdFromBrowserCookie } from '../../../lib/google/google';
3+
4+
/**
5+
* Initialize Algolia search analytics
6+
* @param {string} appId - Algolia app ID
7+
* @param {string} apiKey - Algolia API key
8+
*/
9+
export function initializeSearchAnalytics(appId, apiKey) {
10+
if (typeof window === "undefined") return;
11+
12+
const userToken = getGoogleAnalyticsUserIdFromBrowserCookie('_ga');
13+
aa('init', {
14+
appId,
15+
apiKey,
16+
});
17+
aa('setUserToken', userToken);
18+
}
19+
20+
/**
21+
* Track when a user clicks on a search result
22+
* @param {Object} hit - The search result that was clicked
23+
*/
24+
export function trackSearchResultClick(hit) {
25+
if (!hit.queryID) return;
26+
27+
aa('clickedObjectIDsAfterSearch', {
28+
eventName: 'Search Result Clicked',
29+
index: hit.__autocomplete_indexName,
30+
queryID: hit.queryID,
31+
objectIDs: [hit.objectID],
32+
positions: [hit.index + 1], // algolia indexes from 1
33+
});
34+
}
35+
36+
/**
37+
* Creates a search client wrapper that adds Docusaurus agent and intercepts queries
38+
* @param {Object} searchClient - The original Algolia search client
39+
* @param {string} docusaurusVersion - Version of Docusaurus
40+
* @param {React.MutableRefObject} queryIDRef - Ref to store the current query ID
41+
* @returns {Object} - Enhanced search client
42+
*/
43+
export function createEnhancedSearchClient(searchClient, docusaurusVersion, queryIDRef) {
44+
searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion);
45+
46+
// Wrap the search function to intercept responses
47+
const originalSearch = searchClient.search;
48+
searchClient.search = async (requests) => {
49+
const response = await originalSearch(requests);
50+
// Extract queryID from the response
51+
if (response.results?.length > 0 && response.results[0].queryID) {
52+
queryIDRef.current = response.results[0].queryID;
53+
}
54+
return response;
55+
};
56+
57+
return searchClient;
58+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { isRegexpStringMatch } from '@docusaurus/theme-common';
2+
import { DEFAULT_SEARCH_PARAMS, URL_CONFIG } from '../searchConstants';
3+
4+
/**
5+
* Merge facet filters from different sources
6+
* @param {string|string[]} f1 - First set of facet filters
7+
* @param {string|string[]} f2 - Second set of facet filters
8+
* @returns {string[]} - Merged facet filters
9+
*/
10+
export function mergeFacetFilters(f1, f2) {
11+
const normalize = (f) => (typeof f === 'string' ? [f] : f);
12+
return [...normalize(f1), ...normalize(f2)];
13+
}
14+
15+
/**
16+
* Create search parameters configuration
17+
* @param {Object} props - Component props
18+
* @param {boolean} contextualSearch - Whether to use contextual search
19+
* @param {string[]} contextualSearchFacetFilters - Contextual facet filters
20+
* @returns {Object} - Configured search parameters
21+
*/
22+
export function createSearchParameters(props, contextualSearch, contextualSearchFacetFilters) {
23+
const configFacetFilters = props.searchParameters?.facetFilters ?? [];
24+
const facetFilters = contextualSearch
25+
? mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters)
26+
: configFacetFilters;
27+
28+
return {
29+
...props.searchParameters,
30+
facetFilters,
31+
...DEFAULT_SEARCH_PARAMS,
32+
};
33+
}
34+
35+
/**
36+
* Create navigator for handling search result clicks
37+
* @param {Object} history - React router history object
38+
* @param {RegExp} externalUrlRegex - Regex to match external URLs
39+
* @returns {Object} - Navigator object
40+
*/
41+
export function createSearchNavigator(history, externalUrlRegex) {
42+
return {
43+
navigate({ itemUrl }) {
44+
if (isRegexpStringMatch(externalUrlRegex, itemUrl)) {
45+
window.location.href = itemUrl;
46+
} else {
47+
history.push(itemUrl);
48+
}
49+
},
50+
};
51+
}
52+
53+
/**
54+
* Transform search result items with additional metadata
55+
* @param {Array} items - Raw search results
56+
* @param {Object} options - Transform options
57+
* @param {Function} options.transformItems - Custom transform function from props
58+
* @param {Function} options.processSearchResultUrl - URL processor function
59+
* @param {string} options.currentLocale - Current locale
60+
* @param {Object} options.queryIDRef - Ref containing current query ID
61+
* @returns {Array} - Transformed search results
62+
*/
63+
export function transformSearchItems(items, options) {
64+
const { transformItems, processSearchResultUrl, currentLocale, queryIDRef } = options;
65+
66+
const baseTransform = (items) => items.map((item, index) => ({
67+
...item,
68+
url: (URL_CONFIG.FORCE_ENGLISH_RESULTS && currentLocale === URL_CONFIG.DEFAULT_LOCALE)
69+
? processSearchResultUrl(item.url)
70+
: item.url,
71+
index, // Adding the index property - needed for click metrics
72+
queryID: queryIDRef.current
73+
}));
74+
75+
return transformItems ? transformItems(items) : baseTransform(items);
76+
}

0 commit comments

Comments
 (0)