@raystack/chronicle 0.10.1 → 0.10.2
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/package.json +2 -1
- package/src/components/api/api-overview.tsx +2 -2
- package/src/components/api/playground-dialog.tsx +28 -6
- package/src/components/ui/PrefetchProvider.tsx +70 -0
- package/src/components/ui/search.module.css +6 -0
- package/src/components/ui/search.tsx +4 -1
- package/src/lib/env.ts +9 -0
- package/src/lib/openapi.ts +2 -1
- package/src/lib/page-context.tsx +11 -6
- package/src/lib/preload.ts +37 -0
- package/src/lib/source.ts +21 -2
- package/src/pages/DocsLayout.tsx +11 -8
- package/src/server/App.module.css +4 -0
- package/src/server/App.tsx +32 -15
- package/src/server/api/page.ts +2 -2
- package/src/server/api/search.ts +16 -6
- package/src/server/entry-client.tsx +18 -14
- package/src/server/entry-server.tsx +4 -2
- package/src/themes/default/Layout.tsx +20 -15
- package/src/themes/default/Page.tsx +6 -2
- package/src/types/content.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raystack/chronicle",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Config-driven documentation framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"@radix-ui/react-icons": "^1.3.2",
|
|
50
50
|
"@raystack/apsara": "1.0.0-rc.7",
|
|
51
51
|
"@shikijs/rehype": "^4.0.2",
|
|
52
|
+
"@tanstack/react-query": "5.100.10",
|
|
52
53
|
"@vitejs/plugin-react": "^6.0.1",
|
|
53
54
|
"chalk": "^5.6.2",
|
|
54
55
|
"class-variance-authority": "^0.7.1",
|
|
@@ -21,7 +21,7 @@ interface ApiOverviewProps {
|
|
|
21
21
|
auth?: { type: string; header: string; placeholder?: string }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function ApiOverview({ method, path, operation, auth }: ApiOverviewProps) {
|
|
24
|
+
export function ApiOverview({ method, path, operation, serverUrl, auth }: ApiOverviewProps) {
|
|
25
25
|
const params = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[]
|
|
26
26
|
const body = getRequestBody(operation.requestBody as OpenAPIV3.RequestBodyObject | undefined)
|
|
27
27
|
|
|
@@ -36,7 +36,7 @@ export function ApiOverview({ method, path, operation, auth }: ApiOverviewProps)
|
|
|
36
36
|
? headerFields
|
|
37
37
|
: []
|
|
38
38
|
|
|
39
|
-
const fullUrl =
|
|
39
|
+
const fullUrl = serverUrl + path
|
|
40
40
|
const snippetHeaders: Record<string, string> = {}
|
|
41
41
|
if (auth) snippetHeaders[auth.header] = auth.placeholder ?? 'YOUR_API_KEY'
|
|
42
42
|
if (body) snippetHeaders['Content-Type'] = body.contentType ?? 'application/json'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback, useMemo } from 'react'
|
|
3
|
+
import { useState, useCallback, useMemo, useEffect } from 'react'
|
|
4
4
|
import type { OpenAPIV3 } from 'openapi-types'
|
|
5
5
|
import { Dialog, Button, Badge, IconButton, Input, CopyButton, Select, Menu } from '@raystack/apsara'
|
|
6
6
|
import { Cross2Icon, ChevronDownIcon, ChevronUpIcon, PlayIcon, PlusIcon } from '@radix-ui/react-icons'
|
|
@@ -68,11 +68,21 @@ export function PlaygroundDialog({
|
|
|
68
68
|
|
|
69
69
|
const authSchemes = useMemo(() => getAuthSchemes(document, auth), [document, auth])
|
|
70
70
|
const defaultScheme = authSchemes.find((s) => s.type !== 'none') ?? authSchemes[0]
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
const storageKey = `chronicle:auth:${specName}`
|
|
72
|
+
const savedAuth = useMemo(() => {
|
|
73
|
+
try {
|
|
74
|
+
const raw = sessionStorage.getItem(storageKey)
|
|
75
|
+
return raw ? JSON.parse(raw) : null
|
|
76
|
+
} catch { return null }
|
|
77
|
+
}, [storageKey])
|
|
78
|
+
|
|
79
|
+
const [selectedScheme, setSelectedScheme] = useState(() => {
|
|
80
|
+
if (savedAuth?.scheme && authSchemes.some((s) => s.name === savedAuth.scheme)) return savedAuth.scheme
|
|
81
|
+
return defaultScheme.name
|
|
82
|
+
})
|
|
83
|
+
const [authToken, setAuthToken] = useState(savedAuth?.token ?? '')
|
|
84
|
+
const [basicUser, setBasicUser] = useState(savedAuth?.basicUser ?? '')
|
|
85
|
+
const [basicPass, setBasicPass] = useState(savedAuth?.basicPass ?? '')
|
|
76
86
|
const [headerValues, setHeaderValues] = useState<Record<string, string>>({})
|
|
77
87
|
const [pathValues, setPathValues] = useState<Record<string, string>>({})
|
|
78
88
|
const [queryValues, setQueryValues] = useState<Record<string, string>>({})
|
|
@@ -89,6 +99,17 @@ export function PlaygroundDialog({
|
|
|
89
99
|
})
|
|
90
100
|
const [bodyJsonStr, setBodyJsonStr] = useState(() => body ? body.jsonExample : '{}')
|
|
91
101
|
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
try {
|
|
104
|
+
sessionStorage.setItem(storageKey, JSON.stringify({
|
|
105
|
+
scheme: selectedScheme,
|
|
106
|
+
token: authToken,
|
|
107
|
+
basicUser,
|
|
108
|
+
basicPass,
|
|
109
|
+
}))
|
|
110
|
+
} catch { /* ignore */ }
|
|
111
|
+
}, [storageKey, selectedScheme, authToken, basicUser, basicPass])
|
|
112
|
+
|
|
92
113
|
const [responseData, setResponseData] = useState<{
|
|
93
114
|
status: number; statusText: string; body: unknown; headers?: Record<string, string>; time: number
|
|
94
115
|
} | null>(null)
|
|
@@ -119,6 +140,7 @@ export function PlaygroundDialog({
|
|
|
119
140
|
setAuthToken('')
|
|
120
141
|
setBasicUser('')
|
|
121
142
|
setBasicPass('')
|
|
143
|
+
try { sessionStorage.removeItem(storageKey) } catch { /* ignore */ }
|
|
122
144
|
setHeaderValues({})
|
|
123
145
|
setPathValues({})
|
|
124
146
|
setQueryValues({})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { prefetchPageData } from '@/lib/preload';
|
|
3
|
+
|
|
4
|
+
function resolvePathname(href: string | null): string | null {
|
|
5
|
+
if (!href) return null;
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(href, location.href);
|
|
8
|
+
if (url.origin !== location.origin) return null;
|
|
9
|
+
return url.pathname;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PrefetchProvider({ children }: { children: React.ReactNode }) {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const handleMouseOver = (e: MouseEvent) => {
|
|
18
|
+
const anchor = (e.target as HTMLElement).closest?.('a[href]');
|
|
19
|
+
if (!anchor) return;
|
|
20
|
+
const pathname = resolvePathname(anchor.getAttribute('href'));
|
|
21
|
+
if (pathname) prefetchPageData(pathname);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const handleFocusIn = (e: FocusEvent) => {
|
|
25
|
+
const anchor = (e.target as HTMLElement).closest?.('a[href]');
|
|
26
|
+
if (!anchor) return;
|
|
27
|
+
const pathname = resolvePathname(anchor.getAttribute('href'));
|
|
28
|
+
if (pathname) prefetchPageData(pathname);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
document.addEventListener('mouseover', handleMouseOver);
|
|
32
|
+
document.addEventListener('focusin', handleFocusIn);
|
|
33
|
+
|
|
34
|
+
const observer = new IntersectionObserver(
|
|
35
|
+
(entries) => {
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.isIntersecting) {
|
|
38
|
+
const pathname = resolvePathname((entry.target as HTMLAnchorElement).getAttribute('href'));
|
|
39
|
+
if (pathname) prefetchPageData(pathname);
|
|
40
|
+
observer.unobserve(entry.target);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{ rootMargin: '200px' },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const observeLinks = () => {
|
|
48
|
+
document.querySelectorAll('a[href]:not([data-prefetch-observed])').forEach((link) => {
|
|
49
|
+
const pathname = resolvePathname(link.getAttribute('href'));
|
|
50
|
+
if (pathname) {
|
|
51
|
+
link.setAttribute('data-prefetch-observed', '');
|
|
52
|
+
observer.observe(link);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const mutationObserver = new MutationObserver(observeLinks);
|
|
58
|
+
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
|
59
|
+
observeLinks();
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
document.removeEventListener('mouseover', handleMouseOver);
|
|
63
|
+
document.removeEventListener('focusin', handleFocusIn);
|
|
64
|
+
observer.disconnect();
|
|
65
|
+
mutationObserver.disconnect();
|
|
66
|
+
};
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
return children;
|
|
70
|
+
}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
HashtagIcon,
|
|
4
4
|
MagnifyingGlassIcon
|
|
5
5
|
} from '@heroicons/react/24/outline';
|
|
6
|
-
import { Command, IconButton, Text } from '@raystack/apsara';
|
|
6
|
+
import { Badge, Command, IconButton, Text } from '@raystack/apsara';
|
|
7
7
|
import { debounce } from 'lodash-es';
|
|
8
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
9
|
import { useNavigate } from 'react-router';
|
|
@@ -18,6 +18,7 @@ interface SearchResult {
|
|
|
18
18
|
content: string;
|
|
19
19
|
match?: 'title' | 'heading' | 'body';
|
|
20
20
|
snippet?: string;
|
|
21
|
+
section?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
interface SearchProps {
|
|
@@ -157,6 +158,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
157
158
|
html={stripMethod(result.content)}
|
|
158
159
|
/>
|
|
159
160
|
</Text>
|
|
161
|
+
{result.section && <Badge size="small" className={styles.sectionBadge}>{result.section}</Badge>}
|
|
160
162
|
</div>
|
|
161
163
|
</Command.Item>
|
|
162
164
|
))}
|
|
@@ -187,6 +189,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
187
189
|
</Text>
|
|
188
190
|
)}
|
|
189
191
|
</div>
|
|
192
|
+
{result.section && <Badge size="small" className={styles.sectionBadge}>{result.section}</Badge>}
|
|
190
193
|
</div>
|
|
191
194
|
</Command.Item>
|
|
192
195
|
))}
|
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function substituteEnvVars(value: string): string {
|
|
2
|
+
return value.replace(/\$\{(\w+)\}/g, (_, name) => {
|
|
3
|
+
const val = process.env[name];
|
|
4
|
+
if (val === undefined) {
|
|
5
|
+
throw new Error(`Environment variable '${name}' is not set`);
|
|
6
|
+
}
|
|
7
|
+
return val;
|
|
8
|
+
});
|
|
9
|
+
}
|
package/src/lib/openapi.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { parse as parseYaml } from 'yaml'
|
|
4
4
|
import type { OpenAPIV2, OpenAPIV3 } from 'openapi-types'
|
|
5
5
|
import type { ApiConfig, ApiServerConfig, ApiAuthConfig } from '@/types/config'
|
|
6
|
+
import { substituteEnvVars } from '@/lib/env'
|
|
6
7
|
|
|
7
8
|
type JsonObject = Record<string, unknown>
|
|
8
9
|
|
|
@@ -41,7 +42,7 @@ export async function loadApiSpec(config: ApiConfig, projectRoot: string): Promi
|
|
|
41
42
|
return {
|
|
42
43
|
name: config.name,
|
|
43
44
|
basePath: config.basePath,
|
|
44
|
-
server: config.server,
|
|
45
|
+
server: { ...config.server, url: substituteEnvVars(config.server.url) },
|
|
45
46
|
auth: config.auth,
|
|
46
47
|
document: v3Doc,
|
|
47
48
|
}
|
package/src/lib/page-context.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
|
13
13
|
import type { VersionContext } from '@/lib/version-source';
|
|
14
14
|
import { LATEST_CONTEXT } from '@/lib/version-source';
|
|
15
15
|
import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types';
|
|
16
|
+
import { queryClient } from '@/lib/preload';
|
|
16
17
|
|
|
17
18
|
export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
|
|
18
19
|
|
|
@@ -114,12 +115,16 @@ export function PageProvider({
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
const fetchPageData = useCallback(async (slug: string[]): Promise<PageData> => {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
|
|
119
|
+
const apiPath = key ? `/api/page?slug=${key}` : '/api/page';
|
|
120
|
+
return queryClient.fetchQuery({
|
|
121
|
+
queryKey: ['pageData', key],
|
|
122
|
+
queryFn: async () => {
|
|
123
|
+
const res = await fetch(apiPath);
|
|
124
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
125
|
+
return res.json();
|
|
126
|
+
},
|
|
127
|
+
});
|
|
123
128
|
}, []);
|
|
124
129
|
|
|
125
130
|
const loadDocsPage = useCallback(async (slug: string[], cancelled: { current: boolean }) => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
export const queryClient = new QueryClient({
|
|
4
|
+
defaultOptions: {
|
|
5
|
+
queries: {
|
|
6
|
+
staleTime: Infinity,
|
|
7
|
+
refetchOnWindowFocus: false,
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function pageDataQueryKey(pathname: string) {
|
|
13
|
+
const slug = pathname.split('/').filter(Boolean);
|
|
14
|
+
const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
|
|
15
|
+
return ['pageData', key] as const;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function fetchPageDataByPathname(pathname: string) {
|
|
19
|
+
const slug = pathname.split('/').filter(Boolean);
|
|
20
|
+
const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
|
|
21
|
+
const apiPath = key ? `/api/page?slug=${key}` : '/api/page';
|
|
22
|
+
const res = await fetch(apiPath);
|
|
23
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
24
|
+
return res.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isApisRoute(pathname: string): boolean {
|
|
28
|
+
return pathname === '/apis' || pathname.startsWith('/apis/');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function prefetchPageData(pathname: string) {
|
|
32
|
+
if (isApisRoute(pathname)) return;
|
|
33
|
+
queryClient.prefetchQuery({
|
|
34
|
+
queryKey: pageDataQueryKey(pathname),
|
|
35
|
+
queryFn: () => fetchPageDataByPathname(pathname),
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/lib/source.ts
CHANGED
|
@@ -174,17 +174,31 @@ function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[], me
|
|
|
174
174
|
return { ...tree, children: sortNodes(tree.children, pageOrderMap, folderOrderMap) };
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
function filterDraftsFromTree(tree: Root, draftUrls: Set<string>): Root {
|
|
178
|
+
function filterNodes(nodes: Node[]): Node[] {
|
|
179
|
+
return nodes
|
|
180
|
+
.filter(n => n.type !== NodeType.Page || !draftUrls.has(n.url))
|
|
181
|
+
.map(n => n.type === NodeType.Folder
|
|
182
|
+
? { ...n, children: filterNodes(n.children) } as Folder
|
|
183
|
+
: n
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return { ...tree, children: filterNodes(tree.children) };
|
|
187
|
+
}
|
|
188
|
+
|
|
177
189
|
export async function getPageTree(): Promise<Root> {
|
|
178
190
|
if (cachedTree) return cachedTree;
|
|
179
191
|
const s = await getSource();
|
|
180
192
|
const metaFiles = buildFiles().filter(f => f.type === 'meta') as { path: string; data: Record<string, unknown> }[];
|
|
181
|
-
|
|
193
|
+
const sorted = sortTreeByOrder(s.pageTree as Root, s.getPages(), metaFiles);
|
|
194
|
+
const draftUrls = new Set(s.getPages().filter(p => isDraft(p)).map(p => p.url));
|
|
195
|
+
cachedTree = draftUrls.size > 0 ? filterDraftsFromTree(sorted, draftUrls) : sorted;
|
|
182
196
|
return cachedTree;
|
|
183
197
|
}
|
|
184
198
|
|
|
185
199
|
export async function getPages() {
|
|
186
200
|
const s = await getSource();
|
|
187
|
-
return s.getPages();
|
|
201
|
+
return s.getPages().filter(p => !isDraft(p));
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
export async function getPage(slugs?: string[]) {
|
|
@@ -254,10 +268,15 @@ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: stri
|
|
|
254
268
|
order: d.order as number | undefined,
|
|
255
269
|
icon: d.icon as string | undefined,
|
|
256
270
|
lastModified: d.lastModified as string | undefined,
|
|
271
|
+
draft: d.draft as boolean | undefined,
|
|
257
272
|
_readingTime: d._readingTime as number | undefined,
|
|
258
273
|
};
|
|
259
274
|
}
|
|
260
275
|
|
|
276
|
+
export function isDraft(page: { data: unknown }): boolean {
|
|
277
|
+
return (page.data as Record<string, unknown>).draft === true;
|
|
278
|
+
}
|
|
279
|
+
|
|
261
280
|
export function getRelativePath(page: { data: unknown }): string {
|
|
262
281
|
return ((page.data as Record<string, unknown>)._relativePath as string) ?? '';
|
|
263
282
|
}
|
package/src/pages/DocsLayout.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import { useLocation } from 'react-router';
|
|
3
|
+
import { PrefetchProvider } from '@/components/ui/PrefetchProvider';
|
|
3
4
|
import { usePageContext } from '@/lib/page-context';
|
|
4
5
|
import { getActiveContentDir } from '@/lib/navigation';
|
|
5
6
|
import {
|
|
@@ -27,13 +28,15 @@ export function DocsLayout({ children, hideSidebar }: DocsLayoutProps) {
|
|
|
27
28
|
);
|
|
28
29
|
|
|
29
30
|
return (
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
<PrefetchProvider>
|
|
32
|
+
<Layout
|
|
33
|
+
config={config}
|
|
34
|
+
tree={scopedTree}
|
|
35
|
+
hideSidebar={hideSidebar}
|
|
36
|
+
classNames={{ layout: className }}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</Layout>
|
|
40
|
+
</PrefetchProvider>
|
|
38
41
|
);
|
|
39
42
|
}
|
package/src/server/App.tsx
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import '@raystack/apsara/normalize.css';
|
|
2
2
|
import '@raystack/apsara/style.css';
|
|
3
|
-
import { ThemeProvider } from '@raystack/apsara';
|
|
3
|
+
import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
|
|
4
|
+
import { lazy, Suspense } from 'react';
|
|
4
5
|
import { Navigate, useLocation } from 'react-router';
|
|
5
6
|
import { Head } from '@/lib/head';
|
|
6
7
|
import { usePageContext } from '@/lib/page-context';
|
|
7
8
|
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
8
|
-
import { ApiLayout } from '@/pages/ApiLayout';
|
|
9
|
-
import { ApiPage } from '@/pages/ApiPage';
|
|
10
|
-
import { DocsLayout } from '@/pages/DocsLayout';
|
|
11
|
-
import { DocsPage } from '@/pages/DocsPage';
|
|
12
|
-
import { LandingPage } from '@/pages/LandingPage';
|
|
13
9
|
import type { ChronicleConfig } from '@/types';
|
|
14
10
|
import { getThemeConfig } from '@/themes/registry';
|
|
11
|
+
import styles from './App.module.css';
|
|
12
|
+
|
|
13
|
+
const ApiLayout = lazy(() => import('@/pages/ApiLayout').then(m => ({ default: m.ApiLayout })));
|
|
14
|
+
const ApiPage = lazy(() => import('@/pages/ApiPage').then(m => ({ default: m.ApiPage })));
|
|
15
|
+
const DocsLayout = lazy(() => import('@/pages/DocsLayout').then(m => ({ default: m.DocsLayout })));
|
|
16
|
+
const DocsPage = lazy(() => import('@/pages/DocsPage').then(m => ({ default: m.DocsPage })));
|
|
17
|
+
const LandingPage = lazy(() => import('@/pages/LandingPage').then(m => ({ default: m.LandingPage })));
|
|
15
18
|
|
|
16
19
|
export function App() {
|
|
17
20
|
const { pathname } = useLocation();
|
|
@@ -35,19 +38,33 @@ export function App() {
|
|
|
35
38
|
forcedTheme={themeConfig.forcedTheme}
|
|
36
39
|
>
|
|
37
40
|
<RootHead config={config} />
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
<Suspense fallback={<PageFallback />}>
|
|
42
|
+
{isApi ? (
|
|
43
|
+
<ApiLayout>
|
|
44
|
+
<ApiPage slug={apiSlug} />
|
|
45
|
+
</ApiLayout>
|
|
46
|
+
) : (
|
|
47
|
+
<DocsLayout hideSidebar={isLanding}>
|
|
48
|
+
{isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
|
|
49
|
+
</DocsLayout>
|
|
50
|
+
)}
|
|
51
|
+
</Suspense>
|
|
47
52
|
</ThemeProvider>
|
|
48
53
|
);
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
function PageFallback() {
|
|
57
|
+
return (
|
|
58
|
+
<Flex direction="column" gap={4} className={styles.fallback}>
|
|
59
|
+
<Skeleton width="40%" height="var(--rs-line-height-t2)" />
|
|
60
|
+
<Skeleton width="60%" height="var(--rs-line-height-regular)" />
|
|
61
|
+
{[...new Array(12)].map((_, i) => (
|
|
62
|
+
<Skeleton key={i} width="100%" height="var(--rs-line-height-regular)" />
|
|
63
|
+
))}
|
|
64
|
+
</Flex>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
function RootHead({ config }: { config: ChronicleConfig }) {
|
|
52
69
|
return (
|
|
53
70
|
<Head
|
package/src/server/api/page.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defineHandler, HTTPError } from 'nitro';
|
|
2
|
-
import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
|
|
2
|
+
import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, isDraft } from '@/lib/source';
|
|
3
3
|
|
|
4
4
|
export default defineHandler(async event => {
|
|
5
5
|
const slugParam = event.url.searchParams.get('slug') ?? '';
|
|
6
6
|
const slug = slugParam ? slugParam.split(',').filter(Boolean) : [];
|
|
7
7
|
const page = await getPage(slug);
|
|
8
8
|
|
|
9
|
-
if (!page) {
|
|
9
|
+
if (!page || isDraft(page)) {
|
|
10
10
|
throw new HTTPError({ status: 404, message: 'Page not found' });
|
|
11
11
|
}
|
|
12
12
|
|
package/src/server/api/search.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface SearchDocument {
|
|
|
14
14
|
headings: string;
|
|
15
15
|
body: string;
|
|
16
16
|
type: 'page' | 'api';
|
|
17
|
+
section: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
import fs from 'node:fs/promises';
|
|
@@ -61,7 +62,8 @@ async function buildIndex(ctx: VersionContext, key: string) {
|
|
|
61
62
|
headings TEXT NOT NULL,
|
|
62
63
|
body TEXT NOT NULL,
|
|
63
64
|
type TEXT NOT NULL,
|
|
64
|
-
version TEXT NOT NULL
|
|
65
|
+
version TEXT NOT NULL,
|
|
66
|
+
section TEXT NOT NULL DEFAULT ''
|
|
65
67
|
)`);
|
|
66
68
|
|
|
67
69
|
await db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
|
|
@@ -74,8 +76,8 @@ async function buildIndex(ctx: VersionContext, key: string) {
|
|
|
74
76
|
|
|
75
77
|
const docs = await buildDocs(ctx);
|
|
76
78
|
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
|
+
await db.sql`INSERT INTO search_docs (id, url, title, headings, body, type, version, section)
|
|
80
|
+
VALUES (${doc.id}, ${doc.url}, ${doc.title}, ${doc.headings}, ${doc.body}, ${doc.type}, ${key}, ${doc.section})`;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
await db.sql`INSERT INTO search_fts (rowid, title, headings, body)
|
|
@@ -86,11 +88,15 @@ async function buildIndex(ctx: VersionContext, key: string) {
|
|
|
86
88
|
|
|
87
89
|
async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
|
|
88
90
|
const docs: SearchDocument[] = [];
|
|
91
|
+
const config = loadConfig();
|
|
92
|
+
const contentEntries = config.content ?? [];
|
|
89
93
|
|
|
90
94
|
const pages = await getPagesForVersion(ctx);
|
|
91
95
|
for (const p of pages) {
|
|
92
96
|
const fm = extractFrontmatter(p);
|
|
93
97
|
const { headings, body } = await getPageSearchContent(p);
|
|
98
|
+
const dir = p.url.replace(/^\//, '').split('/')[0];
|
|
99
|
+
const entry = contentEntries.find(c => c.dir === dir);
|
|
94
100
|
docs.push({
|
|
95
101
|
id: p.url,
|
|
96
102
|
url: p.url,
|
|
@@ -98,10 +104,10 @@ async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
|
|
|
98
104
|
headings,
|
|
99
105
|
body: [fm.description ?? '', body].join(' '),
|
|
100
106
|
type: 'page',
|
|
107
|
+
section: entry?.label ?? dir ?? '',
|
|
101
108
|
});
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
const config = loadConfig();
|
|
105
111
|
const apiConfigs = getApiConfigsForVersion(config, ctx.dir);
|
|
106
112
|
if (apiConfigs.length) {
|
|
107
113
|
const specs = await loadApiSpecs(apiConfigs);
|
|
@@ -122,6 +128,7 @@ async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
|
|
|
122
128
|
headings: op.summary ?? opId,
|
|
123
129
|
body: [op.description ?? '', pathStr, method.toUpperCase()].join(' '),
|
|
124
130
|
type: 'api',
|
|
131
|
+
section: spec.name,
|
|
125
132
|
});
|
|
126
133
|
}
|
|
127
134
|
}
|
|
@@ -183,7 +190,7 @@ export default defineHandler(async event => {
|
|
|
183
190
|
const key = versionKey(ctx);
|
|
184
191
|
|
|
185
192
|
if (!query) {
|
|
186
|
-
const result = await db.sql`SELECT id, url, title, type FROM search_docs
|
|
193
|
+
const result = await db.sql`SELECT id, url, title, type, section FROM search_docs
|
|
187
194
|
WHERE version = ${key} AND type = 'page'
|
|
188
195
|
LIMIT 8`;
|
|
189
196
|
return Response.json((result.rows ?? []).map(r => ({
|
|
@@ -191,11 +198,12 @@ export default defineHandler(async event => {
|
|
|
191
198
|
url: r.url,
|
|
192
199
|
type: r.type,
|
|
193
200
|
content: r.title,
|
|
201
|
+
section: r.section || null,
|
|
194
202
|
})));
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
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,
|
|
206
|
+
const result = await db.sql`SELECT s.id, s.url, s.title, s.headings, s.body, s.type, s.section,
|
|
199
207
|
bm25(search_fts, 10.0, 5.0, 1.0) AS score
|
|
200
208
|
FROM search_fts f
|
|
201
209
|
JOIN search_docs s ON s.rowid = f.rowid
|
|
@@ -214,6 +222,8 @@ export default defineHandler(async event => {
|
|
|
214
222
|
content: r.title,
|
|
215
223
|
match,
|
|
216
224
|
snippet,
|
|
225
|
+
section: r.section || null,
|
|
217
226
|
};
|
|
218
227
|
}));
|
|
219
228
|
});
|
|
229
|
+
|
|
@@ -3,9 +3,11 @@ import React from 'react';
|
|
|
3
3
|
import { hydrateRoot } from 'react-dom/client';
|
|
4
4
|
import { BrowserRouter } from 'react-router';
|
|
5
5
|
import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
|
|
6
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
6
7
|
import { mdxComponents } from '@/components/mdx';
|
|
7
8
|
import { getApiConfigsForVersion } from '@/lib/config';
|
|
8
9
|
import { PageProvider } from '@/lib/page-context';
|
|
10
|
+
import { queryClient } from '@/lib/preload';
|
|
9
11
|
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
10
12
|
import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source';
|
|
11
13
|
import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types';
|
|
@@ -93,20 +95,22 @@ async function hydrate() {
|
|
|
93
95
|
|
|
94
96
|
hydrateRoot(
|
|
95
97
|
document.getElementById('root') as HTMLElement,
|
|
96
|
-
<
|
|
97
|
-
<
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
<QueryClientProvider client={queryClient}>
|
|
99
|
+
<BrowserRouter>
|
|
100
|
+
<ReactRouterProvider>
|
|
101
|
+
<PageProvider
|
|
102
|
+
initialConfig={config}
|
|
103
|
+
initialTree={tree}
|
|
104
|
+
initialPage={page}
|
|
105
|
+
initialApiSpecs={apiSpecs}
|
|
106
|
+
initialVersion={version}
|
|
107
|
+
loadMdx={loadMdxModule}
|
|
108
|
+
>
|
|
109
|
+
<App />
|
|
110
|
+
</PageProvider>
|
|
111
|
+
</ReactRouterProvider>
|
|
112
|
+
</BrowserRouter>
|
|
113
|
+
</QueryClientProvider>
|
|
110
114
|
);
|
|
111
115
|
} catch (err) {
|
|
112
116
|
console.error('Hydration failed:', err);
|
|
@@ -9,7 +9,7 @@ import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
|
|
|
9
9
|
import { loadApiSpecs } from '@/lib/openapi';
|
|
10
10
|
import { PageProvider } from '@/lib/page-context';
|
|
11
11
|
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
12
|
-
import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
|
|
12
|
+
import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath, isDraft } from '@/lib/source';
|
|
13
13
|
import { getFirstApiUrl } from '@/lib/api-routes';
|
|
14
14
|
import { StatusCodes } from 'http-status-codes';
|
|
15
15
|
import { resolveDocsRedirect } from '@/lib/tree-utils';
|
|
@@ -44,10 +44,12 @@ export default {
|
|
|
44
44
|
: [];
|
|
45
45
|
const apiSpecs = apiConfigs.length ? await loadApiSpecs(apiConfigs) : [];
|
|
46
46
|
|
|
47
|
-
const [tree,
|
|
47
|
+
const [tree, rawPage] = await Promise.all([
|
|
48
48
|
getPageTree(),
|
|
49
49
|
route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
|
|
50
50
|
]);
|
|
51
|
+
const page = rawPage && isDraft(rawPage) ? null : rawPage;
|
|
52
|
+
|
|
51
53
|
// SSR redirects for index pages
|
|
52
54
|
if (route.type === RouteType.ApiIndex) {
|
|
53
55
|
const firstUrl = getFirstApiUrl(apiSpecs);
|
|
@@ -9,12 +9,13 @@ import {
|
|
|
9
9
|
import { Flex, IconButton, Button, Sidebar } from '@raystack/apsara';
|
|
10
10
|
import { PlayIcon } from '@radix-ui/react-icons';
|
|
11
11
|
import { cx } from 'class-variance-authority';
|
|
12
|
-
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
12
|
+
import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react';
|
|
13
13
|
import { Link as RouterLink, useLocation, useNavigate } from 'react-router';
|
|
14
14
|
import type { OpenAPIV3 } from 'openapi-types';
|
|
15
15
|
import { MethodBadge } from '@/components/api/method-badge';
|
|
16
16
|
import { useApiOperation } from '@/lib/use-api-operation';
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
const PlaygroundDialog = lazy(() => import('@/components/api/playground-dialog').then(m => ({ default: m.PlaygroundDialog })));
|
|
18
19
|
import { ClientThemeSwitcher } from '@/components/ui/client-theme-switcher';
|
|
19
20
|
import { Search } from '@/components/ui/search';
|
|
20
21
|
import { Breadcrumbs } from '@/components/ui/breadcrumbs';
|
|
@@ -144,7 +145,7 @@ export function Layout({
|
|
|
144
145
|
))}
|
|
145
146
|
{apiEntries.map(api => (
|
|
146
147
|
<Sidebar.Item
|
|
147
|
-
key={api.basePath}
|
|
148
|
+
key={`${api.basePath}-${api.name}`}
|
|
148
149
|
href={api.basePath}
|
|
149
150
|
active={isApiBase(api.basePath)}
|
|
150
151
|
leadingIcon={renderConfigIcon(
|
|
@@ -371,18 +372,22 @@ function TestRequestButton() {
|
|
|
371
372
|
>
|
|
372
373
|
Test request
|
|
373
374
|
</Button>
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
375
|
+
{open && (
|
|
376
|
+
<Suspense fallback={null}>
|
|
377
|
+
<PlaygroundDialog
|
|
378
|
+
key={`${match.spec.name}-${match.path}-${match.method}`}
|
|
379
|
+
open={open}
|
|
380
|
+
onOpenChange={setOpen}
|
|
381
|
+
method={match.method}
|
|
382
|
+
path={match.path}
|
|
383
|
+
operation={match.operation}
|
|
384
|
+
serverUrl={match.spec.server.url}
|
|
385
|
+
specName={match.spec.name}
|
|
386
|
+
auth={match.spec.auth}
|
|
387
|
+
document={match.spec.document}
|
|
388
|
+
/>
|
|
389
|
+
</Suspense>
|
|
390
|
+
)}
|
|
386
391
|
</>
|
|
387
392
|
);
|
|
388
393
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Flex, Headline } from '@raystack/apsara';
|
|
4
|
+
import { lazy, Suspense } from 'react';
|
|
4
5
|
import type { ThemePageProps } from '@/types';
|
|
5
6
|
import styles from './Page.module.css';
|
|
6
|
-
|
|
7
|
+
|
|
8
|
+
const Toc = lazy(() => import('./Toc').then(m => ({ default: m.Toc })));
|
|
7
9
|
|
|
8
10
|
export function Page({ page }: ThemePageProps) {
|
|
9
11
|
return (
|
|
@@ -16,7 +18,9 @@ export function Page({ page }: ThemePageProps) {
|
|
|
16
18
|
)}
|
|
17
19
|
<div className={styles.content}>{page.content}</div>
|
|
18
20
|
</article>
|
|
19
|
-
<
|
|
21
|
+
<Suspense fallback={null}>
|
|
22
|
+
<Toc items={page.toc} />
|
|
23
|
+
</Suspense>
|
|
20
24
|
</Flex>
|
|
21
25
|
);
|
|
22
26
|
}
|