blume 1.4.2 → 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 (90) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli/index.js +694 -579
  3. package/dist/cli/index.js.map +56 -55
  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/schema.d.ts +4 -0
  7. package/dist/types/core/sources/types.d.ts +9 -1
  8. package/dist/types/openapi/references.d.ts +8 -2
  9. package/docs/configuration/ai.mdx +26 -8
  10. package/docs/content/sources.mdx +1 -1
  11. package/package.json +11 -1
  12. package/src/ai/agent-readability.ts +3 -2
  13. package/src/ai/api-catalog.ts +2 -2
  14. package/src/ai/ask-context.ts +45 -12
  15. package/src/ai/llms.ts +2 -1
  16. package/src/ai/mcp/discovery.ts +25 -6
  17. package/src/ai/mcp/server.ts +108 -98
  18. package/src/ai/tar.ts +29 -70
  19. package/src/astro/examples.ts +7 -3
  20. package/src/astro/generate.ts +59 -34
  21. package/src/astro/islands.ts +7 -3
  22. package/src/astro/templates.ts +34 -9
  23. package/src/audit/agent.ts +14 -29
  24. package/src/audit/crawl.ts +41 -16
  25. package/src/audit/run.ts +10 -3
  26. package/src/audit/snapshot.ts +27 -2
  27. package/src/cli/commands/audit.ts +12 -17
  28. package/src/cli/commands/build.ts +15 -7
  29. package/src/cli/commands/dev.ts +13 -15
  30. package/src/cli/commands/eject.ts +4 -4
  31. package/src/cli/commands/eval.ts +17 -27
  32. package/src/cli/env.ts +13 -30
  33. package/src/cli/init/scaffold.ts +21 -0
  34. package/src/cli/report-format.ts +22 -0
  35. package/src/components/content/AccordionItem.astro +2 -9
  36. package/src/components/content/ColorItem.astro +5 -13
  37. package/src/components/content/Component.astro +12 -8
  38. package/src/components/content/Frame.astro +2 -12
  39. package/src/components/content/Prompt.astro +12 -31
  40. package/src/components/content/Tab.astro +2 -9
  41. package/src/components/content/Tooltip.astro +1 -9
  42. package/src/components/content/Update.astro +2 -9
  43. package/src/components/content/inline-markdown.ts +28 -0
  44. package/src/components/copy-feedback.ts +96 -0
  45. package/src/components/islands/ask-ai.tsx +78 -9
  46. package/src/components/layout/PageActions.astro +20 -32
  47. package/src/components/layout/PageLayout.astro +8 -28
  48. package/src/components/layout/RootLayout.astro +6 -48
  49. package/src/components/layout/Search.astro +56 -9
  50. package/src/components/layout/drawer-inert.ts +31 -0
  51. package/src/components/layout/search/pagefind.ts +6 -5
  52. package/src/components/layout/search/types.ts +32 -0
  53. package/src/components/openapi/panel.ts +11 -8
  54. package/src/components/raf-throttle.ts +21 -0
  55. package/src/components/slug.ts +14 -0
  56. package/src/core/base-path.ts +18 -1
  57. package/src/core/config-input.ts +8 -0
  58. package/src/core/frontmatter.ts +45 -1
  59. package/src/core/probe.ts +7 -19
  60. package/src/core/project-graph.ts +12 -1
  61. package/src/core/schema.ts +6 -0
  62. package/src/core/site-url.ts +27 -0
  63. package/src/core/sources/cache.ts +10 -8
  64. package/src/core/sources/github-releases.ts +21 -1
  65. package/src/core/sources/normalize.ts +26 -2
  66. package/src/core/sources/notion.ts +27 -5
  67. package/src/core/sources/portable-text.ts +16 -1
  68. package/src/core/sources/resolve.ts +1 -0
  69. package/src/core/sources/types.ts +13 -1
  70. package/src/deploy/cloudflare-negotiation.ts +15 -1
  71. package/src/deploy/robots.ts +2 -1
  72. package/src/deploy/rss.ts +2 -1
  73. package/src/deploy/sitemap.ts +56 -7
  74. package/src/eval/agents.ts +13 -10
  75. package/src/eval/report.ts +1 -14
  76. package/src/markdown/package-commands.ts +61 -54
  77. package/src/og/card.ts +24 -26
  78. package/src/openapi/model.ts +9 -9
  79. package/src/openapi/parse.ts +69 -28
  80. package/src/openapi/references.ts +35 -12
  81. package/src/openapi/render-mdx.ts +64 -25
  82. package/src/openapi/scalar.ts +2 -2
  83. package/src/openapi/source.ts +28 -1
  84. package/src/search/documents.ts +78 -34
  85. package/src/search/orama-index.ts +51 -12
  86. package/src/theme/palette.ts +6 -2
  87. package/src/translate/ledger.ts +4 -2
  88. package/src/translate/report.ts +1 -14
  89. package/src/translate/run.ts +20 -35
  90. package/src/cli/coalesce.ts +0 -43
@@ -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>
@@ -712,6 +712,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
712
712
  type="button"
713
713
  ></button>
714
714
  <script>
715
+ import { copyText, createCopyFlash } from "../copy-feedback.ts";
716
+ import { syncDrawerInert } from "./drawer-inert.ts";
715
717
  import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
716
718
  // Registers the <blume-mermaid> custom element (emitted by ```mermaid
717
719
  // fences). Mermaid itself is lazy-loaded only on pages that use a diagram.
@@ -723,32 +725,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
723
725
  // Tree-shaken out of production builds.
724
726
  import "./hydration-hint.ts";
725
727
 
726
- // The closed mobile drawer is only translated off-canvas, so its links
727
- // would stay in the tab order on every page. Mirror the header's
728
- // `data-blume-nav-open` toggle into `inert`/`aria-hidden` — but only
729
- // below `lg` (64rem), where the same element isn't the static sidebar.
730
- const drawer = document.querySelector<HTMLElement>(
731
- "[data-blume-nav-drawer]"
732
- );
733
- if (drawer) {
734
- const desktop = window.matchMedia("(min-width: 64rem)");
735
- const syncDrawer = () => {
736
- const hidden =
737
- !desktop.matches &&
738
- !document.documentElement.hasAttribute("data-blume-nav-open");
739
- drawer.inert = hidden;
740
- if (hidden) {
741
- drawer.setAttribute("aria-hidden", "true");
742
- } else {
743
- drawer.removeAttribute("aria-hidden");
744
- }
745
- };
746
- syncDrawer();
747
- desktop.addEventListener("change", syncDrawer);
748
- new MutationObserver(syncDrawer).observe(document.documentElement, {
749
- attributeFilter: ["data-blume-nav-open"],
750
- });
751
- }
728
+ syncDrawerInert();
752
729
 
753
730
  const svg = (name: string, cls = "") =>
754
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>`;
@@ -767,14 +744,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
767
744
  const copiedLabel =
768
745
  document.body.getAttribute("data-i18n-copied") || "Copied!";
769
746
 
770
- // Shared polite live region: the icon swap is invisible to screen
771
- // readers, so copy success is announced here. Cleared when a button's
772
- // checked state resets so the next copy re-announces.
773
- const copyStatus = document.createElement("span");
774
- copyStatus.className = "sr-only";
775
- copyStatus.setAttribute("aria-live", "polite");
776
- document.body.appendChild(copyStatus);
777
-
778
747
  const languageLabels: Record<string, string> = {
779
748
  astro: "Astro",
780
749
  bash: "Bash",
@@ -854,7 +823,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
854
823
  button.classList.toggle(cls, !checked);
855
824
  }
856
825
  };
857
- let resetTimeout: number | undefined;
826
+ const flash = createCopyFlash(setChecked, copiedLabel);
858
827
  button.addEventListener("click", async () => {
859
828
  const code = pre.querySelector("code");
860
829
  let text = code?.textContent ?? "";
@@ -870,20 +839,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
870
839
  }
871
840
  text = clone.textContent ?? "";
872
841
  }
873
- try {
874
- await navigator.clipboard.writeText(text);
875
- } catch {
876
- return;
877
- }
878
- if (resetTimeout) {
879
- window.clearTimeout(resetTimeout);
842
+ if (await copyText(text)) {
843
+ flash();
880
844
  }
881
- setChecked(true);
882
- copyStatus.textContent = copiedLabel;
883
- resetTimeout = window.setTimeout(() => {
884
- setChecked(false);
885
- copyStatus.textContent = "";
886
- }, 1500);
887
845
  });
888
846
  pre.appendChild(button);
889
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
  }));
@@ -84,6 +84,38 @@ export const highlight = (text: string, query: string): string => {
84
84
  .join("");
85
85
  };
86
86
 
87
+ // Either a tag-shaped run — an opening `<` with a letter or `/` after it,
88
+ // through the closing `>` (or end of string for an unterminated tag) — or a
89
+ // lone `<`. A run can't span a later `<` (`[^<>]`), so between the two
90
+ // alternatives every `<` in the input lands inside a captured run.
91
+ const ANGLE_RUN = /(?<run><\/?[a-z][^<>]*>?|<)/iu;
92
+ const BARE_MARK = /^<\/?mark>$/iu;
93
+
94
+ /**
95
+ * Reduce provider-supplied excerpt markup to the `<mark>` highlighting the
96
+ * dialog expects. Remote excerpts (Pagefind's index, hosted engines) are
97
+ * rendered via `innerHTML`, so the output alphabet is pinned: bare
98
+ * `<mark>`/`</mark>` tags (attributes make even a mark untrusted), text, and
99
+ * entities. Tag-shaped runs are dropped; every other `<` is escaped, which
100
+ * renders identically but can't be parsed as markup (`<!--` would otherwise
101
+ * open a comment in `innerHTML` and swallow the rest of the excerpt). Split on
102
+ * runs covering every `<` rather than deleting tags in place: a deletion can
103
+ * splice the text around it into a fresh tag (`<<b>script>` → `<script>`),
104
+ * while here no `<` survives outside a run, so the only ones emitted are the
105
+ * bare mark tags. String-level on purpose: this also runs under DOM-less
106
+ * tests, where DOMPurify/DOMParser don't exist.
107
+ */
108
+ export const sanitizeExcerpt = (html: string): string =>
109
+ html
110
+ .split(ANGLE_RUN)
111
+ .map((part, index) => {
112
+ if (index % 2 === 0 || BARE_MARK.test(part)) {
113
+ return part;
114
+ }
115
+ return part === "<" ? "&lt;" : "";
116
+ })
117
+ .join("");
118
+
87
119
  /** First index in `text` where any query token matches (case-insensitive). */
88
120
  const matchIndex = (text: string, query: string): number => {
89
121
  const tokens = queryTokens(query);
@@ -6,6 +6,8 @@
6
6
  * theme.
7
7
  */
8
8
 
9
+ import { copyText, createCopyFlash } from "../copy-feedback.ts";
10
+
9
11
  class BlumePanelTabs extends HTMLElement {
10
12
  connectedCallback() {
11
13
  const tabs = [
@@ -36,18 +38,19 @@ class BlumePanelTabs extends HTMLElement {
36
38
  }
37
39
 
38
40
  if (copy) {
41
+ const flash = createCopyFlash((copied) => {
42
+ if (copied) {
43
+ copy.dataset.copied = "true";
44
+ } else {
45
+ delete copy.dataset.copied;
46
+ }
47
+ }, "Copied");
39
48
  copy.addEventListener("click", async () => {
40
49
  const active = panels.find(
41
50
  (panel) => !panel.classList.contains("hidden")
42
51
  );
43
- try {
44
- await navigator.clipboard.writeText(active?.textContent ?? "");
45
- copy.dataset.copied = "true";
46
- setTimeout(() => {
47
- delete copy.dataset.copied;
48
- }, 1500);
49
- } catch {
50
- // Clipboard unavailable (insecure context); silently ignore.
52
+ if (await copyText(active?.textContent ?? "")) {
53
+ flash();
51
54
  }
52
55
  });
53
56
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Coalesce a high-frequency event handler (resize, scroll) into at most one
3
+ * call per animation frame — the toc-element scroll pattern, shared. Calls
4
+ * landing while a frame is pending are dropped; the handler runs once on the
5
+ * next frame with the latest state. Layout reads inside `fn` then happen once
6
+ * per frame instead of once per event, without the settle lag a debounce
7
+ * would add to position-tracking handlers.
8
+ */
9
+ export const rafThrottle = (fn: () => void): (() => void) => {
10
+ let ticking = false;
11
+ return () => {
12
+ if (ticking) {
13
+ return;
14
+ }
15
+ ticking = true;
16
+ requestAnimationFrame(() => {
17
+ ticking = false;
18
+ fn();
19
+ });
20
+ };
21
+ };
@@ -0,0 +1,14 @@
1
+ import { slug } from "github-slugger";
2
+
3
+ /**
4
+ * Slug a component's title into a DOM id (accordions, tabs, changelog
5
+ * updates), replacing three identical per-component copies. github-slugger —
6
+ * already what heading anchors use, both here and in Satteri's own
7
+ * heading-ids — so a component id slugs exactly like a heading with the same
8
+ * text (unicode letters kept, `user_id` keeps its underscore, `C#` keeps
9
+ * nothing extra dropped). Stateless on purpose: components render across many
10
+ * pages in one build process, so a stateful slugger would leak duplicate
11
+ * suffixes between pages — same-page duplicates are de-duplicated client-side
12
+ * by each component's own script.
13
+ */
14
+ export const componentSlug = (value: string): string => slug(value);
@@ -12,6 +12,8 @@
12
12
  * `components/islands/base-path.ts` and serves `deployment.base` via `BASE_URL`.
13
13
  */
14
14
 
15
+ import { trimEnd } from "./trim.ts";
16
+
15
17
  /**
16
18
  * Canonicalize a configured base path to either `""` (none) or `/seg[/seg…]`
17
19
  * (leading slash, no trailing slash, collapsed inner slashes). A blank value or
@@ -33,10 +35,25 @@ export const normalizeBasePath = (input?: string): string => {
33
35
  * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
34
36
  */
35
37
  export const normalizePath = (path: string): string => {
36
- const trimmed = path.replace(/\/+$/u, "");
38
+ const trimmed = trimEnd(path, "/");
37
39
  return trimmed === "" ? "/" : trimmed;
38
40
  };
39
41
 
42
+ /**
43
+ * Canonicalize a route-ish string (a configured route, a page path, an agent-
44
+ * supplied route) to `/` or `/seg[/seg…]`: trimmed, exactly one leading slash,
45
+ * no trailing slash. The shared spelling of what openapi/references,
46
+ * ai/ask-context, and ai/mcp/server each hand-rolled with slightly different
47
+ * regexes.
48
+ */
49
+ export const normalizeRoute = (input: string): string => {
50
+ const trimmed = trimEnd(input.trim(), "/");
51
+ if (trimmed === "") {
52
+ return "/";
53
+ }
54
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
55
+ };
56
+
40
57
  /**
41
58
  * Whether a link target is a root-relative internal path (`/x`) — the only
42
59
  * shape a base path applies to. Protocol-relative (`//host`), absolute URLs,
@@ -205,6 +205,8 @@ export interface SanitySource {
205
205
  /** A Notion database; pages become entries, blocks become MDX. */
206
206
  export interface NotionSource {
207
207
  type: "notion";
208
+ /** Max concurrent Notion API requests; default 3 (Notion's per-integration pace). */
209
+ concurrency?: number;
208
210
  /** Notion database id. */
209
211
  database: string;
210
212
  /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
@@ -653,6 +655,12 @@ export interface AskConfig {
653
655
  * limiting, and streaming. Accepts an absolute URL or root-relative path.
654
656
  */
655
657
  endpoint?: string;
658
+ /**
659
+ * Extra system-prompt text appended to the built-in instructions — use it
660
+ * for identity, language, or tone. The built-in grounding behavior (answer
661
+ * from the retrieved excerpts, cite pages as Markdown links) is preserved.
662
+ */
663
+ instructions?: string;
656
664
  /** Model id to use. Defaults to `openai/gpt-5.5`. */
657
665
  model?: string;
658
666
  /** Which backend routes the request. Defaults to `gateway`. */