@raystack/chronicle 0.7.3 → 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 (46) hide show
  1. package/dist/cli/index.js +4 -1
  2. package/package.json +2 -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 +57 -23
  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 +2 -2
  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/themes/paper/Page.module.css +7 -2
  32. package/src/themes/paper/Page.tsx +8 -6
  33. package/src/themes/paper/Skeleton.tsx +9 -0
  34. package/src/types/config.ts +1 -0
  35. package/src/components/api/code-snippets.module.css +0 -7
  36. package/src/components/api/code-snippets.tsx +0 -76
  37. package/src/components/api/endpoint-page.module.css +0 -58
  38. package/src/components/api/endpoint-page.tsx +0 -283
  39. package/src/components/api/field-row.module.css +0 -126
  40. package/src/components/api/field-row.tsx +0 -204
  41. package/src/components/api/field-section.module.css +0 -24
  42. package/src/components/api/field-section.tsx +0 -100
  43. package/src/components/api/key-value-editor.module.css +0 -13
  44. package/src/components/api/key-value-editor.tsx +0 -62
  45. package/src/components/api/response-panel.module.css +0 -8
  46. 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
@@ -97,6 +97,8 @@ function buildSyntheticMeta(): {
97
97
  }
98
98
 
99
99
  let cachedSource: ReturnType<typeof loader> | null = null;
100
+ let cachedTree: Root | null = null;
101
+ let cachedNavMap: Map<string, PageNav> | null = null;
100
102
 
101
103
  async function getSource() {
102
104
  if (cachedSource) return cachedSource;
@@ -112,42 +114,65 @@ export { getSource as source };
112
114
 
113
115
  export function invalidate() {
114
116
  cachedSource = null;
117
+ cachedTree = null;
118
+ cachedNavMap = null;
115
119
  }
116
120
 
117
- function getOrder(node: Node, orderMap: Map<string, number>): number | undefined {
118
- if (node.type === 'page') return orderMap.get(node.url);
119
- 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
+ }
120
130
  return undefined;
121
131
  }
122
132
 
123
- function sortNodes(nodes: Node[], orderMap: Map<string, number>): Node[] {
133
+ function sortNodes(nodes: Node[], pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): Node[] {
124
134
  return [...nodes]
125
135
  .map(n =>
126
136
  n.type === 'folder'
127
- ? ({ ...n, children: sortNodes(n.children, orderMap) } as Folder)
137
+ ? ({ ...n, children: sortNodes(n.children, pageOrderMap, folderOrderMap) } as Folder)
128
138
  : n
129
139
  )
130
140
  .sort(
131
141
  (a, b) =>
132
- (getOrder(a, orderMap) ?? Number.MAX_SAFE_INTEGER) -
133
- (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)
134
144
  );
135
145
  }
136
146
 
137
- function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[]): Root {
138
- 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>();
139
160
  for (const page of pages) {
140
161
  const d = page.data as Record<string, unknown>;
141
162
  const order = d.order as number | undefined;
142
- if (order !== undefined) orderMap.set(page.url, order);
143
- if (page.url === '/') orderMap.set('/', order ?? 0);
163
+ if (order !== undefined) pageOrderMap.set(page.url, order);
164
+ if (page.url === '/') pageOrderMap.set('/', order ?? 0);
144
165
  }
145
- return { ...tree, children: sortNodes(tree.children, orderMap) };
166
+ const folderOrderMap = buildFolderOrderMap(metaFiles);
167
+ return { ...tree, children: sortNodes(tree.children, pageOrderMap, folderOrderMap) };
146
168
  }
147
169
 
148
170
  export async function getPageTree(): Promise<Root> {
171
+ if (cachedTree) return cachedTree;
149
172
  const s = await getSource();
150
- return 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);
175
+ return cachedTree;
151
176
  }
152
177
 
153
178
  export async function getPages() {
@@ -186,12 +211,10 @@ function titleFromUrl(url: string): string {
186
211
  .join(' ');
187
212
  }
188
213
 
189
- export async function getPageNav(slug: string[], tree?: Root): Promise<PageNav> {
190
- const resolvedTree = tree ?? (await getPageTree());
191
- const pages = flattenTree(resolvedTree.children);
192
- const url = slug.length === 0 ? '/' : `/${slug.join('/')}`;
193
- const i = pages.findIndex(p => p.url === url);
194
- if (i < 0) return { prev: null, next: null };
214
+ async function getNavMap(): Promise<Map<string, PageNav>> {
215
+ if (cachedNavMap) return cachedNavMap;
216
+ const tree = await getPageTree();
217
+ const pages = flattenTree(tree.children);
195
218
  const toLink = (p: (typeof pages)[number]): PageNavLink => ({
196
219
  url: p.url,
197
220
  title:
@@ -199,10 +222,21 @@ export async function getPageNav(slug: string[], tree?: Root): Promise<PageNav>
199
222
  ? p.name
200
223
  : titleFromUrl(p.url)
201
224
  });
202
- return {
203
- prev: i > 0 ? toLink(pages[i - 1]) : null,
204
- next: i < pages.length - 1 ? toLink(pages[i + 1]) : null
205
- };
225
+ const navMap = new Map<string, PageNav>();
226
+ for (let i = 0; i < pages.length; i++) {
227
+ navMap.set(pages[i].url, {
228
+ prev: i > 0 ? toLink(pages[i - 1]) : null,
229
+ next: i < pages.length - 1 ? toLink(pages[i + 1]) : null
230
+ });
231
+ }
232
+ cachedNavMap = navMap;
233
+ return cachedNavMap;
234
+ }
235
+
236
+ export async function getPageNav(slug: string[]): Promise<PageNav> {
237
+ const navMap = await getNavMap();
238
+ const url = slug.length === 0 ? '/' : `/${slug.join('/')}`;
239
+ return navMap.get(url) ?? { prev: null, next: null };
206
240
  }
207
241
 
208
242
  export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter {
@@ -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);
@@ -45,7 +45,7 @@ export default {
45
45
  getPageTree(),
46
46
  route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null),
47
47
  ]);
48
- const nav = page ? await getPageNav(pageSlug, tree) : { prev: null, next: null };
48
+ const nav = page ? await getPageNav(pageSlug) : { prev: null, next: null };
49
49
 
50
50
  const relativePath = page ? getRelativePath(page) : null;
51
51
  const originalPath = page ? getOriginalPath(page) : null;
@@ -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
  },