Skip to content

Commit 28eae37

Browse files
rsbhclaude
andauthored
feat: full-text search with SQLite FTS5 (#64)
* feat: enable Nitro SQLite database for search Configure experimental database with SQLite connector for FTS. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: replace MiniSearch with SQLite FTS5 for full-text search - FTS5 virtual table for ranked full-text search - Indexes page titles, descriptions, and API operations - Fresh index on every deploy (drops and recreates tables) - FTS MATCH with prefix queries for partial matching - Results ranked by relevance via FTS5 rank function Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove minisearch dependency Replaced by SQLite FTS5 via Nitro database. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: index page body content and headings for FTS - Read raw MDX files and extract headings + body text - Separate FTS columns: title (10x boost), headings (5x), body (1x) - bm25 ranking for relevance-based results - Use fs/promises for async file reading Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add match type and snippet to search results - Response includes match field (title/heading/body) - Snippet shows the matched text with surrounding context - Headings stored newline-separated for individual matching - Body snippets show ~120 chars around the match Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: readiness endpoint, search UI snippets, title highlights - /api/ready returns 503 until search index is built, 200 after - Lock file in /tmp deleted on startup for pod restart detection - Search results show match type (title/heading/body) with snippets - Matched text highlighted in accent color in both title and snippet - # prefix for heading matches - Fixed race condition with index mutex - Fixed table creation with IF NOT EXISTS - Pass items to Command to disable client-side filtering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add deployment guide with Docker, k8s, Vercel, Netlify Covers build, Docker multi-stage, Docker Compose, Kubernetes deployment with health/readiness probes, service, ingress, Vercel and Netlify configs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update lockfile after minisearch removal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: lint errors in search handler - Remove unused getFirstApiUrl import - Suppress useDatabase false positive (Nitro DI, not React hook) - Suppress empty catch block on lock file cleanup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent abc7fe0 commit 28eae37

9 files changed

Lines changed: 455 additions & 112 deletions

File tree

bun.lock

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
---
2+
title: Deployment
3+
description: Deploy Chronicle to production
4+
order: 3
5+
---
6+
7+
# Deployment
8+
9+
Chronicle builds to a standalone Node.js server that can be deployed anywhere.
10+
11+
## Build
12+
13+
```bash
14+
bunx chronicle build
15+
```
16+
17+
This outputs a production build to `.output/`.
18+
19+
## Start
20+
21+
```bash
22+
bunx chronicle start
23+
```
24+
25+
Starts the production server on port 3000.
26+
27+
## Environment Variables
28+
29+
| Variable | Description | Default |
30+
|---|---|---|
31+
| `PORT` | Server port | `3000` |
32+
| `HOST` | Server host | `0.0.0.0` |
33+
34+
## Docker
35+
36+
```dockerfile
37+
FROM oven/bun:latest AS builder
38+
WORKDIR /app
39+
COPY . .
40+
RUN bun install
41+
RUN bunx chronicle build
42+
43+
FROM node:20-slim
44+
WORKDIR /app
45+
COPY --from=builder /app/.output .output
46+
EXPOSE 3000
47+
CMD ["node", ".output/server/index.mjs"]
48+
```
49+
50+
Build and run:
51+
52+
```bash
53+
docker build -t my-docs .
54+
docker run -p 3000:3000 my-docs
55+
```
56+
57+
## Docker Compose
58+
59+
```yaml
60+
version: '3.8'
61+
services:
62+
docs:
63+
build: .
64+
ports:
65+
- '3000:3000'
66+
restart: unless-stopped
67+
```
68+
69+
## Kubernetes
70+
71+
### Deployment
72+
73+
```yaml
74+
apiVersion: apps/v1
75+
kind: Deployment
76+
metadata:
77+
name: chronicle-docs
78+
spec:
79+
replicas: 2
80+
selector:
81+
matchLabels:
82+
app: chronicle-docs
83+
template:
84+
metadata:
85+
labels:
86+
app: chronicle-docs
87+
spec:
88+
containers:
89+
- name: docs
90+
image: my-docs:latest
91+
ports:
92+
- containerPort: 3000
93+
livenessProbe:
94+
httpGet:
95+
path: /api/health
96+
port: 3000
97+
initialDelaySeconds: 5
98+
periodSeconds: 10
99+
readinessProbe:
100+
httpGet:
101+
path: /api/ready
102+
port: 3000
103+
initialDelaySeconds: 5
104+
periodSeconds: 5
105+
resources:
106+
requests:
107+
cpu: 100m
108+
memory: 128Mi
109+
limits:
110+
cpu: 500m
111+
memory: 512Mi
112+
```
113+
114+
### Service
115+
116+
```yaml
117+
apiVersion: v1
118+
kind: Service
119+
metadata:
120+
name: chronicle-docs
121+
spec:
122+
selector:
123+
app: chronicle-docs
124+
ports:
125+
- port: 80
126+
targetPort: 3000
127+
type: ClusterIP
128+
```
129+
130+
### Ingress
131+
132+
```yaml
133+
apiVersion: networking.k8s.io/v1
134+
kind: Ingress
135+
metadata:
136+
name: chronicle-docs
137+
spec:
138+
rules:
139+
- host: docs.example.com
140+
http:
141+
paths:
142+
- path: /
143+
pathType: Prefix
144+
backend:
145+
service:
146+
name: chronicle-docs
147+
port:
148+
number: 80
149+
```
150+
151+
### Health Endpoints
152+
153+
| Endpoint | Purpose | Response |
154+
|---|---|---|
155+
| `GET /api/health` | Liveness probe | `200 {"status":"ok"}` always |
156+
| `GET /api/ready` | Readiness probe | `200 {"status":"ready"}` when search index built, `503` otherwise |
157+
158+
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.
159+
160+
## Vercel
161+
162+
Add `vercel.json`:
163+
164+
```json
165+
{
166+
"buildCommand": "bunx chronicle build",
167+
"outputDirectory": ".output/public",
168+
"rewrites": [
169+
{ "source": "/(.*)", "destination": "/api/server" }
170+
]
171+
}
172+
```
173+
174+
## Netlify
175+
176+
Add `netlify.toml`:
177+
178+
```toml
179+
[build]
180+
command = "bunx chronicle build"
181+
publish = ".output/public"
182+
183+
[[redirects]]
184+
from = "/*"
185+
to = "/.netlify/functions/server"
186+
status = 200
187+
```

packages/chronicle/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@
6161
"h3": "^2.0.1-rc.16",
6262
"lodash": "^4.17.23",
6363
"mermaid": "^11.13.0",
64-
"minisearch": "^7.2.0",
6564
"nitro": "3.0.260311-beta",
6665
"openapi-types": "^12.1.3",
6766
"react": "^19.0.0",

packages/chronicle/src/components/ui/search.module.css

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
.list {
1515
max-height: 400px;
16+
gap: var(--rs-space-3);
1617
}
1718

1819
.list :global([cmdk-group-heading]) {
@@ -24,13 +25,14 @@
2425
}
2526

2627
.item {
27-
height: 32px;
28+
min-height: 40px;
2829
padding: var(--rs-space-3);
2930
gap: var(--rs-space-3);
3031
border-radius: var(--rs-radius-2);
3132
cursor: pointer;
3233
}
3334

35+
3436
.item[data-selected="true"] {
3537
background: var(--rs-color-background-base-primary-hover);
3638
}
@@ -43,8 +45,9 @@
4345

4446
.resultText {
4547
display: flex;
46-
align-items: center;
47-
gap: 8px;
48+
flex-direction: column;
49+
gap: 2px;
50+
min-width: 0;
4851
}
4952

5053
.headingText {
@@ -68,16 +71,35 @@
6871
}
6972

7073
.icon {
71-
width: 18px;
72-
height: 18px;
74+
width: 48px;
75+
height: 24px;
7376
color: var(--rs-color-foreground-base-secondary);
7477
flex-shrink: 0;
7578
}
7679

80+
.itemContent :global([class*="badge-module"]) {
81+
min-width: 48px;
82+
justify-content: center;
83+
}
84+
7785
.item[data-selected="true"] .icon {
7886
color: var(--rs-color-foreground-accent-primary-hover);
7987
}
8088

89+
.snippetText {
90+
font-size: var(--rs-font-size-mini);
91+
line-height: var(--rs-line-height-mini);
92+
color: var(--rs-color-foreground-base-tertiary);
93+
overflow: hidden;
94+
text-overflow: ellipsis;
95+
white-space: nowrap;
96+
}
97+
98+
.matchHighlight {
99+
color: var(--rs-color-foreground-accent-primary);
100+
font-weight: var(--rs-font-weight-medium);
101+
}
102+
81103
.pageText :global(mark),
82104
.headingText :global(mark) {
83105
background: transparent;

packages/chronicle/src/components/ui/search.tsx

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ interface SearchResult {
1616
url: string;
1717
type: string;
1818
content: string;
19+
match?: 'title' | 'heading' | 'body';
20+
snippet?: string;
1921
}
2022

2123
interface SearchProps {
@@ -121,7 +123,7 @@ export function Search({ classNames }: SearchProps) {
121123

122124
<Command.Dialog open={open} onOpenChange={setOpen}>
123125
<Command.DialogContent className={styles.dialogContent}>
124-
<Command>
126+
<Command items={displayResults}>
125127
<Command.Input
126128
placeholder='Search'
127129
leadingIcon={<MagnifyingGlassIcon width={16} height={16} />}
@@ -171,23 +173,17 @@ export function Search({ classNames }: SearchProps) {
171173
<div className={styles.itemContent}>
172174
{getResultIcon(result)}
173175
<div className={styles.resultText}>
174-
{result.type === 'heading' ? (
175-
<>
176-
<Text className={styles.headingText}>
177-
<HighlightedText
178-
html={stripMethod(result.content)}
179-
/>
180-
</Text>
181-
<Text className={styles.separator}>-</Text>
182-
<Text className={styles.pageText}>
183-
{getPageTitle(result.url)}
184-
</Text>
185-
</>
186-
) : (
187-
<Text className={styles.pageText}>
188-
<HighlightedText
189-
html={stripMethod(result.content)}
190-
/>
176+
<Text className={styles.pageText}>
177+
<HighlightQuery text={stripMethod(result.content)} query={search} />
178+
</Text>
179+
{result.snippet && result.match === 'heading' && (
180+
<Text className={styles.snippetText}>
181+
# <HighlightQuery text={result.snippet} query={search} />
182+
</Text>
183+
)}
184+
{result.snippet && result.match === 'body' && (
185+
<Text className={styles.snippetText}>
186+
<HighlightQuery text={result.snippet} query={search} />
191187
</Text>
192188
)}
193189
</div>
@@ -236,6 +232,19 @@ function HighlightedText({
236232
);
237233
}
238234

235+
function HighlightQuery({ text, query }: { text: string; query: string }) {
236+
if (!query) return <>{text}</>;
237+
const idx = text.toLowerCase().indexOf(query.toLowerCase());
238+
if (idx < 0) return <>{text}</>;
239+
return (
240+
<>
241+
{text.slice(0, idx)}
242+
<span className={styles.matchHighlight}>{text.slice(idx, idx + query.length)}</span>
243+
{text.slice(idx + query.length)}
244+
</>
245+
);
246+
}
247+
239248
function getResultIcon(result: SearchResult): React.ReactNode {
240249
if (!result.url.startsWith('/apis/')) {
241250
return result.type === 'page' ? (

0 commit comments

Comments
 (0)