@raystack/chronicle 0.7.4 → 0.8.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 +4 -1
- package/package.json +1 -1
- package/src/components/api/api-code-snippet.module.css +23 -0
- package/src/components/api/api-code-snippet.tsx +64 -0
- package/src/components/api/api-field-list.module.css +76 -0
- package/src/components/api/api-field-list.tsx +91 -0
- package/src/components/api/api-overview.module.css +65 -0
- package/src/components/api/api-overview.tsx +216 -0
- package/src/components/api/api-response-panel.module.css +62 -0
- package/src/components/api/api-response-panel.tsx +54 -0
- package/src/components/api/index.ts +5 -6
- package/src/components/api/json-editor.tsx +8 -8
- package/src/components/api/method-badge.tsx +2 -2
- package/src/components/api/playground-dialog.module.css +342 -0
- package/src/components/api/playground-dialog.tsx +583 -0
- package/src/lib/api-routes.ts +37 -8
- package/src/lib/openapi.ts +26 -0
- package/src/lib/schema.ts +45 -3
- package/src/lib/source.ts +32 -13
- package/src/lib/use-api-operation.ts +15 -0
- package/src/pages/ApiLayout.module.css +1 -0
- package/src/pages/ApiPage.tsx +7 -38
- package/src/pages/DocsPage.tsx +40 -1
- package/src/server/api/apis-proxy.ts +8 -1
- package/src/server/entry-server.tsx +1 -1
- package/src/server/routes/[...slug].md.ts +1 -0
- package/src/server/routes/apis/[...slug].md.ts +181 -0
- package/src/server/vite-config.ts +2 -0
- package/src/themes/default/Layout.module.css +53 -0
- package/src/themes/default/Layout.tsx +162 -11
- package/src/types/config.ts +1 -0
- package/src/components/api/code-snippets.module.css +0 -7
- package/src/components/api/code-snippets.tsx +0 -76
- package/src/components/api/endpoint-page.module.css +0 -58
- package/src/components/api/endpoint-page.tsx +0 -283
- package/src/components/api/field-row.module.css +0 -126
- package/src/components/api/field-row.tsx +0 -204
- package/src/components/api/field-section.module.css +0 -24
- package/src/components/api/field-section.tsx +0 -100
- package/src/components/api/key-value-editor.module.css +0 -13
- package/src/components/api/key-value-editor.tsx +0 -62
- package/src/components/api/response-panel.module.css +0 -8
- package/src/components/api/response-panel.tsx +0 -44
package/src/lib/openapi.ts
CHANGED
|
@@ -120,12 +120,38 @@ function convertV2toV3(doc: OpenAPIV2.Document): OpenAPIV3.Document {
|
|
|
120
120
|
v3Paths[pathStr] = v3PathItem
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
const securitySchemes = convertV2SecurityDefs(resolved.securityDefinitions as Record<string, OpenAPIV2.SecuritySchemeObject> | undefined)
|
|
124
|
+
|
|
123
125
|
return {
|
|
124
126
|
openapi: '3.0.0',
|
|
125
127
|
info: resolved.info as unknown as OpenAPIV3.InfoObject,
|
|
126
128
|
paths: v3Paths,
|
|
127
129
|
tags: (resolved.tags ?? []) as unknown as OpenAPIV3.TagObject[],
|
|
130
|
+
...(resolved.externalDocs ? { externalDocs: resolved.externalDocs as unknown as OpenAPIV3.ExternalDocumentationObject } : {}),
|
|
131
|
+
...(Object.keys(securitySchemes).length > 0 ? { components: { securitySchemes } } : {}),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function convertV2SecurityDefs(defs: Record<string, OpenAPIV2.SecuritySchemeObject> | undefined): Record<string, OpenAPIV3.SecuritySchemeObject> {
|
|
136
|
+
if (!defs) return {}
|
|
137
|
+
const result: Record<string, OpenAPIV3.SecuritySchemeObject> = {}
|
|
138
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
139
|
+
if (def.type === 'apiKey') {
|
|
140
|
+
result[name] = { type: 'apiKey', name: (def as JsonObject).name as string, in: def.in as string } as OpenAPIV3.ApiKeySecurityScheme
|
|
141
|
+
} else if (def.type === 'basic') {
|
|
142
|
+
result[name] = { type: 'http', scheme: 'basic' } as OpenAPIV3.HttpSecurityScheme
|
|
143
|
+
} else if (def.type === 'oauth2') {
|
|
144
|
+
const v2 = def as unknown as { flow?: string; authorizationUrl?: string; tokenUrl?: string; scopes?: Record<string, string> }
|
|
145
|
+
const flow = { authorizationUrl: v2.authorizationUrl ?? '', tokenUrl: v2.tokenUrl ?? '', scopes: v2.scopes ?? {} }
|
|
146
|
+
const flows: OpenAPIV3.OAuth2SecurityScheme['flows'] = {}
|
|
147
|
+
if (v2.flow === 'implicit') flows.implicit = { authorizationUrl: flow.authorizationUrl, scopes: flow.scopes }
|
|
148
|
+
else if (v2.flow === 'password') flows.password = { tokenUrl: flow.tokenUrl, scopes: flow.scopes }
|
|
149
|
+
else if (v2.flow === 'application') flows.clientCredentials = { tokenUrl: flow.tokenUrl, scopes: flow.scopes }
|
|
150
|
+
else if (v2.flow === 'accessCode') flows.authorizationCode = { authorizationUrl: flow.authorizationUrl, tokenUrl: flow.tokenUrl, scopes: flow.scopes }
|
|
151
|
+
result[name] = { type: 'oauth2', flows } as OpenAPIV3.OAuth2SecurityScheme
|
|
152
|
+
}
|
|
128
153
|
}
|
|
154
|
+
return result
|
|
129
155
|
}
|
|
130
156
|
|
|
131
157
|
function convertV2Operation(op: OpenAPIV2.OperationObject): OpenAPIV3.OperationObject {
|
package/src/lib/schema.ts
CHANGED
|
@@ -1,19 +1,56 @@
|
|
|
1
1
|
import type { OpenAPIV3 } from 'openapi-types'
|
|
2
2
|
|
|
3
|
+
const schemaFieldKinds = {
|
|
4
|
+
string: 'string', integer: 'integer', number: 'number',
|
|
5
|
+
boolean: 'boolean', array: 'array', object: 'object',
|
|
6
|
+
} as const
|
|
7
|
+
|
|
8
|
+
export type SchemaFieldKind = keyof typeof schemaFieldKinds
|
|
9
|
+
|
|
10
|
+
export function toKind(type: unknown): SchemaFieldKind {
|
|
11
|
+
if (typeof type === 'string' && type in schemaFieldKinds) return type as SchemaFieldKind
|
|
12
|
+
return 'object'
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
export interface SchemaField {
|
|
4
16
|
name: string
|
|
5
17
|
type: string
|
|
18
|
+
kind: SchemaFieldKind
|
|
6
19
|
required: boolean
|
|
7
20
|
description?: string
|
|
8
21
|
default?: unknown
|
|
22
|
+
example?: unknown
|
|
9
23
|
enum?: unknown[]
|
|
10
24
|
children?: SchemaField[]
|
|
11
25
|
}
|
|
12
26
|
|
|
27
|
+
function mergeAllOf(schema: OpenAPIV3.SchemaObject): OpenAPIV3.SchemaObject {
|
|
28
|
+
const composed = schema.allOf ?? schema.oneOf ?? schema.anyOf
|
|
29
|
+
if (!composed) return schema
|
|
30
|
+
const merged: OpenAPIV3.SchemaObject = { ...schema }
|
|
31
|
+
delete merged.allOf
|
|
32
|
+
delete merged.oneOf
|
|
33
|
+
delete merged.anyOf
|
|
34
|
+
for (const sub of composed as OpenAPIV3.SchemaObject[]) {
|
|
35
|
+
if (sub.type) merged.type = sub.type
|
|
36
|
+
if (sub.properties) {
|
|
37
|
+
merged.properties = { ...(merged.properties ?? {}), ...sub.properties }
|
|
38
|
+
}
|
|
39
|
+
if (sub.required) {
|
|
40
|
+
merged.required = [...(merged.required ?? []), ...sub.required]
|
|
41
|
+
}
|
|
42
|
+
if (sub.description && !merged.description) merged.description = sub.description
|
|
43
|
+
}
|
|
44
|
+
return merged
|
|
45
|
+
}
|
|
46
|
+
|
|
13
47
|
export function flattenSchema(
|
|
14
48
|
schema: OpenAPIV3.SchemaObject,
|
|
15
49
|
requiredFields: string[] = [],
|
|
16
50
|
): SchemaField[] {
|
|
51
|
+
const resolved = mergeAllOf(schema)
|
|
52
|
+
if (resolved !== schema) return flattenSchema(resolved, requiredFields)
|
|
53
|
+
|
|
17
54
|
if (schema.type === 'array' && schema.items) {
|
|
18
55
|
const items = schema.items as OpenAPIV3.SchemaObject
|
|
19
56
|
const itemType = inferType(items)
|
|
@@ -26,6 +63,7 @@ export function flattenSchema(
|
|
|
26
63
|
return [{
|
|
27
64
|
name: 'items',
|
|
28
65
|
type: `${itemType}[]`,
|
|
66
|
+
kind: 'array' as SchemaFieldKind,
|
|
29
67
|
required: true,
|
|
30
68
|
description: items.description,
|
|
31
69
|
children: children?.length ? children : undefined,
|
|
@@ -36,7 +74,8 @@ export function flattenSchema(
|
|
|
36
74
|
const properties = (schema.properties ?? {}) as Record<string, OpenAPIV3.SchemaObject>
|
|
37
75
|
const required = schema.required ?? requiredFields
|
|
38
76
|
|
|
39
|
-
return Object.entries(properties).map(([name,
|
|
77
|
+
return Object.entries(properties).map(([name, rawProp]) => {
|
|
78
|
+
const prop = mergeAllOf(rawProp)
|
|
40
79
|
const fieldType = inferType(prop)
|
|
41
80
|
const children =
|
|
42
81
|
fieldType === 'object' || prop.properties
|
|
@@ -48,9 +87,11 @@ export function flattenSchema(
|
|
|
48
87
|
return {
|
|
49
88
|
name,
|
|
50
89
|
type: fieldType,
|
|
90
|
+
kind: toKind(prop.type),
|
|
51
91
|
required: required.includes(name),
|
|
52
|
-
description: prop.description,
|
|
92
|
+
description: rawProp.description ?? prop.description,
|
|
53
93
|
default: prop.default,
|
|
94
|
+
example: prop.example,
|
|
54
95
|
enum: prop.enum,
|
|
55
96
|
children: children?.length ? children : undefined,
|
|
56
97
|
}
|
|
@@ -87,7 +128,8 @@ export function generateExampleJson(schema: OpenAPIV3.SchemaObject): unknown {
|
|
|
87
128
|
return defaults[schema.type as string] ?? null
|
|
88
129
|
}
|
|
89
130
|
|
|
90
|
-
function inferType(
|
|
131
|
+
function inferType(rawSchema: OpenAPIV3.SchemaObject): string {
|
|
132
|
+
const schema = mergeAllOf(rawSchema)
|
|
91
133
|
if (schema.type === 'array') {
|
|
92
134
|
const items = schema.items as OpenAPIV3.SchemaObject | undefined
|
|
93
135
|
const itemType = items ? inferType(items) : 'unknown'
|
package/src/lib/source.ts
CHANGED
|
@@ -118,41 +118,60 @@ export function invalidate() {
|
|
|
118
118
|
cachedNavMap = null;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
function getOrder(node: Node,
|
|
122
|
-
if (node.type === 'page') return
|
|
123
|
-
if (node.type === 'folder'
|
|
121
|
+
function getOrder(node: Node, pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): number | undefined {
|
|
122
|
+
if (node.type === 'page') return pageOrderMap.get(node.url);
|
|
123
|
+
if (node.type === 'folder') {
|
|
124
|
+
if (node.index) {
|
|
125
|
+
const fromMeta = folderOrderMap.get(node.index.url);
|
|
126
|
+
if (fromMeta !== undefined) return fromMeta;
|
|
127
|
+
return pageOrderMap.get(node.index.url);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
124
130
|
return undefined;
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
function sortNodes(nodes: Node[],
|
|
133
|
+
function sortNodes(nodes: Node[], pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): Node[] {
|
|
128
134
|
return [...nodes]
|
|
129
135
|
.map(n =>
|
|
130
136
|
n.type === 'folder'
|
|
131
|
-
? ({ ...n, children: sortNodes(n.children,
|
|
137
|
+
? ({ ...n, children: sortNodes(n.children, pageOrderMap, folderOrderMap) } as Folder)
|
|
132
138
|
: n
|
|
133
139
|
)
|
|
134
140
|
.sort(
|
|
135
141
|
(a, b) =>
|
|
136
|
-
(getOrder(a,
|
|
137
|
-
(getOrder(b,
|
|
142
|
+
(getOrder(a, pageOrderMap, folderOrderMap) ?? Number.MAX_SAFE_INTEGER) -
|
|
143
|
+
(getOrder(b, pageOrderMap, folderOrderMap) ?? Number.MAX_SAFE_INTEGER)
|
|
138
144
|
);
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
function
|
|
142
|
-
const
|
|
147
|
+
function buildFolderOrderMap(metaFiles: { path: string; data: Record<string, unknown> }[]): Map<string, number> {
|
|
148
|
+
const map = new Map<string, number>();
|
|
149
|
+
for (const meta of metaFiles) {
|
|
150
|
+
const order = meta.data.order as number | undefined;
|
|
151
|
+
if (order === undefined) continue;
|
|
152
|
+
const folderUrl = '/' + meta.path.replace(/\/meta\.json$/, '');
|
|
153
|
+
map.set(folderUrl, order);
|
|
154
|
+
}
|
|
155
|
+
return map;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[], metaFiles: { path: string; data: Record<string, unknown> }[]): Root {
|
|
159
|
+
const pageOrderMap = new Map<string, number>();
|
|
143
160
|
for (const page of pages) {
|
|
144
161
|
const d = page.data as Record<string, unknown>;
|
|
145
162
|
const order = d.order as number | undefined;
|
|
146
|
-
if (order !== undefined)
|
|
147
|
-
if (page.url === '/')
|
|
163
|
+
if (order !== undefined) pageOrderMap.set(page.url, order);
|
|
164
|
+
if (page.url === '/') pageOrderMap.set('/', order ?? 0);
|
|
148
165
|
}
|
|
149
|
-
|
|
166
|
+
const folderOrderMap = buildFolderOrderMap(metaFiles);
|
|
167
|
+
return { ...tree, children: sortNodes(tree.children, pageOrderMap, folderOrderMap) };
|
|
150
168
|
}
|
|
151
169
|
|
|
152
170
|
export async function getPageTree(): Promise<Root> {
|
|
153
171
|
if (cachedTree) return cachedTree;
|
|
154
172
|
const s = await getSource();
|
|
155
|
-
|
|
173
|
+
const metaFiles = buildFiles().filter(f => f.type === 'meta') as { path: string; data: Record<string, unknown> }[];
|
|
174
|
+
cachedTree = sortTreeByOrder(s.pageTree as Root, s.getPages(), metaFiles);
|
|
156
175
|
return cachedTree;
|
|
157
176
|
}
|
|
158
177
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useLocation } from 'react-router'
|
|
3
|
+
import { findApiOperation, type ApiRouteMatch } from '@/lib/api-routes'
|
|
4
|
+
import { usePageContext } from '@/lib/page-context'
|
|
5
|
+
|
|
6
|
+
export function useApiOperation(): ApiRouteMatch | null {
|
|
7
|
+
const { apiSpecs } = usePageContext()
|
|
8
|
+
const { pathname } = useLocation()
|
|
9
|
+
|
|
10
|
+
return useMemo(() => {
|
|
11
|
+
const slug = pathname.replace(/^\/apis\//, '').split('/').filter(Boolean)
|
|
12
|
+
if (slug.length !== 2) return null
|
|
13
|
+
return findApiOperation(apiSpecs, slug)
|
|
14
|
+
}, [apiSpecs, pathname])
|
|
15
|
+
}
|
package/src/pages/ApiPage.tsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { Flex, Headline, Text } from '@raystack/apsara';
|
|
2
1
|
import type { OpenAPIV3 } from 'openapi-types';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { Navigate } from 'react-router';
|
|
3
|
+
import { ApiOverview } from '@/components/api';
|
|
4
|
+
import { findApiOperation, getFirstApiUrl } from '@/lib/api-routes';
|
|
5
5
|
import { Head } from '@/lib/head';
|
|
6
|
-
import type { ApiSpec } from '@/lib/openapi';
|
|
7
6
|
import { usePageContext } from '@/lib/page-context';
|
|
8
7
|
|
|
9
8
|
interface ApiPageProps {
|
|
@@ -14,16 +13,9 @@ export function ApiPage({ slug }: ApiPageProps) {
|
|
|
14
13
|
const { config, apiSpecs } = usePageContext();
|
|
15
14
|
|
|
16
15
|
if (slug.length === 0) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
title='API Reference'
|
|
21
|
-
description={`API documentation for ${config.site.title}`}
|
|
22
|
-
config={config}
|
|
23
|
-
/>
|
|
24
|
-
<ApiLanding specs={apiSpecs} />
|
|
25
|
-
</>
|
|
26
|
-
);
|
|
16
|
+
const firstUrl = getFirstApiUrl(apiSpecs);
|
|
17
|
+
if (firstUrl) return <Navigate to={firstUrl} replace />;
|
|
18
|
+
return null;
|
|
27
19
|
}
|
|
28
20
|
|
|
29
21
|
const match = findApiOperation(apiSpecs, slug);
|
|
@@ -36,7 +28,7 @@ export function ApiPage({ slug }: ApiPageProps) {
|
|
|
36
28
|
return (
|
|
37
29
|
<>
|
|
38
30
|
<Head title={title} description={operation.description} config={config} />
|
|
39
|
-
<
|
|
31
|
+
<ApiOverview
|
|
40
32
|
method={match.method}
|
|
41
33
|
path={match.path}
|
|
42
34
|
operation={match.operation}
|
|
@@ -48,26 +40,3 @@ export function ApiPage({ slug }: ApiPageProps) {
|
|
|
48
40
|
);
|
|
49
41
|
}
|
|
50
42
|
|
|
51
|
-
function ApiLanding({ specs }: { specs: ApiSpec[] }) {
|
|
52
|
-
return (
|
|
53
|
-
<Flex
|
|
54
|
-
direction='column'
|
|
55
|
-
gap='large'
|
|
56
|
-
style={{ padding: 'var(--rs-space-7)' }}
|
|
57
|
-
>
|
|
58
|
-
<Headline size='medium' as='h1'>
|
|
59
|
-
API Reference
|
|
60
|
-
</Headline>
|
|
61
|
-
{specs.map(spec => (
|
|
62
|
-
<Flex key={spec.name} direction='column' gap='small'>
|
|
63
|
-
<Headline size='small' as='h2'>
|
|
64
|
-
{spec.name}
|
|
65
|
-
</Headline>
|
|
66
|
-
{spec.document.info.description && (
|
|
67
|
-
<Text size={3}>{spec.document.info.description}</Text>
|
|
68
|
-
)}
|
|
69
|
-
</Flex>
|
|
70
|
-
))}
|
|
71
|
-
</Flex>
|
|
72
|
-
);
|
|
73
|
-
}
|
package/src/pages/DocsPage.tsx
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
1
|
+
import { Navigate } from 'react-router';
|
|
1
2
|
import { Head } from '@/lib/head';
|
|
2
3
|
import { usePageContext } from '@/lib/page-context';
|
|
3
4
|
import { NotFound } from '@/pages/NotFound';
|
|
4
5
|
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
|
+
}
|
|
5
30
|
|
|
6
31
|
interface DocsPageProps {
|
|
7
32
|
slug: string[];
|
|
@@ -10,7 +35,21 @@ interface DocsPageProps {
|
|
|
10
35
|
export function DocsPage({ slug }: DocsPageProps) {
|
|
11
36
|
const { config, tree, page, isLoading, errorStatus } = usePageContext();
|
|
12
37
|
|
|
13
|
-
if (errorStatus === 404)
|
|
38
|
+
if (errorStatus === 404) {
|
|
39
|
+
const pathname = `/${slug.join('/')}`;
|
|
40
|
+
const contentConfig = config.content?.find(c => c.dir === slug[0]);
|
|
41
|
+
const isContentRoot = slug.length === 1 && slug[0] === contentConfig?.dir;
|
|
42
|
+
if (contentConfig?.index_page) {
|
|
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 />;
|
|
51
|
+
return <NotFound />;
|
|
52
|
+
}
|
|
14
53
|
if (errorStatus) return <NotFound />;
|
|
15
54
|
const { Page, Skeleton } = getTheme(config.theme?.name);
|
|
16
55
|
|
|
@@ -51,10 +51,17 @@ export default defineHandler(async event => {
|
|
|
51
51
|
? await response.json()
|
|
52
52
|
: await response.text();
|
|
53
53
|
|
|
54
|
+
const sensitiveHeaders = new Set(['set-cookie', 'authorization', 'proxy-authorization', 'cookie']);
|
|
55
|
+
const responseHeaders: Record<string, string> = {};
|
|
56
|
+
response.headers.forEach((v, k) => {
|
|
57
|
+
if (!sensitiveHeaders.has(k.toLowerCase())) responseHeaders[k] = v;
|
|
58
|
+
});
|
|
59
|
+
|
|
54
60
|
return Response.json({
|
|
55
61
|
status: response.status,
|
|
56
62
|
statusText: response.statusText,
|
|
57
|
-
body: responseBody
|
|
63
|
+
body: responseBody,
|
|
64
|
+
headers: responseHeaders
|
|
58
65
|
});
|
|
59
66
|
} catch (error) {
|
|
60
67
|
const message =
|
|
@@ -19,7 +19,7 @@ import serverAssets from './entry-server?assets=ssr';
|
|
|
19
19
|
export default {
|
|
20
20
|
async fetch(req: Request) {
|
|
21
21
|
const url = new URL(req.url);
|
|
22
|
-
const pathname = url.pathname;
|
|
22
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
23
23
|
|
|
24
24
|
const config = loadConfig();
|
|
25
25
|
const route = resolveRoute(pathname, config);
|
|
@@ -7,6 +7,7 @@ import { safePath } from '@/server/utils/safe-path';
|
|
|
7
7
|
export default defineHandler(async event => {
|
|
8
8
|
const pathname = event.path || event.req.url?.split('?')[0] || '';
|
|
9
9
|
if (!pathname.endsWith('.md')) return;
|
|
10
|
+
if (pathname.startsWith('/apis/')) return;
|
|
10
11
|
|
|
11
12
|
const stripped = pathname.replace(/\.md$/, '');
|
|
12
13
|
const parts = stripped === '/index' || stripped === '/'
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from 'openapi-types'
|
|
2
|
+
import { defineHandler, HTTPError } from 'nitro'
|
|
3
|
+
import { loadConfig } from '@/lib/config'
|
|
4
|
+
import { loadApiSpecs } from '@/lib/openapi'
|
|
5
|
+
import { findApiOperation } from '@/lib/api-routes'
|
|
6
|
+
import { flattenSchema, generateExampleJson, type SchemaField } from '@/lib/schema'
|
|
7
|
+
import { generateCurl } from '@/lib/snippet-generators'
|
|
8
|
+
|
|
9
|
+
export default defineHandler(async event => {
|
|
10
|
+
const pathname = event.path || event.req.url?.split('?')[0] || ''
|
|
11
|
+
if (!pathname.endsWith('.md')) return
|
|
12
|
+
|
|
13
|
+
const stripped = pathname.replace(/\.md$/, '').replace(/^\/apis\//, '')
|
|
14
|
+
const slug = stripped.split('/').filter(Boolean)
|
|
15
|
+
if (slug.length < 2) {
|
|
16
|
+
throw new HTTPError({ status: 404, message: 'Not Found' })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const config = loadConfig()
|
|
20
|
+
const specs = await loadApiSpecs(config.api ?? [])
|
|
21
|
+
const match = findApiOperation(specs, slug)
|
|
22
|
+
|
|
23
|
+
if (!match) {
|
|
24
|
+
throw new HTTPError({ status: 404, message: 'Not Found' })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const md = generateApiMarkdown(match.method, match.path, match.operation, match.spec.server.url, match.spec.auth)
|
|
28
|
+
return new Response(md, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function generateApiMarkdown(
|
|
32
|
+
method: string,
|
|
33
|
+
path: string,
|
|
34
|
+
operation: OpenAPIV3.OperationObject,
|
|
35
|
+
serverUrl: string,
|
|
36
|
+
auth?: { type: string; header: string; placeholder?: string },
|
|
37
|
+
): string {
|
|
38
|
+
const lines: string[] = []
|
|
39
|
+
const params = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[]
|
|
40
|
+
|
|
41
|
+
lines.push(`# ${operation.summary ?? `${method} ${path}`}`)
|
|
42
|
+
lines.push('')
|
|
43
|
+
if (operation.description) {
|
|
44
|
+
lines.push(operation.description)
|
|
45
|
+
lines.push('')
|
|
46
|
+
}
|
|
47
|
+
lines.push(`\`${method}\` \`${path}\``)
|
|
48
|
+
lines.push('')
|
|
49
|
+
|
|
50
|
+
const headerParams = params.filter(p => p.in === 'header')
|
|
51
|
+
const pathParams = params.filter(p => p.in === 'path')
|
|
52
|
+
const queryParams = params.filter(p => p.in === 'query')
|
|
53
|
+
|
|
54
|
+
if (auth || headerParams.length > 0) {
|
|
55
|
+
lines.push('## Authorization')
|
|
56
|
+
lines.push('')
|
|
57
|
+
lines.push('| Header | Type | Required | Description |')
|
|
58
|
+
lines.push('| --- | --- | --- | --- |')
|
|
59
|
+
if (auth) {
|
|
60
|
+
lines.push(`| \`${auth.header}\` | string | Yes | ${auth.placeholder ?? 'API key'} |`)
|
|
61
|
+
}
|
|
62
|
+
for (const p of headerParams) {
|
|
63
|
+
const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject
|
|
64
|
+
lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`)
|
|
65
|
+
}
|
|
66
|
+
lines.push('')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (pathParams.length > 0) {
|
|
70
|
+
lines.push('## Path Parameters')
|
|
71
|
+
lines.push('')
|
|
72
|
+
lines.push('| Parameter | Type | Required | Description |')
|
|
73
|
+
lines.push('| --- | --- | --- | --- |')
|
|
74
|
+
for (const p of pathParams) {
|
|
75
|
+
const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject
|
|
76
|
+
lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`)
|
|
77
|
+
}
|
|
78
|
+
lines.push('')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (queryParams.length > 0) {
|
|
82
|
+
lines.push('## Query Parameters')
|
|
83
|
+
lines.push('')
|
|
84
|
+
lines.push('| Parameter | Type | Required | Description |')
|
|
85
|
+
lines.push('| --- | --- | --- | --- |')
|
|
86
|
+
for (const p of queryParams) {
|
|
87
|
+
const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject
|
|
88
|
+
lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`)
|
|
89
|
+
}
|
|
90
|
+
lines.push('')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const requestBody = operation.requestBody as OpenAPIV3.RequestBodyObject | undefined
|
|
94
|
+
if (requestBody?.content) {
|
|
95
|
+
const contentType = Object.keys(requestBody.content)[0]
|
|
96
|
+
const schema = contentType ? requestBody.content[contentType]?.schema as OpenAPIV3.SchemaObject : undefined
|
|
97
|
+
if (schema) {
|
|
98
|
+
lines.push('## Request Body')
|
|
99
|
+
lines.push('')
|
|
100
|
+
lines.push(`Content-Type: \`${contentType}\``)
|
|
101
|
+
lines.push('')
|
|
102
|
+
const fields = flattenSchema(schema)
|
|
103
|
+
if (fields.length > 0) {
|
|
104
|
+
lines.push('| Field | Type | Required | Description |')
|
|
105
|
+
lines.push('| --- | --- | --- | --- |')
|
|
106
|
+
renderFieldTable(fields, lines, 0)
|
|
107
|
+
lines.push('')
|
|
108
|
+
}
|
|
109
|
+
const example = generateExampleJson(schema)
|
|
110
|
+
lines.push('**Example:**')
|
|
111
|
+
lines.push('')
|
|
112
|
+
lines.push('```json')
|
|
113
|
+
lines.push(JSON.stringify(example, null, 2))
|
|
114
|
+
lines.push('```')
|
|
115
|
+
lines.push('')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const responses = operation.responses as Record<string, OpenAPIV3.ResponseObject> | undefined
|
|
120
|
+
if (responses) {
|
|
121
|
+
lines.push('## Responses')
|
|
122
|
+
lines.push('')
|
|
123
|
+
for (const [status, resp] of Object.entries(responses)) {
|
|
124
|
+
lines.push(`### ${status}${resp.description ? ` — ${resp.description}` : ''}`)
|
|
125
|
+
lines.push('')
|
|
126
|
+
const content = resp.content ?? {}
|
|
127
|
+
const contentType = Object.keys(content)[0]
|
|
128
|
+
const schema = contentType ? content[contentType]?.schema as OpenAPIV3.SchemaObject : undefined
|
|
129
|
+
if (schema) {
|
|
130
|
+
const fields = flattenSchema(schema)
|
|
131
|
+
if (fields.length > 0) {
|
|
132
|
+
lines.push('| Field | Type | Description |')
|
|
133
|
+
lines.push('| --- | --- | --- |')
|
|
134
|
+
renderResponseFieldTable(fields, lines, 0)
|
|
135
|
+
lines.push('')
|
|
136
|
+
}
|
|
137
|
+
const example = generateExampleJson(schema)
|
|
138
|
+
lines.push('```json')
|
|
139
|
+
lines.push(JSON.stringify(example, null, 2))
|
|
140
|
+
lines.push('```')
|
|
141
|
+
lines.push('')
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const headers: Record<string, string> = {}
|
|
147
|
+
if (auth) headers[auth.header] = auth.placeholder ?? 'YOUR_API_KEY'
|
|
148
|
+
if (requestBody?.content) {
|
|
149
|
+
const ct = Object.keys(requestBody.content)[0]
|
|
150
|
+
if (ct) headers['Content-Type'] = ct
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const bodySchema = requestBody?.content
|
|
154
|
+
? (Object.values(requestBody.content)[0]?.schema as OpenAPIV3.SchemaObject | undefined)
|
|
155
|
+
: undefined
|
|
156
|
+
const bodyStr = bodySchema ? JSON.stringify(generateExampleJson(bodySchema), null, 2) : undefined
|
|
157
|
+
|
|
158
|
+
lines.push('## cURL')
|
|
159
|
+
lines.push('')
|
|
160
|
+
lines.push('```bash')
|
|
161
|
+
lines.push(generateCurl({ method, url: serverUrl + path, headers, body: bodyStr }))
|
|
162
|
+
lines.push('```')
|
|
163
|
+
|
|
164
|
+
return lines.join('\n')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderFieldTable(fields: SchemaField[], lines: string[], depth: number) {
|
|
168
|
+
const indent = ' '.repeat(depth)
|
|
169
|
+
for (const f of fields) {
|
|
170
|
+
lines.push(`| ${indent}\`${f.name}\` | ${f.type} | ${f.required ? 'Yes' : 'No'} | ${f.description ?? ''} |`)
|
|
171
|
+
if (f.children) renderFieldTable(f.children, lines, depth + 1)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderResponseFieldTable(fields: SchemaField[], lines: string[], depth: number) {
|
|
176
|
+
const indent = ' '.repeat(depth)
|
|
177
|
+
for (const f of fields) {
|
|
178
|
+
lines.push(`| ${indent}\`${f.name}\` | ${f.type} | ${f.description ?? ''} |`)
|
|
179
|
+
if (f.children) renderResponseFieldTable(f.children, lines, depth + 1)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -48,6 +48,7 @@ export async function createViteConfig(
|
|
|
48
48
|
|
|
49
49
|
return {
|
|
50
50
|
root: packageRoot,
|
|
51
|
+
publicDir: path.resolve(projectRoot, 'public'),
|
|
51
52
|
configFile: false,
|
|
52
53
|
plugins: [
|
|
53
54
|
nitro({
|
|
@@ -131,6 +132,7 @@ export async function createViteConfig(
|
|
|
131
132
|
},
|
|
132
133
|
nitro: {
|
|
133
134
|
logLevel: 2,
|
|
135
|
+
publicAssets: [{ dir: path.resolve(projectRoot, 'public') }],
|
|
134
136
|
output: {
|
|
135
137
|
dir: resolveOutputDir(projectRoot, preset),
|
|
136
138
|
},
|
|
@@ -226,3 +226,56 @@
|
|
|
226
226
|
.page {
|
|
227
227
|
padding: var(--rs-space-2) 0;
|
|
228
228
|
}
|
|
229
|
+
|
|
230
|
+
.apiGroup {
|
|
231
|
+
margin-top: var(--rs-space-8);
|
|
232
|
+
width: 100%;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.apiGroup:first-child {
|
|
236
|
+
margin-top: 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.apiGroupLabel {
|
|
240
|
+
font-size: var(--rs-font-size-small);
|
|
241
|
+
font-weight: var(--rs-font-weight-medium);
|
|
242
|
+
line-height: var(--rs-line-height-small);
|
|
243
|
+
letter-spacing: var(--rs-letter-spacing-small);
|
|
244
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
245
|
+
padding: 0 var(--rs-space-3);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.apiItem {
|
|
249
|
+
padding: var(--rs-space-3);
|
|
250
|
+
border-radius: var(--rs-radius-2);
|
|
251
|
+
text-decoration: none;
|
|
252
|
+
cursor: pointer;
|
|
253
|
+
white-space: nowrap;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.apiItem:hover {
|
|
257
|
+
background: var(--rs-color-background-neutral-secondary);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.apiItemActive {
|
|
261
|
+
background: var(--rs-color-background-neutral-secondary);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.apiItemName {
|
|
265
|
+
flex: 1;
|
|
266
|
+
min-width: 0;
|
|
267
|
+
overflow: hidden;
|
|
268
|
+
text-overflow: ellipsis;
|
|
269
|
+
font-size: var(--rs-font-size-small);
|
|
270
|
+
font-weight: var(--rs-font-weight-medium);
|
|
271
|
+
line-height: var(--rs-line-height-small);
|
|
272
|
+
letter-spacing: var(--rs-letter-spacing-small);
|
|
273
|
+
color: var(--rs-color-foreground-base-primary);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.apiMethodText {
|
|
277
|
+
font-family: var(--rs-font-mono);
|
|
278
|
+
font-size: var(--rs-font-size-mono-mini);
|
|
279
|
+
line-height: var(--rs-line-height-mini);
|
|
280
|
+
flex-shrink: 0;
|
|
281
|
+
}
|