blume 1.4.2 → 1.5.0

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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -1,10 +1,11 @@
1
1
  import DOMPurify from "dompurify";
2
- import { marked } from "marked";
2
+ import { Marked } from "marked";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
 
7
7
  import type { UIStrings } from "../../core/i18n-ui.ts";
8
+ import { copyText } from "../copy-feedback.ts";
8
9
  import { joinBase, prefixBase } from "./base-path.ts";
9
10
  import { useAskAI } from "./hooks.ts";
10
11
 
@@ -57,12 +58,15 @@ const DEFAULT_ASK: UIStrings["ask"] = {
57
58
  const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
58
59
 
59
60
  // GitHub-flavored markdown with soft line breaks, matching how the docs read.
60
- marked.setOptions({ breaks: true, gfm: true });
61
-
62
- // The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
63
- // link targets to served URLs so citations resolve under `deployment.base`.
64
- // `prefixBase` leaves external URLs and fragments untouched and is idempotent.
65
- marked.use({
61
+ // A dedicated instance, not the shared `marked` singleton: `setOptions`/`use`
62
+ // on the singleton would leak `breaks` and the link rewriter into any other
63
+ // consumer of `marked` on the page (user components included).
64
+ const markdown = new Marked({
65
+ breaks: true,
66
+ gfm: true,
67
+ // The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
68
+ // link targets to served URLs so citations resolve under `deployment.base`.
69
+ // `prefixBase` leaves external URLs and fragments untouched and is idempotent.
66
70
  walkTokens: (token) => {
67
71
  if (token.type === "link") {
68
72
  token.href = prefixBase(import.meta.env.BASE_URL, token.href);
@@ -71,7 +75,7 @@ marked.use({
71
75
  });
72
76
 
73
77
  const renderMarkdown = (content: string): string =>
74
- DOMPurify.sanitize(marked.parse(content, { async: false }));
78
+ DOMPurify.sanitize(markdown.parse(content, { async: false }));
75
79
 
76
80
  const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
77
81
  <svg
@@ -96,12 +100,21 @@ const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
96
100
  const EMPTY_SUGGESTIONS: Suggestion[] = [];
97
101
 
98
102
  // The toggle shortcut accepts both ⌘I and Ctrl+I; show the right modifier per
99
- // platform (same detection Search.astro uses for its ⌘K hint). Guarded so the
100
- // island still server-renders, where `navigator` doesn't exist; the hint itself
101
- // only renders client-side, inside the portaled panel.
103
+ // platform (same detection Search.astro uses for its ⌘K hint). Guarded via
104
+ // `globalThis` so the island still server-renders where `navigator` doesn't
105
+ // exist; the hint itself only renders client-side, inside the portaled panel.
102
106
  const IS_APPLE =
103
- typeof navigator !== "undefined" &&
104
- /mac|iphone|ipad|ipod/iu.test(navigator.platform);
107
+ globalThis.navigator !== undefined &&
108
+ /mac|iphone|ipad|ipod/iu.test(globalThis.navigator.platform);
109
+
110
+ // Duck-typed (`closest` presence) rather than `instanceof Element`, which
111
+ // needs a DOM global the test environment doesn't provide.
112
+ const isElementLike = (target: EventTarget | null): target is Element => {
113
+ // SAFETY: the cast only names the probed surface; `closest` is verified to
114
+ // exist before the caller uses it.
115
+ const candidate = target as Partial<Element> | null;
116
+ return typeof candidate?.closest === "function";
117
+ };
105
118
 
106
119
  // Ghost icon button, matching the header's theme toggle and repo link.
107
120
  const TRIGGER_CLASS =
@@ -146,6 +159,8 @@ const AskAI = ({
146
159
  const inputRef = useRef<HTMLTextAreaElement>(null);
147
160
  const scrollRef = useRef<HTMLDivElement>(null);
148
161
  const triggerRef = useRef<HTMLButtonElement>(null);
162
+ // The portaled panel root, excluded from the overlay-mode inert sweep.
163
+ const panelRef = useRef<HTMLElement>(null);
149
164
  // Where focus came from when the panel opened, restored on close.
150
165
  const returnFocusRef = useRef<HTMLElement | null>(null);
151
166
 
@@ -157,6 +172,8 @@ const AskAI = ({
157
172
  // The search modal forwards its query so "Ask AI: <query>" carries straight in.
158
173
  useEffect(() => {
159
174
  const handler = (event: Event) => {
175
+ // SAFETY: `blume:open-ask-ai` is only ever dispatched as a CustomEvent
176
+ // whose optional detail carries the search query.
160
177
  const query = (event as CustomEvent<{ query?: string }>).detail?.query;
161
178
  if (query) {
162
179
  setInput(query);
@@ -184,11 +201,8 @@ const AskAI = ({
184
201
  // An Escape aimed at a modal surface stacked on top (the search
185
202
  // dialog traps focus inside itself) dismisses that surface only —
186
203
  // this window listener still fires for it, and closing the panel
187
- // underneath too would eat the user's conversation view. Duck-typed
188
- // (`closest` presence) rather than `instanceof Element`, which needs
189
- // a DOM global the test environment doesn't provide.
190
- const target = event.target as Partial<Element> | null;
191
- if (typeof target?.closest === "function" && target.closest("dialog")) {
204
+ // underneath too would eat the user's conversation view.
205
+ if (isElementLike(event.target) && event.target.closest("dialog")) {
192
206
  return;
193
207
  }
194
208
  setOpen(false);
@@ -225,6 +239,68 @@ const AskAI = ({
225
239
  };
226
240
  }, [open]);
227
241
 
242
+ // Below the desktop dock breakpoint the open panel is a full-width overlay,
243
+ // so Tab must not escape into the page it covers: every other child of
244
+ // <body> (the panel portals to body) turns inert until close. The desktop
245
+ // dock keeps the page interactive on purpose — it's a non-modal side panel,
246
+ // so no sweep runs at ≥1024px. Elements that were already inert are left
247
+ // alone so closing doesn't accidentally re-enable them.
248
+ useEffect(() => {
249
+ if (!open) {
250
+ return;
251
+ }
252
+ const media = window.matchMedia("(min-width: 1024px)");
253
+ let inerted: Element[] = [];
254
+ const release = () => {
255
+ for (const el of inerted) {
256
+ el.removeAttribute("inert");
257
+ }
258
+ inerted = [];
259
+ };
260
+ const apply = () => {
261
+ release();
262
+ if (media.matches) {
263
+ return;
264
+ }
265
+ inerted = [...document.body.children].filter(
266
+ (el) => el !== panelRef.current && !el.hasAttribute("inert")
267
+ );
268
+ for (const el of inerted) {
269
+ el.setAttribute("inert", "");
270
+ }
271
+ };
272
+ apply();
273
+ // The sweep snapshots body's children at open time, but overlays keep
274
+ // arriving afterwards — medium-zoom's backdrop, a mermaid render, another
275
+ // island's portal all append to <body> — and an unswept latecomer is a
276
+ // tab stop hiding behind the overlay. Fold additions into the sweep for
277
+ // as long as it is active.
278
+ const observer = new MutationObserver((records) => {
279
+ if (media.matches) {
280
+ return;
281
+ }
282
+ for (const record of records) {
283
+ for (const node of record.addedNodes) {
284
+ if (
285
+ node instanceof HTMLElement &&
286
+ node !== panelRef.current &&
287
+ !node.hasAttribute("inert")
288
+ ) {
289
+ node.setAttribute("inert", "");
290
+ inerted.push(node);
291
+ }
292
+ }
293
+ }
294
+ });
295
+ observer.observe(document.body, { childList: true });
296
+ media.addEventListener("change", apply);
297
+ return () => {
298
+ observer.disconnect();
299
+ media.removeEventListener("change", apply);
300
+ release();
301
+ };
302
+ }, [open]);
303
+
228
304
  // Keep the newest message in view as it streams in.
229
305
  useEffect(() => {
230
306
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
@@ -265,7 +341,7 @@ const AskAI = ({
265
341
  const text = messages
266
342
  .map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
267
343
  .join("\n\n");
268
- void navigator.clipboard?.writeText(text);
344
+ void copyText(text);
269
345
  };
270
346
 
271
347
  const hasMessages = messages.length > 0;
@@ -274,15 +350,16 @@ const AskAI = ({
274
350
  <aside
275
351
  aria-hidden={open ? undefined : "true"}
276
352
  aria-label={t.title}
353
+ ref={panelRef}
277
354
  // The closed panel is only translated off-screen; `inert` drops its
278
355
  // buttons/textarea from the tab order and the accessibility tree.
279
356
  inert={!open}
280
- className={`fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-border border-s bg-background shadow-2xl transition-transform duration-200 ease-out ${
357
+ className={`border-border bg-background fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-s shadow-2xl transition-transform duration-200 ease-out ${
281
358
  open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
282
359
  }`}
283
360
  >
284
- <header className="flex h-16 shrink-0 items-center justify-between gap-2 border-border border-b px-4">
285
- <span className="font-semibold text-foreground">{t.title}</span>
361
+ <header className="border-border flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4">
362
+ <span className="text-foreground font-semibold">{t.title}</span>
286
363
  <div className="flex items-center gap-0.5">
287
364
  <button
288
365
  aria-label={t.copy}
@@ -314,7 +391,7 @@ const AskAI = ({
314
391
  </header>
315
392
 
316
393
  <div
317
- className="flex flex-1 flex-col scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto"
394
+ className="scrollbar-thumb-border flex flex-1 scrollbar-thin scrollbar-track-transparent flex-col overflow-y-auto"
318
395
  ref={scrollRef}
319
396
  >
320
397
  {hasMessages ? (
@@ -324,7 +401,7 @@ const AskAI = ({
324
401
  {messages.map((message, index) =>
325
402
  message.role === "user" ? (
326
403
  <div
327
- className="max-w-[85%] self-end whitespace-pre-wrap rounded-blume bg-muted px-3 py-2 text-foreground text-sm"
404
+ className="rounded-blume bg-muted text-foreground max-w-[85%] self-end px-3 py-2 text-sm whitespace-pre-wrap"
328
405
  // oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
329
406
  key={index}
330
407
  >
@@ -343,7 +420,7 @@ const AskAI = ({
343
420
  }}
344
421
  />
345
422
  ) : (
346
- <span className="animate-pulse text-muted-foreground">
423
+ <span className="text-muted-foreground animate-pulse">
347
424
 
348
425
  </span>
349
426
  )}
@@ -354,18 +431,18 @@ const AskAI = ({
354
431
  ) : (
355
432
  <div className="mt-auto flex flex-col gap-0.5 p-4">
356
433
  {suggestions.length === 0 && (
357
- <p className="px-2 text-muted-foreground text-sm">{t.empty}</p>
434
+ <p className="text-muted-foreground px-2 text-sm">{t.empty}</p>
358
435
  )}
359
436
  {suggestions.map((suggestion) => (
360
437
  <button
361
- className="flex cursor-pointer items-center gap-2.5 rounded-blume px-2 py-2 text-start text-foreground text-sm transition-colors hover:bg-muted"
438
+ className="rounded-blume text-foreground hover:bg-muted flex cursor-pointer items-center gap-2.5 px-2 py-2 text-start text-sm transition-colors"
362
439
  key={suggestion.label}
363
440
  onClick={() => runQuestion(suggestion.label)}
364
441
  type="button"
365
442
  >
366
443
  {suggestion.icon && (
367
444
  <span
368
- className="shrink-0 text-muted-foreground [&_svg]:h-[18px] [&_svg]:w-[18px]"
445
+ className="text-muted-foreground shrink-0 [&_svg]:h-[18px] [&_svg]:w-[18px]"
369
446
  // oxlint-disable-next-line react/no-danger -- trusted server-resolved inline SVG glyph
370
447
  dangerouslySetInnerHTML={{ __html: suggestion.icon }}
371
448
  />
@@ -373,12 +450,12 @@ const AskAI = ({
373
450
  <span>{suggestion.label}</span>
374
451
  </button>
375
452
  ))}
376
- <p className="mt-3 flex items-center gap-1.5 px-2 text-muted-foreground text-sm">
453
+ <p className="text-muted-foreground mt-3 flex items-center gap-1.5 px-2 text-sm">
377
454
  {t.tip}
378
- <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
455
+ <kbd className="border-border bg-muted rounded border px-1.5 py-0.5 font-sans text-xs">
379
456
  {IS_APPLE ? "⌘" : "Ctrl"}
380
457
  </kbd>
381
- <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
458
+ <kbd className="border-border bg-muted rounded border px-1.5 py-0.5 font-sans text-xs">
382
459
  I
383
460
  </kbd>
384
461
  </p>
@@ -387,12 +464,12 @@ const AskAI = ({
387
464
  </div>
388
465
 
389
466
  <form
390
- className="relative shrink-0 border-border border-t"
467
+ className="border-border relative shrink-0 border-t"
391
468
  onSubmit={onSubmit}
392
469
  >
393
470
  <textarea
394
471
  aria-label={t.label}
395
- className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm pointer-coarse:text-base outline-none placeholder:text-muted-foreground"
472
+ className="text-foreground placeholder:text-muted-foreground max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-sm outline-none pointer-coarse:text-base"
396
473
  onChange={(event) => setInput(event.target.value)}
397
474
  onKeyDown={onInputKeyDown}
398
475
  placeholder={t.placeholder}
@@ -402,7 +479,7 @@ const AskAI = ({
402
479
  />
403
480
  <button
404
481
  aria-label={t.send}
405
- className="absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-blume bg-foreground text-background transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
482
+ className="rounded-blume bg-foreground text-background absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
406
483
  disabled={busy || input.trim().length === 0}
407
484
  type="submit"
408
485
  >
@@ -29,7 +29,9 @@ const readClientData = (): BlumeClientData | null => {
29
29
  if (cachedData) {
30
30
  return cachedData;
31
31
  }
32
- if (typeof document === "undefined") {
32
+ // `document` is undeclared on the server; probing `globalThis` avoids both
33
+ // the bare-reference ReferenceError and a `typeof` sniff.
34
+ if (!("document" in globalThis)) {
33
35
  return null;
34
36
  }
35
37
  const element = document.querySelector("#blume-client-data");
@@ -37,6 +39,8 @@ const readClientData = (): BlumeClientData | null => {
37
39
  return null;
38
40
  }
39
41
  try {
42
+ // SAFETY: the layout serialized this script tag's JSON from the same
43
+ // `BlumeClientData` snapshot this reads back.
40
44
  cachedData = JSON.parse(element.textContent) as BlumeClientData;
41
45
  return cachedData;
42
46
  } catch {
@@ -16,11 +16,36 @@ interface WebMcpResult {
16
16
  isError?: boolean;
17
17
  }
18
18
 
19
+ /** A JSON value as a WebMCP agent may supply it in a tool call. */
20
+ export type WebMcpJsonValue =
21
+ | boolean
22
+ | number
23
+ | string
24
+ | null
25
+ | WebMcpJsonValue[]
26
+ | { [key: string]: WebMcpJsonValue };
27
+
28
+ /**
29
+ * Tool arguments exactly as the calling agent supplied them. WebMCP doesn't
30
+ * guarantee schema validation, so each tool checks its own fields at runtime.
31
+ */
32
+ export interface WebMcpToolArgs {
33
+ query?: WebMcpJsonValue;
34
+ route?: WebMcpJsonValue;
35
+ }
36
+
37
+ /** The JSON Schema subset these string-argument tools declare. */
38
+ export interface WebMcpInputSchema {
39
+ properties: Record<string, { description: string; type: "string" }>;
40
+ required?: string[];
41
+ type: "object";
42
+ }
43
+
19
44
  export interface WebMcpTool {
20
45
  annotations: { openWorldHint: boolean; readOnlyHint: boolean };
21
46
  description: string;
22
- execute: (input: Record<string, unknown>) => Promise<WebMcpResult>;
23
- inputSchema: Record<string, unknown>;
47
+ execute: (input: WebMcpToolArgs) => Promise<WebMcpResult>;
48
+ inputSchema: WebMcpInputSchema;
24
49
  name: string;
25
50
  }
26
51
 
@@ -31,14 +56,26 @@ export interface WebMcpTool {
31
56
  * individually via `registerTool`.
32
57
  */
33
58
  export interface ModelContext {
34
- provideContext?: (context: { tools: WebMcpTool[] }) => unknown;
35
- registerTool?: (tool: WebMcpTool) => unknown;
59
+ provideContext?: (context: { tools: WebMcpTool[] }) => void;
60
+ registerTool?: (tool: WebMcpTool) => void;
36
61
  }
37
62
 
38
- const text = (value: string, isError = false): WebMcpResult => ({
39
- content: [{ text: value, type: "text" }],
40
- ...(isError ? { isError: true } : {}),
41
- });
63
+ const text = (value: string, isError = false): WebMcpResult => {
64
+ const result: WebMcpResult = { content: [{ text: value, type: "text" }] };
65
+ if (isError) {
66
+ result.isError = true;
67
+ }
68
+ return result;
69
+ };
70
+
71
+ /** Validates an agent-supplied tool argument before it is used as a string. */
72
+ const isString = (value: WebMcpJsonValue | undefined): value is string =>
73
+ typeof value === "string";
74
+
75
+ /** Detects which registration surface a model context actually implements. */
76
+ const isCallable = <T extends (...args: never[]) => void>(
77
+ value: T | undefined
78
+ ): value is T => typeof value === "function";
42
79
 
43
80
  const TAG = /<[^>]*>?/gu;
44
81
 
@@ -89,7 +126,7 @@ export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
89
126
  description:
90
127
  "Full-text search across this documentation site. Returns matching pages with their title, URL, and a short excerpt.",
91
128
  async execute(input) {
92
- const query = typeof input.query === "string" ? input.query : "";
129
+ const query = isString(input.query) ? input.query : "";
93
130
  if (!query.trim()) {
94
131
  return text("Provide a non-empty `query` string.", true);
95
132
  }
@@ -128,7 +165,7 @@ export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
128
165
  description:
129
166
  "Fetch a page of this site as plain Markdown. Pass the page's root-relative route, e.g. `/quickstart`.",
130
167
  async execute(input) {
131
- const route = typeof input.route === "string" ? input.route : "";
168
+ const route = isString(input.route) ? input.route : "";
132
169
  if (!route.startsWith("/")) {
133
170
  return text("Pass a root-relative route, e.g. `/quickstart`.", true);
134
171
  }
@@ -189,11 +226,11 @@ export const registerWebMcpTools = (
189
226
  if (!context) {
190
227
  return false;
191
228
  }
192
- if (typeof context.provideContext === "function") {
229
+ if (isCallable(context.provideContext)) {
193
230
  context.provideContext({ tools });
194
231
  return true;
195
232
  }
196
- if (typeof context.registerTool === "function") {
233
+ if (isCallable(context.registerTool)) {
197
234
  for (const tool of tools) {
198
235
  context.registerTool(tool);
199
236
  }
@@ -5,7 +5,11 @@ import { withBase } from "../islands/base-path.ts";
5
5
  import type { ComponentOverride } from "../../core/define-components.ts";
6
6
  import { EN_UI } from "../../core/i18n-ui.ts";
7
7
  import type { UIStrings } from "../../core/i18n-ui.ts";
8
- import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
8
+ import type {
9
+ LocaleSwitchOption,
10
+ Navigation,
11
+ NavSelector as NavSelectorConfig,
12
+ } from "../../core/types.ts";
9
13
  import { GITHUB_MARK } from "../github-mark.ts";
10
14
  import Icon from "../Icon.astro";
11
15
  import LanguageSwitcher from "./LanguageSwitcher.astro";
@@ -50,8 +54,16 @@ interface Props {
50
54
  /** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
51
55
  navStrings?: UIStrings["nav"];
52
56
  localeSwitch?: LocaleSwitchOption[];
57
+ /**
58
+ * Auto-populated version switcher, rendered ahead of the configured
59
+ * selectors. `null`/absent when versioning is off — or when the user
60
+ * declares their own `kind: "version"` selector, which then owns the UI.
61
+ */
62
+ versionSelector?: NavSelectorConfig | null;
53
63
  /** Active locale for per-language search filtering. */
54
64
  searchLocale?: string;
65
+ /** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
66
+ searchVersion?: string | null;
55
67
  /**
56
68
  * Layout-slot overrides forwarded from the root layout. The header honors
57
69
  * `Logo` and `Search` here so those pieces can be replaced without swapping
@@ -77,7 +89,9 @@ const {
77
89
  switcherStrings,
78
90
  navStrings,
79
91
  localeSwitch,
92
+ versionSelector,
80
93
  searchLocale,
94
+ searchVersion = null,
81
95
  layout = {},
82
96
  } = Astro.props;
83
97
 
@@ -167,6 +181,15 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
167
181
  )
168
182
  }
169
183
  <div class="flex-1"></div>
184
+ {
185
+ /* Right-aligned next to the language switcher: the spacer absorbs its
186
+ width, so pages without a version selector (blog, generated references)
187
+ keep the logo, tabs, and the rest of this cluster in place — no layout
188
+ shift when crossing into the docs. */
189
+ versionSelector && (
190
+ <NavSelector align="end" route={route} selector={versionSelector} />
191
+ )
192
+ }
170
193
  {
171
194
  localeSwitch && localeSwitch.length > 1 && (
172
195
  <LanguageSwitcher
@@ -184,6 +207,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
184
207
  navigation={navigation}
185
208
  popularPages={data.config.search.popular}
186
209
  strings={searchStrings}
210
+ version={searchVersion}
187
211
  />
188
212
  )
189
213
  }
@@ -11,9 +11,16 @@ import { isUnderPath } from "./nav-utils.ts";
11
11
  interface Props {
12
12
  selector: NavSelector;
13
13
  route: string;
14
+ /**
15
+ * Which edge the dropdown panel anchors to. `start` (the default) suits the
16
+ * header's leading selector group; the version selector sits at the trailing
17
+ * edge next to the language switcher, where a start-anchored panel would
18
+ * overflow the viewport — pass `end` there.
19
+ */
20
+ align?: "start" | "end";
14
21
  }
15
22
 
16
- const { selector, route } = Astro.props;
23
+ const { selector, route, align = "start" } = Astro.props;
17
24
 
18
25
  // The active item is the deepest path the current route sits under (on a path
19
26
  // boundary, so `/api` never claims `/api-reference` routes), falling back to
@@ -46,7 +53,9 @@ const menuRowClass =
46
53
  size={14}
47
54
  />
48
55
  </summary>
49
- <div class="absolute start-0 z-50 mt-2 min-w-56 rounded-blume border border-border bg-background p-1 shadow-xl">
56
+ <div
57
+ class={`absolute z-50 mt-2 min-w-56 rounded-blume border border-border bg-background p-1 shadow-xl ${align === "end" ? "end-0" : "start-0"}`}
58
+ >
50
59
  {selector.items.map((item) => (
51
60
  <a
52
61
  aria-current={item.path === active?.path ? "true" : undefined}
@@ -45,8 +45,8 @@ const n = { ...EN_UI.nav, ...strings };
45
45
  const badgeBase =
46
46
  "shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
47
47
  const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
48
- // HTTP-method badges (from an OpenAPI reference's sidebar) are color-coded;
49
- // every other badge keeps the neutral style.
48
+ // HTTP-method and AsyncAPI-action badges (from a reference's sidebar) are
49
+ // color-coded to match MethodBadge; every other badge keeps the neutral style.
50
50
  const METHOD_BADGE: Record<string, string> = {
51
51
  DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
52
52
  GET: "bg-green-500/15 text-green-700 dark:text-green-300",
@@ -55,6 +55,8 @@ const METHOD_BADGE: Record<string, string> = {
55
55
  PATCH: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
56
56
  POST: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
57
57
  PUT: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
58
+ RECEIVE: "bg-teal-500/15 text-teal-700 dark:text-teal-300",
59
+ SEND: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
58
60
  };
59
61
  const badgeClassFor = (badge: string): string => {
60
62
  const method = METHOD_BADGE[badge.toUpperCase()];
@@ -224,7 +224,9 @@ const menuRowClass =
224
224
  </div>
225
225
 
226
226
  <script>
227
+ import { copyText, flashLabel } from "../copy-feedback.ts";
227
228
  import { prefixBase } from "../islands/base-path.ts";
229
+ import { rafThrottle } from "../raf-throttle.ts";
228
230
 
229
231
  const CHAT_URLS: Record<string, (q: string) => string> = {
230
232
  chatgpt: (q) => `https://chatgpt.com/?hints=search&prompt=${q}`,
@@ -318,12 +320,17 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
318
320
  placeMenu(details);
319
321
  });
320
322
  }
321
- window.addEventListener("resize", () => {
322
- const open = dropdowns.find((details) => details.open);
323
- if (open) {
324
- placeMenu(open);
325
- }
326
- });
323
+ // rAF-coalesced so a live resize drag re-reads layout once per frame,
324
+ // not once per event, while the open menu still tracks the viewport.
325
+ window.addEventListener(
326
+ "resize",
327
+ rafThrottle(() => {
328
+ const open = dropdowns.find((details) => details.open);
329
+ if (open) {
330
+ placeMenu(open);
331
+ }
332
+ })
333
+ );
327
334
 
328
335
  // `data-md` is the base-less logical route; the raw-markdown endpoint is a
329
336
  // generated page route, so it's served under the deployment base like any
@@ -376,24 +383,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
376
383
  `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: id, type: "http", url: mcpUrl }))}`
377
384
  );
378
385
 
379
- const flash = (el: Element | null, text: string) => {
380
- if (!(el instanceof HTMLElement)) {
381
- return;
382
- }
383
- // Remember the element's own (localized) label once — capturing it at
384
- // click time would capture "Copied!" on a double-click and stick.
385
- el.dataset.blumeLabel ??= el.textContent ?? "";
386
- el.textContent = text;
387
- setTimeout(() => {
388
- el.textContent = el.dataset.blumeLabel ?? "";
389
- }, 1500);
390
- };
391
386
  const copy = async (value: string, el: Element | null) => {
392
- try {
393
- await navigator.clipboard.writeText(value);
394
- flash(el, copiedLabel);
395
- } catch {
396
- // Clipboard unavailable; nothing to do.
387
+ if ((await copyText(value)) && el instanceof HTMLElement) {
388
+ flashLabel(el, copiedLabel);
397
389
  }
398
390
  };
399
391
  root
@@ -420,9 +412,6 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
420
412
  }
421
413
 
422
414
  const label = root.querySelector("[data-blume-copy-label]");
423
- // Captured once — inside the handler a double-click would capture and
424
- // permanently restore "Copied!".
425
- const original = label?.textContent ?? "";
426
415
  root
427
416
  .querySelector("[data-blume-copy-page]")
428
417
  ?.addEventListener("click", async () => {
@@ -431,12 +420,11 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
431
420
  if (!response.ok) {
432
421
  throw new Error(`Fetching ${md} failed (${response.status})`);
433
422
  }
434
- await navigator.clipboard.writeText(await response.text());
435
- if (label) {
436
- label.textContent = copiedLabel;
437
- setTimeout(() => {
438
- label.textContent = original;
439
- }, 1500);
423
+ if (
424
+ (await copyText(await response.text())) &&
425
+ label instanceof HTMLElement
426
+ ) {
427
+ flashLabel(label, copiedLabel);
440
428
  }
441
429
  } catch (error) {
442
430
  // Clipboard or fetch unavailable; don't flash "Copied!" untruthfully.