前回の記事では、Expressive Code と rehype-external-links を共存させるまでのトラブルを書きました。同じ日(2026年2月14日)に、もうひとつ格闘していた問題があります。コピーボタンの日本語化です。

こちらも「設定を1行書けば終わり」のつもりが、沼にはまりました。

そもそも何が問題だったのか

Expressive Code のコードブロックには、デフォルトで「Copy to clipboard」というコピーボタンが表示されます。英語のままでも動作に問題はないのですが、日本語サイトに英語のボタンがぽつんと浮いているのが気になっていました。

コピーして数秒後に表示される「Copied!」というフィードバックテキストも同様です。

まず公式ドキュメントを試した

Expressive Code には i18n オプションが用意されています。ドキュメントを参考に、こう書きました。

astro.config.mjs(最初の試み)
expressiveCode({
themes: ['dracula', 'github-light'],
defaultLocale: "ja",
i18n: {
translations: {
ja: {
copyButtonTooltip: "クリップボードにコピー",
copyButtonCopied: "コピーしました!",
terminalWindowFallbackTitle: "ターミナルウィンドウ",
},
},
},
// ...
}),

ビルドして確認しましたが、ボタンの表示は「Copy to clipboard」のままでした。

キーの名前が違うのか、構造が違うのかと思い frames.copyButtonTooltip などプレフィックスを変えながら何パターンか試しましたが、どれも効きませんでした。

結局このオプションは後で丸ごと削除することになります。

次の手:postbuild スクリプトで sed 置換

「正攻法は一旦あきらめよう」ということで、ビルド後の HTML を直接書き換える方法に切り替えました。

package.json
"postbuild": "pagefind --site dist --force-language ja && find dist -name '*.html' -exec sed -i 's/Copy to clipboard/クリップボードにコピー/g' {} + && find dist -name '*.html' -exec sed -i 's/data-copied=\"Copied!\"/data-copied=\"コピーしました!\"/g' {} +"

pnpm build のたびに dist/ 以下の全 HTML ファイルを sed で書き換えるという、なかなか力技な実装です。

動作はしました。コピーボタンには「クリップボードにコピー」と表示され、コピー後には「コピーしました!」に変わります。一応の解決です。

ただ、この方法には気になる点がいくつかありました。

  • Expressive Code がバージョンアップで文字列を変えたら即死する
  • postbuild のコマンドが長くなり、後から読んでも何をやっているのかわかりにくい
  • dist/ を直接書き換えるという行為は、ビルドの冪等性を損なう

プラグイン API で正攻法に書き直す

しばらく sed 運用が続いたあと、Expressive Code のプラグイン API を調べ直したところ postprocessRenderedBlock というフックを見つけました。これはコードブロックのレンダリング後に HTML を操作できるフックで、まさに求めていたものでした。

Expressive Code はレンダリング結果を HAST(HTML Abstract Syntax Tree) という形式で管理しています。このツリーを走査して、ボタン要素の属性値を書き換えるという方法です。

astro.config.mjs(プラグイン実装)
/** @type {import('astro-expressive-code').ExpressiveCodePlugin} */
const japaneseTranslationPlugin = {
name: "Japanese Translation",
hooks: {
postprocessRenderedBlock: ({ renderData }) => {
const traverse = (node) => {
if (node.type === "element" && node.tagName === "button") {
if (node.properties?.title === "Copy to clipboard") {
node.properties.title = "クリップボードにコピー";
}
if (
node.properties?.["data-copied"] === "Copied!" ||
node.properties?.dataCopied === "Copied!"
) {
if (node.properties["data-copied"]) {
node.properties["data-copied"] = "コピーしました!";
}
if (node.properties.dataCopied) {
node.properties.dataCopied = "コピーしました!";
}
}
}
if (node.children) {
node.children.forEach(traverse);
}
};
traverse(renderData.blockAst);
},
},
};

あとはこのプラグインを plugins に渡すだけです。

astro.config.mjs(プラグインを登録)
expressiveCode({
plugins: [
pluginCollapsibleSections(),
pluginLineNumbers(),
japaneseTranslationPlugin, // 追加
],
}),

これで postbuild の sed コマンドを削除できました。

package.json(すっきりした)
- "postbuild": "pagefind --site dist --force-language ja && find dist -name '*.html' -exec sed -i 's/Copy to clipboard/クリップボードにコピー/g' {} + ...",
+ "postbuild": "pagefind --site dist --force-language ja",

ひとつ詰まった:data-copied と dataCopied

実装中に少し詰まったのが、コピー後のフィードバックテキストの属性名です。

HAST に変換される際、HTML の data-copied 属性はキャメルケースの dataCopied に変換されることがあります。どちらの形式になるかはバージョンや処理タイミングによって変わる可能性があるため、両方をチェックする実装にしています。

両方を確認する処理
if (
node.properties?.["data-copied"] === "Copied!" ||
node.properties?.dataCopied === "Copied!"
) {
if (node.properties["data-copied"]) {
node.properties["data-copied"] = "コピーしました!";
}
if (node.properties.dataCopied) {
node.properties.dataCopied = "コピーしました!";
}
}

どちらか一方だけ書き換えると「コピーしました!」が表示されないケースが生じたため、両方に代入しています。

HAST とは

HAST(Hypertext Abstract Syntax Tree)は HTML を木構造で表現したデータ形式です。unist というツールキットの一部で、Astro や remark/rehype のエコシステムでも使われています。タグ・属性・テキストがすべてオブジェクトのツリーとして表現されるため、コードから HTML を安全に操作できます。

今回の試行錯誤をひとことで言うと「公式 API が効かなかったのでビルド後の HTML を sed で殴っていたが、プラグイン API で正攻法に書き直した」という話です。

サイト制作シリーズとして Expressive Code との格闘をふたつの記事に分けて書きましたが、同じ日にこの2つの問題を並行して追いかけていたので、当時はなかなか混乱していました。記録を残しておいてよかったです。