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
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
28 changes: 28 additions & 0 deletions app/components/AppSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { NavigationItem } from 'comark-content'

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

// Setup runs on the server too (the `ClientOnly` is inside, around the palette), so `useSearch`'s
// head-sha `useAsyncData` still resolves during SSR and ships in the payload.
const { search, status, warmup } = useSearch()

const open = useContentSearch().open
watch(open, (isOpen) => {
if (isOpen) warmup()
})
</script>

<template>
<ClientOnly>
<LazyUContentSearch
:search="search"
:search-status="status"
:navigation="navigation"
: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
92 changes: 92 additions & 0 deletions app/composables/useSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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 }>()

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
return
}
const settle = pending.get(message.id)
if (!settle) return
pending.delete(message.id)
if (message.type === 'result') settle.resolve(message.results)
else 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'

await request({ type: 'warmup', apiBase, origin: location.origin })
} 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>
31 changes: 31 additions & 0 deletions app/types/search-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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
}
| {
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.

89 changes: 89 additions & 0 deletions app/workers/search.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Search worker: owns the browser-standalone `comark-content` instance (sqlite-wasm FTS5)
* hydrated from the per-commit snapshot artifacts.
*
* It lives off the main thread because sqlite-wasm's `oo1` binding is synchronous and the FTS
* plugin indexes one row per section — on the main thread the whole hydration collapses into a
* single long task (the `await`s between inserts only yield to the microtask queue, which drains
* before the browser can paint or handle input).
*
* Not a Nuxt-scanned directory, so nothing here is auto-imported.
*/
import { comarkContent } from 'comark-content'
import sqliteWasm from 'comark-content/database/sqlite-wasm'
import sqliteFullTextSearch from 'comark-content/plugins/sqlite-full-text-search'
import { ofetch } from 'ofetch'
import type { CacheArtifact, ComarkContent } from 'comark-content'
import type { SqliteFullTextSearchMethods } from 'comark-content/plugins/sqlite-full-text-search'
import type { SearchWorkerRequest, SearchWorkerResponse } from '../types/search-worker'

type SearchInstance = ComarkContent & SqliteFullTextSearchMethods
type SearchStatus = 'idle' | 'loading' | 'ready' | 'error'

let instance: SearchInstance | undefined
let status: SearchStatus = 'idle'

function post(message: SearchWorkerResponse): void {
self.postMessage(message)
}

/** Every transition is mirrored to the main thread; the worker owns the hydration lifecycle. */
function setStatus(value: Exclude<SearchStatus, 'idle'>): void {
status = value
post({ type: 'status', value })
}

/**
* Loads the database. No-op once loading or ready; retries after a failure.
*
* The guard lives here rather than in `useSearch` because the main thread's copy of `status` lags
* a message behind, so two warmups fired in the same tick would both get through it.
*/
async function loadDatabase(apiBase: string, origin: string): Promise<void> {
if (status === 'loading' || status === 'ready') return

setStatus('loading')
try {
const fetchArtifact = (path: string) => ofetch<CacheArtifact>(new URL(path, origin).href)

const content = comarkContent({
cache: {
loadManifest: () => fetchArtifact(`${apiBase}/manifest.json`),
loadSnapshot: (source: string) => fetchArtifact(`${apiBase}/snapshot/${source}.json`),
},
plugins: [sqliteFullTextSearch({ database: sqliteWasm() })],
})

await content.init()
await content.search(['content'], '') // pulls the snapshot in and builds the FTS index

instance = content
setStatus('ready')
} catch (error) {
setStatus('error')
throw error
}
}

self.onmessage = async (event: MessageEvent<SearchWorkerRequest>) => {
const request = event.data
try {
if (request.type === 'warmup') {
await loadDatabase(request.apiBase, request.origin)
post({ type: 'result', id: request.id, results: [] })
return
}

const results = instance
? await instance.search(['content'], request.query, {
limit: 25,
snippet: { columns: ['content'] },
...request.opts,
})
: []
post({ type: 'result', id: request.id, results })
} catch (error) {
// Serialized rather than cloned: plugin errors can carry non-transferable properties.
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) })
}
}
11 changes: 6 additions & 5 deletions modules/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,18 @@ export default defineNuxtModule<ComarkDocsOptions>({
'/logos': { isr },
// Previews are served live (SSR) off Runtime Cache; `/blob/**` is immutable commit HTML.
'/tree/**': { isr, robots: 'noindex, nofollow' },
'/blob/**': { isr: true, robots: 'noindex, nofollow' },
'/blob/**': { isr: true, robots: 'noindex, nofollow' }, // Immutable since SHA-pinned
// Raw markdown mirrors of every page, for agents.
'/raw/**': { isr, robots: 'noindex' },
// Global content indexes, purged by the push webhook on content changes.
'/llms.txt': { isr },
'/llms-full.txt': { isr },
'/rss.xml': { isr },
// Fetched on every page hydration (see app.vue) and parses every doc body, so cache it.
'/api/content/blob/*/search-sections': { isr: true },
'/api/content/tree/*/search-sections': { isr },
'/api/content/search-sections': { isr },
// Per-commit artifacts hydrating the client-side search database (see `useSearch`)
'/api/content/blob/*/manifest.json': { isr: true }, // Immutable since SHA-pinned
'/api/content/blob/*/snapshot/*': { isr: true }, // Immutable since SHA-pinned
'/api/content/tree/*/manifest.json': { isr },
'/api/content/tree/*/snapshot/*': { isr },
'/api/code-explorer/**': { isr },
}

Expand Down
Loading
Loading