@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.
Files changed (43) hide show
  1. package/dist/cli/index.js +4 -1
  2. package/package.json +1 -1
  3. package/src/components/api/api-code-snippet.module.css +23 -0
  4. package/src/components/api/api-code-snippet.tsx +64 -0
  5. package/src/components/api/api-field-list.module.css +76 -0
  6. package/src/components/api/api-field-list.tsx +91 -0
  7. package/src/components/api/api-overview.module.css +65 -0
  8. package/src/components/api/api-overview.tsx +216 -0
  9. package/src/components/api/api-response-panel.module.css +62 -0
  10. package/src/components/api/api-response-panel.tsx +54 -0
  11. package/src/components/api/index.ts +5 -6
  12. package/src/components/api/json-editor.tsx +8 -8
  13. package/src/components/api/method-badge.tsx +2 -2
  14. package/src/components/api/playground-dialog.module.css +342 -0
  15. package/src/components/api/playground-dialog.tsx +583 -0
  16. package/src/lib/api-routes.ts +37 -8
  17. package/src/lib/openapi.ts +26 -0
  18. package/src/lib/schema.ts +45 -3
  19. package/src/lib/source.ts +32 -13
  20. package/src/lib/use-api-operation.ts +15 -0
  21. package/src/pages/ApiLayout.module.css +1 -0
  22. package/src/pages/ApiPage.tsx +7 -38
  23. package/src/pages/DocsPage.tsx +40 -1
  24. package/src/server/api/apis-proxy.ts +8 -1
  25. package/src/server/entry-server.tsx +1 -1
  26. package/src/server/routes/[...slug].md.ts +1 -0
  27. package/src/server/routes/apis/[...slug].md.ts +181 -0
  28. package/src/server/vite-config.ts +2 -0
  29. package/src/themes/default/Layout.module.css +53 -0
  30. package/src/themes/default/Layout.tsx +162 -11
  31. package/src/types/config.ts +1 -0
  32. package/src/components/api/code-snippets.module.css +0 -7
  33. package/src/components/api/code-snippets.tsx +0 -76
  34. package/src/components/api/endpoint-page.module.css +0 -58
  35. package/src/components/api/endpoint-page.tsx +0 -283
  36. package/src/components/api/field-row.module.css +0 -126
  37. package/src/components/api/field-row.tsx +0 -204
  38. package/src/components/api/field-section.module.css +0 -24
  39. package/src/components/api/field-section.tsx +0 -100
  40. package/src/components/api/key-value-editor.module.css +0 -13
  41. package/src/components/api/key-value-editor.tsx +0 -62
  42. package/src/components/api/response-panel.module.css +0 -8
  43. package/src/components/api/response-panel.tsx +0 -44
@@ -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, prop]) => {
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(schema: OpenAPIV3.SchemaObject): string {
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, orderMap: Map<string, number>): number | undefined {
122
- if (node.type === 'page') return orderMap.get(node.url);
123
- if (node.type === 'folder' && node.index) return orderMap.get(node.index.url);
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[], orderMap: Map<string, number>): 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, orderMap) } as Folder)
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, orderMap) ?? Number.MAX_SAFE_INTEGER) -
137
- (getOrder(b, orderMap) ?? Number.MAX_SAFE_INTEGER)
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 sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[]): Root {
142
- const orderMap = new Map<string, number>();
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) orderMap.set(page.url, order);
147
- if (page.url === '/') orderMap.set('/', order ?? 0);
163
+ if (order !== undefined) pageOrderMap.set(page.url, order);
164
+ if (page.url === '/') pageOrderMap.set('/', order ?? 0);
148
165
  }
149
- return { ...tree, children: sortNodes(tree.children, orderMap) };
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
- cachedTree = sortTreeByOrder(s.pageTree as Root, s.getPages());
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
+ }
@@ -14,6 +14,7 @@
14
14
  .content {
15
15
  height: 100%;
16
16
  overflow-y: auto;
17
+ padding-left: 0;
17
18
  padding-right: 0;
18
19
  }
19
20
 
@@ -1,9 +1,8 @@
1
- import { Flex, Headline, Text } from '@raystack/apsara';
2
1
  import type { OpenAPIV3 } from 'openapi-types';
3
- import { EndpointPage } from '@/components/api';
4
- import { findApiOperation } from '@/lib/api-routes';
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
- return (
18
- <>
19
- <Head
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
- <EndpointPage
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
- }
@@ -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) return <NotFound />;
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
+ }