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.
- package/CHANGELOG.md +32 -0
- package/dist/cli/index.js +328 -644
- package/dist/cli/index.js.map +35 -35
- package/dist/types/core/data.d.ts +10 -0
- package/docs/configuration/ai.mdx +15 -1
- package/package.json +28 -7
- package/src/ai/component-markdown.ts +7 -6
- package/src/ai/link-headers.ts +7 -2
- package/src/astro/generate.ts +8 -13
- package/src/astro/islands.ts +4 -1
- package/src/astro/templates.ts +5 -4
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/env.ts +6 -52
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- package/src/components/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- package/src/components/layout/RootLayout.astro +78 -109
- package/src/components/layout/Search.astro +3 -5
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config.ts +3 -3
- package/src/core/data.ts +7 -0
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- package/src/core/sources/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/notion.ts +17 -23
- package/src/core/tsconfig-aliases.ts +39 -172
- package/src/deploy/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- package/src/search/popular-icon.ts +3 -3
- package/src/translate/ledger.ts +5 -11
- package/src/translate/report.ts +22 -28
- package/src/translate/run.ts +5 -24
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
package/src/audit/report.ts
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 ` ${
|
|
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
|
-
?
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
` ${
|
|
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(` ${
|
|
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(` ${
|
|
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
|
|
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(` ${
|
|
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(` ${
|
|
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(` ${
|
|
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]
|
|
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.`, "");
|
package/src/audit/types.ts
CHANGED
|
@@ -135,8 +135,12 @@ export interface LlmsDoc {
|
|
|
135
135
|
/** A parsed `robots.txt`. */
|
|
136
136
|
export interface RobotsDoc {
|
|
137
137
|
file: string;
|
|
138
|
-
/**
|
|
139
|
-
|
|
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. */
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
20
|
-
const LEADING_RANGE = /^[^\d]*/u;
|
|
20
|
+
const FALLBACK_NODE_RANGE = ">=22.12.0";
|
|
21
21
|
|
|
22
|
-
/** The
|
|
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
|
|
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
|
-
|
|
30
|
-
return range.replace(LEADING_RANGE, "") || FALLBACK_MIN_NODE;
|
|
29
|
+
return pkg.engines?.node || FALLBACK_NODE_RANGE;
|
|
31
30
|
} catch {
|
|
32
|
-
return
|
|
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
|
|
64
|
-
if (
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/cli/init/scaffold.ts
CHANGED
|
@@ -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
|
|
199
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
:
|
|
253
|
+
: SOURCE_SNIPPETS[kind]
|
|
267
254
|
);
|
|
268
255
|
return `
|
|
269
256
|
content: {
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { colors } from "consola/utils";
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
`${
|
|
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("",
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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 =
|
|
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
|
-
|
|
321
|
-
abortRef.current?.abort();
|
|
322
|
-
setMessages([]);
|
|
243
|
+
reset();
|
|
323
244
|
};
|
|
324
245
|
|
|
325
246
|
const onSubmit = (event: FormEvent) => {
|
|
326
247
|
event.preventDefault();
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|