Skip to content
Merged
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
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

187 changes: 187 additions & 0 deletions examples/basic/content/docs/guides/deployment.mdx
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 0 additions & 1 deletion packages/chronicle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 27 additions & 5 deletions packages/chronicle/src/components/ui/search.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

.list {
max-height: 400px;
gap: var(--rs-space-3);
}

.list :global([cmdk-group-heading]) {
Expand All @@ -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);
}
Expand All @@ -43,8 +45,9 @@

.resultText {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
gap: 2px;
min-width: 0;
}

.headingText {
Expand All @@ -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;
Expand Down
45 changes: 27 additions & 18 deletions packages/chronicle/src/components/ui/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface SearchResult {
url: string;
type: string;
content: string;
match?: 'title' | 'heading' | 'body';
snippet?: string;
}

interface SearchProps {
Expand Down Expand Up @@ -121,7 +123,7 @@ export function Search({ classNames }: SearchProps) {

<Command.Dialog open={open} onOpenChange={setOpen}>
<Command.DialogContent className={styles.dialogContent}>
<Command>
<Command items={displayResults}>
<Command.Input
placeholder='Search'
leadingIcon={<MagnifyingGlassIcon width={16} height={16} />}
Expand Down Expand Up @@ -171,23 +173,17 @@ export function Search({ classNames }: SearchProps) {
<div className={styles.itemContent}>
{getResultIcon(result)}
<div className={styles.resultText}>
{result.type === 'heading' ? (
<>
<Text className={styles.headingText}>
<HighlightedText
html={stripMethod(result.content)}
/>
</Text>
<Text className={styles.separator}>-</Text>
<Text className={styles.pageText}>
{getPageTitle(result.url)}
</Text>
</>
) : (
<Text className={styles.pageText}>
<HighlightedText
html={stripMethod(result.content)}
/>
<Text className={styles.pageText}>
<HighlightQuery text={stripMethod(result.content)} query={search} />
</Text>
{result.snippet && result.match === 'heading' && (
<Text className={styles.snippetText}>
# <HighlightQuery text={result.snippet} query={search} />
</Text>
)}
{result.snippet && result.match === 'body' && (
<Text className={styles.snippetText}>
<HighlightQuery text={result.snippet} query={search} />
</Text>
)}
</div>
Expand Down Expand Up @@ -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)}
<span className={styles.matchHighlight}>{text.slice(idx, idx + query.length)}</span>
{text.slice(idx + query.length)}
</>
);
}

function getResultIcon(result: SearchResult): React.ReactNode {
if (!result.url.startsWith('/apis/')) {
return result.type === 'page' ? (
Expand Down
Loading
Loading