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.
pull/5402/head
Paul Jankowski 2 weeks ago
parent 4f373d3186
commit ae2d665ac8
No known key found for this signature in database

@ -3,6 +3,7 @@ import {
buildSidePanelProps, buildSidePanelProps,
hasAskAi, hasAskAi,
hasKeywordSearch, hasKeywordSearch,
mergeLangFilters,
mergeLangFacetFilters, mergeLangFacetFilters,
validateCredentials validateCredentials
} from 'client/theme-default/support/docsearch' } 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', () => { describe('hasKeywordSearch', () => {
test('returns true when all credentials are provided', () => { test('returns true when all credentials are provided', () => {
expect( expect(
hasKeywordSearch({ hasKeywordSearch({
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index' indices: ['index']
}) })
).toBe(true) ).toBe(true)
}) })
@ -57,41 +100,48 @@ describe('client/theme-default/support/docsearch', () => {
hasKeywordSearch({ hasKeywordSearch({
appId: undefined, appId: undefined,
apiKey: 'key', apiKey: 'key',
indexName: 'index' indices: ['index']
}) })
).toBe(false) ).toBe(false)
expect( expect(
hasKeywordSearch({ hasKeywordSearch({
appId: 'app', appId: 'app',
apiKey: undefined, apiKey: undefined,
indexName: 'index' indices: ['index']
})
).toBe(false)
expect(
hasKeywordSearch({
appId: 'app',
apiKey: 'key',
indices: undefined
}) })
).toBe(false) ).toBe(false)
expect( expect(
hasKeywordSearch({ hasKeywordSearch({
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: undefined indices: []
}) })
).toBe(false) ).toBe(false)
}) })
}) })
describe('hasAskAi', () => { describe('hasAskAi', () => {
test('returns true for valid string assistantId', () => { test('returns true for valid string agentId', () => {
expect(hasAskAi('assistant123')).toBe(true) expect(hasAskAi('agent123')).toBe(true)
}) })
test('returns false for empty string assistantId', () => { test('returns false for empty string agentId', () => {
expect(hasAskAi('')).toBe(false) expect(hasAskAi('')).toBe(false)
}) })
test('returns true for object with assistantId', () => { test('returns true for object with agentId', () => {
expect(hasAskAi({ assistantId: 'assistant123' } as any)).toBe(true) expect(hasAskAi({ agentId: 'agent123' } as any)).toBe(true)
}) })
test('returns false for object without assistantId', () => { test('returns false for object without agentId', () => {
expect(hasAskAi({ assistantId: null } as any)).toBe(false) expect(hasAskAi({ agentId: null } as any)).toBe(false)
expect(hasAskAi({} as any)).toBe(false) expect(hasAskAi({} as any)).toBe(false)
}) })
@ -105,12 +155,12 @@ describe('client/theme-default/support/docsearch', () => {
const result = validateCredentials({ const result = validateCredentials({
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index' indices: ['index']
}) })
expect(result.valid).toBe(true) expect(result.valid).toBe(true)
expect(result.appId).toBe('app') expect(result.appId).toBe('app')
expect(result.apiKey).toBe('key') expect(result.apiKey).toBe('key')
expect(result.indexName).toBe('index') expect(result.indices).toEqual(['index'])
}) })
test('invalidates incomplete credentials', () => { test('invalidates incomplete credentials', () => {
@ -118,87 +168,140 @@ describe('client/theme-default/support/docsearch', () => {
validateCredentials({ validateCredentials({
appId: undefined, appId: undefined,
apiKey: 'key', apiKey: 'key',
indexName: 'index' indices: ['index']
}).valid }).valid
).toBe(false) ).toBe(false)
}) })
}) })
describe('buildAskAiConfig', () => { describe('buildAskAiConfig', () => {
test('builds config from string assistantId', () => { test('builds config from string agentId', () => {
const result = buildAskAiConfig( const result = buildAskAiConfig(
'assistant123', 'agent123',
{ {
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index' indices: ['index']
} as any, } as any,
'en' 'en'
) )
expect(result.assistantId).toBe('assistant123') expect(result.agentId).toBe('agent123')
expect(result.appId).toBe('app') expect(result.appId).toBe('app')
expect(result.apiKey).toBe('key') expect(result.apiKey).toBe('key')
expect(result.indexName).toBe('index') expect(result.indices).toBeUndefined()
}) })
test('builds config from object with overrides', () => { test('builds config from object with overrides', () => {
const result = buildAskAiConfig( const result = buildAskAiConfig(
{ {
assistantId: 'assistant123', agentId: 'agent123',
appId: 'custom-app', appId: 'custom-app',
apiKey: 'custom-key', apiKey: 'custom-key'
indexName: 'custom-index'
} as any, } as any,
{ {
appId: 'default-app', appId: 'default-app',
apiKey: 'default-key', apiKey: 'default-key',
indexName: 'default-index' indices: ['default-index']
} as any, } as any,
'en' 'en'
) )
expect(result.assistantId).toBe('assistant123') expect(result.agentId).toBe('agent123')
expect(result.appId).toBe('custom-app') expect(result.appId).toBe('custom-app')
expect(result.apiKey).toBe('custom-key') 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( const result = buildAskAiConfig(
{ {
assistantId: 'assistant123', agentId: 'agent123',
indices: ['ai_index'],
searchParameters: { searchParameters: {
facetFilters: ['tag:docs'] ai_index: {
filters: 'tag:docs'
} }
} as any, }
},
{ {
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index' indices: ['index']
} as any, },
'en' 'en'
) )
expect(result.searchParameters?.facetFilters).toContain('tag:docs') expect(result.searchParameters?.ai_index.filters).toBe(
expect(result.searchParameters?.facetFilters).toContain('lang:en') '(tag:docs) AND lang:en'
)
}) })
test('always adds lang facet filter to searchParameters', () => { test('adds lang filters for indices without search parameters', () => {
const result = buildAskAiConfig( const result = buildAskAiConfig(
'assistant123', {
agentId: 'agent123',
indices: ['ai_index']
},
{ {
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index' indices: ['index']
},
'en'
)
expect(result.searchParameters).toEqual({
ai_index: {
filters: 'lang:en'
}
})
})
test('merges configured indices with search parameter indices', () => {
const result = buildAskAiConfig(
{
agentId: 'agent123',
indices: ['configured_index'],
searchParameters: {
parameter_index: {
distinct: false
}
}
},
{
appId: 'app',
apiKey: 'key',
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, } as any,
'en' '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( const result = buildAskAiConfig(
{ {
assistantId: 'assistant123', agentId: 'agent123',
agentStudio: true,
searchParameters: { searchParameters: {
index: { index: {
distinct: false distinct: false
@ -208,7 +311,7 @@ describe('client/theme-default/support/docsearch', () => {
{ {
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index', indices: ['index'],
searchParameters: { searchParameters: {
facetFilters: ['tag:docs'] facetFilters: ['tag:docs']
} }
@ -218,7 +321,8 @@ describe('client/theme-default/support/docsearch', () => {
expect(result.searchParameters).toEqual({ expect(result.searchParameters).toEqual({
index: { index: {
distinct: false distinct: false,
filters: 'lang:en'
} }
}) })
expect(result.searchParameters).not.toHaveProperty('facetFilters') 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', () => { test('does not add legacy facet filters to Agent Studio config', () => {
const result = buildAskAiConfig( const result = buildAskAiConfig(
{ {
assistantId: 'assistant123', agentId: 'agent123'
agentStudio: true
} as any, } as any,
{ {
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index', indices: ['index'],
searchParameters: { searchParameters: {
facetFilters: ['tag:docs'] facetFilters: ['tag:docs']
} }
@ -249,15 +352,13 @@ describe('client/theme-default/support/docsearch', () => {
test('passes resolved Ask AI options to the side panel', () => { test('passes resolved Ask AI options to the side panel', () => {
const result = buildSidePanelProps( const result = buildSidePanelProps(
{ {
assistantId: 'assistant123', agentId: 'agent123',
agentStudio: true,
searchParameters: { searchParameters: {
index: { index: {
facetFilters: ['lang:en'] facetFilters: ['lang:en']
} }
}, },
suggestedQuestions: true, suggestedQuestions: true,
useStagingEnv: true,
sidePanel: { sidePanel: {
button: { button: {
variant: 'inline' variant: 'inline'
@ -271,7 +372,7 @@ describe('client/theme-default/support/docsearch', () => {
{ {
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index' indices: ['index']
} as any } as any
) )
@ -279,16 +380,13 @@ describe('client/theme-default/support/docsearch', () => {
container: '#vp-docsearch-sidepanel', container: '#vp-docsearch-sidepanel',
appId: 'app', appId: 'app',
apiKey: 'key', apiKey: 'key',
indexName: 'index', agentId: 'agent123',
assistantId: 'assistant123',
agentStudio: true,
searchParameters: { searchParameters: {
index: { index: {
facetFilters: ['lang:en'] facetFilters: ['lang:en']
} }
}, },
suggestedQuestions: true, suggestedQuestions: true,
useStagingEnv: true,
button: { button: {
variant: 'inline' variant: 'inline'
}, },

@ -88,11 +88,13 @@ export default defineConfig({
options: { options: {
appId: '8J64VVRP8K', appId: '8J64VVRP8K',
apiKey: '52f578a92b88ad6abde815aae2b0ad7c', apiKey: '52f578a92b88ad6abde815aae2b0ad7c',
indexName: 'vitepress', indices: ['vitepress']
askAi: { // TODO: Update to use Agent Studio `agentId` once created.
assistantId: 'YaVSonfX5bS8', // For now the Ask AI functionality won't work as DocSearch v5 only support Agent Studio.
sidePanel: true // askAi: {
} // agentId: '',
// sidePanel: true
// }
} }
}, },

@ -98,9 +98,9 @@
"*": "prettier --experimental-cli --ignore-unknown --write" "*": "prettier --experimental-cli --ignore-unknown --write"
}, },
"dependencies": { "dependencies": {
"@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",
"@iconify-json/simple-icons": "^1.2.93", "@iconify-json/simple-icons": "^1.2.93",
"@shikijs/transformers": "^4.4.3", "@shikijs/transformers": "^4.4.3",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",

@ -9,14 +9,14 @@ importers:
.: .:
dependencies: dependencies:
'@docsearch/css': '@docsearch/css':
specifier: ^4.7.0 specifier: ^5.0.4
version: 4.7.0 version: 5.0.4
'@docsearch/js': '@docsearch/js':
specifier: ^4.7.0 specifier: ^5.0.4
version: 4.7.0 version: 5.0.4
'@docsearch/sidepanel-js': '@docsearch/sidepanel-js':
specifier: ^4.7.0 specifier: ^5.0.4
version: 4.7.0 version: 5.0.4
'@iconify-json/simple-icons': '@iconify-json/simple-icons':
specifier: ^1.2.93 specifier: ^1.2.93
version: 1.2.93 version: 1.2.93
@ -370,14 +370,14 @@ packages:
resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@docsearch/css@4.7.0': '@docsearch/css@5.0.4':
resolution: {integrity: sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw==} resolution: {integrity: sha512-Bg2VmrPbhBmqKBrt6FL8bvd66f9IaU448Ip9K+elLlX2rivtV5FvBJq7iGsMvvj42etsdJe6VqisoTKZMv0Qdg==}
'@docsearch/js@4.7.0': '@docsearch/js@5.0.4':
resolution: {integrity: sha512-x5lCqu1tetgsJFkjQ6VSocbHldsRkGEgwg5N98Vx21sq/V5wcmj4u226PY9k+TEpIgQ772zlYbPLTPicWyGnpA==} resolution: {integrity: sha512-eKoVwAWKWXYPDZiDXBX4g1RVQnvHHCX91+Z+1ZEJuHPkZjTANk+L4/9t6xM5wxiiNcwD6YjfDMhcE/99sWT4SA==}
'@docsearch/sidepanel-js@4.7.0': '@docsearch/sidepanel-js@5.0.4':
resolution: {integrity: sha512-A8r34jCU8kcIk2viECEn2msA28ojUF1BLi/3v5OWWc5G2N3jOuuumBXoeYjfr8dA0UxgFSy5R2bt12dnFJQSyA==} resolution: {integrity: sha512-RZ6YO4SvLqfjlz49fNlYtlHARSuD0YxAQ8HTl5DDbiW5iHNu8Ichu0teughu1uLBVg06t4OELP3RRU6O7f0fHQ==}
'@esbuild/aix-ppc64@0.27.7': '@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
@ -1275,6 +1275,7 @@ packages:
'@xmldom/xmldom@0.9.10': '@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'} engines: {node: '>=14.6'}
deprecated: this version has critical issues, please update to the latest version
agent-base@6.0.2: agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
@ -2884,11 +2885,11 @@ snapshots:
'@conventional-changelog/template@1.2.1': {} '@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': '@esbuild/aix-ppc64@0.27.7':
optional: true optional: true

@ -12,8 +12,6 @@ import {
validateCredentials validateCredentials
} from '../support/docsearch' } from '../support/docsearch'
import '../styles/docsearch.css'
const props = defineProps<{ const props = defineProps<{
algoliaOptions: DefaultTheme.AlgoliaSearchOptions algoliaOptions: DefaultTheme.AlgoliaSearchOptions
openRequest?: { openRequest?: {
@ -30,7 +28,8 @@ let docsearchInstance: DocSearchInstance | undefined
let sidepanelInstance: SidepanelInstance | undefined let sidepanelInstance: SidepanelInstance | undefined
let openOnReady: 'search' | 'askAi' | null = null let openOnReady: 'search' | 'askAi' | null = null
let initializeCount = 0 let initializeCount = 0
let docsearchLoader: Promise<typeof import('@docsearch/js')> | undefined let docsearchLoader: Promise<typeof import('@docsearch/js/docsearch')> | undefined
let docsearchAiLoader: Promise<typeof import('@docsearch/js')> | undefined
let sidepanelLoader: Promise<typeof import('@docsearch/sidepanel-js')> | undefined let sidepanelLoader: Promise<typeof import('@docsearch/sidepanel-js')> | undefined
let lastFocusedElement: HTMLElement | null = null let lastFocusedElement: HTMLElement | null = null
let skipEventDocsearch = false let skipEventDocsearch = false
@ -81,11 +80,13 @@ async function update(options: DefaultTheme.AlgoliaSearchOptions) {
const { valid, ...credentials } = validateCredentials({ const { valid, ...credentials } = validateCredentials({
appId: options.appId ?? askAi?.appId, appId: options.appId ?? askAi?.appId,
apiKey: options.apiKey ?? askAi?.apiKey, apiKey: options.apiKey ?? askAi?.apiKey,
indexName: options.indexName ?? askAi?.indexName indices: options.indices,
mode: options.mode,
askAi: options.askAi
}) })
if (!valid) { 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 return
} }
@ -98,16 +99,24 @@ async function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) {
// Always tear down previous instances first (e.g. on locale changes) // Always tear down previous instances first (e.g. on locale changes)
cleanup() cleanup()
const { useSidePanel } = resolveMode(userOptions) const { useSidePanel, showKeywordSearch } = resolveMode(userOptions)
const askAi = userOptions.askAi as DocSearchAskAi | undefined const askAi = userOptions.askAi as DocSearchAskAi | undefined
const usingAskAi = !!askAi
const usingSidepanel = useSidePanel && !!askAi?.sidePanel
const { default: docsearch } = await loadDocsearch() // Load everything that is needed in parallel
if (currentInitialize !== initializeCount) return const [, docsearchModule, sidepanelModule] = await Promise.all([
loadDocSearchCssBundles({ usingAskAi, usingSidepanel }),
showKeywordSearch ? loadDocsearch(usingAskAi) : undefined,
usingSidepanel ? loadSidepanel() : undefined
])
if (useSidePanel && askAi?.sidePanel) {
const { default: sidepanel } = await loadSidepanel()
if (currentInitialize !== initializeCount) return if (currentInitialize !== initializeCount) return
const docsearch = docsearchModule?.default
const sidepanel = sidepanelModule?.default
if (usingSidepanel && sidepanel) {
sidepanelInstance = sidepanel({ sidepanelInstance = sidepanel({
...buildSidePanelProps(askAi, userOptions), ...buildSidePanelProps(askAi, userOptions),
onOpen: focusInput, onOpen: focusInput,
@ -124,6 +133,7 @@ async function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) {
}) })
} }
if (docsearch) {
const options = { const options = {
...userOptions, ...userOptions,
container: '#vp-docsearch', container: '#vp-docsearch',
@ -159,6 +169,7 @@ async function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) {
} as DocSearchProps } as DocSearchProps
docsearchInstance = docsearch(options) docsearchInstance = docsearch(options)
}
cleanup = () => { cleanup = () => {
docsearchInstance?.destroy() docsearchInstance?.destroy()
@ -220,10 +231,34 @@ function onClose(target: 'docsearch' | 'sidepanel') {
} }
} }
function loadDocsearch() { // DocSearch V5 now splits it's CSS bundles, namely that `modal.css` does not contain Ask AI related CSS.
if (!docsearchLoader) { // With this, we can better target load the needed CSS bundles to save some page load.
docsearchLoader = import('@docsearch/js') 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 return docsearchLoader
} }

@ -1,6 +1,3 @@
@import '@docsearch/css/dist/style.css';
@import '@docsearch/css/dist/sidepanel.css';
#vp-docsearch, #vp-docsearch,
#vp-docsearch-sidepanel, #vp-docsearch-sidepanel,
.DocSearch-SidepanelButton { .DocSearch-SidepanelButton {
@ -10,11 +7,14 @@
:root:root { :root:root {
--docsearch-actions-height: auto; --docsearch-actions-height: auto;
--docsearch-actions-width: 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-border-radius: 0.25rem;
--docsearch-modal-radius: 0.25rem;
--docsearch-container-background: var(--vp-backdrop-bg-color); --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-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-focus-color: var(--vp-c-brand-1);
--docsearch-footer-background: var(--vp-c-bg-alt); --docsearch-footer-background: var(--vp-c-bg-alt);
--docsearch-footer-height: 3.25rem; --docsearch-footer-height: 3.25rem;
@ -23,6 +23,7 @@
--docsearch-hit-color: var(--vp-c-text-1); --docsearch-hit-color: var(--vp-c-text-1);
--docsearch-hit-height: 3.5rem; --docsearch-hit-height: 3.5rem;
--docsearch-hit-highlight-color: var(--vp-c-brand-soft); --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-icon-color: var(--vp-c-text-2);
--docsearch-key-background: var(--vp-code-bg); --docsearch-key-background: var(--vp-code-bg);
--docsearch-modal-background: var(--vp-c-bg-soft); --docsearch-modal-background: var(--vp-c-bg-soft);
@ -43,6 +44,7 @@
--docsearch-subtle-color: var(--vp-c-divider); --docsearch-subtle-color: var(--vp-c-divider);
--docsearch-success-color: var(--vp-c-brand-soft); --docsearch-success-color: var(--vp-c-brand-soft);
--docsearch-text-color: var(--vp-c-text-1); --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 /* mirror the tightened mobile values from @docsearch/css, which the
@ -135,3 +137,8 @@
font-size: revert; font-size: revert;
line-height: revert; line-height: revert;
} }
.DocSearch-Feedback-Panel-Submit {
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-1);
}

@ -9,7 +9,7 @@ export interface ValidatedCredentials {
valid: boolean valid: boolean
appId?: string appId?: string
apiKey?: string apiKey?: string
indexName?: string indices?: DefaultTheme.AlgoliaSearchOptions['indices']
} }
export type DocSearchMode = 'auto' | 'sidePanel' | 'hybrid' | 'modal' export type DocSearchMode = 'auto' | 'sidePanel' | 'hybrid' | 'modal'
@ -20,14 +20,6 @@ export interface ResolvedMode {
useSidePanel: boolean 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. * Resolves the effective mode based on config and available features.
* *
@ -39,7 +31,7 @@ export type ResolvedSidePanelProps = SidepanelProps & {
export function resolveMode( export function resolveMode(
options: Pick< options: Pick<
DefaultTheme.AlgoliaSearchOptions, DefaultTheme.AlgoliaSearchOptions,
'appId' | 'apiKey' | 'indexName' | 'askAi' | 'mode' 'appId' | 'apiKey' | 'indices' | 'askAi' | 'mode'
> >
): ResolvedMode { ): ResolvedMode {
const mode = options.mode ?? 'auto' const mode = options.mode ?? 'auto'
@ -62,7 +54,7 @@ export function resolveMode(
// Force hybrid - keyword search must be configured // Force hybrid - keyword search must be configured
if (!hasKeyword) { if (!hasKeyword) {
console.error( console.error(
'[vitepress] mode: "hybrid" requires keyword search credentials (appId, apiKey, indexName).' '[vitepress] mode: "hybrid" requires keyword search credentials (appId, apiKey, indices).'
) )
} }
return { return {
@ -93,10 +85,15 @@ export function resolveMode(
export function hasKeywordSearch( export function hasKeywordSearch(
options: Pick< options: Pick<
DefaultTheme.AlgoliaSearchOptions, DefaultTheme.AlgoliaSearchOptions,
'appId' | 'apiKey' | 'indexName' 'appId' | 'apiKey' | 'indices'
> >
): boolean { ): boolean {
return Boolean(options.appId && options.apiKey && options.indexName) return Boolean(
options.appId &&
options.apiKey &&
options.indices &&
options.indices.length > 0
)
} }
export function hasAskAi( export function hasAskAi(
@ -104,7 +101,40 @@ export function hasAskAi(
): boolean { ): boolean {
if (!askAi) return false if (!askAi) return false
if (typeof askAi === 'string') return askAi.length > 0 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}`] return [...filtered, `lang:${lang}`]
} }
type CredentialOptions = Pick<
DefaultTheme.AlgoliaSearchOptions,
'appId' | 'apiKey' | 'indices' | 'mode' | 'askAi'
>
/** /**
* Validates that required Algolia credentials are present. * Validates that required Algolia credentials are present.
*/ */
export function validateCredentials( export function validateCredentials(
options: Pick< options: CredentialOptions
DefaultTheme.AlgoliaSearchOptions,
'appId' | 'apiKey' | 'indexName'
>
): ValidatedCredentials { ): ValidatedCredentials {
const appId = options.appId const appId = options.appId
const apiKey = options.apiKey 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 { return {
valid: Boolean(appId && apiKey && indexName), valid: isValid,
appId, appId,
apiKey, apiKey,
indexName indices
} }
} }
@ -173,41 +232,45 @@ export function buildAskAiConfig(
): DocSearchAskAi { ): DocSearchAskAi {
const isAskAiString = typeof askAiProp === 'string' const isAskAiString = typeof askAiProp === 'string'
const askAiSearchParameters = let askAiSearchParameters: DocSearchAskAi['searchParameters']
!isAskAiString && askAiProp.searchParameters
? { ...askAiProp.searchParameters }
: undefined
const isAgentStudio = !isAskAiString && askAiProp.agentStudio === true
const askAiFacetFiltersSource = if (!isAskAiString) {
askAiSearchParameters?.facetFilters ?? const mergedSearchParameters: NonNullable<
options.searchParameters?.facetFilters DocSearchAskAi['searchParameters']
const askAiFacetFilters = mergeLangFacetFilters( > = {}
askAiFacetFiltersSource as FacetFilter | FacetFilter[] | undefined,
lang const indexes = new Set([
) ...(askAiProp.indices ?? []),
...Object.keys(askAiProp.searchParameters ?? {})
])
for (const indexName of indexes) {
const searchParameters = askAiProp.searchParameters?.[indexName] ?? {}
mergedSearchParameters[indexName] = {
...searchParameters,
filters: mergeLangFilters(searchParameters.filters, lang)
}
}
const mergedAskAiSearchParameters = isAgentStudio if (indexes.size > 0) {
? askAiSearchParameters askAiSearchParameters = mergedSearchParameters
: { }
...askAiSearchParameters,
facetFilters: askAiFacetFilters.length ? askAiFacetFilters : undefined
} }
const result: Record<string, any> = { const result: Record<string, any> = {
...(isAskAiString ? {} : askAiProp), ...(isAskAiString ? {} : askAiProp),
indexName: isAskAiString ? options.indexName : askAiProp.indexName,
apiKey: isAskAiString ? options.apiKey : askAiProp.apiKey, apiKey: isAskAiString ? options.apiKey : askAiProp.apiKey,
appId: isAskAiString ? options.appId : askAiProp.appId, 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. // Keep `searchParameters` undefined unless it has at least one key.
if ( if (
mergedAskAiSearchParameters && askAiSearchParameters &&
Object.values(mergedAskAiSearchParameters).some((v) => v != null) Object.values(askAiSearchParameters).some((v) => v != null)
) { ) {
result.searchParameters = mergedAskAiSearchParameters result.searchParameters = askAiSearchParameters
} }
return result return result
@ -219,19 +282,18 @@ export function buildAskAiConfig(
export function buildSidePanelProps( export function buildSidePanelProps(
askAi: DocSearchAskAi, askAi: DocSearchAskAi,
options: DefaultTheme.AlgoliaSearchOptions options: DefaultTheme.AlgoliaSearchOptions
): ResolvedSidePanelProps { ): SidepanelProps {
const { sidePanel, ...askAiRest } = JSON.parse( const { sidePanel, ...askAiRest } = JSON.parse(
JSON.stringify(askAi) JSON.stringify(askAi)
) as DocSearchAskAi ) as DocSearchAskAi
return { return {
container: '#vp-docsearch-sidepanel', container: '#vp-docsearch-sidepanel',
indexName: options.indexName,
appId: options.appId, appId: options.appId,
apiKey: options.apiKey, apiKey: options.apiKey,
...askAiRest, ...askAiRest,
...(sidePanel && sidePanel !== true ? sidePanel : {}) ...(sidePanel && sidePanel !== true ? sidePanel : {})
} as ResolvedSidePanelProps } as SidepanelProps
} }
/** /**
@ -245,17 +307,33 @@ export function resolveOptionsForLanguage(
): DefaultTheme.AlgoliaSearchOptions { ): DefaultTheme.AlgoliaSearchOptions {
options = deepMerge(options, options.locales?.[localeIndex] || {}) options = deepMerge(options, options.locales?.[localeIndex] || {})
const facetFilters = mergeLangFacetFilters( const indices = (options.indices ?? []).map((index) => {
options.searchParameters?.facetFilters, if (typeof index === 'string') {
return {
name: index,
searchParameters: { facetFilters: [`lang:${lang}`] }
}
}
return {
name: index.name,
searchParameters: {
...index.searchParameters,
facetFilters: mergeLangFacetFilters(
index.searchParameters?.facetFilters,
lang lang
) )
}
}
})
const askAi = options.askAi const askAi = options.askAi
? buildAskAiConfig(options.askAi, options, lang) ? buildAskAiConfig(options.askAi, options, lang)
: undefined : undefined
return { return {
...options, ...options,
searchParameters: { ...options.searchParameters, facetFilters }, indices,
askAi askAi
} }
} }

@ -13,22 +13,16 @@ export type DocSearchProps = Partial<
| 'translations' | 'translations'
| 'recentSearchesLimit' | 'recentSearchesLimit'
| 'recentSearchesWithFavoritesLimit' | '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 client integration options to send analytics events.
*/ */
insights?: boolean 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 askAi?: DocSearchAskAi | string
/** /**

Loading…
Cancel
Save