# An Expressive Code plugin that labels command output — the gutter API and the copy payload

> Building an Expressive Code plugin for Astro that marks command output: the gutter element API, plugin ordering, and rewriting the copy button payload.

- Source: https://oharu121.com/blog/expressive-code-in-out-gutter-labels-copy-button-plugin/
- Published: 2026-09-10T20:34:34+09:00
- Tags: Astro, TypeScript, Expressive Code

---
## Introduction

Claude Code labels a command `IN` and its result `OUT` in its own transcript, and I wanted the same in this blog's code blocks. Bash output here was written as a `#` comment in the same block. It reads fine but brings two problems:

1. **A reader cannot tell an instruction from printed output.** Both are spelled `#`.
2. **Neither can the copy button.** It was shipping the printed line inside the command you paste.

*Image: Claude Code's transcript: a shell command on one line labelled IN, and below a horizontal rule, its two lines of output labelled OUT, with both labels in a narrow left-hand gutter*

*Claude Code's own transcript, not this site. The labels sit in a gutter beside the code rather than in the code, which is the part worth stealing.*

The fix is an Expressive Code plugin that adds an `out={…}` option to a fence:

```bash
gh api repos/oharu121/<repo-name>/automated-security-fixes
# {"enabled":true,"paused":false}
```

It uses three extension points:

1. **A gutter element** carries the IN/OUT labels.
2. **A post-render rewrite** takes the output back out of the copy button.
3. **A specificity override** drops Shiki's colouring on output lines.

This article walks through those three, what the marker refuses, and the two obstacles I faced.

## Why Expressive Code's comment-stripping is off here

Expressive Code already handles problem 2. Its frames plugin strips whole-line `#` comments from a copied terminal frame, on by default:

```ts title="@expressive-code/plugin-frames/dist/index.js"
if (options.removeCommentsWhenCopyingTerminalFrames && isTerminal) {
  codeToCopy = codeToCopy.replace(/(?<=^|\n)\s*#.*($|\n+)/g, "").trim();
}
```

This repo turns it off, deliberately: most `#` lines here are the instructions, not noise. With the option off, those instructions stay on the clipboard. **The trade-off is that output lines stay too.**

*Figure — OverloadedHash: The same character in both blocks. With comment-stripping off, the copy button treats them identically, so the reader pastes a command with a JSON response glued to the end of it.*

In the end, **neither setting is right**:

- Off copies the instructions, and the output with them.
- On strips the output, and the instructions with it.

Input and output have to be marked apart, the way Claude Code does it. A divider between input and output lets the copy button take the input only, and the instructions live in the input. Expressive Code has the extension points for exactly that.

## The three extension points I made

Three parts of Expressive Code's API carry the feature, and they fire at different points in the render.

### A gutter element for the labels

Expressive Code exposes the gutter its line-numbers plugin uses, and **any plugin can add to it**. `addGutterElement` takes three things:

1. `renderLine` builds the cell for each line: the IN label, the OUT label, or an empty one.
2. `renderPlaceholder` builds it for lines the engine inserts itself, and here it is always empty.
3. `renderPhase` orders the cells when several plugins add to the gutter, and here nothing else does.

```ts title="src/lib/expressive-code-io.ts"
context.addGutterElement({
  renderPhase: "earlier",
  renderLine: ({ lineIndex }) => {
    const run =
      lineIndex === 0 ? firstOut : lineIndex === firstOut ? lines.length - firstOut : 0;
    if (!run) return h("div.io-label", "");
    const label = lineIndex === 0 ? texts.inputLabel : texts.outputLabel;
    return h("div.io-label", { style: `--io-run:${run}` }, label);
  },
  renderPlaceholder: () => h("div.io-label", ""),
});
```

*Figure — GutterApi: One call per line, one cell back. renderLine does all the work here; renderPlaceholder only fires for a line no source file has, and renderPhase has nothing to be ordered against.*

One hard constraint shapes the CSS later: **every line's gutter must be the same width**, or the code stops lining up. A line with no label still renders a cell, sized rather than left to its content.

Labels go through `PluginTexts`, the class frames uses for its own strings, so three locales register once in `astro.config.mjs`. `getBlockLocale` already resolves the locale from the filename, so **a Japanese page renders 入力 and 出力 with no extra work**.

### Plugin order, and the copy button rewrite

Registered plugins run **after** the built-in ones, because the built-ins are prepended rather than appended:

```ts title="expressive-code/dist/index.js"
const pluginsWithDefaults = [...pluginsToPrepend, ...baseConfig.plugins || []];
```

`pluginsToPrepend` is Shiki, then text markers, then frames. Hooks run in plugin order, so by the time a hook in `plugins: [pluginIo()]` fires, all three have already had their turn on the same block.

*Figure — PluginOrder: Two hooks matter here. In annotateCode the block's lines and metadata are settled; in postprocessRenderedBlock the frame and its copy button already exist as a HAST tree that a later plugin can edit.*

That ordering is what the copy rewrite rests on. **This plugin never builds the copy button**; frames already did, and `postprocessRenderedBlock` reaches into the rendered tree to change what it holds.

Validation lives in `annotateCode`. Before that hook, a plugin can still add or remove lines in the block; `annotateCode` is the first one to run after all of them, so **the line numbers it checks cannot be shifted afterwards**.

### A specificity override on Shiki's colours

Output lines lose their syntax colouring, and **an ordinary class selector does it without `!important`**. Shiki never writes a colour on the span: it writes an inline custom property, and a separate rule turns that variable into the colour you see:

```css
.ec-line :where(span[style^='--']:not([class])) {
  color: var(--0, inherit);
}
```

**`:where()` contributes nothing to specificity**, so that rule scores as a single class. The override here counts three classes, an attribute and a type, so it takes precedence:

```css
.ec-line.is-out .code span[style^="--"] {
  color: inherit;
  background-color: transparent;
  font-style: inherit;
  font-weight: inherit;
}
```

This is worth the trouble because a `#` comment rendered uniformly, and **it is the one place the old convention beat the new marker**. Bash tokenisation colours a JSON response's punctuation at random, inventing structure the output does not have. Targeting `span[style^="--"]` leaves the text-markers plugin's own spans alone.

## Validation, and the plain-text twin

Neither of these is a rendering concern, and both are the kind of thing that only shows up once the marker is in an article rather than in a test.

### Why the marker only accepts trailing lines

**Six shapes fail the build, and the first is the one you hit by habit**: several command and result pairs in one block. An earlier version allowed it, and the frame read IN, OUT, IN, OUT, IN, OUT. I rejected that, so the marked lines now have to be the block's trailing run and each pair gets its own block.

**The rule is enforced rather than documented.** An interleaved frame is plainly wrong to look at, and an author can still write one by habit:

```ts title="src/lib/expressive-code-io.ts"
if (!outLines.has(lines.length - 1) || outLines.size !== lines.length - firstOut) {
  throw new Error(
    "`out=` marks one command and its result, so the output must be the " +
      "block's trailing lines. Split a block that pairs several commands " +
      "with their results into one block per pair. …",
  );
}
```

On a three-line block, five more shapes fail the build:

| Marker | Why it fails |
| --- | --- |
| `out={x}` | Not a number. |
| `out={4-2}` | Runs backwards. |
| `out={9}` | Past the end of the block. |
| `out={1-3}` | Every line is output, so nothing is a command. |
| `out=2` | No braces, so `getRanges` never returns it. |

The last one is the subtle one. An unbraced marker parses as a different kind of meta option and comes back as an empty list, which looks exactly like a fence that never asked for a marker. `metaOptions.list("out")` is kind-agnostic and tells the two apart.

Failing loudly matters because **a marker that silently matches nothing puts output back in the clipboard**, the exact bug the plugin removes, and it would ship looking correct.

### Keeping the plain-text twin readable

**A fence marker leaks into every other output you have.** Every article here has a `.md` twin, and `out={…}` means nothing in plain Markdown. An unmarked output line there reads as a second command, so the twin generator converts the marker back into `#`-prefixed lines. That is the form these articles always used, and the twin is what answer engines read.

The conversion runs on the raw source, before the existing code-masking pass, which makes a fence quoted inside another fence safe. An article documenting this syntax puts its example in a four-backtick block, and the outer fence matches first with no `out=` in its info string:

````markdown
```bash out={2}
gh api repos/oharu121/<repo-name>/automated-security-fixes
{"enabled":true,"paused":false}
```
````

The check that mattered compared every fence body in the twin against the pre-change source, byte for byte, with no whitespace normalising. **A leading space in `  26M` is not decoration**, because `du -sh` right-aligns its size field.

## The two obstacles I faced

One produced a page that looked right and copied the wrong text. The other looked wrong on sight and took a measurement to explain.

### Why writing `data-code` added a second attribute

The copy-button rewrite carried a bug. Writing the new payload to `properties["data-code"]` **added a second `data-code` attribute rather than replacing the first**, and HTML resolves a duplicate attribute to the one that appears first. The button silently went on copying the output.

`hastscript` normalises `data-*` names to camelCase, so frames' `h("button", { "data-code": … })` lands in the tree as `dataCode`. A literal `"data-code"` key is a different key, and both serialise:

```html
<button data-code="gh api …&#x7f;{&quot;enabled&quot;:true}" data-code="gh api …">
```

**Nothing failed.** The page rendered, the button worked, and it copied the wrong thing. What gave it away was counting `data-code` in the serialised HTML and finding two, in a harness that rendered a block through the engine directly.

The fix overwrites whichever key is actually present, rather than assuming either:

```ts title="src/lib/expressive-code-io.ts"
const key = "data-code" in button.properties ? "data-code" : "dataCode";
button.properties[key] = inputCode.replace(/\n/g, "\x7F");
```

**The payload is rebuilt from the block's own lines**, never parsed back out of the attribute. What frames put there depends on `removeCommentsWhenCopyingTerminalFrames`, so reading it would tie this plugin's correctness to that setting staying off. `\x7F` is frames' own newline placeholder, mapped back by its client script before the write.

### Sizing the gutter against the block

The spacing around the divider comes from Expressive Code's own `codePaddingBlock`, **not from this site's spacing scale**. The first attempt used `--space-3`, a 12px design token, and read wrong on sight: both rows sat closer to the rule than to the edges of the block.

The numbers said the same thing. `codePaddingBlock` is `1rem`, so **each row had 16px at the block edge and 6px at the divider**. Twice the block's own padding, rule centred in it, makes all four gaps equal:

```css
.ec-line.is-io-boundary {
  padding-block-start: calc(2 * var(--ec-codePaddingBlock));
}

.ec-line.is-io-boundary::before {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: var(--ec-codePaddingBlock);
  height: 1px;
}
```

*Figure — GutterAnatomy: The three constraints the gutter has to satisfy at once. Every cell is the same width, the divider's two gaps match the block's own padding, and a label sits at the centre of its run rather than on the run's first line.*

Centring a label over a multi-line run is the other half. A gutter element renders per line, so the label goes on the run's first line and shifts down by half the run:

```css
.io-label {
  height: 100%;
  display: flex;
  align-items: center;
  transform: translateY(calc((var(--io-run, 1) - 1) * 50%));
}
```

**`height: 100%` is load-bearing there.** A `translateY` percentage resolves against the element's own height, and `.gutter` is a stretched grid item exactly one code line tall. Left to its own content the label would be one `--font-size-1` line box, and every multi-line run would land short. Measured offset from each run's centre: **0px for runs of one, two, three and four lines**.

## Summary

An Expressive Code plugin has **more reach than the styling options in `astro.config.mjs` suggest**. Three extension points carried this feature:

1. `addGutterElement` puts content in the line-numbers plugin's gutter, under one rule: **every cell the same width**.
2. **Plugin order** puts your `postprocessRenderedBlock` after the built-ins', so the frame and copy button are already rendered and yours to edit.
3. **Zero-specificity `:where()`** in the core stylesheet means an ordinary class selector overrides Shiki's colours, no `!important` needed.

Two things cost more than they should have:

1. **A duplicate `data-code` attribute.** The page looked right and copied the wrong text; the only tell was counting the attribute in the serialised HTML.
2. **A 6px gap against a 16px one.** That is what a component gets when its internal rhythm comes from a design scale outside it rather than the padding it already has.

## References

- [Expressive Code: plugin API reference, including `addGutterElement` and the hook list](https://expressive-code.com/reference/plugin-api/)
- [Expressive Code: the frames plugin and its `removeCommentsWhenCopyingTerminalFrames` option](https://expressive-code.com/key-features/frames/)
- [MDN on duplicate attributes in HTML parsing, where the first occurrence wins](https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute)
- [MDN on `:where()` and its zero specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/:where)
- [hastscript, which normalises `data-*` attribute names to camelCase properties](https://github.com/syntax-tree/hastscript)
