@clidey/dory 1.0.5 → 1.0.6
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/bin/dist/dory.js +36 -2
- package/bin/dist/dory.js.map +1 -1
- package/index.html +0 -1
- package/package.json +16 -22
- package/src/components/hooks.tsx +2 -1
- package/src/components/store.ts +5 -4
- package/src/config/mdx.js +8 -3
- package/src/config/mdx.ts +8 -3
- package/src/embed/widget-iframe.ts +10 -3
- package/src/entry-server.tsx +11 -7
- package/src/main.tsx +5 -1
- package/src/mdx/accordion.tsx +11 -8
- package/src/mdx/api.tsx +2 -352
- package/src/mdx/async-api.tsx +124 -295
- package/src/mdx/callout.tsx +22 -24
- package/src/mdx/card.tsx +20 -22
- package/src/mdx/chip.tsx +14 -0
- package/src/mdx/code.tsx +133 -110
- package/src/mdx/expandable.tsx +8 -8
- package/src/mdx/fence-meta.test.ts +39 -0
- package/src/mdx/fence-meta.ts +61 -0
- package/src/mdx/fence.tsx +155 -44
- package/src/mdx/icon-grid.tsx +29 -0
- package/src/mdx/icon.tsx +2 -2
- package/src/mdx/latex.tsx +14 -7
- package/src/mdx/list.tsx +3 -3
- package/src/mdx/mdx.tsx +7 -1
- package/src/mdx/mermaid.tsx +81 -11
- package/src/mdx/open-api.tsx +396 -239
- package/src/mdx/source.tsx +148 -40
- package/src/mdx/specimen.tsx +69 -0
- package/src/mdx/step.tsx +22 -30
- package/src/mdx/table.tsx +4 -9
- package/src/mdx/tabs.tsx +39 -0
- package/src/mdx/tag.tsx +13 -21
- package/src/mdx/text.tsx +17 -9
- package/src/mdx/type-scale.tsx +28 -0
- package/src/mdx/websocket-playground.tsx +221 -704
- package/src/plugins/code-meta.ts +23 -0
- package/src/plugins/frontmatter-dev-server.ts +6 -2
- package/src/plugins/html-files-middleware.ts +9 -2
- package/src/plugins/html-metadata-injector.ts +4 -3
- package/src/plugins/llm-txt-dev-server.ts +5 -2
- package/src/plugins/llm-txt-generator.ts +2 -1
- package/src/plugins/prerender.ts +7 -4
- package/src/plugins/relative-assets.test.ts +26 -0
- package/src/plugins/relative-assets.ts +80 -0
- package/src/plugins/robots-generator.ts +6 -4
- package/src/plugins/sanitize.test.ts +5 -0
- package/src/plugins/sanitize.ts +2 -0
- package/src/plugins/shared.ts +17 -0
- package/src/plugins/sitemap-generator.ts +2 -1
- package/src/theme.scss +282 -3
- package/src/types/config.ts +3 -0
- package/src/ui/header.tsx +4 -3
- package/src/ui/layout.tsx +22 -4
- package/src/ui/page-meta.tsx +37 -0
- package/src/ui/prev-next-link.tsx +4 -3
- package/src/ui/table-of-content.tsx +11 -3
- package/src/utils/base-path.test.ts +46 -0
- package/src/utils/base-path.ts +41 -0
- package/vite.config.ssr.ts +3 -0
- package/vite.config.ts +3 -0
package/src/mdx/open-api.tsx
CHANGED
|
@@ -1,311 +1,468 @@
|
|
|
1
|
+
import { Button, Sheet, SheetContent, SheetHeader, SheetTitle } from "@clidey/ux"
|
|
2
|
+
import classNames from "classnames"
|
|
3
|
+
import { Check, ChevronRight, Copy, Play } from "lucide-react"
|
|
4
|
+
import { h } from "preact"
|
|
1
5
|
import { useState } from "preact/hooks"
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { Fence } from "./fence"
|
|
6
|
+
import { APIPlayground, type Server } from "./api-playground"
|
|
7
|
+
import { CodeFrame, CodeGroup } from "./code"
|
|
8
|
+
import { Fence, Pre } from "./fence"
|
|
5
9
|
import { Tag } from "./tag"
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Field list used by the API pages and exposed to MDX as Properties/Property.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
8
14
|
|
|
9
15
|
export function Properties({ children }: { children: React.ReactNode }) {
|
|
10
|
-
return (
|
|
11
|
-
<div className="my-2">
|
|
12
|
-
<ul
|
|
13
|
-
role="list"
|
|
14
|
-
className="m-0 max-w-[calc(var(--container-lg)-(--spacing(8)))] list-none divide-y divide-zinc-900/5 p-0 dark:divide-white/10"
|
|
15
|
-
>
|
|
16
|
-
{children}
|
|
17
|
-
</ul>
|
|
18
|
-
</div>
|
|
19
|
-
)
|
|
16
|
+
return <div className="dory-fields not-prose my-2 divide-y divide-[var(--border)]">{children}</div>
|
|
20
17
|
}
|
|
21
18
|
|
|
19
|
+
/** One field: mono name, blue type, orange REQUIRED, optional default pill, muted description. */
|
|
22
20
|
export function Property({
|
|
23
21
|
name,
|
|
24
22
|
children,
|
|
25
23
|
type,
|
|
26
24
|
required = false,
|
|
25
|
+
defaultValue,
|
|
27
26
|
}: {
|
|
28
27
|
name: string
|
|
29
|
-
children
|
|
28
|
+
children?: React.ReactNode
|
|
30
29
|
type?: string
|
|
31
30
|
required?: boolean
|
|
31
|
+
defaultValue?: unknown
|
|
32
32
|
}) {
|
|
33
33
|
return (
|
|
34
|
-
<div className="
|
|
35
|
-
<div className="
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
</div>
|
|
45
|
-
<p className="w-full flex-none [&>:first-child]:mt-0 [&>:last-child]:mb-0 text-xs text-muted-foreground">
|
|
46
|
-
{children}
|
|
47
|
-
</p>
|
|
34
|
+
<div className="py-3.5">
|
|
35
|
+
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
|
36
|
+
<span className="font-mono text-sm font-semibold text-foreground">{name}</span>
|
|
37
|
+
{type && <span className="font-mono text-xs text-[var(--primary)]">{type}</span>}
|
|
38
|
+
{required && <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--brand-readable,#b65401)]">required</span>}
|
|
39
|
+
{defaultValue !== undefined && defaultValue !== null && (
|
|
40
|
+
<span className="rounded-[5px] border border-[var(--border)] bg-muted px-1.5 py-px font-mono text-[11px] text-muted-foreground">
|
|
41
|
+
default: {typeof defaultValue === 'string' ? defaultValue : JSON.stringify(defaultValue)}
|
|
42
|
+
</span>
|
|
43
|
+
)}
|
|
48
44
|
</div>
|
|
45
|
+
{children && <div className="mt-1 text-sm leading-relaxed text-muted-foreground [&_p]:mb-0">{children}</div>}
|
|
49
46
|
</div>
|
|
50
47
|
)
|
|
51
48
|
}
|
|
52
49
|
|
|
53
|
-
|
|
54
|
-
body: string
|
|
55
|
-
type: string
|
|
56
|
-
default?: string | number
|
|
57
|
-
required?: boolean
|
|
58
|
-
children: React.ReactNode
|
|
50
|
+
export function ParamField({ body, type, default: defaultValue, required, children }: {
|
|
51
|
+
body: string
|
|
52
|
+
type: string
|
|
53
|
+
default?: string | number
|
|
54
|
+
required?: boolean
|
|
55
|
+
children: React.ReactNode
|
|
56
|
+
}) {
|
|
57
|
+
return <Property name={body} type={type} required={required} defaultValue={defaultValue}>{children}</Property>
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
export function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
</Property>
|
|
69
|
-
);
|
|
60
|
+
export function ResponseField({ name, type, required, children }: {
|
|
61
|
+
name: string
|
|
62
|
+
type: string
|
|
63
|
+
required?: boolean
|
|
64
|
+
children: React.ReactNode
|
|
65
|
+
}) {
|
|
66
|
+
return <Property name={name} type={type} required={required}>{children}</Property>
|
|
70
67
|
}
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
required?: boolean;
|
|
76
|
-
children: React.ReactNode;
|
|
77
|
-
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// OpenAPI types (the subset Dory renders)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
78
72
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
73
|
+
interface Schema {
|
|
74
|
+
$ref?: string
|
|
75
|
+
type?: string
|
|
76
|
+
format?: string
|
|
77
|
+
enum?: unknown[]
|
|
78
|
+
items?: Schema
|
|
79
|
+
properties?: Record<string, Schema>
|
|
80
|
+
required?: string[]
|
|
81
|
+
description?: string
|
|
82
|
+
default?: unknown
|
|
83
|
+
example?: unknown
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
interface
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
description?: string;
|
|
94
|
-
default?: string | number;
|
|
86
|
+
interface SecurityScheme {
|
|
87
|
+
type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect'
|
|
88
|
+
in?: 'header' | 'query' | 'cookie'
|
|
89
|
+
name?: string
|
|
90
|
+
scheme?: string
|
|
91
|
+
description?: string
|
|
95
92
|
}
|
|
96
93
|
|
|
97
94
|
interface Components {
|
|
98
|
-
schemas?: Record<string, Schema
|
|
95
|
+
schemas?: Record<string, Schema>
|
|
96
|
+
securitySchemes?: Record<string, SecurityScheme>
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
interface Parameter {
|
|
102
|
-
name: string
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
interface Content {
|
|
109
|
-
schema?: Schema;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface RequestBody {
|
|
113
|
-
description?: string;
|
|
114
|
-
content?: {
|
|
115
|
-
'application/json'?: Content;
|
|
116
|
-
};
|
|
100
|
+
name: string
|
|
101
|
+
in?: 'query' | 'path' | 'header' | 'cookie'
|
|
102
|
+
schema?: Schema
|
|
103
|
+
required?: boolean
|
|
104
|
+
description?: string
|
|
117
105
|
}
|
|
118
106
|
|
|
119
|
-
interface
|
|
120
|
-
|
|
121
|
-
'application/json'?: Content;
|
|
122
|
-
};
|
|
107
|
+
interface MediaTypes {
|
|
108
|
+
'application/json'?: { schema?: Schema; example?: unknown }
|
|
123
109
|
}
|
|
124
110
|
|
|
125
111
|
interface PathMethod {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
112
|
+
summary?: string
|
|
113
|
+
description?: string
|
|
114
|
+
parameters?: Parameter[]
|
|
115
|
+
requestBody?: { description?: string; required?: boolean; content?: MediaTypes }
|
|
116
|
+
responses: Record<string, { description?: string; content?: MediaTypes }>
|
|
117
|
+
security?: Array<Record<string, string[]>>
|
|
130
118
|
}
|
|
131
119
|
|
|
132
120
|
export interface OpenAPIJson {
|
|
133
|
-
paths: Record<string, Record<string, PathMethod
|
|
134
|
-
components: Components
|
|
135
|
-
servers?: Server[]
|
|
121
|
+
paths: Record<string, Record<string, PathMethod | Parameter[]>>
|
|
122
|
+
components: Components
|
|
123
|
+
servers?: Server[]
|
|
124
|
+
security?: Array<Record<string, string[]>>
|
|
136
125
|
}
|
|
137
126
|
|
|
138
|
-
|
|
139
|
-
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Schema helpers
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
140
130
|
|
|
131
|
+
function resolveSchema(schema: Schema | undefined, components: Components): Schema {
|
|
132
|
+
if (!schema) return {}
|
|
141
133
|
if (schema.$ref) {
|
|
142
|
-
const
|
|
143
|
-
return resolveSchema(components.schemas?.[
|
|
134
|
+
const name = schema.$ref.replace(/^#\/components\/schemas\//, '')
|
|
135
|
+
return resolveSchema(components.schemas?.[name], components)
|
|
144
136
|
}
|
|
145
|
-
|
|
146
137
|
if (schema.type === 'array' && schema.items) {
|
|
147
|
-
return {
|
|
148
|
-
...schema,
|
|
149
|
-
items: resolveSchema(schema.items, components)
|
|
150
|
-
};
|
|
138
|
+
return { ...schema, items: resolveSchema(schema.items, components) }
|
|
151
139
|
}
|
|
152
|
-
|
|
153
140
|
if (schema.properties) {
|
|
154
|
-
const
|
|
155
|
-
for (const [key, value] of Object.entries(schema.properties))
|
|
156
|
-
|
|
141
|
+
const properties: Record<string, Schema> = {}
|
|
142
|
+
for (const [key, value] of Object.entries(schema.properties)) properties[key] = resolveSchema(value, components)
|
|
143
|
+
return { ...schema, properties }
|
|
144
|
+
}
|
|
145
|
+
return schema
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Type label in the PDF style: `enum<string>`, `string · date-time`, `array<object>`. */
|
|
149
|
+
function typeLabel(schema: Schema): string {
|
|
150
|
+
if (schema.enum) return `enum<${schema.type ?? 'string'}>`
|
|
151
|
+
if (schema.type === 'array') return `array<${schema.items ? typeLabel(schema.items) : 'any'}>`
|
|
152
|
+
if (schema.format) return `${schema.type ?? 'string'} · ${schema.format}`
|
|
153
|
+
return schema.type ?? 'object'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Builds an example value from a schema, preferring declared examples and defaults. */
|
|
157
|
+
function exampleOf(schema: Schema, depth = 0): unknown {
|
|
158
|
+
if (schema.example !== undefined) return schema.example
|
|
159
|
+
if (schema.default !== undefined) return schema.default
|
|
160
|
+
if (schema.enum?.length) return schema.enum[0]
|
|
161
|
+
switch (schema.type) {
|
|
162
|
+
case 'string':
|
|
163
|
+
if (schema.format === 'date-time') return '2026-10-19T09:41:00Z'
|
|
164
|
+
if (schema.format === 'date') return '2026-10-19'
|
|
165
|
+
if (schema.format === 'uuid') return '8f2a91c4-1b7e-4c7a-9f3d-2e6b5a0c1d8e'
|
|
166
|
+
if (schema.format === 'email') return 'user@example.com'
|
|
167
|
+
return 'string'
|
|
168
|
+
case 'integer': return 0
|
|
169
|
+
case 'number': return 0
|
|
170
|
+
case 'boolean': return true
|
|
171
|
+
case 'array': return schema.items && depth < 4 ? [exampleOf(schema.items, depth + 1)] : []
|
|
172
|
+
default: {
|
|
173
|
+
if (!schema.properties || depth > 4) return {}
|
|
174
|
+
const out: Record<string, unknown> = {}
|
|
175
|
+
for (const [key, value] of Object.entries(schema.properties)) out[key] = exampleOf(value, depth + 1)
|
|
176
|
+
return out
|
|
157
177
|
}
|
|
158
|
-
return {
|
|
159
|
-
...schema,
|
|
160
|
-
properties: resolvedProps
|
|
161
|
-
};
|
|
162
178
|
}
|
|
179
|
+
}
|
|
163
180
|
|
|
164
|
-
|
|
181
|
+
function describeDefault(schema: Schema, description?: string): string | undefined {
|
|
182
|
+
if (description) return description
|
|
183
|
+
if (schema.description) return schema.description
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Pieces
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
function SectionTitle({ children, note }: { children: string; note?: string }) {
|
|
192
|
+
return (
|
|
193
|
+
<div className="mt-10 mb-1">
|
|
194
|
+
<h3 className="dory-h3 font-heading">{children}</h3>
|
|
195
|
+
{note && <p className="mt-0.5 font-mono text-xs text-muted-foreground">{note}</p>}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Field rows for an object schema, with nested objects behind a "Show N child attributes" toggle. */
|
|
201
|
+
function SchemaFields({ schema, components }: { schema: Schema; components: Components }) {
|
|
202
|
+
const target = schema.type === 'array' && schema.items ? schema.items : schema
|
|
203
|
+
const properties = Object.entries(target.properties ?? {})
|
|
204
|
+
if (properties.length === 0) return null
|
|
205
|
+
return (
|
|
206
|
+
<Properties>
|
|
207
|
+
{properties.map(([name, raw]) => {
|
|
208
|
+
const prop = resolveSchema(raw, components)
|
|
209
|
+
const nested = prop.type === 'array' && prop.items?.properties ? prop.items : prop.properties ? prop : null
|
|
210
|
+
return (
|
|
211
|
+
<Property key={name} name={name} type={typeLabel(prop)} required={target.required?.includes(name)} defaultValue={prop.default}>
|
|
212
|
+
{describeDefault(prop)}
|
|
213
|
+
{nested && <ChildAttributes schema={nested} components={components} />}
|
|
214
|
+
</Property>
|
|
215
|
+
)
|
|
216
|
+
})}
|
|
217
|
+
</Properties>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ChildAttributes({ schema, components }: { schema: Schema; components: Components }) {
|
|
222
|
+
const [open, setOpen] = useState(false)
|
|
223
|
+
const count = Object.keys(schema.properties ?? {}).length
|
|
224
|
+
if (count === 0) return null
|
|
225
|
+
return (
|
|
226
|
+
<div className="mt-3">
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onClick={() => setOpen((v) => !v)}
|
|
230
|
+
aria-expanded={open}
|
|
231
|
+
className="flex w-full items-center gap-2 rounded-lg border border-[var(--border)] bg-muted px-4 py-2.5 text-left text-sm font-medium text-foreground"
|
|
232
|
+
>
|
|
233
|
+
<ChevronRight aria-hidden="true" className={classNames('h-3.5 w-3.5 transition-transform', { 'rotate-90': open })} />
|
|
234
|
+
{open ? 'Hide' : 'Show'} {count} child attribute{count === 1 ? '' : 's'}
|
|
235
|
+
</button>
|
|
236
|
+
{open && <div className="pl-4"><SchemaFields schema={schema} components={components} /></div>}
|
|
237
|
+
</div>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function CopyUrl({ value }: { value: string }) {
|
|
242
|
+
const [copied, setCopied] = useState(false)
|
|
243
|
+
return (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
aria-label="Copy URL"
|
|
247
|
+
onClick={() => navigator.clipboard.writeText(value).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1000) })}
|
|
248
|
+
className="rounded-md p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
249
|
+
>
|
|
250
|
+
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
251
|
+
</button>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function statusTone(code: string): string {
|
|
256
|
+
if (code.startsWith('2')) return 'bg-green-600'
|
|
257
|
+
if (code === '401' || code === '403' || code.startsWith('5')) return 'bg-[var(--destructive)]'
|
|
258
|
+
return 'bg-[var(--highlight)]'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Response examples per status code, with a colored dot per status in the tab strip. */
|
|
262
|
+
function ResponseTabs({ responses, components }: { responses: PathMethod['responses']; components: Components }) {
|
|
263
|
+
const entries = Object.entries(responses)
|
|
264
|
+
const [active, setActive] = useState(0)
|
|
265
|
+
const current = entries[active]
|
|
266
|
+
if (!current) return null
|
|
267
|
+
const media = current[1].content?.['application/json']
|
|
268
|
+
const example = media?.example ?? (media?.schema ? exampleOf(resolveSchema(media.schema, components)) : { description: current[1].description })
|
|
269
|
+
const code = JSON.stringify(example, null, 2)
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<CodeFrame
|
|
273
|
+
code={code}
|
|
274
|
+
header={
|
|
275
|
+
<div role="tablist" className="flex min-h-10 items-end gap-4 border-b border-[var(--border)] bg-muted pl-4 pr-12">
|
|
276
|
+
{entries.map(([status], index) => (
|
|
277
|
+
<button
|
|
278
|
+
key={status}
|
|
279
|
+
type="button"
|
|
280
|
+
role="tab"
|
|
281
|
+
aria-selected={index === active}
|
|
282
|
+
onClick={() => setActive(index)}
|
|
283
|
+
className={classNames('-mb-px flex items-center gap-1.5 border-b-2 pb-2.5 pt-3 font-mono text-xs transition', {
|
|
284
|
+
'border-[var(--primary)] text-foreground': index === active,
|
|
285
|
+
'border-transparent text-muted-foreground hover:text-foreground': index !== active,
|
|
286
|
+
})}
|
|
287
|
+
>
|
|
288
|
+
<span aria-hidden="true" className={classNames('size-1.5 rounded-full', statusTone(status))} />
|
|
289
|
+
{status}
|
|
290
|
+
</button>
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
}
|
|
294
|
+
>
|
|
295
|
+
<div className="overflow-x-auto py-3 pl-4 pr-12 font-mono text-[12.5px] leading-[1.7]">
|
|
296
|
+
<Fence language="json">{code}</Fence>
|
|
297
|
+
</div>
|
|
298
|
+
</CodeFrame>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** cURL / JavaScript / Python samples generated from the operation. */
|
|
303
|
+
function RequestSamples({ method, url, headers, body }: { method: string; url: string; headers: Record<string, string>; body?: unknown }) {
|
|
304
|
+
const headerLines = Object.entries(headers)
|
|
305
|
+
const json = body === undefined ? undefined : JSON.stringify(body)
|
|
306
|
+
const curl = [
|
|
307
|
+
`curl -X ${method} ${url} \\`,
|
|
308
|
+
...headerLines.map(([key, value]) => ` -H "${key}: ${value}" \\`),
|
|
309
|
+
...(json ? [` -d '${json}'`] : []),
|
|
310
|
+
].join('\n').replace(/ \\$/, '')
|
|
311
|
+
const js = [
|
|
312
|
+
`const response = await fetch("${url}", {`,
|
|
313
|
+
` method: "${method}",`,
|
|
314
|
+
` headers: ${JSON.stringify(headers, null, 2).replace(/\n/g, '\n ')},`,
|
|
315
|
+
...(json ? [` body: JSON.stringify(${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}),`] : []),
|
|
316
|
+
`});`,
|
|
317
|
+
`const data = await response.json();`,
|
|
318
|
+
].join('\n')
|
|
319
|
+
const py = [
|
|
320
|
+
`import requests`,
|
|
321
|
+
``,
|
|
322
|
+
`response = requests.${method.toLowerCase()}(`,
|
|
323
|
+
` "${url}",`,
|
|
324
|
+
` headers=${JSON.stringify(headers)},`,
|
|
325
|
+
...(json ? [` json=${JSON.stringify(body)},`] : []),
|
|
326
|
+
`)`,
|
|
327
|
+
`print(response.json())`,
|
|
328
|
+
].join('\n')
|
|
329
|
+
|
|
330
|
+
// Mirrors what MDX emits for a fence: <pre><code className="language-x" metastring="…">
|
|
331
|
+
const sample = (title: string, language: string, code: string) => (
|
|
332
|
+
<Pre key={title}>{h('code', { className: `language-${language}`, metastring: `title="${title}"` }, code)}</Pre>
|
|
333
|
+
)
|
|
334
|
+
return (
|
|
335
|
+
<CodeGroup title="">
|
|
336
|
+
{sample('cURL', 'bash', curl)}
|
|
337
|
+
{sample('JavaScript', 'javascript', js)}
|
|
338
|
+
{sample('Python', 'python', py)}
|
|
339
|
+
</CodeGroup>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Page
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
function authFields(security: Array<Record<string, string[]>> | undefined, components: Components) {
|
|
348
|
+
const schemes = components.securitySchemes ?? {}
|
|
349
|
+
const names = (security ?? []).flatMap((requirement) => Object.keys(requirement))
|
|
350
|
+
return names.map((name) => schemes[name]).filter((scheme): scheme is SecurityScheme => Boolean(scheme))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function authHeader(scheme: SecurityScheme): [string, string] | null {
|
|
354
|
+
if (scheme.type === 'apiKey' && scheme.in === 'header' && scheme.name) return [scheme.name, '$API_KEY']
|
|
355
|
+
if (scheme.type === 'http') return ['Authorization', `${scheme.scheme === 'basic' ? 'Basic' : 'Bearer'} $TOKEN`]
|
|
356
|
+
if (scheme.type === 'oauth2' || scheme.type === 'openIdConnect') return ['Authorization', 'Bearer $TOKEN']
|
|
357
|
+
return null
|
|
165
358
|
}
|
|
166
359
|
|
|
167
360
|
export function OpenAPI({ openAPIJson, method: selectedMethod, path: selectedPath }: { openAPIJson: OpenAPIJson, method: string, path: string }) {
|
|
168
|
-
const { paths, components } = openAPIJson
|
|
169
|
-
const [showPlayground, setShowPlayground] = useState(false)
|
|
361
|
+
const { paths, components } = openAPIJson
|
|
362
|
+
const [showPlayground, setShowPlayground] = useState(false)
|
|
363
|
+
|
|
364
|
+
const methods = paths[selectedPath]
|
|
365
|
+
const operation = methods?.[selectedMethod.toLowerCase()]
|
|
366
|
+
if (!operation || Array.isArray(operation)) return null
|
|
367
|
+
|
|
368
|
+
const pathParameters = Array.isArray(methods.parameters) ? methods.parameters : []
|
|
369
|
+
const parameters = [...pathParameters, ...(operation.parameters ?? [])]
|
|
370
|
+
const serverUrl = (openAPIJson.servers?.[0]?.url ?? '').replace(/\/$/, '')
|
|
371
|
+
const fullUrl = `${serverUrl}${selectedPath}`
|
|
372
|
+
const schemes = authFields(operation.security ?? openAPIJson.security, components)
|
|
373
|
+
const bodyMedia = operation.requestBody?.content?.['application/json']
|
|
374
|
+
const bodySchema = bodyMedia?.schema ? resolveSchema(bodyMedia.schema, components) : undefined
|
|
375
|
+
const successResponse = Object.entries(operation.responses).find(([code]) => code.startsWith('2'))?.[1]
|
|
376
|
+
const responseSchema = successResponse?.content?.['application/json']?.schema
|
|
377
|
+
? resolveSchema(successResponse.content['application/json'].schema, components)
|
|
378
|
+
: undefined
|
|
170
379
|
|
|
171
|
-
const
|
|
172
|
-
|
|
380
|
+
const headers: Record<string, string> = {}
|
|
381
|
+
for (const scheme of schemes) {
|
|
382
|
+
const header = authHeader(scheme)
|
|
383
|
+
if (header) headers[header[0]] = header[1]
|
|
173
384
|
}
|
|
385
|
+
if (bodySchema) headers['Content-Type'] = 'application/json'
|
|
174
386
|
|
|
175
387
|
return (
|
|
176
|
-
<div className="max-w-none w-full">
|
|
388
|
+
<div className="dory-api max-w-none w-full">
|
|
177
389
|
<Sheet open={showPlayground} onOpenChange={setShowPlayground} modal={true}>
|
|
178
390
|
<SheetContent side="bottom" className="h-[90vh] w-full overflow-y-auto p-8">
|
|
179
391
|
<SheetHeader className="p-0">
|
|
180
392
|
<SheetTitle>API Playground</SheetTitle>
|
|
181
393
|
</SheetHeader>
|
|
182
|
-
<APIPlayground
|
|
183
|
-
method={selectedMethod}
|
|
184
|
-
url={selectedPath}
|
|
185
|
-
servers={openAPIJson.servers}
|
|
186
|
-
/>
|
|
394
|
+
<APIPlayground method={selectedMethod} url={selectedPath} servers={openAPIJson.servers} />
|
|
187
395
|
</SheetContent>
|
|
188
396
|
</Sheet>
|
|
189
|
-
{Object.entries(paths).map(([path, methods]) => {
|
|
190
|
-
if (path !== selectedPath) {
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
397
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
<div key={name} className="mt-8">
|
|
263
|
-
<h4>{name}</h4>
|
|
264
|
-
<Properties>
|
|
265
|
-
{Object.entries(schema.properties || {}).map(([propName, prop]) => (
|
|
266
|
-
<ResponseField
|
|
267
|
-
key={propName}
|
|
268
|
-
name={propName}
|
|
269
|
-
type={prop.type ?? 'string'}
|
|
270
|
-
required={prop.required}
|
|
271
|
-
>
|
|
272
|
-
{prop.description}
|
|
273
|
-
</ResponseField>
|
|
274
|
-
))}
|
|
275
|
-
</Properties>
|
|
276
|
-
</div>
|
|
277
|
-
))}
|
|
278
|
-
</div>
|
|
279
|
-
)}
|
|
280
|
-
</Col>
|
|
281
|
-
<Col sticky>
|
|
282
|
-
<Button
|
|
283
|
-
onClick={handleShowPlayground}
|
|
284
|
-
className='ml-auto mt-3 flex items-center gap-2'>
|
|
285
|
-
Try
|
|
286
|
-
<PlayButton className="h-4 w-4" />
|
|
287
|
-
</Button>
|
|
288
|
-
<APIGroup title="Request" tag={methodUpper} label={path}>
|
|
289
|
-
{Object.entries(responses).map(([code, response]) => (
|
|
290
|
-
<div key={code} title={code}>
|
|
291
|
-
{response.content?.['application/json']?.schema && (() => {
|
|
292
|
-
const resolvedSchema = resolveSchema(response.content['application/json'].schema, components);
|
|
293
|
-
return (
|
|
294
|
-
<Fence language="json">
|
|
295
|
-
{JSON.stringify(resolvedSchema, null, 2)}
|
|
296
|
-
</Fence>
|
|
297
|
-
);
|
|
298
|
-
})()}
|
|
299
|
-
</div>
|
|
300
|
-
))}
|
|
301
|
-
</APIGroup>
|
|
302
|
-
</Col>
|
|
303
|
-
</Row>
|
|
304
|
-
)
|
|
305
|
-
})}
|
|
306
|
-
</div>
|
|
307
|
-
)
|
|
308
|
-
})}
|
|
398
|
+
{operation.description && <p className="dory-lead -mt-4 mb-6">{operation.description}</p>}
|
|
399
|
+
|
|
400
|
+
<div className="not-prose flex items-center gap-3 rounded-lg border border-[var(--border)] bg-card py-2 pl-3 pr-2">
|
|
401
|
+
<Tag>{selectedMethod}</Tag>
|
|
402
|
+
<span className="min-w-0 flex-1 truncate font-mono text-sm">
|
|
403
|
+
<span className="text-muted-foreground">{serverUrl}</span>
|
|
404
|
+
<span className="font-semibold text-foreground">{selectedPath}</span>
|
|
405
|
+
</span>
|
|
406
|
+
<CopyUrl value={fullUrl} />
|
|
407
|
+
<Button onClick={() => setShowPlayground(true)} className="flex items-center gap-1.5 bg-[var(--primary)] text-white hover:bg-[var(--primary)]/90">
|
|
408
|
+
<Play className="h-3 w-3 fill-current" />
|
|
409
|
+
Try it
|
|
410
|
+
</Button>
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
{schemes.length > 0 && (
|
|
414
|
+
<>
|
|
415
|
+
<SectionTitle>Authorization</SectionTitle>
|
|
416
|
+
<Properties>
|
|
417
|
+
{schemes.map((scheme, index) => {
|
|
418
|
+
const header = authHeader(scheme)
|
|
419
|
+
const name = header?.[0] ?? scheme.name ?? scheme.type
|
|
420
|
+
const location = scheme.type === 'apiKey' ? scheme.in ?? 'header' : 'header'
|
|
421
|
+
return (
|
|
422
|
+
<Property key={index} name={name} type={`${location} · string`} required>
|
|
423
|
+
{scheme.description ?? (scheme.type === 'http' ? `${scheme.scheme === 'basic' ? 'Basic' : 'Bearer'} token.` : 'API key.')}
|
|
424
|
+
</Property>
|
|
425
|
+
)
|
|
426
|
+
})}
|
|
427
|
+
</Properties>
|
|
428
|
+
</>
|
|
429
|
+
)}
|
|
430
|
+
|
|
431
|
+
{parameters.length > 0 && (
|
|
432
|
+
<>
|
|
433
|
+
<SectionTitle>Parameters</SectionTitle>
|
|
434
|
+
<Properties>
|
|
435
|
+
{parameters.map((param) => {
|
|
436
|
+
const schema = resolveSchema(param.schema, components)
|
|
437
|
+
return (
|
|
438
|
+
<Property key={`${param.in}-${param.name}`} name={param.name} type={`${param.in ?? 'query'} · ${typeLabel(schema)}`} required={param.required} defaultValue={schema.default}>
|
|
439
|
+
{describeDefault(schema, param.description)}
|
|
440
|
+
</Property>
|
|
441
|
+
)
|
|
442
|
+
})}
|
|
443
|
+
</Properties>
|
|
444
|
+
</>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{bodySchema && (
|
|
448
|
+
<>
|
|
449
|
+
<SectionTitle note="application/json">Body</SectionTitle>
|
|
450
|
+
{operation.requestBody?.description && <p className="mt-2 text-sm text-muted-foreground">{operation.requestBody.description}</p>}
|
|
451
|
+
<SchemaFields schema={bodySchema} components={components} />
|
|
452
|
+
</>
|
|
453
|
+
)}
|
|
454
|
+
|
|
455
|
+
{responseSchema && (
|
|
456
|
+
<>
|
|
457
|
+
<SectionTitle>Response</SectionTitle>
|
|
458
|
+
<SchemaFields schema={responseSchema} components={components} />
|
|
459
|
+
</>
|
|
460
|
+
)}
|
|
461
|
+
|
|
462
|
+
<div className="mt-8">
|
|
463
|
+
<RequestSamples method={selectedMethod} url={fullUrl} headers={headers} body={bodySchema ? exampleOf(bodySchema) : undefined} />
|
|
464
|
+
<ResponseTabs responses={operation.responses} components={components} />
|
|
465
|
+
</div>
|
|
309
466
|
</div>
|
|
310
467
|
)
|
|
311
468
|
}
|