blume 0.5.3 → 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 (132) hide show
  1. package/dist/cli/index.js +3349 -7024
  2. package/dist/cli/index.js.map +39 -69
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +6 -2
  5. package/dist/types/core/i18n-ui.d.ts +50 -0
  6. package/dist/types/core/schema.d.ts +379 -485
  7. package/dist/types/core/types.d.ts +8 -6
  8. package/docs/advanced/meta.ts +1 -8
  9. package/docs/advanced/skills.mdx +28 -0
  10. package/docs/configuration/ai.mdx +58 -0
  11. package/docs/configuration/index.mdx +13 -17
  12. package/docs/configuration/seo.mdx +59 -1
  13. package/docs/configuration/theming.mdx +15 -18
  14. package/docs/content/components.mdx +2 -53
  15. package/docs/content/i18n.mdx +0 -4
  16. package/docs/content/meta.mdx +3 -17
  17. package/docs/content/navigation.mdx +41 -4
  18. package/docs/content/syntax.mdx +1 -1
  19. package/docs/index.mdx +0 -1
  20. package/docs/reference/cli.mdx +12 -13
  21. package/package.json +6 -6
  22. package/skills/blume/SKILL.md +71 -0
  23. package/skills/blume-update-docs/SKILL.md +52 -0
  24. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  25. package/src/ai/agent-readability.ts +97 -0
  26. package/src/ai/ask-context.ts +131 -8
  27. package/src/ai/ask-data.ts +4 -1
  28. package/src/astro/generate.ts +19 -12
  29. package/src/astro/integration.ts +0 -21
  30. package/src/astro/templates.ts +33 -21
  31. package/src/cli/commands/build.ts +15 -0
  32. package/src/cli/commands/dev.ts +31 -20
  33. package/src/cli/commands/validate.ts +0 -2
  34. package/src/cli/dev-lock.ts +94 -21
  35. package/src/cli/index.ts +0 -2
  36. package/src/components/BlumePage.astro +0 -6
  37. package/src/components/Icon.astro +1 -12
  38. package/src/components/content/AccordionItem.astro +3 -6
  39. package/src/components/content/Badge.astro +1 -3
  40. package/src/components/content/Callout.astro +3 -9
  41. package/src/components/content/Card.astro +2 -3
  42. package/src/components/content/ColorItem.astro +2 -2
  43. package/src/components/content/Column.astro +1 -1
  44. package/src/components/content/GithubInfo.astro +11 -10
  45. package/src/components/content/Prompt.astro +1 -1
  46. package/src/components/content/Step.astro +3 -4
  47. package/src/components/content/Tab.astro +2 -3
  48. package/src/components/content/TypeTable.astro +13 -8
  49. package/src/components/content/Update.astro +1 -1
  50. package/src/components/islands/AskAI.astro +66 -2
  51. package/src/components/islands/ask-ai.tsx +289 -53
  52. package/src/components/layout/Header.astro +27 -4
  53. package/src/components/layout/Logo.astro +5 -1
  54. package/src/components/layout/NavSelector.astro +1 -1
  55. package/src/components/layout/NavTree.astro +15 -15
  56. package/src/components/layout/PageActions.astro +73 -30
  57. package/src/components/layout/PageLayout.astro +42 -0
  58. package/src/components/layout/ReferenceLayout.astro +1 -0
  59. package/src/components/layout/RootLayout.astro +79 -4
  60. package/src/components/layout/Search.astro +5 -5
  61. package/src/components/layout/nav-utils.ts +9 -4
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/core/builtin-tags.ts +1 -3
  65. package/src/core/config.ts +5 -28
  66. package/src/core/data.ts +6 -2
  67. package/src/core/graph.ts +8 -6
  68. package/src/core/i18n-ui.ts +5 -0
  69. package/src/core/links.ts +5 -19
  70. package/src/core/meta.ts +1 -1
  71. package/src/core/nav-diagnostics.ts +7 -0
  72. package/src/core/navigation.ts +38 -17
  73. package/src/core/project-graph.ts +0 -5
  74. package/src/core/schema.ts +133 -95
  75. package/src/core/sources/filesystem.ts +5 -1
  76. package/src/core/sources/resolve.ts +0 -13
  77. package/src/core/sources/watch.ts +43 -11
  78. package/src/core/types.ts +8 -6
  79. package/src/deploy/robots.ts +37 -4
  80. package/src/openapi/parse.ts +197 -14
  81. package/src/openapi/render-mdx.ts +44 -10
  82. package/src/openapi/scalar.ts +1 -1
  83. package/src/openapi/source.ts +19 -2
  84. package/src/search/documents.ts +9 -2
  85. package/src/theme/entry.ts +45 -17
  86. package/src/theme/icons.ts +18 -109
  87. package/src/theme/palette.ts +25 -51
  88. package/src/theme/twoslash.ts +6 -1
  89. package/dist/types/core/bridge.d.ts +0 -24
  90. package/dist/types/core/package-json.d.ts +0 -12
  91. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  92. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  93. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  94. package/dist/types/migrate/shared.d.ts +0 -153
  95. package/docs/advanced/bridge.mdx +0 -76
  96. package/docs/advanced/migrate.mdx +0 -124
  97. package/src/astro/static-assets.ts +0 -124
  98. package/src/cli/commands/migrate.ts +0 -39
  99. package/src/components/content/ApiField.astro +0 -75
  100. package/src/components/content/ParamField.astro +0 -39
  101. package/src/components/content/RequestField.astro +0 -23
  102. package/src/components/content/ResponseField.astro +0 -23
  103. package/src/components/content/Warning.astro +0 -9
  104. package/src/core/assets.ts +0 -31
  105. package/src/core/bridge.ts +0 -102
  106. package/src/core/sources/mintlify.ts +0 -190
  107. package/src/migrate/fumadocs/config.ts +0 -155
  108. package/src/migrate/fumadocs/content.ts +0 -376
  109. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  110. package/src/migrate/fumadocs/groups.ts +0 -237
  111. package/src/migrate/fumadocs/index.ts +0 -355
  112. package/src/migrate/fumadocs/meta.ts +0 -244
  113. package/src/migrate/migrate.ts +0 -53
  114. package/src/migrate/mintlify/assets.ts +0 -46
  115. package/src/migrate/mintlify/config.ts +0 -954
  116. package/src/migrate/mintlify/content.ts +0 -120
  117. package/src/migrate/mintlify/frontmatter.ts +0 -126
  118. package/src/migrate/mintlify/i18n.ts +0 -51
  119. package/src/migrate/mintlify/icons.ts +0 -128
  120. package/src/migrate/mintlify/index.ts +0 -459
  121. package/src/migrate/mintlify/snippets.ts +0 -315
  122. package/src/migrate/mintlify/transform.ts +0 -82
  123. package/src/migrate/nextra/content.ts +0 -46
  124. package/src/migrate/nextra/frontmatter.ts +0 -40
  125. package/src/migrate/nextra/index.ts +0 -389
  126. package/src/migrate/nextra/meta.ts +0 -266
  127. package/src/migrate/shared.ts +0 -801
  128. package/src/migrate/starlight/config.ts +0 -455
  129. package/src/migrate/starlight/content.ts +0 -75
  130. package/src/migrate/starlight/frontmatter.ts +0 -111
  131. package/src/migrate/starlight/i18n.ts +0 -54
  132. package/src/migrate/starlight/index.ts +0 -131
@@ -3,10 +3,17 @@
3
3
  // at build time (no client JS). Pass `owner`/`repo`, or omit them to use the
4
4
  // repo from blume.config. A `GITHUB_TOKEN` env var lifts the API rate limit.
5
5
  // If the API is unreachable the card still renders, just without counts.
6
+ import { resolveIcon } from "../../theme/icons.ts";
6
7
  import data from "blume:data";
7
8
  import { GITHUB_MARK } from "../github-mark.ts";
8
9
  import { fetchRepositoryInfo } from "./github-info.ts";
9
10
 
11
+ // Star/fork glyphs resolve from Lucide at build time; the `<svg>` wrappers below
12
+ // supply size and the shared `statIcon` class. (The GitHub octocat above is a
13
+ // brand mark, not a Lucide icon, so it stays as GITHUB_MARK.)
14
+ const starIcon = resolveIcon("star")?.body ?? "";
15
+ const forkIcon = resolveIcon("git-fork")?.body ?? "";
16
+
10
17
  interface Props {
11
18
  owner?: string;
12
19
  repo?: string;
@@ -77,9 +84,8 @@ const statIcon =
77
84
  stroke-width="2"
78
85
  viewBox="0 0 24 24"
79
86
  width="14"
80
- >
81
- <path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
82
- </svg>
87
+ set:html={starIcon}
88
+ />
83
89
  <span>{numbers.format(info.stars)}</span>
84
90
  </span>
85
91
  <span class="flex items-center gap-1.5">
@@ -94,13 +100,8 @@ const statIcon =
94
100
  stroke-width="2"
95
101
  viewBox="0 0 24 24"
96
102
  width="14"
97
- >
98
- <circle cx="12" cy="18" r="3" />
99
- <circle cx="6" cy="6" r="3" />
100
- <circle cx="18" cy="6" r="3" />
101
- <path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9" />
102
- <path d="M12 12v3" />
103
- </svg>
103
+ set:html={forkIcon}
104
+ />
104
105
  <span>{numbers.format(info.forks)}</span>
105
106
  </span>
106
107
  </span>
@@ -45,7 +45,7 @@ const secondaryButton =
45
45
  >
46
46
  <div class="flex items-center justify-between gap-3 px-4 py-3">
47
47
  <p
48
- class="m-0! min-w-0 text-foreground text-sm [&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_strong]:font-semibold [&_strong]:text-foreground"
48
+ class="m-0! text-foreground text-sm [&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_strong]:font-semibold [&_strong]:text-foreground"
49
49
  data-blume-prompt-description
50
50
  set:html={descriptionHtml}
51
51
  />
@@ -4,11 +4,10 @@ import { hasIcon } from "../../theme/icons.ts";
4
4
 
5
5
  interface Props {
6
6
  icon?: string;
7
- iconType?: string;
8
7
  title?: string;
9
8
  }
10
9
 
11
- const { icon, iconType, title } = Astro.props;
10
+ const { icon, title } = Astro.props;
12
11
  const markerClass =
13
12
  "absolute top-0 -start-[2.75rem] flex size-6 items-center justify-center rounded-full bg-accent text-center font-semibold text-accent-foreground text-xs";
14
13
  const isImageIcon = (value: string): boolean =>
@@ -18,14 +17,14 @@ const canRenderIcon =
18
17
  icon !== undefined &&
19
18
  (/^\s*<svg[\s\S]*<\/svg>\s*$/u.test(icon) ||
20
19
  isImageIcon(icon) ||
21
- hasIcon(icon, { iconType }));
20
+ hasIcon(icon));
22
21
  ---
23
22
 
24
23
  <div
25
24
  class="relative [counter-increment:blume-step]"
26
25
  >
27
26
  <span class:list={[markerClass, !canRenderIcon && "before:[content:counter(blume-step)]"]}>
28
- {canRenderIcon && <Icon iconType={iconType} name={icon} size={14} />}
27
+ {canRenderIcon && <Icon name={icon} size={14} />}
29
28
  </span>
30
29
  {title && <p class="mb-1.5 font-semibold">{title}</p>}
31
30
  <div class="[&>:first-child]:mt-0! [&>:last-child]:mb-0!">
@@ -4,13 +4,12 @@ import Icon from "../Icon.astro";
4
4
  interface Props {
5
5
  class?: string;
6
6
  icon?: string;
7
- iconType?: string;
8
7
  id?: string;
9
8
  style?: string;
10
9
  title: string;
11
10
  }
12
11
 
13
- const { class: className, icon, iconType, id, style, title } = Astro.props;
12
+ const { class: className, icon, id, style, title } = Astro.props;
14
13
  const slugify = (value: string): string =>
15
14
  value
16
15
  .toLowerCase()
@@ -39,7 +38,7 @@ const tabId = id ?? slugify(title);
39
38
  {
40
39
  icon && (
41
40
  <template data-blume-tab-icon>
42
- <Icon class="shrink-0" iconType={iconType} name={icon} size={14} />
41
+ <Icon class="shrink-0" name={icon} size={14} />
43
42
  </template>
44
43
  )
45
44
  }
@@ -4,6 +4,12 @@
4
4
  // the accent-colored prop name (with a `?` suffix when optional) and its type;
5
5
  // expanded, the row lifts into a card showing the description and a Type/Default
6
6
  // detail grid. Uses native <details>/<summary> so the core stays React-free.
7
+ import { resolveIcon } from "../../theme/icons.ts";
8
+
9
+ // The row disclosure caret; resolved from Lucide at build time and injected into
10
+ // the summary's `<svg>` (which supplies size and the rotate-on-open transition).
11
+ const chevronDown = resolveIcon("chevron-down")?.body ?? "";
12
+
7
13
  interface TypeEntry {
8
14
  default?: string;
9
15
  description?: string;
@@ -37,19 +43,19 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
37
43
  {entries.map(([name, info]) => (
38
44
  <details class="group/tt border-border border-t [[open]+&]:border-t-transparent open:rounded-blume open:border-transparent open:bg-background open:shadow-sm open:ring-1 open:ring-border">
39
45
  <summary class={`${columns} cursor-pointer list-none px-3 py-2.5 [&::-webkit-details-marker]:hidden`}>
40
- <code class="min-w-0 break-words font-mono text-accent text-xs">
46
+ <code class="break-words font-mono text-accent text-xs">
41
47
  {name}
42
48
  {info.required ? "" : "?"}
43
49
  </code>
44
50
  {info.typeDescriptionLink ? (
45
51
  <a
46
- class="min-w-0 break-words font-mono text-foreground text-xs underline-offset-2 hover:underline"
52
+ class="break-words font-mono text-foreground text-xs underline-offset-2 hover:underline"
47
53
  href={info.typeDescriptionLink}
48
54
  >
49
55
  {info.type}
50
56
  </a>
51
57
  ) : (
52
- <code class="min-w-0 break-words font-mono text-foreground text-xs">
58
+ <code class="break-words font-mono text-foreground text-xs">
53
59
  {info.type}
54
60
  </code>
55
61
  )}
@@ -64,9 +70,8 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
64
70
  stroke-width="2"
65
71
  viewBox="0 0 24 24"
66
72
  width="16"
67
- >
68
- <path d="m6 9 6 6 6-6" />
69
- </svg>
73
+ set:html={chevronDown}
74
+ />
70
75
  </summary>
71
76
 
72
77
  <div class="border-border border-t px-3 py-3 text-xs">
@@ -81,7 +86,7 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
81
86
  <div class="space-y-1.5">
82
87
  <div class={columns}>
83
88
  <span class="text-muted-foreground">Type</span>
84
- <code class="min-w-0 break-words font-mono text-foreground">
89
+ <code class="break-words font-mono text-foreground">
85
90
  {info.type}
86
91
  </code>
87
92
  <span aria-hidden="true" class="size-3.5" />
@@ -89,7 +94,7 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
89
94
  {info.default && (
90
95
  <div class={columns}>
91
96
  <span class="text-muted-foreground">Default</span>
92
- <code class="min-w-0 break-words font-mono text-foreground">
97
+ <code class="break-words font-mono text-foreground">
93
98
  {info.default}
94
99
  </code>
95
100
  <span aria-hidden="true" class="size-3.5" />
@@ -60,7 +60,7 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
60
60
  )
61
61
  }
62
62
  </header>
63
- <div class="min-w-0 text-muted-foreground text-sm leading-6 [&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_h2]:mt-0! [&_h2]:border-t-0! [&_h2]:pt-0! [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:font-semibold [&_h3]:text-foreground [&_img]:rounded-blume [&_li]:my-1 [&_p:first-child]:mt-0! [&_p:last-child]:mb-0!">
63
+ <div class="text-muted-foreground text-sm leading-6 [&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_h2]:mt-0! [&_h2]:border-t-0! [&_h2]:pt-0! [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:font-semibold [&_h3]:text-foreground [&_img]:rounded-blume [&_li]:my-1 [&_p:first-child]:mt-0! [&_p:last-child]:mb-0!">
64
64
  <slot />
65
65
  </div>
66
66
  </article>
@@ -1,12 +1,76 @@
1
1
  ---
2
2
  import type { UIStrings } from "../../core/i18n-ui.ts";
3
+ import { resolveIcon } from "../../theme/icons.ts";
3
4
  import AskAI from "./ask-ai.tsx";
4
5
 
6
+ interface Suggestion {
7
+ icon?: string;
8
+ label: string;
9
+ }
10
+
5
11
  interface Props {
6
12
  strings?: UIStrings["ask"];
13
+ suggestions?: Suggestion[];
7
14
  }
8
15
 
9
- const { strings } = Astro.props;
16
+ const { strings, suggestions = [] } = Astro.props;
17
+
18
+ // Icons resolve to inline SVG here (server-only module), so the client island
19
+ // gets ready-to-render markup rather than a name it can't resolve. Bare Lucide
20
+ // bodies carry no styling, so wrap them in a stroke-styled root like Icon.astro.
21
+ const svgFor = (name: string | undefined): string | null => {
22
+ const resolved = resolveIcon(name ?? "sparkles") ?? resolveIcon("sparkles");
23
+ return resolved
24
+ ? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${resolved.body}</svg>`
25
+ : null;
26
+ };
27
+
28
+ const items = suggestions.map((suggestion) => ({
29
+ icon: svgFor(suggestion.icon),
30
+ label: suggestion.label,
31
+ }));
32
+
33
+ // The panel's own chrome glyphs resolve here too, so the client island carries
34
+ // no icon data. Each value is the bare Lucide body; the island's <Glyph> wraps
35
+ // it in a sized, stroke-styled <svg>. Keyed by role → Lucide icon name.
36
+ const bodyFor = (name: string): string => resolveIcon(name)?.body ?? "";
37
+ const icons = {
38
+ arrowUp: bodyFor("arrow-up"),
39
+ chat: bodyFor("message-circle"),
40
+ clear: bodyFor("trash-2"),
41
+ close: bodyFor("chevrons-right"),
42
+ copy: bodyFor("copy"),
43
+ };
10
44
  ---
11
45
 
12
- <AskAI client:load strings={strings} />
46
+ <AskAI client:load icons={icons} strings={strings} suggestions={items} />
47
+
48
+ <style is:global>
49
+ :root {
50
+ --blume-ask-width: min(30rem, 100vw);
51
+ }
52
+
53
+ /* Desktop docks the panel and shrinks the page to fit, like vercel.com/docs.
54
+ On smaller screens the panel is a full-width overlay (no push). */
55
+ @media (min-width: 1024px) {
56
+ body {
57
+ transition: padding-inline-end 0.2s ease-out;
58
+ }
59
+
60
+ body[data-blume-ask="open"] {
61
+ padding-inline-end: var(--blume-ask-width);
62
+ }
63
+ }
64
+
65
+ /* The table of contents only shows at xl; reclaim its column for the article
66
+ while the panel is open so the shrunken content still has room to breathe. */
67
+ @media (min-width: 1280px) {
68
+ body[data-blume-ask="open"] [data-blume-toc] {
69
+ display: none;
70
+ }
71
+
72
+ body[data-blume-ask="open"] [data-blume-doc-grid] {
73
+ grid-template-columns: 17.5rem minmax(0, 1fr);
74
+ }
75
+ }
76
+ </style>
@@ -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