blume 1.4.1 → 1.4.3

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 (93) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/cli/index.js +700 -579
  3. package/dist/cli/index.js.map +57 -56
  4. package/dist/types/core/base-path.d.ts +8 -0
  5. package/dist/types/core/config-input.d.ts +8 -0
  6. package/dist/types/core/data.d.ts +10 -0
  7. package/dist/types/core/schema.d.ts +4 -0
  8. package/dist/types/core/sources/types.d.ts +9 -1
  9. package/dist/types/openapi/references.d.ts +8 -2
  10. package/docs/configuration/ai.mdx +41 -9
  11. package/docs/content/sources.mdx +1 -1
  12. package/package.json +17 -7
  13. package/src/ai/agent-readability.ts +3 -2
  14. package/src/ai/api-catalog.ts +2 -2
  15. package/src/ai/ask-context.ts +45 -12
  16. package/src/ai/link-headers.ts +7 -2
  17. package/src/ai/llms.ts +2 -1
  18. package/src/ai/mcp/discovery.ts +25 -6
  19. package/src/ai/mcp/server.ts +108 -98
  20. package/src/ai/tar.ts +29 -70
  21. package/src/astro/examples.ts +7 -3
  22. package/src/astro/generate.ts +63 -34
  23. package/src/astro/islands.ts +7 -3
  24. package/src/astro/templates.ts +36 -9
  25. package/src/audit/agent.ts +14 -29
  26. package/src/audit/crawl.ts +41 -16
  27. package/src/audit/run.ts +10 -3
  28. package/src/audit/snapshot.ts +27 -2
  29. package/src/cli/commands/audit.ts +12 -17
  30. package/src/cli/commands/build.ts +15 -7
  31. package/src/cli/commands/dev.ts +13 -15
  32. package/src/cli/commands/eject.ts +4 -4
  33. package/src/cli/commands/eval.ts +17 -27
  34. package/src/cli/env.ts +13 -30
  35. package/src/cli/init/scaffold.ts +21 -0
  36. package/src/cli/report-format.ts +22 -0
  37. package/src/components/content/AccordionItem.astro +2 -9
  38. package/src/components/content/ColorItem.astro +5 -13
  39. package/src/components/content/Component.astro +12 -8
  40. package/src/components/content/Frame.astro +2 -12
  41. package/src/components/content/Prompt.astro +12 -31
  42. package/src/components/content/Tab.astro +2 -9
  43. package/src/components/content/Tooltip.astro +1 -9
  44. package/src/components/content/Update.astro +2 -9
  45. package/src/components/content/inline-markdown.ts +28 -0
  46. package/src/components/copy-feedback.ts +96 -0
  47. package/src/components/islands/ask-ai.tsx +78 -9
  48. package/src/components/layout/PageActions.astro +20 -32
  49. package/src/components/layout/PageLayout.astro +8 -28
  50. package/src/components/layout/RootLayout.astro +47 -48
  51. package/src/components/layout/Search.astro +56 -9
  52. package/src/components/layout/drawer-inert.ts +31 -0
  53. package/src/components/layout/search/pagefind.ts +6 -5
  54. package/src/components/layout/search/types.ts +32 -0
  55. package/src/components/openapi/panel.ts +11 -8
  56. package/src/components/raf-throttle.ts +21 -0
  57. package/src/components/slug.ts +14 -0
  58. package/src/core/base-path.ts +18 -1
  59. package/src/core/config-input.ts +8 -0
  60. package/src/core/data.ts +7 -0
  61. package/src/core/frontmatter.ts +45 -1
  62. package/src/core/probe.ts +7 -19
  63. package/src/core/project-graph.ts +12 -1
  64. package/src/core/schema.ts +6 -0
  65. package/src/core/site-url.ts +27 -0
  66. package/src/core/sources/cache.ts +10 -8
  67. package/src/core/sources/github-releases.ts +21 -1
  68. package/src/core/sources/normalize.ts +26 -2
  69. package/src/core/sources/notion.ts +27 -5
  70. package/src/core/sources/portable-text.ts +16 -1
  71. package/src/core/sources/resolve.ts +1 -0
  72. package/src/core/sources/types.ts +13 -1
  73. package/src/deploy/cloudflare-negotiation.ts +15 -1
  74. package/src/deploy/robots.ts +2 -1
  75. package/src/deploy/rss.ts +2 -1
  76. package/src/deploy/sitemap.ts +56 -7
  77. package/src/eval/agents.ts +13 -10
  78. package/src/eval/report.ts +1 -14
  79. package/src/markdown/package-commands.ts +61 -54
  80. package/src/og/card.ts +24 -26
  81. package/src/openapi/model.ts +9 -9
  82. package/src/openapi/parse.ts +69 -28
  83. package/src/openapi/references.ts +35 -12
  84. package/src/openapi/render-mdx.ts +64 -25
  85. package/src/openapi/scalar.ts +2 -2
  86. package/src/openapi/source.ts +28 -1
  87. package/src/search/documents.ts +78 -34
  88. package/src/search/orama-index.ts +51 -12
  89. package/src/theme/palette.ts +6 -2
  90. package/src/translate/ledger.ts +4 -2
  91. package/src/translate/report.ts +1 -14
  92. package/src/translate/run.ts +20 -35
  93. 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
@@ -146,6 +150,8 @@ const AskAI = ({
146
150
  const inputRef = useRef<HTMLTextAreaElement>(null);
147
151
  const scrollRef = useRef<HTMLDivElement>(null);
148
152
  const triggerRef = useRef<HTMLButtonElement>(null);
153
+ // The portaled panel root, excluded from the overlay-mode inert sweep.
154
+ const panelRef = useRef<HTMLElement>(null);
149
155
  // Where focus came from when the panel opened, restored on close.
150
156
  const returnFocusRef = useRef<HTMLElement | null>(null);
151
157
 
@@ -225,6 +231,68 @@ const AskAI = ({
225
231
  };
226
232
  }, [open]);
227
233
 
234
+ // Below the desktop dock breakpoint the open panel is a full-width overlay,
235
+ // so Tab must not escape into the page it covers: every other child of
236
+ // <body> (the panel portals to body) turns inert until close. The desktop
237
+ // dock keeps the page interactive on purpose — it's a non-modal side panel,
238
+ // so no sweep runs at ≥1024px. Elements that were already inert are left
239
+ // alone so closing doesn't accidentally re-enable them.
240
+ useEffect(() => {
241
+ if (!open) {
242
+ return;
243
+ }
244
+ const media = window.matchMedia("(min-width: 1024px)");
245
+ let inerted: Element[] = [];
246
+ const release = () => {
247
+ for (const el of inerted) {
248
+ el.removeAttribute("inert");
249
+ }
250
+ inerted = [];
251
+ };
252
+ const apply = () => {
253
+ release();
254
+ if (media.matches) {
255
+ return;
256
+ }
257
+ inerted = [...document.body.children].filter(
258
+ (el) => el !== panelRef.current && !el.hasAttribute("inert")
259
+ );
260
+ for (const el of inerted) {
261
+ el.setAttribute("inert", "");
262
+ }
263
+ };
264
+ apply();
265
+ // The sweep snapshots body's children at open time, but overlays keep
266
+ // arriving afterwards — medium-zoom's backdrop, a mermaid render, another
267
+ // island's portal all append to <body> — and an unswept latecomer is a
268
+ // tab stop hiding behind the overlay. Fold additions into the sweep for
269
+ // as long as it is active.
270
+ const observer = new MutationObserver((records) => {
271
+ if (media.matches) {
272
+ return;
273
+ }
274
+ for (const record of records) {
275
+ for (const node of record.addedNodes) {
276
+ if (
277
+ node instanceof HTMLElement &&
278
+ node !== panelRef.current &&
279
+ !node.hasAttribute("inert")
280
+ ) {
281
+ node.setAttribute("inert", "");
282
+ inerted.push(node);
283
+ }
284
+ }
285
+ }
286
+ });
287
+ observer.observe(document.body, { childList: true });
288
+ media.addEventListener("change", apply);
289
+ return () => {
290
+ observer.disconnect();
291
+ media.removeEventListener("change", apply);
292
+ release();
293
+ };
294
+ }, [open]);
295
+
228
296
  // Keep the newest message in view as it streams in.
229
297
  useEffect(() => {
230
298
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
@@ -265,7 +333,7 @@ const AskAI = ({
265
333
  const text = messages
266
334
  .map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
267
335
  .join("\n\n");
268
- void navigator.clipboard?.writeText(text);
336
+ void copyText(text);
269
337
  };
270
338
 
271
339
  const hasMessages = messages.length > 0;
@@ -274,6 +342,7 @@ const AskAI = ({
274
342
  <aside
275
343
  aria-hidden={open ? undefined : "true"}
276
344
  aria-label={t.title}
345
+ ref={panelRef}
277
346
  // The closed panel is only translated off-screen; `inert` drops its
278
347
  // buttons/textarea from the tab order and the accessibility tree.
279
348
  inert={!open}
@@ -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.
@@ -152,9 +152,13 @@ const basedRoute = withBase(route);
152
152
  // strip it before joining with the root-relative route so canonical/og URLs
153
153
  // don't come out double-slashed — the catch-all strips it the same way.
154
154
  const siteBase = siteUrl ? siteUrl.replace(/\/$/u, "") : null;
155
+ // The route-derived part is percent-encoded (the sitemap convention) so a
156
+ // Unicode route slug yields a legal URI that byte-matches the sitemap <loc>.
155
157
  const resolvedCanonical =
156
158
  canonical ??
157
- (siteBase ? `${siteBase}${basedRoute === "/" ? "" : basedRoute}` : null);
159
+ (siteBase
160
+ ? `${siteBase}${basedRoute === "/" ? "" : encodeURI(basedRoute)}`
161
+ : null);
158
162
  // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
159
163
  // `public/`) is resolved against the site URL so crawlers get an absolute
160
164
  // `og:image`; an already-absolute URL passes through untouched. Otherwise fall
@@ -164,7 +168,7 @@ const absolutizeOgImage = (value: string): string =>
164
168
  const resolvedOgImage = ogImage
165
169
  ? absolutizeOgImage(ogImage)
166
170
  : ogEnabled && siteBase
167
- ? `${siteBase}${withBase(`/og/${ogSlug}.png`)}`
171
+ ? `${siteBase}${encodeURI(withBase(`/og/${ogSlug}.png`))}`
168
172
  : null;
169
173
  // Only the generated card has a known size and format, so its dimensions are
170
174
  // declared for crawlers; a user-supplied `ogImage` could be any image.
@@ -305,36 +309,12 @@ const bannerKey = banner?.dismissible ? banner.key : null;
305
309
  )
306
310
  }
307
311
  <script>
312
+ import { syncDrawerInert } from "./drawer-inert.ts";
308
313
  // Dev-only: friendly hint after a React island hydration mismatch;
309
314
  // tree-shaken out of production builds.
310
315
  import "./hydration-hint.ts";
311
316
 
312
- // The closed tabs drawer is only translated off-canvas, so its links
313
- // would stay in the tab order. Mirror the header's `data-blume-nav-open`
314
- // toggle into `inert`/`aria-hidden` below `lg` (64rem), matching the
315
- // breakpoint where the drawer is display-hidden anyway.
316
- const drawer = document.querySelector<HTMLElement>(
317
- "[data-blume-nav-drawer]"
318
- );
319
- if (drawer) {
320
- const desktop = window.matchMedia("(min-width: 64rem)");
321
- const syncDrawer = () => {
322
- const hidden =
323
- !desktop.matches &&
324
- !document.documentElement.hasAttribute("data-blume-nav-open");
325
- drawer.inert = hidden;
326
- if (hidden) {
327
- drawer.setAttribute("aria-hidden", "true");
328
- } else {
329
- drawer.removeAttribute("aria-hidden");
330
- }
331
- };
332
- syncDrawer();
333
- desktop.addEventListener("change", syncDrawer);
334
- new MutationObserver(syncDrawer).observe(document.documentElement, {
335
- attributeFilter: ["data-blume-nav-open"],
336
- });
337
- }
317
+ syncDrawerInert();
338
318
  </script>
339
319
  </body>
340
320
  </html>
@@ -117,6 +117,14 @@ interface Props {
117
117
  exportPdf?: boolean;
118
118
  exportEpub?: boolean;
119
119
  feeds?: { title: string; href: string }[];
120
+ /**
121
+ * Which agent-discovery resources exist, advertised as `describedby` head
122
+ * links on every page — so an agent entering on a deep page (a search
123
+ * result, a shared link) finds the machine-readable surface without probing
124
+ * the site root. The HTML counterpart of the homepage-only HTTP `Link`
125
+ * header (see `ai/link-headers.ts`).
126
+ */
127
+ discovery?: { agentReadability: boolean; llmsTxt: boolean } | null;
120
128
  siteUrl?: string | null;
121
129
  pageType?: string;
122
130
  published?: string | Date | null;
@@ -198,6 +206,7 @@ const {
198
206
  exportPdf,
199
207
  exportEpub,
200
208
  feeds,
209
+ discovery,
201
210
  siteUrl,
202
211
  pageType,
203
212
  published,
@@ -312,6 +321,15 @@ const formattedLastModified =
312
321
  ).format(lastModifiedDate)
313
322
  : null;
314
323
 
324
+ // This page's raw-Markdown mirror, advertised as a `text/markdown` alternate
325
+ // in the head. Content routes always have one (see `markdownRoutePaths`, which
326
+ // serves the same `route === "/" ? "/index.md" : "<route>.md"` mapping as the
327
+ // PageActions menu); the generated changelog index — the only "bare" page — is
328
+ // not a content route and has none.
329
+ const markdownMirror = isBare
330
+ ? null
331
+ : withBase(page.route === "/" ? "/index.md" : `${page.route}.md`);
332
+
315
333
  // The hosted MCP server's absolute URL, used by the page-actions install menu.
316
334
  // Needs a configured site to be useful, so the menu is hidden without one.
317
335
  const mcpUrl =
@@ -428,6 +446,29 @@ const bannerKey = banner?.dismissible ? banner.key : null;
428
446
  />
429
447
  ))
430
448
  }
449
+ {/* Agent discovery on every page, not just the root: an agent that enters
450
+ on a deep page (a search result, a shared link) never sees the homepage
451
+ HTTP Link header, so the head carries the same describedby links plus
452
+ this page's own raw-Markdown mirror. Both rels are IANA-registered. */}
453
+ {
454
+ discovery?.agentReadability && (
455
+ <link
456
+ href={withBase("/agent-readability.json")}
457
+ rel="describedby"
458
+ type="application/json"
459
+ />
460
+ )
461
+ }
462
+ {
463
+ discovery?.llmsTxt && (
464
+ <link href={withBase("/llms.txt")} rel="describedby" type="text/plain" />
465
+ )
466
+ }
467
+ {
468
+ markdownMirror && (
469
+ <link href={markdownMirror} rel="alternate" type="text/markdown" />
470
+ )
471
+ }
431
472
  {
432
473
  structuredDataJson && (
433
474
  <script
@@ -671,6 +712,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
671
712
  type="button"
672
713
  ></button>
673
714
  <script>
715
+ import { copyText, createCopyFlash } from "../copy-feedback.ts";
716
+ import { syncDrawerInert } from "./drawer-inert.ts";
674
717
  import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
675
718
  // Registers the <blume-mermaid> custom element (emitted by ```mermaid
676
719
  // fences). Mermaid itself is lazy-loaded only on pages that use a diagram.
@@ -682,32 +725,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
682
725
  // Tree-shaken out of production builds.
683
726
  import "./hydration-hint.ts";
684
727
 
685
- // The closed mobile drawer is only translated off-canvas, so its links
686
- // would stay in the tab order on every page. Mirror the header's
687
- // `data-blume-nav-open` toggle into `inert`/`aria-hidden` — but only
688
- // below `lg` (64rem), where the same element isn't the static sidebar.
689
- const drawer = document.querySelector<HTMLElement>(
690
- "[data-blume-nav-drawer]"
691
- );
692
- if (drawer) {
693
- const desktop = window.matchMedia("(min-width: 64rem)");
694
- const syncDrawer = () => {
695
- const hidden =
696
- !desktop.matches &&
697
- !document.documentElement.hasAttribute("data-blume-nav-open");
698
- drawer.inert = hidden;
699
- if (hidden) {
700
- drawer.setAttribute("aria-hidden", "true");
701
- } else {
702
- drawer.removeAttribute("aria-hidden");
703
- }
704
- };
705
- syncDrawer();
706
- desktop.addEventListener("change", syncDrawer);
707
- new MutationObserver(syncDrawer).observe(document.documentElement, {
708
- attributeFilter: ["data-blume-nav-open"],
709
- });
710
- }
728
+ syncDrawerInert();
711
729
 
712
730
  const svg = (name: string, cls = "") =>
713
731
  `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${cls ? ` class="${cls}"` : ""}>${icons[name]}</svg>`;
@@ -726,14 +744,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
726
744
  const copiedLabel =
727
745
  document.body.getAttribute("data-i18n-copied") || "Copied!";
728
746
 
729
- // Shared polite live region: the icon swap is invisible to screen
730
- // readers, so copy success is announced here. Cleared when a button's
731
- // checked state resets so the next copy re-announces.
732
- const copyStatus = document.createElement("span");
733
- copyStatus.className = "sr-only";
734
- copyStatus.setAttribute("aria-live", "polite");
735
- document.body.appendChild(copyStatus);
736
-
737
747
  const languageLabels: Record<string, string> = {
738
748
  astro: "Astro",
739
749
  bash: "Bash",
@@ -813,7 +823,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
813
823
  button.classList.toggle(cls, !checked);
814
824
  }
815
825
  };
816
- let resetTimeout: number | undefined;
826
+ const flash = createCopyFlash(setChecked, copiedLabel);
817
827
  button.addEventListener("click", async () => {
818
828
  const code = pre.querySelector("code");
819
829
  let text = code?.textContent ?? "";
@@ -829,20 +839,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
829
839
  }
830
840
  text = clone.textContent ?? "";
831
841
  }
832
- try {
833
- await navigator.clipboard.writeText(text);
834
- } catch {
835
- return;
836
- }
837
- if (resetTimeout) {
838
- window.clearTimeout(resetTimeout);
842
+ if (await copyText(text)) {
843
+ flash();
839
844
  }
840
- setChecked(true);
841
- copyStatus.textContent = copiedLabel;
842
- resetTimeout = window.setTimeout(() => {
843
- setChecked(false);
844
- copyStatus.textContent = "";
845
- }, 1500);
846
845
  });
847
846
  pre.appendChild(button);
848
847
  }
@@ -86,11 +86,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
86
86
  >
87
87
  <Icon name="search" size={18} />
88
88
  <input
89
+ aria-autocomplete="list"
90
+ aria-controls="blume-search-listbox"
91
+ aria-expanded="false"
89
92
  aria-label={s.label}
90
93
  autocomplete="off"
91
94
  class="flex-1 border-0 bg-transparent text-foreground text-sm pointer-coarse:text-base focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
92
95
  data-blume-search-input
93
96
  placeholder={s.placeholder}
97
+ role="combobox"
94
98
  type="search"
95
99
  />
96
100
  <kbd class={`${kbd} text-[0.7rem]`}>Esc</kbd>
@@ -107,8 +111,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
107
111
  >
108
112
  </div>
109
113
  <div
114
+ aria-label={s.label}
110
115
  class="min-h-0 flex-1 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto p-2"
111
116
  data-blume-search-results
117
+ id="blume-search-listbox"
118
+ role="listbox"
112
119
  >
113
120
  </div>
114
121
  <p
@@ -207,6 +214,26 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
207
214
  const svg = (name: string, size = 16): string =>
208
215
  `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] ?? ""}</svg>`;
209
216
 
217
+ // localStorage access throws SecurityError when storage is blocked (Safari
218
+ // "Block All Cookies", some embedded webviews). These guards make blocked
219
+ // storage degrade to session-default preferences instead of throwing inside
220
+ // connectedCallback before the open/keyboard listeners are attached — which
221
+ // would leave search completely dead.
222
+ const readStorage = (key: string): string | null => {
223
+ try {
224
+ return localStorage.getItem(key);
225
+ } catch {
226
+ return null;
227
+ }
228
+ };
229
+ const writeStorage = (key: string, value: string): void => {
230
+ try {
231
+ localStorage.setItem(key, value);
232
+ } catch {
233
+ // Preference simply isn't remembered.
234
+ }
235
+ };
236
+
210
237
  class BlumeSearch extends HTMLElement {
211
238
  dialog!: HTMLDialogElement;
212
239
  input!: HTMLInputElement;
@@ -226,6 +253,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
226
253
  selectedIndex = -1;
227
254
  activeSection: string | null = null;
228
255
  renderGeneration = 0;
256
+ /** Monotonic id source for option rows (aria-activedescendant). */
257
+ optionSeq = 0;
229
258
  previewOn = true;
230
259
  devOnlyMsg = "Search is available in the production build.";
231
260
  noResultsMsg = "No results found.";
@@ -273,8 +302,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
273
302
  this.popular = [];
274
303
  }
275
304
 
276
- this.previewOn =
277
- localStorage.getItem("blume-search-preview") !== "0";
305
+ this.previewOn = readStorage("blume-search-preview") !== "0";
278
306
  this.applyPreviewState();
279
307
 
280
308
  // Per-language filtering: default to the active locale, with an opt-in
@@ -283,12 +311,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
283
311
  "[data-blume-search-all-locales]"
284
312
  );
285
313
  if (allToggle) {
286
- this.allLocales =
287
- localStorage.getItem("blume-search-all-locales") === "1";
314
+ this.allLocales = readStorage("blume-search-all-locales") === "1";
288
315
  allToggle.checked = this.allLocales;
289
316
  allToggle.addEventListener("change", () => {
290
317
  this.allLocales = allToggle.checked;
291
- localStorage.setItem(
318
+ writeStorage(
292
319
  "blume-search-all-locales",
293
320
  this.allLocales ? "1" : "0"
294
321
  );
@@ -568,7 +595,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
568
595
  header.className =
569
596
  "m-0 px-2.5 pt-3 pb-1 font-normal text-muted-foreground text-xs";
570
597
  header.textContent = label;
598
+ // The listbox tree allows only group/option descendants: the group
599
+ // carries the label for assistive tech, the visual header is
600
+ // decoration.
601
+ header.setAttribute("aria-hidden", "true");
571
602
  const container = document.createElement("div");
603
+ container.setAttribute("role", "group");
604
+ container.setAttribute("aria-label", label);
572
605
  this.results.append(header, container);
573
606
  return container;
574
607
  }
@@ -632,6 +665,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
632
665
  }
633
666
 
634
667
  bindRow(item: Selectable) {
668
+ // Options for the combobox pattern: selection is announced through
669
+ // aria-activedescendant on the input (focus never leaves it), so
670
+ // every row needs a stable id and an aria-selected to flip.
671
+ item.el.setAttribute("role", "option");
672
+ item.el.setAttribute("aria-selected", "false");
673
+ this.optionSeq += 1;
674
+ item.el.id = `blume-search-option-${this.optionSeq}`;
635
675
  item.el.addEventListener("mouseenter", () => {
636
676
  this.selectIndex(this.selectables.indexOf(item));
637
677
  });
@@ -644,9 +684,14 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
644
684
  }
645
685
 
646
686
  finishRender() {
687
+ this.input.setAttribute(
688
+ "aria-expanded",
689
+ String(this.selectables.length > 0)
690
+ );
647
691
  if (this.selectables.length > 0) {
648
692
  this.selectIndex(0);
649
693
  } else {
694
+ this.input.removeAttribute("aria-activedescendant");
650
695
  this.clearPreview();
651
696
  }
652
697
  }
@@ -659,11 +704,16 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
659
704
  if (current) {
660
705
  current.el.classList.remove(...ROW_ON);
661
706
  current.el.classList.add(...ROW_OFF);
707
+ current.el.setAttribute("aria-selected", "false");
662
708
  }
663
709
  this.selectedIndex = index;
664
710
  const next = this.selectables[index];
665
711
  next.el.classList.remove(...ROW_OFF);
666
712
  next.el.classList.add(...ROW_ON);
713
+ next.el.setAttribute("aria-selected", "true");
714
+ // Focus stays on the input; the selection is surfaced to assistive
715
+ // tech through the active descendant.
716
+ this.input.setAttribute("aria-activedescendant", next.el.id);
667
717
  next.el.scrollIntoView({ block: "nearest" });
668
718
  this.updatePreview();
669
719
  }
@@ -714,10 +764,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
714
764
 
715
765
  togglePreview() {
716
766
  this.previewOn = !this.previewOn;
717
- localStorage.setItem(
718
- "blume-search-preview",
719
- this.previewOn ? "1" : "0"
720
- );
767
+ writeStorage("blume-search-preview", this.previewOn ? "1" : "0");
721
768
  this.applyPreviewState();
722
769
  this.updatePreview();
723
770
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Keep the mobile nav drawer out of the tab order while it is closed. The
3
+ * closed drawer is only translated off-canvas, so its links would otherwise
4
+ * stay focusable on every page. Mirrors the header's `data-blume-nav-open`
5
+ * toggle into `inert`/`aria-hidden` — but only below `lg` (64rem), where the
6
+ * same element isn't the static sidebar (RootLayout) or is display-hidden
7
+ * anyway (PageLayout). Shared by both layouts' inline scripts.
8
+ */
9
+ export const syncDrawerInert = (): void => {
10
+ const drawer = document.querySelector<HTMLElement>("[data-blume-nav-drawer]");
11
+ if (!drawer) {
12
+ return;
13
+ }
14
+ const desktop = window.matchMedia("(min-width: 64rem)");
15
+ const sync = () => {
16
+ const hidden =
17
+ !desktop.matches &&
18
+ !Object.hasOwn(document.documentElement.dataset, "blumeNavOpen");
19
+ drawer.inert = hidden;
20
+ if (hidden) {
21
+ drawer.setAttribute("aria-hidden", "true");
22
+ } else {
23
+ drawer.removeAttribute("aria-hidden");
24
+ }
25
+ };
26
+ sync();
27
+ desktop.addEventListener("change", sync);
28
+ new MutationObserver(sync).observe(document.documentElement, {
29
+ attributeFilter: ["data-blume-nav-open"],
30
+ });
31
+ };
@@ -1,4 +1,4 @@
1
- import { highlight, SEARCH_LIMIT } from "./types.ts";
1
+ import { highlight, sanitizeExcerpt, SEARCH_LIMIT } from "./types.ts";
2
2
  import type { SearchFn } from "./types.ts";
3
3
 
4
4
  interface PagefindResult {
@@ -28,16 +28,17 @@ export const createSearch = async (opts: {
28
28
  /* @vite-ignore */
29
29
  opts.url
30
30
  )) as PagefindModule;
31
- // Pagefind builds its own marked-up excerpt; we keep that and only highlight
32
- // the title. It carries no section/breadcrumb data, so pills stay hidden and
33
- // the preview pane falls back to the excerpt.
31
+ // Pagefind builds its own marked-up excerpt; we keep its `<mark>` highlights
32
+ // (dropping any other markup the excerpt is rendered via innerHTML) and
33
+ // only highlight the title ourselves. It carries no section/breadcrumb data,
34
+ // so pills stay hidden and the preview pane falls back to the excerpt.
34
35
  return async (query) => {
35
36
  const response = await pagefind.search(query);
36
37
  const docs = await Promise.all(
37
38
  response.results.slice(0, SEARCH_LIMIT).map((result) => result.data())
38
39
  );
39
40
  const hits = docs.map((doc) => ({
40
- excerpt: doc.excerpt,
41
+ excerpt: sanitizeExcerpt(doc.excerpt),
41
42
  title: highlight(doc.meta?.title ?? doc.url, query),
42
43
  url: doc.url,
43
44
  }));