@djangocfg/ui-tools 2.1.287 → 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-IKH7BLSU.cjs +3464 -0
- package/dist/DocsLayout-IKH7BLSU.cjs.map +1 -0
- package/dist/DocsLayout-JPXFUKAR.mjs +3457 -0
- 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 +34 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.mjs +5 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +21 -14
- package/src/components/markdown/MarkdownMessage.tsx +46 -0
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +93 -157
- 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 +331 -53
- 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 +40 -11
- 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/DocsLayout/anchor.ts +19 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
- 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/index.ts +3 -1
- package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +164 -74
- package/src/tools/OpenapiViewer/types.ts +46 -1
- 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/OpenapiViewer/utils/scrollParent.ts +68 -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-BCVU6TTX.cjs +0 -2027
- package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
- package/dist/DocsLayout-ERETJLLV.mjs +0 -2020
- package/dist/DocsLayout-ERETJLLV.mjs.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 -268
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -211
- package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronRight } from 'lucide-react';
|
|
4
|
+
import React, { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
|
|
8
|
+
import type { FieldNode } from './types';
|
|
9
|
+
|
|
10
|
+
interface FieldRowProps {
|
|
11
|
+
field: FieldNode;
|
|
12
|
+
/** 0-based nesting level. Used to compute left padding so the tree
|
|
13
|
+
* visually expresses hierarchy without relying on a separate tree
|
|
14
|
+
* component (no deps, one file). */
|
|
15
|
+
depth: number;
|
|
16
|
+
/** Parent container nodes render with an extra vertical tick so the
|
|
17
|
+
* tree reads as a rooted graph instead of flat indented rows. */
|
|
18
|
+
showTreeLine?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Recursive tree row. Object/array nodes are collapsible and default
|
|
22
|
+
* to expanded at shallow depths — readers almost always want to see
|
|
23
|
+
* the first level of nesting, and explicitly collapsing uninteresting
|
|
24
|
+
* subtrees is cheaper than expanding every interesting one. */
|
|
25
|
+
export function FieldRow({ field, depth, showTreeLine = true }: FieldRowProps) {
|
|
26
|
+
const isExpandable = (field.kind === 'object' || field.kind === 'array') &&
|
|
27
|
+
Array.isArray(field.children) && field.children.length > 0;
|
|
28
|
+
|
|
29
|
+
// Expand levels 0-1 by default; beyond that the tree can explode
|
|
30
|
+
// visually for deeply-nested objects (think Pet.category.tag.…).
|
|
31
|
+
const [open, setOpen] = useState(depth < 2);
|
|
32
|
+
|
|
33
|
+
// Inline padding via style so arbitrary depths render without a
|
|
34
|
+
// pile of Tailwind classes. 14px per level matches the chevron gap
|
|
35
|
+
// at smaller text sizes.
|
|
36
|
+
const padLeft = showTreeLine ? depth * 14 : 0;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="bg-background">
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
'grid grid-cols-[16px_minmax(0,1fr)] items-baseline gap-2 px-3 py-2',
|
|
43
|
+
isExpandable && 'cursor-pointer hover:bg-muted/30',
|
|
44
|
+
)}
|
|
45
|
+
style={{ paddingLeft: 12 + padLeft }}
|
|
46
|
+
onClick={() => isExpandable && setOpen((v) => !v)}
|
|
47
|
+
role={isExpandable ? 'button' : undefined}
|
|
48
|
+
aria-expanded={isExpandable ? open : undefined}
|
|
49
|
+
>
|
|
50
|
+
<ChevronRight
|
|
51
|
+
className={cn(
|
|
52
|
+
'h-3.5 w-3.5 text-muted-foreground/50 shrink-0 transition-transform',
|
|
53
|
+
!isExpandable && 'opacity-0',
|
|
54
|
+
open && 'rotate-90',
|
|
55
|
+
)}
|
|
56
|
+
/>
|
|
57
|
+
<div className="min-w-0 space-y-1">
|
|
58
|
+
<div className="flex items-baseline gap-2 flex-wrap">
|
|
59
|
+
<code className="font-mono text-xs font-medium text-foreground">
|
|
60
|
+
{field.name}
|
|
61
|
+
</code>
|
|
62
|
+
{field.required && (
|
|
63
|
+
<span
|
|
64
|
+
title="Required"
|
|
65
|
+
className="text-[9px] text-destructive font-bold leading-none"
|
|
66
|
+
>
|
|
67
|
+
*
|
|
68
|
+
</span>
|
|
69
|
+
)}
|
|
70
|
+
<code className="font-mono text-[11px] text-muted-foreground/70">
|
|
71
|
+
{field.type}
|
|
72
|
+
</code>
|
|
73
|
+
</div>
|
|
74
|
+
{field.description && (
|
|
75
|
+
<p className="text-xs text-muted-foreground leading-relaxed break-words">
|
|
76
|
+
{field.description}
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
{field.enumValues && field.enumValues.length > 0 && (
|
|
80
|
+
<div className="flex flex-wrap gap-1 pt-0.5">
|
|
81
|
+
{field.enumValues.map((v) => (
|
|
82
|
+
<code
|
|
83
|
+
key={v}
|
|
84
|
+
className="inline-flex items-center rounded border border-border/60 bg-muted/40 px-1.5 py-px font-mono text-[10px] text-muted-foreground"
|
|
85
|
+
>
|
|
86
|
+
{v}
|
|
87
|
+
</code>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
{isExpandable && open && (
|
|
94
|
+
<div>
|
|
95
|
+
{field.children!.map((child, i) => (
|
|
96
|
+
<FieldRow
|
|
97
|
+
key={`${child.name}-${i}`}
|
|
98
|
+
field={child}
|
|
99
|
+
depth={depth + 1}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema → ``FieldNode`` tree.
|
|
3
|
+
*
|
|
4
|
+
* Handles the OpenAPI 3.x subset we actually see: objects with
|
|
5
|
+
* ``properties`` + ``required``, arrays with ``items``, enums,
|
|
6
|
+
* primitives with ``format``. ``allOf`` / ``oneOf`` / ``anyOf`` are
|
|
7
|
+
* flattened best-effort — we pick the first branch so the UI shows
|
|
8
|
+
* *a* shape rather than an empty row. Upstream dereferencing
|
|
9
|
+
* (``dereferenceSchema``) is expected to have resolved ``$ref`` before
|
|
10
|
+
* this runs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FieldKind, FieldNode } from './types';
|
|
14
|
+
|
|
15
|
+
type JsonSchemaNode = Record<string, unknown> & {
|
|
16
|
+
type?: string;
|
|
17
|
+
properties?: Record<string, JsonSchemaNode>;
|
|
18
|
+
required?: string[];
|
|
19
|
+
items?: JsonSchemaNode;
|
|
20
|
+
enum?: unknown[];
|
|
21
|
+
description?: string;
|
|
22
|
+
format?: string;
|
|
23
|
+
allOf?: JsonSchemaNode[];
|
|
24
|
+
oneOf?: JsonSchemaNode[];
|
|
25
|
+
anyOf?: JsonSchemaNode[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Hard cap on recursion so self-referential schemas can't blow the
|
|
29
|
+
* call stack. Anything this deep is unreadable in a docs view anyway. */
|
|
30
|
+
const MAX_DEPTH = 5;
|
|
31
|
+
|
|
32
|
+
/** Merge ``allOf`` branches into a single pseudo-object. Shallow merge
|
|
33
|
+
* is enough for the display — deep merge would require semantic JSON
|
|
34
|
+
* Schema reasoning that we don't need for a read-only viewer. */
|
|
35
|
+
function mergeAllOf(branches: JsonSchemaNode[]): JsonSchemaNode {
|
|
36
|
+
const properties: Record<string, JsonSchemaNode> = {};
|
|
37
|
+
const required: string[] = [];
|
|
38
|
+
for (const b of branches) {
|
|
39
|
+
if (b.properties) Object.assign(properties, b.properties);
|
|
40
|
+
if (Array.isArray(b.required)) required.push(...b.required);
|
|
41
|
+
}
|
|
42
|
+
return { type: 'object', properties, required };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function describeType(node: JsonSchemaNode): { label: string; kind: FieldKind } {
|
|
46
|
+
if (node.type === 'array') {
|
|
47
|
+
const itemLabel = node.items ? describeType(node.items).label : 'any';
|
|
48
|
+
return { label: `array<${itemLabel}>`, kind: 'array' };
|
|
49
|
+
}
|
|
50
|
+
if (node.type === 'object' || node.properties) {
|
|
51
|
+
return { label: 'object', kind: 'object' };
|
|
52
|
+
}
|
|
53
|
+
const base = node.type || 'any';
|
|
54
|
+
if (Array.isArray(node.enum) && node.enum.length > 0) {
|
|
55
|
+
return { label: `${base} enum`, kind: 'primitive' };
|
|
56
|
+
}
|
|
57
|
+
if (node.format) {
|
|
58
|
+
return { label: `${base} (${node.format})`, kind: 'primitive' };
|
|
59
|
+
}
|
|
60
|
+
return { label: base, kind: 'primitive' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveCombinators(node: JsonSchemaNode): JsonSchemaNode {
|
|
64
|
+
// ``allOf`` → merge shallowly. Other combinators → pick the first
|
|
65
|
+
// branch (see module docstring rationale).
|
|
66
|
+
if (Array.isArray(node.allOf) && node.allOf.length > 0) {
|
|
67
|
+
return { ...mergeAllOf(node.allOf), description: node.description };
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(node.oneOf) && node.oneOf.length > 0) {
|
|
70
|
+
return { ...node.oneOf[0]!, description: node.description ?? node.oneOf[0]!.description };
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(node.anyOf) && node.anyOf.length > 0) {
|
|
73
|
+
return { ...node.anyOf[0]!, description: node.description ?? node.anyOf[0]!.description };
|
|
74
|
+
}
|
|
75
|
+
return node;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildNode(
|
|
79
|
+
name: string,
|
|
80
|
+
schema: JsonSchemaNode,
|
|
81
|
+
isRequired: boolean,
|
|
82
|
+
depth: number,
|
|
83
|
+
): FieldNode {
|
|
84
|
+
const resolved = resolveCombinators(schema);
|
|
85
|
+
const { label, kind } = describeType(resolved);
|
|
86
|
+
const node: FieldNode = {
|
|
87
|
+
name,
|
|
88
|
+
type: label,
|
|
89
|
+
kind,
|
|
90
|
+
required: isRequired,
|
|
91
|
+
description: resolved.description,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
|
|
95
|
+
node.enumValues = resolved.enum.map((v) => String(v));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (depth >= MAX_DEPTH) return node;
|
|
99
|
+
|
|
100
|
+
if (kind === 'object' && resolved.properties) {
|
|
101
|
+
const required = new Set(resolved.required ?? []);
|
|
102
|
+
node.children = Object.entries(resolved.properties).map(([key, child]) =>
|
|
103
|
+
buildNode(key, child, required.has(key), depth + 1),
|
|
104
|
+
);
|
|
105
|
+
} else if (kind === 'array' && resolved.items) {
|
|
106
|
+
// Synthesise a single ``[]`` child. If items are objects, the
|
|
107
|
+
// child's own children will appear — so the tree reads as
|
|
108
|
+
// ``tags → [] → { id, name }``.
|
|
109
|
+
node.children = [buildNode('[]', resolved.items, false, depth + 1)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return node;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Top-level entry. The root schema is usually an object or array; we
|
|
116
|
+
* return its children directly so the tree starts one level deep
|
|
117
|
+
* (skipping the redundant "root" node). */
|
|
118
|
+
export function buildSchemaTree(schema: JsonSchemaNode | undefined): FieldNode[] {
|
|
119
|
+
if (!schema) return [];
|
|
120
|
+
const root = buildNode('', schema, false, 0);
|
|
121
|
+
if (root.children && root.children.length > 0) return root.children;
|
|
122
|
+
// Primitive body (rare) — return the root itself as a one-row tree.
|
|
123
|
+
if (root.kind === 'primitive' || (!root.children && root.name === '')) {
|
|
124
|
+
return [{ ...root, name: root.name || '(body)' }];
|
|
125
|
+
}
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
import { buildSchemaTree } from './buildTree';
|
|
6
|
+
import { FieldRow } from './FieldRow';
|
|
7
|
+
|
|
8
|
+
interface SchemaFieldsProps {
|
|
9
|
+
schema: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Tree-view of a JSON schema's fields. Replaces the old flat list
|
|
13
|
+
* where nested objects appeared as ``category.id`` / ``category.name``
|
|
14
|
+
* as siblings — here they nest visually under ``category``.
|
|
15
|
+
*
|
|
16
|
+
* The component is purely presentational; all shape logic lives in
|
|
17
|
+
* ``buildSchemaTree``. That split lets us swap the renderer later
|
|
18
|
+
* (e.g. virtualised rows for huge schemas) without touching the
|
|
19
|
+
* traversal. */
|
|
20
|
+
export function SchemaFields({ schema }: SchemaFieldsProps) {
|
|
21
|
+
const tree = useMemo(() => buildSchemaTree(schema as never), [schema]);
|
|
22
|
+
if (tree.length === 0) return null;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="divide-y border rounded-md overflow-hidden">
|
|
26
|
+
{tree.map((node, i) => (
|
|
27
|
+
<FieldRow key={`${node.name}-${i}`} field={node} depth={0} />
|
|
28
|
+
))}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree-view schema model.
|
|
3
|
+
*
|
|
4
|
+
* A ``FieldNode`` represents one row in the schema tree: either a
|
|
5
|
+
* primitive leaf (``string``, ``integer``, ``number``, ``boolean``) or
|
|
6
|
+
* a container (``object`` / ``array``) with children. Keeping children
|
|
7
|
+
* materialised (not lazy) makes the collapse state trivial — we know
|
|
8
|
+
* in advance whether a node is expandable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type FieldKind = 'object' | 'array' | 'primitive';
|
|
12
|
+
|
|
13
|
+
export interface FieldNode {
|
|
14
|
+
/** Leaf name. For arrays the node synthesises a pseudo-field named
|
|
15
|
+
* ``[]`` so the tree shows the ``items`` shape without the parent
|
|
16
|
+
* having to special-case array children. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Short type label — rendered alongside the name in the row. */
|
|
19
|
+
type: string;
|
|
20
|
+
kind: FieldKind;
|
|
21
|
+
required: boolean;
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Enum values when the field is an enum. Rendered as hint chips
|
|
24
|
+
* below the row when present and the caller opts in. */
|
|
25
|
+
enumValues?: string[];
|
|
26
|
+
/** Nested children — non-empty only for ``object`` / ``array`` kinds. */
|
|
27
|
+
children?: FieldNode[];
|
|
28
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, ChevronDown, Link2 } from 'lucide-react';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
|
|
8
|
+
import { useEndpointDocContext } from '../context';
|
|
9
|
+
import { buildSectionHash } from '../hooks/useSectionHash';
|
|
10
|
+
import type { SectionId } from '../types';
|
|
11
|
+
|
|
12
|
+
interface SectionHeaderProps {
|
|
13
|
+
/** Section identifier — needed to build the shareable hash link. */
|
|
14
|
+
sectionId: SectionId;
|
|
15
|
+
title: string;
|
|
16
|
+
badge?: number;
|
|
17
|
+
open: boolean;
|
|
18
|
+
onToggle: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Clickable header for a collapsible section. The whole row toggles,
|
|
22
|
+
* plus a hover-revealed anchor button copies a ``#section=...`` URL so
|
|
23
|
+
* users can share a link that lands with this exact section already
|
|
24
|
+
* expanded (handled by ``useSectionHashRouter``). */
|
|
25
|
+
export function SectionHeader({ sectionId, title, badge, open, onToggle }: SectionHeaderProps) {
|
|
26
|
+
const { endpointId } = useEndpointDocContext();
|
|
27
|
+
const [copied, setCopied] = useState(false);
|
|
28
|
+
|
|
29
|
+
const copyHash = (e: React.MouseEvent) => {
|
|
30
|
+
// Prevent the row toggle — the anchor button must not collapse
|
|
31
|
+
// the section while the user is copying the link.
|
|
32
|
+
e.stopPropagation();
|
|
33
|
+
if (typeof window === 'undefined') return;
|
|
34
|
+
const hash = buildSectionHash(endpointId, sectionId);
|
|
35
|
+
const url = `${window.location.origin}${window.location.pathname}#${hash}`;
|
|
36
|
+
void navigator.clipboard?.writeText(url).then(() => {
|
|
37
|
+
setCopied(true);
|
|
38
|
+
setTimeout(() => setCopied(false), 1200);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={cn(
|
|
45
|
+
'group/section w-full flex items-center gap-2 py-1.5 -ml-1 px-1 rounded cursor-pointer',
|
|
46
|
+
'hover:bg-muted/30 transition-colors',
|
|
47
|
+
)}
|
|
48
|
+
onClick={onToggle}
|
|
49
|
+
role="button"
|
|
50
|
+
aria-expanded={open}
|
|
51
|
+
tabIndex={0}
|
|
52
|
+
onKeyDown={(e) => {
|
|
53
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
onToggle();
|
|
56
|
+
}
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<ChevronDown
|
|
60
|
+
className={cn(
|
|
61
|
+
'h-3.5 w-3.5 text-muted-foreground/50 transition-transform shrink-0',
|
|
62
|
+
!open && '-rotate-90',
|
|
63
|
+
)}
|
|
64
|
+
/>
|
|
65
|
+
<h4 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80">
|
|
66
|
+
{title}
|
|
67
|
+
</h4>
|
|
68
|
+
{typeof badge === 'number' && badge > 0 && (
|
|
69
|
+
<span className="font-mono text-[10px] text-muted-foreground/50 tabular-nums">
|
|
70
|
+
{badge}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={copyHash}
|
|
76
|
+
title="Copy link to this section"
|
|
77
|
+
className={cn(
|
|
78
|
+
'ml-auto shrink-0 p-1 rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-all',
|
|
79
|
+
'opacity-0 group-hover/section:opacity-100 focus-visible:opacity-100',
|
|
80
|
+
copied && 'opacity-100 text-emerald-500',
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
{copied ? <Check className="h-3 w-3" /> : <Link2 className="h-3 w-3" />}
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SectionId } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Per-HTTP-method defaults for which sections start open. The goal is
|
|
4
|
+
* "the most useful section is already visible" without burying the
|
|
5
|
+
* less-important ones entirely.
|
|
6
|
+
*
|
|
7
|
+
* Rationale by method:
|
|
8
|
+
* - GET/DELETE: parameters + 2xx response — the request itself has
|
|
9
|
+
* no body, so that's what the reader needs to see first.
|
|
10
|
+
* - POST/PUT/PATCH: request body + 2xx response — the shape to send
|
|
11
|
+
* is the main question; parameters and code samples are
|
|
12
|
+
* secondary, available one click away.
|
|
13
|
+
* - Other (HEAD/OPTIONS/…): be conservative, open nothing extra
|
|
14
|
+
* beyond responses. */
|
|
15
|
+
const DEFAULTS_BY_METHOD: Record<string, Partial<Record<SectionId, boolean>>> = {
|
|
16
|
+
GET: { parameters: true, responses: true },
|
|
17
|
+
DELETE: { parameters: true, responses: true },
|
|
18
|
+
POST: { requestBody: true, responses: true },
|
|
19
|
+
PUT: { requestBody: true, responses: true },
|
|
20
|
+
PATCH: { requestBody: true, responses: true },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Resolve the default open state for a given (method, section). Falls
|
|
24
|
+
* back to ``false`` for unknown combinations. */
|
|
25
|
+
export function defaultSectionOpen(method: string, sectionId: SectionId): boolean {
|
|
26
|
+
return DEFAULTS_BY_METHOD[method.toUpperCase()]?.[sectionId] ?? false;
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { useEndpointDocContext } from '../context';
|
|
6
|
+
import { useEndpointDocStore } from '../store';
|
|
7
|
+
import { useIsSectionOpen } from '../store/selectors';
|
|
8
|
+
import type { SectionId } from '../types';
|
|
9
|
+
import { defaultSectionOpen } from './defaults';
|
|
10
|
+
import { SectionHeader } from './SectionHeader';
|
|
11
|
+
|
|
12
|
+
interface SectionProps {
|
|
13
|
+
/** Identifies the section inside the endpoint's card — used as a
|
|
14
|
+
* key in zustand + URL hash routing. Must match one of ``SectionId``. */
|
|
15
|
+
id: SectionId;
|
|
16
|
+
title: string;
|
|
17
|
+
/** Optional count badge shown next to the title. */
|
|
18
|
+
badge?: number;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Collapsible section wrapper. Pulls endpoint identity from
|
|
23
|
+
* ``EndpointDocProvider`` so the component API stays at "id + title"
|
|
24
|
+
* — the rest is implicit from context. Children render lazily when
|
|
25
|
+
* open; a docs page may mount dozens of endpoints × four sections and
|
|
26
|
+
* mounting everything eagerly would churn on the first scroll pass. */
|
|
27
|
+
export function Section({ id, title, badge, children }: SectionProps) {
|
|
28
|
+
const { endpointId, method } = useEndpointDocContext();
|
|
29
|
+
const defaultOpen = defaultSectionOpen(method, id);
|
|
30
|
+
const open = useIsSectionOpen(endpointId, id, defaultOpen);
|
|
31
|
+
const toggleSection = useEndpointDocStore((s) => s.toggleSection);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="space-y-2.5">
|
|
35
|
+
<SectionHeader
|
|
36
|
+
sectionId={id}
|
|
37
|
+
title={title}
|
|
38
|
+
badge={badge}
|
|
39
|
+
open={open}
|
|
40
|
+
onToggle={() => toggleSection(endpointId, id)}
|
|
41
|
+
/>
|
|
42
|
+
{open && <div>{children}</div>}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
/** Identity context for a single EndpointDoc subtree.
|
|
6
|
+
*
|
|
7
|
+
* This is NOT a state container — state lives in the zustand store at
|
|
8
|
+
* ``./store``. The context only carries per-endpoint *identity* values
|
|
9
|
+
* so children (Section, CodeSamples, future ExpandAll button) don't
|
|
10
|
+
* have to re-derive them or receive them as repeated props.
|
|
11
|
+
*
|
|
12
|
+
* Why identity-only:
|
|
13
|
+
* - One zustand singleton handles state for *all* endpoints on the
|
|
14
|
+
* page, keyed by ``endpointId``. Wrapping zustand itself in a
|
|
15
|
+
* Provider would defeat that composite-key design.
|
|
16
|
+
* - Identity (anchor, HTTP method) is invariant inside an endpoint
|
|
17
|
+
* card; deriving it per-child is wasted work and makes the props
|
|
18
|
+
* table look noisier than the logic actually is. */
|
|
19
|
+
interface EndpointDocContextValue {
|
|
20
|
+
/** DOM-safe anchor id for this endpoint — used as the store key
|
|
21
|
+
* prefix (``openSections[`${endpointId}:${sectionId}`]``) and as
|
|
22
|
+
* the URL hash target for shareable links. */
|
|
23
|
+
endpointId: string;
|
|
24
|
+
/** HTTP method (``GET`` / ``POST`` / …). Drives per-method default
|
|
25
|
+
* open state for Section. Kept in context so ``defaultSectionOpen``
|
|
26
|
+
* doesn't need to be threaded as a prop through every subtree. */
|
|
27
|
+
method: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const EndpointDocContext = createContext<EndpointDocContextValue | null>(null);
|
|
31
|
+
|
|
32
|
+
interface EndpointDocProviderProps extends EndpointDocContextValue {
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function EndpointDocProvider({ endpointId, method, children }: EndpointDocProviderProps) {
|
|
37
|
+
// Memoise the value so every re-render of the parent orchestrator
|
|
38
|
+
// doesn't invalidate children reading the context — the common
|
|
39
|
+
// hover/focus state on the header row re-renders EndpointDoc often.
|
|
40
|
+
const value = useMemo(() => ({ endpointId, method }), [endpointId, method]);
|
|
41
|
+
return <EndpointDocContext.Provider value={value}>{children}</EndpointDocContext.Provider>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useEndpointDocContext(): EndpointDocContextValue {
|
|
45
|
+
const ctx = useContext(EndpointDocContext);
|
|
46
|
+
if (!ctx) {
|
|
47
|
+
// Treated as a programming error, not a runtime one — if this
|
|
48
|
+
// fires, something rendered EndpointDoc internals outside the
|
|
49
|
+
// provider, which means the store keys will collide or be
|
|
50
|
+
// missing entirely. Loud failure beats silent corruption.
|
|
51
|
+
throw new Error(
|
|
52
|
+
'[OpenapiViewer] useEndpointDocContext must be used inside <EndpointDocProvider>.',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return ctx;
|
|
56
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useEndpointDocStore } from '../store';
|
|
6
|
+
import { ALL_SECTION_IDS, type SectionId } from '../types';
|
|
7
|
+
|
|
8
|
+
/** Parse ``#section=<endpointId>.<sectionId>`` out of a hash string.
|
|
9
|
+
* Returns ``null`` for any other shape (including the plain
|
|
10
|
+
* ``#<endpointId>`` form used elsewhere for scrolling to an endpoint). */
|
|
11
|
+
export function parseSectionHash(hash: string): { endpointId: string; sectionId: SectionId } | null {
|
|
12
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
13
|
+
if (!raw.startsWith('section=')) return null;
|
|
14
|
+
const value = raw.slice('section='.length);
|
|
15
|
+
const dot = value.lastIndexOf('.');
|
|
16
|
+
if (dot <= 0 || dot === value.length - 1) return null;
|
|
17
|
+
const endpointId = value.slice(0, dot);
|
|
18
|
+
const sectionIdCandidate = value.slice(dot + 1);
|
|
19
|
+
if (!(ALL_SECTION_IDS as readonly string[]).includes(sectionIdCandidate)) return null;
|
|
20
|
+
return { endpointId, sectionId: sectionIdCandidate as SectionId };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Build the shareable hash that opens a specific section. */
|
|
24
|
+
export function buildSectionHash(endpointId: string, sectionId: SectionId): string {
|
|
25
|
+
return `section=${endpointId}.${sectionId}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** On mount + on hashchange, read ``#section=...``, open that section
|
|
29
|
+
* in the store, and scroll its endpoint into view. Runs once per
|
|
30
|
+
* hash change, not per endpoint render — the effect lives above the
|
|
31
|
+
* per-endpoint level in the tree.
|
|
32
|
+
*
|
|
33
|
+
* Behaviour:
|
|
34
|
+
* 1. Parse hash. If it isn't our ``section=`` form, bail — the
|
|
35
|
+
* browser's own anchor-scrolling handles ``#ep-…`` URLs already.
|
|
36
|
+
* 2. Open the referenced section in the zustand store so its body
|
|
37
|
+
* is visible the moment the user lands.
|
|
38
|
+
* 3. Scroll the endpoint's ``<section id={endpointId}>`` into view
|
|
39
|
+
* on the next frame (after the section body has mounted). */
|
|
40
|
+
export function useSectionHashRouter(): void {
|
|
41
|
+
const setSectionOpen = useEndpointDocStore((s) => s.setSectionOpen);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (typeof window === 'undefined') return;
|
|
45
|
+
|
|
46
|
+
function apply() {
|
|
47
|
+
const parsed = parseSectionHash(window.location.hash);
|
|
48
|
+
if (!parsed) return;
|
|
49
|
+
setSectionOpen(parsed.endpointId, parsed.sectionId, true);
|
|
50
|
+
// Defer scroll to let the section body render first —
|
|
51
|
+
// scrolling a collapsed row lands on the wrong offset
|
|
52
|
+
// because the body takes vertical space once expanded.
|
|
53
|
+
requestAnimationFrame(() => {
|
|
54
|
+
const el = document.getElementById(parsed.endpointId);
|
|
55
|
+
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
apply();
|
|
60
|
+
window.addEventListener('hashchange', apply);
|
|
61
|
+
return () => window.removeEventListener('hashchange', apply);
|
|
62
|
+
}, [setSectionOpen]);
|
|
63
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ApiEndpoint } from '../../../types';
|
|
6
|
+
import { endpointAnchor } from '../anchor';
|
|
7
|
+
import { CodeSamples } from './CodeSamples';
|
|
8
|
+
import { EndpointDocProvider } from './context';
|
|
9
|
+
import { EndpointHeader } from './Header';
|
|
10
|
+
import { Parameters } from './Parameters';
|
|
11
|
+
import { RequestBody } from './RequestBody';
|
|
12
|
+
import { Responses } from './Responses';
|
|
13
|
+
import { Section } from './Section';
|
|
14
|
+
import type { SectionId } from './types';
|
|
15
|
+
|
|
16
|
+
interface EndpointDocProps {
|
|
17
|
+
endpoint: ApiEndpoint;
|
|
18
|
+
/** Is this endpoint currently loaded in the sticky playground? */
|
|
19
|
+
isLoadedInPlayground: boolean;
|
|
20
|
+
onTryIt: () => void;
|
|
21
|
+
/** Scoping prefix for the anchor, so endpoints from different schemas
|
|
22
|
+
* don't collide on a single page. Falls back to ``endpoint.schemaId``. */
|
|
23
|
+
schemaId?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Card that documents one API endpoint: header + a stack of collapsible
|
|
27
|
+
* sections (parameters, request body, responses, code samples). The
|
|
28
|
+
* component itself is a thin orchestrator — each child folder owns its
|
|
29
|
+
* own rendering concerns and reads the endpoint identity from context. */
|
|
30
|
+
export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt, schemaId }: EndpointDocProps) {
|
|
31
|
+
const scopedSchemaId = schemaId ?? endpoint.schemaId ?? null;
|
|
32
|
+
const anchor = endpointAnchor(endpoint, scopedSchemaId);
|
|
33
|
+
const pathParams = endpoint.parameters?.filter((p) => endpoint.path.includes(`{${p.name}}`)) ?? [];
|
|
34
|
+
const queryParams = endpoint.parameters?.filter((p) => !endpoint.path.includes(`{${p.name}}`)) ?? [];
|
|
35
|
+
|
|
36
|
+
const hasParameters = pathParams.length > 0 || queryParams.length > 0;
|
|
37
|
+
const hasResponses = (endpoint.responses?.length ?? 0) > 0;
|
|
38
|
+
|
|
39
|
+
// Collect section ids that actually render so the header's
|
|
40
|
+
// expand/collapse-all toggle only touches visible sections.
|
|
41
|
+
// ``codeSamples`` is always present — it synthesises a snippet
|
|
42
|
+
// even for parameter-less endpoints.
|
|
43
|
+
const presentSections: SectionId[] = [];
|
|
44
|
+
if (hasParameters) presentSections.push('parameters');
|
|
45
|
+
if (endpoint.requestBody) presentSections.push('requestBody');
|
|
46
|
+
presentSections.push('codeSamples');
|
|
47
|
+
if (hasResponses) presentSections.push('responses');
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<EndpointDocProvider endpointId={anchor} method={endpoint.method}>
|
|
51
|
+
<section
|
|
52
|
+
id={anchor}
|
|
53
|
+
data-endpoint-anchor={anchor}
|
|
54
|
+
data-schema-id={scopedSchemaId ?? ''}
|
|
55
|
+
className="scroll-mt-24 py-10 first:pt-0"
|
|
56
|
+
>
|
|
57
|
+
<EndpointHeader
|
|
58
|
+
endpoint={endpoint}
|
|
59
|
+
anchor={anchor}
|
|
60
|
+
isLoadedInPlayground={isLoadedInPlayground}
|
|
61
|
+
onTryIt={onTryIt}
|
|
62
|
+
presentSections={presentSections}
|
|
63
|
+
/>
|
|
64
|
+
|
|
65
|
+
<div className="mt-8 space-y-5">
|
|
66
|
+
{hasParameters && (
|
|
67
|
+
<Section
|
|
68
|
+
id="parameters"
|
|
69
|
+
title="Parameters"
|
|
70
|
+
badge={pathParams.length + queryParams.length}
|
|
71
|
+
>
|
|
72
|
+
<Parameters pathParams={pathParams} queryParams={queryParams} />
|
|
73
|
+
</Section>
|
|
74
|
+
)}
|
|
75
|
+
{endpoint.requestBody && (
|
|
76
|
+
<Section id="requestBody" title="Request body">
|
|
77
|
+
<RequestBody body={endpoint.requestBody} />
|
|
78
|
+
</Section>
|
|
79
|
+
)}
|
|
80
|
+
<Section id="codeSamples" title="Code samples">
|
|
81
|
+
<CodeSamples endpoint={endpoint} />
|
|
82
|
+
</Section>
|
|
83
|
+
{hasResponses && (
|
|
84
|
+
<Section
|
|
85
|
+
id="responses"
|
|
86
|
+
title="Responses"
|
|
87
|
+
badge={endpoint.responses!.length}
|
|
88
|
+
>
|
|
89
|
+
<Responses responses={endpoint.responses!} />
|
|
90
|
+
</Section>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</section>
|
|
94
|
+
</EndpointDocProvider>
|
|
95
|
+
);
|
|
96
|
+
}
|