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.
- package/CHANGELOG.md +54 -0
- package/README.md +1 -1
- package/dist/cli/index.js +1473 -149
- package/dist/cli/index.js.map +47 -36
- package/dist/types/core/config-input.d.ts +18 -0
- package/dist/types/core/config.d.ts +4 -0
- package/dist/types/core/data.d.ts +3 -0
- package/dist/types/core/schema.d.ts +132 -17
- package/dist/types/core/types.d.ts +5 -3
- package/dist/types/openapi/references.d.ts +6 -0
- package/docs/advanced/api-reference.mdx +27 -0
- package/docs/advanced/changelog.mdx +10 -0
- package/docs/configuration/ai.mdx +38 -2
- package/docs/configuration/customization.mdx +27 -0
- package/docs/configuration/index.mdx +5 -0
- package/docs/content/navigation.mdx +12 -0
- package/docs/reference/cli.mdx +17 -13
- package/docs/reference/eval.mdx +106 -0
- package/docs/reference/meta.ts +1 -1
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +19 -1
- package/src/ai/llms.ts +9 -4
- package/src/ai/mcp/server.ts +48 -14
- package/src/ai/mcp/stdio.ts +35 -0
- package/src/astro/generate.ts +119 -48
- package/src/astro/templates.ts +173 -37
- package/src/audit/checks/duplicates.ts +15 -6
- package/src/audit/checks/indexability.ts +11 -2
- package/src/audit/checks/network.ts +22 -8
- package/src/audit/checks/sitemap.ts +42 -16
- package/src/audit/redirects.ts +12 -1
- package/src/audit/run.ts +13 -3
- package/src/audit/url.ts +21 -2
- package/src/cli/commands/audit.ts +21 -6
- package/src/cli/commands/dev.ts +19 -2
- package/src/cli/commands/eval.ts +291 -0
- package/src/cli/commands/init.ts +9 -4
- package/src/cli/commands/mcp-stdio.ts +36 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/required-secrets.ts +1 -1
- package/src/components/content/AccordionItem.astro +2 -2
- package/src/components/content/Frame.astro +4 -1
- package/src/components/content/Prompt.astro +4 -1
- package/src/components/content/Tooltip.astro +4 -1
- package/src/components/content/TreeFolder.astro +1 -2
- package/src/components/content/Update.astro +45 -0
- package/src/components/islands/AskAI.astro +9 -2
- package/src/components/islands/ask-ai.tsx +23 -4
- package/src/components/islands/hooks.ts +48 -15
- package/src/components/layout/NavTree.astro +37 -19
- package/src/components/layout/ReferenceLayout.astro +4 -0
- package/src/components/layout/RootLayout.astro +14 -3
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/components/openapi/SchemaProperty.astro +3 -3
- package/src/core/config-input.ts +18 -0
- package/src/core/config.ts +4 -0
- package/src/core/data.ts +3 -0
- package/src/core/deployment-env.ts +7 -2
- package/src/core/graph.ts +8 -1
- package/src/core/i18n.ts +10 -2
- package/src/core/navigation.ts +16 -5
- package/src/core/schema.ts +51 -4
- package/src/core/server-features.ts +1 -1
- package/src/core/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/core/types.ts +5 -3
- package/src/eval/agents.ts +340 -0
- package/src/eval/findings.ts +103 -0
- package/src/eval/prompts.ts +78 -0
- package/src/eval/report.ts +214 -0
- package/src/eval/run.ts +290 -0
- package/src/eval/schema.ts +124 -0
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- package/src/openapi/references.ts +23 -2
- package/src/openapi/render-mdx.ts +39 -11
- package/src/openapi/scalar.ts +1 -0
- package/src/openapi/source.ts +11 -4
- package/src/registry/eject.ts +23 -1
- 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
|
+
});
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -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
|
|
161
|
-
// of version control. Idempotent: creates
|
|
162
|
-
// entries already present
|
|
163
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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(
|
|
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() },
|