blume 1.5.0 → 1.5.2

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 (89) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +449 -135
  4. package/dist/cli/index.js.map +24 -23
  5. package/dist/types/ai/ask-context.d.ts +78 -0
  6. package/dist/types/core/config-input.d.ts +54 -2
  7. package/dist/types/core/data.d.ts +19 -2
  8. package/dist/types/core/open-in-chat.d.ts +9 -0
  9. package/dist/types/core/schema.d.ts +48 -1
  10. package/dist/types/core/types.d.ts +10 -3
  11. package/dist/types/openapi/references.d.ts +9 -0
  12. package/dist/types/search/orama-index.d.ts +70 -0
  13. package/dist/types/theme/fonts.d.ts +11 -2
  14. package/docs/advanced/api-reference.mdx +67 -5
  15. package/docs/advanced/custom-pages.mdx +5 -1
  16. package/docs/configuration/ai.mdx +35 -0
  17. package/docs/configuration/index.mdx +14 -2
  18. package/docs/configuration/search.mdx +4 -4
  19. package/docs/configuration/theming.mdx +4 -2
  20. package/docs/reference/cli.mdx +2 -2
  21. package/package.json +1 -1
  22. package/skills/blume-migrate/SKILL.md +1 -1
  23. package/skills/blume-migrate/references/mintlify.md +1 -1
  24. package/src/ai/ask-context.ts +51 -11
  25. package/src/ai/mcp/data.ts +3 -2
  26. package/src/ai/mcp/server.ts +3 -2
  27. package/src/assets/icon-dark.png +0 -0
  28. package/src/astro/generate.ts +172 -18
  29. package/src/astro/templates.ts +89 -15
  30. package/src/components/content/AccordionItem.astro +4 -0
  31. package/src/components/content/Update.astro +3 -0
  32. package/src/components/islands/AskAI.astro +6 -0
  33. package/src/components/islands/ask-ai.tsx +39 -9
  34. package/src/components/layout/Analytics.astro +9 -1
  35. package/src/components/layout/Favicon.astro +29 -8
  36. package/src/components/layout/Fonts.astro +23 -3
  37. package/src/components/layout/Header.astro +2 -2
  38. package/src/components/layout/NavSelector.astro +1 -1
  39. package/src/components/layout/PageActions.astro +120 -78
  40. package/src/components/layout/PageFeedback.astro +12 -3
  41. package/src/components/layout/PageLayout.astro +79 -5
  42. package/src/components/layout/ReferenceLayout.astro +12 -9
  43. package/src/components/layout/RootLayout.astro +153 -121
  44. package/src/components/layout/Search.astro +41 -26
  45. package/src/components/layout/drawer-inert.ts +10 -5
  46. package/src/components/layout/head-scripts.ts +34 -16
  47. package/src/components/layout/nav-utils.ts +34 -15
  48. package/src/components/layout/search/orama.ts +3 -2
  49. package/src/components/openapi/AsyncApiOperation.astro +22 -7
  50. package/src/components/openapi/MessageComposer.astro +238 -0
  51. package/src/components/openapi/Operation.astro +26 -12
  52. package/src/components/openapi/PanelTabs.astro +7 -0
  53. package/src/components/openapi/Playground.astro +320 -0
  54. package/src/components/openapi/RequestPanel.astro +1 -0
  55. package/src/components/openapi/async-snippets.ts +20 -7
  56. package/src/components/openapi/async.ts +13 -2
  57. package/src/components/openapi/message-composer.ts +242 -0
  58. package/src/components/openapi/message-model.ts +108 -0
  59. package/src/components/openapi/message.ts +153 -0
  60. package/src/components/openapi/operation-model.ts +260 -0
  61. package/src/components/openapi/playground-client.ts +486 -0
  62. package/src/components/openapi/playground-schema.ts +109 -0
  63. package/src/components/openapi/request.ts +287 -0
  64. package/src/components/openapi/security.ts +0 -56
  65. package/src/components/openapi/snippets.ts +23 -136
  66. package/src/components/openapi/validate-json.ts +144 -0
  67. package/src/components/openapi/ws-client.ts +194 -0
  68. package/src/core/config-input.ts +67 -1
  69. package/src/core/content-assets.ts +66 -15
  70. package/src/core/data.ts +16 -2
  71. package/src/core/last-modified.ts +76 -2
  72. package/src/core/links.ts +30 -4
  73. package/src/core/navigation.ts +26 -1
  74. package/src/core/open-in-chat.ts +17 -0
  75. package/src/core/project-graph.ts +11 -0
  76. package/src/core/schema.ts +60 -1
  77. package/src/core/server-features.ts +11 -0
  78. package/src/core/sources/normalize.ts +10 -2
  79. package/src/core/types.ts +10 -3
  80. package/src/deploy/vercel-negotiation.ts +34 -14
  81. package/src/og/card.ts +3 -1
  82. package/src/openapi/model.ts +7 -0
  83. package/src/openapi/proxy.ts +217 -0
  84. package/src/openapi/references.ts +8 -0
  85. package/src/openapi/source.ts +13 -0
  86. package/src/registry/eject.ts +4 -5
  87. package/src/search/orama-index.ts +109 -36
  88. package/src/theme/entry.ts +15 -2
  89. package/src/theme/fonts.ts +75 -3
@@ -0,0 +1,287 @@
1
+ import type { RequestSample } from "./snippets.ts";
2
+
3
+ /**
4
+ * Framework-free request model for the "Try it" playground. This module ships
5
+ * in the client bundle, so it must stay dependency-free: parameter and body
6
+ * examples are precomputed server-side (`operation-model.ts`) and embedded in
7
+ * the model JSON — nothing here touches openapi-sampler or the spec document.
8
+ * `buildRequest` is THE one request builder: the code samples, the copy
9
+ * buttons, and the live fetch all consume its output, so what readers see is
10
+ * byte-for-byte what gets sent.
11
+ */
12
+
13
+ export interface PlaygroundParam {
14
+ name: string;
15
+ in: "path" | "query" | "header";
16
+ required: boolean;
17
+ description?: string;
18
+ /** Short type label for the input: "string" | "number" | "integer" | "boolean" | ... */
19
+ type: string;
20
+ /** Enum members stringified, when the schema declares them. */
21
+ enum?: string[];
22
+ /** Precomputed default from spec example/sampler; "" when none (optional params default ""). */
23
+ value: string;
24
+ }
25
+
26
+ export interface PlaygroundBodyField {
27
+ name: string;
28
+ required: boolean;
29
+ description?: string;
30
+ /** Primitive type name; the client coerces number/integer/boolean. */
31
+ type: string;
32
+ enum?: string[];
33
+ /** Stringified default, "" when none. */
34
+ value: string;
35
+ }
36
+
37
+ /** A pruned, cycle-free JSON-schema subset `validate-json.ts` understands. */
38
+ export interface ValidationSchema {
39
+ type?: string;
40
+ properties?: Record<string, ValidationSchema>;
41
+ required?: string[];
42
+ items?: ValidationSchema;
43
+ enum?: unknown[];
44
+ }
45
+
46
+ export interface PlaygroundBody {
47
+ contentType: string;
48
+ /** Present when the schema is a flat object of primitives -> typed fields UI. */
49
+ fields?: PlaygroundBodyField[];
50
+ /** Pretty-printed example JSON prefill for the raw editor; "" when none. */
51
+ example: string;
52
+ schema?: ValidationSchema;
53
+ }
54
+
55
+ export type AuthKind = "bearer" | "basic" | "apiKey" | "oauth2";
56
+
57
+ export interface PlaygroundAuthInput {
58
+ /** SecurityScheme component key. */
59
+ id: string;
60
+ kind: AuthKind;
61
+ /** `schemeLabel()` output. */
62
+ label: string;
63
+ carrier: { in: "header" | "query" | "cookie"; name: string };
64
+ /** Placeholder credential used in redacted samples, e.g. "YOUR_TOKEN". */
65
+ placeholder: string;
66
+ /** Header value prefix, e.g. "Bearer " ("" for apiKey). Basic uses "Basic ". */
67
+ prefix: string;
68
+ }
69
+
70
+ export interface PlaygroundModel {
71
+ /** Upper-case HTTP method. */
72
+ method: string;
73
+ /** Templated path, e.g. `/pets/{id}`. */
74
+ path: string;
75
+ /** Spec servers in order; first is the default base. */
76
+ servers: string[];
77
+ params: PlaygroundParam[];
78
+ body?: PlaygroundBody;
79
+ /** First security alternative (AND set). */
80
+ auth: PlaygroundAuthInput[];
81
+ authOptional: boolean;
82
+ }
83
+
84
+ export interface AuthValue {
85
+ value: string;
86
+ username?: string;
87
+ password?: string;
88
+ }
89
+
90
+ export interface RequestValues {
91
+ /** Resolved base URL (custom override already applied). */
92
+ server: string;
93
+ /** Key: `paramKey(param)`. */
94
+ params: Record<string, string>;
95
+ /** Raw JSON text; undefined = no body. */
96
+ body?: string;
97
+ /** Key: `PlaygroundAuthInput.id`. */
98
+ auth: Record<string, AuthValue>;
99
+ }
100
+
101
+ export const paramKey = (p: { in: string; name: string }): string =>
102
+ `${p.in}:${p.name}`;
103
+
104
+ /** Values pre-filled from the model's precomputed examples; auth entries empty. */
105
+ export const defaultValues = (model: PlaygroundModel): RequestValues => ({
106
+ auth: Object.fromEntries(
107
+ model.auth.map((input) => [input.id, { value: "" }])
108
+ ),
109
+ body: model.body ? model.body.example : undefined,
110
+ params: Object.fromEntries(
111
+ model.params.map((param) => [paramKey(param), param.value])
112
+ ),
113
+ server: model.servers[0] ?? "",
114
+ });
115
+
116
+ /** Copy of `values` with every auth value emptied (redacted) so samples show placeholders. */
117
+ export const redactAuth = (
118
+ model: PlaygroundModel,
119
+ values: RequestValues
120
+ ): RequestValues => ({
121
+ ...values,
122
+ auth: Object.fromEntries(
123
+ model.auth.map((input) => [input.id, { value: "" }])
124
+ ),
125
+ });
126
+
127
+ const TRAILING_SLASH = /\/+$/u;
128
+
129
+ /**
130
+ * Base64 of `text`'s UTF-8 bytes. `btoa` alone throws on any code point above
131
+ * U+00FF, so a credential with non-Latin-1 characters (a Cyrillic username, an
132
+ * emoji in a password) would take down every sample render; RFC 7617 names
133
+ * UTF-8 as the charset to encode a `user:password` pair in.
134
+ */
135
+ const base64Utf8 = (text: string): string => {
136
+ let binary = "";
137
+ for (const byte of new TextEncoder().encode(text)) {
138
+ binary += String.fromCodePoint(byte);
139
+ }
140
+ return btoa(binary);
141
+ };
142
+
143
+ /**
144
+ * The credential a request carries for one auth input: the user's value when
145
+ * present, else the redaction placeholder. Basic auth encodes user:password
146
+ * per RFC 7617 — the placeholder stands in until either field is filled.
147
+ */
148
+ const credentialFor = (
149
+ input: PlaygroundAuthInput,
150
+ auth: AuthValue | undefined
151
+ ): string => {
152
+ if (input.kind === "basic") {
153
+ const username = auth?.username ?? "";
154
+ const password = auth?.password ?? "";
155
+ return username !== "" || password !== ""
156
+ ? base64Utf8(`${username}:${password}`)
157
+ : input.placeholder;
158
+ }
159
+ const value = auth?.value ?? "";
160
+ return value === "" ? input.placeholder : value;
161
+ };
162
+
163
+ /**
164
+ * Apply the model's auth inputs to the outgoing query/headers. Query-borne
165
+ * credentials skip names an explicit query parameter already contributed (the
166
+ * spec's example wins); cookie-borne ones collapse into a single `Cookie`
167
+ * header, matching how browsers send them.
168
+ */
169
+ const applyAuth = (
170
+ model: PlaygroundModel,
171
+ values: RequestValues,
172
+ seen: ReadonlySet<string>,
173
+ query: string[],
174
+ headers: Record<string, string>
175
+ ): void => {
176
+ const cookies: string[] = [];
177
+ for (const input of model.auth) {
178
+ const credential = credentialFor(input, values.auth[input.id]);
179
+ if (input.carrier.in === "query") {
180
+ if (!seen.has(input.carrier.name)) {
181
+ query.push(
182
+ `${encodeURIComponent(input.carrier.name)}=${encodeURIComponent(
183
+ credential
184
+ )}`
185
+ );
186
+ }
187
+ } else if (input.carrier.in === "cookie") {
188
+ cookies.push(`${input.carrier.name}=${credential}`);
189
+ } else {
190
+ headers[input.carrier.name] = `${input.prefix}${credential}`;
191
+ }
192
+ }
193
+ if (cookies.length > 0) {
194
+ headers.Cookie = cookies.join("; ");
195
+ }
196
+ };
197
+
198
+ /** The raw editor text to send, with a parsed mirror when it is valid JSON. */
199
+ const bodyFor = (
200
+ text: string | undefined
201
+ ): Pick<RequestSample, "body" | "bodyValue"> => {
202
+ if (text === undefined || text === "") {
203
+ return {};
204
+ }
205
+ try {
206
+ return { body: text, bodyValue: JSON.parse(text) };
207
+ } catch {
208
+ // Not valid JSON (mid-edit or intentionally raw): still send it — the
209
+ // structured mirror is only a nicety for consumers of `bodyValue`.
210
+ return { body: text };
211
+ }
212
+ };
213
+
214
+ /** THE one request builder: samples, copy buttons, and fetch all consume its output. */
215
+ export const buildRequest = (
216
+ model: PlaygroundModel,
217
+ values: RequestValues
218
+ ): RequestSample => {
219
+ const base = values.server.replace(TRAILING_SLASH, "");
220
+
221
+ // Path params: an empty value substitutes the raw param name, so a blank
222
+ // form still renders a readable templated URL instead of `//`.
223
+ let resolvedPath = model.path;
224
+ for (const param of model.params) {
225
+ if (param.in !== "path") {
226
+ continue;
227
+ }
228
+ const value = values.params[paramKey(param)] ?? "";
229
+ resolvedPath = resolvedPath.replace(
230
+ `{${param.name}}`,
231
+ value === "" ? param.name : encodeURIComponent(value)
232
+ );
233
+ }
234
+
235
+ // Query params with a value contribute in model order; a query-borne auth
236
+ // credential appends after — unless the spec also declares that name as an
237
+ // explicit query parameter, whose (better) example wins.
238
+ const query: string[] = [];
239
+ const seen = new Set<string>();
240
+ for (const param of model.params) {
241
+ if (param.in !== "query") {
242
+ continue;
243
+ }
244
+ const value = values.params[paramKey(param)] ?? "";
245
+ // A required param stays visible even when blank (`filter=`) — silently
246
+ // dropping it would make the samples deny the parameter exists. Optional
247
+ // blanks drop out entirely.
248
+ if (value === "" && !param.required) {
249
+ continue;
250
+ }
251
+ seen.add(param.name);
252
+ query.push(
253
+ `${encodeURIComponent(param.name)}=${encodeURIComponent(value)}`
254
+ );
255
+ }
256
+
257
+ // Auth first, so a spec that also declares the credential as an explicit
258
+ // header parameter overrides it below with its own (better) example.
259
+ const headers: Record<string, string> = {};
260
+ applyAuth(model, values, seen, query, headers);
261
+ for (const param of model.params) {
262
+ if (param.in !== "header") {
263
+ continue;
264
+ }
265
+ const value = values.params[paramKey(param)] ?? "";
266
+ // Required headers emit even when blank, mirroring the query rule above —
267
+ // except a blank one never clobbers a credential auth already placed
268
+ // under the same name.
269
+ if (value !== "" || (param.required && headers[param.name] === undefined)) {
270
+ headers[param.name] = value;
271
+ }
272
+ }
273
+
274
+ const { body, bodyValue } = model.body ? bodyFor(values.body) : {};
275
+ if (body !== undefined && model.body) {
276
+ headers["Content-Type"] = model.body.contentType;
277
+ }
278
+
279
+ const search = query.length > 0 ? `?${query.join("&")}` : "";
280
+ return {
281
+ body,
282
+ bodyValue,
283
+ headers,
284
+ method: model.method.toUpperCase(),
285
+ url: `${base}${resolvedPath}${search}`,
286
+ };
287
+ };
@@ -152,9 +152,6 @@ export const resolveAsyncApiSecurity = (
152
152
  return { alternatives, optional: false };
153
153
  };
154
154
 
155
- const capitalize = (text: string): string =>
156
- text.charAt(0).toUpperCase() + text.slice(1);
157
-
158
155
  /**
159
156
  * Fixed labels for scheme types with no per-scheme variation. Covers both
160
157
  * OpenAPI's types and the broker-auth types AsyncAPI adds; `http` is handled
@@ -219,56 +216,3 @@ export const schemeCarrier = (
219
216
  }
220
217
  }
221
218
  };
222
-
223
- /** Placeholder credentials the request samples send. */
224
- export interface SampleAuth {
225
- headers: Record<string, string>;
226
- query: Record<string, string>;
227
- }
228
-
229
- /**
230
- * Placeholder credentials for an operation's request samples, from its first
231
- * alternative (the spec's preferred way to authorize). Schemes that don't
232
- * travel in the request (mutual TLS) and unknown refs contribute nothing.
233
- */
234
- export const sampleAuth = (security: OperationSecurity): SampleAuth => {
235
- const headers: Record<string, string> = {};
236
- const query: Record<string, string> = {};
237
- const cookies: string[] = [];
238
- for (const resolved of security.alternatives[0] ?? []) {
239
- const { scheme } = resolved;
240
- switch (scheme?.type) {
241
- case "http": {
242
- const kind = (scheme.scheme ?? "bearer").toLowerCase();
243
- headers.Authorization =
244
- kind === "bearer"
245
- ? "Bearer YOUR_TOKEN"
246
- : `${capitalize(kind)} YOUR_CREDENTIALS`;
247
- break;
248
- }
249
- case "oauth2":
250
- case "openIdConnect": {
251
- headers.Authorization = "Bearer YOUR_ACCESS_TOKEN";
252
- break;
253
- }
254
- case "apiKey": {
255
- const name = scheme.name ?? resolved.key;
256
- if (scheme.in === "query") {
257
- query[name] = "YOUR_API_KEY";
258
- } else if (scheme.in === "cookie") {
259
- cookies.push(`${name}=YOUR_API_KEY`);
260
- } else {
261
- headers[name] = "YOUR_API_KEY";
262
- }
263
- break;
264
- }
265
- default: {
266
- break;
267
- }
268
- }
269
- }
270
- if (cookies.length > 0) {
271
- headers.Cookie = cookies.join("; ");
272
- }
273
- return { headers, query };
274
- };
@@ -1,31 +1,12 @@
1
- import { exampleValue, toJson } from "./helpers.ts";
2
- import type { SchemaLike } from "./helpers.ts";
3
- import type { SampleAuth } from "./security.ts";
4
-
5
1
  /**
6
- * Request example + code-sample generation for an operation. Kept separate from
7
- * `helpers.ts` so the schema renderers don't pull in the sample builders. Output
8
- * is intentionally simple, copy-pasteable starter code — not an exhaustive SDK.
2
+ * Code-sample generation for an operation. Deliberately dependency-free: the
3
+ * playground client renders samples live in the browser, so this module must
4
+ * stay out of the server-only dependency graph (no helpers.ts, no
5
+ * openapi-sampler). Requests are assembled once by `buildRequest` in
6
+ * `request.ts`; the builders here only render a finished `RequestSample` as
7
+ * simple, copy-pasteable starter code — not an exhaustive SDK.
9
8
  */
10
9
 
11
- interface ParamLike {
12
- name?: string;
13
- in?: string;
14
- required?: boolean;
15
- schema?: SchemaLike;
16
- example?: unknown;
17
- }
18
-
19
- interface MediaTypeLike {
20
- schema?: SchemaLike;
21
- example?: unknown;
22
- }
23
-
24
- export interface OperationLike {
25
- parameters?: ParamLike[];
26
- requestBody?: { content?: Record<string, MediaTypeLike> };
27
- }
28
-
29
10
  export interface RequestSample {
30
11
  method: string;
31
12
  url: string;
@@ -35,115 +16,6 @@ export interface RequestSample {
35
16
  bodyValue?: unknown;
36
17
  }
37
18
 
38
- const TRAILING_SLASH = /\/+$/u;
39
-
40
- const jsonContentType = (
41
- content: Record<string, MediaTypeLike> | undefined
42
- ): [string, MediaTypeLike] | undefined => {
43
- const entries = Object.entries(content ?? {});
44
- return entries.find(([type]) => type.includes("json")) ?? entries[0];
45
- };
46
-
47
- /**
48
- * The `?a=1&b=2` query string from an operation's required query params, plus
49
- * any extra entries (a query-borne API key from the security requirements).
50
- */
51
- const queryString = (
52
- params: ParamLike[],
53
- schemas: Record<string, SchemaLike>,
54
- extra: Record<string, string>
55
- ): string => {
56
- const query: string[] = [];
57
- const seen = new Set<string>();
58
- for (const param of params) {
59
- if (!(param.in === "query" && param.required && param.name)) {
60
- continue;
61
- }
62
- seen.add(param.name);
63
- const value = param.example ?? exampleValue(param.schema, schemas);
64
- query.push(
65
- `${encodeURIComponent(param.name)}=${encodeURIComponent(
66
- String(value ?? "")
67
- )}`
68
- );
69
- }
70
- for (const [name, value] of Object.entries(extra)) {
71
- // A spec may declare the credential as an explicit query parameter too;
72
- // its (better) example wins over the auth placeholder, as in headers.
73
- if (seen.has(name)) {
74
- continue;
75
- }
76
- query.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
77
- }
78
- return query.length > 0 ? `?${query.join("&")}` : "";
79
- };
80
-
81
- /**
82
- * The sample's headers: auth placeholders first, so a spec that also declares
83
- * the credential as an explicit header parameter overrides them with its own
84
- * (better) example.
85
- */
86
- const headerValues = (
87
- params: ParamLike[],
88
- schemas: Record<string, SchemaLike>,
89
- auth: SampleAuth | undefined
90
- ) => {
91
- const headers = { ...auth?.headers };
92
- for (const param of params) {
93
- if (param.in === "header" && param.required && param.name) {
94
- headers[param.name] = String(
95
- param.example ?? exampleValue(param.schema, schemas) ?? ""
96
- );
97
- }
98
- }
99
- return headers;
100
- };
101
-
102
- /** Assemble a representative request from an operation and the spec servers. */
103
- export const buildRequestSample = (
104
- operation: OperationLike,
105
- method: string,
106
- path: string,
107
- servers: { url?: string }[],
108
- schemas: Record<string, SchemaLike>,
109
- auth?: SampleAuth
110
- ): RequestSample => {
111
- const base = (servers[0]?.url ?? "").replace(TRAILING_SLASH, "");
112
- const params = operation.parameters ?? [];
113
-
114
- let resolvedPath = path;
115
- for (const param of params) {
116
- if (param.in === "path" && param.name) {
117
- const value = param.example ?? exampleValue(param.schema, schemas);
118
- resolvedPath = resolvedPath.replace(
119
- `{${param.name}}`,
120
- encodeURIComponent(String(value ?? param.name))
121
- );
122
- }
123
- }
124
-
125
- const search = queryString(params, schemas, auth?.query ?? {});
126
- const headers = headerValues(params, schemas, auth);
127
-
128
- const media = jsonContentType(operation.requestBody?.content);
129
- let body: string | undefined;
130
- let bodyValue: unknown;
131
- if (media) {
132
- const [type, mediaType] = media;
133
- headers["Content-Type"] = type;
134
- bodyValue = mediaType.example ?? exampleValue(mediaType.schema, schemas);
135
- body = toJson(bodyValue);
136
- }
137
-
138
- return {
139
- body,
140
- bodyValue,
141
- headers,
142
- method: method.toUpperCase(),
143
- url: `${base}${resolvedPath}${search}`,
144
- };
145
- };
146
-
147
19
  const headerLines = (
148
20
  headers: Record<string, string>,
149
21
  format: (key: string, value: string) => string
@@ -174,7 +46,15 @@ const fetchSnippet = (sample: RequestSample): string => {
174
46
  options.push(` headers: {\n${headers}\n }`);
175
47
  }
176
48
  if (sample.body) {
177
- options.push(` body: JSON.stringify(${sample.body})`);
49
+ // `bodyValue` mirrors the body only when it parses as JSON. Mid-edit text
50
+ // that doesn't can't be inlined as a JS expression — a string literal of
51
+ // the raw text keeps the snippet syntactically valid and byte-identical
52
+ // to what the live send transmits.
53
+ options.push(
54
+ sample.bodyValue === undefined
55
+ ? ` body: ${JSON.stringify(sample.body)}`
56
+ : ` body: JSON.stringify(${sample.body})`
57
+ );
178
58
  }
179
59
  return `const response = await fetch("${sample.url}", {\n${options.join(
180
60
  ",\n"
@@ -209,7 +89,14 @@ const pythonSnippet = (sample: RequestSample): string => {
209
89
  args.push(` headers={\n${headers}\n }`);
210
90
  }
211
91
  if (sample.body) {
212
- args.push(` json=${toPython(sample.body)}`);
92
+ // Same rule as the fetch snippet: only valid JSON rewrites into a Python
93
+ // literal for `json=`; anything else travels as a raw string via `data=`
94
+ // (JSON string escapes are a subset of Python's, so the literal is valid).
95
+ args.push(
96
+ sample.bodyValue === undefined
97
+ ? ` data=${JSON.stringify(sample.body)}`
98
+ : ` json=${toPython(sample.body)}`
99
+ );
213
100
  }
214
101
  return `import requests\n\nresponse = requests.${sample.method.toLowerCase()}(\n${args.join(
215
102
  ",\n"
@@ -0,0 +1,144 @@
1
+ import type { ValidationSchema } from "./request.ts";
2
+
3
+ /**
4
+ * Minimal JSON-schema validation for the playground body editor. Client-safe
5
+ * and dependency-free by design: the server prunes the operation's schema into
6
+ * the tiny `ValidationSchema` subset (`operation-model.ts`), so a full
7
+ * draft-2020 validator would be dead weight in the browser bundle. Checks are
8
+ * advisory — they catch the common "typo'd a required field" mistakes, they do
9
+ * not gate the Send button.
10
+ */
11
+
12
+ /** An already-parsed JSON object — string keys, parsed-JSON values. */
13
+ interface JsonObject {
14
+ [key: string]: JsonValue;
15
+ }
16
+
17
+ /** Already-parsed JSON — everything `JSON.parse` can produce. */
18
+ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
19
+
20
+ // `typeof` checks live in named predicates (the form the oxlint anti-slop
21
+ // config sanctions); each narrows the parsed-JSON union at its call site.
22
+ const isString = (value: JsonValue): value is string =>
23
+ typeof value === "string";
24
+
25
+ const isNumber = (value: JsonValue): value is number =>
26
+ typeof value === "number";
27
+
28
+ const isBoolean = (value: JsonValue): value is boolean =>
29
+ typeof value === "boolean";
30
+
31
+ const isJsonObject = (value: JsonValue): value is JsonObject =>
32
+ typeof value === "object" && value !== null && !Array.isArray(value);
33
+
34
+ /** A human-readable name for a value's actual type, for mismatch messages. */
35
+ const describeValue = (value: JsonValue): string => {
36
+ if (value === null) {
37
+ return "null";
38
+ }
39
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- typeof's result string is itself the human-readable label here, not a narrowing check
40
+ return Array.isArray(value) ? "array" : typeof value;
41
+ };
42
+
43
+ /** Whether `value` satisfies a schema `type` keyword; unknown keywords pass. */
44
+ const matchesType = (value: JsonValue, type: string): boolean => {
45
+ switch (type) {
46
+ case "object": {
47
+ return isJsonObject(value);
48
+ }
49
+ case "array": {
50
+ return Array.isArray(value);
51
+ }
52
+ case "string": {
53
+ return isString(value);
54
+ }
55
+ case "number": {
56
+ return isNumber(value);
57
+ }
58
+ case "integer": {
59
+ return isNumber(value) && Number.isInteger(value);
60
+ }
61
+ case "boolean": {
62
+ return isBoolean(value);
63
+ }
64
+ default: {
65
+ // A keyword the pruned subset doesn't model (e.g. "null"): no opinion.
66
+ return true;
67
+ }
68
+ }
69
+ };
70
+
71
+ const walk = (
72
+ value: JsonValue,
73
+ schema: ValidationSchema | undefined,
74
+ path: string,
75
+ errors: string[]
76
+ ): void => {
77
+ if (!schema) {
78
+ return;
79
+ }
80
+ if (schema.type !== undefined && !matchesType(value, schema.type)) {
81
+ // Deeper checks against a wrong-shaped value would only produce noise.
82
+ errors.push(
83
+ `${path} should be ${schema.type}, got ${describeValue(value)}`
84
+ );
85
+ return;
86
+ }
87
+ if (
88
+ schema.enum !== undefined &&
89
+ !schema.enum.some(
90
+ (member) => JSON.stringify(member) === JSON.stringify(value)
91
+ )
92
+ ) {
93
+ errors.push(
94
+ `${path} must be one of: ${schema.enum
95
+ .map((member) => JSON.stringify(member))
96
+ .join(", ")}`
97
+ );
98
+ }
99
+ if (isJsonObject(value)) {
100
+ for (const name of schema.required ?? []) {
101
+ if (!(name in value)) {
102
+ errors.push(`${path}.${name} is required`);
103
+ }
104
+ }
105
+ for (const [name, property] of Object.entries(schema.properties ?? {})) {
106
+ if (name in value) {
107
+ // SAFETY: `name in value` above proves the key exists, and parsed
108
+ // JSON never holds `undefined` — the lookup is always a `JsonValue`
109
+ // that `noUncheckedIndexedAccess` alone cannot see.
110
+ walk(value[name] as JsonValue, property, `${path}.${name}`, errors);
111
+ }
112
+ }
113
+ }
114
+ if (Array.isArray(value) && schema.items) {
115
+ for (const [index, item] of value.entries()) {
116
+ walk(item, schema.items, `${path}[${index}]`, errors);
117
+ }
118
+ }
119
+ };
120
+
121
+ /**
122
+ * Validate raw JSON text against a pruned schema. Returns human-readable
123
+ * error messages; an empty array means valid. With no schema, only syntax is
124
+ * checked. `root` names the value in those messages — the HTTP panel edits a
125
+ * `body`, the event composer a `payload`.
126
+ */
127
+ export const validateJson = (
128
+ text: string,
129
+ schema?: ValidationSchema,
130
+ root = "body"
131
+ ): string[] => {
132
+ // `JSON.parse` returns `any`; `JsonValue` is exactly its possible outputs.
133
+ let value: JsonValue;
134
+ try {
135
+ value = JSON.parse(text);
136
+ } catch (error) {
137
+ return [
138
+ `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
139
+ ];
140
+ }
141
+ const errors: string[] = [];
142
+ walk(value, schema, root, errors);
143
+ return errors;
144
+ };