mirror of https://github.com/vuejs/vitepress
feat(theme): upgrade DocSearch to 4.5 with sidepanel (#5092)
--------- Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>pull/5106/head
parent
d4796a0373
commit
0d646a66cd
@ -0,0 +1,196 @@
|
||||
import {
|
||||
buildAskAiConfig,
|
||||
hasAskAi,
|
||||
hasKeywordSearch,
|
||||
mergeLangFacetFilters,
|
||||
validateCredentials
|
||||
} from 'client/theme-default/support/docsearch'
|
||||
|
||||
describe('client/theme-default/support/docsearch', () => {
|
||||
describe('mergeLangFacetFilters', () => {
|
||||
test('adds a lang facet filter when none is provided', () => {
|
||||
expect(mergeLangFacetFilters(undefined, 'en')).toEqual(['lang:en'])
|
||||
})
|
||||
|
||||
test('replaces existing lang facet filters', () => {
|
||||
expect(mergeLangFacetFilters('lang:fr', 'en')).toEqual(['lang:en'])
|
||||
expect(mergeLangFacetFilters(['foo', 'lang:fr'], 'en')).toEqual([
|
||||
'foo',
|
||||
'lang:en'
|
||||
])
|
||||
})
|
||||
|
||||
test('handles nested facet filters (OR conditions)', () => {
|
||||
expect(
|
||||
mergeLangFacetFilters([['tag:foo', 'tag:bar'], 'lang:fr'], 'en')
|
||||
).toEqual([['tag:foo', 'tag:bar'], 'lang:en'])
|
||||
})
|
||||
|
||||
test('removes empty nested arrays', () => {
|
||||
expect(mergeLangFacetFilters([['lang:fr'], 'other'], 'en')).toEqual([
|
||||
'other',
|
||||
'lang:en'
|
||||
])
|
||||
})
|
||||
|
||||
test('handles multiple lang filters in nested arrays', () => {
|
||||
expect(
|
||||
mergeLangFacetFilters([['lang:fr', 'tag:foo'], 'bar'], 'en')
|
||||
).toEqual([['tag:foo'], 'bar', 'lang:en'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasKeywordSearch', () => {
|
||||
test('returns true when all credentials are provided', () => {
|
||||
expect(
|
||||
hasKeywordSearch({
|
||||
appId: 'app',
|
||||
apiKey: 'key',
|
||||
indexName: 'index'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when any credential is missing', () => {
|
||||
expect(
|
||||
hasKeywordSearch({
|
||||
appId: undefined,
|
||||
apiKey: 'key',
|
||||
indexName: 'index'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasKeywordSearch({
|
||||
appId: 'app',
|
||||
apiKey: undefined,
|
||||
indexName: 'index'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasKeywordSearch({
|
||||
appId: 'app',
|
||||
apiKey: 'key',
|
||||
indexName: undefined
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAskAi', () => {
|
||||
test('returns true for valid string assistantId', () => {
|
||||
expect(hasAskAi('assistant123')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for empty string assistantId', () => {
|
||||
expect(hasAskAi('')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true for object with assistantId', () => {
|
||||
expect(hasAskAi({ assistantId: 'assistant123' } as any)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for object without assistantId', () => {
|
||||
expect(hasAskAi({ assistantId: null } as any)).toBe(false)
|
||||
expect(hasAskAi({} as any)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for undefined', () => {
|
||||
expect(hasAskAi(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateCredentials', () => {
|
||||
test('validates complete credentials', () => {
|
||||
const result = validateCredentials({
|
||||
appId: 'app',
|
||||
apiKey: 'key',
|
||||
indexName: 'index'
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.appId).toBe('app')
|
||||
expect(result.apiKey).toBe('key')
|
||||
expect(result.indexName).toBe('index')
|
||||
})
|
||||
|
||||
test('invalidates incomplete credentials', () => {
|
||||
expect(
|
||||
validateCredentials({
|
||||
appId: undefined,
|
||||
apiKey: 'key',
|
||||
indexName: 'index'
|
||||
}).valid
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAskAiConfig', () => {
|
||||
test('builds config from string assistantId', () => {
|
||||
const result = buildAskAiConfig(
|
||||
'assistant123',
|
||||
{
|
||||
appId: 'app',
|
||||
apiKey: 'key',
|
||||
indexName: 'index'
|
||||
} as any,
|
||||
'en'
|
||||
)
|
||||
expect(result.assistantId).toBe('assistant123')
|
||||
expect(result.appId).toBe('app')
|
||||
expect(result.apiKey).toBe('key')
|
||||
expect(result.indexName).toBe('index')
|
||||
})
|
||||
|
||||
test('builds config from object with overrides', () => {
|
||||
const result = buildAskAiConfig(
|
||||
{
|
||||
assistantId: 'assistant123',
|
||||
appId: 'custom-app',
|
||||
apiKey: 'custom-key',
|
||||
indexName: 'custom-index'
|
||||
} as any,
|
||||
{
|
||||
appId: 'default-app',
|
||||
apiKey: 'default-key',
|
||||
indexName: 'default-index'
|
||||
} as any,
|
||||
'en'
|
||||
)
|
||||
expect(result.assistantId).toBe('assistant123')
|
||||
expect(result.appId).toBe('custom-app')
|
||||
expect(result.apiKey).toBe('custom-key')
|
||||
expect(result.indexName).toBe('custom-index')
|
||||
})
|
||||
|
||||
test('merges facet filters with lang', () => {
|
||||
const result = buildAskAiConfig(
|
||||
{
|
||||
assistantId: 'assistant123',
|
||||
searchParameters: {
|
||||
facetFilters: ['tag:docs']
|
||||
}
|
||||
} as any,
|
||||
{
|
||||
appId: 'app',
|
||||
apiKey: 'key',
|
||||
indexName: 'index'
|
||||
} as any,
|
||||
'en'
|
||||
)
|
||||
expect(result.searchParameters?.facetFilters).toContain('tag:docs')
|
||||
expect(result.searchParameters?.facetFilters).toContain('lang:en')
|
||||
})
|
||||
|
||||
test('always adds lang facet filter to searchParameters', () => {
|
||||
const result = buildAskAiConfig(
|
||||
'assistant123',
|
||||
{
|
||||
appId: 'app',
|
||||
apiKey: 'key',
|
||||
indexName: 'index'
|
||||
} as any,
|
||||
'en'
|
||||
)
|
||||
expect(result.searchParameters?.facetFilters).toEqual(['lang:en'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,101 @@
|
||||
new Crawler({
|
||||
appId: '...',
|
||||
apiKey: '...',
|
||||
rateLimit: 8,
|
||||
startUrls: ['https://vitepress.dev/'],
|
||||
renderJavaScript: false,
|
||||
sitemaps: [],
|
||||
exclusionPatterns: [],
|
||||
ignoreCanonicalTo: false,
|
||||
discoveryPatterns: ['https://vitepress.dev/**'],
|
||||
schedule: 'at 05:10 on Saturday',
|
||||
actions: [
|
||||
{
|
||||
indexName: 'vitepress',
|
||||
pathsToMatch: ['https://vitepress.dev/**'],
|
||||
recordExtractor: ({ $, helpers }) => {
|
||||
return helpers.docsearch({
|
||||
recordProps: {
|
||||
lvl1: '.content h1',
|
||||
content: '.content p, .content li',
|
||||
lvl0: {
|
||||
selectors: 'section.has-active div h2',
|
||||
defaultValue: 'Documentation'
|
||||
},
|
||||
lvl2: '.content h2',
|
||||
lvl3: '.content h3',
|
||||
lvl4: '.content h4',
|
||||
lvl5: '.content h5'
|
||||
},
|
||||
indexHeadings: true
|
||||
})
|
||||
}
|
||||
}
|
||||
],
|
||||
initialIndexSettings: {
|
||||
vitepress: {
|
||||
attributesForFaceting: ['type', 'lang'],
|
||||
attributesToRetrieve: ['hierarchy', 'content', 'anchor', 'url'],
|
||||
attributesToHighlight: ['hierarchy', 'hierarchy_camel', 'content'],
|
||||
attributesToSnippet: ['content:10'],
|
||||
camelCaseAttributes: ['hierarchy', 'hierarchy_radio', 'content'],
|
||||
searchableAttributes: [
|
||||
'unordered(hierarchy_radio_camel.lvl0)',
|
||||
'unordered(hierarchy_radio.lvl0)',
|
||||
'unordered(hierarchy_radio_camel.lvl1)',
|
||||
'unordered(hierarchy_radio.lvl1)',
|
||||
'unordered(hierarchy_radio_camel.lvl2)',
|
||||
'unordered(hierarchy_radio.lvl2)',
|
||||
'unordered(hierarchy_radio_camel.lvl3)',
|
||||
'unordered(hierarchy_radio.lvl3)',
|
||||
'unordered(hierarchy_radio_camel.lvl4)',
|
||||
'unordered(hierarchy_radio.lvl4)',
|
||||
'unordered(hierarchy_radio_camel.lvl5)',
|
||||
'unordered(hierarchy_radio.lvl5)',
|
||||
'unordered(hierarchy_radio_camel.lvl6)',
|
||||
'unordered(hierarchy_radio.lvl6)',
|
||||
'unordered(hierarchy_camel.lvl0)',
|
||||
'unordered(hierarchy.lvl0)',
|
||||
'unordered(hierarchy_camel.lvl1)',
|
||||
'unordered(hierarchy.lvl1)',
|
||||
'unordered(hierarchy_camel.lvl2)',
|
||||
'unordered(hierarchy.lvl2)',
|
||||
'unordered(hierarchy_camel.lvl3)',
|
||||
'unordered(hierarchy.lvl3)',
|
||||
'unordered(hierarchy_camel.lvl4)',
|
||||
'unordered(hierarchy.lvl4)',
|
||||
'unordered(hierarchy_camel.lvl5)',
|
||||
'unordered(hierarchy.lvl5)',
|
||||
'unordered(hierarchy_camel.lvl6)',
|
||||
'unordered(hierarchy.lvl6)',
|
||||
'content'
|
||||
],
|
||||
distinct: true,
|
||||
attributeForDistinct: 'url',
|
||||
customRanking: [
|
||||
'desc(weight.pageRank)',
|
||||
'desc(weight.level)',
|
||||
'asc(weight.position)'
|
||||
],
|
||||
ranking: [
|
||||
'words',
|
||||
'filters',
|
||||
'typo',
|
||||
'attribute',
|
||||
'proximity',
|
||||
'exact',
|
||||
'custom'
|
||||
],
|
||||
highlightPreTag: '<span class="algolia-docsearch-suggestion--highlight">',
|
||||
highlightPostTag: '</span>',
|
||||
minWordSizefor1Typo: 3,
|
||||
minWordSizefor2Typos: 7,
|
||||
allowTyposOnNumericTokens: false,
|
||||
minProximity: 1,
|
||||
ignorePlurals: true,
|
||||
advancedSyntax: true,
|
||||
attributeCriteriaComputedByMinProximity: true,
|
||||
removeWordsIfNoResults: 'allOptional'
|
||||
}
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<button type="button" class="VPNavBarAskAiButton">
|
||||
<span class="vpi-sparkles" aria-hidden="true"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.VPNavBarAskAiButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--vp-nav-height);
|
||||
padding: 8px 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.VPNavBarAskAiButton {
|
||||
height: auto;
|
||||
padding: 11.5px;
|
||||
transition: color 0.3s ease;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.VPNavBarAskAiButton:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,147 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ButtonTranslations } from '../../../../types/local-search'
|
||||
import { createSearchTranslate } from '../support/translation'
|
||||
|
||||
// button translations
|
||||
const defaultTranslations: { button: ButtonTranslations } = {
|
||||
button: {
|
||||
buttonText: 'Search',
|
||||
buttonAriaLabel: 'Search'
|
||||
}
|
||||
}
|
||||
|
||||
const translate = createSearchTranslate(defaultTranslations)
|
||||
defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="translate('button.buttonAriaLabel')"
|
||||
aria-keyshortcuts="/ control+k meta+k"
|
||||
class="DocSearch DocSearch-Button"
|
||||
>
|
||||
<span class="DocSearch-Button-Container">
|
||||
<span class="vpi-search DocSearch-Search-Icon"></span>
|
||||
<span class="DocSearch-Button-Placeholder">{{ translate('button.buttonText') }}</span>
|
||||
</span>
|
||||
<span class="DocSearch-Button-Keys">
|
||||
<kbd class="DocSearch-Button-Key"></kbd>
|
||||
<kbd class="DocSearch-Button-Key"></kbd>
|
||||
<button type="button" class="VPNavBarSearchButton">
|
||||
<span class="vpi-search" aria-hidden="true"></span>
|
||||
<span class="text">{{ text }}</span>
|
||||
<span class="keys" aria-hidden="true">
|
||||
<kbd class="key-cmd">⌘</kbd>
|
||||
<kbd class="key-ctrl">Ctrl</kbd>
|
||||
<kbd>K</kbd>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
[class*='DocSearch'] {
|
||||
--docsearch-actions-height: auto;
|
||||
--docsearch-actions-width: auto;
|
||||
--docsearch-background-color: var(--vp-c-bg-soft);
|
||||
--docsearch-container-background: var(--vp-backdrop-bg-color);
|
||||
--docsearch-focus-color: var(--vp-c-brand-1);
|
||||
--docsearch-footer-background: var(--vp-c-bg);
|
||||
--docsearch-highlight-color: var(--vp-c-brand-1);
|
||||
--docsearch-hit-background: var(--vp-c-default-soft);
|
||||
--docsearch-hit-color: var(--vp-c-text-1);
|
||||
--docsearch-hit-highlight-color: var(--vp-c-brand-soft);
|
||||
--docsearch-icon-color: var(--vp-c-text-2);
|
||||
--docsearch-key-background: transparent;
|
||||
--docsearch-key-color: var(--vp-c-text-2);
|
||||
--docsearch-modal-background: var(--vp-c-bg-soft);
|
||||
--docsearch-muted-color: var(--vp-c-text-2);
|
||||
--docsearch-primary-color: var(--vp-c-brand-1);
|
||||
--docsearch-searchbox-focus-background: transparent;
|
||||
--docsearch-secondary-text-color: var(--vp-c-text-2);
|
||||
--docsearch-soft-primary-color: var(--vp-c-brand-soft);
|
||||
--docsearch-subtle-color: var(--vp-c-divider);
|
||||
--docsearch-success-color: var(--vp-c-brand-soft);
|
||||
--docsearch-text-color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.dark [class*='DocSearch'] {
|
||||
--docsearch-modal-shadow: none;
|
||||
}
|
||||
|
||||
.DocSearch-Clear {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.DocSearch-Commands-Key {
|
||||
padding: 4px;
|
||||
border: 1px solid var(--docsearch-subtle-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.DocSearch-Hit a:focus-visible {
|
||||
outline: 2px solid var(--docsearch-focus-color);
|
||||
}
|
||||
|
||||
.DocSearch-Logo [class^='cls-'] {
|
||||
fill: currentColor;
|
||||
<style scoped>
|
||||
.VPNavBarSearchButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: var(--vp-nav-height);
|
||||
padding: 8px 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.DocSearch-SearchBar + .DocSearch-Footer {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.DocSearch-Title {
|
||||
font-size: revert;
|
||||
line-height: revert;
|
||||
}
|
||||
|
||||
.DocSearch-Button {
|
||||
--docsearch-muted-color: var(--docsearch-text-color);
|
||||
--docsearch-searchbox-background: transparent;
|
||||
width: auto;
|
||||
padding: 2px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
.text,
|
||||
.keys,
|
||||
:root.mac .key-ctrl,
|
||||
:root:not(.mac) .key-cmd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Search-Icon {
|
||||
color: inherit !important;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
kbd {
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.DocSearch-Button {
|
||||
--docsearch-muted-color: var(--docsearch-secondary-text-color);
|
||||
--docsearch-searchbox-background: var(--vp-c-bg-alt);
|
||||
.VPNavBarSearchButton {
|
||||
height: auto;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.DocSearch-Search-Icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.DocSearch-Button-Placeholder {
|
||||
.text {
|
||||
display: inline;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.DocSearch-Button-Keys {
|
||||
min-width: auto;
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
background-color: var(--docsearch-key-background);
|
||||
border: 1px solid var(--docsearch-subtle-color);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--docsearch-key-color);
|
||||
}
|
||||
|
||||
.DocSearch-Button-Keys > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Button-Keys:after {
|
||||
/*rtl:ignore*/
|
||||
direction: ltr;
|
||||
content: 'Ctrl K';
|
||||
}
|
||||
|
||||
.mac .DocSearch-Button-Keys:after {
|
||||
content: '\2318 K';
|
||||
.keys {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,120 @@
|
||||
@import '@docsearch/css/dist/style.css';
|
||||
@import '@docsearch/css/dist/sidepanel.css';
|
||||
|
||||
#vp-docsearch,
|
||||
#vp-docsearch-sidepanel,
|
||||
.DocSearch-SidepanelButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root:root {
|
||||
--docsearch-actions-height: auto;
|
||||
--docsearch-actions-width: auto;
|
||||
--docsearch-background-color: var(--vp-c-bg-soft);
|
||||
--docsearch-container-background: var(--vp-backdrop-bg-color);
|
||||
--docsearch-dropdown-menu-background: var(--vp-c-bg-elv);
|
||||
--docsearch-dropdown-menu-item-hover-background: var(--vp-c-default-soft);
|
||||
--docsearch-focus-color: var(--vp-c-brand-1);
|
||||
--docsearch-footer-background: var(--vp-c-bg-alt);
|
||||
--docsearch-highlight-color: var(--vp-c-brand-1);
|
||||
--docsearch-hit-background: var(--vp-c-bg);
|
||||
--docsearch-hit-color: var(--vp-c-text-1);
|
||||
--docsearch-hit-highlight-color: 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);
|
||||
--docsearch-muted-color: var(--vp-c-text-2);
|
||||
--docsearch-primary-color: var(--vp-c-brand-1);
|
||||
--docsearch-searchbox-background: var(--vp-c-bg-alt);
|
||||
--docsearch-searchbox-focus-background: transparent;
|
||||
--docsearch-secondary-text-color: var(--vp-c-text-2);
|
||||
--docsearch-sidepanel-accent-muted: var(--vp-c-text-3);
|
||||
--docsearch-sidepanel-text-base: var(--vp-c-text-1);
|
||||
--docsearch-soft-muted-color: var(--vp-c-default-soft);
|
||||
--docsearch-soft-primary-color: var(--vp-c-brand-soft);
|
||||
--docsearch-subtle-color: var(--vp-c-divider);
|
||||
--docsearch-success-color: var(--vp-c-brand-soft);
|
||||
--docsearch-text-color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--docsearch-modal-shadow: none;
|
||||
}
|
||||
|
||||
.DocSearch-AskAiScreen-RelatedSources-Item-Link {
|
||||
padding: 8px 12px 8px 10px;
|
||||
}
|
||||
|
||||
.DocSearch-AskAiScreen-RelatedSources-Item-Link svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.DocSearch-AskAiScreen-RelatedSources-Title {
|
||||
padding-bottom: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.DocSearch-Clear {
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.DocSearch-Commands-Key {
|
||||
padding: 4px;
|
||||
border: 1px solid var(--docsearch-subtle-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.DocSearch-Hit a:focus-visible {
|
||||
outline: 2px solid var(--docsearch-focus-color);
|
||||
}
|
||||
|
||||
.DocSearch-Logo [class^='cls-'] {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.DocSearch-Markdown-Content code {
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
|
||||
.DocSearch-Menu-content {
|
||||
margin-top: -4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--vp-shadow-2);
|
||||
}
|
||||
|
||||
.DocSearch-Menu-item {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.DocSearch-SearchBar + .DocSearch-Footer {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.DocSearch-Sidepanel-Prompt--form {
|
||||
border-color: var(--docsearch-subtle-color);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.DocSearch-Sidepanel-Prompt--submit {
|
||||
background-color: var(--docsearch-soft-primary-color);
|
||||
color: var(--docsearch-primary-color);
|
||||
}
|
||||
|
||||
.DocSearch-Sidepanel-Prompt--submit:hover {
|
||||
background-color: var(--vp-button-brand-hover-bg);
|
||||
color: var(--vp-button-brand-text);
|
||||
}
|
||||
|
||||
.DocSearch-Sidepanel-Prompt--submit:disabled,
|
||||
.DocSearch-Sidepanel-Prompt--submit[aria-disabled='true'] {
|
||||
background-color: var(--docsearch-soft-muted-color);
|
||||
color: var(--docsearch-muted-color);
|
||||
}
|
||||
|
||||
.DocSearch-Title {
|
||||
font-size: revert;
|
||||
line-height: revert;
|
||||
}
|
||||
@ -0,0 +1,253 @@
|
||||
import type { DefaultTheme } from 'vitepress/theme'
|
||||
import type { DocSearchAskAi } from '../../../../types/docsearch'
|
||||
import { isObject } from '../../shared'
|
||||
|
||||
export type FacetFilter = string | string[] | FacetFilter[]
|
||||
|
||||
export interface ValidatedCredentials {
|
||||
valid: boolean
|
||||
appId?: string
|
||||
apiKey?: string
|
||||
indexName?: string
|
||||
}
|
||||
|
||||
export type DocSearchMode = 'auto' | 'sidePanel' | 'hybrid' | 'modal'
|
||||
|
||||
export interface ResolvedMode {
|
||||
mode: DocSearchMode
|
||||
showKeywordSearch: boolean
|
||||
useSidePanel: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective mode based on config and available features.
|
||||
*
|
||||
* - 'auto': infer hybrid vs sidePanel-only from provided config
|
||||
* - 'sidePanel': force sidePanel-only even if keyword search is configured
|
||||
* - 'hybrid': force hybrid (error if keyword search is not configured)
|
||||
* - 'modal': force modal even if sidePanel is configured
|
||||
*/
|
||||
export function resolveMode(
|
||||
options: Pick<
|
||||
DefaultTheme.AlgoliaSearchOptions,
|
||||
'appId' | 'apiKey' | 'indexName' | 'askAi' | 'mode'
|
||||
>
|
||||
): ResolvedMode {
|
||||
const mode = options.mode ?? 'auto'
|
||||
const hasKeyword = hasKeywordSearch(options)
|
||||
const askAi = options.askAi
|
||||
const hasSidePanelConfig = Boolean(
|
||||
askAi && typeof askAi === 'object' && askAi.sidePanel
|
||||
)
|
||||
|
||||
switch (mode) {
|
||||
case 'sidePanel':
|
||||
// Force sidePanel-only - hide keyword search
|
||||
return {
|
||||
mode,
|
||||
showKeywordSearch: false,
|
||||
useSidePanel: true
|
||||
}
|
||||
|
||||
case 'hybrid':
|
||||
// Force hybrid - keyword search must be configured
|
||||
if (!hasKeyword) {
|
||||
console.error(
|
||||
'[vitepress] mode: "hybrid" requires keyword search credentials (appId, apiKey, indexName).'
|
||||
)
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
showKeywordSearch: hasKeyword,
|
||||
useSidePanel: true
|
||||
}
|
||||
|
||||
case 'modal':
|
||||
// Force modal - don't use sidepanel for askai, even if configured
|
||||
return {
|
||||
mode,
|
||||
showKeywordSearch: hasKeyword,
|
||||
useSidePanel: false
|
||||
}
|
||||
|
||||
case 'auto':
|
||||
default:
|
||||
// Auto-detect based on config
|
||||
return {
|
||||
mode: 'auto',
|
||||
showKeywordSearch: hasKeyword,
|
||||
useSidePanel: hasSidePanelConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasKeywordSearch(
|
||||
options: Pick<
|
||||
DefaultTheme.AlgoliaSearchOptions,
|
||||
'appId' | 'apiKey' | 'indexName'
|
||||
>
|
||||
): boolean {
|
||||
return Boolean(options.appId && options.apiKey && options.indexName)
|
||||
}
|
||||
|
||||
export function hasAskAi(
|
||||
askAi: DefaultTheme.AlgoliaSearchOptions['askAi']
|
||||
): boolean {
|
||||
if (!askAi) return false
|
||||
if (typeof askAi === 'string') return askAi.length > 0
|
||||
return Boolean(askAi.assistantId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing `lang:` filters and appends `lang:${lang}`.
|
||||
* Handles both flat arrays and nested arrays (for OR conditions).
|
||||
*/
|
||||
export function mergeLangFacetFilters(
|
||||
rawFacetFilters: FacetFilter | FacetFilter[] | undefined,
|
||||
lang: string
|
||||
): FacetFilter[] {
|
||||
const input = Array.isArray(rawFacetFilters)
|
||||
? rawFacetFilters
|
||||
: rawFacetFilters
|
||||
? [rawFacetFilters]
|
||||
: []
|
||||
|
||||
const filtered = input
|
||||
.map((filter) => {
|
||||
if (Array.isArray(filter)) {
|
||||
// Handle nested arrays (OR conditions)
|
||||
return filter.filter(
|
||||
(f) => typeof f === 'string' && !f.startsWith('lang:')
|
||||
)
|
||||
}
|
||||
return filter
|
||||
})
|
||||
.filter((filter) => {
|
||||
if (typeof filter === 'string') {
|
||||
return !filter.startsWith('lang:')
|
||||
}
|
||||
// Keep nested arrays with remaining filters
|
||||
return Array.isArray(filter) && filter.length > 0
|
||||
})
|
||||
|
||||
return [...filtered, `lang:${lang}`]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that required Algolia credentials are present.
|
||||
*/
|
||||
export function validateCredentials(
|
||||
options: Pick<
|
||||
DefaultTheme.AlgoliaSearchOptions,
|
||||
'appId' | 'apiKey' | 'indexName'
|
||||
>
|
||||
): ValidatedCredentials {
|
||||
const appId = options.appId
|
||||
const apiKey = options.apiKey
|
||||
const indexName = options.indexName
|
||||
|
||||
return {
|
||||
valid: Boolean(appId && apiKey && indexName),
|
||||
appId,
|
||||
apiKey,
|
||||
indexName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Ask AI configuration from various input formats.
|
||||
*/
|
||||
export function buildAskAiConfig(
|
||||
askAiProp: NonNullable<DefaultTheme.AlgoliaSearchOptions['askAi']>,
|
||||
options: DefaultTheme.AlgoliaSearchOptions,
|
||||
lang: string
|
||||
): DocSearchAskAi {
|
||||
const isAskAiString = typeof askAiProp === 'string'
|
||||
|
||||
const askAiSearchParameters =
|
||||
!isAskAiString && askAiProp.searchParameters
|
||||
? { ...askAiProp.searchParameters }
|
||||
: undefined
|
||||
|
||||
// If Ask AI defines its own facetFilters, merge lang filtering into those.
|
||||
// Otherwise, reuse the keyword search facetFilters so Ask AI follows the
|
||||
// same language filtering behavior by default.
|
||||
const askAiFacetFiltersSource =
|
||||
askAiSearchParameters?.facetFilters ??
|
||||
options.searchParameters?.facetFilters
|
||||
const askAiFacetFilters = mergeLangFacetFilters(
|
||||
askAiFacetFiltersSource as FacetFilter | FacetFilter[] | undefined,
|
||||
lang
|
||||
)
|
||||
|
||||
const mergedAskAiSearchParameters = {
|
||||
...askAiSearchParameters,
|
||||
facetFilters: askAiFacetFilters.length ? askAiFacetFilters : undefined
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {
|
||||
...(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
|
||||
}
|
||||
|
||||
// Keep `searchParameters` undefined unless it has at least one key.
|
||||
if (Object.values(mergedAskAiSearchParameters).some((v) => v != null)) {
|
||||
result.searchParameters = mergedAskAiSearchParameters
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves Algolia search options for the given language,
|
||||
* merging in locale-specific overrides and language facet filters.
|
||||
*/
|
||||
export function resolveOptionsForLanguage(
|
||||
options: DefaultTheme.AlgoliaSearchOptions,
|
||||
localeIndex: string,
|
||||
lang: string
|
||||
): DefaultTheme.AlgoliaSearchOptions {
|
||||
options = deepMerge(options, options.locales?.[localeIndex] || {})
|
||||
|
||||
const facetFilters = mergeLangFacetFilters(
|
||||
options.searchParameters?.facetFilters,
|
||||
lang
|
||||
)
|
||||
const askAi = options.askAi
|
||||
? buildAskAiConfig(options.askAi, options, lang)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...options,
|
||||
searchParameters: { ...options.searchParameters, facetFilters },
|
||||
askAi
|
||||
}
|
||||
}
|
||||
|
||||
function deepMerge<T>(target: T, source: Partial<T>): T {
|
||||
const result = { ...target } as any
|
||||
|
||||
for (const key in source) {
|
||||
const value = source[key]
|
||||
if (value === undefined) continue
|
||||
|
||||
// special case: replace entirely
|
||||
if (key === 'searchParameters') {
|
||||
result[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
// deep-merge only plain objects; arrays are replaced entirely
|
||||
if (isObject(value) && isObject(result[key])) {
|
||||
result[key] = deepMerge(result[key], value)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
delete result.locales
|
||||
return result
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { type ComputedRef, computed } from 'vue'
|
||||
|
||||
export function smartComputed<T>(
|
||||
getter: () => T,
|
||||
comparator = (oldValue: T, newValue: T) =>
|
||||
JSON.stringify(oldValue) === JSON.stringify(newValue)
|
||||
): ComputedRef<T> {
|
||||
return computed((oldValue) => {
|
||||
const newValue = getter()
|
||||
return oldValue === undefined || !comparator(oldValue, newValue)
|
||||
? newValue
|
||||
: oldValue
|
||||
})
|
||||
}
|
||||
@ -1,205 +1,63 @@
|
||||
export interface DocSearchProps {
|
||||
appId: string
|
||||
apiKey: string
|
||||
indexName: string
|
||||
placeholder?: string
|
||||
searchParameters?: SearchOptions
|
||||
disableUserPersonalization?: boolean
|
||||
initialQuery?: string
|
||||
insights?: boolean
|
||||
translations?: DocSearchTranslations
|
||||
askAi?: DocSearchAskAi | string
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
query?: string
|
||||
similarQuery?: string
|
||||
facetFilters?: string | string[]
|
||||
optionalFilters?: string | string[]
|
||||
numericFilters?: string | string[]
|
||||
tagFilters?: string | string[]
|
||||
sumOrFiltersScores?: boolean
|
||||
filters?: string
|
||||
page?: number
|
||||
hitsPerPage?: number
|
||||
offset?: number
|
||||
length?: number
|
||||
attributesToHighlight?: string[]
|
||||
attributesToSnippet?: string[]
|
||||
attributesToRetrieve?: string[]
|
||||
highlightPreTag?: string
|
||||
highlightPostTag?: string
|
||||
snippetEllipsisText?: string
|
||||
restrictHighlightAndSnippetArrays?: boolean
|
||||
facets?: string[]
|
||||
maxValuesPerFacet?: number
|
||||
facetingAfterDistinct?: boolean
|
||||
minWordSizefor1Typo?: number
|
||||
minWordSizefor2Typos?: number
|
||||
allowTyposOnNumericTokens?: boolean
|
||||
disableTypoToleranceOnAttributes?: string[]
|
||||
queryType?: 'prefixLast' | 'prefixAll' | 'prefixNone'
|
||||
removeWordsIfNoResults?: 'none' | 'lastWords' | 'firstWords' | 'allOptional'
|
||||
advancedSyntax?: boolean
|
||||
advancedSyntaxFeatures?: ('exactPhrase' | 'excludeWords')[]
|
||||
optionalWords?: string | string[]
|
||||
disableExactOnAttributes?: string[]
|
||||
exactOnSingleWordQuery?: 'attribute' | 'none' | 'word'
|
||||
alternativesAsExact?: (
|
||||
| 'ignorePlurals'
|
||||
| 'singleWordSynonym'
|
||||
| 'multiWordsSynonym'
|
||||
)[]
|
||||
enableRules?: boolean
|
||||
ruleContexts?: string[]
|
||||
distinct?: boolean | number
|
||||
analytics?: boolean
|
||||
analyticsTags?: string[]
|
||||
synonyms?: boolean
|
||||
replaceSynonymsInHighlight?: boolean
|
||||
minProximity?: number
|
||||
responseFields?: string[]
|
||||
maxFacetHits?: number
|
||||
percentileComputation?: boolean
|
||||
clickAnalytics?: boolean
|
||||
personalizationImpact?: number
|
||||
enablePersonalization?: boolean
|
||||
restrictSearchableAttributes?: string[]
|
||||
sortFacetValuesBy?: 'count' | 'alpha'
|
||||
typoTolerance?: boolean | 'min' | 'strict'
|
||||
aroundLatLng?: string
|
||||
aroundLatLngViaIP?: boolean
|
||||
aroundRadius?: number | 'all'
|
||||
aroundPrecision?: number | { from: number; value: number }[]
|
||||
minimumAroundRadius?: number
|
||||
insideBoundingBox?: number[][]
|
||||
insidePolygon?: number[][]
|
||||
ignorePlurals?: boolean | string[]
|
||||
removeStopWords?: boolean | string[]
|
||||
naturalLanguages?: string[]
|
||||
getRankingInfo?: boolean
|
||||
userToken?: string
|
||||
enableABTest?: boolean
|
||||
decompoundQuery?: boolean
|
||||
relevancyStrictness?: number
|
||||
}
|
||||
|
||||
export interface DocSearchTranslations {
|
||||
button?: ButtonTranslations
|
||||
modal?: ModalTranslations
|
||||
}
|
||||
|
||||
export interface ButtonTranslations {
|
||||
buttonText?: string
|
||||
buttonAriaLabel?: string
|
||||
}
|
||||
|
||||
export interface ModalTranslations extends ScreenStateTranslations {
|
||||
searchBox?: SearchBoxTranslations
|
||||
footer?: FooterTranslations
|
||||
}
|
||||
|
||||
export interface ScreenStateTranslations {
|
||||
errorScreen?: ErrorScreenTranslations
|
||||
startScreen?: StartScreenTranslations
|
||||
resultsScreen?: ResultsScreenTranslations
|
||||
noResultsScreen?: NoResultsScreenTranslations
|
||||
askAiScreen?: AskAiScreenTranslations
|
||||
}
|
||||
|
||||
export interface SearchBoxTranslations {
|
||||
clearButtonTitle?: string
|
||||
clearButtonAriaLabel?: string
|
||||
closeButtonText?: string
|
||||
closeButtonAriaLabel?: string
|
||||
placeholderText?: string
|
||||
placeholderTextAskAi?: string
|
||||
searchInputLabel?: string
|
||||
placeholderTextAskAiStreaming?: string
|
||||
backToKeywordSearchButtonText?: string
|
||||
backToKeywordSearchButtonAriaLabel?: string
|
||||
}
|
||||
|
||||
export interface FooterTranslations {
|
||||
selectText?: string
|
||||
submitQuestionText?: string
|
||||
selectKeyAriaLabel?: string
|
||||
navigateText?: string
|
||||
navigateUpKeyAriaLabel?: string
|
||||
backToSearchText?: string
|
||||
navigateDownKeyAriaLabel?: string
|
||||
closeText?: string
|
||||
closeKeyAriaLabel?: string
|
||||
poweredByText?: string
|
||||
}
|
||||
|
||||
export interface ErrorScreenTranslations {
|
||||
titleText?: string
|
||||
helpText?: string
|
||||
}
|
||||
|
||||
export interface StartScreenTranslations {
|
||||
recentSearchesTitle?: string
|
||||
noRecentSearchesText?: string
|
||||
saveRecentSearchButtonTitle?: string
|
||||
removeRecentSearchButtonTitle?: string
|
||||
favoriteSearchesTitle?: string
|
||||
removeFavoriteSearchButtonTitle?: string
|
||||
recentConversationsTitle?: string
|
||||
removeRecentConversationButtonTitle?: string
|
||||
}
|
||||
|
||||
export interface ResultsScreenTranslations {
|
||||
askAiPlaceholder?: string
|
||||
}
|
||||
|
||||
export interface NoResultsScreenTranslations {
|
||||
noResultsText?: string
|
||||
suggestedQueryText?: string
|
||||
reportMissingResultsText?: string
|
||||
reportMissingResultsLinkText?: string
|
||||
}
|
||||
|
||||
export interface AskAiScreenTranslations {
|
||||
disclaimerText?: string
|
||||
relatedSourcesText?: string
|
||||
thinkingText?: string
|
||||
copyButtonText?: string
|
||||
copyButtonCopiedText?: string
|
||||
copyButtonTitle?: string
|
||||
likeButtonTitle?: string
|
||||
dislikeButtonTitle?: string
|
||||
thanksForFeedbackText?: string
|
||||
preToolCallText?: string
|
||||
duringToolCallText?: string
|
||||
afterToolCallText?: string
|
||||
aggregatedToolCallText?: string
|
||||
}
|
||||
|
||||
export interface DocSearchAskAi {
|
||||
import { type DocSearchProps as DocSearchPropsJS } from '@docsearch/js'
|
||||
import { type SidepanelProps as SidepanelPropsBase } from '@docsearch/sidepanel-js'
|
||||
|
||||
export type DocSearchProps = Partial<
|
||||
Pick<
|
||||
DocSearchPropsJS,
|
||||
| 'appId'
|
||||
| 'apiKey'
|
||||
| 'placeholder'
|
||||
| 'maxResultsPerGroup'
|
||||
| 'disableUserPersonalization'
|
||||
| 'initialQuery'
|
||||
| 'translations'
|
||||
| 'recentSearchesLimit'
|
||||
| 'recentSearchesWithFavoritesLimit'
|
||||
>
|
||||
> & {
|
||||
/**
|
||||
* The index name to use for the ask AI feature. Your assistant will search this index for relevant documents.
|
||||
* If not provided, the index name will be used.
|
||||
* Name of the algolia index to query.
|
||||
*/
|
||||
indexName?: string
|
||||
/**
|
||||
* The API key to use for the ask AI feature. Your assistant will use this API key to search the index.
|
||||
* If not provided, the API key will be used.
|
||||
* Additional algolia search parameters to merge into each query.
|
||||
*/
|
||||
apiKey?: string
|
||||
searchParameters?: DocSearchPropsJS['searchParameters']
|
||||
/**
|
||||
* The app ID to use for the ask AI feature. Your assistant will use this app ID to search the index.
|
||||
* If not provided, the app ID will be used.
|
||||
* Insights client integration options to send analytics events.
|
||||
*/
|
||||
appId?: string
|
||||
insights?: boolean
|
||||
/**
|
||||
* The assistant ID to use for the ask AI feature.
|
||||
* Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object.
|
||||
*/
|
||||
assistantId: string | null
|
||||
askAi?: DocSearchAskAi | string
|
||||
/**
|
||||
* Ask AI side panel integration mode.
|
||||
*
|
||||
* - 'auto': infer hybrid vs sidePanel-only from provided config
|
||||
* - 'sidePanel': force sidePanel-only even if keyword search is configured
|
||||
* - 'hybrid': force hybrid (error if keyword search is not configured)
|
||||
* - 'modal': force modal even if sidePanel is configured (ask ai in modal stays in modal)
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
mode?: 'auto' | 'sidePanel' | 'hybrid' | 'modal'
|
||||
}
|
||||
|
||||
export type DocSearchAskAi = Partial<
|
||||
Exclude<DocSearchPropsJS['askAi'], string | undefined>
|
||||
> & {
|
||||
/**
|
||||
* Ask AI side panel configuration.
|
||||
*/
|
||||
sidePanel?: boolean | SidepanelProps
|
||||
}
|
||||
|
||||
export type SidepanelProps = Partial<
|
||||
Pick<SidepanelPropsBase, 'button' | 'keyboardShortcuts'>
|
||||
> & {
|
||||
/**
|
||||
* The search parameters to use for the ask AI feature.
|
||||
* Props specific to the Sidepanel panel.
|
||||
*/
|
||||
searchParameters?: {
|
||||
facetFilters?: SearchOptions['facetFilters']
|
||||
}
|
||||
panel?: Omit<NonNullable<SidepanelPropsBase['panel']>, 'portalContainer'>
|
||||
}
|
||||
|
||||
Loading…
Reference in new issue