@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.
Files changed (97) hide show
  1. package/README.md +14 -3
  2. package/dist/{DocsLayout-YDR7DSMM.cjs → DocsLayout-IKH7BLSU.cjs} +1537 -682
  3. package/dist/DocsLayout-IKH7BLSU.cjs.map +1 -0
  4. package/dist/{DocsLayout-TKJQ5W5E.mjs → DocsLayout-JPXFUKAR.mjs} +1429 -574
  5. package/dist/DocsLayout-JPXFUKAR.mjs.map +1 -0
  6. package/dist/{PrettyCode.client-5GABIN2I.cjs → PrettyCode.client-RPDIE5CH.cjs} +104 -3
  7. package/dist/PrettyCode.client-RPDIE5CH.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-IZTXXYHG.mjs → PrettyCode.client-SPMTQEG4.mjs} +106 -5
  9. package/dist/PrettyCode.client-SPMTQEG4.mjs.map +1 -0
  10. package/dist/{chunk-IULI4XII.cjs → chunk-5Q4UMSWB.cjs} +355 -9
  11. package/dist/chunk-5Q4UMSWB.cjs.map +1 -0
  12. package/dist/{chunk-VZGQC3NG.mjs → chunk-EFWOJPA6.mjs} +349 -9
  13. package/dist/chunk-EFWOJPA6.mjs.map +1 -0
  14. package/dist/index.cjs +10 -10
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +18 -0
  17. package/dist/index.d.ts +18 -0
  18. package/dist/index.mjs +5 -5
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +18 -14
  21. package/src/components/markdown/MarkdownMessage.tsx +46 -0
  22. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +87 -178
  23. package/src/tools/OpenapiViewer/README.md +114 -6
  24. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +20 -6
  25. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +6 -0
  26. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/LanguageTabs.tsx +36 -0
  27. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/index.tsx +56 -0
  28. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/useCodeSnippet.ts +77 -0
  29. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +146 -0
  30. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MethodBadge.tsx +6 -0
  31. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/PathDisplay.tsx +26 -0
  32. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/index.tsx +87 -0
  33. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamGroup.tsx +30 -0
  34. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamRow.tsx +36 -0
  35. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/index.tsx +22 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/RequestBody/index.tsx +33 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +76 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseRow.tsx +80 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +32 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/index.tsx +21 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +106 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +127 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/index.tsx +31 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/types.ts +28 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +87 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/defaults.ts +27 -0
  47. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/index.tsx +45 -0
  48. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/context.tsx +56 -0
  49. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/hooks/useSectionHash.ts +63 -0
  50. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +96 -0
  51. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/index.ts +133 -0
  52. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/selectors.ts +40 -0
  53. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/types.ts +17 -0
  54. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +8 -2
  55. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/BrandHeader.tsx +48 -0
  56. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/CategoryBlock.tsx +33 -0
  57. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/EndpointRow.tsx +73 -0
  58. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/MethodChips.tsx +43 -0
  59. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SchemaSection.tsx +27 -0
  60. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SearchInput.tsx +45 -0
  61. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SidebarBody.tsx +50 -0
  62. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/Toolbar.tsx +64 -0
  63. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/buildVM.ts +126 -0
  64. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/index.tsx +112 -0
  65. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/types.ts +42 -0
  66. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/useDebouncedValue.ts +14 -0
  67. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +10 -7
  68. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +9 -6
  69. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +55 -0
  70. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PreviewView.tsx +115 -0
  71. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/RawView.tsx +24 -0
  72. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/StatusBar.tsx +63 -0
  73. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/ViewTabs.tsx +45 -0
  74. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/detectContent.ts +97 -0
  75. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/index.tsx +93 -0
  76. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/types.ts +26 -0
  77. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/useResponseView.ts +62 -0
  78. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +41 -71
  79. package/src/tools/OpenapiViewer/types.ts +10 -0
  80. package/src/tools/OpenapiViewer/utils/codeSamples.ts +287 -0
  81. package/src/tools/OpenapiViewer/utils/index.ts +3 -0
  82. package/src/tools/OpenapiViewer/utils/operationToHar.ts +119 -0
  83. package/src/tools/OpenapiViewer/utils/sampler.ts +72 -0
  84. package/src/tools/PrettyCode/PrettyCode.client.tsx +88 -1
  85. package/src/tools/PrettyCode/PrettyCode.story.tsx +114 -361
  86. package/src/tools/PrettyCode/index.tsx +13 -0
  87. package/src/tools/PrettyCode/lazy.tsx +5 -0
  88. package/src/tools/PrettyCode/registerPrismLanguages.ts +111 -0
  89. package/dist/DocsLayout-TKJQ5W5E.mjs.map +0 -1
  90. package/dist/DocsLayout-YDR7DSMM.cjs.map +0 -1
  91. package/dist/PrettyCode.client-5GABIN2I.cjs.map +0 -1
  92. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +0 -1
  93. package/dist/chunk-IULI4XII.cjs.map +0 -1
  94. package/dist/chunk-VZGQC3NG.mjs.map +0 -1
  95. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +0 -273
  96. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -439
  97. package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import { CopyButton } from '@djangocfg/ui-core/components';
6
+
7
+ import JsonTree from '../../../../../JsonTree';
8
+
9
+ // Compact, read-only JsonTree config. Docs longread is space-constrained
10
+ // so we ship less chrome than the interactive ``ResponsePanel`` — copy
11
+ // button lives outside the tree (on the row header) and collection-info
12
+ // stays on so long arrays don't silently truncate.
13
+ const EXAMPLE_JSON_TREE_CONFIG = {
14
+ maxAutoExpandDepth: 2,
15
+ maxAutoExpandArrayItems: 5,
16
+ maxAutoExpandObjectKeys: 8,
17
+ maxStringLength: 160,
18
+ collectionLimit: 25,
19
+ showCollectionInfo: true,
20
+ showExpandControls: false,
21
+ showActionButtons: false,
22
+ preserveKeyOrder: true,
23
+ className: 'border-0 rounded-none',
24
+ } as const;
25
+
26
+ interface ResponseBodyProps {
27
+ example: string;
28
+ contentType?: string;
29
+ }
30
+
31
+ /** Render the example response body: content-type strip + copy button
32
+ * header, then the JSON tree (falling back to a ``<pre>`` if the
33
+ * example isn't valid JSON — sampler occasionally emits non-JSON for
34
+ * exotic content types). */
35
+ export function ResponseBody({ example, contentType }: ResponseBodyProps) {
36
+ // Parse once and cache. ``example`` is pre-stringified by the
37
+ // sampler; JsonTree wants a live object to render nodes with proper
38
+ // folding, so we flip it back here.
39
+ const parsed = useMemo(() => {
40
+ try {
41
+ return JSON.parse(example);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }, [example]);
46
+
47
+ return (
48
+ <div className="border-t bg-muted/20">
49
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-border/50">
50
+ <code className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
51
+ {contentType ?? 'application/json'}
52
+ </code>
53
+ <CopyButton
54
+ value={example}
55
+ variant="ghost"
56
+ size="sm"
57
+ className="h-6 px-2 text-[10px] text-muted-foreground"
58
+ >
59
+ Copy
60
+ </CopyButton>
61
+ </div>
62
+ {parsed != null ? (
63
+ <JsonTree
64
+ title=""
65
+ data={parsed}
66
+ mode="compact"
67
+ config={EXAMPLE_JSON_TREE_CONFIG}
68
+ />
69
+ ) : (
70
+ <pre className="p-3 text-[11px] font-mono text-foreground/70 whitespace-pre-wrap break-all leading-relaxed">
71
+ {example}
72
+ </pre>
73
+ )}
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { ChevronRight } from 'lucide-react';
4
+ import { useState } from 'react';
5
+
6
+ import { cn } from '@djangocfg/ui-core/lib';
7
+
8
+ import type { ApiEndpoint } from '../../../../types';
9
+ import { ResponseBody } from './ResponseBody';
10
+ import { StatusTag } from './StatusTag';
11
+
12
+ interface ResponseRowProps {
13
+ response: NonNullable<ApiEndpoint['responses']>[number];
14
+ }
15
+
16
+ /** One status-code row in the Responses section.
17
+ *
18
+ * Layout:
19
+ * - Chevron column only renders when there's an example to expand.
20
+ * Otherwise the row is inert (no button wrapper, no hover state)
21
+ * and the space stays tight instead of reserving an empty gutter
22
+ * under a disabled chevron.
23
+ * - Status tag column is ``48px`` wide — enough for ``default`` and
24
+ * 3-digit codes in the font size we use. Keeps descriptions
25
+ * aligned on their left edge across all rows regardless of code.
26
+ *
27
+ * Expansion defaults:
28
+ * - 2xx with example → open (happy-path is what readers want first).
29
+ * - Everything else → closed, one click to inspect. */
30
+ export function ResponseRow({ response }: ResponseRowProps) {
31
+ const hasExample = Boolean(response.example);
32
+ const numeric = Number.parseInt(response.code, 10);
33
+ const isSuccess = Number.isFinite(numeric) && numeric >= 200 && numeric < 300;
34
+ const [open, setOpen] = useState(hasExample && isSuccess);
35
+
36
+ // Inert row (no example available) — render as a plain div so the
37
+ // layout stays identical but without button affordances.
38
+ if (!hasExample) {
39
+ return (
40
+ <div className="flex items-center gap-3 px-3 py-2 bg-background">
41
+ <div className="w-12 shrink-0 flex justify-start">
42
+ <StatusTag code={response.code} />
43
+ </div>
44
+ <span className="text-sm text-muted-foreground leading-relaxed break-words min-w-0">
45
+ {response.description}
46
+ </span>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div className="bg-background">
53
+ <button
54
+ type="button"
55
+ onClick={() => setOpen((v) => !v)}
56
+ className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-muted/40 cursor-pointer transition-colors"
57
+ aria-expanded={open}
58
+ >
59
+ <ChevronRight
60
+ className={cn(
61
+ 'h-3.5 w-3.5 text-muted-foreground/60 transition-transform shrink-0',
62
+ open && 'rotate-90',
63
+ )}
64
+ />
65
+ <div className="w-12 shrink-0 flex justify-start">
66
+ <StatusTag code={response.code} />
67
+ </div>
68
+ <span className="text-sm text-muted-foreground leading-relaxed break-words min-w-0 flex-1">
69
+ {response.description}
70
+ </span>
71
+ </button>
72
+ {open && (
73
+ <ResponseBody
74
+ example={response.example!}
75
+ contentType={response.contentType}
76
+ />
77
+ )}
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ interface StatusTagProps {
6
+ code: string;
7
+ }
8
+
9
+ /** HTTP status pill. Colours map to the usual conventions: 2xx green,
10
+ * 3xx blue, 4xx amber, 5xx red, non-numeric (``default``) neutral.
11
+ * Codes that don't parse as integers render in the neutral style so we
12
+ * never flash a wrong-severity colour at the user. */
13
+ export function StatusTag({ code }: StatusTagProps) {
14
+ const numeric = Number.parseInt(code, 10);
15
+ const cls = !Number.isFinite(numeric)
16
+ ? 'bg-muted text-muted-foreground border-border'
17
+ : numeric >= 500
18
+ ? 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25'
19
+ : numeric >= 400
20
+ ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25'
21
+ : numeric >= 300
22
+ ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25'
23
+ : 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25';
24
+ return (
25
+ <span className={cn(
26
+ 'inline-flex items-center justify-center rounded border px-2 py-0.5 font-mono text-[11px] font-bold leading-none shrink-0 tabular-nums',
27
+ cls,
28
+ )}>
29
+ {code}
30
+ </span>
31
+ );
32
+ }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+
3
+ import type { ApiEndpoint } from '../../../../types';
4
+ import { ResponseRow } from './ResponseRow';
5
+
6
+ interface ResponsesProps {
7
+ responses: NonNullable<ApiEndpoint['responses']>;
8
+ }
9
+
10
+ /** Flat list of response rows. Outer collapsible wrapper lives in the
11
+ * ``Section`` component higher up; this component only knows how to
12
+ * arrange the individual rows. */
13
+ export function Responses({ responses }: ResponsesProps) {
14
+ return (
15
+ <div className="divide-y border rounded-md overflow-hidden">
16
+ {responses.map((r) => (
17
+ <ResponseRow key={r.code} response={r} />
18
+ ))}
19
+ </div>
20
+ );
21
+ }
@@ -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
+ }