blume 1.6.2 → 1.6.3

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.
@@ -251,7 +251,7 @@ Lay cards or blocks out in a responsive grid of equal columns that reflows on mo
251
251
 
252
252
  ## CodeGroup
253
253
 
254
- Group several code blocks into one tabbed switcher — a tab per language or file. The tab label is each block's title (the text after the language). Add `dropdown` to switch with a menu instead of a tab bar.
254
+ Group several code blocks into one tabbed switcher — a tab per language or file. The tab label is each block's title (the text after the language), and the group's copy button sits in the tab bar and copies the block that is showing. Add `dropdown` to switch with a menu instead of a tab bar.
255
255
 
256
256
  <CodeGroup>
257
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -10,10 +10,7 @@
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
+ import { CODE_PADDING_BLOCK_REM } from "../../theme/code-block-padding.ts";
17
14
 
18
15
  import { highlightCode } from "../../markdown/index.ts";
19
16
  import { withBase } from "../islands/base-path.ts";
@@ -63,11 +60,10 @@ const codeHtml = entry
63
60
  // readers aren't hurt by the cap, since the source scrolls at any height.
64
61
  const LINE_PX = 21;
65
62
  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;
63
+ // The pre's vertical padding, from the same constant the theme emits: the
64
+ // plain inset above and below (the pane is a flush block inside tabs, with no
65
+ // language bar). The tab panel and the pre carry no border of their own.
66
+ const PADDING_PX = 2 * CODE_PADDING_BLOCK_REM * REM_PX;
71
67
  const ESTIMATE_MAX_PX = 400;
72
68
  // The floor also clamps the measured height client-side; it rides along on the
73
69
  // iframe as `data-blume-min-pane` so the script and this estimate can't drift.
@@ -62,19 +62,34 @@ const useDropdown = dropdown && !inline;
62
62
  data-sync={sync ? "true" : "false"}
63
63
  data-sync-key={syncKey}
64
64
  >
65
- <div
66
- class:list={[
67
- inline ? "not-prose" : "",
68
- useDropdown ? "" : "overflow-x-auto",
69
- ]}
70
- >
65
+ {/*
66
+ Header row: the scrolling tab strip (or the dropdown) grows, and an
67
+ actions slot sits fixed at the inline end, outside the scroller so it
68
+ never rides along with an overflowing strip. The docs layout fills it
69
+ with one copy button when every panel is a bare code block (CodeGroup,
70
+ ts2js pairs) — the strip is that group's chrome, so the button belongs
71
+ there rather than painted over the first line of each block. Empty
72
+ otherwise, and hidden so it adds no padding.
73
+ */}
74
+ <div class:list={["flex", inline ? "not-prose" : ""]}>
75
+ <div class:list={["min-w-0 grow", useDropdown ? "" : "overflow-x-auto"]}>
76
+ <div
77
+ class:list={[
78
+ useDropdown || borderBottom ? "border-border border-b" : "",
79
+ useDropdown ? "p-3" : inline ? "flex gap-4" : "flex gap-4 px-3",
80
+ ]}
81
+ data-blume-tablist
82
+ role={useDropdown ? undefined : "tablist"}
83
+ >
84
+ </div>
85
+ </div>
71
86
  <div
72
87
  class:list={[
88
+ "flex shrink-0 items-center gap-2 ps-2 empty:hidden",
89
+ inline ? "" : "pe-3",
73
90
  useDropdown || borderBottom ? "border-border border-b" : "",
74
- useDropdown ? "p-3" : inline ? "flex gap-4" : "flex gap-4 px-3",
75
91
  ]}
76
- data-blume-tablist
77
- role={useDropdown ? undefined : "tablist"}
92
+ data-blume-tab-actions
78
93
  >
79
94
  </div>
80
95
  </div>
@@ -798,9 +798,55 @@ const bannerKey = banner?.dismissible ? banner.key : null;
798
798
  // Opaque icon chip over the code (in the style of Lina's code block),
799
799
  // with the check icon scale-swapping in over the copy icon after a copy.
800
800
  const buttonClass =
801
- "absolute right-3 z-[2] inline-flex size-[1.875rem] select-none items-center justify-center rounded-md text-muted-foreground bg-background transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0";
801
+ "inline-flex size-[1.875rem] select-none items-center justify-center rounded-md text-muted-foreground bg-background transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0";
802
+ // Pinned over the block's top-right corner when the button lives in
803
+ // the pre; a strip-hosted button (see codeGroupActions) is in flow.
804
+ const overlayClass = "absolute right-3 z-[2]";
802
805
  const idleClasses = ["hover:bg-muted", "hover:text-foreground"];
803
806
 
807
+ // A tab panel that is nothing but code: a CodeGroup/ts2js block (the
808
+ // pre is the panel) or a <Tab> holding only fences and the icon
809
+ // template — the same predicate Tab.astro's flush-padding selector uses.
810
+ const isCodePanel = (panel: Element) =>
811
+ panel.matches("pre, figure") ||
812
+ (panel.matches("[data-blume-tab-panel]") &&
813
+ panel.querySelector(":scope > pre") !== null &&
814
+ Array.from(panel.children).every((child) =>
815
+ child.matches("pre, template")
816
+ ));
817
+
818
+ // The tab group's actions slot, when the group is a code switcher —
819
+ // every panel a bare block — so one copy button in the strip serves
820
+ // whichever panel is showing. A group with prose panels (or none) gets
821
+ // null and keeps a button in each pre.
822
+ const codeGroupActions = (pre: Element) => {
823
+ const tabs = pre.closest("blume-tabs");
824
+ const content = tabs?.querySelector(":scope > [data-blume-tab-content]");
825
+ const panels = content ? Array.from(content.children) : [];
826
+ if (!tabs || panels.length === 0 || !panels.every(isCodePanel)) {
827
+ return null;
828
+ }
829
+ return tabs.querySelector<HTMLElement>(
830
+ ":scope > * > [data-blume-tab-actions]"
831
+ );
832
+ };
833
+
834
+ // The block the strip's copy button should read: the visible panel's
835
+ // pre. Panels toggle a `hidden` class once the tabs element upgrades;
836
+ // before that only the first is painted, and it is also the first
837
+ // match here.
838
+ const activeCodePre = (tabs: Element) => {
839
+ const content = tabs.querySelector(":scope > [data-blume-tab-content]");
840
+ const panel =
841
+ Array.from(content?.children ?? []).find(
842
+ (child) => !child.classList.contains("hidden")
843
+ ) ?? content?.firstElementChild;
844
+ if (!panel) {
845
+ return null;
846
+ }
847
+ return panel.matches("pre") ? panel : panel.querySelector("pre");
848
+ };
849
+
804
850
  const languageLabels: Record<string, string> = {
805
851
  astro: "Astro",
806
852
  bash: "Bash",
@@ -836,41 +882,16 @@ const bannerKey = banner?.dismissible ? banner.key : null;
836
882
  const copiedLabel =
837
883
  document.body.getAttribute("data-i18n-copied") || "Copied!";
838
884
 
839
- for (const pre of document.querySelectorAll(".prose pre")) {
840
- if (pre.querySelector("[data-blume-copy]")) {
841
- continue;
842
- }
843
- // Skip nested <pre>: Twoslash renders each hover popup's type signature
844
- // as a <pre> inside the code block, which shouldn't get its own button.
845
- if (pre.parentElement?.closest("pre")) {
846
- continue;
847
- }
848
- const language = pre.getAttribute("data-language");
849
- if (language) {
850
- pre.setAttribute(
851
- "data-language",
852
- languageLabels[language.toLowerCase()] ?? language
853
- );
854
- }
855
- pre.classList.add("group", "relative");
856
- // The code element is the scroll container (see the theme entry), but
857
- // Shiki's tab stop lands on the pre, which no longer scrolls. Move the
858
- // stop to the code so keyboard users can actually scroll the block
859
- // (WCAG 2.1.1 — the same rule the table wrapper handles). Twoslash and
860
- // API-panel blocks keep the pre as their scroller, so theirs stays.
861
- const scroller = pre.querySelector("code");
862
- if (scroller && !pre.matches(".twoslash, blume-panel-tabs *")) {
863
- scroller.setAttribute("tabindex", "0");
864
- pre.removeAttribute("tabindex");
865
- }
885
+ // `placement` is the extra class set for an overlay button (empty for
886
+ // one hosted by a tab strip); `readPre` resolves the block to copy at
887
+ // click time, since a strip button follows the active panel.
888
+ const createCopyButton = (
889
+ placement: string,
890
+ readPre: () => Element | null
891
+ ) => {
866
892
  const button = document.createElement("button");
867
893
  button.type = "button";
868
- // The language-label bar (prose) vs flush code (tabs) need a different
869
- // offset; pick the Tailwind class by context instead of a CSS override.
870
- const topClass = pre.closest("blume-tabs, .not-prose")
871
- ? "top-2.5"
872
- : "top-2";
873
- button.className = `${buttonClass} ${idleClasses.join(" ")} ${topClass}`;
894
+ button.className = `${buttonClass} ${idleClasses.join(" ")} ${placement}`;
874
895
  button.setAttribute("data-blume-copy", "");
875
896
  button.setAttribute("aria-label", copyCodeLabel);
876
897
  button.innerHTML =
@@ -896,7 +917,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
896
917
  };
897
918
  const flash = createCopyFlash(setChecked, copiedLabel);
898
919
  button.addEventListener("click", async () => {
899
- const code = pre.querySelector("code");
920
+ const code = readPre()?.querySelector("code");
900
921
  let text = code?.textContent ?? "";
901
922
  // Twoslash nests each hover popup's type signature and docs inside
902
923
  // the <code>; copying textContent verbatim would interleave them
@@ -914,7 +935,55 @@ const bannerKey = banner?.dismissible ? banner.key : null;
914
935
  flash();
915
936
  }
916
937
  });
917
- pre.appendChild(button);
938
+ return button;
939
+ };
940
+
941
+ for (const pre of document.querySelectorAll(".prose pre")) {
942
+ if (pre.querySelector("[data-blume-copy]")) {
943
+ continue;
944
+ }
945
+ // Skip nested <pre>: Twoslash renders each hover popup's type signature
946
+ // as a <pre> inside the code block, which shouldn't get its own button.
947
+ if (pre.parentElement?.closest("pre")) {
948
+ continue;
949
+ }
950
+ const language = pre.getAttribute("data-language");
951
+ if (language) {
952
+ pre.setAttribute(
953
+ "data-language",
954
+ languageLabels[language.toLowerCase()] ?? language
955
+ );
956
+ }
957
+ pre.classList.add("group", "relative");
958
+ // The code element is the scroll container (see the theme entry), but
959
+ // Shiki's tab stop lands on the pre, which no longer scrolls. Move the
960
+ // stop to the code so keyboard users can actually scroll the block
961
+ // (WCAG 2.1.1 — the same rule the table wrapper handles). Twoslash and
962
+ // API-panel blocks keep the pre as their scroller, so theirs stays.
963
+ const scroller = pre.querySelector("code");
964
+ if (scroller && !pre.matches(".twoslash, blume-panel-tabs *")) {
965
+ scroller.setAttribute("tabindex", "0");
966
+ pre.removeAttribute("tabindex");
967
+ }
968
+ const actions = codeGroupActions(pre);
969
+ if (actions) {
970
+ // One button per code switcher, reading whichever panel shows.
971
+ if (!actions.querySelector("[data-blume-copy]")) {
972
+ const tabs = actions.closest("blume-tabs");
973
+ actions.appendChild(
974
+ createCopyButton("", () => (tabs ? activeCodePre(tabs) : null))
975
+ );
976
+ }
977
+ continue;
978
+ }
979
+ // The language-label bar (prose) vs flush code (tabs) need a different
980
+ // offset; pick the Tailwind class by context instead of a CSS override.
981
+ const topClass = pre.closest("blume-tabs, .not-prose")
982
+ ? "top-2.5"
983
+ : "top-2";
984
+ pre.appendChild(
985
+ createCopyButton(`${overlayClass} ${topClass}`, () => pre)
986
+ );
918
987
  }
919
988
 
920
989
  // Click-to-zoom for content images (gated by `markdown.imageZoom`),
@@ -6,11 +6,3 @@
6
6
 
7
7
  /** Top and bottom inset of a plain prose block with no chrome, in rem. */
8
8
  export const CODE_PADDING_BLOCK_REM = 1;
9
-
10
- /**
11
- * Top inset of a flush block — inside tabs or a `not-prose` component, or an
12
- * untitled block with no language bar — where the layout's copy button is
13
- * absolutely positioned over the first line: `top-2.5` plus a 1.875rem button
14
- * lands at 2.5rem, so the first line starts there.
15
- */
16
- export const FLUSH_CODE_PADDING_TOP_REM = 2.5;
@@ -1,7 +1,4 @@
1
- import {
2
- CODE_PADDING_BLOCK_REM,
3
- FLUSH_CODE_PADDING_TOP_REM,
4
- } from "./code-block-padding.ts";
1
+ import { CODE_PADDING_BLOCK_REM } from "./code-block-padding.ts";
5
2
 
6
3
  interface TailwindEntryOptions {
7
4
  /**
@@ -614,27 +611,19 @@ blume-tabs pre[data-language],
614
611
  padding-top: ${CODE_PADDING_BLOCK_REM}rem;
615
612
  }
616
613
 
617
- /* Room for the copy button. The docs layout injects it into every \`.prose pre\`
618
- absolutely positioned against the pre's border box, 1.875rem tall, at
619
- \`top-2.5\` in the flush contexts above and \`top-2\` elsewhere and it is
620
- unconditional, so wherever no language bar exists to hold it, it painted
621
- over the first line of code: any line long enough to reach the button's
622
- strip (a curl invocation, an install command, an import path) went under
623
- it. Keyed on the attribute the docs layout stamps on <body>, so the strip
624
- only appears where the injector runs (PageLayout pages run none), and on
625
- \`.astro-code\` so the playground's response pre created after the
626
- injector ran, never given a button keeps its plain inset. The first
627
- selector is the injector's own flush predicate; the second covers the
628
- remaining bar-less Shiki case, an untitled <CodeBlock> in plain prose, from
629
- first paint. The third covers a raw \`<pre><code>\` written in prose — no
630
- \`.astro-code\`, no language bar, but the injector gives it a button all
631
- the same — keyed on that injected button, which is exactly what separates
632
- it from the playground's pre. API panel blocks match, but their own
633
- !important padding wins and they hide the injected button. */
634
- [data-blume-code-copy] :is(blume-tabs, .not-prose) pre.astro-code,
635
- [data-blume-code-copy] .prose pre.astro-code:not([data-language]),
636
- [data-blume-code-copy] .prose pre:not([data-language]):has(> [data-blume-copy]) {
637
- padding-top: ${FLUSH_CODE_PADDING_TOP_REM}rem;
614
+ /* Where no language bar holds the copy button a flush block in tabs or a
615
+ not-prose component, a bar-less block in prose the docs layout still pins
616
+ one over the block's top-right corner, so a first line long enough to reach
617
+ it would end underneath. A code switcher (CodeGroup, ts2js) hosts the button
618
+ in its tab strip instead and needs nothing here; for every block that keeps
619
+ an overlay button, give the scrolling code element enough end padding that
620
+ the line's tail clears the button at the end of its scroll. Keyed on the
621
+ injected button itself (which the playground's client-created response pre
622
+ never gets), and \`.prose\`-scoped to outrank the base \`:where(pre code)\`
623
+ inset. Not a vertical insetthat reserved a strip above every one-line
624
+ command and read as a rendering bug. */
625
+ .prose :is(blume-tabs pre, .not-prose pre, pre:not([data-language])):not(.twoslash, .twoslash pre, blume-panel-tabs *):has(> [data-blume-copy]) > code {
626
+ padding-inline-end: 3.5rem;
638
627
  }
639
628
 
640
629
  blume-tabs pre[data-language]::before,