@djangocfg/ui-tools 2.1.289 → 2.1.290
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/README.md +14 -3
- package/dist/{DocsLayout-YDR7DSMM.cjs → DocsLayout-IKH7BLSU.cjs} +1537 -682
- package/dist/DocsLayout-IKH7BLSU.cjs.map +1 -0
- package/dist/{DocsLayout-TKJQ5W5E.mjs → DocsLayout-JPXFUKAR.mjs} +1429 -574
- package/dist/DocsLayout-JPXFUKAR.mjs.map +1 -0
- package/dist/{PrettyCode.client-5GABIN2I.cjs → PrettyCode.client-RPDIE5CH.cjs} +104 -3
- package/dist/PrettyCode.client-RPDIE5CH.cjs.map +1 -0
- package/dist/{PrettyCode.client-IZTXXYHG.mjs → PrettyCode.client-SPMTQEG4.mjs} +106 -5
- package/dist/PrettyCode.client-SPMTQEG4.mjs.map +1 -0
- package/dist/{chunk-IULI4XII.cjs → chunk-5Q4UMSWB.cjs} +355 -9
- package/dist/chunk-5Q4UMSWB.cjs.map +1 -0
- package/dist/{chunk-VZGQC3NG.mjs → chunk-EFWOJPA6.mjs} +349 -9
- package/dist/chunk-EFWOJPA6.mjs.map +1 -0
- package/dist/index.cjs +10 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.mjs +5 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -14
- package/src/components/markdown/MarkdownMessage.tsx +46 -0
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +87 -178
- package/src/tools/OpenapiViewer/README.md +114 -6
- package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +20 -6
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +6 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/LanguageTabs.tsx +36 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/index.tsx +56 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/useCodeSnippet.ts +77 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +146 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MethodBadge.tsx +6 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/PathDisplay.tsx +26 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/index.tsx +87 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamGroup.tsx +30 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamRow.tsx +36 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/index.tsx +22 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/RequestBody/index.tsx +33 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +76 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseRow.tsx +80 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +32 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/index.tsx +21 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +106 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +127 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/index.tsx +31 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/types.ts +28 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +87 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/defaults.ts +27 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/index.tsx +45 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/context.tsx +56 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/hooks/useSectionHash.ts +63 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +96 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/index.ts +133 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/selectors.ts +40 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/types.ts +17 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +8 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/BrandHeader.tsx +48 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/CategoryBlock.tsx +33 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/EndpointRow.tsx +73 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/MethodChips.tsx +43 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SchemaSection.tsx +27 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SearchInput.tsx +45 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SidebarBody.tsx +50 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/Toolbar.tsx +64 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/buildVM.ts +126 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/index.tsx +112 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/types.ts +42 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/useDebouncedValue.ts +14 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +10 -7
- package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +9 -6
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +55 -0
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PreviewView.tsx +115 -0
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/RawView.tsx +24 -0
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/StatusBar.tsx +63 -0
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/ViewTabs.tsx +45 -0
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/detectContent.ts +97 -0
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/index.tsx +93 -0
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/types.ts +26 -0
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel/useResponseView.ts +62 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +41 -71
- package/src/tools/OpenapiViewer/types.ts +10 -0
- package/src/tools/OpenapiViewer/utils/codeSamples.ts +287 -0
- package/src/tools/OpenapiViewer/utils/index.ts +3 -0
- package/src/tools/OpenapiViewer/utils/operationToHar.ts +119 -0
- package/src/tools/OpenapiViewer/utils/sampler.ts +72 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +88 -1
- package/src/tools/PrettyCode/PrettyCode.story.tsx +114 -361
- package/src/tools/PrettyCode/index.tsx +13 -0
- package/src/tools/PrettyCode/lazy.tsx +5 -0
- package/src/tools/PrettyCode/registerPrismLanguages.ts +111 -0
- package/dist/DocsLayout-TKJQ5W5E.mjs.map +0 -1
- package/dist/DocsLayout-YDR7DSMM.cjs.map +0 -1
- package/dist/PrettyCode.client-5GABIN2I.cjs.map +0 -1
- package/dist/PrettyCode.client-IZTXXYHG.mjs.map +0 -1
- package/dist/chunk-IULI4XII.cjs.map +0 -1
- package/dist/chunk-VZGQC3NG.mjs.map +0 -1
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +0 -273
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -439
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Info, ShieldCheck } from 'lucide-react';
|
|
4
|
+
import { useMemo } from 'react';
|
|
5
|
+
|
|
6
|
+
interface PreviewViewProps {
|
|
7
|
+
html: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Heuristic: does ``html`` look like a JS-only single-page app shell?
|
|
11
|
+
*
|
|
12
|
+
* SPAs (Vite, CRA, Next-app dev index) ship a near-empty ``<body>``
|
|
13
|
+
* with a mount div + a ``<script type="module">`` that hydrates the
|
|
14
|
+
* page at runtime. Previewing those in a ``sandbox=""`` iframe is
|
|
15
|
+
* pointless because scripts can't run — the reader sees a blank
|
|
16
|
+
* page and assumes preview is broken.
|
|
17
|
+
*
|
|
18
|
+
* We detect this by extracting body content, stripping scripts, and
|
|
19
|
+
* checking what's left. If it's basically just a mount div, we show
|
|
20
|
+
* an explanatory empty-state instead of an empty iframe. */
|
|
21
|
+
function looksLikeSpaShell(html: string): boolean {
|
|
22
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
23
|
+
const bodyContent = (bodyMatch?.[1] ?? html)
|
|
24
|
+
// Strip all script tags with their contents — runtime-only code
|
|
25
|
+
// doesn't count as visible markup for this check.
|
|
26
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
27
|
+
// Strip comments.
|
|
28
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
29
|
+
.trim();
|
|
30
|
+
|
|
31
|
+
if (bodyContent.length === 0) return true;
|
|
32
|
+
|
|
33
|
+
// A single empty container (``<div id="root"></div>`` and friends)
|
|
34
|
+
// is the classic SPA mount point. Anything else — including static
|
|
35
|
+
// server-rendered pages with real content — won't match.
|
|
36
|
+
const singleEmptyContainer = /^<(div|main|section)[^>]*>\s*<\/\1>$/i;
|
|
37
|
+
if (singleEmptyContainer.test(bodyContent)) return true;
|
|
38
|
+
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Render an HTML response inside a sandboxed iframe so the reader can
|
|
43
|
+
* see what the server's error page or template actually looks like.
|
|
44
|
+
*
|
|
45
|
+
* Security model — ``sandbox`` is intentionally **empty**:
|
|
46
|
+
* - no ``allow-scripts`` → JavaScript in the response cannot run
|
|
47
|
+
* - no ``allow-same-origin`` → the page can't read parent cookies
|
|
48
|
+
* - no ``allow-forms`` / ``allow-popups`` → can't phish the user
|
|
49
|
+
*
|
|
50
|
+
* We feed the HTML via ``srcDoc`` (not ``src``) so we never issue a
|
|
51
|
+
* network request to render it — the string is already local. */
|
|
52
|
+
export function PreviewView({ html }: PreviewViewProps) {
|
|
53
|
+
const isSpaShell = useMemo(() => looksLikeSpaShell(html), [html]);
|
|
54
|
+
|
|
55
|
+
if (!html) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="py-10 text-center text-xs text-muted-foreground">
|
|
58
|
+
Empty response body
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isSpaShell) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3 min-h-[400px]">
|
|
66
|
+
<div className="inline-flex items-center justify-center h-10 w-10 rounded-full bg-muted">
|
|
67
|
+
<Info className="h-5 w-5 text-muted-foreground" />
|
|
68
|
+
</div>
|
|
69
|
+
<div className="max-w-sm space-y-1.5">
|
|
70
|
+
<p className="text-sm font-medium text-foreground">
|
|
71
|
+
Looks like a single-page app shell
|
|
72
|
+
</p>
|
|
73
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
74
|
+
This page renders its content with JavaScript at runtime.
|
|
75
|
+
Scripts are disabled in the sandbox, so Preview would show
|
|
76
|
+
a blank page. Switch to <strong>Pretty</strong> or{' '}
|
|
77
|
+
<strong>Raw</strong> to inspect the HTML source.
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex flex-col h-full min-h-[400px]">
|
|
86
|
+
<div className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 bg-muted/30 border-b text-[10px] text-muted-foreground/70">
|
|
87
|
+
<ShieldCheck className="h-3 w-3" />
|
|
88
|
+
Sandboxed preview — scripts, forms and popups are disabled
|
|
89
|
+
</div>
|
|
90
|
+
{/*
|
|
91
|
+
* Checker-pattern background so "black on black" responses
|
|
92
|
+
* still show a clear iframe bounding box, and so readers
|
|
93
|
+
* can tell the iframe has loaded even if the page itself
|
|
94
|
+
* is empty / fully transparent.
|
|
95
|
+
*/}
|
|
96
|
+
<div
|
|
97
|
+
className="flex-1 min-h-[360px] p-2"
|
|
98
|
+
style={{
|
|
99
|
+
backgroundColor: '#fff',
|
|
100
|
+
backgroundImage:
|
|
101
|
+
'linear-gradient(45deg, #f3f4f6 25%, transparent 25%), linear-gradient(-45deg, #f3f4f6 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f3f4f6 75%), linear-gradient(-45deg, transparent 75%, #f3f4f6 75%)',
|
|
102
|
+
backgroundSize: '16px 16px',
|
|
103
|
+
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<iframe
|
|
107
|
+
title="Response preview"
|
|
108
|
+
srcDoc={html}
|
|
109
|
+
sandbox=""
|
|
110
|
+
className="w-full h-full min-h-[360px] bg-white border-0 rounded shadow-sm"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
interface RawViewProps {
|
|
4
|
+
rawText: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Verbatim response body. No parsing, no highlighting — readers
|
|
8
|
+
* occasionally need to inspect trailing whitespace, escape
|
|
9
|
+
* sequences, or a payload that accidentally claims one content type
|
|
10
|
+
* and ships another, and the Pretty view hides some of that. */
|
|
11
|
+
export function RawView({ rawText }: RawViewProps) {
|
|
12
|
+
if (!rawText) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="py-10 text-center text-xs text-muted-foreground">
|
|
15
|
+
Empty response body
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return (
|
|
20
|
+
<pre className="p-4 text-[11px] font-mono text-foreground/70 whitespace-pre-wrap break-all leading-relaxed">
|
|
21
|
+
{rawText}
|
|
22
|
+
</pre>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { CopyButton } from '@djangocfg/ui-core/components';
|
|
4
|
+
|
|
5
|
+
import type { ApiResponse } from '../../../types';
|
|
6
|
+
import { StatusBadge } from '../ui';
|
|
7
|
+
|
|
8
|
+
interface StatusBarProps {
|
|
9
|
+
response: ApiResponse;
|
|
10
|
+
rawText: string;
|
|
11
|
+
/** Content-Type label shown inline next to size/duration. ``null``
|
|
12
|
+
* when the server didn't send one — we hide the slot instead of
|
|
13
|
+
* showing an empty dash. */
|
|
14
|
+
contentType: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Top strip of the response panel: status badge, statusText, size,
|
|
18
|
+
* duration, content-type, copy button. Compact — one line, tabular
|
|
19
|
+
* numerals so 1.4 KB and 107ms don't shift as values update. */
|
|
20
|
+
export function StatusBar({ response, rawText, contentType }: StatusBarProps) {
|
|
21
|
+
const sizeKb = rawText ? `${(rawText.length / 1024).toFixed(1)} KB` : '';
|
|
22
|
+
const duration = response.duration != null ? `${response.duration}ms` : '';
|
|
23
|
+
const hasStatus = response.status != null;
|
|
24
|
+
const hasCopy = Boolean(rawText);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="shrink-0 border-b px-4 py-2 flex items-center justify-between gap-3 bg-muted/20">
|
|
28
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
29
|
+
{hasStatus && <StatusBadge status={response.status!} />}
|
|
30
|
+
{response.statusText && (
|
|
31
|
+
<span className="text-xs text-muted-foreground truncate">
|
|
32
|
+
{response.statusText}
|
|
33
|
+
</span>
|
|
34
|
+
)}
|
|
35
|
+
{sizeKb && (
|
|
36
|
+
<span className="text-[10px] text-muted-foreground/50 tabular-nums shrink-0">
|
|
37
|
+
{sizeKb}
|
|
38
|
+
</span>
|
|
39
|
+
)}
|
|
40
|
+
{duration && (
|
|
41
|
+
<span className="text-[10px] text-muted-foreground/50 tabular-nums shrink-0">
|
|
42
|
+
{duration}
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
{contentType && (
|
|
46
|
+
<span className="text-[10px] text-muted-foreground/50 font-mono truncate">
|
|
47
|
+
{contentType}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
{hasCopy && (
|
|
52
|
+
<CopyButton
|
|
53
|
+
value={rawText}
|
|
54
|
+
variant="ghost"
|
|
55
|
+
size="sm"
|
|
56
|
+
className="h-6 px-2 text-[10px] text-muted-foreground shrink-0"
|
|
57
|
+
>
|
|
58
|
+
Copy
|
|
59
|
+
</CopyButton>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
4
|
+
|
|
5
|
+
import type { ViewMode } from './types';
|
|
6
|
+
|
|
7
|
+
interface ViewTabsProps {
|
|
8
|
+
active: ViewMode;
|
|
9
|
+
onChange: (mode: ViewMode) => void;
|
|
10
|
+
/** When false the Preview tab is hidden — only HTML responses get
|
|
11
|
+
* a useful preview, everything else renders the same as Pretty. */
|
|
12
|
+
showPreview: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const LABELS: Record<ViewMode, string> = {
|
|
16
|
+
pretty: 'Pretty',
|
|
17
|
+
raw: 'Raw',
|
|
18
|
+
preview: 'Preview',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Tab strip for switching between Pretty / Raw / Preview. Matches the
|
|
22
|
+
* visual weight of the ``LanguageTabs`` in CodeSamples so the page
|
|
23
|
+
* reads as one coherent toolbar system. */
|
|
24
|
+
export function ViewTabs({ active, onChange, showPreview }: ViewTabsProps) {
|
|
25
|
+
const tabs: ViewMode[] = showPreview ? ['pretty', 'raw', 'preview'] : ['pretty', 'raw'];
|
|
26
|
+
return (
|
|
27
|
+
<div className="shrink-0 border-b px-3 py-1.5 flex items-center gap-1">
|
|
28
|
+
{tabs.map((t) => (
|
|
29
|
+
<button
|
|
30
|
+
key={t}
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={() => onChange(t)}
|
|
33
|
+
className={cn(
|
|
34
|
+
'h-6 px-2.5 rounded text-[11px] font-medium transition-colors',
|
|
35
|
+
active === t
|
|
36
|
+
? 'bg-muted text-foreground'
|
|
37
|
+
: 'text-muted-foreground/70 hover:text-foreground hover:bg-muted/50',
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{LABELS[t]}
|
|
41
|
+
</button>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ContentKind, DetectedContent } from './types';
|
|
2
|
+
|
|
3
|
+
/** Strip Content-Type parameters (``; charset=utf-8`` etc.) and lower-case. */
|
|
4
|
+
function normaliseContentType(raw: string | null): string | null {
|
|
5
|
+
if (!raw) return null;
|
|
6
|
+
const semi = raw.indexOf(';');
|
|
7
|
+
return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Pull Content-Type out of the headers bag. We accept both plain
|
|
11
|
+
* objects (``{ "content-type": "..." }``) and ``Headers``-like shapes
|
|
12
|
+
* with a ``.get`` method. The context type is ``any`` so we can't be
|
|
13
|
+
* stricter without plumbing a proper type through the response code
|
|
14
|
+
* path. */
|
|
15
|
+
function readContentType(headers: unknown): string | null {
|
|
16
|
+
if (!headers) return null;
|
|
17
|
+
// Headers instance — use ``.get`` (case-insensitive).
|
|
18
|
+
if (typeof (headers as Headers).get === 'function') {
|
|
19
|
+
return (headers as Headers).get('content-type');
|
|
20
|
+
}
|
|
21
|
+
// Plain object — look up with case-insensitive key match so both
|
|
22
|
+
// ``Content-Type`` and ``content-type`` work.
|
|
23
|
+
if (typeof headers === 'object') {
|
|
24
|
+
for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
|
|
25
|
+
if (k.toLowerCase() === 'content-type') {
|
|
26
|
+
return typeof v === 'string' ? v : null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Map a normalised MIME type to our short ``ContentKind``. Unknown
|
|
34
|
+
* types fall back to ``text`` so PrettyCode still shows something
|
|
35
|
+
* sensible (plain text is a valid Prism language). */
|
|
36
|
+
function kindFromContentType(mime: string | null): ContentKind {
|
|
37
|
+
if (!mime) return 'text';
|
|
38
|
+
if (mime === 'application/json' || mime.endsWith('+json')) return 'json';
|
|
39
|
+
if (mime === 'text/html' || mime === 'application/xhtml+xml') return 'html';
|
|
40
|
+
if (
|
|
41
|
+
mime === 'application/xml' ||
|
|
42
|
+
mime === 'text/xml' ||
|
|
43
|
+
mime.endsWith('+xml')
|
|
44
|
+
) return 'xml';
|
|
45
|
+
if (mime === 'text/css') return 'css';
|
|
46
|
+
if (
|
|
47
|
+
mime === 'application/javascript' ||
|
|
48
|
+
mime === 'text/javascript' ||
|
|
49
|
+
mime === 'application/x-javascript'
|
|
50
|
+
) return 'javascript';
|
|
51
|
+
return 'text';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Heuristic fallback when ``Content-Type`` is missing or opaque.
|
|
55
|
+
* Returns ``null`` when nothing firm can be inferred; the caller
|
|
56
|
+
* then defaults to ``text``. */
|
|
57
|
+
function kindFromBody(body: string): ContentKind | null {
|
|
58
|
+
const trimmed = body.trimStart();
|
|
59
|
+
if (!trimmed) return null;
|
|
60
|
+
if (trimmed.startsWith('<!DOCTYPE') || /^<html[\s>]/i.test(trimmed)) return 'html';
|
|
61
|
+
if (trimmed.startsWith('<?xml')) return 'xml';
|
|
62
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
63
|
+
// Cheap validity check — full parse happens in ResponsePanel.
|
|
64
|
+
try { JSON.parse(trimmed); return 'json'; } catch { /* fall through */ }
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const PRISM_BY_KIND: Record<ContentKind, DetectedContent['prism']> = {
|
|
70
|
+
json: 'json',
|
|
71
|
+
// ``markup`` is Prism's HTML/XML grammar — there isn't a separate
|
|
72
|
+
// ``html`` language. XML piggy-backs on the same tokeniser.
|
|
73
|
+
html: 'markup',
|
|
74
|
+
xml: 'markup',
|
|
75
|
+
css: 'css',
|
|
76
|
+
javascript: 'javascript',
|
|
77
|
+
text: 'markup',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Detect content kind from headers, falling back to body sniffing
|
|
81
|
+
* when the header is missing. Pure function — safe to call inside
|
|
82
|
+
* ``useMemo`` deps on the raw body and headers. */
|
|
83
|
+
export function detectContent(headers: unknown, rawBody: string): DetectedContent {
|
|
84
|
+
const contentType = normaliseContentType(readContentType(headers));
|
|
85
|
+
const headerKind = kindFromContentType(contentType);
|
|
86
|
+
|
|
87
|
+
// If headers say it's text/plain but the body looks like HTML or
|
|
88
|
+
// JSON, trust the body — a lot of framework 500 pages claim
|
|
89
|
+
// text/html but some setups default to text/plain for errors.
|
|
90
|
+
const kind = headerKind === 'text' ? (kindFromBody(rawBody) ?? 'text') : headerKind;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
kind,
|
|
94
|
+
prism: PRISM_BY_KIND[kind],
|
|
95
|
+
contentType,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Loader2, Send, Terminal, WifiOff } from 'lucide-react';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { usePlaygroundContext } from '../../../context/PlaygroundContext';
|
|
7
|
+
import { EmptyState, ScrollArea } from '../ui';
|
|
8
|
+
import { PreviewView } from './PreviewView';
|
|
9
|
+
import { PrettyView } from './PrettyView';
|
|
10
|
+
import { RawView } from './RawView';
|
|
11
|
+
import { StatusBar } from './StatusBar';
|
|
12
|
+
import type { ViewMode } from './types';
|
|
13
|
+
import { useResponseView } from './useResponseView';
|
|
14
|
+
import { ViewTabs } from './ViewTabs';
|
|
15
|
+
|
|
16
|
+
/** Response panel used by both the sticky ``SlideInPlayground`` and the
|
|
17
|
+
* modal ``TryItSheet``. Responsibilities:
|
|
18
|
+
* - Empty / loading / network-error gates
|
|
19
|
+
* - Three view modes (Pretty / Raw / Preview) with a tab bar
|
|
20
|
+
* - Auto-pick the best default view per response type (JSON → Pretty,
|
|
21
|
+
* HTML → Preview, everything else → Pretty as the widest-purpose)
|
|
22
|
+
*
|
|
23
|
+
* View mode is local component state rather than context state — it
|
|
24
|
+
* should reset when the selected endpoint changes and not leak between
|
|
25
|
+
* endpoints. */
|
|
26
|
+
export function ResponsePanel() {
|
|
27
|
+
const { state } = usePlaygroundContext();
|
|
28
|
+
const { response, loading, selectedEndpoint } = state;
|
|
29
|
+
|
|
30
|
+
const { treeData, rawText, detected } = useResponseView(response?.data, response?.headers);
|
|
31
|
+
const showPreview = detected.kind === 'html';
|
|
32
|
+
|
|
33
|
+
// Default view heuristic: show HTML pages preview-first so the
|
|
34
|
+
// reader sees the actual rendered server page immediately.
|
|
35
|
+
// Everything else lands on Pretty.
|
|
36
|
+
const [mode, setMode] = useState<ViewMode>(showPreview ? 'preview' : 'pretty');
|
|
37
|
+
|
|
38
|
+
// Reset the mode when the endpoint or response changes so a
|
|
39
|
+
// previously-selected tab from a different shape doesn't stick
|
|
40
|
+
// around (e.g. stuck on Preview when switching from HTML→JSON).
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setMode(showPreview ? 'preview' : 'pretty');
|
|
43
|
+
}, [selectedEndpoint, response, showPreview]);
|
|
44
|
+
|
|
45
|
+
if (loading) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex items-center justify-center h-full gap-2">
|
|
48
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
49
|
+
<span className="text-xs text-muted-foreground">Sending…</span>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!selectedEndpoint) return <EmptyState icon={Terminal} text="Response will appear here" />;
|
|
55
|
+
if (!response) return <EmptyState icon={Send} text='Press "Send Request" to see the response' />;
|
|
56
|
+
|
|
57
|
+
const hasError = Boolean(response.error);
|
|
58
|
+
const hasStatus = response.status != null;
|
|
59
|
+
|
|
60
|
+
// Pure network error (no HTTP response at all — CORS, offline, timeout)
|
|
61
|
+
if (hasError && !hasStatus) {
|
|
62
|
+
return (
|
|
63
|
+
<EmptyState
|
|
64
|
+
icon={WifiOff}
|
|
65
|
+
text={response.error!}
|
|
66
|
+
className="text-destructive [&_svg]:text-destructive"
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
<StatusBar response={response} rawText={rawText} contentType={detected.contentType} />
|
|
74
|
+
|
|
75
|
+
{/* HTTP-level error body (4xx/5xx — has status but also error flag) */}
|
|
76
|
+
{hasError && (
|
|
77
|
+
<div className="shrink-0 mx-4 mt-3 rounded border border-destructive/20 bg-destructive/5 px-3 py-2">
|
|
78
|
+
<p className="text-xs text-destructive">{response.error}</p>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
<ViewTabs active={mode} onChange={setMode} showPreview={showPreview} />
|
|
83
|
+
|
|
84
|
+
<ScrollArea>
|
|
85
|
+
{mode === 'pretty' && (
|
|
86
|
+
<PrettyView treeData={treeData} rawText={rawText} detected={detected} />
|
|
87
|
+
)}
|
|
88
|
+
{mode === 'raw' && <RawView rawText={rawText} />}
|
|
89
|
+
{mode === 'preview' && <PreviewView html={rawText} />}
|
|
90
|
+
</ScrollArea>
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Language } from '../../../../PrettyCode';
|
|
2
|
+
|
|
3
|
+
/** Panels the user can switch between in the response view.
|
|
4
|
+
*
|
|
5
|
+
* - ``pretty``: structured rendering — JsonTree for JSON, syntax-
|
|
6
|
+
* highlighted code for HTML/XML/CSS/text.
|
|
7
|
+
* - ``raw``: plain ``<pre>`` dump of whatever the server returned.
|
|
8
|
+
* - ``preview``: sandboxed iframe for HTML responses (when you want
|
|
9
|
+
* to *see* an error page the server produced rather
|
|
10
|
+
* than read its source). Tab is hidden for non-HTML. */
|
|
11
|
+
export type ViewMode = 'pretty' | 'raw' | 'preview';
|
|
12
|
+
|
|
13
|
+
/** Content categories derived from the server's ``Content-Type`` header.
|
|
14
|
+
* We project the rich MIME type space onto a short enum so the renderer
|
|
15
|
+
* and tab-list only have to switch on a handful of values. */
|
|
16
|
+
export type ContentKind = 'json' | 'html' | 'xml' | 'css' | 'javascript' | 'text';
|
|
17
|
+
|
|
18
|
+
export interface DetectedContent {
|
|
19
|
+
kind: ContentKind;
|
|
20
|
+
/** Prism ``Language`` id matching ``kind`` — fed into PrettyCode. */
|
|
21
|
+
prism: Language;
|
|
22
|
+
/** Full Content-Type header value, stripped of parameters (e.g.
|
|
23
|
+
* ``application/json`` without ``; charset=utf-8``). ``null`` when
|
|
24
|
+
* the server didn't send one. */
|
|
25
|
+
contentType: string | null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { detectContent } from './detectContent';
|
|
4
|
+
import type { DetectedContent } from './types';
|
|
5
|
+
|
|
6
|
+
interface UseResponseViewResult {
|
|
7
|
+
/** Parsed JSON value when the body is JSON, otherwise ``null``.
|
|
8
|
+
* JsonTree needs a live object to render properly, so we parse
|
|
9
|
+
* once up-front and cache. */
|
|
10
|
+
treeData: unknown;
|
|
11
|
+
/** Stringified body, always present when the server returned
|
|
12
|
+
* anything. Used by Raw and Pretty-for-text tabs. */
|
|
13
|
+
rawText: string;
|
|
14
|
+
/** Content-type + prism language inference. */
|
|
15
|
+
detected: DetectedContent;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Shape the raw response payload into the pieces each view tab
|
|
19
|
+
* needs. Exposed as a hook so the component stays thin and the
|
|
20
|
+
* normalisation is easy to test in isolation. */
|
|
21
|
+
export function useResponseView(
|
|
22
|
+
data: unknown,
|
|
23
|
+
headers: unknown,
|
|
24
|
+
): UseResponseViewResult {
|
|
25
|
+
return useMemo(() => {
|
|
26
|
+
if (data == null) {
|
|
27
|
+
return {
|
|
28
|
+
treeData: null,
|
|
29
|
+
rawText: '',
|
|
30
|
+
detected: detectContent(headers, ''),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// String body — might be JSON-in-a-string or a plain text dump.
|
|
35
|
+
if (typeof data === 'string') {
|
|
36
|
+
try {
|
|
37
|
+
return {
|
|
38
|
+
treeData: JSON.parse(data),
|
|
39
|
+
rawText: data,
|
|
40
|
+
detected: detectContent(headers, data),
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return {
|
|
44
|
+
treeData: null,
|
|
45
|
+
rawText: data,
|
|
46
|
+
detected: detectContent(headers, data),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Object body — axios already parsed it. Stringify for Raw/
|
|
52
|
+
// Preview tabs and treat it as JSON regardless of headers.
|
|
53
|
+
const stringified = (() => {
|
|
54
|
+
try { return JSON.stringify(data, null, 2); } catch { return String(data); }
|
|
55
|
+
})();
|
|
56
|
+
return {
|
|
57
|
+
treeData: data,
|
|
58
|
+
rawText: stringified,
|
|
59
|
+
detected: detectContent(headers, stringified),
|
|
60
|
+
};
|
|
61
|
+
}, [data, headers]);
|
|
62
|
+
}
|