Skip to content
Open
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
27 changes: 2 additions & 25 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script setup lang="ts">
import type { NavigationItem } from 'comark-content'
import type { SearchSection } from './utils/search-sections'

const { seo, docs } = useAppConfig()

Expand All @@ -9,25 +8,8 @@ const content = useDocsContent()
const { data: navigation } = await useAsyncData('navigation', () => content.value.client.navigation(), {
watch: [() => content.value.base],
})
const {
data: files,
status,
execute: loadSearchSections,
} = useLazyAsyncData('search-sections', () => content.value.client.searchSections(), {
server: false,
watch: [() => content.value.base],
immediate: false,
})

onNuxtReady(() => loadSearchSections())

const navTree = computed<NavigationItem[]>(() => prefixNavigation(navigation.value ?? [], content.value.base))
const searchFiles = computed<SearchSection[]>(() =>
(files.value ?? []).map((section) => {
const [path, hash] = section.id.split('#')
return { ...section, id: prefixLink(path!, content.value.base) + (hash ? `#${hash}` : '') }
})
)

useHead({
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
Expand Down Expand Up @@ -89,14 +71,9 @@ defineShortcuts({

<AppFooter />

<AppSearch :navigation="navTree" />

<ClientOnly>
<LazyUContentSearch
:files="searchFiles"
:navigation="navTree"
:transition="false"
:loading="status !== 'success'"
:placeholder="status !== 'success' ? 'Loading...' : undefined"
/>
<LazyVersionHistory />
<LazyAssistantChat v-if="assistant?.enabled && assistantMounted" />
</ClientOnly>
Expand Down
67 changes: 67 additions & 0 deletions app/components/AppSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { NavigationItem } from 'comark-content'

const props = defineProps<{
navigation: NavigationItem[]
}>()

const { search, status } = useSearch()

const appConfig = useAppConfig()

interface PageItem {
label: string
prefix?: string
suffix?: string
to: string
icon: string
}

/** Leaf pages, flattened; ancestor titles become the `Section > Page` prefix the palette renders. */
function pageItems(items: NavigationItem[], ancestors: string[] = []): PageItem[] {
return items.flatMap((item) => {
if (item.children?.length) return pageItems(item.children, [...ancestors, item.title])
if (!item.path || item.page === false) return []
return [{
label: item.title,
prefix: ancestors.length ? `${ancestors.join(' > ')} >` : undefined,
suffix: item.description,
to: item.path,
icon: (item.icon as string | undefined) || appConfig.ui.icons.file,
}]
})
}

function browseOnly(query: string, items?: PageItem[]): PageItem[] {
return query ? [] : (items ?? [])
}

// One group per top-level section, mirroring how `UContentSearch` groups navigation when it can.
const groups = computed(() => {
if (props.navigation.some((item) => item.children?.length)) {
return props.navigation
.filter((section) => section.children?.length)
.map((section) => ({
id: section.path,
label: section.title,
items: pageItems(section.children ?? []),
postFilter: browseOnly,
}))
.filter((group) => group.items.length > 0)
}
return [{ id: 'docs', items: pageItems(props.navigation), postFilter: browseOnly }]
})
</script>

<template>
<ClientOnly>
<LazyUContentSearch
:search="search"
:search-status="status"
:navigation="navigation"
:groups="groups"
:transition="false"
:loading="status === 'loading'"
/>
</ClientOnly>
</template>
3 changes: 0 additions & 3 deletions app/composables/useDocsContent.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { createContentClient } from 'comark-content/client'
import { searchSectionsClient } from '../utils/search-sections'
import type { ContentMode } from '../types/content'
import { withLeadingSlash } from 'ufo'

export const prodContent = createContentClient({
basePath: '/api/content',
fetch: $fetch,
plugins: [searchSectionsClient()],
})

const clients = new Map<string, typeof prodContent>()
Expand All @@ -17,7 +15,6 @@ function getClient(basePath: string) {
client = createContentClient({
basePath,
fetch: $fetch,
plugins: [searchSectionsClient()],
})
clients.set(basePath, client)
}
Expand Down
107 changes: 107 additions & 0 deletions app/composables/useSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { SearchOptions, SearchResult } from 'comark-content'
import type { SearchWorkerPayload, SearchWorkerResponse } from '../types/search-worker'

type SearchStatus = 'idle' | 'loading' | 'ready' | 'error'

const status = ref<SearchStatus>('idle')

let worker: Worker | undefined
let nextId = 0
const pending = new Map<number, { resolve: (results: SearchResult[]) => void, reject: (error: Error) => void }>()

/**
* Hydration logging switch: `?debug=search`
*/
function searchDebug(): boolean {
if (!import.meta.client) return false
return new URLSearchParams(location.search).get('debug') === 'search'
}

function getWorker(): Worker {
if (worker) return worker

worker = new Worker(new URL('../workers/search.worker.ts', import.meta.url), { type: 'module' })

worker.onmessage = (event: MessageEvent<SearchWorkerResponse>) => {
const message = event.data
if (message.type === 'status') {
status.value = message.value
if (searchDebug()) console.info(`[search] status -> ${message.value}`)
return
}
const settle = pending.get(message.id)
if (!settle) return
pending.delete(message.id)
if (message.type === 'result') settle.resolve(message.results)
else {
if (searchDebug()) console.error(`[search] request ${message.id} failed:`, message.message)
settle.reject(new Error(message.message))
}
}

worker.onerror = () => {
status.value = 'error'
for (const { reject } of pending.values()) reject(new Error('[search] the search worker failed to load'))
pending.clear()
}

return worker
}

function request(message: SearchWorkerPayload): Promise<SearchResult[]> {
const id = ++nextId
return new Promise<SearchResult[]>((resolve, reject) => {
pending.set(id, { resolve, reject })
try {
getWorker().postMessage({ ...message, id })
} catch (error) {
pending.delete(id)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}

/**
* Client-side full-text search over production content (sqlite-wasm FTS5) hydrated from the
* per-commit snapshot artifacts.
*/
export function useSearch() {
const { data: headSha } = useAsyncData(
'content-head-sha',
() => $fetch<{ sha: string | null }>('/api/content/head').then(({ sha }) => sha),
{ default: () => null }
)

/**
* Load the database ahead of the first keystroke. No-op once loading or ready; retries after a
* failure — the worker holds that guard, since this side's `status` lags a message behind.
*/
async function warmup(): Promise<void> {
try {
if (!headSha.value && !import.meta.dev) {
throw new Error('[search] /api/content/head returned no commit pin')
}

// Immutable per-commit artifacts, CDN-cached forever. Only unpinned in dev, per the guard above.
const apiBase = headSha.value ? `/api/content/blob/${headSha.value}` : '/api/content'

const debug = searchDebug()
if (debug) console.info(`[search] warmup from ${apiBase} (head ${headSha.value ?? 'unpinned'})`)

await request({ type: 'warmup', apiBase, origin: location.origin, debug })
} catch (error) {
status.value = 'error'
console.error('[search] could not load the search database', error)
}
}

if (import.meta.client) {
onNuxtReady(warmup)
}

async function search(query: string, opts?: SearchOptions): Promise<SearchResult[]> {
return request({ type: 'search', query, opts })
}

return { search, status: readonly(status), warmup }
}
10 changes: 1 addition & 9 deletions app/error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ useSeoMeta({
})

const { data: navigation } = await useAsyncData('navigation', () => prodContent.navigation())
const { data: files } = useLazyAsyncData('search-sections', () => prodContent.searchSections(), {
server: false,
})

provide('navigation', navigation)
</script>
Expand All @@ -32,11 +29,6 @@ provide('navigation', navigation)

<AppFooter />

<ClientOnly>
<LazyUContentSearch
:files="files ?? []"
:navigation="navigation ?? []"
/>
</ClientOnly>
<AppSearch :navigation="navigation ?? []" />
</UApp>
</template>
33 changes: 33 additions & 0 deletions app/types/search-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { SearchOptions, SearchResult } from 'comark-content'

/**
* Protocol between `useSearch` and `app/workers/search.worker.ts`.
*
* Every request carries an `id` and gets exactly one `result`/`error` reply — `warmup` answers
* with an empty array — so the caller can drain its pending map uniformly.
*/
export type SearchWorkerPayload =
| {
type: 'warmup'
apiBase: string
origin: string
/** Turns on the worker's hydration logging. Resolved on the main thread, which owns `?debug=search`. */
debug?: boolean
}
| {
type: 'search',
query: string,
opts?: SearchOptions
}

/**
* Intersected rather than spread into each member: `Omit<Union, 'id'>` would collapse to the
* union's common keys, dropping every payload field.
*/
export type SearchWorkerRequest = SearchWorkerPayload & { id: number }

/** `status` arrives unsolicited: the worker owns the hydration lifecycle, the caller mirrors it. */
export type SearchWorkerResponse =
| { type: 'status', value: 'loading' | 'ready' | 'error' }
| { type: 'result', id: number, results: SearchResult[] }
| { type: 'error', id: number, message: string }
23 changes: 0 additions & 23 deletions app/utils/search-sections.ts

This file was deleted.

Loading
Loading