Gutenbergへ貼ったMarkdownの
コードブロック言語を、シンタックス
ハイライトプラグインへ自動反映する

関連記事

1. コードブロックにフェンス言語を入れたい

Markdownで書いたコードを ```lisp のようにフェンスしてGutenbergへ貼り付けると、コードブロック自体は生成されます。

しかし、シンタックス・ハイライトのWordPressプラグインに Syntax-highlighting Code Block (with Server-side Rendering) で、core/code ブロックの language 属性を使う設計になっています1を使っているのですが、言語情報は消えてしまいます。

この問題を解決するWordPressプラグインの実装記録をまとめました。

2. 作ったものと背景

2.1. 解決した問題

MarkdownからGutenbergへ貼り付けた場合、Gutenbergの変換は text/plain の fenced code block を認識して core/code ブロックを生成します2
しかし言語情報は引き継がれません。

差分は {"language":"lisp"} の有無だけですが、この属性がないとシンタックスハイライトが機能しません。

なお、CommonMark仕様ではfenced code blockのinfo stringの先頭の単語を言語識別子として扱うことが慣習として定義されています3
空白を含むラベル(common lisp など)はinfo stringの先頭の単語として扱えないため、Gutenbergがfenced codeとして解釈しません。

2.2. 完成後の動作

プラグインを有効化した状態で ```lisp のようなfenced code blockをGutenbergへ貼り付けると、core/code ブロックに language: "lisp" が付与されます。
コンソールには次の順でログが出ます。

[chiilabo-paste-codeblock] paste-observer:captured    {rawLanguages: ["lisp"], ...}
[chiilabo-paste-codeblock] paste-observer:observed-new-code-blocks    [{...}]
[chiilabo-paste-codeblock] paste-observer:applied    {assignments: [{clientId: "...", language: "lisp"}]}Code language: CSS (css)

common-lisp のような別名は lisp へ既定マッピングで解決します。
マッピングにない言語は安全に無視しつつ管理画面へ観測ログとして記録し、そこからユーザーがマッピングを追加すれば次回から解決されます。

3. pasteHandlerという罠

3.1. ラップしようとしたコード

最初の設計では、wp.blocks.pasteHandler をラップする方針を採りました。
Gutenbergの貼り付け変換の入口がここにあり、ここで言語を注入すれば最小介入で済むと考えたためです。

var original = wp.blocks.pasteHandler;

wp.blocks.pasteHandler = function (options) {
    var blocks = original(options);
    // ここで core/code ブロックへ language を付与する
    return applyLanguages(blocks, extractLanguages(options.plainText));
};Code language: JavaScript (javascript)

実機では何も反映されませんでした。
デバッグログを入れて確認すると、bootstrap:loaded は出ていました。
スクリプト自体は読み込まれていたものの、その直後に次のエラーが発生していました。

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

3.2. descriptorでgetter-onlyを特定する

問題が「言語抽出ロジック」なのか「介入点そのもの」なのかを切り分けるため、pasteHandler の property descriptor を確認しました。

var descriptor = Object.getOwnPropertyDescriptor(wp.blocks, 'pasteHandler');
console.log(descriptor);
// {
//   configurable: false,
//   enumerable: true,
//   get: function() {...},
//   set: undefined
// }Code language: JavaScript (javascript)

configurable: false かつ getter-only だったため、次の両方が使えないことが確定しました。

  • wp.blocks.pasteHandler = wrapped(代入)
  • Object.defineProperty() による再定義(configurable: false のため不可)4

この getter-only の原因は、GutenbergがES Modulesとwebpackの組み合わせでビルドされていることにあります。
ES Modulesの名前空間オブジェクトでは各エクスポートがlive bindingとしてgetterで公開されます。
webpackはこれを __webpack_require__.dObject.defineProperty(exports, name, { configurable: false, get: getter }) として実装するため、外部からの書き換えが不可能になります5

この判定ロジックを editor-runtime.js に分離しました。

function canReplacePasteHandler(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' && typeof descriptor.set !== 'function') {
        return { replaceable: false, reason: 'getter-only' };
    }

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

3.3. 介入点の選定ミスをロジックミスと取り違えない

この失敗から得た教訓は、「動かない原因がどのレイヤーにあるか」を最初に特定することです。

今回の処理経路は次の順になっていました。

  1. editor スクリプト読み込み → 成功
  2. pasteHandler 差し替え → ここで止まっていた
  3. 言語抽出 → 到達していない
  4. ブロックへの適用 → 到達していない

言語抽出や適用ロジックをいくら見ても原因は見つかりません。
介入点の選定が間違っていたため、その手前で止まっていました。

Gutenbergの export の性質を事前に確認せず、「書き換え可能な関数プロパティだろう」と決めつけていたことが問題でした。
Gutenbergのような大きなフレームワークでは、public APIに見えても書き換え不能な export は珍しくありません。
実装に入る前にdescriptorを確認する習慣が必要でした。

4. 正しい介入点と競合回避

4.1. capture phaseで観測だけ行う

既存の同系統プラグインを確認したところ、pasteHandler を差し替えずに document.addEventListener('paste', handler, true) の capture phase で介入していました。
この方針へ切り替えます。

capture phase を使う理由は、Gutenbergの paste ハンドラより先にイベントを受け取るためです6
ただしここでは preventDefault() しません。
観測だけ行い、実際の変換はGutenbergに委ねます。

function handlePaste(event) {
    var clipboardData = event && event.clipboardData;
    if (!clipboardData) return;

    var plainText = clipboardData.getData('text/plain') || '';
    if (!plainText) return;

    capturePendingPaste(plainText);
    // preventDefault() は呼ばない
}

function attachPasteListener(targetDocument) {
    if (!targetDocument || targetDocument[LISTENER_FLAG]) return;

    targetDocument.addEventListener('paste', handlePaste, true);
    targetDocument[LISTENER_FLAG] = true;
}Code language: JavaScript (javascript)

Gutenbergはiframe内でエディタを動かす場合もあります7
MutationObserver でiframeの追加を監視し、出現したiframeのdocumentにも同じlistenerを付けます。

domReady(function () {
    attachPasteListener(document);
    attachListenersToIframes();

    var observer = new MutationObserver(function () {
        attachListenersToIframes();
    });

    observer.observe(document.documentElement, {
        childList: true,
        subtree: true,
    });
});Code language: JavaScript (javascript)

4.2. wp.data.subscribe()でblock tree変化を監視する

capturePendingPaste では言語一覧と貼り付け前のblock snapshotを保持するだけにとどめます。
実際の補完は、wp.data.subscribe()core/block-editor の変化を検知してから行います8

function capturePendingPaste(plainText) {
    var rawLanguages = fenceLanguages.extractFencedCodeLanguages(plainText);
    if (rawLanguages.length === 0) return;

    var beforeBlocks = data.select('core/block-editor').getBlocks();

    pendingPaste = {
        beforeSnapshot: {
            clientIds: blockObserver.snapshotClientIds(beforeBlocks),
        },
        createdAt: Date.now(),
        rawLanguages: rawLanguages,
    };
}

data.subscribe(maybeApplyPendingPaste);Code language: JavaScript (javascript)

subscribe のコールバックはblock storeの変化ごとに呼ばれます。
pendingPaste がある間だけ処理し、適用が終わるか有効期限の1500msを過ぎたら破棄します9

4.3. preventDefault()しない理由

preventDefault() を呼ぶとGutenbergの標準変換が止まり、自前でMarkdownをブロックへ変換する責任を持つことになります。
実装コストが高くなるだけでなく、他の貼り付け補助プラグインとの衝突も起こしやすくなります。

設計方針は observe first, mutate later としました。
paste イベントでは観測だけ行い、変換後のblock treeに対して後から最小限の補完を加えます。
これで chiilabo-latex-paste-to-mathml のような既存プラグインが動いていても、干渉せずに同居できます。

5. 貼り付け前後のsnapshot比較と言語補完

5.1. snapshotClientIdsとfindNewCodeBlocks

貼り付け前の全blockの clientId をスナップショットとして保持し、貼り付け後に新規出現した core/code だけを対象にします10

function snapshotClientIds(blocks) {
    return flattenBlocks(blocks).reduce(function (ids, block) {
        if (block.clientId) {
            ids[block.clientId] = true;
        }
        return ids;
    }, {});
}

function findNewCodeBlocks(beforeSnapshot, afterBlocks) {
    var beforeIds = beforeSnapshot && beforeSnapshot.clientIds
        ? beforeSnapshot.clientIds
        : {};

    return flattenBlocks(afterBlocks).filter(function (block) {
        return (
            block &&
            block.name === 'core/code' &&
            block.clientId &&
            !beforeIds[block.clientId]
        );
    });
}Code language: JavaScript (javascript)

flattenBlocks はネストされた innerBlocks を再帰的に展開します。
グループブロック内にコードブロックが入る場合もカバーできます。

function flattenBlocks(blocks) {
    var flattened = [];

    function visit(block) {
        if (!block || typeof block !== 'object') return;
        flattened.push(block);
        if (Array.isArray(block.innerBlocks)) {
            block.innerBlocks.forEach(visit);
        }
    }

    if (Array.isArray(blocks)) {
        blocks.forEach(visit);
    }

    return flattened;
}Code language: PHP (php)

5.2. 新規core/codeだけを対象にする

buildAssignments で、新規 core/code ブロックと rawLanguages 配列を順番に対応付けます。
すでに language 属性が設定されているブロックはスキップします。

function buildAssignments(beforeSnapshot, afterBlocks, rawLanguages, resolveLanguage) {
    var newCodeBlocks = findNewCodeBlocks(beforeSnapshot, afterBlocks);
    var assignments = [];
    var unresolvedLanguages = [];

    newCodeBlocks.forEach(function (block, index) {
        var rawLanguage = rawLanguages[index];
        if (typeof rawLanguage === 'undefined' || rawLanguage === null) return;

        // 既に language が付いているブロックは触らない
        if (block.attributes && block.attributes.language) return;

        var resolution = resolveLanguage(rawLanguage);

        if (resolution && resolution.resolvedLanguage) {
            assignments.push({
                clientId: block.clientId,
                language: resolution.resolvedLanguage,
            });
            return;
        }

        if (resolution && resolution.normalizedInput) {
            unresolvedLanguages.push({
                language: resolution.normalizedInput,
                reason: resolution.reason,
            });
        }
    });

    return { assignments, newCodeBlocks, unresolvedLanguages };
}Code language: JavaScript (javascript)

補完の適用は updateBlockAttributes() で最小単位に絞ります。
replaceBlocks() は使いません。
replaceBlocks() はblock全体を置き換えるため、他プラグインが同じblockに対して操作していた場合に衝突しやすくなります11

observed.assignments.forEach(function (assignment) {
    dispatcher.updateBlockAttributes(assignment.clientId, {
        language: assignment.language,
    });
});Code language: PHP (php)

5.3. resolveLanguageの優先順

言語名の解決は、ユーザー定義マッピング、既定マッピング、直接一致の順で試みます。

function resolveLanguage(rawLanguage, options) {
    var normalizedInput = normalizeLanguageKey(rawLanguage);
    var supportedLanguages = buildSupportedLanguageSet(options.supportedLanguages);
    var defaultMappings = normalizeMappingTable(options.defaultMappings || DEFAULT_MAPPINGS);
    var userMappings = normalizeMappingTable(options.userMappings);

    if (!normalizedInput) {
        return { normalizedInput: null, reason: 'empty', resolvedLanguage: null };
    }

    // 1. ユーザー定義マッピングを優先する
    if (Object.prototype.hasOwnProperty.call(userMappings, normalizedInput)) {
        var mapped = userMappings[normalizedInput];
        if (supportedLanguages[mapped]) {
            return { normalizedInput, reason: 'mapped', resolvedLanguage: mapped, via: 'user' };
        }
        return { normalizedInput, reason: 'mapped-target-unsupported', resolvedLanguage: null, via: 'user' };
    }

    // 2. 既定マッピングを試みる
    if (Object.prototype.hasOwnProperty.call(defaultMappings, normalizedInput)) {
        var mapped = defaultMappings[normalizedInput];
        if (supportedLanguages[mapped]) {
            return { normalizedInput, reason: 'mapped', resolvedLanguage: mapped, via: 'default' };
        }
        return { normalizedInput, reason: 'mapped-target-unsupported', resolvedLanguage: null, via: 'default' };
    }

    // 3. supported languages に直接一致するか確認する
    if (supportedLanguages[normalizedInput]) {
        return { normalizedInput, reason: 'supported', resolvedLanguage: normalizedInput, via: 'direct' };
    }

    return { normalizedInput, reason: 'unsupported', resolvedLanguage: null };
}Code language: JavaScript (javascript)

既定マッピングには代表的な別名を同梱しました。

var DEFAULT_MAPPINGS = {
    'asm':         'x86asm',
    'c':           'cpp',
    'c#':          'cs',
    'c++':         'cpp',
    'common-lisp': 'lisp',
    'commonlisp':  'lisp',
    'elisp':       'lisp',
    'golang':      'go',
    'html':        'xml',
    'js':          'javascript',
    'md':          'markdown',
    'py':          'python',
    'rb':          'ruby',
    'rs':          'rust',
    'sh':          'bash',
    'toml':        'ini',
    'ts':          'typescript',
    'yml':         'yaml',
};Code language: JavaScript (javascript)

normalizeLanguageKey で大文字小文字、アンダースコア、複数ハイフンを正規化してから比較します。

function normalizeLanguageKey(value) {
    if (typeof value !== 'string') return null;

    var normalized = value
        .trim()
        .toLowerCase()
        .replace(/_/g, '-')
        .replace(/\s+/g, '-')
        .replace(/-+/g, '-');

    return normalized === '' ? null : normalized;
}Code language: JavaScript (javascript)

5.4. 未登録言語を壊さず観測する

解決できなかった言語は language を付けずに、Ajax経由で管理画面へ記録します12
貼り付け操作自体は止めません。

function reportUnknownLanguages(unresolvedLanguages) {
    if (!Array.isArray(unresolvedLanguages) || unresolvedLanguages.length === 0) return;
    if (!config.ajaxUrl || !config.observeNonce) return;

    var uniqueLanguages = {};
    unresolvedLanguages.forEach(function (item) {
        if (item && item.language) {
            uniqueLanguages[item.language] = item.reason || 'unsupported';
        }
    });

    Object.keys(uniqueLanguages).forEach(function (language) {
        var body = new URLSearchParams();
        body.set('action', 'chii_paste_codeblock_observe_language');
        body.set('nonce', config.observeNonce);
        body.set('language', language);
        body.set('reason', uniqueLanguages[language]);

        fetch(config.ajaxUrl, {
            body: body.toString(),
            credentials: 'same-origin',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
            method: 'POST',
        }).catch(function () {});
    });
}Code language: PHP (php)

管理画面の Observed unknown languages に表示され、そこからマッピングを追加できます。
設定画面ではマッピング先を supported languages からの選択式にすることでtypoを防いでいます。

6. テストと境界条件

6.1. Gutenberg依存を切り離す構造

Gutenbergはブラウザ環境でしか動きません。
テストできる範囲を広げるには、Gutenberg依存を含むコードと純粋なロジックを分離する必要があります。

このプラグインでは次の分離を採りました。

  • fence-languages.js: fenced code block から言語一覧を抽出する。
    window 依存なし。
  • language-mapping.js: 言語名の正規化とマッピング解決を行う。
    window 依存なし。
  • block-observer.js: block snapshot 比較と assignment 生成を行う。
    window 依存なし。
  • editor-runtime.js: pasteHandler の descriptor 確認など、Gutenberg APIの性質を扱う。
  • editor.js: wp.datawp.hooks など、Gutenberg実依存を持つ統合レイヤー。

前者3つはNode.jsで直接テストできます。
editor.js だけが実機確認を要する部分として残ります。

各ファイルはUMDパターンで書いており、Node環境では module.exports、ブラウザ環境では window.ChiiPasteCodeblock.xxx として使えます13

(function (root, factory) {
    if (typeof module === 'object' && module.exports) {
        module.exports = factory();
        return;
    }
    root.ChiiPasteCodeblock = root.ChiiPasteCodeblock || {};
    root.ChiiPasteCodeblock.fenceLanguages = factory();
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
    'use strict';
    // ロジック本体
    return { extractFencedCodeLanguages };
});Code language: JavaScript (javascript)

テストはNode.js標準の node --test で動かします14
追加依存なしで実行できます。

npm test
# → node --test tests/unit/**/*.test.jsCode language: PHP (php)

6.2. getter-only条件をNodeテストで再現する

実機で見つかった pasteHandler getter-only 問題は、そのままNodeテストで再現しました。
実機でしか見えない制約をローカルで確認できるようにするためです。

// tests/unit/editor-runtime.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { canReplacePasteHandler } from '../../src/editor-runtime.js';

test('getter-only の場合は replaceable: false を返す', function () {
    var blocksApi = {};

    Object.defineProperty(blocksApi, 'pasteHandler', {
        configurable: false,
        enumerable: true,
        get: function () { return function () {}; },
    });

    var result = canReplacePasteHandler(blocksApi);

    assert.equal(result.replaceable, false);
    assert.equal(result.reason, 'getter-only');
});Code language: JavaScript (javascript)

このテストがあることで、「getter-only でも即死せず診断ログを出す」という挙動を回帰確認できます。

6.3. Gutenberg側が認識しない入力の切り分け

実機確認で、空白を含む言語ラベルはGutenbergが fenced code block として認識しないことがわかりました15

```common lisp       ← Gutenbergはfenced codeとして解釈しない
(defun hello () ...)Code language: JavaScript (javascript)

“`

この場合 core/code ブロックが生成されないため、このプラグインが介入する余地はありません。
これは「language補完の失敗」ではなく「Markdown解釈段階でcode block化されなかった」問題として切り分けます。

空白なしの common-lispcommonlisp は既定マッピングでカバーします。
空白入りのラベルをサポートしたい場合は、code block化そのものを補う別機能として切り出す必要があります。

6.4. 運用フロー(観測から設定追加、解決まで)

プラグインの実運用では次の流れになります。

  1. is-lisp のような未登録言語で貼り付けると、core/code は生成されるが language は空のまま。
  2. 同時に、管理画面の Observed unknown languagesis-lisp が記録される。
  3. 設定画面で is-lisp から lisp へのマッピングを追加する。
  4. 次回の貼り付けから language: "lisp" が付与される。

既定マッピングで頻出する別名をあらかじめ吸収し、カバーしきれないものを観測と設定で段階的に広げていく設計です。
asm のように観測ログで頻出することがわかったものは、次のバージョンで既定マッピングへ昇格させます16

  1. Syntax-highlighting Code Block は core/code ブロックをサーバーサイドで拡張し、highlight.php(highlight.js のPHPポート)を使ってシンタックスハイライトを適用します。フロントエンドでJavaScriptを読み込まずにハイライトを実現している点が特徴です。 – Syntax-highlighting Code Block – WordPress.org
  2. Gutenbergは貼り付け時にクリップボードの text/plain@wordpress/blockspasteHandler で処理し、Markdownのfenced code blockを core/code ブロックへ変換します。ただしinfo stringに書かれた言語識別子はこの変換時に破棄されます。 – Block Editor Handbook: @wordpress/blocks
  3. CommonMarkの仕様では「info stringの先頭の単語は通常、コードサンプルの言語を指定するために使われ、code タグの class 属性にレンダリングされる」と記載されています。ただし仕様としての強制はなく、あくまで慣習の規定です。 – CommonMark Spec – Fenced Code Blocks
  4. Object.defineProperty() で既存プロパティを再定義できるのは、そのプロパティの configurabletrue の場合に限られます。configurable: false のプロパティに対して defineProperty() を呼ぶと TypeError が発生します。 – MDN: Object.defineProperty()
  5. webpackがES Modulesをバンドルする際、harmony export (binding) として __webpack_require__.d を使い、各エクスポートを configurable: false のgetterとして定義します。これはES Modules仕様のlive bindingセマンティクスを再現するための実装です。 – Allow module system that does not rely on getters in Webpack 4 · Issue #6979 · webpack/webpack
  6. DOMイベントはcapture phase(ルートから対象要素へ向かう経路)とbubble phase(対象要素からルートへ向かう経路)の2段階で伝播します。addEventListener の第3引数に true を渡すとcapture phaseで登録でき、同じイベントをbubble phaseで待ち受けるハンドラより先に呼び出されます。 – MDN: EventTarget.addEventListener()
  7. Gutenbergのブロックエディタは、テーマとの干渉を避けるためにiframe内でレンダリングされることがあります。この場合、document に登録したイベントリスナーはiframe内のイベントを受け取れないため、iframeのdocumentへ個別にlistenerを付ける必要があります。
  8. wp.data.subscribe() はReduxライクな @wordpress/data ストアの変化を監視するAPIです。core/block-editor ストアはブロックツリー全体を管理しており、blockの追加・削除・属性変更のたびにコールバックが呼ばれます。 – The Block Editor’s Data – Block Editor Handbook
  9. 有効期限を設けているのは、貼り付けが発生したが何らかの理由でblock treeが変化しなかった場合に、古い pendingPaste が次回の貼り付けに干渉しないようにするためです。1500msはGutenbergのblock生成処理が通常完了するのに十分な時間として設定しています。
  10. Gutenbergの各ブロックには固有の clientId(UUIDベースの文字列)が付与されます。ブロックの内容が編集されても clientId は変わりません。貼り付け前後で clientId セットを比較することで、新規に追加されたブロックだけを特定できます。
  11. dispatch('core/block-editor').updateBlockAttributes(clientId, attributes) は指定したblockの属性だけを更新します。一方 replaceBlocks(clientId, blocks) はblock全体を新しいblockで置き換えるため、他プラグインが付与した属性ごと上書きするリスクがあります。 – The Block Editor’s Data – Block Editor Handbook
  12. WordPress の管理画面向けAjaxエンドポイントは wp_ajax_{action} フックで登録します。クライアント側は wp_localize_script で渡した ajaxUrl と nonce を使ってリクエストを送ります。サーバー側では check_ajax_referer() でnonceを検証し、sanitize_text_field() で入力をサニタイズしてから保存します。 – check_ajax_referer() – Developer.WordPress.org
  13. UMD(Universal Module Definition)はAMD、CommonJS、グローバル変数のいずれの環境でも動作するJavaScriptモジュールパターンです。実行環境を判定して適切な形式でモジュールをエクスポートするため、ビルドツールなしにNode.jsとブラウザの両方で同じファイルを使えます。 – umdjs/umd – GitHub
  14. node --test はNode.js 18で実験的機能として導入され、Node.js 20でstableになったビルトインのテストランナーです。JestやMochaなどの外部ライブラリなしに、node:test モジュールと node:assert モジュールだけでテストを書けます。 – Node.js Test runner – Node.js Docs
  15. CommonMarkの仕様では、info stringの先頭の単語(最初のスペースまで)が言語識別子として扱われます。そのため common lisp のように空白を含む場合、common だけが言語識別子として抽出されます。ただしGutenbergの実装では、このような不完全な識別子を持つfenced codeをcode blockとして変換しないケースがありました。
  16. highlight.phpが対応する言語識別子の一覧は language-names.php で管理されています。asm はこのリストに存在せず、x86asm が正式な識別子です。観測ログへの頻出を確認した後、既定マッピングに asm → x86asm を追加しました。 – Syntax-highlighting Code Block – GitHub