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,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>
@@ -33,6 +33,7 @@ const requestPanels = await Promise.all(
33
33
  }),
34
34
  key: language.id,
35
35
  label: language.label,
36
+ lang: language.id,
36
37
  }))
37
38
  );
38
39
 
@@ -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
- toJson(sample.payload ?? {});
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
- /** `wss://host/path` for a WebSocket channel; the address is the path. */
50
- const wsUrl = (sample: MessageSample): string => {
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(wsUrl(sample))}`;
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(wsUrl(sample))});`;
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
- parameters.push({
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
+ };