@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.
- package/dist/cli/index.js +14 -2
- package/package.json +3 -4
- 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/components/ui/search.module.css +27 -5
- package/src/components/ui/search.tsx +28 -19
- package/src/lib/api-routes.ts +37 -8
- package/src/lib/openapi.ts +26 -0
- package/src/lib/page-context.tsx +1 -1
- package/src/lib/schema.ts +45 -3
- package/src/lib/source.ts +79 -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/api/ready.ts +15 -0
- package/src/server/api/search.ts +159 -85
- 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 +11 -0
- package/src/themes/default/Layout.module.css +53 -0
- package/src/themes/default/Layout.tsx +162 -11
- package/src/themes/default/Page.module.css +4 -0
- package/src/themes/default/Page.tsx +6 -1
- package/src/types/config.ts +2 -1
- 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
|
@@ -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
|
|
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
|
-
{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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' ? (
|
package/src/lib/api-routes.ts
CHANGED
|
@@ -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
|
|
23
|
-
|
|
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
|
|
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
|
|
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 ??
|
|
86
|
-
url: `/apis/${specSlug}/${encodeURIComponent(
|
|
114
|
+
name: op.summary ?? opId,
|
|
115
|
+
url: `/apis/${specSlug}/${encodeURIComponent(opId)}`,
|
|
87
116
|
icon: `method-${method}`,
|
|
88
117
|
})
|
|
89
118
|
}
|
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/page-context.tsx
CHANGED
|
@@ -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,
|
|
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
|
@@ -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
|
|
122
|
-
|
|
123
|
-
if (
|
|
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[],
|
|
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,
|
|
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,
|
|
137
|
-
(getOrder(b,
|
|
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
|
|
142
|
-
const
|
|
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)
|
|
147
|
-
if (page.url === '/')
|
|
181
|
+
if (order !== undefined) pageOrderMap.set(page.url, order);
|
|
182
|
+
if (page.url === '/') pageOrderMap.set('/', order ?? 0);
|
|
148
183
|
}
|
|
149
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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 =
|
|
@@ -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
|
+
});
|