ehsan.blog
~/blog/how-to-build-a-code-block-toolbar-rehype-plugin — zsh
cat how-to-build-a-code-block-toolbar-rehype-plugin.md

How to build a code-block toolbar (copy + fullscreen) with a rehype plugin

·6 min read

Every code block on this blog has a little terminal-style bar across the top: the language name on the left, and “fullscreen” plus “copy” buttons on the right. That toolbar isn’t added by JavaScript after the page loads — it’s baked into the HTML at build time by a rehype plugin. In this post I’ll walk through the real plugin that does it, why building the markup at build time matters, and how the buttons get wired up on the client.

rehype/hast vs remark/mdast

If you’ve written a remark plugin, this will feel familiar — but there’s an important difference.

  • remark works on mdast, the Markdown syntax tree. It’s the right place for logic about headings, paragraphs, and word counts.
  • rehype works on hast, the HTML syntax tree. By the time rehype runs, your Markdown has already been converted toward HTML — so nodes look like element with a tagName of "pre", "code", "figure", etc.

We want to wrap a code block’s <pre> in a <figure>. That’s an HTML-shaped transformation, so it belongs in rehype, not remark. Both kinds of plugin only run once you’ve opted into the unified pipeline in Astro 7.

Building HTML with hastscript

Constructing hast nodes by hand (writing out { type: "element", tagName: "figure", properties: {...}, children: [...] }) is painful. The hastscript package gives us two helpers:

  • h(tag, props, children) builds an HTML element.
  • s(tag, props, children) builds an SVG element (needed for our icon paths).

Here’s the copy icon, built entirely with s:

mjs
// blog/src/utils/rehype-code-toolbar.mjs
import { h, s } from "hastscript"

const copyIcon = s(
  "svg",
  {
    class: "code-block__icon",
    viewBox: "0 0 24 24",
    width: 14, height: 14,
    fill: "none", stroke: "currentColor",
    "stroke-width": 2,
    "aria-hidden": "true",
  },
  [
    s("rect", { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
    s("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" }),
  ],
)

Note aria-hidden="true" on the icon — it’s decorative, so screen readers skip it. The button itself carries the accessible label, which we’ll get to.

Walking the tree

To find every code block, we walk the hast tree with unist-util-visit, which calls our function for each matching node:

mjs
import { visit } from "unist-util-visit"

export function rehypeCodeToolbar() {
  return function (tree) {
    visit(tree, "element", (node, index, parent) => {
      if (node.tagName !== "pre" || !parent || index === null) return
      // ...
    })
  }
}

Like a remark plugin, this is a function that returns a transformer. visit hands us each element node plus its parent and its index in the parent’s children — we need those to replace the node later.

Skipping the blocks we shouldn’t touch

Not every <pre> should get a toolbar. The plugin guards against three cases:

mjs
// Already wrapped in our figure? Leave it (avoids re-wrapping on revisit).
if (parent.tagName === "figure" && hasClass(parent.properties, "code-block"))
  return
// Mermaid diagrams are handled by a different plugin.
if (hasClass(node.properties, "mermaid")) return
// Must actually contain a <code> child (a fenced code block).
const hasCode = node.children?.some(
  (c) => c.type === "element" && c.tagName === "code",
)
if (!hasCode) return

The hasClass helper is just a small utility because a node’s className can be either an array or a space-separated string, and we need to handle both. Skipping mermaid matters because those blocks become diagrams, not code — wrapping them in a copy toolbar would be nonsense. (They get rewritten into <pre class="mermaid"> by a separate remark plugin.)

Detecting the language

Shiki (Astro’s syntax highlighter, which you configure through shikiConfig) puts the language in a language-js-style class on the <code> element. We pull it back out:

mjs
const langFromCode = (pre) => {
  const code = pre.children?.find(
    (c) => c.type === "element" && c.tagName === "code",
  )
  const list = /* the code element's className, normalized to an array */
  const hit = list.find((cls) => cls.startsWith("language-"))
  return hit ? hit.slice("language-".length) : null
}

const lang = node.properties?.dataLanguage || langFromCode(node) || "text"

We try a data-language attribute first, fall back to the language-* class, and default to "text" so the label is never empty.

Building and swapping in the figure

Now the payoff — we build the whole toolbar with h and put the original <pre> (the node) inside it:

mjs
const figure = h("figure", { class: "code-block", "data-lang": lang }, [
  h("figcaption", { class: "code-block__bar" }, [
    h("span", { class: "code-block__lang" }, lang),
    h("div", { class: "code-block__actions" }, [
      h("button", {
        type: "button",
        class: "code-block__btn",
        "data-code-expand": "",
        "aria-label": "View code fullscreen",
        title: "Fullscreen",
      }, [expandIcon]),
      h("button", {
        type: "button",
        class: "code-block__btn",
        "data-code-copy": "",
        "aria-label": "Copy code",
        title: "Copy",
      }, [copyIcon, h("span", { class: "code-block__btn-label" }, "copy")]),
    ]),
  ]),
  node,
])

parent.children[index] = figure

Two accessibility details worth calling out: each button gets a real aria-label (so a screen reader announces “Copy code”, not just an icon), and the buttons are marked with plain data attributes — data-code-expand and data-code-copy — rather than IDs or classes tied to behavior. Those data attributes are the hooks the client script looks for.

The final line replaces the original <pre> in its parent with the new <figure>.

Why emit the markup at build time?

This is the whole point. The toolbar HTML ships inside the prerendered page. That means:

  • No layout shift. The bar is already there on first paint; nothing jumps when JS loads.
  • It’s visible before (or without) JavaScript. The language label and buttons render regardless. Only the click behavior needs JS.
flowchart LR
  A[Shiki emits pre + code] --> B[rehypeCodeToolbar wraps in figure]
  B --> C[toolbar HTML in prerendered page]
  C --> D[PostEnhancements.astro wires clicks]

Wiring the buttons on the client

The plugin only draws the toolbar. The behavior lives in a client script (PostEnhancements.astro). It finds the buttons by their data attributes and attaches handlers:

ts
document.querySelectorAll("[data-code-copy]").forEach((btn) => {
  if (btn.dataset.wired) return
  btn.dataset.wired = "1"
  btn.addEventListener("click", async () => {
    const code = btn.closest(".code-block")?.querySelector("code")?.textContent
    if (!code) return
    await navigator.clipboard.writeText(code)
    // swap the label to "copied" for ~1.6s, then restore it
  })
})

The btn.dataset.wired check prevents double-binding. The copy handler reads the raw text out of the sibling <code> element and writes it to the clipboard (with a textarea fallback for older browsers). The fullscreen button does something similar — it grabs the <pre> and opens it in a zoomable lightbox overlay.

Takeaways

  • rehype works on hast (HTML tree); use it when your transformation is about HTML structure, not Markdown.
  • Use hastscript’s h for HTML and s for SVG instead of hand-writing node objects.
  • Guard your visit so you skip already-wrapped, mermaid, and non-code <pre> elements.
  • Emit interactive markup at build time so there’s no layout shift and it degrades gracefully without JS.
  • Give buttons real aria-labels and hook client behavior via data-* attributes.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.