@raystack/chronicle 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -424,6 +424,15 @@ async function createViteConfig(options) {
424
424
  publicAssets: [{ dir: path6.resolve(projectRoot, "public") }],
425
425
  output: {
426
426
  dir: resolveOutputDir(projectRoot, preset)
427
+ },
428
+ experimental: {
429
+ database: true
430
+ },
431
+ database: {
432
+ default: {
433
+ connector: "sqlite",
434
+ options: { name: "chronicle-search" }
435
+ }
427
436
  }
428
437
  }
429
438
  };
@@ -449,7 +458,7 @@ import chalk from "chalk";
449
458
  import { parse } from "yaml";
450
459
 
451
460
  // src/types/config.ts
452
- import uniqBy from "lodash/uniqBy.js";
461
+ import { uniqBy } from "lodash-es";
453
462
  import { z } from "zod";
454
463
  var logoSchema = z.object({
455
464
  light: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "@biomejs/biome": "^2.3.13",
24
24
  "@raystack/tools-config": "0.56.0",
25
25
  "@types/hast": "^3.0.4",
26
- "@types/lodash": "^4.17.23",
26
+ "@types/lodash-es": "^4.17.12",
27
27
  "@types/mdast": "^4.0.4",
28
28
  "@types/mdx": "^2.0.13",
29
29
  "@types/node": "^25.1.0",
@@ -59,9 +59,8 @@
59
59
  "glob": "^11.0.0",
60
60
  "gray-matter": "^4.0.3",
61
61
  "h3": "^2.0.1-rc.16",
62
- "lodash": "^4.17.23",
62
+ "lodash-es": "^4.17.23",
63
63
  "mermaid": "^11.13.0",
64
- "minisearch": "^7.2.0",
65
64
  "nitro": "3.0.260311-beta",
66
65
  "openapi-types": "^12.1.3",
67
66
  "react": "^19.0.0",
@@ -13,6 +13,7 @@
13
13
 
14
14
  .list {
15
15
  max-height: 400px;
16
+ gap: var(--rs-space-3);
16
17
  }
17
18
 
18
19
  .list :global([cmdk-group-heading]) {
@@ -24,13 +25,14 @@
24
25
  }
25
26
 
26
27
  .item {
27
- height: 32px;
28
+ min-height: 40px;
28
29
  padding: var(--rs-space-3);
29
30
  gap: var(--rs-space-3);
30
31
  border-radius: var(--rs-radius-2);
31
32
  cursor: pointer;
32
33
  }
33
34
 
35
+
34
36
  .item[data-selected="true"] {
35
37
  background: var(--rs-color-background-base-primary-hover);
36
38
  }
@@ -43,8 +45,9 @@
43
45
 
44
46
  .resultText {
45
47
  display: flex;
46
- align-items: center;
47
- gap: 8px;
48
+ flex-direction: column;
49
+ gap: 2px;
50
+ min-width: 0;
48
51
  }
49
52
 
50
53
  .headingText {
@@ -68,16 +71,35 @@
68
71
  }
69
72
 
70
73
  .icon {
71
- width: 18px;
72
- height: 18px;
74
+ width: 48px;
75
+ height: 24px;
73
76
  color: var(--rs-color-foreground-base-secondary);
74
77
  flex-shrink: 0;
75
78
  }
76
79
 
80
+ .itemContent :global([class*="badge-module"]) {
81
+ min-width: 48px;
82
+ justify-content: center;
83
+ }
84
+
77
85
  .item[data-selected="true"] .icon {
78
86
  color: var(--rs-color-foreground-accent-primary-hover);
79
87
  }
80
88
 
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
+
81
103
  .pageText :global(mark),
82
104
  .headingText :global(mark) {
83
105
  background: transparent;
@@ -4,7 +4,7 @@ import {
4
4
  MagnifyingGlassIcon
5
5
  } from '@heroicons/react/24/outline';
6
6
  import { Command, IconButton, Text } from '@raystack/apsara';
7
- import debounce from 'lodash/debounce';
7
+ import { debounce } from 'lodash-es';
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
9
  import { useNavigate } from 'react-router';
10
10
  import { MethodBadge } from '@/components/api/method-badge';
@@ -16,6 +16,8 @@ interface SearchResult {
16
16
  url: string;
17
17
  type: string;
18
18
  content: string;
19
+ match?: 'title' | 'heading' | 'body';
20
+ snippet?: string;
19
21
  }
20
22
 
21
23
  interface SearchProps {
@@ -121,7 +123,7 @@ export function Search({ classNames }: SearchProps) {
121
123
 
122
124
  <Command.Dialog open={open} onOpenChange={setOpen}>
123
125
  <Command.DialogContent className={styles.dialogContent}>
124
- <Command>
126
+ <Command items={displayResults}>
125
127
  <Command.Input
126
128
  placeholder='Search'
127
129
  leadingIcon={<MagnifyingGlassIcon width={16} height={16} />}
@@ -171,23 +173,17 @@ export function Search({ classNames }: SearchProps) {
171
173
  <div className={styles.itemContent}>
172
174
  {getResultIcon(result)}
173
175
  <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} />
191
187
  </Text>
192
188
  )}
193
189
  </div>
@@ -236,6 +232,19 @@ function HighlightedText({
236
232
  );
237
233
  }
238
234
 
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
+
239
248
  function getResultIcon(result: SearchResult): React.ReactNode {
240
249
  if (!result.url.startsWith('/apis/')) {
241
250
  return result.type === 'page' ? (
@@ -116,7 +116,7 @@ export function PageProvider({
116
116
  const fetchPageData = useCallback(async (slug: string[]): Promise<PageData> => {
117
117
  const apiPath = slug.length === 0
118
118
  ? '/api/page'
119
- : `/api/page?slug=${slug.join(',')}`;
119
+ : `/api/page?slug=${slug.map(s => encodeURIComponent(s)).join(',')}`;
120
120
  const res = await fetch(apiPath);
121
121
  if (!res.ok) throw new Error(String(res.status));
122
122
  return res.json();
package/src/lib/source.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
1
3
  import { loader } from 'fumadocs-core/source';
2
4
  import { flattenTree } from 'fumadocs-core/page-tree';
3
5
  import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
@@ -118,14 +120,30 @@ export function invalidate() {
118
120
  cachedNavMap = null;
119
121
  }
120
122
 
123
+ function getFolderPath(node: Folder): string | null {
124
+ const firstPage = findFirstPage(node);
125
+ if (!firstPage) return null;
126
+ const parts = firstPage.url.split('/').filter(Boolean);
127
+ parts.pop();
128
+ return '/' + parts.join('/');
129
+ }
130
+
131
+ function findFirstPage(node: Folder): { url: string } | null {
132
+ for (const child of node.children) {
133
+ if (child.type === 'page') return child;
134
+ if (child.type === 'folder') {
135
+ const found = findFirstPage(child);
136
+ if (found) return found;
137
+ }
138
+ }
139
+ return node.index ?? null;
140
+ }
141
+
121
142
  function getOrder(node: Node, pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): number | undefined {
122
143
  if (node.type === 'page') return pageOrderMap.get(node.url);
123
144
  if (node.type === 'folder') {
124
- if (node.index) {
125
- const fromMeta = folderOrderMap.get(node.index.url);
126
- if (fromMeta !== undefined) return fromMeta;
127
- return pageOrderMap.get(node.index.url);
128
- }
145
+ const folderPath = getFolderPath(node);
146
+ if (folderPath) return folderOrderMap.get(folderPath);
129
147
  }
130
148
  return undefined;
131
149
  }
@@ -259,6 +277,35 @@ export function getOriginalPath(page: { data: unknown }): string {
259
277
  return ((page.data as Record<string, unknown>)._originalPath as string) ?? '';
260
278
  }
261
279
 
280
+ export async function getPageSearchContent(page: { data: unknown }): Promise<{ headings: string; body: string }> {
281
+ const originalPath = getOriginalPath(page);
282
+ if (!originalPath) return { headings: '', body: '' };
283
+ try {
284
+ const contentDir = typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined' ? __CHRONICLE_CONTENT_DIR__ : process.cwd();
285
+ const filePath = path.resolve(contentDir, originalPath);
286
+ const raw = await fs.readFile(filePath, 'utf-8');
287
+ const withoutFrontmatter = raw.replace(/^---[\s\S]*?---/m, '');
288
+ const headings: string[] = [];
289
+ const lines: string[] = [];
290
+ for (const line of withoutFrontmatter.split('\n')) {
291
+ const headingMatch = line.match(/^#{1,6}\s+(.+)/);
292
+ if (headingMatch) {
293
+ headings.push(headingMatch[1]);
294
+ } else if (!line.startsWith('import ') && !line.startsWith('export ') && !line.startsWith('```')) {
295
+ const cleaned = line
296
+ .replace(/<[^>]+>/g, '')
297
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
298
+ .replace(/[*_~`]+/g, '')
299
+ .trim();
300
+ if (cleaned) lines.push(cleaned);
301
+ }
302
+ }
303
+ return { headings: headings.join('\n'), body: lines.join(' ') };
304
+ } catch {
305
+ return { headings: '', body: '' };
306
+ }
307
+ }
308
+
262
309
  interface ReadingTime {
263
310
  text: string;
264
311
  minutes: number;
@@ -0,0 +1,15 @@
1
+ import { defineHandler } from 'nitro';
2
+ import { isSearchReady } from './search';
3
+
4
+ export default defineHandler(() => {
5
+ const searchReady = isSearchReady();
6
+
7
+ if (!searchReady) {
8
+ return Response.json(
9
+ { status: 'not_ready', search: false },
10
+ { status: 503 },
11
+ );
12
+ }
13
+
14
+ return Response.json({ status: 'ready', search: true });
15
+ });
@@ -1,79 +1,129 @@
1
- import MiniSearch from 'minisearch';
2
1
  import { defineHandler, HTTPError } from 'nitro';
2
+ import { useDatabase } from 'nitro/database';
3
3
  import type { OpenAPIV3 } from 'openapi-types';
4
4
  import { getSpecSlug } from '@/lib/api-routes';
5
5
  import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
6
6
  import { loadApiSpecs } from '@/lib/openapi';
7
- import { extractFrontmatter, getPagesForVersion } from '@/lib/source';
7
+ import { extractFrontmatter, getPageSearchContent, getPagesForVersion } from '@/lib/source';
8
8
  import { LATEST_CONTEXT, type VersionContext } from '@/lib/version-source';
9
9
 
10
10
  interface SearchDocument {
11
11
  id: string;
12
12
  url: string;
13
13
  title: string;
14
- content: string;
14
+ headings: string;
15
+ body: string;
15
16
  type: 'page' | 'api';
16
17
  }
17
18
 
18
- const indexCache = new Map<string, MiniSearch<SearchDocument>>();
19
- const docsCache = new Map<string, SearchDocument[]>();
19
+ import fs from 'node:fs/promises';
20
+ import { existsSync } from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
20
23
 
21
- function keyFor(ctx: VersionContext): string {
24
+ const LOCK_FILE = path.join(os.tmpdir(), 'chronicle-search-ready');
25
+
26
+ export const indexedVersions = new Set<string>();
27
+ let indexPromise: Promise<void> | null = null;
28
+
29
+ function versionKey(ctx: VersionContext): string {
22
30
  return ctx.dir ?? '__latest__';
23
31
  }
24
32
 
25
- function createIndex(docs: SearchDocument[]): MiniSearch<SearchDocument> {
26
- const index = new MiniSearch<SearchDocument>({
27
- fields: ['title', 'content'],
28
- storeFields: ['url', 'title', 'type'],
29
- searchOptions: {
30
- boost: { title: 2 },
31
- fuzzy: 0.2,
32
- prefix: true
33
- }
34
- });
35
- index.addAll(docs);
36
- return index;
33
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op catch
34
+ fs.unlink(LOCK_FILE).catch(() => {});
35
+
36
+ export function isSearchReady(): boolean {
37
+ return existsSync(LOCK_FILE);
38
+ }
39
+
40
+ export async function ensureIndex(ctx: VersionContext) {
41
+ const key = versionKey(ctx);
42
+ if (indexedVersions.has(key)) return;
43
+ if (indexPromise) return indexPromise;
44
+ indexPromise = buildIndex(ctx, key);
45
+ await indexPromise;
46
+ indexPromise = null;
47
+ await fs.writeFile(LOCK_FILE, new Date().toISOString());
48
+ }
49
+
50
+ async function buildIndex(ctx: VersionContext, key: string) {
51
+ // biome-ignore lint/correctness/useHookAtTopLevel: useDatabase is a Nitro DI accessor, not a React hook
52
+ const db = useDatabase();
53
+
54
+ await db.exec('DROP TABLE IF EXISTS search_fts');
55
+ await db.exec('DROP TABLE IF EXISTS search_docs');
56
+
57
+ await db.exec(`CREATE TABLE IF NOT EXISTS search_docs (
58
+ id TEXT PRIMARY KEY,
59
+ url TEXT NOT NULL,
60
+ title TEXT NOT NULL,
61
+ headings TEXT NOT NULL,
62
+ body TEXT NOT NULL,
63
+ type TEXT NOT NULL,
64
+ version TEXT NOT NULL
65
+ )`);
66
+
67
+ await db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
68
+ title,
69
+ headings,
70
+ body,
71
+ content=search_docs,
72
+ content_rowid=rowid
73
+ )`);
74
+
75
+ const docs = await buildDocs(ctx);
76
+ for (const doc of docs) {
77
+ await db.sql`INSERT INTO search_docs (id, url, title, headings, body, type, version)
78
+ VALUES (${doc.id}, ${doc.url}, ${doc.title}, ${doc.headings}, ${doc.body}, ${doc.type}, ${key})`;
79
+ }
80
+
81
+ await db.sql`INSERT INTO search_fts (rowid, title, headings, body)
82
+ SELECT rowid, title, headings, body FROM search_docs WHERE version = ${key}`;
83
+
84
+ indexedVersions.add(key);
37
85
  }
38
86
 
39
- async function scanContent(ctx: VersionContext): Promise<SearchDocument[]> {
87
+ async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
88
+ const docs: SearchDocument[] = [];
89
+
40
90
  const pages = await getPagesForVersion(ctx);
41
- return pages.map(p => {
91
+ for (const p of pages) {
42
92
  const fm = extractFrontmatter(p);
43
- return {
93
+ const { headings, body } = await getPageSearchContent(p);
94
+ docs.push({
44
95
  id: p.url,
45
96
  url: p.url,
46
97
  title: fm.title,
47
- content: fm.description ?? '',
48
- type: 'page' as const
49
- };
50
- });
51
- }
98
+ headings,
99
+ body: [fm.description ?? '', body].join(' '),
100
+ type: 'page',
101
+ });
102
+ }
52
103
 
53
- async function buildApiDocs(ctx: VersionContext): Promise<SearchDocument[]> {
54
104
  const config = loadConfig();
55
105
  const apiConfigs = getApiConfigsForVersion(config, ctx.dir);
56
- if (!apiConfigs.length) return [];
57
-
58
- const docs: SearchDocument[] = [];
59
- const specs = await loadApiSpecs(apiConfigs);
60
-
61
- for (const spec of specs) {
62
- const specSlug = getSpecSlug(spec);
63
- const paths = spec.document.paths ?? {};
64
- for (const [, pathItem] of Object.entries(paths)) {
65
- if (!pathItem) continue;
66
- for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
67
- const op = pathItem[method] as OpenAPIV3.OperationObject | undefined;
68
- if (!op?.operationId) continue;
69
- const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(op.operationId)}`;
70
- docs.push({
71
- id: url,
72
- url,
73
- title: `${method.toUpperCase()} ${op.summary ?? op.operationId}`,
74
- content: op.description ?? '',
75
- type: 'api'
76
- });
106
+ if (apiConfigs.length) {
107
+ const specs = await loadApiSpecs(apiConfigs);
108
+ for (const spec of specs) {
109
+ const specSlug = getSpecSlug(spec);
110
+ const paths = spec.document.paths ?? {};
111
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
112
+ if (!pathItem) continue;
113
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
114
+ const op = pathItem[method] as OpenAPIV3.OperationObject | undefined;
115
+ if (!op) continue;
116
+ const opId = op.operationId ?? `${method}_${pathStr.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
117
+ const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(opId)}`;
118
+ docs.push({
119
+ id: url,
120
+ url,
121
+ title: `${method.toUpperCase()} ${op.summary ?? opId}`,
122
+ headings: op.summary ?? opId,
123
+ body: [op.description ?? '', pathStr, method.toUpperCase()].join(' '),
124
+ type: 'api',
125
+ });
126
+ }
77
127
  }
78
128
  }
79
129
  }
@@ -81,27 +131,32 @@ async function buildApiDocs(ctx: VersionContext): Promise<SearchDocument[]> {
81
131
  return docs;
82
132
  }
83
133
 
84
- async function getDocs(ctx: VersionContext): Promise<SearchDocument[]> {
85
- const key = keyFor(ctx);
86
- const cached = docsCache.get(key);
87
- if (cached) return cached;
88
- const [contentDocs, apiDocs] = await Promise.all([
89
- scanContent(ctx),
90
- buildApiDocs(ctx)
91
- ]);
92
- const docs = [...contentDocs, ...apiDocs];
93
- docsCache.set(key, docs);
94
- return docs;
95
- }
134
+ function findMatch(
135
+ query: string,
136
+ title: string,
137
+ headings: string,
138
+ body: string,
139
+ ): { match: 'title' | 'heading' | 'body'; snippet: string } {
140
+ if (title.toLowerCase().includes(query)) {
141
+ return { match: 'title', snippet: title };
142
+ }
96
143
 
97
- async function getIndex(ctx: VersionContext): Promise<MiniSearch<SearchDocument>> {
98
- const key = keyFor(ctx);
99
- const cached = indexCache.get(key);
100
- if (cached) return cached;
101
- const docs = await getDocs(ctx);
102
- const index = createIndex(docs);
103
- indexCache.set(key, index);
104
- return index;
144
+ const headingList = headings.split('\n').filter(Boolean);
145
+ for (const h of headingList) {
146
+ if (h.toLowerCase().includes(query)) {
147
+ return { match: 'heading', snippet: h };
148
+ }
149
+ }
150
+
151
+ const idx = body.toLowerCase().indexOf(query);
152
+ if (idx >= 0) {
153
+ const start = Math.max(0, idx - 40);
154
+ const end = Math.min(body.length, idx + query.length + 80);
155
+ const snippet = (start > 0 ? '...' : '') + body.slice(start, end).trim() + (end < body.length ? '...' : '');
156
+ return { match: 'body', snippet };
157
+ }
158
+
159
+ return { match: 'title', snippet: title };
105
160
  }
106
161
 
107
162
  function resolveCtx(tag: string | null): VersionContext {
@@ -121,25 +176,44 @@ export default defineHandler(async event => {
121
176
  const query = event.url.searchParams.get('query') ?? '';
122
177
  const tag = event.url.searchParams.get('tag');
123
178
  const ctx = resolveCtx(tag);
124
- const index = await getIndex(ctx);
179
+
180
+ await ensureIndex(ctx);
181
+ // biome-ignore lint/correctness/useHookAtTopLevel: useDatabase is a Nitro DI accessor, not a React hook
182
+ const db = useDatabase();
183
+ const key = versionKey(ctx);
125
184
 
126
185
  if (!query) {
127
- const docs = await getDocs(ctx);
128
- return Response.json(docs
129
- .filter(d => d.type === 'page')
130
- .slice(0, 8)
131
- .map(d => ({
132
- id: d.id,
133
- url: d.url,
134
- type: d.type,
135
- content: d.title
136
- })));
186
+ const result = await db.sql`SELECT id, url, title, type FROM search_docs
187
+ WHERE version = ${key} AND type = 'page'
188
+ LIMIT 8`;
189
+ return Response.json((result.rows ?? []).map(r => ({
190
+ id: r.id,
191
+ url: r.url,
192
+ type: r.type,
193
+ content: r.title,
194
+ })));
137
195
  }
138
196
 
139
- return Response.json(index.search(query).map(r => ({
140
- id: r.id,
141
- url: r.url,
142
- type: r.type,
143
- content: r.title
144
- })));
197
+ const searchTerm = query.split(/\s+/).map(t => `"${t}"*`).join(' ');
198
+ const result = await db.sql`SELECT s.id, s.url, s.title, s.headings, s.body, s.type,
199
+ bm25(search_fts, 10.0, 5.0, 1.0) AS score
200
+ FROM search_fts f
201
+ JOIN search_docs s ON s.rowid = f.rowid
202
+ WHERE search_fts MATCH ${searchTerm}
203
+ AND s.version = ${key}
204
+ ORDER BY score
205
+ LIMIT 20`;
206
+
207
+ const queryLower = query.toLowerCase();
208
+ return Response.json((result.rows ?? []).map(r => {
209
+ const { match, snippet } = findMatch(queryLower, r.title as string, r.headings as string, r.body as string);
210
+ return {
211
+ id: r.id,
212
+ url: r.url,
213
+ type: r.type,
214
+ content: r.title,
215
+ match,
216
+ snippet,
217
+ };
218
+ }));
145
219
  });
@@ -136,6 +136,15 @@ export async function createViteConfig(
136
136
  output: {
137
137
  dir: resolveOutputDir(projectRoot, preset),
138
138
  },
139
+ experimental: {
140
+ database: true,
141
+ },
142
+ database: {
143
+ default: {
144
+ connector: 'sqlite',
145
+ options: { name: 'chronicle-search' },
146
+ },
147
+ },
139
148
  },
140
149
  };
141
150
  }
@@ -11,6 +11,10 @@
11
11
  max-width: 768px;
12
12
  }
13
13
 
14
+ .title {
15
+ margin: 0 0 var(--rs-space-8) 0;
16
+ }
17
+
14
18
  .content {
15
19
  line-height: 1.7;
16
20
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { Flex } from '@raystack/apsara';
3
+ import { Flex, Headline } from '@raystack/apsara';
4
4
  import type { ThemePageProps } from '@/types';
5
5
  import styles from './Page.module.css';
6
6
  import { Toc } from './Toc';
@@ -9,6 +9,11 @@ export function Page({ page }: ThemePageProps) {
9
9
  return (
10
10
  <Flex className={styles.page}>
11
11
  <article className={styles.article} data-article-content>
12
+ {page.frontmatter.title && (
13
+ <Headline size="t2" render={<h1 />} className={styles.title}>
14
+ {page.frontmatter.title}
15
+ </Headline>
16
+ )}
12
17
  <div className={styles.content}>{page.content}</div>
13
18
  </article>
14
19
  <Toc items={page.toc} />
@@ -1,4 +1,4 @@
1
- import uniqBy from 'lodash/uniqBy.js'
1
+ import { uniqBy } from 'lodash-es'
2
2
  import { z } from 'zod'
3
3
 
4
4
  const logoSchema = z.object({