@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.
Files changed (34) hide show
  1. package/dist/{DocsLayout-ERETJLLV.mjs → DocsLayout-TKJQ5W5E.mjs} +848 -266
  2. package/dist/DocsLayout-TKJQ5W5E.mjs.map +1 -0
  3. package/dist/{DocsLayout-BCVU6TTX.cjs → DocsLayout-YDR7DSMM.cjs} +843 -261
  4. package/dist/DocsLayout-YDR7DSMM.cjs.map +1 -0
  5. package/dist/index.cjs +2 -2
  6. package/dist/index.d.cts +16 -0
  7. package/dist/index.d.ts +16 -0
  8. package/dist/index.mjs +2 -2
  9. package/package.json +9 -6
  10. package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +2 -0
  11. package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
  12. package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
  13. package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
  14. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
  15. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
  16. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
  17. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
  18. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
  19. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +2 -2
  20. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +27 -0
  21. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +326 -54
  22. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +7 -2
  23. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +32 -9
  24. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +348 -120
  25. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +19 -2
  26. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
  27. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
  28. package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
  29. package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
  30. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +127 -7
  31. package/src/tools/OpenapiViewer/types.ts +36 -1
  32. package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
  33. package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
  34. 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 { longestCommonPrefix, sidebarLabel, sidebarTooltip } from './sidebarLabel';
19
+ import { buildSchemaSections, groupEndpoints, type EndpointGroup } from './grouping';
20
+ import { SchemaCopyMenu } from './SchemaCopyMenu';
21
+ import { sidebarLabel, sidebarTooltip } from './sidebarLabel';
20
22
 
21
- type Group = {
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 filteredGroups = useMemo(() => {
88
- let list = deduplicateEndpoints(endpoints, selectedVersion);
89
- if (debounced) {
90
- const q = debounced.toLowerCase();
91
- list = list.filter((e) =>
92
- e.summary.toLowerCase().includes(q) ||
93
- e.name.toLowerCase().includes(q) ||
94
- e.description.toLowerCase().includes(q) ||
95
- e.path.toLowerCase().includes(q),
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 groupEndpoints(list);
99
- }, [endpoints, debounced, selectedVersion]);
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-4 h-12 flex items-center gap-2">
112
- <span className="text-[13px] font-semibold text-foreground truncate">
113
- {apiTitle}
114
- </span>
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
- {hasMultipleSchemas && (
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
- {filteredGroups.length === 0 ? (
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
- export function endpointAnchor(ep: Pick<ApiEndpoint, 'method' | 'path'>): string {
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
- return `ep-${ep.method.toLowerCase()}-${slug}`;
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 type { ApiEndpoint } from '../../types';
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 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]) => ({
52
+ const byCategory = groupBy(list, 'category');
53
+ const all: EndpointGroup[] = Object.entries(byCategory).map(([category, endpoints]) => ({
49
54
  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
+ endpoints: orderBy(endpoints, ['path', methodRank], ['asc', 'asc']),
55
56
  commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)),
56
57
  }));
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;
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
+ }