@djangocfg/ui-tools 2.1.284 → 2.1.286

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 (71) hide show
  1. package/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
  2. package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
  3. package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
  4. package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
  5. package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
  6. package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
  7. package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
  9. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
  10. package/dist/chunk-IULI4XII.cjs +1129 -0
  11. package/dist/chunk-IULI4XII.cjs.map +1 -0
  12. package/dist/chunk-VZGQC3NG.mjs +1100 -0
  13. package/dist/chunk-VZGQC3NG.mjs.map +1 -0
  14. package/dist/index.cjs +88 -552
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +18 -6
  17. package/dist/index.d.ts +18 -6
  18. package/dist/index.mjs +25 -496
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +6 -6
  21. package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +4 -0
  22. package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
  23. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
  24. package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
  25. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
  26. package/src/tools/OpenapiViewer/README.md +104 -51
  27. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
  28. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
  29. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
  30. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
  31. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
  32. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
  33. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
  34. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
  35. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
  39. package/src/tools/OpenapiViewer/components/index.ts +5 -2
  40. package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
  41. package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
  42. package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
  43. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
  44. package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
  45. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
  46. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
  47. package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
  48. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
  49. package/src/tools/OpenapiViewer/index.tsx +3 -7
  50. package/src/tools/OpenapiViewer/lazy.tsx +6 -27
  51. package/src/tools/OpenapiViewer/types.ts +44 -0
  52. package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
  53. package/src/tools/OpenapiViewer/utils/index.ts +3 -1
  54. package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
  55. package/src/tools/OpenapiViewer/utils/url.ts +202 -0
  56. package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
  57. package/src/tools/PrettyCode/index.tsx +6 -0
  58. package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
  59. package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
  60. package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
  61. package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
  62. package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
  63. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
  64. package/dist/chunk-5FKE7OME.cjs +0 -369
  65. package/dist/chunk-5FKE7OME.cjs.map +0 -1
  66. package/dist/chunk-BKWDHJKF.mjs +0 -356
  67. package/dist/chunk-BKWDHJKF.mjs.map +0 -1
  68. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
  69. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
  70. /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
  71. /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { SidePanel } from '@djangocfg/ui-core/components';
6
+ import { cn } from '@djangocfg/ui-core/lib';
7
+
8
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
9
+ import { RequestPanel } from '../shared/RequestPanel';
10
+ import { ResponsePanel } from '../shared/ResponsePanel';
11
+ import { SendButton } from '../shared/SendButton';
12
+ import { MethodBadge, Panel, relativePath } from '../shared/ui';
13
+
14
+ interface SlideInPlaygroundProps {
15
+ open: boolean;
16
+ onClose: () => void;
17
+ }
18
+
19
+ // Width when only Request is visible vs. when Response is also rendered.
20
+ // ``clamp(min, preferred, max)`` lets the panel scale with the viewport
21
+ // instead of sitting at a fixed size that looks tiny on ultra-wide or
22
+ // cramped on laptops.
23
+ //
24
+ // Narrow (Request only) — form content, no need to ever exceed ~480px.
25
+ // 1280 viewport → 384 | 1440 → 432 | 1920 → 480 (cap) | 2560 → 480 (cap)
26
+ //
27
+ // Wide (Request + Response) — response can be huge JSON/HTML; give it room.
28
+ // 1280 → 768 | 1440 → 864 | 1920 → 1152 | 2560 → 1280 (cap) | 3840 → 1280 (cap)
29
+ //
30
+ // Transition between the two values is animated by SidePanel.Content
31
+ // (both ``transform`` and ``width`` listed there). clamp() interpolates
32
+ // smoothly during that transition, no extra plumbing needed.
33
+ const WIDTH_NARROW = 'clamp(380px, 30vw, 480px)';
34
+ const WIDTH_WIDE = 'clamp(720px, 60vw, 1280px)';
35
+
36
+ /**
37
+ * Right-side slide-in playground. Two-column layout appears once the
38
+ * user has a response in hand — before that, only the Request form is
39
+ * shown and the panel stays narrow so docs have room to breathe.
40
+ *
41
+ * State transitions:
42
+ * - no endpoint ⇒ panel not rendered
43
+ * - endpoint, no resp ⇒ narrow (Request only)
44
+ * - endpoint + loading ⇒ wide (Request + Response spinner)
45
+ * - endpoint + resp ⇒ wide (Request + Response)
46
+ *
47
+ * Selecting a different endpoint clears ``response`` in the reducer,
48
+ * so the panel smoothly collapses back to narrow.
49
+ */
50
+ export function SlideInPlayground({ open, onClose }: SlideInPlaygroundProps) {
51
+ const { state } = usePlaygroundContext();
52
+ const ep = state.selectedEndpoint;
53
+ const showResponse = state.response !== null || state.loading;
54
+ const width = showResponse ? WIDTH_WIDE : WIDTH_NARROW;
55
+
56
+ return (
57
+ <SidePanel open={open} onOpenChange={(v) => !v && onClose()} side="right">
58
+ <SidePanel.Content width={width} className="max-w-[95vw]">
59
+ <SidePanel.Header>
60
+ <SidePanel.Title>Playground</SidePanel.Title>
61
+ {ep && (
62
+ <div className="flex items-center gap-2 min-w-0 flex-1">
63
+ <MethodBadge method={ep.method} />
64
+ <code className="font-mono text-[11px] text-muted-foreground truncate">
65
+ {relativePath(ep.path)}
66
+ </code>
67
+ </div>
68
+ )}
69
+ <SidePanel.Close className="ml-auto" />
70
+ </SidePanel.Header>
71
+
72
+ {/* Body: 1 or 2 columns depending on whether we have a
73
+ response to show. ``divide-x`` gives a 1px seam
74
+ between the panes so they read as distinct surfaces. */}
75
+ <SidePanel.Body
76
+ className={cn(
77
+ 'overflow-hidden grid divide-x transition-[grid-template-columns] duration-250',
78
+ showResponse
79
+ ? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)]'
80
+ : 'grid-cols-1',
81
+ )}
82
+ >
83
+ <Panel>
84
+ <RequestPanel />
85
+ </Panel>
86
+ {showResponse && (
87
+ <Panel>
88
+ <ResponsePanel />
89
+ </Panel>
90
+ )}
91
+ </SidePanel.Body>
92
+
93
+ {ep && (
94
+ <SidePanel.Footer className="px-4 py-3">
95
+ <SendButton />
96
+ </SidePanel.Footer>
97
+ )}
98
+ </SidePanel.Content>
99
+ </SidePanel>
100
+ );
101
+ }
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import {
6
+ ResponsiveSheet,
7
+ ResponsiveSheetContent,
8
+ ResponsiveSheetHeader,
9
+ ResponsiveSheetTitle,
10
+ } from '@djangocfg/ui-core/components';
11
+
12
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
13
+ import { RequestPanel } from '../shared/RequestPanel';
14
+ import { ResponsePanel } from '../shared/ResponsePanel';
15
+ import { SendButton } from '../shared/SendButton';
16
+
17
+ interface TryItSheetProps {
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ }
21
+
22
+ /**
23
+ * Mobile/tablet fallback: the slide-in playground doesn't fit, so we
24
+ * open Request + Response inside a ResponsiveSheet (bottom drawer on
25
+ * mobile, side sheet on desktop). Response stays hidden until a
26
+ * request has been sent — matching the desktop ``SlideInPlayground``
27
+ * behaviour.
28
+ */
29
+ export function TryItSheet({ open, onOpenChange }: TryItSheetProps) {
30
+ const { state } = usePlaygroundContext();
31
+ const showResponse = state.response !== null || state.loading;
32
+
33
+ return (
34
+ <ResponsiveSheet open={open} onOpenChange={onOpenChange}>
35
+ <ResponsiveSheetContent className="sm:max-w-xl flex flex-col h-full p-0">
36
+ <ResponsiveSheetHeader className="px-4 py-3 border-b shrink-0">
37
+ <ResponsiveSheetTitle className="text-sm">Playground</ResponsiveSheetTitle>
38
+ </ResponsiveSheetHeader>
39
+ <div className="flex-1 min-h-0 flex flex-col divide-y">
40
+ <div className={showResponse ? 'flex-1 min-h-0 flex flex-col' : 'flex-1 min-h-0 flex flex-col'}>
41
+ <RequestPanel />
42
+ </div>
43
+ {showResponse && (
44
+ <div className="flex-1 min-h-0 flex flex-col">
45
+ <ResponsePanel />
46
+ </div>
47
+ )}
48
+ </div>
49
+ {state.selectedEndpoint && (
50
+ <div className="shrink-0 border-t px-4 py-3 bg-background/95 backdrop-blur-sm">
51
+ <SendButton />
52
+ </div>
53
+ )}
54
+ </ResponsiveSheetContent>
55
+ </ResponsiveSheet>
56
+ );
57
+ }
@@ -0,0 +1,11 @@
1
+ import type { ApiEndpoint } from '../../types';
2
+
3
+ export function endpointAnchor(ep: Pick<ApiEndpoint, 'method' | 'path'>): string {
4
+ const slug = ep.path
5
+ .replace(/^https?:\/\/[^/]+/, '')
6
+ .replace(/[{}]/g, '')
7
+ .replace(/[^a-zA-Z0-9]+/g, '-')
8
+ .replace(/^-+|-+$/g, '')
9
+ .toLowerCase();
10
+ return `ep-${ep.method.toLowerCase()}-${slug}`;
11
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared grouping / sort for the docs layout.
3
+ *
4
+ * Sidebar and the docs longread MUST use the same order — otherwise
5
+ * scrollspy highlights jump around as the user scrolls (the sidebar's
6
+ * ordered list doesn't match the visual order of sections in the docs).
7
+ * This module is the single source of truth for that ordering.
8
+ */
9
+
10
+ import type { ApiEndpoint } from '../../types';
11
+ import { longestCommonPrefix } from './sidebarLabel';
12
+
13
+ export type EndpointGroup = {
14
+ category: string;
15
+ endpoints: ApiEndpoint[];
16
+ /** Longest ``/``-aligned prefix shared by every endpoint in this
17
+ * group. Used by the sidebar to strip the redundant group prefix
18
+ * from fallback labels. */
19
+ commonPrefix: string;
20
+ };
21
+
22
+ const METHOD_ORDER: Record<string, number> = {
23
+ GET: 0,
24
+ POST: 1,
25
+ PUT: 2,
26
+ PATCH: 3,
27
+ DELETE: 4,
28
+ };
29
+
30
+ /**
31
+ * Stable, deterministic ordering so two different renders with the
32
+ * same endpoint list always produce the same visual sequence.
33
+ *
34
+ * Groups: alphabetical by tag/category, with ``Other`` pinned to the
35
+ * bottom (spec-less endpoints should not steal the top slot).
36
+ *
37
+ * Within a group: endpoints sorted by path first (so related resources
38
+ * cluster), then by HTTP method (read → write → delete).
39
+ */
40
+ export function groupEndpoints(list: ApiEndpoint[]): EndpointGroup[] {
41
+ const map = new Map<string, ApiEndpoint[]>();
42
+ for (const ep of list) {
43
+ const arr = map.get(ep.category) ?? [];
44
+ arr.push(ep);
45
+ map.set(ep.category, arr);
46
+ }
47
+
48
+ const groups: EndpointGroup[] = Array.from(map.entries()).map(([category, endpoints]) => ({
49
+ category,
50
+ endpoints: [...endpoints].sort((a, b) => {
51
+ const byPath = a.path.localeCompare(b.path);
52
+ if (byPath !== 0) return byPath;
53
+ return (METHOD_ORDER[a.method] ?? 99) - (METHOD_ORDER[b.method] ?? 99);
54
+ }),
55
+ commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
56
+ }));
57
+
58
+ groups.sort((a, b) => {
59
+ if (a.category === 'Other') return 1;
60
+ if (b.category === 'Other') return -1;
61
+ return a.category.localeCompare(b.category);
62
+ });
63
+ return groups;
64
+ }
65
+
66
+ /** Flatten grouped endpoints back into a linear list that preserves
67
+ * group order + within-group order. This is the canonical order for
68
+ * both the sidebar and the docs longread. */
69
+ export function flattenGrouped(groups: EndpointGroup[]): ApiEndpoint[] {
70
+ return groups.flatMap((g) => g.endpoints);
71
+ }
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import React, { useCallback, useRef, useState } from 'react';
4
+
5
+ import { Skeleton, TooltipProvider } from '@djangocfg/ui-core/components';
6
+ import { useMediaQuery } from '@djangocfg/ui-core/hooks';
7
+
8
+ import useOpenApiSchema from '../../hooks/useOpenApiSchema';
9
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
10
+ import type { ApiEndpoint } from '../../types';
11
+ import { EndpointDraftSync } from '../shared/EndpointDraftSync';
12
+ import { DocsSidebar } from './Sidebar';
13
+ import { DocsView, type DocsViewHandle } from './DocsView';
14
+ import { SlideInPlayground } from './SlideInPlayground';
15
+ import { TryItSheet } from './TryItSheet';
16
+
17
+ // ─── Root ─────────────────────────────────────────────────────────────────────
18
+
19
+ export const DocsLayout: React.FC = () => {
20
+ const { state, config, setSelectedEndpoint } = usePlaygroundContext();
21
+ // The docs layout has a sidebar + docs column that already eat ~260px
22
+ // before the slide-in opens. Below 1024px the slide-in (min 720 wide)
23
+ // leaves docs with <250px — unreadable — so we fall back to the
24
+ // mobile-style ``TryItSheet`` on those viewports.
25
+ const isDesktop = useMediaQuery('(min-width: 1024px)');
26
+ const isMobile = !isDesktop;
27
+ const {
28
+ endpoints,
29
+ schemaInfo,
30
+ rawSchema,
31
+ resolvedBaseUrl,
32
+ loading,
33
+ error,
34
+ schemas,
35
+ currentSchema,
36
+ setCurrentSchema,
37
+ } = useOpenApiSchema({
38
+ schemas: config.schemas,
39
+ defaultSchemaId: config.defaultSchemaId,
40
+ baseUrl: config.baseUrl,
41
+ });
42
+
43
+ const [activeAnchor, setActiveAnchor] = useState<string | null>(null);
44
+ const [sheetOpen, setSheetOpen] = useState(false);
45
+ const docsRef = useRef<DocsViewHandle | null>(null);
46
+
47
+ // Desktop slide-in is driven directly by ``selectedEndpoint``. Keeping a
48
+ // separate open-state would mean two sources of truth for the same
49
+ // semantic — "which endpoint is loaded into the playground".
50
+ const slideOpen = !isMobile && state.selectedEndpoint !== null;
51
+
52
+ const handleTry = useCallback(
53
+ (ep: ApiEndpoint) => {
54
+ setSelectedEndpoint(ep);
55
+ if (isMobile) setSheetOpen(true);
56
+ },
57
+ [isMobile, setSelectedEndpoint],
58
+ );
59
+
60
+ const handleCloseSlide = useCallback(() => {
61
+ setSelectedEndpoint(null);
62
+ }, [setSelectedEndpoint]);
63
+
64
+ const handleNavigate = useCallback((anchor: string) => {
65
+ docsRef.current?.scrollToAnchor(anchor);
66
+ }, []);
67
+ // Esc handling lives inside SidePanel itself — no duplicate listener.
68
+
69
+ if (loading) {
70
+ return (
71
+ <div
72
+ className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden"
73
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
74
+ >
75
+ <div className="border-r p-3 space-y-1.5">
76
+ {Array.from({ length: 12 }).map((_, i) => (
77
+ <Skeleton key={i} className="h-8 w-full rounded" />
78
+ ))}
79
+ </div>
80
+ <div className="p-8 space-y-4">
81
+ <Skeleton className="h-8 w-1/2" />
82
+ <Skeleton className="h-4 w-full" />
83
+ <Skeleton className="h-4 w-3/4" />
84
+ <div className="mt-8 space-y-6">
85
+ {Array.from({ length: 3 }).map((_, i) => (
86
+ <div key={i} className="space-y-3">
87
+ <Skeleton className="h-6 w-1/3" />
88
+ <Skeleton className="h-20 w-full" />
89
+ </div>
90
+ ))}
91
+ </div>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ if (error) {
98
+ return (
99
+ <div
100
+ className="flex items-center justify-center p-8"
101
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
102
+ >
103
+ <p className="text-sm text-destructive">Failed to load schema: {error}</p>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ // Mobile/tablet: sidebar + docs only, playground opens in sheet.
109
+ if (isMobile) {
110
+ return (
111
+ <div
112
+ className="flex flex-col overflow-hidden"
113
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
114
+ >
115
+ <EndpointDraftSync schemaId={currentSchema?.id ?? null} />
116
+ <DocsView
117
+ ref={docsRef}
118
+ info={schemaInfo}
119
+ rawSchema={rawSchema}
120
+ resolvedBaseUrl={resolvedBaseUrl}
121
+ endpoints={endpoints}
122
+ selectedVersion={state.selectedVersion}
123
+ loadedEndpoint={state.selectedEndpoint}
124
+ onTryEndpoint={handleTry}
125
+ onActiveChange={setActiveAnchor}
126
+ />
127
+ <TryItSheet open={sheetOpen} onOpenChange={setSheetOpen} />
128
+ </div>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <TooltipProvider delayDuration={350}>
134
+ <div
135
+ className="grid grid-cols-[260px_minmax(0,1fr)] min-h-0 overflow-hidden"
136
+ style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
137
+ >
138
+ <EndpointDraftSync schemaId={currentSchema?.id ?? null} />
139
+ <DocsSidebar
140
+ info={schemaInfo}
141
+ endpoints={endpoints}
142
+ schemas={schemas}
143
+ currentSchemaId={currentSchema?.id ?? null}
144
+ onSchemaChange={setCurrentSchema}
145
+ activeEndpointId={activeAnchor}
146
+ selectedVersion={state.selectedVersion}
147
+ onNavigate={handleNavigate}
148
+ />
149
+ <DocsView
150
+ ref={docsRef}
151
+ info={schemaInfo}
152
+ rawSchema={rawSchema}
153
+ resolvedBaseUrl={resolvedBaseUrl}
154
+ endpoints={endpoints}
155
+ selectedVersion={state.selectedVersion}
156
+ loadedEndpoint={state.selectedEndpoint}
157
+ onTryEndpoint={handleTry}
158
+ onActiveChange={setActiveAnchor}
159
+ />
160
+ {/* SidePanel renders into <body> via portal, so it floats
161
+ above the whole layout (sidebar + navbar included). */}
162
+ <SlideInPlayground open={slideOpen} onClose={handleCloseSlide} />
163
+ </div>
164
+ </TooltipProvider>
165
+ );
166
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Flatten a JSON Schema node into a flat list of (name, type, required,
3
+ * description) rows, ready to feed into the existing parameter table.
4
+ *
5
+ * Rules:
6
+ * - ``type: object`` → one row per property.
7
+ * - ``type: array`` → recurse into items; prefix each row with ``[].``
8
+ * so the caller sees "[].username string" etc.
9
+ * - Nested objects → dot-joined path: ``category.name``. We expand
10
+ * one level; deeper graphs get a single summary
11
+ * row ("category.* (object)") to keep the table
12
+ * readable.
13
+ *
14
+ * This is a presentation helper — not a full form generator. It powers
15
+ * the read-only fields table shown in the docs longread.
16
+ */
17
+
18
+ type JsonSchemaNode = Record<string, unknown> & {
19
+ type?: string;
20
+ properties?: Record<string, JsonSchemaNode>;
21
+ required?: string[];
22
+ items?: JsonSchemaNode;
23
+ enum?: unknown[];
24
+ description?: string;
25
+ format?: string;
26
+ };
27
+
28
+ export interface SchemaField {
29
+ name: string;
30
+ type: string;
31
+ required: boolean;
32
+ description?: string;
33
+ }
34
+
35
+ const MAX_DEPTH = 2;
36
+
37
+ function describeType(node: JsonSchemaNode): string {
38
+ if (!node.type && node.properties) return 'object';
39
+ const base = node.type || 'any';
40
+ if (base === 'array') {
41
+ const itemType = node.items ? describeType(node.items) : 'any';
42
+ return `array<${itemType}>`;
43
+ }
44
+ if (Array.isArray(node.enum) && node.enum.length > 0) {
45
+ return `${base} enum`;
46
+ }
47
+ if (node.format) return `${base} (${node.format})`;
48
+ return base;
49
+ }
50
+
51
+ export function schemaToFields(
52
+ schema: JsonSchemaNode | undefined,
53
+ prefix = '',
54
+ depth = 0,
55
+ ): SchemaField[] {
56
+ if (!schema || depth > MAX_DEPTH) return [];
57
+
58
+ // Unwrap arrays: show the inner item's fields, prefixed with ``[]``.
59
+ if (schema.type === 'array') {
60
+ if (!schema.items) {
61
+ return [{ name: prefix || '[]', type: 'array', required: false }];
62
+ }
63
+ const inner = schemaToFields(schema.items, prefix ? `${prefix}[]` : '[]', depth);
64
+ if (inner.length === 0) {
65
+ return [
66
+ {
67
+ name: prefix || '[]',
68
+ type: describeType(schema),
69
+ required: false,
70
+ description: schema.description,
71
+ },
72
+ ];
73
+ }
74
+ return inner;
75
+ }
76
+
77
+ // Primitives and unknown — single row.
78
+ if (schema.type !== 'object' && !schema.properties) {
79
+ return [
80
+ {
81
+ name: prefix || '(body)',
82
+ type: describeType(schema),
83
+ required: false,
84
+ description: schema.description,
85
+ },
86
+ ];
87
+ }
88
+
89
+ // Objects — one row per property, recurse for nested objects/arrays.
90
+ const required = new Set(schema.required ?? []);
91
+ const rows: SchemaField[] = [];
92
+ const props = schema.properties ?? {};
93
+ for (const [key, node] of Object.entries(props)) {
94
+ const fullName = prefix ? `${prefix}.${key}` : key;
95
+ const isRequired = required.has(key);
96
+
97
+ const isNestedExpandable =
98
+ (node.type === 'object' && node.properties) ||
99
+ (node.type === 'array' && node.items);
100
+
101
+ if (isNestedExpandable && depth < MAX_DEPTH) {
102
+ // Leaf summary row for the parent itself (so the user sees
103
+ // its description) + recurse for inner fields.
104
+ rows.push({
105
+ name: fullName,
106
+ type: describeType(node),
107
+ required: isRequired,
108
+ description: node.description,
109
+ });
110
+ rows.push(...schemaToFields(node, fullName, depth + 1));
111
+ } else {
112
+ rows.push({
113
+ name: fullName,
114
+ type: describeType(node),
115
+ required: isRequired,
116
+ description: node.description,
117
+ });
118
+ }
119
+ }
120
+ return rows;
121
+ }
@@ -0,0 +1,60 @@
1
+ import type { ApiEndpoint } from '../../types';
2
+
3
+ /**
4
+ * Given a list of full endpoint paths, return the longest ``/``-aligned
5
+ * prefix they all share. Used to strip the redundant group prefix
6
+ * (``/api/v3/pet``) from sidebar labels so the meaningful tail is visible.
7
+ *
8
+ * Works on full URLs too — if every path begins with the same origin,
9
+ * the origin is part of the common prefix and gets stripped.
10
+ */
11
+ export function longestCommonPrefix(paths: string[]): string {
12
+ if (paths.length === 0) return '';
13
+ if (paths.length === 1) return '';
14
+
15
+ const segments = paths.map((p) => p.split('/'));
16
+ const minLen = Math.min(...segments.map((s) => s.length));
17
+
18
+ const shared: string[] = [];
19
+ for (let i = 0; i < minLen; i++) {
20
+ const first = segments[0]![i];
21
+ if (segments.every((s) => s[i] === first)) {
22
+ shared.push(first!);
23
+ } else {
24
+ break;
25
+ }
26
+ }
27
+ // Don't strip everything — we always want at least a leading "/" or
28
+ // the method on the visible side. If the group has one endpoint,
29
+ // the caller guards with paths.length check already.
30
+ const joined = shared.join('/');
31
+ // Trim trailing slash so the tail is clean (``/foo`` not ``foo``).
32
+ return joined;
33
+ }
34
+
35
+ /**
36
+ * Compute the label to show in the sidebar for a given endpoint.
37
+ *
38
+ * Priority:
39
+ * 1. ``ep.summary`` — human-readable, from OpenAPI ``operation.summary``.
40
+ * 2. The tail of ``ep.path`` after stripping the group's common prefix.
41
+ * 3. Full ``ep.path`` if the group has a single endpoint (no prefix to strip).
42
+ */
43
+ export function sidebarLabel(ep: ApiEndpoint, groupCommonPrefix: string): string {
44
+ if (ep.summary) return ep.summary;
45
+
46
+ if (groupCommonPrefix && ep.path.startsWith(groupCommonPrefix)) {
47
+ const tail = ep.path.slice(groupCommonPrefix.length) || '/';
48
+ return tail;
49
+ }
50
+ return relativePath(ep.path);
51
+ }
52
+
53
+ function relativePath(full: string): string {
54
+ try { return new URL(full).pathname; } catch { return full; }
55
+ }
56
+
57
+ /** Tooltip text: always the definitive ``METHOD relative/path``. */
58
+ export function sidebarTooltip(ep: ApiEndpoint): string {
59
+ return `${ep.method} ${relativePath(ep.path)}`;
60
+ }
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Playground Components
2
+ * OpenapiViewer — component exports.
3
+ * Only ``DocsLayout`` remains; the legacy 3-column ``PlaygroundLayout``
4
+ * has been removed. Shared panels live under ``./shared`` and are
5
+ * consumed by the docs layout + its mobile sheet.
3
6
  */
4
7
 
5
- export { PlaygroundLayout } from './PlaygroundLayout';
8
+ export { DocsLayout } from './DocsLayout';