@raystack/chronicle 0.7.4 → 0.9.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 (50) hide show
  1. package/dist/cli/index.js +14 -2
  2. package/package.json +3 -4
  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/components/ui/search.module.css +27 -5
  17. package/src/components/ui/search.tsx +28 -19
  18. package/src/lib/api-routes.ts +37 -8
  19. package/src/lib/openapi.ts +26 -0
  20. package/src/lib/page-context.tsx +1 -1
  21. package/src/lib/schema.ts +45 -3
  22. package/src/lib/source.ts +79 -13
  23. package/src/lib/use-api-operation.ts +15 -0
  24. package/src/pages/ApiLayout.module.css +1 -0
  25. package/src/pages/ApiPage.tsx +7 -38
  26. package/src/pages/DocsPage.tsx +40 -1
  27. package/src/server/api/apis-proxy.ts +8 -1
  28. package/src/server/api/ready.ts +15 -0
  29. package/src/server/api/search.ts +159 -85
  30. package/src/server/entry-server.tsx +1 -1
  31. package/src/server/routes/[...slug].md.ts +1 -0
  32. package/src/server/routes/apis/[...slug].md.ts +181 -0
  33. package/src/server/vite-config.ts +11 -0
  34. package/src/themes/default/Layout.module.css +53 -0
  35. package/src/themes/default/Layout.tsx +162 -11
  36. package/src/themes/default/Page.module.css +4 -0
  37. package/src/themes/default/Page.tsx +6 -1
  38. package/src/types/config.ts +2 -1
  39. package/src/components/api/code-snippets.module.css +0 -7
  40. package/src/components/api/code-snippets.tsx +0 -76
  41. package/src/components/api/endpoint-page.module.css +0 -58
  42. package/src/components/api/endpoint-page.tsx +0 -283
  43. package/src/components/api/field-row.module.css +0 -126
  44. package/src/components/api/field-row.tsx +0 -204
  45. package/src/components/api/field-section.module.css +0 -24
  46. package/src/components/api/field-section.tsx +0 -100
  47. package/src/components/api/key-value-editor.module.css +0 -13
  48. package/src/components/api/key-value-editor.tsx +0 -62
  49. package/src/components/api/response-panel.module.css +0 -8
  50. package/src/components/api/response-panel.tsx +0 -44
@@ -4,7 +4,7 @@ import {
4
4
  MagnifyingGlassIcon
5
5
  } from '@heroicons/react/24/outline';
6
6
  import { Command, IconButton, Text } from '@raystack/apsara';
7
- import debounce from 'lodash/debounce';
7
+ import { debounce } from 'lodash-es';
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
9
  import { useNavigate } from 'react-router';
10
10
  import { MethodBadge } from '@/components/api/method-badge';
@@ -16,6 +16,8 @@ interface SearchResult {
16
16
  url: string;
17
17
  type: string;
18
18
  content: string;
19
+ match?: 'title' | 'heading' | 'body';
20
+ snippet?: string;
19
21
  }
20
22
 
21
23
  interface SearchProps {
@@ -121,7 +123,7 @@ export function Search({ classNames }: SearchProps) {
121
123
 
122
124
  <Command.Dialog open={open} onOpenChange={setOpen}>
123
125
  <Command.DialogContent className={styles.dialogContent}>
124
- <Command>
126
+ <Command items={displayResults}>
125
127
  <Command.Input
126
128
  placeholder='Search'
127
129
  leadingIcon={<MagnifyingGlassIcon width={16} height={16} />}
@@ -171,23 +173,17 @@ export function Search({ classNames }: SearchProps) {
171
173
  <div className={styles.itemContent}>
172
174
  {getResultIcon(result)}
173
175
  <div className={styles.resultText}>
174
- {result.type === 'heading' ? (
175
- <>
176
- <Text className={styles.headingText}>
177
- <HighlightedText
178
- html={stripMethod(result.content)}
179
- />
180
- </Text>
181
- <Text className={styles.separator}>-</Text>
182
- <Text className={styles.pageText}>
183
- {getPageTitle(result.url)}
184
- </Text>
185
- </>
186
- ) : (
187
- <Text className={styles.pageText}>
188
- <HighlightedText
189
- html={stripMethod(result.content)}
190
- />
176
+ <Text className={styles.pageText}>
177
+ <HighlightQuery text={stripMethod(result.content)} query={search} />
178
+ </Text>
179
+ {result.snippet && result.match === 'heading' && (
180
+ <Text className={styles.snippetText}>
181
+ # <HighlightQuery text={result.snippet} query={search} />
182
+ </Text>
183
+ )}
184
+ {result.snippet && result.match === 'body' && (
185
+ <Text className={styles.snippetText}>
186
+ <HighlightQuery text={result.snippet} query={search} />
191
187
  </Text>
192
188
  )}
193
189
  </div>
@@ -236,6 +232,19 @@ function HighlightedText({
236
232
  );
237
233
  }
238
234
 
235
+ function HighlightQuery({ text, query }: { text: string; query: string }) {
236
+ if (!query) return <>{text}</>;
237
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
238
+ if (idx < 0) return <>{text}</>;
239
+ return (
240
+ <>
241
+ {text.slice(0, idx)}
242
+ <span className={styles.matchHighlight}>{text.slice(idx, idx + query.length)}</span>
243
+ {text.slice(idx + query.length)}
244
+ </>
245
+ );
246
+ }
247
+
239
248
  function getResultIcon(result: SearchResult): React.ReactNode {
240
249
  if (!result.url.startsWith('/apis/')) {
241
250
  return result.type === 'page' ? (
@@ -7,6 +7,31 @@ export function getSpecSlug(spec: ApiSpec): string {
7
7
  return slugify(spec.name, { lower: true, strict: true })
8
8
  }
9
9
 
10
+ function deriveOperationId(method: string, path: string): string {
11
+ const slug = path.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')
12
+ return `${method}_${slug || 'root'}`
13
+ }
14
+
15
+ function getOperationId(op: OpenAPIV3.OperationObject, method: string, path: string): string {
16
+ return op.operationId || deriveOperationId(method, path)
17
+ }
18
+
19
+
20
+ export function getFirstApiUrl(specs: ApiSpec[]): string | null {
21
+ for (const spec of specs) {
22
+ const specSlug = getSpecSlug(spec)
23
+ const paths = spec.document.paths ?? {}
24
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
25
+ if (!pathItem) continue
26
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
27
+ const op = pathItem[method]
28
+ if (!op) continue
29
+ return `/apis/${specSlug}/${encodeURIComponent(getOperationId(op, method, pathStr))}`
30
+ }
31
+ }
32
+ }
33
+ return null
34
+ }
10
35
 
11
36
  export function buildApiRoutes(specs: ApiSpec[]): { slug: string[] }[] {
12
37
  const routes: { slug: string[] }[] = []
@@ -15,12 +40,13 @@ export function buildApiRoutes(specs: ApiSpec[]): { slug: string[] }[] {
15
40
  const specSlug = getSpecSlug(spec)
16
41
  const paths = spec.document.paths ?? {}
17
42
 
18
- for (const [, pathItem] of Object.entries(paths)) {
43
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
19
44
  if (!pathItem) continue
20
45
  for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
21
46
  const op = pathItem[method]
22
- if (!op?.operationId) continue
23
- routes.push({ slug: [specSlug, encodeURIComponent(op.operationId)] })
47
+ if (!op) continue
48
+ const opId = getOperationId(op, method, pathStr)
49
+ routes.push({ slug: [specSlug, encodeURIComponent(opId)] })
24
50
  }
25
51
  }
26
52
  }
@@ -47,7 +73,9 @@ export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatc
47
73
  if (!pathItem) continue
48
74
  for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
49
75
  const op = pathItem[method]
50
- if (op?.operationId && encodeURIComponent(op.operationId) === operationId) {
76
+ if (!op) continue
77
+ const opId = getOperationId(op, method, pathStr)
78
+ if (encodeURIComponent(opId) === operationId) {
51
79
  return { spec, operation: op, method: method.toUpperCase(), path: pathStr }
52
80
  }
53
81
  }
@@ -67,12 +95,13 @@ export function buildApiPageTree(specs: ApiSpec[]): Root {
67
95
  const opsByTag = new Map<string, Item[]>()
68
96
  const tagDisplayName = new Map<string, string>()
69
97
 
70
- for (const [, pathItem] of Object.entries(paths)) {
98
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
71
99
  if (!pathItem) continue
72
100
  for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
73
101
  const op = pathItem[method]
74
- if (!op?.operationId) continue
102
+ if (!op) continue
75
103
 
104
+ const opId = getOperationId(op, method, pathStr)
76
105
  const rawTag = op.tags?.[0] ?? 'default'
77
106
  const tagKey = rawTag.toLowerCase()
78
107
  if (!opsByTag.has(tagKey)) {
@@ -82,8 +111,8 @@ export function buildApiPageTree(specs: ApiSpec[]): Root {
82
111
 
83
112
  opsByTag.get(tagKey)!.push({
84
113
  type: 'page',
85
- name: op.summary ?? op.operationId,
86
- url: `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`,
114
+ name: op.summary ?? opId,
115
+ url: `/apis/${specSlug}/${encodeURIComponent(opId)}`,
87
116
  icon: `method-${method}`,
88
117
  })
89
118
  }
@@ -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 {
@@ -116,7 +116,7 @@ export function PageProvider({
116
116
  const fetchPageData = useCallback(async (slug: string[]): Promise<PageData> => {
117
117
  const apiPath = slug.length === 0
118
118
  ? '/api/page'
119
- : `/api/page?slug=${slug.join(',')}`;
119
+ : `/api/page?slug=${slug.map(s => encodeURIComponent(s)).join(',')}`;
120
120
  const res = await fetch(apiPath);
121
121
  if (!res.ok) throw new Error(String(res.status));
122
122
  return res.json();
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
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
1
3
  import { loader } from 'fumadocs-core/source';
2
4
  import { flattenTree } from 'fumadocs-core/page-tree';
3
5
  import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
@@ -118,41 +120,76 @@ export function invalidate() {
118
120
  cachedNavMap = null;
119
121
  }
120
122
 
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);
123
+ function getFolderPath(node: Folder): string | null {
124
+ const firstPage = findFirstPage(node);
125
+ if (!firstPage) return null;
126
+ const parts = firstPage.url.split('/').filter(Boolean);
127
+ parts.pop();
128
+ return '/' + parts.join('/');
129
+ }
130
+
131
+ function findFirstPage(node: Folder): { url: string } | null {
132
+ for (const child of node.children) {
133
+ if (child.type === 'page') return child;
134
+ if (child.type === 'folder') {
135
+ const found = findFirstPage(child);
136
+ if (found) return found;
137
+ }
138
+ }
139
+ return node.index ?? null;
140
+ }
141
+
142
+ function getOrder(node: Node, pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): number | undefined {
143
+ if (node.type === 'page') return pageOrderMap.get(node.url);
144
+ if (node.type === 'folder') {
145
+ const folderPath = getFolderPath(node);
146
+ if (folderPath) return folderOrderMap.get(folderPath);
147
+ }
124
148
  return undefined;
125
149
  }
126
150
 
127
- function sortNodes(nodes: Node[], orderMap: Map<string, number>): Node[] {
151
+ function sortNodes(nodes: Node[], pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): Node[] {
128
152
  return [...nodes]
129
153
  .map(n =>
130
154
  n.type === 'folder'
131
- ? ({ ...n, children: sortNodes(n.children, orderMap) } as Folder)
155
+ ? ({ ...n, children: sortNodes(n.children, pageOrderMap, folderOrderMap) } as Folder)
132
156
  : n
133
157
  )
134
158
  .sort(
135
159
  (a, b) =>
136
- (getOrder(a, orderMap) ?? Number.MAX_SAFE_INTEGER) -
137
- (getOrder(b, orderMap) ?? Number.MAX_SAFE_INTEGER)
160
+ (getOrder(a, pageOrderMap, folderOrderMap) ?? Number.MAX_SAFE_INTEGER) -
161
+ (getOrder(b, pageOrderMap, folderOrderMap) ?? Number.MAX_SAFE_INTEGER)
138
162
  );
139
163
  }
140
164
 
141
- function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[]): Root {
142
- const orderMap = new Map<string, number>();
165
+ function buildFolderOrderMap(metaFiles: { path: string; data: Record<string, unknown> }[]): Map<string, number> {
166
+ const map = new Map<string, number>();
167
+ for (const meta of metaFiles) {
168
+ const order = meta.data.order as number | undefined;
169
+ if (order === undefined) continue;
170
+ const folderUrl = '/' + meta.path.replace(/\/meta\.json$/, '');
171
+ map.set(folderUrl, order);
172
+ }
173
+ return map;
174
+ }
175
+
176
+ function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[], metaFiles: { path: string; data: Record<string, unknown> }[]): Root {
177
+ const pageOrderMap = new Map<string, number>();
143
178
  for (const page of pages) {
144
179
  const d = page.data as Record<string, unknown>;
145
180
  const order = d.order as number | undefined;
146
- if (order !== undefined) orderMap.set(page.url, order);
147
- if (page.url === '/') orderMap.set('/', order ?? 0);
181
+ if (order !== undefined) pageOrderMap.set(page.url, order);
182
+ if (page.url === '/') pageOrderMap.set('/', order ?? 0);
148
183
  }
149
- return { ...tree, children: sortNodes(tree.children, orderMap) };
184
+ const folderOrderMap = buildFolderOrderMap(metaFiles);
185
+ return { ...tree, children: sortNodes(tree.children, pageOrderMap, folderOrderMap) };
150
186
  }
151
187
 
152
188
  export async function getPageTree(): Promise<Root> {
153
189
  if (cachedTree) return cachedTree;
154
190
  const s = await getSource();
155
- cachedTree = sortTreeByOrder(s.pageTree as Root, s.getPages());
191
+ const metaFiles = buildFiles().filter(f => f.type === 'meta') as { path: string; data: Record<string, unknown> }[];
192
+ cachedTree = sortTreeByOrder(s.pageTree as Root, s.getPages(), metaFiles);
156
193
  return cachedTree;
157
194
  }
158
195
 
@@ -240,6 +277,35 @@ export function getOriginalPath(page: { data: unknown }): string {
240
277
  return ((page.data as Record<string, unknown>)._originalPath as string) ?? '';
241
278
  }
242
279
 
280
+ export async function getPageSearchContent(page: { data: unknown }): Promise<{ headings: string; body: string }> {
281
+ const originalPath = getOriginalPath(page);
282
+ if (!originalPath) return { headings: '', body: '' };
283
+ try {
284
+ const contentDir = typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined' ? __CHRONICLE_CONTENT_DIR__ : process.cwd();
285
+ const filePath = path.resolve(contentDir, originalPath);
286
+ const raw = await fs.readFile(filePath, 'utf-8');
287
+ const withoutFrontmatter = raw.replace(/^---[\s\S]*?---/m, '');
288
+ const headings: string[] = [];
289
+ const lines: string[] = [];
290
+ for (const line of withoutFrontmatter.split('\n')) {
291
+ const headingMatch = line.match(/^#{1,6}\s+(.+)/);
292
+ if (headingMatch) {
293
+ headings.push(headingMatch[1]);
294
+ } else if (!line.startsWith('import ') && !line.startsWith('export ') && !line.startsWith('```')) {
295
+ const cleaned = line
296
+ .replace(/<[^>]+>/g, '')
297
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
298
+ .replace(/[*_~`]+/g, '')
299
+ .trim();
300
+ if (cleaned) lines.push(cleaned);
301
+ }
302
+ }
303
+ return { headings: headings.join('\n'), body: lines.join(' ') };
304
+ } catch {
305
+ return { headings: '', body: '' };
306
+ }
307
+ }
308
+
243
309
  interface ReadingTime {
244
310
  text: string;
245
311
  minutes: number;
@@ -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 =
@@ -0,0 +1,15 @@
1
+ import { defineHandler } from 'nitro';
2
+ import { isSearchReady } from './search';
3
+
4
+ export default defineHandler(() => {
5
+ const searchReady = isSearchReady();
6
+
7
+ if (!searchReady) {
8
+ return Response.json(
9
+ { status: 'not_ready', search: false },
10
+ { status: 503 },
11
+ );
12
+ }
13
+
14
+ return Response.json({ status: 'ready', search: true });
15
+ });