WordPress Logo WordPress Highlight.js カスタムブロックの作成

WordPress でシンタックスハイライター Highlight.js を使ってハイライト表示するカスタムブロックを作成する方法についての解説のような覚書です。

create-block を使用して比較的簡単にカスタムブロックのプラグインを作成することができます。

更新日:2024年08月10日

作成日:2024年02月03日

以下で使用している環境(node や npm、WordPress のローカル環境が必要です)

  • @wordpress/create-block: v4.34.0
  • @wordpress/scripts: v27.1.0
  • node: v20.6.1
  • npm: v10.1.0
  • MAMP: v6.6
  • WordPress: v6.4.3
  • Highlight.js: v11.9.0

作成したブロックを挿入してコードを直接入力すると、

フロントエンド側では以下のように表示されます(テーマ Twenty Twenty-Four での表示例)。

カスタムブロック側でエスケープするので、コードをエスケープ処理する必要がありません。

以下では Highlight.js の JavaScript と CSS が必要になるので、予めダウンロードしておきます。

また、このサンプルの CSS は atom-one-dark.min.css の使用を前提にしています。

関連ページ:

参考サイト:

ブロックの初期構成(ひな形)を作成

@wordpress/create-block を使用してブロックの初期構成のひな形を作成します。

コマンドラインツール(ターミナル)を起動して /wp-content/plugins フォルダに移動します。

% cd wp-content/plugins

以下のコマンドを実行すると、plugins フォルダ内に新しいディレクトリ my-highlight-block が作成され、その中にブロックをカスタマイズするために必要な初期ファイル(ひな形)が生成されます。

この例では --namespace で名前空間に wdl を指定しています(--namespace を省略した場合の名前空間の値は create-block になります)。

% npx @wordpress/create-block@latest my-highlight-block --namespace wdl

もし、以下のように表示されれば、y を入力し return を押して必要なパッケージをインストールします。

Need to install the following packages:
@wordpress/create-block@4.34.0
Ok to proceed? (y)

リファレンス:create-block 入門

インストールには数分かかります。処理が完了すると以下のようなメッセージが表示されます。

plugins ディレクトリの中に以下のような my-highlight-block ディレクトリが作成されます。

カスタマイズで使用するファイルはプラグインファイル(この例の場合は my-highlight-block.php)と src フォルダに入っている以下のファイルになります。

src
├── block.json
├── edit.js
├── editor.scss
├── index.js
├── save.js
├── style.scss
└── view.js

リファレンス:ブロックのファイル構成

以下がプラグインファイル my-highlight-block.php です。Description を Hightlight.js Syntax Highlight Block. などに変更します。この Description はプラグインページの説明部分に表示されます。

Author なども必要に応じて変更します。Text Domain は翻訳関数などに使用するので変更しません。

また、ファイル名や Text Domain、init アクションに登録している関数名、ブロックに自動的に付与されるクラス名などは create-block で指定したディレクトリ名と名前空間を元に生成されるので、別の名前を指定した場合は、適宜読み替えてください。

<?php
/**
* Plugin Name:       My Highlight Block
* Description:       Hightlight.js Syntax Highlight Block.
* Requires at least: 6.1
* Requires PHP:      7.0
* Version:           0.1.0
* Author:            The WordPress Contributors
* License:           GPL-2.0-or-later
* License URI:       https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain:       my-highlight-block
*
* @package           wdl
*/

if ( ! defined( 'ABSPATH' ) ) {
  exit; // Exit if accessed directly.
}

/**
* Registers the block using the metadata loaded from the `block.json` file.
* Behind the scenes, it registers also all assets so they can be enqueued
* through the block editor in the corresponding context.
*
* @see https://developer.wordpress.org/reference/functions/register_block_type/
*/
function my_highlight_block_my_highlight_block_block_init() {
  register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'my_highlight_block_my_highlight_block_block_init' );

作成したディレクトリ my-highlight-block に移動して npm start を実行して開発を始めます。

終了するには control + c を押します。

% cd my-highlight-block
% npm start

> my-highlight-block@0.1.0 start
> wp-scripts start

assets by chunk 16.2 KiB (name: index)
  asset index.js 15.1 KiB [emitted] (name: index) 1 related asset
  asset index.css 953 bytes [emitted] (name: index) 1 related asset
  asset index.asset.php 134 bytes [emitted] (name: index)
assets by chunk 1.07 KiB (name: view)
  asset view.js 1010 bytes [emitted] (name: view) 1 related asset
  asset view.asset.php 84 bytes [emitted] (name: view)
asset ./style-index.css 1020 bytes [emitted] (name: ./style-index) (id hint: style) 1 related asset
asset block.json 521 bytes [emitted] [from: src/block.json] [copied]
Entrypoint view 1.07 KiB (1.14 KiB) = view.js 1010 bytes view.asset.php 84 bytes 1 auxiliary asset
Entrypoint index 17.2 KiB (11.8 KiB) = ./style-index.css 1020 bytes index.css 953 bytes index.js 15.1 KiB index.asset.php 134 bytes 3 auxiliary assets
runtime modules 5.18 KiB 14 modules
orphan modules 4.81 KiB [orphan] 4 modules
built modules 4.54 KiB (javascript) 466 bytes (css/mini-extract) [built]
  javascript modules 4.09 KiB
    cacheable modules 3.93 KiB
      modules by path ./src/*.js 3.83 KiB 4 modules
      modules by path ./src/*.scss 100 bytes 2 modules
    external ["wp","blocks"] 42 bytes [built] [code generated]
    external "React" 42 bytes [built] [code generated]
    external ["wp","i18n"] 42 bytes [built] [code generated]
    external ["wp","blockEditor"] 42 bytes [built] [code generated]
  css modules 466 bytes
    css ./node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[4].use[1]!./node_modules/postcss-loader/dist/cjs.js??ruleSet[1].rules[4].use[2]!./node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[4].use[3]!./src/style.scss 264 bytes [built] [code generated]
    css ./node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[4].use[1]!./node_modules/postcss-loader/dist/cjs.js??ruleSet[1].rules[4].use[2]!./node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[4].use[3]!./src/editor.scss 202 bytes [built] [code generated]
  ./src/block.json 461 bytes [built] [code generated]
webpack 5.90.0 compiled successfully in 885 ms

「プラグイン」画面を開くと、作成した「My Highlight Block」プラグインが表示されています。プラグインの説明部分にはプラグインファイルの Description の内容が表示されます。

作成した「My Highlight Block」プラグインを有効化します。

新しい投稿を作成し、My Highlight Block ブロックを挿入します。

検索欄に high などとブロック名の一部を入力するとブロックの候補が表示されるので選択します。表示されない場合は、ページを再読込みします。

My Highlight Block ブロックを挿入できることを確認します。

リファレンス:ブロック開発の基本原理

block.json の編集

src フォルダの block.json を開くと初期状態では以下のようになっています。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-highlight-block",
  "version": "0.1.0",
  "title": "My Highlight Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false
  },
  "textdomain": "my-highlight-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "viewScript": "file:./view.js"
}

WordPress 5.8 から、PHP (サーバーサイド) と JavaScript (クライアントサイド) の両方でブロックタイプを登録する正規の方法として、block.json メタデータファイルの使用が推奨されています。

block.json を使用することでブロックの登録が簡単になります。

この例では以下のように icon(8行目)と description(9行目)を変更し、attributes(属性)を11〜22行目に追加します。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-highlight-block",
  "version": "0.1.0",
  "title": "My Highlight Block",
  "category": "widgets",
  "icon": "editor-code",
  "description": "Hightlight.js Syntax Highlight Block.",
  "example": {},
  "attributes": {
    "codeText": {
      "type": "string",
      "default": "",
      "source": "text",
      "selector": "code"
    },
    "language": {
      "type": "string",
      "default": ""
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-highlight-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "viewScript": "file:./view.js"
}

icon は Dashicons にアイコンが掲載されています。使用したいアイコンを表示して、dashicons-xxxxx の xxxxxの部分を指定します。また、独自のアイコンを設定して表示することもできます。

attributes(ブロック属性)の codeText は編集画面で入力するコードの文字列を保持するための属性で、type に string、default(初期値)に ""(空文字列)を指定し、ブロックの code 要素にテキストとして保存するように、source に text と selector に code を指定しています。

language は編集画面のサイドバーのインスペクタで入力する言語名の文字列を保持するための属性で、type と default を指定しています。これらの値は edit.js や save.js などから参照することができます。

リファレンス:属性

edit.js の編集

Gutenberg ブロックエディターでは、ブロックのエディター上での表示と保存時の表示を別々に定義することができます。エディター上での表示は edit.js に定義し、保存時の表示は save.js ファイルに定義します。

edit.js は、ブロックがどのように機能し、どのようにエディターに表示されるかを制御するファイルです。

ひな形のファイルでは以下のように、ユーザーに「My Highlight Block – hello from the editor!」と表示されるようになっているので編集します(コメント部分は省略しています)。

// ローカライズ(テキスト文字列の国際化)の __() のインポート
import { __ } from '@wordpress/i18n';
// useBlockProps() のインポート(React フック)
import { useBlockProps } from '@wordpress/block-editor';
// 編集画面用 CSS のインポート
import './editor.scss';

// Edit 関数
export default function Edit() {
  return (
    <p { ...useBlockProps() }>
      { __( 'My Highlight Block – hello from the editor!', 'my-highlight-block' ) }
    </p>
  );
}

Edit 関数は、エディター(編集画面)がどのようにブロックをレンダリングするかを定義して返す関数です。上記の Edit 関数では p 要素をレンダリングする JSX 返しています。

useBlockProps()ブロックラッパー内に、エディターで必要とされるすべてのクラスとスタイル(ブロックの動作の有効化に必要な属性とイベントハンドラ)を出力します。

基本的にエディターにレンダリングするブロックのラッパー要素に {...useBlockProps()} を指定して必要な属性を展開します。

リファレンス:edit と save

__() はテキストの翻訳を取得する関数で、__( text, textdomain ) の形式で使用します。textdomain に指定する値は block.json の textdomain の値です。

edit.js を以下のように書き換えます。

エディターでは、textarea にコードを入力し、サイドバーのインスペクタでテキストボックスに言語名を入力できるようにします。そのための必要なコンポーネントをインポートします。

TextareaControl はテキストを入力することができる textarea 要素を使ったコンポーネントです。

TextControl は input 要素を使ったコンポーネントです。どちらも components パッケージにあります。

インスペクターをカスタマイズするには block-editor パッケージにある InspectorControls コンポーネントを使用します。Edit 関数の return の中で InspectorControls コンポーネントで囲んだ内容はサイドバーに表示されます。インスペクターにパネルを追加するには PanelBody コンポーネントを使います。

import { __ } from "@wordpress/i18n";
// InspectorControls を追加でインポート
import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
// TextareaControl, PanelBody, TextControl を components からインポート
import { TextareaControl, PanelBody, TextControl } from "@wordpress/components";
import "./editor.scss";

export default function Edit({ attributes, setAttributes, isSelected }) {
  // テキストエリア(TextareaControl)の行数
  const codeTextRowCount = attributes.codeText.split(/\r|\r\n|\n/).length;
  const codeTextRows = codeTextRowCount > 3 ? codeTextRowCount : 3;

  // ブロックの内容を JSX で返す
  return (
    <>
      <InspectorControls>
        <PanelBody title={__("Settings", "my-highlight-block")}>
          <TextControl
            label={__("Language", "my-highlight-block")}
            value={attributes.language || ""}
            onChange={(value) => setAttributes({ language: value })}
          />
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        <TextareaControl
          label={__("Highlight Code", "my-highlight-block")}
          value={attributes.codeText}
          onChange={(value) => setAttributes({ codeText: value })}
          rows={codeTextRows}
          placeholder={__("Write your code...", "my-highlight-block")}
          hideLabelFromVision={ isSelected ? false : true}
        />
      </div>
    </>
  );
}

Edit 関数では属性(attributes)と属性を更新する関数(setAttributes)を変数に受け取り、codeText と language の値(value)を onChange を使って入力された値で更新します。

また、ブロックが現在選択されているかどうかを表す isSelected も受け取って、ブロックが選択されている場合のみ入力エリアにラベルを表示するようにしています。

引数に props を受け取り、以下のように別途分割代入することもできます。

export default function Edit(props) {
  const { attributes, setAttributes, isSelected } = props;
  ・・・
}

テキストエリアに入力された値は attributes.codeText で、インスペクタのテキストボックスに入力された値は attributes.language になります。

Edit 関数ではテキストエリアの TextareaControl とテキストボックスを含む InspectorControls コンポーネント(ブロックの内容)を返します。適切な JSX 構文となるためにすべてをフラグメント (<>〜</>) で囲みます。

テキストボックスやテキストエリアに入力される値が変更されると onChange プロパティの setAttributes メソッドで値(value)を更新します。

TextControl のプロパティ(一部抜粋)
プロパティ 説明
label このプロパティを指定すると、指定された値の文字列を使って label 要素が出力されます。
value この要素の値(表示される文字列)※必須
type レンダリングする入力要素のタイプ(デフォルトは text)
onChange 入力の値が変更されたら呼び出される関数(イベントハンドラ)※必須
TextareaControl のプロパティ(一部抜粋)
プロパティ 説明
label このプロパティを指定すると、指定された値の文字列を使って label 要素が出力されます。
hideLabelFromVision true の場合、label はスクリーンリーダーにのみ表示されます。
help このプロパティに文字列を指定するとヘルプテキストを出力します。
rows テキストエリアの行数を指定します。デフォルトは4です。
value この要素の値(表示される文字列)※必須
onChange 入力の値が変更されたら呼び出される関数(イベントハンドラ)※必須

また、TextareaControl コンポーネントのデフォルトの行数は4なので、テキストエリアに入力されている値(attributes.codeText)を改行文字で分割し、その数を行数に設定するようにしています。

上記の場合、何も入力されていない状態では3行分の高さのテキストエリアを表示します。※但し、折り返しは反映されないので、入力内容によっては、入力されたコードが全て表示されるわけではありません。

ファイルを保存してエディターを更新すると、エディターは以下のようにコードを入力するテキストエリアとサイドバーにインスペクタ(LANGUAGE 入力欄)が表示されます。

ブロック周りの背景色とボーダーは style.scss と editor.scss に初期値として記述されているスタイルによるものです。

save.js の編集

ひな形の save.js は以下のようになっています(コメント部分は省略)。Edit 関数と同様、return しているのは JSX です。

import { useBlockProps } from '@wordpress/block-editor';

export default function save() {
  return (
    <p { ...useBlockProps.save() }>
      { 'My Highlight Block – hello from the saved content!' }
    </p>
  );
}

Edit 関数同様、静的ブロックをレンダーする際は、ブロックのラッパー要素(上記の場合は p 要素)に useBlockProps.save() から返されるブロック props を追加します。これによりブロックサポート API からの任意の HTML 属性に加え、ブロッククラス名が正しくレンダーされます。

save.js ファイルは、ブロックが保存されたときに表示される HTML 構造を定義します。

この例では、save.js を以下のように書き換えます。

import { useBlockProps } from "@wordpress/block-editor";

export default function save({ attributes }) {

  // code 要素に設定する属性のオブジェクト
  const codeAttributes = {};
  if(attributes.language !== "") {
    // attributes.language が空でなければ language-xxxx というクラス属性を設定
    codeAttributes.className = `language-${attributes.language}`;
  }

  // テキストエリアに入力された特殊文字をエスケープ( replace の第2引数に関数を指定)
  const escapedCodeText = attributes.codeText.replace(/[<>&'"]/g, (match) => {
    // キーに特殊文字、値にエスケープ後の文字列のプロパティを持つオブジェクト
    const specialChars = {
      '<': '&lt;',
      '>': '&gt;',
      '&': '&amp;',
      "'": '&#39;',
      '"': '&quot;'
    };
    // [] を使って対応するキー(match)の値にアクセスして返す
    return specialChars[match];
  });

  return (
    <div {...useBlockProps.save()}>
      <div className="hljs-wrap">
        <pre>
          <code {...codeAttributes}>{escapedCodeText}</code>
        </pre>
      </div>
    </div>
  );
}

save 関数では、attributes を引数に受け取るようにします。

インスペクタの LANGUAGE に入力された値(言語名)は attributes.language で、テキストエリアに入力された値(ハイライト表示するコードのテキスト)は attributes.codeText で取得できます。

code 要素に設定する属性のためのオブジェクト(codeAttributes)を作成し、attributes.language が空でなければ「language-言語名」という値のクラス属性を追加します。そしてスプレッド構文を使って code 要素の属性として展開します。

テキストエリアに入力された値をエスケープして code 要素のコンテンツに指定します。

JSX の場合、クラス属性は class ではなく、className とします。

ラッパーの div 要素には useBlockProps.save() でブロック props を追加します(この場合は wp-block-wdl-my-highlight-block クラスが出力されます)。

言語名が指定されている場合、上記により出力されるマークアップは以下のようになります(言語名が指定されていなければ code 要素に class は出力されません)。

<div class="wp-block-wdl-my-highlight-block"><!-- useBlockProps.save() で出力されるクラス -->
  <div class="hljs-wrap">
    <pre><code class="language-言語名">エスケープした値</code></pre>
  </div>
</div>

リファレンス(LEARN REACT):

ファイルを保存してエディターを再読み込みすると、save() 関数の return ステートメントが変更されたので以下のように表示されます。「ブロックのリカバリーを試行」をクリックします。

ブロックに入力できる状態になるので、例えば以下のようなコードを記述して保存(投稿を更新)すると、

フロントエンド側では以下のように表示されます。

また、エディターをコードエディターに切り替えると以下のように表示されます。

block.json で attributes.codeText(入力されたコードの属性)は source に text、selector に code を指定しているので、入力されたコードはエスケープされテキストとして code 要素内に保存されています。

attributes.language は source と selector を指定していないので、コメントタグ内に保存されています。

Highlight.js の読み込み

入力したコードをハイライト表示するように Highlight.js の JavaScript と CSS を読み込みます。

Highlight.js から JavaScript とテーマの CSS をダウンロードして、プラグインディレクトリに highlight-js というフォルダを作成して保存します。

この例ではテーマの CSS は atom-one-dark.min.css を highlight.min.css という名前に変更して保存しています。

highlight-js
├── highlight.min.css  // テーマの CSS(atom-one-dark.min.css)を名前を変えて保存
└── highlight.min.js

プラグインファイルの編集

プラグインファイル my-highlight-block.php で Highlight.js の JavaScript と CSS の読み込みます。

以下の20〜55行目を追加します。

highlight-js フォルダに保存した highlight.min.js と highlight.min.css を wp_enqueue_scriptwp_enqueue_style を使って、enqueue_block_assets アクションで読み込みます。

また、wp_add_inline_script を使って highlight.js の初期化の JavaScript を上記の highlight.min.js の読み込みの後に出力します(highlight.min.js に含めてしまうこともできます)。

<?php
/**
* Plugin Name:       My Highlight Block
* Description:       Hightlight.js Syntax Highlight Block.
* Requires at least: 6.1
* Requires PHP:      7.0
* Version:           0.1.0
* Author:            The WordPress Contributors
* License:           GPL-2.0-or-later
* License URI:       https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain:       my-highlight-block
*
* @package           wdl
*/

if (!defined('ABSPATH')) {
  exit; // Exit if accessed directly.
}

function add_my_code_block_scripts_and_styles() {
  $dir = dirname(__FILE__);

  //管理画面以外(フロントエンド側でのみ読み込む)
  if (!is_admin()) {
    // highlight.js の JavaScript ファイルのエンキュー
    wp_enqueue_script(
      'highlight-js',
      plugins_url('/highlight-js/highlight.min.js', __FILE__),
      array(),
      filemtime("$dir/highlight-js/highlight.min.js"),
      true
    );

    // highlight.js の基本スタイルのエンキュー
    wp_enqueue_style(
      'highlight-js-style',
      plugins_url('/highlight-js/highlight.min.css', __FILE__),
      array(),
      filemtime("$dir/highlight-js/highlight.min.css")
    );

    // highlight.js の初期化の JavaScript を上記の highlight.min.js の読み込みの後に出力
    wp_add_inline_script(
      // 上記で登録したハンドル名を指定
      'highlight-js',
      // script タグに出力する JavaScript
      'document.addEventListener("DOMContentLoaded", (event) => {
  document.querySelectorAll("pre code").forEach((el) => {
    hljs.highlightElement(el);
  });
});'
    );
  }
}
add_action('enqueue_block_assets', 'add_my_code_block_scripts_and_styles');

function my_highlight_block_my_highlight_block_block_init() {
  register_block_type(__DIR__ . '/build');
}
add_action('init', 'my_highlight_block_my_highlight_block_block_init');

ファイルを保存して、エディターに以下のようなブロックを追加して保存すると、

フロントエンド側では以下のように表示されます。

この例の場合、追加したブロックには LANGUAGE を指定していませんが、Highlight.js が自動的に言語を検出して code 要素に language-javascript クラスを追加しているのが確認できます。

以下は編集画面でコードエディタに切り替えた場合の表示です。

editor.scss の編集

ひな形で生成された editor.scss には、useBlockProps() により出力されるクラスを使った以下のスタイルが記述されていますが不要なので削除します。

.wp-block-wdl-my-highlight-block {
	border: 1px dotted #f00;
}

必要に応じて出力されるクラス(この場合は .wp-block-wdl-my-highlight-block)を使って編集画面のブロックのスタイルを記述します。例えば、以下のように記述して保存すると、

.wp-block-wdl-my-highlight-block textarea:focus {
  background-color: #f6fdf6;
}

.wp-block-wdl-my-highlight-block label{
  font-size: 16px;
}

編集画面では以下のように、ブロックがフォーカスされると薄緑色の背景色が表示されます(適用されない場合は、ページを再読込します)。

カスタムアイコンの追加

現在は block.json で "icon": "editor-code" を指定しているので、< > のようなアイコンが表示されていますが、必要に応じて独自のアイコンを設定することもできます。

カスタムアイコンを追加するには、アイコンの SVG を用意して index.js の registerBlockType で icon プロパティを追加します。

ブロックのアイコンは 24 ピクセルの正方形でなければなりません(とのことです)。

以下は Bootstrap Icons の Highlights アイコンを設定する例です。オリジナルのサイズは 16 ピクセルですが、24 ピクセルに変更しています。

import { registerBlockType } from "@wordpress/blocks";
import './style.scss';
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";

// SVG アイコンの定義
const highlightIcon = (
  <svg
    viewBox="0 0 16 16"
    xmlns="http://www.w3.org/2000/svg"
    aria-hidden="true"
    focusable="false"
    width="24"
    height="24"
  >
    <path d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0m-8 5v1H4.5a.5.5 0 0 0-.093.009A7 7 0 0 1 3.1 13zm0-1H2.255a7 7 0 0 1-.581-1H8zm-6.71-2a7 7 0 0 1-.22-1H8v1zM1 8q0-.51.07-1H8v1zm.29-2q.155-.519.384-1H8v1zm.965-2q.377-.54.846-1H8v1zm2.137-2A6.97 6.97 0 0 1 8 1v1z"/>
  </svg>
);

registerBlockType(metadata.name, {
  icon: highlightIcon,  // icon プロパティを追加
  edit: Edit,
  save,
});

上記を設定した場合は、block.json の "icon": "editor-code" の行を削除します。

ファイルを保存してエディターを確認すると、アイコンが変わっています。

チュートリアル: はじめてのブロック作成(カスタムアイコンの追加)

リファレンス:components/Icon

クリーンアップ

不要なコードやファイルを削除します。

ファイルを削除する前に、削除するファイルの読み込み部分をコード上から削除しておきます。

先にファイルを削除するとターミナル上に以下のようなエラーが出てしまいますが、後からコードを修正すればエラーは消えます。

Skipping "./view.js" listed in "src/block.json". File does not exist in the "src" directory.

ERROR in ./src/index.js 3:0-22
Module not found: Error: Can't resolve './style.scss' in '/Applications/MAMP/htdocs/wp-sample/wp-content/plugins/my-highlight-block/src'

style.scss と view.js の削除

このカスタムブロックでは src ディレクトリの style.scss ファイルと view.js ファイルは使用しないので、まず block.json と index.js で不要なコードを削除して、その後不要なファイルを削除します。

プレビュー機能を追加する場合は、style.scss は残します(不要であれば後から削除できます)。

また、style.scss と view.js に Highlight.js のスタイルと JavaScript を記述する方法もあります(style.scss と view.js を使う場合)。

block.json

block.json で、"style": "file:./style-index.css" の行と "viewScript": "file:./view.js" の行、及びその前の "editorStyle" の行の最後のカンマを削除します。

この例ではカスタムアイコンを設定しているので、"icon": "editor-code" の行も削除しています(カスタムアイコンを設定していない場合は残します)。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-highlight-block",
  "version": "0.1.0",
  "title": "My Highlight Block",
  "category": "widgets",
  "description": "Hightlight.js Syntax Highlight Block.",
  "example": {},
  "attributes": {
    "codeText": {
      "type": "string",
      "default": "",
      "source": "text",
      "selector": "code"
    },
    "language": {
      "type": "string",
      "default": ""
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-highlight-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css"
}
index.js

index.js ファイルで、style.scss の import 行を削除します。

import { registerBlockType } from "@wordpress/blocks";
// import './style.scss'; この行を削除
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";

// SVG アイコンの定義(カスタムアイコンを設定する場合)
const highlightIcon = (
  <svg
    viewBox="0 0 16 16"
    xmlns="http://www.w3.org/2000/svg"
    aria-hidden="true"
    focusable="false"
    width="24"
    height="24"
  >
    <path d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0m-8 5v1H4.5a.5.5 0 0 0-.093.009A7 7 0 0 1 3.1 13zm0-1H2.255a7 7 0 0 1-.581-1H8zm-6.71-2a7 7 0 0 1-.22-1H8v1zM1 8q0-.51.07-1H8v1zm.29-2q.155-.519.384-1H8v1zm.965-2q.377-.54.846-1H8v1zm2.137-2A6.97 6.97 0 0 1 8 1v1z"/>
  </svg>
);

registerBlockType(metadata.name, {
  icon: highlightIcon,  // カスタムアイコンを設定する場合
  edit: Edit,
  save,
});
不要なファイルを削除

src ディレクトリの style.scss と view.js の2つのファイルを削除します。

style.scss はエディターとサイトのフロントエンドの両方でコンテンツが表示される際に読み込まれます。

このカスタムブロックではフロントエンドのスタイルはテーマの CSS で調整することとして style.scss を削除していますが、必要に応じて残してスタイルを設定することもできます。

表示の確認

style.scss のスタイルが削除されたので、エディターではテキストエリアの周りの青色が消え、ブロックにフォーカスすると以下のように表示されます。

フロントエンド側も同様に周りの青色が消えて以下のように表示されます。

プラグインを無効化して確認

プラグインが無効化された場合に、フォールバックとして入力したコードが正しく残るのを確認します。

ブロックのプラグインを無効化してエディターを再読み込みすると、以下のように表示されます。

「HTMLとして保存」をクリックして、以下のように入力したコードがエスケープされ、div pre code でマークアップされていれば問題ありません。

投稿を保存して、フロントエンド側で確認すると、以下のように表示されます。

このブロックの場合、ブロックのプラグインを再度有効化するとマークアップに対して JavaScript と CSS が適用されるので、カスタム HTML に変換したブロックでもハイライト表示されます。

または、変換を「やり直し」で元に戻してプラグインを再度有効化すれば、元に戻ります。

ビルドを実行

最後に、変更がすべて保存されていることを確認して、control + c を押して npm run start コマンドを終了します。npm run build を実行して、コードを最適化し、本番環境用にビルドします。

% npm run build

> my-highlight-block@0.1.0 build
> wp-scripts build

asset index.js 1.75 KiB [emitted] [minimized] (name: index)
asset block.json 629 bytes [emitted] [from: src/block.json] [copied]
asset index.asset.php 151 bytes [emitted] (name: index)
asset index.css 128 bytes [emitted] (name: index)
Entrypoint index 2.02 KiB = index.css 128 bytes index.js 1.75 KiB index.asset.php 151 bytes
orphan modules 5.92 KiB (javascript) 937 bytes (runtime) [orphan] 16 modules
built modules 3.8 KiB (javascript) 127 bytes (css/mini-extract) [built]
  ./src/index.js + 8 modules 3.8 KiB [not cacheable] [built] [code generated]
  css ./node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[3].use[1]!./node_modules/postcss-loader/dist/cjs.js??ruleSet[1].rules[3].use[2]!./node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[3].use[3]!./src/editor.scss 127 bytes [built] [code generated]
webpack 5.90.0 compiled successfully in 5336 ms

投稿にブロックを作成して表示に問題がなければ、基本的なブロックの作成はこれで完成です。

必要に応じてプレビュー機能を追加することもできます。

plugin-zip

npm run plugin-zip を実行して、プラグインの zip ファイルを作成することができます。

但し、この例の場合、そのまま実行すると、プラグインディレクトリに配置した Highlight.js 用のフォルダは zip ファイルに含まれません。

% npm run plugin-zip

> my-highlight-block@0.1.0 plugin-zip
> wp-scripts plugin-zip

Creating archive for `my-highlight-block` plugin... 🎁

Using Plugin Handbook best practices to discover files:

  Adding `my-highlight-block.php`.
  Adding `readme.txt`.
  Adding `build/block.json`.
  Adding `build/index.asset.php`.
  Adding `build/index.css`.
  Adding `build/index.js`.

Done. `my-highlight-block.zip` is ready! 🎉

上記の場合、作成された zip ファイルを解凍して生成されるファイルは以下になります。

my-highlight-block
├── build
│   ├── block.json
│   ├── index.asset.php
│   ├── index.css
│   └── index.js
├── my-highlight-block.php
└── readme.txt

プラグインディレクトリに配置した Highlight.js 用のフォルダを zip ファイルに含めるには、package.json に files フィールドの行を追加し、zip ファイルに含めるファイルやディレクトリを指定します。

{
  "name": "my-highlight-block",
  "version": "0.1.0",
  "description": "Hightlight.js Syntax Highlight Block tool.",
  "author": "The WordPress Contributors",
  "license": "GPL-2.0-or-later",
  "main": "build/index.js",
  "scripts": {
    "build": "wp-scripts build",
    "format": "wp-scripts format",
    "lint:css": "wp-scripts lint-style",
    "lint:js": "wp-scripts lint-js",
    "packages-update": "wp-scripts packages-update",
    "plugin-zip": "wp-scripts plugin-zip",
    "start": "wp-scripts start"
  },
  "files": [ "highlight-js", "build", "my-highlight-block.php" ],
  "devDependencies": {
    "@wordpress/scripts": "^27.1.0"
  }
}

package.json を上記に変更して保存し、npm run plugin-zip を実行すると以下のようになります。

% npm run plugin-zip

> my-highlight-block@0.1.0 plugin-zip
> wp-scripts plugin-zip

Creating archive for `my-highlight-block` plugin... 🎁

Using the `files` field from `package.json` to detect files:

  Adding `highlight-js/highlight.min.css`.
  Adding `build/index.css`.
  Adding `highlight-js/highlight.min.js`.
  Adding `build/index.js`.
  Adding `build/block.json`.
  Adding `package.json`.
  Adding `build/index.asset.php`.
  Adding `my-highlight-block.php`.
  Adding `readme.txt`.

Done. `my-highlight-block.zip` is ready! 🎉

上記の場合、作成された zip ファイルを解凍して生成されるファイルは以下になります。

my-highlight-block
├── build
│   ├── block.json
│   ├── index.asset.php
│   ├── index.css
│   └── index.js
├── highlight-js
│   ├── highlight.min.css
│   └── highlight.min.js
├── my-highlight-block.php
├── package.json
└── readme.txt

package.json に files フィールドを "files": [ "*" ] とすると、全てのファイルを zip ファイルに含めることができますが、.DS_Store や .gitignore などのファイルも含まれれてしまいます。

files フィールドに src ディレクトリも含めて自分のバックアップ用としておくのも良いかもしれません。

plugin-zip の詳細は、node_modules/@wordpress/scripts/scripts/plugin-zip.js で確認できます。

参考リンク:

この例の場合、npm run build でビルド実行後、npm run plugin-zip により作成される zip ファイルのサイズは44kbで、解凍後のサイズは139kbでした。

作成された zip ファイルを使って、「新規プラグインを追加」→「プラグインを追加」の「プラグインをアップロード」のボタンをクリックして、ファイルを選択すれば、別のサイトでプラグインとしてインストールすることができます。

ブロックの非推奨プロセス

save() 関数の出力は、投稿コンテンツの HTML と正確に一致しなければならないため、save() 関数を変更して return ステートメント内に変更が発生すると、ブロックの検証(妥当性検証プロセス)により、ブロックは無効(invalid)としてマークされ、ブロックでバリデーションエラーが発生します。

ブロックに機能を追加したり、削除するとこのバリデーションエラーが発生しますが、ブロックを完全に構築し終わったときにバリデーションエラーが出なければ問題ありません。

また、save() 関数の return ステートメント内を変更しても、バリデーションエラーが発生しない場合もあります。詳細は edit と save のバリデーションの項に記載されています。

以下は save() 関数を変更してバリデーションエラーが発生した場合に、非推奨バージョンを用意してバリデーションエラーを回避する例です。

例えば、現在のブロックにエディタのインスペクタで自動折り返しの有効・無効を切り替える機能を追加する場合、以下のような作業が必要になります。

  1. block.json に自動折り返しの有効・無効の値を保存する属性を追加
  2. edit.js で自動折り返しの有効・無効を切り替えるチェックボックスをインスペクタに追加
  3. save.js で保存するマークアップを変更(チェックボックスの値にり pre 要素にクラスを追加)
  4. 追加したクラスに対応するスタイルを設定

npm start を実行して開発を始めます。

% npm start

block.json を編集

block.json に自動折り返しの有効・無効の値を保存する属性 wrap を追加して保存します。

wrap の type は boolean、default は false として、エディタでインスペクタのチェックボックスで true と false を切り替えます。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-highlight-block",
  "version": "0.1.0",
  "title": "My Highlight Block",
  "category": "widgets",
  "description": "Hightlight.js Syntax Highlight Block.",
  "example": {},
  "attributes": {
    "codeText": {
      "type": "string",
      "default": "",
      "source": "text",
      "selector": "code"
    },
    "language": {
      "type": "string",
      "default": ""
    },
    "wrap": {
      "type": "boolean",
      "default": false
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-highlight-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css"
}

edit.js を編集

edit.js で自動折り返しの有効・無効を切り替えるチェックボックスをインスペクタに追加します。

PanelRow と CheckboxControl を追加で components からインポートします。

Edit 関数で PanelBody の中にパネル内のコンテナである PanelRow で TextControl と CheckboxControl コンポーネントを囲んで配置します。

import { __ } from "@wordpress/i18n";
import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
// PanelRow と CheckboxControl を追加で components からインポート
import { TextareaControl, PanelBody, TextControl, PanelRow, CheckboxControl } from "@wordpress/components";
import "./editor.scss";

export default function Edit({ attributes, setAttributes }) {
  const codeTextRowCount = attributes.codeText.split(/\r|\r\n|\n/).length;
  const codeTextRows = codeTextRowCount > 3 ? codeTextRowCount : 3;

  return (
    <>
      <InspectorControls>
        <PanelBody title={__("Settings", "my-highlight-block")}>
          <PanelRow>
            <TextControl
              label={__("Language", "my-highlight-block")}
              value={attributes.language || ""}
              onChange={(value) => setAttributes({ language: value })}
            />
          </PanelRow>
          <PanelRow>
            <CheckboxControl
              label={__("white-space: pre-wrap", "my-highlight-block")}
              checked={attributes.wrap}
              onChange={(val) => setAttributes({ wrap: val })}
              help={__("Enable Text Auto Wrap", "my-highlight-block")}
            />
          </PanelRow>
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        <TextareaControl
          label={__("Highlight Code", "my-highlight-block")}
          value={attributes.codeText}
          onChange={(value) => setAttributes({ codeText: value })}
          rows={codeTextRows}
          placeholder={__("Write your code...", "my-highlight-block")}
        />
      </div>
    </>
  );
}

チェックボックスの checked プロパティは状態を表す真偽値で、チェックボックスがチェックされている場合、checked の値は true で、チェックされていない場合は false になります。onChange で現在の状態(checked の値の真偽値)を setAttributes() で wrap 属性に設定します。

CheckboxControl
プロパティ 説明
heading 指定すると label 要素で見出しをチェックボックスの上に出力します。
label 指定すると label 要素でラベルをチェックボックスの右側(横)に出力します。
help 指定するとヘルプテキストを出力します。
checked チェックボックスの状態を表す真偽値。チェックボックスがチェックされている場合、checked の値は true になり、false の場合はチェックされていない状態です。
onChange checked の値(真偽値)を引数に取る関数

ファイルを保存して、エディターを再読込するとインスペクタにチェックボックスが追加されます。

save.js を編集

save.js を以下のように編集してチェックボックスがチェックされていれば(attributes.wrap の値が true であれば)、pre 要素に class="pre-wrap" を追加するように変更します。

import { useBlockProps } from "@wordpress/block-editor";

export default function save({ attributes }) {

  const codeAttributes = {};
  if (attributes.language !== "") {
    codeAttributes.className = `language-${attributes.language}`;
  }

  // pre 要素に設定する属性のオブジェクト
  const preAttributes = {};
  if (attributes.wrap) {
    // attributes.wrap が true であれば pre 要素に pre-wrap というクラス属性を設定
    preAttributes.className = "pre-wrap";
  }

  const escapedCodeText = attributes.codeText.replace(/[<>&'"]/g, (match) => {
    const specialChars = {
      '<': '&lt;',
      '>': '&gt;',
      '&': '&amp;',
      "'": '&#39;',
      '"': '&quot;'
    };
    return specialChars[match];
  });

  return (
    <div {...useBlockProps.save()}>
      <div className="hljs-wrap">
        <pre {...preAttributes}>
          <code {...codeAttributes}>{escapedCodeText}</code>
        </pre>
      </div>
    </div>
  );
}

この場合、attributes.wrap のデフォルトは false なので、既存のブロックの pre 要素にはクラスは追加されないため、既存のブロックの出力は変わりません。

そのため、既存のブロックでバリデーションエラーは発生しません。

但し、attributes.wrap のデフォルトを true にした場合、既存のブロックの pre 要素にも pre-wrap クラスが追加されるため、妥当性検証プロセスによりブロックでバリデーションエラーが発生します。

{
  ・・・中略・・・
    "wrap": {
      "type": "boolean",
      "default": true
    }
    ・・・中略・・・
}

block.json を上記のように変更して、既存のブロックのあるページのエディタを再読込すると、以下のようにバリデーションエラーが表示されます。コンソールを確認すると、投稿に保存されている内容と save() により出力される内容が異なる旨のエラーが表示されます。

フロントエンド側はブロックのコード変更前のコンテンツが表示されていて、エラーはありません。

既存のブロックでは、「ブロックのリカバリーを試行」をクリックすればエラーは解消されますが、全ての既存のブロックでそれを行うのは大変ですし、このように表示されているのは望ましくありません。

このような場合、ブロックの「deprecated(非推奨)」バージョンを提供することで、バリデーションエラーを回避することができます。

非推奨バージョンを提供するには、src ディレクトリに deprecated.js というファイルを作成し、古いバージョンの save() 関数を含むオブジェクトを指定します。そして、index.js にインポートし、registerBlockType 関数内で参照します。

これにより、パースしたブロックの現行の状態が不正 (invalid) の場合、非推奨プロセス (deprecation) が実行されます。

リファレンス:

以下は非推奨(deprecated)バージョンを用意して、上記のバリデーションエラーを回避する例です。

実際にはこの例の場合、非推奨バージョンを作成しなくてもバリデーションエラーを回避することはできますが、参考例として非推奨バージョンを作成して回避します。

※ 但し、これが正しい方法かどうかは不明です。

deprecated.js

src ディレクトリに deprecated.js というファイルを作成します。

この例では v1 という変数に1つの非推奨バージョンのオブジェクトを定義します(ブロックは複数の非推奨バージョンを持つことができます)。

v1 には 古いバージョンの save() 関数を含むオブジェクトの値を指定します。そして非推奨バージョンオブジェクトの配列(この場合1つなので [v1] )をデフォルトエクスポートします。

※ attributes、supports、save は自動で現行バージョンから継承されないため、非推奨オブジェクトで定義する必要があります。

この例の場合、非推奨バージョンの attributes に wrap 属性を追加して、値を false にしています。

これにより古いブロックでは、インスペクタの wrap 属性のチェックボックスがチェックされていない状態になります。可能であれば、migrate( attributes, innerBlocks ) を使ってブロック内部を書き換えられれば良いのですが、方法がわかりませんでした。

import { useBlockProps } from "@wordpress/block-editor";

// 非推奨バージョンのオブジェクトを定義
const v1 = {
  // attributes を定義
  attributes: {
    codeText: {
      type: "string",
      default: "",
      source: "text",
      selector: "code",
    },
    language: {
      type: "string",
      default: "",
    },
    wrap: {
      type: "boolean",
      default: false,  // 古いバージョン用の初期値
    },
  },

  // 古いバージョンの save() 関数
  save({ attributes }) {

    const codeAttributes = {};
    if (attributes.language !== "") {
      codeAttributes.className = `language-${attributes.language}`;
    }

    const escapedCodeText = attributes.codeText.replace(/[<>&'"]/g, (match) => {
      const specialChars = {
        '<': '&lt;',
        '>': '&gt;',
        '&': '&amp;',
        "'": '&#39;',
        '"': '&quot;'
      };
      return specialChars[match];
    });

    return (
      <div {...useBlockProps.save()}>
        <div className="hljs-wrap">
          <pre>
            <code {...codeAttributes}>{escapedCodeText}</code>
          </pre>
        </div>
      </div>
    );
  },
};

// 非推奨バージョンのオブジェクトを配列でデフォルトエクスポート
export default [v1];
registerBlockType を更新

index.js で deprecated.js でエクスポートした非推奨バージョンの配列をインポートし、registerBlockType 関数呼び出しに渡されるオブジェクト内で参照します。

import { registerBlockType } from "@wordpress/blocks";
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";
// 非推奨バージョンのオブジェクト(配列)をインポート
import deprecated from './deprecated';

const highlightIcon = (
  <svg
    viewBox="0 0 16 16"
    xmlns="http://www.w3.org/2000/svg"
    aria-hidden="true"
    focusable="false"
    width="24"
    height="24"
  >
    <path d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0m-8 5v1H4.5a.5.5 0 0 0-.093.009A7 7 0 0 1 3.1 13zm0-1H2.255a7 7 0 0 1-.581-1H8zm-6.71-2a7 7 0 0 1-.22-1H8v1zM1 8q0-.51.07-1H8v1zm.29-2q.155-.519.384-1H8v1zm.965-2q.377-.54.846-1H8v1zm2.137-2A6.97 6.97 0 0 1 8 1v1z"/>
  </svg>
);

registerBlockType(metadata.name, {
  icon: highlightIcon,
  edit: Edit,
  save,
  deprecated  // deprecated: deprecated と同じこと
});

ファイルを保存して、エディタを開いてページを再読込するとバリデーションエラーは消えます。

また、Updated Block: wdl/my-highlight-block Block successfully updated for `wdl/my-highlight-block` のようなメッセージがコンソールに表示されます。ブロックを更新して、ページを保存すると、このメッセージは出力されないようになります。

もし、完成後に save() 関数を変更する必要が出た場合は、上記のような方法でバリデーションエラーを回避することができます。

プレビュー機能の追加

エディター画面で、入力したコードのハイライト表示をプレビューする機能を追加する例です。

ブロックを選択すると、ツールバーにプレビューモードと編集モードのボタンを表示し、クリックしてプレビューと編集を切り替えて表示します。

注意点としては、プレビューボタンをクリックしてプレビューすると、何も変更していないのに投稿の「更新」ボタンがアクティブになります(プレビュー時に「更新」ボタンがアクティブに)。

deprecated.js は不要なので削除

前項(ブロックの非推奨プロセス)で作成した deprecated.js は不要なので、もし、追加している場合は index.js から deprecated のインポートと registerBlockType 内の deprecated を削除して以下のように変更します。

import { registerBlockType } from "@wordpress/blocks";
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";

const highlightIcon = (
  <svg
    viewBox="0 0 16 16"
    xmlns="http://www.w3.org/2000/svg"
    aria-hidden="true"
    focusable="false"
    width="24"
    height="24"
  >
    <path d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0m-8 5v1H4.5a.5.5 0 0 0-.093.009A7 7 0 0 1 3.1 13zm0-1H2.255a7 7 0 0 1-.581-1H8zm-6.71-2a7 7 0 0 1-.22-1H8v1zM1 8q0-.51.07-1H8v1zm.29-2q.155-.519.384-1H8v1zm.965-2q.377-.54.846-1H8v1zm2.137-2A6.97 6.97 0 0 1 8 1v1z"/>
  </svg>
);

registerBlockType(metadata.name, {
  icon: highlightIcon,
  edit: Edit,
  save,
});

deprecated.js ファイルを削除します。

block.json

block.json に現在編集モードなのかプレビューモードなのかの真偽値を保持する属性 isEditMode を追加します。この例では前項(ブロックの非推奨プロセス)で追加した自動折り返しの有効・無効の値を保存する属性 wrap(21〜24行目)も含めています。

[追記] isEditMode を属性を使って定義すると、プレビュー時に「更新」ボタンがアクティブになってしまいます。これを回避するには解決策を参照ください。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-highlight-block",
  "version": "0.1.0",
  "title": "My Highlight Block",
  "category": "widgets",
  "description": "Hightlight.js Syntax Highlight Block.",
  "example": {},
  "attributes": {
    "codeText": {
      "type": "string",
      "default": "",
      "source": "text",
      "selector": "code"
    },
    "language": {
      "type": "string",
      "default": ""
    },
    "wrap": {
      "type": "boolean",
      "default": false
    },
    "isEditMode": {
      "type": "boolean",
      "default": true
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-highlight-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css"
}

プラグインファイル

Highlight.js の JavaScript と CSS をエディターでも読み込むようにプラグインファイル(my-highlight-block.php)を変更します。

但し、highlight.js の初期化はフロントエンド側のみで行い、プレビュー表示での初期化はボタンをクリックした際に Edit() 関数内で useRef() と useEffect() を使って行います。

<?php
/**
* Plugin Name:       My Highlight Block
* Description:       Hightlight.js Syntax Highlight Code Block.
* Requires at least: 6.1
* Requires PHP:      7.0
* Version:           0.1.0
* Author:            The WordPress Contributors
* License:           GPL-2.0-or-later
* License URI:       https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain:       my-highlight-block
*
* @package           wdl
*/

if ( ! defined( 'ABSPATH' ) ) {
  exit; // Exit if accessed directly.
}

function add_my_code_block_scripts_and_styles() {
  $dir = dirname(__FILE__);

  //管理画面とフロントエンド側の両方で highlight.js の JavaScript と CSS を読み込む
  wp_enqueue_script(
    'highlight-js',
    plugins_url('/highlight-js/highlight.min.js', __FILE__),
    array(),
    filemtime("$dir/highlight-js/highlight.min.js"),
    true
  );

  wp_enqueue_style(
    'highlight-js-style',
    plugins_url('/highlight-js/highlight.min.css', __FILE__),
    array(),
    filemtime("$dir/highlight-js/highlight.min.css")
  );

  // フロントエンド側でのみ highlight.js の初期化を実行
  if (!is_admin()) {
    // highlight.js の初期化の JavaScript を上記の highlight.min.js の読み込みの後に出力
    wp_add_inline_script(
      // 上記で登録したハンドル名を指定
      'highlight-js',
      // script タグに出力する JavaScript
      'document.addEventListener("DOMContentLoaded", (event) => {
  document.querySelectorAll("pre code").forEach((el) => {
    hljs.highlightElement(el);
  });
});'
    );
  }
}
add_action('enqueue_block_assets', 'add_my_code_block_scripts_and_styles');

function my_hljs_block_my_hljs_block_block_init() {
  register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'my_hljs_block_my_hljs_block_block_init' );

edit.js

追加で以下のコンポーネントやフックをインポートします。

ツールバーに使用するコンポーネント

  • BlockControls
  • ToolbarGroup
  • ToolbarButton

プレビュー時に Highlight.js の初期化に使用するフック

  • useRef
  • useEffect

プレビュー時にインスペクタを無効(disabled)にする際に使用するフック

  • useDisabled

Edit 関数では引数に isSelected も受け取り、ブロックが選択されてい場合は、この値を使ってプレビューを終了するようにしています。

インスペクターの出力は getInspectorControls、ツールバーの出力は getToolbarControls という関数を別途定義して、Edit 関数の return ステートメントで配列で指定することで記述順にレンダリングされ、関数を直接呼び出すことができます。

インスペクターの言語名を入力するテキストボックスと自動折り返しのチェックボックスはプレビュー時には操作できないように useDisabled を使ってまとめて disabled にしています。

useDisabled フックは要素に inert プロパティを設定して無効にします(要素を無視します)。この例の場合は、無効にする要素は2つだけなので、個々に disabled={!attributes.isEditMode} を指定して、要素を disabled にする方が良いかもしれません。

ツールバーでは BlockControlsToolbarGroup で囲んだ ToolbarButton で 「Edit」と「Preview」というボタンを表示し、onClick で isEditMode の値を更新します。その際、isEditMode の値により is-pressed クラスをトグルしてボタンの背景色を切り替えています。

import { __ } from "@wordpress/i18n";
// BlockControls を追加
import { useBlockProps, InspectorControls, BlockControls } from "@wordpress/block-editor";
// ToolbarGroup, ToolbarButton を追加
import { TextareaControl, PanelBody, TextControl, PanelRow, CheckboxControl, ToolbarGroup, ToolbarButton } from "@wordpress/components";
import "./editor.scss";
// useEffect, useRef を追加
import { useEffect, useRef } from "@wordpress/element";
// useDisabled を追加
import { useDisabled } from "@wordpress/compose";

// 引数に追加で isSelected を受け取る
export default function Edit({ attributes, setAttributes, isSelected }) {
  // useDisabled フック
  const disabledRef = useDisabled();
  // インスペクタの各要素のラッパーの div 要素へ設定する属性
  const inspectorDivAttributes = {};
  inspectorDivAttributes.className = 'inspectorDiv';
  if (!attributes.isEditMode) {
    // プレビューモードではインスペクタへの入力を disalbed に(ref 属性に disabledRef を指定)
    inspectorDivAttributes.ref = disabledRef;
  }

  const codeTextRowCount = attributes.codeText.split(/\r|\r\n|\n/).length;
  const codeTextRows = codeTextRowCount > 3 ? codeTextRowCount : 3;

  // インスペクターを出力する関数
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title={__("Settings", "my-highlight-block")}>
          <div {...inspectorDivAttributes}>
            <PanelRow>
              <TextControl
                label={__("Language", "my-highlight-block")}
                value={attributes.language || ""}
                onChange={(value) => setAttributes({ language: value })}
                className="lang-name"
              />
            </PanelRow>
            <PanelRow>
              <CheckboxControl
                label={__("white-space: pre-wrap", "my-highlight-block")}
                checked={attributes.wrap}
                onChange={(val) => setAttributes({ wrap: val })}
                help={__("Enable Text Auto Wrap", "my-highlight-block")}
              />
            </PanelRow>
          </div>
        </PanelBody>
      </InspectorControls>
    );
  };

  // ツールバーを出力する関数
  const getToolbarControls = () => {
    return (
      <BlockControls>
        <ToolbarGroup>
          <ToolbarButton
            text="Edit"
            className={
              attributes.isEditMode ? "edit-button is-pressed" : "edit-button"
            }
            onClick={() =>
              // isEditMode の値を true に
              setAttributes({ isEditMode: true })
            }
          />
          <ToolbarButton
            text="Preview"
            className={
              attributes.isEditMode
                ? "preview-button"
                : "preview-button is-pressed"
            }
            onClick={() =>
              // isEditMode の値を false に
              setAttributes({ isEditMode: false })
            }
          />
        </ToolbarGroup>
      </BlockControls>
    );
  };

  // ref を宣言してコードの祖先の div.hljs-wrap に指定
  const codeWrapperRef = useRef(null);

  useEffect(() => {
    // プレビューモードであれば
    if (!attributes.isEditMode) {
      // ref を指定した要素から code 要素を取得
      const code = codeWrapperRef.current.querySelector("code");
      // 取得した code 要素をハイライト表示
      hljs.highlightElement(code);
    }
  }, [attributes.isEditMode]);

  const codeAttributes = {};
  if (attributes.language !== "") {
    codeAttributes.className = `language-${attributes.language}`;
  }
  const preAttributes = {};
  if (attributes.wrap) {
    preAttributes.className = "pre-wrap";
  }

  // ブロックが選択されていない場合はプレビューモードを終了
  if (!isSelected) {
    setAttributes({ isEditMode: true });
  }

  //配列で指定
  return [
    getToolbarControls(),
    getInspectorControls(),
    <>
      {attributes.isEditMode && (  // 編集モード
        <div {...useBlockProps()}>
          <TextareaControl
            label={__("Highlight Code", "my-highlight-block")}
            value={attributes.codeText}
            onChange={(value) => setAttributes({ codeText: value })}
            rows={codeTextRows}
            placeholder={__("Write your code...", "my-highlight-block")}
            hideLabelFromVision={isSelected ? false : true}
          />
        </div>
      )}
      {!attributes.isEditMode && (  // プレビューモード
        <div {...useBlockProps()}>
          <div className="hljs-wrap" ref={codeWrapperRef}>
            <pre {...preAttributes}>
              <code {...codeAttributes}>{attributes.codeText}</code>
            </pre>
          </div>
        </div>
      )}
    </>,
  ];
}

プレビュー時に「更新」ボタンがアクティブになる問題を解決した edit.js のコードは 解決策 を参照ください。

プレビューモードと編集モードの切り替え

ブロックのレンダリング(return ステートメント)では、isEditMode の値により、attributes.isEditMode &&!attributes.isEditMode && で出力を切り替えています。

プレビューモードでは save() 関数同様、codeAttributes と preAttributes で定義したクラス属性をそれぞれ code 要素と pre 要素に指定し、div.hljs-wrap には ref 属性を指定して useRef で宣言した codeWrapperRef を指定しています。

また、save() 関数の場合はブロックのラッパー要素に {...useBlockProps.save()} を指定しますが、Edit() のプレビュー時では {...useBlockProps()} を指定します。

useEffect と useRef

useEffect フックに指定した処理はコンポーネントがレンダリングされた直後に毎回実行されますが、第2引数を指定することによって、実行される条件を制御することができます。この例の場合は isEditMode の値が変更された場合にのみ実行すれば良いので、第2引数に attributes.isEditMode を指定しています。

Preview ボタンをクリックすると、attributes.isEditMode の値が変化するので、useEffect フックに登録した処理が実行されます。

ただ、このとき何故かプレビューとしてレンダリングした code 要素(及びその親要素の pre 要素や div.hljs-wrap)を参照できないので、 useRef フックを使って ref を作成し、div.hljs-wrap に設定して参照するようにしています。

リファレンス(React):ref で DOM を操作する

1つのボタンで切り替える

上記ではツールバーを出力する関数 getToolbarControls で「Edit」と「Preview」という2つのボタンを表示してモードを切り替えていますが、以下のように1つのボタンで切り替えることもできます。

この例ではプレビューモード用のボタンに表示するカスタムアイコンを別途定義して使用しています。

// プレビューモードのボタンに使用するアイコン
const codeIcon = (
  <svg
    viewBox="0 0 16 16"
    xmlns="http://www.w3.org/2000/svg"
    aria-hidden="true"
    focusable="false"
    width="24"
    height="24"
  >
    <path d="M10.478 1.647a.5.5 0 1 0-.956-.294l-4 13a.5.5 0 0 0 .956.294l4-13zM4.854 4.146a.5.5 0 0 1 0 .708L1.707 8l3.147 3.146a.5.5 0 0 1-.708.708l-3.5-3.5a.5.5 0 0 1 0-.708l3.5-3.5a.5.5 0 0 1 .708 0zm6.292 0a.5.5 0 0 0 0 .708L14.293 8l-3.147 3.146a.5.5 0 0 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0z" />
  </svg>
);

// ツールバーを出力する関数
const getToolbarControls = () => {
  return (
    <BlockControls>
      <ToolbarGroup>
        <ToolbarButton
          text={attributes.isEditMode ? "Preview" : "Edit"}
          icon={attributes.isEditMode ? codeIcon : "edit"}
          label={attributes.isEditMode ? "Preview" : "Edit"}
          className="edit-preview-button"
          onClick={() =>
            setAttributes({ isEditMode: !attributes.isEditMode }) // 値を反転
          }
        />
      </ToolbarGroup>
    </BlockControls>
  );
};

以下のように表示されます。

editor.scss

editor.scss にプレビュー時に useDisabled フックを使ってインスペクタで入力できないようにしているので、わかりやすいように以下のようなスタイルを設定してみます。

useDisabled フックは実際には要素に disabled 属性を設定するのではなく、inert 属性を追加します。

インスペクターを出力する関数 getInspectorControls で言語名と自動折り返しの要素の親要素に .inspectorDiv クラスを指定しているので、そのクラスと、useDisabled フックにより出力される inert="true" を使って背景色やラベルの色を設定しています。

.wp-block-wdl-my-highlight-block textarea:focus {
  background-color: #f6fdf6;
}

.wp-block-wdl-my-highlight-block label {
  font-size: 16px;
}

/* disabled の要素のスタイル */
.inspectorDiv [inert="true"] input {
  background-color: #eee;
  color: #ccc;
}

.inspectorDiv [inert="true"] label {
  color: #ccc;
}

.inspectorDiv [inert="true"] input[type="checkbox"] {
  background-color: #ccc;
}

上記を適用すると、プレビュー時のインスペクタは以下のように表示されます。

style.scss

インスペクタの white-space: pre-wrap で切り替える自動折り返しのスタイルを追加します。

style.scss に以下を追加します。クリーンアップの項で style.scss を削除している場合は、highlight.min.css やテーマの CSS に追加して動作を確認することができます 。

pre.pre-wrap {white-space:pre-wrap}

style.scss に記述したスタイルはコンパイル時にインラインスタイルとして出力されます。

ビルドを実行

最後に、変更がすべて保存されていることを確認して、control + c を押して npm run start コマンドを終了し、npm run build を実行して本番環境用にビルドします。

表示を確認して問題がなければ完了です。

以下がこの時点でのコンパイル前のファイルの例です。

my-highlight-block.php
<?php
/**
* Plugin Name:       My Highlight Block
* Description:       Hightlight.js Syntax Highlight Code Block.
* Requires at least: 6.1
* Requires PHP:      7.0
* Version:           0.1.0
* Author:            The WordPress Contributors
* License:           GPL-2.0-or-later
* License URI:       https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain:       my-highlight-block
*
* @package           wdl
*/

if ( ! defined( 'ABSPATH' ) ) {
  exit; // Exit if accessed directly.
}

function add_my_code_block_scripts_and_styles() {
  $dir = dirname(__FILE__);

  wp_enqueue_script(
    'highlight-js',
    plugins_url('/highlight-js/highlight.min.js', __FILE__),
    array(),
    filemtime("$dir/highlight-js/highlight.min.js"),
    true
  );

  wp_enqueue_style(
    'highlight-js-style',
    plugins_url('/highlight-js/highlight.min.css', __FILE__),
    array(),
    filemtime("$dir/highlight-js/highlight.min.css")
  );

  if (!is_admin()) {
    wp_add_inline_script(
      'highlight-js',
      'document.addEventListener("DOMContentLoaded", (event) => {
  document.querySelectorAll("pre code").forEach((el) => {
    hljs.highlightElement(el);
  });
});'
    );
  }
}
add_action('enqueue_block_assets', 'add_my_code_block_scripts_and_styles');

function my_hljs_block_my_hljs_block_block_init() {
  register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'my_hljs_block_my_hljs_block_block_init' );
block.json
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-highlight-block",
  "version": "0.1.0",
  "title": "My Highlight Block",
  "category": "widgets",
  "description": "Hightlight.js Syntax Highlight Block.",
  "example": {},
  "attributes": {
    "codeText": {
      "type": "string",
      "default": "",
      "source": "text",
      "selector": "code"
    },
    "language": {
      "type": "string",
      "default": ""
    },
    "wrap": {
      "type": "boolean",
      "default": false
    },
    "isEditMode": {
      "type": "boolean",
      "default": true
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-highlight-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}
edit.js
import { __ } from "@wordpress/i18n";
import { useBlockProps, InspectorControls, BlockControls } from "@wordpress/block-editor";
import { TextareaControl, PanelBody, TextControl, PanelRow, CheckboxControl, ToolbarGroup, ToolbarButton } from "@wordpress/components";
import "./editor.scss";
import { useEffect, useRef } from "@wordpress/element";
import { useDisabled } from "@wordpress/compose";

export default function Edit({ attributes, setAttributes, isSelected }) {
  const disabledRef = useDisabled();
  const inspectorDivAttributes = {};
  inspectorDivAttributes.className = 'inspectorDiv';
  if (!attributes.isEditMode) {
    inspectorDivAttributes.ref = disabledRef;
  }

  const codeTextRowCount = attributes.codeText.split(/\r|\r\n|\n/).length;
  const codeTextRows = codeTextRowCount > 3 ? codeTextRowCount : 3;

  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title={__("Settings", "my-highlight-block")}>
          <div {...inspectorDivAttributes}>
            <PanelRow>
              <TextControl
                label={__("Language", "my-highlight-block")}
                value={attributes.language || ""}
                onChange={(value) => setAttributes({ language: value })}
                className="lang-name"
              />
            </PanelRow>
            <PanelRow>
              <CheckboxControl
                label={__("white-space: pre-wrap", "my-highlight-block")}
                checked={attributes.wrap}
                onChange={(val) => setAttributes({ wrap: val })}
                help={__("Enable Text Auto Wrap", "my-highlight-block")}
              />
            </PanelRow>
          </div>
        </PanelBody>
      </InspectorControls>
    );
  };

  const getToolbarControls = () => {
    return (
      <BlockControls>
        <ToolbarGroup>
          <ToolbarButton
            text="Edit"
            className={
              attributes.isEditMode ? "edit-button is-pressed" : "edit-button"
            }
            onClick={() =>
              setAttributes({ isEditMode: true })
            }
          />
          <ToolbarButton
            text="Preview"
            className={
              attributes.isEditMode
                ? "preview-button"
                : "preview-button is-pressed"
            }
            onClick={() =>
              setAttributes({ isEditMode: false })
            }
          />
        </ToolbarGroup>
      </BlockControls>
    );
  };

  const codeWrapperRef = useRef(null);

  useEffect(() => {
    if (!attributes.isEditMode) {
      const code = codeWrapperRef.current.querySelector("code");
      hljs.highlightElement(code);
    }
  }, [attributes.isEditMode]);

  const codeAttributes = {};
  if (attributes.language !== "") {
    codeAttributes.className = `language-${attributes.language}`;
  }
  const preAttributes = {};
  if (attributes.wrap) {
    preAttributes.className = "pre-wrap";
  }

  if (!isSelected) {
    setAttributes({ isEditMode: true });
  }

  return [
    getToolbarControls(),
    getInspectorControls(),
    <>
      {attributes.isEditMode && (
        <div {...useBlockProps()}>
          <TextareaControl
            label={__("Highlight Code", "my-highlight-block")}
            value={attributes.codeText}
            onChange={(value) => setAttributes({ codeText: value })}
            rows={codeTextRows}
            placeholder={__("Write your code...", "my-highlight-block")}
            hideLabelFromVision={isSelected ? false : true}
          />
        </div>
      )}
      {!attributes.isEditMode && (
        <div {...useBlockProps()}>
          <div className="hljs-wrap" ref={codeWrapperRef}>
            <pre {...preAttributes}>
              <code {...codeAttributes}>{attributes.codeText}</code>
            </pre>
          </div>
        </div>
      )}
    </>,
  ];
}
save.js
import { useBlockProps } from "@wordpress/block-editor";

export default function save({ attributes }) {

  const codeAttributes = {};
  if (attributes.language !== "") {
    codeAttributes.className = `language-${attributes.language}`;
  }

  const preAttributes = {};
  if (attributes.wrap) {
    preAttributes.className = "pre-wrap";
  }

  const escapedCodeText = attributes.codeText.replace(/[<>&'"]/g, (match) => {
    const specialChars = {
      '<': '&lt;',
      '>': '&gt;',
      '&': '&amp;',
      "'": '&#39;',
      '"': '&quot;'
    };
    return specialChars[match];
  });

  return (
    <div {...useBlockProps.save()}>
      <div className="hljs-wrap">
        <pre {...preAttributes}>
          <code {...codeAttributes}>{escapedCodeText}</code>
        </pre>
      </div>
    </div>
  );
}
index.js
import { registerBlockType } from "@wordpress/blocks";
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";
import './style.scss';

const highlightIcon = (
  <svg
    viewBox="0 0 16 16"
    xmlns="http://www.w3.org/2000/svg"
    aria-hidden="true"
    focusable="false"
    width="24"
    height="24"
  >
    <path d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0m-8 5v1H4.5a.5.5 0 0 0-.093.009A7 7 0 0 1 3.1 13zm0-1H2.255a7 7 0 0 1-.581-1H8zm-6.71-2a7 7 0 0 1-.22-1H8v1zM1 8q0-.51.07-1H8v1zm.29-2q.155-.519.384-1H8v1zm.965-2q.377-.54.846-1H8v1zm2.137-2A6.97 6.97 0 0 1 8 1v1z"/>
  </svg>
);

registerBlockType(metadata.name, {
  icon: highlightIcon,
  edit: Edit,
  save,
});
editor.scss
.wp-block-wdl-my-highlight-block textarea:focus {
  background-color: #f6fdf6;
}

.wp-block-wdl-my-highlight-block label {
  font-size: 16px;
}

.inspectorDiv [inert="true"] input {
  background-color: #eee;
  color: #ccc;
}

.inspectorDiv [inert="true"] label {
  color: #ccc;
}

.inspectorDiv [inert="true"] input[type="checkbox"] {
  background-color: #ccc;
}
style.scss
pre.pre-wrap {white-space:pre-wrap}
package.json
{
  "name": "my-highlight-block",
  "version": "0.1.0",
  "description": "Example block scaffolded with Create Block tool.",
  "author": "The WordPress Contributors",
  "license": "GPL-2.0-or-later",
  "main": "build/index.js",
  "scripts": {
    "build": "wp-scripts build",
    "format": "wp-scripts format",
    "lint:css": "wp-scripts lint-style",
    "lint:js": "wp-scripts lint-js",
    "packages-update": "wp-scripts packages-update",
    "plugin-zip": "wp-scripts plugin-zip",
    "start": "wp-scripts start"
  },
  "files": [ "highlight-js", "build", "my-highlight-block.php" ],
  "devDependencies": {
    "@wordpress/scripts": "^27.1.0"
  }
}
highlight.min.js
/*!
  Highlight.js v11.9.0 (git: b7ec4bfafc)
  (c) 2006-2023 undefined and other contributors
  License: BSD-3-Clause
*/
var hljs=function(){"use strict";function e(t){
return t instanceof Map?t.clear=t.delete=t.set=()=>{
  // 以下省略(右上の Highlight.js のサイトよりダウンロード)

    }
  }
}
highlight.min.css
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.
/* 以下省略(右上の Highlight.js のサイトよりダウンロード) */

プレビュー時に「更新」ボタンがアクティブになる

プレビューボタンをクリックしてプレビューを表示すると、何も変更をしていなくても「更新」ボタンがアクティブになります。

これはプレビューボタンをクリックしたことで、isEditMode 属性がコメントタグに追加されるためです。この時、コードエディターを確認すると以下のようにコメントタブに isEditMode 属性が追加されているのが確認できます。

また、何も変更しないで、プレビュー表示した状態でページを再読込したり、ページから離れると、例えば以下のように表示されます。

続行すると、以下のように「新しい自動保存された投稿があります」と表示されます。

「自動保存を表示」をクリックすると、この場合も以下のようにコメントタブに isEditMode 属性が追加されているのが確認できます。

この場合、自動保存を復元しても、しなくてもあまり変わりはありません。

解決策

2024年8月9日 追記

isEditMode を属性(attributes)として設定すると、上記のように切り替えの際に setAttributes() により属性がコメントタグに追加されてしまうので、isEditMode を state 変数として設定します。

useState を追加でインポートして、state 変数 isEditMode とその更新関数 setEditMode を宣言して、それらを attributes.isEditMode と setAttributes({ isEditMode: xxxx }) に代わって使用します。

edit.js を以下のように変更します。

import { __ } from "@wordpress/i18n";
// BlockControls を追加
import { useBlockProps, InspectorControls, BlockControls } from "@wordpress/block-editor";
// ToolbarGroup, ToolbarButton を追加
import { TextareaControl, PanelBody, TextControl, PanelRow, CheckboxControl, ToolbarGroup, ToolbarButton } from "@wordpress/components";
import "./editor.scss";
// useEffect, useRef を追加(※追加で useState をインポート)
import { useEffect, useRef, useState } from "@wordpress/element";
// useDisabled を追加
import { useDisabled } from "@wordpress/compose";

// 引数に追加で isSelected を受け取る
export default function Edit({ attributes, setAttributes, isSelected }) {
  // useDisabled フック
  const disabledRef = useDisabled();
  // インスペクタの各要素のラッパーの div 要素へ設定する属性
  const inspectorDivAttributes = {};
  inspectorDivAttributes.className = 'inspectorDiv';

  // state 変数 isEditMode とその更新関数 setEditMode の宣言(※追加)
  const [isEditMode, setEditMode] = useState(true);

  if (!isEditMode) {
    // プレビューモードではインスペクタへの入力を disalbed に(ref 属性に disabledRef を指定)
    inspectorDivAttributes.ref = disabledRef;
  }

  const codeTextRowCount = attributes.codeText.split(/\r|\r\n|\n/).length;
  const codeTextRows = codeTextRowCount > 3 ? codeTextRowCount : 3;

  // インスペクターを出力する関数
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title={__("Settings", "my-highlight-block")}>
          <div {...inspectorDivAttributes}>
            <PanelRow>
              <TextControl
                label={__("Language", "my-highlight-block")}
                value={attributes.language || ""}
                onChange={(value) => setAttributes({ language: value })}
                className="lang-name"
              />
            </PanelRow>
            <PanelRow>
              <CheckboxControl
                label={__("white-space: pre-wrap", "my-highlight-block")}
                checked={attributes.wrap}
                onChange={(val) => setAttributes({ wrap: val })}
                help={__("Enable Text Auto Wrap", "my-highlight-block")}
              />
            </PanelRow>
          </div>
        </PanelBody>
      </InspectorControls>
    );
  };

  // ツールバーを出力する関数
  const getToolbarControls = () => {
    return (
      <BlockControls>
        <ToolbarGroup>
          <ToolbarButton
            text="Edit"
            className={
              isEditMode ? "edit-button is-pressed" : "edit-button"
            }
            onClick={() =>
              // isEditMode の値を true に
              // setAttributes({ isEditMode: true }) 以下に変更
              setEditMode(true)
            }
          />
          <ToolbarButton
            text="Preview"
            className={
              isEditMode
                ? "preview-button"
                : "preview-button is-pressed"
            }
            onClick={() =>
              // isEditMode の値を false に
              //setAttributes({ isEditMode: false }) 以下に変更
              setEditMode(false)
            }
          />
        </ToolbarGroup>
      </BlockControls>
    );
  };

  // ref を宣言してコードの祖先の div.hljs-wrap に指定
  const codeWrapperRef = useRef(null);

  useEffect(() => {
    // プレビューモードであれば
    if (!isEditMode) {
      // ref を指定した要素から code 要素を取得
      const code = codeWrapperRef.current.querySelector("code");
      // 取得した code 要素をハイライト表示
      hljs.highlightElement(code);
    }
  }, [isEditMode]);  // state 変数 isEditMode を指定

  const codeAttributes = {};
  if (attributes.language !== "") {
    codeAttributes.className = `language-${attributes.language}`;
  }
  const preAttributes = {};
  if (attributes.wrap) {
    preAttributes.className = "pre-wrap";
  }

  // ブロックが選択されていない場合はプレビューモードを終了(以下に変更)
/*  if (!isSelected) {
    setAttributes({ isEditMode: true });
  } */

  // 上記を以下に変更
  useEffect(() => {
    setEditMode(true)
  }, [isSelected]);

  //配列で指定
  return [
    getToolbarControls(),
    getInspectorControls(),
    <>
      {isEditMode && (  // 編集モード
        <div {...useBlockProps()}>
          <TextareaControl
            label={__("Highlight Code", "my-highlight-block")}
            value={attributes.codeText}
            onChange={(value) => setAttributes({ codeText: value })}
            rows={codeTextRows}
            placeholder={__("Write your code...", "my-highlight-block")}
            hideLabelFromVision={isSelected ? false : true}
          />
        </div>
      )}
      {!isEditMode && (  // プレビューモード
        <div {...useBlockProps()}>
          <div className="hljs-wrap" ref={codeWrapperRef}>
            <pre {...preAttributes}>
              <code {...codeAttributes}>{attributes.codeText}</code>
            </pre>
          </div>
        </div>
      )}
    </>,
  ];
}

この場合、isEditMode は state 変数として定義しているので、block.json に属性として定義した以下の isEditMode は不要なので削除します。

"isEditMode": {
  "type": "boolean",
  "default": true
}

★ 以降は isEditMode を属性として定義しているので、適宜 state 変数に変更してください。

具体撃には、以下の state 変数と更新関数の宣言を追加して、attributes.isEditMode を isEditMode に、setAttributes({ isEditMode: xxxx }) を setEditMode(xxxx) に変更します。

// state 変数 isEditMode とその更新関数 setEditMode の宣言(※追加)
  const [isEditMode, setEditMode] = useState(true);

また、以下も変更します。

// 以下は削除
if (!isSelected) {
  setAttributes({ isEditMode: true });
}

// 上記を useEffect を使った以下に変更
useEffect(() => {
  setEditMode(true)
}, [isSelected]);

プレビュー時に設定を変更

プレビュー時には、インスペクターの操作をできないようにしていますが、プレビュー時にインスペクタで言語名や自動折り返しの設定を変更できるようにする例です。

但し、プレビュー時に設定変更できるようにすると、操作上の混乱を招く恐れがあるので注意が必要です。

また、チェックボックスによる変更は瞬時に切り替わりますが、言語名はテキストボックスに入力するため、テキストを入力するごとに更新が発生するので(Debounce である程度制御できますが)、正しい言語名が入力されていない状態(ハイライトが適用されない状態)で表示される場合があります。

以下は、プレビュー時にインスペクターで操作できるようにする例ですが、試してみたことの覚書のようなものです。この方法で問題ないのかなどは不明ですので、予めご了承ください。

インスペクターの操作を可能にしてみる

インスペクターの項目(言語名と自動折り返し)のラッパーの div 要素に指定した useDisabled フックによる ref 属性 {...inspectorDivAttributes} を外してインスペクターの操作を可能にしてみると、自動折り返しの変更は CSS が適用されるかどうかなので、問題なく機能します。

但し、言語名を変更すると、以下のように正しくハイライト表示されません。

edit.js を編集

edit.js を以下のように変更します。

useState と useDebounce を追加でインポートします。

useDisabled のインポートを削除し、useDisabled フックを使った ref の作成や、その ref の div 要素への指定などを削除します。

useState フックを使って state 変数(langName)とそれを更新するための関数(setLangName)を定義します。

langName は入力された言語名を格納する変数で、この値が変わると、追加した useEffect フックで Highlight.js を適用するようにします。

言語名を1文字入力する度に更新されると、その都度 Highlight.js により言語が検証されるので、useDebounce フックで Debounce を適用したステート変数を更新する関数 debouncedSetLangName を作成し、TextControl の onChange に追加します。

これでインスペクタで言語名が変更されると、ステート変数(langName)が更新され、useEffect で定義した処理を実行します。

import { __ } from "@wordpress/i18n";
import { useBlockProps, InspectorControls, BlockControls } from "@wordpress/block-editor";
import { TextareaControl, PanelBody, TextControl, PanelRow, CheckboxControl, ToolbarGroup, ToolbarButton } from "@wordpress/components";
import "./editor.scss";
// useState を追加
import { useEffect, useRef, useState } from "@wordpress/element";
// useDebounce を追加
import { useDebounce } from '@wordpress/compose';
// useDisabled のインポートを削除

export default function Edit({ attributes, setAttributes, isSelected }) {
  // useDisabled の処理を削除
  const codeTextRowCount = attributes.codeText.split(/\r|\r\n|\n/).length;
  const codeTextRows = codeTextRowCount > 3 ? codeTextRowCount : 3;

  // <div {...inspectorDivAttributes}> とその閉じタグ </div> を削除
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title={__("Settings", "my-highlight-block")}>
          <PanelRow>
            <TextControl
              label={__("Language", "my-highlight-block")}
              value={attributes.language || ""}
              onChange={(val) => {
                setAttributes({ language: val });
                // Debounce を適用した state 変数を更新する関数を追加で指定
                debouncedSetLangName(val);
              }}
              className="lang-name"
            />
          </PanelRow>
          <PanelRow>
            <CheckboxControl
              label={__("white-space: pre-wrap", "my-highlight-block")}
              checked={attributes.wrap}
              onChange={(val) => setAttributes({ wrap: val })}
              help={__("Enable Text Auto Wrap", "my-highlight-block")}
            />
          </PanelRow>
        </PanelBody>
      </InspectorControls>
    );
  };

  const getToolbarControls = () => {
    return (
      <BlockControls>
        <ToolbarGroup>
          <ToolbarButton
            text="Edit"
            className={
              attributes.isEditMode ? "edit-button is-pressed" : "edit-button"
            }
            onClick={() => setAttributes({ isEditMode: true })}
          />
          <ToolbarButton
            text="Preview"
            className={
              attributes.isEditMode
                ? "preview-button"
                : "preview-button is-pressed"
            }
            onClick={() => setAttributes({ isEditMode: false })}
          />
        </ToolbarGroup>
      </BlockControls>
    );
  };

  const codeWrapperRef = useRef(null);

  useEffect(() => {
    if (!attributes.isEditMode) {
      const code = codeWrapperRef.current.querySelector("code");
      hljs.highlightElement(code);
    }
  }, [attributes.isEditMode]);

  // 入力された言語名を格納する state 変数(langName)とその更新関数(setLangName)
  const [langName, setLangName] = useState("");
  // setLangName に Debounce を適用した関数
  const debouncedSetLangName = useDebounce(setLangName, 600);

  useEffect(() => {
    if (!attributes.isEditMode) {
      // Hightlight.js の警告を出力しないように
      hljs.configure({
        ignoreUnescapedHTML: true,
      });
      const code = codeWrapperRef.current.querySelector("code");
      // code 要素に data-highlighted が指定されていれば削除
      if (code.dataset.highlighted) {
        delete code.dataset.highlighted;
      }
      // 取得した code 要素をハイライト表示
      hljs.highlightElement(code);
    }
  }, [langName]); // 配列(依存変数)に langName を指定

  const codeAttributes = {};
  if (attributes.language !== "") {
    codeAttributes.className = `language-${attributes.language}`;
  }
  const preAttributes = {};
  if (attributes.wrap) {
    preAttributes.className = "pre-wrap";
  }

  if (!isSelected) {
    setAttributes({ isEditMode: true });
  }

  return [
    getToolbarControls(),
    getInspectorControls(),
    <>
      {attributes.isEditMode && (
        <div {...useBlockProps()}>
          <TextareaControl
            label={__("Highlight Code", "my-highlight-block")}
            value={attributes.codeText}
            onChange={(value) => setAttributes({ codeText: value })}
            rows={codeTextRows}
            placeholder={__("Write your code...", "my-highlight-block")}
            hideLabelFromVision={isSelected ? false : true}
          />
        </div>
      )}
      {!attributes.isEditMode && (
        <div {...useBlockProps()}>
          <div className="hljs-wrap" ref={codeWrapperRef}>
            <pre {...preAttributes}>
              <code {...codeAttributes}>{attributes.codeText}</code>
            </pre>
          </div>
        </div>
      )}
    </>,
  ];
}

追加した useEffect では、一度ハイライトされた要素を再度初期化するために code 要素に付与された data-highlighted 属性を削除する必要があります。

data-highlighted 属性を削除しないと、例えば以下のようなメッセージがコンソールに出力され、初期化(ハイライト表示)することができません。

また、data-highlighted 属性を削除しただけでは、すでに Highlihgt.js により出力されたマークアップをエスケープ処理されていない出力と判定され、以下のような警告がコンソールに出力されます。そのため、hljs.configure で ignoreUnescapedHTML: true を指定して、Hightlight.js の警告を出力しないようにしています。

正しくない言語を指定した場合、

正しくない言語を指定した場合は、no-highlight mode になりハイライト表示されません。

気にする必要はないと思いますが、useDebounce の第2引数に指定した 600ms 以内に各文字を連続して入力すれば1まとまりの単語として認識されますが、例えば、1秒に1文字ずつ s, c, s と入力すると、コンソールには以下のような WARN が出力されます。

最終的に正しい言語名を指定すれば、正しくハイライト表示されます。

style.scss と view.js を使う場合

Highlight.js の CSS とプレビュー用の CSS を style.scss に、Highlight.js の JavaScript を view.js に記述して使用することもできます。

以下は style.scss と view.js を使う場合の例です。

index.js

index.js の style.scss のインポートを削除してある場合は追加します。

import { registerBlockType } from "@wordpress/blocks";
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";
import './style.scss';  // 削除してあれば追加

const highlightIcon = (
  <svg
    viewBox="0 0 16 16"
    xmlns="http://www.w3.org/2000/svg"
    aria-hidden="true"
    focusable="false"
    width="24"
    height="24"
  >
    <path d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0m-8 5v1H4.5a.5.5 0 0 0-.093.009A7 7 0 0 1 3.1 13zm0-1H2.255a7 7 0 0 1-.581-1H8zm-6.71-2a7 7 0 0 1-.22-1H8v1zM1 8q0-.51.07-1H8v1zm.29-2q.155-.519.384-1H8v1zm.965-2q.377-.54.846-1H8v1zm2.137-2A6.97 6.97 0 0 1 8 1v1z"/>
  </svg>
);

registerBlockType(metadata.name, {
  icon: highlightIcon,
  edit: Edit,
  save,
});

my-highlight-block.php

プラグインファイル my-highlight-block.php では管理画面の場合にのみ Highlight.js の JavaScript を読み込みます(プレビュー機能を使わない場合はこの読み込みも不要です)。

<?php
/**
* Plugin Name:       My Highlight Block
* Description:       Hightlight.js Syntax Highlight Code Block.
* Requires at least: 6.1
* Requires PHP:      7.0
* Version:           0.1.0
* Author:            The WordPress Contributors
* License:           GPL-2.0-or-later
* License URI:       https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain:       my-highlight-block
*
* @package           wdl
*/

if ( ! defined( 'ABSPATH' ) ) {
  exit; // Exit if accessed directly.
}

function add_my_code_block_scripts_and_styles() {
  $dir = dirname(__FILE__);
  if (is_admin()) {
    wp_enqueue_script(
      'highlight-js',
      plugins_url('/highlight-js/highlight.min.js', __FILE__),
      array(),
      filemtime("$dir/highlight-js/highlight.min.js"),
      true
    );
  }
}
add_action('enqueue_block_assets', 'add_my_code_block_scripts_and_styles');

function my_hljs_block_my_hljs_block_block_init() {
  register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'my_hljs_block_my_hljs_block_block_init' );

style.scss

style.scss にプレビューのスタイルの前に、Highlight.js のテーマの CSS をコピーします。

/* Highlight.js のテーマの CSS  */
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}
  ・・・中略・・・
.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
/* プレビュー用のスタイル */
pre.pre-wrap {white-space:pre-wrap}

view.js

src ディレクトリに view.js ファイルを作成し、highlight.min.js のコードとその後に Highlight.js を初期化するコードを記述します。

/*!
  Highlight.js v11.9.0 (git: b7ec4bfafc)
  (c) 2006-2023 undefined and other contributors
  License: BSD-3-Clause
*/
  var hljs=function(){"use strict";function e(t){
    return t instanceof Map?t.clear=t.delete=t.set=()=>{

      ・・・中略・・・

},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},t,g,s],r=[...b]
;return r.pop(),r.push(i),l.contains=r,{name:"YAML",case_insensitive:!0,
aliases:["yml"],contains:b}}})();hljs.registerLanguage("yaml",e)})();

document.addEventListener("DOMContentLoaded", (event) => {
  document.querySelectorAll("pre code").forEach((el) => {
    hljs.highlightElement(el);
  });
});

block.json

block.json に "style" と "viewScript" の行を追加します(削除している場合)。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-highlight-block",
  "version": "0.1.0",
  "title": "My Highlight Block",
  "category": "widgets",
  "description": "Hightlight.js Syntax Highlight Block.",
  "example": {},
  "attributes": {
    "codeText": {
      "type": "string",
      "default": "",
      "source": "text",
      "selector": "code"
    },
    "language": {
      "type": "string",
      "default": ""
    },
    "wrap": {
      "type": "boolean",
      "default": false
    },
    "isEditMode": {
      "type": "boolean",
      "default": true
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-highlight-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "viewScript": "file:./view.js"
}

edit.js と save.js

edit.js と save.js に変更はありません。

ビルドを実行

ファイルを保存してビルドを実行します。

ページのソースを確認すると、フロントエンド側では view.js は以下のように defer 属性が指定されて head 内で読み込まれます。

<script src="http://localhost/wp-block/wp-content/plugins/my-highlight-block/build/view.js?ver=1cf63a3590ea8b901cd4" id="wdl-my-highlight-block-view-script-js" defer data-wp-strategy="defer"></script>

この例の場合、style.scss の内容は以下のようにインラインで head 内に出力されます。

<style id='wdl-my-highlight-block-style-inline-css'>
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}. ・・・中略・・・ pre.pre-wrap{white-space:pre-wrap}
</style>