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
package/src/cli/env.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
 
3
- import { parse } from "dotenv";
3
+ import { config } from "dotenv";
4
4
  import { dirname, join, resolve } from "pathe";
5
5
 
6
6
  // Blume's remote sources (GitHub Releases, mdx-remote, Sanity, Notion…) read
@@ -9,44 +9,27 @@ import { dirname, join, resolve } from "pathe";
9
9
  // that gap: it cascades `.env`/`.env.local` from the working dir up to the repo
10
10
  // root, so a monorepo can keep one `.env` at the root and every app picks it up.
11
11
 
12
- /** Apply parsed vars without clobbering anything already in `process.env`. */
13
- const applyEnv = (parsed: Record<string, string>): void => {
14
- for (const [key, value] of Object.entries(parsed)) {
15
- if (!(key in process.env)) {
16
- process.env[key] = value;
17
- }
18
- }
19
- };
20
-
21
- const loadFile = (path: string): void => {
22
- try {
23
- if (existsSync(path)) {
24
- // dotenv is the same parser Vite runs over these files at build time,
25
- // so a value means the same thing to the pre-boot content scan and the
26
- // built site — including multi-line double-quoted values (PEM keys),
27
- // which a line-based parser silently truncates.
28
- applyEnv(parse(readFileSync(path, "utf-8")));
29
- }
30
- } catch {
31
- // Env files are best-effort; a read/parse failure must not abort a build.
32
- }
33
- };
34
-
35
12
  /**
36
13
  * Load `.env`/`.env.local`, cascading from `startDir` up to the repository root
37
- * (the first ancestor containing a `.git`) or the filesystem root. Nearer files
38
- * and existing `process.env` values win, so shell/CI overrides are never lost
39
- * and `.env.local` layers over `.env`.
14
+ * (the first ancestor containing a `.git`) or the filesystem root. The ordered
15
+ * path list is handed to dotenv, whose `config` is first-wins, never clobbers
16
+ * existing `process.env` values, and treats unreadable files as best-effort —
17
+ * so shell/CI overrides are never lost, `.env.local` layers over `.env`, and a
18
+ * bad file never aborts a build. dotenv is also the parser Vite runs over
19
+ * these files at build time, so a value means the same thing to the pre-boot
20
+ * content scan and the built site — including multi-line double-quoted values
21
+ * (PEM keys), which a line-based parser silently truncates.
40
22
  */
41
23
  export const loadEnvFiles = (startDir: string): void => {
24
+ const paths: string[] = [];
42
25
  let dir = resolve(startDir);
43
26
  let done = false;
44
27
  while (!done) {
45
- loadFile(join(dir, ".env.local"));
46
- loadFile(join(dir, ".env"));
28
+ paths.push(join(dir, ".env.local"), join(dir, ".env"));
47
29
  const parent = dirname(dir);
48
30
  // Stop at the repo root (nearest `.git`) or the filesystem root.
49
31
  done = existsSync(join(dir, ".git")) || parent === dir;
50
32
  dir = parent;
51
33
  }
34
+ config({ path: paths, quiet: true });
52
35
  };
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
 
4
+ import { detect } from "package-manager-detector/detect";
4
5
  import { basename, dirname, isAbsolute, join, relative } from "pathe";
5
6
 
6
7
  import { blumePackageJson, toPackageName } from "../../core/package-json.ts";
@@ -155,12 +156,32 @@ export const commandsFor = (
155
156
  /**
156
157
  * Derive the package manager from an npm user-agent string (the first
157
158
  * `name/version` token of `npm_config_user_agent`), falling back to npm.
159
+ * Right for `init`, where the project doesn't exist yet and the invoking
160
+ * runner is the only signal.
158
161
  */
159
162
  export const detectPackageManager = (userAgent?: string): PackageManager => {
160
163
  const name = userAgent?.split("/")[0] as PackageManager | undefined;
161
164
  return name !== undefined && PACKAGE_MANAGERS.includes(name) ? name : "npm";
162
165
  };
163
166
 
167
+ /**
168
+ * Detect an existing project's package manager from its lockfile /
169
+ * `packageManager` field (package-manager-detector), falling back to the
170
+ * user agent. Right for `eject`: the CLI is often run directly (`npx blume
171
+ * eject`, a bare `blume eject`), where `npm_config_user_agent` is absent or
172
+ * names the runner rather than the project's manager, and the old
173
+ * user-agent-only detection silently printed npm hints for a pnpm project.
174
+ */
175
+ export const detectProjectPackageManager = async (
176
+ root: string
177
+ ): Promise<PackageManager> => {
178
+ const detected = await detect({ cwd: root });
179
+ const name = detected?.name as PackageManager | undefined;
180
+ return name !== undefined && PACKAGE_MANAGERS.includes(name)
181
+ ? name
182
+ : detectPackageManager(process.env.npm_config_user_agent);
183
+ };
184
+
164
185
  /**
165
186
  * The content dir is joined into every scaffolded file path, so an absolute or
166
187
  * `../`-escaping value would write outside the project. Returns an error
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Number formatting shared by the translate and eval report renderers. The
3
+ * `1.5s` / `4m 12s` shapes are asserted in both suites — change them in both
4
+ * minds at once.
5
+ */
6
+
7
+ /** Milliseconds as `1.5s`. */
8
+ export const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
9
+
10
+ /** A dollar cost as `$1.23`, or nothing when the run reported none. */
11
+ export const money = (cost: number | undefined): string =>
12
+ cost === undefined ? "" : `$${cost.toFixed(2)}`;
13
+
14
+ /** Milliseconds as `12.3s` under a minute, `4m 12s` from there up. */
15
+ export const duration = (ms: number): string => {
16
+ if (ms < 60_000) {
17
+ return seconds(ms);
18
+ }
19
+ const minutes = Math.floor(ms / 60_000);
20
+ const rest = Math.round((ms % 60_000) / 1000);
21
+ return `${minutes}m ${rest}s`;
22
+ };
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "../Icon.astro";
3
+ import { componentSlug } from "../slug.ts";
3
4
 
4
5
  interface Props {
5
6
  title: string;
@@ -10,15 +11,7 @@ interface Props {
10
11
  }
11
12
 
12
13
  const { defaultOpen = false, description, icon, id, title } = Astro.props;
13
- const slugify = (value: string): string =>
14
- value
15
- .toLowerCase()
16
- .trim()
17
- .replaceAll(/[^\w\s-]/gu, "")
18
- .replaceAll(/[\s_]+/gu, "-")
19
- .replaceAll(/-+/gu, "-")
20
- .replaceAll(/^-|-$/gu, "");
21
- const accordionId = id ?? slugify(title);
14
+ const accordionId = id ?? componentSlug(title);
22
15
  ---
23
16
 
24
17
  <details
@@ -53,6 +53,8 @@ const displayValue =
53
53
  </style>
54
54
 
55
55
  <script>
56
+ import { copyText, flashLabel } from "../copy-feedback.ts";
57
+
56
58
  const state = window as Window & { __blumeColorCopy?: boolean };
57
59
  if (!state.__blumeColorCopy) {
58
60
  state.__blumeColorCopy = true;
@@ -67,24 +69,14 @@ const displayValue =
67
69
  return;
68
70
  }
69
71
 
70
- try {
71
- await navigator.clipboard.writeText(button.dataset.blumeColorCopy ?? "");
72
- } catch {
72
+ if (!(await copyText(button.dataset.blumeColorCopy ?? ""))) {
73
73
  return;
74
74
  }
75
75
 
76
76
  const status = button.querySelector<HTMLElement>("[data-blume-color-status]");
77
- if (!status) {
78
- return;
77
+ if (status) {
78
+ flashLabel(status, "Copied");
79
79
  }
80
-
81
- // Remember the real value once — capturing at click time would capture
82
- // "Copied" on a double-click and stick until reload.
83
- status.dataset.blumeLabel ??= status.textContent ?? "";
84
- status.textContent = "Copied";
85
- window.setTimeout(() => {
86
- status.textContent = status.dataset.blumeLabel ?? "";
87
- }, 1500);
88
80
  });
89
81
  }
90
82
  </script>
@@ -165,12 +165,16 @@ const paneClass = "motion-safe:transition-[height] motion-safe:duration-200";
165
165
 
166
166
  // A pane capped by a small viewport would otherwise stay small after the
167
167
  // window grows: the frame's content stopped changing size, so its observer
168
- // has nothing new to report.
169
- window.addEventListener("resize", () => {
170
- for (const frame of document.querySelectorAll<HTMLIFrameElement>(
171
- "iframe[data-blume-example-frame][data-blume-reported-height]"
172
- )) {
173
- applyMeasuredHeight(frame);
174
- }
175
- });
168
+ // has nothing new to report. rAF-coalesced so a live resize drag runs the
169
+ // read-then-write pass once per frame, not once per event.
170
+ window.addEventListener(
171
+ "resize",
172
+ rafThrottle(() => {
173
+ for (const frame of document.querySelectorAll<HTMLIFrameElement>(
174
+ "iframe[data-blume-example-frame][data-blume-reported-height]"
175
+ )) {
176
+ applyMeasuredHeight(frame);
177
+ }
178
+ })
179
+ );
176
180
  </script>
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import { createSatteriMarkdownProcessor } from "@astrojs/markdown-satteri";
3
3
 
4
+ import { escapeRawHtml, unwrapParagraph } from "./inline-markdown.ts";
5
+
4
6
  interface Props {
5
7
  caption?: string;
6
8
  hint?: string;
@@ -8,18 +10,6 @@ interface Props {
8
10
 
9
11
  const { caption, hint } = Astro.props;
10
12
 
11
- const escapeRawHtml = (value: string): string =>
12
- value.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
13
-
14
- const unwrapParagraph = (html: string): string => {
15
- const trimmed = html.trim();
16
- // Only a *single* paragraph is unwrapped: the content must not contain its
17
- // own `</p>`, or `<p>a</p>\n<p>b</p>` would "unwrap" to `a</p>\n<p>b` —
18
- // unbalanced HTML injected via set:html.
19
- const match = trimmed.match(/^<p>(?<content>(?:(?!<\/p>)[\s\S])*)<\/p>$/u);
20
- return match?.groups?.content ?? trimmed;
21
- };
22
-
23
13
  const markdownProcessor = await createSatteriMarkdownProcessor();
24
14
  const captionHtml = caption
25
15
  ? unwrapParagraph((await markdownProcessor.render(escapeRawHtml(caption))).code)
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import { createSatteriMarkdownProcessor } from "@astrojs/markdown-satteri";
3
3
 
4
+ import { escapeRawHtml, unwrapParagraph } from "./inline-markdown.ts";
5
+
4
6
  interface Props {
5
7
  actions?: boolean | string | string[];
6
8
  description: string;
@@ -8,18 +10,6 @@ interface Props {
8
10
 
9
11
  const { actions = ["copy"], description } = Astro.props;
10
12
 
11
- const escapeRawHtml = (value: string): string =>
12
- value.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
13
-
14
- const unwrapParagraph = (html: string): string => {
15
- const trimmed = html.trim();
16
- // Only a *single* paragraph is unwrapped: the content must not contain its
17
- // own `</p>`, or `<p>a</p>\n<p>b</p>` would "unwrap" to `a</p>\n<p>b` —
18
- // unbalanced HTML injected via set:html.
19
- const match = trimmed.match(/^<p>(?<content>(?:(?!<\/p>)[\s\S])*)<\/p>$/u);
20
- return match?.groups?.content ?? trimmed;
21
- };
22
-
23
13
  const normalizeActions = (value: Props["actions"]): string[] => {
24
14
  if (Array.isArray(value)) {
25
15
  return value;
@@ -93,6 +83,8 @@ const secondaryButton =
93
83
  </blume-prompt>
94
84
 
95
85
  <script>
86
+ import { copyText, createCopyFlash } from "../copy-feedback.ts";
87
+
96
88
  class BlumePrompt extends HTMLElement {
97
89
  connectedCallback() {
98
90
  const content = this.querySelector<HTMLElement>(
@@ -116,28 +108,17 @@ const secondaryButton =
116
108
  const done = copy?.querySelector<HTMLElement>(
117
109
  "[data-blume-prompt-copy-done]"
118
110
  );
119
- let resetTimer: ReturnType<typeof setTimeout> | undefined;
111
+ // Toggle visibility rather than swapping text: both labels occupy the
112
+ // same grid cell, so the button width never changes.
113
+ const flash = createCopyFlash((copied) => {
114
+ idle?.classList.toggle("invisible", copied);
115
+ done?.classList.toggle("invisible", !copied);
116
+ }, "Copied");
120
117
  copy?.addEventListener("click", async () => {
121
118
  const text = promptText();
122
- if (!text) {
123
- return;
124
- }
125
- try {
126
- await navigator.clipboard.writeText(text);
127
- } catch {
128
- return;
119
+ if (text && (await copyText(text))) {
120
+ flash();
129
121
  }
130
-
131
- // Toggle visibility rather than swapping text: both labels occupy the
132
- // same grid cell, so the button width never changes. Clearing the timer
133
- // keeps the "Copied" state 1.5s past the last of several rapid clicks.
134
- clearTimeout(resetTimer);
135
- idle?.classList.add("invisible");
136
- done?.classList.remove("invisible");
137
- resetTimer = setTimeout(() => {
138
- idle?.classList.remove("invisible");
139
- done?.classList.add("invisible");
140
- }, 1500);
141
122
  });
142
123
  }
143
124
  }
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "../Icon.astro";
3
+ import { componentSlug } from "../slug.ts";
3
4
 
4
5
  interface Props {
5
6
  class?: string;
@@ -10,15 +11,7 @@ interface Props {
10
11
  }
11
12
 
12
13
  const { class: className, icon, id, style, title } = Astro.props;
13
- const slugify = (value: string): string =>
14
- value
15
- .toLowerCase()
16
- .trim()
17
- .replaceAll(/[^\w\s-]/gu, "")
18
- .replaceAll(/[\s_]+/gu, "-")
19
- .replaceAll(/-+/gu, "-")
20
- .replaceAll(/^-|-$/gu, "");
21
- const tabId = id ?? slugify(title);
14
+ const tabId = id ?? componentSlug(title);
22
15
  ---
23
16
 
24
17
  {/*
@@ -2,6 +2,7 @@
2
2
  import { randomUUID } from "node:crypto";
3
3
 
4
4
  import { contentHref } from "./base-href.ts";
5
+ import { unwrapParagraph } from "./inline-markdown.ts";
5
6
 
6
7
  interface Props {
7
8
  cta?: string;
@@ -14,15 +15,6 @@ const { cta, headline, href, tip } = Astro.props;
14
15
  const tooltipId = `blume-tooltip-${randomUUID()}`;
15
16
  const external = href?.startsWith("http");
16
17
 
17
- const unwrapParagraph = (html: string): string => {
18
- const trimmed = html.trim();
19
- // Only a *single* paragraph is unwrapped: the content must not contain its
20
- // own `</p>`, or `<p>a</p>\n<p>b</p>` would "unwrap" to `a</p>\n<p>b` —
21
- // unbalanced HTML injected via set:html.
22
- const match = trimmed.match(/^<p>(?<content>(?:(?!<\/p>)[\s\S])*)<\/p>$/u);
23
- return match?.groups?.content ?? trimmed;
24
- };
25
-
26
18
  const labelHtml = Astro.slots.has("default")
27
19
  ? unwrapParagraph(await Astro.slots.render("default"))
28
20
  : "";
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { componentSlug } from "../slug.ts";
2
3
  import { contentHref } from "./base-href.ts";
3
4
 
4
5
  interface RssMetadata {
@@ -31,16 +32,8 @@ const {
31
32
  tags,
32
33
  title,
33
34
  } = Astro.props;
34
- const slugify = (text: string): string =>
35
- text
36
- .toLowerCase()
37
- .trim()
38
- .replaceAll(/[^\w\s-]/gu, "")
39
- .replaceAll(/[\s_]+/gu, "-")
40
- .replaceAll(/-+/gu, "-")
41
- .replaceAll(/^-|-$/gu, "");
42
35
  const updateLabel = label ?? title ?? "Update";
43
- const id = providedId ?? (slugify(updateLabel) || "update");
36
+ const id = providedId ?? (componentSlug(updateLabel) || "update");
44
37
  const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
45
38
  ---
46
39
 
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared helpers for components that render a short Markdown prop (a caption,
3
+ * a description, a tooltip label) into inline HTML injected via `set:html`.
4
+ * Previously copied verbatim into Prompt, Frame, and Tooltip.
5
+ */
6
+
7
+ /**
8
+ * Neutralize raw HTML in a Markdown-rendered text prop by escaping only
9
+ * `<`/`>`. `&` deliberately stays: CommonMark already renders a bare `&` as
10
+ * `&amp;` and resolves real entity references, so leaving it alone keeps
11
+ * `&copy;`-style authoring working — a full HTML escape would render it as
12
+ * the literal text `&copy;`.
13
+ */
14
+ export const escapeRawHtml = (value: string): string =>
15
+ value.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
16
+
17
+ /**
18
+ * Unwrap the `<p>` a block-level Markdown render wraps around single-line
19
+ * content, so it can sit inside inline markup. Only a *single* paragraph is
20
+ * unwrapped: the content must not contain its own `</p>`, or
21
+ * `<p>a</p>\n<p>b</p>` would "unwrap" to `a</p>\n<p>b` — unbalanced HTML
22
+ * injected via `set:html`.
23
+ */
24
+ export const unwrapParagraph = (html: string): string => {
25
+ const trimmed = html.trim();
26
+ const match = trimmed.match(/^<p>(?<content>(?:(?!<\/p>)[\s\S])*)<\/p>$/u);
27
+ return match?.groups?.content ?? trimmed;
28
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared clipboard + "Copied" feedback used by every copy affordance (code
3
+ * blocks, page actions, color swatches, prompts, API panels, Ask AI). One
4
+ * implementation owns the invariants each site used to hand-roll:
5
+ *
6
+ * - the clipboard write is guarded, and nothing flashes on failure — a
7
+ * confirmation must never lie;
8
+ * - repeat copies restart the hold instead of stacking timers, so the copied
9
+ * state never reverts early after a double-click;
10
+ * - every successful copy is announced to a shared polite live region, so the
11
+ * confirmation is audible, not just visual (previously only the code-block
12
+ * button announced).
13
+ */
14
+
15
+ /** How long the copied confirmation holds before reverting. */
16
+ const HOLD_MS = 1500;
17
+
18
+ /** The shared visually-hidden live region, created on first announcement. */
19
+ let region: HTMLElement | null = null;
20
+
21
+ /**
22
+ * Announce `message` to screen readers. The region is re-created if a swap
23
+ * (view transition, client router) disconnected it.
24
+ */
25
+ export const announceCopied = (message: string): void => {
26
+ if (!region?.isConnected) {
27
+ region = document.createElement("div");
28
+ region.setAttribute("role", "status");
29
+ region.className = "sr-only";
30
+ document.body.append(region);
31
+ }
32
+ // Clear first so repeating the same message is re-announced.
33
+ region.textContent = "";
34
+ region.textContent = message;
35
+ };
36
+
37
+ /**
38
+ * Copy `text` to the clipboard. Returns whether the write succeeded; failures
39
+ * (insecure context, permissions) are swallowed so callers can simply skip
40
+ * their confirmation.
41
+ */
42
+ export const copyText = async (text: string): Promise<boolean> => {
43
+ try {
44
+ await navigator.clipboard.writeText(text);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ };
50
+
51
+ /**
52
+ * A per-affordance flash: `apply(true)` paints the copied state, `apply(false)`
53
+ * reverts it after the hold. Calling the returned function again restarts the
54
+ * hold. `announce` (the localized "Copied" label) is spoken on each flash.
55
+ */
56
+ export const createCopyFlash = (
57
+ apply: (copied: boolean) => void,
58
+ announce?: string,
59
+ holdMs: number = HOLD_MS
60
+ ): (() => void) => {
61
+ let timer: ReturnType<typeof setTimeout> | undefined;
62
+ return () => {
63
+ clearTimeout(timer);
64
+ apply(true);
65
+ if (announce) {
66
+ announceCopied(announce);
67
+ }
68
+ timer = setTimeout(() => apply(false), holdMs);
69
+ };
70
+ };
71
+
72
+ /** Flash timers for {@link flashLabel}, keyed per element. */
73
+ const labelTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>();
74
+
75
+ /**
76
+ * The text-swap flash: show `message` in `el`, then restore its own (possibly
77
+ * localized) label after the hold. The original label is captured once, on the
78
+ * first flash — capturing at click time would capture the flash message itself
79
+ * on a double-click and stick until reload.
80
+ */
81
+ export const flashLabel = (
82
+ el: HTMLElement,
83
+ message: string,
84
+ holdMs: number = HOLD_MS
85
+ ): void => {
86
+ el.dataset.blumeLabel ??= el.textContent ?? "";
87
+ el.textContent = message;
88
+ announceCopied(message);
89
+ clearTimeout(labelTimers.get(el));
90
+ labelTimers.set(
91
+ el,
92
+ setTimeout(() => {
93
+ el.textContent = el.dataset.blumeLabel ?? "";
94
+ }, holdMs)
95
+ );
96
+ };
@@ -1,10 +1,11 @@
1
1
  import DOMPurify from "dompurify";
2
- import { marked } from "marked";
2
+ import { Marked } from "marked";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
 
7
7
  import type { UIStrings } from "../../core/i18n-ui.ts";
8
+ import { copyText } from "../copy-feedback.ts";
8
9
  import { joinBase, prefixBase } from "./base-path.ts";
9
10
  import { useAskAI } from "./hooks.ts";
10
11
 
@@ -57,12 +58,15 @@ const DEFAULT_ASK: UIStrings["ask"] = {
57
58
  const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
58
59
 
59
60
  // GitHub-flavored markdown with soft line breaks, matching how the docs read.
60
- marked.setOptions({ breaks: true, gfm: true });
61
-
62
- // The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
63
- // link targets to served URLs so citations resolve under `deployment.base`.
64
- // `prefixBase` leaves external URLs and fragments untouched and is idempotent.
65
- marked.use({
61
+ // A dedicated instance, not the shared `marked` singleton: `setOptions`/`use`
62
+ // on the singleton would leak `breaks` and the link rewriter into any other
63
+ // consumer of `marked` on the page (user components included).
64
+ const markdown = new Marked({
65
+ breaks: true,
66
+ gfm: true,
67
+ // The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
68
+ // link targets to served URLs so citations resolve under `deployment.base`.
69
+ // `prefixBase` leaves external URLs and fragments untouched and is idempotent.
66
70
  walkTokens: (token) => {
67
71
  if (token.type === "link") {
68
72
  token.href = prefixBase(import.meta.env.BASE_URL, token.href);
@@ -71,7 +75,7 @@ marked.use({
71
75
  });
72
76
 
73
77
  const renderMarkdown = (content: string): string =>
74
- DOMPurify.sanitize(marked.parse(content, { async: false }));
78
+ DOMPurify.sanitize(markdown.parse(content, { async: false }));
75
79
 
76
80
  const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
77
81
  <svg
@@ -146,6 +150,8 @@ const AskAI = ({
146
150
  const inputRef = useRef<HTMLTextAreaElement>(null);
147
151
  const scrollRef = useRef<HTMLDivElement>(null);
148
152
  const triggerRef = useRef<HTMLButtonElement>(null);
153
+ // The portaled panel root, excluded from the overlay-mode inert sweep.
154
+ const panelRef = useRef<HTMLElement>(null);
149
155
  // Where focus came from when the panel opened, restored on close.
150
156
  const returnFocusRef = useRef<HTMLElement | null>(null);
151
157
 
@@ -225,6 +231,68 @@ const AskAI = ({
225
231
  };
226
232
  }, [open]);
227
233
 
234
+ // Below the desktop dock breakpoint the open panel is a full-width overlay,
235
+ // so Tab must not escape into the page it covers: every other child of
236
+ // <body> (the panel portals to body) turns inert until close. The desktop
237
+ // dock keeps the page interactive on purpose — it's a non-modal side panel,
238
+ // so no sweep runs at ≥1024px. Elements that were already inert are left
239
+ // alone so closing doesn't accidentally re-enable them.
240
+ useEffect(() => {
241
+ if (!open) {
242
+ return;
243
+ }
244
+ const media = window.matchMedia("(min-width: 1024px)");
245
+ let inerted: Element[] = [];
246
+ const release = () => {
247
+ for (const el of inerted) {
248
+ el.removeAttribute("inert");
249
+ }
250
+ inerted = [];
251
+ };
252
+ const apply = () => {
253
+ release();
254
+ if (media.matches) {
255
+ return;
256
+ }
257
+ inerted = [...document.body.children].filter(
258
+ (el) => el !== panelRef.current && !el.hasAttribute("inert")
259
+ );
260
+ for (const el of inerted) {
261
+ el.setAttribute("inert", "");
262
+ }
263
+ };
264
+ apply();
265
+ // The sweep snapshots body's children at open time, but overlays keep
266
+ // arriving afterwards — medium-zoom's backdrop, a mermaid render, another
267
+ // island's portal all append to <body> — and an unswept latecomer is a
268
+ // tab stop hiding behind the overlay. Fold additions into the sweep for
269
+ // as long as it is active.
270
+ const observer = new MutationObserver((records) => {
271
+ if (media.matches) {
272
+ return;
273
+ }
274
+ for (const record of records) {
275
+ for (const node of record.addedNodes) {
276
+ if (
277
+ node instanceof HTMLElement &&
278
+ node !== panelRef.current &&
279
+ !node.hasAttribute("inert")
280
+ ) {
281
+ node.setAttribute("inert", "");
282
+ inerted.push(node);
283
+ }
284
+ }
285
+ }
286
+ });
287
+ observer.observe(document.body, { childList: true });
288
+ media.addEventListener("change", apply);
289
+ return () => {
290
+ observer.disconnect();
291
+ media.removeEventListener("change", apply);
292
+ release();
293
+ };
294
+ }, [open]);
295
+
228
296
  // Keep the newest message in view as it streams in.
229
297
  useEffect(() => {
230
298
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
@@ -265,7 +333,7 @@ const AskAI = ({
265
333
  const text = messages
266
334
  .map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
267
335
  .join("\n\n");
268
- void navigator.clipboard?.writeText(text);
336
+ void copyText(text);
269
337
  };
270
338
 
271
339
  const hasMessages = messages.length > 0;
@@ -274,6 +342,7 @@ const AskAI = ({
274
342
  <aside
275
343
  aria-hidden={open ? undefined : "true"}
276
344
  aria-label={t.title}
345
+ ref={panelRef}
277
346
  // The closed panel is only translated off-screen; `inert` drops its
278
347
  // buttons/textarea from the tab order and the accessibility tree.
279
348
  inert={!open}