【WordPressプラグイン】
Markdownのフェンス言語を
シンタックスハイライトに設定する
(Gutenbergの介入点)

  • GutenbergにMarkdownを貼り付けるとcore/codeブロックが生成されるが、フェンス言語がlanguage属性に反映されないため、自動補完プラグインを作った。
  • 最初はpasteHandlerをラップしようとしたが、ES Modulesの仕様でgetter-onlyになっており代入も再定義もできなかった。
  • 代わりにpasteイベントのcaptureフェーズで言語を抽出し、Gutenbergの変換処理が終わった後にsubscribedispatchで新規ブロックだけに書き込む設計にした。
  • 介入点の選定ミスはロジックのミスと混同しやすいため、処理がどこまで到達しているかをログで先に確定することが重要だった。

関連記事

1. Markdownのコードのフェンス言語を読み取りたい

Gutenberg に対して「ちょっとだけ手を加える」補助プラグインを作るとき、どこに介入するかを間違えると、ロジック自体は正しくても何も動きません。判断の経緯と設計方針をまとめておきます。

Markdown をコピーして Gutenberg エディタに貼り付けると、core/code ブロックが生成されます。
しかし、シンタックスハイライトの言語設定は空のままでした。

Gutenbergプラグイン設計の記録 問題 “`python print(“hello”) “` language属性が空のまま 解決 1 pasteイベントで言語抽出 2 subscribeでブロック変化を検知 3 dispatchでlanguageを書込

毎回手動で選択していましたが、面倒だったので、フェンス言語(```python のような記法)を読み取って language 属性を補完するプラグインを作りました1

2. 【失敗】Gutenberg の export は書き換えられなかった

最初は window.wp.blocks.pasteHandler をラップする方針を取りました。

【失敗】pasteHandlerは書き換えられない TypeError: Cannot set property pasteHandler getter-only / configurable: false → 代入も defineProperty も不可 ES Modules live binding webpack バンドル window.wp.blocks 読み取り専用 介入前の確認手順 1 Object.getOwnPropertyDescriptor で descriptor を確認 2 writable / setter がなければ別の介入経路を探す

貼り付け処理の入口をフックして、変換と同時に言語を付与できれば最小介入で済むと思ったためです。
しかし、実装してブラウザで確認すると、何も反映されません。
コンソールを見ると、こんなエラーが出ていました。

Uncaught TypeError: Cannot set property pasteHandler of #<Object> which has only a getterCode language: JavaScript (javascript)

pasteHandler への代入そのもので止まっていました。
Object.getOwnPropertyDescriptor で確認すると、こういう状態です。

{
  "configurable": false,
  "enumerable": true,
  "hasGetter": true,
  "hasSetter": false,
  "writable": false
}Code language: JSON / JSON with Comments (json)

getter-only で、configurable: false でもあります。
代入で失敗するだけでなく、Object.defineProperty() による再定義も不可能です2

Gutenberg の export が書き換え可能だという設計の前提で間違っていました。

ES Modules 仕様では、名前空間オブジェクトの各プロパティが getter-only の live binding として公開されます。
import { pasteHandler } from '@wordpress/blocks' で公開されているものを webpack がバンドルして window.wp.blocks に乗せると、その時点でプロパティは読み取り専用になります3

補助プラグイン側が window.wp.blocks.pasteHandler = ... と書いても、割り当てに失敗するか、実際のモジュール export とは切り離されたプロパティになるかのどちらかです。

介入を試みる前に、まず descriptor を確認する必要がありました。

function canReplace( blocksApi ) {
  var descriptor = Object.getOwnPropertyDescriptor( blocksApi, 'pasteHandler' );

  if ( ! descriptor ) {
    return { replaceable: true, reason: 'missing-descriptor' };
  }

  if ( descriptor.writable === true || typeof descriptor.set === 'function' ) {
    return { replaceable: true, reason: 'replaceable' };
  }

  if ( typeof descriptor.get === 'function' && ! descriptor.set ) {
    return { replaceable: false, reason: 'getter-only' };
  }

  return { replaceable: false, reason: 'non-writable' };
}Code language: JavaScript (javascript)

replaceable: false が返ってきたなら、その介入経路は諦めて別の手段を探します。

2.1. 正しい介入点は「観測してから補完する」

似たようなプラグインを振り返ると、代わりに使っていたのは次の組み合わせです。

  • document.addEventListener('paste', handler, true) で capture phase を観測する4
  • wp.data.select('core/block-editor').getBlocks() で block tree を読む
  • wp.data.dispatch('core/block-editor').updateBlockAttributes() で属性を書き込む

Gutenberg の標準変換処理に割り込まなくても成立すると分かりました。

最終的な動作順序はこうなっています。

  1. capture phase の paste listener で text/plain を受け取る5
  2. fenced code block の言語一覧を抽出する(フェンス開始行だけをパースする)
  3. 貼り付け前の block tree の clientId 集合をスナップショットとして保持する
  4. Gutenberg 標準の変換処理が走る(プラグインは関与しない)
  5. wp.data.subscribe で block tree の変化を監視する
  6. 貼り付け後に新規出現した core/code ブロックだけを対象に language を書き込む

Gutenberg の変換処理そのものには触れていないので、他プラグインが pasteHandler をラップしていても衝突しません。

3. 【教訓】「observe first, mutate later」

今回の判断を言語化すると「まず観測し、変化が確定してから最小限の箇所だけ書き換える」という原則になります。

【教訓】observe first, mutate later 観測してから、確定した後に最小限だけ書き換える ①観測 paste capture ②検知 subscribe 差分比較 ③確認 select 新規ブロック ④書込 dispatch language設定 公開APIのみ使用 select / dispatch / subscribe → 他プラグインと干渉しない 既存属性を上書きしない 新規ブロックの差分だけに限定 → 副作用なし

Gutenberg は Redux ライクな store を持っています。
subscribe で変化を監視し、select で状態を読み、dispatch でアクションを投げる6
このパターンは公開 API として安定しており、補助プラグインが使える正規の経路です。
pasteHandler のような内部 export をラップするより、store を通じた操作のほうが壊れにくく、他のプラグインとの干渉面積も小さくなります。

貼り付け前後の差分だけを見て新規出現した core/code ブロックにだけ書き込む設計にすることで、既存の language を上書きすることもありません。

3.1. 失敗地点の特定がデバッグの前提

最初は「言語抽出か、ブロックへの適用のどこかで失敗しているだろう」と思っていました。
しかし実際の失敗地点はもっと手前の「pasteHandler への代入」でした。

ロジックのデバッグを始める前に、そもそも処理がそこまで到達しているかを確認しなければなりません。

スクリプトが読み込まれたことを示す bootstrap:loaded ログを最初に仕込んでいたおかげで、「読み込まれていない問題」と「読み込まれたが動かない問題」を切り分けられました。
そこから descriptor の確認に進んで、「動かない理由は介入点選定のミスだ」と確定できました。

問題の所在が分からないまま、ロジック側を直し続けてしまうパターンはよくあります。
介入点の選定ミスをロジックのミスと混同しないようにするには、「どの段階まで処理が進んでいるか」を先に確定することが必要です。

3.2. Gutenberg 補助プラグインを作るときの反省

Gutenberg の export をラップするときには、まず Object.getOwnPropertyDescriptorconfigurablewritable、getter、setter を確認するようにします。

介入点を決める前に、既存の同系統プラグインが実際にどの API を呼んでいるかを確認します。
公開されている store 操作(selectdispatchsubscribe)で目的が達成できるなら、内部 export をラップするより先にそちらを試してください。

複数プラグインが同じエディタ上で動く前提なら、変換処理そのものには関与せず、変化が出た後に限定した場所だけを書き換える設計が安全です。

実機でしか見えない制約はログで確定してからテストに落とします。
getter-only の環境を再現してテストを書いておけば、Gutenberg のバージョンが変わって差し替えが可能になったとき、あるいは逆にさらに制限されたときに気づけます7

  1. core/code ブロックに language のような独自属性を追加するには、wp.hooks.addFilter('blocks.registerBlockType', ...) を使います。フィルタ関数が settings と name を受け取り、対象ブロックにだけ attributes を追記して返すパターンが公式の推奨です。 – Block Filters – Block Editor Handbook | Developer.WordPress.org
  2. configurable: false のプロパティは Object.defineProperty() で再定義できません。writable: false のデータプロパティであれば代入は失敗しますが Object.defineProperty() が使える場合があります。getter-only かつ configurable: false の場合は両方とも不可です。 – Property descriptors – MDN Web Docs
  3. webpack 4 以降の harmony export は、各プロパティを Object.defineProperty(exports, name, { configurable: false, enumerable: true, get: getter }) で登録します。これにより live binding が実現されますが、外部から書き換えることはできなくなります。webpack 3 ではプロパティへの直接代入が使われており、この挙動はバージョン間で変化しています。 – Allow module system that does not rely on getters in Webpack 4 · Issue #6979 · webpack/webpack
  4. addEventListener の第3引数を true にすると、イベントがDOMを上から下へ伝播する「capture phase」でハンドラが呼ばれます。他のプラグインが bubbling phase(デフォルト)で stopPropagation() を呼んでも、capture phase のリスナーはその影響を受けません。補助プラグインが先にクリップボードの内容を読む用途に向いています。 – EventTarget: addEventListener() method – MDN Web Docs
  5. paste イベントの clipboardData.getData('text/plain') で貼り付けられたプレーンテキストを取得できます。Gutenberg が Markdown を block に変換する前のraw textを読めるのはこのタイミングだけです。 – Working with Pasted Content in JavaScript – Raymond Camden
  6. @wordpress/datasubscribe は Redux と異なり、state が実際に変化したときだけサブスクライバーを呼びます。Redux では dispatch のたびに subscribe リスナーが呼ばれますが、Gutenberg のデータモジュールはこの点で最適化されています。 – @wordpress/data – Block Editor Handbook | Developer.WordPress.org
  7. ES Modules の live binding の挙動はバンドラによって実装が異なります。Rollup は Object.freeze で namespace object を凍結し、webpack は Object.defineProperty で getter を登録します。Gutenberg が使うバンドラやビルド設定が変わると、同じ export でもプロパティの descriptor が変化する可能性があります。 – JavaScript live bindings are just concatenation – James Fisher