@umami/shiso 1.17.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.
Files changed (64) hide show
  1. package/dist/chunks/App.js +1217 -196
  2. package/dist/chunks/architectureDiagram-5GKGNRK7.js +1 -1
  3. package/dist/chunks/chunk-GMAD6QVW.js +1 -1
  4. package/dist/chunks/cose-bilkent-JH36ORCC.js +1 -1
  5. package/dist/chunks/dist.js +1 -1
  6. package/dist/chunks/docs.js +843 -22
  7. package/dist/chunks/ganttDiagram-EL5Y4UJY.js +1 -1
  8. package/dist/chunks/src.js +1 -1
  9. package/dist/components.js +1 -1
  10. package/dist/entry-client.js +1 -1
  11. package/dist/entry-server.js +1 -1
  12. package/docs.schema.json +1164 -1131
  13. package/package.json +1 -2
  14. package/scripts/check-content.mjs +44 -15
  15. package/scripts/expand-openapi-navigation.mjs +47 -21
  16. package/scripts/generate-openapi.mjs +36 -11
  17. package/scripts/generate-search-index.mjs +19 -18
  18. package/scripts/lib/openapi-project.mjs +197 -0
  19. package/scripts/lib/openapi.mjs +257 -111
  20. package/scripts/lib/request-samples.mjs +323 -0
  21. package/scripts/load-docs-config.mjs +23 -15
  22. package/scripts/load-shiso-config.mjs +30 -1
  23. package/scripts/prerender.mjs +17 -14
  24. package/scripts/vite-docs-config.mjs +1 -0
  25. package/src/App.tsx +32 -22
  26. package/src/components/ApiPlayground.tsx +522 -0
  27. package/src/components/CodeBlock.tsx +3 -1
  28. package/src/components/DocContent.tsx +15 -4
  29. package/src/components/Docs.tsx +19 -2
  30. package/src/components/Footer.tsx +3 -1
  31. package/src/components/Header.tsx +4 -3
  32. package/src/components/LanguageSwitcher.tsx +38 -29
  33. package/src/components/OpenApiOperation.tsx +73 -20
  34. package/src/components/OpenApiSchema.tsx +97 -0
  35. package/src/components/PageActions.tsx +8 -7
  36. package/src/components/SideNav.tsx +2 -2
  37. package/src/components/docs/Changelog.tsx +5 -5
  38. package/src/components/docs/CodeGroup.tsx +3 -1
  39. package/src/components/docs/Mermaid.tsx +8 -6
  40. package/src/components/docs/PropertiesTable.tsx +11 -6
  41. package/src/components/docs/Tabs.tsx +3 -1
  42. package/src/components/docs/Tree.tsx +3 -1
  43. package/src/components/docs/ZoomableImage.tsx +4 -2
  44. package/src/components/ui/dialog.tsx +7 -2
  45. package/src/components/ui/sheet.tsx +5 -2
  46. package/src/lib/label-context.tsx +7 -0
  47. package/src/lib/labels.ts +37 -0
  48. package/src/lib/openapi.generated.ts +2 -1
  49. package/src/lib/openapi.ts +123 -16
  50. package/src/lib/site-config.ts +66 -2
  51. package/src/lib/site-model.ts +14 -32
  52. package/src/lib/standalone-pages.ts +15 -1
  53. package/src/lib/translations/de.json +99 -0
  54. package/src/lib/translations/en.json +99 -0
  55. package/src/lib/translations/es.json +99 -0
  56. package/src/lib/translations/fr.json +99 -0
  57. package/src/lib/translations/ja.json +99 -0
  58. package/src/lib/translations/zh-Hans.json +99 -0
  59. package/src/lib/translations/zh-Hant.json +99 -0
  60. package/src/lib/types.ts +109 -37
  61. package/types/config.d.ts +7 -1
  62. package/types/labels.d.ts +101 -0
  63. package/vite.config.ts +3 -4
  64. 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,6 +3,7 @@ import { type ComponentProps, type CSSProperties, useRef, useState } from 'react
3
3
  import { CheckIcon, Copy } from '@/components/icons';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import { ScrollArea } from '@/components/ui/scroll-area';
6
+ import { useLabels } from '@/lib/label-context';
6
7
  import { cn } from '@/lib/utils';
7
8
  import { Mermaid, type MermaidPlacement } from './docs/Mermaid';
8
9
 
@@ -60,6 +61,7 @@ function copyText(pre: HTMLPreElement | null, language?: string): string {
60
61
  }
61
62
 
62
63
  export function CodeBlock({ children, className, style, ...rest }: CodeBlockProps) {
64
+ const labels = useLabels();
63
65
  const {
64
66
  'data-title': title,
65
67
  'data-language': language,
@@ -137,7 +139,7 @@ export function CodeBlock({ children, className, style, ...rest }: CodeBlockProp
137
139
  title ? 'top-1' : 'top-2.5',
138
140
  )}
139
141
  onClick={handleCopy}
140
- aria-label="Copy code"
142
+ aria-label={copied ? labels.copied : labels.copyCode}
141
143
  >
142
144
  {copied ? <CheckIcon className="size-3.5 text-primary" /> : <Copy className="size-3.5" />}
143
145
  </Button>
@@ -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 color={methodColor(operation.method)} size="sm" className="font-mono">
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}
@@ -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 { getOperation, operationSections } from '@/lib/openapi';
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,7 +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);
63
- const toc = operation ? [...(doc.toc || []), ...operationSections(operation)] : doc.toc;
69
+ const schema = operation ? undefined : getSchema(doc.frontmatter?.['openapi-schema']);
70
+ const toc = operation
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;
64
81
 
65
82
  return (
66
83
  <PanelProvider>
@@ -1,4 +1,5 @@
1
1
  import { ConfiguredIcon } from '@/components/ConfiguredIcon';
2
+ import { useLabels } from '@/lib/label-context';
2
3
  import type { NormalizedFooter } from '@/lib/types';
3
4
 
4
5
  export function Footer({
@@ -8,6 +9,7 @@ export function Footer({
8
9
  footer: NormalizedFooter | null;
9
10
  className?: string;
10
11
  }) {
12
+ const labels = useLabels();
11
13
  if (!footer) {
12
14
  return null;
13
15
  }
@@ -48,7 +50,7 @@ export function Footer({
48
50
  href="https://shiso.umami.is?ref=docs-footer"
49
51
  className="text-sm hover:text-foreground"
50
52
  >
51
- Powered by <span className="font-bold">shiso</span>
53
+ {labels.poweredBy} <span className="font-bold">shiso</span>
52
54
  </a>
53
55
  ) : (
54
56
  <span />
@@ -6,7 +6,7 @@ import { ThemeToggle } from '@/components/ThemeToggle';
6
6
  import { TopNav } from '@/components/TopNav';
7
7
  import { VersionSwitcher } from '@/components/VersionSwitcher';
8
8
  import { isExternalHref } from '@/lib/paths';
9
- import { docsHomeUrl, getScopeByPathname, hasRootStandalonePage } from '@/lib/site-config';
9
+ import { getHomeHref, getScopeByPathname } from '@/lib/site-config';
10
10
  import type { NormalizedLink, SiteModel } from '@/lib/types';
11
11
 
12
12
  /**
@@ -62,8 +62,9 @@ export function Header({ site }: { site: SiteModel }) {
62
62
  const { pathname } = useLocation();
63
63
  // The header renders the navigation of whichever scope owns the current page.
64
64
  const docs = getScopeByPathname(pathname).docs;
65
- // The brand links to the standalone home page when one owns "/".
66
- const brandHref = logo?.href || (hasRootStandalonePage ? '/' : docsHomeUrl);
65
+ // The brand links to the standalone home page in the current language when
66
+ // one owns "/", else the docs home.
67
+ const brandHref = logo?.href || getHomeHref(pathname);
67
68
  const hasBrand = !!name || !!logo?.light || !!logo?.dark;
68
69
  const brandClassName =
69
70
  'inline-flex items-center gap-2 text-xl font-bold text-foreground tracking-[-0.03em]';