blume 1.1.3 → 1.2.0

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 (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +1473 -149
  4. package/dist/cli/index.js.map +47 -36
  5. package/dist/types/core/config-input.d.ts +18 -0
  6. package/dist/types/core/config.d.ts +4 -0
  7. package/dist/types/core/data.d.ts +3 -0
  8. package/dist/types/core/schema.d.ts +132 -17
  9. package/dist/types/core/types.d.ts +5 -3
  10. package/dist/types/openapi/references.d.ts +6 -0
  11. package/docs/advanced/api-reference.mdx +27 -0
  12. package/docs/advanced/changelog.mdx +10 -0
  13. package/docs/configuration/ai.mdx +38 -2
  14. package/docs/configuration/customization.mdx +27 -0
  15. package/docs/configuration/index.mdx +5 -0
  16. package/docs/content/navigation.mdx +12 -0
  17. package/docs/reference/cli.mdx +17 -13
  18. package/docs/reference/eval.mdx +106 -0
  19. package/docs/reference/meta.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +19 -1
  22. package/src/ai/llms.ts +9 -4
  23. package/src/ai/mcp/server.ts +48 -14
  24. package/src/ai/mcp/stdio.ts +35 -0
  25. package/src/astro/generate.ts +119 -48
  26. package/src/astro/templates.ts +173 -37
  27. package/src/audit/checks/duplicates.ts +15 -6
  28. package/src/audit/checks/indexability.ts +11 -2
  29. package/src/audit/checks/network.ts +22 -8
  30. package/src/audit/checks/sitemap.ts +42 -16
  31. package/src/audit/redirects.ts +12 -1
  32. package/src/audit/run.ts +13 -3
  33. package/src/audit/url.ts +21 -2
  34. package/src/cli/commands/audit.ts +21 -6
  35. package/src/cli/commands/dev.ts +19 -2
  36. package/src/cli/commands/eval.ts +291 -0
  37. package/src/cli/commands/init.ts +9 -4
  38. package/src/cli/commands/mcp-stdio.ts +36 -0
  39. package/src/cli/index.ts +4 -0
  40. package/src/cli/required-secrets.ts +1 -1
  41. package/src/components/content/AccordionItem.astro +2 -2
  42. package/src/components/content/Frame.astro +4 -1
  43. package/src/components/content/Prompt.astro +4 -1
  44. package/src/components/content/Tooltip.astro +4 -1
  45. package/src/components/content/TreeFolder.astro +1 -2
  46. package/src/components/content/Update.astro +45 -0
  47. package/src/components/islands/AskAI.astro +9 -2
  48. package/src/components/islands/ask-ai.tsx +23 -4
  49. package/src/components/islands/hooks.ts +48 -15
  50. package/src/components/layout/NavTree.astro +37 -19
  51. package/src/components/layout/ReferenceLayout.astro +4 -0
  52. package/src/components/layout/RootLayout.astro +14 -3
  53. package/src/components/layout/Search.astro +5 -1
  54. package/src/components/layout/head-scripts.ts +22 -5
  55. package/src/components/openapi/SchemaProperty.astro +3 -3
  56. package/src/core/config-input.ts +18 -0
  57. package/src/core/config.ts +4 -0
  58. package/src/core/data.ts +3 -0
  59. package/src/core/deployment-env.ts +7 -2
  60. package/src/core/graph.ts +8 -1
  61. package/src/core/i18n.ts +10 -2
  62. package/src/core/navigation.ts +16 -5
  63. package/src/core/schema.ts +51 -4
  64. package/src/core/server-features.ts +1 -1
  65. package/src/core/sources/normalize.ts +69 -8
  66. package/src/core/sources/notion.ts +4 -2
  67. package/src/core/sources/sanity.ts +5 -3
  68. package/src/core/types.ts +5 -3
  69. package/src/eval/agents.ts +340 -0
  70. package/src/eval/findings.ts +103 -0
  71. package/src/eval/prompts.ts +78 -0
  72. package/src/eval/report.ts +214 -0
  73. package/src/eval/run.ts +290 -0
  74. package/src/eval/schema.ts +124 -0
  75. package/src/markdown/code-title.ts +7 -1
  76. package/src/openapi/model.ts +31 -2
  77. package/src/openapi/references.ts +23 -2
  78. package/src/openapi/render-mdx.ts +39 -11
  79. package/src/openapi/scalar.ts +1 -0
  80. package/src/openapi/source.ts +11 -4
  81. package/src/registry/eject.ts +23 -1
  82. package/src/search/build.ts +4 -3
@@ -0,0 +1,291 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import { defineCommand } from "citty";
4
+ import { join } from "pathe";
5
+
6
+ import {
7
+ AGENTS,
8
+ launchAgent,
9
+ WINDOWS_COMMAND_NOT_FOUND,
10
+ } from "../../audit/agent.ts";
11
+ import type { AgentKind } from "../../audit/agent.ts";
12
+ import { BlumeError } from "../../core/diagnostics.ts";
13
+ import { scanProject } from "../../core/project-graph.ts";
14
+ import { evalFixPrompt, initPrompt } from "../../eval/prompts.ts";
15
+ import {
16
+ evalReportJson,
17
+ fixLines,
18
+ headerLine,
19
+ questionDetails,
20
+ questionLine,
21
+ startLine,
22
+ summaryLine,
23
+ warningLines,
24
+ writeEvalReport,
25
+ } from "../../eval/report.ts";
26
+ import { runEval } from "../../eval/run.ts";
27
+ import type { EvalResult } from "../../eval/run.ts";
28
+ import { EvalsFileError, loadEvalsFile } from "../../eval/schema.ts";
29
+ import { reportInternalError } from "../internal-error.ts";
30
+ import { flushStdout, logger } from "../log.ts";
31
+
32
+ const DEFAULT_FILE = "evals.yaml";
33
+
34
+ /** Reader wall-clock ceiling per question, in seconds. */
35
+ const DEFAULT_TIMEOUT_S = 180;
36
+
37
+ const isAgentKind = (value: string): value is AgentKind => value in AGENTS;
38
+
39
+ /**
40
+ * Launch the interactive agent CLI, translating a missing executable into the
41
+ * Windows not-found sentinel. Only `ENOENT` means "not installed" — any other
42
+ * spawn failure (`EACCES`, `EMFILE`, …) must surface as itself.
43
+ */
44
+ const launchAgentCode = async (
45
+ bin: string,
46
+ prompt: string
47
+ ): Promise<number> => {
48
+ try {
49
+ return await launchAgent(bin, prompt);
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
52
+ throw error;
53
+ }
54
+ return WINDOWS_COMMAND_NOT_FOUND;
55
+ }
56
+ };
57
+
58
+ const notInstalled = (agent: AgentKind): never => {
59
+ const cli = AGENTS[agent];
60
+ logger.error(
61
+ `${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
62
+ );
63
+ return process.exit(1);
64
+ };
65
+
66
+ /** The fraction of run (non-skipped) questions that passed. */
67
+ export const passFraction = (result: EvalResult): number => {
68
+ const ran = result.results.length - result.counts.skip;
69
+ return ran === 0 ? 1 : result.counts.pass / ran;
70
+ };
71
+
72
+ interface EvalFlags {
73
+ action?: string;
74
+ agent: string;
75
+ file: string;
76
+ fix?: boolean;
77
+ json?: boolean;
78
+ threshold?: string;
79
+ timeout?: string;
80
+ }
81
+
82
+ /** Validate the flag surface, exiting with a message on the first offense. */
83
+ const parseFlags = (
84
+ args: EvalFlags
85
+ ): { agent: AgentKind; threshold: number; timeoutS: number } => {
86
+ if (!isAgentKind(args.agent)) {
87
+ logger.error(`Invalid --agent "${args.agent}" (use claude | codex).`);
88
+ process.exit(1);
89
+ }
90
+ if (args.action !== undefined && args.action !== "init") {
91
+ logger.error(`Unknown action "${args.action}" (did you mean "init"?).`);
92
+ process.exit(1);
93
+ }
94
+ if (args.json && args.fix) {
95
+ logger.error("--json and --fix are mutually exclusive.");
96
+ process.exit(1);
97
+ }
98
+ const threshold = args.threshold === undefined ? 1 : Number(args.threshold);
99
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
100
+ logger.error(`Invalid --threshold "${args.threshold}" (use 0..1).`);
101
+ process.exit(1);
102
+ }
103
+ const timeoutS =
104
+ args.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(args.timeout);
105
+ if (!Number.isInteger(timeoutS) || timeoutS <= 0) {
106
+ logger.error(`Invalid --timeout "${args.timeout}" (whole seconds).`);
107
+ process.exit(1);
108
+ }
109
+ return { agent: args.agent, threshold, timeoutS };
110
+ };
111
+
112
+ /** `blume eval --fix`: hand the failing report to the interactive agent. */
113
+ const runFixHandoff = async (
114
+ agent: AgentKind,
115
+ result: EvalResult,
116
+ root: string,
117
+ threshold: number
118
+ ): Promise<void> => {
119
+ const count = result.counts.fail + result.counts.error;
120
+ if (count === 0) {
121
+ return;
122
+ }
123
+ const cli = AGENTS[agent];
124
+ const report = await writeEvalReport(result, root, threshold);
125
+ process.stderr.write(
126
+ ` Handing ${count} failed question${count === 1 ? "" : "s"} to ${cli.name}…\n\n`
127
+ );
128
+ const code = await launchAgentCode(cli.bin, evalFixPrompt(report));
129
+ if (code === WINDOWS_COMMAND_NOT_FOUND) {
130
+ notInstalled(agent);
131
+ }
132
+ if (code !== 0) {
133
+ process.exit(code);
134
+ }
135
+ };
136
+
137
+ /** `blume eval init`: draft a starter evals file via the interactive agent. */
138
+ const runInit = async (agent: AgentKind, file: string): Promise<void> => {
139
+ const path = join(process.cwd(), file);
140
+ if (existsSync(path)) {
141
+ logger.error(
142
+ `${file} already exists — edit it directly, or pass --file to draft elsewhere.`
143
+ );
144
+ process.exit(1);
145
+ }
146
+ const code = await launchAgentCode(AGENTS[agent].bin, initPrompt(file));
147
+ if (code === WINDOWS_COMMAND_NOT_FOUND) {
148
+ notInstalled(agent);
149
+ }
150
+ if (code !== 0) {
151
+ process.exit(code);
152
+ }
153
+ };
154
+
155
+ export const evalCommand = defineCommand({
156
+ args: {
157
+ action: {
158
+ description: 'Optional action: "init" drafts a starter evals file.',
159
+ required: false,
160
+ type: "positional",
161
+ },
162
+ agent: {
163
+ default: "claude",
164
+ description: "Agent CLI that reads and grades the docs: claude | codex.",
165
+ type: "string",
166
+ },
167
+ file: {
168
+ default: DEFAULT_FILE,
169
+ description: "The evals file to run.",
170
+ type: "string",
171
+ },
172
+ fix: {
173
+ description:
174
+ "After a failing run, hand the report to the agent to fix the docs interactively.",
175
+ type: "boolean",
176
+ },
177
+ json: {
178
+ description: "Emit the report as JSON on stdout (for CI/editors).",
179
+ type: "boolean",
180
+ },
181
+ threshold: {
182
+ description:
183
+ "Minimum passing fraction (0..1) before the run exits non-zero. Defaults to 1.",
184
+ type: "string",
185
+ },
186
+ timeout: {
187
+ description: `Reader time limit per question, in seconds. Defaults to ${DEFAULT_TIMEOUT_S}.`,
188
+ type: "string",
189
+ },
190
+ verbose: {
191
+ description: "Include the reader's full answer under each failure.",
192
+ type: "boolean",
193
+ },
194
+ },
195
+ meta: {
196
+ description:
197
+ "Test the docs: an agent answers your questions using only the documentation.",
198
+ name: "eval",
199
+ },
200
+ async run({ args }) {
201
+ const root = process.cwd();
202
+ const { agent, threshold, timeoutS } = parseFlags(args);
203
+ if (args.action === "init") {
204
+ await runInit(agent, args.file);
205
+ return;
206
+ }
207
+
208
+ let result: EvalResult;
209
+ try {
210
+ // `scanProject`, not `prepareProject`: the eval reads the content tree
211
+ // and never regenerates the runtime, so it doesn't contend with a
212
+ // running dev server. Same reasoning as `blume audit`.
213
+ const project = await scanProject(root, { mode: "build" });
214
+ const evalsPath = join(root, args.file);
215
+ const { evals, raw } = await loadEvalsFile(evalsPath);
216
+
217
+ process.stderr.write(`${headerLine(evals.questions.length, agent)}\n\n`);
218
+ result = await runEval({
219
+ agent,
220
+ evals,
221
+ evalsPath,
222
+ onProgress: (event) => {
223
+ // Straight to stderr, not `logger.info` — consola drops info-level
224
+ // lines in test and CI environments.
225
+ if (event.kind === "question-start") {
226
+ process.stderr.write(
227
+ `${startLine(event.id, event.index, event.total)}\n`
228
+ );
229
+ return;
230
+ }
231
+ const lines = [
232
+ questionLine(event.result),
233
+ ...questionDetails(event.result, Boolean(args.verbose)),
234
+ ];
235
+ process.stderr.write(`${lines.join("\n")}\n`);
236
+ },
237
+ project,
238
+ rawEvals: raw,
239
+ readerTimeoutMs: timeoutS * 1000,
240
+ });
241
+ } catch (error) {
242
+ if (error instanceof EvalsFileError) {
243
+ logger.error(error.message);
244
+ process.exit(1);
245
+ }
246
+ if (error instanceof BlumeError) {
247
+ logger.error(error.diagnostic.message);
248
+ process.exit(1);
249
+ }
250
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
251
+ notInstalled(agent);
252
+ }
253
+ reportInternalError(error);
254
+ process.exit(1);
255
+ }
256
+
257
+ const tail = [
258
+ "",
259
+ ...warningLines(result, root),
260
+ ...fixLines(result, root),
261
+ "",
262
+ ` ${summaryLine(result)}`,
263
+ "",
264
+ ];
265
+ process.stderr.write(tail.join("\n"));
266
+
267
+ const failed = passFraction(result) < threshold;
268
+
269
+ if (args.fix) {
270
+ // The gate is a CI concern; a handoff run succeeds when the agent
271
+ // session does, not when the docs already passed.
272
+ await runFixHandoff(agent, result, root, threshold);
273
+ return;
274
+ }
275
+
276
+ if (args.json) {
277
+ process.stdout.write(evalReportJson(result, root, threshold));
278
+ if (failed) {
279
+ // `process.exit` doesn't flush a piped stdout — without this the JSON
280
+ // is truncated mid-write in exactly the CI setups that consume it.
281
+ await flushStdout();
282
+ process.exit(1);
283
+ }
284
+ return;
285
+ }
286
+
287
+ if (failed) {
288
+ process.exit(1);
289
+ }
290
+ },
291
+ });
@@ -157,10 +157,15 @@ export const initCommand = defineCommand({
157
157
  const sink = interactive ? clack.log : logger;
158
158
  const { createdPackage } = await applyPlan(buildPlan(root, answers), sink);
159
159
 
160
- // Keep Blume's generated runtime (`.blume/`) and build output (`dist/`) out
161
- // of version control. Idempotent: creates `.gitignore` when absent and skips
162
- // entries already present (trailing-slash agnostic).
163
- const ignored = await ensureGitignore(root, [".blume/", "dist/"]);
160
+ // Keep installed dependencies, Blume's generated runtime (`.blume/`), and
161
+ // build output (`dist/`) out of version control. Idempotent: creates
162
+ // `.gitignore` when absent and skips entries already present
163
+ // (trailing-slash agnostic).
164
+ const ignored = await ensureGitignore(root, [
165
+ "node_modules/",
166
+ ".blume/",
167
+ "dist/",
168
+ ]);
164
169
  if (ignored.length > 0) {
165
170
  sink.success(`Added ${ignored.join(", ")} to .gitignore`);
166
171
  }
@@ -0,0 +1,36 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { defineCommand } from "citty";
4
+
5
+ import type { McpData } from "../../ai/mcp/data.ts";
6
+ import { serveMcpStdio } from "../../ai/mcp/stdio.ts";
7
+
8
+ export const mcpStdioCommand = defineCommand({
9
+ args: {
10
+ data: {
11
+ description: "Path to a serialized MCP data snapshot (JSON).",
12
+ required: true,
13
+ type: "string",
14
+ },
15
+ },
16
+ meta: {
17
+ description:
18
+ "Serve an MCP data snapshot over stdio (internal, used by `blume eval`).",
19
+ name: "mcp-stdio",
20
+ },
21
+ async run({ args }) {
22
+ // stdout belongs to the JSON-RPC transport from here on; every diagnostic
23
+ // must go to stderr or the MCP client chokes on the stray line.
24
+ let data: McpData;
25
+ try {
26
+ data = JSON.parse(await readFile(args.data, "utf-8")) as McpData;
27
+ } catch (error) {
28
+ const detail = error instanceof Error ? error.message : String(error);
29
+ process.stderr.write(
30
+ `blume mcp-stdio: cannot load the snapshot at ${args.data}: ${detail}\n`
31
+ );
32
+ process.exit(1);
33
+ }
34
+ await serveMcpStdio(data);
35
+ },
36
+ });
package/src/cli/index.ts CHANGED
@@ -8,7 +8,9 @@ import { checkCommand } from "./commands/check.ts";
8
8
  import { devCommand } from "./commands/dev.ts";
9
9
  import { doctorCommand } from "./commands/doctor.ts";
10
10
  import { ejectCommand } from "./commands/eject.ts";
11
+ import { evalCommand } from "./commands/eval.ts";
11
12
  import { initCommand } from "./commands/init.ts";
13
+ import { mcpStdioCommand } from "./commands/mcp-stdio.ts";
12
14
  import { previewCommand } from "./commands/preview.ts";
13
15
  import { syncCommand } from "./commands/sync.ts";
14
16
  import { validateCommand } from "./commands/validate.ts";
@@ -29,7 +31,9 @@ const main = defineCommand({
29
31
  dev: devCommand,
30
32
  doctor: doctorCommand,
31
33
  eject: ejectCommand,
34
+ eval: evalCommand,
32
35
  init: initCommand,
36
+ "mcp-stdio": mcpStdioCommand,
33
37
  preview: previewCommand,
34
38
  sync: syncCommand,
35
39
  validate: validateCommand,
@@ -24,7 +24,7 @@ export const checkRequiredSecrets = (config: ResolvedConfig): Diagnostic[] => {
24
24
  });
25
25
  };
26
26
 
27
- if (config.ai.ask?.enabled) {
27
+ if (config.ai.ask?.enabled && !config.ai.ask.endpoint) {
28
28
  const backend = resolveAskBackend(config.ai.ask);
29
29
  if (backend.kind === "gateway") {
30
30
  requireSecret(
@@ -22,7 +22,7 @@ const accordionId = id ?? slugify(title);
22
22
  ---
23
23
 
24
24
  <details
25
- class="not-prose group"
25
+ class="not-prose"
26
26
  data-blume-accordion
27
27
  id={accordionId}
28
28
  open={defaultOpen}
@@ -52,7 +52,7 @@ const accordionId = id ?? slugify(title);
52
52
  </span>
53
53
  </span>
54
54
  <Icon
55
- class="text-muted-foreground transition-transform group-open:rotate-180"
55
+ class="text-muted-foreground transition-transform [details[open]>summary_&]:rotate-180"
56
56
  name="chevron-down"
57
57
  size={16}
58
58
  />
@@ -13,7 +13,10 @@ const escapeRawHtml = (value: string): string =>
13
13
 
14
14
  const unwrapParagraph = (html: string): string => {
15
15
  const trimmed = html.trim();
16
- const match = trimmed.match(/^<p>(?<content>[\s\S]*)<\/p>$/u);
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);
17
20
  return match?.groups?.content ?? trimmed;
18
21
  };
19
22
 
@@ -13,7 +13,10 @@ const escapeRawHtml = (value: string): string =>
13
13
 
14
14
  const unwrapParagraph = (html: string): string => {
15
15
  const trimmed = html.trim();
16
- const match = trimmed.match(/^<p>(?<content>[\s\S]*)<\/p>$/u);
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);
17
20
  return match?.groups?.content ?? trimmed;
18
21
  };
19
22
 
@@ -16,7 +16,10 @@ const external = href?.startsWith("http");
16
16
 
17
17
  const unwrapParagraph = (html: string): string => {
18
18
  const trimmed = html.trim();
19
- const match = trimmed.match(/^<p>(?<content>[\s\S]*)<\/p>$/u);
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);
20
23
  return match?.groups?.content ?? trimmed;
21
24
  };
22
25
 
@@ -7,7 +7,6 @@ const { defaultOpen = false, name, openable = true } = Astro.props;
7
7
  {
8
8
  openable ? (
9
9
  <details
10
- class="group"
11
10
  data-blume-tree-folder
12
11
  data-blume-tree-openable="true"
13
12
  open={defaultOpen}
@@ -20,7 +19,7 @@ const { defaultOpen = false, name, openable = true } = Astro.props;
20
19
  role="treeitem"
21
20
  >
22
21
  <Icon
23
- class="shrink-0 transition-transform group-open:rotate-90"
22
+ class="shrink-0 transition-transform [details[open]>summary_&]:rotate-90"
24
23
  name="chevron-right"
25
24
  size={15}
26
25
  />
@@ -81,3 +81,48 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
81
81
  <slot />
82
82
  </div>
83
83
  </article>
84
+
85
+ <script>
86
+ const state = window as Window & { __blumeUpdateIds?: boolean };
87
+ if (!state.__blumeUpdateIds) {
88
+ state.__blumeUpdateIds = true;
89
+
90
+ // Repeated labels slug to the same id (two "Bug fixes" entries on one
91
+ // changelog page); suffix the later ones so ids stay unique and each
92
+ // entry's self-anchor jumps to itself, not the first duplicate. The first
93
+ // keeps the plain slug, so hash deep-links stay stable. Mirrors the
94
+ // accordion id dedupe in `AccordionItem.astro`.
95
+ const dedupeUpdateIds = () => {
96
+ const seen = new Map<string, number>();
97
+ for (const update of document.querySelectorAll<HTMLElement>(
98
+ "[data-blume-update][id]"
99
+ )) {
100
+ const previous = update.id;
101
+ const count = seen.get(previous) ?? 0;
102
+ seen.set(previous, count + 1);
103
+ if (count === 0) {
104
+ continue;
105
+ }
106
+ update.id = `${previous}-${count + 1}`;
107
+ // Re-point the entry's own header anchor when it targets the old id;
108
+ // an explicit `href` prop (an external permalink) is left alone.
109
+ const anchor = update.querySelector<HTMLAnchorElement>("header a[href]");
110
+ const href = anchor?.getAttribute("href");
111
+ if (anchor && href?.endsWith(`#${previous}`)) {
112
+ anchor.setAttribute(
113
+ "href",
114
+ `${href.slice(0, -previous.length)}${update.id}`
115
+ );
116
+ }
117
+ }
118
+ };
119
+
120
+ if (document.readyState === "loading") {
121
+ document.addEventListener("DOMContentLoaded", dedupeUpdateIds, {
122
+ once: true,
123
+ });
124
+ } else {
125
+ dedupeUpdateIds();
126
+ }
127
+ }
128
+ </script>
@@ -9,11 +9,12 @@ interface Suggestion {
9
9
  }
10
10
 
11
11
  interface Props {
12
+ endpoint?: string;
12
13
  strings?: UIStrings["ask"];
13
14
  suggestions?: Suggestion[];
14
15
  }
15
16
 
16
- const { strings, suggestions = [] } = Astro.props;
17
+ const { endpoint, strings, suggestions = [] } = Astro.props;
17
18
 
18
19
  // Icons resolve to inline SVG here (server-only module), so the client island
19
20
  // gets ready-to-render markup rather than a name it can't resolve. Bare Lucide
@@ -43,7 +44,13 @@ const icons = {
43
44
  };
44
45
  ---
45
46
 
46
- <AskAI client:load icons={icons} strings={strings} suggestions={items} />
47
+ <AskAI
48
+ client:load
49
+ endpoint={endpoint}
50
+ icons={icons}
51
+ strings={strings}
52
+ suggestions={items}
53
+ />
47
54
 
48
55
  <style is:global>
49
56
  :root {
@@ -65,7 +65,7 @@ const nextId = (): number => {
65
65
 
66
66
  // The endpoint and page path both honor the deployment `base` so grounding works
67
67
  // under a non-root base path (the server matches base-less document routes).
68
- const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
68
+ const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
69
69
 
70
70
  /** The current route with the deployment base stripped, for page-context lookup. */
71
71
  const currentPath = (): string =>
@@ -133,10 +133,12 @@ const ANSWER_CLASS =
133
133
  "prose prose-sm max-w-none text-foreground [&_a]:inline-flex [&_a]:items-center [&_a]:gap-1 [&_a]:rounded-full [&_a]:bg-muted [&_a]:px-2 [&_a]:py-1 [&_a]:align-middle [&_a]:font-medium [&_a]:text-[0.7rem] [&_a]:leading-none [&_a]:text-muted-foreground! [&_a]:no-underline! [&_a:hover]:text-foreground!";
134
134
 
135
135
  const AskAI = ({
136
+ endpoint = DEFAULT_ASK_ENDPOINT,
136
137
  icons = EMPTY_ICONS,
137
138
  strings,
138
139
  suggestions = EMPTY_SUGGESTIONS,
139
140
  }: {
141
+ endpoint?: string;
140
142
  icons?: AskIcons;
141
143
  strings?: UIStrings["ask"];
142
144
  suggestions?: Suggestion[];
@@ -180,13 +182,30 @@ const AskAI = ({
180
182
  return () => window.removeEventListener("blume:open-ask-ai", handler);
181
183
  }, []);
182
184
 
183
- // ⌘I / Ctrl+I toggles the panel; Escape closes it.
185
+ // ⌘I / Ctrl+I toggles the panel; Escape closes it. Shift/Alt chords are
186
+ // left alone — Ctrl+Shift+I is the browser's DevTools shortcut, and
187
+ // capturing it would flap the panel open alongside them.
184
188
  useEffect(() => {
185
189
  const onKey = (event: KeyboardEvent) => {
186
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "i") {
190
+ if (
191
+ (event.metaKey || event.ctrlKey) &&
192
+ !event.shiftKey &&
193
+ !event.altKey &&
194
+ event.key.toLowerCase() === "i"
195
+ ) {
187
196
  event.preventDefault();
188
197
  setOpen((value) => !value);
189
198
  } else if (event.key === "Escape" && open) {
199
+ // An Escape aimed at a modal surface stacked on top (the search
200
+ // dialog traps focus inside itself) dismisses that surface only —
201
+ // this window listener still fires for it, and closing the panel
202
+ // underneath too would eat the user's conversation view. Duck-typed
203
+ // (`closest` presence) rather than `instanceof Element`, which needs
204
+ // a DOM global the test environment doesn't provide.
205
+ const target = event.target as Partial<Element> | null;
206
+ if (typeof target?.closest === "function" && target.closest("dialog")) {
207
+ return;
208
+ }
190
209
  setOpen(false);
191
210
  }
192
211
  };
@@ -251,7 +270,7 @@ const AskAI = ({
251
270
  abortRef.current = controller;
252
271
 
253
272
  try {
254
- const response = await fetch(ASK_ENDPOINT, {
273
+ const response = await fetch(endpoint, {
255
274
  body: JSON.stringify({
256
275
  messages: history.map((m) => ({ content: m.content, role: m.role })),
257
276
  page: { path: currentPath() },