@raystack/chronicle 0.8.0 → 0.10.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 +25 -1
- package/package.json +4 -4
- package/src/components/ui/search.module.css +27 -5
- package/src/components/ui/search.tsx +28 -19
- package/src/lib/folder-utils.ts +26 -0
- package/src/lib/page-context.tsx +1 -1
- package/src/lib/route-resolver.test.ts +3 -3
- package/src/lib/route-resolver.ts +12 -2
- package/src/lib/source-utils.test.ts +85 -0
- package/src/lib/source.ts +43 -7
- package/src/lib/tree-utils.test.ts +113 -0
- package/src/lib/tree-utils.ts +57 -0
- package/src/pages/DocsPage.tsx +5 -36
- package/src/server/api/ready.ts +15 -0
- package/src/server/api/search.ts +159 -85
- package/src/server/entry-server.tsx +28 -1
- package/src/server/vite-config.ts +19 -0
- package/src/themes/default/Page.module.css +4 -0
- package/src/themes/default/Page.tsx +6 -1
- package/src/types/config.ts +9 -1
package/dist/cli/index.js
CHANGED
|
@@ -312,6 +312,18 @@ import { nitro } from "nitro/vite";
|
|
|
312
312
|
import fs3 from "node:fs/promises";
|
|
313
313
|
import path6 from "node:path";
|
|
314
314
|
import remarkDirective from "remark-directive";
|
|
315
|
+
function getDatabaseConnector(preset) {
|
|
316
|
+
switch (preset) {
|
|
317
|
+
case "bun":
|
|
318
|
+
return { connector: "bun-sqlite", options: { name: "chronicle-search" } };
|
|
319
|
+
case "cloudflare":
|
|
320
|
+
case "cloudflare-pages":
|
|
321
|
+
case "cloudflare-module":
|
|
322
|
+
return { connector: "cloudflare-d1", options: { bindingName: "CHRONICLE_DB" } };
|
|
323
|
+
default:
|
|
324
|
+
return { connector: "sqlite", options: { name: "chronicle-search" } };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
315
327
|
function resolveOutputDir(projectRoot, preset) {
|
|
316
328
|
if (preset === "vercel" || preset === "vercel-static")
|
|
317
329
|
return path6.resolve(projectRoot, ".vercel/output");
|
|
@@ -424,6 +436,12 @@ async function createViteConfig(options) {
|
|
|
424
436
|
publicAssets: [{ dir: path6.resolve(projectRoot, "public") }],
|
|
425
437
|
output: {
|
|
426
438
|
dir: resolveOutputDir(projectRoot, preset)
|
|
439
|
+
},
|
|
440
|
+
experimental: {
|
|
441
|
+
database: true
|
|
442
|
+
},
|
|
443
|
+
database: {
|
|
444
|
+
default: getDatabaseConnector(preset)
|
|
427
445
|
}
|
|
428
446
|
}
|
|
429
447
|
};
|
|
@@ -449,7 +467,7 @@ import chalk from "chalk";
|
|
|
449
467
|
import { parse } from "yaml";
|
|
450
468
|
|
|
451
469
|
// src/types/config.ts
|
|
452
|
-
import uniqBy from "lodash
|
|
470
|
+
import { uniqBy } from "lodash-es";
|
|
453
471
|
import { z } from "zod";
|
|
454
472
|
var logoSchema = z.object({
|
|
455
473
|
light: z.string().optional(),
|
|
@@ -552,6 +570,11 @@ var RESERVED_ROUTE_SEGMENTS = [
|
|
|
552
570
|
"robots.txt",
|
|
553
571
|
"sitemap.xml"
|
|
554
572
|
];
|
|
573
|
+
var redirectSchema = z.object({
|
|
574
|
+
from: z.string(),
|
|
575
|
+
to: z.string(),
|
|
576
|
+
permanent: z.boolean().optional()
|
|
577
|
+
});
|
|
555
578
|
var chronicleConfigSchema = z.object({
|
|
556
579
|
site: siteSchema,
|
|
557
580
|
url: z.string().optional(),
|
|
@@ -564,6 +587,7 @@ var chronicleConfigSchema = z.object({
|
|
|
564
587
|
navigation: navigationSchema.optional(),
|
|
565
588
|
search: searchSchema.optional(),
|
|
566
589
|
api: z.array(apiSchema).optional(),
|
|
590
|
+
redirects: z.array(redirectSchema).optional(),
|
|
567
591
|
analytics: analyticsSchema.optional(),
|
|
568
592
|
telemetry: telemetrySchema.optional()
|
|
569
593
|
}).strict().refine((cfg) => allUnique(cfg.content, (c) => c.dir), {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raystack/chronicle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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,9 @@
|
|
|
59
59
|
"glob": "^11.0.0",
|
|
60
60
|
"gray-matter": "^4.0.3",
|
|
61
61
|
"h3": "^2.0.1-rc.16",
|
|
62
|
-
"
|
|
62
|
+
"http-status-codes": "^2.3.0",
|
|
63
|
+
"lodash-es": "^4.17.23",
|
|
63
64
|
"mermaid": "^11.13.0",
|
|
64
|
-
"minisearch": "^7.2.0",
|
|
65
65
|
"nitro": "3.0.260311-beta",
|
|
66
66
|
"openapi-types": "^12.1.3",
|
|
67
67
|
"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' ? (
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Node, Folder } from 'fumadocs-core/page-tree';
|
|
2
|
+
|
|
3
|
+
const NodeType = {
|
|
4
|
+
Page: 'page',
|
|
5
|
+
Folder: 'folder',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export function parentPath(url: string): string {
|
|
9
|
+
const parts = url.split('/').filter(Boolean);
|
|
10
|
+
parts.pop();
|
|
11
|
+
return '/' + parts.join('/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getFolderPath(node: Folder): string | null {
|
|
15
|
+
if (node.index) return node.index.url;
|
|
16
|
+
for (const child of node.children) {
|
|
17
|
+
if (child.type === NodeType.Page) return parentPath(child.url);
|
|
18
|
+
}
|
|
19
|
+
for (const child of node.children) {
|
|
20
|
+
if (child.type === NodeType.Folder) {
|
|
21
|
+
const childPath = getFolderPath(child as Folder);
|
|
22
|
+
if (childPath) return parentPath(childPath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
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();
|
|
@@ -60,7 +60,7 @@ describe('resolveRoute — root', () => {
|
|
|
60
60
|
expect(resolveRoute('/', singleContent())).toEqual({
|
|
61
61
|
type: RouteType.Redirect,
|
|
62
62
|
to: '/docs',
|
|
63
|
-
status:
|
|
63
|
+
status: 307,
|
|
64
64
|
})
|
|
65
65
|
})
|
|
66
66
|
|
|
@@ -75,7 +75,7 @@ describe('resolveRoute — root', () => {
|
|
|
75
75
|
expect(resolveRoute('/', multiContentNoLanding())).toEqual({
|
|
76
76
|
type: RouteType.Redirect,
|
|
77
77
|
to: '/docs',
|
|
78
|
-
status:
|
|
78
|
+
status: 307,
|
|
79
79
|
})
|
|
80
80
|
})
|
|
81
81
|
|
|
@@ -83,7 +83,7 @@ describe('resolveRoute — root', () => {
|
|
|
83
83
|
expect(resolveRoute('/v2', versioned())).toEqual({
|
|
84
84
|
type: RouteType.Redirect,
|
|
85
85
|
to: '/v2/docs',
|
|
86
|
-
status:
|
|
86
|
+
status: 307,
|
|
87
87
|
})
|
|
88
88
|
})
|
|
89
89
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StatusCodes } from 'http-status-codes'
|
|
1
2
|
import type { ChronicleConfig } from '@/types'
|
|
2
3
|
import { getLatestContentRoots, getVersionContentRoots } from './config'
|
|
3
4
|
import { type VersionContext, resolveVersionFromUrl } from './version-source'
|
|
@@ -13,7 +14,7 @@ export const RouteType = {
|
|
|
13
14
|
export type RouteType = (typeof RouteType)[keyof typeof RouteType]
|
|
14
15
|
|
|
15
16
|
export type Route =
|
|
16
|
-
| { type: typeof RouteType.Redirect; to: string; status:
|
|
17
|
+
| { type: typeof RouteType.Redirect; to: string; status: StatusCodes.TEMPORARY_REDIRECT | StatusCodes.PERMANENT_REDIRECT }
|
|
17
18
|
| { type: typeof RouteType.DocsIndex; version: VersionContext }
|
|
18
19
|
| { type: typeof RouteType.DocsPage; version: VersionContext; slug: string[] }
|
|
19
20
|
| { type: typeof RouteType.ApiIndex; version: VersionContext }
|
|
@@ -45,6 +46,15 @@ export function resolveRoute(
|
|
|
45
46
|
pathname: string,
|
|
46
47
|
config: ChronicleConfig,
|
|
47
48
|
): Route {
|
|
49
|
+
const redirect = config.redirects?.find((r) => r.from === pathname)
|
|
50
|
+
if (redirect) {
|
|
51
|
+
return {
|
|
52
|
+
type: RouteType.Redirect,
|
|
53
|
+
to: redirect.to,
|
|
54
|
+
status: redirect.permanent ? StatusCodes.PERMANENT_REDIRECT : StatusCodes.TEMPORARY_REDIRECT,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
const parts = pathname.split('/').filter(Boolean)
|
|
49
59
|
const version = resolveVersionFromUrl(pathname, config)
|
|
50
60
|
const remainder =
|
|
@@ -65,7 +75,7 @@ export function resolveRoute(
|
|
|
65
75
|
return {
|
|
66
76
|
type: RouteType.Redirect,
|
|
67
77
|
to: `${version.urlPrefix}/${dirs[0]}`,
|
|
68
|
-
status:
|
|
78
|
+
status: StatusCodes.TEMPORARY_REDIRECT,
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Node, Folder } from 'fumadocs-core/page-tree'
|
|
3
|
+
import { parentPath, getFolderPath } from './folder-utils'
|
|
4
|
+
|
|
5
|
+
function page(url: string): Node {
|
|
6
|
+
return { type: 'page', name: 'Page', url } as Node
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function folder(name: string, children: Node[], indexUrl?: string): Folder {
|
|
10
|
+
return {
|
|
11
|
+
type: 'folder',
|
|
12
|
+
name,
|
|
13
|
+
children,
|
|
14
|
+
...(indexUrl ? { index: { url: indexUrl } } : {}),
|
|
15
|
+
} as Folder
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('parentPath', () => {
|
|
19
|
+
test('returns parent of page URL', () => {
|
|
20
|
+
expect(parentPath('/docs/guides/install')).toBe('/docs/guides')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('returns root for top-level page', () => {
|
|
24
|
+
expect(parentPath('/docs')).toBe('/')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('handles trailing segments', () => {
|
|
28
|
+
expect(parentPath('/a/b/c/d')).toBe('/a/b/c')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('handles root', () => {
|
|
32
|
+
expect(parentPath('/')).toBe('/')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('getFolderPath', () => {
|
|
37
|
+
test('returns index URL when folder has index', () => {
|
|
38
|
+
const f = folder('Guides', [page('/docs/guides/install')], '/docs/guides')
|
|
39
|
+
expect(getFolderPath(f)).toBe('/docs/guides')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('derives path from direct child page', () => {
|
|
43
|
+
const f = folder('Guides', [page('/docs/guides/install')])
|
|
44
|
+
expect(getFolderPath(f)).toBe('/docs/guides')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('derives path from subfolder child (not deeply nested)', () => {
|
|
48
|
+
const f = folder('Tasking', [
|
|
49
|
+
folder('Via Order Desk', [page('/docs/tasking/via_order_desk/package')])
|
|
50
|
+
])
|
|
51
|
+
expect(getFolderPath(f)).toBe('/docs/tasking')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('handles folder with & in path', () => {
|
|
55
|
+
const f = folder('Cart & Order', [page('/docs/cart&order/working_with_cart')])
|
|
56
|
+
expect(getFolderPath(f)).toBe('/docs/cart&order')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('handles folder with space in path', () => {
|
|
60
|
+
const f = folder('My Folder', [page('/docs/my folder/intro')])
|
|
61
|
+
expect(getFolderPath(f)).toBe('/docs/my folder')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('returns null for empty folder', () => {
|
|
65
|
+
const f = folder('Empty', [])
|
|
66
|
+
expect(getFolderPath(f)).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('prefers direct child page over subfolder', () => {
|
|
70
|
+
const f = folder('Mixed', [
|
|
71
|
+
page('/docs/mixed/intro'),
|
|
72
|
+
folder('Sub', [page('/docs/mixed/sub/deep')])
|
|
73
|
+
])
|
|
74
|
+
expect(getFolderPath(f)).toBe('/docs/mixed')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('deeply nested only-subfolder chain', () => {
|
|
78
|
+
const f = folder('Root', [
|
|
79
|
+
folder('Mid', [
|
|
80
|
+
folder('Deep', [page('/a/b/c/d/page')])
|
|
81
|
+
])
|
|
82
|
+
])
|
|
83
|
+
expect(getFolderPath(f)).toBe('/a/b')
|
|
84
|
+
})
|
|
85
|
+
})
|
package/src/lib/source.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
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';
|
|
6
|
+
|
|
7
|
+
import { parentPath, getFolderPath } from './folder-utils';
|
|
8
|
+
|
|
9
|
+
const NodeType = {
|
|
10
|
+
Page: 'page',
|
|
11
|
+
Folder: 'folder',
|
|
12
|
+
} as const;
|
|
4
13
|
import type { MDXContent } from 'mdx/types';
|
|
5
14
|
import type { TableOfContents } from 'fumadocs-core/toc';
|
|
6
15
|
import {
|
|
@@ -118,14 +127,12 @@ export function invalidate() {
|
|
|
118
127
|
cachedNavMap = null;
|
|
119
128
|
}
|
|
120
129
|
|
|
130
|
+
|
|
121
131
|
function getOrder(node: Node, pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): number | undefined {
|
|
122
|
-
if (node.type ===
|
|
123
|
-
if (node.type ===
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (fromMeta !== undefined) return fromMeta;
|
|
127
|
-
return pageOrderMap.get(node.index.url);
|
|
128
|
-
}
|
|
132
|
+
if (node.type === NodeType.Page) return pageOrderMap.get(node.url);
|
|
133
|
+
if (node.type === NodeType.Folder) {
|
|
134
|
+
const folderPath = getFolderPath(node);
|
|
135
|
+
if (folderPath) return folderOrderMap.get(folderPath);
|
|
129
136
|
}
|
|
130
137
|
return undefined;
|
|
131
138
|
}
|
|
@@ -259,6 +266,35 @@ export function getOriginalPath(page: { data: unknown }): string {
|
|
|
259
266
|
return ((page.data as Record<string, unknown>)._originalPath as string) ?? '';
|
|
260
267
|
}
|
|
261
268
|
|
|
269
|
+
export async function getPageSearchContent(page: { data: unknown }): Promise<{ headings: string; body: string }> {
|
|
270
|
+
const originalPath = getOriginalPath(page);
|
|
271
|
+
if (!originalPath) return { headings: '', body: '' };
|
|
272
|
+
try {
|
|
273
|
+
const contentDir = typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined' ? __CHRONICLE_CONTENT_DIR__ : process.cwd();
|
|
274
|
+
const filePath = path.resolve(contentDir, originalPath);
|
|
275
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
276
|
+
const withoutFrontmatter = raw.replace(/^---[\s\S]*?---/m, '');
|
|
277
|
+
const headings: string[] = [];
|
|
278
|
+
const lines: string[] = [];
|
|
279
|
+
for (const line of withoutFrontmatter.split('\n')) {
|
|
280
|
+
const headingMatch = line.match(/^#{1,6}\s+(.+)/);
|
|
281
|
+
if (headingMatch) {
|
|
282
|
+
headings.push(headingMatch[1]);
|
|
283
|
+
} else if (!line.startsWith('import ') && !line.startsWith('export ') && !line.startsWith('```')) {
|
|
284
|
+
const cleaned = line
|
|
285
|
+
.replace(/<[^>]+>/g, '')
|
|
286
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
287
|
+
.replace(/[*_~`]+/g, '')
|
|
288
|
+
.trim();
|
|
289
|
+
if (cleaned) lines.push(cleaned);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { headings: headings.join('\n'), body: lines.join(' ') };
|
|
293
|
+
} catch {
|
|
294
|
+
return { headings: '', body: '' };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
262
298
|
interface ReadingTime {
|
|
263
299
|
text: string;
|
|
264
300
|
minutes: number;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Node } from 'fumadocs-core/page-tree'
|
|
3
|
+
import { getFirstPageUrl, findFolderFirstPage, resolveDocsRedirect } from './tree-utils'
|
|
4
|
+
|
|
5
|
+
function page(url: string, name = 'Page'): Node {
|
|
6
|
+
return { type: 'page', name, url } as Node
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function folder(name: string, children: Node[], indexUrl?: string): Node {
|
|
10
|
+
return {
|
|
11
|
+
type: 'folder',
|
|
12
|
+
name,
|
|
13
|
+
children,
|
|
14
|
+
...(indexUrl ? { index: { url: indexUrl } } : {}),
|
|
15
|
+
} as Node
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('getFirstPageUrl', () => {
|
|
19
|
+
test('returns first page url', () => {
|
|
20
|
+
expect(getFirstPageUrl([page('/docs/intro')])).toBe('/docs/intro')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('returns first page from nested folder', () => {
|
|
24
|
+
const nodes = [folder('Guides', [page('/docs/guides/install')])]
|
|
25
|
+
expect(getFirstPageUrl(nodes)).toBe('/docs/guides/install')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('skips empty folders', () => {
|
|
29
|
+
const nodes = [folder('Empty', []), page('/docs/hello')]
|
|
30
|
+
expect(getFirstPageUrl(nodes)).toBe('/docs/hello')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('returns null for empty list', () => {
|
|
34
|
+
expect(getFirstPageUrl([])).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('returns null for folders with no pages', () => {
|
|
38
|
+
expect(getFirstPageUrl([folder('Empty', [])])).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('findFolderFirstPage', () => {
|
|
43
|
+
test('finds folder by index url', () => {
|
|
44
|
+
const nodes = [
|
|
45
|
+
folder('Guides', [page('/docs/guides/install'), page('/docs/guides/config')], '/docs/guides'),
|
|
46
|
+
]
|
|
47
|
+
expect(findFolderFirstPage(nodes, '/docs/guides')).toBe('/docs/guides/install')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('finds folder without index by child page path', () => {
|
|
51
|
+
const nodes = [
|
|
52
|
+
folder('Guides', [page('/docs/guides/install'), page('/docs/guides/config')]),
|
|
53
|
+
]
|
|
54
|
+
expect(findFolderFirstPage(nodes, '/docs/guides')).toBe('/docs/guides/install')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('finds nested folder', () => {
|
|
58
|
+
const nodes = [
|
|
59
|
+
folder('Docs', [
|
|
60
|
+
folder('Advanced', [page('/docs/advanced/perf'), page('/docs/advanced/debug')]),
|
|
61
|
+
]),
|
|
62
|
+
]
|
|
63
|
+
expect(findFolderFirstPage(nodes, '/docs/advanced')).toBe('/docs/advanced/perf')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('returns null for non-matching path', () => {
|
|
67
|
+
const nodes = [folder('Guides', [page('/docs/guides/install')])]
|
|
68
|
+
expect(findFolderFirstPage(nodes, '/docs/api')).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('returns null for empty folder', () => {
|
|
72
|
+
const nodes = [folder('Empty', [])]
|
|
73
|
+
expect(findFolderFirstPage(nodes, '/docs/empty')).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('resolveDocsRedirect', () => {
|
|
78
|
+
const tree = {
|
|
79
|
+
children: [
|
|
80
|
+
page('/docs/intro'),
|
|
81
|
+
folder('Guides', [page('/docs/guides/install')]),
|
|
82
|
+
] as Node[],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('redirects to index_page when set', () => {
|
|
86
|
+
expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs', index_page: 'getting-started' }))
|
|
87
|
+
.toBe('/docs/getting-started')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('redirects content root to first page', () => {
|
|
91
|
+
expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs' }))
|
|
92
|
+
.toBe('/docs/intro')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('redirects folder to first child', () => {
|
|
96
|
+
expect(resolveDocsRedirect(['docs', 'guides'], tree, { dir: 'docs' }))
|
|
97
|
+
.toBe('/docs/guides/install')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('returns null for non-matching path', () => {
|
|
101
|
+
expect(resolveDocsRedirect(['docs', 'nonexistent'], tree, { dir: 'docs' }))
|
|
102
|
+
.toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('returns null without content config', () => {
|
|
106
|
+
expect(resolveDocsRedirect(['other'], tree)).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('index_page takes priority over first page', () => {
|
|
110
|
+
expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs', index_page: 'custom' }))
|
|
111
|
+
.toBe('/docs/custom')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Node } from 'fumadocs-core/page-tree';
|
|
2
|
+
|
|
3
|
+
export const NodeType = {
|
|
4
|
+
Page: 'page',
|
|
5
|
+
Folder: 'folder',
|
|
6
|
+
Separator: 'separator',
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export function getFirstPageUrl(nodes: Node[]): string | null {
|
|
10
|
+
for (const node of nodes) {
|
|
11
|
+
if (node.type === NodeType.Page) return node.url;
|
|
12
|
+
if (node.type === NodeType.Folder) {
|
|
13
|
+
const url = getFirstPageUrl(node.children);
|
|
14
|
+
if (url) return url;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getFolderPath(node: Node): string | null {
|
|
21
|
+
if (node.type !== NodeType.Folder) return null;
|
|
22
|
+
if (node.index) return node.index.url;
|
|
23
|
+
const firstPage = getFirstPageUrl(node.children);
|
|
24
|
+
if (!firstPage) return null;
|
|
25
|
+
const parts = firstPage.split('/').filter(Boolean);
|
|
26
|
+
parts.pop();
|
|
27
|
+
return '/' + parts.join('/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function findFolderFirstPage(nodes: Node[], pathname: string): string | null {
|
|
31
|
+
for (const node of nodes) {
|
|
32
|
+
if (node.type === NodeType.Folder) {
|
|
33
|
+
const folderPath = getFolderPath(node);
|
|
34
|
+
if (folderPath === pathname) return getFirstPageUrl(node.children);
|
|
35
|
+
const found = findFolderFirstPage(node.children, pathname);
|
|
36
|
+
if (found) return found;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveDocsRedirect(
|
|
43
|
+
slug: string[],
|
|
44
|
+
tree: { children: Node[] },
|
|
45
|
+
contentConfig?: { dir: string; index_page?: string },
|
|
46
|
+
): string | null {
|
|
47
|
+
const isContentRoot = slug.length === 1 && slug[0] === contentConfig?.dir;
|
|
48
|
+
|
|
49
|
+
if (isContentRoot) {
|
|
50
|
+
if (contentConfig?.index_page) {
|
|
51
|
+
return `/${contentConfig.dir}/${contentConfig.index_page}`;
|
|
52
|
+
}
|
|
53
|
+
return getFirstPageUrl(tree.children);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return findFolderFirstPage(tree.children, `/${slug.join('/')}`);
|
|
57
|
+
}
|
package/src/pages/DocsPage.tsx
CHANGED
|
@@ -1,32 +1,10 @@
|
|
|
1
1
|
import { Navigate } from 'react-router';
|
|
2
|
+
import { StatusCodes } from 'http-status-codes';
|
|
2
3
|
import { Head } from '@/lib/head';
|
|
3
4
|
import { usePageContext } from '@/lib/page-context';
|
|
5
|
+
import { resolveDocsRedirect } from '@/lib/tree-utils';
|
|
4
6
|
import { NotFound } from '@/pages/NotFound';
|
|
5
7
|
import { getTheme } from '@/themes/registry';
|
|
6
|
-
import type { Node } from 'fumadocs-core/page-tree';
|
|
7
|
-
|
|
8
|
-
function getFirstPageUrl(nodes: Node[]): string | null {
|
|
9
|
-
for (const node of nodes) {
|
|
10
|
-
if (node.type === 'page') return node.url;
|
|
11
|
-
if (node.type === 'folder') {
|
|
12
|
-
const url = getFirstPageUrl(node.children);
|
|
13
|
-
if (url) return url;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function findFolderFirstPage(nodes: Node[], pathname: string): string | null {
|
|
20
|
-
for (const node of nodes) {
|
|
21
|
-
if (node.type === 'folder') {
|
|
22
|
-
const folderUrl = node.index?.url;
|
|
23
|
-
if (folderUrl === pathname) return getFirstPageUrl(node.children);
|
|
24
|
-
const found = findFolderFirstPage(node.children, pathname);
|
|
25
|
-
if (found) return found;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
8
|
|
|
31
9
|
interface DocsPageProps {
|
|
32
10
|
slug: string[];
|
|
@@ -35,19 +13,10 @@ interface DocsPageProps {
|
|
|
35
13
|
export function DocsPage({ slug }: DocsPageProps) {
|
|
36
14
|
const { config, tree, page, isLoading, errorStatus } = usePageContext();
|
|
37
15
|
|
|
38
|
-
if (errorStatus ===
|
|
39
|
-
const pathname = `/${slug.join('/')}`;
|
|
16
|
+
if (errorStatus === StatusCodes.NOT_FOUND) {
|
|
40
17
|
const contentConfig = config.content?.find(c => c.dir === slug[0]);
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
return <Navigate to={`/${contentConfig.dir}/${contentConfig.index_page}`} replace />;
|
|
44
|
-
}
|
|
45
|
-
if (isContentRoot) {
|
|
46
|
-
const firstUrl = getFirstPageUrl(tree.children);
|
|
47
|
-
if (firstUrl) return <Navigate to={firstUrl} replace />;
|
|
48
|
-
}
|
|
49
|
-
const folderFirstUrl = findFolderFirstPage(tree.children, pathname);
|
|
50
|
-
if (folderFirstUrl) return <Navigate to={folderFirstUrl} replace />;
|
|
18
|
+
const redirectUrl = resolveDocsRedirect(slug, tree, contentConfig);
|
|
19
|
+
if (redirectUrl) return <Navigate to={redirectUrl} replace />;
|
|
51
20
|
return <NotFound />;
|
|
52
21
|
}
|
|
53
22
|
if (errorStatus) return <NotFound />;
|
|
@@ -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
|
});
|
|
@@ -10,6 +10,9 @@ import { loadApiSpecs } from '@/lib/openapi';
|
|
|
10
10
|
import { PageProvider } from '@/lib/page-context';
|
|
11
11
|
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
12
12
|
import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
|
|
13
|
+
import { getFirstApiUrl } from '@/lib/api-routes';
|
|
14
|
+
import { StatusCodes } from 'http-status-codes';
|
|
15
|
+
import { resolveDocsRedirect } from '@/lib/tree-utils';
|
|
13
16
|
import { useNitroApp } from 'nitro/app';
|
|
14
17
|
import { App } from './App';
|
|
15
18
|
|
|
@@ -45,6 +48,30 @@ export default {
|
|
|
45
48
|
getPageTree(),
|
|
46
49
|
route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
|
|
47
50
|
]);
|
|
51
|
+
// SSR redirects for index pages
|
|
52
|
+
if (route.type === RouteType.ApiIndex) {
|
|
53
|
+
const firstUrl = getFirstApiUrl(apiSpecs);
|
|
54
|
+
if (firstUrl) {
|
|
55
|
+
return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: firstUrl } });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (route.type === RouteType.DocsPage && !page) {
|
|
60
|
+
const versionPrefix = route.version.urlPrefix;
|
|
61
|
+
const slugWithoutVersion = versionPrefix && route.slug[0] === route.version.dir
|
|
62
|
+
? route.slug.slice(1)
|
|
63
|
+
: route.slug;
|
|
64
|
+
const contentEntries = route.version.dir
|
|
65
|
+
? config.versions?.find(v => v.dir === route.version.dir)?.content ?? config.content
|
|
66
|
+
: config.content;
|
|
67
|
+
const contentConfig = contentEntries?.find((c: { dir: string }) => c.dir === slugWithoutVersion[0]);
|
|
68
|
+
const redirectUrl = resolveDocsRedirect(slugWithoutVersion, tree, contentConfig);
|
|
69
|
+
if (redirectUrl) {
|
|
70
|
+
const fullUrl = versionPrefix ? `${versionPrefix}${redirectUrl}` : redirectUrl;
|
|
71
|
+
return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: fullUrl } });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
const nav = page ? await getPageNav(pageSlug) : { prev: null, next: null };
|
|
49
76
|
|
|
50
77
|
const relativePath = page ? getRelativePath(page) : null;
|
|
@@ -120,7 +147,7 @@ export default {
|
|
|
120
147
|
|
|
121
148
|
const renderDuration = performance.now() - renderStart;
|
|
122
149
|
|
|
123
|
-
const status = route.type === RouteType.DocsPage && !page ?
|
|
150
|
+
const status = route.type === RouteType.DocsPage && !page ? StatusCodes.NOT_FOUND : StatusCodes.OK;
|
|
124
151
|
|
|
125
152
|
// biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook
|
|
126
153
|
useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
|
|
@@ -12,6 +12,19 @@ import remarkResolveLinks from '../lib/remark-resolve-links';
|
|
|
12
12
|
import remarkReadingTime from 'remark-reading-time';
|
|
13
13
|
import remarkUnusedDirectives from '../lib/remark-unused-directives';
|
|
14
14
|
|
|
15
|
+
function getDatabaseConnector(preset?: string): { connector: string; options?: Record<string, unknown> } {
|
|
16
|
+
switch (preset) {
|
|
17
|
+
case 'bun':
|
|
18
|
+
return { connector: 'bun-sqlite', options: { name: 'chronicle-search' } };
|
|
19
|
+
case 'cloudflare':
|
|
20
|
+
case 'cloudflare-pages':
|
|
21
|
+
case 'cloudflare-module':
|
|
22
|
+
return { connector: 'cloudflare-d1', options: { bindingName: 'CHRONICLE_DB' } };
|
|
23
|
+
default:
|
|
24
|
+
return { connector: 'sqlite', options: { name: 'chronicle-search' } };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
function resolveOutputDir(projectRoot: string, preset?: string): string {
|
|
16
29
|
if (preset === 'vercel' || preset === 'vercel-static') return path.resolve(projectRoot, '.vercel/output');
|
|
17
30
|
return path.resolve(projectRoot, '.output');
|
|
@@ -136,6 +149,12 @@ export async function createViteConfig(
|
|
|
136
149
|
output: {
|
|
137
150
|
dir: resolveOutputDir(projectRoot, preset),
|
|
138
151
|
},
|
|
152
|
+
experimental: {
|
|
153
|
+
database: true,
|
|
154
|
+
},
|
|
155
|
+
database: {
|
|
156
|
+
default: getDatabaseConnector(preset),
|
|
157
|
+
},
|
|
139
158
|
},
|
|
140
159
|
};
|
|
141
160
|
}
|
|
@@ -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import uniqBy from 'lodash
|
|
1
|
+
import { uniqBy } from 'lodash-es'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
|
|
4
4
|
const logoSchema = z.object({
|
|
@@ -131,6 +131,12 @@ const RESERVED_ROUTE_SEGMENTS = [
|
|
|
131
131
|
'sitemap.xml',
|
|
132
132
|
] as const
|
|
133
133
|
|
|
134
|
+
const redirectSchema = z.object({
|
|
135
|
+
from: z.string(),
|
|
136
|
+
to: z.string(),
|
|
137
|
+
permanent: z.boolean().optional(),
|
|
138
|
+
})
|
|
139
|
+
|
|
134
140
|
export const chronicleConfigSchema = z
|
|
135
141
|
.object({
|
|
136
142
|
site: siteSchema,
|
|
@@ -144,6 +150,7 @@ export const chronicleConfigSchema = z
|
|
|
144
150
|
navigation: navigationSchema.optional(),
|
|
145
151
|
search: searchSchema.optional(),
|
|
146
152
|
api: z.array(apiSchema).optional(),
|
|
153
|
+
redirects: z.array(redirectSchema).optional(),
|
|
147
154
|
analytics: analyticsSchema.optional(),
|
|
148
155
|
telemetry: telemetrySchema.optional(),
|
|
149
156
|
})
|
|
@@ -225,6 +232,7 @@ export type SocialLink = z.infer<typeof socialLinkSchema>
|
|
|
225
232
|
export type SearchConfig = z.infer<typeof searchSchema>
|
|
226
233
|
export type ApiConfig = z.infer<typeof apiSchema>
|
|
227
234
|
export type ApiServerConfig = z.infer<typeof apiServerSchema>
|
|
235
|
+
export type RedirectConfig = z.infer<typeof redirectSchema>
|
|
228
236
|
export type ApiAuthConfig = z.infer<typeof apiAuthSchema>
|
|
229
237
|
export type AnalyticsConfig = z.infer<typeof analyticsSchema>
|
|
230
238
|
export type GoogleAnalyticsConfig = z.infer<typeof googleAnalyticsSchema>
|