@raystack/chronicle 0.1.0-canary.0efaef0
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/bin/chronicle.js +2 -0
- package/dist/cli/index.js +276 -0
- package/package.json +71 -0
- package/src/cli/commands/build.ts +34 -0
- package/src/cli/commands/dev.ts +32 -0
- package/src/cli/commands/init.ts +69 -0
- package/src/cli/commands/serve.ts +40 -0
- package/src/cli/commands/start.ts +28 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/utils/config.ts +42 -0
- package/src/cli/utils/index.ts +3 -0
- package/src/cli/utils/resolve.ts +10 -0
- package/src/cli/utils/scaffold.ts +18 -0
- package/src/components/api/code-snippets.module.css +7 -0
- package/src/components/api/code-snippets.tsx +76 -0
- package/src/components/api/endpoint-page.module.css +58 -0
- package/src/components/api/endpoint-page.tsx +283 -0
- package/src/components/api/field-row.module.css +126 -0
- package/src/components/api/field-row.tsx +204 -0
- package/src/components/api/field-section.module.css +24 -0
- package/src/components/api/field-section.tsx +100 -0
- package/src/components/api/index.ts +8 -0
- package/src/components/api/json-editor.module.css +9 -0
- package/src/components/api/json-editor.tsx +61 -0
- package/src/components/api/key-value-editor.module.css +13 -0
- package/src/components/api/key-value-editor.tsx +62 -0
- package/src/components/api/method-badge.module.css +4 -0
- package/src/components/api/method-badge.tsx +29 -0
- package/src/components/api/response-panel.module.css +8 -0
- package/src/components/api/response-panel.tsx +44 -0
- package/src/components/common/breadcrumb.tsx +3 -0
- package/src/components/common/button.tsx +3 -0
- package/src/components/common/callout.module.css +7 -0
- package/src/components/common/callout.tsx +27 -0
- package/src/components/common/code-block.tsx +3 -0
- package/src/components/common/dialog.tsx +3 -0
- package/src/components/common/index.ts +10 -0
- package/src/components/common/input-field.tsx +3 -0
- package/src/components/common/sidebar.tsx +3 -0
- package/src/components/common/switch.tsx +3 -0
- package/src/components/common/table.tsx +3 -0
- package/src/components/common/tabs.tsx +3 -0
- package/src/components/mdx/code.module.css +42 -0
- package/src/components/mdx/code.tsx +27 -0
- package/src/components/mdx/details.module.css +37 -0
- package/src/components/mdx/details.tsx +18 -0
- package/src/components/mdx/image.tsx +9 -0
- package/src/components/mdx/index.tsx +35 -0
- package/src/components/mdx/link.tsx +41 -0
- package/src/components/mdx/mermaid.module.css +9 -0
- package/src/components/mdx/mermaid.tsx +37 -0
- package/src/components/mdx/paragraph.module.css +8 -0
- package/src/components/mdx/paragraph.tsx +19 -0
- package/src/components/mdx/table.tsx +40 -0
- package/src/components/ui/breadcrumbs.tsx +72 -0
- package/src/components/ui/client-theme-switcher.tsx +18 -0
- package/src/components/ui/footer.module.css +27 -0
- package/src/components/ui/footer.tsx +30 -0
- package/src/components/ui/search.module.css +111 -0
- package/src/components/ui/search.tsx +218 -0
- package/src/lib/api-routes.ts +120 -0
- package/src/lib/config.ts +58 -0
- package/src/lib/get-llm-text.ts +10 -0
- package/src/lib/head.tsx +49 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/openapi.ts +188 -0
- package/src/lib/page-context.tsx +117 -0
- package/src/lib/remark-unused-directives.ts +30 -0
- package/src/lib/schema.ts +99 -0
- package/src/lib/snippet-generators.ts +87 -0
- package/src/lib/source.ts +186 -0
- package/src/pages/ApiLayout.module.css +22 -0
- 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 +67 -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 +170 -0
- package/src/server/api/specs.ts +9 -0
- package/src/server/build-search-index.ts +117 -0
- package/src/server/entry-client.tsx +73 -0
- package/src/server/entry-server.tsx +95 -0
- package/src/server/routes/llms.txt.ts +61 -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 +39 -0
- package/src/server/utils/safe-path.ts +17 -0
- package/src/server/vite-config.ts +71 -0
- package/src/themes/default/Layout.module.css +81 -0
- package/src/themes/default/Layout.tsx +160 -0
- package/src/themes/default/Page.module.css +46 -0
- package/src/themes/default/Page.tsx +19 -0
- package/src/themes/default/Toc.module.css +48 -0
- package/src/themes/default/Toc.tsx +68 -0
- package/src/themes/default/index.ts +11 -0
- package/src/themes/paper/ChapterNav.module.css +71 -0
- package/src/themes/paper/ChapterNav.tsx +115 -0
- package/src/themes/paper/Layout.module.css +33 -0
- package/src/themes/paper/Layout.tsx +37 -0
- package/src/themes/paper/Page.module.css +181 -0
- package/src/themes/paper/Page.tsx +126 -0
- package/src/themes/paper/ReadingProgress.module.css +132 -0
- package/src/themes/paper/ReadingProgress.tsx +315 -0
- package/src/themes/paper/index.ts +8 -0
- package/src/themes/registry.ts +14 -0
- package/src/types/config.ts +80 -0
- package/src/types/content.ts +36 -0
- package/src/types/globals.d.ts +4 -0
- package/src/types/index.ts +3 -0
- package/src/types/theme.ts +22 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,218 @@
|
|
|
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';
|
|
10
|
+
|
|
11
|
+
function SearchShortcutKey({ className }: { className?: string }) {
|
|
12
|
+
const [key, setKey] = useState('⌘');
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const isMac = navigator.platform?.toUpperCase().includes('MAC');
|
|
16
|
+
setKey(isMac ? '⌘' : 'Ctrl');
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<kbd className={className} suppressHydrationWarning>
|
|
21
|
+
{key} K
|
|
22
|
+
</kbd>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SearchProps {
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Search({ className }: SearchProps) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const navigate = useNavigate();
|
|
33
|
+
|
|
34
|
+
const { search, setSearch, query } = useDocsSearch({
|
|
35
|
+
type: 'fetch',
|
|
36
|
+
api: '/api/search',
|
|
37
|
+
delayMs: 100,
|
|
38
|
+
allowEmpty: true
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const onSelect = useCallback(
|
|
42
|
+
(url: string) => {
|
|
43
|
+
setOpen(false);
|
|
44
|
+
navigate(url);
|
|
45
|
+
},
|
|
46
|
+
[navigate]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const down = (e: KeyboardEvent) => {
|
|
51
|
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
setOpen(open => !open);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
document.addEventListener('keydown', down);
|
|
58
|
+
return () => document.removeEventListener('keydown', down);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const results = deduplicateByUrl(
|
|
62
|
+
query.data === 'empty' ? [] : (query.data ?? [])
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<Button
|
|
68
|
+
variant='outline'
|
|
69
|
+
color='neutral'
|
|
70
|
+
size='small'
|
|
71
|
+
onClick={() => setOpen(true)}
|
|
72
|
+
className={cx(styles.trigger, className)}
|
|
73
|
+
trailingIcon={<SearchShortcutKey className={styles.kbd} />}
|
|
74
|
+
>
|
|
75
|
+
Search...
|
|
76
|
+
</Button>
|
|
77
|
+
|
|
78
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
79
|
+
<Dialog.Content className={styles.dialogContent}>
|
|
80
|
+
<Dialog.Title className={styles.visuallyHidden}>
|
|
81
|
+
Search documentation
|
|
82
|
+
</Dialog.Title>
|
|
83
|
+
<Command loop>
|
|
84
|
+
<Command.Input
|
|
85
|
+
placeholder='Search'
|
|
86
|
+
value={search}
|
|
87
|
+
onValueChange={setSearch}
|
|
88
|
+
className={styles.input}
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
<Command.List className={styles.list}>
|
|
92
|
+
{query.isLoading && <Command.Empty>Loading...</Command.Empty>}
|
|
93
|
+
{!query.isLoading &&
|
|
94
|
+
search.length > 0 &&
|
|
95
|
+
results.length === 0 && (
|
|
96
|
+
<Command.Empty>No results found.</Command.Empty>
|
|
97
|
+
)}
|
|
98
|
+
{!query.isLoading &&
|
|
99
|
+
search.length === 0 &&
|
|
100
|
+
results.length > 0 && (
|
|
101
|
+
<Command.Group heading='Suggestions'>
|
|
102
|
+
{results.slice(0, 8).map((result: SortedResult) => (
|
|
103
|
+
<Command.Item
|
|
104
|
+
key={result.id}
|
|
105
|
+
value={result.id}
|
|
106
|
+
onSelect={() => onSelect(result.url)}
|
|
107
|
+
className={styles.item}
|
|
108
|
+
>
|
|
109
|
+
<div className={styles.itemContent}>
|
|
110
|
+
{getResultIcon(result)}
|
|
111
|
+
<Text className={styles.pageText}>
|
|
112
|
+
<HighlightedText
|
|
113
|
+
html={stripMethod(result.content)}
|
|
114
|
+
/>
|
|
115
|
+
</Text>
|
|
116
|
+
</div>
|
|
117
|
+
</Command.Item>
|
|
118
|
+
))}
|
|
119
|
+
</Command.Group>
|
|
120
|
+
)}
|
|
121
|
+
{search.length > 0 &&
|
|
122
|
+
results.map((result: SortedResult) => (
|
|
123
|
+
<Command.Item
|
|
124
|
+
key={result.id}
|
|
125
|
+
value={result.id}
|
|
126
|
+
onSelect={() => onSelect(result.url)}
|
|
127
|
+
className={styles.item}
|
|
128
|
+
>
|
|
129
|
+
<div className={styles.itemContent}>
|
|
130
|
+
{getResultIcon(result)}
|
|
131
|
+
<div className={styles.resultText}>
|
|
132
|
+
{result.type === 'heading' ? (
|
|
133
|
+
<>
|
|
134
|
+
<Text className={styles.headingText}>
|
|
135
|
+
<HighlightedText
|
|
136
|
+
html={stripMethod(result.content)}
|
|
137
|
+
/>
|
|
138
|
+
</Text>
|
|
139
|
+
<Text className={styles.separator}>-</Text>
|
|
140
|
+
<Text className={styles.pageText}>
|
|
141
|
+
{getPageTitle(result.url)}
|
|
142
|
+
</Text>
|
|
143
|
+
</>
|
|
144
|
+
) : (
|
|
145
|
+
<Text className={styles.pageText}>
|
|
146
|
+
<HighlightedText
|
|
147
|
+
html={stripMethod(result.content)}
|
|
148
|
+
/>
|
|
149
|
+
</Text>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</Command.Item>
|
|
154
|
+
))}
|
|
155
|
+
</Command.List>
|
|
156
|
+
</Command>
|
|
157
|
+
</Dialog.Content>
|
|
158
|
+
</Dialog>
|
|
159
|
+
</>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function deduplicateByUrl(results: SortedResult[]): SortedResult[] {
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
return results.filter(r => {
|
|
166
|
+
const base = r.url.split('#')[0];
|
|
167
|
+
if (seen.has(base)) return false;
|
|
168
|
+
seen.add(base);
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const API_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']);
|
|
174
|
+
|
|
175
|
+
function extractMethod(content: string): string | null {
|
|
176
|
+
const first = content.split(' ')[0];
|
|
177
|
+
return API_METHODS.has(first) ? first : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function stripMethod(content: string): string {
|
|
181
|
+
const first = content.split(' ')[0];
|
|
182
|
+
return API_METHODS.has(first) ? content.slice(first.length + 1) : content;
|
|
183
|
+
}
|
|
184
|
+
|
|
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
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getResultIcon(result: SortedResult): React.ReactNode {
|
|
198
|
+
if (!result.url.startsWith('/apis/')) {
|
|
199
|
+
return result.type === 'page' ? (
|
|
200
|
+
<DocumentIcon className={styles.icon} />
|
|
201
|
+
) : (
|
|
202
|
+
<HashtagIcon className={styles.icon} />
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const method = extractMethod(result.content);
|
|
206
|
+
return method ? <MethodBadge method={method} size='micro' /> : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getPageTitle(url: string): string {
|
|
210
|
+
const path = url.split('#')[0];
|
|
211
|
+
const segments = path.split('/').filter(Boolean);
|
|
212
|
+
const lastSegment = segments[segments.length - 1];
|
|
213
|
+
if (!lastSegment) return 'Home';
|
|
214
|
+
return lastSegment
|
|
215
|
+
.split('-')
|
|
216
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
217
|
+
.join(' ');
|
|
218
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from 'openapi-types'
|
|
2
|
+
import slugify from 'slugify'
|
|
3
|
+
import type { PageTree, PageTreeItem } from '@/types/content'
|
|
4
|
+
import type { ApiSpec } from './openapi'
|
|
5
|
+
|
|
6
|
+
export function getSpecSlug(spec: ApiSpec): string {
|
|
7
|
+
return slugify(spec.name, { lower: true, strict: true })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export function buildApiRoutes(specs: ApiSpec[]): { slug: string[] }[] {
|
|
12
|
+
const routes: { slug: string[] }[] = []
|
|
13
|
+
|
|
14
|
+
for (const spec of specs) {
|
|
15
|
+
const specSlug = getSpecSlug(spec)
|
|
16
|
+
const paths = spec.document.paths ?? {}
|
|
17
|
+
|
|
18
|
+
for (const [, pathItem] of Object.entries(paths)) {
|
|
19
|
+
if (!pathItem) continue
|
|
20
|
+
for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
|
|
21
|
+
const op = pathItem[method]
|
|
22
|
+
if (!op?.operationId) continue
|
|
23
|
+
routes.push({ slug: [specSlug, encodeURIComponent(op.operationId)] })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return routes
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApiRouteMatch {
|
|
32
|
+
spec: ApiSpec
|
|
33
|
+
operation: OpenAPIV3.OperationObject
|
|
34
|
+
method: string
|
|
35
|
+
path: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatch | null {
|
|
39
|
+
if (slug.length !== 2) return null
|
|
40
|
+
const [specSlug, operationId] = slug
|
|
41
|
+
|
|
42
|
+
const spec = specs.find((s) => getSpecSlug(s) === specSlug)
|
|
43
|
+
if (!spec) return null
|
|
44
|
+
|
|
45
|
+
const paths = spec.document.paths ?? {}
|
|
46
|
+
for (const [pathStr, pathItem] of Object.entries(paths)) {
|
|
47
|
+
if (!pathItem) continue
|
|
48
|
+
for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
|
|
49
|
+
const op = pathItem[method]
|
|
50
|
+
if (op?.operationId && encodeURIComponent(op.operationId) === operationId) {
|
|
51
|
+
return { spec, operation: op, method: method.toUpperCase(), path: pathStr }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildApiPageTree(specs: ApiSpec[]): PageTree {
|
|
60
|
+
const children: PageTreeItem[] = []
|
|
61
|
+
|
|
62
|
+
for (const spec of specs) {
|
|
63
|
+
const specSlug = getSpecSlug(spec)
|
|
64
|
+
const paths = spec.document.paths ?? {}
|
|
65
|
+
const tags = spec.document.tags ?? []
|
|
66
|
+
|
|
67
|
+
// Group operations by tag (case-insensitive to avoid duplicates)
|
|
68
|
+
const opsByTag = new Map<string, PageTreeItem[]>()
|
|
69
|
+
const tagDisplayName = new Map<string, string>()
|
|
70
|
+
|
|
71
|
+
for (const [, pathItem] of Object.entries(paths)) {
|
|
72
|
+
if (!pathItem) continue
|
|
73
|
+
for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
|
|
74
|
+
const op = pathItem[method]
|
|
75
|
+
if (!op?.operationId) continue
|
|
76
|
+
|
|
77
|
+
const rawTag = op.tags?.[0] ?? 'default'
|
|
78
|
+
const tagKey = rawTag.toLowerCase()
|
|
79
|
+
if (!opsByTag.has(tagKey)) {
|
|
80
|
+
opsByTag.set(tagKey, [])
|
|
81
|
+
tagDisplayName.set(tagKey, rawTag.charAt(0).toUpperCase() + rawTag.slice(1))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
opsByTag.get(tagKey)!.push({
|
|
85
|
+
type: 'page',
|
|
86
|
+
name: op.summary ?? op.operationId,
|
|
87
|
+
url: `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`,
|
|
88
|
+
icon: `method-${method}`,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Use doc.tags display names where available
|
|
94
|
+
for (const t of tags) {
|
|
95
|
+
const key = t.name.toLowerCase()
|
|
96
|
+
if (opsByTag.has(key)) {
|
|
97
|
+
tagDisplayName.set(key, t.name.charAt(0).toUpperCase() + t.name.slice(1))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tagFolders: PageTreeItem[] = Array.from(opsByTag.entries()).map(([key, ops]) => ({
|
|
102
|
+
type: 'folder' as const,
|
|
103
|
+
name: tagDisplayName.get(key) ?? key,
|
|
104
|
+
icon: 'rectangle-stack',
|
|
105
|
+
children: ops,
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
if (specs.length > 1) {
|
|
109
|
+
children.push({
|
|
110
|
+
type: 'folder',
|
|
111
|
+
name: spec.name,
|
|
112
|
+
children: tagFolders,
|
|
113
|
+
})
|
|
114
|
+
} else {
|
|
115
|
+
children.push(...tagFolders)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { name: 'API Reference', children }
|
|
120
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import type { ChronicleConfig } from '@/types';
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILE = 'chronicle.yaml';
|
|
7
|
+
|
|
8
|
+
const defaultConfig: ChronicleConfig = {
|
|
9
|
+
title: 'Documentation',
|
|
10
|
+
theme: { name: 'default' },
|
|
11
|
+
search: { enabled: true, placeholder: 'Search...' }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function resolveConfigPath(): string | null {
|
|
15
|
+
const projectRoot =
|
|
16
|
+
typeof __CHRONICLE_PROJECT_ROOT__ !== 'undefined'
|
|
17
|
+
? __CHRONICLE_PROJECT_ROOT__
|
|
18
|
+
: process.cwd();
|
|
19
|
+
|
|
20
|
+
const rootPath = path.join(projectRoot, CONFIG_FILE);
|
|
21
|
+
if (fs.existsSync(rootPath)) return rootPath;
|
|
22
|
+
|
|
23
|
+
const contentDir =
|
|
24
|
+
typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined'
|
|
25
|
+
? __CHRONICLE_CONTENT_DIR__
|
|
26
|
+
: undefined;
|
|
27
|
+
if (contentDir) {
|
|
28
|
+
const contentPath = path.join(contentDir, CONFIG_FILE);
|
|
29
|
+
if (fs.existsSync(contentPath)) return contentPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadConfig(): ChronicleConfig {
|
|
36
|
+
const configPath = resolveConfigPath();
|
|
37
|
+
|
|
38
|
+
if (!configPath) {
|
|
39
|
+
return defaultConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
43
|
+
const userConfig = parse(raw) as Partial<ChronicleConfig>;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...defaultConfig,
|
|
47
|
+
...userConfig,
|
|
48
|
+
theme: {
|
|
49
|
+
name: userConfig.theme?.name ?? defaultConfig.theme!.name,
|
|
50
|
+
colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }
|
|
51
|
+
},
|
|
52
|
+
search: { ...defaultConfig.search, ...userConfig.search },
|
|
53
|
+
footer: userConfig.footer,
|
|
54
|
+
api: userConfig.api,
|
|
55
|
+
llms: { enabled: false, ...userConfig.llms },
|
|
56
|
+
analytics: { enabled: false, ...userConfig.analytics }
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { source } from '@/lib/source'
|
|
2
|
+
import type { InferPageType } from 'fumadocs-core/source'
|
|
3
|
+
|
|
4
|
+
export async function getLLMText(page: InferPageType<typeof source>) {
|
|
5
|
+
const processed = await page.data.getText('processed')
|
|
6
|
+
|
|
7
|
+
return `# ${page.data.title} (${page.url})
|
|
8
|
+
|
|
9
|
+
${processed}`
|
|
10
|
+
}
|
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/index.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parse as parseYaml } from 'yaml'
|
|
4
|
+
import type { OpenAPIV2, OpenAPIV3 } from 'openapi-types'
|
|
5
|
+
import type { ApiConfig, ApiServerConfig, ApiAuthConfig } from '@/types/config'
|
|
6
|
+
|
|
7
|
+
type JsonObject = Record<string, unknown>
|
|
8
|
+
|
|
9
|
+
export interface ApiSpec {
|
|
10
|
+
name: string
|
|
11
|
+
basePath: string
|
|
12
|
+
server: ApiServerConfig
|
|
13
|
+
auth?: ApiAuthConfig
|
|
14
|
+
document: OpenAPIV3.Document
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type { SchemaField } from './schema'
|
|
18
|
+
export { flattenSchema } from './schema'
|
|
19
|
+
|
|
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
|
+
}
|
|
24
|
+
|
|
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
|
+
const isYaml = specPath.endsWith('.yaml') || specPath.endsWith('.yml')
|
|
29
|
+
const doc = (isYaml ? parseYaml(raw) : JSON.parse(raw)) as OpenAPIV2.Document | OpenAPIV3.Document
|
|
30
|
+
|
|
31
|
+
let v3Doc: OpenAPIV3.Document
|
|
32
|
+
|
|
33
|
+
if ('swagger' in doc && doc.swagger === '2.0') {
|
|
34
|
+
v3Doc = convertV2toV3(doc as OpenAPIV2.Document)
|
|
35
|
+
} else if ('openapi' in doc && doc.openapi.startsWith('3.')) {
|
|
36
|
+
v3Doc = resolveDocument(doc as OpenAPIV3.Document)
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error(`Unsupported spec version in ${config.spec}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name: config.name,
|
|
43
|
+
basePath: config.basePath,
|
|
44
|
+
server: config.server,
|
|
45
|
+
auth: config.auth,
|
|
46
|
+
document: v3Doc,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- $ref resolution ---
|
|
51
|
+
|
|
52
|
+
function resolveRef(ref: string, root: JsonObject): JsonObject {
|
|
53
|
+
const parts = ref.replace(/^#\//, '').split('/')
|
|
54
|
+
let current: unknown = root
|
|
55
|
+
for (const part of parts) {
|
|
56
|
+
if (current && typeof current === 'object' && !Array.isArray(current)) {
|
|
57
|
+
current = (current as JsonObject)[part]
|
|
58
|
+
} else {
|
|
59
|
+
throw new Error(`Cannot resolve $ref: ${ref}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return current as JsonObject
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function deepResolveRefs(
|
|
66
|
+
obj: unknown,
|
|
67
|
+
root: JsonObject,
|
|
68
|
+
stack = new Set<string>(),
|
|
69
|
+
cache = new Map<string, JsonObject>(),
|
|
70
|
+
): unknown {
|
|
71
|
+
if (obj === null || obj === undefined || typeof obj !== 'object') return obj
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(obj)) {
|
|
74
|
+
return obj.map((item) => deepResolveRefs(item, root, stack, cache))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const record = obj as JsonObject
|
|
78
|
+
|
|
79
|
+
if (typeof record.$ref === 'string') {
|
|
80
|
+
const ref = record.$ref
|
|
81
|
+
if (cache.has(ref)) return cache.get(ref) as JsonObject
|
|
82
|
+
if (stack.has(ref)) return { type: 'object', description: '[circular]' }
|
|
83
|
+
stack.add(ref)
|
|
84
|
+
const resolved = deepResolveRefs(resolveRef(ref, root), root, stack, cache) as JsonObject
|
|
85
|
+
stack.delete(ref)
|
|
86
|
+
cache.set(ref, resolved)
|
|
87
|
+
return resolved
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result: JsonObject = {}
|
|
91
|
+
for (const [key, value] of Object.entries(record)) {
|
|
92
|
+
result[key] = deepResolveRefs(value, root, stack, cache)
|
|
93
|
+
}
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveDocument(doc: OpenAPIV3.Document): OpenAPIV3.Document {
|
|
98
|
+
const root = doc as unknown as JsonObject
|
|
99
|
+
return deepResolveRefs(doc, root) as unknown as OpenAPIV3.Document
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- V2 → V3 conversion ---
|
|
103
|
+
|
|
104
|
+
function convertV2toV3(doc: OpenAPIV2.Document): OpenAPIV3.Document {
|
|
105
|
+
const root = doc as unknown as JsonObject
|
|
106
|
+
const resolved = deepResolveRefs(doc, root) as unknown as OpenAPIV2.Document
|
|
107
|
+
|
|
108
|
+
const v3Paths: OpenAPIV3.PathsObject = {}
|
|
109
|
+
|
|
110
|
+
for (const [pathStr, pathItem] of Object.entries(resolved.paths ?? {})) {
|
|
111
|
+
if (!pathItem) continue
|
|
112
|
+
const v3PathItem: OpenAPIV3.PathItemObject = {}
|
|
113
|
+
|
|
114
|
+
for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
|
|
115
|
+
const op = (pathItem as Record<string, unknown>)[method] as OpenAPIV2.OperationObject | undefined
|
|
116
|
+
if (!op) continue
|
|
117
|
+
v3PathItem[method] = convertV2Operation(op)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
v3Paths[pathStr] = v3PathItem
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
openapi: '3.0.0',
|
|
125
|
+
info: resolved.info as unknown as OpenAPIV3.InfoObject,
|
|
126
|
+
paths: v3Paths,
|
|
127
|
+
tags: (resolved.tags ?? []) as unknown as OpenAPIV3.TagObject[],
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function convertV2Operation(op: OpenAPIV2.OperationObject): OpenAPIV3.OperationObject {
|
|
132
|
+
const params = (op.parameters ?? []) as OpenAPIV2.Parameter[]
|
|
133
|
+
|
|
134
|
+
const v3Params: OpenAPIV3.ParameterObject[] = params
|
|
135
|
+
.filter((p) => p.in !== 'body')
|
|
136
|
+
.map((p) => ({
|
|
137
|
+
name: p.name,
|
|
138
|
+
in: p.in as 'path' | 'query' | 'header' | 'cookie',
|
|
139
|
+
required: p.required ?? false,
|
|
140
|
+
description: p.description,
|
|
141
|
+
schema: { type: ((p as JsonObject).type as string) ?? 'string', format: (p as JsonObject).format as string | undefined } as OpenAPIV3.SchemaObject,
|
|
142
|
+
}))
|
|
143
|
+
|
|
144
|
+
const bodyParam = params.find((p) => p.in === 'body') as JsonObject | undefined
|
|
145
|
+
let requestBody: OpenAPIV3.RequestBodyObject | undefined
|
|
146
|
+
if (bodyParam?.schema) {
|
|
147
|
+
requestBody = {
|
|
148
|
+
required: (bodyParam.required as boolean) ?? false,
|
|
149
|
+
content: {
|
|
150
|
+
'application/json': {
|
|
151
|
+
schema: bodyParam.schema as OpenAPIV3.SchemaObject,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const v3Responses: OpenAPIV3.ResponsesObject = {}
|
|
158
|
+
for (const [status, resp] of Object.entries(op.responses ?? {})) {
|
|
159
|
+
const v2Resp = resp as OpenAPIV2.ResponseObject
|
|
160
|
+
const v3Resp: OpenAPIV3.ResponseObject = {
|
|
161
|
+
description: v2Resp.description ?? '',
|
|
162
|
+
}
|
|
163
|
+
if ((v2Resp as unknown as JsonObject).schema) {
|
|
164
|
+
v3Resp.content = {
|
|
165
|
+
'application/json': {
|
|
166
|
+
schema: (v2Resp as unknown as JsonObject).schema as OpenAPIV3.SchemaObject,
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
v3Responses[status] = v3Resp
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result: OpenAPIV3.OperationObject = {
|
|
174
|
+
operationId: op.operationId,
|
|
175
|
+
summary: op.summary,
|
|
176
|
+
description: op.description,
|
|
177
|
+
tags: op.tags,
|
|
178
|
+
parameters: v3Params,
|
|
179
|
+
responses: v3Responses,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (requestBody) {
|
|
183
|
+
result.requestBody = requestBody
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return result
|
|
187
|
+
}
|
|
188
|
+
|