@djangocfg/ui-tools 2.1.286 → 2.1.289
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/dist/{DocsLayout-ERETJLLV.mjs → DocsLayout-TKJQ5W5E.mjs} +848 -266
- package/dist/DocsLayout-TKJQ5W5E.mjs.map +1 -0
- package/dist/{DocsLayout-BCVU6TTX.cjs → DocsLayout-YDR7DSMM.cjs} +843 -261
- package/dist/DocsLayout-YDR7DSMM.cjs.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.mjs +2 -2
- package/package.json +9 -6
- package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +2 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +2 -2
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +27 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +326 -54
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +7 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +32 -9
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +348 -120
- 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/hooks/index.ts +3 -1
- package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +127 -7
- package/src/tools/OpenapiViewer/types.ts +36 -1
- package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
- package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
|
@@ -12,60 +12,176 @@ import {
|
|
|
12
12
|
} from '@djangocfg/ui-core/components';
|
|
13
13
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
14
|
|
|
15
|
-
import type { ApiEndpoint, OpenApiInfo, SchemaSource } from '../../types';
|
|
15
|
+
import type { ApiEndpoint, OpenApiInfo, OpenApiSchema, SchemaSource } from '../../types';
|
|
16
16
|
import { deduplicateEndpoints } from '../../utils/versionManager';
|
|
17
17
|
import { MethodBadge, ScrollArea } from '../shared/ui';
|
|
18
18
|
import { endpointAnchor } from './anchor';
|
|
19
|
-
import {
|
|
19
|
+
import { buildSchemaSections, groupEndpoints, type EndpointGroup } from './grouping';
|
|
20
|
+
import { SchemaCopyMenu } from './SchemaCopyMenu';
|
|
21
|
+
import { sidebarLabel, sidebarTooltip } from './sidebarLabel';
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
category: string;
|
|
23
|
-
endpoints: ApiEndpoint[];
|
|
24
|
-
/** Longest ``/``-aligned prefix shared by every endpoint in this group.
|
|
25
|
-
* Stripped from labels that fall back to showing the path. */
|
|
26
|
-
commonPrefix: string;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const METHOD_ORDER: Record<string, number> = {
|
|
30
|
-
GET: 0, POST: 1, PUT: 2, PATCH: 3, DELETE: 4,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
function groupEndpoints(list: ApiEndpoint[]): Group[] {
|
|
34
|
-
const map = new Map<string, ApiEndpoint[]>();
|
|
35
|
-
for (const ep of list) {
|
|
36
|
-
const arr = map.get(ep.category) ?? [];
|
|
37
|
-
arr.push(ep);
|
|
38
|
-
map.set(ep.category, arr);
|
|
39
|
-
}
|
|
40
|
-
const groups: Group[] = Array.from(map.entries()).map(([category, endpoints]) => ({
|
|
41
|
-
category,
|
|
42
|
-
endpoints: [...endpoints].sort((a, b) => {
|
|
43
|
-
const byPath = a.path.localeCompare(b.path);
|
|
44
|
-
if (byPath !== 0) return byPath;
|
|
45
|
-
return (METHOD_ORDER[a.method] ?? 99) - (METHOD_ORDER[b.method] ?? 99);
|
|
46
|
-
}),
|
|
47
|
-
commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
|
|
48
|
-
}));
|
|
49
|
-
// Alphabetical, but "Other" sinks to the bottom.
|
|
50
|
-
groups.sort((a, b) => {
|
|
51
|
-
if (a.category === 'Other') return 1;
|
|
52
|
-
if (b.category === 'Other') return -1;
|
|
53
|
-
return a.category.localeCompare(b.category);
|
|
54
|
-
});
|
|
55
|
-
return groups;
|
|
56
|
-
}
|
|
23
|
+
// ─── Public props ────────────────────────────────────────────────────────────
|
|
57
24
|
|
|
58
25
|
export interface DocsSidebarProps {
|
|
59
26
|
info: OpenApiInfo | null;
|
|
27
|
+
/** Active-schema endpoints — used by ``selector`` mode. */
|
|
60
28
|
endpoints: ApiEndpoint[];
|
|
29
|
+
/** All configured schemas (used by both modes). */
|
|
61
30
|
schemas: SchemaSource[];
|
|
62
31
|
currentSchemaId: string | null;
|
|
63
32
|
onSchemaChange: (id: string) => void;
|
|
64
33
|
activeEndpointId: string | null;
|
|
65
34
|
selectedVersion: string;
|
|
66
|
-
onNavigate: (anchor: string) => void;
|
|
35
|
+
onNavigate: (anchor: string, schemaId?: string | null) => void;
|
|
36
|
+
/** Presentation mode. Default: ``selector`` (back-compat). */
|
|
37
|
+
grouping?: 'selector' | 'sections';
|
|
38
|
+
/** Required for ``sections`` mode — endpoints grouped by their source
|
|
39
|
+
* schema id. The sidebar renders them as two-level sections. */
|
|
40
|
+
endpointsBySchema?: Record<string, ApiEndpoint[]>;
|
|
41
|
+
/** Raw active schema + resolved base URL — used by the Copy-for-AI
|
|
42
|
+
* dropdown in the brand row. ``null`` disables the button. */
|
|
43
|
+
rawSchema?: OpenApiSchema | null;
|
|
44
|
+
resolvedBaseUrl?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** HTTP methods the sidebar can filter by. Reordered like the docs
|
|
48
|
+
* themselves: read, then write. ``ALL`` is a sentinel for no filter. */
|
|
49
|
+
const METHOD_FILTERS = ['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
|
50
|
+
type MethodFilter = (typeof METHOD_FILTERS)[number];
|
|
51
|
+
|
|
52
|
+
// ─── View-model types ────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
interface EndpointRowVM {
|
|
55
|
+
key: string;
|
|
56
|
+
anchor: string;
|
|
57
|
+
schemaId: string | null;
|
|
58
|
+
label: string;
|
|
59
|
+
tooltip: string;
|
|
60
|
+
method: string;
|
|
61
|
+
/** Summary-less endpoints get a monospace font for the path-tail label. */
|
|
62
|
+
useMono: boolean;
|
|
63
|
+
isActive: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface CategoryVM {
|
|
67
|
+
key: string;
|
|
68
|
+
category: string;
|
|
69
|
+
rows: EndpointRowVM[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface SchemaSectionVM {
|
|
73
|
+
sourceId: string;
|
|
74
|
+
sourceName: string;
|
|
75
|
+
categories: CategoryVM[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type SidebarBodyVM =
|
|
79
|
+
| { kind: 'flat'; categories: CategoryVM[]; emptyText: string }
|
|
80
|
+
| { kind: 'sections'; sections: SchemaSectionVM[]; emptyText: string };
|
|
81
|
+
|
|
82
|
+
// ─── Pure builders ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function filterEndpoints(
|
|
85
|
+
list: ApiEndpoint[],
|
|
86
|
+
query: string,
|
|
87
|
+
method: MethodFilter,
|
|
88
|
+
): ApiEndpoint[] {
|
|
89
|
+
let out = list;
|
|
90
|
+
if (method !== 'ALL') {
|
|
91
|
+
out = out.filter((e) => e.method === method);
|
|
92
|
+
}
|
|
93
|
+
if (query) {
|
|
94
|
+
const q = query.toLowerCase();
|
|
95
|
+
out = out.filter(
|
|
96
|
+
(e) =>
|
|
97
|
+
e.summary.toLowerCase().includes(q) ||
|
|
98
|
+
e.name.toLowerCase().includes(q) ||
|
|
99
|
+
e.description.toLowerCase().includes(q) ||
|
|
100
|
+
e.path.toLowerCase().includes(q),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildCategory(
|
|
107
|
+
group: EndpointGroup,
|
|
108
|
+
activeEndpointId: string | null,
|
|
109
|
+
schemaId: string | null,
|
|
110
|
+
keyPrefix: string,
|
|
111
|
+
): CategoryVM {
|
|
112
|
+
const rows: EndpointRowVM[] = group.endpoints.map((ep) => {
|
|
113
|
+
const anchor = endpointAnchor(ep, schemaId ?? ep.schemaId ?? null);
|
|
114
|
+
return {
|
|
115
|
+
key: `${ep.method}-${ep.path}`,
|
|
116
|
+
anchor,
|
|
117
|
+
schemaId: schemaId ?? ep.schemaId ?? null,
|
|
118
|
+
label: sidebarLabel(ep, group.commonPrefix),
|
|
119
|
+
tooltip: sidebarTooltip(ep),
|
|
120
|
+
method: ep.method,
|
|
121
|
+
useMono: !ep.summary,
|
|
122
|
+
isActive: activeEndpointId === anchor,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
key: `${keyPrefix}${group.category}`,
|
|
127
|
+
category: group.category,
|
|
128
|
+
rows,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const emptyTextFor = (query: string, method: MethodFilter, defaultText: string): string => {
|
|
133
|
+
if (query && method !== 'ALL') return `No ${method} endpoints match "${query}"`;
|
|
134
|
+
if (query) return `No endpoints match "${query}"`;
|
|
135
|
+
if (method !== 'ALL') return `No ${method} endpoints`;
|
|
136
|
+
return defaultText;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function buildFlatVM(
|
|
140
|
+
endpoints: ApiEndpoint[],
|
|
141
|
+
selectedVersion: string,
|
|
142
|
+
query: string,
|
|
143
|
+
method: MethodFilter,
|
|
144
|
+
activeEndpointId: string | null,
|
|
145
|
+
): SidebarBodyVM {
|
|
146
|
+
const filtered = filterEndpoints(deduplicateEndpoints(endpoints, selectedVersion), query, method);
|
|
147
|
+
const groups = groupEndpoints(filtered);
|
|
148
|
+
return {
|
|
149
|
+
kind: 'flat',
|
|
150
|
+
categories: groups.map((g) => buildCategory(g, activeEndpointId, null, '')),
|
|
151
|
+
emptyText: emptyTextFor(query, method, 'No endpoints in this schema'),
|
|
152
|
+
};
|
|
67
153
|
}
|
|
68
154
|
|
|
155
|
+
function buildSectionsVM(
|
|
156
|
+
schemas: SchemaSource[],
|
|
157
|
+
endpointsBySchema: Record<string, ApiEndpoint[]>,
|
|
158
|
+
selectedVersion: string,
|
|
159
|
+
query: string,
|
|
160
|
+
method: MethodFilter,
|
|
161
|
+
activeEndpointId: string | null,
|
|
162
|
+
): SidebarBodyVM {
|
|
163
|
+
const filteredMap: Record<string, ApiEndpoint[]> = {};
|
|
164
|
+
for (const src of schemas) {
|
|
165
|
+
const raw = endpointsBySchema[src.id] ?? [];
|
|
166
|
+
filteredMap[src.id] = filterEndpoints(deduplicateEndpoints(raw, selectedVersion), query, method);
|
|
167
|
+
}
|
|
168
|
+
const rawSections = buildSchemaSections(schemas, filteredMap);
|
|
169
|
+
const sections: SchemaSectionVM[] = rawSections
|
|
170
|
+
.filter((s) => s.groups.length > 0)
|
|
171
|
+
.map((s) => ({
|
|
172
|
+
sourceId: s.source.id,
|
|
173
|
+
sourceName: s.source.name,
|
|
174
|
+
categories: s.groups.map((g) => buildCategory(g, activeEndpointId, s.source.id, `${s.source.id}-`)),
|
|
175
|
+
}));
|
|
176
|
+
return {
|
|
177
|
+
kind: 'sections',
|
|
178
|
+
sections,
|
|
179
|
+
emptyText: emptyTextFor(query, method, 'No endpoints in any schema'),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
69
185
|
export function DocsSidebar({
|
|
70
186
|
info,
|
|
71
187
|
endpoints,
|
|
@@ -75,28 +191,42 @@ export function DocsSidebar({
|
|
|
75
191
|
activeEndpointId,
|
|
76
192
|
selectedVersion,
|
|
77
193
|
onNavigate,
|
|
194
|
+
grouping = 'selector',
|
|
195
|
+
endpointsBySchema,
|
|
196
|
+
rawSchema,
|
|
197
|
+
resolvedBaseUrl,
|
|
78
198
|
}: DocsSidebarProps) {
|
|
79
199
|
const [search, setSearch] = useState('');
|
|
80
200
|
const [debounced, setDebounced] = useState('');
|
|
201
|
+
const [methodFilter, setMethodFilter] = useState<MethodFilter>('ALL');
|
|
81
202
|
|
|
82
203
|
useEffect(() => {
|
|
83
204
|
const id = setTimeout(() => setDebounced(search), 120);
|
|
84
205
|
return () => clearTimeout(id);
|
|
85
206
|
}, [search]);
|
|
86
207
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
208
|
+
const body = useMemo<SidebarBodyVM>(() => {
|
|
209
|
+
if (grouping === 'sections') {
|
|
210
|
+
return buildSectionsVM(
|
|
211
|
+
schemas,
|
|
212
|
+
endpointsBySchema ?? {},
|
|
213
|
+
selectedVersion,
|
|
214
|
+
debounced,
|
|
215
|
+
methodFilter,
|
|
216
|
+
activeEndpointId,
|
|
96
217
|
);
|
|
97
218
|
}
|
|
98
|
-
return
|
|
99
|
-
}, [
|
|
219
|
+
return buildFlatVM(endpoints, selectedVersion, debounced, methodFilter, activeEndpointId);
|
|
220
|
+
}, [
|
|
221
|
+
grouping,
|
|
222
|
+
schemas,
|
|
223
|
+
endpointsBySchema,
|
|
224
|
+
endpoints,
|
|
225
|
+
selectedVersion,
|
|
226
|
+
debounced,
|
|
227
|
+
methodFilter,
|
|
228
|
+
activeEndpointId,
|
|
229
|
+
]);
|
|
100
230
|
|
|
101
231
|
const schemaOptions = useMemo(
|
|
102
232
|
() => schemas.map((s) => ({ value: s.id, label: s.name })),
|
|
@@ -104,24 +234,36 @@ export function DocsSidebar({
|
|
|
104
234
|
);
|
|
105
235
|
const hasMultipleSchemas = schemas.length > 1;
|
|
106
236
|
const apiTitle = info?.title ?? 'API Reference';
|
|
237
|
+
const showCombobox = grouping === 'selector' && hasMultipleSchemas;
|
|
238
|
+
const copyReady = rawSchema !== null && rawSchema !== undefined && endpoints.length > 0;
|
|
107
239
|
|
|
108
240
|
return (
|
|
109
|
-
<aside className="flex flex-col min-h-0 border-r bg-muted/10">
|
|
241
|
+
<aside className="flex flex-col h-full min-h-0 border-r bg-muted/10">
|
|
110
242
|
{/* Brand row */}
|
|
111
|
-
<div className="shrink-0 border-b px-
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
{info?.version && (
|
|
116
|
-
<span className="font-mono text-[10px] text-muted-foreground/70 shrink-0">
|
|
117
|
-
v{info.version}
|
|
243
|
+
<div className="shrink-0 border-b px-3 h-12 flex items-center gap-2">
|
|
244
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
245
|
+
<span className="text-[13px] font-semibold text-foreground truncate">
|
|
246
|
+
{apiTitle}
|
|
118
247
|
</span>
|
|
248
|
+
{info?.version && (
|
|
249
|
+
<span className="font-mono text-[10px] text-muted-foreground/70 shrink-0">
|
|
250
|
+
v{info.version}
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
{copyReady && (
|
|
255
|
+
<SchemaCopyMenu
|
|
256
|
+
schema={rawSchema ?? null}
|
|
257
|
+
endpoints={endpoints}
|
|
258
|
+
baseUrl={resolvedBaseUrl}
|
|
259
|
+
variant="icon"
|
|
260
|
+
/>
|
|
119
261
|
)}
|
|
120
262
|
</div>
|
|
121
263
|
|
|
122
264
|
{/* Controls */}
|
|
123
265
|
<div className="shrink-0 border-b px-3 py-3 space-y-2">
|
|
124
|
-
{
|
|
266
|
+
{showCombobox && (
|
|
125
267
|
<Combobox
|
|
126
268
|
options={schemaOptions}
|
|
127
269
|
value={currentSchemaId ?? ''}
|
|
@@ -141,71 +283,157 @@ export function DocsSidebar({
|
|
|
141
283
|
className="pl-8 h-8 text-xs"
|
|
142
284
|
/>
|
|
143
285
|
</div>
|
|
286
|
+
<MethodChips value={methodFilter} onChange={setMethodFilter} />
|
|
144
287
|
</div>
|
|
145
288
|
|
|
146
289
|
<ScrollArea>
|
|
147
|
-
{
|
|
148
|
-
<div className="py-10 px-4 text-center text-xs text-muted-foreground">
|
|
149
|
-
{debounced
|
|
150
|
-
? `No endpoints match "${debounced}"`
|
|
151
|
-
: 'No endpoints in this schema'}
|
|
152
|
-
</div>
|
|
153
|
-
) : (
|
|
154
|
-
<nav className="py-2">
|
|
155
|
-
{filteredGroups.map((group) => (
|
|
156
|
-
<div key={group.category} className="mb-4 last:mb-2">
|
|
157
|
-
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/50 select-none">
|
|
158
|
-
{group.category}
|
|
159
|
-
</div>
|
|
160
|
-
<div>
|
|
161
|
-
{group.endpoints.map((ep) => {
|
|
162
|
-
const anchor = endpointAnchor(ep);
|
|
163
|
-
const isActive = activeEndpointId === anchor;
|
|
164
|
-
const label = sidebarLabel(ep, group.commonPrefix);
|
|
165
|
-
const tooltip = sidebarTooltip(ep);
|
|
166
|
-
// Summary → sans-serif (reads like an outline).
|
|
167
|
-
// Path-tail fallback → mono (reads like code).
|
|
168
|
-
const useMono = !ep.summary;
|
|
169
|
-
return (
|
|
170
|
-
<Tooltip key={`${ep.method}-${ep.path}`} delayDuration={350}>
|
|
171
|
-
<TooltipTrigger asChild>
|
|
172
|
-
<button
|
|
173
|
-
onClick={() => onNavigate(anchor)}
|
|
174
|
-
aria-current={isActive ? 'location' : undefined}
|
|
175
|
-
className={cn(
|
|
176
|
-
'relative group w-full text-left flex items-center gap-2 pl-4 pr-3 py-1.5 transition-colors',
|
|
177
|
-
isActive
|
|
178
|
-
? 'bg-primary/10 text-foreground'
|
|
179
|
-
: 'hover:bg-muted/40 text-foreground/75 hover:text-foreground',
|
|
180
|
-
)}
|
|
181
|
-
>
|
|
182
|
-
{isActive && (
|
|
183
|
-
<span className="absolute left-0 top-1 bottom-1 w-0.5 rounded-r bg-primary" />
|
|
184
|
-
)}
|
|
185
|
-
<MethodBadge method={ep.method} />
|
|
186
|
-
<span
|
|
187
|
-
className={cn(
|
|
188
|
-
'truncate leading-tight flex-1 min-w-0',
|
|
189
|
-
useMono ? 'font-mono text-[11px]' : 'text-[12px]',
|
|
190
|
-
isActive && 'text-foreground font-medium',
|
|
191
|
-
)}
|
|
192
|
-
>
|
|
193
|
-
{label}
|
|
194
|
-
</span>
|
|
195
|
-
</button>
|
|
196
|
-
</TooltipTrigger>
|
|
197
|
-
<TooltipContent side="right" align="center" className="font-mono text-[11px]">
|
|
198
|
-
{tooltip}
|
|
199
|
-
</TooltipContent>
|
|
200
|
-
</Tooltip>
|
|
201
|
-
);
|
|
202
|
-
})}
|
|
203
|
-
</div>
|
|
204
|
-
</div>
|
|
205
|
-
))}
|
|
206
|
-
</nav>
|
|
207
|
-
)}
|
|
290
|
+
<SidebarBody body={body} onNavigate={onNavigate} />
|
|
208
291
|
</ScrollArea>
|
|
209
292
|
</aside>
|
|
210
293
|
);
|
|
211
294
|
}
|
|
295
|
+
|
|
296
|
+
function MethodChips({
|
|
297
|
+
value,
|
|
298
|
+
onChange,
|
|
299
|
+
}: {
|
|
300
|
+
value: MethodFilter;
|
|
301
|
+
onChange: (v: MethodFilter) => void;
|
|
302
|
+
}) {
|
|
303
|
+
return (
|
|
304
|
+
<div className="flex items-center gap-1 flex-wrap">
|
|
305
|
+
{METHOD_FILTERS.map((m) => {
|
|
306
|
+
const active = value === m;
|
|
307
|
+
return (
|
|
308
|
+
<button
|
|
309
|
+
key={m}
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={() => onChange(m)}
|
|
312
|
+
aria-pressed={active}
|
|
313
|
+
className={cn(
|
|
314
|
+
'px-2 py-0.5 rounded font-mono text-[10px] font-semibold tracking-wide transition-colors border',
|
|
315
|
+
active
|
|
316
|
+
? 'bg-primary/15 border-primary/40 text-foreground'
|
|
317
|
+
: 'bg-transparent border-border/40 text-muted-foreground hover:text-foreground hover:border-border',
|
|
318
|
+
)}
|
|
319
|
+
>
|
|
320
|
+
{m}
|
|
321
|
+
</button>
|
|
322
|
+
);
|
|
323
|
+
})}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Rendering primitives ────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
type NavigateFn = (anchor: string, schemaId?: string | null) => void;
|
|
331
|
+
|
|
332
|
+
function SidebarBody({ body, onNavigate }: { body: SidebarBodyVM; onNavigate: NavigateFn }) {
|
|
333
|
+
if (body.kind === 'flat') {
|
|
334
|
+
if (body.categories.length === 0) {
|
|
335
|
+
return (
|
|
336
|
+
<div className="py-10 px-4 text-center text-xs text-muted-foreground">
|
|
337
|
+
{body.emptyText}
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return (
|
|
342
|
+
<nav className="py-2">
|
|
343
|
+
{body.categories.map((cat) => (
|
|
344
|
+
<CategoryBlock key={cat.key} category={cat} onNavigate={onNavigate} />
|
|
345
|
+
))}
|
|
346
|
+
</nav>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (body.sections.length === 0) {
|
|
351
|
+
return (
|
|
352
|
+
<div className="py-10 px-4 text-center text-xs text-muted-foreground">
|
|
353
|
+
{body.emptyText}
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<nav className="py-2">
|
|
360
|
+
{body.sections.map((section) => (
|
|
361
|
+
<div key={section.sourceId} className="mb-5 last:mb-2">
|
|
362
|
+
<div className="px-4 py-2 sticky top-0 z-[1] bg-muted/30 backdrop-blur-[2px] border-b border-border/30">
|
|
363
|
+
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-foreground/80">
|
|
364
|
+
{section.sourceName}
|
|
365
|
+
</span>
|
|
366
|
+
</div>
|
|
367
|
+
{section.categories.map((cat) => (
|
|
368
|
+
<CategoryBlock key={cat.key} category={cat} onNavigate={onNavigate} />
|
|
369
|
+
))}
|
|
370
|
+
</div>
|
|
371
|
+
))}
|
|
372
|
+
</nav>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const CategoryBlock = React.memo(function CategoryBlock({
|
|
377
|
+
category,
|
|
378
|
+
onNavigate,
|
|
379
|
+
}: {
|
|
380
|
+
category: CategoryVM;
|
|
381
|
+
onNavigate: NavigateFn;
|
|
382
|
+
}) {
|
|
383
|
+
return (
|
|
384
|
+
<div className="mb-4 last:mb-2">
|
|
385
|
+
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/50 select-none">
|
|
386
|
+
{category.category}
|
|
387
|
+
</div>
|
|
388
|
+
<div>
|
|
389
|
+
{category.rows.map((row) => (
|
|
390
|
+
<EndpointRow key={row.key} row={row} onNavigate={onNavigate} />
|
|
391
|
+
))}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const EndpointRow = React.memo(function EndpointRow({
|
|
398
|
+
row,
|
|
399
|
+
onNavigate,
|
|
400
|
+
}: {
|
|
401
|
+
row: EndpointRowVM;
|
|
402
|
+
onNavigate: NavigateFn;
|
|
403
|
+
}) {
|
|
404
|
+
return (
|
|
405
|
+
<Tooltip delayDuration={350}>
|
|
406
|
+
<TooltipTrigger asChild>
|
|
407
|
+
<button
|
|
408
|
+
onClick={() => onNavigate(row.anchor, row.schemaId)}
|
|
409
|
+
aria-current={row.isActive ? 'location' : undefined}
|
|
410
|
+
className={cn(
|
|
411
|
+
'relative group w-full text-left flex items-start gap-2 pl-4 pr-3 py-1.5 transition-colors',
|
|
412
|
+
row.isActive
|
|
413
|
+
? 'bg-primary/10 text-foreground'
|
|
414
|
+
: 'hover:bg-muted/40 text-foreground/75 hover:text-foreground',
|
|
415
|
+
)}
|
|
416
|
+
>
|
|
417
|
+
{row.isActive && (
|
|
418
|
+
<span className="absolute left-0 top-1 bottom-1 w-0.5 rounded-r bg-primary" />
|
|
419
|
+
)}
|
|
420
|
+
<span className="shrink-0 mt-[1px]">
|
|
421
|
+
<MethodBadge method={row.method} />
|
|
422
|
+
</span>
|
|
423
|
+
<span
|
|
424
|
+
className={cn(
|
|
425
|
+
'line-clamp-2 leading-snug flex-1 min-w-0',
|
|
426
|
+
row.useMono ? 'font-mono text-[11px] break-all' : 'text-[12px]',
|
|
427
|
+
row.isActive && 'text-foreground font-medium',
|
|
428
|
+
)}
|
|
429
|
+
>
|
|
430
|
+
{row.label}
|
|
431
|
+
</span>
|
|
432
|
+
</button>
|
|
433
|
+
</TooltipTrigger>
|
|
434
|
+
<TooltipContent side="right" align="center" className="font-mono text-[11px]">
|
|
435
|
+
{row.tooltip}
|
|
436
|
+
</TooltipContent>
|
|
437
|
+
</Tooltip>
|
|
438
|
+
);
|
|
439
|
+
});
|
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
import type { ApiEndpoint } from '../../types';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** DOM-safe anchor for a single endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Two forms:
|
|
6
|
+
* - Scoped (``schemaId`` provided) — ``ep-<schema>-<method>-<slug>``.
|
|
7
|
+
* Used in ``sections`` mode where endpoints from different schemas
|
|
8
|
+
* coexist on one page and would otherwise collide.
|
|
9
|
+
* - Flat — ``ep-<method>-<slug>``. Used in ``selector`` mode where
|
|
10
|
+
* only one schema is mounted at a time. */
|
|
11
|
+
export function endpointAnchor(
|
|
12
|
+
ep: Pick<ApiEndpoint, 'method' | 'path'>,
|
|
13
|
+
schemaId?: string | null,
|
|
14
|
+
): string {
|
|
4
15
|
const slug = ep.path
|
|
5
16
|
.replace(/^https?:\/\/[^/]+/, '')
|
|
6
17
|
.replace(/[{}]/g, '')
|
|
7
18
|
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
8
19
|
.replace(/^-+|-+$/g, '')
|
|
9
20
|
.toLowerCase();
|
|
10
|
-
|
|
21
|
+
const schemaSlug = schemaId ? `${slugifySchemaId(schemaId)}-` : '';
|
|
22
|
+
return `ep-${schemaSlug}${ep.method.toLowerCase()}-${slug}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Canonical slug for a schema id — safe for anchors and hash fragments. */
|
|
26
|
+
export function slugifySchemaId(id: string): string {
|
|
27
|
+
return id.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
11
28
|
}
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* This module is the single source of truth for that ordering.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import { groupBy, orderBy, partition, sortBy } from 'lodash-es';
|
|
11
|
+
|
|
12
|
+
import type { ApiEndpoint, SchemaSource } from '../../types';
|
|
11
13
|
import { longestCommonPrefix } from './sidebarLabel';
|
|
12
14
|
|
|
13
15
|
export type EndpointGroup = {
|
|
@@ -19,6 +21,13 @@ export type EndpointGroup = {
|
|
|
19
21
|
commonPrefix: string;
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
/** A schema's worth of categorised endpoints. The outer level of the
|
|
25
|
+
* ``sections`` sidebar iterates over these. */
|
|
26
|
+
export type SchemaSection = {
|
|
27
|
+
source: SchemaSource;
|
|
28
|
+
groups: EndpointGroup[];
|
|
29
|
+
};
|
|
30
|
+
|
|
22
31
|
const METHOD_ORDER: Record<string, number> = {
|
|
23
32
|
GET: 0,
|
|
24
33
|
POST: 1,
|
|
@@ -27,6 +36,8 @@ const METHOD_ORDER: Record<string, number> = {
|
|
|
27
36
|
DELETE: 4,
|
|
28
37
|
};
|
|
29
38
|
|
|
39
|
+
const methodRank = (ep: ApiEndpoint) => METHOD_ORDER[ep.method] ?? 99;
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* Stable, deterministic ordering so two different renders with the
|
|
32
43
|
* same endpoint list always produce the same visual sequence.
|
|
@@ -38,29 +49,15 @@ const METHOD_ORDER: Record<string, number> = {
|
|
|
38
49
|
* cluster), then by HTTP method (read → write → delete).
|
|
39
50
|
*/
|
|
40
51
|
export function groupEndpoints(list: ApiEndpoint[]): EndpointGroup[] {
|
|
41
|
-
const
|
|
42
|
-
|
|
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]) => ({
|
|
52
|
+
const byCategory = groupBy(list, 'category');
|
|
53
|
+
const all: EndpointGroup[] = Object.entries(byCategory).map(([category, endpoints]) => ({
|
|
49
54
|
category,
|
|
50
|
-
endpoints: [
|
|
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
|
+
endpoints: orderBy(endpoints, ['path', methodRank], ['asc', 'asc']),
|
|
55
56
|
commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
|
|
56
57
|
}));
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (b.category === 'Other') return -1;
|
|
61
|
-
return a.category.localeCompare(b.category);
|
|
62
|
-
});
|
|
63
|
-
return groups;
|
|
58
|
+
// "Other" sinks to the bottom regardless of alphabet.
|
|
59
|
+
const [other, named] = partition(all, (g) => g.category === 'Other');
|
|
60
|
+
return [...sortBy(named, (g) => g.category.toLowerCase()), ...other];
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
/** Flatten grouped endpoints back into a linear list that preserves
|
|
@@ -69,3 +66,23 @@ export function groupEndpoints(list: ApiEndpoint[]): EndpointGroup[] {
|
|
|
69
66
|
export function flattenGrouped(groups: EndpointGroup[]): ApiEndpoint[] {
|
|
70
67
|
return groups.flatMap((g) => g.endpoints);
|
|
71
68
|
}
|
|
69
|
+
|
|
70
|
+
/** Build per-schema sections in the same order as the original
|
|
71
|
+
* ``schemas`` array. Schemas with zero endpoints are kept so users see
|
|
72
|
+
* an empty-state placeholder instead of "the section silently vanished". */
|
|
73
|
+
export function buildSchemaSections(
|
|
74
|
+
sources: SchemaSource[],
|
|
75
|
+
endpointsBySchema: Record<string, ApiEndpoint[]>,
|
|
76
|
+
): SchemaSection[] {
|
|
77
|
+
return sources.map((source) => ({
|
|
78
|
+
source,
|
|
79
|
+
groups: groupEndpoints(endpointsBySchema[source.id] ?? []),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Flatten schema-sections into a linear endpoint list. Used by scrollspy
|
|
84
|
+
* and by the docs longread to render endpoints in the exact same order
|
|
85
|
+
* as the sidebar. */
|
|
86
|
+
export function flattenSchemaSections(sections: SchemaSection[]): ApiEndpoint[] {
|
|
87
|
+
return sections.flatMap((s) => flattenGrouped(s.groups));
|
|
88
|
+
}
|