@djangocfg/ui-tools 2.1.287 → 2.1.290

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +14 -3
  2. package/dist/DocsLayout-IKH7BLSU.cjs +3464 -0
  3. package/dist/DocsLayout-IKH7BLSU.cjs.map +1 -0
  4. package/dist/DocsLayout-JPXFUKAR.mjs +3457 -0
  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 +34 -0
  17. package/dist/index.d.ts +34 -0
  18. package/dist/index.mjs +5 -5
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +21 -14
  21. package/src/components/markdown/MarkdownMessage.tsx +46 -0
  22. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +93 -157
  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 +331 -53
  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 +40 -11
  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/DocsLayout/anchor.ts +19 -2
  70. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
  71. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
  72. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +55 -0
  73. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PreviewView.tsx +115 -0
  74. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/RawView.tsx +24 -0
  75. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/StatusBar.tsx +63 -0
  76. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/ViewTabs.tsx +45 -0
  77. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/detectContent.ts +97 -0
  78. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/index.tsx +93 -0
  79. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/types.ts +26 -0
  80. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/useResponseView.ts +62 -0
  81. package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
  82. package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
  83. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +164 -74
  84. package/src/tools/OpenapiViewer/types.ts +46 -1
  85. package/src/tools/OpenapiViewer/utils/codeSamples.ts +287 -0
  86. package/src/tools/OpenapiViewer/utils/index.ts +3 -0
  87. package/src/tools/OpenapiViewer/utils/operationToHar.ts +119 -0
  88. package/src/tools/OpenapiViewer/utils/sampler.ts +72 -0
  89. package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
  90. package/src/tools/PrettyCode/PrettyCode.client.tsx +88 -1
  91. package/src/tools/PrettyCode/PrettyCode.story.tsx +114 -361
  92. package/src/tools/PrettyCode/index.tsx +13 -0
  93. package/src/tools/PrettyCode/lazy.tsx +5 -0
  94. package/src/tools/PrettyCode/registerPrismLanguages.ts +111 -0
  95. package/dist/DocsLayout-BCVU6TTX.cjs +0 -2027
  96. package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
  97. package/dist/DocsLayout-ERETJLLV.mjs +0 -2020
  98. package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
  99. package/dist/PrettyCode.client-5GABIN2I.cjs.map +0 -1
  100. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +0 -1
  101. package/dist/chunk-IULI4XII.cjs.map +0 -1
  102. package/dist/chunk-VZGQC3NG.mjs.map +0 -1
  103. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +0 -268
  104. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -211
  105. package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { Combobox } from '@djangocfg/ui-core/components';
6
+
7
+ import type { SchemaSource } from '../../../types';
8
+ import { MethodChips } from './MethodChips';
9
+ import { SearchInput } from './SearchInput';
10
+ import type { MethodFilter } from './types';
11
+
12
+ interface ToolbarProps {
13
+ schemas: SchemaSource[];
14
+ currentSchemaId: string | null;
15
+ onSchemaChange: (id: string) => void;
16
+ showSchemaSelector: boolean;
17
+
18
+ search: string;
19
+ onSearchChange: (v: string) => void;
20
+
21
+ methodFilter: MethodFilter;
22
+ onMethodFilterChange: (v: MethodFilter) => void;
23
+ }
24
+
25
+ /** Filter / control panel of the sidebar. Groups the schema selector,
26
+ * search box, and HTTP method chips into a single visually cohesive
27
+ * block so they read as "one toolbar" rather than three separate
28
+ * affordances stacked on top of each other.
29
+ *
30
+ * Each row is optional in the sense that we only render the schema
31
+ * selector when there are multiple schemas to choose between. */
32
+ export function Toolbar({
33
+ schemas,
34
+ currentSchemaId,
35
+ onSchemaChange,
36
+ showSchemaSelector,
37
+ search,
38
+ onSearchChange,
39
+ methodFilter,
40
+ onMethodFilterChange,
41
+ }: ToolbarProps) {
42
+ const schemaOptions = React.useMemo(
43
+ () => schemas.map((s) => ({ value: s.id, label: s.name })),
44
+ [schemas],
45
+ );
46
+
47
+ return (
48
+ <div className="shrink-0 border-b px-3 py-2.5 space-y-2">
49
+ {showSchemaSelector && (
50
+ <Combobox
51
+ options={schemaOptions}
52
+ value={currentSchemaId ?? ''}
53
+ onValueChange={(id) => id && onSchemaChange(id)}
54
+ placeholder="Select API"
55
+ searchPlaceholder="Search APIs…"
56
+ emptyText="No APIs found"
57
+ className="w-full h-8 text-xs"
58
+ />
59
+ )}
60
+ <SearchInput value={search} onChange={onSearchChange} />
61
+ <MethodChips value={methodFilter} onChange={onMethodFilterChange} />
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,126 @@
1
+ import type { ApiEndpoint, OpenApiSchema, SchemaSource } from '../../../types';
2
+ import { deduplicateEndpoints } from '../../../utils/versionManager';
3
+ import { endpointAnchor } from '../anchor';
4
+ import { buildSchemaSections, groupEndpoints, type EndpointGroup } from '../grouping';
5
+ import { sidebarLabel, sidebarTooltip } from '../sidebarLabel';
6
+ import type {
7
+ CategoryVM,
8
+ EndpointRowVM,
9
+ MethodFilter,
10
+ SchemaSectionVM,
11
+ SidebarBodyVM,
12
+ } from './types';
13
+
14
+ // ``OpenApiSchema`` is imported to satisfy the build VM signatures even
15
+ // though we never read it here — re-exported so consumers can reuse
16
+ // the same type surface without another import.
17
+ export type { OpenApiSchema };
18
+
19
+ export function filterEndpoints(
20
+ list: ApiEndpoint[],
21
+ query: string,
22
+ method: MethodFilter,
23
+ ): ApiEndpoint[] {
24
+ let out = list;
25
+ if (method !== 'ALL') {
26
+ out = out.filter((e) => e.method === method);
27
+ }
28
+ if (query) {
29
+ const q = query.toLowerCase();
30
+ out = out.filter(
31
+ (e) =>
32
+ e.summary.toLowerCase().includes(q) ||
33
+ e.name.toLowerCase().includes(q) ||
34
+ e.description.toLowerCase().includes(q) ||
35
+ e.path.toLowerCase().includes(q),
36
+ );
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function buildCategory(
42
+ group: EndpointGroup,
43
+ activeEndpointId: string | null,
44
+ schemaId: string | null,
45
+ keyPrefix: string,
46
+ ): CategoryVM {
47
+ const rows: EndpointRowVM[] = group.endpoints.map((ep) => {
48
+ const anchor = endpointAnchor(ep, schemaId ?? ep.schemaId ?? null);
49
+ return {
50
+ key: `${ep.method}-${ep.path}`,
51
+ anchor,
52
+ schemaId: schemaId ?? ep.schemaId ?? null,
53
+ label: sidebarLabel(ep, group.commonPrefix),
54
+ tooltip: sidebarTooltip(ep),
55
+ method: ep.method,
56
+ useMono: !ep.summary,
57
+ isActive: activeEndpointId === anchor,
58
+ };
59
+ });
60
+ return {
61
+ key: `${keyPrefix}${group.category}`,
62
+ category: group.category,
63
+ rows,
64
+ };
65
+ }
66
+
67
+ function emptyTextFor(query: string, method: MethodFilter, defaultText: string): string {
68
+ if (query && method !== 'ALL') return `No ${method} endpoints match "${query}"`;
69
+ if (query) return `No endpoints match "${query}"`;
70
+ if (method !== 'ALL') return `No ${method} endpoints`;
71
+ return defaultText;
72
+ }
73
+
74
+ export function buildFlatVM(
75
+ endpoints: ApiEndpoint[],
76
+ selectedVersion: string,
77
+ query: string,
78
+ method: MethodFilter,
79
+ activeEndpointId: string | null,
80
+ ): SidebarBodyVM {
81
+ const filtered = filterEndpoints(
82
+ deduplicateEndpoints(endpoints, selectedVersion),
83
+ query,
84
+ method,
85
+ );
86
+ const groups = groupEndpoints(filtered);
87
+ return {
88
+ kind: 'flat',
89
+ categories: groups.map((g) => buildCategory(g, activeEndpointId, null, '')),
90
+ emptyText: emptyTextFor(query, method, 'No endpoints in this schema'),
91
+ };
92
+ }
93
+
94
+ export function buildSectionsVM(
95
+ schemas: SchemaSource[],
96
+ endpointsBySchema: Record<string, ApiEndpoint[]>,
97
+ selectedVersion: string,
98
+ query: string,
99
+ method: MethodFilter,
100
+ activeEndpointId: string | null,
101
+ ): SidebarBodyVM {
102
+ const filteredMap: Record<string, ApiEndpoint[]> = {};
103
+ for (const src of schemas) {
104
+ const raw = endpointsBySchema[src.id] ?? [];
105
+ filteredMap[src.id] = filterEndpoints(
106
+ deduplicateEndpoints(raw, selectedVersion),
107
+ query,
108
+ method,
109
+ );
110
+ }
111
+ const rawSections = buildSchemaSections(schemas, filteredMap);
112
+ const sections: SchemaSectionVM[] = rawSections
113
+ .filter((s) => s.groups.length > 0)
114
+ .map((s) => ({
115
+ sourceId: s.source.id,
116
+ sourceName: s.source.name,
117
+ categories: s.groups.map((g) =>
118
+ buildCategory(g, activeEndpointId, s.source.id, `${s.source.id}-`),
119
+ ),
120
+ }));
121
+ return {
122
+ kind: 'sections',
123
+ sections,
124
+ emptyText: emptyTextFor(query, method, 'No endpoints in any schema'),
125
+ };
126
+ }
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import React, { useMemo, useState } from 'react';
4
+
5
+ import type { ApiEndpoint, OpenApiInfo, OpenApiSchema, SchemaSource } from '../../../types';
6
+ import { ScrollArea } from '../../shared/ui';
7
+ import { BrandHeader } from './BrandHeader';
8
+ import { buildFlatVM, buildSectionsVM } from './buildVM';
9
+ import { SidebarBody } from './SidebarBody';
10
+ import { Toolbar } from './Toolbar';
11
+ import type { MethodFilter, SidebarBodyVM } from './types';
12
+ import { useDebouncedValue } from './useDebouncedValue';
13
+
14
+ export interface DocsSidebarProps {
15
+ info: OpenApiInfo | null;
16
+ /** Active-schema endpoints — used by ``selector`` mode. */
17
+ endpoints: ApiEndpoint[];
18
+ /** All configured schemas (used by both modes). */
19
+ schemas: SchemaSource[];
20
+ currentSchemaId: string | null;
21
+ onSchemaChange: (id: string) => void;
22
+ activeEndpointId: string | null;
23
+ selectedVersion: string;
24
+ onNavigate: (anchor: string, schemaId?: string | null) => void;
25
+ /** Presentation mode. Default: ``selector`` (back-compat). */
26
+ grouping?: 'selector' | 'sections';
27
+ /** Required for ``sections`` mode — endpoints grouped by their source
28
+ * schema id. The sidebar renders them as two-level sections. */
29
+ endpointsBySchema?: Record<string, ApiEndpoint[]>;
30
+ /** Raw active schema + resolved base URL — used by the Copy-for-AI
31
+ * dropdown in the brand row. ``null`` disables the button. */
32
+ rawSchema?: OpenApiSchema | null;
33
+ resolvedBaseUrl?: string;
34
+ }
35
+
36
+ /** Docs sidebar orchestrator. Thin — each child folder owns its own
37
+ * rendering; this file wires props, debounces search, and decides
38
+ * which view-model builder to call. */
39
+ export function DocsSidebar({
40
+ info,
41
+ endpoints,
42
+ schemas,
43
+ currentSchemaId,
44
+ onSchemaChange,
45
+ activeEndpointId,
46
+ selectedVersion,
47
+ onNavigate,
48
+ grouping = 'selector',
49
+ endpointsBySchema,
50
+ rawSchema,
51
+ resolvedBaseUrl,
52
+ }: DocsSidebarProps) {
53
+ const [search, setSearch] = useState('');
54
+ const [methodFilter, setMethodFilter] = useState<MethodFilter>('ALL');
55
+ const debouncedSearch = useDebouncedValue(search);
56
+
57
+ const body = useMemo<SidebarBodyVM>(() => {
58
+ if (grouping === 'sections') {
59
+ return buildSectionsVM(
60
+ schemas,
61
+ endpointsBySchema ?? {},
62
+ selectedVersion,
63
+ debouncedSearch,
64
+ methodFilter,
65
+ activeEndpointId,
66
+ );
67
+ }
68
+ return buildFlatVM(
69
+ endpoints,
70
+ selectedVersion,
71
+ debouncedSearch,
72
+ methodFilter,
73
+ activeEndpointId,
74
+ );
75
+ }, [
76
+ grouping,
77
+ schemas,
78
+ endpointsBySchema,
79
+ endpoints,
80
+ selectedVersion,
81
+ debouncedSearch,
82
+ methodFilter,
83
+ activeEndpointId,
84
+ ]);
85
+
86
+ const hasMultipleSchemas = schemas.length > 1;
87
+ const showSchemaSelector = grouping === 'selector' && hasMultipleSchemas;
88
+
89
+ return (
90
+ <aside className="flex flex-col h-full min-h-0 border-r bg-muted/10">
91
+ <BrandHeader
92
+ info={info}
93
+ endpoints={endpoints}
94
+ rawSchema={rawSchema}
95
+ resolvedBaseUrl={resolvedBaseUrl}
96
+ />
97
+ <Toolbar
98
+ schemas={schemas}
99
+ currentSchemaId={currentSchemaId}
100
+ onSchemaChange={onSchemaChange}
101
+ showSchemaSelector={showSchemaSelector}
102
+ search={search}
103
+ onSearchChange={setSearch}
104
+ methodFilter={methodFilter}
105
+ onMethodFilterChange={setMethodFilter}
106
+ />
107
+ <ScrollArea>
108
+ <SidebarBody body={body} onNavigate={onNavigate} />
109
+ </ScrollArea>
110
+ </aside>
111
+ );
112
+ }
@@ -0,0 +1,42 @@
1
+ import type { ApiEndpoint } from '../../../types';
2
+
3
+ /** HTTP method filter. ``ALL`` is a sentinel for no filter — using a
4
+ * string union (rather than ``string | null``) keeps the filter chips
5
+ * and reducer logic type-safe without null juggling. */
6
+ export const METHOD_FILTERS = ['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
7
+ export type MethodFilter = (typeof METHOD_FILTERS)[number];
8
+
9
+ export interface EndpointRowVM {
10
+ key: string;
11
+ anchor: string;
12
+ schemaId: string | null;
13
+ label: string;
14
+ tooltip: string;
15
+ method: string;
16
+ /** Summary-less endpoints get a monospace font for the path-tail
17
+ * label so the tech string is readable against a sea of prose. */
18
+ useMono: boolean;
19
+ isActive: boolean;
20
+ }
21
+
22
+ export interface CategoryVM {
23
+ key: string;
24
+ category: string;
25
+ rows: EndpointRowVM[];
26
+ }
27
+
28
+ export interface SchemaSectionVM {
29
+ sourceId: string;
30
+ sourceName: string;
31
+ categories: CategoryVM[];
32
+ }
33
+
34
+ export type SidebarBodyVM =
35
+ | { kind: 'flat'; categories: CategoryVM[]; emptyText: string }
36
+ | { kind: 'sections'; sections: SchemaSectionVM[]; emptyText: string };
37
+
38
+ export type NavigateFn = (anchor: string, schemaId?: string | null) => void;
39
+
40
+ // Re-exported here so downstream files don't all re-import from the
41
+ // outer ``types`` module for every component prop.
42
+ export type { ApiEndpoint };
@@ -0,0 +1,14 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /** Debounce a fast-changing value. Used for the sidebar search box so
4
+ * each keystroke doesn't re-run the filter pipeline (endpoint list
5
+ * can be hundreds of items long in some specs). 120ms is short
6
+ * enough to feel instant while still batching rapid typing. */
7
+ export function useDebouncedValue<T>(value: T, delayMs = 120): T {
8
+ const [debounced, setDebounced] = useState(value);
9
+ useEffect(() => {
10
+ const id = setTimeout(() => setDebounced(value), delayMs);
11
+ return () => clearTimeout(id);
12
+ }, [value, delayMs]);
13
+ return debounced;
14
+ }
@@ -71,7 +71,11 @@ export function SlideInPlayground({ open, onClose }: SlideInPlaygroundProps) {
71
71
 
72
72
  {/* Body: 1 or 2 columns depending on whether we have a
73
73
  response to show. ``divide-x`` gives a 1px seam
74
- between the panes so they read as distinct surfaces. */}
74
+ between the panes so they read as distinct surfaces.
75
+ Send button lives inside the Request column so it
76
+ reads as an action on the form it belongs to — not
77
+ as a page-level footer that ambiguously sat under
78
+ the response column too. */}
75
79
  <SidePanel.Body
76
80
  className={cn(
77
81
  'overflow-hidden grid divide-x transition-[grid-template-columns] duration-250',
@@ -82,6 +86,11 @@ export function SlideInPlayground({ open, onClose }: SlideInPlaygroundProps) {
82
86
  >
83
87
  <Panel>
84
88
  <RequestPanel />
89
+ {ep && (
90
+ <div className="shrink-0 border-t px-4 py-3 bg-background">
91
+ <SendButton />
92
+ </div>
93
+ )}
85
94
  </Panel>
86
95
  {showResponse && (
87
96
  <Panel>
@@ -89,12 +98,6 @@ export function SlideInPlayground({ open, onClose }: SlideInPlaygroundProps) {
89
98
  </Panel>
90
99
  )}
91
100
  </SidePanel.Body>
92
-
93
- {ep && (
94
- <SidePanel.Footer className="px-4 py-3">
95
- <SendButton />
96
- </SidePanel.Footer>
97
- )}
98
101
  </SidePanel.Content>
99
102
  </SidePanel>
100
103
  );
@@ -36,9 +36,17 @@ export function TryItSheet({ open, onOpenChange }: TryItSheetProps) {
36
36
  <ResponsiveSheetHeader className="px-4 py-3 border-b shrink-0">
37
37
  <ResponsiveSheetTitle className="text-sm">Playground</ResponsiveSheetTitle>
38
38
  </ResponsiveSheetHeader>
39
+ {/* Send button sits inside the Request section so it
40
+ reads as an action on the form, not an ambiguous
41
+ page-level footer when Response is also visible. */}
39
42
  <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'}>
43
+ <div className="flex-1 min-h-0 flex flex-col">
41
44
  <RequestPanel />
45
+ {state.selectedEndpoint && (
46
+ <div className="shrink-0 border-t px-4 py-3 bg-background">
47
+ <SendButton />
48
+ </div>
49
+ )}
42
50
  </div>
43
51
  {showResponse && (
44
52
  <div className="flex-1 min-h-0 flex flex-col">
@@ -46,11 +54,6 @@ export function TryItSheet({ open, onOpenChange }: TryItSheetProps) {
46
54
  </div>
47
55
  )}
48
56
  </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
57
  </ResponsiveSheetContent>
55
58
  </ResponsiveSheet>
56
59
  );
@@ -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
+ }