blume 1.4.0 → 1.4.2

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 (50) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli/index.js +328 -644
  3. package/dist/cli/index.js.map +35 -35
  4. package/dist/types/core/data.d.ts +10 -0
  5. package/docs/configuration/ai.mdx +15 -1
  6. package/package.json +28 -7
  7. package/src/ai/component-markdown.ts +7 -6
  8. package/src/ai/link-headers.ts +7 -2
  9. package/src/astro/generate.ts +8 -13
  10. package/src/astro/islands.ts +4 -1
  11. package/src/astro/templates.ts +5 -4
  12. package/src/audit/checks/indexability.ts +3 -6
  13. package/src/audit/checks/robots.ts +18 -37
  14. package/src/audit/crawl.ts +49 -49
  15. package/src/audit/image-size.ts +13 -53
  16. package/src/audit/report.ts +22 -33
  17. package/src/audit/types.ts +6 -2
  18. package/src/cli/commands/dev.ts +9 -21
  19. package/src/cli/commands/doctor.ts +9 -22
  20. package/src/cli/env.ts +6 -52
  21. package/src/cli/init/scaffold.ts +15 -28
  22. package/src/cli/internal-error.ts +11 -11
  23. package/src/components/islands/ask-ai.tsx +25 -100
  24. package/src/components/islands/hooks.ts +10 -3
  25. package/src/components/layout/RootLayout.astro +78 -109
  26. package/src/components/layout/Search.astro +3 -5
  27. package/src/components/layout/search/types.ts +4 -16
  28. package/src/components/openapi/helpers.ts +21 -75
  29. package/src/core/component-overrides.ts +0 -7
  30. package/src/core/config.ts +3 -3
  31. package/src/core/data.ts +7 -0
  32. package/src/core/diagnostics.ts +10 -20
  33. package/src/core/fs-atomic.ts +22 -0
  34. package/src/core/sources/github-releases.ts +29 -26
  35. package/src/core/sources/mdx-remote.ts +10 -57
  36. package/src/core/sources/notion.ts +17 -23
  37. package/src/core/tsconfig-aliases.ts +39 -172
  38. package/src/deploy/rss.ts +4 -1
  39. package/src/deploy/sitemap.ts +3 -1
  40. package/src/eval/report.ts +20 -28
  41. package/src/markdown/directives.ts +6 -18
  42. package/src/markdown/index.ts +1 -6
  43. package/src/markdown/package-commands.ts +0 -4
  44. package/src/openapi/parse.ts +11 -9
  45. package/src/search/popular-icon.ts +3 -3
  46. package/src/translate/ledger.ts +5 -11
  47. package/src/translate/report.ts +22 -28
  48. package/src/translate/run.ts +5 -24
  49. package/src/translate/work-list.ts +0 -0
  50. package/src/deploy/xml.ts +0 -8
@@ -1,3 +1,5 @@
1
+ import { colors } from "consola/utils";
2
+ import type { ColorFunction } from "consola/utils";
1
3
  import { relative } from "pathe";
2
4
 
3
5
  import { countBySeverity } from "../core/diagnostics.ts";
@@ -7,21 +9,10 @@ import type { CheckId } from "./catalog.ts";
7
9
  import type { AuditResult } from "./run.ts";
8
10
  import type { AuditCategory, AuditTier } from "./types.ts";
9
11
 
10
- const ESC = String.fromCodePoint(27);
11
- const COLORS = {
12
- bold: `${ESC}[1m`,
13
- cyan: `${ESC}[36m`,
14
- dim: `${ESC}[2m`,
15
- green: `${ESC}[32m`,
16
- red: `${ESC}[31m`,
17
- reset: `${ESC}[0m`,
18
- yellow: `${ESC}[33m`,
19
- };
20
-
21
- const SEVERITY_COLOR: Record<DiagnosticSeverity, string> = {
22
- error: COLORS.red,
23
- info: `${ESC}[34m`,
24
- warning: COLORS.yellow,
12
+ const SEVERITY_COLOR: Record<DiagnosticSeverity, ColorFunction> = {
13
+ error: colors.red,
14
+ info: colors.blue,
15
+ warning: colors.yellow,
25
16
  };
26
17
 
27
18
  const GLYPH: Record<DiagnosticSeverity, string> = {
@@ -108,7 +99,7 @@ const skippedTiers = (tiers: Record<AuditTier, boolean>): string[] =>
108
99
  .filter((tier) => !tiers[tier])
109
100
  .map((tier) => {
110
101
  const label = CHECKS.filter((check) => check.tier === tier).length;
111
- return ` ${COLORS.dim}⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)${COLORS.reset}`;
102
+ return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)`)}`;
112
103
  });
113
104
 
114
105
  /** How many checks actually ran, i.e. those whose tier was enabled. */
@@ -138,9 +129,11 @@ const summaryLine = (
138
129
  const findingLine = (diagnostic: Diagnostic, root: string): string => {
139
130
  const url = diagnostic.url ?? "";
140
131
  const source = diagnostic.file
141
- ? `${COLORS.dim}${relative(root, diagnostic.file)}${
142
- diagnostic.line === undefined ? "" : `:${diagnostic.line}`
143
- }${COLORS.reset}`
132
+ ? colors.dim(
133
+ `${relative(root, diagnostic.file)}${
134
+ diagnostic.line === undefined ? "" : `:${diagnostic.line}`
135
+ }`
136
+ )
144
137
  : "";
145
138
  // padEnd alone yields no gap once the URL reaches the column width.
146
139
  return ` ${url.padEnd(34)} ${source}`.trimEnd();
@@ -164,13 +157,13 @@ export const formatReport = (
164
157
  : `${relative(root, result.staticDir) || "dist"} · offline`;
165
158
  lines.push(
166
159
  "",
167
- ` ${COLORS.bold}blume audit${COLORS.reset} ${COLORS.dim}${result.pages} pages · ${where}${COLORS.reset}`,
160
+ ` ${colors.bold("blume audit")} ${colors.dim(`${result.pages} pages · ${where}`)}`,
168
161
  ` ${summaryLine(counts, auditCount(result))}`,
169
162
  ""
170
163
  );
171
164
 
172
165
  if (groups.length === 0) {
173
- lines.push(` ${COLORS.green}✔ No issues found.${COLORS.reset}`, "");
166
+ lines.push(` ${colors.green("✔ No issues found.")}`, "");
174
167
  }
175
168
 
176
169
  let category: AuditCategory | null = null;
@@ -178,13 +171,13 @@ export const formatReport = (
178
171
  const { category: next } = group;
179
172
  if (next !== category) {
180
173
  category = next;
181
- lines.push(` ${COLORS.bold}${category}${COLORS.reset}`, "");
174
+ lines.push(` ${colors.bold(category)}`, "");
182
175
  }
183
176
 
184
177
  const color = SEVERITY_COLOR[group.severity];
185
178
  const pages = `${group.count} page${group.count === 1 ? "" : "s"}`;
186
179
  lines.push(
187
- ` ${color}${GLYPH[group.severity]} ${group.title}${COLORS.reset} ${COLORS.dim}${pages}${COLORS.reset}`
180
+ ` ${color(`${GLYPH[group.severity]} ${group.title}`)} ${colors.dim(pages)}`
188
181
  );
189
182
 
190
183
  const shown = options.verbose
@@ -195,14 +188,12 @@ export const formatReport = (
195
188
  // The message names the specifics the rolled-up line can't — which target
196
189
  // is broken, what the duplicate is — so --verbose prints it per finding.
197
190
  if (options.verbose) {
198
- lines.push(` ${COLORS.dim}${diagnostic.message}${COLORS.reset}`);
191
+ lines.push(` ${colors.dim(diagnostic.message)}`);
199
192
  }
200
193
  }
201
194
  const hidden = group.count - shown.length;
202
195
  if (hidden > 0) {
203
- lines.push(
204
- ` ${COLORS.dim}… and ${hidden} more (--verbose)${COLORS.reset}`
205
- );
196
+ lines.push(` ${colors.dim(`… and ${hidden} more (--verbose)`)}`);
206
197
  }
207
198
 
208
199
  // Every finding in a group shares the catalog's fix unless it overrode it,
@@ -210,7 +201,7 @@ export const formatReport = (
210
201
  const [first] = group.findings;
211
202
  const fix = first?.suggestion;
212
203
  if (fix) {
213
- lines.push(` ${COLORS.cyan}fix: ${fix}${COLORS.reset}`);
204
+ lines.push(` ${colors.cyan(`fix: ${fix}`)}`);
214
205
  }
215
206
  lines.push("");
216
207
  }
@@ -268,14 +259,12 @@ export const formatCatalog = (): string => {
268
259
  const { category: next } = check;
269
260
  if (next !== category) {
270
261
  category = next;
271
- lines.push(` ${COLORS.bold}${category}${COLORS.reset}`);
262
+ lines.push(` ${colors.bold(category)}`);
272
263
  }
273
264
  const tier =
274
- check.tier === "static"
275
- ? ""
276
- : ` ${COLORS.dim}[${check.tier}]${COLORS.reset}`;
265
+ check.tier === "static" ? "" : ` ${colors.dim(`[${check.tier}]`)}`;
277
266
  lines.push(
278
- ` ${SEVERITY_COLOR[check.severity]}${GLYPH[check.severity]}${COLORS.reset} ${check.id.replace("BLUME_AUDIT_", "").toLowerCase().padEnd(34)} ${COLORS.dim}${check.title}${COLORS.reset}${tier}`
267
+ ` ${SEVERITY_COLOR[check.severity](GLYPH[check.severity])} ${check.id.replace("BLUME_AUDIT_", "").toLowerCase().padEnd(34)} ${colors.dim(check.title)}${tier}`
279
268
  );
280
269
  }
281
270
  lines.push("", ` ${CHECKS.length} checks.`, "");
@@ -135,8 +135,12 @@ export interface LlmsDoc {
135
135
  /** A parsed `robots.txt`. */
136
136
  export interface RobotsDoc {
137
137
  file: string;
138
- /** `Disallow:` paths for `User-agent: *`. */
139
- disallow: string[];
138
+ /**
139
+ * The file's full text. Rule matching runs through robots-parser (which
140
+ * owns longest-match Allow/Disallow semantics), so the raw text is the
141
+ * source of truth rather than a pre-extracted rule list.
142
+ */
143
+ raw: string;
140
144
  /** `Sitemap:` declarations. */
141
145
  sitemaps: string[];
142
146
  /** Lines that aren't a recognized directive, with their 1-based line number. */
@@ -1,8 +1,6 @@
1
- import { watch } from "node:fs";
2
-
3
1
  import { dev } from "astro";
2
+ import { watch } from "chokidar";
4
3
  import { defineCommand } from "citty";
5
- import { basename, dirname } from "pathe";
6
4
 
7
5
  import { generateRuntime } from "../../astro/generate.ts";
8
6
  import { showBlumeErrorOverlay } from "../../astro/integration.ts";
@@ -212,26 +210,16 @@ export const devCommand = defineCommand({
212
210
  project.context.componentsFile,
213
211
  ].filter((target) => target !== null);
214
212
 
213
+ // chokidar handles what raw fs.watch made us hand-roll: recursive
214
+ // directory watching on every platform, and single files surviving a
215
+ // rename-replace save (vim and most "atomic save" editors), which orphans
216
+ // an inode-tracking fs.watch watcher after the first write.
217
+ const projectWatcher = watch([...dirTargets, ...fileTargets], {
218
+ ignoreInitial: true,
219
+ }).on("all", regenerate);
215
220
  const disposers = [
216
221
  ...project.sources.map((source) => source.watch?.(regenerate)),
217
- ...dirTargets.map((target) => {
218
- const watcher = watch(target, { recursive: true }, regenerate);
219
- return () => watcher.close();
220
- }),
221
- // Single files are watched via their parent directory: fs.watch on the
222
- // file itself tracks the inode, so a rename-replace save (vim and most
223
- // "atomic save" editors) orphans the watcher after the first write and
224
- // every later edit is silently ignored.
225
- ...fileTargets.map((target) => {
226
- const name = basename(target);
227
- const watcher = watch(dirname(target), (_event, filename) => {
228
- // A null filename (some platforms) can't be filtered — regenerate.
229
- if (!filename || filename === name) {
230
- regenerate();
231
- }
232
- });
233
- return () => watcher.close();
234
- }),
222
+ () => void projectWatcher.close(),
235
223
  ].filter((dispose) => dispose !== undefined);
236
224
 
237
225
  const shutdown = async () => {
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
 
3
3
  import { defineCommand } from "citty";
4
4
  import { join } from "pathe";
5
+ import { satisfies } from "semver";
5
6
 
6
7
  import { BlumeError } from "../../core/diagnostics.ts";
7
8
  import { packageRoot } from "../../core/package-root.ts";
@@ -16,35 +17,21 @@ import {
16
17
  reportDiagnosticsJson,
17
18
  } from "../log.ts";
18
19
 
19
- const FALLBACK_MIN_NODE = "22.12.0";
20
- const LEADING_RANGE = /^[^\d]*/u;
20
+ const FALLBACK_NODE_RANGE = ">=22.12.0";
21
21
 
22
- /** The minimum Node version, read from the package's own `engines` field so
22
+ /** The supported Node range, read from the package's own `engines` field so
23
23
  * doctor can never drift from what the package actually declares. */
24
- const minSupportedNode = (): string => {
24
+ const supportedNodeRange = (): string => {
25
25
  try {
26
26
  const pkg = JSON.parse(
27
27
  readFileSync(join(packageRoot(), "package.json"), "utf-8")
28
28
  ) as { engines?: { node?: string } };
29
- const range = pkg.engines?.node ?? "";
30
- return range.replace(LEADING_RANGE, "") || FALLBACK_MIN_NODE;
29
+ return pkg.engines?.node || FALLBACK_NODE_RANGE;
31
30
  } catch {
32
- return FALLBACK_MIN_NODE;
31
+ return FALLBACK_NODE_RANGE;
33
32
  }
34
33
  };
35
34
 
36
- const versionBelow = (current: string, minimum: string): boolean => {
37
- const a = current.split(".").map((part) => Math.trunc(Number(part)));
38
- const b = minimum.split(".").map((part) => Math.trunc(Number(part)));
39
- for (let i = 0; i < 3; i += 1) {
40
- const delta = (a[i] ?? 0) - (b[i] ?? 0);
41
- if (delta !== 0) {
42
- return delta < 0;
43
- }
44
- }
45
- return false;
46
- };
47
-
48
35
  export const doctorCommand = defineCommand({
49
36
  args: {
50
37
  json: {
@@ -60,11 +47,11 @@ export const doctorCommand = defineCommand({
60
47
  const root = process.cwd();
61
48
  const diagnostics: Diagnostic[] = [];
62
49
 
63
- const minNode = minSupportedNode();
64
- if (versionBelow(process.versions.node, minNode)) {
50
+ const nodeRange = supportedNodeRange();
51
+ if (!satisfies(process.versions.node, nodeRange)) {
65
52
  diagnostics.push({
66
53
  code: "BLUME_NODE_VERSION",
67
- message: `Node ${process.versions.node} is below the supported minimum (${minNode}).`,
54
+ message: `Node ${process.versions.node} is outside the supported range (${nodeRange}).`,
68
55
  severity: "warning",
69
56
  });
70
57
  }
package/src/cli/env.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
+ import { parse } from "dotenv";
3
4
  import { dirname, join, resolve } from "pathe";
4
5
 
5
6
  // Blume's remote sources (GitHub Releases, mdx-remote, Sanity, Notion…) read
@@ -8,57 +9,6 @@ import { dirname, join, resolve } from "pathe";
8
9
  // that gap: it cascades `.env`/`.env.local` from the working dir up to the repo
9
10
  // root, so a monorepo can keep one `.env` at the root and every app picks it up.
10
11
 
11
- const ENV_LINE =
12
- /^\s*(?:export\s+)?(?<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?<value>.*?)\s*$/u;
13
- const DOUBLE_QUOTED = /^"(?<body>[\s\S]*)"$/u;
14
- const SINGLE_QUOTED = /^'(?<body>[\s\S]*)'$/u;
15
-
16
- const ESCAPE = /\\(?<char>[\\nt"])/gu;
17
- const UNESCAPED: Record<string, string> = {
18
- '"': '"',
19
- "\\": "\\",
20
- n: "\n",
21
- t: "\t",
22
- };
23
-
24
- /** Unquote a value, expanding `\n`/`\t`/escapes inside double quotes only. */
25
- const unquote = (raw: string): string => {
26
- const double = raw.match(DOUBLE_QUOTED)?.groups?.body;
27
- if (double !== undefined) {
28
- // A single pass so each backslash is consumed exactly once — sequential
29
- // replaceAll calls would expand the `n` in `\\n` (an escaped backslash
30
- // followed by a literal `n`) into a newline.
31
- return double.replaceAll(
32
- ESCAPE,
33
- (match, char: string) => UNESCAPED[char] ?? match
34
- );
35
- }
36
- const single = raw.match(SINGLE_QUOTED)?.groups?.body;
37
- if (single !== undefined) {
38
- return single;
39
- }
40
- // dotenv/Vite treat an unquoted `#` as the start of an inline comment (a
41
- // value containing `#` must be quoted) — keeping the comment would hand
42
- // consumers a silently corrupted value.
43
- const hash = raw.indexOf("#");
44
- return (hash === -1 ? raw : raw.slice(0, hash)).trim();
45
- };
46
-
47
- /** Parse `.env` text into key/value pairs, skipping blanks and `#` comments. */
48
- export const parseEnv = (content: string): Record<string, string> => {
49
- const env: Record<string, string> = {};
50
- for (const line of content.split(/\r?\n/u)) {
51
- if (line.trim() === "" || line.trimStart().startsWith("#")) {
52
- continue;
53
- }
54
- const groups = line.match(ENV_LINE)?.groups;
55
- if (groups?.key !== undefined && groups.value !== undefined) {
56
- env[groups.key] = unquote(groups.value);
57
- }
58
- }
59
- return env;
60
- };
61
-
62
12
  /** Apply parsed vars without clobbering anything already in `process.env`. */
63
13
  const applyEnv = (parsed: Record<string, string>): void => {
64
14
  for (const [key, value] of Object.entries(parsed)) {
@@ -71,7 +21,11 @@ const applyEnv = (parsed: Record<string, string>): void => {
71
21
  const loadFile = (path: string): void => {
72
22
  try {
73
23
  if (existsSync(path)) {
74
- applyEnv(parseEnv(readFileSync(path, "utf-8")));
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")));
75
29
  }
76
30
  } catch {
77
31
  // Env files are best-effort; a read/parse failure must not abort a build.
@@ -195,28 +195,29 @@ const hasRemoteSource = (sources: SourceKind[]): boolean =>
195
195
  * Config snippets for each remote source kind, with placeholder values to
196
196
  * replace and comments naming the env var each source authenticates with.
197
197
  */
198
- const sourceSnippetFor = (kind: Exclude<SourceKind, "filesystem">): string => {
199
- switch (kind) {
200
- case "github-releases": {
201
- return ` // Changelog entries from GitHub Releases. Private repos read
198
+ const SOURCE_SNIPPETS: Record<Exclude<SourceKind, "filesystem">, string> = {
199
+ "github-releases": ` // Changelog entries from GitHub Releases. Private repos read
202
200
  // GITHUB_TOKEN from the environment.
203
201
  {
204
202
  type: "github-releases",
205
203
  owner: "your-org",
206
204
  repo: "your-repo",
207
205
  prefix: "changelog",
208
- },`;
209
- }
210
- case "notion": {
211
- return ` // Pages from a Notion database. Reads NOTION_TOKEN from the environment.
206
+ },`,
207
+ "mdx-remote": ` // MDX fetched from a GitHub repo. Private repos read GITHUB_TOKEN
208
+ // from the environment.
209
+ {
210
+ type: "mdx-remote",
211
+ github: { owner: "your-org", repo: "your-repo", path: "docs" },
212
+ prefix: "remote",
213
+ },`,
214
+ notion: ` // Pages from a Notion database. Reads NOTION_TOKEN from the environment.
212
215
  {
213
216
  type: "notion",
214
217
  database: "your-database-id",
215
218
  prefix: "notion",
216
- },`;
217
- }
218
- case "sanity": {
219
- return ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
219
+ },`,
220
+ sanity: ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
220
221
  // from the environment.
221
222
  {
222
223
  type: "sanity",
@@ -224,21 +225,7 @@ const sourceSnippetFor = (kind: Exclude<SourceKind, "filesystem">): string => {
224
225
  dataset: "production",
225
226
  query: \`*[_type == "doc"]\`,
226
227
  prefix: "sanity",
227
- },`;
228
- }
229
- case "mdx-remote": {
230
- return ` // MDX fetched from a GitHub repo. Private repos read GITHUB_TOKEN
231
- // from the environment.
232
- {
233
- type: "mdx-remote",
234
- github: { owner: "your-org", repo: "your-repo", path: "docs" },
235
- prefix: "remote",
236
- },`;
237
- }
238
- default: {
239
- return kind satisfies never;
240
- }
241
- }
228
+ },`,
242
229
  };
243
230
 
244
231
  /**
@@ -263,7 +250,7 @@ const contentBlockFor = (answers: InitAnswers): string => {
263
250
  (kind) =>
264
251
  kind === "filesystem"
265
252
  ? ` { type: "filesystem", root: ${JSON.stringify(answers.contentDir)} },`
266
- : sourceSnippetFor(kind)
253
+ : SOURCE_SNIPPETS[kind]
267
254
  );
268
255
  return `
269
256
  content: {
@@ -1,10 +1,6 @@
1
- import { getBlumeVersion } from "../core/version.ts";
1
+ import { colors } from "consola/utils";
2
2
 
3
- const ESC = String.fromCodePoint(27);
4
- const DIM = `${ESC}[2m`;
5
- const RED = `${ESC}[31m`;
6
- const BOLD = `${ESC}[1m`;
7
- const RESET = `${ESC}[0m`;
3
+ import { getBlumeVersion } from "../core/version.ts";
8
4
 
9
5
  const ISSUES_URL = "https://github.com/haydenbleasel/blume/issues";
10
6
 
@@ -40,7 +36,7 @@ export const remapBlumeStack = (stack: string): string =>
40
36
  export const reportInternalError = (error: unknown): void => {
41
37
  const err = error instanceof Error ? error : new Error(String(error));
42
38
  const lines = [
43
- `${RED}${BOLD}BLUME_INTERNAL${RESET} An unexpected error occurred.`,
39
+ `${colors.red(colors.bold("BLUME_INTERNAL"))} An unexpected error occurred.`,
44
40
  ` ${err.message}`,
45
41
  ];
46
42
 
@@ -52,15 +48,19 @@ export const reportInternalError = (error: unknown): void => {
52
48
  .map((line) => line.trim())
53
49
  .filter(Boolean);
54
50
  if (stack.length > 0) {
55
- lines.push("", `${DIM}${stack.join("\n")}${RESET}`);
51
+ lines.push("", colors.dim(stack.join("\n")));
56
52
  }
57
53
 
58
54
  lines.push(
59
55
  "",
60
56
  "This is likely a bug in Blume. Please report it with the details below:",
61
- ` ${DIM}Blume: ${getBlumeVersion()}`,
62
- ` Node: ${process.version}`,
63
- ` Platform: ${process.platform} ${process.arch}${RESET}`,
57
+ colors.dim(
58
+ [
59
+ ` Blume: ${getBlumeVersion()}`,
60
+ ` Node: ${process.version}`,
61
+ ` Platform: ${process.platform} ${process.arch}`,
62
+ ].join("\n")
63
+ ),
64
64
  ` ${ISSUES_URL}`
65
65
  );
66
66
 
@@ -5,13 +5,8 @@ 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 { joinBase, prefixBase, stripBase } from "./base-path.ts";
9
-
10
- interface ChatMessage {
11
- content: string;
12
- id: number;
13
- role: "assistant" | "user";
14
- }
8
+ import { joinBase, prefixBase } from "./base-path.ts";
9
+ import { useAskAI } from "./hooks.ts";
15
10
 
16
11
  /** A resolved empty-state prompt; `icon` is ready-to-inline SVG (or null). */
17
12
  interface Suggestion {
@@ -57,20 +52,10 @@ const DEFAULT_ASK: UIStrings["ask"] = {
57
52
  you: "You",
58
53
  };
59
54
 
60
- let idCounter = 0;
61
- const nextId = (): number => {
62
- idCounter += 1;
63
- return idCounter;
64
- };
65
-
66
- // The endpoint and page path both honor the deployment `base` so grounding works
67
- // under a non-root base path (the server matches base-less document routes).
55
+ // The endpoint honors the deployment `base` so grounding works under a
56
+ // non-root base path (the server matches base-less document routes).
68
57
  const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
69
58
 
70
- /** The current route with the deployment base stripped, for page-context lookup. */
71
- const currentPath = (): string =>
72
- stripBase(import.meta.env.BASE_URL, window.location.pathname);
73
-
74
59
  // GitHub-flavored markdown with soft line breaks, matching how the docs read.
75
60
  marked.setOptions({ breaks: true, gfm: true });
76
61
 
@@ -149,20 +134,20 @@ const AskAI = ({
149
134
  const [mounted, setMounted] = useState(false);
150
135
  const [open, setOpen] = useState(false);
151
136
  const [input, setInput] = useState("");
152
- const [messages, setMessages] = useState<ChatMessage[]>([]);
153
- const [busy, setBusy] = useState(false);
137
+ // The streaming client — request shaping, optimistic assistant bubble,
138
+ // stale-stream/abort guards, error-body handling — is the public useAskAI
139
+ // hook, so the built-in panel and custom UIs share one implementation.
140
+ const {
141
+ ask,
142
+ loading: busy,
143
+ messages,
144
+ reset,
145
+ } = useAskAI({ endpoint, errorMessage: t.error });
154
146
  const inputRef = useRef<HTMLTextAreaElement>(null);
155
147
  const scrollRef = useRef<HTMLDivElement>(null);
156
148
  const triggerRef = useRef<HTMLButtonElement>(null);
157
149
  // Where focus came from when the panel opened, restored on close.
158
150
  const returnFocusRef = useRef<HTMLElement | null>(null);
159
- // The stream writes into the conversation via functional updates, so "Clear
160
- // conversation" mid-answer must revoke the in-flight stream's right to write
161
- // — otherwise its next chunk re-appends the assistant bubble onto the
162
- // emptied list as an orphaned answer. Clearing bumps the generation (stale
163
- // streams stop writing) and aborts the request (the stream stops arriving).
164
- const abortRef = useRef<AbortController | null>(null);
165
- const generationRef = useRef(0);
166
151
 
167
152
  // Portal target (document.body) only exists after mount; guards SSR. The
168
153
  // one-time false→true flip is deliberate, so the initial `false` is required.
@@ -245,86 +230,22 @@ const AskAI = ({
245
230
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
246
231
  }, [messages]);
247
232
 
248
- const runQuestion = async (raw: string) => {
233
+ const runQuestion = (raw: string) => {
249
234
  const question = raw.trim();
250
235
  if (!question || busy) {
251
236
  return;
252
237
  }
253
-
254
- const userMessage: ChatMessage = {
255
- content: question,
256
- id: nextId(),
257
- role: "user",
258
- };
259
- const history = [...messages, userMessage];
260
- const assistant: ChatMessage = {
261
- content: "",
262
- id: nextId(),
263
- role: "assistant",
264
- };
265
- setMessages([...history, assistant]);
238
+ void ask(question);
266
239
  setInput("");
267
- setBusy(true);
268
- const generation = generationRef.current;
269
- const controller = new AbortController();
270
- abortRef.current = controller;
271
-
272
- try {
273
- const response = await fetch(endpoint, {
274
- body: JSON.stringify({
275
- messages: history.map((m) => ({ content: m.content, role: m.role })),
276
- page: { path: currentPath() },
277
- }),
278
- headers: { "content-type": "application/json" },
279
- method: "POST",
280
- signal: controller.signal,
281
- });
282
- // A 4xx/5xx still has a body; without this guard its error text would be
283
- // decoded and shown as the assistant's answer instead of the error notice.
284
- if (!(response.ok && response.body)) {
285
- throw new Error(`Ask AI request failed (${response.status}).`);
286
- }
287
- const reader = response.body.getReader();
288
- const decoder = new TextDecoder();
289
- let done = false;
290
- while (!done) {
291
- // oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
292
- const chunk = await reader.read();
293
- ({ done } = chunk);
294
- if (chunk.value) {
295
- // Streaming mode: a multi-byte UTF-8 sequence split across chunks
296
- // must not flush as U+FFFD garbage.
297
- // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
298
- assistant.content += decoder.decode(chunk.value, { stream: true });
299
- if (generationRef.current === generation) {
300
- setMessages((current) => [
301
- ...current.slice(0, -1),
302
- { ...assistant },
303
- ]);
304
- }
305
- }
306
- }
307
- } catch {
308
- // A cleared (aborted) stream must not resurrect its bubble as an error.
309
- if (generationRef.current === generation) {
310
- // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
311
- assistant.content = t.error;
312
- setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
313
- }
314
- } finally {
315
- setBusy(false);
316
- }
317
240
  };
318
241
 
319
242
  const clearConversation = () => {
320
- generationRef.current += 1;
321
- abortRef.current?.abort();
322
- setMessages([]);
243
+ reset();
323
244
  };
324
245
 
325
246
  const onSubmit = (event: FormEvent) => {
326
247
  event.preventDefault();
327
- void runQuestion(input);
248
+ runQuestion(input);
328
249
  };
329
250
 
330
251
  const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
@@ -336,7 +257,7 @@ const AskAI = ({
336
257
  !event.nativeEvent.isComposing
337
258
  ) {
338
259
  event.preventDefault();
339
- void runQuestion(input);
260
+ runQuestion(input);
340
261
  }
341
262
  };
342
263
 
@@ -398,16 +319,20 @@ const AskAI = ({
398
319
  >
399
320
  {hasMessages ? (
400
321
  <div className="flex flex-col gap-4 p-4">
401
- {messages.map((message) =>
322
+ {/* Index keys are safe here: the list only appends, mutates its
323
+ last entry while streaming, or clears wholesale on reset. */}
324
+ {messages.map((message, index) =>
402
325
  message.role === "user" ? (
403
326
  <div
404
327
  className="max-w-[85%] self-end whitespace-pre-wrap rounded-blume bg-muted px-3 py-2 text-foreground text-sm"
405
- key={message.id}
328
+ // oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
329
+ key={index}
406
330
  >
407
331
  {message.content}
408
332
  </div>
409
333
  ) : (
410
- <div className={ANSWER_CLASS} key={message.id}>
334
+ // oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
335
+ <div className={ANSWER_CLASS} key={index}>
411
336
  {message.content ? (
412
337
  // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized above
413
338
  <div