blume 0.5.4 → 0.6.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/dist/cli/index.js +380 -157
- package/dist/cli/index.js.map +23 -22
- package/dist/types/core/data.d.ts +4 -0
- package/dist/types/core/i18n-ui.d.ts +50 -0
- package/dist/types/core/schema.d.ts +328 -39
- package/dist/types/core/types.d.ts +8 -0
- package/docs/configuration/ai.mdx +56 -0
- package/docs/configuration/seo.mdx +59 -1
- package/docs/configuration/theming.mdx +14 -9
- package/docs/content/meta.mdx +3 -17
- package/docs/content/navigation.mdx +41 -4
- package/package.json +3 -1
- package/src/ai/agent-readability.ts +97 -0
- package/src/ai/ask-context.ts +131 -8
- package/src/ai/ask-data.ts +4 -1
- package/src/astro/generate.ts +4 -0
- package/src/astro/templates.ts +24 -5
- package/src/cli/commands/build.ts +15 -0
- package/src/cli/commands/dev.ts +31 -14
- package/src/cli/dev-lock.ts +94 -21
- package/src/components/content/GithubInfo.astro +11 -10
- package/src/components/content/TypeTable.astro +8 -3
- package/src/components/islands/AskAI.astro +66 -2
- package/src/components/islands/ask-ai.tsx +289 -53
- package/src/components/layout/Header.astro +1 -1
- package/src/components/layout/NavTree.astro +1 -1
- package/src/components/layout/PageActions.astro +73 -30
- package/src/components/layout/RootLayout.astro +48 -2
- package/src/core/data.ts +4 -0
- package/src/core/graph.ts +7 -2
- package/src/core/i18n-ui.ts +5 -0
- package/src/core/nav-diagnostics.ts +7 -0
- package/src/core/navigation.ts +38 -12
- package/src/core/schema.ts +124 -9
- package/src/core/sources/filesystem.ts +5 -1
- package/src/core/sources/watch.ts +43 -12
- package/src/core/types.ts +9 -0
- package/src/deploy/robots.ts +37 -4
- package/src/openapi/scalar.ts +1 -1
- package/src/search/documents.ts +9 -2
- package/src/theme/palette.ts +21 -14
|
@@ -1,22 +1,57 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
3
6
|
|
|
4
7
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
5
8
|
import { joinBase, stripBase } from "./base-path.ts";
|
|
6
9
|
|
|
7
10
|
interface ChatMessage {
|
|
8
|
-
id: number;
|
|
9
|
-
role: "user" | "assistant";
|
|
10
11
|
content: string;
|
|
12
|
+
id: number;
|
|
13
|
+
role: "assistant" | "user";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A resolved empty-state prompt; `icon` is ready-to-inline SVG (or null). */
|
|
17
|
+
interface Suggestion {
|
|
18
|
+
icon: string | null;
|
|
19
|
+
label: string;
|
|
11
20
|
}
|
|
12
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The panel's chrome glyphs, resolved server-side in `AskAI.astro` and passed in
|
|
24
|
+
* as ready-to-inline Lucide bodies so this client island ships no icon data.
|
|
25
|
+
*/
|
|
26
|
+
interface AskIcons {
|
|
27
|
+
arrowUp: string;
|
|
28
|
+
chat: string;
|
|
29
|
+
clear: string;
|
|
30
|
+
close: string;
|
|
31
|
+
copy: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Empty bodies so the island still renders (iconless) if instantiated without
|
|
35
|
+
// the Astro wrapper that resolves the real Lucide glyphs.
|
|
36
|
+
const EMPTY_ICONS: AskIcons = {
|
|
37
|
+
arrowUp: "",
|
|
38
|
+
chat: "",
|
|
39
|
+
clear: "",
|
|
40
|
+
close: "",
|
|
41
|
+
copy: "",
|
|
42
|
+
};
|
|
43
|
+
|
|
13
44
|
// English fallback so the island renders even if no dictionary is passed.
|
|
14
45
|
const DEFAULT_ASK: UIStrings["ask"] = {
|
|
46
|
+
clear: "Clear conversation",
|
|
47
|
+
close: "Close",
|
|
48
|
+
copy: "Copy conversation",
|
|
15
49
|
empty: "Ask a question about the docs.",
|
|
16
50
|
error: "Sorry, something went wrong.",
|
|
17
51
|
label: "Ask a question",
|
|
18
52
|
placeholder: "Ask a question…",
|
|
19
53
|
send: "Send",
|
|
54
|
+
tip: "Tip: You can open and close chat with",
|
|
20
55
|
title: "Ask AI",
|
|
21
56
|
};
|
|
22
57
|
|
|
@@ -34,20 +69,69 @@ const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
|
34
69
|
const currentPath = (): string =>
|
|
35
70
|
stripBase(import.meta.env.BASE_URL, window.location.pathname);
|
|
36
71
|
|
|
37
|
-
|
|
38
|
-
|
|
72
|
+
// GitHub-flavored markdown with soft line breaks, matching how the docs read.
|
|
73
|
+
marked.setOptions({ breaks: true, gfm: true });
|
|
74
|
+
|
|
75
|
+
const renderMarkdown = (content: string): string =>
|
|
76
|
+
DOMPurify.sanitize(marked.parse(content, { async: false }));
|
|
77
|
+
|
|
78
|
+
const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
|
|
79
|
+
<svg
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
dangerouslySetInnerHTML={{ __html: path }}
|
|
82
|
+
fill="none"
|
|
83
|
+
height={size}
|
|
84
|
+
stroke="currentColor"
|
|
85
|
+
strokeLinecap="round"
|
|
86
|
+
strokeLinejoin="round"
|
|
87
|
+
strokeWidth={2}
|
|
88
|
+
viewBox="0 0 24 24"
|
|
89
|
+
width={size}
|
|
90
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Stable empty default so an unset `suggestions` prop doesn't re-render.
|
|
95
|
+
const EMPTY_SUGGESTIONS: Suggestion[] = [];
|
|
39
96
|
|
|
40
|
-
|
|
97
|
+
// Ghost icon button, matching the header's theme toggle and repo link.
|
|
98
|
+
const TRIGGER_CLASS =
|
|
99
|
+
"inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
|
|
100
|
+
const ICON_BUTTON_CLASS =
|
|
101
|
+
"inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-blume text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40";
|
|
102
|
+
|
|
103
|
+
// The assistant answer: standard prose, but citation links (every `[Title](/route)`
|
|
104
|
+
// the model emits) render as small neutral pills so they read as sources instead
|
|
105
|
+
// of inline text links. `no-underline`/color use `!` to beat the theme's unlayered
|
|
106
|
+
// `.prose a` rule; `leading-none` drops the inherited prose line-height so the
|
|
107
|
+
// pill hugs its label.
|
|
108
|
+
const ANSWER_CLASS =
|
|
109
|
+
"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!";
|
|
110
|
+
|
|
111
|
+
const AskAI = ({
|
|
112
|
+
icons = EMPTY_ICONS,
|
|
113
|
+
strings,
|
|
114
|
+
suggestions = EMPTY_SUGGESTIONS,
|
|
115
|
+
}: {
|
|
116
|
+
icons?: AskIcons;
|
|
117
|
+
strings?: UIStrings["ask"];
|
|
118
|
+
suggestions?: Suggestion[];
|
|
119
|
+
}) => {
|
|
41
120
|
const t = strings ?? DEFAULT_ASK;
|
|
121
|
+
const [mounted, setMounted] = useState(false);
|
|
42
122
|
const [open, setOpen] = useState(false);
|
|
43
123
|
const [input, setInput] = useState("");
|
|
44
124
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
45
125
|
const [busy, setBusy] = useState(false);
|
|
126
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
127
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
128
|
+
|
|
129
|
+
// Portal target (document.body) only exists after mount; guards SSR.
|
|
130
|
+
useEffect(() => setMounted(true), []);
|
|
46
131
|
|
|
132
|
+
// The search modal forwards its query so "Ask AI: <query>" carries straight in.
|
|
47
133
|
useEffect(() => {
|
|
48
134
|
const handler = (event: Event) => {
|
|
49
|
-
// The search modal forwards the typed query so "Ask AI: <query>" carries
|
|
50
|
-
// straight into the chat input.
|
|
51
135
|
const query = (event as CustomEvent<{ query?: string }>).detail?.query;
|
|
52
136
|
if (query) {
|
|
53
137
|
setInput(query);
|
|
@@ -58,9 +142,40 @@ const AskAI = ({ strings }: { strings?: UIStrings["ask"] }) => {
|
|
|
58
142
|
return () => window.removeEventListener("blume:open-ask-ai", handler);
|
|
59
143
|
}, []);
|
|
60
144
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
145
|
+
// ⌘I / Ctrl+I toggles the panel; Escape closes it.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const onKey = (event: KeyboardEvent) => {
|
|
148
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "i") {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
setOpen((value) => !value);
|
|
151
|
+
} else if (event.key === "Escape" && open) {
|
|
152
|
+
setOpen(false);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
window.addEventListener("keydown", onKey);
|
|
156
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
157
|
+
}, [open]);
|
|
158
|
+
|
|
159
|
+
// Drive the desktop content push from a body attribute (see AskAI.astro CSS).
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (open) {
|
|
162
|
+
document.body.dataset.blumeAsk = "open";
|
|
163
|
+
inputRef.current?.focus();
|
|
164
|
+
} else {
|
|
165
|
+
delete document.body.dataset.blumeAsk;
|
|
166
|
+
}
|
|
167
|
+
return () => {
|
|
168
|
+
delete document.body.dataset.blumeAsk;
|
|
169
|
+
};
|
|
170
|
+
}, [open]);
|
|
171
|
+
|
|
172
|
+
// Keep the newest message in view as it streams in.
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
175
|
+
}, [messages]);
|
|
176
|
+
|
|
177
|
+
const runQuestion = async (raw: string) => {
|
|
178
|
+
const question = raw.trim();
|
|
64
179
|
if (!question || busy) {
|
|
65
180
|
return;
|
|
66
181
|
}
|
|
@@ -114,52 +229,173 @@ const AskAI = ({ strings }: { strings?: UIStrings["ask"] }) => {
|
|
|
114
229
|
}
|
|
115
230
|
};
|
|
116
231
|
|
|
232
|
+
const onSubmit = (event: FormEvent) => {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
void runQuestion(input);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
238
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
void runQuestion(input);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const copyConversation = () => {
|
|
245
|
+
const text = messages
|
|
246
|
+
.map((m) => `${m.role === "user" ? "You" : "AI"}: ${m.content}`)
|
|
247
|
+
.join("\n\n");
|
|
248
|
+
void navigator.clipboard?.writeText(text);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const hasMessages = messages.length > 0;
|
|
252
|
+
|
|
253
|
+
const panel = (
|
|
254
|
+
<aside
|
|
255
|
+
aria-hidden={open ? undefined : "true"}
|
|
256
|
+
aria-label={t.title}
|
|
257
|
+
className={`fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-border border-s bg-background shadow-2xl transition-transform duration-200 ease-out ${
|
|
258
|
+
open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
|
|
259
|
+
}`}
|
|
260
|
+
>
|
|
261
|
+
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-border border-b px-4">
|
|
262
|
+
<span className="font-semibold text-foreground">{t.title}</span>
|
|
263
|
+
<div className="flex items-center gap-0.5">
|
|
264
|
+
<button
|
|
265
|
+
aria-label={t.copy}
|
|
266
|
+
className={ICON_BUTTON_CLASS}
|
|
267
|
+
disabled={!hasMessages}
|
|
268
|
+
onClick={copyConversation}
|
|
269
|
+
title={t.copy}
|
|
270
|
+
type="button"
|
|
271
|
+
>
|
|
272
|
+
<Glyph path={icons.copy} />
|
|
273
|
+
</button>
|
|
274
|
+
<button
|
|
275
|
+
aria-label={t.clear}
|
|
276
|
+
className={ICON_BUTTON_CLASS}
|
|
277
|
+
disabled={!hasMessages}
|
|
278
|
+
onClick={() => setMessages([])}
|
|
279
|
+
title={t.clear}
|
|
280
|
+
type="button"
|
|
281
|
+
>
|
|
282
|
+
<Glyph path={icons.clear} />
|
|
283
|
+
</button>
|
|
284
|
+
<button
|
|
285
|
+
aria-label={t.close}
|
|
286
|
+
className={ICON_BUTTON_CLASS}
|
|
287
|
+
onClick={() => setOpen(false)}
|
|
288
|
+
title={t.close}
|
|
289
|
+
type="button"
|
|
290
|
+
>
|
|
291
|
+
<Glyph path={icons.close} size={18} />
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
</header>
|
|
295
|
+
|
|
296
|
+
<div className="flex flex-1 flex-col overflow-y-auto" ref={scrollRef}>
|
|
297
|
+
{hasMessages ? (
|
|
298
|
+
<div className="flex flex-col gap-4 p-4">
|
|
299
|
+
{messages.map((message) =>
|
|
300
|
+
message.role === "user" ? (
|
|
301
|
+
<div
|
|
302
|
+
className="max-w-[85%] self-end whitespace-pre-wrap rounded-blume bg-muted px-3 py-2 text-foreground text-sm"
|
|
303
|
+
key={message.id}
|
|
304
|
+
>
|
|
305
|
+
{message.content}
|
|
306
|
+
</div>
|
|
307
|
+
) : (
|
|
308
|
+
<div className={ANSWER_CLASS} key={message.id}>
|
|
309
|
+
{message.content ? (
|
|
310
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized above
|
|
311
|
+
<div
|
|
312
|
+
dangerouslySetInnerHTML={{
|
|
313
|
+
__html: renderMarkdown(message.content),
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
316
|
+
) : (
|
|
317
|
+
<span className="animate-pulse text-muted-foreground">
|
|
318
|
+
…
|
|
319
|
+
</span>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
)
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
) : (
|
|
326
|
+
<div className="mt-auto flex flex-col gap-0.5 p-4">
|
|
327
|
+
{suggestions.length === 0 && (
|
|
328
|
+
<p className="px-2 text-muted-foreground text-sm">{t.empty}</p>
|
|
329
|
+
)}
|
|
330
|
+
{suggestions.map((suggestion) => (
|
|
331
|
+
<button
|
|
332
|
+
className="flex cursor-pointer items-center gap-2.5 rounded-blume px-2 py-2 text-start text-foreground text-sm transition-colors hover:bg-muted"
|
|
333
|
+
key={suggestion.label}
|
|
334
|
+
onClick={() => runQuestion(suggestion.label)}
|
|
335
|
+
type="button"
|
|
336
|
+
>
|
|
337
|
+
{suggestion.icon && (
|
|
338
|
+
<span
|
|
339
|
+
className="shrink-0 text-muted-foreground [&_svg]:h-[18px] [&_svg]:w-[18px]"
|
|
340
|
+
dangerouslySetInnerHTML={{ __html: suggestion.icon }}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
<span>{suggestion.label}</span>
|
|
344
|
+
</button>
|
|
345
|
+
))}
|
|
346
|
+
<p className="mt-3 flex items-center gap-1.5 px-2 text-muted-foreground text-sm">
|
|
347
|
+
{t.tip}
|
|
348
|
+
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
|
|
349
|
+
⌘
|
|
350
|
+
</kbd>
|
|
351
|
+
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
|
|
352
|
+
I
|
|
353
|
+
</kbd>
|
|
354
|
+
</p>
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<form
|
|
360
|
+
className="relative shrink-0 border-border border-t"
|
|
361
|
+
onSubmit={onSubmit}
|
|
362
|
+
>
|
|
363
|
+
<textarea
|
|
364
|
+
aria-label={t.label}
|
|
365
|
+
className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm outline-none placeholder:text-muted-foreground"
|
|
366
|
+
onChange={(event) => setInput(event.target.value)}
|
|
367
|
+
onKeyDown={onInputKeyDown}
|
|
368
|
+
placeholder={t.placeholder}
|
|
369
|
+
ref={inputRef}
|
|
370
|
+
rows={3}
|
|
371
|
+
value={input}
|
|
372
|
+
/>
|
|
373
|
+
<button
|
|
374
|
+
aria-label={t.send}
|
|
375
|
+
className="absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-blume bg-foreground text-background transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
|
|
376
|
+
disabled={busy || input.trim().length === 0}
|
|
377
|
+
type="submit"
|
|
378
|
+
>
|
|
379
|
+
<Glyph path={icons.arrowUp} />
|
|
380
|
+
</button>
|
|
381
|
+
</form>
|
|
382
|
+
</aside>
|
|
383
|
+
);
|
|
384
|
+
|
|
117
385
|
return (
|
|
118
|
-
|
|
386
|
+
<>
|
|
119
387
|
<button
|
|
120
|
-
|
|
121
|
-
|
|
388
|
+
aria-expanded={open}
|
|
389
|
+
aria-label={t.title}
|
|
390
|
+
className={TRIGGER_CLASS}
|
|
391
|
+
onClick={() => setOpen((value) => !value)}
|
|
392
|
+
title={t.title}
|
|
122
393
|
type="button"
|
|
123
394
|
>
|
|
124
|
-
{
|
|
395
|
+
<Glyph path={icons.chat} size={18} />
|
|
125
396
|
</button>
|
|
126
|
-
{
|
|
127
|
-
|
|
128
|
-
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto p-3.5">
|
|
129
|
-
{messages.length === 0 && (
|
|
130
|
-
<p className="m-0 text-muted-foreground text-sm">{t.empty}</p>
|
|
131
|
-
)}
|
|
132
|
-
{messages.map((message) => (
|
|
133
|
-
<div
|
|
134
|
-
className={`whitespace-pre-wrap rounded-blume px-2.5 py-2 text-sm ${
|
|
135
|
-
message.role === "user"
|
|
136
|
-
? "self-end bg-accent/15"
|
|
137
|
-
: "self-start bg-muted"
|
|
138
|
-
}`}
|
|
139
|
-
key={message.id}
|
|
140
|
-
>
|
|
141
|
-
{message.content}
|
|
142
|
-
</div>
|
|
143
|
-
))}
|
|
144
|
-
</div>
|
|
145
|
-
<form
|
|
146
|
-
className="flex gap-2 border-border border-t p-2.5"
|
|
147
|
-
onSubmit={send}
|
|
148
|
-
>
|
|
149
|
-
<input
|
|
150
|
-
aria-label={t.label}
|
|
151
|
-
className="flex-1 rounded-blume border border-border bg-transparent px-2.5 py-1.5 text-sm"
|
|
152
|
-
onChange={(event) => setInput(event.target.value)}
|
|
153
|
-
placeholder={t.placeholder}
|
|
154
|
-
value={input}
|
|
155
|
-
/>
|
|
156
|
-
<button className={BUTTON_CLASS} disabled={busy} type="submit">
|
|
157
|
-
{t.send}
|
|
158
|
-
</button>
|
|
159
|
-
</form>
|
|
160
|
-
</div>
|
|
161
|
-
)}
|
|
162
|
-
</div>
|
|
397
|
+
{mounted && createPortal(panel, document.body)}
|
|
398
|
+
</>
|
|
163
399
|
);
|
|
164
400
|
};
|
|
165
401
|
|
|
@@ -140,7 +140,6 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
140
140
|
)
|
|
141
141
|
}
|
|
142
142
|
<div class="flex-1"></div>
|
|
143
|
-
<slot name="ask" />
|
|
144
143
|
{
|
|
145
144
|
localeSwitch && localeSwitch.length > 1 && (
|
|
146
145
|
<LanguageSwitcher
|
|
@@ -191,6 +190,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
191
190
|
<span class="inline-flex dark:hidden"><Icon name="sun" size={18} /></span>
|
|
192
191
|
<span class="hidden dark:inline-flex"><Icon name="moon" size={18} /></span>
|
|
193
192
|
</button>
|
|
193
|
+
<slot name="ask" />
|
|
194
194
|
</div>
|
|
195
195
|
</header>
|
|
196
196
|
<script is:inline set:html={clickScript} />
|
|
@@ -7,7 +7,6 @@ import Icon from "../Icon.astro";
|
|
|
7
7
|
interface Props {
|
|
8
8
|
route: string;
|
|
9
9
|
editUrl?: string | null;
|
|
10
|
-
askEnabled?: boolean;
|
|
11
10
|
/** Show the "Export to PDF" action (native browser print). */
|
|
12
11
|
exportPdf?: boolean;
|
|
13
12
|
/** Show the "Export to EPUB" action (client-side, current page). */
|
|
@@ -20,17 +19,11 @@ interface Props {
|
|
|
20
19
|
strings?: UIStrings["actions"];
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
const {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
exportEpub,
|
|
29
|
-
mcpUrl,
|
|
30
|
-
mcpName,
|
|
31
|
-
strings,
|
|
32
|
-
} = Astro.props;
|
|
33
|
-
const a = strings ?? EN_UI.actions;
|
|
22
|
+
const { route, editUrl, exportPdf, exportEpub, mcpUrl, mcpName, strings } =
|
|
23
|
+
Astro.props;
|
|
24
|
+
// Merge over the English defaults so a label missing from a translation (or from
|
|
25
|
+
// a not-yet-regenerated snapshot) still renders instead of coming out blank.
|
|
26
|
+
const a = { ...EN_UI.actions, ...strings };
|
|
34
27
|
const mdPath = route === "/" ? "/index.md" : `${route}.md`;
|
|
35
28
|
|
|
36
29
|
const GITHUB_LOGO = `<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">${GITHUB_MARK}</svg>`;
|
|
@@ -41,6 +34,8 @@ const LOGOS: Record<string, string> = {
|
|
|
41
34
|
'<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>',
|
|
42
35
|
claude:
|
|
43
36
|
'<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" fill-rule="evenodd" d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"/></svg>',
|
|
37
|
+
codex:
|
|
38
|
+
'<svg class="size-4 shrink-0" fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>',
|
|
44
39
|
cursor:
|
|
45
40
|
'<svg class="size-4 shrink-0" viewBox="0 0 466.73 532.09" xmlns="http://www.w3.org/2000/svg"><path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" fill="currentColor"/></svg>',
|
|
46
41
|
scira:
|
|
@@ -110,7 +105,10 @@ const menuRowClass =
|
|
|
110
105
|
size={14}
|
|
111
106
|
/>
|
|
112
107
|
</summary>
|
|
113
|
-
<div
|
|
108
|
+
<div
|
|
109
|
+
class="absolute top-full right-2 z-50 mt-1 w-max min-w-[14rem] max-w-[22rem] rounded-blume border border-border bg-background p-1 shadow-xl"
|
|
110
|
+
data-blume-menu
|
|
111
|
+
>
|
|
114
112
|
{exportPdf && (
|
|
115
113
|
<button class={menuRowClass} data-blume-export-pdf type="button">
|
|
116
114
|
<Icon name="file" size={16} />
|
|
@@ -130,15 +128,6 @@ const menuRowClass =
|
|
|
130
128
|
)
|
|
131
129
|
}
|
|
132
130
|
|
|
133
|
-
{
|
|
134
|
-
askEnabled && (
|
|
135
|
-
<button class={rowClass} data-blume-ask-ai type="button">
|
|
136
|
-
<Icon name="message-circle" size={16} />
|
|
137
|
-
{a.askAI}
|
|
138
|
-
</button>
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
131
|
<details class="group relative">
|
|
143
132
|
<summary
|
|
144
133
|
class={`${rowClass} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
|
|
@@ -151,7 +140,10 @@ const menuRowClass =
|
|
|
151
140
|
size={14}
|
|
152
141
|
/>
|
|
153
142
|
</summary>
|
|
154
|
-
<div
|
|
143
|
+
<div
|
|
144
|
+
class="absolute top-full right-2 z-50 mt-1 w-max min-w-[14rem] max-w-[22rem] rounded-blume border border-border bg-background p-1 shadow-xl"
|
|
145
|
+
data-blume-menu
|
|
146
|
+
>
|
|
155
147
|
{
|
|
156
148
|
PROVIDERS.map((provider, index) => (
|
|
157
149
|
<>
|
|
@@ -186,7 +178,10 @@ const menuRowClass =
|
|
|
186
178
|
size={14}
|
|
187
179
|
/>
|
|
188
180
|
</summary>
|
|
189
|
-
<div
|
|
181
|
+
<div
|
|
182
|
+
class="absolute top-full right-2 z-50 mt-1 w-max min-w-[14rem] max-w-[22rem] rounded-blume border border-border bg-background p-1 shadow-xl"
|
|
183
|
+
data-blume-menu
|
|
184
|
+
>
|
|
190
185
|
<button class={menuRowClass} data-mcp-copy-url type="button">
|
|
191
186
|
<Icon name="link" size={16} />
|
|
192
187
|
<span class="flex-1" data-mcp-url-label>
|
|
@@ -199,6 +194,12 @@ const menuRowClass =
|
|
|
199
194
|
{a.copyClaudeCode}
|
|
200
195
|
</span>
|
|
201
196
|
</button>
|
|
197
|
+
<button class={menuRowClass} data-mcp-copy-codex type="button">
|
|
198
|
+
<span set:html={LOGOS.codex} />
|
|
199
|
+
<span class="flex-1" data-mcp-codex-label>
|
|
200
|
+
{a.copyCodex}
|
|
201
|
+
</span>
|
|
202
|
+
</button>
|
|
202
203
|
<hr class="my-1 border-border border-t" />
|
|
203
204
|
<a class={menuRowClass} data-mcp-cursor rel="noreferrer">
|
|
204
205
|
<span set:html={LOGOS.cursor} />
|
|
@@ -276,6 +277,46 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
276
277
|
|
|
277
278
|
const root = document.querySelector("[data-blume-page-actions]");
|
|
278
279
|
if (root) {
|
|
280
|
+
// The action dropdowns are native <details>. Make them behave like a proper
|
|
281
|
+
// menu: only one open at a time, and flip above the trigger when opening
|
|
282
|
+
// downward would run past the viewport bottom.
|
|
283
|
+
const dropdowns = [...root.querySelectorAll<HTMLDetailsElement>("details")];
|
|
284
|
+
const placeMenu = (details: HTMLDetailsElement) => {
|
|
285
|
+
const menu = details.querySelector<HTMLElement>("[data-blume-menu]");
|
|
286
|
+
const summary = details.querySelector("summary");
|
|
287
|
+
if (!(menu && summary)) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const rect = summary.getBoundingClientRect();
|
|
291
|
+
const margin = 8;
|
|
292
|
+
const flipUp =
|
|
293
|
+
rect.bottom + menu.offsetHeight + margin > window.innerHeight &&
|
|
294
|
+
rect.top - menu.offsetHeight - margin > 0;
|
|
295
|
+
menu.classList.toggle("top-full", !flipUp);
|
|
296
|
+
menu.classList.toggle("mt-1", !flipUp);
|
|
297
|
+
menu.classList.toggle("bottom-full", flipUp);
|
|
298
|
+
menu.classList.toggle("mb-1", flipUp);
|
|
299
|
+
};
|
|
300
|
+
for (const details of dropdowns) {
|
|
301
|
+
details.addEventListener("toggle", () => {
|
|
302
|
+
if (!details.open) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
for (const other of dropdowns) {
|
|
306
|
+
if (other !== details) {
|
|
307
|
+
other.open = false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
placeMenu(details);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
window.addEventListener("resize", () => {
|
|
314
|
+
const open = dropdowns.find((details) => details.open);
|
|
315
|
+
if (open) {
|
|
316
|
+
placeMenu(open);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
279
320
|
const md = root.getAttribute("data-md") ?? "";
|
|
280
321
|
const copiedLabel = root.getAttribute("data-i18n-copied") || "Copied!";
|
|
281
322
|
const absolute = new URL(md, location.origin).href;
|
|
@@ -296,12 +337,6 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
296
337
|
window.scrollTo({ behavior: "smooth", top: 0 })
|
|
297
338
|
);
|
|
298
339
|
|
|
299
|
-
root
|
|
300
|
-
.querySelector("[data-blume-ask-ai]")
|
|
301
|
-
?.addEventListener("click", () =>
|
|
302
|
-
window.dispatchEvent(new CustomEvent("blume:open-ask-ai"))
|
|
303
|
-
);
|
|
304
|
-
|
|
305
340
|
const mcpUrl = root.getAttribute("data-mcp-url");
|
|
306
341
|
if (mcpUrl) {
|
|
307
342
|
const rawName = root.getAttribute("data-mcp-name") || "docs";
|
|
@@ -360,6 +395,14 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
360
395
|
root.querySelector("[data-mcp-claude-label]")
|
|
361
396
|
)
|
|
362
397
|
);
|
|
398
|
+
root
|
|
399
|
+
.querySelector("[data-mcp-copy-codex]")
|
|
400
|
+
?.addEventListener("click", () =>
|
|
401
|
+
copy(
|
|
402
|
+
`codex mcp add ${id} --url ${mcpUrl}`,
|
|
403
|
+
root.querySelector("[data-mcp-codex-label]")
|
|
404
|
+
)
|
|
405
|
+
);
|
|
363
406
|
}
|
|
364
407
|
|
|
365
408
|
const label = root.querySelector("[data-blume-copy-label]");
|