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,320 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
import { paramKey, type PlaygroundModel } from "./request.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The interactive "Try it" panel for one operation. Everything is
|
|
8
|
+
* server-rendered — a native collapsed `<details>` with plain labelled
|
|
9
|
+
* inputs, keyboard-operable with zero JS — and the client module only loads
|
|
10
|
+
* when the reader first opens it (see the loader script at the foot of this
|
|
11
|
+
* file). The request model the client consumes is embedded as JSON so the
|
|
12
|
+
* lazy chunk needs no data fetch.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
model: PlaygroundModel;
|
|
17
|
+
/** Proxy route for the send, or false to fetch the API directly. */
|
|
18
|
+
proxy: string | false;
|
|
19
|
+
/** Spec slug + operation key form the per-operation localStorage key. */
|
|
20
|
+
slug: string;
|
|
21
|
+
operation: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { model, proxy, slug, operation } = Astro.props;
|
|
25
|
+
|
|
26
|
+
// `</script>`-proof embedding: `<` only ever occurs inside JSON strings,
|
|
27
|
+
// where the \u escape is equivalent, so a spec description can never close
|
|
28
|
+
// the model script early and inject markup.
|
|
29
|
+
const modelJson = JSON.stringify(model).replaceAll("<", "\\u003c");
|
|
30
|
+
|
|
31
|
+
// Unique per rendered panel (a page can hold several), so the textarea's
|
|
32
|
+
// aria-describedby can point at ITS error container and no other.
|
|
33
|
+
const bodyErrorsId = `blume-body-errors-${randomUUID()}`;
|
|
34
|
+
|
|
35
|
+
const LABEL_ROW =
|
|
36
|
+
"mb-1 flex flex-wrap items-baseline gap-x-1.5 font-medium text-foreground text-xs";
|
|
37
|
+
const FIELD =
|
|
38
|
+
"w-full rounded border border-border bg-background px-2 py-1.5 font-mono text-foreground text-xs";
|
|
39
|
+
const HINT = "mt-1 block text-muted-foreground text-xs";
|
|
40
|
+
const GROUP = "flex flex-col gap-3";
|
|
41
|
+
const GROUP_HEADING =
|
|
42
|
+
"font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide";
|
|
43
|
+
const CHECKBOX_ROW =
|
|
44
|
+
"flex cursor-pointer items-center gap-2 text-foreground text-xs";
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<blume-playground
|
|
48
|
+
class="mb-6 block"
|
|
49
|
+
data-proxy={proxy || undefined}
|
|
50
|
+
data-storage-key={`blume-playground:${slug}:${operation}`}
|
|
51
|
+
>
|
|
52
|
+
<script
|
|
53
|
+
data-playground-model
|
|
54
|
+
is:inline
|
|
55
|
+
set:html={modelJson}
|
|
56
|
+
type="application/json"
|
|
57
|
+
/>
|
|
58
|
+
<details
|
|
59
|
+
class="not-prose rounded-blume border border-border bg-background"
|
|
60
|
+
data-playground
|
|
61
|
+
>
|
|
62
|
+
<summary class="cursor-pointer px-4 py-3 font-semibold text-foreground text-sm">
|
|
63
|
+
Try it
|
|
64
|
+
</summary>
|
|
65
|
+
<div class="flex flex-col gap-5 border-border border-t px-4 py-4">
|
|
66
|
+
<div class={GROUP}>
|
|
67
|
+
<div class={GROUP_HEADING}>Server</div>
|
|
68
|
+
{
|
|
69
|
+
model.servers.length > 0 && (
|
|
70
|
+
<label class="block">
|
|
71
|
+
<span class={LABEL_ROW}>Base URL</span>
|
|
72
|
+
<select class={FIELD} data-server>
|
|
73
|
+
{model.servers.map((server) => (
|
|
74
|
+
<option value={server}>{server}</option>
|
|
75
|
+
))}
|
|
76
|
+
</select>
|
|
77
|
+
</label>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
<label class="block">
|
|
81
|
+
<span class={LABEL_ROW}>Custom base URL</span>
|
|
82
|
+
<input
|
|
83
|
+
class={FIELD}
|
|
84
|
+
data-server-custom
|
|
85
|
+
placeholder="https://api.example.com"
|
|
86
|
+
type="text"
|
|
87
|
+
/>
|
|
88
|
+
<span class={HINT}>Overrides the selected server when set.</span>
|
|
89
|
+
</label>
|
|
90
|
+
</div>
|
|
91
|
+
{
|
|
92
|
+
model.auth.length > 0 && (
|
|
93
|
+
<div class={GROUP}>
|
|
94
|
+
<div class={GROUP_HEADING}>
|
|
95
|
+
Authorization{model.authOptional && " (optional)"}
|
|
96
|
+
</div>
|
|
97
|
+
{model.auth.map((input) =>
|
|
98
|
+
input.kind === "basic" ? (
|
|
99
|
+
<div class={GROUP}>
|
|
100
|
+
<label class="block">
|
|
101
|
+
<span class={LABEL_ROW}>{input.label} username</span>
|
|
102
|
+
<input
|
|
103
|
+
autocomplete="off"
|
|
104
|
+
class={FIELD}
|
|
105
|
+
data-auth-username={input.id}
|
|
106
|
+
type="text"
|
|
107
|
+
/>
|
|
108
|
+
</label>
|
|
109
|
+
<label class="block">
|
|
110
|
+
<span class={LABEL_ROW}>{input.label} password</span>
|
|
111
|
+
<input
|
|
112
|
+
autocomplete="off"
|
|
113
|
+
class={FIELD}
|
|
114
|
+
data-auth-password={input.id}
|
|
115
|
+
type="password"
|
|
116
|
+
/>
|
|
117
|
+
</label>
|
|
118
|
+
</div>
|
|
119
|
+
) : (
|
|
120
|
+
<label class="block">
|
|
121
|
+
<span class={LABEL_ROW}>{input.label}</span>
|
|
122
|
+
<input
|
|
123
|
+
autocomplete="off"
|
|
124
|
+
class={FIELD}
|
|
125
|
+
data-auth-value={input.id}
|
|
126
|
+
placeholder={input.placeholder}
|
|
127
|
+
type="password"
|
|
128
|
+
/>
|
|
129
|
+
</label>
|
|
130
|
+
)
|
|
131
|
+
)}
|
|
132
|
+
<label class={CHECKBOX_ROW}>
|
|
133
|
+
<input data-auth-remember type="checkbox" />
|
|
134
|
+
Remember on this device
|
|
135
|
+
</label>
|
|
136
|
+
<label class={CHECKBOX_ROW}>
|
|
137
|
+
<input data-samples-auth type="checkbox" />
|
|
138
|
+
Include my values in samples
|
|
139
|
+
</label>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
{
|
|
144
|
+
model.params.length > 0 && (
|
|
145
|
+
<div class={GROUP}>
|
|
146
|
+
<div class={GROUP_HEADING}>Parameters</div>
|
|
147
|
+
{model.params.map((param) => (
|
|
148
|
+
<label class="block">
|
|
149
|
+
<span class={LABEL_ROW}>
|
|
150
|
+
<code class="font-mono">{param.name}</code>
|
|
151
|
+
<span class="text-muted-foreground">{param.in}</span>
|
|
152
|
+
{param.required && (
|
|
153
|
+
<span
|
|
154
|
+
class="text-red-600 dark:text-red-400"
|
|
155
|
+
title="required"
|
|
156
|
+
>
|
|
157
|
+
*
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
</span>
|
|
161
|
+
{param.enum ? (
|
|
162
|
+
<select
|
|
163
|
+
class={FIELD}
|
|
164
|
+
data-param={paramKey(param)}
|
|
165
|
+
title={param.description}
|
|
166
|
+
>
|
|
167
|
+
{!param.required && <option value="">—</option>}
|
|
168
|
+
{param.enum.map((value) => (
|
|
169
|
+
<option selected={value === param.value} value={value}>
|
|
170
|
+
{value}
|
|
171
|
+
</option>
|
|
172
|
+
))}
|
|
173
|
+
</select>
|
|
174
|
+
) : (
|
|
175
|
+
<input
|
|
176
|
+
class={FIELD}
|
|
177
|
+
data-param={paramKey(param)}
|
|
178
|
+
placeholder={param.type}
|
|
179
|
+
title={param.description}
|
|
180
|
+
type="text"
|
|
181
|
+
value={param.value}
|
|
182
|
+
/>
|
|
183
|
+
)}
|
|
184
|
+
{param.description && (
|
|
185
|
+
<span class={HINT} set:text={param.description} />
|
|
186
|
+
)}
|
|
187
|
+
</label>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
{
|
|
193
|
+
model.body && (
|
|
194
|
+
<div class={GROUP}>
|
|
195
|
+
<div class={GROUP_HEADING}>
|
|
196
|
+
Body
|
|
197
|
+
<span class="ml-1.5 font-mono normal-case">
|
|
198
|
+
{model.body.contentType}
|
|
199
|
+
</span>
|
|
200
|
+
</div>
|
|
201
|
+
{model.body.fields ? (
|
|
202
|
+
model.body.fields.map((bodyField) => (
|
|
203
|
+
<label class="block">
|
|
204
|
+
<span class={LABEL_ROW}>
|
|
205
|
+
<code class="font-mono">{bodyField.name}</code>
|
|
206
|
+
<span class="text-muted-foreground">{bodyField.type}</span>
|
|
207
|
+
{bodyField.required && (
|
|
208
|
+
<span
|
|
209
|
+
class="text-red-600 dark:text-red-400"
|
|
210
|
+
title="required"
|
|
211
|
+
>
|
|
212
|
+
*
|
|
213
|
+
</span>
|
|
214
|
+
)}
|
|
215
|
+
</span>
|
|
216
|
+
{bodyField.enum ? (
|
|
217
|
+
<select
|
|
218
|
+
class={FIELD}
|
|
219
|
+
data-body-field={bodyField.name}
|
|
220
|
+
title={bodyField.description}
|
|
221
|
+
>
|
|
222
|
+
{!bodyField.required && <option value="">—</option>}
|
|
223
|
+
{bodyField.enum.map((value) => (
|
|
224
|
+
<option
|
|
225
|
+
selected={value === bodyField.value}
|
|
226
|
+
value={value}
|
|
227
|
+
>
|
|
228
|
+
{value}
|
|
229
|
+
</option>
|
|
230
|
+
))}
|
|
231
|
+
</select>
|
|
232
|
+
) : (
|
|
233
|
+
<input
|
|
234
|
+
class={FIELD}
|
|
235
|
+
data-body-field={bodyField.name}
|
|
236
|
+
placeholder={bodyField.type}
|
|
237
|
+
title={bodyField.description}
|
|
238
|
+
type="text"
|
|
239
|
+
value={bodyField.value}
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
{bodyField.description && (
|
|
243
|
+
<span class={HINT} set:text={bodyField.description} />
|
|
244
|
+
)}
|
|
245
|
+
</label>
|
|
246
|
+
))
|
|
247
|
+
) : (
|
|
248
|
+
<>
|
|
249
|
+
<label class="block">
|
|
250
|
+
<span class={LABEL_ROW}>Request body</span>
|
|
251
|
+
<textarea
|
|
252
|
+
aria-describedby={bodyErrorsId}
|
|
253
|
+
class={FIELD}
|
|
254
|
+
data-body
|
|
255
|
+
rows="8"
|
|
256
|
+
spellcheck="false">{model.body.example}</textarea
|
|
257
|
+
>
|
|
258
|
+
</label>
|
|
259
|
+
<div
|
|
260
|
+
aria-live="polite"
|
|
261
|
+
class="mt-1 flex flex-col gap-0.5 empty:hidden"
|
|
262
|
+
data-body-errors
|
|
263
|
+
id={bodyErrorsId}
|
|
264
|
+
/>
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
<div>
|
|
271
|
+
<button
|
|
272
|
+
class="cursor-pointer rounded-blume bg-accent px-4 py-2 font-medium text-accent-foreground text-sm transition hover:bg-accent/90"
|
|
273
|
+
data-send
|
|
274
|
+
type="button"
|
|
275
|
+
>
|
|
276
|
+
Send
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
<div
|
|
280
|
+
aria-live="polite"
|
|
281
|
+
class="flex flex-col gap-2 empty:hidden"
|
|
282
|
+
data-response
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
</details>
|
|
286
|
+
</blume-playground>
|
|
287
|
+
|
|
288
|
+
<script>
|
|
289
|
+
// Hydration waits for intent: a collapsed <details> can only fire its first
|
|
290
|
+
// `toggle` by opening, so the one-shot listener imports the client module —
|
|
291
|
+
// a separate chunk readers who never try a request never download — and
|
|
292
|
+
// wires the form. The custom element lives here, not in the client module,
|
|
293
|
+
// so that module stays import-safe under the test runner.
|
|
294
|
+
class BlumePlayground extends HTMLElement {
|
|
295
|
+
connectedCallback() {
|
|
296
|
+
const details = this.querySelector<HTMLDetailsElement>(
|
|
297
|
+
"details[data-playground]"
|
|
298
|
+
);
|
|
299
|
+
if (!details) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const init = async () => {
|
|
303
|
+
const { initPlayground } = await import("./playground-client.ts");
|
|
304
|
+
initPlayground(this);
|
|
305
|
+
};
|
|
306
|
+
// A re-attached element (bfcache restore, DOM moves) can arrive with the
|
|
307
|
+
// panel already open — its first toggle has fired, so a listener alone
|
|
308
|
+
// would leave a visible form dead.
|
|
309
|
+
if (details.open) {
|
|
310
|
+
void init();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
details.addEventListener("toggle", init, { once: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!customElements.get("blume-playground")) {
|
|
318
|
+
customElements.define("blume-playground", BlumePlayground);
|
|
319
|
+
}
|
|
320
|
+
</script>
|
|
@@ -2,7 +2,6 @@ import type {
|
|
|
2
2
|
AsyncApiAction,
|
|
3
3
|
AsyncApiServerObject,
|
|
4
4
|
} from "../../openapi/asyncapi.ts";
|
|
5
|
-
import { toJson } from "./helpers.ts";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Protocol-aware code samples for AsyncAPI operations — the async counterpart
|
|
@@ -12,6 +11,11 @@ import { toJson } from "./helpers.ts";
|
|
|
12
11
|
* Protocols without a supported tool yield no samples at all — the message
|
|
13
12
|
* example panel already shows the payload, and fabricating a client for an
|
|
14
13
|
* unknown binding would be worse than nothing.
|
|
14
|
+
*
|
|
15
|
+
* Dependency-free for the same reason `snippets.ts` is: the event composer
|
|
16
|
+
* re-renders these samples live in the browser, so importing anything from
|
|
17
|
+
* `helpers.ts` would drag openapi-sampler into that lazy chunk (~13 kB) to
|
|
18
|
+
* pretty-print a payload.
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
21
|
/** Everything a snippet builder needs about one operation. */
|
|
@@ -39,15 +43,24 @@ const hostParts = (server?: AsyncApiServerObject) => {
|
|
|
39
43
|
const shellQuote = (text: string): string =>
|
|
40
44
|
`'${text.replaceAll("'", String.raw`'\''`)}'`;
|
|
41
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Indented payload for a snippet that spans lines, matching `toJson`. Only an
|
|
48
|
+
* absent payload (unparseable editor text) becomes `{}`; a payload of `null`
|
|
49
|
+
* is what the reader typed and travels as written.
|
|
50
|
+
*/
|
|
42
51
|
const payloadJson = (sample: MessageSample): string =>
|
|
43
|
-
|
|
52
|
+
JSON.stringify(sample.payload === undefined ? {} : sample.payload, null, 2);
|
|
44
53
|
|
|
45
54
|
/** Compact single-line payload for shell `-m`/`echo` arguments. */
|
|
46
55
|
const payloadInline = (sample: MessageSample): string =>
|
|
47
|
-
JSON.stringify(sample.payload
|
|
56
|
+
JSON.stringify(sample.payload === undefined ? {} : sample.payload);
|
|
48
57
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `wss://host/path` for a WebSocket channel; the address is the path. Exported
|
|
60
|
+
* because the live composer connects to exactly this URL — the samples and the
|
|
61
|
+
* connection cannot point at different endpoints.
|
|
62
|
+
*/
|
|
63
|
+
export const webSocketUrl = (sample: MessageSample): string => {
|
|
51
64
|
const { server } = sample;
|
|
52
65
|
const scheme = server?.protocol === "ws" ? "ws" : "wss";
|
|
53
66
|
const host = server?.host ?? "localhost";
|
|
@@ -59,14 +72,14 @@ const wsUrl = (sample: MessageSample): string => {
|
|
|
59
72
|
};
|
|
60
73
|
|
|
61
74
|
const wscatSnippet = (sample: MessageSample): string => {
|
|
62
|
-
const connect = `wscat -c ${shellQuote(
|
|
75
|
+
const connect = `wscat -c ${shellQuote(webSocketUrl(sample))}`;
|
|
63
76
|
return sample.action === "receive"
|
|
64
77
|
? `${connect}\n> ${payloadInline(sample)}`
|
|
65
78
|
: `# Prints each message as it arrives\n${connect}`;
|
|
66
79
|
};
|
|
67
80
|
|
|
68
81
|
const webSocketSnippet = (sample: MessageSample): string => {
|
|
69
|
-
const open = `const socket = new WebSocket(${JSON.stringify(
|
|
82
|
+
const open = `const socket = new WebSocket(${JSON.stringify(webSocketUrl(sample))});`;
|
|
70
83
|
if (sample.action === "receive") {
|
|
71
84
|
return [
|
|
72
85
|
open,
|
|
@@ -182,6 +182,7 @@ interface AsyncApiParameterLike extends AsyncApiRefLike {
|
|
|
182
182
|
description?: string;
|
|
183
183
|
default?: AsyncApiSpecValue;
|
|
184
184
|
enum?: AsyncApiSpecValue[];
|
|
185
|
+
examples?: AsyncApiSpecValue[];
|
|
185
186
|
}
|
|
186
187
|
|
|
187
188
|
/**
|
|
@@ -213,7 +214,7 @@ export const channelParameters = (
|
|
|
213
214
|
if (parameter.default !== undefined) {
|
|
214
215
|
schema.default = parameter.default;
|
|
215
216
|
}
|
|
216
|
-
|
|
217
|
+
const lowered: ParameterLike = {
|
|
217
218
|
description: isString(parameter.description)
|
|
218
219
|
? parameter.description
|
|
219
220
|
: undefined,
|
|
@@ -221,7 +222,17 @@ export const channelParameters = (
|
|
|
221
222
|
name,
|
|
222
223
|
required: true,
|
|
223
224
|
schema,
|
|
224
|
-
}
|
|
225
|
+
};
|
|
226
|
+
// AsyncAPI parameters carry `examples` (an array of strings) rather than
|
|
227
|
+
// OpenAPI's singular; the first usable one lowers into the shared slot so
|
|
228
|
+
// the composer can prefill from it.
|
|
229
|
+
const example = Array.isArray(parameter.examples)
|
|
230
|
+
? parameter.examples.find(isString)
|
|
231
|
+
: undefined;
|
|
232
|
+
if (example !== undefined) {
|
|
233
|
+
lowered.example = example;
|
|
234
|
+
}
|
|
235
|
+
parameters.push(lowered);
|
|
225
236
|
}
|
|
226
237
|
return parameters;
|
|
227
238
|
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client logic for the event "Try it" composer, loaded lazily on the first open
|
|
3
|
+
* of the `<details data-composer>` disclosure (see MessageComposer.astro — the
|
|
4
|
+
* custom-element loader lives there so this module stays import-safe in
|
|
5
|
+
* tests). One message model drives everything: the same {@link buildMessage}
|
|
6
|
+
* output feeds the protocol-aware samples, the connect URL, and the frame that
|
|
7
|
+
* goes on the wire, so what a reader copies is what the panel sends.
|
|
8
|
+
*
|
|
9
|
+
* Frames are rendered through `textContent` — a broker echoing HTML back must
|
|
10
|
+
* never execute in the docs page.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { asyncSampleLanguages, webSocketUrl } from "./async-snippets.ts";
|
|
14
|
+
import type { MessageModel, MessageValues } from "./message.ts";
|
|
15
|
+
import { buildMessage, messageFrame } from "./message.ts";
|
|
16
|
+
import { validateJson } from "./validate-json.ts";
|
|
17
|
+
import type { WsFrame, WsState } from "./ws-client.ts";
|
|
18
|
+
import { createWsClient } from "./ws-client.ts";
|
|
19
|
+
|
|
20
|
+
/** Convention used across the OpenAPI components for error-severity text. */
|
|
21
|
+
const ERROR_TEXT = "text-red-600 text-xs dark:text-red-400";
|
|
22
|
+
|
|
23
|
+
/** The status line's normal (non-error) styling. */
|
|
24
|
+
const MUTED_TEXT = "text-muted-foreground text-xs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A failed WebSocket handshake gives the page no reason — the browser withholds
|
|
28
|
+
* it — so the status line explains the causes a docs author can act on instead
|
|
29
|
+
* of showing a bare "error".
|
|
30
|
+
*/
|
|
31
|
+
const ERROR_MESSAGE =
|
|
32
|
+
"The connection failed. The broker may reject requests from this origin, " +
|
|
33
|
+
"require credentials the URL doesn't carry, or be unreachable from here.";
|
|
34
|
+
|
|
35
|
+
/** Wall-clock prefix for a logged frame, e.g. `14:03:11`. */
|
|
36
|
+
const stamp = (at: number): string => new Date(at).toLocaleTimeString();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wire the composer inside `root` (the `<blume-message-composer>` element).
|
|
40
|
+
* Keeps the samples in sync with the form, validates the payload, and — for
|
|
41
|
+
* WebSocket bindings only — drives a live connection.
|
|
42
|
+
*/
|
|
43
|
+
export const initComposer = (root: HTMLElement): void => {
|
|
44
|
+
const modelScript = root.querySelector("script[data-composer-model]");
|
|
45
|
+
if (!modelScript) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// SAFETY: this script tag is written only by MessageComposer.astro, which
|
|
49
|
+
// serializes a typed MessageModel into it at build time — the JSON here can
|
|
50
|
+
// hold nothing else.
|
|
51
|
+
const model = JSON.parse(modelScript.textContent ?? "") as MessageModel;
|
|
52
|
+
|
|
53
|
+
// The sample panes live in the sibling rail, so they are looked up from the
|
|
54
|
+
// shared operation-panel wrapper rather than this element.
|
|
55
|
+
const scope = root.closest("[data-operation-panel]") ?? document;
|
|
56
|
+
const panes = [...scope.querySelectorAll<HTMLElement>("[data-sample-lang]")];
|
|
57
|
+
const languages = new Map(
|
|
58
|
+
asyncSampleLanguages(
|
|
59
|
+
panes.map((pane) => pane.dataset.sampleLang ?? ""),
|
|
60
|
+
model.protocol
|
|
61
|
+
).map((language) => [language.id, language])
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const serverSelect = root.querySelector<HTMLSelectElement>("[data-server]");
|
|
65
|
+
const serverCustom = root.querySelector<HTMLInputElement>(
|
|
66
|
+
"[data-server-custom]"
|
|
67
|
+
);
|
|
68
|
+
const paramInputs = [
|
|
69
|
+
...root.querySelectorAll<HTMLInputElement>("[data-param]"),
|
|
70
|
+
];
|
|
71
|
+
const payloadArea = root.querySelector<HTMLTextAreaElement>("[data-payload]");
|
|
72
|
+
const payloadErrors = root.querySelector<HTMLElement>(
|
|
73
|
+
"[data-payload-errors]"
|
|
74
|
+
);
|
|
75
|
+
const connectButton = root.querySelector<HTMLButtonElement>("[data-connect]");
|
|
76
|
+
const disconnectButton =
|
|
77
|
+
root.querySelector<HTMLButtonElement>("[data-disconnect]");
|
|
78
|
+
const sendButton = root.querySelector<HTMLButtonElement>("[data-send]");
|
|
79
|
+
const status = root.querySelector<HTMLElement>("[data-status]");
|
|
80
|
+
const log = root.querySelector<HTMLElement>("[data-log]");
|
|
81
|
+
|
|
82
|
+
/** The current form state as the shared MessageValues shape. */
|
|
83
|
+
const collect = (): MessageValues => {
|
|
84
|
+
const params: Record<string, string> = {};
|
|
85
|
+
for (const input of paramInputs) {
|
|
86
|
+
params[input.dataset.param ?? ""] = input.value;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
customUrl: serverCustom?.value ?? "",
|
|
90
|
+
params,
|
|
91
|
+
payload: payloadArea?.value ?? model.payload.example,
|
|
92
|
+
server: Number(serverSelect?.value ?? "0"),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Re-render every sample pane from the live form. */
|
|
97
|
+
const syncSamples = (): void => {
|
|
98
|
+
const sample = buildMessage(model, collect());
|
|
99
|
+
for (const pane of panes) {
|
|
100
|
+
const language = languages.get(pane.dataset.sampleLang ?? "");
|
|
101
|
+
if (!language) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const target = pane.querySelector("code") ?? pane;
|
|
105
|
+
target.textContent = language.build(sample);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Validate the payload editor and list the messages; [] when there is none. */
|
|
110
|
+
const validatePayload = (): string[] => {
|
|
111
|
+
if (!(payloadArea && payloadErrors)) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
// An empty editor means "no payload" — `buildMessage` derives none and the
|
|
115
|
+
// frame degrades to `{}` — not invalid JSON. Reporting a syntax error there
|
|
116
|
+
// would permanently block Connect on an operation with no message example.
|
|
117
|
+
const errors =
|
|
118
|
+
payloadArea.value.trim() === ""
|
|
119
|
+
? []
|
|
120
|
+
: validateJson(payloadArea.value, model.payload.schema, "payload");
|
|
121
|
+
payloadErrors.textContent = "";
|
|
122
|
+
for (const error of errors) {
|
|
123
|
+
const item = document.createElement("span");
|
|
124
|
+
item.className = ERROR_TEXT;
|
|
125
|
+
item.textContent = error;
|
|
126
|
+
payloadErrors.append(item);
|
|
127
|
+
}
|
|
128
|
+
return errors;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The URL the live socket was dialed with; "" when nothing is connected. The
|
|
133
|
+
* status line and the send path read this rather than recomputing from the
|
|
134
|
+
* form, because the form can move on while a socket stays where it was.
|
|
135
|
+
*/
|
|
136
|
+
let connectedUrl = "";
|
|
137
|
+
|
|
138
|
+
/** The connect URL the form currently describes. */
|
|
139
|
+
const formUrl = (): string => webSocketUrl(buildMessage(model, collect()));
|
|
140
|
+
|
|
141
|
+
/** Write one message onto the status line, if the panel rendered one. */
|
|
142
|
+
const setStatus = (text: string, className: string): void => {
|
|
143
|
+
if (!status) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
status.textContent = text;
|
|
147
|
+
status.className = className;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Reflect the connection state onto the status line and the buttons. */
|
|
151
|
+
const renderState = (state: WsState, detail?: string): void => {
|
|
152
|
+
const live = state === "connecting" || state === "open";
|
|
153
|
+
if (!live) {
|
|
154
|
+
connectedUrl = "";
|
|
155
|
+
}
|
|
156
|
+
if (connectButton) {
|
|
157
|
+
connectButton.disabled = live;
|
|
158
|
+
}
|
|
159
|
+
if (disconnectButton) {
|
|
160
|
+
disconnectButton.disabled = !live;
|
|
161
|
+
}
|
|
162
|
+
if (sendButton) {
|
|
163
|
+
sendButton.disabled = state !== "open";
|
|
164
|
+
}
|
|
165
|
+
if (state === "connecting") {
|
|
166
|
+
setStatus("Connecting\u2026", MUTED_TEXT);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (state === "open") {
|
|
170
|
+
setStatus(`Connected to ${connectedUrl}.`, MUTED_TEXT);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (state === "error") {
|
|
174
|
+
setStatus(ERROR_MESSAGE, ERROR_TEXT);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
setStatus(
|
|
178
|
+
detail ? `Disconnected (${detail}).` : "Not connected.",
|
|
179
|
+
MUTED_TEXT
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Append one frame to the log: timestamp, direction, and the raw text. */
|
|
184
|
+
const renderFrame = (frame: WsFrame): void => {
|
|
185
|
+
if (!log) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const row = document.createElement("div");
|
|
189
|
+
row.className = "flex gap-2 font-mono text-xs";
|
|
190
|
+
const meta = document.createElement("span");
|
|
191
|
+
meta.className = "shrink-0 text-muted-foreground";
|
|
192
|
+
meta.textContent = `${stamp(frame.at)} ${frame.direction === "sent" ? "\u2191" : "\u2193"}`;
|
|
193
|
+
const text = document.createElement("span");
|
|
194
|
+
text.className = "break-all text-foreground";
|
|
195
|
+
text.textContent = frame.text;
|
|
196
|
+
row.append(meta, text);
|
|
197
|
+
log.append(row);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const client = createWsClient({ onFrame: renderFrame, onState: renderState });
|
|
201
|
+
|
|
202
|
+
connectButton?.addEventListener("click", () => {
|
|
203
|
+
if (validatePayload().length > 0) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// A blank channel parameter leaves its `{name}` template in the address, so
|
|
207
|
+
// connecting would dial a URL no broker has a channel for.
|
|
208
|
+
const missing = paramInputs
|
|
209
|
+
.filter((input) => input.value === "")
|
|
210
|
+
.map((input) => input.dataset.param ?? "");
|
|
211
|
+
if (missing.length > 0) {
|
|
212
|
+
setStatus(
|
|
213
|
+
`Fill in every channel parameter before connecting: ${missing.join(", ")}.`,
|
|
214
|
+
ERROR_TEXT
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
connectedUrl = formUrl();
|
|
219
|
+
client.connect(connectedUrl);
|
|
220
|
+
});
|
|
221
|
+
disconnectButton?.addEventListener("click", () => client.disconnect());
|
|
222
|
+
sendButton?.addEventListener("click", () => {
|
|
223
|
+
if (validatePayload().length > 0) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
client.send(messageFrame(buildMessage(model, collect())));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const onEdit = (): void => {
|
|
230
|
+
// The socket is bound to the URL it was dialed with: once the server or a
|
|
231
|
+
// channel parameter moves, the open connection is the wrong endpoint, so it
|
|
232
|
+
// is dropped rather than left to answer for the form on screen.
|
|
233
|
+
if (connectedUrl !== "" && formUrl() !== connectedUrl) {
|
|
234
|
+
client.disconnect();
|
|
235
|
+
setStatus("The endpoint changed. Connect again to use it.", MUTED_TEXT);
|
|
236
|
+
}
|
|
237
|
+
validatePayload();
|
|
238
|
+
syncSamples();
|
|
239
|
+
};
|
|
240
|
+
root.addEventListener("input", onEdit);
|
|
241
|
+
root.addEventListener("change", onEdit);
|
|
242
|
+
};
|