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'