@djangocfg/ui-tools 2.1.285 → 2.1.287

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 (79) 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 +6 -0
  22. package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
  23. package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
  24. package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
  25. package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
  26. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
  27. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
  28. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
  29. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
  30. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
  31. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
  32. package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
  33. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
  34. package/src/tools/OpenapiViewer/README.md +104 -51
  35. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
  47. package/src/tools/OpenapiViewer/components/index.ts +5 -2
  48. package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
  49. package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
  50. package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
  51. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
  52. package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
  53. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
  54. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
  55. package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
  56. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
  57. package/src/tools/OpenapiViewer/index.tsx +3 -7
  58. package/src/tools/OpenapiViewer/lazy.tsx +6 -27
  59. package/src/tools/OpenapiViewer/types.ts +44 -0
  60. package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
  61. package/src/tools/OpenapiViewer/utils/index.ts +3 -1
  62. package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
  63. package/src/tools/OpenapiViewer/utils/url.ts +202 -0
  64. package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
  65. package/src/tools/PrettyCode/index.tsx +6 -0
  66. package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
  67. package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
  68. package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
  69. package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
  70. package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
  71. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
  72. package/dist/chunk-5FKE7OME.cjs +0 -369
  73. package/dist/chunk-5FKE7OME.cjs.map +0 -1
  74. package/dist/chunk-BKWDHJKF.mjs +0 -356
  75. package/dist/chunk-BKWDHJKF.mjs.map +0 -1
  76. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
  77. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
  78. /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
  79. /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
@@ -19,6 +19,9 @@ export interface ApiEndpoint {
19
19
  name: string;
20
20
  method: string;
21
21
  path: string;
22
+ /** Short human label from OpenAPI ``operation.summary``. Empty when
23
+ * the spec provides none. Prefer this for sidebar rows and breadcrumbs. */
24
+ summary: string;
22
25
  description: string;
23
26
  category: string;
24
27
  parameters?: Array<{
@@ -30,6 +33,12 @@ export interface ApiEndpoint {
30
33
  requestBody?: {
31
34
  type: string;
32
35
  description?: string;
36
+ /** Dereferenced JSON Schema for the body. Kept so the docs layout
37
+ * can render a fields table and the playground can seed the body
38
+ * editor with a generated example. */
39
+ schema?: Record<string, unknown>;
40
+ /** Pre-generated example JSON ready to drop into a textarea. */
41
+ example?: string;
33
42
  };
34
43
  responses?: Array<{
35
44
  code: string;
@@ -59,12 +68,23 @@ export interface SchemaSource {
59
68
  id: string;
60
69
  name: string;
61
70
  url: string; // URL to fetch OpenAPI schema from (full URL)
71
+ /** Per-schema override for the request base URL. Wins over the
72
+ * global ``PlaygroundConfig.baseUrl`` and over ``schema.servers[0].url``
73
+ * from the OpenAPI document. Use when a single playground hosts
74
+ * several APIs that live on different domains. */
75
+ baseUrl?: string;
62
76
  }
63
77
 
64
78
  // Playground configuration
65
79
  export interface PlaygroundConfig {
66
80
  schemas: SchemaSource[]; // Array of schema URLs (full URLs from API)
67
81
  defaultSchemaId?: string; // Default schema to select
82
+ /** Global override for the request base URL. Used when the OpenAPI
83
+ * document has no ``servers`` entry, or when docs are hosted on a
84
+ * different origin than the API. Resolution order (highest wins):
85
+ * ``SchemaSource.baseUrl`` → ``PlaygroundConfig.baseUrl`` →
86
+ * ``schema.servers[0].url`` → empty string (relative paths). */
87
+ baseUrl?: string;
68
88
  /** Optional API keys the user can pick from in the request panel.
69
89
  * When provided, the playground auto-selects the first one and
70
90
  * syncs the ``X-API-Key`` header from ``ApiKey.secret``. Pass
@@ -101,6 +121,11 @@ export interface PlaygroundState {
101
121
 
102
122
  // UI state
103
123
  sidebarOpen: boolean;
124
+
125
+ /** Id of the schema the viewer is currently on. Drives per-endpoint
126
+ * draft scoping in localStorage and is pushed in from the layout that
127
+ * owns the ``useOpenApiSchema`` hook. ``null`` before first load. */
128
+ activeSchemaId: string | null;
104
129
  }
105
130
 
106
131
  export type PlaygroundStep = 'endpoints' | 'request' | 'response';
@@ -148,12 +173,23 @@ export interface PlaygroundContextType {
148
173
 
149
174
  // UI management
150
175
  setSidebarOpen: (open: boolean) => void;
176
+ setActiveSchemaId: (id: string | null) => void;
151
177
 
152
178
  // Actions
153
179
  clearAll: () => void;
154
180
  sendRequest: () => Promise<void>;
155
181
  }
156
182
 
183
+ // Subset of OpenAPI ``info`` surfaced to consumers — lets the docs
184
+ // layout render an intro section (title, version, description) without
185
+ // re-parsing the schema. ``servers`` piggy-backs here for the same reason.
186
+ export interface OpenApiInfo {
187
+ title: string;
188
+ version: string;
189
+ description?: string;
190
+ servers?: Array<{ url: string; description?: string }>;
191
+ }
192
+
157
193
  // Hook return types
158
194
  export interface UseOpenApiSchemaReturn {
159
195
  loading: boolean;
@@ -162,6 +198,14 @@ export interface UseOpenApiSchemaReturn {
162
198
  categories: string[];
163
199
  schemas: SchemaSource[];
164
200
  currentSchema: SchemaSource | null;
201
+ /** Parsed ``info`` from the active schema. ``null`` while loading. */
202
+ schemaInfo: OpenApiInfo | null;
203
+ /** Raw parsed OpenAPI document — exposed so consumers can serialise it
204
+ * (Copy-for-AI) without re-fetching. ``null`` while loading. */
205
+ rawSchema: OpenApiSchema | null;
206
+ /** Base URL used for endpoint paths, after applying priority chain
207
+ * (SchemaSource.baseUrl → config.baseUrl → schema.servers[0].url). */
208
+ resolvedBaseUrl: string | undefined;
165
209
  setCurrentSchema: (schemaId: string) => void;
166
210
  refresh: () => void;
167
211
  }
@@ -46,26 +46,5 @@ export const parseRequestHeaders = (headersString: string): Record<string, strin
46
46
  }
47
47
  };
48
48
 
49
- // Substitute URL parameters like {id}, {userId}, etc.
50
- export const substituteUrlParameters = (
51
- url: string,
52
- parameters: Record<string, string>
53
- ): string => {
54
- let substitutedUrl = url;
55
-
56
- Object.entries(parameters).forEach(([key, value]) => {
57
- if (value && value.trim() !== '') {
58
- // Replace both {key} and %7Bkey%7D patterns (URL encoded version)
59
- const patterns = [
60
- new RegExp(`\\{${key}\\}`, 'g'),
61
- new RegExp(`%7B${key}%7D`, 'gi'),
62
- ];
63
-
64
- patterns.forEach((pattern) => {
65
- substitutedUrl = substitutedUrl.replace(pattern, encodeURIComponent(value));
66
- });
67
- }
68
- });
69
-
70
- return substitutedUrl;
71
- };
49
+ // URL helpers moved to ./url.ts — keep this file focused on non-URL
50
+ // formatters (method/status colour maps, JSON validation, header parsing).
@@ -6,4 +6,6 @@
6
6
 
7
7
  export * from './apiKeyManager';
8
8
  export * from './versionManager';
9
- export * from './formatters';
9
+ export * from './formatters';
10
+ export * from './schemaExport';
11
+ export * from './url';
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Export the current OpenAPI schema in formats friendly for LLM agents.
3
+ *
4
+ * Three flavours:
5
+ * - raw: JSON.stringify(schema) — full fidelity, largest.
6
+ * - compact: dereferenced + pruned (no xml/examples, short descriptions,
7
+ * no whitespace). Usually 30–50% of raw.
8
+ * - markdown: prose summary of endpoints + params + responses. Smallest,
9
+ * best for small prompts. Drops response body schemas.
10
+ *
11
+ * Per-endpoint markdown is also exported so a Copy-for-AI button on a
12
+ * single endpoint stays cheap and does not leak unrelated paths.
13
+ *
14
+ * ``baseUrl`` resolution is done by the caller; we just substitute the
15
+ * resolved value into ``servers[0]`` so the copied schema points at the
16
+ * live API, not at whatever ``schema.servers`` originally held.
17
+ */
18
+
19
+ import type { ApiEndpoint, OpenApiSchema } from '../types';
20
+ import { relativePath, resolveBaseUrl } from './url';
21
+
22
+ // ─── Dereference ──────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Walk a JSON object and replace every ``$ref`` with the resolved value.
26
+ * Shallow circular-ref protection: stops at ``maxDepth`` to avoid infinite
27
+ * recursion on self-referential schemas (common in recursive OpenAPI types).
28
+ */
29
+ export function dereferenceSchema(schema: OpenApiSchema, maxDepth = 10): OpenApiSchema {
30
+ const root = schema as unknown as Record<string, unknown>;
31
+
32
+ function resolveRef(ref: string): unknown {
33
+ // Only handle local refs (#/components/schemas/Foo). External refs are
34
+ // out of scope — the schema we have in memory is already a single file.
35
+ if (!ref.startsWith('#/')) return null;
36
+ const parts = ref.slice(2).split('/');
37
+ let node: unknown = root;
38
+ for (const part of parts) {
39
+ if (node && typeof node === 'object' && part in (node as Record<string, unknown>)) {
40
+ node = (node as Record<string, unknown>)[part];
41
+ } else {
42
+ return null;
43
+ }
44
+ }
45
+ return node;
46
+ }
47
+
48
+ function walk(value: unknown, depth: number): unknown {
49
+ if (depth > maxDepth) return value;
50
+ if (Array.isArray(value)) return value.map((v) => walk(v, depth + 1));
51
+ if (value && typeof value === 'object') {
52
+ const obj = value as Record<string, unknown>;
53
+ if (typeof obj.$ref === 'string') {
54
+ const resolved = resolveRef(obj.$ref);
55
+ if (resolved === null) return obj;
56
+ return walk(resolved, depth + 1);
57
+ }
58
+ const out: Record<string, unknown> = {};
59
+ for (const [k, v] of Object.entries(obj)) out[k] = walk(v, depth + 1);
60
+ return out;
61
+ }
62
+ return value;
63
+ }
64
+
65
+ return walk(schema, 0) as OpenApiSchema;
66
+ }
67
+
68
+ // ─── Pruning helpers ──────────────────────────────────────────────────────────
69
+
70
+ const NOISY_KEYS = new Set(['xml', 'example', 'examples', 'externalDocs']);
71
+ const MAX_DESCRIPTION_LEN = 500;
72
+
73
+ /**
74
+ * In-place-friendly pruner (returns a new tree). Drops keys that only
75
+ * add bytes without giving the LLM new semantic info, and truncates
76
+ * very long prose descriptions.
77
+ */
78
+ function pruneForCompact(value: unknown): unknown {
79
+ if (Array.isArray(value)) return value.map(pruneForCompact);
80
+ if (value && typeof value === 'object') {
81
+ const out: Record<string, unknown> = {};
82
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
83
+ if (NOISY_KEYS.has(k)) continue;
84
+ if (k === 'description' && typeof v === 'string' && v.length > MAX_DESCRIPTION_LEN) {
85
+ out[k] = v.slice(0, MAX_DESCRIPTION_LEN).trimEnd() + '…';
86
+ continue;
87
+ }
88
+ out[k] = pruneForCompact(v);
89
+ }
90
+ return out;
91
+ }
92
+ return value;
93
+ }
94
+
95
+ // ─── Public API ───────────────────────────────────────────────────────────────
96
+
97
+ /** Overwrite ``servers[0]`` with the resolved base URL if provided. */
98
+ function withResolvedBaseUrl(schema: OpenApiSchema, baseUrl: string | undefined): OpenApiSchema {
99
+ if (!baseUrl) return schema;
100
+ return { ...schema, servers: [{ url: baseUrl }] };
101
+ }
102
+
103
+ export function toRawJson(schema: OpenApiSchema, baseUrl?: string): string {
104
+ return JSON.stringify(withResolvedBaseUrl(schema, baseUrl), null, 2);
105
+ }
106
+
107
+ export function toCompactJson(schema: OpenApiSchema, baseUrl?: string): string {
108
+ const resolved = dereferenceSchema(withResolvedBaseUrl(schema, baseUrl));
109
+ const pruned = pruneForCompact(resolved);
110
+ return JSON.stringify(pruned);
111
+ }
112
+
113
+ // ─── Markdown ────────────────────────────────────────────────────────────────
114
+
115
+
116
+ export function endpointToMarkdown(ep: ApiEndpoint): string {
117
+ const lines: string[] = [];
118
+ lines.push(`### ${ep.method} ${ep.path}`);
119
+ if (ep.description) lines.push(ep.description);
120
+
121
+ const pathParams = ep.parameters?.filter((p) => ep.path.includes(`{${p.name}}`)) ?? [];
122
+ const queryParams = ep.parameters?.filter((p) => !ep.path.includes(`{${p.name}}`)) ?? [];
123
+
124
+ if (pathParams.length > 0) {
125
+ lines.push('', '**Path parameters**');
126
+ for (const p of pathParams) {
127
+ const req = p.required ? ' (required)' : '';
128
+ const desc = p.description ? ` — ${p.description}` : '';
129
+ lines.push(`- \`${p.name}\`: ${p.type}${req}${desc}`);
130
+ }
131
+ }
132
+ if (queryParams.length > 0) {
133
+ lines.push('', '**Query parameters**');
134
+ for (const p of queryParams) {
135
+ const req = p.required ? ' (required)' : '';
136
+ const desc = p.description ? ` — ${p.description}` : '';
137
+ lines.push(`- \`${p.name}\`: ${p.type}${req}${desc}`);
138
+ }
139
+ }
140
+ if (ep.requestBody) {
141
+ const desc = ep.requestBody.description ? ` — ${ep.requestBody.description}` : '';
142
+ lines.push('', `**Request body:** ${ep.requestBody.type}${desc}`);
143
+ }
144
+ if (ep.responses && ep.responses.length > 0) {
145
+ lines.push('', '**Responses**');
146
+ for (const r of ep.responses) {
147
+ lines.push(`- \`${r.code}\` — ${r.description}`);
148
+ }
149
+ }
150
+ return lines.join('\n');
151
+ }
152
+
153
+ export function toMarkdown(
154
+ schema: OpenApiSchema,
155
+ endpoints: ApiEndpoint[],
156
+ baseUrl?: string,
157
+ ): string {
158
+ const lines: string[] = [];
159
+ const info = schema.info;
160
+ const resolvedBase = resolveBaseUrl({
161
+ config: baseUrl,
162
+ fromServers: schema.servers?.[0]?.url,
163
+ });
164
+
165
+ lines.push(`# ${info?.title ?? 'API'}${info?.version ? ` (v${info.version})` : ''}`);
166
+ if (resolvedBase) lines.push('', `**Base URL:** \`${resolvedBase}\``);
167
+ if (info?.description) lines.push('', info.description.trim());
168
+
169
+ // Group by tag/category for readable output.
170
+ const grouped = new Map<string, ApiEndpoint[]>();
171
+ for (const ep of endpoints) {
172
+ const arr = grouped.get(ep.category) ?? [];
173
+ arr.push(ep);
174
+ grouped.set(ep.category, arr);
175
+ }
176
+
177
+ const categories = Array.from(grouped.keys()).sort((a, b) => {
178
+ if (a === 'Other') return 1;
179
+ if (b === 'Other') return -1;
180
+ return a.localeCompare(b);
181
+ });
182
+
183
+ for (const category of categories) {
184
+ lines.push('', `## ${category}`);
185
+ const list = grouped.get(category)!;
186
+ for (const ep of list) {
187
+ // Use relativePath for section headers to keep markdown compact —
188
+ // the full URL is already available as "Base URL" above.
189
+ const displayPath = relativePath(ep.path);
190
+ const sub = endpointToMarkdown({ ...ep, path: displayPath });
191
+ lines.push('', sub);
192
+ }
193
+ }
194
+
195
+ return lines.join('\n');
196
+ }
197
+
198
+ // ─── Size helper ──────────────────────────────────────────────────────────────
199
+
200
+ /** Human-readable byte count: ``12.3 KB``, ``480 B``. */
201
+ export function formatBytes(s: string): string {
202
+ const bytes = new Blob([s]).size;
203
+ if (bytes < 1024) return `${bytes} B`;
204
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
205
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
206
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * URL utilities for the OpenAPI playground.
3
+ *
4
+ * Consolidated here to avoid the pre-existing split where
5
+ * ``substituteUrlParameters`` lived in formatters.ts, base URL priority
6
+ * lived inside ``useOpenApiSchema``, query params were never assembled
7
+ * at all (that was a bug), and ``resolveAbsoluteUrl`` was tacked on
8
+ * later.
9
+ *
10
+ * Conceptual model: a request URL is built from four layers, highest
11
+ * priority first:
12
+ *
13
+ * 1. An absolute override URL the user pasted (rare — not plumbed
14
+ * through the UI today, but the shape supports it).
15
+ * 2. ``baseUrl`` — from ``SchemaSource.baseUrl`` → ``PlaygroundConfig.baseUrl``
16
+ * → ``schema.servers[0].url``.
17
+ * 3. Endpoint path template (``/pet/{petId}``) + substituted path params.
18
+ * 4. Query string built from parameters that don't match any path
19
+ * placeholder.
20
+ *
21
+ * Every helper below lives on one of those layers. ``UrlBuilder`` wires
22
+ * them together when the caller has all four; individual functions are
23
+ * exported for cases that only need one step (e.g. ``relativePath``
24
+ * for display in the sidebar).
25
+ */
26
+
27
+ import type { ApiEndpoint } from '../types';
28
+
29
+ // ─── Path substitution ────────────────────────────────────────────────────────
30
+
31
+ const PATH_PARAM_RE = /\{([^{}]+)\}/g;
32
+
33
+ /**
34
+ * Return the list of ``{name}`` placeholders in a path template.
35
+ * Preserves order, deduplicates.
36
+ */
37
+ export function extractPathPlaceholders(template: string): string[] {
38
+ const seen = new Set<string>();
39
+ const out: string[] = [];
40
+ for (const match of template.matchAll(PATH_PARAM_RE)) {
41
+ const name = match[1]!;
42
+ if (!seen.has(name)) {
43
+ seen.add(name);
44
+ out.push(name);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Replace ``{name}`` placeholders with values from ``values``. Values
52
+ * are URL-path-encoded with ``encodeURIComponent`` — safe for single
53
+ * segments but escapes ``/`` too. That's intentional: a user typing
54
+ * ``a/b`` into a path param should get ``a%2Fb`` so the server receives
55
+ * the intended single segment, not two.
56
+ *
57
+ * Values are only substituted when non-empty after trimming.
58
+ */
59
+ export function substitutePath(template: string, values: Record<string, string>): string {
60
+ return template.replace(PATH_PARAM_RE, (whole, name) => {
61
+ const v = values[name];
62
+ return v && v.trim() !== '' ? encodeURIComponent(v) : whole;
63
+ });
64
+ }
65
+
66
+ /** Placeholders that have no non-empty value in ``values``. */
67
+ export function unfilledPlaceholders(
68
+ template: string,
69
+ values: Record<string, string>,
70
+ ): string[] {
71
+ return extractPathPlaceholders(template).filter(
72
+ (name) => !(values[name] ?? '').trim(),
73
+ );
74
+ }
75
+
76
+ // ─── Query assembly ───────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Append a query string to ``url`` from the keys of ``values`` that
80
+ * are not path placeholders in ``pathTemplate``. Existing query strings
81
+ * are preserved: new keys are merged, existing keys are overwritten.
82
+ *
83
+ * Empty values are skipped (a form field that was left blank does not
84
+ * become ``?foo=`` on the wire).
85
+ */
86
+ export function appendQuery(
87
+ url: string,
88
+ values: Record<string, string>,
89
+ pathTemplate: string,
90
+ ): string {
91
+ const pathNames = new Set(extractPathPlaceholders(pathTemplate));
92
+ const queryEntries = Object.entries(values).filter(
93
+ ([name, value]) => !pathNames.has(name) && value && value.trim() !== '',
94
+ );
95
+ if (queryEntries.length === 0) return url;
96
+
97
+ // Split any existing query so we can merge cleanly.
98
+ const [base, existingQuery = ''] = url.split('?', 2);
99
+ const params = new URLSearchParams(existingQuery);
100
+ for (const [k, v] of queryEntries) params.set(k, v);
101
+ const qs = params.toString();
102
+ return qs ? `${base}?${qs}` : (base ?? url);
103
+ }
104
+
105
+ // ─── Absolute / relative ──────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Prepend ``window.location.origin`` when the URL is relative so the
109
+ * result is runnable from a terminal via curl. No-op on SSR or when
110
+ * already absolute.
111
+ */
112
+ export function resolveAbsolute(url: string): string {
113
+ if (!url) return url;
114
+ if (/^https?:\/\//i.test(url)) return url;
115
+ if (typeof window === 'undefined') return url;
116
+ if (url.startsWith('//')) return `${window.location.protocol}${url}`;
117
+ if (url.startsWith('/')) return `${window.location.origin}${url}`;
118
+ return url;
119
+ }
120
+
121
+ /** Pull just the path out of any URL (absolute or relative). */
122
+ export function relativePath(url: string): string {
123
+ try { return new URL(url).pathname; } catch { return url; }
124
+ }
125
+
126
+ /**
127
+ * Concatenate URL segments with exactly one slash between each. Works
128
+ * whether inputs have trailing/leading slashes or not.
129
+ *
130
+ * joinUrl('https://api.example.com/', '/v3', 'pet') → 'https://api.example.com/v3/pet'
131
+ */
132
+ export function joinUrl(...parts: string[]): string {
133
+ return parts
134
+ .filter((p) => p !== undefined && p !== null && p !== '')
135
+ .map((p, i) => {
136
+ if (i === 0) return String(p).replace(/\/+$/, '');
137
+ return String(p).replace(/^\/+|\/+$/g, '');
138
+ })
139
+ .filter((p) => p !== '')
140
+ .join('/');
141
+ }
142
+
143
+ // ─── Base URL resolution ──────────────────────────────────────────────────────
144
+
145
+ export interface BaseUrlSources {
146
+ /** Highest priority — per-schema override. */
147
+ schemaSource?: string;
148
+ /** Global config-level override. */
149
+ config?: string;
150
+ /** Fallback from the OpenAPI document itself. */
151
+ fromServers?: string;
152
+ }
153
+
154
+ /**
155
+ * Apply the documented priority chain:
156
+ * ``schemaSource → config → fromServers → ''``.
157
+ */
158
+ export function resolveBaseUrl(sources: BaseUrlSources): string {
159
+ return sources.schemaSource || sources.config || sources.fromServers || '';
160
+ }
161
+
162
+ // ─── UrlBuilder ───────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * End-to-end builder that takes an endpoint + user-entered parameters
166
+ * and produces both the fetch URL and a copy-paste-friendly preview.
167
+ */
168
+ export class UrlBuilder {
169
+ constructor(
170
+ private readonly endpoint: ApiEndpoint,
171
+ private readonly parameters: Record<string, string>,
172
+ ) {}
173
+
174
+ /** What ``fetch()`` receives: substituted path + query string. Origin
175
+ * is whatever the endpoint template already had (relative paths
176
+ * stay relative so the browser resolves them against the page). */
177
+ build(): string {
178
+ const substituted = substitutePath(this.endpoint.path, this.parameters);
179
+ return appendQuery(substituted, this.parameters, this.endpoint.path);
180
+ }
181
+
182
+ /** Same as ``build()`` but guaranteed absolute — for curl snippets
183
+ * and anywhere the URL leaves the browser context. */
184
+ buildAbsolute(): string {
185
+ return resolveAbsolute(this.build());
186
+ }
187
+
188
+ /** Names of required path/query params still empty. */
189
+ missingRequired(): string[] {
190
+ if (!this.endpoint.parameters) return [];
191
+ return this.endpoint.parameters
192
+ .filter((p) => p.required && !(this.parameters[p.name] ?? '').trim())
193
+ .map((p) => p.name);
194
+ }
195
+
196
+ /** Placeholders in the template with no matching value. Usually a
197
+ * superset of ``missingRequired`` — catches schemas that forgot
198
+ * to flag a path param as required. */
199
+ unfilledPlaceholders(): string[] {
200
+ return unfilledPlaceholders(this.endpoint.path, this.parameters);
201
+ }
202
+ }
@@ -17,9 +17,15 @@ interface PrettyCodeProps {
17
17
  customBg?: string; // Custom background class
18
18
  isCompact?: boolean; // Compact mode for smaller font sizes
19
19
  scrollIsolation?: boolean; // Block scroll capture until user clicks (default: true)
20
+ /**
21
+ * Line count at which the viewer starts to scroll instead of growing.
22
+ * ``undefined`` (default) = always grows to fit content, no scroll.
23
+ * Set e.g. ``50`` to cap short snippets inline and scroll long ones.
24
+ */
25
+ maxLines?: number;
20
26
  }
21
27
 
22
- const PrettyCode = ({ data, language, className, mode, inline = false, customBg, isCompact = false, scrollIsolation }: PrettyCodeProps) => {
28
+ const PrettyCode = ({ data, language, className, mode, inline = false, customBg, isCompact = false, scrollIsolation, maxLines }: PrettyCodeProps) => {
23
29
  const containerRef = useRef<HTMLDivElement>(null);
24
30
  const t = useAppT();
25
31
  const detectedTheme = useResolvedTheme();
@@ -41,6 +47,19 @@ const PrettyCode = ({ data, language, className, mode, inline = false, customBg,
41
47
 
42
48
  // Convert form object to JSON string with proper formatting
43
49
  const contentJson = typeof data === 'string' ? data : JSON.stringify(data || {}, null, 2);
50
+
51
+ // Enable scroll only when content exceeds maxLines. Otherwise the block
52
+ // grows to fit — short snippets feel natural, long ones get a cap.
53
+ const lineCount = contentJson ? contentJson.split('\n').length : 0;
54
+ const lineHeightRatio = isCompact ? 1.4 : 1.5;
55
+ const fontSizePx = isCompact ? 12 : 14;
56
+ // Vertical padding of the <pre> (top + bottom, in px) — keep in sync
57
+ // with the padding string below.
58
+ const verticalPadPx = 16 + 12; // 1rem top + 0.75rem bottom (≈)
59
+ const shouldScroll = maxLines !== undefined && lineCount > maxLines;
60
+ const maxHeightPx = maxLines !== undefined
61
+ ? maxLines * fontSizePx * lineHeightRatio + verticalPadPx
62
+ : undefined;
44
63
 
45
64
  // Handle empty content
46
65
  if (!contentJson || contentJson.trim() === '') {
@@ -170,13 +189,24 @@ const PrettyCode = ({ data, language, className, mode, inline = false, customBg,
170
189
  const borderClass = isDarkMode ? 'border-zinc-700' : 'border-border';
171
190
 
172
191
  return (
173
- <div ref={containerRef} className={`group relative h-full ${bgClass} rounded-lg border ${borderClass} ${className || ''}`}>
174
- {/* Toolbar: hidden by default, appears on hover. Absolute overlay so it doesn't shift layout. */}
175
- <div className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-30">
192
+ <div
193
+ ref={containerRef}
194
+ className={`group relative ${bgClass} rounded-lg border ${borderClass} ${className || ''}`}
195
+ style={
196
+ // maxHeight caps growth at ``maxLines`` rows; without maxLines we
197
+ // let the block grow to fit its content (no scroll).
198
+ maxHeightPx ? { maxHeight: `${maxHeightPx}px` } : undefined
199
+ }
200
+ >
201
+ {/* Toolbar: hidden by default, appears on hover. Absolute overlay so it doesn't shift layout.
202
+ scrollIsolation is force-disabled when content fits without scrolling —
203
+ otherwise the "Click to scroll" prompt shows on a block that has
204
+ nothing to scroll, which reads as a bug. */}
205
+ <div className="absolute inset-x-0 top-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-30">
176
206
  <div className="pointer-events-auto">
177
207
  <FloatingToolbar
178
208
  containerRef={containerRef}
179
- scrollIsolation={scrollIsolation}
209
+ scrollIsolation={shouldScroll ? scrollIsolation : false}
180
210
  label={
181
211
  <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted/80 text-muted-foreground border border-border/50 backdrop-blur-sm">
182
212
  {displayLanguage}
@@ -188,7 +218,7 @@ const PrettyCode = ({ data, language, className, mode, inline = false, customBg,
188
218
  </div>
189
219
  </div>
190
220
 
191
- <div className="h-full overflow-auto">
221
+ <div className={shouldScroll ? 'h-full overflow-auto' : ''} style={shouldScroll ? { maxHeight: maxHeightPx } : undefined}>
192
222
  <Highlight theme={prismTheme} code={contentJson} language={normalizedLanguage as Language}>
193
223
  {({ className, style, tokens, getLineProps, getTokenProps }) => {
194
224
  // Remove background from Prism theme - we use our own via CSS
@@ -199,9 +229,13 @@ const PrettyCode = ({ data, language, className, mode, inline = false, customBg,
199
229
  style={{
200
230
  ...restStyle,
201
231
  margin: 0,
202
- padding: '2.5rem 1rem 1rem 1rem',
232
+ // Top padding gives the hover toolbar room to sit over
233
+ // the first line without covering it. Before: 2.5rem —
234
+ // too much empty space on short snippets. Now: 1rem,
235
+ // toolbar overlays with translucent bg on hover only.
236
+ padding: '1rem 1rem 0.75rem 1rem',
203
237
  fontSize,
204
- lineHeight: isCompact ? 1.4 : 1.5,
238
+ lineHeight: lineHeightRatio,
205
239
  fontFamily: 'monospace',
206
240
  whiteSpace: 'pre-wrap',
207
241
  wordBreak: 'break-word',
@@ -35,6 +35,12 @@ export interface PrettyCodeProps {
35
35
  isCompact?: boolean;
36
36
  /** Block scroll capture until user clicks (default: true) */
37
37
  scrollIsolation?: boolean;
38
+ /**
39
+ * Line count above which the block starts scrolling instead of growing.
40
+ * ``undefined`` (default) = no cap, block grows to fit. Set e.g. ``50``
41
+ * to inline short snippets and cap long ones.
42
+ */
43
+ maxLines?: number;
38
44
  }
39
45
 
40
46
  const PrettyCode: React.FC<PrettyCodeProps> = (props) => {