diff --git a/app/app.vue b/app/app.vue
index 62ef764..9347ad6 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -1,6 +1,5 @@
+
+
+
+
+
+
diff --git a/app/composables/useDocsContent.ts b/app/composables/useDocsContent.ts
index 17ee0ca..3083ea3 100644
--- a/app/composables/useDocsContent.ts
+++ b/app/composables/useDocsContent.ts
@@ -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()
@@ -17,7 +15,6 @@ function getClient(basePath: string) {
client = createContentClient({
basePath,
fetch: $fetch,
- plugins: [searchSectionsClient()],
})
clients.set(basePath, client)
}
diff --git a/app/composables/useSearch.ts b/app/composables/useSearch.ts
new file mode 100644
index 0000000..aaac194
--- /dev/null
+++ b/app/composables/useSearch.ts
@@ -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('idle')
+
+let worker: Worker | undefined
+let nextId = 0
+const pending = new Map 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) => {
+ 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 {
+ const id = ++nextId
+ return new Promise((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 {
+ 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 {
+ return request({ type: 'search', query, opts })
+ }
+
+ return { search, status: readonly(status), warmup }
+}
diff --git a/app/error.vue b/app/error.vue
index 23b1c7c..b9e4ea8 100644
--- a/app/error.vue
+++ b/app/error.vue
@@ -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)
@@ -32,11 +29,6 @@ provide('navigation', navigation)
-
-
-
+
diff --git a/app/types/search-worker.ts b/app/types/search-worker.ts
new file mode 100644
index 0000000..fd1038b
--- /dev/null
+++ b/app/types/search-worker.ts
@@ -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` 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 }
diff --git a/app/utils/search-sections.ts b/app/utils/search-sections.ts
deleted file mode 100644
index 4aa65d9..0000000
--- a/app/utils/search-sections.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { defineContentClientPlugin } from 'comark-content/client'
-import { joinURL } from 'ufo'
-
-/** One search entry per document heading — consumed by `UContentSearch`. */
-export interface SearchSection {
- id: string
- title: string
- titles: string[]
- level: number
- content: string
-}
-
-interface SearchSectionsClientMethods {
- searchSections(): Promise
-}
-
-/** Client half of the `search-sections` serve handler (`server/utils/content.ts`); adds `content.searchSections()`. */
-export const searchSectionsClient = defineContentClientPlugin, SearchSectionsClientMethods>(() => ({
- name: 'search-sections',
- setup: ({ options }) => ({
- searchSections: () => options.fetch(joinURL(options.baseURL, options.basePath, 'search-sections')),
- }),
-}))
diff --git a/app/workers/search-logger.ts b/app/workers/search-logger.ts
new file mode 100644
index 0000000..66790d6
--- /dev/null
+++ b/app/workers/search-logger.ts
@@ -0,0 +1,72 @@
+/**
+ * Logging for the search worker: the `?debug=search` switch, the phase-timing helpers, and the
+ * {@link Logger} handed to `comarkContent()` so the package's own diagnostics come out under this
+ * prefix. Separate from `search.worker.ts` to keep the hydration path free of instrumentation.
+ *
+ * Worker-side only. The main thread has its own `[search]` lines in `useSearch`, which is also where
+ * the switch is resolved — a worker cannot see the page URL, so the flag arrives with `warmup`.
+ */
+import type { ContentFile, Logger, RelationalDatabase } from 'comark-content'
+
+const PREFIX = '[search:worker]'
+
+let debug = false
+
+/** Called on every `warmup`; once on, it stays on for the life of the worker. */
+export function setDebug(value: boolean): void {
+ debug = debug || value
+}
+
+export function isDebug(): boolean {
+ return debug
+}
+
+export function log(...args: unknown[]): void {
+ if (debug) console.info(PREFIX, ...args)
+}
+
+/** Milliseconds since `from`, for log lines. */
+export function since(from: number): string {
+ return `${(performance.now() - from).toFixed(1)}ms`
+}
+
+/**
+ * Warn and error are deliberately ungated: the FTS plugin reports a missing snapshot through this
+ * channel, and that failure is otherwise indistinguishable from "the query matched nothing".
+ */
+export const logger: Logger = {
+ debug: (tag, ...args) => log(`${tag}:`, ...args),
+ info: (tag, ...args) => log(`${tag}:`, ...args),
+ warn: (tag, ...args) => console.warn(`${PREFIX} ${tag}:`, ...args),
+ error: (tag, ...args) => console.error(`${PREFIX} ${tag}:`, ...args),
+}
+
+/**
+ * What a decoded artifact holds: a snapshot decodes to the source's items, the manifest to an object
+ * keyed by path. `with nodes` is the number that matters — the FTS plugin indexes
+ * `kind === 'document' && nodes?.length`, so a bodies-less (partial) snapshot builds an empty index.
+ */
+export function describeArtifact(decoded: unknown): string {
+ if (Array.isArray(decoded)) {
+ const items = decoded as ContentFile[]
+ const documents = items.filter((item) => item.meta.kind === 'document')
+ const withNodes = documents.filter((item) => item.nodes?.length)
+ return `${items.length} item(s), ${documents.length} document(s), ${withNodes.length} with nodes`
+ }
+ const items = (decoded as { items?: Record } | null)?.items
+ return `${items ? Object.keys(items).length : 0} manifest item(s)`
+}
+
+/**
+ * Rows in the FTS plugin's index — the one number that separates "nothing was indexed" from "the
+ * query found nothing", since `search()` catches SQL errors and returns `[]` either way. Reads the
+ * plugin's private table, so it is a diagnostic, not something to build on.
+ */
+export async function indexedRows(database: RelationalDatabase, source: string): Promise {
+ try {
+ const rows = await database.all<{ n: number }>('SELECT count(*) as n FROM __fts_search WHERE source = ?', [source])
+ return rows?.[0]?.n ?? 'unknown'
+ } catch (error) {
+ return `unknown (${error instanceof Error ? error.message : String(error)})`
+ }
+}
diff --git a/app/workers/search.worker.ts b/app/workers/search.worker.ts
new file mode 100644
index 0000000..093144b
--- /dev/null
+++ b/app/workers/search.worker.ts
@@ -0,0 +1,124 @@
+/**
+ * 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, readArtifact } 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 { describeArtifact, indexedRows, isDebug, log, logger, setDebug, since } from './search-logger'
+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): 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 {
+ if (status === 'loading' || status === 'ready') {
+ log(`warmup ignored — already ${status}`)
+ return
+ }
+
+ setStatus('loading')
+ const started = performance.now()
+ try {
+ const fetchArtifact = async (path: string): Promise => {
+ const url = new URL(path, origin).href
+ const fetchStarted = performance.now()
+ try {
+ const artifact = await ofetch(url)
+ if (isDebug()) {
+ let contents: string
+ try {
+ contents = describeArtifact(await readArtifact(artifact))
+ } catch (error) {
+ contents = `undecodable: ${error instanceof Error ? error.message : String(error)}`
+ }
+ log(`fetched ${path} in ${since(fetchStarted)} — ${artifact?.size ?? 0} bytes, ${contents}`)
+ }
+ return artifact
+ } catch (error) {
+ log(`failed ${path} after ${since(fetchStarted)}`, error)
+ throw error
+ }
+ }
+
+ // Held rather than inlined into the plugin so the row count below can query the index directly.
+ const database = sqliteWasm()
+ const content = comarkContent({
+ cache: {
+ loadManifest: () => fetchArtifact(`${apiBase}/manifest.json`),
+ loadSnapshot: (source: string) => fetchArtifact(`${apiBase}/snapshot/${source}.json`),
+ },
+ plugins: [sqliteFullTextSearch({ database })],
+ logger,
+ })
+
+ await content.init()
+
+ const indexStarted = performance.now()
+ await content.search(['content'], '') // pulls the snapshot in and builds the FTS index
+ log(`index built in ${since(indexStarted)} — ${await indexedRows(database, 'content')} row(s)`)
+
+ instance = content
+ setStatus('ready')
+ log(`ready in ${since(started)}`)
+ } catch (error) {
+ setStatus('error')
+ log(`hydration failed after ${since(started)}`, error)
+ throw error
+ }
+}
+
+self.onmessage = async (event: MessageEvent) => {
+ const request = event.data
+ try {
+ if (request.type === 'warmup') {
+ setDebug(request.debug === true)
+ await loadDatabase(request.apiBase, request.origin)
+ post({ type: 'result', id: request.id, results: [] })
+ return
+ }
+
+ const queryStarted = performance.now()
+ const results = instance
+ ? await instance.search(['content'], request.query, {
+ limit: 25,
+ snippet: { columns: ['content'] },
+ ...request.opts,
+ })
+ : []
+ if (!instance) log(`dropped query "${request.query}" — no instance yet (status ${status})`)
+ else log(`query "${request.query}" -> ${results.length} result(s) in ${since(queryStarted)}`)
+ post({ type: 'result', id: request.id, results })
+ } catch (error) {
+ post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) })
+ }
+}
diff --git a/modules/config.ts b/modules/config.ts
index fea7649..ca2873b 100644
--- a/modules/config.ts
+++ b/modules/config.ts
@@ -159,17 +159,18 @@ export default defineNuxtModule({
'/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 },
}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index d43e6e9..5fc7da1 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -33,6 +33,7 @@ export default defineNuxtConfig({
resolve: {
alias: { 'beautiful-mermaid': resolveModulePath('beautiful-mermaid', { from: import.meta.url }) },
},
+ worker: { format: 'es' },
optimizeDeps: {
include: [
'beautiful-mermaid',
@@ -42,6 +43,8 @@ export default defineNuxtConfig({
'js-yaml',
'markdown-exit',
],
+ // Pre-bundling would break the wasm/worker assets sqlite loads relative to its module URL.
+ exclude: ['@sqlite.org/sqlite-wasm'],
},
},
nitro: {
diff --git a/package.json b/package.json
index 5bf2129..8476aa3 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"@octokit/webhooks-methods": "^6.0.0",
"@opentelemetry/api": "^1.9.1",
"@resvg/resvg-js": "^2.6.2",
+ "@sqlite.org/sqlite-wasm": "3.53.0-build1",
"@vercel/analytics": "^2.0.1",
"@vercel/functions": "^3.7.6",
"@vercel/otel": "^2.1.3",
@@ -57,7 +58,7 @@
"ai": "^7.0.22",
"beautiful-mermaid": "^1.1.3",
"comark": "https://pkg.pr.new/comark@af8d3e8",
- "comark-content": "https://pkg.pr.new/comark-content@6b8aae4",
+ "comark-content": "https://pkg.pr.new/comark-content@67c137f",
"defu": "^6.1.7",
"exsolve": "^1.1.0",
"js-yaml": "^5.2.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5277785..a6bc5a0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,6 +56,9 @@ importers:
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
+ '@sqlite.org/sqlite-wasm':
+ specifier: 3.53.0-build1
+ version: 3.53.0-build1
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(nuxt@4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(@types/node@26.1.1)(@vercel/functions@3.7.6(ws@8.21.1))(@vue/compiler-sfc@3.5.40)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.3)(optionator@0.9.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.0)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.11.22)(terser@5.49.0)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue-tsc@3.3.8(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))
@@ -81,8 +84,8 @@ importers:
specifier: https://pkg.pr.new/comark@af8d3e8
version: https://pkg.pr.new/comark@af8d3e8(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.3.1)
comark-content:
- specifier: https://pkg.pr.new/comark-content@6b8aae4
- version: https://pkg.pr.new/comark-content@6b8aae4(@vercel/functions@3.7.6(ws@8.21.1))(beautiful-mermaid@1.1.3)(db0@0.3.4)(ioredis@5.11.1)(rangi@2.2.0)(shiki@4.3.1)
+ specifier: https://pkg.pr.new/comark-content@67c137f
+ version: https://pkg.pr.new/comark-content@67c137f(@vercel/functions@3.7.6(ws@8.21.1))(beautiful-mermaid@1.1.3)(db0@0.3.4)(ioredis@5.11.1)(rangi@2.2.0)(shiki@4.3.1)
defu:
specifier: ^6.1.7
version: 6.1.7
@@ -2264,6 +2267,10 @@ packages:
'@speed-highlight/core@1.2.17':
resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==}
+ '@sqlite.org/sqlite-wasm@3.53.0-build1':
+ resolution: {integrity: sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g==}
+ engines: {node: '>=22'}
+
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -3570,8 +3577,8 @@ packages:
colortranslator@5.0.0:
resolution: {integrity: sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==}
- comark-content@https://pkg.pr.new/comark-content@6b8aae4:
- resolution: {integrity: sha512-xAVSgpAUw8HXop9QoqU1PpRkBmsHRu/r/3fp4IjpGW0LrffScF+VXRo7C6BG91GR3xIJlkS4t1nywPOlWR0ssQ==, tarball: https://pkg.pr.new/comark-content@6b8aae4}
+ comark-content@https://pkg.pr.new/comark-content@67c137f:
+ resolution: {integrity: sha512-X6IbRRKi2IU8COgDCpoHtLZ8wiFprgQpIrm3UWyp9K3F/omo03w/lko+uGSE9SMoakFtfJS74pswUJDLcy05tw==, tarball: https://pkg.pr.new/comark-content@67c137f}
version: 0.3.0
hasBin: true
@@ -9223,6 +9230,8 @@ snapshots:
'@speed-highlight/core@1.2.17': {}
+ '@sqlite.org/sqlite-wasm@3.53.0-build1': {}
+
'@standard-schema/spec@1.1.0': {}
'@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0))':
@@ -10548,7 +10557,7 @@ snapshots:
colortranslator@5.0.0: {}
- comark-content@https://pkg.pr.new/comark-content@6b8aae4(@vercel/functions@3.7.6(ws@8.21.1))(beautiful-mermaid@1.1.3)(db0@0.3.4)(ioredis@5.11.1)(rangi@2.2.0)(shiki@4.3.1):
+ comark-content@https://pkg.pr.new/comark-content@67c137f(@vercel/functions@3.7.6(ws@8.21.1))(beautiful-mermaid@1.1.3)(db0@0.3.4)(ioredis@5.11.1)(rangi@2.2.0)(shiki@4.3.1):
dependencies:
citty: 0.2.2
comark: 0.6.2(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.3.1)
diff --git a/server/api/content/[...path].get.ts b/server/api/content/[...path].get.ts
index e10db39..c4f39d7 100644
--- a/server/api/content/[...path].get.ts
+++ b/server/api/content/[...path].get.ts
@@ -1,6 +1,6 @@
/**
- * Single data endpoint: `content.handler()` dispatches `get`, `navigation`, `list` and custom handlers
- * (e.g. `search-sections`). Cached per-URL — see `routeRules`.
+ * Single data endpoint: `content.handler()` dispatches `get`, `navigation`, `list`, `manifest`
+ * and `snapshot`. Must be cached per-URL by layer consumer.
*/
export default defineEventHandler(async (event) => {
const content = await getProdContent()
diff --git a/server/api/content/blob/[sha]/[...path].get.ts b/server/api/content/blob/[sha]/[...path].get.ts
index fa15393..b8ce7a1 100644
--- a/server/api/content/blob/[sha]/[...path].get.ts
+++ b/server/api/content/blob/[sha]/[...path].get.ts
@@ -15,6 +15,19 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Invalid commit SHA' })
}
+ // Head-of-branch requests reuse the shared prod instance (same source ref, same per-SHA cache
+ // namespace) instead of minting a duplicate preview instance that would pin an LRU slot with a
+ // clone of production. Re-checked after `getProdContent()`, which may advance the head.
+ if (sha === getHeadRef()) {
+ const prod = await getProdContent()
+ if (sha === getHeadRef()) {
+ const request = toWebRequest(event)
+ const url = new URL(request.url)
+ url.pathname = url.pathname.replace(`/blob/${rawSha}`, '')
+ return await prod.handler(new Request(url, request))
+ }
+ }
+
const content = await getPreviewContent(sha, `/api/content/blob/${sha}`)
return await content.handler(toWebRequest(event))
diff --git a/server/api/content/head.get.ts b/server/api/content/head.get.ts
new file mode 100644
index 0000000..485f81c
--- /dev/null
+++ b/server/api/content/head.get.ts
@@ -0,0 +1,9 @@
+/**
+ * The commit SHA production content is pinned to, or `null` in dev.
+ */
+export default defineEventHandler(async (event) => {
+ if (import.meta.dev) return { sha: null }
+
+ const { contentDir } = useRuntimeConfig(event).docs
+ return { sha: await resolveContentSha(targetBranch(), contentDir) }
+})
diff --git a/server/api/revalidate.post.ts b/server/api/revalidate.post.ts
index 70d65a8..214730e 100644
--- a/server/api/revalidate.post.ts
+++ b/server/api/revalidate.post.ts
@@ -150,8 +150,8 @@ export default defineEventHandler(async (event) => {
// URL the browser loads (`…/_payload.json?`).
const buildId = useRuntimeConfig(event).app.buildId
- // Any content change invalidates the llms indexes, the feed, and the body-derived search index.
- const paths = new Set(['/llms.txt', '/llms-full.txt', '/rss.xml', '/api/content/search-sections'])
+ // Any content change invalidates the llms indexes and the feed.
+ const paths = new Set(['/llms.txt', '/llms-full.txt', '/rss.xml'])
for (const f of changedFiles) {
const pageUrl = pageUrlForPath(f)
if (pageUrl) {
@@ -190,13 +190,9 @@ export default defineEventHandler(async (event) => {
throw err
})
- // Warm the per-SHA body cache so cold instances skip re-parsing from GitHub.
- // `metaOnly` became `partial` in comark-content 0.2.0 with no alias and consumers straddle both,
- // so send both keys — each version ignores the other's. Not inlined: as a literal,
- // excess-property checking rejects whichever key the installed types don't declare.
- const full = { partial: false, metaOnly: false }
- await headContent.init(full).catch((err) => {
- console.error(`${tag} cache warm failed`, err?.message ?? err)
+ // Warms the per-SHA body cache and persists the snapshot artifact
+ await warmSnapshot(headContent).catch((err) => {
+ console.error(`${tag} snapshot warm failed`, err?.message ?? err)
})
await useStorage('cache:nuxt:payload').clear()
diff --git a/server/utils/content.ts b/server/utils/content.ts
index e30eeb3..07f95fd 100644
--- a/server/utils/content.ts
+++ b/server/utils/content.ts
@@ -1,4 +1,4 @@
-import { defineContentPlugin, type ComarkContent, type CacheOptions, comarkContent } from 'comark-content';
+import { type ComarkContent, type CacheOptions, comarkContent } from 'comark-content';
import fs from 'comark-content/sources/fs'
import github from 'comark-content/sources/github'
import rangi from 'comark/plugins/rangi'
@@ -27,14 +27,6 @@ const comarkPlugins = [
}),
]
-// Bound to THIS instance so a preview content instance serves its own version's sections, not production's.
-const searchSectionsPlugin = defineContentPlugin(() => ({
- name: 'search-sections',
- setup(ctx) {
- ctx.addServeHandler('search-sections', async () => Response.json(await buildSearchSections(ctx as unknown as ComarkContent)))
- },
-}))()
-
/**
* Create a new content instance reading content at `ref` (a commit SHA or branch). `remote` forces the
* GitHub source, `cache` overrides comark's (in-memory by default), `watch` is dev file watching.
@@ -56,7 +48,6 @@ export async function createSourceContent(
},
plugins: [
yaml(), // enable .navigation.yml to be detected
- searchSectionsPlugin,
tracer && tracingOtel({ tracer }),
],
cache: opts.cache,
@@ -77,6 +68,15 @@ export async function createSourceContent(
return instance
}
+/**
+ * Fully parse and persist the snapshot artifact into this instance's per-SHA cache.
+ */
+export async function warmSnapshot(content: ComarkContent): Promise {
+ await content.init({ partial: false })
+ const artifact = await content.cache.snapshot('content')
+ console.log(`[content] snapshot artifact ${artifact ? `${artifact.size} bytes` : 'not produced'}`)
+}
+
/** Production branch, resolved per request: content pushes skip redeploys (`vercel.json` `ignoreCommand`). */
export function targetBranch(): string {
return process.env.VERCEL_GIT_COMMIT_REF || useRuntimeConfig().docs.github.branch || 'main'