blume 1.5.3 → 1.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 (194) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +2 -0
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +42 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +30 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. package/src/translate/work-list.ts +0 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Light-dismiss for the site's `<details>`-based dropdowns: the page actions
3
+ * (Export / Open in chat / Connect to MCP), the header language switcher and
4
+ * the nav selectors all render the same floating `<details>` + absolute panel,
5
+ * and a native `<details>` only closes when its own `<summary>` is clicked
6
+ * again, leaving the panel hanging over the page. Any dropdown that opts in
7
+ * with `data-blume-dropdown` gets the four legs of a real menu:
8
+ *
9
+ * - a pointer press outside the open panel closes it;
10
+ * - Escape closes it, restoring focus to the trigger when the keypress
11
+ * originated inside the panel;
12
+ * - keyboard focus leaving the panel closes it;
13
+ * - the window losing focus closes it — the only signal the parent document
14
+ * gets when a press lands inside an `<iframe>` embed, since pointer events
15
+ * in a child browsing context never propagate up.
16
+ *
17
+ * Every listener sits on `document`/`window` once per real page load and
18
+ * queries the DOM live, so client-router swaps (which rebuild the dropdown
19
+ * markup) need no re-init and never stack duplicate listeners. Per-component
20
+ * behavior — one-open-at-a-time within a group, viewport flipping — stays in
21
+ * the component that owns it.
22
+ */
23
+
24
+ const OPEN_DROPDOWN = "details[data-blume-dropdown][open]";
25
+
26
+ /**
27
+ * Every dropdown currently open. Each component group keeps itself to one
28
+ * open panel, but a page holds several groups (the header selectors and the
29
+ * page actions), and a browser that doesn't focus a `<summary>` on click lets
30
+ * a keyboard-opened panel join a pointer-opened one — so the dismissal rules
31
+ * apply to all of them, never just the first in DOM order.
32
+ */
33
+ const openDropdowns = (): HTMLDetailsElement[] => [
34
+ ...document.querySelectorAll<HTMLDetailsElement>(OPEN_DROPDOWN),
35
+ ];
36
+
37
+ /**
38
+ * Close `open`. `restoreFocus` moves focus back to its trigger, which a
39
+ * keyboard dismissal wants and a pointer or focus-driven one does not.
40
+ */
41
+ const dismiss = (open: HTMLDetailsElement, restoreFocus: boolean): void => {
42
+ open.open = false;
43
+ if (restoreFocus) {
44
+ open.querySelector<HTMLElement>("summary")?.focus();
45
+ }
46
+ };
47
+
48
+ const isInside = (
49
+ dropdown: HTMLDetailsElement,
50
+ target: EventTarget | null
51
+ ): boolean => target instanceof Node && dropdown.contains(target);
52
+
53
+ const onPointerDown = (event: PointerEvent): void => {
54
+ for (const open of openDropdowns()) {
55
+ if (!isInside(open, event.target)) {
56
+ dismiss(open, false);
57
+ }
58
+ }
59
+ };
60
+
61
+ const onKeyDown = (event: KeyboardEvent): void => {
62
+ // `isComposing`: an IME cancel arrives as Escape and isn't a dismissal.
63
+ if (event.key !== "Escape" || event.isComposing) {
64
+ return;
65
+ }
66
+ // An Escape aimed at a modal surface stacked on top (the search dialog
67
+ // traps focus inside itself) dismisses that surface only — the same guard
68
+ // the Ask panel applies. Everything outside the modal is inert, so a focus
69
+ // restore here could not land anyway.
70
+ if (event.target instanceof Element && event.target.closest("dialog")) {
71
+ return;
72
+ }
73
+ // Only a keypress that originated inside a panel gets its focus returned
74
+ // to that trigger; yanking focus from an unrelated control the user had
75
+ // moved on to would be a surprise.
76
+ for (const open of openDropdowns()) {
77
+ dismiss(open, isInside(open, event.target));
78
+ }
79
+ };
80
+
81
+ const onFocusOut = (event: FocusEvent): void => {
82
+ // Only focus *leaving a panel* counts: a move between two unrelated controls
83
+ // (a dialog handing focus back to its trigger, say) must not close a panel
84
+ // that never held focus. And a null `relatedTarget` means focus went
85
+ // nowhere focusable (a click on plain content, the panel being hidden, the
86
+ // window blurring) — the pointer and blur legs own those, and closing here
87
+ // would hide a panel item before its own click lands in browsers that
88
+ // don't focus buttons on press.
89
+ const { relatedTarget } = event;
90
+ if (!(relatedTarget instanceof Node)) {
91
+ return;
92
+ }
93
+ for (const open of openDropdowns()) {
94
+ if (isInside(open, event.target) && !open.contains(relatedTarget)) {
95
+ dismiss(open, false);
96
+ }
97
+ }
98
+ };
99
+
100
+ const onWindowBlur = (): void => {
101
+ for (const open of openDropdowns()) {
102
+ dismiss(open, false);
103
+ }
104
+ };
105
+
106
+ let installed = false;
107
+
108
+ /**
109
+ * Register the document/window listeners. Idempotent: every component that
110
+ * renders a dropdown calls this from its own script, and a page may render
111
+ * several of them.
112
+ */
113
+ export const installDropdownDismiss = (): void => {
114
+ if (installed) {
115
+ return;
116
+ }
117
+ installed = true;
118
+ document.addEventListener("pointerdown", onPointerDown);
119
+ document.addEventListener("keydown", onKeyDown);
120
+ document.addEventListener("focusout", onFocusOut);
121
+ window.addEventListener("blur", onWindowBlur);
122
+ };
@@ -2,7 +2,8 @@
2
2
  // Emits the optimized @font-face declarations + preload links for each
3
3
  // configured font (Astro's Fonts API). Renders nothing when no fonts are set;
4
4
  // the CSS variables match the astro.config `fonts:` entries. Preloads are
5
- // narrowed to the weights above-the-fold text renders in (see
5
+ // narrowed to the weights above-the-fold text renders in and, for providers
6
+ // that split faces by subset, to the subsets the site's locales need (see
6
7
  // `theme/fonts.ts`); a bare string entry — an older generated template — keeps
7
8
  // the previous preload-everything behavior.
8
9
  import { Font } from "astro:assets";
@@ -14,16 +15,22 @@ interface Props {
14
15
 
15
16
  const { cssVars } = Astro.props;
16
17
 
18
+ /** The preload filters for one head entry: every weight × every subset. */
19
+ const preloadFilters = (head: FontHead) =>
20
+ head.preloadWeights.flatMap((weight) =>
21
+ head.preloadSubsets
22
+ ? head.preloadSubsets.map((subset) => ({
23
+ style: "normal" as const,
24
+ subset,
25
+ weight,
26
+ }))
27
+ : [{ style: "normal" as const, weight }]
28
+ );
29
+
17
30
  const entries = cssVars.map((value) =>
18
31
  typeof value === "string"
19
32
  ? { cssVariable: value, preload: true as const }
20
- : {
21
- cssVariable: value.cssVariable,
22
- preload: value.preloadWeights.map((weight) => ({
23
- style: "normal" as const,
24
- weight,
25
- })),
26
- }
33
+ : { cssVariable: value.cssVariable, preload: preloadFilters(value) }
27
34
  );
28
35
  ---
29
36
 
@@ -2,6 +2,7 @@
2
2
  import Ask from "blume:ask";
3
3
  import data from "blume:data";
4
4
  import { withBase } from "../islands/base-path.ts";
5
+ import { isExternalUrl } from "../../core/base-path.ts";
5
6
  import type { ComponentOverride } from "../../core/define-components.ts";
6
7
  import { EN_UI } from "../../core/i18n-ui.ts";
7
8
  import type { UIStrings } from "../../core/i18n-ui.ts";
@@ -120,6 +121,19 @@ const tabsNavClass = hasSidebar
120
121
  const iconButton =
121
122
  "inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
122
123
 
124
+ // Header links: plain actions are secondary and hide below `sm`, where the
125
+ // header has room for the logo and the drawer toggle and nothing else. The one
126
+ // call to action hides there too — but only when a drawer toggle is actually
127
+ // competing for the space. A page with no drawer (a PageLayout landing page
128
+ // with no tabs) has nowhere else to surface it, so it stays. `withBase` passes
129
+ // external, protocol-relative and fragment hrefs through untouched.
130
+ const actionClass =
131
+ "hidden rounded-full px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:text-foreground sm:inline-flex";
132
+ const ctaClass = [
133
+ showNavToggle ? "hidden sm:inline-flex" : "inline-flex",
134
+ "items-center gap-1 rounded-full bg-accent px-3.5 py-1.5 font-medium text-accent-foreground text-sm transition-opacity hover:opacity-90",
135
+ ].join(" ");
136
+
123
137
  // Delegated handlers for the theme toggle, mobile nav drawer, and banner
124
138
  // dismiss. Living with the header means every page that renders it — docs pages
125
139
  // and custom pages alike — gets identical behavior from one source.
@@ -212,6 +226,36 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
212
226
  )
213
227
  }
214
228
  <div class="flex items-center gap-2">
229
+ {
230
+ /* Plain links first, then the one filled button: the reader's eye lands
231
+ on the button because nothing beside it competes. */
232
+ (navigation.actions ?? []).map((action) => {
233
+ const external = isExternalUrl(action.href);
234
+ return (
235
+ <a
236
+ class={actionClass}
237
+ href={withBase(action.href)}
238
+ rel={external ? "noreferrer" : undefined}
239
+ target={external ? "_blank" : undefined}
240
+ >
241
+ {action.label}
242
+ </a>
243
+ );
244
+ })
245
+ }
246
+ {
247
+ navigation.cta && (
248
+ <a
249
+ class={ctaClass}
250
+ href={withBase(navigation.cta.href)}
251
+ rel={isExternalUrl(navigation.cta.href) ? "noreferrer" : undefined}
252
+ target={isExternalUrl(navigation.cta.href) ? "_blank" : undefined}
253
+ >
254
+ {navigation.cta.label}
255
+ <Icon name="chevron-right" size={14} />
256
+ </a>
257
+ )
258
+ }
215
259
  {
216
260
  navigation.repoUrl && (
217
261
  <a
@@ -20,7 +20,7 @@ const menuRowClass =
20
20
 
21
21
  {
22
22
  options.length > 1 && (
23
- <details class="group relative">
23
+ <details class="group relative" data-blume-dropdown>
24
24
  <summary
25
25
  aria-label={label}
26
26
  class={`${iconButton} list-none [&::-webkit-details-marker]:hidden`}
@@ -56,3 +56,11 @@ const menuRowClass =
56
56
  </details>
57
57
  )
58
58
  }
59
+
60
+ <script>
61
+ import { installDropdownDismiss } from "../dropdown-dismiss.ts";
62
+
63
+ // Outside-click / Escape / focus-out dismissal, shared with the nav
64
+ // selectors and the page actions.
65
+ installDropdownDismiss();
66
+ </script>
@@ -2,8 +2,9 @@
2
2
  import { withBase } from "../islands/base-path.ts";
3
3
  // A top-level navigation selector: a dropdown that switches
4
4
  // between partitions of the site — a product, a version, or any grouped set of
5
- // destinations (`navigation.selectors` in the config). Zero-JS, built on
6
- // <details>/<summary> like the language switcher.
5
+ // destinations (`navigation.selectors` in the config). Built on
6
+ // <details>/<summary> like the language switcher; the only script is the
7
+ // shared light-dismiss so an open panel doesn't linger over the page.
7
8
  import type { NavSelector } from "../../core/types.ts";
8
9
  import Icon from "../Icon.astro";
9
10
  import { isUnderPath } from "../../core/navigation.ts";
@@ -40,7 +41,7 @@ const menuRowClass =
40
41
 
41
42
  {
42
43
  selector.items.length > 0 && (
43
- <details class="group relative">
44
+ <details class="group relative" data-blume-dropdown>
44
45
  <summary
45
46
  aria-label={selector.label}
46
47
  class={`${iconButton} list-none [&::-webkit-details-marker]:hidden`}
@@ -85,3 +86,11 @@ const menuRowClass =
85
86
  </details>
86
87
  )
87
88
  }
89
+
90
+ <script>
91
+ import { installDropdownDismiss } from "../dropdown-dismiss.ts";
92
+
93
+ // Outside-click / Escape / focus-out dismissal, shared with the language
94
+ // switcher and the page actions.
95
+ installDropdownDismiss();
96
+ </script>
@@ -2,6 +2,7 @@
2
2
  import { EN_UI } from "../../core/i18n-ui.ts";
3
3
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
4
  import type { NavNode } from "../../core/types.ts";
5
+ import { methodColor } from "../colors.ts";
5
6
  import Icon from "../Icon.astro";
6
7
  import { withBase } from "../islands/base-path.ts";
7
8
  import Self from "./NavTree.astro";
@@ -44,24 +45,11 @@ const n = { ...EN_UI.nav, ...strings };
44
45
 
45
46
  const badgeBase =
46
47
  "shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
47
- const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
48
- // HTTP-method and AsyncAPI-action badges (from a reference's sidebar) are
49
- // color-coded to match MethodBadge; every other badge keeps the neutral style.
50
- const METHOD_BADGE: Record<string, string> = {
51
- DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
52
- GET: "bg-green-500/15 text-green-700 dark:text-green-300",
53
- HEAD: "bg-muted text-muted-foreground",
54
- OPTIONS: "bg-muted text-muted-foreground",
55
- PATCH: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
56
- POST: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
57
- PUT: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
58
- RECEIVE: "bg-teal-500/15 text-teal-700 dark:text-teal-300",
59
- SEND: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
60
- };
61
- const badgeClassFor = (badge: string): string => {
62
- const method = METHOD_BADGE[badge.toUpperCase()];
63
- return method ? `${badgeBase} ${method}` : badgeClass;
64
- };
48
+ // HTTP-method, AsyncAPI-action, and GraphQL operation-kind badges (from a
49
+ // reference's sidebar) share MethodBadge's table; every other badge falls
50
+ // through to the same neutral style.
51
+ const badgeClassFor = (badge: string): string =>
52
+ `${badgeBase} ${methodColor(badge)}`;
65
53
  const deprecatedClass =
66
54
  "shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 font-medium text-[0.65rem] text-amber-700 leading-none dark:text-amber-300";
67
55
 
@@ -24,6 +24,12 @@ interface Props {
24
24
  openInChat?: readonly OpenInChatProvider[];
25
25
  /** Localized action labels. */
26
26
  strings?: UIStrings["actions"];
27
+ /**
28
+ * Draw the top rule and spacing that separate the block from the table of
29
+ * contents above it. The rail passes `false` when no contents rendered, so
30
+ * the block does not open an otherwise empty column with a divider.
31
+ */
32
+ divider?: boolean;
27
33
  }
28
34
 
29
35
  const {
@@ -35,6 +41,7 @@ const {
35
41
  mcpName,
36
42
  openInChat,
37
43
  strings,
44
+ divider = true,
38
45
  } = Astro.props;
39
46
  // Merge over the English defaults so a label missing from a translation (or from
40
47
  // a not-yet-regenerated snapshot) still renders instead of coming out blank.
@@ -93,8 +100,9 @@ const menuRowClass =
93
100
  ---
94
101
 
95
102
  <div
96
- class="mt-8 space-y-0.5 border-border border-t pt-4"
103
+ class:list={["space-y-0.5", divider && "mt-8 border-border border-t pt-4"]}
97
104
  data-blume-page-actions
105
+ data-i18n-chat-prompt={a.openInChatPrompt}
98
106
  data-i18n-copied={a.copied}
99
107
  data-i18n-generating={a.generating}
100
108
  data-mcp-name={mcpName ?? undefined}
@@ -122,7 +130,7 @@ const menuRowClass =
122
130
 
123
131
  {
124
132
  (exportPdf || exportEpub) && (
125
- <details class="group relative">
133
+ <details class="group relative" data-blume-dropdown>
126
134
  <summary
127
135
  class={`${rowClass} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
128
136
  >
@@ -159,7 +167,7 @@ const menuRowClass =
159
167
 
160
168
  {
161
169
  providers.length > 0 && (
162
- <details class="group relative">
170
+ <details class="group relative" data-blume-dropdown>
163
171
  <summary
164
172
  class={`${rowClass} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
165
173
  >
@@ -199,7 +207,7 @@ const menuRowClass =
199
207
 
200
208
  {
201
209
  mcpUrl && (
202
- <details class="group relative">
210
+ <details class="group relative" data-blume-dropdown>
203
211
  <summary
204
212
  class={`${rowClass} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
205
213
  >
@@ -252,6 +260,7 @@ const menuRowClass =
252
260
 
253
261
  <script>
254
262
  import { copyText, flashLabel } from "../copy-feedback.ts";
263
+ import { installDropdownDismiss } from "../dropdown-dismiss.ts";
255
264
  import { prefixBase } from "../islands/base-path.ts";
256
265
  import { rafThrottle } from "../raf-throttle.ts";
257
266
 
@@ -329,6 +338,11 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
329
338
  menu.classList.toggle("mb-1", flipUp);
330
339
  };
331
340
 
341
+ const openDropdown = () =>
342
+ document.querySelector<HTMLDetailsElement>(
343
+ "[data-blume-page-actions] details[open]"
344
+ );
345
+
332
346
  // rAF-coalesced so a live resize drag re-reads layout once per frame, not
333
347
  // once per event, while the open menu still tracks the viewport. Registered
334
348
  // once at module scope (this bundle runs once per real page load) and
@@ -336,15 +350,17 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
336
350
  window.addEventListener(
337
351
  "resize",
338
352
  rafThrottle(() => {
339
- const open = document.querySelector<HTMLDetailsElement>(
340
- "[data-blume-page-actions] details[open]"
341
- );
353
+ const open = openDropdown();
342
354
  if (open) {
343
355
  placeMenu(open);
344
356
  }
345
357
  })
346
358
  );
347
359
 
360
+ // Outside-click / Escape / focus-out dismissal is the shared <details>
361
+ // dropdown behavior (also the header language switcher and nav selectors).
362
+ installDropdownDismiss();
363
+
348
364
  // Per-page setup, run on the initial load and again after every
349
365
  // client-router swap: the swap rebuilds the actions menu from
350
366
  // server-rendered markup, so every handler below binds to fresh elements.
@@ -379,9 +395,14 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
379
395
  const copiedLabel = root.getAttribute("data-i18n-copied") || "Copied!";
380
396
  const generatingLabel =
381
397
  root.getAttribute("data-i18n-generating") || "Generating…";
398
+ // Always stamped: the frontmatter merges `EN_UI.actions` under any
399
+ // override, so the English default lives only in core/i18n-ui.ts.
400
+ const promptTemplate = root.getAttribute("data-i18n-chat-prompt") ?? "";
382
401
  const absolute = new URL(md, location.origin).href;
402
+ // Replacer function, not a string: a URL may contain `$`, which a string
403
+ // replacement would interpret as a substitution pattern.
383
404
  const query = encodeURIComponent(
384
- `Read ${absolute} so I can ask you questions about this page.`
405
+ promptTemplate.replaceAll("{url}", () => absolute)
385
406
  );
386
407
 
387
408
  for (const link of root.querySelectorAll("[data-open-in]")) {
@@ -22,6 +22,7 @@ import type {
22
22
  BlumeFavicon,
23
23
  BlumeLogo,
24
24
  } from "../../core/data.ts";
25
+ import data from "blume:data";
25
26
  import { EN_UI } from "../../core/i18n-ui.ts";
26
27
  import type { UIStrings } from "../../core/i18n-ui.ts";
27
28
  import type { FontHead } from "../../theme/fonts.ts";
@@ -217,6 +218,7 @@ const structuredData =
217
218
  base: import.meta.env.BASE_URL,
218
219
  breadcrumbs: [],
219
220
  description,
221
+ identity: data.config.identity,
220
222
  locale,
221
223
  route,
222
224
  siteName: site.title,
@@ -10,7 +10,11 @@ import WebMcp from "./WebMcp.astro";
10
10
  import Banner from "./Banner.astro";
11
11
  import Favicon from "./Favicon.astro";
12
12
  import Fonts from "./Fonts.astro";
13
- import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
13
+ import {
14
+ BANNER_INIT_SCRIPT,
15
+ SCALAR_THEME_INIT_SCRIPT,
16
+ THEME_INIT_SCRIPT,
17
+ } from "./head-scripts.ts";
14
18
  import Header from "./Header.astro";
15
19
 
16
20
  // A minimal shell for the Scalar API/AsyncAPI reference: Blume's banner + navbar
@@ -129,6 +133,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
129
133
  id="blume-content"
130
134
  >
131
135
  <slot />
136
+ <script is:inline set:html={SCALAR_THEME_INIT_SCRIPT} />
132
137
  </div>
133
138
  <WebMcp />
134
139
  </body>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import data from "blume:data";
2
3
  import { EN_UI } from "../../core/i18n-ui.ts";
3
4
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
5
  import { resolveDateFormatOptions } from "../../core/date-format.ts";
@@ -12,6 +13,15 @@ import type {
12
13
  NavSelector as NavSelectorType,
13
14
  } from "../../core/types.ts";
14
15
  import "blume:theme";
16
+ // medium-zoom's default build injects its stylesheet into <head> as a bare
17
+ // <style> the moment the module evaluates — once per document. The client
18
+ // router's head swap drops every non-persisted head element on the first
19
+ // navigation, and the cached module never re-injects, so the zoom cursor
20
+ // vanished and `close()` hung forever waiting for a `transitionend` its
21
+ // (now absent) transition rule never fires. Importing the stylesheet here
22
+ // puts it in the page CSS the router carries across swaps; the script below
23
+ // loads the `pure` build, which ships no injection.
24
+ import "medium-zoom/dist/style.css";
15
25
  import { ClientRouter } from "astro:transitions";
16
26
  import type { FontHead } from "../../theme/fonts.ts";
17
27
  import type { ComponentOverride } from "../../core/define-components.ts";
@@ -23,6 +33,7 @@ import {
23
33
  import { buildStructuredData, toIso } from "../../seo/jsonld.ts";
24
34
  import { normalizeXHandle } from "../../seo/x-handle.ts";
25
35
  import { withBase } from "../islands/base-path.ts";
36
+ import { isExternalUrl } from "../../core/base-path.ts";
26
37
  import Analytics from "./Analytics.astro";
27
38
  import WebMcp from "./WebMcp.astro";
28
39
  import Banner from "./Banner.astro";
@@ -295,6 +306,9 @@ const tocHeadings =
295
306
  toc.enabled && showToc
296
307
  ? headings.filter((h) => h.depth >= toc.minLevel && h.depth <= toc.maxLevel)
297
308
  : [];
309
+ // One predicate for both the outline and the page-actions divider under it,
310
+ // so the rail cannot draw a rule under contents that were never rendered.
311
+ const hasToc = tocHeadings.length > 0;
298
312
  let gridClass =
299
313
  "lg:grid-cols-[17.5rem_minmax(0,1fr)] xl:grid-cols-[17.5rem_minmax(0,1fr)_17.5rem]";
300
314
  if (isBare) {
@@ -387,6 +401,7 @@ const structuredData =
387
401
  base: import.meta.env.BASE_URL,
388
402
  breadcrumbs: crumbs,
389
403
  description,
404
+ identity: data.config.identity,
390
405
  locale,
391
406
  modified: lastModified,
392
407
  pageType,
@@ -524,6 +539,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
524
539
  </head>
525
540
  <body
526
541
  class="bg-background font-sans text-foreground antialiased"
542
+ data-blume-code-copy=""
527
543
  data-blume-code-wrap={codeWrap ? "" : undefined}
528
544
  data-blume-image-zoom={imageZoom ? "" : undefined}
529
545
  data-i18n-copied={actionStrings.copied}
@@ -579,7 +595,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
579
595
  >
580
596
  <ul class="m-0 list-none p-0">
581
597
  {navigation.featured.map((link) => {
582
- const external = /^https?:\/\//u.test(link.href);
598
+ const external = isExternalUrl(link.href);
583
599
  return (
584
600
  <li>
585
601
  <a
@@ -680,11 +696,15 @@ const bannerKey = banner?.dismissible ? banner.key : null;
680
696
  strings={navStrings}
681
697
  wide={isApiOperation}
682
698
  />
683
- <TableOfContentsSlot
684
- headings={tocHeadings}
685
- title={strings.toc.title}
686
- variant="mobile"
687
- />
699
+ {
700
+ hasToc && (
701
+ <TableOfContentsSlot
702
+ headings={tocHeadings}
703
+ title={strings.toc.title}
704
+ variant="mobile"
705
+ />
706
+ )
707
+ }
688
708
  <PageHeaderSlot headings={tocHeadings} page={page} route={page.route} />
689
709
  <article class:list={[articleClass]} dir={contentDir}>
690
710
  <slot />
@@ -715,12 +735,15 @@ const bannerKey = banner?.dismissible ? banner.key : null;
715
735
  class="sticky top-16 hidden h-[calc(100dvh-4rem)] scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto px-4 pt-6 pb-10 text-sm xl:block"
716
736
  data-blume-toc
717
737
  >
718
- <TableOfContentsSlot
719
- headings={tocHeadings}
720
- title={strings.toc.title}
721
- variant="desktop"
722
- />
738
+ {hasToc && (
739
+ <TableOfContentsSlot
740
+ headings={tocHeadings}
741
+ title={strings.toc.title}
742
+ variant="desktop"
743
+ />
744
+ )}
723
745
  <PageActions
746
+ divider={hasToc}
724
747
  editUrl={editUrl}
725
748
  exportEpub={exportEpub}
726
749
  exportPdf={exportPdf}
@@ -910,7 +933,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
910
933
  if (zoomTargets.length > 0) {
911
934
  // Lazy: pages without a zoomable image never load the library,
912
935
  // matching how mermaid is only fetched on pages with a diagram.
913
- const { default: mediumZoom } = await import("medium-zoom");
936
+ const { default: mediumZoom } = await import(
937
+ "medium-zoom/dist/pure"
938
+ );
914
939
  mediumZoom(zoomTargets, {
915
940
  background:
916
941
  "color-mix(in oklab, var(--color-background) 80%, transparent)",
@@ -927,7 +952,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
927
952
  </script>
928
953
  <style is:global>
929
954
  /* medium-zoom ships no z-index; lift the lightbox above the chrome
930
- (header/sidebar) the way the previous z-[100] overlay sat. */
955
+ (header/sidebar) the way the previous z-[100] overlay sat. Its own
956
+ rules come from the `medium-zoom/dist/style.css` import in the
957
+ frontmatter, not runtime injection. */
931
958
  .medium-zoom-overlay {
932
959
  backdrop-filter: blur(4px);
933
960
  z-index: 100;
@@ -936,8 +963,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
936
963
  z-index: 101;
937
964
  }
938
965
  @media (prefers-reduced-motion: reduce) {
939
- /* html prefix outranks the library's injected rules regardless of
940
- insertion order; its transition declarations carry !important. */
966
+ /* html prefix outranks the library's rules regardless of sheet
967
+ order; its transition declarations carry !important. */
941
968
  html .medium-zoom-image,
942
969
  html .medium-zoom-overlay {
943
970
  transition: none !important;