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
@@ -1,3 +1,4 @@
1
+ import { isRootTab, isUnderPath } from "../../core/navigation.ts";
1
2
  import type { NavNode, NavTab } from "../../core/types.ts";
2
3
 
3
4
  /** A flat, ordered page reference used for previous/next pagination. */
@@ -71,14 +72,6 @@ export const findBreadcrumbs = (nodes: NavNode[], route: string): Crumb[] => {
71
72
  return search(nodes, []) ?? [];
72
73
  };
73
74
 
74
- /**
75
- * Whether `route` is the section root `base` or nested beneath it. Requires a
76
- * path boundary, so `/api-reference` is not under `/api`. The root `/` spans
77
- * every route.
78
- */
79
- export const isUnderPath = (route: string, base: string): boolean =>
80
- base === "/" || route === base || route.startsWith(`${base}/`);
81
-
82
75
  /**
83
76
  * The tab whose `path` is the longest prefix of `route`. The root tab (`/`)
84
77
  * acts as the fallback when no more specific tab matches.
@@ -99,6 +92,28 @@ export const activeTabForRoute = (
99
92
  return match;
100
93
  };
101
94
 
95
+ /**
96
+ * The tab to mark as the current one (`aria-current`) for `route`. Same
97
+ * longest-prefix match as {@link activeTabForRoute}, except inside an archived
98
+ * version tree: the root tab claims every archived route through its
99
+ * spans-everything fallback while its link points back at the current docs, so
100
+ * no tab is genuinely current there.
101
+ */
102
+ export const currentTabForRoute = (
103
+ tabs: NavTab[],
104
+ route: string,
105
+ root = "/"
106
+ ): NavTab | null => {
107
+ const tab = activeTabForRoute(tabs, route);
108
+ // The root tab sitting away from the tree root means the tree is a version
109
+ // snapshot in a different path space — the tab matched as a fallback, not
110
+ // because it owns the route.
111
+ if (tab && isRootTab(tab, root) && tab.path !== root) {
112
+ return null;
113
+ }
114
+ return tab;
115
+ };
116
+
102
117
  /**
103
118
  * The children of the group whose path is `base`, searched at any depth — so a
104
119
  * content tree wrapped in a top-level container group still resolves to the
@@ -144,7 +159,7 @@ const withoutTabSections = (
144
159
  ): NavNode[] => {
145
160
  const tabPaths = new Set<string>();
146
161
  for (const tab of tabs) {
147
- if (tab.path !== root) {
162
+ if (!isRootTab(tab, root)) {
148
163
  tabPaths.add(tab.path);
149
164
  }
150
165
  }
@@ -181,11 +196,15 @@ const withoutTabSections = (
181
196
  * the root tab), the tab-owned groups are hidden so the root sidebar shows
182
197
  * only pages that don't belong to a tab.
183
198
  *
184
- * `root` is the tree root in the tabs' own path space (`Navigation.root`)
185
- * tab paths arrive localized and based, so under i18n or a `basePath` the root
186
- * tab is `/en` or `/docs`, not `/`. Comparing against `/` would misread it as
187
- * a section tab: a root-level `(group)` folder's path is exactly that prefix,
188
- * so the sidebar collapsed to that one group (or blanked entirely).
199
+ * `root` is the tree root (`Navigation.root`), localized and based like tab
200
+ * paths under i18n or a `basePath` the root tab sits at `/en` or `/docs`,
201
+ * not `/`, and a bare-`/` comparison would misread it as a section tab (a
202
+ * root-level `(group)` folder's path is exactly that prefix, so the sidebar
203
+ * collapsed to that one group or blanked entirely). In an archived version
204
+ * tree the root is versionized (`/v1.0`) while tab paths stay in current-docs
205
+ * space, so root-tab checks use {@link isRootTab} containment, not equality:
206
+ * the root tab owns no group in a snapshot, and misreading it as a section
207
+ * tab blanked the archived sidebar.
189
208
  *
190
209
  * When a matched tab owns no sidebar group — a standalone page like the
191
210
  * generated changelog timeline (`/changelog`), or a tab whose source produced
@@ -201,7 +220,7 @@ export const sidebarForRoute = (
201
220
  root = "/"
202
221
  ): NavNode[] => {
203
222
  const tab = activeTabForRoute(tabs, route);
204
- if (tab && tab.path !== root) {
223
+ if (tab && !isRootTab(tab, root)) {
205
224
  return sectionChildren(sidebar, tab.path) ?? [];
206
225
  }
207
226
  const scoped = withoutTabSections(sidebar, tabs, root);
@@ -11,8 +11,9 @@ import type { IndexedDocument, SearchFn } from "./types.ts";
11
11
  * available in both dev and the production build. A generous match pool is
12
12
  * pulled so the section pills can count across the whole result set before the
13
13
  * active filter and display limit are applied. `locale` is the site's
14
- * `i18n.defaultLocale`, baked into the generated client so unspaced scripts
15
- * (Japanese, Chinese, Korean, Thai) get a word-segmenting tokenizer.
14
+ * `i18n.defaultLocale`, baked into the generated client so non-Latin scripts
15
+ * (Japanese and Chinese, but equally Cyrillic, Greek, Hebrew, Devanagari…)
16
+ * get a word-segmenting tokenizer.
16
17
  */
17
18
  export const createSearch = async (opts: {
18
19
  indexUrl: string;
@@ -18,6 +18,9 @@ import {
18
18
  } from "./async.ts";
19
19
  import type { MessageSample } from "./async-snippets.ts";
20
20
  import { asyncSampleLanguages } from "./async-snippets.ts";
21
+ import { buildMessage, defaultMessageValues } from "./message.ts";
22
+ import { messageModel } from "./message-model.ts";
23
+ import MessageComposer from "./MessageComposer.astro";
21
24
  import Authorization from "./Authorization.astro";
22
25
  import Bindings from "./Bindings.astro";
23
26
  import { exampleValue, type SchemaLike, toJson } from "./helpers.ts";
@@ -86,13 +89,21 @@ const messagePanels = await Promise.all(
86
89
  })
87
90
  );
88
91
 
89
- const sample: MessageSample | null = ref
90
- ? {
92
+ // One derived model feeds the static default sample, the composer form, and
93
+ // (client-side) the live samples + WebSocket frame — they can never drift.
94
+ const model = ref
95
+ ? messageModel({
91
96
  action: ref.method === "send" ? "send" : "receive",
92
97
  address: ref.path,
93
- payload: messages.length > 0 ? exampleOf(messages[0].message) : undefined,
94
- server: servers[0],
95
- }
98
+ messages,
99
+ parameters,
100
+ protocol,
101
+ schemas,
102
+ servers,
103
+ })
104
+ : null;
105
+ const sample: MessageSample | null = model
106
+ ? buildMessage(model, defaultMessageValues(model))
96
107
  : null;
97
108
  const languages = asyncSampleLanguages(spec?.codeSamples ?? [], protocol);
98
109
  const samplePanels = sample
@@ -104,6 +115,7 @@ const samplePanels = sample
104
115
  }),
105
116
  key: language.id,
106
117
  label: language.label,
118
+ lang: language.id,
107
119
  }))
108
120
  )
109
121
  : [];
@@ -223,8 +235,11 @@ const channelBindings = bindingGroups(channel?.bindings);
223
235
  title="Channel bindings"
224
236
  />
225
237
  </div>
226
- {(samplePanels.length > 0 || messagePanels.length > 0) && (
227
- <div class="xl:sticky xl:top-24 xl:self-start">
238
+ {(spec.playground.enabled ||
239
+ samplePanels.length > 0 ||
240
+ messagePanels.length > 0) && (
241
+ <div class="xl:sticky xl:top-24 xl:self-start" data-operation-panel>
242
+ {spec.playground.enabled && model && <MessageComposer model={model} />}
228
243
  <div class="not-prose flex flex-col gap-6">
229
244
  <PanelTabs copy heading="Example" panels={samplePanels} />
230
245
  <PanelTabs heading="Message" panels={messagePanels} />
@@ -0,0 +1,238 @@
1
+ ---
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ import type { MessageModel } from "./message.ts";
5
+
6
+ /**
7
+ * The interactive "Try it" panel for one event operation. Server-rendered as a
8
+ * native collapsed `<details>` with plain labelled controls — keyboard
9
+ * operable with zero JS — and the client module loads only when the reader
10
+ * first opens it (loader script at the foot of this file). The message model
11
+ * rides along as JSON so the lazy chunk needs no data fetch.
12
+ *
13
+ * Composing and copying a sample works on every protocol; a live connection is
14
+ * offered only where a browser can actually make one (WebSocket bindings), and
15
+ * the panel says so plainly rather than presenting a dead button.
16
+ */
17
+
18
+ interface Props {
19
+ model: MessageModel;
20
+ }
21
+
22
+ const { model } = Astro.props;
23
+
24
+ // `</script>`-proof embedding: `<` only ever occurs inside JSON strings, where
25
+ // the \u escape is equivalent, so a spec description can never close the model
26
+ // script early and inject markup.
27
+ const modelJson = JSON.stringify(model).replaceAll("<", "\\u003c");
28
+
29
+ // Unique per rendered composer (a page can hold several), so the textarea's
30
+ // aria-describedby can point at ITS error container and no other.
31
+ const payloadErrorsId = `blume-payload-errors-${randomUUID()}`;
32
+
33
+ const LABEL_ROW =
34
+ "mb-1 flex flex-wrap items-baseline gap-x-1.5 font-medium text-foreground text-xs";
35
+ const FIELD =
36
+ "w-full rounded border border-border bg-background px-2 py-1.5 font-mono text-foreground text-xs";
37
+ const HINT = "mt-1 block text-muted-foreground text-xs";
38
+ const GROUP = "flex flex-col gap-3";
39
+ const GROUP_HEADING =
40
+ "font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide";
41
+ const BUTTON =
42
+ "cursor-pointer rounded-blume bg-accent px-4 py-2 font-medium text-accent-foreground text-sm transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50";
43
+ const BUTTON_QUIET =
44
+ "cursor-pointer rounded-blume border border-border px-4 py-2 font-medium text-foreground text-sm transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50";
45
+ ---
46
+
47
+ <blume-message-composer class="mb-6 block">
48
+ <script
49
+ data-composer-model
50
+ is:inline
51
+ set:html={modelJson}
52
+ type="application/json"
53
+ />
54
+ <details
55
+ class="not-prose rounded-blume border border-border bg-background"
56
+ data-composer
57
+ >
58
+ <summary class="cursor-pointer px-4 py-3 font-semibold text-foreground text-sm">
59
+ Try it
60
+ </summary>
61
+ <div class="flex flex-col gap-5 border-border border-t px-4 py-4">
62
+ <div class={GROUP}>
63
+ <div class={GROUP_HEADING}>Server</div>
64
+ {
65
+ model.servers.length > 0 && (
66
+ <label class="block">
67
+ <span class={LABEL_ROW}>Server</span>
68
+ <select class={FIELD} data-server>
69
+ {model.servers.map((option, index) => (
70
+ <option value={String(index)}>{option.label}</option>
71
+ ))}
72
+ </select>
73
+ </label>
74
+ )
75
+ }
76
+ <label class="block">
77
+ <span class={LABEL_ROW}>Custom server URL</span>
78
+ <input
79
+ class={FIELD}
80
+ data-server-custom
81
+ placeholder="wss://events.example.com"
82
+ type="text"
83
+ />
84
+ <span class={HINT}>
85
+ Overrides the selected server; the channel address is appended.
86
+ </span>
87
+ </label>
88
+ </div>
89
+ {
90
+ model.params.length > 0 && (
91
+ <div class={GROUP}>
92
+ <div class={GROUP_HEADING}>Channel parameters</div>
93
+ {model.params.map((param) => (
94
+ <label class="block">
95
+ <span class={LABEL_ROW}>
96
+ <code class="font-mono">{param.name}</code>
97
+ <span class="text-red-600 dark:text-red-400" title="required">
98
+ *
99
+ </span>
100
+ </span>
101
+ {param.enum ? (
102
+ <select
103
+ class={FIELD}
104
+ data-param={param.name}
105
+ title={param.description}
106
+ >
107
+ {param.enum.map((value) => (
108
+ <option selected={value === param.value} value={value}>
109
+ {value}
110
+ </option>
111
+ ))}
112
+ </select>
113
+ ) : (
114
+ <input
115
+ class={FIELD}
116
+ data-param={param.name}
117
+ placeholder="string"
118
+ title={param.description}
119
+ type="text"
120
+ value={param.value}
121
+ />
122
+ )}
123
+ {param.description && (
124
+ <span class={HINT} set:text={param.description} />
125
+ )}
126
+ </label>
127
+ ))}
128
+ </div>
129
+ )
130
+ }
131
+ <div class={GROUP}>
132
+ <div class={GROUP_HEADING}>
133
+ Payload
134
+ {
135
+ model.payload.contentType && (
136
+ <span class="ml-1.5 font-mono normal-case">
137
+ {model.payload.contentType}
138
+ </span>
139
+ )
140
+ }
141
+ </div>
142
+ <label class="block">
143
+ <span class={LABEL_ROW}>Message payload</span>
144
+ <textarea
145
+ aria-describedby={payloadErrorsId}
146
+ class={FIELD}
147
+ data-payload
148
+ rows="8"
149
+ spellcheck="false">{model.payload.example}</textarea
150
+ >
151
+ </label>
152
+ <div
153
+ aria-live="polite"
154
+ class="mt-1 flex flex-col gap-0.5 empty:hidden"
155
+ data-payload-errors
156
+ id={payloadErrorsId}
157
+ />
158
+ </div>
159
+ {
160
+ model.connectable ? (
161
+ <div class={GROUP}>
162
+ <div class={GROUP_HEADING}>Connection</div>
163
+ <div class="flex flex-wrap items-center gap-2">
164
+ <button class={BUTTON} data-connect type="button">
165
+ Connect
166
+ </button>
167
+ <button class={BUTTON_QUIET} data-disconnect disabled type="button">
168
+ Disconnect
169
+ </button>
170
+ {/* AsyncAPI 3 states the action from the API's side: a
171
+ `receive` operation is the one a reader publishes to, while a
172
+ `send` operation only streams frames at them. */}
173
+ {model.action === "receive" && (
174
+ <button class={BUTTON} data-send disabled type="button">
175
+ Send
176
+ </button>
177
+ )}
178
+ </div>
179
+ <div
180
+ aria-live="polite"
181
+ class="text-muted-foreground text-xs"
182
+ data-status
183
+ >
184
+ {model.action === "send"
185
+ ? "Not connected. Connect to stream messages as they arrive."
186
+ : "Not connected."}
187
+ </div>
188
+ <div
189
+ aria-live="polite"
190
+ class="flex flex-col gap-1 empty:hidden"
191
+ data-log
192
+ />
193
+ </div>
194
+ ) : (
195
+ <p class="text-muted-foreground text-xs">
196
+ {model.protocol
197
+ ? `Live sending isn't possible for ${model.protocol} from a browser tab — copy the sample above and run it against your broker.`
198
+ : "Live sending needs a WebSocket binding — copy the sample above and run it against your broker."}
199
+ </p>
200
+ )
201
+ }
202
+ </div>
203
+ </details>
204
+ </blume-message-composer>
205
+
206
+ <script>
207
+ // Hydration waits for intent: a collapsed <details> can only fire its first
208
+ // `toggle` by opening, so the one-shot listener imports the client module —
209
+ // a separate chunk readers who never compose a message never download — and
210
+ // wires the form. The custom element lives here, not in the client module,
211
+ // so that module stays import-safe under the test runner.
212
+ class BlumeMessageComposer extends HTMLElement {
213
+ connectedCallback() {
214
+ const details = this.querySelector<HTMLDetailsElement>(
215
+ "details[data-composer]"
216
+ );
217
+ if (!details) {
218
+ return;
219
+ }
220
+ const init = async () => {
221
+ const { initComposer } = await import("./message-composer.ts");
222
+ initComposer(this);
223
+ };
224
+ // A re-attached element (bfcache restore, DOM moves) can arrive with the
225
+ // panel already open — its first toggle has fired, so a listener alone
226
+ // would leave a visible form dead.
227
+ if (details.open) {
228
+ void init();
229
+ return;
230
+ }
231
+ details.addEventListener("toggle", init, { once: true });
232
+ }
233
+ }
234
+
235
+ if (!customElements.get("blume-message-composer")) {
236
+ customElements.define("blume-message-composer", BlumeMessageComposer);
237
+ }
238
+ </script>
@@ -6,18 +6,20 @@ import {
6
6
  resolveComponentRef,
7
7
  type SchemaLike,
8
8
  } from "./helpers.ts";
9
+ import { operationModel } from "./operation-model.ts";
10
+ import { buildRequest, defaultValues } from "./request.ts";
9
11
  import {
10
12
  effectiveSecurity,
11
13
  resolveSecurity,
12
- sampleAuth,
13
14
  type SecurityRequirementLike,
14
15
  type SecuritySchemeLike,
15
16
  } from "./security.ts";
16
- import { buildRequestSample, sampleLanguages } from "./snippets.ts";
17
+ import { sampleLanguages } from "./snippets.ts";
17
18
  import AsyncApiOperation from "./AsyncApiOperation.astro";
18
19
  import Authorization from "./Authorization.astro";
19
20
  import MethodBadge from "./MethodBadge.astro";
20
21
  import ParametersTable from "./ParametersTable.astro";
22
+ import Playground from "./Playground.astro";
21
23
  import RequestBody from "./RequestBody.astro";
22
24
  import RequestPanel from "./RequestPanel.astro";
23
25
  import Responses from "./Responses.astro";
@@ -104,17 +106,21 @@ const security = resolveSecurity(
104
106
  doc.components?.securitySchemes
105
107
  );
106
108
 
107
- const sample =
109
+ // One derived model feeds the static default sample, the playground form,
110
+ // and (client-side) the live samples + fetch — they can never drift apart.
111
+ const model =
108
112
  ref && operation
109
- ? buildRequestSample(
110
- { parameters: params, requestBody },
111
- ref.method,
112
- ref.path,
113
- doc.servers ?? [],
113
+ ? operationModel({
114
+ method: ref.method,
115
+ parameters: params,
116
+ path: ref.path,
117
+ requestBody,
114
118
  schemas,
115
- sampleAuth(security)
116
- )
119
+ security,
120
+ servers: doc.servers ?? [],
121
+ })
117
122
  : null;
123
+ const sample = model ? buildRequest(model, defaultValues(model)) : null;
118
124
  const languages = sampleLanguages(spec?.codeSamples ?? []);
119
125
  ---
120
126
  {
@@ -154,8 +160,16 @@ const languages = sampleLanguages(spec?.codeSamples ?? []);
154
160
  />
155
161
  )}
156
162
  </div>
157
- {sample && (
158
- <div class="xl:sticky xl:top-24 xl:self-start">
163
+ {sample && model && (
164
+ <div class="xl:sticky xl:top-24 xl:self-start" data-operation-panel>
165
+ {spec.playground.enabled && (
166
+ <Playground
167
+ model={model}
168
+ operation={id}
169
+ proxy={spec.playground.proxy}
170
+ slug={spec.slug}
171
+ />
172
+ )}
159
173
  <RequestPanel
160
174
  languages={languages}
161
175
  responses={responses}
@@ -13,6 +13,12 @@ interface Panel {
13
13
  html?: string | null;
14
14
  /** Plain-text fallback when there is nothing to highlight. */
15
15
  text?: string;
16
+ /**
17
+ * Code-sample language id for a request-sample panel. Tags the panel with
18
+ * `data-sample-lang` so the playground client can re-render it live; panels
19
+ * that aren't samples leave it off.
20
+ */
21
+ lang?: string;
16
22
  }
17
23
 
18
24
  interface Props {
@@ -88,6 +94,7 @@ const TAB_CLASS =
88
94
  aria-labelledby={idFor("tab", panel.key)}
89
95
  class:list={[index === 0 ? "" : "hidden"]}
90
96
  data-panel={panel.key}
97
+ data-sample-lang={panel.lang}
91
98
  id={idFor("panel", panel.key)}
92
99
  role="tabpanel"
93
100
  >