@raystack/chronicle 0.3.0 → 0.5.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 +425 -9937
- package/package.json +19 -10
- package/src/cli/commands/build.ts +33 -31
- package/src/cli/commands/dev.ts +23 -31
- package/src/cli/commands/init.ts +38 -132
- package/src/cli/commands/serve.ts +41 -55
- package/src/cli/commands/start.ts +20 -31
- package/src/cli/index.ts +14 -14
- package/src/cli/utils/config.ts +58 -30
- package/src/cli/utils/index.ts +3 -3
- package/src/cli/utils/resolve.ts +7 -3
- package/src/cli/utils/scaffold.ts +11 -130
- package/src/components/mdx/code.tsx +10 -1
- package/src/components/mdx/details.module.css +1 -26
- package/src/components/mdx/details.tsx +2 -3
- package/src/components/mdx/image.tsx +5 -34
- package/src/components/mdx/index.tsx +15 -1
- package/src/components/mdx/link.tsx +18 -15
- package/src/components/ui/breadcrumbs.tsx +8 -42
- package/src/components/ui/search.tsx +63 -51
- package/src/lib/api-routes.ts +6 -8
- package/src/lib/config.ts +12 -36
- package/src/lib/head.tsx +49 -0
- package/src/lib/openapi.ts +8 -8
- package/src/lib/page-context.tsx +111 -0
- package/src/lib/remark-strip-md-extensions.ts +14 -0
- package/src/lib/source.ts +139 -63
- package/src/pages/ApiLayout.tsx +33 -0
- package/src/pages/ApiPage.tsx +73 -0
- package/src/pages/DocsLayout.tsx +18 -0
- package/src/pages/DocsPage.tsx +43 -0
- package/src/pages/NotFound.tsx +17 -0
- package/src/server/App.tsx +72 -0
- package/src/server/api/apis-proxy.ts +69 -0
- package/src/server/api/health.ts +5 -0
- package/src/server/api/page/[...slug].ts +18 -0
- package/src/server/api/search.ts +118 -0
- package/src/server/api/specs.ts +9 -0
- package/src/server/build-search-index.ts +117 -0
- package/src/server/entry-client.tsx +88 -0
- package/src/server/entry-server.tsx +102 -0
- package/src/server/routes/llms.txt.ts +21 -0
- package/src/server/routes/og.tsx +75 -0
- package/src/server/routes/robots.txt.ts +11 -0
- package/src/server/routes/sitemap.xml.ts +40 -0
- package/src/server/utils/safe-path.ts +17 -0
- package/src/server/vite-config.ts +133 -0
- package/src/themes/default/Layout.tsx +78 -48
- package/src/themes/default/Page.module.css +44 -0
- package/src/themes/default/Page.tsx +9 -11
- package/src/themes/default/Toc.tsx +25 -39
- package/src/themes/default/index.ts +7 -9
- package/src/themes/paper/ChapterNav.tsx +64 -45
- package/src/themes/paper/Layout.module.css +1 -1
- package/src/themes/paper/Layout.tsx +24 -12
- package/src/themes/paper/Page.module.css +16 -4
- package/src/themes/paper/Page.tsx +56 -63
- package/src/themes/paper/ReadingProgress.tsx +160 -139
- package/src/themes/paper/index.ts +5 -5
- package/src/themes/registry.ts +14 -7
- package/src/types/config.ts +86 -67
- package/src/types/content.ts +5 -21
- package/src/types/globals.d.ts +4 -0
- package/src/types/theme.ts +4 -3
- package/tsconfig.json +2 -3
- package/next.config.mjs +0 -10
- package/source.config.ts +0 -51
- package/src/app/[[...slug]]/layout.tsx +0 -15
- package/src/app/[[...slug]]/page.tsx +0 -106
- package/src/app/api/apis-proxy/route.ts +0 -59
- package/src/app/api/health/route.ts +0 -3
- package/src/app/api/search/route.ts +0 -90
- package/src/app/apis/[[...slug]]/layout.tsx +0 -26
- package/src/app/apis/[[...slug]]/page.tsx +0 -117
- package/src/app/layout.tsx +0 -57
- package/src/app/llms-full.txt/route.ts +0 -18
- package/src/app/llms.txt/route.ts +0 -15
- package/src/app/og/route.tsx +0 -62
- package/src/app/providers.tsx +0 -8
- package/src/app/robots.ts +0 -10
- package/src/app/sitemap.ts +0 -29
- package/src/cli/utils/process.ts +0 -7
- package/src/themes/default/font.ts +0 -6
- /package/src/{app/apis/[[...slug]]/layout.module.css → pages/ApiLayout.module.css} +0 -0
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import { isMacOs } from "react-device-detect";
|
|
11
|
-
import { MethodBadge } from "@/components/api/method-badge";
|
|
12
|
-
import styles from "./search.module.css";
|
|
1
|
+
import { DocumentIcon, HashtagIcon } from '@heroicons/react/24/outline';
|
|
2
|
+
import { Button, Command, Dialog, Text } from '@raystack/apsara';
|
|
3
|
+
import { cx } from 'class-variance-authority';
|
|
4
|
+
import type { SortedResult } from 'fumadocs-core/search';
|
|
5
|
+
import { useDocsSearch } from 'fumadocs-core/search/client';
|
|
6
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
7
|
+
import { useNavigate } from 'react-router';
|
|
8
|
+
import { MethodBadge } from '@/components/api/method-badge';
|
|
9
|
+
import styles from './search.module.css';
|
|
13
10
|
|
|
14
11
|
function SearchShortcutKey({ className }: { className?: string }) {
|
|
15
|
-
const [key, setKey] = useState(
|
|
12
|
+
const [key, setKey] = useState('⌘');
|
|
16
13
|
|
|
17
14
|
useEffect(() => {
|
|
18
|
-
|
|
15
|
+
const isMac = navigator.platform?.toUpperCase().includes('MAC');
|
|
16
|
+
setKey(isMac ? '⌘' : 'Ctrl');
|
|
19
17
|
}, []);
|
|
20
18
|
|
|
21
19
|
return (
|
|
@@ -26,50 +24,50 @@ function SearchShortcutKey({ className }: { className?: string }) {
|
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
interface SearchProps {
|
|
29
|
-
className?: string
|
|
27
|
+
className?: string;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
export function Search({ className }: SearchProps) {
|
|
33
31
|
const [open, setOpen] = useState(false);
|
|
34
|
-
const
|
|
32
|
+
const navigate = useNavigate();
|
|
35
33
|
|
|
36
34
|
const { search, setSearch, query } = useDocsSearch({
|
|
37
|
-
type:
|
|
38
|
-
api:
|
|
35
|
+
type: 'fetch',
|
|
36
|
+
api: '/api/search',
|
|
39
37
|
delayMs: 100,
|
|
40
|
-
allowEmpty: true
|
|
38
|
+
allowEmpty: true
|
|
41
39
|
});
|
|
42
40
|
|
|
43
41
|
const onSelect = useCallback(
|
|
44
42
|
(url: string) => {
|
|
45
43
|
setOpen(false);
|
|
46
|
-
|
|
44
|
+
navigate(url);
|
|
47
45
|
},
|
|
48
|
-
[
|
|
46
|
+
[navigate]
|
|
49
47
|
);
|
|
50
48
|
|
|
51
49
|
useEffect(() => {
|
|
52
50
|
const down = (e: KeyboardEvent) => {
|
|
53
|
-
if (e.key ===
|
|
51
|
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
54
52
|
e.preventDefault();
|
|
55
|
-
setOpen(
|
|
53
|
+
setOpen(open => !open);
|
|
56
54
|
}
|
|
57
55
|
};
|
|
58
56
|
|
|
59
|
-
document.addEventListener(
|
|
60
|
-
return () => document.removeEventListener(
|
|
57
|
+
document.addEventListener('keydown', down);
|
|
58
|
+
return () => document.removeEventListener('keydown', down);
|
|
61
59
|
}, []);
|
|
62
60
|
|
|
63
61
|
const results = deduplicateByUrl(
|
|
64
|
-
query.data ===
|
|
62
|
+
query.data === 'empty' ? [] : (query.data ?? [])
|
|
65
63
|
);
|
|
66
64
|
|
|
67
65
|
return (
|
|
68
66
|
<>
|
|
69
67
|
<Button
|
|
70
|
-
variant=
|
|
71
|
-
color=
|
|
72
|
-
size=
|
|
68
|
+
variant='outline'
|
|
69
|
+
color='neutral'
|
|
70
|
+
size='small'
|
|
73
71
|
onClick={() => setOpen(true)}
|
|
74
72
|
className={cx(styles.trigger, className)}
|
|
75
73
|
trailingIcon={<SearchShortcutKey className={styles.kbd} />}
|
|
@@ -84,7 +82,7 @@ export function Search({ className }: SearchProps) {
|
|
|
84
82
|
</Dialog.Title>
|
|
85
83
|
<Command loop>
|
|
86
84
|
<Command.Input
|
|
87
|
-
placeholder=
|
|
85
|
+
placeholder='Search'
|
|
88
86
|
value={search}
|
|
89
87
|
onValueChange={setSearch}
|
|
90
88
|
className={styles.input}
|
|
@@ -100,7 +98,7 @@ export function Search({ className }: SearchProps) {
|
|
|
100
98
|
{!query.isLoading &&
|
|
101
99
|
search.length === 0 &&
|
|
102
100
|
results.length > 0 && (
|
|
103
|
-
<Command.Group heading=
|
|
101
|
+
<Command.Group heading='Suggestions'>
|
|
104
102
|
{results.slice(0, 8).map((result: SortedResult) => (
|
|
105
103
|
<Command.Item
|
|
106
104
|
key={result.id}
|
|
@@ -111,7 +109,9 @@ export function Search({ className }: SearchProps) {
|
|
|
111
109
|
<div className={styles.itemContent}>
|
|
112
110
|
{getResultIcon(result)}
|
|
113
111
|
<Text className={styles.pageText}>
|
|
114
|
-
<HighlightedText
|
|
112
|
+
<HighlightedText
|
|
113
|
+
html={stripMethod(result.content)}
|
|
114
|
+
/>
|
|
115
115
|
</Text>
|
|
116
116
|
</div>
|
|
117
117
|
</Command.Item>
|
|
@@ -129,10 +129,12 @@ export function Search({ className }: SearchProps) {
|
|
|
129
129
|
<div className={styles.itemContent}>
|
|
130
130
|
{getResultIcon(result)}
|
|
131
131
|
<div className={styles.resultText}>
|
|
132
|
-
{result.type ===
|
|
132
|
+
{result.type === 'heading' ? (
|
|
133
133
|
<>
|
|
134
134
|
<Text className={styles.headingText}>
|
|
135
|
-
<HighlightedText
|
|
135
|
+
<HighlightedText
|
|
136
|
+
html={stripMethod(result.content)}
|
|
137
|
+
/>
|
|
136
138
|
</Text>
|
|
137
139
|
<Text className={styles.separator}>-</Text>
|
|
138
140
|
<Text className={styles.pageText}>
|
|
@@ -141,7 +143,9 @@ export function Search({ className }: SearchProps) {
|
|
|
141
143
|
</>
|
|
142
144
|
) : (
|
|
143
145
|
<Text className={styles.pageText}>
|
|
144
|
-
<HighlightedText
|
|
146
|
+
<HighlightedText
|
|
147
|
+
html={stripMethod(result.content)}
|
|
148
|
+
/>
|
|
145
149
|
</Text>
|
|
146
150
|
)}
|
|
147
151
|
</div>
|
|
@@ -158,49 +162,57 @@ export function Search({ className }: SearchProps) {
|
|
|
158
162
|
|
|
159
163
|
function deduplicateByUrl(results: SortedResult[]): SortedResult[] {
|
|
160
164
|
const seen = new Set<string>();
|
|
161
|
-
return results.filter(
|
|
162
|
-
const base = r.url.split(
|
|
165
|
+
return results.filter(r => {
|
|
166
|
+
const base = r.url.split('#')[0];
|
|
163
167
|
if (seen.has(base)) return false;
|
|
164
168
|
seen.add(base);
|
|
165
169
|
return true;
|
|
166
170
|
});
|
|
167
171
|
}
|
|
168
172
|
|
|
169
|
-
const API_METHODS = new Set([
|
|
173
|
+
const API_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);
|
|
170
174
|
|
|
171
175
|
function extractMethod(content: string): string | null {
|
|
172
|
-
const first = content.split(
|
|
176
|
+
const first = content.split(' ')[0];
|
|
173
177
|
return API_METHODS.has(first) ? first : null;
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
function stripMethod(content: string): string {
|
|
177
|
-
const first = content.split(
|
|
181
|
+
const first = content.split(' ')[0];
|
|
178
182
|
return API_METHODS.has(first) ? content.slice(first.length + 1) : content;
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
function HighlightedText({
|
|
182
|
-
|
|
185
|
+
function HighlightedText({
|
|
186
|
+
html,
|
|
187
|
+
className
|
|
188
|
+
}: {
|
|
189
|
+
html: string;
|
|
190
|
+
className?: string;
|
|
191
|
+
}) {
|
|
192
|
+
return (
|
|
193
|
+
<span className={className} dangerouslySetInnerHTML={{ __html: html }} />
|
|
194
|
+
);
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
function getResultIcon(result: SortedResult): React.ReactNode {
|
|
186
|
-
if (!result.url.startsWith(
|
|
187
|
-
return result.type ===
|
|
198
|
+
if (!result.url.startsWith('/apis/')) {
|
|
199
|
+
return result.type === 'page' ? (
|
|
188
200
|
<DocumentIcon className={styles.icon} />
|
|
189
201
|
) : (
|
|
190
202
|
<HashtagIcon className={styles.icon} />
|
|
191
203
|
);
|
|
192
204
|
}
|
|
193
205
|
const method = extractMethod(result.content);
|
|
194
|
-
return method ? <MethodBadge method={method} size=
|
|
206
|
+
return method ? <MethodBadge method={method} size='micro' /> : null;
|
|
195
207
|
}
|
|
196
208
|
|
|
197
209
|
function getPageTitle(url: string): string {
|
|
198
|
-
const path = url.split(
|
|
199
|
-
const segments = path.split(
|
|
210
|
+
const path = url.split('#')[0];
|
|
211
|
+
const segments = path.split('/').filter(Boolean);
|
|
200
212
|
const lastSegment = segments[segments.length - 1];
|
|
201
|
-
if (!lastSegment) return
|
|
213
|
+
if (!lastSegment) return 'Home';
|
|
202
214
|
return lastSegment
|
|
203
|
-
.split(
|
|
204
|
-
.map(
|
|
205
|
-
.join(
|
|
215
|
+
.split('-')
|
|
216
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
217
|
+
.join(' ');
|
|
206
218
|
}
|
package/src/lib/api-routes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OpenAPIV3 } from 'openapi-types'
|
|
2
2
|
import slugify from 'slugify'
|
|
3
|
-
import type {
|
|
3
|
+
import type { Root, Node, Item, Folder } from 'fumadocs-core/page-tree'
|
|
4
4
|
import type { ApiSpec } from './openapi'
|
|
5
5
|
|
|
6
6
|
export function getSpecSlug(spec: ApiSpec): string {
|
|
@@ -56,16 +56,15 @@ export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatc
|
|
|
56
56
|
return null
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function buildApiPageTree(specs: ApiSpec[]):
|
|
60
|
-
const children:
|
|
59
|
+
export function buildApiPageTree(specs: ApiSpec[]): Root {
|
|
60
|
+
const children: Node[] = []
|
|
61
61
|
|
|
62
62
|
for (const spec of specs) {
|
|
63
63
|
const specSlug = getSpecSlug(spec)
|
|
64
64
|
const paths = spec.document.paths ?? {}
|
|
65
65
|
const tags = spec.document.tags ?? []
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
const opsByTag = new Map<string, PageTreeItem[]>()
|
|
67
|
+
const opsByTag = new Map<string, Item[]>()
|
|
69
68
|
const tagDisplayName = new Map<string, string>()
|
|
70
69
|
|
|
71
70
|
for (const [, pathItem] of Object.entries(paths)) {
|
|
@@ -90,7 +89,6 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
|
|
|
90
89
|
}
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
// Use doc.tags display names where available
|
|
94
92
|
for (const t of tags) {
|
|
95
93
|
const key = t.name.toLowerCase()
|
|
96
94
|
if (opsByTag.has(key)) {
|
|
@@ -98,7 +96,7 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
|
|
|
98
96
|
}
|
|
99
97
|
}
|
|
100
98
|
|
|
101
|
-
const tagFolders:
|
|
99
|
+
const tagFolders: Folder[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
|
|
102
100
|
type: 'folder' as const,
|
|
103
101
|
name: tagDisplayName.get(key) ?? key,
|
|
104
102
|
icon: 'rectangle-stack',
|
|
@@ -110,7 +108,7 @@ export function buildApiPageTree(specs: ApiSpec[]): PageTree {
|
|
|
110
108
|
type: 'folder',
|
|
111
109
|
name: spec.name,
|
|
112
110
|
children: tagFolders,
|
|
113
|
-
})
|
|
111
|
+
} as Folder)
|
|
114
112
|
} else {
|
|
115
113
|
children.push(...tagFolders)
|
|
116
114
|
}
|
package/src/lib/config.ts
CHANGED
|
@@ -1,56 +1,32 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import { parse } from 'yaml'
|
|
4
|
-
import type { ChronicleConfig } from '@/types'
|
|
5
|
-
|
|
6
|
-
const CONFIG_FILE = 'chronicle.yaml'
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
import type { ChronicleConfig } from '@/types';
|
|
7
3
|
|
|
8
4
|
const defaultConfig: ChronicleConfig = {
|
|
9
5
|
title: 'Documentation',
|
|
10
6
|
theme: { name: 'default' },
|
|
11
|
-
search: { enabled: true, placeholder: 'Search...' }
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function resolveConfigPath(): string | null {
|
|
15
|
-
// Check project root via env var
|
|
16
|
-
const projectRoot = process.env.CHRONICLE_PROJECT_ROOT
|
|
17
|
-
if (projectRoot) {
|
|
18
|
-
const rootPath = path.join(projectRoot, CONFIG_FILE)
|
|
19
|
-
if (fs.existsSync(rootPath)) return rootPath
|
|
20
|
-
}
|
|
21
|
-
// Check cwd
|
|
22
|
-
const cwdPath = path.join(process.cwd(), CONFIG_FILE)
|
|
23
|
-
if (fs.existsSync(cwdPath)) return cwdPath
|
|
24
|
-
// Check content dir
|
|
25
|
-
const contentDir = process.env.CHRONICLE_CONTENT_DIR
|
|
26
|
-
if (contentDir) {
|
|
27
|
-
const contentPath = path.join(contentDir, CONFIG_FILE)
|
|
28
|
-
if (fs.existsSync(contentPath)) return contentPath
|
|
29
|
-
}
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
7
|
+
search: { enabled: true, placeholder: 'Search...' }
|
|
8
|
+
};
|
|
32
9
|
|
|
33
10
|
export function loadConfig(): ChronicleConfig {
|
|
34
|
-
const
|
|
11
|
+
const raw = typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined' ? __CHRONICLE_CONFIG_RAW__ : null;
|
|
35
12
|
|
|
36
|
-
if (!
|
|
37
|
-
return defaultConfig
|
|
13
|
+
if (!raw) {
|
|
14
|
+
return defaultConfig;
|
|
38
15
|
}
|
|
39
16
|
|
|
40
|
-
const
|
|
41
|
-
const userConfig = parse(raw) as Partial<ChronicleConfig>
|
|
17
|
+
const userConfig = parse(raw) as Partial<ChronicleConfig>;
|
|
42
18
|
|
|
43
19
|
return {
|
|
44
20
|
...defaultConfig,
|
|
45
21
|
...userConfig,
|
|
46
22
|
theme: {
|
|
47
23
|
name: userConfig.theme?.name ?? defaultConfig.theme!.name,
|
|
48
|
-
colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }
|
|
24
|
+
colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }
|
|
49
25
|
},
|
|
50
26
|
search: { ...defaultConfig.search, ...userConfig.search },
|
|
51
27
|
footer: userConfig.footer,
|
|
52
28
|
api: userConfig.api,
|
|
53
29
|
llms: { enabled: false, ...userConfig.llms },
|
|
54
|
-
analytics: { enabled: false, ...userConfig.analytics }
|
|
55
|
-
}
|
|
56
|
-
}
|
|
30
|
+
analytics: { enabled: false, ...userConfig.analytics }
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/lib/head.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ChronicleConfig } from '@/types';
|
|
2
|
+
|
|
3
|
+
export interface HeadProps {
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
config: ChronicleConfig;
|
|
7
|
+
jsonLd?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Head({ title, description, config, jsonLd }: HeadProps) {
|
|
11
|
+
const fullTitle = `${title} | ${config.title}`;
|
|
12
|
+
const ogParams = new URLSearchParams({ title });
|
|
13
|
+
if (description) ogParams.set('description', description);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<title>{fullTitle}</title>
|
|
18
|
+
{description && <meta name='description' content={description} />}
|
|
19
|
+
|
|
20
|
+
{config.url && (
|
|
21
|
+
<>
|
|
22
|
+
<meta property='og:title' content={title} />
|
|
23
|
+
{description && (
|
|
24
|
+
<meta property='og:description' content={description} />
|
|
25
|
+
)}
|
|
26
|
+
<meta property='og:site_name' content={config.title} />
|
|
27
|
+
<meta property='og:type' content='website' />
|
|
28
|
+
<meta property='og:image' content={`/og?${ogParams.toString()}`} />
|
|
29
|
+
<meta property='og:image:width' content='1200' />
|
|
30
|
+
<meta property='og:image:height' content='630' />
|
|
31
|
+
|
|
32
|
+
<meta name='twitter:card' content='summary_large_image' />
|
|
33
|
+
<meta name='twitter:title' content={title} />
|
|
34
|
+
{description && (
|
|
35
|
+
<meta name='twitter:description' content={description} />
|
|
36
|
+
)}
|
|
37
|
+
<meta name='twitter:image' content={`/og?${ogParams.toString()}`} />
|
|
38
|
+
</>
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
{jsonLd && (
|
|
42
|
+
<script
|
|
43
|
+
type='application/ld+json'
|
|
44
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd, null, 2) }}
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/lib/openapi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import fs from 'fs'
|
|
2
|
-
import path from 'path'
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
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'
|
|
@@ -17,14 +17,14 @@ export interface ApiSpec {
|
|
|
17
17
|
export type { SchemaField } from './schema'
|
|
18
18
|
export { flattenSchema } from './schema'
|
|
19
19
|
|
|
20
|
-
export function loadApiSpecs(apiConfigs: ApiConfig[]): ApiSpec[] {
|
|
21
|
-
const
|
|
22
|
-
return apiConfigs.map((config) => loadApiSpec(config,
|
|
20
|
+
export async function loadApiSpecs(apiConfigs: ApiConfig[]): Promise<ApiSpec[]> {
|
|
21
|
+
const projectRoot = typeof __CHRONICLE_PROJECT_ROOT__ !== 'undefined' ? __CHRONICLE_PROJECT_ROOT__ : process.cwd()
|
|
22
|
+
return Promise.all(apiConfigs.map((config) => loadApiSpec(config, projectRoot)))
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function loadApiSpec(config: ApiConfig,
|
|
26
|
-
const specPath = path.resolve(
|
|
27
|
-
const raw = fs.
|
|
25
|
+
export async function loadApiSpec(config: ApiConfig, projectRoot: string): Promise<ApiSpec> {
|
|
26
|
+
const specPath = path.resolve(projectRoot, config.spec)
|
|
27
|
+
const raw = await fs.readFile(specPath, 'utf-8')
|
|
28
28
|
const isYaml = specPath.endsWith('.yaml') || specPath.endsWith('.yml')
|
|
29
29
|
const doc = (isYaml ? parseYaml(raw) : JSON.parse(raw)) as OpenAPIV2.Document | OpenAPIV3.Document
|
|
30
30
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useState
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useLocation } from 'react-router';
|
|
9
|
+
import type { ApiSpec } from '@/lib/openapi';
|
|
10
|
+
import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
|
|
11
|
+
|
|
12
|
+
export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
|
|
13
|
+
|
|
14
|
+
interface PageData {
|
|
15
|
+
slug: string[];
|
|
16
|
+
frontmatter: Frontmatter;
|
|
17
|
+
content: ReactNode;
|
|
18
|
+
toc: TableOfContents;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PageContextValue {
|
|
22
|
+
config: ChronicleConfig;
|
|
23
|
+
tree: Root;
|
|
24
|
+
page: PageData | null;
|
|
25
|
+
apiSpecs: ApiSpec[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PageContext = createContext<PageContextValue | null>(null);
|
|
29
|
+
|
|
30
|
+
export function usePageContext(): PageContextValue {
|
|
31
|
+
const ctx = useContext(PageContext);
|
|
32
|
+
if (!ctx) {
|
|
33
|
+
console.error('usePageContext: no context found!');
|
|
34
|
+
return {
|
|
35
|
+
config: { title: 'Documentation' },
|
|
36
|
+
tree: { name: 'root', children: [] } as Root,
|
|
37
|
+
page: null,
|
|
38
|
+
apiSpecs: []
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return ctx;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PageProviderProps {
|
|
45
|
+
initialConfig: ChronicleConfig;
|
|
46
|
+
initialTree: Root;
|
|
47
|
+
initialPage: PageData | null;
|
|
48
|
+
initialApiSpecs: ApiSpec[];
|
|
49
|
+
loadMdx: MdxLoader;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function PageProvider({
|
|
54
|
+
initialConfig,
|
|
55
|
+
initialTree,
|
|
56
|
+
initialPage,
|
|
57
|
+
initialApiSpecs,
|
|
58
|
+
loadMdx,
|
|
59
|
+
children
|
|
60
|
+
}: PageProviderProps) {
|
|
61
|
+
const { pathname } = useLocation();
|
|
62
|
+
const [tree] = useState<Root>(initialTree);
|
|
63
|
+
const [page, setPage] = useState<PageData | null>(initialPage);
|
|
64
|
+
const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
|
|
65
|
+
const [currentPath, setCurrentPath] = useState(pathname);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (pathname === currentPath) return;
|
|
69
|
+
setCurrentPath(pathname);
|
|
70
|
+
|
|
71
|
+
const cancelled = { current: false };
|
|
72
|
+
|
|
73
|
+
if (pathname.startsWith('/apis')) {
|
|
74
|
+
if (apiSpecs.length === 0) {
|
|
75
|
+
fetch('/api/specs')
|
|
76
|
+
.then(res => res.json())
|
|
77
|
+
.then(specs => {
|
|
78
|
+
if (!cancelled.current) setApiSpecs(specs);
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {});
|
|
81
|
+
}
|
|
82
|
+
return () => { cancelled.current = true; };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const slug = pathname === '/'
|
|
86
|
+
? []
|
|
87
|
+
: pathname.slice(1).split('/').filter(Boolean);
|
|
88
|
+
|
|
89
|
+
const apiPath = slug.length === 0 ? '/api/page/' : `/api/page/${slug.join('/')}`;
|
|
90
|
+
|
|
91
|
+
fetch(apiPath)
|
|
92
|
+
.then(res => res.json())
|
|
93
|
+
.then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string }) => {
|
|
94
|
+
if (cancelled.current) return;
|
|
95
|
+
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
|
|
96
|
+
if (cancelled.current) return;
|
|
97
|
+
setPage({ slug, frontmatter: data.frontmatter, content, toc });
|
|
98
|
+
})
|
|
99
|
+
.catch(() => {});
|
|
100
|
+
|
|
101
|
+
return () => { cancelled.current = true; };
|
|
102
|
+
}, [pathname]);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<PageContext.Provider
|
|
106
|
+
value={{ config: initialConfig, tree, page, apiSpecs }}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</PageContext.Provider>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit'
|
|
2
|
+
import type { Plugin } from 'unified'
|
|
3
|
+
|
|
4
|
+
const remarkStripMdExtensions: Plugin = () => {
|
|
5
|
+
return (tree) => {
|
|
6
|
+
visit(tree, 'link', (node: any) => {
|
|
7
|
+
if (!node.url) return
|
|
8
|
+
if (node.url.startsWith('http://') || node.url.startsWith('https://')) return
|
|
9
|
+
node.url = node.url.replace(/\.mdx?(#|$)/, '$1')
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default remarkStripMdExtensions
|