blume 1.5.3 → 1.6.1

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 (209) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +3949 -1403
  3. package/dist/cli/index.js.map +111 -96
  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 +33 -2
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +10 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +122 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +29 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +26 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/07-faq.mdx +9 -9
  21. package/docs/_snippets/include-demo.mdx +7 -0
  22. package/docs/advanced/api-reference.mdx +13 -4
  23. package/docs/advanced/custom-pages.mdx +4 -2
  24. package/docs/advanced/graphql.mdx +84 -0
  25. package/docs/advanced/meta.ts +8 -1
  26. package/docs/configuration/ai.mdx +25 -3
  27. package/docs/configuration/index.mdx +24 -0
  28. package/docs/configuration/search.mdx +13 -1
  29. package/docs/configuration/seo.mdx +30 -3
  30. package/docs/configuration/theming.mdx +23 -0
  31. package/docs/content/components.mdx +15 -1
  32. package/docs/content/includes.mdx +68 -0
  33. package/docs/content/meta.ts +1 -0
  34. package/docs/content/navigation.mdx +25 -0
  35. package/docs/content/sources.mdx +42 -1
  36. package/docs/content/syntax.mdx +69 -1
  37. package/docs/content/versioning.mdx +15 -9
  38. package/docs/reference/cli.mdx +2 -1
  39. package/package.json +66 -57
  40. package/skills/blume-migrate/SKILL.md +16 -7
  41. package/skills/blume-migrate/references/docusaurus.md +5 -3
  42. package/skills/blume-migrate/references/fumadocs.md +10 -2
  43. package/skills/blume-migrate/references/mintlify.md +3 -2
  44. package/skills/blume-migrate/references/nextra.md +2 -2
  45. package/skills/blume-migrate/references/starlight.md +1 -1
  46. package/src/ai/agent-readability.ts +2 -1
  47. package/src/ai/ask-data.ts +2 -1
  48. package/src/ai/component-markdown.ts +199 -36
  49. package/src/ai/llms.ts +93 -6
  50. package/src/ai/markdown.ts +2 -2
  51. package/src/ai/mcp/discovery.ts +10 -2
  52. package/src/ai/mcp/server.ts +74 -2
  53. package/src/astro/examples.ts +29 -2
  54. package/src/astro/generate.ts +282 -177
  55. package/src/astro/include-hmr.ts +81 -0
  56. package/src/astro/include-refresh.ts +0 -0
  57. package/src/astro/index.ts +10 -5
  58. package/src/astro/markdown-negotiation.ts +1 -1
  59. package/src/astro/runtime-modules.ts +196 -0
  60. package/src/astro/templates.ts +365 -113
  61. package/src/cli/commands/build.ts +91 -16
  62. package/src/cli/commands/dev.ts +6 -3
  63. package/src/cli/host-args.ts +18 -0
  64. package/src/cli/index.ts +2 -1
  65. package/src/cli/init/questions.ts +1 -0
  66. package/src/cli/init/scaffold.ts +27 -4
  67. package/src/components/colors.ts +142 -0
  68. package/src/components/content/Badge.astro +5 -12
  69. package/src/components/content/Callout.astro +19 -36
  70. package/src/components/content/Card.astro +15 -21
  71. package/src/components/content/Component.astro +10 -1
  72. package/src/components/content/GithubInfo.astro +28 -9
  73. package/src/components/content/Tabs.astro +27 -5
  74. package/src/components/content/github-info.ts +20 -5
  75. package/src/components/copy-feedback.ts +93 -9
  76. package/src/components/dropdown-dismiss.ts +122 -0
  77. package/src/components/islands/ask-ai.tsx +4 -1
  78. package/src/components/islands/hooks.ts +3 -1
  79. package/src/components/layout/Fonts.astro +15 -8
  80. package/src/components/layout/Header.astro +44 -0
  81. package/src/components/layout/LanguageSwitcher.astro +9 -1
  82. package/src/components/layout/NavSelector.astro +12 -3
  83. package/src/components/layout/NavTree.astro +6 -18
  84. package/src/components/layout/PageActions.astro +54 -22
  85. package/src/components/layout/PageLayout.astro +2 -0
  86. package/src/components/layout/ReferenceLayout.astro +6 -1
  87. package/src/components/layout/RootLayout.astro +42 -15
  88. package/src/components/layout/Search.astro +36 -4
  89. package/src/components/layout/TableOfContents.astro +8 -2
  90. package/src/components/layout/head-scripts.ts +30 -1
  91. package/src/components/openapi/ApiOverview.astro +13 -3
  92. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  93. package/src/components/openapi/GraphqlChip.astro +33 -0
  94. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  95. package/src/components/openapi/GraphqlOperation.astro +186 -0
  96. package/src/components/openapi/GraphqlType.astro +154 -0
  97. package/src/components/openapi/MethodBadge.astro +3 -14
  98. package/src/components/openapi/Operation.astro +12 -5
  99. package/src/components/openapi/OperationPanel.astro +43 -0
  100. package/src/components/openapi/RequestPanel.astro +5 -10
  101. package/src/components/openapi/Responses.astro +1 -16
  102. package/src/components/openapi/graphql-helpers.ts +466 -0
  103. package/src/components/openapi/playground-client.ts +15 -0
  104. package/src/components/openapi/sample-panels.ts +45 -0
  105. package/src/components/openapi/snippets.ts +13 -35
  106. package/src/core/base-path.ts +11 -0
  107. package/src/core/config-input.ts +209 -2
  108. package/src/core/config.ts +6 -4
  109. package/src/core/content-assets.ts +15 -4
  110. package/src/core/data.ts +28 -3
  111. package/src/core/define-components.ts +2 -0
  112. package/src/core/diagnostics.ts +8 -0
  113. package/src/core/frontmatter.ts +20 -8
  114. package/src/core/github.ts +71 -0
  115. package/src/core/graph.ts +22 -8
  116. package/src/core/heading-markers.ts +96 -0
  117. package/src/core/i18n-ui.ts +12 -0
  118. package/src/core/includes.ts +633 -0
  119. package/src/core/last-modified.ts +36 -11
  120. package/src/core/links.ts +79 -13
  121. package/src/core/manifest.ts +10 -0
  122. package/src/core/meta.ts +2 -1
  123. package/src/core/nav-diagnostics.ts +11 -2
  124. package/src/core/navigation.ts +27 -6
  125. package/src/core/project-graph.ts +61 -9
  126. package/src/core/schema.ts +235 -36
  127. package/src/core/server-features.ts +5 -9
  128. package/src/core/sources/github-releases.ts +2 -2
  129. package/src/core/sources/normalize.ts +502 -115
  130. package/src/core/sources/notion.ts +43 -8
  131. package/src/core/sources/obsidian.ts +1038 -0
  132. package/src/core/sources/read.ts +36 -1
  133. package/src/core/sources/resolve.ts +34 -1
  134. package/src/core/sources/types.ts +28 -6
  135. package/src/core/sources/watch.ts +12 -8
  136. package/src/core/tsconfig-aliases.ts +48 -35
  137. package/src/core/types.ts +31 -2
  138. package/src/core/ui-packs/ar.ts +2 -0
  139. package/src/core/ui-packs/bg.ts +3 -0
  140. package/src/core/ui-packs/bn.ts +2 -0
  141. package/src/core/ui-packs/ca.ts +3 -0
  142. package/src/core/ui-packs/cs.ts +2 -0
  143. package/src/core/ui-packs/da.ts +2 -0
  144. package/src/core/ui-packs/de.ts +3 -0
  145. package/src/core/ui-packs/el.ts +3 -0
  146. package/src/core/ui-packs/es.ts +3 -0
  147. package/src/core/ui-packs/fa.ts +2 -0
  148. package/src/core/ui-packs/fi.ts +2 -0
  149. package/src/core/ui-packs/fr.ts +3 -0
  150. package/src/core/ui-packs/he.ts +2 -0
  151. package/src/core/ui-packs/hi.ts +2 -0
  152. package/src/core/ui-packs/hr.ts +3 -0
  153. package/src/core/ui-packs/hu.ts +3 -0
  154. package/src/core/ui-packs/id.ts +3 -0
  155. package/src/core/ui-packs/it.ts +2 -0
  156. package/src/core/ui-packs/ja.ts +3 -0
  157. package/src/core/ui-packs/ko.ts +3 -0
  158. package/src/core/ui-packs/nl.ts +3 -0
  159. package/src/core/ui-packs/no.ts +3 -0
  160. package/src/core/ui-packs/pl.ts +3 -0
  161. package/src/core/ui-packs/pt-br.ts +3 -0
  162. package/src/core/ui-packs/pt.ts +3 -0
  163. package/src/core/ui-packs/ro.ts +3 -0
  164. package/src/core/ui-packs/ru.ts +3 -0
  165. package/src/core/ui-packs/sk.ts +2 -0
  166. package/src/core/ui-packs/sr.ts +2 -0
  167. package/src/core/ui-packs/sv.ts +3 -0
  168. package/src/core/ui-packs/th.ts +2 -0
  169. package/src/core/ui-packs/tr.ts +3 -0
  170. package/src/core/ui-packs/uk.ts +3 -0
  171. package/src/core/ui-packs/vi.ts +2 -0
  172. package/src/core/ui-packs/zh-tw.ts +2 -0
  173. package/src/core/ui-packs/zh.ts +2 -0
  174. package/src/core/version-cut.ts +26 -6
  175. package/src/core/yaml.ts +26 -0
  176. package/src/deploy/function-bundle.ts +251 -0
  177. package/src/deploy/vercel-negotiation.ts +49 -6
  178. package/src/eval/schema.ts +3 -1
  179. package/src/markdown/code-title.ts +22 -16
  180. package/src/markdown/features.ts +21 -0
  181. package/src/markdown/fence-meta.ts +50 -0
  182. package/src/markdown/heading-anchors.ts +198 -37
  183. package/src/markdown/include.ts +247 -0
  184. package/src/markdown/index.ts +43 -34
  185. package/src/markdown/language-icon.ts +2 -2
  186. package/src/markdown/mdast.ts +7 -3
  187. package/src/markdown/ts2js.ts +264 -0
  188. package/src/og/card.ts +1 -1
  189. package/src/openapi/asyncapi.ts +4 -1
  190. package/src/openapi/graphql-build.ts +293 -0
  191. package/src/openapi/graphql.ts +212 -0
  192. package/src/openapi/model.ts +38 -5
  193. package/src/openapi/parse.ts +34 -0
  194. package/src/openapi/proxy.ts +30 -5
  195. package/src/openapi/references.ts +97 -13
  196. package/src/openapi/render-mdx.ts +66 -12
  197. package/src/openapi/scalar.ts +5 -16
  198. package/src/openapi/source.ts +91 -23
  199. package/src/registry/eject.ts +47 -17
  200. package/src/search/documents.ts +229 -37
  201. package/src/search/orama-index.ts +9 -5
  202. package/src/seo/jsonld.ts +293 -51
  203. package/src/theme/code-block-padding.ts +16 -0
  204. package/src/theme/entry.ts +67 -13
  205. package/src/theme/fonts.ts +189 -16
  206. package/src/theme/sources.ts +49 -0
  207. package/src/translate/prompts.ts +2 -0
  208. package/src/translate/run.ts +7 -0
  209. package/src/translate/work-list.ts +0 -0
@@ -10,6 +10,10 @@
10
10
  // examples are all supported. The source is highlighted with the same Shiki
11
11
  // setup as ordinary code fences.
12
12
  import data from "blume:data";
13
+ import {
14
+ CODE_PADDING_BLOCK_REM,
15
+ FLUSH_CODE_PADDING_TOP_REM,
16
+ } from "../../theme/code-block-padding.ts";
13
17
 
14
18
  import { highlightCode } from "../../markdown/index.ts";
15
19
  import { withBase } from "../islands/base-path.ts";
@@ -58,7 +62,12 @@ const codeHtml = entry
58
62
  // whose collapse to the measured height no transition could hide. No-JS
59
63
  // readers aren't hurt by the cap, since the source scrolls at any height.
60
64
  const LINE_PX = 21;
61
- const PADDING_PX = 36;
65
+ const REM_PX = 16;
66
+ // The pre's vertical padding, from the same constants the theme emits: the
67
+ // copy-button strip on top (the pane is a flush block inside tabs) and the
68
+ // plain inset below. The tab panel and the pre carry no border of their own.
69
+ const PADDING_PX =
70
+ (FLUSH_CODE_PADDING_TOP_REM + CODE_PADDING_BLOCK_REM) * REM_PX;
62
71
  const ESTIMATE_MAX_PX = 400;
63
72
  // The floor also clamps the measured height client-side; it rides along on the
64
73
  // iframe as `data-blume-min-pane` so the script and this estimate can't drift.
@@ -3,6 +3,7 @@
3
3
  // at build time (no client JS). Pass `owner`/`repo`, or omit them to use the
4
4
  // repo from blume.config. A `GITHUB_TOKEN` env var lifts the API rate limit.
5
5
  // If the API is unreachable the card still renders, just without counts.
6
+ import { apiUrl, PUBLIC_HOST_URL } from "../../core/github.ts";
6
7
  import { resolveIcon } from "../../theme/icons.ts";
7
8
  import data from "blume:data";
8
9
  import { GITHUB_MARK } from "../github-mark.ts";
@@ -15,22 +16,40 @@ const starIcon = resolveIcon("star")?.body ?? "";
15
16
  const forkIcon = resolveIcon("git-fork")?.body ?? "";
16
17
 
17
18
  interface Props {
19
+ /**
20
+ * Origin of the GitHub instance the card's repo lives on. Defaults to the
21
+ * configured `github.host`, so a card on an Enterprise site reads that
22
+ * instance; set it to point one card elsewhere — `https://github.com` for a
23
+ * public project from an Enterprise-hosted docs site, say. The REST base is
24
+ * derived from it the same way `github.api` is derived from `github.host`.
25
+ */
26
+ host?: string;
18
27
  owner?: string;
19
28
  repo?: string;
20
29
  token?: string;
21
30
  }
22
31
 
23
- // The resolved config exposes the repo as `repoUrl`, not `github`; parse the
24
- // owner/repo back out so <GithubInfo /> can default to the configured repo.
25
- const repoMatch = data.config.repoUrl?.match(
26
- /github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)/u
27
- );
28
- const owner = Astro.props.owner ?? repoMatch?.groups?.owner;
29
- const repo = Astro.props.repo ?? repoMatch?.groups?.repo;
32
+ // `data.config.github` carries the configured repo's coordinates, so the card
33
+ // defaults to it and addresses the right instance — an Enterprise host serves
34
+ // both the link and the API from somewhere other than github.com. Explicit
35
+ // `owner`/`repo` props are read against that same instance unless `host`
36
+ // points them somewhere else; with no `github` configured at all, they fall
37
+ // back to the public one.
38
+ const configured = data.config.github;
39
+ const owner = Astro.props.owner ?? configured?.owner;
40
+ const repo = Astro.props.repo ?? configured?.repo;
30
41
  const token = Astro.props.token ?? process.env.GITHUB_TOKEN;
42
+ // A `host` prop is reduced to its origin, as the config field is, so a
43
+ // trailing slash or a path never lands mid-link.
44
+ const host = Astro.props.host
45
+ ? new URL(Astro.props.host).origin
46
+ : (configured?.host ?? PUBLIC_HOST_URL);
47
+ const baseUrl = Astro.props.host ? apiUrl({ host }) : configured?.api;
31
48
 
32
49
  const info =
33
- owner && repo ? await fetchRepositoryInfo({ owner, repo, token }) : null;
50
+ owner && repo
51
+ ? await fetchRepositoryInfo({ baseUrl, owner, repo, token })
52
+ : null;
34
53
 
35
54
  // Compact notation matches GitHub's own counts (e.g. 1.2k, 34.5k).
36
55
  const numbers = new Intl.NumberFormat("en", {
@@ -46,7 +65,7 @@ const statIcon =
46
65
  owner && repo && (
47
66
  <a
48
67
  class="not-prose my-6 flex flex-col gap-2 rounded-blume border border-border p-4 no-underline! transition-colors hover:border-foreground/30 hover:bg-muted/40"
49
- href={`https://github.com/${owner}/${repo}`}
68
+ href={`${host}/${owner}/${repo}`}
50
69
  rel="noreferrer"
51
70
  target="_blank"
52
71
  >
@@ -19,6 +19,13 @@ interface Props {
19
19
  */
20
20
  param?: string;
21
21
  sync?: boolean;
22
+ /**
23
+ * Sync tab selection only with other groups sharing the same key. Groups
24
+ * without a key form one page-wide pool (the default); keyed groups — like
25
+ * the generated ts2js dialect pairs — sync among themselves, so picking a
26
+ * tab there can't drag along an authored group with a same-titled tab.
27
+ */
28
+ syncKey?: string;
22
29
  }
23
30
 
24
31
  const {
@@ -29,6 +36,7 @@ const {
29
36
  inline = false,
30
37
  param,
31
38
  sync = true,
39
+ syncKey,
32
40
  } = Astro.props;
33
41
 
34
42
  // MDX string attributes (`hash="false"` from generated markup) must read as
@@ -52,6 +60,7 @@ const useDropdown = dropdown && !inline;
52
60
  data-hash={hashEnabled ? "true" : "false"}
53
61
  data-param={param}
54
62
  data-sync={sync ? "true" : "false"}
63
+ data-sync-key={syncKey}
55
64
  >
56
65
  <div
57
66
  class:list={[
@@ -116,9 +125,12 @@ const useDropdown = dropdown && !inline;
116
125
 
117
126
  connectedCallback() {
118
127
  const list = this.querySelector<HTMLElement>("[data-blume-tablist]");
128
+ // Nested groups (a generated ts2js pair inside a CodeGroup, say) own
129
+ // their panels; adopting a descendant group's panels here would leave
130
+ // both groups toggling the same elements.
119
131
  let panels = Array.from(
120
132
  this.querySelectorAll<HTMLElement>("[data-blume-tab-panel]")
121
- );
133
+ ).filter((panel) => panel.closest("blume-tabs") === this);
122
134
  // CodeGroup passes raw code fences instead of <Tab> elements, so adopt
123
135
  // the content wrapper's direct code blocks as panels. The tab label comes
124
136
  // from each block's title (the text after the language in the fence).
@@ -251,7 +263,10 @@ const useDropdown = dropdown && !inline;
251
263
  if (sync && this.#syncEnabled()) {
252
264
  document.dispatchEvent(
253
265
  new CustomEvent(SYNC_EVENT, {
254
- detail: { title: panelTitle(activePanel, index) },
266
+ detail: {
267
+ key: this.#syncKey(),
268
+ title: panelTitle(activePanel, index),
269
+ },
255
270
  })
256
271
  );
257
272
  }
@@ -339,6 +354,10 @@ const useDropdown = dropdown && !inline;
339
354
  return this.dataset.sync !== "false";
340
355
  }
341
356
 
357
+ #syncKey() {
358
+ return this.dataset.syncKey ?? "";
359
+ }
360
+
342
361
  #hashEnabled() {
343
362
  return this.dataset.hash !== "false";
344
363
  }
@@ -358,9 +377,12 @@ const useDropdown = dropdown && !inline;
358
377
  }
359
378
 
360
379
  #sync = (event: Event) => {
361
- const title = (event as CustomEvent<{ title?: string }>).detail?.title;
362
- if (title) {
363
- this.activateByTitle(title, false);
380
+ const detail = (event as CustomEvent<{ key?: string; title?: string }>)
381
+ .detail;
382
+ // Only same-key groups sync: keyless groups form one page-wide pool,
383
+ // keyed groups (generated dialect pairs) their own.
384
+ if (detail?.title && (detail.key ?? "") === this.#syncKey()) {
385
+ this.activateByTitle(detail.title, false);
364
386
  }
365
387
  };
366
388
 
@@ -7,6 +7,8 @@
7
7
  * than throwing, so a card never breaks the build when the API is unreachable.
8
8
  */
9
9
 
10
+ import { PUBLIC_API_URL } from "../../core/github.ts";
11
+
10
12
  export interface RepositoryInfo {
11
13
  description: string | null;
12
14
  forks: number;
@@ -20,18 +22,31 @@ export interface FetchRepositoryOptions {
20
22
  token?: string;
21
23
  }
22
24
 
23
- const DEFAULT_BASE_URL = "https://api.github.com";
24
-
25
25
  /** In-process dedupe so the same repo is fetched once per build. */
26
26
  const cache = new Map<string, Promise<RepositoryInfo | null>>();
27
27
 
28
+ /** Cleartext bases already warned about, so a site of cards logs once. */
29
+ const warnedCleartext = new Set<string>();
30
+
28
31
  const load = async (
29
32
  options: FetchRepositoryOptions
30
33
  ): Promise<RepositoryInfo | null> => {
31
- const { baseUrl = DEFAULT_BASE_URL, owner, repo, token } = options;
34
+ const { baseUrl = PUBLIC_API_URL, owner, repo, token } = options;
32
35
  const headers = new Headers({ Accept: "application/vnd.github+json" });
36
+ // An Enterprise instance can be configured on plain HTTP; a bearer token is
37
+ // never worth putting on the wire in cleartext, so it is dropped rather than
38
+ // sent. The request still goes out — a public repo's counts render either way
39
+ // — but a private repo's card comes back bare, which looks exactly like a
40
+ // network failure, so say why once per base.
33
41
  if (token) {
34
- headers.set("Authorization", `Bearer ${token}`);
42
+ if (new URL(baseUrl).protocol === "https:") {
43
+ headers.set("Authorization", `Bearer ${token}`);
44
+ } else if (!warnedCleartext.has(baseUrl)) {
45
+ warnedCleartext.add(baseUrl);
46
+ console.warn(
47
+ `[blume] <GithubInfo> is not sending its token to ${baseUrl}: the API base is plain HTTP, so the bearer token would travel in cleartext. Counts for a private repository will be missing; serve the API over https to authenticate.`
48
+ );
49
+ }
35
50
  }
36
51
 
37
52
  const response = await fetch(`${baseUrl}/repos/${owner}/${repo}`, {
@@ -69,7 +84,7 @@ const loadSafe = async (
69
84
  export const fetchRepositoryInfo = (
70
85
  options: FetchRepositoryOptions
71
86
  ): Promise<RepositoryInfo | null> => {
72
- const key = `${options.baseUrl ?? DEFAULT_BASE_URL}/${options.owner}/${options.repo}`;
87
+ const key = `${options.baseUrl ?? PUBLIC_API_URL}/${options.owner}/${options.repo}`;
73
88
  const existing = cache.get(key);
74
89
  if (existing) {
75
90
  return existing;
@@ -3,13 +3,14 @@
3
3
  * blocks, page actions, color swatches, prompts, API panels, Ask AI). One
4
4
  * implementation owns the invariants each site used to hand-roll:
5
5
  *
6
- * - the clipboard write is guarded, and nothing flashes on failure — a
7
- * confirmation must never lie;
6
+ * - the clipboard write is guarded and a confirmation must never lie: a
7
+ * site either shows nothing on failure or (the page actions) shows a
8
+ * failure label, but never a false "Copied";
8
9
  * - repeat copies restart the hold instead of stacking timers, so the copied
9
10
  * state never reverts early after a double-click;
10
- * - every successful copy is announced to a shared polite live region, so the
11
- * confirmation is audible, not just visual (previously only the code-block
12
- * button announced).
11
+ * - every flash is announced to a shared polite live region, so the
12
+ * confirmation — or the failure — is audible, not just visual (previously
13
+ * only the code-block button announced).
13
14
  */
14
15
 
15
16
  /** How long the copied confirmation holds before reverting. */
@@ -35,17 +36,100 @@ export const announceCopied = (message: string): void => {
35
36
  };
36
37
 
37
38
  /**
38
- * Copy `text` to the clipboard. Returns whether the write succeeded; failures
39
- * (insecure context, permissions) are swallowed so callers can simply skip
40
- * their confirmation.
39
+ * The legacy copy path: select `text` in an off-screen textarea and run the
40
+ * `copy` editing command. It needs no clipboard permission, only the user
41
+ * activation the click already provides, so it covers the places the async
42
+ * Clipboard API doesn't reach — in-app browsers and WebViews that ship no
43
+ * `navigator.clipboard`, insecure origins, and a denied permission prompt.
44
+ */
45
+ const copyViaCommand = (text: string): boolean => {
46
+ const previous = document.activeElement;
47
+ const textarea = document.createElement("textarea");
48
+ textarea.value = text;
49
+ textarea.setAttribute("readonly", "");
50
+ textarea.setAttribute("aria-hidden", "true");
51
+ // Off-screen rather than `display: none`: hidden controls can't be selected.
52
+ textarea.style.position = "fixed";
53
+ textarea.style.top = "0";
54
+ textarea.style.left = "-9999px";
55
+ textarea.style.opacity = "0";
56
+ // Beside the focused control, not on `<body>`: `select()` moves focus to the
57
+ // textarea, and a copy button inside a light-dismissed `<details>` menu (the
58
+ // MCP actions) would otherwise see focus leave the panel — closing the menu
59
+ // mid-click and hiding the label the outcome is about to flash on.
60
+ const host =
61
+ previous instanceof HTMLElement && previous.parentElement
62
+ ? previous.parentElement
63
+ : document.body;
64
+ host.append(textarea);
65
+ textarea.select();
66
+ // iOS WebKit has been known to ignore `select()` on a readonly textarea;
67
+ // an explicit range covers it (the same belt-and-braces clipboard.js uses).
68
+ textarea.setSelectionRange(0, text.length);
69
+ let copied = false;
70
+ try {
71
+ copied = document.execCommand("copy");
72
+ } catch {
73
+ // Some engines throw instead of returning false; either way it failed.
74
+ }
75
+ textarea.remove();
76
+ // `select()` moved focus to the textarea; put it back on the button so a
77
+ // keyboard user isn't dropped at the top of the document.
78
+ if (previous instanceof HTMLElement) {
79
+ previous.focus();
80
+ }
81
+ return copied;
82
+ };
83
+
84
+ /**
85
+ * Copy `text` to the clipboard. Returns whether the write succeeded. The async
86
+ * Clipboard API is tried first; when it is missing (in-app browsers, insecure
87
+ * contexts) or rejects (a denied permission), the legacy `copy` command is
88
+ * tried before giving up, so callers only see `false` when nothing worked and
89
+ * can show a failure instead of silently doing nothing.
41
90
  */
42
91
  export const copyText = async (text: string): Promise<boolean> => {
43
92
  try {
44
93
  await navigator.clipboard.writeText(text);
45
94
  return true;
46
95
  } catch {
47
- return false;
96
+ return copyViaCommand(text);
97
+ }
98
+ };
99
+
100
+ /**
101
+ * Copy text that still has to be loaded — the page's Markdown mirror, fetched
102
+ * on click. Safari and Firefox only honor a clipboard write issued inside the
103
+ * click's own task: awaiting the load first lands the write outside the user
104
+ * activation, so `writeText` rejects and the legacy command returns `false`
105
+ * even though the clipboard is perfectly available. `ClipboardItem` accepts a
106
+ * promise for its payload, so the write is issued synchronously with the
107
+ * load still in flight and the activation intact. Engines without it (or a
108
+ * write that rejects for any reason, a failed load included) fall back to the
109
+ * awaited {@link copyText}, which is what Chrome's longer activation window
110
+ * already tolerated. A load failure still throws, so the caller can report
111
+ * it rather than a clipboard problem.
112
+ */
113
+ export const copyDeferredText = async (
114
+ load: () => Promise<string>
115
+ ): Promise<boolean> => {
116
+ const text = load();
117
+ if ("ClipboardItem" in globalThis) {
118
+ try {
119
+ await navigator.clipboard.write([
120
+ new ClipboardItem({
121
+ "text/plain": text.then(
122
+ (value) => new Blob([value], { type: "text/plain" })
123
+ ),
124
+ }),
125
+ ]);
126
+ return true;
127
+ } catch {
128
+ // Fall through to the awaited write; if the load itself failed, the
129
+ // `await` below rethrows that error for the caller.
130
+ }
48
131
  }
132
+ return copyText(await text);
49
133
  };
50
134
 
51
135
  /**
@@ -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
+ };
@@ -176,7 +176,7 @@ const AskAI = ({
176
176
  useEffect(() => {
177
177
  // The initial null→body flip is deliberate (there is no body during SSR);
178
178
  // it is the same one-time post-mount cascade the old `mounted` flag had.
179
- // oxlint-disable-next-line react/react-compiler -- deliberate post-mount portal-target initialization
179
+ // oxlint-disable-next-line react/react-compiler, react/set-state-in-effect -- deliberate post-mount portal-target initialization
180
180
  setPortalTarget(document.body);
181
181
  const onSwap = () => setPortalTarget(document.body);
182
182
  document.addEventListener("astro:after-swap", onSwap);
@@ -263,6 +263,7 @@ const AskAI = ({
263
263
  if (open && portalTarget) {
264
264
  document.body.dataset.blumeAsk = "open";
265
265
  }
266
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- see above
266
267
  }, [open, portalTarget]);
267
268
 
268
269
  // Below the desktop dock breakpoint the open panel is a full-width overlay,
@@ -328,12 +329,14 @@ const AskAI = ({
328
329
  // portalTarget: each swap installs a new <body>, so the sweep and its
329
330
  // observer must re-run against the new children (the old ones are
330
331
  // detached).
332
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- see above
331
333
  }, [open, portalTarget]);
332
334
 
333
335
  // Keep the newest message in view as it streams in — and after a swap, when
334
336
  // the re-portaled panel's scroll container is reborn at the top.
335
337
  useEffect(() => {
336
338
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
339
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- both deps are triggers, not values read here: re-run per streamed message and per re-portaled panel
337
340
  }, [messages, portalTarget]);
338
341
 
339
342
  const runQuestion = (raw: string) => {
@@ -57,7 +57,7 @@ const useClientData = (): BlumeClientData | null => {
57
57
  // Intentional post-mount hydration guard: `null` on the server and first
58
58
  // client render so hydration matches, then the snapshot once mounted. The
59
59
  // extra render is required; do not seed the initial value from the DOM.
60
- // oxlint-disable-next-line react/react-compiler, react-doctor/no-initialize-state -- deliberate SSR hydration guard
60
+ // oxlint-disable-next-line react/react-compiler, react/set-state-in-effect, react-doctor/no-initialize-state -- deliberate SSR hydration guard
61
61
  useEffect(() => setData(readClientData()), []);
62
62
  return data;
63
63
  };
@@ -109,6 +109,7 @@ export const useSearch = (): UseSearch => {
109
109
  // Before the lazy client import: the first search's heaviest phase is
110
110
  // creating the provider client (index download), and it must show loading.
111
111
  setLoading(true);
112
+ // oxlint-disable-next-line react/todo -- React Compiler cannot lower try/finally; the hook stays manually memoized above
112
113
  try {
113
114
  if (!searchFn.current) {
114
115
  const { createSearch } = await import("blume:search-client");
@@ -250,6 +251,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
250
251
  assistant.content = errorMessage;
251
252
  setMessages([...history, { ...assistant }]);
252
253
  }
254
+ // oxlint-disable-next-line react/todo -- React Compiler cannot lower try/finally; the hook stays manually memoized above
253
255
  } finally {
254
256
  if (live()) {
255
257
  setLoading(false);
@@ -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>