@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 +10 -1
- package/package.json +3 -4
- package/src/components/ui/search.module.css +27 -5
- package/src/components/ui/search.tsx +28 -19
- package/src/lib/page-context.tsx +1 -1
- package/src/lib/source.ts +52 -5
- package/src/server/api/ready.ts +15 -0
- package/src/server/api/search.ts +159 -85
- package/src/server/vite-config.ts +9 -0
- package/src/themes/default/Page.module.css +4 -0
- package/src/themes/default/Page.tsx +6 -1
- package/src/types/config.ts +1 -1
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
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
47
|
-
gap:
|
|
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:
|
|
72
|
-
height:
|
|
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
|
|
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
|
-
{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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' ? (
|
package/src/lib/page-context.tsx
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
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
|
+
});
|
package/src/server/api/search.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
+
headings: string;
|
|
15
|
+
body: string;
|
|
15
16
|
type: 'page' | 'api';
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
87
|
+
async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
|
|
88
|
+
const docs: SearchDocument[] = [];
|
|
89
|
+
|
|
40
90
|
const pages = await getPagesForVersion(ctx);
|
|
41
|
-
|
|
91
|
+
for (const p of pages) {
|
|
42
92
|
const fm = extractFrontmatter(p);
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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 (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
}
|
|
@@ -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} />
|
package/src/types/config.ts
CHANGED