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.

On this page
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:
- A reader cannot tell an instruction from printed output. Both are spelled
#. - Neither can the copy button. It was shipping the printed line inside the command you paste.

The fix is an Expressive Code plugin that adds an out={…} option to a fence:
INgh api repos/oharu121/<repo-name>/automated-security-fixesOUT{"enabled":true,"paused":false}It uses three extension points:
- A gutter element carries the IN/OUT labels.
- A post-render rewrite takes the output back out of the copy button.
- 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:
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.
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:
renderLinebuilds the cell for each line: the IN label, the OUT label, or an empty one.renderPlaceholderbuilds it for lines the engine inserts itself, and here it is always empty.renderPhaseorders the cells when several plugins add to the gutter, and here nothing else does.
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", ""),});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:
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.
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:
.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:
.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:
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:
```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:
<button data-code="gh api …{"enabled":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:
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:
.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;}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:
.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:
addGutterElementputs content in the line-numbers plugin’s gutter, under one rule: every cell the same width.- Plugin order puts your
postprocessRenderedBlockafter the built-ins’, so the frame and copy button are already rendered and yours to edit. - Zero-specificity
:where()in the core stylesheet means an ordinary class selector overrides Shiki’s colours, no!importantneeded.
Two things cost more than they should have:
- A duplicate
data-codeattribute. The page looked right and copied the wrong text; the only tell was counting the attribute in the serialised HTML. - 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
addGutterElementand the hook list - Expressive Code: the frames plugin and its
removeCommentsWhenCopyingTerminalFramesoption - MDN on duplicate attributes in HTML parsing, where the first occurrence wins
- MDN on
:where()and its zero specificity - hastscript, which normalises
data-*attribute names to camelCase properties





