@umami/shiso 1.18.0 → 1.19.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/chunks/App.js +1081 -47
- package/dist/chunks/architectureDiagram-5GKGNRK7.js +1 -1
- package/dist/chunks/chunk-GMAD6QVW.js +1 -1
- package/dist/chunks/cose-bilkent-JH36ORCC.js +1 -1
- package/dist/chunks/dist.js +1 -1
- package/dist/chunks/docs.js +183 -8
- package/dist/chunks/ganttDiagram-EL5Y4UJY.js +1 -1
- package/dist/chunks/src.js +1 -1
- package/dist/components.js +1 -1
- package/dist/entry-client.js +1 -1
- package/dist/entry-server.js +1 -1
- package/docs.schema.json +1164 -1135
- package/package.json +1 -2
- package/scripts/check-content.mjs +42 -15
- package/scripts/expand-openapi-navigation.mjs +47 -21
- package/scripts/generate-openapi.mjs +36 -11
- package/scripts/generate-search-index.mjs +19 -18
- package/scripts/lib/openapi-project.mjs +197 -0
- package/scripts/lib/openapi.mjs +257 -111
- package/scripts/lib/request-samples.mjs +323 -0
- package/scripts/load-docs-config.mjs +23 -15
- package/scripts/prerender.mjs +17 -14
- package/scripts/vite-docs-config.mjs +1 -0
- package/src/components/ApiPlayground.tsx +522 -0
- package/src/components/DocContent.tsx +15 -4
- package/src/components/Docs.tsx +18 -3
- package/src/components/LanguageSwitcher.tsx +26 -30
- package/src/components/OpenApiOperation.tsx +58 -12
- package/src/components/OpenApiSchema.tsx +97 -0
- package/src/components/SideNav.tsx +2 -2
- package/src/lib/openapi.generated.ts +2 -1
- package/src/lib/openapi.ts +104 -10
- package/src/lib/site-model.ts +12 -0
- package/src/lib/translations/de.json +26 -1
- package/src/lib/translations/en.json +26 -1
- package/src/lib/translations/es.json +26 -1
- package/src/lib/translations/fr.json +26 -1
- package/src/lib/translations/ja.json +26 -1
- package/src/lib/translations/zh-Hans.json +26 -1
- package/src/lib/translations/zh-Hant.json +26 -1
- package/src/lib/types.ts +89 -5
- package/types/labels.d.ts +25 -0
- package/vite.config.ts +3 -4
- package/CHANGELOG.md +0 -8
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { type FormEvent, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { CodeBlock } from '@/components/CodeBlock';
|
|
3
|
+
import { Badge } from '@/components/docs/Badge';
|
|
4
|
+
import { Expandable } from '@/components/docs/Expandable';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { useLabels } from '@/lib/label-context';
|
|
7
|
+
import { methodColor, statusColor } from '@/lib/openapi';
|
|
8
|
+
import type { NormalizedOperation, SchemaNode, SecurityScheme } from '@/lib/types';
|
|
9
|
+
import { cn } from '@/lib/utils';
|
|
10
|
+
import { buildRequest, curlSample } from '../../scripts/lib/request-samples.mjs';
|
|
11
|
+
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
13
|
+
const AUTH_STORAGE_KEY = 'shiso:api-auth';
|
|
14
|
+
const BODYLESS_METHODS = new Set(['GET', 'HEAD']);
|
|
15
|
+
const PARAMETER_LOCATIONS = ['path', 'query', 'header'] as const;
|
|
16
|
+
|
|
17
|
+
type ParameterLocation = (typeof PARAMETER_LOCATIONS)[number];
|
|
18
|
+
type BasicCredentials = { username: string; password: string };
|
|
19
|
+
type AuthValue = string | BasicCredentials;
|
|
20
|
+
type AuthValues = Record<string, AuthValue>;
|
|
21
|
+
type ParameterValues = Record<ParameterLocation, Record<string, string>>;
|
|
22
|
+
|
|
23
|
+
interface PlaygroundResponse {
|
|
24
|
+
status: number;
|
|
25
|
+
statusText: string;
|
|
26
|
+
headers: [string, string][];
|
|
27
|
+
body: string;
|
|
28
|
+
json: boolean;
|
|
29
|
+
elapsed: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const inputClass =
|
|
33
|
+
'h-8 w-full min-w-0 rounded-md border border-border bg-background px-2.5 font-mono text-foreground text-sm placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50';
|
|
34
|
+
|
|
35
|
+
function isBasic(scheme: SecurityScheme): boolean {
|
|
36
|
+
return scheme.type === 'http' && scheme.scheme === 'basic';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function initialParameters(operation: NormalizedOperation): ParameterValues {
|
|
40
|
+
const values = { path: {}, query: {}, header: {} } as ParameterValues;
|
|
41
|
+
for (const location of PARAMETER_LOCATIONS) {
|
|
42
|
+
for (const node of operation.parameters[location]) {
|
|
43
|
+
if (node.name) values[location][node.name] = node.example ?? '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return values;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readStoredAuth(): AuthValues {
|
|
50
|
+
try {
|
|
51
|
+
const raw = window.sessionStorage.getItem(AUTH_STORAGE_KEY);
|
|
52
|
+
const parsed = raw ? JSON.parse(raw) : undefined;
|
|
53
|
+
return parsed && typeof parsed === 'object' ? (parsed as AuthValues) : {};
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeStoredAuth(values: AuthValues) {
|
|
60
|
+
try {
|
|
61
|
+
window.sessionStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(values));
|
|
62
|
+
} catch {
|
|
63
|
+
// Storage may be unavailable (private mode, quota); credentials then live
|
|
64
|
+
// only in component state.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolves the playground proxy template into the URL to fetch. */
|
|
69
|
+
export function proxiedUrl(url: string, proxy?: string): string {
|
|
70
|
+
if (!proxy) return url;
|
|
71
|
+
const encoded = encodeURIComponent(url);
|
|
72
|
+
return proxy.includes('$url') ? proxy.replaceAll('$url', encoded) : `${proxy}${encoded}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function prettyBody(text: string, contentType: string | null): { body: string; json: boolean } {
|
|
76
|
+
const looksJson = /json/i.test(contentType || '') || /^\s*[{[]/.test(text);
|
|
77
|
+
if (looksJson) {
|
|
78
|
+
try {
|
|
79
|
+
return { body: JSON.stringify(JSON.parse(text), null, 2), json: true };
|
|
80
|
+
} catch {
|
|
81
|
+
// Not JSON after all; show the raw text.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { body: text, json: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ParameterInput({
|
|
88
|
+
id,
|
|
89
|
+
node,
|
|
90
|
+
value,
|
|
91
|
+
onChange,
|
|
92
|
+
}: {
|
|
93
|
+
id: string;
|
|
94
|
+
node: SchemaNode;
|
|
95
|
+
value: string;
|
|
96
|
+
onChange: (value: string) => void;
|
|
97
|
+
}) {
|
|
98
|
+
const options = node.enum ?? (node.type === 'boolean' ? ['true', 'false'] : undefined);
|
|
99
|
+
|
|
100
|
+
if (options) {
|
|
101
|
+
return (
|
|
102
|
+
<select
|
|
103
|
+
id={id}
|
|
104
|
+
className={inputClass}
|
|
105
|
+
value={value}
|
|
106
|
+
required={node.required}
|
|
107
|
+
onChange={event => onChange(event.target.value)}
|
|
108
|
+
>
|
|
109
|
+
{!node.required && <option value="">—</option>}
|
|
110
|
+
{options.map(option => (
|
|
111
|
+
<option key={option} value={option}>
|
|
112
|
+
{option}
|
|
113
|
+
</option>
|
|
114
|
+
))}
|
|
115
|
+
</select>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<input
|
|
121
|
+
id={id}
|
|
122
|
+
type="text"
|
|
123
|
+
className={inputClass}
|
|
124
|
+
value={value}
|
|
125
|
+
required={node.required}
|
|
126
|
+
placeholder={node.type}
|
|
127
|
+
autoComplete="off"
|
|
128
|
+
spellCheck={false}
|
|
129
|
+
onChange={event => onChange(event.target.value)}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function Field({
|
|
135
|
+
id,
|
|
136
|
+
label,
|
|
137
|
+
required,
|
|
138
|
+
hint,
|
|
139
|
+
children,
|
|
140
|
+
}: {
|
|
141
|
+
id: string;
|
|
142
|
+
label: string;
|
|
143
|
+
required?: boolean;
|
|
144
|
+
hint?: string;
|
|
145
|
+
children: React.ReactNode;
|
|
146
|
+
}) {
|
|
147
|
+
const labels = useLabels();
|
|
148
|
+
return (
|
|
149
|
+
<div className="grid grid-cols-[minmax(0,1fr)] gap-1 sm:grid-cols-[minmax(8rem,30%)_minmax(0,1fr)] sm:items-center sm:gap-3">
|
|
150
|
+
<label htmlFor={id} className="flex flex-wrap items-baseline gap-1.5 text-sm">
|
|
151
|
+
<span className="font-mono text-foreground">{label}</span>
|
|
152
|
+
{required ? (
|
|
153
|
+
<span className="text-destructive text-xs">{labels.fieldRequired}</span>
|
|
154
|
+
) : hint ? (
|
|
155
|
+
<span className="text-muted-foreground text-xs">{hint}</span>
|
|
156
|
+
) : null}
|
|
157
|
+
</label>
|
|
158
|
+
{children}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ApiPlaygroundProps {
|
|
164
|
+
operation: NormalizedOperation;
|
|
165
|
+
/** Optional CORS proxy template from `api.playground.proxy`. */
|
|
166
|
+
proxy?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The "Try it" panel: a form built from the operation's servers, security
|
|
171
|
+
* schemes, parameters, and request body that sends real requests from the
|
|
172
|
+
* browser and shows the response. Credentials persist in sessionStorage so a
|
|
173
|
+
* token entered once is reused on every endpoint page during the tab's life.
|
|
174
|
+
*/
|
|
175
|
+
export function ApiPlayground({ operation, proxy }: ApiPlaygroundProps) {
|
|
176
|
+
const labels = useLabels();
|
|
177
|
+
const idPrefix = useId();
|
|
178
|
+
const [server, setServer] = useState(operation.servers[0]?.url ?? operation.serverUrl);
|
|
179
|
+
const [auth, setAuth] = useState<AuthValues>({});
|
|
180
|
+
const [parameters, setParameters] = useState(() => initialParameters(operation));
|
|
181
|
+
const [body, setBody] = useState(operation.requestBody?.example ?? '');
|
|
182
|
+
const [status, setStatus] = useState<'idle' | 'pending' | 'done' | 'error'>('idle');
|
|
183
|
+
const [response, setResponse] = useState<PlaygroundResponse>();
|
|
184
|
+
const [error, setError] = useState<string>();
|
|
185
|
+
const controller = useRef<AbortController | null>(null);
|
|
186
|
+
|
|
187
|
+
// Stored credentials are read after hydration so server and client markup match.
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
setAuth(readStoredAuth());
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
useEffect(() => () => controller.current?.abort(), []);
|
|
193
|
+
|
|
194
|
+
const hasBody = !!operation.requestBody && !BODYLESS_METHODS.has(operation.method);
|
|
195
|
+
const requestValues = useMemo(
|
|
196
|
+
() => ({ live: true, server, auth, body: hasBody ? body : undefined, ...parameters }),
|
|
197
|
+
[server, auth, body, hasBody, parameters],
|
|
198
|
+
);
|
|
199
|
+
const preview = useMemo(
|
|
200
|
+
() => curlSample(buildRequest(operation, requestValues)),
|
|
201
|
+
[operation, requestValues],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
function updateAuth(name: string, value: AuthValue) {
|
|
205
|
+
setAuth(current => {
|
|
206
|
+
const next = { ...current, [name]: value };
|
|
207
|
+
writeStoredAuth(next);
|
|
208
|
+
return next;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function updateParameter(location: ParameterLocation, name: string, value: string) {
|
|
213
|
+
setParameters(current => ({
|
|
214
|
+
...current,
|
|
215
|
+
[location]: { ...current[location], [name]: value },
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function cancel() {
|
|
220
|
+
controller.current?.abort();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function send(event: FormEvent) {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
if (status === 'pending') return;
|
|
226
|
+
|
|
227
|
+
const request = buildRequest(operation, requestValues);
|
|
228
|
+
const abort = new AbortController();
|
|
229
|
+
controller.current = abort;
|
|
230
|
+
setStatus('pending');
|
|
231
|
+
setError(undefined);
|
|
232
|
+
const timeout = window.setTimeout(() => abort.abort('timeout'), REQUEST_TIMEOUT_MS);
|
|
233
|
+
const started = performance.now();
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const headers = new Headers();
|
|
237
|
+
for (const [key, value] of Object.entries(request.headers as Record<string, string>)) {
|
|
238
|
+
// Browsers refuse to set Cookie from scripts; leave it to the user agent.
|
|
239
|
+
if (key.toLowerCase() !== 'cookie') headers.set(key, value);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let payload: BodyInit | undefined;
|
|
243
|
+
if (hasBody) {
|
|
244
|
+
const fields = (request.fields ?? {}) as Record<string, string>;
|
|
245
|
+
if (request.bodyKind === 'form') {
|
|
246
|
+
payload = new URLSearchParams(fields);
|
|
247
|
+
} else if (request.bodyKind === 'multipart') {
|
|
248
|
+
const form = new FormData();
|
|
249
|
+
for (const [key, value] of Object.entries(fields)) form.append(key, value);
|
|
250
|
+
payload = form;
|
|
251
|
+
} else if (request.body) {
|
|
252
|
+
payload = request.body as string;
|
|
253
|
+
if (request.contentType) headers.set('Content-Type', request.contentType);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const result = await fetch(proxiedUrl(request.url, proxy), {
|
|
258
|
+
method: request.method,
|
|
259
|
+
headers,
|
|
260
|
+
body: payload,
|
|
261
|
+
signal: abort.signal,
|
|
262
|
+
});
|
|
263
|
+
const text = await result.text();
|
|
264
|
+
const { body: pretty, json } = prettyBody(text, result.headers.get('content-type'));
|
|
265
|
+
const responseHeaders: [string, string][] = [];
|
|
266
|
+
result.headers.forEach((value, key) => {
|
|
267
|
+
responseHeaders.push([key, value]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
setResponse({
|
|
271
|
+
status: result.status,
|
|
272
|
+
statusText: result.statusText,
|
|
273
|
+
headers: responseHeaders,
|
|
274
|
+
body: pretty,
|
|
275
|
+
json,
|
|
276
|
+
elapsed: Math.round(performance.now() - started),
|
|
277
|
+
});
|
|
278
|
+
setStatus('done');
|
|
279
|
+
} catch (caught) {
|
|
280
|
+
if (abort.signal.aborted && abort.signal.reason === 'timeout') {
|
|
281
|
+
setError(labels.apiRequestTimedOut);
|
|
282
|
+
setStatus('error');
|
|
283
|
+
} else if (abort.signal.aborted) {
|
|
284
|
+
setStatus(response ? 'done' : 'idle');
|
|
285
|
+
} else {
|
|
286
|
+
const detail = caught instanceof Error && caught.message ? ` (${caught.message})` : '';
|
|
287
|
+
setError(`${labels.apiRequestFailed}${detail}`);
|
|
288
|
+
setStatus('error');
|
|
289
|
+
}
|
|
290
|
+
} finally {
|
|
291
|
+
window.clearTimeout(timeout);
|
|
292
|
+
if (controller.current === abort) controller.current = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const serverListId = `${idPrefix}-servers`;
|
|
297
|
+
const parameterGroups = PARAMETER_LOCATIONS.map(location => ({
|
|
298
|
+
location,
|
|
299
|
+
name: {
|
|
300
|
+
path: labels.apiPathParameters,
|
|
301
|
+
query: labels.apiQueryParameters,
|
|
302
|
+
header: labels.apiHeaders,
|
|
303
|
+
}[location],
|
|
304
|
+
nodes: operation.parameters[location].filter(node => node.name),
|
|
305
|
+
})).filter(group => group.nodes.length > 0);
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<form
|
|
309
|
+
onSubmit={send}
|
|
310
|
+
className="not-prose my-4 flex flex-col gap-5 rounded-lg border border-border bg-card p-4 text-sm"
|
|
311
|
+
data-slot="api-playground"
|
|
312
|
+
data-pagefind-ignore
|
|
313
|
+
aria-busy={status === 'pending'}
|
|
314
|
+
>
|
|
315
|
+
<div className="flex flex-col gap-2">
|
|
316
|
+
<label htmlFor={`${idPrefix}-server`} className="text-muted-foreground text-xs">
|
|
317
|
+
{labels.apiServer}
|
|
318
|
+
</label>
|
|
319
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
320
|
+
<Badge color={methodColor(operation.method)} size="sm" className="font-mono">
|
|
321
|
+
{operation.method}
|
|
322
|
+
</Badge>
|
|
323
|
+
<input
|
|
324
|
+
id={`${idPrefix}-server`}
|
|
325
|
+
type="text"
|
|
326
|
+
className={cn(inputClass, 'flex-1 basis-48')}
|
|
327
|
+
value={server}
|
|
328
|
+
list={operation.servers.length > 1 ? serverListId : undefined}
|
|
329
|
+
autoComplete="off"
|
|
330
|
+
spellCheck={false}
|
|
331
|
+
onChange={event => setServer(event.target.value)}
|
|
332
|
+
/>
|
|
333
|
+
{operation.servers.length > 1 && (
|
|
334
|
+
<datalist id={serverListId}>
|
|
335
|
+
{operation.servers.map(item => (
|
|
336
|
+
<option key={item.url} value={item.url}>
|
|
337
|
+
{item.description}
|
|
338
|
+
</option>
|
|
339
|
+
))}
|
|
340
|
+
</datalist>
|
|
341
|
+
)}
|
|
342
|
+
<code className="font-mono text-muted-foreground">{operation.path}</code>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{operation.security.length > 0 && (
|
|
347
|
+
<fieldset className="flex flex-col gap-3">
|
|
348
|
+
<legend className="mb-2 text-muted-foreground text-xs">{labels.apiAuthorization}</legend>
|
|
349
|
+
{operation.security.map(scheme => {
|
|
350
|
+
const value = auth[scheme.name];
|
|
351
|
+
const fieldId = `${idPrefix}-auth-${scheme.name}`;
|
|
352
|
+
if (isBasic(scheme)) {
|
|
353
|
+
const basic =
|
|
354
|
+
value && typeof value === 'object' ? value : { username: '', password: '' };
|
|
355
|
+
return (
|
|
356
|
+
<div key={scheme.name} className="flex flex-col gap-2">
|
|
357
|
+
<Field id={`${fieldId}-user`} label={labels.apiUsername} hint={scheme.name}>
|
|
358
|
+
<input
|
|
359
|
+
id={`${fieldId}-user`}
|
|
360
|
+
type="text"
|
|
361
|
+
className={inputClass}
|
|
362
|
+
value={basic.username}
|
|
363
|
+
autoComplete="off"
|
|
364
|
+
onChange={event =>
|
|
365
|
+
updateAuth(scheme.name, { ...basic, username: event.target.value })
|
|
366
|
+
}
|
|
367
|
+
/>
|
|
368
|
+
</Field>
|
|
369
|
+
<Field id={`${fieldId}-pass`} label={labels.apiPassword}>
|
|
370
|
+
<input
|
|
371
|
+
id={`${fieldId}-pass`}
|
|
372
|
+
type="password"
|
|
373
|
+
className={inputClass}
|
|
374
|
+
value={basic.password}
|
|
375
|
+
autoComplete="off"
|
|
376
|
+
onChange={event =>
|
|
377
|
+
updateAuth(scheme.name, { ...basic, password: event.target.value })
|
|
378
|
+
}
|
|
379
|
+
/>
|
|
380
|
+
</Field>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const label =
|
|
385
|
+
scheme.type === 'apiKey'
|
|
386
|
+
? scheme.paramName || labels.apiApiKey
|
|
387
|
+
: scheme.type === 'http' && scheme.scheme !== 'bearer'
|
|
388
|
+
? 'Authorization'
|
|
389
|
+
: labels.apiToken;
|
|
390
|
+
return (
|
|
391
|
+
<Field key={scheme.name} id={fieldId} label={label} hint={scheme.label}>
|
|
392
|
+
<input
|
|
393
|
+
id={fieldId}
|
|
394
|
+
type="password"
|
|
395
|
+
className={inputClass}
|
|
396
|
+
value={typeof value === 'string' ? value : ''}
|
|
397
|
+
autoComplete="off"
|
|
398
|
+
onChange={event => updateAuth(scheme.name, event.target.value)}
|
|
399
|
+
/>
|
|
400
|
+
</Field>
|
|
401
|
+
);
|
|
402
|
+
})}
|
|
403
|
+
<p className="m-0 text-muted-foreground text-xs">{labels.apiCredentialsStored}</p>
|
|
404
|
+
</fieldset>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
{parameterGroups.map(group => (
|
|
408
|
+
<fieldset key={group.location} className="flex flex-col gap-3">
|
|
409
|
+
<legend className="mb-2 text-muted-foreground text-xs">{group.name}</legend>
|
|
410
|
+
{group.nodes.map(node => {
|
|
411
|
+
const name = node.name as string;
|
|
412
|
+
const fieldId = `${idPrefix}-${group.location}-${name}`;
|
|
413
|
+
return (
|
|
414
|
+
<Field
|
|
415
|
+
key={name}
|
|
416
|
+
id={fieldId}
|
|
417
|
+
label={name}
|
|
418
|
+
required={node.required}
|
|
419
|
+
hint={labels.apiOptional}
|
|
420
|
+
>
|
|
421
|
+
<ParameterInput
|
|
422
|
+
id={fieldId}
|
|
423
|
+
node={node}
|
|
424
|
+
value={parameters[group.location][name] ?? ''}
|
|
425
|
+
onChange={value => updateParameter(group.location, name, value)}
|
|
426
|
+
/>
|
|
427
|
+
</Field>
|
|
428
|
+
);
|
|
429
|
+
})}
|
|
430
|
+
</fieldset>
|
|
431
|
+
))}
|
|
432
|
+
|
|
433
|
+
{operation.parameters.cookie.length > 0 && (
|
|
434
|
+
<p className="m-0 text-muted-foreground text-xs">{labels.apiCookiesUnsupported}</p>
|
|
435
|
+
)}
|
|
436
|
+
|
|
437
|
+
{hasBody && (
|
|
438
|
+
<div className="flex flex-col gap-2">
|
|
439
|
+
<label
|
|
440
|
+
htmlFor={`${idPrefix}-body`}
|
|
441
|
+
className="flex items-baseline gap-2 text-muted-foreground text-xs"
|
|
442
|
+
>
|
|
443
|
+
{labels.apiBody}
|
|
444
|
+
<span className="font-mono">{operation.requestBody?.contentType}</span>
|
|
445
|
+
</label>
|
|
446
|
+
<textarea
|
|
447
|
+
id={`${idPrefix}-body`}
|
|
448
|
+
className={cn(inputClass, 'h-auto min-h-32 resize-y py-2 leading-relaxed')}
|
|
449
|
+
value={body}
|
|
450
|
+
spellCheck={false}
|
|
451
|
+
onChange={event => setBody(event.target.value)}
|
|
452
|
+
/>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
|
|
456
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
457
|
+
<Button type="submit" disabled={status === 'pending'}>
|
|
458
|
+
{status === 'pending' ? labels.apiSending : labels.apiSend}
|
|
459
|
+
</Button>
|
|
460
|
+
{status === 'pending' && (
|
|
461
|
+
<Button type="button" variant="outline" onClick={cancel}>
|
|
462
|
+
{labels.apiCancel}
|
|
463
|
+
</Button>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
<Expandable title={labels.apiRequest}>
|
|
468
|
+
<CodeBlock
|
|
469
|
+
data-language="bash"
|
|
470
|
+
data-line-count={String(preview.split('\n').length)}
|
|
471
|
+
className="text-xs"
|
|
472
|
+
>
|
|
473
|
+
<code className="language-bash">{preview}</code>
|
|
474
|
+
</CodeBlock>
|
|
475
|
+
</Expandable>
|
|
476
|
+
|
|
477
|
+
<div className="flex flex-col gap-2" role="status" aria-live="polite">
|
|
478
|
+
<div className="text-muted-foreground text-xs">{labels.apiResponse}</div>
|
|
479
|
+
{status === 'error' && error && <p className="m-0 text-destructive">{error}</p>}
|
|
480
|
+
{response ? (
|
|
481
|
+
<>
|
|
482
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
483
|
+
<Badge color={statusColor(String(response.status))} size="sm">
|
|
484
|
+
{response.status}
|
|
485
|
+
</Badge>
|
|
486
|
+
{response.statusText && (
|
|
487
|
+
<span className="text-muted-foreground">{response.statusText}</span>
|
|
488
|
+
)}
|
|
489
|
+
<span className="text-muted-foreground text-xs">
|
|
490
|
+
{labels.apiElapsed.replace('{ms}', () => String(response.elapsed))}
|
|
491
|
+
</span>
|
|
492
|
+
</div>
|
|
493
|
+
{response.headers.length > 0 && (
|
|
494
|
+
<Expandable title={labels.apiResponseHeaders}>
|
|
495
|
+
<dl className="m-0 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 font-mono text-xs">
|
|
496
|
+
{response.headers.map(([key, value]) => (
|
|
497
|
+
<div key={key} className="contents">
|
|
498
|
+
<dt className="text-muted-foreground">{key}</dt>
|
|
499
|
+
<dd className="m-0 break-all text-foreground">{value}</dd>
|
|
500
|
+
</div>
|
|
501
|
+
))}
|
|
502
|
+
</dl>
|
|
503
|
+
</Expandable>
|
|
504
|
+
)}
|
|
505
|
+
<CodeBlock
|
|
506
|
+
data-language={response.json ? 'json' : 'text'}
|
|
507
|
+
data-title={labels.apiResponseBody}
|
|
508
|
+
data-line-count={String(response.body.split('\n').length)}
|
|
509
|
+
className="text-xs"
|
|
510
|
+
>
|
|
511
|
+
<code className={response.json ? 'language-json' : 'language-text'}>
|
|
512
|
+
{response.body || ' '}
|
|
513
|
+
</code>
|
|
514
|
+
</CodeBlock>
|
|
515
|
+
</>
|
|
516
|
+
) : (
|
|
517
|
+
status !== 'error' && <p className="m-0 text-muted-foreground">{labels.apiNoResponse}</p>
|
|
518
|
+
)}
|
|
519
|
+
</div>
|
|
520
|
+
</form>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
@@ -3,11 +3,12 @@ import { ContextualMenu } from '@/components/ContextualMenu';
|
|
|
3
3
|
import { Badge } from '@/components/docs/Badge';
|
|
4
4
|
import { ArrowLeft, ArrowRight, FileText } from '@/components/icons';
|
|
5
5
|
import { OpenApiOperation } from '@/components/OpenApiOperation';
|
|
6
|
+
import { OpenApiSchema } from '@/components/OpenApiSchema';
|
|
6
7
|
import { PageActions } from '@/components/PageActions';
|
|
7
8
|
import { getLastModified } from '@/lib/content';
|
|
8
9
|
import { getScopeForPage } from '@/lib/docs-config';
|
|
9
10
|
import { resolveLocale } from '@/lib/locale';
|
|
10
|
-
import { getOperation, methodColor } from '@/lib/openapi';
|
|
11
|
+
import { getOperation, getSchema, methodColor, resolvePlaygroundDisplay } from '@/lib/openapi';
|
|
11
12
|
import { docsSite, getPageByPathname } from '@/lib/site-config';
|
|
12
13
|
import { resolveContextualOptions } from '@/lib/site-model';
|
|
13
14
|
import type { DocModule, NormalizedDocsPage, RelatedEntry, SiteModel } from '@/lib/types';
|
|
@@ -86,6 +87,11 @@ export function DocContent({ page, doc, site }: DocContentProps) {
|
|
|
86
87
|
const contextualOptions = resolveContextualOptions(site.contextualOptions, page, site.labels);
|
|
87
88
|
const related = resolveRelated(doc.frontmatter?.related);
|
|
88
89
|
const operation = getOperation(doc.frontmatter?.openapi);
|
|
90
|
+
const schema = operation ? undefined : getSchema(doc.frontmatter?.['openapi-schema']);
|
|
91
|
+
const playground =
|
|
92
|
+
operation && resolvePlaygroundDisplay(site.api.playground, doc.frontmatter) === 'interactive'
|
|
93
|
+
? { proxy: site.api.playground.proxy }
|
|
94
|
+
: false;
|
|
89
95
|
// Dates follow the page's language when it is a valid locale code.
|
|
90
96
|
const dateFormat = new Intl.DateTimeFormat(resolveLocale(page.language, site.locale), {
|
|
91
97
|
dateStyle: 'medium',
|
|
@@ -118,8 +124,12 @@ export function DocContent({ page, doc, site }: DocContentProps) {
|
|
|
118
124
|
</div>
|
|
119
125
|
{operation && (
|
|
120
126
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
121
|
-
<Badge
|
|
122
|
-
{operation.method}
|
|
127
|
+
<Badge
|
|
128
|
+
color={methodColor(operation.webhook ? 'WEBHOOK' : operation.method)}
|
|
129
|
+
size="sm"
|
|
130
|
+
className="font-mono"
|
|
131
|
+
>
|
|
132
|
+
{operation.webhook ? 'WEBHOOK' : operation.method}
|
|
123
133
|
</Badge>
|
|
124
134
|
<code className="font-mono text-muted-foreground text-sm">{operation.path}</code>
|
|
125
135
|
{operation.deprecated && (
|
|
@@ -135,7 +145,8 @@ export function DocContent({ page, doc, site }: DocContentProps) {
|
|
|
135
145
|
<div className="docs-markdown">
|
|
136
146
|
<Content />
|
|
137
147
|
</div>
|
|
138
|
-
{operation && <OpenApiOperation operation={operation} />}
|
|
148
|
+
{operation && <OpenApiOperation operation={operation} playground={playground} />}
|
|
149
|
+
{schema && <OpenApiSchema page={schema} />}
|
|
139
150
|
<PageActions
|
|
140
151
|
key={page.url}
|
|
141
152
|
page={page}
|
package/src/components/Docs.tsx
CHANGED
|
@@ -12,7 +12,13 @@ import { Button } from '@/components/ui/button';
|
|
|
12
12
|
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
|
13
13
|
import { VersionSwitcher } from '@/components/VersionSwitcher';
|
|
14
14
|
import { renderInlineMarkdown } from '@/lib/inline-markdown';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
getOperation,
|
|
17
|
+
getSchema,
|
|
18
|
+
operationSections,
|
|
19
|
+
resolvePlaygroundDisplay,
|
|
20
|
+
schemaSections,
|
|
21
|
+
} from '@/lib/openapi';
|
|
16
22
|
import { docsHomeUrl, getScopeByPathname } from '@/lib/site-config';
|
|
17
23
|
import type { DocModule, NormalizedDocsPage, SiteModel, TocEntry } from '@/lib/types';
|
|
18
24
|
|
|
@@ -60,9 +66,18 @@ export function Docs({ page, doc, site }: DocsProps) {
|
|
|
60
66
|
|
|
61
67
|
// API reference pages append their generated section anchors to the TOC.
|
|
62
68
|
const operation = getOperation(doc.frontmatter?.openapi);
|
|
69
|
+
const schema = operation ? undefined : getSchema(doc.frontmatter?.['openapi-schema']);
|
|
63
70
|
const toc = operation
|
|
64
|
-
? [
|
|
65
|
-
|
|
71
|
+
? [
|
|
72
|
+
...(doc.toc || []),
|
|
73
|
+
...operationSections(operation, site.labels, {
|
|
74
|
+
playground:
|
|
75
|
+
resolvePlaygroundDisplay(site.api.playground, doc.frontmatter) === 'interactive',
|
|
76
|
+
}),
|
|
77
|
+
]
|
|
78
|
+
: schema
|
|
79
|
+
? [...(doc.toc || []), ...schemaSections(schema, site.labels)]
|
|
80
|
+
: doc.toc;
|
|
66
81
|
|
|
67
82
|
return (
|
|
68
83
|
<PanelProvider>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useLocation, useNavigate } from 'react-router';
|
|
2
|
-
import {
|
|
2
|
+
import { ChevronRight } from '@/components/icons';
|
|
3
3
|
import { Button } from '@/components/ui/button';
|
|
4
4
|
import {
|
|
5
5
|
DropdownMenu,
|
|
6
6
|
DropdownMenuContent,
|
|
7
|
-
|
|
7
|
+
DropdownMenuRadioGroup,
|
|
8
|
+
DropdownMenuRadioItem,
|
|
8
9
|
DropdownMenuTrigger,
|
|
9
10
|
} from '@/components/ui/dropdown-menu';
|
|
10
11
|
import { getLanguageScopes } from '@/lib/docs-config';
|
|
@@ -39,40 +40,35 @@ export function LanguageSwitcher() {
|
|
|
39
40
|
return (
|
|
40
41
|
<DropdownMenu>
|
|
41
42
|
<DropdownMenuTrigger
|
|
42
|
-
render={
|
|
43
|
-
|
|
44
|
-
variant="outline"
|
|
45
|
-
className="h-auto gap-1.5 rounded-md bg-card px-2.5 py-1.5 text-sm font-medium text-foreground"
|
|
46
|
-
/>
|
|
47
|
-
}
|
|
43
|
+
render={<Button variant="outline" />}
|
|
44
|
+
aria-label={`Language: ${getLanguageName(current.language)}`}
|
|
48
45
|
>
|
|
49
46
|
{getLanguageName(current.language)}
|
|
50
47
|
<ChevronRight className="size-3.5 rotate-90 text-muted-foreground" />
|
|
51
48
|
</DropdownMenuTrigger>
|
|
52
49
|
<DropdownMenuContent align="start" className="min-w-32">
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
<DropdownMenuRadioGroup
|
|
51
|
+
value={current.language}
|
|
52
|
+
onValueChange={language => {
|
|
53
|
+
const scope = options.find(option => option.language === language);
|
|
54
|
+
if (scope && scope.language !== current.language) {
|
|
55
|
+
const counterpart =
|
|
56
|
+
standalone && scope.language
|
|
57
|
+
? getStandaloneCounterpart(standalone, scope.language)
|
|
58
|
+
: null;
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
lang={isValidLocale(scope.language) ? scope.language : undefined}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
</
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
))}
|
|
60
|
+
navigate(counterpart?.path || scope.firstPageUrl);
|
|
61
|
+
}
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{options.map(scope => (
|
|
65
|
+
<DropdownMenuRadioItem key={scope.id} value={scope.language}>
|
|
66
|
+
<span lang={isValidLocale(scope.language) ? scope.language : undefined}>
|
|
67
|
+
{scope.language ? getLanguageName(scope.language) : null}
|
|
68
|
+
</span>
|
|
69
|
+
</DropdownMenuRadioItem>
|
|
70
|
+
))}
|
|
71
|
+
</DropdownMenuRadioGroup>
|
|
76
72
|
</DropdownMenuContent>
|
|
77
73
|
</DropdownMenu>
|
|
78
74
|
);
|