blume 0.5.2 → 0.5.4

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 (137) hide show
  1. package/dist/cli/index.js +2235 -5942
  2. package/dist/cli/index.js.map +36 -65
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +2 -2
  5. package/dist/types/core/schema.d.ts +58 -453
  6. package/dist/types/core/types.d.ts +1 -7
  7. package/docs/advanced/meta.ts +1 -8
  8. package/docs/advanced/skills.mdx +28 -0
  9. package/docs/configuration/ai.mdx +3 -1
  10. package/docs/configuration/index.mdx +13 -17
  11. package/docs/configuration/theming.mdx +3 -11
  12. package/docs/content/components.mdx +2 -53
  13. package/docs/content/i18n.mdx +1 -5
  14. package/docs/content/sources.mdx +1 -1
  15. package/docs/content/syntax.mdx +1 -1
  16. package/docs/index.mdx +0 -1
  17. package/docs/reference/cli.mdx +12 -13
  18. package/package.json +4 -6
  19. package/skills/blume/SKILL.md +71 -0
  20. package/skills/blume-update-docs/SKILL.md +52 -0
  21. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  22. package/src/ai/mcp/discovery.ts +3 -1
  23. package/src/ai/mcp/server.ts +3 -1
  24. package/src/astro/component-slots.ts +10 -2
  25. package/src/astro/generate.ts +15 -12
  26. package/src/astro/integration.ts +0 -21
  27. package/src/astro/templates.ts +62 -37
  28. package/src/cli/coalesce.ts +43 -0
  29. package/src/cli/commands/dev.ts +30 -22
  30. package/src/cli/commands/validate.ts +0 -2
  31. package/src/cli/dev-lock.ts +4 -2
  32. package/src/cli/index.ts +0 -2
  33. package/src/components/BlumePage.astro +0 -6
  34. package/src/components/Icon.astro +1 -12
  35. package/src/components/content/AccordionItem.astro +3 -6
  36. package/src/components/content/Badge.astro +1 -3
  37. package/src/components/content/Callout.astro +3 -9
  38. package/src/components/content/Card.astro +2 -3
  39. package/src/components/content/ColorItem.astro +8 -5
  40. package/src/components/content/Column.astro +1 -1
  41. package/src/components/content/Prompt.astro +8 -4
  42. package/src/components/content/Step.astro +3 -4
  43. package/src/components/content/Tab.astro +2 -3
  44. package/src/components/content/Tabs.astro +13 -2
  45. package/src/components/content/TypeTable.astro +5 -5
  46. package/src/components/content/Update.astro +1 -1
  47. package/src/components/content/mermaid-element.ts +20 -2
  48. package/src/components/islands/ask-ai.tsx +4 -8
  49. package/src/components/islands/base-path.ts +30 -0
  50. package/src/components/islands/hooks.ts +12 -8
  51. package/src/components/layout/Header.astro +26 -3
  52. package/src/components/layout/Logo.astro +5 -1
  53. package/src/components/layout/NavSelector.astro +1 -1
  54. package/src/components/layout/NavTree.astro +14 -14
  55. package/src/components/layout/PageActions.astro +17 -11
  56. package/src/components/layout/PageLayout.astro +42 -0
  57. package/src/components/layout/ReferenceLayout.astro +1 -0
  58. package/src/components/layout/RootLayout.astro +31 -2
  59. package/src/components/layout/Search.astro +9 -6
  60. package/src/components/layout/nav-utils.ts +9 -4
  61. package/src/components/layout/search/types.ts +16 -5
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/components/openapi/ParametersTable.astro +1 -1
  65. package/src/components/openapi/SchemaProperty.astro +1 -1
  66. package/src/components/openapi/SchemaTable.astro +3 -3
  67. package/src/components/openapi/helpers.ts +17 -8
  68. package/src/components/openapi/snippets.ts +17 -4
  69. package/src/core/builtin-tags.ts +1 -3
  70. package/src/core/config.ts +19 -33
  71. package/src/core/data.ts +2 -2
  72. package/src/core/graph.ts +7 -5
  73. package/src/core/links.ts +5 -19
  74. package/src/core/meta.ts +1 -1
  75. package/src/core/navigation.ts +5 -6
  76. package/src/core/project-graph.ts +0 -5
  77. package/src/core/schema.ts +13 -90
  78. package/src/core/sources/filesystem.ts +19 -1
  79. package/src/core/sources/mdx-remote.ts +20 -4
  80. package/src/core/sources/normalize.ts +28 -6
  81. package/src/core/sources/resolve.ts +0 -13
  82. package/src/core/sources/watch.ts +45 -0
  83. package/src/core/types.ts +1 -8
  84. package/src/markdown/code-title.ts +6 -3
  85. package/src/markdown/package-install.ts +3 -1
  86. package/src/og/card.ts +16 -4
  87. package/src/openapi/parse.ts +197 -14
  88. package/src/openapi/render-mdx.ts +54 -11
  89. package/src/openapi/source.ts +19 -2
  90. package/src/search/sync/orama-cloud.ts +2 -0
  91. package/src/search/sync/typesense.ts +4 -0
  92. package/src/theme/entry.ts +45 -17
  93. package/src/theme/icons.ts +22 -104
  94. package/src/theme/palette.ts +35 -47
  95. package/src/theme/twoslash.ts +6 -1
  96. package/dist/types/core/bridge.d.ts +0 -24
  97. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  98. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  99. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  100. package/docs/advanced/bridge.mdx +0 -76
  101. package/docs/advanced/migrate.mdx +0 -123
  102. package/src/astro/static-assets.ts +0 -117
  103. package/src/cli/commands/migrate.ts +0 -39
  104. package/src/components/content/ApiField.astro +0 -75
  105. package/src/components/content/ParamField.astro +0 -39
  106. package/src/components/content/RequestField.astro +0 -23
  107. package/src/components/content/ResponseField.astro +0 -23
  108. package/src/components/content/Warning.astro +0 -9
  109. package/src/core/assets.ts +0 -31
  110. package/src/core/bridge.ts +0 -102
  111. package/src/core/sources/mintlify.ts +0 -204
  112. package/src/migrate/fumadocs/config.ts +0 -155
  113. package/src/migrate/fumadocs/content.ts +0 -378
  114. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  115. package/src/migrate/fumadocs/groups.ts +0 -237
  116. package/src/migrate/fumadocs/index.ts +0 -340
  117. package/src/migrate/fumadocs/meta.ts +0 -244
  118. package/src/migrate/migrate.ts +0 -53
  119. package/src/migrate/mintlify/assets.ts +0 -46
  120. package/src/migrate/mintlify/config.ts +0 -958
  121. package/src/migrate/mintlify/content.ts +0 -120
  122. package/src/migrate/mintlify/frontmatter.ts +0 -126
  123. package/src/migrate/mintlify/i18n.ts +0 -51
  124. package/src/migrate/mintlify/icons.ts +0 -128
  125. package/src/migrate/mintlify/index.ts +0 -372
  126. package/src/migrate/mintlify/snippets.ts +0 -306
  127. package/src/migrate/mintlify/transform.ts +0 -82
  128. package/src/migrate/nextra/content.ts +0 -46
  129. package/src/migrate/nextra/frontmatter.ts +0 -40
  130. package/src/migrate/nextra/index.ts +0 -374
  131. package/src/migrate/nextra/meta.ts +0 -266
  132. package/src/migrate/shared.ts +0 -728
  133. package/src/migrate/starlight/config.ts +0 -455
  134. package/src/migrate/starlight/content.ts +0 -78
  135. package/src/migrate/starlight/frontmatter.ts +0 -111
  136. package/src/migrate/starlight/i18n.ts +0 -54
  137. package/src/migrate/starlight/index.ts +0 -131
@@ -21,7 +21,7 @@ const displayValue =
21
21
 
22
22
  <button
23
23
  aria-label={`Copy ${name} color ${displayValue}`}
24
- class="group flex w-full min-w-0 cursor-pointer flex-col gap-2.5 text-center focus:outline-none"
24
+ class="group flex w-full cursor-pointer flex-col gap-2.5 text-center focus:outline-none"
25
25
  data-blume-color-copy={displayValue}
26
26
  style={`--blume-color-light:${lightValue};--blume-color-dark:${darkValue};`}
27
27
  type="button"
@@ -32,7 +32,7 @@ const displayValue =
32
32
  style="background:var(--blume-color-light)"
33
33
  >
34
34
  </span>
35
- <span class="block min-w-0 leading-tight">
35
+ <span class="block leading-tight">
36
36
  <span class="block truncate font-semibold text-foreground text-sm">
37
37
  {name}
38
38
  </span>
@@ -46,7 +46,8 @@ const displayValue =
46
46
  </button>
47
47
 
48
48
  <style>
49
- :global(.dark) [data-blume-color-swatch] {
49
+ /* Dark mode is data-theme="dark" on <html> (see theme/entry.ts), not a class. */
50
+ :global([data-theme="dark"]) [data-blume-color-swatch] {
50
51
  background: var(--blume-color-dark, var(--blume-color-light));
51
52
  }
52
53
  </style>
@@ -77,10 +78,12 @@ const displayValue =
77
78
  return;
78
79
  }
79
80
 
80
- const original = status.textContent;
81
+ // Remember the real value once — capturing at click time would capture
82
+ // "Copied" on a double-click and stick until reload.
83
+ status.dataset.blumeLabel ??= status.textContent ?? "";
81
84
  status.textContent = "Copied";
82
85
  window.setTimeout(() => {
83
- status.textContent = original;
86
+ status.textContent = status.dataset.blumeLabel ?? "";
84
87
  }, 1500);
85
88
  });
86
89
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  ---
3
3
 
4
- <div class="min-w-0">
4
+ <div>
5
5
  <slot />
6
6
  </div>
@@ -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
  />
@@ -110,14 +110,18 @@ const secondaryButton =
110
110
  return;
111
111
  }
112
112
 
113
- const label = copy.querySelector("[data-blume-prompt-copy-label]");
113
+ const label = copy.querySelector<HTMLElement>(
114
+ "[data-blume-prompt-copy-label]"
115
+ );
114
116
  if (!label) {
115
117
  return;
116
118
  }
117
- const previous = label.textContent ?? "Copy prompt";
119
+ // Remember the real label once capturing at click time would
120
+ // capture "Copied" on a double-click and stick until reload.
121
+ label.dataset.blumeLabel ??= label.textContent ?? "Copy prompt";
118
122
  label.textContent = "Copied";
119
123
  setTimeout(() => {
120
- label.textContent = previous;
124
+ label.textContent = label.dataset.blumeLabel ?? "Copy prompt";
121
125
  }, 1500);
122
126
  });
123
127
  }
@@ -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
  }
@@ -3,7 +3,7 @@ interface Props {
3
3
  borderBottom?: boolean;
4
4
  defaultTabIndex?: number;
5
5
  dropdown?: boolean;
6
- hash?: boolean;
6
+ hash?: boolean | "false" | "true";
7
7
  sync?: boolean;
8
8
  }
9
9
 
@@ -14,6 +14,10 @@ const {
14
14
  hash = true,
15
15
  sync = true,
16
16
  } = Astro.props;
17
+
18
+ // MDX string attributes (`hash="false"` from generated markup) must read as
19
+ // their boolean meaning — the string "false" is truthy.
20
+ const hashEnabled = String(hash) !== "false";
17
21
  ---
18
22
 
19
23
  <blume-tabs
@@ -21,7 +25,7 @@ const {
21
25
  data-border-bottom={borderBottom ? "true" : "false"}
22
26
  data-default-tab-index={defaultTabIndex}
23
27
  data-dropdown={dropdown ? "true" : "false"}
24
- data-hash={hash ? "true" : "false"}
28
+ data-hash={hashEnabled ? "true" : "false"}
25
29
  data-sync={sync ? "true" : "false"}
26
30
  >
27
31
  <div class:list={[dropdown ? "" : "overflow-x-auto"]}>
@@ -100,6 +104,13 @@ const {
100
104
  return;
101
105
  }
102
106
  if (this.dataset.blumeTabsReady === "true") {
107
+ // The built DOM survives a move; the listeners removed by
108
+ // disconnectedCallback don't — re-attach them (addEventListener with
109
+ // the same reference is idempotent).
110
+ if (this.#syncEnabled()) {
111
+ document.addEventListener(SYNC_EVENT, this.#sync);
112
+ }
113
+ window.addEventListener("hashchange", this.#hashChange);
103
114
  return;
104
115
  }
105
116
  this.dataset.blumeTabsReady = "true";
@@ -37,19 +37,19 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
37
37
  {entries.map(([name, info]) => (
38
38
  <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
39
  <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">
40
+ <code class="break-words font-mono text-accent text-xs">
41
41
  {name}
42
42
  {info.required ? "" : "?"}
43
43
  </code>
44
44
  {info.typeDescriptionLink ? (
45
45
  <a
46
- class="min-w-0 break-words font-mono text-foreground text-xs underline-offset-2 hover:underline"
46
+ class="break-words font-mono text-foreground text-xs underline-offset-2 hover:underline"
47
47
  href={info.typeDescriptionLink}
48
48
  >
49
49
  {info.type}
50
50
  </a>
51
51
  ) : (
52
- <code class="min-w-0 break-words font-mono text-foreground text-xs">
52
+ <code class="break-words font-mono text-foreground text-xs">
53
53
  {info.type}
54
54
  </code>
55
55
  )}
@@ -81,7 +81,7 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
81
81
  <div class="space-y-1.5">
82
82
  <div class={columns}>
83
83
  <span class="text-muted-foreground">Type</span>
84
- <code class="min-w-0 break-words font-mono text-foreground">
84
+ <code class="break-words font-mono text-foreground">
85
85
  {info.type}
86
86
  </code>
87
87
  <span aria-hidden="true" class="size-3.5" />
@@ -89,7 +89,7 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
89
89
  {info.default && (
90
90
  <div class={columns}>
91
91
  <span class="text-muted-foreground">Default</span>
92
- <code class="min-w-0 break-words font-mono text-foreground">
92
+ <code class="break-words font-mono text-foreground">
93
93
  {info.default}
94
94
  </code>
95
95
  <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>
@@ -24,6 +24,9 @@ const prefersDark = () => document.documentElement.dataset.theme === "dark";
24
24
  let counter = 0;
25
25
 
26
26
  class BlumeMermaid extends HTMLElement {
27
+ #observer: MutationObserver | null = null;
28
+ #renderToken = 0;
29
+
27
30
  connectedCallback() {
28
31
  const source = this.dataset.source ?? "";
29
32
  if (!source.trim()) {
@@ -35,6 +38,8 @@ class BlumeMermaid extends HTMLElement {
35
38
  this.replaceChildren(output);
36
39
 
37
40
  const render = async () => {
41
+ this.#renderToken += 1;
42
+ const token = this.#renderToken;
38
43
  const mermaid = await loadMermaid();
39
44
  mermaid.initialize({
40
45
  securityLevel: "strict",
@@ -47,7 +52,11 @@ class BlumeMermaid extends HTMLElement {
47
52
  `blume-mermaid-${counter}`,
48
53
  source
49
54
  );
50
- output.innerHTML = svg;
55
+ // A newer render (rapid theme toggles) superseded this one — dropping
56
+ // the stale result keeps the diagram in the latest theme.
57
+ if (token === this.#renderToken) {
58
+ output.innerHTML = svg;
59
+ }
51
60
  } catch {
52
61
  output.textContent = "Could not render this diagram.";
53
62
  }
@@ -57,10 +66,19 @@ class BlumeMermaid extends HTMLElement {
57
66
  render();
58
67
 
59
68
  // Re-render on color-theme changes so the diagram tracks light and dark.
60
- new MutationObserver(() => render()).observe(document.documentElement, {
69
+ // One observer per connection, disconnected on removal — otherwise every
70
+ // DOM move stacks another observer that renders into detached DOM forever.
71
+ this.#observer?.disconnect();
72
+ this.#observer = new MutationObserver(() => render());
73
+ this.#observer.observe(document.documentElement, {
61
74
  attributeFilter: ["data-theme"],
62
75
  });
63
76
  }
77
+
78
+ disconnectedCallback() {
79
+ this.#observer?.disconnect();
80
+ this.#observer = null;
81
+ }
64
82
  }
65
83
 
66
84
  if (!customElements.get("blume-mermaid")) {
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import type { FormEvent } from "react";
3
3
 
4
4
  import type { UIStrings } from "../../core/i18n-ui.ts";
5
+ import { joinBase, stripBase } from "./base-path.ts";
5
6
 
6
7
  interface ChatMessage {
7
8
  id: number;
@@ -27,16 +28,11 @@ const nextId = (): number => {
27
28
 
28
29
  // The endpoint and page path both honor the deployment `base` so grounding works
29
30
  // under a non-root base path (the server matches base-less document routes).
30
- const ASK_ENDPOINT = `${import.meta.env.BASE_URL}api/ask`.replace("//", "/");
31
+ const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
31
32
 
32
33
  /** The current route with the deployment base stripped, for page-context lookup. */
33
- const currentPath = (): string => {
34
- const base = import.meta.env.BASE_URL;
35
- const path = window.location.pathname;
36
- return base.length > 1 && path.startsWith(base)
37
- ? `/${path.slice(base.length)}`
38
- : path;
39
- };
34
+ const currentPath = (): string =>
35
+ stripBase(import.meta.env.BASE_URL, window.location.pathname);
40
36
 
41
37
  const BUTTON_CLASS =
42
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";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Base-path helpers for client islands. Astro's default `trailingSlash:
3
+ * "ignore"` passes `deployment.base` through as-is, so `BASE_URL` may arrive
4
+ * with or without a trailing slash (`/docs` or `/docs/`); every consumer must
5
+ * treat both forms the same or endpoints/grounding break under a base path.
6
+ */
7
+
8
+ /** The base with a guaranteed trailing slash (`/docs` -> `/docs/`). */
9
+ export const withTrailingSlash = (base: string): string =>
10
+ base.endsWith("/") ? base : `${base}/`;
11
+
12
+ /** Join a base-relative path (`api/ask`) onto the deployment base. */
13
+ export const joinBase = (base: string, path: string): string =>
14
+ `${withTrailingSlash(base)}${path}`;
15
+
16
+ /**
17
+ * A pathname with the deployment base stripped (`/docs/guide` -> `/guide`),
18
+ * for page-context lookups against base-less document routes.
19
+ */
20
+ export const stripBase = (base: string, pathname: string): string => {
21
+ const slashed = withTrailingSlash(base);
22
+ if (slashed === "/") {
23
+ return pathname;
24
+ }
25
+ if (pathname.startsWith(slashed)) {
26
+ return `/${pathname.slice(slashed.length)}`;
27
+ }
28
+ // The bare base itself ("/docs") is the base-less root.
29
+ return `${pathname}/` === slashed ? "/" : pathname;
30
+ };
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import type { BlumeClientData } from "../../core/data.ts";
4
4
  import type { SearchFn, SearchResult } from "../layout/search/types.ts";
5
+ import { joinBase, stripBase } from "./base-path.ts";
5
6
 
6
7
  /**
7
8
  * React hooks for Blume islands.
@@ -117,16 +118,11 @@ export interface UseAskAI {
117
118
  reset: () => void;
118
119
  }
119
120
 
120
- const ASK_ENDPOINT = `${import.meta.env.BASE_URL}api/ask`.replace("//", "/");
121
+ const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
121
122
 
122
123
  /** The current route with the deployment base stripped, for page grounding. */
123
- const currentPath = (): string => {
124
- const base = import.meta.env.BASE_URL;
125
- const path = window.location.pathname;
126
- return base.length > 1 && path.startsWith(base)
127
- ? `/${path.slice(base.length)}`
128
- : path;
129
- };
124
+ const currentPath = (): string =>
125
+ stripBase(import.meta.env.BASE_URL, window.location.pathname);
130
126
 
131
127
  /**
132
128
  * Stream answers from the Ask AI endpoint. Mirrors the built-in Ask AI island so
@@ -158,6 +154,14 @@ export const useAskAI = (): UseAskAI => {
158
154
  headers: { "content-type": "application/json" },
159
155
  method: "POST",
160
156
  });
157
+ if (!response.ok) {
158
+ // An error body (JSON, HTML error page) must not stream in as the
159
+ // assistant's answer.
160
+ assistant.content =
161
+ "Something went wrong answering that. Please try again.";
162
+ setMessages([...history, { ...assistant }]);
163
+ return;
164
+ }
161
165
  const reader = response.body?.getReader();
162
166
  const decoder = new TextDecoder();
163
167
  if (reader) {
@@ -18,6 +18,7 @@ interface Props {
18
18
  dark?: string;
19
19
  alt: string;
20
20
  href: string;
21
+ text?: string;
21
22
  } | null;
22
23
  navigation: Navigation;
23
24
  route: string;
@@ -57,6 +58,19 @@ const {
57
58
  const LogoSlot = resolveSlot(layout.Logo, Logo);
58
59
  const SearchSlot = resolveSlot(layout.Search, Search);
59
60
 
61
+ // The mobile nav-toggle opens a drawer of navigation. On docs pages that drawer
62
+ // is the sidebar tree (`hasSidebar`); on chrome-only pages (a landing page via
63
+ // PageLayout) it's a tabs-only drawer the layout renders — so the button is also
64
+ // needed whenever there are tabs to reveal.
65
+ const showNavToggle = hasSidebar || navigation.tabs.length > 0;
66
+ // Where the header's inline tab bar appears. With a sidebar it shares the `md`
67
+ // breakpoint with the docs drawer; without one, the tabs-only drawer is the sole
68
+ // mobile nav below `lg`, so the inline tabs wait until `lg` to avoid duplicating
69
+ // the drawer's links between `md` and `lg`.
70
+ const tabsNavClass = hasSidebar
71
+ ? "hidden gap-1 md:flex"
72
+ : "hidden gap-1 lg:flex";
73
+
60
74
  const iconButton =
61
75
  "inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
62
76
 
@@ -68,14 +82,23 @@ const iconButton =
68
82
  // styles fade while the non-animated ones snap, so we disable all transitions
69
83
  // for the swap (next-themes' disableTransitionOnChange trick), force a
70
84
  // synchronous style flush, then restore them on the next tick.
71
- const clickScript = `document.addEventListener("click",(e)=>{const t=e.target.closest("[data-blume-theme-toggle]");if(t){const d=document.documentElement;const next=d.dataset.theme==="dark"?"light":"dark";const s=document.createElement("style");s.appendChild(document.createTextNode("*,*::before,*::after{transition:none!important}"));document.head.appendChild(s);d.dataset.theme=next;localStorage.setItem("blume-theme",next);window.getComputedStyle(d).opacity;setTimeout(()=>document.head.removeChild(s),1);}const n=e.target.closest("[data-blume-nav-toggle]");if(n){document.documentElement.toggleAttribute("data-blume-nav-open");}const b=e.target.closest("[data-blume-banner-dismiss]");if(b){const bar=b.closest("[data-blume-banner]");const k=bar&&bar.getAttribute("data-banner-key");if(k){localStorage.setItem("blume-banner:"+k,"1");}document.documentElement.setAttribute("data-blume-banner-hidden","");}});`;
85
+ //
86
+ // The mobile drawer sits below the sticky header, but the banner above the
87
+ // header has no fixed height (its text wraps), so the drawer's top offset can't
88
+ // be static: on open we measure the header's on-screen bottom edge into
89
+ // `--blume-drawer-top` (read by the drawer's `top`/`height`), lock page scroll
90
+ // so the offset can't drift while open, and re-measure when a banner dismiss or
91
+ // a resize/rotation moves the header. Crossing into the desktop breakpoint
92
+ // (64rem, Tailwind `lg`) closes the drawer so the scroll lock can't outlive it.
93
+ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-blume-header]");if(h){document.documentElement.style.setProperty("--blume-drawer-top",h.getBoundingClientRect().bottom+"px");}};document.addEventListener("click",(e)=>{const t=e.target.closest("[data-blume-theme-toggle]");if(t){const d=document.documentElement;const next=d.dataset.theme==="dark"?"light":"dark";const s=document.createElement("style");s.appendChild(document.createTextNode("*,*::before,*::after{transition:none!important}"));document.head.appendChild(s);d.dataset.theme=next;localStorage.setItem("blume-theme",next);window.getComputedStyle(d).opacity;setTimeout(()=>document.head.removeChild(s),1);}const n=e.target.closest("[data-blume-nav-toggle]");if(n){const d=document.documentElement;const open=d.toggleAttribute("data-blume-nav-open");if(open){dr();}d.style.overflow=open?"hidden":"";}const b=e.target.closest("[data-blume-banner-dismiss]");if(b){const bar=b.closest("[data-blume-banner]");const k=bar&&bar.getAttribute("data-banner-key");if(k){localStorage.setItem("blume-banner:"+k,"1");}document.documentElement.setAttribute("data-blume-banner-hidden","");requestAnimationFrame(dr);}});addEventListener("resize",()=>{const d=document.documentElement;if(!d.hasAttribute("data-blume-nav-open")){return;}if(matchMedia("(min-width:64rem)").matches){d.removeAttribute("data-blume-nav-open");d.style.overflow="";}else{dr();}});})();`;
72
94
  ---
73
95
 
74
96
  <header
75
97
  class="sticky top-0 z-40 flex h-16 items-center gap-3 bg-background/90 px-4 backdrop-blur md:px-6"
98
+ data-blume-header
76
99
  >
77
100
  {
78
- hasSidebar && (
101
+ showNavToggle && (
79
102
  <button
80
103
  aria-label="Toggle navigation"
81
104
  class={`${iconButton} lg:hidden`}
@@ -98,7 +121,7 @@ const clickScript = `document.addEventListener("click",(e)=>{const t=e.target.cl
98
121
  }
99
122
  {
100
123
  navigation.tabs.length > 0 && (
101
- <nav aria-label="Sections" class="hidden gap-1 md:flex">
124
+ <nav aria-label="Sections" class={tabsNavClass}>
102
125
  {navigation.tabs.map((tab) => (
103
126
  <a
104
127
  aria-current={
@@ -19,6 +19,10 @@ const logoLight = logo?.light;
19
19
  const logoDark = logo?.dark ?? logo?.light;
20
20
  const logoAlt = logo?.alt ?? "";
21
21
  const brandHref = logo?.href ?? "/";
22
+ // Wordmark beside the mark: the configured `logo.text`, or the site title when
23
+ // it's omitted. An explicit `""` hides it (an image-only logo whose mark already
24
+ // carries the wordmark).
25
+ const brandText = logo?.text ?? site.title;
22
26
  ---
23
27
 
24
28
  <a
@@ -46,5 +50,5 @@ const brandHref = logo?.href ?? "/";
46
50
  </>
47
51
  ))
48
52
  }
49
- {site.title}
53
+ {brandText && <span>{brandText}</span>}
50
54
  </a>
@@ -1,5 +1,5 @@
1
1
  ---
2
- // A top-level navigation selector (Mintlify-style): a dropdown that switches
2
+ // A top-level navigation selector: a dropdown that switches
3
3
  // between partitions of the site — a product, a version, or any grouped set of
4
4
  // destinations (`navigation.selectors` in the config). Zero-JS, built on
5
5
  // <details>/<summary> like the language switcher.
@@ -129,13 +129,13 @@ const initialId =
129
129
  {panel.route ? (
130
130
  <a
131
131
  aria-current={panel.route === currentRoute ? "page" : undefined}
132
- class="min-w-0 flex-1 truncate font-semibold text-foreground text-sm hover:underline"
132
+ class="flex-1 truncate font-semibold text-foreground text-sm hover:underline"
133
133
  href={panel.route}
134
134
  >
135
135
  {panel.label}
136
136
  </a>
137
137
  ) : (
138
- <span class="min-w-0 flex-1 truncate font-semibold text-foreground text-sm">
138
+ <span class="flex-1 truncate font-semibold text-foreground text-sm">
139
139
  {panel.label}
140
140
  </span>
141
141
  )}
@@ -161,7 +161,7 @@ const initialId =
161
161
  class="block rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:font-medium aria-[current=page]:text-foreground"
162
162
  href={item.route}
163
163
  >
164
- <span class="flex min-w-0 items-center gap-2">
164
+ <span class="flex items-center gap-2">
165
165
  {item.icon && (
166
166
  <Icon
167
167
  class="shrink-0 text-muted-foreground"
@@ -169,7 +169,7 @@ const initialId =
169
169
  size={14}
170
170
  />
171
171
  )}
172
- <span class="min-w-0 flex-1 truncate">{item.label}</span>
172
+ <span class="flex-1 truncate">{item.label}</span>
173
173
  {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
174
174
  {item.deprecated && (
175
175
  <span class={deprecatedClass}>deprecated</span>
@@ -192,7 +192,7 @@ const initialId =
192
192
  return (
193
193
  <li class={spacing}>
194
194
  <button
195
- class="flex w-full min-w-0 items-center gap-2 rounded-[0.65rem] px-2.5 py-1.5 text-left font-medium text-foreground text-sm transition-colors hover:bg-muted"
195
+ class="flex w-full items-center gap-2 rounded-[0.65rem] px-2.5 py-1.5 text-left font-medium text-foreground text-sm transition-colors hover:bg-muted"
196
196
  data-nav-to={id}
197
197
  type="button"
198
198
  >
@@ -203,7 +203,7 @@ const initialId =
203
203
  size={14}
204
204
  />
205
205
  )}
206
- <span class="min-w-0 flex-1 truncate">{item.label}</span>
206
+ <span class="flex-1 truncate">{item.label}</span>
207
207
  {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
208
208
  <Icon
209
209
  class="shrink-0 text-muted-foreground"
@@ -222,13 +222,13 @@ const initialId =
222
222
  return (
223
223
  <li class={spacing}>
224
224
  <details class="group" open={open}>
225
- <summary class="mb-1 flex cursor-pointer list-none items-center gap-1.5 rounded-[0.65rem] px-2.5 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-muted [&::-webkit-details-marker]:hidden">
225
+ <summary class="flex cursor-pointer list-none items-center gap-1.5 rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden">
226
226
  {item.route ? (
227
227
  <a
228
228
  aria-current={
229
229
  item.route === currentRoute ? "page" : undefined
230
230
  }
231
- class="-my-1 flex min-w-0 flex-1 items-center gap-1.5 rounded py-1 text-foreground transition-colors aria-[current=page]:font-semibold"
231
+ class="-my-1 flex flex-1 items-center gap-1.5 rounded py-1 transition-colors aria-[current=page]:text-foreground aria-[current=page]:font-semibold"
232
232
  href={item.route}
233
233
  >
234
234
  {item.icon && (
@@ -238,7 +238,7 @@ const initialId =
238
238
  size={14}
239
239
  />
240
240
  )}
241
- <span class="min-w-0 flex-1 truncate">{item.label}</span>
241
+ <span class="flex-1 truncate">{item.label}</span>
242
242
  {item.badge && (
243
243
  <span class={badgeClassFor(item.badge)}>{item.badge}</span>
244
244
  )}
@@ -252,7 +252,7 @@ const initialId =
252
252
  size={14}
253
253
  />
254
254
  )}
255
- <span class="min-w-0 flex-1 truncate">{item.label}</span>
255
+ <span class="flex-1 truncate">{item.label}</span>
256
256
  {item.badge && (
257
257
  <span class={badgeClassFor(item.badge)}>{item.badge}</span>
258
258
  )}
@@ -279,11 +279,11 @@ const initialId =
279
279
  // `flat` (default): a non-collapsible header with its items beneath.
280
280
  return (
281
281
  <li class={spacing}>
282
- <p class="mb-2 flex min-w-0 items-center gap-1.5 px-2.5 font-medium text-foreground text-sm">
282
+ <p class="mb-2 flex items-center gap-1.5 px-2.5 font-medium text-foreground text-sm">
283
283
  {item.route ? (
284
284
  <a
285
285
  aria-current={item.route === currentRoute ? "page" : undefined}
286
- class="-ml-1 flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
286
+ class="-ml-1 flex flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
287
287
  href={item.route}
288
288
  >
289
289
  {item.icon && (
@@ -293,7 +293,7 @@ const initialId =
293
293
  size={14}
294
294
  />
295
295
  )}
296
- <span class="min-w-0 truncate">{item.label}</span>
296
+ <span class="truncate">{item.label}</span>
297
297
  {item.badge && (
298
298
  <span class={`ml-auto ${badgeClassFor(item.badge)}`}>{item.badge}</span>
299
299
  )}
@@ -307,7 +307,7 @@ const initialId =
307
307
  size={14}
308
308
  />
309
309
  )}
310
- <span class="min-w-0 truncate">{item.label}</span>
310
+ <span class="truncate">{item.label}</span>
311
311
  {item.badge && (
312
312
  <span class={`ml-auto ${badgeClassFor(item.badge)}`}>{item.badge}</span>
313
313
  )}
@@ -311,7 +311,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
311
311
  .replace(/[^a-z0-9]+/gu, "-")
312
312
  .replace(/^-+|-+$/gu, "") || "docs";
313
313
 
314
- const cursorConfig = btoa(JSON.stringify({ url: mcpUrl }));
314
+ // Base64 output can contain `+`/`=`, which a query string mangles
315
+ // (`+` decodes as a space) — it must be URL-encoded like any other value.
316
+ const cursorConfig = encodeURIComponent(btoa(JSON.stringify({ url: mcpUrl })));
315
317
  root
316
318
  .querySelector("[data-mcp-cursor]")
317
319
  ?.setAttribute(
@@ -325,20 +327,22 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
325
327
  `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: id, type: "http", url: mcpUrl }))}`
326
328
  );
327
329
 
328
- const flash = (el: Element | null, text: string, reset: string) => {
329
- if (el) {
330
- el.textContent = text;
331
- setTimeout(() => {
332
- el.textContent = reset;
333
- }, 1500);
330
+ const flash = (el: Element | null, text: string) => {
331
+ if (!(el instanceof HTMLElement)) {
332
+ return;
334
333
  }
334
+ // Remember the element's own (localized) label once — capturing it at
335
+ // click time would capture "Copied!" on a double-click and stick.
336
+ el.dataset.blumeLabel ??= el.textContent ?? "";
337
+ el.textContent = text;
338
+ setTimeout(() => {
339
+ el.textContent = el.dataset.blumeLabel ?? "";
340
+ }, 1500);
335
341
  };
336
342
  const copy = async (value: string, el: Element | null) => {
337
- // Restore the element's own label, so localized text round-trips.
338
- const reset = el?.textContent ?? "";
339
343
  try {
340
344
  await navigator.clipboard.writeText(value);
341
- flash(el, copiedLabel, reset);
345
+ flash(el, copiedLabel);
342
346
  } catch {
343
347
  // Clipboard unavailable; nothing to do.
344
348
  }
@@ -359,10 +363,12 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
359
363
  }
360
364
 
361
365
  const label = root.querySelector("[data-blume-copy-label]");
366
+ // Captured once — inside the handler a double-click would capture and
367
+ // permanently restore "Copied!".
368
+ const original = label?.textContent ?? "";
362
369
  root
363
370
  .querySelector("[data-blume-copy-page]")
364
371
  ?.addEventListener("click", async () => {
365
- const original = label?.textContent ?? "";
366
372
  try {
367
373
  const response = await fetch(md);
368
374
  await navigator.clipboard.writeText(await response.text());