Expressive Codeでコマンド出力にラベルを付ける — Gutter APIとコピーボタンの書き換え
AstroブログのExpressive Codeプラグインでコマンド出力を示す方法。ガター要素のAPI、プラグインの実行順、そしてコピーボタンの中身を書き換える手順まで。

目次
はじめに
Claude Codeは自分のトランスクリプトで、コマンドをIN、その結果をOUTとラベル付けします。同じものをこのブログのコードブロックでも使いたくなりました。ここではBashの出力を、同じブロックの中に#コメントとして書いていました。読む分には問題ありませんが、2つの問題があります。
- 読者には指示と出力の区別がつきません。 どちらも
#で書かれています。 - コピーボタンにも区別がつきません。 貼り付けるコマンドの中に、出力の行がそのまま入っていました。

解決策は、フェンスにout={…}オプションを追加するExpressive Codeプラグインです。
入力gh api repos/oharu121/<repo-name>/automated-security-fixes出力{"enabled":true,"paused":false}使っている拡張ポイントは3つです。
- ガター要素がIN/OUTのラベルを載せます。
- レンダリング後の書き換えが、コピーボタンから出力を取り除きます。
- 詳細度の上書きが、出力行からShikiの色付けを外します。
本記事ではこの3つと、マーカーが受け付けないもの、そして私が直面した2つの障害を扱います。
Expressive Codeのコメント除去をなぜ無効にしているのか
問題2にはExpressive Code自身が対応しています。framesプラグインは、ターミナル枠をコピーするときに行全体が#のコメントを除去します。これは既定で有効です。
if (options.removeCommentsWhenCopyingTerminalFrames && isTerminal) { codeToCopy = codeToCopy.replace(/(?<=^|\n)\s*#.*($|\n+)/g, "").trim();}このリポジトリでは、それを意図的に無効にしています。ここにある#行のほとんどはノイズではなく指示だからです。無効にしておけば、その指示はクリップボードに残ります。引き換えに、出力の行も残ってしまいます。
結局のところ、どちらの設定も正解ではありません。
- 無効にすると、指示がコピーされ、出力も一緒にコピーされます。
- 有効にすると、出力が除去され、指示も一緒に除去されます。
Claude Codeがそうしているように、入力と出力を分けて示す必要があります。入力と出力のあいだに区切り線があれば、コピーボタンは入力だけを取れます。そして指示は入力の側にあります。Expressive Codeには、まさにそのための拡張ポイントがあります。
私が組み込んだ3つの拡張ポイント
この機能を支えているのはExpressive CodeのAPIの3か所で、それぞれレンダリングの別のタイミングで動きます。
ラベルを載せるガター要素
Expressive Codeは行番号プラグインが使っているガターを公開していて、どのプラグインからでもそこに要素を追加できます。addGutterElementが受け取るのは3つです。
renderLineは各行のセルを作ります。INのラベル、OUTのラベル、あるいは空のセルです。renderPlaceholderはエンジン自身が挿入した行のセルを作ります。ここでは常に空です。renderPhaseは複数のプラグインがガターに追加したときの並び順を決めます。ここでは他に追加するものがありません。
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", ""),});あとのCSSを縛る条件が1つあります。すべての行でガターの幅が同じでなければならない、という条件です。そうでないとコードの位置が揃わなくなります。ラベルのない行にもセルは描かれ、その幅は中身任せではなく指定されています。
ラベルはPluginTextsを通ります。framesプラグインが自分の文言に使っているのと同じクラスなので、3つのロケールはastro.config.mjsで一度登録するだけで済みます。getBlockLocaleはすでにファイル名からロケールを解決しているため、日本語のページでは追加の作業なしに入力と出力が表示されます。
プラグインの実行順と、コピーボタンの書き換え
登録したプラグインは組み込みのプラグインより後に走ります。組み込みのほうが末尾ではなく先頭に差し込まれるからです。
const pluginsWithDefaults = [...pluginsToPrepend, ...baseConfig.plugins || []];pluginsToPrependの中身はShiki、text markers、framesの順です。フックはプラグインの順に走るので、plugins: [pluginIo()]のフックが動くころには、3つとも同じブロックを処理し終えています。
コピーボタンの書き換えは、この順序の上に成り立っています。このプラグインはコピーボタンを作りません。framesがすでに作っており、postprocessRenderedBlockはレンダリング済みのツリーに手を入れて中身を差し替えるだけです。
バリデーションはannotateCodeに置いています。このフックより前の段階では、プラグインがブロックの行を足したり削ったりできます。annotateCodeはその段階がすべて終わったあとの最初のフックなので、そこで検査した行番号があとからずれることはありません。
Shikiの色付けに対する詳細度の上書き
出力行からは構文の色付けを外していて、!importantなしのふつうのクラスセレクタで済みます。Shikiはspanに色そのものを書き込みません。書き込むのはインラインのカスタムプロパティで、その変数を実際の色に変えているのは別のルールです。
.ec-line :where(span[style^='--']:not([class])) { color: var(--0, inherit);}:where()は詳細度に寄与しません。そのためこのルールはクラス1つ分としか数えられません。ここで使っているセレクタはクラス3つと属性1つ、要素1つなので、詳細度が上回り、上書きされます。
.ec-line.is-out .code span[style^="--"] { color: inherit; background-color: transparent; font-style: inherit; font-weight: inherit;}手間をかける価値があるのは、#コメントなら一様な色で表示されていたからです。この一点だけは、古い書き方のほうが新しいマーカーより優れていました。BashのトークナイザはJSONのレスポンスの記号をばらばらに色付けし、出力にはない構造をでっち上げてしまいます。span[style^="--"]だけを狙うことで、text markersプラグイン自身のspanには手を出しません。
バリデーションと、プレーンテキスト版
どちらもレンダリングの話ではありません。そして、どちらもテストの中ではなく記事の中にマーカーを置いて初めて表に出てくる類の問題です。
マーカーが末尾の行しか受け付けない理由
ビルドを失敗させる形は6つあり、最初の1つは癖でやってしまうものです。 1つのブロックに、コマンドと結果の組を複数入れる書き方です。初期の版はこれを許していて、枠にはIN、OUT、IN、OUT、IN、OUTと並びました。これを却下したので、マーカーを付ける行はブロックの末尾の連続した行でなければならず、組ごとにブロックを分けることになりました。
このルールは文書化ではなく強制です。 入れ子になった表示が、そのまま出荷できそうな見た目だったからです。
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. …", );}3行のブロックの場合、あと5つの形がビルドを失敗させます。
| マーカー | 失敗する理由 |
|---|---|
out={x} | 数字ではありません。 |
out={4-2} | 範囲が逆向きです。 |
out={9} | ブロックの末尾を超えています。 |
out={1-3} | 全行が出力になり、コマンドが残りません。 |
out=2 | 波かっこがないため、getRangesが返しません。 |
最後の1つが厄介です。波かっこのないマーカーは別の種類のメタオプションとして解釈され、空のリストとして返ってきます。これはマーカーを指定していないフェンスとまったく見分けがつきません。metaOptions.list("out")は種類を問わないので、この2つを区別できます。
派手に失敗させることが重要なのは、何にも一致しないマーカーは出力をクリップボードに戻してしまうからです。それはこのプラグインが取り除こうとしているバグそのもので、しかも見た目は正しいまま出荷されます。
プレーンテキスト版を読めるまま保つ
フェンスのマーカーは、ほかのすべての出力先に漏れ出します。 このブログの記事にはすべて.mdのプレーンテキスト版があり、out={…}は素のMarkdownでは何の意味も持ちません。そこでラベルの付いていない出力行は2つ目のコマンドのように読めてしまうため、生成側でマーカーを#付きの行に戻しています。これらの記事がずっと使ってきた形であり、回答エンジンが読むのもこのプレーンテキスト版です。
この変換は、既存のコード退避処理より前に、生のソースに対して走ります。おかげでフェンスの中に引用されたフェンスも安全です。この記法を説明する記事は、例をバッククォート4つのブロックに入れます。外側のフェンスが先に一致し、その情報文字列にout=は含まれません。
```bash out={2}gh api repos/oharu121/<repo-name>/automated-security-fixes{"enabled":true,"paused":false}```効いた検査は、プレーンテキスト版のフェンスの中身を変更前のソースと1バイトずつ突き合わせるものでした。空白の正規化はしません。 26Mの行頭の空白は飾りではありません。 du -shがサイズの桁を右寄せするからです。
私が直面した2つの障害
一方は、見た目は正しいのに違うテキストをコピーするページを作りました。もう一方は見た瞬間におかしいと分かり、理由の説明に計測が要りました。
data-codeへの書き込みが属性を2つに増やした理由
コピーボタンの書き換えにはバグがありました。新しい内容をproperties["data-code"]に書くと、1つ目を置き換えるのではなく2つ目のdata-code属性が増えていました。HTMLは属性が重複したとき先に現れたほうを採用します。こうしてボタンは黙って出力をコピーし続けていました。
hastscriptはdata-*の名前をキャメルケースに正規化します。そのためframesのh("button", { "data-code": … })はツリーの中でdataCodeになります。文字列の"data-code"キーは別のキーであり、両方が出力されます。
<button data-code="gh api …{"enabled":true}" data-code="gh api …">何も失敗しませんでした。 ページは表示され、ボタンは動き、そして違うものをコピーしました。気づけたのは、ブロックをエンジンに直接通すハーネスで、出力されたHTMLのdata-codeを数えて2つあったからです。
修正では、どちらか一方を決め打ちせず、実際に存在するほうのキーを上書きします。
const key = "data-code" in button.properties ? "data-code" : "dataCode";button.properties[key] = inputCode.replace(/\n/g, "\x7F");コピーする内容はブロック自身の行から組み立て直します。 既存の属性から読み戻すことはしません。framesがそこに入れる値はremoveCommentsWhenCopyingTerminalFramesの設定しだいなので、読み戻すとこのプラグインの正しさがその設定に縛られてしまいます。\x7Fはframes自身が使う改行の代替文字で、書き込みの直前にクライアント側のスクリプトが元に戻します。
ブロックを基準にガターの間隔を決める
区切り線まわりの間隔は、このサイトのスペーシングスケールではなく、Expressive Code自身のcodePaddingBlockから取っています。最初は--space-3という12pxのデザイントークンを使い、見た瞬間に違うと分かりました。どちらの行も、ブロックの端よりも区切り線のほうに寄っていたのです。
数字も同じことを示していました。codePaddingBlockは1remなので、各行はブロックの端まで16px、区切り線まで6pxでした。ブロック自身のパディングの2倍を取り、その中央に線を置けば、4つの間隔がすべて揃います。
.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;}複数行にまたがる行群の中央にラベルを置くのが、もう半分の話です。ガター要素は行ごとに描かれるので、ラベルは行群の先頭行に出したうえで、行群の半分だけ下にずらします。
.io-label { height: 100%; display: flex; align-items: center; transform: translateY(calc((var(--io-run, 1) - 1) * 50%));}ここで効いているのがheight: 100%です。 translateYのパーセント値は要素自身の高さを基準に解決され、.gutterはグリッドアイテムとして引き伸ばされ、ちょうどコード1行分の高さになります。中身任せにするとラベルは--font-size-1の1行分の高さになり、複数行の行群では毎回ずれて足りなくなります。各行群の中央からのずれを計測すると、1行、2行、3行、4行のいずれでも0pxでした。
まとめ
Expressive Codeのプラグインには、astro.config.mjsのスタイル設定から想像するよりずっと広い守備範囲があります。この機能を支えたのは3つの拡張ポイントでした。
addGutterElementは行番号プラグインと同じガターに要素を置きます。条件は1つ、すべてのセルが同じ幅であることです。- プラグインの実行順により、自分の
postprocessRenderedBlockは組み込みのあとに走ります。枠もコピーボタンもすでにレンダリング済みで、こちらから書き換えられます。 - コアのスタイルシートにある詳細度ゼロの
:where()のおかげで、Shikiの色付けはふつうのクラスセレクタで上書きできます。!importantは要りません。
見合わない手間がかかったものが2つあります。
data-code属性の重複。 ページの見た目は正しく、コピーされるテキストだけが違いました。手がかりは、出力されたHTMLでその属性を数えることだけでした。- 16pxに対する6pxの間隔。 コンポーネント内部のリズムを、すでに持っているパディングではなく外側のデザインスケールから取ると、こうなります。





