Expressive Codeでコマンド出力にラベルを付ける — Gutter APIとコピーボタンの書き換え

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

白地に置かれた紫のタイトルバーを持つ暗いコードウィンドウ。コード行はグレーと緑のバーで表され、一段がハイライトされている。下半分を虹色の毛先の絵筆が横切っている
目次

はじめに

Claude Codeは自分のトランスクリプトで、コマンドをIN、その結果をOUTとラベル付けします。同じものをこのブログのコードブロックでも使いたくなりました。ここではBashの出力を、同じブロックの中に#コメントとして書いていました。読む分には問題ありませんが、2つの問題があります。

  1. 読者には指示と出力の区別がつきません。 どちらも#で書かれています。
  2. コピーボタンにも区別がつきません。 貼り付けるコマンドの中に、出力の行がそのまま入っていました。

Claude Codeのトランスクリプト。1行目のシェルコマンドにINというラベルが付き、水平の区切り線の下にある2行の出力にOUTというラベルが付いている。どちらのラベルも左側の細いガターに置かれている

これはこのサイトではなく、Claude Code自身のトランスクリプトです。ラベルがコードの中ではなくコードの横のガターに置かれていて、そこが真似する価値のある部分でした。

解決策は、フェンスにout={…}オプションを追加するExpressive Codeプラグインです。

ターミナルウィンドウ
入力
gh api repos/oharu121/<repo-name>/automated-security-fixes
出力
{"enabled":true,"paused":false}

使っている拡張ポイントは3つです。

  1. ガター要素がIN/OUTのラベルを載せます。
  2. レンダリング後の書き換えが、コピーボタンから出力を取り除きます。
  3. 詳細度の上書きが、出力行からShikiの色付けを外します。

本記事ではこの3つと、マーカーが受け付けないもの、そして私が直面した2つの障害を扱います。

Expressive Codeのコメント除去をなぜ無効にしているのか

問題2にはExpressive Code自身が対応しています。framesプラグインは、ターミナル枠をコピーするときに行全体が#のコメントを除去します。これは既定で有効です。

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

このリポジトリでは、それを意図的に無効にしています。ここにある#行のほとんどはノイズではなく指示だからです。無効にしておけば、その指示はクリップボードに残ります。引き換えに、出力の行も残ってしまいます。

2 つの意味を担う 1 文字2 つのターミナル用スニペットが左右に並ぶ。どちらにも先頭が # の行がある。左ではその行はコマンドと一緒に貼り付けたい指示であり、右ではコマンドが出力した JSON である。それぞれの下に、コピーボタンがクリップボードへ入れる内容を示す枠があり、どちらの枠にも # の行が含まれている。右側は誤りとして示されている。出力された結果が、コマンドの一部であるかのように貼り付けられてしまうためである。# は指示# 先に API を有効化gcloud services enable aiplatformコピーボタンがクリップボードに入れる内容# 先に API を有効化gcloud services enable aiplatform✓意図どおり# は出力gh api repos/OWNER/REPO/automated-security-fixes# {"enabled":true,"paused":false}コピーボタンがクリップボードに入れる内容gh api repos/OWNER/REPO/automated-security-fixes# {"enabled":true,"paused":false}✗コマンドではない同じ文字、同じ扱い、意図は正反対
どちらのブロックも同じ文字です。コメント除去が無効だと、コピーボタンは両者を同じように扱います。その結果、読者はJSONのレスポンスが末尾にくっついたコマンドを貼り付けることになります。

結局のところ、どちらの設定も正解ではありません。

  • 無効にすると、指示がコピーされ、出力も一緒にコピーされます。
  • 有効にすると、出力が除去され、指示も一緒に除去されます。

Claude Codeがそうしているように、入力と出力を分けて示す必要があります。入力と出力のあいだに区切り線があれば、コピーボタンは入力だけを取れます。そして指示は入力の側にあります。Expressive Codeには、まさにそのための拡張ポイントがあります。

私が組み込んだ3つの拡張ポイント

この機能を支えているのはExpressive CodeのAPIの3か所で、それぞれレンダリングの別のタイミングで動きます。

ラベルを載せるガター要素

Expressive Codeは行番号プラグインが使っているガターを公開していて、どのプラグインからでもそこに要素を追加できます。addGutterElementが受け取るのは3つです。

  1. renderLineは各行のセルを作ります。INのラベル、OUTのラベル、あるいは空のセルです。
  2. renderPlaceholderはエンジン自身が挿入した行のセルを作ります。ここでは常に空です。
  3. renderPhaseは複数のプラグインがガターに追加したときの並び順を決めます。ここでは他に追加するものがありません。
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", ""),
});
ガターのどの部分をどの関数が作るか右側に描かれたコードブロックと、その左端に沿ったガター列。ガターにはコマンドの横に入力セル、2 行の出力の横に中央揃えの出力セルがあり、それ以外は空のセルになっている。左側の 3 つの注記がそこを指す。renderLine は実在する行の並びを指し、入力ラベル・出力ラベル・空のセルのいずれかを返すと説明されている。renderPlaceholder は破線で描かれた 4 行目を指し、これはソース由来ではなくエンジンが挿入した行である。renderPhase はガター列全体を指し、他のプラグインもセルを追加するときにこの列がどこに置かれるかを決めると説明されている。入力git count-objects -vH出力count: 2081size: 16.46 MiB挿入された行renderPhase他のプラグインもセルを足すときの、この列の位置。renderLine実在する行ごとに呼ばれる。入力ラベル・出力ラベル・空のセルのいずれかを返す。renderPlaceholderエンジンが自ら挿入した行のときだけ呼ばれる。
1行につき1回の呼び出しと、1つのセル。ここで実際に働いているのはrenderLineだけです。renderPlaceholderはソースに存在しない行のときだけ動き、renderPhaseには並び順を競う相手がいません。

あとのCSSを縛る条件が1つあります。すべての行でガターの幅が同じでなければならない、という条件です。そうでないとコードの位置が揃わなくなります。ラベルのない行にもセルは描かれ、その幅は中身任せではなく指定されています。

ラベルはPluginTextsを通ります。framesプラグインが自分の文言に使っているのと同じクラスなので、3つのロケールはastro.config.mjsで一度登録するだけで済みます。getBlockLocaleはすでにファイル名からロケールを解決しているため、日本語のページでは追加の作業なしに入力と出力が表示されます。

プラグインの実行順と、コピーボタンの書き換え

登録したプラグインは組み込みのプラグインより後に走ります。組み込みのほうが末尾ではなく先頭に差し込まれるからです。

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

pluginsToPrependの中身はShiki、text markers、framesの順です。フックはプラグインの順に走るので、plugins: [pluginIo()]のフックが動くころには、3つとも同じブロックを処理し終えています。

組み込みプラグインが先に走るので、コピーボタンはすでに存在する4 つのプラグインの箱が左から右へ並ぶパイプライン。最初の 3 つ、Shiki、text markers、frames は「エンジンが先頭に追加」としてまとめられている。4 つ目の pluginIo は Astro の設定で登録したもので、その後ろに置かれる。4 つ目の箱の下に 2 つの吹き出しがあり、使用するフックを示す。行とメタデータが確定している annotateCode と、枠とコピーボタンがすでに描画され、プラグインが書き換えられるツリーになっている postprocessRenderedBlock である。エンジンが先頭に追加Shiki色をインライン変数で付与Text markersins / del / ハイライトFrames枠・タイトル・コピーボタンplugins: [pluginIo()]pluginIoガター・ペイロード・色annotateCode行とメタデータが確定済み。out={…} を検証し、ガター要素を追加。postprocessRenderedBlock枠とコピーボタンは描画済み。HAST ツリーをその場で書き換える。
ここで重要なフックは2つです。annotateCodeの時点でブロックの行とメタデータは確定しています。postprocessRenderedBlockの時点では枠もコピーボタンもすでにHASTツリーとして存在していて、後続のプラグインから書き換えられます。

コピーボタンの書き換えは、この順序の上に成り立っています。このプラグインはコピーボタンを作りません。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と並びました。これを却下したので、マーカーを付ける行はブロックの末尾の連続した行でなければならず、組ごとにブロックを分けることになりました。

このルールは文書化ではなく強制です。 入れ子になった表示が、そのまま出荷できそうな見た目だったからです。

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. …",
);
}

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 …&#x7f;{&quot;enabled&quot;:true}" data-code="gh api …">

何も失敗しませんでした。 ページは表示され、ボタンは動き、そして違うものをコピーしました。気づけたのは、ブロックをエンジンに直接通すハーネスで、出力されたHTMLのdata-codeを数えて2つあったからです。

修正では、どちらか一方を決め打ちせず、実際に存在するほうのキーを上書きします。

src/lib/expressive-code-io.ts
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;
}
ガターが同時に満たすべき条件左側にガター列を持つコードブロックが 1 つ。1 行目はコマンドで、入力と表示されている。水平な罫線の下に 4 行の出力があり、出力ラベルを 1 つだけ共有する。図には 3 つの注記が向けられている。ガターのセルはラベルの有無にかかわらずすべて同じ幅であること。罫線まわりの余白はブロック自身の上下パディングの 2 倍で、上下それぞれ 16 ピクセルであること。そして出力ラベルは 4 行の中央の高さにあり、先頭行の横ではないことである。入力git count-objects -vH16px16px出力count: 2081size: 16.46 MiBin-pack: 240size-pack: 437.45 KiB余白は codePaddingBlock の 2 倍、罫線はその中央ラベルは行群の中央、先頭行の横ではないラベルの有無にかかわらずすべてのセルが同じ幅
ガターが同時に満たすべき3つの条件。すべてのセルが同じ幅であること、区切り線の上下の間隔がブロック自身のパディングと一致すること、そしてラベルが行群の先頭ではなく中央に位置することです。

複数行にまたがる行群の中央にラベルを置くのが、もう半分の話です。ガター要素は行ごとに描かれるので、ラベルは行群の先頭行に出したうえで、行群の半分だけ下にずらします。

.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つの拡張ポイントでした。

  1. addGutterElementは行番号プラグインと同じガターに要素を置きます。条件は1つ、すべてのセルが同じ幅であることです。
  2. プラグインの実行順により、自分のpostprocessRenderedBlockは組み込みのあとに走ります。枠もコピーボタンもすでにレンダリング済みで、こちらから書き換えられます。
  3. コアのスタイルシートにある詳細度ゼロの:where() のおかげで、Shikiの色付けはふつうのクラスセレクタで上書きできます。!importantは要りません。

見合わない手間がかかったものが2つあります。

  1. data-code属性の重複。 ページの見た目は正しく、コピーされるテキストだけが違いました。手がかりは、出力されたHTMLでその属性を数えることだけでした。
  2. 16pxに対する6pxの間隔。 コンポーネント内部のリズムを、すでに持っているパディングではなく外側のデザインスケールから取ると、こうなります。

参考リンク

この記事をシェア