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.
- package/CHANGELOG.md +32 -0
- package/README.md +16 -12
- package/dist/cli/index.js +449 -135
- package/dist/cli/index.js.map +24 -23
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +54 -2
- package/dist/types/core/data.d.ts +19 -2
- package/dist/types/core/open-in-chat.d.ts +9 -0
- package/dist/types/core/schema.d.ts +48 -1
- package/dist/types/core/types.d.ts +10 -3
- package/dist/types/openapi/references.d.ts +9 -0
- package/dist/types/search/orama-index.d.ts +70 -0
- package/dist/types/theme/fonts.d.ts +11 -2
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/advanced/custom-pages.mdx +5 -1
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +14 -2
- package/docs/configuration/search.mdx +4 -4
- package/docs/configuration/theming.mdx +4 -2
- package/docs/reference/cli.mdx +2 -2
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/ask-context.ts +51 -11
- package/src/ai/mcp/data.ts +3 -2
- package/src/ai/mcp/server.ts +3 -2
- package/src/assets/icon-dark.png +0 -0
- package/src/astro/generate.ts +172 -18
- package/src/astro/templates.ts +89 -15
- package/src/components/content/AccordionItem.astro +4 -0
- package/src/components/content/Update.astro +3 -0
- package/src/components/islands/AskAI.astro +6 -0
- package/src/components/islands/ask-ai.tsx +39 -9
- package/src/components/layout/Analytics.astro +9 -1
- package/src/components/layout/Favicon.astro +29 -8
- package/src/components/layout/Fonts.astro +23 -3
- package/src/components/layout/Header.astro +2 -2
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/PageActions.astro +120 -78
- package/src/components/layout/PageFeedback.astro +12 -3
- package/src/components/layout/PageLayout.astro +79 -5
- package/src/components/layout/ReferenceLayout.astro +12 -9
- package/src/components/layout/RootLayout.astro +153 -121
- package/src/components/layout/Search.astro +41 -26
- package/src/components/layout/drawer-inert.ts +10 -5
- package/src/components/layout/head-scripts.ts +34 -16
- package/src/components/layout/nav-utils.ts +34 -15
- package/src/components/layout/search/orama.ts +3 -2
- package/src/components/openapi/AsyncApiOperation.astro +22 -7
- package/src/components/openapi/MessageComposer.astro +238 -0
- package/src/components/openapi/Operation.astro +26 -12
- package/src/components/openapi/PanelTabs.astro +7 -0
- package/src/components/openapi/Playground.astro +320 -0
- package/src/components/openapi/RequestPanel.astro +1 -0
- package/src/components/openapi/async-snippets.ts +20 -7
- package/src/components/openapi/async.ts +13 -2
- package/src/components/openapi/message-composer.ts +242 -0
- package/src/components/openapi/message-model.ts +108 -0
- package/src/components/openapi/message.ts +153 -0
- package/src/components/openapi/operation-model.ts +260 -0
- package/src/components/openapi/playground-client.ts +486 -0
- package/src/components/openapi/playground-schema.ts +109 -0
- package/src/components/openapi/request.ts +287 -0
- package/src/components/openapi/security.ts +0 -56
- package/src/components/openapi/snippets.ts +23 -136
- package/src/components/openapi/validate-json.ts +144 -0
- package/src/components/openapi/ws-client.ts +194 -0
- package/src/core/config-input.ts +67 -1
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +16 -2
- package/src/core/last-modified.ts +76 -2
- package/src/core/links.ts +30 -4
- package/src/core/navigation.ts +26 -1
- package/src/core/open-in-chat.ts +17 -0
- package/src/core/project-graph.ts +11 -0
- package/src/core/schema.ts +60 -1
- package/src/core/server-features.ts +11 -0
- package/src/core/sources/normalize.ts +10 -2
- package/src/core/types.ts +10 -3
- package/src/deploy/vercel-negotiation.ts +34 -14
- package/src/og/card.ts +3 -1
- package/src/openapi/model.ts +7 -0
- package/src/openapi/proxy.ts +217 -0
- package/src/openapi/references.ts +8 -0
- package/src/openapi/source.ts +13 -0
- package/src/registry/eject.ts +4 -5
- package/src/search/orama-index.ts +109 -36
- package/src/theme/entry.ts +15 -2
- package/src/theme/fonts.ts +75 -3
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client logic for the "Try it" playground panel, loaded lazily on the first
|
|
3
|
+
* open of the `<details data-playground>` disclosure (see Playground.astro —
|
|
4
|
+
* the custom-element loader lives there so this module stays import-safe in
|
|
5
|
+
* tests). One request model drives everything: the same `buildRequest` output
|
|
6
|
+
* feeds the live code samples, the copyable snippets, and the actual fetch, so
|
|
7
|
+
* what a reader sends is byte-for-byte what the samples show.
|
|
8
|
+
*
|
|
9
|
+
* All response data is rendered through `textContent` — an API echoing HTML
|
|
10
|
+
* back must never execute in the docs page.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AuthValue,
|
|
15
|
+
PlaygroundAuthInput,
|
|
16
|
+
PlaygroundModel,
|
|
17
|
+
RequestValues,
|
|
18
|
+
} from "./request.ts";
|
|
19
|
+
import { buildRequest, redactAuth } from "./request.ts";
|
|
20
|
+
import { sampleLanguages } from "./snippets.ts";
|
|
21
|
+
import { validateJson } from "./validate-json.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A failed fetch surfaces as a TypeError with no status — almost always the
|
|
25
|
+
* browser's CORS wall, not the API being down — so the message explains the
|
|
26
|
+
* one fix docs authors control instead of parroting "failed to fetch".
|
|
27
|
+
*/
|
|
28
|
+
const CORS_MESSAGE =
|
|
29
|
+
"The browser blocked this request before it reached the API — the API " +
|
|
30
|
+
"likely does not allow cross-origin requests from this docs site. Set " +
|
|
31
|
+
"`openapi.playground.proxy` in the Blume config to route playground " +
|
|
32
|
+
"requests through the docs server instead.";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `Cookie` is a forbidden header name: a page cannot set it, so a credential
|
|
36
|
+
* the spec carries in a cookie can never ride a live send — the request would
|
|
37
|
+
* simply arrive unauthenticated. The samples DO carry it, so the message points
|
|
38
|
+
* at them rather than pretending the panel can.
|
|
39
|
+
*/
|
|
40
|
+
const COOKIE_MESSAGE =
|
|
41
|
+
"Browsers don't allow a page to set a `Cookie` header, so this panel can't " +
|
|
42
|
+
"send the cookie credential you entered. Copy the sample above and run it " +
|
|
43
|
+
"from a terminal instead.";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long a live send waits before giving up. Without a deadline a request
|
|
47
|
+
* that never answers leaves the panel on "Sending…" and the Send button
|
|
48
|
+
* disabled for the rest of the page's life.
|
|
49
|
+
*/
|
|
50
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
51
|
+
|
|
52
|
+
/** Convention used across the OpenAPI components for error-severity text. */
|
|
53
|
+
const ERROR_TEXT = "text-red-600 text-xs dark:text-red-400";
|
|
54
|
+
|
|
55
|
+
/** A typed flat-body field's JSON value: the raw input text or its coercion. */
|
|
56
|
+
type FieldValue = string | number | boolean;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Coerce a typed flat-body field's raw input text into its JSON value. Text
|
|
60
|
+
* that doesn't parse as the declared type is kept verbatim so the API (not
|
|
61
|
+
* the playground) reports the real validation error. An empty field stays the
|
|
62
|
+
* empty string — `Number("")` is 0, which would silently invent a value for a
|
|
63
|
+
* required numeric field the reader left blank.
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* Escape a spec-derived name for interpolation into a double-quoted attribute
|
|
67
|
+
* selector. `querySelector` throws a SyntaxError on an unescaped `"` or `\`,
|
|
68
|
+
* so one hostile body-property or scheme name would otherwise take the whole
|
|
69
|
+
* panel down on the first sync.
|
|
70
|
+
*/
|
|
71
|
+
const attrEscape = (value: string): string =>
|
|
72
|
+
value.replaceAll(/["\\]/gu, String.raw`\$&`);
|
|
73
|
+
|
|
74
|
+
const coerce = (raw: string, type: string): FieldValue => {
|
|
75
|
+
if (raw === "") {
|
|
76
|
+
return raw;
|
|
77
|
+
}
|
|
78
|
+
if (type === "number" || type === "integer") {
|
|
79
|
+
const numeric = Number(raw);
|
|
80
|
+
return Number.isNaN(numeric) ? raw : numeric;
|
|
81
|
+
}
|
|
82
|
+
if (type === "boolean" && (raw === "true" || raw === "false")) {
|
|
83
|
+
return raw === "true";
|
|
84
|
+
}
|
|
85
|
+
return raw;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pretty-print response text that parses as JSON; anything else (HTML error
|
|
90
|
+
* pages, plain text) is shown verbatim.
|
|
91
|
+
*/
|
|
92
|
+
const prettyBody = (text: string): string => {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
95
|
+
} catch {
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** A one-line text element for the response/error regions. */
|
|
101
|
+
const line = (className: string, text: string): HTMLElement => {
|
|
102
|
+
const el = document.createElement("div");
|
|
103
|
+
el.className = className;
|
|
104
|
+
el.textContent = text;
|
|
105
|
+
return el;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wire the playground inside `root` (the `<blume-playground>` element). Reads
|
|
110
|
+
* the server-rendered model JSON, keeps the request samples in sync with the
|
|
111
|
+
* form, validates deep JSON bodies, and sends the request on demand.
|
|
112
|
+
*/
|
|
113
|
+
export const initPlayground = (root: HTMLElement): void => {
|
|
114
|
+
const modelScript = root.querySelector("script[data-playground-model]");
|
|
115
|
+
if (!modelScript) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// SAFETY: the script tag is rendered by Playground.astro, which serializes
|
|
119
|
+
// exactly the `PlaygroundModel` that `operationModel` produced at build time.
|
|
120
|
+
const model = JSON.parse(modelScript.textContent ?? "") as PlaygroundModel;
|
|
121
|
+
|
|
122
|
+
// The sample panes live in the sibling RequestPanel, so they are looked up
|
|
123
|
+
// from the shared operation-panel wrapper rather than this element.
|
|
124
|
+
const scope = root.closest("[data-operation-panel]") ?? document;
|
|
125
|
+
const panes = [...scope.querySelectorAll<HTMLElement>("[data-sample-lang]")];
|
|
126
|
+
const languages = new Map(
|
|
127
|
+
sampleLanguages(panes.map((pane) => pane.dataset.sampleLang ?? "")).map(
|
|
128
|
+
(language) => [language.id, language]
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const serverSelect = root.querySelector<HTMLSelectElement>("[data-server]");
|
|
133
|
+
const serverCustom = root.querySelector<HTMLInputElement>(
|
|
134
|
+
"[data-server-custom]"
|
|
135
|
+
);
|
|
136
|
+
const paramInputs = [
|
|
137
|
+
...root.querySelectorAll<HTMLInputElement>("[data-param]"),
|
|
138
|
+
];
|
|
139
|
+
const bodyArea = root.querySelector<HTMLTextAreaElement>("[data-body]");
|
|
140
|
+
const bodyErrors = root.querySelector<HTMLElement>("[data-body-errors]");
|
|
141
|
+
const remember = root.querySelector<HTMLInputElement>("[data-auth-remember]");
|
|
142
|
+
const samplesAuth = root.querySelector<HTMLInputElement>(
|
|
143
|
+
"[data-samples-auth]"
|
|
144
|
+
);
|
|
145
|
+
const sendButton = root.querySelector<HTMLButtonElement>("[data-send]");
|
|
146
|
+
const response = root.querySelector<HTMLElement>("[data-response]");
|
|
147
|
+
const storageKey = root.dataset.storageKey ?? "";
|
|
148
|
+
const proxy = root.dataset.proxy ?? "";
|
|
149
|
+
|
|
150
|
+
/** True while a send is outstanding, so a second click can't race it. */
|
|
151
|
+
let sending = false;
|
|
152
|
+
|
|
153
|
+
const field = (selector: string): HTMLInputElement | null =>
|
|
154
|
+
root.querySelector<HTMLInputElement>(selector);
|
|
155
|
+
|
|
156
|
+
/** Credentials straight from the inputs, keyed by security-scheme id. */
|
|
157
|
+
const collectAuth = () => {
|
|
158
|
+
const auth: Record<string, AuthValue> = {};
|
|
159
|
+
for (const input of model.auth) {
|
|
160
|
+
auth[input.id] =
|
|
161
|
+
input.kind === "basic"
|
|
162
|
+
? {
|
|
163
|
+
password:
|
|
164
|
+
field(`[data-auth-password="${attrEscape(input.id)}"]`)
|
|
165
|
+
?.value ?? "",
|
|
166
|
+
username:
|
|
167
|
+
field(`[data-auth-username="${attrEscape(input.id)}"]`)
|
|
168
|
+
?.value ?? "",
|
|
169
|
+
value: "",
|
|
170
|
+
}
|
|
171
|
+
: {
|
|
172
|
+
value:
|
|
173
|
+
field(`[data-auth-value="${attrEscape(input.id)}"]`)?.value ??
|
|
174
|
+
"",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return auth;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Assemble the flat typed-fields UI into a JSON body. Empty optional fields
|
|
182
|
+
* are omitted; empty required fields are kept (as "") so the API reports
|
|
183
|
+
* the miss. No fields set at all means no body.
|
|
184
|
+
*/
|
|
185
|
+
const flatBody = (): string | undefined => {
|
|
186
|
+
const out: Record<string, FieldValue> = {};
|
|
187
|
+
for (const spec of model.body?.fields ?? []) {
|
|
188
|
+
const raw =
|
|
189
|
+
field(`[data-body-field="${attrEscape(spec.name)}"]`)?.value ?? "";
|
|
190
|
+
if (raw === "" && !spec.required) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
out[spec.name] = coerce(raw, spec.type);
|
|
194
|
+
}
|
|
195
|
+
return Object.keys(out).length > 0
|
|
196
|
+
? JSON.stringify(out, null, 2)
|
|
197
|
+
: undefined;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** The current form state as the shared RequestValues shape. */
|
|
201
|
+
const collect = (): RequestValues => {
|
|
202
|
+
const custom = serverCustom?.value.trim() ?? "";
|
|
203
|
+
const params: Record<string, string> = {};
|
|
204
|
+
for (const input of paramInputs) {
|
|
205
|
+
params[input.dataset.param ?? ""] = input.value;
|
|
206
|
+
}
|
|
207
|
+
let body: string | undefined;
|
|
208
|
+
if (bodyArea) {
|
|
209
|
+
body = bodyArea.value;
|
|
210
|
+
} else if (model.body?.fields) {
|
|
211
|
+
body = flatBody();
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
auth: collectAuth(),
|
|
215
|
+
body,
|
|
216
|
+
params,
|
|
217
|
+
server: custom || serverSelect?.value || model.servers[0] || "",
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Re-render every request-sample pane from the live form. Credentials are
|
|
223
|
+
* redacted to their placeholders unless the reader opted in — a copied
|
|
224
|
+
* snippet must never leak a real token by accident.
|
|
225
|
+
*/
|
|
226
|
+
const syncSamples = (): void => {
|
|
227
|
+
const values = collect();
|
|
228
|
+
const shown = samplesAuth?.checked ? values : redactAuth(model, values);
|
|
229
|
+
const sample = buildRequest(model, shown);
|
|
230
|
+
for (const pane of panes) {
|
|
231
|
+
const language = languages.get(pane.dataset.sampleLang ?? "");
|
|
232
|
+
if (!language) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const target = pane.querySelector("code") ?? pane;
|
|
236
|
+
target.textContent = language.build(sample);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** Validate the raw JSON body and list the messages; [] when body-less. */
|
|
241
|
+
const validateBody = (): string[] => {
|
|
242
|
+
if (!(bodyArea && bodyErrors)) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
// An emptied editor means "no body" (see `bodyFor`), not invalid JSON —
|
|
246
|
+
// reporting a syntax error there would block a send the request builder is
|
|
247
|
+
// perfectly happy to make.
|
|
248
|
+
const errors =
|
|
249
|
+
bodyArea.value.trim() === ""
|
|
250
|
+
? []
|
|
251
|
+
: validateJson(bodyArea.value, model.body?.schema);
|
|
252
|
+
bodyErrors.textContent = "";
|
|
253
|
+
for (const error of errors) {
|
|
254
|
+
bodyErrors.append(line(ERROR_TEXT, error));
|
|
255
|
+
}
|
|
256
|
+
return errors;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Restore remembered credentials into the inputs and re-check the box. */
|
|
260
|
+
const restoreAuth = (): void => {
|
|
261
|
+
const stored = localStorage.getItem(storageKey);
|
|
262
|
+
if (!stored) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
// SAFETY: this storage key is only ever written by `onEdit` below, which
|
|
267
|
+
// persists exactly the `collectAuth()` record; a corrupt foreign value at
|
|
268
|
+
// worst restores odd strings into the credential inputs.
|
|
269
|
+
const saved = JSON.parse(stored) as Record<string, AuthValue>;
|
|
270
|
+
for (const input of model.auth) {
|
|
271
|
+
const value = saved[input.id];
|
|
272
|
+
if (!value) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (input.kind === "basic") {
|
|
276
|
+
const username = field(
|
|
277
|
+
`[data-auth-username="${attrEscape(input.id)}"]`
|
|
278
|
+
);
|
|
279
|
+
const password = field(
|
|
280
|
+
`[data-auth-password="${attrEscape(input.id)}"]`
|
|
281
|
+
);
|
|
282
|
+
if (username) {
|
|
283
|
+
username.value = value.username ?? "";
|
|
284
|
+
}
|
|
285
|
+
if (password) {
|
|
286
|
+
password.value = value.password ?? "";
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
const single = field(`[data-auth-value="${attrEscape(input.id)}"]`);
|
|
290
|
+
if (single) {
|
|
291
|
+
single.value = value.value;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (remember) {
|
|
296
|
+
remember.checked = true;
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// A corrupt entry (older format, manual edit) must not break init.
|
|
300
|
+
localStorage.removeItem(storageKey);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/** Render one settled HTTP exchange: status + time, headers, pretty body. */
|
|
305
|
+
const renderResponse = (
|
|
306
|
+
region: HTMLElement,
|
|
307
|
+
res: Response,
|
|
308
|
+
ms: number,
|
|
309
|
+
text: string
|
|
310
|
+
): void => {
|
|
311
|
+
region.textContent = "";
|
|
312
|
+
region.append(
|
|
313
|
+
line(
|
|
314
|
+
"font-mono font-semibold text-foreground text-sm",
|
|
315
|
+
`${res.status} ${res.statusText} \u00B7 ${ms} ms`.trim()
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
const table = document.createElement("table");
|
|
319
|
+
table.className = "w-full text-left text-xs";
|
|
320
|
+
for (const [name, value] of res.headers) {
|
|
321
|
+
const row = document.createElement("tr");
|
|
322
|
+
const header = document.createElement("th");
|
|
323
|
+
header.setAttribute("scope", "row");
|
|
324
|
+
header.className = "pr-3 align-top font-medium text-muted-foreground";
|
|
325
|
+
header.textContent = name;
|
|
326
|
+
const cell = document.createElement("td");
|
|
327
|
+
cell.className = "break-all font-mono text-foreground";
|
|
328
|
+
cell.textContent = value;
|
|
329
|
+
row.append(header, cell);
|
|
330
|
+
table.append(row);
|
|
331
|
+
}
|
|
332
|
+
region.append(table);
|
|
333
|
+
const pre = document.createElement("pre");
|
|
334
|
+
pre.className =
|
|
335
|
+
"overflow-x-auto rounded-blume border border-border p-3 font-mono text-foreground text-xs";
|
|
336
|
+
const code = document.createElement("code");
|
|
337
|
+
code.textContent = prettyBody(text);
|
|
338
|
+
pre.append(code);
|
|
339
|
+
region.append(pre);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Send the real request. HTTP error statuses render like any response; only
|
|
344
|
+
* a rejected fetch (the CORS wall) gets the explanatory message. One request
|
|
345
|
+
* at a time: the Send button stays disabled until the exchange is rendered,
|
|
346
|
+
* and a request that never answers is abandoned after
|
|
347
|
+
* {@link REQUEST_TIMEOUT_MS} instead of leaving the panel stuck on "Sending".
|
|
348
|
+
*/
|
|
349
|
+
const send = async (): Promise<void> => {
|
|
350
|
+
if (!response || sending) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (validateBody().length > 0) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const values = collect();
|
|
357
|
+
// A cookie-borne credential cannot ride a live send: `Cookie` is a
|
|
358
|
+
// forbidden header name, so the browser drops it and the API answers 401
|
|
359
|
+
// for reasons the reader can't see. The copyable sample carries it fine.
|
|
360
|
+
if (
|
|
361
|
+
model.auth.some(
|
|
362
|
+
(input) =>
|
|
363
|
+
input.carrier.in === "cookie" &&
|
|
364
|
+
(values.auth[input.id]?.value ?? "") !== ""
|
|
365
|
+
)
|
|
366
|
+
) {
|
|
367
|
+
response.textContent = "";
|
|
368
|
+
response.append(line(ERROR_TEXT, COOKIE_MESSAGE));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// An auth input the reader left empty must not ride the wire:
|
|
372
|
+
// `buildRequest` would substitute the redaction placeholder \u2014 right for
|
|
373
|
+
// samples, but a live `Authorization: Bearer YOUR_TOKEN` turns an
|
|
374
|
+
// anonymous-capable request into a guaranteed 401. Untouched inputs are
|
|
375
|
+
// omitted so the send goes out without them.
|
|
376
|
+
const filled = (input: PlaygroundAuthInput): boolean => {
|
|
377
|
+
const auth = values.auth[input.id];
|
|
378
|
+
return input.kind === "basic"
|
|
379
|
+
? (auth?.username ?? "") !== "" || (auth?.password ?? "") !== ""
|
|
380
|
+
: (auth?.value ?? "") !== "";
|
|
381
|
+
};
|
|
382
|
+
const sample = buildRequest(
|
|
383
|
+
{ ...model, auth: model.auth.filter(filled) },
|
|
384
|
+
values
|
|
385
|
+
);
|
|
386
|
+
// An external proxy URL may already carry a query string of its own; the
|
|
387
|
+
// target parameter joins with `&` there, or the proxy would receive no
|
|
388
|
+
// `url` at all.
|
|
389
|
+
const url = proxy
|
|
390
|
+
? `${proxy}${proxy.includes("?") ? "&" : "?"}url=${encodeURIComponent(
|
|
391
|
+
sample.url
|
|
392
|
+
)}`
|
|
393
|
+
: sample.url;
|
|
394
|
+
// A `Cookie` header can still arrive via a spec-declared header parameter;
|
|
395
|
+
// the browser would silently drop the forbidden name anyway, so strip it
|
|
396
|
+
// rather than earn a console warning.
|
|
397
|
+
const headers = { ...sample.headers };
|
|
398
|
+
delete headers.Cookie;
|
|
399
|
+
// fetch surfaces an invalid URL or header value as the same TypeError a
|
|
400
|
+
// CORS rejection produces, and the catch below would misdiagnose it as the
|
|
401
|
+
// CORS wall. Both are validated here, where the real error can be shown,
|
|
402
|
+
// before anything is sent.
|
|
403
|
+
try {
|
|
404
|
+
void new Headers(headers);
|
|
405
|
+
void new URL(url, "http://localhost/");
|
|
406
|
+
} catch (error) {
|
|
407
|
+
response.textContent = "";
|
|
408
|
+
response.append(line(ERROR_TEXT, `Request failed: ${String(error)}`));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
response.textContent = "Sending\u2026";
|
|
412
|
+
sending = true;
|
|
413
|
+
if (sendButton) {
|
|
414
|
+
sendButton.disabled = true;
|
|
415
|
+
}
|
|
416
|
+
const start = performance.now();
|
|
417
|
+
try {
|
|
418
|
+
const res = await fetch(url, {
|
|
419
|
+
// fetch throws a synchronous TypeError for a GET/HEAD with a body —
|
|
420
|
+
// which the catch below would mislabel as CORS. A spec that declares
|
|
421
|
+
// a GET requestBody keeps its samples, but the live send drops it.
|
|
422
|
+
body:
|
|
423
|
+
sample.method === "GET" || sample.method === "HEAD"
|
|
424
|
+
? undefined
|
|
425
|
+
: sample.body,
|
|
426
|
+
headers,
|
|
427
|
+
method: sample.method,
|
|
428
|
+
// A `TimeoutError` DOMException is not a TypeError, so it reads as a
|
|
429
|
+
// request failure rather than the CORS wall.
|
|
430
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
431
|
+
});
|
|
432
|
+
const ms = Math.round(performance.now() - start);
|
|
433
|
+
renderResponse(response, res, ms, await res.text());
|
|
434
|
+
} catch (error) {
|
|
435
|
+
response.textContent = "";
|
|
436
|
+
response.append(
|
|
437
|
+
line(
|
|
438
|
+
ERROR_TEXT,
|
|
439
|
+
error instanceof TypeError
|
|
440
|
+
? CORS_MESSAGE
|
|
441
|
+
: `Request failed: ${String(error)}`
|
|
442
|
+
)
|
|
443
|
+
);
|
|
444
|
+
} finally {
|
|
445
|
+
sending = false;
|
|
446
|
+
if (sendButton) {
|
|
447
|
+
sendButton.disabled = false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* One delegated edit handler for both `input` and `change`: revalidate the
|
|
454
|
+
* body, maintain the remembered credentials, and re-sync the samples.
|
|
455
|
+
* Running twice for events that fire both is harmless — every action here
|
|
456
|
+
* is idempotent.
|
|
457
|
+
*/
|
|
458
|
+
const onEdit = (target: EventTarget | null): void => {
|
|
459
|
+
if (!(target instanceof HTMLElement)) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (bodyArea && target === bodyArea) {
|
|
463
|
+
validateBody();
|
|
464
|
+
}
|
|
465
|
+
if (remember && target === remember) {
|
|
466
|
+
if (remember.checked) {
|
|
467
|
+
localStorage.setItem(storageKey, JSON.stringify(collectAuth()));
|
|
468
|
+
} else {
|
|
469
|
+
localStorage.removeItem(storageKey);
|
|
470
|
+
}
|
|
471
|
+
} else if (
|
|
472
|
+
remember?.checked &&
|
|
473
|
+
(target.dataset.authValue !== undefined ||
|
|
474
|
+
target.dataset.authUsername !== undefined ||
|
|
475
|
+
target.dataset.authPassword !== undefined)
|
|
476
|
+
) {
|
|
477
|
+
localStorage.setItem(storageKey, JSON.stringify(collectAuth()));
|
|
478
|
+
}
|
|
479
|
+
syncSamples();
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
restoreAuth();
|
|
483
|
+
root.addEventListener("input", (event) => onEdit(event.target));
|
|
484
|
+
root.addEventListener("change", (event) => onEdit(event.target));
|
|
485
|
+
sendButton?.addEventListener("click", () => send());
|
|
486
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { resolveSchema } from "./helpers.ts";
|
|
2
|
+
import type { SchemaLike, SpecValue } from "./helpers.ts";
|
|
3
|
+
import type { ValidationSchema } from "./request.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Spec-schema lowering shared by both playground models — the HTTP request
|
|
7
|
+
* model (`operation-model.ts`) and the event message model
|
|
8
|
+
* (`message-model.ts`). Both need the same three things from a spec schema: a
|
|
9
|
+
* short type label for an input, a stringified default, and a pruned schema
|
|
10
|
+
* the client-side validator understands. Keeping them here is what lets the
|
|
11
|
+
* OpenAPI and AsyncAPI panels behave identically on the parts that are
|
|
12
|
+
* genuinely identical.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// `typeof` checks live in named predicates (the form the oxlint anti-slop
|
|
16
|
+
// config sanctions), mirroring the private guard in `helpers.ts`.
|
|
17
|
+
const isString = (value: SpecValue): value is string =>
|
|
18
|
+
typeof value === "string";
|
|
19
|
+
|
|
20
|
+
/** A schema's declared non-null type names (3.1 arrays flattened). */
|
|
21
|
+
export const declaredTypes = (
|
|
22
|
+
type: string | string[] | undefined
|
|
23
|
+
): string[] => {
|
|
24
|
+
if (!type) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
return (Array.isArray(type) ? type : [type]).filter(
|
|
28
|
+
(entry) => entry !== "null"
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Short input-type label for a schema, resolved one `$ref` level: "string" default. */
|
|
33
|
+
export const scalarType = (
|
|
34
|
+
schema: SchemaLike | undefined,
|
|
35
|
+
schemas: Record<string, SchemaLike>
|
|
36
|
+
): string => declaredTypes(resolveSchema(schemas, schema).type)[0] ?? "string";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stringify a precomputed default for a form input. Empty string means "no
|
|
40
|
+
* default"; non-string primitives round-trip through JSON so booleans and
|
|
41
|
+
* numbers read back exactly.
|
|
42
|
+
*/
|
|
43
|
+
export const inputValue = (value: SpecValue): string => {
|
|
44
|
+
if (value === undefined || value === null) {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
return isString(value) ? value : JSON.stringify(value);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Recursion limit for pruned validation schemas — deep enough for real specs. */
|
|
51
|
+
const MAX_SCHEMA_DEPTH = 6;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Prune a spec schema into the tiny subset `validate-json.ts` understands:
|
|
55
|
+
* `$ref`s resolved inline, cycles cut (the visited set is copied per branch so
|
|
56
|
+
* a ref reused by siblings still prunes fully), depth capped. Structure beyond
|
|
57
|
+
* the cut simply goes unvalidated — advisory checks, not a gate.
|
|
58
|
+
*/
|
|
59
|
+
const pruneSchema = (
|
|
60
|
+
schema: SchemaLike | undefined,
|
|
61
|
+
schemas: Record<string, SchemaLike>,
|
|
62
|
+
depth: number,
|
|
63
|
+
seen: ReadonlySet<string>
|
|
64
|
+
): ValidationSchema | undefined => {
|
|
65
|
+
if (!schema || depth >= MAX_SCHEMA_DEPTH) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
let visited = seen;
|
|
69
|
+
if (isString(schema.$ref)) {
|
|
70
|
+
if (seen.has(schema.$ref)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
visited = new Set(seen).add(schema.$ref);
|
|
74
|
+
}
|
|
75
|
+
const resolved = resolveSchema(schemas, schema);
|
|
76
|
+
const out: ValidationSchema = {};
|
|
77
|
+
const [type] = declaredTypes(resolved.type);
|
|
78
|
+
if (type) {
|
|
79
|
+
out.type = type;
|
|
80
|
+
}
|
|
81
|
+
if (resolved.enum) {
|
|
82
|
+
out.enum = resolved.enum;
|
|
83
|
+
}
|
|
84
|
+
if (resolved.required && resolved.required.length > 0) {
|
|
85
|
+
out.required = resolved.required;
|
|
86
|
+
}
|
|
87
|
+
if (resolved.properties) {
|
|
88
|
+
const properties: Record<string, ValidationSchema> = {};
|
|
89
|
+
for (const [name, property] of Object.entries(resolved.properties)) {
|
|
90
|
+
const pruned = pruneSchema(property, schemas, depth + 1, visited);
|
|
91
|
+
if (pruned) {
|
|
92
|
+
properties[name] = pruned;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(properties).length > 0) {
|
|
96
|
+
out.properties = properties;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (resolved.items) {
|
|
100
|
+
out.items = pruneSchema(resolved.items, schemas, depth + 1, visited);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** The validator-ready view of a spec schema; undefined when there is none. */
|
|
106
|
+
export const validationSchema = (
|
|
107
|
+
schema: SchemaLike | undefined,
|
|
108
|
+
schemas: Record<string, SchemaLike>
|
|
109
|
+
): ValidationSchema | undefined => pruneSchema(schema, schemas, 0, new Set());
|