@djangocfg/ui-tools 2.1.285 → 2.1.286
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
- package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
- package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
- package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
- package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
- package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
- package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
- package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
- package/dist/chunk-IULI4XII.cjs +1129 -0
- package/dist/chunk-IULI4XII.cjs.map +1 -0
- package/dist/chunk-VZGQC3NG.mjs +1100 -0
- package/dist/chunk-VZGQC3NG.mjs.map +1 -0
- package/dist/index.cjs +88 -552
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -6
- package/dist/index.d.ts +18 -6
- package/dist/index.mjs +25 -496
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +4 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
- package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
- package/src/tools/OpenapiViewer/README.md +104 -51
- package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
- package/src/tools/OpenapiViewer/components/index.ts +5 -2
- package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
- package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
- package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
- package/src/tools/OpenapiViewer/index.tsx +3 -7
- package/src/tools/OpenapiViewer/lazy.tsx +6 -27
- package/src/tools/OpenapiViewer/types.ts +44 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
- package/src/tools/OpenapiViewer/utils/index.ts +3 -1
- package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
- package/src/tools/OpenapiViewer/utils/url.ts +202 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
- package/src/tools/PrettyCode/index.tsx +6 -0
- package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
- package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
- package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
- package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
- package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
- package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
- package/dist/chunk-5FKE7OME.cjs +0 -369
- package/dist/chunk-5FKE7OME.cjs.map +0 -1
- package/dist/chunk-BKWDHJKF.mjs +0 -356
- package/dist/chunk-BKWDHJKF.mjs.map +0 -1
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
- /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
- /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
|
@@ -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
|
|
174
|
-
{
|
|
175
|
-
|
|
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=
|
|
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
|
|
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:
|
|
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) => {
|