blume 1.5.2 → 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 +85 -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 +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -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 +53 -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";
@@ -40,7 +41,11 @@ import Analytics from "./Analytics.astro";
40
41
  import Banner from "./Banner.astro";
41
42
  import Favicon from "./Favicon.astro";
42
43
  import Fonts from "./Fonts.astro";
43
- import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
44
+ import {
45
+ BANNER_INIT_SCRIPT,
46
+ SWAP_STYLESHEET_INIT_SCRIPT,
47
+ THEME_INIT_SCRIPT,
48
+ } from "./head-scripts.ts";
44
49
  import Header from "./Header.astro";
45
50
  import { currentTabForRoute } from "./nav-utils.ts";
46
51
  import WebMcp from "./WebMcp.astro";
@@ -213,6 +218,7 @@ const structuredData =
213
218
  base: import.meta.env.BASE_URL,
214
219
  breadcrumbs: [],
215
220
  description,
221
+ identity: data.config.identity,
216
222
  locale,
217
223
  route,
218
224
  siteName: site.title,
@@ -235,6 +241,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
235
241
  swap the DOM in place instead of tearing the document down, so no
236
242
  browser paints a blank frame between pages. */}
237
243
  <ClientRouter />
244
+ {/* Loads a next page's body-hoisted CSS before the router swaps it in —
245
+ see SWAP_STYLESHEET_INIT_SCRIPT for the unstyled-frame failure mode. */}
246
+ <script is:inline set:html={SWAP_STYLESHEET_INIT_SCRIPT} />
238
247
  <title>{pageTitle}</title>
239
248
  <Favicon appleIcon={appleIcon} favicon={favicon} />
240
249
  <Fonts cssVars={fontCssVars ?? []} />
@@ -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";
@@ -33,6 +44,7 @@ import Fonts from "./Fonts.astro";
33
44
  import {
34
45
  BANNER_INIT_SCRIPT,
35
46
  SIDEBAR_SCROLL_INIT_SCRIPT,
47
+ SWAP_STYLESHEET_INIT_SCRIPT,
36
48
  THEME_INIT_SCRIPT,
37
49
  } from "./head-scripts.ts";
38
50
  import Header from "./Header.astro";
@@ -294,6 +306,9 @@ const tocHeadings =
294
306
  toc.enabled && showToc
295
307
  ? headings.filter((h) => h.depth >= toc.minLevel && h.depth <= toc.maxLevel)
296
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;
297
312
  let gridClass =
298
313
  "lg:grid-cols-[17.5rem_minmax(0,1fr)] xl:grid-cols-[17.5rem_minmax(0,1fr)_17.5rem]";
299
314
  if (isBare) {
@@ -386,6 +401,7 @@ const structuredData =
386
401
  base: import.meta.env.BASE_URL,
387
402
  breadcrumbs: crumbs,
388
403
  description,
404
+ identity: data.config.identity,
389
405
  locale,
390
406
  modified: lastModified,
391
407
  pageType,
@@ -414,6 +430,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
414
430
  supported, with Astro's simulated fade elsewhere; pairs with the
415
431
  prefetch option in the generated Astro config. */}
416
432
  <ClientRouter />
433
+ {/* Loads a next page's body-hoisted CSS before the router swaps it in —
434
+ see SWAP_STYLESHEET_INIT_SCRIPT for the unstyled-frame failure mode. */}
435
+ <script is:inline set:html={SWAP_STYLESHEET_INIT_SCRIPT} />
417
436
  <title>{pageTitle}</title>
418
437
  <Favicon favicon={favicon} appleIcon={appleIcon} />
419
438
  <Fonts cssVars={fontCssVars ?? []} />
@@ -520,6 +539,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
520
539
  </head>
521
540
  <body
522
541
  class="bg-background font-sans text-foreground antialiased"
542
+ data-blume-code-copy=""
523
543
  data-blume-code-wrap={codeWrap ? "" : undefined}
524
544
  data-blume-image-zoom={imageZoom ? "" : undefined}
525
545
  data-i18n-copied={actionStrings.copied}
@@ -575,7 +595,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
575
595
  >
576
596
  <ul class="m-0 list-none p-0">
577
597
  {navigation.featured.map((link) => {
578
- const external = /^https?:\/\//u.test(link.href);
598
+ const external = isExternalUrl(link.href);
579
599
  return (
580
600
  <li>
581
601
  <a
@@ -676,11 +696,15 @@ const bannerKey = banner?.dismissible ? banner.key : null;
676
696
  strings={navStrings}
677
697
  wide={isApiOperation}
678
698
  />
679
- <TableOfContentsSlot
680
- headings={tocHeadings}
681
- title={strings.toc.title}
682
- variant="mobile"
683
- />
699
+ {
700
+ hasToc && (
701
+ <TableOfContentsSlot
702
+ headings={tocHeadings}
703
+ title={strings.toc.title}
704
+ variant="mobile"
705
+ />
706
+ )
707
+ }
684
708
  <PageHeaderSlot headings={tocHeadings} page={page} route={page.route} />
685
709
  <article class:list={[articleClass]} dir={contentDir}>
686
710
  <slot />
@@ -711,12 +735,15 @@ const bannerKey = banner?.dismissible ? banner.key : null;
711
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"
712
736
  data-blume-toc
713
737
  >
714
- <TableOfContentsSlot
715
- headings={tocHeadings}
716
- title={strings.toc.title}
717
- variant="desktop"
718
- />
738
+ {hasToc && (
739
+ <TableOfContentsSlot
740
+ headings={tocHeadings}
741
+ title={strings.toc.title}
742
+ variant="desktop"
743
+ />
744
+ )}
719
745
  <PageActions
746
+ divider={hasToc}
720
747
  editUrl={editUrl}
721
748
  exportEpub={exportEpub}
722
749
  exportPdf={exportPdf}
@@ -906,7 +933,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
906
933
  if (zoomTargets.length > 0) {
907
934
  // Lazy: pages without a zoomable image never load the library,
908
935
  // matching how mermaid is only fetched on pages with a diagram.
909
- const { default: mediumZoom } = await import("medium-zoom");
936
+ const { default: mediumZoom } = await import(
937
+ "medium-zoom/dist/pure"
938
+ );
910
939
  mediumZoom(zoomTargets, {
911
940
  background:
912
941
  "color-mix(in oklab, var(--color-background) 80%, transparent)",
@@ -923,7 +952,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
923
952
  </script>
924
953
  <style is:global>
925
954
  /* medium-zoom ships no z-index; lift the lightbox above the chrome
926
- (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. */
927
958
  .medium-zoom-overlay {
928
959
  backdrop-filter: blur(4px);
929
960
  z-index: 100;
@@ -932,8 +963,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
932
963
  z-index: 101;
933
964
  }
934
965
  @media (prefers-reduced-motion: reduce) {
935
- /* html prefix outranks the library's injected rules regardless of
936
- 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. */
937
968
  html .medium-zoom-image,
938
969
  html .medium-zoom-overlay {
939
970
  transition: none !important;