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.
- package/CHANGELOG.md +64 -0
- package/dist/cli/index.js +700 -579
- package/dist/cli/index.js.map +57 -56
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +8 -0
- package/dist/types/core/data.d.ts +10 -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 +41 -9
- package/docs/content/sources.mdx +1 -1
- package/package.json +17 -7
- 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/link-headers.ts +7 -2
- 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 +63 -34
- package/src/astro/islands.ts +7 -3
- package/src/astro/templates.ts +36 -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 +47 -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/data.ts +7 -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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import DOMPurify from "dompurify";
|
|
2
|
-
import {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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
|
}));
|