Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ const versionFiles = computed(() => files.value?.filter((file) => {

provide('navigation', versionNavigation)

const { groups } = useContentSearchWorker(versionFiles, versionNavigation, searchTerm)

const appear = ref(false)
const appeared = ref(false)

Expand All @@ -94,16 +96,9 @@ onMounted(() => {
<ClientOnly>
<LazyUContentSearch
v-model:search-term="searchTerm"
:files="versionFiles"
:navigation="versionNavigation"
:groups="searchGroups"
:groups="[...groups, ...searchGroups]"
:links="searchLinks"
:fuse="{
resultLimit: 42,
fuseOptions: {
threshold: 0
}
}"
:fuse="{ resultLimit: 42 }"
/>
</ClientOnly>
</UApp>
Expand Down
177 changes: 177 additions & 0 deletions app/composables/useContentSearchWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { Ref, ComputedRef } from 'vue'
import type { ContentNavigationItem } from '@nuxt/content'
import type { ContentSearchFile, ContentSearchItem } from '@nuxt/ui'
import { useWebWorkerFn, refDebounced } from '@vueuse/core'

export interface UseContentSearchWorkerOptions {
/**
* Debounce delay in milliseconds (lower = faster, but more worker calls)
* @defaultValue 50
*/
debounce?: number
/**
* Fuse.js threshold
* @defaultValue 0.1
*/
threshold?: number
/**
* Maximum number of results to return per group (to optimize data transfer from worker)
* @defaultValue 42
*/
resultLimit?: number
/**
* Web worker timeout in milliseconds
* @defaultValue 10000
*/
timeout?: number
/**
* Keys to search in Fuse
* @defaultValue ['label', 'suffix']
*/
fuseKeys?: string[]
}

export function useContentSearchWorker(
files: Ref<ContentSearchFile[]> | ComputedRef<ContentSearchFile[]>,
navigation: Ref<ContentNavigationItem[]> | ComputedRef<ContentNavigationItem[]>,
searchTerm: Ref<string>,
options: UseContentSearchWorkerOptions = {}
) {
const {
debounce = 50,
threshold = 0.1,
resultLimit = 42,
timeout = 10000,
fuseKeys = ['label', 'suffix']
} = options

const { mapNavigationItems, postFilter } = useContentSearch()

// Map navigation groups (matching Nuxt UI ContentSearch pattern)
const navigationGroups = computed(() => {
if (!navigation.value?.length || !files.value?.length) {
return []
}

return navigation.value?.[0]?.children?.map(group => ({
id: group.path,
label: group.title,
items: mapNavigationItems(group.children || [], files.value)
}))
})

// Create web worker function that handles Fuse search for each group
const { workerFn, workerStatus, workerTerminate } = useWebWorkerFn(
(
groups: Array<{ id: string, label?: string, items: ContentSearchItem[] }>,
query: string,
threshold: number,
resultLimit: number,
fuseKeys: string[]
) => {
// Import Fuse in worker context
importScripts('https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js')

// If no query, return groups as-is (postFilter will handle level filtering)
if (!query || query.trim() === '') {
return groups.map(group => ({
...group,
items: group.items
}))
}

// Flatten all items from all groups for a single Fuse search
const allItems = groups.flatMap(group => group.items)

// Create one Fuse instance with all items (more efficient than per-group)
// @ts-expect-error - Fuse is loaded via importScripts
const fuse = new Fuse(allItems, {
threshold,
includeScore: true,
includeMatches: true,
ignoreLocation: true,
keys: fuseKeys
})

// Search once across all items
const searchResults = fuse.search(query, { limit: resultLimit })
const resultsWithMetadata = searchResults.map((result: any) => ({
...result.item,
matches: result.matches,
score: result.score
}))

// Group results back by their original group
return groups.map((group) => {
return {
...group,
items: resultsWithMetadata.filter((item: any) => item.to?.startsWith(group.id))
}
})
},
{
timeout,
dependencies: [
'https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js'
]
}
)

const debouncedSearchTerm = refDebounced(searchTerm, debounce)
const searchedGroups = ref<Array<{ id: string, label?: string, items: ContentSearchItem[] }>>([])
const isLoading = computed(() => workerStatus.value === 'RUNNING')

// Watch for changes and trigger search automatically
watch(
[navigationGroups, debouncedSearchTerm],
async ([groups, query]) => {
if (!groups?.length) {
searchedGroups.value = []
return
}

// Skip if worker is already running (prevents the "only one instance" error)
if (workerStatus.value === 'RUNNING') {
return
}

try {
const results = await workerFn(
groups as any,
query,
threshold,
resultLimit,
fuseKeys
)

searchedGroups.value = results
} catch (error) {
console.error('[useContentSearchWorker] Search failed:', error)
searchedGroups.value = []
}
},
{ immediate: true }
)

// Create final groups with ignoreFilter and postFilter for CommandPalette
const groups = computed(() => {
return searchedGroups.value.map((group: any) => ({
id: group.id,
label: group.label,
items: group.items,
ignoreFilter: true,
postFilter
})) as any
})

// Cleanup on unmount
onBeforeUnmount(() => {
workerTerminate()
})

return {
groups,
isLoading,
workerStatus
}
}
16 changes: 6 additions & 10 deletions app/composables/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,22 +173,18 @@ const _useNavigation = () => {
const { modules } = useModules()
const { providers } = useHostingProviders()

const searchLinks = computed(() => [{
const searchLinks = computed(() => [!searchTerm.value && {
label: 'Ask AI',
icon: 'i-lucide-wand',
to: 'javascript:void(0);',
onSelect: () => nuxtApp.$kapa?.openModal()
}, ...headerLinks.value.map((link) => {
}, ...headerLinks.value.flatMap((link): any => {
// Remove `/docs` and `/enterprise` links from command palette
if (link.search === false) {
return {
label: link.label,
icon: link.icon,
children: link.children
}
return link.children
}
return link
}).filter(Boolean), {
return [link]
}), {
label: 'Team',
icon: 'i-lucide-users',
to: '/team'
Expand All @@ -200,7 +196,7 @@ const _useNavigation = () => {
label: 'Newsletter',
icon: 'i-lucide-mail',
to: '/newsletter'
}])
}].filter(Boolean))

const modulesItems = computed(() => modules.value.map(module => ({
id: `module-${module.name}`,
Expand Down
13 changes: 4 additions & 9 deletions app/error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const versionFiles = computed(() => files.value?.filter((file) => {
}) ?? [])

provide('navigation', versionNavigation)

const { groups } = useContentSearchWorker(versionFiles, versionNavigation, searchTerm)
</script>

<template>
Expand All @@ -63,16 +65,9 @@ provide('navigation', versionNavigation)
<ClientOnly>
<LazyUContentSearch
v-model:search-term="searchTerm"
:files="versionFiles"
:navigation="versionNavigation"
:groups="searchGroups"
:groups="[...groups, ...searchGroups]"
:links="searchLinks"
:fuse="{
resultLimit: 42,
fuseOptions: {
threshold: 0
}
}"
:fuse="{ resultLimit: 42 }"
/>
</ClientOnly>
</div>
Expand Down
10 changes: 3 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@nuxt/content": "^3.7.1",
"@nuxt/image": "https://pkg.pr.new/@nuxt/image@62998ab",
"@nuxt/scripts": "^0.13.0",
"@nuxt/ui": "^4.1.0",
"@nuxt/ui": "https://pkg.pr.new/@nuxt/ui@afd73db",
"@nuxthub/core": "0.9.0",
"@nuxtjs/html-validator": "^2.1.0",
"@nuxtjs/mdc": "^0.18.0",
Expand Down Expand Up @@ -69,8 +69,7 @@
"wrangler": "^4.45.0"
},
"resolutions": {
"chokidar": "3.6.0",
"unimport": "4.1.1"
"chokidar": "3.6.0"
},
"pnpm": {
"onlyBuiltDependencies": [
Expand All @@ -83,9 +82,6 @@
"@parcel/watcher",
"puppeteer",
"vue-demi"
],
"overrides": {
"vite": "npm:rolldown-vite@latest"
}
]
}
}
Loading