From ae2d665ac8b92a8682019d4850323a896974be3b Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8BitTitan@gmail.com> Date: Wed, 26 Aug 2026 15:20:36 -0400 Subject: [PATCH] feat(theme): upgrade Algolia DocSearch to v5 Migrate the Algolia search integration to @docsearch v5. Replace the singular `indexName` option with an `indices` array and rename the Ask AI `assistantId` to Agent Studio's `agentId`. Split the DocSearch JS and CSS bundles so keyword-only, Ask AI, and side panel modes each load only what they need, and cache each entry point separately. Move language filtering into per-index search parameters and validate credentials per resolved mode so side-panel-only configurations initialize correctly. BREAKING CHANGE: The Algolia search options have changed. `indexName` is replaced by an `indices` array, the Ask AI `assistantId` is renamed to `agentId`, and the root-level `searchParameters` option is removed in favor of per-index `searchParameters` inside `indices`. Update your `themeConfig.search.options` accordingly. --- .../theme-default/support/docsearch.test.ts | 204 +++++++++++++----- docs/.vitepress/config.ts | 12 +- package.json | 6 +- pnpm-lock.yaml | 31 +-- .../components/VPAlgoliaSearchBox.vue | 127 +++++++---- src/client/theme-default/styles/docsearch.css | 17 +- src/client/theme-default/support/docsearch.ts | 182 +++++++++++----- types/docsearch.d.ts | 12 +- 8 files changed, 403 insertions(+), 188 deletions(-) diff --git a/__tests__/unit/client/theme-default/support/docsearch.test.ts b/__tests__/unit/client/theme-default/support/docsearch.test.ts index 78c5ee68..563540a2 100644 --- a/__tests__/unit/client/theme-default/support/docsearch.test.ts +++ b/__tests__/unit/client/theme-default/support/docsearch.test.ts @@ -3,6 +3,7 @@ import { buildSidePanelProps, hasAskAi, hasKeywordSearch, + mergeLangFilters, mergeLangFacetFilters, validateCredentials } from 'client/theme-default/support/docsearch' @@ -41,13 +42,55 @@ describe('client/theme-default/support/docsearch', () => { }) }) + describe('mergeLangFilters', () => { + test('adds a lang filter when none is provided', () => { + expect(mergeLangFilters(undefined, 'en')).toBe('lang:en') + }) + + test('replaces a lang filter in an AND expression', () => { + expect( + mergeLangFilters('type:lvl AND lang:en-US AND content:*', 'en') + ).toBe('(type:lvl AND lang:en AND content:*) AND lang:en') + }) + + test('replaces a lang filter in an OR group', () => { + expect( + mergeLangFilters('(type:lvl OR lang:en-US) AND content:*', 'en') + ).toBe('((type:lvl OR lang:en) AND content:*) AND lang:en') + }) + + test('preserves top-level OR precedence', () => { + expect(mergeLangFilters('tag:a OR tag:b', 'en')).toBe( + '(tag:a OR tag:b) AND lang:en' + ) + }) + + test('preserves nested groups containing only lang filters', () => { + expect(mergeLangFilters('type:a AND (lang:en OR lang:fr)', 'de')).toBe( + '(type:a AND (lang:de OR lang:de)) AND lang:de' + ) + }) + + test('preserves negated lang filters', () => { + expect(mergeLangFilters('tag:a AND NOT lang:fr', 'en')).toBe( + '(tag:a AND NOT lang:fr) AND lang:en' + ) + }) + + test('normalizes quoted lang filter values', () => { + expect(mergeLangFilters('lang:"en US" AND tag:a', 'en')).toBe( + '(lang:en AND tag:a) AND lang:en' + ) + }) + }) + describe('hasKeywordSearch', () => { test('returns true when all credentials are provided', () => { expect( hasKeywordSearch({ appId: 'app', apiKey: 'key', - indexName: 'index' + indices: ['index'] }) ).toBe(true) }) @@ -57,41 +100,48 @@ describe('client/theme-default/support/docsearch', () => { hasKeywordSearch({ appId: undefined, apiKey: 'key', - indexName: 'index' + indices: ['index'] }) ).toBe(false) expect( hasKeywordSearch({ appId: 'app', apiKey: undefined, - indexName: 'index' + indices: ['index'] + }) + ).toBe(false) + expect( + hasKeywordSearch({ + appId: 'app', + apiKey: 'key', + indices: undefined }) ).toBe(false) expect( hasKeywordSearch({ appId: 'app', apiKey: 'key', - indexName: undefined + indices: [] }) ).toBe(false) }) }) describe('hasAskAi', () => { - test('returns true for valid string assistantId', () => { - expect(hasAskAi('assistant123')).toBe(true) + test('returns true for valid string agentId', () => { + expect(hasAskAi('agent123')).toBe(true) }) - test('returns false for empty string assistantId', () => { + test('returns false for empty string agentId', () => { expect(hasAskAi('')).toBe(false) }) - test('returns true for object with assistantId', () => { - expect(hasAskAi({ assistantId: 'assistant123' } as any)).toBe(true) + test('returns true for object with agentId', () => { + expect(hasAskAi({ agentId: 'agent123' } as any)).toBe(true) }) - test('returns false for object without assistantId', () => { - expect(hasAskAi({ assistantId: null } as any)).toBe(false) + test('returns false for object without agentId', () => { + expect(hasAskAi({ agentId: null } as any)).toBe(false) expect(hasAskAi({} as any)).toBe(false) }) @@ -105,12 +155,12 @@ describe('client/theme-default/support/docsearch', () => { const result = validateCredentials({ appId: 'app', apiKey: 'key', - indexName: 'index' + indices: ['index'] }) expect(result.valid).toBe(true) expect(result.appId).toBe('app') expect(result.apiKey).toBe('key') - expect(result.indexName).toBe('index') + expect(result.indices).toEqual(['index']) }) test('invalidates incomplete credentials', () => { @@ -118,87 +168,140 @@ describe('client/theme-default/support/docsearch', () => { validateCredentials({ appId: undefined, apiKey: 'key', - indexName: 'index' + indices: ['index'] }).valid ).toBe(false) }) }) describe('buildAskAiConfig', () => { - test('builds config from string assistantId', () => { + test('builds config from string agentId', () => { const result = buildAskAiConfig( - 'assistant123', + 'agent123', { appId: 'app', apiKey: 'key', - indexName: 'index' + indices: ['index'] } as any, 'en' ) - expect(result.assistantId).toBe('assistant123') + expect(result.agentId).toBe('agent123') expect(result.appId).toBe('app') expect(result.apiKey).toBe('key') - expect(result.indexName).toBe('index') + expect(result.indices).toBeUndefined() }) test('builds config from object with overrides', () => { const result = buildAskAiConfig( { - assistantId: 'assistant123', + agentId: 'agent123', appId: 'custom-app', - apiKey: 'custom-key', - indexName: 'custom-index' + apiKey: 'custom-key' } as any, { appId: 'default-app', apiKey: 'default-key', - indexName: 'default-index' + indices: ['default-index'] } as any, 'en' ) - expect(result.assistantId).toBe('assistant123') + expect(result.agentId).toBe('agent123') expect(result.appId).toBe('custom-app') expect(result.apiKey).toBe('custom-key') - expect(result.indexName).toBe('custom-index') + expect(result.indices).toBeUndefined() }) - test('merges facet filters with lang', () => { + test('merges filters with lang by index', () => { const result = buildAskAiConfig( { - assistantId: 'assistant123', + agentId: 'agent123', + indices: ['ai_index'], searchParameters: { - facetFilters: ['tag:docs'] + ai_index: { + filters: 'tag:docs' + } } - } as any, + }, { appId: 'app', apiKey: 'key', - indexName: 'index' - } as any, + indices: ['index'] + }, + 'en' + ) + expect(result.searchParameters?.ai_index.filters).toBe( + '(tag:docs) AND lang:en' + ) + }) + + test('adds lang filters for indices without search parameters', () => { + const result = buildAskAiConfig( + { + agentId: 'agent123', + indices: ['ai_index'] + }, + { + appId: 'app', + apiKey: 'key', + indices: ['index'] + }, 'en' ) - expect(result.searchParameters?.facetFilters).toContain('tag:docs') - expect(result.searchParameters?.facetFilters).toContain('lang:en') + + expect(result.searchParameters).toEqual({ + ai_index: { + filters: 'lang:en' + } + }) }) - test('always adds lang facet filter to searchParameters', () => { + test('merges configured indices with search parameter indices', () => { const result = buildAskAiConfig( - 'assistant123', + { + agentId: 'agent123', + indices: ['configured_index'], + searchParameters: { + parameter_index: { + distinct: false + } + } + }, { appId: 'app', apiKey: 'key', - indexName: 'index' + indices: ['index'] + }, + 'en' + ) + + expect(result.searchParameters).toEqual({ + configured_index: { + filters: 'lang:en' + }, + parameter_index: { + distinct: false, + filters: 'lang:en' + } + }) + }) + + test('does not create index-specific search parameters for string config', () => { + const result = buildAskAiConfig( + 'agent123', + { + appId: 'app', + apiKey: 'key', + indices: ['index'] } as any, 'en' ) - expect(result.searchParameters?.facetFilters).toEqual(['lang:en']) + expect(result.searchParameters).toBeUndefined() }) - test('preserves Agent Studio search parameters by index', () => { + test('adds lang filters to search parameters by index', () => { const result = buildAskAiConfig( { - assistantId: 'assistant123', - agentStudio: true, + agentId: 'agent123', searchParameters: { index: { distinct: false @@ -208,7 +311,7 @@ describe('client/theme-default/support/docsearch', () => { { appId: 'app', apiKey: 'key', - indexName: 'index', + indices: ['index'], searchParameters: { facetFilters: ['tag:docs'] } @@ -218,7 +321,8 @@ describe('client/theme-default/support/docsearch', () => { expect(result.searchParameters).toEqual({ index: { - distinct: false + distinct: false, + filters: 'lang:en' } }) expect(result.searchParameters).not.toHaveProperty('facetFilters') @@ -227,13 +331,12 @@ describe('client/theme-default/support/docsearch', () => { test('does not add legacy facet filters to Agent Studio config', () => { const result = buildAskAiConfig( { - assistantId: 'assistant123', - agentStudio: true + agentId: 'agent123' } as any, { appId: 'app', apiKey: 'key', - indexName: 'index', + indices: ['index'], searchParameters: { facetFilters: ['tag:docs'] } @@ -249,15 +352,13 @@ describe('client/theme-default/support/docsearch', () => { test('passes resolved Ask AI options to the side panel', () => { const result = buildSidePanelProps( { - assistantId: 'assistant123', - agentStudio: true, + agentId: 'agent123', searchParameters: { index: { facetFilters: ['lang:en'] } }, suggestedQuestions: true, - useStagingEnv: true, sidePanel: { button: { variant: 'inline' @@ -271,7 +372,7 @@ describe('client/theme-default/support/docsearch', () => { { appId: 'app', apiKey: 'key', - indexName: 'index' + indices: ['index'] } as any ) @@ -279,16 +380,13 @@ describe('client/theme-default/support/docsearch', () => { container: '#vp-docsearch-sidepanel', appId: 'app', apiKey: 'key', - indexName: 'index', - assistantId: 'assistant123', - agentStudio: true, + agentId: 'agent123', searchParameters: { index: { facetFilters: ['lang:en'] } }, suggestedQuestions: true, - useStagingEnv: true, button: { variant: 'inline' }, diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 570f51e7..4205b2fa 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -88,11 +88,13 @@ export default defineConfig({ options: { appId: '8J64VVRP8K', apiKey: '52f578a92b88ad6abde815aae2b0ad7c', - indexName: 'vitepress', - askAi: { - assistantId: 'YaVSonfX5bS8', - sidePanel: true - } + indices: ['vitepress'] + // TODO: Update to use Agent Studio `agentId` once created. + // For now the Ask AI functionality won't work as DocSearch v5 only support Agent Studio. + // askAi: { + // agentId: '', + // sidePanel: true + // } } }, diff --git a/package.json b/package.json index 89aeebcc..7eb41c96 100644 --- a/package.json +++ b/package.json @@ -98,9 +98,9 @@ "*": "prettier --experimental-cli --ignore-unknown --write" }, "dependencies": { - "@docsearch/css": "^4.7.0", - "@docsearch/js": "^4.7.0", - "@docsearch/sidepanel-js": "^4.7.0", + "@docsearch/css": "^5.0.4", + "@docsearch/js": "^5.0.4", + "@docsearch/sidepanel-js": "^5.0.4", "@iconify-json/simple-icons": "^1.2.93", "@shikijs/transformers": "^4.4.3", "@types/markdown-it": "^14.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c09fe11e..dbcd7468 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,14 @@ importers: .: dependencies: '@docsearch/css': - specifier: ^4.7.0 - version: 4.7.0 + specifier: ^5.0.4 + version: 5.0.4 '@docsearch/js': - specifier: ^4.7.0 - version: 4.7.0 + specifier: ^5.0.4 + version: 5.0.4 '@docsearch/sidepanel-js': - specifier: ^4.7.0 - version: 4.7.0 + specifier: ^5.0.4 + version: 5.0.4 '@iconify-json/simple-icons': specifier: ^1.2.93 version: 1.2.93 @@ -370,14 +370,14 @@ packages: resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} engines: {node: '>=22'} - '@docsearch/css@4.7.0': - resolution: {integrity: sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw==} + '@docsearch/css@5.0.4': + resolution: {integrity: sha512-Bg2VmrPbhBmqKBrt6FL8bvd66f9IaU448Ip9K+elLlX2rivtV5FvBJq7iGsMvvj42etsdJe6VqisoTKZMv0Qdg==} - '@docsearch/js@4.7.0': - resolution: {integrity: sha512-x5lCqu1tetgsJFkjQ6VSocbHldsRkGEgwg5N98Vx21sq/V5wcmj4u226PY9k+TEpIgQ772zlYbPLTPicWyGnpA==} + '@docsearch/js@5.0.4': + resolution: {integrity: sha512-eKoVwAWKWXYPDZiDXBX4g1RVQnvHHCX91+Z+1ZEJuHPkZjTANk+L4/9t6xM5wxiiNcwD6YjfDMhcE/99sWT4SA==} - '@docsearch/sidepanel-js@4.7.0': - resolution: {integrity: sha512-A8r34jCU8kcIk2viECEn2msA28ojUF1BLi/3v5OWWc5G2N3jOuuumBXoeYjfr8dA0UxgFSy5R2bt12dnFJQSyA==} + '@docsearch/sidepanel-js@5.0.4': + resolution: {integrity: sha512-RZ6YO4SvLqfjlz49fNlYtlHARSuD0YxAQ8HTl5DDbiW5iHNu8Ichu0teughu1uLBVg06t4OELP3RRU6O7f0fHQ==} '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} @@ -1275,6 +1275,7 @@ packages: '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} @@ -2884,11 +2885,11 @@ snapshots: '@conventional-changelog/template@1.2.1': {} - '@docsearch/css@4.7.0': {} + '@docsearch/css@5.0.4': {} - '@docsearch/js@4.7.0': {} + '@docsearch/js@5.0.4': {} - '@docsearch/sidepanel-js@4.7.0': {} + '@docsearch/sidepanel-js@5.0.4': {} '@esbuild/aix-ppc64@0.27.7': optional: true diff --git a/src/client/theme-default/components/VPAlgoliaSearchBox.vue b/src/client/theme-default/components/VPAlgoliaSearchBox.vue index 3d56c670..f854b1c0 100644 --- a/src/client/theme-default/components/VPAlgoliaSearchBox.vue +++ b/src/client/theme-default/components/VPAlgoliaSearchBox.vue @@ -12,8 +12,6 @@ import { validateCredentials } from '../support/docsearch' -import '../styles/docsearch.css' - const props = defineProps<{ algoliaOptions: DefaultTheme.AlgoliaSearchOptions openRequest?: { @@ -30,7 +28,8 @@ let docsearchInstance: DocSearchInstance | undefined let sidepanelInstance: SidepanelInstance | undefined let openOnReady: 'search' | 'askAi' | null = null let initializeCount = 0 -let docsearchLoader: Promise | undefined +let docsearchLoader: Promise | undefined +let docsearchAiLoader: Promise | undefined let sidepanelLoader: Promise | undefined let lastFocusedElement: HTMLElement | null = null let skipEventDocsearch = false @@ -81,11 +80,13 @@ async function update(options: DefaultTheme.AlgoliaSearchOptions) { const { valid, ...credentials } = validateCredentials({ appId: options.appId ?? askAi?.appId, apiKey: options.apiKey ?? askAi?.apiKey, - indexName: options.indexName ?? askAi?.indexName + indices: options.indices, + mode: options.mode, + askAi: options.askAi }) if (!valid) { - console.warn('[vitepress] Algolia search cannot be initialized: missing appId/apiKey/indexName.') + console.warn('[vitepress] Algolia search cannot be initialized: missing appId/apiKey/indices.') return } @@ -98,16 +99,24 @@ async function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) { // Always tear down previous instances first (e.g. on locale changes) cleanup() - const { useSidePanel } = resolveMode(userOptions) + const { useSidePanel, showKeywordSearch } = resolveMode(userOptions) const askAi = userOptions.askAi as DocSearchAskAi | undefined + const usingAskAi = !!askAi + const usingSidepanel = useSidePanel && !!askAi?.sidePanel + + // Load everything that is needed in parallel + const [, docsearchModule, sidepanelModule] = await Promise.all([ + loadDocSearchCssBundles({ usingAskAi, usingSidepanel }), + showKeywordSearch ? loadDocsearch(usingAskAi) : undefined, + usingSidepanel ? loadSidepanel() : undefined + ]) - const { default: docsearch } = await loadDocsearch() if (currentInitialize !== initializeCount) return - if (useSidePanel && askAi?.sidePanel) { - const { default: sidepanel } = await loadSidepanel() - if (currentInitialize !== initializeCount) return + const docsearch = docsearchModule?.default + const sidepanel = sidepanelModule?.default + if (usingSidepanel && sidepanel) { sidepanelInstance = sidepanel({ ...buildSidePanelProps(askAi, userOptions), onOpen: focusInput, @@ -124,41 +133,43 @@ async function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) { }) } - const options = { - ...userOptions, - container: '#vp-docsearch', - navigator: { - navigate(item) { - router.go(item.itemUrl) - } - }, - transformItems: (items) => items.map((item) => ({ ...item, url: getRelativePath(item.url) })), - // When sidepanel is enabled, intercept Ask AI events to open it instead (hybrid mode) - ...(useSidePanel && sidepanelInstance && { - interceptAskAiEvent: (initialMessage) => { - onBeforeOpen('sidepanel', () => sidepanelInstance?.open(initialMessage)) - return true - } - }), - onOpen: focusInput, - onClose: onClose.bind(null, 'docsearch'), - onReady: () => { - if (openOnReady === 'search') { - openOnReady = null - onBeforeOpen('docsearch', () => docsearchInstance?.open()) - } else if (openOnReady === 'askAi' && !sidepanelInstance) { - // No sidepanel configured, use docsearch modal for askAi - openOnReady = null - onBeforeOpen('docsearch', () => docsearchInstance?.openAskAi()) + if (docsearch) { + const options = { + ...userOptions, + container: '#vp-docsearch', + navigator: { + navigate(item) { + router.go(item.itemUrl) + } + }, + transformItems: (items) => items.map((item) => ({ ...item, url: getRelativePath(item.url) })), + // When sidepanel is enabled, intercept Ask AI events to open it instead (hybrid mode) + ...(useSidePanel && sidepanelInstance && { + interceptAskAiEvent: (initialMessage) => { + onBeforeOpen('sidepanel', () => sidepanelInstance?.open(initialMessage)) + return true + } + }), + onOpen: focusInput, + onClose: onClose.bind(null, 'docsearch'), + onReady: () => { + if (openOnReady === 'search') { + openOnReady = null + onBeforeOpen('docsearch', () => docsearchInstance?.open()) + } else if (openOnReady === 'askAi' && !sidepanelInstance) { + // No sidepanel configured, use docsearch modal for askAi + openOnReady = null + onBeforeOpen('docsearch', () => docsearchInstance?.openAskAi()) + } + }, + keyboardShortcuts: { + '/': false, + 'Ctrl/Cmd+K': false } - }, - keyboardShortcuts: { - '/': false, - 'Ctrl/Cmd+K': false - } - } as DocSearchProps + } as DocSearchProps - docsearchInstance = docsearch(options) + docsearchInstance = docsearch(options) + } cleanup = () => { docsearchInstance?.destroy() @@ -220,10 +231,34 @@ function onClose(target: 'docsearch' | 'sidepanel') { } } -function loadDocsearch() { - if (!docsearchLoader) { - docsearchLoader = import('@docsearch/js') +// DocSearch V5 now splits it's CSS bundles, namely that `modal.css` does not contain Ask AI related CSS. +// With this, we can better target load the needed CSS bundles to save some page load. +async function loadDocSearchCssBundles({ usingAskAi, usingSidepanel }: { usingAskAi: boolean; usingSidepanel: boolean; }) { + if (usingAskAi) { + await import('@docsearch/css/dist/style.css') + } else { + await import('@docsearch/css/dist/_variables.css') + await import('@docsearch/css/dist/modal.css') + } + + if (usingSidepanel) { + await import('@docsearch/css/dist/sidepanel.css') } + + await import('../styles/docsearch.css') +} + +// DocSearch V5 now splits it's bundles between Ask AI or search only. +// As such, we can better target load the needed bundle. +function loadDocsearch(usingAskAi: boolean) { + // The default export includes Ask AI + if (usingAskAi) { + docsearchAiLoader ??= import('@docsearch/js') + return docsearchAiLoader + } + + docsearchLoader ??= import('@docsearch/js/docsearch') + return docsearchLoader } diff --git a/src/client/theme-default/styles/docsearch.css b/src/client/theme-default/styles/docsearch.css index f011a408..1802c47a 100644 --- a/src/client/theme-default/styles/docsearch.css +++ b/src/client/theme-default/styles/docsearch.css @@ -1,6 +1,3 @@ -@import '@docsearch/css/dist/style.css'; -@import '@docsearch/css/dist/sidepanel.css'; - #vp-docsearch, #vp-docsearch-sidepanel, .DocSearch-SidepanelButton { @@ -10,11 +7,14 @@ :root:root { --docsearch-actions-height: auto; --docsearch-actions-width: auto; - --docsearch-background-color: var(--vp-c-bg-soft); + --docsearch-background-color: var(--vp-c-divider); --docsearch-border-radius: 0.25rem; + --docsearch-modal-radius: 0.25rem; --docsearch-container-background: var(--vp-backdrop-bg-color); - --docsearch-dropdown-menu-background: var(--vp-c-bg-elv); + --docsearch-dropdown-menu-background: var(--vp-c-bg-alt); --docsearch-dropdown-menu-item-hover-background: var(--vp-c-default-soft); + --docsearch-popover-background: var(--vp-c-bg-alt); + --docsearch-popover-arrow-color: var(--docsearch-popover-background); --docsearch-focus-color: var(--vp-c-brand-1); --docsearch-footer-background: var(--vp-c-bg-alt); --docsearch-footer-height: 3.25rem; @@ -23,6 +23,7 @@ --docsearch-hit-color: var(--vp-c-text-1); --docsearch-hit-height: 3.5rem; --docsearch-hit-highlight-color: var(--vp-c-brand-soft); + --docsearch-hit-focus-background: var(--vp-c-brand-soft); --docsearch-icon-color: var(--vp-c-text-2); --docsearch-key-background: var(--vp-code-bg); --docsearch-modal-background: var(--vp-c-bg-soft); @@ -43,6 +44,7 @@ --docsearch-subtle-color: var(--vp-c-divider); --docsearch-success-color: var(--vp-c-brand-soft); --docsearch-text-color: var(--vp-c-text-1); + --docsearch-code-block-background: var(--vp-code-bg); } /* mirror the tightened mobile values from @docsearch/css, which the @@ -135,3 +137,8 @@ font-size: revert; line-height: revert; } + +.DocSearch-Feedback-Panel-Submit { + background: var(--vp-c-brand-soft); + color: var(--vp-c-brand-1); +} diff --git a/src/client/theme-default/support/docsearch.ts b/src/client/theme-default/support/docsearch.ts index d22c659c..fae3edb8 100644 --- a/src/client/theme-default/support/docsearch.ts +++ b/src/client/theme-default/support/docsearch.ts @@ -9,7 +9,7 @@ export interface ValidatedCredentials { valid: boolean appId?: string apiKey?: string - indexName?: string + indices?: DefaultTheme.AlgoliaSearchOptions['indices'] } export type DocSearchMode = 'auto' | 'sidePanel' | 'hybrid' | 'modal' @@ -20,14 +20,6 @@ export interface ResolvedMode { useSidePanel: boolean } -// FIXME: remove when https://github.com/algolia/docsearch/pull/2906 is released -export type ResolvedSidePanelProps = SidepanelProps & { - agentStudio?: boolean - searchParameters?: DocSearchAskAi['searchParameters'] - suggestedQuestions?: boolean - useStagingEnv?: boolean -} - /** * Resolves the effective mode based on config and available features. * @@ -39,7 +31,7 @@ export type ResolvedSidePanelProps = SidepanelProps & { export function resolveMode( options: Pick< DefaultTheme.AlgoliaSearchOptions, - 'appId' | 'apiKey' | 'indexName' | 'askAi' | 'mode' + 'appId' | 'apiKey' | 'indices' | 'askAi' | 'mode' > ): ResolvedMode { const mode = options.mode ?? 'auto' @@ -62,7 +54,7 @@ export function resolveMode( // Force hybrid - keyword search must be configured if (!hasKeyword) { console.error( - '[vitepress] mode: "hybrid" requires keyword search credentials (appId, apiKey, indexName).' + '[vitepress] mode: "hybrid" requires keyword search credentials (appId, apiKey, indices).' ) } return { @@ -93,10 +85,15 @@ export function resolveMode( export function hasKeywordSearch( options: Pick< DefaultTheme.AlgoliaSearchOptions, - 'appId' | 'apiKey' | 'indexName' + 'appId' | 'apiKey' | 'indices' > ): boolean { - return Boolean(options.appId && options.apiKey && options.indexName) + return Boolean( + options.appId && + options.apiKey && + options.indices && + options.indices.length > 0 + ) } export function hasAskAi( @@ -104,7 +101,40 @@ export function hasAskAi( ): boolean { if (!askAi) return false if (typeof askAi === 'string') return askAi.length > 0 - return Boolean(askAi.assistantId) + return Boolean(askAi.agentId) +} + +const LANG_FILTER_REGEXP = + /"(?:\\.|[^"\\])*"|(^|[\s(])((?:NOT\s+)?lang:(?:"(?:\\.|[^"\\])*"|[^\s()]+))/gi + +/** + * Normalizes existing positive `lang:` filters and applies `lang:${lang}` to + * the complete expression. + */ +export function mergeLangFilters( + existing: string | undefined, + lang: string +): string { + const langFilter = `lang:${lang}` + + if (!existing) { + return langFilter + } + + const normalized = existing.replace( + LANG_FILTER_REGEXP, + (match, prefix, predicate) => { + if (!predicate) { + return match + } + + if (/^NOT\b/i.test(predicate)) return match + + return `${prefix ?? ''}${langFilter}` + } + ) + + return `(${normalized}) AND ${langFilter}` } /** @@ -142,24 +172,53 @@ export function mergeLangFacetFilters( return [...filtered, `lang:${lang}`] } +type CredentialOptions = Pick< + DefaultTheme.AlgoliaSearchOptions, + 'appId' | 'apiKey' | 'indices' | 'mode' | 'askAi' +> + /** * Validates that required Algolia credentials are present. */ export function validateCredentials( - options: Pick< - DefaultTheme.AlgoliaSearchOptions, - 'appId' | 'apiKey' | 'indexName' - > + options: CredentialOptions ): ValidatedCredentials { const appId = options.appId const apiKey = options.apiKey - const indexName = options.indexName + const indices = options.indices + const askAiConfigured = options.askAi !== undefined + const hasValidAskAi = hasAskAi(options.askAi) + const mode = options.mode || 'auto' + const hasSidepanel = + typeof options.askAi === 'object' && Boolean(options.askAi.sidePanel) + const requiresSidepanel = mode === 'sidePanel' || mode === 'hybrid' + const canOmitIndices = + mode === 'sidePanel' || (mode === 'auto' && hasSidepanel) + + let isValid = true + + if (askAiConfigured && !hasValidAskAi) { + isValid = false + } + + if (requiresSidepanel && !hasSidepanel) { + isValid = false + } + + // Sidepanel only (or auto mode with sidepanel) does not require `indices` since there could be no search + if (!canOmitIndices && !indices?.length) { + isValid = false + } + + if (!appId || !apiKey) { + isValid = false + } return { - valid: Boolean(appId && apiKey && indexName), + valid: isValid, appId, apiKey, - indexName + indices } } @@ -173,41 +232,45 @@ export function buildAskAiConfig( ): DocSearchAskAi { const isAskAiString = typeof askAiProp === 'string' - const askAiSearchParameters = - !isAskAiString && askAiProp.searchParameters - ? { ...askAiProp.searchParameters } - : undefined - const isAgentStudio = !isAskAiString && askAiProp.agentStudio === true - - const askAiFacetFiltersSource = - askAiSearchParameters?.facetFilters ?? - options.searchParameters?.facetFilters - const askAiFacetFilters = mergeLangFacetFilters( - askAiFacetFiltersSource as FacetFilter | FacetFilter[] | undefined, - lang - ) + let askAiSearchParameters: DocSearchAskAi['searchParameters'] + + if (!isAskAiString) { + const mergedSearchParameters: NonNullable< + DocSearchAskAi['searchParameters'] + > = {} + + const indexes = new Set([ + ...(askAiProp.indices ?? []), + ...Object.keys(askAiProp.searchParameters ?? {}) + ]) - const mergedAskAiSearchParameters = isAgentStudio - ? askAiSearchParameters - : { - ...askAiSearchParameters, - facetFilters: askAiFacetFilters.length ? askAiFacetFilters : undefined + for (const indexName of indexes) { + const searchParameters = askAiProp.searchParameters?.[indexName] ?? {} + + mergedSearchParameters[indexName] = { + ...searchParameters, + filters: mergeLangFilters(searchParameters.filters, lang) } + } + + if (indexes.size > 0) { + askAiSearchParameters = mergedSearchParameters + } + } const result: Record = { ...(isAskAiString ? {} : askAiProp), - indexName: isAskAiString ? options.indexName : askAiProp.indexName, apiKey: isAskAiString ? options.apiKey : askAiProp.apiKey, appId: isAskAiString ? options.appId : askAiProp.appId, - assistantId: isAskAiString ? askAiProp : askAiProp.assistantId + agentId: isAskAiString ? askAiProp : askAiProp.agentId } // Keep `searchParameters` undefined unless it has at least one key. if ( - mergedAskAiSearchParameters && - Object.values(mergedAskAiSearchParameters).some((v) => v != null) + askAiSearchParameters && + Object.values(askAiSearchParameters).some((v) => v != null) ) { - result.searchParameters = mergedAskAiSearchParameters + result.searchParameters = askAiSearchParameters } return result @@ -219,19 +282,18 @@ export function buildAskAiConfig( export function buildSidePanelProps( askAi: DocSearchAskAi, options: DefaultTheme.AlgoliaSearchOptions -): ResolvedSidePanelProps { +): SidepanelProps { const { sidePanel, ...askAiRest } = JSON.parse( JSON.stringify(askAi) ) as DocSearchAskAi return { container: '#vp-docsearch-sidepanel', - indexName: options.indexName, appId: options.appId, apiKey: options.apiKey, ...askAiRest, ...(sidePanel && sidePanel !== true ? sidePanel : {}) - } as ResolvedSidePanelProps + } as SidepanelProps } /** @@ -245,17 +307,33 @@ export function resolveOptionsForLanguage( ): DefaultTheme.AlgoliaSearchOptions { options = deepMerge(options, options.locales?.[localeIndex] || {}) - const facetFilters = mergeLangFacetFilters( - options.searchParameters?.facetFilters, - lang - ) + const indices = (options.indices ?? []).map((index) => { + if (typeof index === 'string') { + return { + name: index, + searchParameters: { facetFilters: [`lang:${lang}`] } + } + } + + return { + name: index.name, + searchParameters: { + ...index.searchParameters, + facetFilters: mergeLangFacetFilters( + index.searchParameters?.facetFilters, + lang + ) + } + } + }) + const askAi = options.askAi ? buildAskAiConfig(options.askAi, options, lang) : undefined return { ...options, - searchParameters: { ...options.searchParameters, facetFilters }, + indices, askAi } } diff --git a/types/docsearch.d.ts b/types/docsearch.d.ts index 9fbc4cab..8d4a4955 100644 --- a/types/docsearch.d.ts +++ b/types/docsearch.d.ts @@ -13,22 +13,16 @@ export type DocSearchProps = Partial< | 'translations' | 'recentSearchesLimit' | 'recentSearchesWithFavoritesLimit' + | 'indices' + | 'facets' > > & { - /** - * Name of the algolia index to query. - */ - indexName?: string - /** - * Additional algolia search parameters to merge into each query. - */ - searchParameters?: DocSearchPropsJS['searchParameters'] /** * Insights client integration options to send analytics events. */ insights?: boolean /** - * Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object. + * Configuration or agent id to enable ask ai mode. Pass a string agent id or a full config object. */ askAi?: DocSearchAskAi | string /**