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.
- package/CHANGELOG.md +54 -0
- package/dist/cli/index.js +694 -579
- package/dist/cli/index.js.map +56 -55
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +8 -0
- package/dist/types/core/schema.d.ts +4 -0
- package/dist/types/core/sources/types.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +8 -2
- package/docs/configuration/ai.mdx +26 -8
- package/docs/content/sources.mdx +1 -1
- package/package.json +11 -1
- package/src/ai/agent-readability.ts +3 -2
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +45 -12
- package/src/ai/llms.ts +2 -1
- package/src/ai/mcp/discovery.ts +25 -6
- package/src/ai/mcp/server.ts +108 -98
- package/src/ai/tar.ts +29 -70
- package/src/astro/examples.ts +7 -3
- package/src/astro/generate.ts +59 -34
- package/src/astro/islands.ts +7 -3
- package/src/astro/templates.ts +34 -9
- package/src/audit/agent.ts +14 -29
- package/src/audit/crawl.ts +41 -16
- package/src/audit/run.ts +10 -3
- package/src/audit/snapshot.ts +27 -2
- package/src/cli/commands/audit.ts +12 -17
- package/src/cli/commands/build.ts +15 -7
- package/src/cli/commands/dev.ts +13 -15
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +17 -27
- package/src/cli/env.ts +13 -30
- package/src/cli/init/scaffold.ts +21 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- package/src/components/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +78 -9
- package/src/components/layout/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +6 -48
- package/src/components/layout/Search.astro +56 -9
- package/src/components/layout/drawer-inert.ts +31 -0
- package/src/components/layout/search/pagefind.ts +6 -5
- package/src/components/layout/search/types.ts +32 -0
- package/src/components/openapi/panel.ts +11 -8
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/config-input.ts +8 -0
- package/src/core/frontmatter.ts +45 -1
- package/src/core/probe.ts +7 -19
- package/src/core/project-graph.ts +12 -1
- package/src/core/schema.ts +6 -0
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/cache.ts +10 -8
- package/src/core/sources/github-releases.ts +21 -1
- package/src/core/sources/normalize.ts +26 -2
- package/src/core/sources/notion.ts +27 -5
- package/src/core/sources/portable-text.ts +16 -1
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/types.ts +13 -1
- package/src/deploy/cloudflare-negotiation.ts +15 -1
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +56 -7
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +1 -14
- package/src/markdown/package-commands.ts +61 -54
- package/src/og/card.ts +24 -26
- package/src/openapi/model.ts +9 -9
- package/src/openapi/parse.ts +69 -28
- package/src/openapi/references.ts +35 -12
- package/src/openapi/render-mdx.ts +64 -25
- package/src/openapi/scalar.ts +2 -2
- package/src/openapi/source.ts +28 -1
- package/src/search/documents.ts +78 -34
- package/src/search/orama-index.ts +51 -12
- package/src/theme/palette.ts +6 -2
- package/src/translate/ledger.ts +4 -2
- package/src/translate/report.ts +1 -14
- package/src/translate/run.ts +20 -35
- 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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
label
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
//
|
|
33
|
-
// the
|
|
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 === "<" ? "<" : "";
|
|
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
|
-
|
|
44
|
-
|
|
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);
|
package/src/core/base-path.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/core/config-input.ts
CHANGED
|
@@ -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`. */
|