diff --git a/bun.lock b/bun.lock index b743f63e..5cf718b1 100644 --- a/bun.lock +++ b/bun.lock @@ -44,7 +44,6 @@ "h3": "^2.0.1-rc.16", "lodash": "^4.17.23", "mermaid": "^11.13.0", - "minisearch": "^7.2.0", "nitro": "3.0.260311-beta", "openapi-types": "^12.1.3", "react": "^19.0.0", @@ -869,8 +868,6 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], - "mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/examples/basic/content/docs/guides/deployment.mdx b/examples/basic/content/docs/guides/deployment.mdx new file mode 100644 index 00000000..89f54f1d --- /dev/null +++ b/examples/basic/content/docs/guides/deployment.mdx @@ -0,0 +1,187 @@ +--- +title: Deployment +description: Deploy Chronicle to production +order: 3 +--- + +# Deployment + +Chronicle builds to a standalone Node.js server that can be deployed anywhere. + +## Build + +```bash +bunx chronicle build +``` + +This outputs a production build to `.output/`. + +## Start + +```bash +bunx chronicle start +``` + +Starts the production server on port 3000. + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Server port | `3000` | +| `HOST` | Server host | `0.0.0.0` | + +## Docker + +```dockerfile +FROM oven/bun:latest AS builder +WORKDIR /app +COPY . . +RUN bun install +RUN bunx chronicle build + +FROM node:20-slim +WORKDIR /app +COPY --from=builder /app/.output .output +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] +``` + +Build and run: + +```bash +docker build -t my-docs . +docker run -p 3000:3000 my-docs +``` + +## Docker Compose + +```yaml +version: '3.8' +services: + docs: + build: . + ports: + - '3000:3000' + restart: unless-stopped +``` + +## Kubernetes + +### Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: chronicle-docs +spec: + replicas: 2 + selector: + matchLabels: + app: chronicle-docs + template: + metadata: + labels: + app: chronicle-docs + spec: + containers: + - name: docs + image: my-docs:latest + ports: + - containerPort: 3000 + livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /api/ready + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi +``` + +### Service + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: chronicle-docs +spec: + selector: + app: chronicle-docs + ports: + - port: 80 + targetPort: 3000 + type: ClusterIP +``` + +### Ingress + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: chronicle-docs +spec: + rules: + - host: docs.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: chronicle-docs + port: + number: 80 +``` + +### Health Endpoints + +| Endpoint | Purpose | Response | +|---|---|---| +| `GET /api/health` | Liveness probe | `200 {"status":"ok"}` always | +| `GET /api/ready` | Readiness probe | `200 {"status":"ready"}` when search index built, `503` otherwise | + +The readiness probe returns `503` until the search FTS index is built. On pod restart or redeploy, the index rebuilds automatically on first request. Kubernetes will not route traffic to the pod until it reports ready. + +## Vercel + +Add `vercel.json`: + +```json +{ + "buildCommand": "bunx chronicle build", + "outputDirectory": ".output/public", + "rewrites": [ + { "source": "/(.*)", "destination": "/api/server" } + ] +} +``` + +## Netlify + +Add `netlify.toml`: + +```toml +[build] + command = "bunx chronicle build" + publish = ".output/public" + +[[redirects]] + from = "/*" + to = "/.netlify/functions/server" + status = 200 +``` diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index e13ac3bd..a9194f69 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -61,7 +61,6 @@ "h3": "^2.0.1-rc.16", "lodash": "^4.17.23", "mermaid": "^11.13.0", - "minisearch": "^7.2.0", "nitro": "3.0.260311-beta", "openapi-types": "^12.1.3", "react": "^19.0.0", diff --git a/packages/chronicle/src/components/ui/search.module.css b/packages/chronicle/src/components/ui/search.module.css index d36228a8..c82985db 100644 --- a/packages/chronicle/src/components/ui/search.module.css +++ b/packages/chronicle/src/components/ui/search.module.css @@ -13,6 +13,7 @@ .list { max-height: 400px; + gap: var(--rs-space-3); } .list :global([cmdk-group-heading]) { @@ -24,13 +25,14 @@ } .item { - height: 32px; + min-height: 40px; padding: var(--rs-space-3); gap: var(--rs-space-3); border-radius: var(--rs-radius-2); cursor: pointer; } + .item[data-selected="true"] { background: var(--rs-color-background-base-primary-hover); } @@ -43,8 +45,9 @@ .resultText { display: flex; - align-items: center; - gap: 8px; + flex-direction: column; + gap: 2px; + min-width: 0; } .headingText { @@ -68,16 +71,35 @@ } .icon { - width: 18px; - height: 18px; + width: 48px; + height: 24px; color: var(--rs-color-foreground-base-secondary); flex-shrink: 0; } +.itemContent :global([class*="badge-module"]) { + min-width: 48px; + justify-content: center; +} + .item[data-selected="true"] .icon { color: var(--rs-color-foreground-accent-primary-hover); } +.snippetText { + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + color: var(--rs-color-foreground-base-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.matchHighlight { + color: var(--rs-color-foreground-accent-primary); + font-weight: var(--rs-font-weight-medium); +} + .pageText :global(mark), .headingText :global(mark) { background: transparent; diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index f5c8bd62..107def14 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -16,6 +16,8 @@ interface SearchResult { url: string; type: string; content: string; + match?: 'title' | 'heading' | 'body'; + snippet?: string; } interface SearchProps { @@ -121,7 +123,7 @@ export function Search({ classNames }: SearchProps) { - + } @@ -171,23 +173,17 @@ export function Search({ classNames }: SearchProps) {
{getResultIcon(result)}
- {result.type === 'heading' ? ( - <> - - - - - - - {getPageTitle(result.url)} - - - ) : ( - - + + + + {result.snippet && result.match === 'heading' && ( + + # + + )} + {result.snippet && result.match === 'body' && ( + + )}
@@ -236,6 +232,19 @@ function HighlightedText({ ); } +function HighlightQuery({ text, query }: { text: string; query: string }) { + if (!query) return <>{text}; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx < 0) return <>{text}; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + query.length)} + {text.slice(idx + query.length)} + + ); +} + function getResultIcon(result: SearchResult): React.ReactNode { if (!result.url.startsWith('/apis/')) { return result.type === 'page' ? ( diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 581e3e44..dbc84170 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; import { loader } from 'fumadocs-core/source'; import { flattenTree } from 'fumadocs-core/page-tree'; import type { Root, Node, Folder } from 'fumadocs-core/page-tree'; @@ -240,6 +242,35 @@ export function getOriginalPath(page: { data: unknown }): string { return ((page.data as Record)._originalPath as string) ?? ''; } +export async function getPageSearchContent(page: { data: unknown }): Promise<{ headings: string; body: string }> { + const originalPath = getOriginalPath(page); + if (!originalPath) return { headings: '', body: '' }; + try { + const contentDir = typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined' ? __CHRONICLE_CONTENT_DIR__ : process.cwd(); + const filePath = path.resolve(contentDir, originalPath); + const raw = await fs.readFile(filePath, 'utf-8'); + const withoutFrontmatter = raw.replace(/^---[\s\S]*?---/m, ''); + const headings: string[] = []; + const lines: string[] = []; + for (const line of withoutFrontmatter.split('\n')) { + const headingMatch = line.match(/^#{1,6}\s+(.+)/); + if (headingMatch) { + headings.push(headingMatch[1]); + } else if (!line.startsWith('import ') && !line.startsWith('export ') && !line.startsWith('```')) { + const cleaned = line + .replace(/<[^>]+>/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*_~`]+/g, '') + .trim(); + if (cleaned) lines.push(cleaned); + } + } + return { headings: headings.join('\n'), body: lines.join(' ') }; + } catch { + return { headings: '', body: '' }; + } +} + interface ReadingTime { text: string; minutes: number; diff --git a/packages/chronicle/src/server/api/ready.ts b/packages/chronicle/src/server/api/ready.ts new file mode 100644 index 00000000..b42b56c3 --- /dev/null +++ b/packages/chronicle/src/server/api/ready.ts @@ -0,0 +1,15 @@ +import { defineHandler } from 'nitro'; +import { isSearchReady } from './search'; + +export default defineHandler(() => { + const searchReady = isSearchReady(); + + if (!searchReady) { + return Response.json( + { status: 'not_ready', search: false }, + { status: 503 }, + ); + } + + return Response.json({ status: 'ready', search: true }); +}); diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts index ac12532f..d790becb 100644 --- a/packages/chronicle/src/server/api/search.ts +++ b/packages/chronicle/src/server/api/search.ts @@ -1,79 +1,129 @@ -import MiniSearch from 'minisearch'; import { defineHandler, HTTPError } from 'nitro'; +import { useDatabase } from 'nitro/database'; import type { OpenAPIV3 } from 'openapi-types'; import { getSpecSlug } from '@/lib/api-routes'; import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; -import { extractFrontmatter, getPagesForVersion } from '@/lib/source'; +import { extractFrontmatter, getPageSearchContent, getPagesForVersion } from '@/lib/source'; import { LATEST_CONTEXT, type VersionContext } from '@/lib/version-source'; interface SearchDocument { id: string; url: string; title: string; - content: string; + headings: string; + body: string; type: 'page' | 'api'; } -const indexCache = new Map>(); -const docsCache = new Map(); +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; -function keyFor(ctx: VersionContext): string { +const LOCK_FILE = path.join(os.tmpdir(), 'chronicle-search-ready'); + +export const indexedVersions = new Set(); +let indexPromise: Promise | null = null; + +function versionKey(ctx: VersionContext): string { return ctx.dir ?? '__latest__'; } -function createIndex(docs: SearchDocument[]): MiniSearch { - const index = new MiniSearch({ - fields: ['title', 'content'], - storeFields: ['url', 'title', 'type'], - searchOptions: { - boost: { title: 2 }, - fuzzy: 0.2, - prefix: true - } - }); - index.addAll(docs); - return index; +// biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op catch +fs.unlink(LOCK_FILE).catch(() => {}); + +export function isSearchReady(): boolean { + return existsSync(LOCK_FILE); +} + +export async function ensureIndex(ctx: VersionContext) { + const key = versionKey(ctx); + if (indexedVersions.has(key)) return; + if (indexPromise) return indexPromise; + indexPromise = buildIndex(ctx, key); + await indexPromise; + indexPromise = null; + await fs.writeFile(LOCK_FILE, new Date().toISOString()); +} + +async function buildIndex(ctx: VersionContext, key: string) { + // biome-ignore lint/correctness/useHookAtTopLevel: useDatabase is a Nitro DI accessor, not a React hook + const db = useDatabase(); + + await db.exec('DROP TABLE IF EXISTS search_fts'); + await db.exec('DROP TABLE IF EXISTS search_docs'); + + await db.exec(`CREATE TABLE IF NOT EXISTS search_docs ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + title TEXT NOT NULL, + headings TEXT NOT NULL, + body TEXT NOT NULL, + type TEXT NOT NULL, + version TEXT NOT NULL + )`); + + await db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( + title, + headings, + body, + content=search_docs, + content_rowid=rowid + )`); + + const docs = await buildDocs(ctx); + for (const doc of docs) { + await db.sql`INSERT INTO search_docs (id, url, title, headings, body, type, version) + VALUES (${doc.id}, ${doc.url}, ${doc.title}, ${doc.headings}, ${doc.body}, ${doc.type}, ${key})`; + } + + await db.sql`INSERT INTO search_fts (rowid, title, headings, body) + SELECT rowid, title, headings, body FROM search_docs WHERE version = ${key}`; + + indexedVersions.add(key); } -async function scanContent(ctx: VersionContext): Promise { +async function buildDocs(ctx: VersionContext): Promise { + const docs: SearchDocument[] = []; + const pages = await getPagesForVersion(ctx); - return pages.map(p => { + for (const p of pages) { const fm = extractFrontmatter(p); - return { + const { headings, body } = await getPageSearchContent(p); + docs.push({ id: p.url, url: p.url, title: fm.title, - content: fm.description ?? '', - type: 'page' as const - }; - }); -} + headings, + body: [fm.description ?? '', body].join(' '), + type: 'page', + }); + } -async function buildApiDocs(ctx: VersionContext): Promise { const config = loadConfig(); const apiConfigs = getApiConfigsForVersion(config, ctx.dir); - if (!apiConfigs.length) return []; - - const docs: SearchDocument[] = []; - const specs = await loadApiSpecs(apiConfigs); - - for (const spec of specs) { - const specSlug = getSpecSlug(spec); - const paths = spec.document.paths ?? {}; - for (const [, pathItem] of Object.entries(paths)) { - if (!pathItem) continue; - for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { - const op = pathItem[method] as OpenAPIV3.OperationObject | undefined; - if (!op?.operationId) continue; - const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(op.operationId)}`; - docs.push({ - id: url, - url, - title: `${method.toUpperCase()} ${op.summary ?? op.operationId}`, - content: op.description ?? '', - type: 'api' - }); + if (apiConfigs.length) { + const specs = await loadApiSpecs(apiConfigs); + for (const spec of specs) { + const specSlug = getSpecSlug(spec); + const paths = spec.document.paths ?? {}; + for (const [pathStr, pathItem] of Object.entries(paths)) { + if (!pathItem) continue; + for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { + const op = pathItem[method] as OpenAPIV3.OperationObject | undefined; + if (!op) continue; + const opId = op.operationId ?? `${method}_${pathStr.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`; + const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(opId)}`; + docs.push({ + id: url, + url, + title: `${method.toUpperCase()} ${op.summary ?? opId}`, + headings: op.summary ?? opId, + body: [op.description ?? '', pathStr, method.toUpperCase()].join(' '), + type: 'api', + }); + } } } } @@ -81,27 +131,32 @@ async function buildApiDocs(ctx: VersionContext): Promise { return docs; } -async function getDocs(ctx: VersionContext): Promise { - const key = keyFor(ctx); - const cached = docsCache.get(key); - if (cached) return cached; - const [contentDocs, apiDocs] = await Promise.all([ - scanContent(ctx), - buildApiDocs(ctx) - ]); - const docs = [...contentDocs, ...apiDocs]; - docsCache.set(key, docs); - return docs; -} +function findMatch( + query: string, + title: string, + headings: string, + body: string, +): { match: 'title' | 'heading' | 'body'; snippet: string } { + if (title.toLowerCase().includes(query)) { + return { match: 'title', snippet: title }; + } -async function getIndex(ctx: VersionContext): Promise> { - const key = keyFor(ctx); - const cached = indexCache.get(key); - if (cached) return cached; - const docs = await getDocs(ctx); - const index = createIndex(docs); - indexCache.set(key, index); - return index; + const headingList = headings.split('\n').filter(Boolean); + for (const h of headingList) { + if (h.toLowerCase().includes(query)) { + return { match: 'heading', snippet: h }; + } + } + + const idx = body.toLowerCase().indexOf(query); + if (idx >= 0) { + const start = Math.max(0, idx - 40); + const end = Math.min(body.length, idx + query.length + 80); + const snippet = (start > 0 ? '...' : '') + body.slice(start, end).trim() + (end < body.length ? '...' : ''); + return { match: 'body', snippet }; + } + + return { match: 'title', snippet: title }; } function resolveCtx(tag: string | null): VersionContext { @@ -121,25 +176,44 @@ export default defineHandler(async event => { const query = event.url.searchParams.get('query') ?? ''; const tag = event.url.searchParams.get('tag'); const ctx = resolveCtx(tag); - const index = await getIndex(ctx); + + await ensureIndex(ctx); + // biome-ignore lint/correctness/useHookAtTopLevel: useDatabase is a Nitro DI accessor, not a React hook + const db = useDatabase(); + const key = versionKey(ctx); if (!query) { - const docs = await getDocs(ctx); - return Response.json(docs - .filter(d => d.type === 'page') - .slice(0, 8) - .map(d => ({ - id: d.id, - url: d.url, - type: d.type, - content: d.title - }))); + const result = await db.sql`SELECT id, url, title, type FROM search_docs + WHERE version = ${key} AND type = 'page' + LIMIT 8`; + return Response.json((result.rows ?? []).map(r => ({ + id: r.id, + url: r.url, + type: r.type, + content: r.title, + }))); } - return Response.json(index.search(query).map(r => ({ - id: r.id, - url: r.url, - type: r.type, - content: r.title - }))); + const searchTerm = query.split(/\s+/).map(t => `"${t}"*`).join(' '); + const result = await db.sql`SELECT s.id, s.url, s.title, s.headings, s.body, s.type, + bm25(search_fts, 10.0, 5.0, 1.0) AS score + FROM search_fts f + JOIN search_docs s ON s.rowid = f.rowid + WHERE search_fts MATCH ${searchTerm} + AND s.version = ${key} + ORDER BY score + LIMIT 20`; + + const queryLower = query.toLowerCase(); + return Response.json((result.rows ?? []).map(r => { + const { match, snippet } = findMatch(queryLower, r.title as string, r.headings as string, r.body as string); + return { + id: r.id, + url: r.url, + type: r.type, + content: r.title, + match, + snippet, + }; + })); }); diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 5ce7df6e..ded42722 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -136,6 +136,15 @@ export async function createViteConfig( output: { dir: resolveOutputDir(projectRoot, preset), }, + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'chronicle-search' }, + }, + }, }, }; }