mirror of https://github.com/vuejs/vitepress
commit
f4d448fc61
@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue