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.
Files changed (41) hide show
  1. package/dist/cli/index.js +380 -157
  2. package/dist/cli/index.js.map +23 -22
  3. package/dist/types/core/data.d.ts +4 -0
  4. package/dist/types/core/i18n-ui.d.ts +50 -0
  5. package/dist/types/core/schema.d.ts +328 -39
  6. package/dist/types/core/types.d.ts +8 -0
  7. package/docs/configuration/ai.mdx +56 -0
  8. package/docs/configuration/seo.mdx +59 -1
  9. package/docs/configuration/theming.mdx +14 -9
  10. package/docs/content/meta.mdx +3 -17
  11. package/docs/content/navigation.mdx +41 -4
  12. package/package.json +3 -1
  13. package/src/ai/agent-readability.ts +97 -0
  14. package/src/ai/ask-context.ts +131 -8
  15. package/src/ai/ask-data.ts +4 -1
  16. package/src/astro/generate.ts +4 -0
  17. package/src/astro/templates.ts +24 -5
  18. package/src/cli/commands/build.ts +15 -0
  19. package/src/cli/commands/dev.ts +31 -14
  20. package/src/cli/dev-lock.ts +94 -21
  21. package/src/components/content/GithubInfo.astro +11 -10
  22. package/src/components/content/TypeTable.astro +8 -3
  23. package/src/components/islands/AskAI.astro +66 -2
  24. package/src/components/islands/ask-ai.tsx +289 -53
  25. package/src/components/layout/Header.astro +1 -1
  26. package/src/components/layout/NavTree.astro +1 -1
  27. package/src/components/layout/PageActions.astro +73 -30
  28. package/src/components/layout/RootLayout.astro +48 -2
  29. package/src/core/data.ts +4 -0
  30. package/src/core/graph.ts +7 -2
  31. package/src/core/i18n-ui.ts +5 -0
  32. package/src/core/nav-diagnostics.ts +7 -0
  33. package/src/core/navigation.ts +38 -12
  34. package/src/core/schema.ts +124 -9
  35. package/src/core/sources/filesystem.ts +5 -1
  36. package/src/core/sources/watch.ts +43 -12
  37. package/src/core/types.ts +9 -0
  38. package/src/deploy/robots.ts +37 -4
  39. package/src/openapi/scalar.ts +1 -1
  40. package/src/search/documents.ts +9 -2
  41. package/src/theme/palette.ts +21 -14
@@ -1,22 +1,57 @@
1
- import { useEffect, useState } from "react";
2
- import type { FormEvent } from "react";
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
- const BUTTON_CLASS =
38
- "inline-flex h-9 cursor-pointer items-center gap-2 rounded-blume border border-border bg-muted px-2.5 text-muted-foreground text-sm hover:border-accent disabled:opacity-50";
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
- const AskAI = ({ strings }: { strings?: UIStrings["ask"] }) => {
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
- const send = async (event: FormEvent) => {
62
- event.preventDefault();
63
- const question = input.trim();
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
- <div className="relative">
386
+ <>
119
387
  <button
120
- className={BUTTON_CLASS}
121
- onClick={() => setOpen(!open)}
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
- {t.title}
395
+ <Glyph path={icons.chat} size={18} />
125
396
  </button>
126
- {open && (
127
- <div className="absolute top-[calc(100%+0.5rem)] end-0 z-50 flex max-h-[70vh] w-[min(24rem,90vw)] flex-col overflow-hidden rounded-blume border border-border bg-background shadow-xl">
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} />
@@ -151,7 +151,7 @@ const initialId =
151
151
  ))}
152
152
  </blume-nav>
153
153
  ) : (
154
- <ul class="m-0 list-none p-0">
154
+ <ul class="m-0 list-none space-y-px p-0">
155
155
  {items.map((item, index) => {
156
156
  if (item.kind === "page") {
157
157
  return (
@@ -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
- route,
25
- editUrl,
26
- askEnabled,
27
- exportPdf,
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 class="absolute right-0 left-0 z-50 mt-1 rounded-blume border border-border bg-background p-1 shadow-xl">
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 class="absolute right-0 left-0 z-50 mt-1 rounded-blume border border-border bg-background p-1 shadow-xl">
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 class="absolute right-0 left-0 z-50 mt-1 rounded-blume border border-border bg-background p-1 shadow-xl">
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]");