WordPress Logo WordPress インスタ表示用カスタムブロックの作成

WordPress で既製のプラグインを使わずに Instagram の投稿画像(タイムライン)を一覧表示するカスタムブロックを作成する例です(サンプルコードあり)。

create-block を使用して独自のカスタムブロックを作成します。

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

また、Instagram ビジネスアカウント ID とアクセストークンが必要になります。

更新日:2024年08月13日

作成日:2024年08月07日

WordPress カスタムブロック関連ページ

概要

以下では Instagram グラフ API からデータを取得して投稿画像(タイムライン)を一覧表示するカスタムブロックを作成します(※ プラグインを利用したほうがはるかに簡単ですが)。

グラフ API からデータを取得するには、Instagram ビジネスアカウント ID とアクセストークンが必要になりますが、その方法については省略しています(すでに取得していることを前提にていします)。

ビジネスアカウント ID とアクセストークンの取得方法については以下を御覧ください。

カスタム HTML ブロックを使って表示する例

WordPress の投稿ページでインスタの投稿画像を一覧表示する1つの方法は、表示用の JavaScript と CSS を読み込み、投稿ページでカスタム HTML ブロックに以下のような HTML を記述します。

以下の HTML ではカスタムデータ属性を使って、表示件数やボタンのテキストなどを指定できるようにしています。そして読み込んだ JavaScript でグラフ API からデータを取得して div.insta-div の要素内に一覧表示の HTML を出力します。

<div class="insta-div" data-thumbs-limit="3" data-max-media-count="6" data-heading-text="見出しテキスト" data-heading-tag="h4" data-load-more-text="もっと見る"></div>

以下は グラフ API からデータを取得して div.insta-div の要素内に一覧表示の HTML を出力するための JavaScript と CSS の例です。

document.addEventListener("DOMContentLoaded", () => {
  // Instagram ビジネスアカウント ID  (xxxxxx には実際の値を指定)
  const baid = "xxxxxx";
  // アクセストークン
  const uat = "xxxxxx";
  // 次のデータのエンドポイントを入れる変数を宣言
  let next;
  // 表示した投稿の総数
  let mediaLoaded = 0;

  // 出力先の要素(.insta-div の最初の要素)を取得
  const target = document.getElementsByClassName("insta-div")[0];

  if (target) {
    //グラフAPI ホストURL(ルートエンドポイント)
    const api = "https://graph.facebook.com/";
    // API のバージョン(使用するバージョンに応じて変更)
    const version = 'v20.0';
    // 表示件数(.insta-div に data-thumbs-limit 属性が指定されていればその値)
    const limit = target.dataset.thumbsLimit ? parseInt(target.dataset.thumbsLimit): 4;
    //取得するフィールド
    const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
    // fields パラメータ
    const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
    // 渡したいパラメータ(パラメーターを表すオブジェクト)
    const params = {
      fields,   // fields: fields, と同じこと
      access_token: uat,
    };
    // オブジェクト形式のパラメータをクエリ文字列に変換
    const query = new URLSearchParams(params);
    // fetch() に渡す URL の組み立て
    const url = `${api}/${version}/${baid}?${query}`;

    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `Insta Fetch リクエスト失敗:${response.status} ${response.statusText}`
          );
        }
      })
      .then((instaResponse) => {
        // 次のデータのエンドポイントを取得
        next = instaResponse.media.paging.next;
        // 取得したデータから投稿一覧の HTML を作成(関数を別途定義)
        const instaFeeds = createInstaFeeds(instaResponse.media.data);
        // 最大表示数(.insta-div に data-max-media-count 属性が指定されていればその値)
        const maxMediaCount = target.dataset.maxMediaCount ? parseInt(target.dataset.maxMediaCount): 0;
        // 表示した投稿数をカウント
        mediaLoaded += limit;
        // .insta-div に data-heading-text 属性が指定されていればその値を見出し文字列に(指定がなければ見出しを表示しない)
        const headingText =  target.dataset.headingText ? target.dataset.headingText: '';
        // .insta-div に data-heading-tag 属性が指定されていればその値を heading の要素に(デフォルトは h2)
        const headingTag = target.dataset.headingTag ? target.dataset.headingTag: 'h2';
        // 見出し部分のマークアップを作成
        const heading = headingText ? `<${headingTag} class="insta-icon">${headingText}</${headingTag}>`: '';
        // .insta-div に data-load-more-text 属性が指定されていればその値をボタンのテキストに(デフォルトは Load More)
        const loadMoreText = target.dataset.loadMoreText ? target.dataset.loadMoreText : "Load More";
        if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
          // 取得した画像データと見出しなどを出力
          target.innerHTML = `<div class="insta-wrapper">
      ${heading}
      <div class="insta-container">${instaFeeds}</div>
    </div>`;
        } else {
          // 次のデータのエンドポイントが存在すれば、投稿を読み込むボタンを出力
          target.innerHTML = `<div class="insta-wrapper">
      ${heading}
      <div class="insta-container">${instaFeeds}</div>
      <button id="load-more-insta-feeds">${loadMoreText}</button>
    </div>`;
          const loadMoreInstaFeeds = target.querySelector( "#load-more-insta-feeds");
          // 投稿を読み込むボタンをクリックしたら次のデータのエンドポイントからデータを取得
          if (loadMoreInstaFeeds) {
            loadMoreInstaFeeds.addEventListener("click", () => {
              // loadMoreFeeds() を呼び出す際に表示件数と最大表示数も引数に渡す
              loadMoreFeeds(next, limit, maxMediaCount);
            });
          }
        }
      }).catch((error) => {
        console.warn(error.message);
        // 管理者としてログインしていればエラーを表示
        showErrorForAdmin(error.message);
      });
  }

  // 引数に fetch() で取得したレスポンスの data を受取、インスタ一覧の HTML を生成する関数
  function createInstaFeeds(data) {
    // 出力する値を入れる変数
    let instaFeeds = ""; // フィード部分の HTML
    let src = ""; // img の src
    let video = ""; // メディアタイプがビデオの場合に出力する要素
    // レスポンスのデータ(media.data)は配列なので forEach でループ処理
    data.forEach((item) => {
      // メディアタイプがビデオの場合
      if (item.media_type === "VIDEO") {
        src = item.thumbnail_url;
        // ビデオの場合は以下の span 要素でアイコンを表示
        video = '<span class="video-icon"></span>';
      }
      // メディアタイプが画像の場合
      else {
        src = item.media_url;
        video = "";
      }
      // フィード部分の HTML を作成
      const instaMediaDiv = `<div class="insta-media">
    <a href="${item.permalink}" target="_blank" rel="noopener">
      <img src="${src}" alt="${item.caption ? item.caption : ""}">
      ${video ? video : ""}
      <span class="like-count">${item.like_count}</span>
      <span class="comments-count">${item.comments_count}</span>
    </a>
  </div>
  `;
      instaFeeds += instaMediaDiv;
    });
    return instaFeeds;
  }

  // 次のデータのエンドポイント(url)を引数に受取、HTML を更新する関数
  function loadMoreFeeds(url, limit, maxMediaCount) {
    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`
          );
        }
      })
      .then((instaResponse) => {
        const instaFeeds = createInstaFeeds(instaResponse.data);
        // 次のデータのエンドポイントを取得(instaResponse.media.paging.next ではなく instaResponse.paging.next になる)
        next = instaResponse.paging.next;
        // 投稿のコンテナを取得
        const instaContainer = target.querySelector(".insta-container");
        if (instaContainer) {
          // 投稿のコンテナに追加の投稿の HTML を挿入
          instaContainer.insertAdjacentHTML("beforeend", instaFeeds);
          // 読み込み数をカウント
          mediaLoaded += limit;
          // next が存在しないか、表示数が最大表示数より大きければ
          if (!next ||  maxMediaCount && maxMediaCount <= mediaLoaded) {
            // 次のデータのエンドポイント(next)が存在しなければボタンを削除
            target.querySelector("#load-more-insta-feeds").remove();
          }
        }
      });
  }

  // wp_add_inline_script の出力から管理者としてログインしているかを判定してエラーを表示する関数
  // 引数 message には出力するエラーメッセージを受け取る
  function showErrorForAdmin(message) {
    // login_status が定義されていれば
    if(login_status) {
      // 管理者としてログインしていればエラーを表示
      if(login_status.isLoggedIn && login_status.isAdminUser) {
        target.innerHTML = `<div class="insta-error" style="background-color:pink; padding:20px; margin:50px 0;">
            <h2>エラー</h2>
            <p>${message} </p>
          </div>`;
      }
    }
  }
});

関連ページ:インスタグラム投稿の一覧を出力

/* 環境に合わせて変更します */
.insta-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  grid-auto-rows: minmax(100px, auto);
  gap: 10px;
}

.insta-media {
  position: relative;
}

.insta-media img {
  max-width: 100%;
  aspect-ratio: 1/1;
  object-fit: cover;
  display: block;
  width: 100%;
  height: auto;
}

.like-count,
.comments-count {
  position: absolute;
  bottom: 10px;
  color: #fff;
}

.like-count {
  right: 25%;
}

.comments-count {
  right: 10%;
}

.like-count::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  margin-right: 5px;
  background-repeat: no-repeat;
  /*アイコンのSVG画像*/
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath d='M8.864.046C7.908-.193 7.02.53 6.956 1.466c-.072 1.051-.23 2.016-.428 2.59-.125.36-.479 1.013-1.04 1.639-.557.623-1.282 1.178-2.131 1.41C2.685 7.288 2 7.87 2 8.72v4.001c0 .845.682 1.464 1.448 1.545 1.07.114 1.564.415 2.068.723l.048.03c.272.165.578.348.97.484.397.136.861.217 1.466.217h3.5c.937 0 1.599-.477 1.934-1.064a1.86 1.86 0 0 0 .254-.912c0-.152-.023-.312-.077-.464.201-.263.38-.578.488-.901.11-.33.172-.762.004-1.149.069-.13.12-.269.159-.403.077-.27.113-.568.113-.857 0-.288-.036-.585-.113-.856a2.144 2.144 0 0 0-.138-.362 1.9 1.9 0 0 0 .234-1.734c-.206-.592-.682-1.1-1.2-1.272-.847-.282-1.803-.276-2.516-.211a9.84 9.84 0 0 0-.443.05 9.365 9.365 0 0 0-.062-4.509A1.38 1.38 0 0 0 9.125.111L8.864.046zM11.5 14.721H8c-.51 0-.863-.069-1.14-.164-.281-.097-.506-.228-.776-.393l-.04-.024c-.555-.339-1.198-.731-2.49-.868-.333-.036-.554-.29-.554-.55V8.72c0-.254.226-.543.62-.65 1.095-.3 1.977-.996 2.614-1.708.635-.71 1.064-1.475 1.238-1.978.243-.7.407-1.768.482-2.85.025-.362.36-.594.667-.518l.262.066c.16.04.258.143.288.255a8.34 8.34 0 0 1-.145 4.725.5.5 0 0 0 .595.644l.003-.001.014-.003.058-.014a8.908 8.908 0 0 1 1.036-.157c.663-.06 1.457-.054 2.11.164.175.058.45.3.57.65.107.308.087.67-.266 1.022l-.353.353.353.354c.043.043.105.141.154.315.048.167.075.37.075.581 0 .212-.027.414-.075.582-.05.174-.111.272-.154.315l-.353.353.353.354c.047.047.109.177.005.488a2.224 2.224 0 0 1-.505.805l-.353.353.353.354c.006.005.041.05.041.17a.866.866 0 0 1-.121.416c-.165.288-.503.56-1.066.56z'/%3E%3C/svg%3E");
}

.comments-count::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  margin-right: 5px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath d='M2.678 11.894a1 1 0 0 1 .287.801 10.97 10.97 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 .71-.074A8.06 8.06 0 0 0 8 14c3.996 0 7-2.807 7-6 0-3.192-3.004-6-7-6S1 4.808 1 8c0 1.468.617 2.83 1.678 3.894zm-.493 3.905a21.682 21.682 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a9.68 9.68 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9.06 9.06 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105z'/%3E%3C/svg%3E");
}

.video-icon {
  position: absolute;
  top: 3px;
  right: 5px;
}

.video-icon::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath fill-rule='evenodd' d='M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2V5zm11.5 5.175 3.5 1.556V4.269l-3.5 1.556v4.35zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H2z'/%3E%3C/svg%3E");
}

.insta-icon::before {
  content: "";
  display: inline-block;
  height: 30px;
  width: 30px;
  vertical-align: -4px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23a41867' viewBox='0 0 448 512'%3E%3Cpath d='M194.4 211.7a53.3 53.3 0 1 0 59.3 88.7 53.3 53.3 0 1 0 -59.3-88.7zm142.3-68.4c-5.2-5.2-11.5-9.3-18.4-12c-18.1-7.1-57.6-6.8-83.1-6.5c-4.1 0-7.9 .1-11.2 .1c-3.3 0-7.2 0-11.4-.1c-25.5-.3-64.8-.7-82.9 6.5c-6.9 2.7-13.1 6.8-18.4 12s-9.3 11.5-12 18.4c-7.1 18.1-6.7 57.7-6.5 83.2c0 4.1 .1 7.9 .1 11.1s0 7-.1 11.1c-.2 25.5-.6 65.1 6.5 83.2c2.7 6.9 6.8 13.1 12 18.4s11.5 9.3 18.4 12c18.1 7.1 57.6 6.8 83.1 6.5c4.1 0 7.9-.1 11.2-.1c3.3 0 7.2 0 11.4 .1c25.5 .3 64.8 .7 82.9-6.5c6.9-2.7 13.1-6.8 18.4-12s9.3-11.5 12-18.4c7.2-18 6.8-57.4 6.5-83c0-4.2-.1-8.1-.1-11.4s0-7.1 .1-11.4c.3-25.5 .7-64.9-6.5-83l0 0c-2.7-6.9-6.8-13.1-12-18.4zm-67.1 44.5A82 82 0 1 1 178.4 324.2a82 82 0 1 1 91.1-136.4zm29.2-1.3c-3.1-2.1-5.6-5.1-7.1-8.6s-1.8-7.3-1.1-11.1s2.6-7.1 5.2-9.8s6.1-4.5 9.8-5.2s7.6-.4 11.1 1.1s6.5 3.9 8.6 7s3.2 6.8 3.2 10.6c0 2.5-.5 5-1.4 7.3s-2.4 4.4-4.1 6.2s-3.9 3.2-6.2 4.2s-4.8 1.5-7.3 1.5l0 0c-3.8 0-7.5-1.1-10.6-3.2zM448 96c0-35.3-28.7-64-64-64H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96zM357 389c-18.7 18.7-41.4 24.6-67 25.9c-26.4 1.5-105.6 1.5-132 0c-25.6-1.3-48.3-7.2-67-25.9s-24.6-41.4-25.8-67c-1.5-26.4-1.5-105.6 0-132c1.3-25.6 7.1-48.3 25.8-67s41.5-24.6 67-25.8c26.4-1.5 105.6-1.5 132 0c25.6 1.3 48.3 7.1 67 25.8s24.6 41.4 25.8 67c1.5 26.3 1.5 105.4 0 131.9c-1.3 25.6-7.1 48.3-25.8 67z'/%3E%3C/svg%3E");
  margin-right: 5px;
}

h2.insta-icon {
  font-size: 30px;
}

h3.insta-icon::before {
  height: 26px;
  width: 26px;
}

h3.insta-icon {
  font-size: 26px;
}

h4.insta-icon {
  font-size: 24px;
}

h4.insta-icon::before {
  height: 24px;
  width: 24px;
  vertical-align: -3px;
}

h5.insta-icon {
  font-size: 20px;
}

h5.insta-icon::before {
  height: 20px;
  width: 20px;
  vertical-align: -2px;
}

h6.insta-icon {
  font-size: 18px;
}

h6.insta-icon::before {
  height: 18px;
  width: 18px;
}

.insta-media {
  animation: fadeInThumbs .6s;
}

@keyframes fadeInThumbs {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

#load-more-insta-feeds {
  background-color: #111;
  padding: 10px 20px;
  color: #fff;
  margin: 30px 0;
  cursor: pointer;
  border: none;
  transition: background-color .3s;
}

#load-more-insta-feeds:hover {
  background-color: #444;
}

functions.php で上記 JavaScript を読み込みます。

JavaScritp ファイル insta-feeds.js はテーマフォルダ内に insta というフォルダを作成して、そこに保存してあるものとします。

また、insta-feeds.js では管理者としてログインしているかを判定してエラーを表示するので、JavaScript の読み込みの際に wp_add_inline_script() でログイン状態を検出した値のオブジェクト login_status をインラインで出力しています(WordPress のログイン状態を JavaScript で判定) 。

function add_my_insta_scripts() {
  if (is_front_page() || is_single()) {
    // JavaScript(insta-feeds.js)の読み込み
    wp_enqueue_script(
      'instaFeeds',
      get_theme_file_uri('/insta/insta-feeds.js'),
      array(),
      filemtime(get_theme_file_path('/insta/insta-feeds.js')),
      true
    );

    // ユーザーのログイン状態を表すオブジェクトをインラインで出力
    wp_add_inline_script(
      // 上記 JavaScript のハンドル名
      'instaFeeds',
      // script タグに以下のインライン JavaScript を出力(変数名 login_status に設定したオブジェクト)
      'const login_status = ' . json_encode(array(
        // ユーザーのログイン状態
        'isLoggedIn' =>  is_user_logged_in(),
        // ログインしているユーザーが管理者かどうか
        'isAdminUser' => current_user_can('manage_options'),
      )),
      // 上記内容の script タグを JavaScript の読み込みの前に出力
      'before'
    );
  }
}
add_action('wp_enqueue_scripts', 'add_my_insta_scripts');

テーマによっては style.css が有効になっていないものもあるので、その場合は必要に応じて functions.php で style.css を読み込みます。

// style.css を head 内に読み込む
function add_my_style_css() {
  wp_enqueue_style('style', get_stylesheet_uri());
}
add_action('wp_enqueue_scripts', 'add_my_style_css');

これで例えば以下のようなインスタ投稿画像の一覧を投稿ページに表示することができます。

この方法の場合、投稿ページでカスタム HTML ブロックに一覧表示の HTML を手動で記述する必要があり、表示件数や見出しのテキスト、ボタンのテキストなどをカスタムデータ属性を使って指定します。

以降では投稿編集画面のインスペクタ(設定パネル)の入力欄で表示件数や見出しのテキスト、ボタンのテキストなどを設定できるカスタムブロックを作成します。

create-block を使ってブロックの雛形を作成し、それらのファイルを編集することで比較的簡単に作成できます。

以下のカスタムブロックを作成する場合、上記 JavaScript があると競合するので(同じ内容を使用するため)、もし作成した場合は削除します(上記 functions.php や sytle.css の記述も削除します)。

2024年08月11日 更新

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

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

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

% cd wp-content/plugins

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

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

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

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

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

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

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

メッセージには npm start などの使用できるコマンド(npm scripts)や作成されたディレクトリへの移動コマンド(cd my-insta-block)などが表示されます。

plugins ディレクトリの中に my-insta-block ディレクトリが作成されます。build と src ディレクトリを展開すると以下のようなファイルが入っているのが確認できます。

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

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

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

プラグインファイル

プラグインファイル my-insta-block.php を開くと、初期状態では以下のように Description や Author などの値にデフォルトの値が設定されています。

<?php
/**
 * Plugin Name:       My Insta Block
 * Description:       Example block scaffolded with Create Block tool.
 * 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-insta-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 wdl_my_insta_block_block_init() {
	register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'wdl_my_insta_block_block_init' );

Description はプラグインページの説明部分に表示されるので、「インスタ一覧表示用ブロック」などに変更し、Author なども変更します。

Text Domain は翻訳関数などに使用するので変更しません。

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

<?php
/**
 * Plugin Name:       My Insta Block
 * Description:       インスタ投稿画像一覧表示用ブロック
 * Requires at least: 6.1
 * Requires PHP:      7.0
 * Version:           0.1.0
 * Author:            Webdesignleaves
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-insta-block
 *
 * @package Wdl
 */

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

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

npm start で開発を開始

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

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

% cd my-insta-block
% npm start

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

assets by chunk 17.1 KiB (name: index)
  assets by path *.css 1.82 KiB
    asset index.css 949 bytes [emitted] (name: index) 1 related asset
    asset index-rtl.css 912 bytes [emitted] (name: index)
  asset index.js 15.1 KiB [emitted] (name: index) 1 related asset
  asset index.asset.php 134 bytes [emitted] (name: index)
assets by path ./*.css 1.94 KiB
  asset ./style-index.css 1010 bytes [emitted] (name: ./style-index) (id hint: style) 1 related asset
  asset ./style-index-rtl.css 971 bytes [emitted] (name: ./style-index) (id hint: style)
assets by chunk 1.03 KiB (name: view)
  asset view.js 973 bytes [emitted] (name: view) 1 related asset
  asset view.asset.php 84 bytes [emitted] (name: view)
asset block.json 509 bytes [emitted] [from: src/block.json] [copied]
Entrypoint view 1.03 KiB (1.13 KiB) = view.js 973 bytes view.asset.php 84 bytes 1 auxiliary asset
Entrypoint index 19 KiB (11.7 KiB) = 6 assets 3 auxiliary assets
runtime modules 5.18 KiB 14 modules
orphan modules 4.79 KiB [orphan] 4 modules
built modules 4.51 KiB (javascript) 458 bytes (css/mini-extract) [built]
  javascript modules 4.07 KiB
    cacheable modules 3.91 KiB
      modules by path ./src/*.js 3.81 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 458 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 260 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 198 bytes [built] [code generated]
  ./src/block.json 449 bytes [built] [code generated]
webpack 5.93.0 compiled successfully in 907 ms

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

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

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

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

作成した My Insta Block ブロックを挿入できることを確認します。例えばエディター側は以下のように表示されます。

以下はフロントエンド側の表示例です。これらの初期表示は自動的に生成された雛形のファイル(edit.jssave.js)に記述されています。

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

block.json

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

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-insta-block",
  "version": "0.1.0",
  "title": "My Insta Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false
  },
  "textdomain": "my-insta-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行目)を変更し、11〜36行目に attributes(属性)を追加します。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-insta-block",
  "version": "0.1.0",
  "title": "My Insta Block",
  "category": "widgets",
  "icon": "instagram",
  "description": "Instagram Timeline Block.",
  "example": {},
  "attributes": {
    "limit": {
      "type": "number",
      "default": 4
    },
    "maxMediaCount": {
      "type": "number",
      "default": 12
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "headingTag": {
      "type": "string",
      "default": "h3"
    },
    "loadMoreText": {
      "type": "string",
      "default": "Load More"
    },
    "apiVersion": {
      "type": "string",
      "default": "v20.0"
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-insta-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "viewScript": "file:./view.js"
}

8行目の icon に指定できるアイコンは Dashicons にアイコンが掲載されています(カスタムアイコンを表示することも可能)。使用したいアイコンを表示して、dashicons-xxxxx の xxxxxの部分を指定します。

この例では 7行目の category は widgets のままなので、作成したブロックはカテゴリーのウィジェットの場所に配置されます。

attributes(ブロック属性)

block.json の attributes(ブロック属性)には以下を追加しています。

表示件数(limit)や最大読み込み数(maxMediaCount)などは必要に応じて編集画面のサイドバーのインスペクタ(設定パネル)で入力できるようにします(省略時は default の値を使用)。

これらの値は edit.js や save.js などでは attributes.属性名 で参照することができます。また、ダイナミックブロックの render.php などでは $attributes['属性名'] で参照することができます。

edit.js や save.js ではインスペクタで入力された値で attributes(ブロック属性)を更新し、出力する HTML のカスタムデータ属性(data-* 属性)に設定します(apiVersion は data-* 属性 には使用しません)。

属性名(キー) 説明 data-* 属性
limit 表示する件数(画像の枚数) data-thumbs-limit
maxMediaCount 追加する最大読み込み数(0 は無制限)。この値を limit 以下に指定すると追加の読み込みボタンを表示しません。 data-max-media-count
headingText 見出しのテキスト(値が空の場合は見出しを出力しません) data-heading-text
headingTag 見出しタグ data-heading-tag
loadMoreText 追加ボタンのテキスト data-load-more-text
apiVersion グラフ API のバージョン 使用しない

(注)上記 maxMediaCount(追加する最大読み込み数)は正確に適用されるわけではなく、例えば、limit を 4、maxMediaCount を 6 とすると、最大8件(limit の倍数)読み込まれます。

リファレンス:属性

edit.js

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

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

自動的に生成された雛形のファイルでは以下のように、必要なコンポーネントをインポートして、「My Insta Block – hello from the editor!」と表示される p 要素(JSX)を返す Edit 関数を定義してデフォルトエクスポートしています。

// ローカライズ(テキスト文字列の国際化)の __() のインポート
import { __ } from '@wordpress/i18n';
// エディターで必要とされる属性を出力する useBlockProps() のインポート
import { useBlockProps } from '@wordpress/block-editor';
// 編集画面用 CSS のインポート
import './editor.scss';

// コメント部分は省略

// Edit 関数をデフォルトエクスポート
export default function Edit() {
  return (
    <p { ...useBlockProps() }>
      { __(
        'My Insta Block – hello from the editor!',
        'my-insta-block'
      ) }
    </p>
  );
}

Edit 関数は、エディターがどのようにブロックをレンダリングするかを定義して返す関数です。

上記のコードにより、編集画面は以下のように表示されます。

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

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

リファレンス:edit と save

edit.js を編集

この例では、編集画面のサイドバーにインスペクターを表示して必要に応じて表示件数や見出しのテキストなどを設定(変更)できるようにします。ブロック部分にはとりあえず「Instagram Timeline 表示ブロック」などと表示しておきます。

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

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

NumberControl は数値を入力することができる input[type="number] を使ったコンポーネントで、「This feature is still experimental」とあり、将来的に変更が発生する可能性があります。

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

Edit() 関数では属性(attributes)と属性を更新する関数(setAttributes)を変数({ attributes, setAttributes })に受け取ります。

インスペクター部分は2つの PanelBody で構成し、2つ目の PanelBody には initialOpen に {false} を指定して初期状態では非表示(折りたたんだ状態)にしています。

属性の値がインスペクタで変更されたら、それぞれのコンポーネントの onChangesetAttributes() を使って更新しています。属性の値には attributes.xxxx(xxxx は属性のキー名)でアクセスできます。

// インスペクターをカスタマイズするために InspectorControls をインポート
import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
// インスペクターで使用するコンポーネント PanelBody, TextControl, SelectControl, NumberControl を components からインポート
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl } from "@wordpress/components";
// エディター用スタイルシートをインポート
import "./editor.scss";

export default function Edit({ attributes, setAttributes }) {
  // ブロックの内容(インスペクタと出力する要素)を JSX で返す
  return (
    <>
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            // インスペクターで値が変更されたら属性を更新
            onChange={(value) => setAttributes({ limit: parseInt(value) })}
          />
          <NumberControl
            label="最大表示数"
            min="0"
            max="60"
            value={attributes.maxMediaCount}
            onChange={(value) =>
              setAttributes({ maxMediaCount: parseInt(value) })
            }
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
          <TextControl
            label="Load More ボタンテキスト"
            value={attributes.loadMoreText}
            onChange={(value) => setAttributes({ loadMoreText: value })}
            placeholder="デフォルト:Load More"
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => setAttributes({ apiVersion: value })}
          />
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        <div className="my-insta-block">
          <h3>Instagram Timeline 表示ブロック</h3>
          <p>設定/変更はサイドバーのインスペクターで行います。</p>
        </div>
      </div>
    </>
  );
}

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

JSX では class は className にします。もし、インラインスタイルを指定する場合は、style 属性の設定は JavaScript のオブジェクト形式 { } で記述する必要があります。

また、この例のように複数の要素を返す場合は、フラグメント (<>〜</>) で全体を囲みます。

インスペクター部分を別途関数で定義

以下は インスペクター部分を返す関数 getInspectorControls を別途定義して使用する例です。この場合、Edit() の return は配列で指定してインスペクター部分の関数 getInspectorControls() を呼び出します。

import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl } from "@wordpress/components";
import "./editor.scss";

export default function Edit({ attributes, setAttributes }) {

  // インスペクターを出力する関数を定義
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            onChange={(value) => setAttributes({ limit: parseInt(value) })}
          />
          <NumberControl
            label="最大表示数"
            min="0"
            max="60"
            value={attributes.maxMediaCount}
            onChange={(value) =>
              setAttributes({ maxMediaCount: parseInt(value) })
            }
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
          <TextControl
            label="Load More ボタンテキスト"
            value={attributes.loadMoreText}
            onChange={(value) => setAttributes({ loadMoreText: value })}
            placeholder="デフォルト:Load More"
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => setAttributes({ apiVersion: value })}
          />
        </PanelBody>
      </InspectorControls>
    );
  };

  // インスペクターを出力する関数と表示する要素を配列で指定して返す
  return [
    getInspectorControls(),
    <div {...useBlockProps()}>
      <div className="my-insta-block">
        <h3>Instagram Timeline 表示ブロック</h3>
        <p>設定/変更はサイドバーのインスペクターで行います。</p>
      </div>
    </div>
  ];
}

ファイルを保存して、ブロックを挿入した投稿をエディターを確認すると以下のように、インスペクターにはデフォルトの値とブロックの要素(Instagram Timeline 表示ブロック...)表示されます。

インスペクターで値を変更したり、入力して設定が問題なく保存できることを確認します。

表示されたブロックの背景色などは editor.scss と style.scss に設定されているスタイルによるものです。

フロントエンド側は、まだ初期状態のままです。

editor.scss

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

/**
 * The following styles get applied inside the editor only.
 *
 * Replace them with your own styles or remove the file completely.
 */

 /* useBlockProps() により出力されるクラス */
.wp-block-wdl-my-insta-block {
  border: 1px dotted #f00;
}

必要に応じて出力されるクラスを使って編集画面のブロックのスタイルを記述します。

この例では .wp-block-wdl-my-insta-block がブロックのラッパー要素に指定されているので、そのクラスを利用してスタイルを設定します。

また、Edit() 関数で return したブロックの要素には独自に my-insta-block クラスを指定しているので、そのクラスも使ってスタイルを設定しています(CSS ネスティングを使っていますが、古いブラウザは対応していないので注意が必要です)。

.wp-block-wdl-my-insta-block {
  border: 1px solid #666;
  background-color: #eee;

  .my-insta-block {
    padding: 20px;
  }

  h3 {
    font-size: 24px;
  }

  p {
    color: #999;
  }
}

style.scss のスタイルも編集画面のブロックで読み込まれますが、editor.scss の設定が優先されます。

この時点で編集画面で挿入したブロックを確認すると、例えば以下のように表示されます。使用しているテーマにより文字色などが異なるかもしれません。

また、以下では右側のインスペクタで表示件数や見出しテキストなどのデフォルトの値を変更しているので初期状態と異なる表示になっています。

save.js

ひな形の save.js は以下のようになっています(コメント部分は省略)。

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

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

Edit 関数と同様、save() 関数が return しているのは JSX です。上記コードにより初期状態では以下のようなブロックがフロントエンド側に出力されます。

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

save.js では JSX を返す save() 関数をエクスポートします。この関数は引数に属性のオブジェクト { attributes } を受け取ることができ、関数内では属性の値に attributes.xxxxx でアクセスできます。

※ save.js を使った静的ブロックは、ブロックマークアップ、属性、出力をデータベース内に保存します。

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

出力する要素に設定する属性のオブジェクト(divAttributes)を作成し、クラス属性とカスタムデータ(data-*)属性のプロパティを追加し、一覧表示用の div 要素のマークアップに展開しています。

ブロックのラッパー要素には { ...useBlockProps.save() }useBlockProps.save() から返されるブロック props を展開することで、必要な属性やブロッククラス名が出力されます。

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

export default function save({ attributes }) {

  // div 要素に設定するクラス属性とカスタムデータ属性で構成されるオブジェクトの初期化
  const divAttributes = {};
  // 上記オブジェクトにクラス属性のプロパティを追加
  divAttributes.className = 'insta-div';

  // attributes の値により、div 要素にカスタムデータ属性(data-* 属性)のプロパティを作成してオブジェクトに追加
  if (attributes.limit) divAttributes["data-thumbs-limit"] = attributes.limit;
  if (attributes.maxMediaCount) divAttributes["data-max-media-count"] = attributes.maxMediaCount;
  if (attributes.headingText) divAttributes["data-heading-text"] = attributes.headingText;
  if (attributes.headingTag) divAttributes["data-heading-tag"] = attributes.headingTag;
  if (attributes.loadMoreText) divAttributes["data-load-more-text"] = attributes.loadMoreText;

  return (
    <div { ...useBlockProps.save() }>
      <div  {...divAttributes}></div>
    </div>
  );
}

save.js を変更して、編集画面を再読込すると以下のようなエラーが表示されます。

※ 静的なブロックでは save 関数の return ステートメント内に変更が発生するとブロックの検証(妥当性検証プロセス)により、ブロックは無効(invalid)としてマークされ上記のように表示されます。

「ブロックのリカバリーを試行」をクリックするとエラーは消えて正常な表示に戻ります。

投稿を保存して、この時点でフロントエンド側を確認すると、以下のようなクラスとカスタムデータ属性を指定した div 要素が出力されています。

ブロックのラッパー要素には useBlockProps.save() により wp-block-wdl-my-insta-block クラスが出力されています。

<div class="wp-block-wdl-my-insta-block">
  <div class="insta-div" data-thumbs-limit="3" data-max-media-count="12" data-heading-text="見出し" data-heading-tag="h4" data-load-more-text="Load More"></div>
</div>

まだ JavaScript や CSS は適用されていないので一覧は表示されません。

style.scss

style.scss に記述したスタイルは、ブロックエディターとフロントエンドの両方に適用されます。

この時点ではエディター側にはインスタ表示用のスタイルは不要ですが(後でプレビュー表示をエディタに追加する際に必要になるので)、エディターとフロントエンドの両方に以下のスタイルを適用します。

以下のスタイルは必要に応じてサイトに合わせて変更します。

.insta-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  grid-auto-rows: minmax(100px, auto);
  gap: 20px;
}

.insta-media {
  position: relative;
}

.insta-media img {
  max-width: 100%;
  aspect-ratio: 1/1;
  object-fit: cover;
  display: block;
  width: 100%;
  height: auto;
}

.like-count,
.comments-count {
  position: absolute;
  bottom: 10px;
  color: #fff;
}

.like-count {
  right: 25%;
}

.comments-count {
  right: 10%;
}

.like-count::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  margin-right: 5px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath d='M8.864.046C7.908-.193 7.02.53 6.956 1.466c-.072 1.051-.23 2.016-.428 2.59-.125.36-.479 1.013-1.04 1.639-.557.623-1.282 1.178-2.131 1.41C2.685 7.288 2 7.87 2 8.72v4.001c0 .845.682 1.464 1.448 1.545 1.07.114 1.564.415 2.068.723l.048.03c.272.165.578.348.97.484.397.136.861.217 1.466.217h3.5c.937 0 1.599-.477 1.934-1.064a1.86 1.86 0 0 0 .254-.912c0-.152-.023-.312-.077-.464.201-.263.38-.578.488-.901.11-.33.172-.762.004-1.149.069-.13.12-.269.159-.403.077-.27.113-.568.113-.857 0-.288-.036-.585-.113-.856a2.144 2.144 0 0 0-.138-.362 1.9 1.9 0 0 0 .234-1.734c-.206-.592-.682-1.1-1.2-1.272-.847-.282-1.803-.276-2.516-.211a9.84 9.84 0 0 0-.443.05 9.365 9.365 0 0 0-.062-4.509A1.38 1.38 0 0 0 9.125.111L8.864.046zM11.5 14.721H8c-.51 0-.863-.069-1.14-.164-.281-.097-.506-.228-.776-.393l-.04-.024c-.555-.339-1.198-.731-2.49-.868-.333-.036-.554-.29-.554-.55V8.72c0-.254.226-.543.62-.65 1.095-.3 1.977-.996 2.614-1.708.635-.71 1.064-1.475 1.238-1.978.243-.7.407-1.768.482-2.85.025-.362.36-.594.667-.518l.262.066c.16.04.258.143.288.255a8.34 8.34 0 0 1-.145 4.725.5.5 0 0 0 .595.644l.003-.001.014-.003.058-.014a8.908 8.908 0 0 1 1.036-.157c.663-.06 1.457-.054 2.11.164.175.058.45.3.57.65.107.308.087.67-.266 1.022l-.353.353.353.354c.043.043.105.141.154.315.048.167.075.37.075.581 0 .212-.027.414-.075.582-.05.174-.111.272-.154.315l-.353.353.353.354c.047.047.109.177.005.488a2.224 2.224 0 0 1-.505.805l-.353.353.353.354c.006.005.041.05.041.17a.866.866 0 0 1-.121.416c-.165.288-.503.56-1.066.56z'/%3E%3C/svg%3E");
}

.comments-count::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  margin-right: 5px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath d='M2.678 11.894a1 1 0 0 1 .287.801 10.97 10.97 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 .71-.074A8.06 8.06 0 0 0 8 14c3.996 0 7-2.807 7-6 0-3.192-3.004-6-7-6S1 4.808 1 8c0 1.468.617 2.83 1.678 3.894zm-.493 3.905a21.682 21.682 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a9.68 9.68 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9.06 9.06 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105z'/%3E%3C/svg%3E");
}

.video-icon {
  position: absolute;
  top: 3px;
  right: 5px;
}

.video-icon::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath fill-rule='evenodd' d='M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2V5zm11.5 5.175 3.5 1.556V4.269l-3.5 1.556v4.35zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H2z'/%3E%3C/svg%3E");
}

.insta-icon::before {
  content: "";
  display: inline-block;
  height: 30px;
  width: 30px;
  vertical-align: -6px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23a41867' viewBox='0 0 448 512'%3E%3Cpath d='M194.4 211.7a53.3 53.3 0 1 0 59.3 88.7 53.3 53.3 0 1 0 -59.3-88.7zm142.3-68.4c-5.2-5.2-11.5-9.3-18.4-12c-18.1-7.1-57.6-6.8-83.1-6.5c-4.1 0-7.9 .1-11.2 .1c-3.3 0-7.2 0-11.4-.1c-25.5-.3-64.8-.7-82.9 6.5c-6.9 2.7-13.1 6.8-18.4 12s-9.3 11.5-12 18.4c-7.1 18.1-6.7 57.7-6.5 83.2c0 4.1 .1 7.9 .1 11.1s0 7-.1 11.1c-.2 25.5-.6 65.1 6.5 83.2c2.7 6.9 6.8 13.1 12 18.4s11.5 9.3 18.4 12c18.1 7.1 57.6 6.8 83.1 6.5c4.1 0 7.9-.1 11.2-.1c3.3 0 7.2 0 11.4 .1c25.5 .3 64.8 .7 82.9-6.5c6.9-2.7 13.1-6.8 18.4-12s9.3-11.5 12-18.4c7.2-18 6.8-57.4 6.5-83c0-4.2-.1-8.1-.1-11.4s0-7.1 .1-11.4c.3-25.5 .7-64.9-6.5-83l0 0c-2.7-6.9-6.8-13.1-12-18.4zm-67.1 44.5A82 82 0 1 1 178.4 324.2a82 82 0 1 1 91.1-136.4zm29.2-1.3c-3.1-2.1-5.6-5.1-7.1-8.6s-1.8-7.3-1.1-11.1s2.6-7.1 5.2-9.8s6.1-4.5 9.8-5.2s7.6-.4 11.1 1.1s6.5 3.9 8.6 7s3.2 6.8 3.2 10.6c0 2.5-.5 5-1.4 7.3s-2.4 4.4-4.1 6.2s-3.9 3.2-6.2 4.2s-4.8 1.5-7.3 1.5l0 0c-3.8 0-7.5-1.1-10.6-3.2zM448 96c0-35.3-28.7-64-64-64H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96zM357 389c-18.7 18.7-41.4 24.6-67 25.9c-26.4 1.5-105.6 1.5-132 0c-25.6-1.3-48.3-7.2-67-25.9s-24.6-41.4-25.8-67c-1.5-26.4-1.5-105.6 0-132c1.3-25.6 7.1-48.3 25.8-67s41.5-24.6 67-25.8c26.4-1.5 105.6-1.5 132 0c25.6 1.3 48.3 7.1 67 25.8s24.6 41.4 25.8 67c1.5 26.3 1.5 105.4 0 131.9c-1.3 25.6-7.1 48.3-25.8 67z'/%3E%3C/svg%3E");
  margin-right: 5px;
}

h3.insta-icon::before {
  height: 26px;
  width: 26px;
}

h4.insta-icon::before {
  height: 24px;
  width: 24px;
  vertical-align: -5px;
}

h5.insta-icon::before {
  height: 23px;
  width: 23px;
  vertical-align: -5px;
}

h6.insta-icon::before {
  height: 22px;
  width: 22px;
  vertical-align: -5px;
}

.insta-media {
  animation: fadeInThumbs .6s;
}

@keyframes fadeInThumbs {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

#load-more-insta-feeds {
  background-color: #111;
  padding: 10px 20px;
  color: #fff;
  margin: 30px 0;
  cursor: pointer;
  border: none;
  transition: background-color .3s;
}

#load-more-insta-feeds:hover {
  background-color: #444;
}

view.js

view.js ファイルはブロックが表示されるときにフロントエンド側で読み込まれます。

ひな形の view.js は以下のようになっていて、フロントエンド側でコンソールにメッセージが表示されるようになっています。

/**
 * Use this file for JavaScript code that you want to run in the front-end
 * on posts/pages that contain this block.
 *
 * When this file is defined as the value of the `viewScript` property
 * in `block.json` it will be enqueued on the front end of the site.
 *
 * Example:
 *
 * ```js
 * {
 *   "viewScript": "file:./view.js"
 * }
 * ```
 *
 * If you're not making any changes to this file because your project doesn't need any
 * JavaScript running in the front-end, then you should delete this file and remove
 * the `viewScript` property from `block.json`.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#view-script
 */

/* eslint-disable no-console */
console.log( 'Hello World! (from wdl-my-insta-block block)' );
/* eslint-enable no-console */

view.js を以下に書き換えてフロントエンド側でインスタの投稿の一覧を表示します。

xxxxxx の部分には実際の Instagram ビジネスアカウント ID とアクセストークンの値を指定します。

insta-div クラスを指定した要素が存在すれば、そこにインスタの投稿の一覧を出力します。

以下はカスタム HTML ブロックでのインスタ表示用 JavaScript(insta-feeds.js)と同じものです。

グラフ API からのデータの取得でエラーがあれば、管理者としてログインしている場合は関数 showErrorForAdmin() でエラーを表示します(管理者としてログインしているかどうかの判定はプラグインファイル my-insta-block.php で行い、wp_add_inline_script で出力して view.js で参照します)。

document.addEventListener("DOMContentLoaded", () => {
  // Instagram ビジネスアカウント ID  (xxxxxx には実際の値を指定)
  const baid = "xxxxxx";
  // アクセストークン
  const uat = "xxxxxx";
  // 次のデータのエンドポイントを入れる変数を宣言
  let next;
  // 表示した投稿の総数
  let mediaLoaded = 0;

  // 出力先の要素(.insta-div の最初の要素)を取得
  const target = document.getElementsByClassName("insta-div")[0];

  if (target) {
    //グラフAPI ホストURL(ルートエンドポイント)
    const api = "https://graph.facebook.com/";
    // API のバージョン(使用するバージョンに応じて変更)
    const version = 'v20.0';
    // 表示件数(.insta-div に data-thumbs-limit 属性が指定されていればその値)
    const limit = target.dataset.thumbsLimit ? parseInt(target.dataset.thumbsLimit): 4;
    //取得するフィールド
    const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
    // fields パラメータ
    const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
    // 渡したいパラメータ(パラメーターを表すオブジェクト)
    const params = {
      fields,   // fields: fields, と同じこと
      access_token: uat,
    };
    // オブジェクト形式のパラメータをクエリ文字列に変換
    const query = new URLSearchParams(params);
    // fetch() に渡す URL の組み立て
    const url = `${api}/${version}/${baid}?${query}`;

    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `Insta Fetch リクエスト失敗:${response.status} ${response.statusText}`
          );
        }
      })
      .then((instaResponse) => {
        // 次のデータのエンドポイントを取得
        next = instaResponse.media.paging.next;
        // 取得したデータから投稿一覧の HTML を作成(関数を別途定義)
        const instaFeeds = createInstaFeeds(instaResponse.media.data);
        // 最大表示数(.insta-div に data-max-media-count 属性が指定されていればその値)
        const maxMediaCount = target.dataset.maxMediaCount ? parseInt(target.dataset.maxMediaCount): 0;
        // 表示した投稿数をカウント
        mediaLoaded += limit;
        // .insta-div に data-heading-text 属性が指定されていればその値を見出し文字列に(指定がなければ見出しを表示しない)
        const headingText =  target.dataset.headingText ? target.dataset.headingText: '';
        // .insta-div に data-heading-tag 属性が指定されていればその値を heading の要素に(デフォルトは h2)
        const headingTag = target.dataset.headingTag ? target.dataset.headingTag: 'h2';
        // 見出し部分のマークアップを作成
        const heading = headingText ? `<${headingTag} class="insta-icon">${headingText}</${headingTag}>`: '';
        // .insta-div に data-load-more-text 属性が指定されていればその値をボタンのテキストに(デフォルトは Load More)
        const loadMoreText = target.dataset.loadMoreText ? target.dataset.loadMoreText : "Load More";
        if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
          // 取得した画像データと見出しなどを出力
          target.innerHTML = `<div class="insta-wrapper">
      ${heading}
      <div class="insta-container">${instaFeeds}</div>
    </div>`;
        } else {
          // 次のデータのエンドポイントが存在すれば、投稿を読み込むボタンを出力
          target.innerHTML = `<div class="insta-wrapper">
      ${heading}
      <div class="insta-container">${instaFeeds}</div>
      <button id="load-more-insta-feeds">${loadMoreText}</button>
    </div>`;
          const loadMoreInstaFeeds = target.querySelector( "#load-more-insta-feeds");
          // 投稿を読み込むボタンをクリックしたら次のデータのエンドポイントからデータを取得
          if (loadMoreInstaFeeds) {
            loadMoreInstaFeeds.addEventListener("click", () => {
              // loadMoreFeeds() を呼び出す際に表示件数と最大表示数も引数に渡す
              loadMoreFeeds(next, limit, maxMediaCount);
            });
          }
        }
      }).catch((error) => {
        console.warn(error.message);
        // 管理者としてログインしていればエラーを表示
        showErrorForAdmin(error.message);
      });
  }

  // 引数に fetch() で取得したレスポンスの data を受取、インスタ一覧の HTML を生成する関数
  function createInstaFeeds(data) {
    // 出力する値を入れる変数
    let instaFeeds = ""; // フィード部分の HTML
    let src = ""; // img の src
    let video = ""; // メディアタイプがビデオの場合に出力する要素
    // レスポンスのデータ(media.data)は配列なので forEach でループ処理
    data.forEach((item) => {
      // メディアタイプがビデオの場合
      if (item.media_type === "VIDEO") {
        src = item.thumbnail_url;
        // ビデオの場合は以下の span 要素でアイコンを表示
        video = '<span class="video-icon"></span>';
      }
      // メディアタイプが画像の場合
      else {
        src = item.media_url;
        video = "";
      }
      // フィード部分の HTML を作成
      const instaMediaDiv = `<div class="insta-media">
    <a href="${item.permalink}" target="_blank" rel="noopener">
      <img src="${src}" alt="${item.caption ? item.caption : ""}">
      ${video ? video : ""}
      <span class="like-count">${item.like_count}</span>
      <span class="comments-count">${item.comments_count}</span>
    </a>
  </div>
  `;
      instaFeeds += instaMediaDiv;
    });
    return instaFeeds;
  }

  // 次のデータのエンドポイント(url)を引数に受取、HTML を更新する関数
  function loadMoreFeeds(url, limit, maxMediaCount) {
    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`
          );
        }
      })
      .then((instaResponse) => {
        const instaFeeds = createInstaFeeds(instaResponse.data);
        // 次のデータのエンドポイントを取得(instaResponse.media.paging.next ではなく instaResponse.paging.next になる)
        next = instaResponse.paging.next;
        // 投稿のコンテナを取得
        const instaContainer = target.querySelector(".insta-container");
        if (instaContainer) {
          // 投稿のコンテナに追加の投稿の HTML を挿入
          instaContainer.insertAdjacentHTML("beforeend", instaFeeds);
          // 読み込み数をカウント
          mediaLoaded += limit;
          // next が存在しないか、表示数が最大表示数より大きければ
          if (!next ||  maxMediaCount && maxMediaCount <= mediaLoaded) {
            // 次のデータのエンドポイント(next)が存在しなければボタンを削除
            target.querySelector("#load-more-insta-feeds").remove();
          }
        }
      });
  }

  // wp_add_inline_script の出力から管理者としてログインしているかを判定してエラーを表示する関数
  // 引数 message には出力するエラーメッセージを受け取る
  function showErrorForAdmin(message) {
    // login_status が定義されていれば
    if(login_status) {
      // 管理者としてログインしていればエラーを表示
      if(login_status.isLoggedIn && login_status.isAdminUser) {
        target.innerHTML = `<div class="insta-error" style="background-color:pink; padding:20px; margin:50px 0;">
            <h2>エラー</h2>
            <p>${message} </p>
          </div>`;
      }
    }
  }
});

プラグインファイル

プラグインファイル my-insta-block.php では enqueue_block_assets フックを使って、view.js に記述した関数 showErrorForAdmin() で使用するプロパティにログイン状態の結果を持つオブジェクト(login_status)を wp_add_inline_script() でインラインで出力します。

以下の25-44行目を追加します。

<?php
/**
 * Plugin Name:       My Insta Block
 * Description:       インスタ投稿画像一覧表示用ブロック
 * Requires at least: 6.1
 * Requires PHP:      7.0
 * Version:           0.1.0
 * Author:            Webdesignleaves
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-insta-block
 *
 * @package Wdl
 */

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

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

function add_my_insta_block_inline_script() {
  //管理画面以外(フロントエンド側でのみ読み込む)
  if (!is_admin()) {
    // ユーザーのログイン状態を表すオブジェクトをインラインで出力
    wp_add_inline_script(
      // view.js のハンドル名を指定 ( view.js の id から -js を除いたもの)
      'wdl-my-insta-block-view-script',
      // script タグに以下のインライン JavaScript を出力(変数名 login_status に設定したオブジェクト)
      'const login_status = ' . json_encode(array(
        // ユーザーのログイン状態
        'isLoggedIn' =>  is_user_logged_in(),
        // ログインしているユーザーが管理者かどうか
        'isAdminUser' => current_user_can('manage_options'),
      )),
      // 上記内容の script タグを JavaScript の読み込みの前に出力
      'before'
    );
  }
}
add_action('enqueue_block_assets', 'add_my_insta_block_inline_script');

ブラウザの「ページのソースを表示」で出力を確認すると、view.js の読み込みの前には以下のようなインラインスクリプトが出力されます(WordPress のログイン状態を JavaScript で判定する方法)。

また、31行目の view.js のハンドル名は、view.js の id 属性の値(wdl-my-insta-block-view-script-js)の -js の部分を除いたものを指定します(命名規則があると思いますが、実際の出力から確認しました)。

<script id="wdl-my-insta-block-view-script-js-before">
const login_status = {"isLoggedIn":true,"isAdminUser":true}
</script>
<script src="http://localhost/wp-sample/wp-content/plugins/my-insta-block/build/view.js?ver=43aa44dd1155f2ea8332" id="wdl-my-insta-block-view-script-js" defer data-wp-strategy="defer"></script>

フロントエンド側を確認すると例えば以下のように表示されます。

基本的なカスタムブロックはこれで完成です。

ビルド

問題なく表示されれば、変更がすべて保存されていることを確認して、control + c を押して終了します。

ターミナルで npm run build コマンドを実行してビルドします。

必要に応じてダイナミックブロックに変更したり、エディタ側でプレビュー機能を追加することもできます。

静的ブロックとダイナミックブロック

save.js を使った静的にレンダーされるブロックは、常にブロックマークアップ、属性、出力はデータベース内に保存されます。例えば、以下のようなブロックを追加したページを

コードエディターで確認すると、以下のようにブロックのコンテンツは HTML として保存されます。

プラグインを無効化したり削除した場合

save.js を使った静的ブロックプでは、データベースに HTML として保存されるので、ブロックのプラグインを無効化したり削除してもマークアップは残ります。

プラグインを無効化や削除すると、挿入したブロックは以下のように表示され、ブロックを HTML として残すこともできます。

「HTML として保存」をクリックすると、カスタム HTML ブロックとして保存されます。

save.js を変更した場合

save.js を変更すると妥当性検証プロセスによりブロックの編集画面で「このブロックには、想定されていないか無効なコンテンツが含まれています」と表示され、コンソールにはエラーが表示されます。

この場合、変更した save.js に問題がなければ、「ブロックのリカバリーを試行」をクリックするとエラーは消えます。

save.js を使用しないダイナミックブロックではブロックのレンダリングに PHP が使用され、エディターはデータベースにブロックの属性のみを保存します。ダイナミックブロックの場合、データベースに HTML は保存されないため、妥当性検証プロセスによるエラーは発生しません。

メリット・デメリット

静的ブロックの場合、フロントエンド側の出力の save.js の内容を変更すると、すべてのブロックで妥当性検証プロセスによるバリデーションエラーが発生するので、編集画面で「ブロックのリカバリーを試行」をクリックする必要があります(save.js)。すこし手間がかかりますが、ブロックの deprecated バージョンを提供することで、バリデーションエラーを回避することもできます。

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

また、静的ブロックの場合、プラグインを無効化または削除しても、ブロックを HTML として残すことができるので、別途 JavaScript や CSS を読み込んでその HTML を使って表示することもできます(冒頭のカスタム HTML ブロックを使った例のように)。

ダイナミックブロックの場合、フロントエンド側の出力の render.php の内容を変更しても妥当性検証プロセスは行われず、編集画面でエラーが発生することなく出力を変更できます。

但し、ダイナミックブロックの場合、プラグインを無効化または削除すると HTML は残りません。

ダイナミックブロックへ変更

以下は作成したカスタムブロックをダイナミックブロックへ変更する例です。

block.json に render プロパティを追加し、render.php を作成して index.js で save.js を無効にします。

block.json

block.json に render プロパティを追加します。

この例では render.php というファイルを作成するので "render": "file:./render.php" という行を追加します(44行目の最後にはカンマが必要です)。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-insta-block",
  "version": "0.1.0",
  "title": "My Insta Block",
  "category": "widgets",
  "icon": "instagram",
  "description": "Instagram Timeline Block.",
  "example": {},
  "attributes": {
    "limit": {
      "type": "number",
      "default": 4
    },
    "maxMediaCount": {
      "type": "number",
      "default": 12
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "headingTag": {
      "type": "string",
      "default": "h3"
    },
    "loadMoreText": {
      "type": "string",
      "default": "Load More"
    },
    "apiVersion": {
      "type": "string",
      "default": "v20.0"
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-insta-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "viewScript": "file:./view.js",
  "render": "file:./render.php"
}
render.php

src ディレクトリに render.php(block.json の render プロパティに指定したファイル)を作成して以下を記述します。

render.php ではフロントエンド側に出力するマークアップを記述します。ブロックのラッパー要素には save.js での useBlockProps.save() 同様、get_block_wrapper_attributes() を使って必要なすべての CSS クラスとスタイルを出力します。

また、render.php では属性(attributes)の値には $attributes['属性名''] でアクセスできます。以下では属性の値からカスタムデータ属性を作成して出力する要素に指定しています。

<?php

$className = 'insta-div';
$dataAttrs = '';

if ( isset( $attributes['limit'] ) ) {
  $dataAttrs = 'data-thumbs-limit="' .esc_attr($attributes['limit'])  .'"';
}

if ( isset( $attributes['maxMediaCount'] ) ) {
  $dataAttrs .= ' data-max-media-count="' .esc_attr($attributes['maxMediaCount'])  .'"';
}

if ( isset( $attributes['headingText'] ) ) {
  $dataAttrs .= ' data-heading-text="' .esc_attr($attributes['headingText'])  .'"';
}

if ( isset( $attributes['headingTag'] ) ) {
  $dataAttrs .= ' data-heading-tag="' .esc_attr($attributes['headingTag'])  .'"';
}

if ( isset( $attributes['loadMoreText'] ) ) {
  $dataAttrs .= ' data-load-more-text="' .esc_attr($attributes['loadMoreText'])  .'"';
}

?>
<div <?php echo get_block_wrapper_attributes(); ?>>
  <div class="<?php echo esc_attr($className) ?>" <?php echo $dataAttrs; ?>></div>
</div>
index.js

index.js は雛形のまま変更していないので以下のようになっています。

/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';

/**
 * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files.
 * All files containing `style` keyword are bundled together. The code used
 * gets applied both to the front of your site and to the editor.
 *
 * @see https://www.npmjs.com/package/@wordpress/scripts#using-css
 */
import './style.scss';

/**
 * Internal dependencies
 */
import Edit from './edit';
import save from './save';
import metadata from './block.json';

/**
 * Every block starts by registering a new block type definition.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
  /**
    * @see ./edit.js
    */
  edit: Edit,

  /**
    * @see ./save.js
    */
  save,

} );

ダイナミックブロックでは save 関数が null を返すようにして import も不要なので削除します(save.js 自体は削除しても、残してもかまいません)。

以下のように書き換えます(コメント部分は削除しています)。

import { registerBlockType } from '@wordpress/blocks';
import './style.scss';

import Edit from './edit';
//import save from './save';
import metadata from './block.json';

registerBlockType( metadata.name, {
  edit: Edit,
  // save, 以下に変更
  save: () => { return null },
} );
確認

編集画面で再読込すると、save.js が返す値が変更されたので妥当性検証プロセスにより以下のようなエラーが表示されます。

「ブロックのリカバリーを試行」をクリックすると正しく表示されます。

コードエディターに切り替えて確認すると、静的ブロックの場合とは異なり、以下のようにブロックのコンテンツには属性のみが保存されています(HTML はデータベースに保存されません)。

これで render.php を編集してフロントエンド側の出力内容を変更しても妥当性検証プロセスは行われないためバリデーションエラーは発生しません。

また、プラグインを無効化や削除すると以下のように表示されます(静的ブロックと異なり HTML は保存されていないので、HTML として保存するオプションは表示されません)。

フロントエンド側には何も残りません。

エディターにプレビューを表示

eidt.js を編集して、エディターでブロックのプレビューを表示する例です。

最初の例は、単純に Edit() 関数の return の表示する要素に .insta-div の要素を追加して、その要素にグラフ API から取得したデータを使ってインスタの投稿一覧を表示するものです。

グラフ API からのデータの取得などの処理は view.js とほぼ同じですが、useRef を使って .insta-div の要素を参照するようにしています。

また、view.js の場合は、管理者としてログインしているかどうかを AJAX を使って判定してエラーを表示していますが、この場合は、その必要はないので単純に出力先にエラーを表示するようにしています。

import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl } from "@wordpress/components";
import "./editor.scss";
// useRef を追加でインポート
import { useRef } from "@wordpress/element";

export default function Edit({ attributes, setAttributes }) {
  // Instagram ビジネスアカウント ID
  const baid = "xxxxxx";
  // アクセストークン
  const uat ="xxxxxx";
  // ref を宣言してインスタ一覧表示の要素 div.insta-div に指定
  const instaWrapperRef = useRef(null);
  // 次に読み込むエンドポイントの URL
  let next;
  // 表示した投稿の総数
  let mediaLoaded = 0;

  //グラフAPI ホストURL(ルートエンドポイント)
  const api = "https://graph.facebook.com/";
  //API のバージョン
  const version = attributes.apiVersion;
  //表示件数
  const limit = attributes.limit;

  //取得するフィールド
  const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
  // fields パラメータ
  const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
  // パラメーターを表すオブジェクト(フィールドとトークン)
  const params = {
    fields,  // fields: fields, と同じこと
    access_token: uat,
  };
  // オブジェクト形式のパラメータをクエリ文字列に変換
  const query = new URLSearchParams(params);
  // fetch() に渡す URL の組み立て
  const url = `${api}/${version}/${baid}?${query}`;
  // fetch() でデータを取得
  fetch(url)
    .then((response) => {
      // ステータスが ok であればレスポンスを JSON として解析
      if (response.ok) {
        return response.json();
      } else {
        // ステータスが ok でなければエラーにする
        throw new Error(
          `リクエスト失敗:${response.status} ${response.statusText}`,
        );
      }
    })
    .then((instaResponse) => {
      // 次のデータのエンドポイントを取得
      next = instaResponse.media.paging.next;
      // 取得したデータから投稿一覧の HTML を作成(関数を別途定義)
      const instaFeeds = createInstaFeeds(instaResponse.media.data);
      // 最大表示数
      const maxMediaCount = attributes.maxMediaCount;
      // 表示した投稿数をカウント
      mediaLoaded += limit;
      // 見出し文字列
      const headingText = attributes.headingText;
      // 見出しタグ
      const headingTag = attributes.headingTag;
      // 見出し部分のマークアップを作成
      const heading = headingText ? `<${headingTag} class="insta-icon">${headingText}</${headingTag}>` : "";
      // 次のデータのエンドポイントが存在しないか、読み込み数が最大読み込み数より大きい場合
      if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
        // 取得した画像データと見出しなどを出力
        instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
    ${heading}
    <div class="insta-container">${instaFeeds}</div>
  </div>`;
      } else {
        // 次のデータのエンドポイントが存在すれば、投稿を読み込むボタンを出力
        instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
    ${heading}
    <div class="insta-container">${instaFeeds}</div>
    <button id="load-more-insta-feeds">${attributes.loadMoreText}</button>
  </div>`;
        const loadMoreInstaFeeds = instaWrapperRef.current.querySelector(
          "#load-more-insta-feeds",
        );
        // 投稿を読み込むボタンをクリックしたら次のデータのエンドポイントからデータを取得
        if (loadMoreInstaFeeds) {
          loadMoreInstaFeeds.addEventListener("click", () => {
            // loadMoreFeeds() を呼び出す際に表示件数と最大表示数も引数に渡す
            loadMoreFeeds(next, limit, maxMediaCount);
          });
        }
      }
    })
    .catch((error) => {
      // エラーがあれば編集画面に表示
      instaWrapperRef.current.innerHTML = `<div class="insta-error" style="background-color:pink; padding:20px; margin:50px 0;">
          <h2>エラー</h2>
          <p>${error} </p>
        </div>`;
    });

  // 引数に fetch() で取得したレスポンスの data を受取、インスタ一覧の HTML を生成する関数
  function createInstaFeeds(data) {
    // 出力する値を入れる変数
    let instaFeeds = ""; // フィード部分の HTML
    let src = ""; // img の src
    let video = ""; // メディアタイプがビデオの場合に出力する要素
    // レスポンスのデータ(media.data)は配列なので forEach でループ処理
    data.forEach((item) => {
      // メディアタイプがビデオの場合
      if (item.media_type === "VIDEO") {
        src = item.thumbnail_url;
        // ビデオの場合は以下の span 要素でアイコンを表示
        video = '<span class="video-icon"></span>';
      }
      // メディアタイプが画像の場合
      else {
        src = item.media_url;
        video = "";
      }
      // フィード部分の HTML を作成
      const instaMediaDiv = `<div class="insta-media">
  <a href="${item.permalink}" target="_blank" rel="noopener">
    <img src="${src}" alt="${item.caption ? item.caption : ""}">
    ${video ? video : ""}
    <span class="like-count">${item.like_count}</span>
    <span class="comments-count">${item.comments_count}</span>
  </a>
</div>
`;
      instaFeeds += instaMediaDiv;
    });
    return instaFeeds;
  }

  // 次のデータのエンドポイント(url)を引数に受取、HTML を更新する関数
  function loadMoreFeeds(url, limit, maxMediaCount) {
    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`,
          );
        }
      })
      .then((instaResponse) => {
        const instaFeeds = createInstaFeeds(instaResponse.data);
        // 次のデータのエンドポイントを取得(instaResponse.media.paging.next ではなく instaResponse.paging.next になる)
        next = instaResponse.paging.next;
        // 投稿のコンテナを取得
        const instaContainer =
          instaWrapperRef.current.querySelector(".insta-container");
        if (instaContainer) {
          // 投稿のコンテナに追加の投稿の HTML を挿入
          instaContainer.insertAdjacentHTML("beforeend", instaFeeds);
          // 読み込み数をカウント
          mediaLoaded += limit;
          // next が存在しないか、表示数が最大表示数より大きければ
          if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
            // 次のデータのエンドポイント(next)が存在しなければボタンを削除
            instaWrapperRef.current.querySelector("#load-more-insta-feeds").remove();
          }
        }
      });
  }

  // インスペクターを出力する関数を定義
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            onChange={(value) => setAttributes({ limit: parseInt(value) })}
          />
          <NumberControl
            label="最大表示数"
            min="0"
            max="60"
            value={attributes.maxMediaCount}
            onChange={(value) =>
              setAttributes({ maxMediaCount: parseInt(value) })
            }
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
          <TextControl
            label="Load More ボタンテキスト"
            value={attributes.loadMoreText}
            onChange={(value) => setAttributes({ loadMoreText: value })}
            placeholder="デフォルト:Load More"
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => setAttributes({ apiVersion: value })}
          />
        </PanelBody>
      </InspectorControls>
    );
  };

  // 表示する要素に .insta-div の要素を追加
  return [
    getInspectorControls(),
    <div {...useBlockProps()}>
      <div className="my-insta-block">
        <h3>Instagram Timeline 表示ブロック</h3>
        <p>設定/変更はサイドバーのインスペクターで行います。</p>
      </div>
      <div className="insta-div" ref={instaWrapperRef}></div>
    </div>
  ];
}

edit.js を上記に変更して保存すると、エディターでは以下のように表示されます。

インスペクターで表示件数や見出しのテキストを変更すると、プレビューにも反映されます。

但し、上記の例では以下のような問題があります。

  1. 見出しやボタンのテキストを変更すると、1文字変更するごとに API へのリクエストが発生する。
  2. 少ない画像であれば問題ないが、多数の画像を表示する場合、表示に時間がかかる可能性がある。

以下は1つ目の問題のテキスト変更時に API リクエストを発生させない例です。

useEffect() を使って、属性の limit(表示件数)と maxMediaCount(最大表示数)が変更された場合にのみ、API へリクエストするようにしています。もっと良い方法があるかもしれません。

import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl } from "@wordpress/components";
import "./editor.scss";
// useEffect を追加でインポート
import { useEffect, useRef } from "@wordpress/element";

export default function Edit({ attributes, setAttributes }) {
  // Instagram ビジネスアカウント ID
  const baid = "xxxxxx";
  // アクセストークン
  const uat = "xxxxxx";
  // ref を宣言してインスタ一覧表示の要素 div.insta-div に指定
  const instaWrapperRef = useRef(null);
  // 次に読み込むエンドポイントの URL
  let next;
  // 表示した投稿の総数
  let mediaLoaded = 0;

  // 初回読み込み時と attributes.limit または attributes.maxMediaCount が変更された場合
  useEffect(() => {
    //グラフAPI ホストURL(ルートエンドポイント)
    const api = "https://graph.facebook.com/";
    //API のバージョン
    const version = attributes.apiVersion;
    //表示件数
    const limit = attributes.limit;

    //取得するフィールド
    const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
    // fields パラメータ
    const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
    // パラメーターを表すオブジェクト(フィールドとトークン)
    const params = {
      fields,  // fields: fields, と同じこと
      access_token: uat,
    };
    // オブジェクト形式のパラメータをクエリ文字列に変換
    const query = new URLSearchParams(params);
    // fetch() に渡す URL の組み立て
    const url = `${api}/${version}/${baid}?${query}`;
    // fetch() でデータを取得
    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`,
          );
        }
      })
      .then((instaResponse) => {
        // 次のデータのエンドポイントを取得
        next = instaResponse.media.paging.next;
        // 取得したデータから投稿一覧の HTML を作成(関数を別途定義)
        const instaFeeds = createInstaFeeds(instaResponse.media.data);
        // 最大表示数
        const maxMediaCount = attributes.maxMediaCount;
        // 表示した投稿数をカウント
        mediaLoaded += limit;
        // 見出し文字列
        const headingText = attributes.headingText;
        // 見出しタグ
        const headingTag = attributes.headingTag;
        // 見出し部分のマークアップを作成
        const heading = headingText ? `<${headingTag} class="insta-icon">${headingText}</${headingTag}>` : "";
        // 次のデータのエンドポイントが存在しないか、読み込み数が最大読み込み数より大きい場合
        if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
          // 取得した画像データと見出しなどを出力
          instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
      ${heading}
      <div class="insta-container">${instaFeeds}</div>
    </div>`;
        } else {
          // 次のデータのエンドポイントが存在すれば、投稿を読み込むボタンを出力
          instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
      ${heading}
      <div class="insta-container">${instaFeeds}</div>
      <button id="load-more-insta-feeds">${attributes.loadMoreText}</button>
    </div>`;
          const loadMoreInstaFeeds = instaWrapperRef.current.querySelector(
            "#load-more-insta-feeds",
          );
          // 投稿を読み込むボタンをクリックしたら次のデータのエンドポイントからデータを取得
          if (loadMoreInstaFeeds) {
            loadMoreInstaFeeds.addEventListener("click", () => {
              // loadMoreFeeds() を呼び出す際に表示件数と最大表示数も引数に渡す
              loadMoreFeeds(next, limit, maxMediaCount);
            });
          }
        }
      })
      .catch((error) => {
        // エラーがあれば編集画面に表示
        instaWrapperRef.current.innerHTML = `<div class="insta-error" style="background-color:pink; padding:20px; margin:50px 0;">
            <h2>エラー</h2>
            <p>${error} </p>
          </div>`;
      });
  }, [attributes.limit, attributes.maxMediaCount]);

  // インスペクタで attributes.headingText(見出しのテキスト)が変更された場合
  useEffect(() => {
    const headingElem = instaWrapperRef.current.querySelector(attributes.headingTag);
    if (headingElem) {
      // 見出しのテキストを更新
      headingElem.textContent = attributes.headingText;
      if (attributes.headingText === "") {
        // 見出しのテキストが空文字の場合は見出しを削除
        headingElem.remove();
      }
    } else {
      // 見出し要素が存在しない場合
      const instaWrapper = instaWrapperRef.current.querySelector(".insta-wrapper");
      if (instaWrapper) {
        const newHeading = document.createElement(attributes.headingTag);
        newHeading.setAttribute("class", "insta-icon");
        newHeading.appendChild(document.createTextNode(attributes.headingText));
        instaWrapper.prepend(newHeading);
      }
    }
  }, [attributes.headingText]);

  // attributes.headingTag(見出しのタグ)が変更された場合
  useEffect(() => {
    // 見出しの要素を取得
    const headingElem = instaWrapperRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6").item(0);
    // attributes.headingTag が変更されているので instaWrapperRef.current.querySelector(attributes.headingTag) では取得できない
    if (headingElem) {
      const headingParent = headingElem.parentElement;
      if (headingParent) {
        const newHeading = document.createElement(attributes.headingTag);
        newHeading.setAttribute("class", "insta-icon");
        newHeading.appendChild(document.createTextNode(attributes.headingText));
        headingParent.replaceChild(newHeading, headingElem);
      }
    }
  }, [attributes.headingTag]);

  // attributes.loadMoreText(追加ボタンのテキスト)が変更された場合
  useEffect(() => {
    const loadMoreBtn = instaWrapperRef.current.querySelector("#load-more-insta-feeds");
    if (loadMoreBtn) {
      if(attributes.loadMoreText) {
        loadMoreBtn.textContent = attributes.loadMoreText;
      }else{
        loadMoreBtn.textContent = 'Load More';
      }
    }
  }, [attributes.loadMoreText]);

  // 引数に fetch() で取得したレスポンスの data を受取、インスタ一覧の HTML を生成する関数
  function createInstaFeeds(data) {
    // 出力する値を入れる変数
    let instaFeeds = ""; // フィード部分の HTML
    let src = ""; // img の src
    let video = ""; // メディアタイプがビデオの場合に出力する要素
    // レスポンスのデータ(media.data)は配列なので forEach でループ処理
    data.forEach((item) => {
      // メディアタイプがビデオの場合
      if (item.media_type === "VIDEO") {
        src = item.thumbnail_url;
        // ビデオの場合は以下の span 要素でアイコンを表示
        video = '<span class="video-icon"></span>';
      }
      // メディアタイプが画像の場合
      else {
        src = item.media_url;
        video = "";
      }
      // フィード部分の HTML を作成
      const instaMediaDiv = `<div class="insta-media">
  <a href="${item.permalink}" target="_blank" rel="noopener">
    <img src="${src}" alt="${item.caption ? item.caption : ""}">
    ${video ? video : ""}
    <span class="like-count">${item.like_count}</span>
    <span class="comments-count">${item.comments_count}</span>
  </a>
  </div>
  `;
      instaFeeds += instaMediaDiv;
    });
    return instaFeeds;
  }

  // 次のデータのエンドポイント(url)を引数に受取、HTML を更新する関数
  function loadMoreFeeds(url, limit, maxMediaCount) {
    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`,
          );
        }
      })
      .then((instaResponse) => {
        const instaFeeds = createInstaFeeds(instaResponse.data);
        // 次のデータのエンドポイントを取得(instaResponse.media.paging.next ではなく instaResponse.paging.next になる)
        next = instaResponse.paging.next;
        // 投稿のコンテナを取得
        const instaContainer =
          instaWrapperRef.current.querySelector(".insta-container");
        if (instaContainer) {
          // 投稿のコンテナに追加の投稿の HTML を挿入
          instaContainer.insertAdjacentHTML("beforeend", instaFeeds);
          // 読み込み数をカウント
          mediaLoaded += limit;
          // next が存在しないか、表示数が最大表示数より大きければ
          if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
            // 次のデータのエンドポイント(next)が存在しなければボタンを削除
            instaWrapperRef.current.querySelector("#load-more-insta-feeds").remove();
          }
        }
      });
  }

  // インスペクターを出力する関数を定義
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            onChange={(value) => setAttributes({ limit: parseInt(value) })}
          />
          <NumberControl
            label="最大表示数"
            min="0"
            max="60"
            value={attributes.maxMediaCount}
            onChange={(value) =>
              setAttributes({ maxMediaCount: parseInt(value) })
            }
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
          <TextControl
            label="Load More ボタンテキスト"
            value={attributes.loadMoreText}
            onChange={(value) => setAttributes({ loadMoreText: value })}
            placeholder="デフォルト:Load More"
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => setAttributes({ apiVersion: value })}
          />
        </PanelBody>
      </InspectorControls>
    );
  };

  // 表示する要素に .insta-div の要素を追加
  return [
    getInspectorControls(),
    <div {...useBlockProps()}>
      <div className="my-insta-block">
        <h3>Instagram Timeline 表示ブロック</h3>
        <p>設定/変更はサイドバーのインスペクターで行います。</p>
      </div>
      <div className="insta-div" ref={instaWrapperRef}></div>
    </div>
  ];
}

プレビュー切り替えボタンを追加

以下は2つ目の問題の対策として前述の例にプレビュー切り替えボタンを追加して、必要な場合のみプレビューを表示する例です。

以下が edit.js です。

Edit 関数ではブロックが現在選択されているかどうかを表す isSelected を追加で受け取ります。

また、useState を追加でインポートし、現在プレビューを表示しているかどうかの真偽値を保持する state 変数 isEditMode とその更新関数 setEditMode を定義し、プレビュー切り替えボタンがクリックされたら値を反転させます。

  • isEditMode が false :プレビューを表示している状態
  • isEditMode が true : プレビューを表示していない状態

プレビュー切り替えボタンはツールバーに表示します。ツールバーの出力は getToolbarControls という関数で定義し、ToolbarButtonBlockControlsToolbarGroup で囲みます。

そして、 ToolbarButton の onClick で isEditMode の値を useState の更新関数 setEditMode() で更新します。

isEditMode を block.json で属性として定義することもできますが、setAttributes を使って値を変更すると属性の値が更新されるため、プレビュー切り替えボタンをクリックするだけで「更新」ボタンがアクティブになってしまいます。そのため、isEditMode を state 変数で定義しています。

また、ブロックが選択されていない場合はプレビューモードを終了しています(179〜181行目)。

// 追加で BlockControls をインポート
import { useBlockProps, InspectorControls, BlockControls } from "@wordpress/block-editor";
// 追加で  ToolbarGroup と ToolbarButton をインポート
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl, ToolbarGroup, ToolbarButton } from "@wordpress/components";
import "./editor.scss";
// useState を追加でインポート
import { useEffect, useRef, useState } from "@wordpress/element";

// 追加で isSelected を受け取る
export default function Edit({ attributes, setAttributes, isSelected }) {
  // Instagram ビジネスアカウント ID
  const baid = "xxxxxx";
  // アクセストークン
  const uat = "xxxxxx";
  // ref を宣言してインスタ一覧表示の要素 div.insta-div に指定
  const instaWrapperRef = useRef(null);
  // 次に読み込むエンドポイントの URL
  let next;
  // 表示した投稿の総数
  let mediaLoaded = 0;

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

  // 初回読み込み時と attributes.limit または attributes.maxMediaCount が変更された場合
  useEffect(() => {
    // プレビューモードの場合
    if (!isEditMode) {
      //グラフAPI ホストURL(ルートエンドポイント)
      const api = "https://graph.facebook.com/";
      //API のバージョン
      const version = attributes.apiVersion;
      //表示件数
      const limit = attributes.limit;

      //取得するフィールド
      const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
      // fields パラメータ
      const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
      // パラメーターを表すオブジェクト(フィールドとトークン)
      const params = {
        fields, // fields: fields, と同じこと
        access_token: uat,
      };
      // オブジェクト形式のパラメータをクエリ文字列に変換
      const query = new URLSearchParams(params);
      // fetch() に渡す URL の組み立て
      const url = `${api}/${version}/${baid}?${query}`;
      // fetch() でデータを取得
      fetch(url)
        .then((response) => {
          // ステータスが ok であればレスポンスを JSON として解析
          if (response.ok) {
            return response.json();
          } else {
            // ステータスが ok でなければエラーにする
            throw new Error(
              `リクエスト失敗:${response.status} ${response.statusText}`,
            );
          }
        })
        .then((instaResponse) => {
          // 次のデータのエンドポイントを取得
          next = instaResponse.media.paging.next;
          // 取得したデータから投稿一覧の HTML を作成(関数を別途定義)
          const instaFeeds = createInstaFeeds(instaResponse.media.data);
          // 最大表示数
          const maxMediaCount = attributes.maxMediaCount;
          // 表示した投稿数をカウント
          mediaLoaded += limit;
          // 見出し文字列
          const headingText = attributes.headingText;
          // 見出しタグ
          const headingTag = attributes.headingTag;
          // 見出し部分のマークアップを作成
          const heading = headingText ? `<${headingTag} class="insta-icon">${headingText}</${headingTag}>` : "";
          // 次のデータのエンドポイントが存在しないか、読み込み数が最大読み込み数より大きい場合
          if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
            // 取得した画像データと見出しなどを出力
            instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
        ${heading}
        <div class="insta-container">${instaFeeds}</div>
      </div>`;
          } else {
            // 次のデータのエンドポイントが存在すれば、投稿を読み込むボタンを出力
            instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
        ${heading}
        <div class="insta-container">${instaFeeds}</div>
        <button id="load-more-insta-feeds">${attributes.loadMoreText}</button>
      </div>`;
            const loadMoreInstaFeeds = instaWrapperRef.current.querySelector(
              "#load-more-insta-feeds",
            );
            // 投稿を読み込むボタンをクリックしたら次のデータのエンドポイントからデータを取得
            if (loadMoreInstaFeeds) {
              loadMoreInstaFeeds.addEventListener("click", () => {
                // loadMoreFeeds() を呼び出す際に表示件数と最大表示数も引数に渡す
                loadMoreFeeds(next, limit, maxMediaCount);
              });
            }
          }
        })
        .catch((error) => {
          // instaWrapperRef.current を参照できれば
          if (instaWrapperRef.current) {
            // エラーがあれば編集画面に表示
            instaWrapperRef.current.innerHTML = `<div class="insta-error" style="background-color:pink; padding:20px; margin:50px 0;">
              <h2>エラー</h2>
              <p>${error} </p>
            </div>`;
          }
        });
    }
  }, [isEditMode, attributes.limit, attributes.maxMediaCount]);

  // インスペクタで attributes.headingText(見出しのテキスト)が変更された場合
  useEffect(() => {
    if (instaWrapperRef.current) {
      const headingElem = instaWrapperRef.current.querySelector(
        attributes.headingTag,
      );
      if (headingElem) {
        // 見出しのテキストを更新
        headingElem.textContent = attributes.headingText;
        if (attributes.headingText === "") {
          // 見出しのテキストが空文字の場合は見出しを削除
          headingElem.remove();
        }
      } else {
        // 見出し要素が存在しない場合
        const instaWrapper =
          instaWrapperRef.current.querySelector(".insta-wrapper");
        if (instaWrapper) {
          const newHeading = document.createElement(attributes.headingTag);
          newHeading.setAttribute("class", "insta-icon");
          newHeading.appendChild( document.createTextNode(attributes.headingText) );
          instaWrapper.prepend(newHeading);
        }
      }
    }
  }, [attributes.headingText]);

  // attributes.headingTag(見出しのタグ)が変更された場合
  useEffect(() => {
    if (instaWrapperRef.current) {
      // 見出しの要素を取得
      const headingElem = instaWrapperRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6").item(0);
      // attributes.headingTag が変更されているので instaWrapperRef.current.querySelector(attributes.headingTag) では取得できない
      if (headingElem) {
        const headingParent = headingElem.parentElement;
        if (headingParent) {
          const newHeading = document.createElement(attributes.headingTag);
          newHeading.setAttribute("class", "insta-icon");
          newHeading.appendChild( document.createTextNode(attributes.headingText) );
          headingParent.replaceChild(newHeading, headingElem);
        }
      }
    }
  }, [attributes.headingTag]);

  // attributes.loadMoreText(追加ボタンのテキスト)が変更された場合
  useEffect(() => {
    if (instaWrapperRef.current) {
      const loadMoreBtn = instaWrapperRef.current.querySelector(
        "#load-more-insta-feeds",
      );
      if (loadMoreBtn) {
        // loadMoreBtn.textContent = attributes.loadMoreText;
        if (attributes.loadMoreText) {
          loadMoreBtn.textContent = attributes.loadMoreText;
        } else {
          loadMoreBtn.textContent = "Load More";
        }
      }
    }
  }, [attributes.loadMoreText]);

  // ブロックが選択されていない場合はプレビューモードを終了
  useEffect(() => {
    setEditMode(true);
  }, [isSelected]);

  // 引数に fetch() で取得したレスポンスの data を受取、インスタ一覧の HTML を生成する関数
  function createInstaFeeds(data) {
    // 出力する値を入れる変数
    let instaFeeds = ""; // フィード部分の HTML
    let src = ""; // img の src
    let video = ""; // メディアタイプがビデオの場合に出力する要素
    // レスポンスのデータ(media.data)は配列なので forEach でループ処理
    data.forEach((item) => {
      // メディアタイプがビデオの場合
      if (item.media_type === "VIDEO") {
        src = item.thumbnail_url;
        // ビデオの場合は以下の span 要素でアイコンを表示
        video = '<span class="video-icon"></span>';
      }
      // メディアタイプが画像の場合
      else {
        src = item.media_url;
        video = "";
      }
      // フィード部分の HTML を作成
      const instaMediaDiv = `<div class="insta-media">
  <a href="${item.permalink}" target="_blank" rel="noopener">
    <img src="${src}" alt="${item.caption ? item.caption : ""}">
    ${video ? video : ""}
    <span class="like-count">${item.like_count}</span>
    <span class="comments-count">${item.comments_count}</span>
  </a>
</div>
`;
      instaFeeds += instaMediaDiv;
    });
    return instaFeeds;
  }

  // 次のデータのエンドポイント(url)を引数に受取、HTML を更新する関数
  function loadMoreFeeds(url, limit, maxMediaCount) {
    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`,
          );
        }
      })
      .then((instaResponse) => {
        const instaFeeds = createInstaFeeds(instaResponse.data);
        // 次のデータのエンドポイントを取得(instaResponse.media.paging.next ではなく instaResponse.paging.next になる)
        next = instaResponse.paging.next;
        // 投稿のコンテナを取得
        const instaContainer =
          instaWrapperRef.current.querySelector(".insta-container");
        if (instaContainer) {
          // 投稿のコンテナに追加の投稿の HTML を挿入
          instaContainer.insertAdjacentHTML("beforeend", instaFeeds);
          // 読み込み数をカウント
          mediaLoaded += limit;
          // next が存在しないか、表示数が最大表示数より大きければ
          if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
            // 次のデータのエンドポイント(next)が存在しなければボタンを削除
            instaWrapperRef.current.querySelector("#load-more-insta-feeds").remove();
          }
        }
      });
  }

  // インスペクターを出力する関数
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            onChange={(value) => setAttributes({ limit: parseInt(value) })}
          />
          <NumberControl
            label="最大表示数"
            min="0"
            max="60"
            value={attributes.maxMediaCount}
            onChange={(value) =>
              setAttributes({ maxMediaCount: parseInt(value) })
            }
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
          <TextControl
            label="Load More ボタンテキスト"
            value={attributes.loadMoreText}
            onChange={(value) => setAttributes({ loadMoreText: value })}
            placeholder="デフォルト:Load More"
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => setAttributes({ apiVersion: value })}
          />
        </PanelBody>
      </InspectorControls>
    );
  };

  // ツールバーを出力する関数
  const getToolbarControls = () => {
    return (
      <BlockControls>
        <ToolbarGroup>
          <ToolbarButton
            text={isEditMode ? "プレビューを表示" : "プレビューを終了"}
            icon={isEditMode ? "format-gallery" : "yes"}
            label={isEditMode ? "Show Preview" : "Hide Preview"}
            className="edit-preview-button"
            onClick={() =>
              // isEditMode の値を反転
              setEditMode(!isEditMode)
            }
          />
        </ToolbarGroup>
      </BlockControls>
    );
  };

  // isEditMode の値により出力を変える
  return [
    getToolbarControls(),
    getInspectorControls(),
    <>
      {isEditMode && ( // 編集モード
        <div {...useBlockProps()}>
          <div className="my-insta-block">
            <h3>Instagram Timeline 表示ブロック</h3>
            <p>設定/変更はサイドバーのインスペクターで行います。</p>
          </div>
        </div>
      )}
      {!isEditMode && ( // プレビューモード
        <div {...useBlockProps()}>
          <div className="my-insta-block">
            <h3>Instagram Timeline 表示ブロック</h3>
            <p>設定/変更はサイドバーのインスペクターで行います。</p>
          </div>
          <div className="insta-div" ref={instaWrapperRef}></div>
        </div>
      )}
    </>,
  ];
}

エディターでブロックを選択すると「プレビューを表示」ボタンを表示します。

「プレビューを終了」ボタンをクリックするか、他のブロックを選択するとプレビューは終了します。

編集モードでもエラーを表示

以下は編集モードでも API リクエストが失敗した場合はエラーを表示する例です。

state 変数 isError とその更新関数 setError を宣言し、API リクエストが失敗した場合は更新関数 setError で isError を true に更新してエラーを表示します。

// 追加で BlockControls をインポート
import { useBlockProps, InspectorControls, BlockControls } from "@wordpress/block-editor";
// 追加で  ToolbarGroup と ToolbarButton をインポート
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl, ToolbarGroup, ToolbarButton } from "@wordpress/components";
import "./editor.scss";
// useState を追加でインポート
import { useEffect, useRef, useState } from "@wordpress/element";

// 追加で isSelected を受け取る
export default function Edit({ attributes, setAttributes, isSelected }) {
  // Instagram ビジネスアカウント ID
  const baid = "xxxxxx";
  // アクセストークン
  const uat = "xxxxxx";
  // ref を宣言してインスタ一覧表示の要素 div.insta-div に指定
  const instaWrapperRef = useRef(null);
  // 次に読み込むエンドポイントの URL
  let next;
  // 表示した投稿の総数
  let mediaLoaded = 0;

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

  // 初回読み込み時と attributes.limit または attributes.maxMediaCount が変更された場合
  useEffect(() => {
    //グラフAPI ホストURL(ルートエンドポイント)
    const api = "https://graph.facebook.com/";
    //API のバージョン
    const version = attributes.apiVersion;
    //表示件数
    const limit = attributes.limit;

    //取得するフィールド
    const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
    // fields パラメータ
    const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
    // パラメーターを表すオブジェクト(フィールドとトークン)
    const params = {
      fields, // fields: fields, と同じこと
      access_token: uat,
    };
    // オブジェクト形式のパラメータをクエリ文字列に変換
    const query = new URLSearchParams(params);
    // fetch() に渡す URL の組み立て
    const url = `${api}/${version}/${baid}?${query}`;

    // プレビューモードの場合
    if (!isEditMode) {
      // fetch() でデータを取得
      fetch(url)
        .then((response) => {
          // ステータスが ok であればレスポンスを JSON として解析
          if (response.ok) {
            return response.json();
          } else {
            // ステータスが ok でなければエラーにする
            throw new Error(
              `リクエスト失敗:${response.status} ${response.statusText}`,
            );
          }
        })
        .then((instaResponse) => {
          // 次のデータのエンドポイントを取得
          next = instaResponse.media.paging.next;
          // 取得したデータから投稿一覧の HTML を作成(関数を別途定義)
          const instaFeeds = createInstaFeeds(instaResponse.media.data);
          // 最大表示数
          const maxMediaCount = attributes.maxMediaCount;
          // 表示した投稿数をカウント
          mediaLoaded += limit;
          // 見出し文字列
          const headingText = attributes.headingText;
          // 見出しタグ
          const headingTag = attributes.headingTag;
          // 見出し部分のマークアップを作成
          const heading = headingText ? `<${headingTag} class="insta-icon">${headingText}</${headingTag}>` : "";
          // 次のデータのエンドポイントが存在しないか、読み込み数が最大読み込み数より大きい場合
          if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
            // 取得した画像データと見出しなどを出力
            instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
        ${heading}
        <div class="insta-container">${instaFeeds}</div>
      </div>`;
          } else {
            // 次のデータのエンドポイントが存在すれば、投稿を読み込むボタンを出力
            instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
        ${heading}
        <div class="insta-container">${instaFeeds}</div>
        <button id="load-more-insta-feeds">${attributes.loadMoreText}</button>
      </div>`;
            const loadMoreInstaFeeds = instaWrapperRef.current.querySelector(
              "#load-more-insta-feeds",
            );
            // 投稿を読み込むボタンをクリックしたら次のデータのエンドポイントからデータを取得
            if (loadMoreInstaFeeds) {
              loadMoreInstaFeeds.addEventListener("click", () => {
                // loadMoreFeeds() を呼び出す際に表示件数と最大表示数も引数に渡す
                loadMoreFeeds(next, limit, maxMediaCount);
              });
            }
          }
        })
        .catch((error) => {
          // instaWrapperRef.current を参照できれば
          if (instaWrapperRef.current) {
            // エラーがあれば編集画面に表示
            instaWrapperRef.current.innerHTML = `<div class="insta-error" style="background-color:pink; padding:20px; margin:50px 0;">
              <h2>エラー</h2>
              <p>${error} </p>
            </div>`;
          }
        });
    }else{
      // 編集モードではエラーがあれば表示
      fetch(url)
        .then((response) => {
          // ステータスが ok でなければエラーにする
          if (!response.ok) {
            throw new Error(
              `リクエスト失敗:${response.status} ${response.statusText}`,
            );
          }
          // エラーがなければ state 変数 isError を false に
          setError(false);
        }).catch(() => {
          // エラーがあれば state 変数 isError を true に
          setError(true);
        });
    }
  }, [isEditMode, attributes.limit, attributes.maxMediaCount, attributes.apiVersion]);
  // API バージョンの入力も検知するように attributes.apiVersion を追加

  // インスペクタで attributes.headingText(見出しのテキスト)が変更された場合
  useEffect(() => {
    if (instaWrapperRef.current) {
      const headingElem = instaWrapperRef.current.querySelector(
        attributes.headingTag,
      );
      if (headingElem) {
        // 見出しのテキストを更新
        headingElem.textContent = attributes.headingText;
        if (attributes.headingText === "") {
          // 見出しのテキストが空文字の場合は見出しを削除
          headingElem.remove();
        }
      } else {
        // 見出し要素が存在しない場合
        const instaWrapper =
          instaWrapperRef.current.querySelector(".insta-wrapper");
        if (instaWrapper) {
          const newHeading = document.createElement(attributes.headingTag);
          newHeading.setAttribute("class", "insta-icon");
          newHeading.appendChild( document.createTextNode(attributes.headingText) );
          instaWrapper.prepend(newHeading);
        }
      }
    }
  }, [attributes.headingText]);

  // attributes.headingTag(見出しのタグ)が変更された場合
  useEffect(() => {
    if (instaWrapperRef.current) {
      // 見出しの要素を取得
      const headingElem = instaWrapperRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6").item(0);
      // attributes.headingTag が変更されているので instaWrapperRef.current.querySelector(attributes.headingTag) では取得できない
      if (headingElem) {
        const headingParent = headingElem.parentElement;
        if (headingParent) {
          const newHeading = document.createElement(attributes.headingTag);
          newHeading.setAttribute("class", "insta-icon");
          newHeading.appendChild( document.createTextNode(attributes.headingText) );
          headingParent.replaceChild(newHeading, headingElem);
        }
      }
    }
  }, [attributes.headingTag]);

  // attributes.loadMoreText(追加ボタンのテキスト)が変更された場合
  useEffect(() => {
    if (instaWrapperRef.current) {
      const loadMoreBtn = instaWrapperRef.current.querySelector(
        "#load-more-insta-feeds",
      );
      if (loadMoreBtn) {
        // loadMoreBtn.textContent = attributes.loadMoreText;
        if (attributes.loadMoreText) {
          loadMoreBtn.textContent = attributes.loadMoreText;
        } else {
          loadMoreBtn.textContent = "Load More";
        }
      }
    }
  }, [attributes.loadMoreText]);

  // ブロックが選択されていない場合はプレビューモードを終了
  useEffect(() => {
    setEditMode(true);
  }, [isSelected]);

  // 引数に fetch() で取得したレスポンスの data を受取、インスタ一覧の HTML を生成する関数
  function createInstaFeeds(data) {
    // 出力する値を入れる変数
    let instaFeeds = ""; // フィード部分の HTML
    let src = ""; // img の src
    let video = ""; // メディアタイプがビデオの場合に出力する要素
    // レスポンスのデータ(media.data)は配列なので forEach でループ処理
    data.forEach((item) => {
      // メディアタイプがビデオの場合
      if (item.media_type === "VIDEO") {
        src = item.thumbnail_url;
        // ビデオの場合は以下の span 要素でアイコンを表示
        video = '<span class="video-icon"></span>';
      }
      // メディアタイプが画像の場合
      else {
        src = item.media_url;
        video = "";
      }
      // フィード部分の HTML を作成
      const instaMediaDiv = `<div class="insta-media">
  <a href="${item.permalink}" target="_blank" rel="noopener">
    <img src="${src}" alt="${item.caption ? item.caption : ""}">
    ${video ? video : ""}
    <span class="like-count">${item.like_count}</span>
    <span class="comments-count">${item.comments_count}</span>
  </a>
</div>
`;
      instaFeeds += instaMediaDiv;
    });
    return instaFeeds;
  }

  // 次のデータのエンドポイント(url)を引数に受取、HTML を更新する関数
  function loadMoreFeeds(url, limit, maxMediaCount) {
    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`,
          );
        }
      })
      .then((instaResponse) => {
        const instaFeeds = createInstaFeeds(instaResponse.data);
        // 次のデータのエンドポイントを取得(instaResponse.media.paging.next ではなく instaResponse.paging.next になる)
        next = instaResponse.paging.next;
        // 投稿のコンテナを取得
        const instaContainer =
          instaWrapperRef.current.querySelector(".insta-container");
        if (instaContainer) {
          // 投稿のコンテナに追加の投稿の HTML を挿入
          instaContainer.insertAdjacentHTML("beforeend", instaFeeds);
          // 読み込み数をカウント
          mediaLoaded += limit;
          // next が存在しないか、表示数が最大表示数より大きければ
          if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
            // 次のデータのエンドポイント(next)が存在しなければボタンを削除
            instaWrapperRef.current.querySelector("#load-more-insta-feeds").remove();
          }
        }
      });
  }

  // インスペクターを出力する関数
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            onChange={(value) => setAttributes({ limit: parseInt(value) })}
          />
          <NumberControl
            label="最大表示数"
            min="0"
            max="60"
            value={attributes.maxMediaCount}
            onChange={(value) =>
              setAttributes({ maxMediaCount: parseInt(value) })
            }
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
          <TextControl
            label="Load More ボタンテキスト"
            value={attributes.loadMoreText}
            onChange={(value) => setAttributes({ loadMoreText: value })}
            placeholder="デフォルト:Load More"
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => setAttributes({ apiVersion: value })}
          />
        </PanelBody>
      </InspectorControls>
    );
  };

  // ツールバーを出力する関数
  const getToolbarControls = () => {
    return (
      <BlockControls>
        <ToolbarGroup>
          <ToolbarButton
            text={isEditMode ? "プレビューを表示" : "プレビューを終了"}
            icon={isEditMode ? "format-gallery" : "yes"}
            label={isEditMode ? "Show Preview" : "Hide Preview"}
            className="edit-preview-button"
            onClick={() =>
              // isEditMode の値を反転
              setEditMode(!isEditMode)
            }
          />
        </ToolbarGroup>
      </BlockControls>
    );
  };

  // isEditMode の値により出力を変える
  return [
    getToolbarControls(),
    getInspectorControls(),
    <>
      {isEditMode && ( // 編集モード
        <div {...useBlockProps()}>
          <div className="my-insta-block">
            <h3>Instagram Timeline 表示ブロック</h3>
            <p>設定/変更はサイドバーのインスペクターで行います。</p>
            { isError && (
              <p style={ {color:'#fff', backgroundColor:'tomato', padding: '10px 20px'} } > エラー発生</p>
            )}
          </div>
        </div>
      )}
      {!isEditMode && ( // プレビューモード
        <div {...useBlockProps()}>
          <div className="my-insta-block">
            <h3>Instagram Timeline 表示ブロック</h3>
            <p>設定/変更はサイドバーのインスペクターで行います。</p>
          </div>
          <div className="insta-div" ref={instaWrapperRef}></div>
        </div>
      )}
    </>,
  ];
}

エラーがあれば以下のように表示されます(ブロックが選択されていなくてもエラーは表示されます)。

このとき、プレビュー表示すると、エラーメッセージを確認できます。

アカウント ID とトークンをインポート

プレビュー表示する場合、view.js に加え edit.js にも Instagram ビジネスアカウント ID とアクセストークンを記述します。

以下は src ディレクトリに ig-acct.js という JavaScript ファイルを作成し、そこに Instagram ビジネスアカウント ID とアクセストークンを記述して view.js と edit.js からインポートして読み込む例です。

ig-acct.js ではビジネスアカウント ID とトークンの値を代入した変数を定義してエクスポートします。

export const uat = 'xxxxxx'; // ユーザーアクセストークン
export const baid = "xxxxx";  // Instagram ビジネスアカウント ID

view.js と edit.js ではビジネスアカウント ID とトークンを ig-acct.js からインポートします。

定義していた変数 baid と uat は削除します。

// ig-acct.js に記述されたビジネスアカウント ID(baid)とトークン(uat)をインポート
import { baid, uat } from "./ig-acct";

document.addEventListener("DOMContentLoaded", () => {
  // 定義していた変数 baid と uat は削除
  //  以下は変更なし
  let next;
  let mediaLoaded = 0;
  const target = document.getElementsByClassName("insta-div")[0];
  if (target) {
    const api = "https://graph.facebook.com/";
    const version = 'v20.0';
    const limit = target.dataset.thumbsLimit ? parseInt(target.dataset.thumbsLimit): 4;
    const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
    const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
    const params = {
      fields,
      access_token: uat,
    };
    const query = new URLSearchParams(params);
    const url = `${api}/${version}/${baid}?${query}`;

    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        } else {
          throw new Error(
            `Insta Fetch リクエスト失敗:${response.status} ${response.statusText}`
          );
        }
      })
      .then((instaResponse) => {
        next = instaResponse.media.paging.next;
        const instaFeeds = createInstaFeeds(instaResponse.media.data);
        const maxMediaCount = target.dataset.maxMediaCount ? parseInt(target.dataset.maxMediaCount): 0;
        mediaLoaded += limit;
        const headingText =  target.dataset.headingText ? target.dataset.headingText: '';
        const headingTag = target.dataset.headingTag ? target.dataset.headingTag: 'h2';
        const heading = headingText ? `<${headingTag} class="insta-icon">${headingText}</${headingTag}>`: '';
        const loadMoreText = target.dataset.loadMoreText ? target.dataset.loadMoreText : "Load More";
        if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
          target.innerHTML = `<div class="insta-wrapper">
      ${heading}
      <div class="insta-container">${instaFeeds}</div>
    </div>`;
        } else {
          target.innerHTML = `<div class="insta-wrapper">
      ${heading}
      <div class="insta-container">${instaFeeds}</div>
      <button id="load-more-insta-feeds">${loadMoreText}</button>
    </div>`;
          const loadMoreInstaFeeds = target.querySelector( "#load-more-insta-feeds");
          if (loadMoreInstaFeeds) {
            loadMoreInstaFeeds.addEventListener("click", () => {
              loadMoreFeeds(next, limit, maxMediaCount);
            });
          }
        }
      }).catch((error) => {
        console.warn(error.message);
        showErrorForAdmin(error.message);
      });
  }

  function createInstaFeeds(data) {
    let instaFeeds = "";
    let src = "";
    let video = "";
    data.forEach((item) => {
      if (item.media_type === "VIDEO") {
        src = item.thumbnail_url;
        video = '<span class="video-icon"></span>';
      }
      else {
        src = item.media_url;
        video = "";
      }
      const instaMediaDiv = `<div class="insta-media">
    <a href="${item.permalink}" target="_blank" rel="noopener">
      <img src="${src}" alt="${item.caption ? item.caption : ""}">
      ${video ? video : ""}
      <span class="like-count">${item.like_count}</span>
      <span class="comments-count">${item.comments_count}</span>
    </a>
  </div>
  `;
      instaFeeds += instaMediaDiv;
    });
    return instaFeeds;
  }

  function loadMoreFeeds(url, limit, maxMediaCount) {
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        } else {
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`
          );
        }
      })
      .then((instaResponse) => {
        const instaFeeds = createInstaFeeds(instaResponse.data);
        next = instaResponse.paging.next;
        const instaContainer = target.querySelector(".insta-container");
        if (instaContainer) {
          instaContainer.insertAdjacentHTML("beforeend", instaFeeds);
          mediaLoaded += limit;
          if (!next ||  maxMediaCount && maxMediaCount <= mediaLoaded) {
            target.querySelector("#load-more-insta-feeds").remove();
          }
        }
      });
  }

  function showErrorForAdmin(message) {
    if(login_status) {
      if(login_status.isLoggedIn && login_status.isAdminUser) {
        target.innerHTML = `<div class="insta-error" style="background-color:pink; padding:20px; margin:50px 0;">
            <h2>エラー</h2>
            <p>${message} </p>
          </div>`;
      }
    }
  }
});
import { useBlockProps, InspectorControls, BlockControls } from "@wordpress/block-editor";
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl, ToolbarGroup, ToolbarButton } from "@wordpress/components";
import "./editor.scss";
import { useEffect, useRef, useState } from "@wordpress/element";
// ig-acct.js に記述されたビジネスアカウント ID(baid)とトークン(uat)をインポート
import { baid, uat } from "./ig-acct";

export default function Edit({ attributes, setAttributes, isSelected }) {
  const instaWrapperRef = useRef(null);
  let next;
  let mediaLoaded = 0;

  const [isEditMode, setEditMode] = useState(true);
  const [isError, setError] = useState(false);

  useEffect(() => {
    const api = "https://graph.facebook.com/";
    const version = attributes.apiVersion;
    const limit = attributes.limit;

    const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
    const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
    const params = {
      fields,
      access_token: uat,
    };
    const query = new URLSearchParams(params);
    const url = `${api}/${version}/${baid}?${query}`;

    if (!isEditMode) {
      fetch(url)
        .then((response) => {
          if (response.ok) {
            return response.json();
          } else {
            throw new Error(
              `リクエスト失敗:${response.status} ${response.statusText}`,
            );
          }
        })
        .then((instaResponse) => {
          next = instaResponse.media.paging.next;
          const instaFeeds = createInstaFeeds(instaResponse.media.data);
          const maxMediaCount = attributes.maxMediaCount;
          mediaLoaded += limit;
          const headingText = attributes.headingText;
          const headingTag = attributes.headingTag;
          const heading = headingText ? `<${headingTag} class="insta-icon">${headingText}</${headingTag}>` : "";
          if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
            instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
        ${heading}
        <div class="insta-container">${instaFeeds}</div>
      </div>`;
          } else {
            instaWrapperRef.current.innerHTML = `<div class="insta-wrapper">
        ${heading}
        <div class="insta-container">${instaFeeds}</div>
        <button id="load-more-insta-feeds">${attributes.loadMoreText}</button>
      </div>`;
            const loadMoreInstaFeeds = instaWrapperRef.current.querySelector(
              "#load-more-insta-feeds",
            );
            if (loadMoreInstaFeeds) {
              loadMoreInstaFeeds.addEventListener("click", () => {
                loadMoreFeeds(next, limit, maxMediaCount);
              });
            }
          }
        })
        .catch((error) => {
          if (instaWrapperRef.current) {
            instaWrapperRef.current.innerHTML = `<div class="insta-error" style="background-color:pink; padding:20px; margin:50px 0;">
              <h2>エラー</h2>
              <p>${error} </p>
            </div>`;
          }
        });
    }else{
      fetch(url)
        .then((response) => {
          if (!response.ok) {
            throw new Error(
              `リクエスト失敗:${response.status} ${response.statusText}`,
            );
          }
          setError(false);
        }).catch(() => {
          setError(true);
        });
    }
  }, [isEditMode, attributes.limit, attributes.maxMediaCount, attributes.apiVersion]);

  useEffect(() => {
    if (instaWrapperRef.current) {
      const headingElem = instaWrapperRef.current.querySelector(
        attributes.headingTag,
      );
      if (headingElem) {
        headingElem.textContent = attributes.headingText;
        if (attributes.headingText === "") {
          headingElem.remove();
        }
      } else {
        const instaWrapper =
          instaWrapperRef.current.querySelector(".insta-wrapper");
        if (instaWrapper) {
          const newHeading = document.createElement(attributes.headingTag);
          newHeading.setAttribute("class", "insta-icon");
          newHeading.appendChild( document.createTextNode(attributes.headingText) );
          instaWrapper.prepend(newHeading);
        }
      }
    }
  }, [attributes.headingText]);

  useEffect(() => {
    if (instaWrapperRef.current) {
      const headingElem = instaWrapperRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6").item(0);
      if (headingElem) {
        const headingParent = headingElem.parentElement;
        if (headingParent) {
          const newHeading = document.createElement(attributes.headingTag);
          newHeading.setAttribute("class", "insta-icon");
          newHeading.appendChild( document.createTextNode(attributes.headingText) );
          headingParent.replaceChild(newHeading, headingElem);
        }
      }
    }
  }, [attributes.headingTag]);

  useEffect(() => {
    if (instaWrapperRef.current) {
      const loadMoreBtn = instaWrapperRef.current.querySelector(
        "#load-more-insta-feeds",
      );
      if (loadMoreBtn) {
        if (attributes.loadMoreText) {
          loadMoreBtn.textContent = attributes.loadMoreText;
        } else {
          loadMoreBtn.textContent = "Load More";
        }
      }
    }
  }, [attributes.loadMoreText]);

  useEffect(() => {
    setEditMode(true);
  }, [isSelected]);

  function createInstaFeeds(data) {
    let instaFeeds = "";
    let src = "";
    let video = "";
    data.forEach((item) => {
      if (item.media_type === "VIDEO") {
        src = item.thumbnail_url;
        video = '<span class="video-icon"></span>';
      }
      else {
        src = item.media_url;
        video = "";
      }
      const instaMediaDiv = `<div class="insta-media">
  <a href="${item.permalink}" target="_blank" rel="noopener">
    <img src="${src}" alt="${item.caption ? item.caption : ""}">
    ${video ? video : ""}
    <span class="like-count">${item.like_count}</span>
    <span class="comments-count">${item.comments_count}</span>
  </a>
</div>
`;
      instaFeeds += instaMediaDiv;
    });
    return instaFeeds;
  }

  function loadMoreFeeds(url, limit, maxMediaCount) {
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        } else {
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`,
          );
        }
      })
      .then((instaResponse) => {
        const instaFeeds = createInstaFeeds(instaResponse.data);
        next = instaResponse.paging.next;
        const instaContainer =
          instaWrapperRef.current.querySelector(".insta-container");
        if (instaContainer) {
          instaContainer.insertAdjacentHTML("beforeend", instaFeeds);
          mediaLoaded += limit;
          if (!next || (maxMediaCount && maxMediaCount <= mediaLoaded)) {
            instaWrapperRef.current.querySelector("#load-more-insta-feeds").remove();
          }
        }
      });
  }

  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            onChange={(value) => setAttributes({ limit: parseInt(value) })}
          />
          <NumberControl
            label="最大表示数"
            min="0"
            max="60"
            value={attributes.maxMediaCount}
            onChange={(value) =>
              setAttributes({ maxMediaCount: parseInt(value) })
            }
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
          <TextControl
            label="Load More ボタンテキスト"
            value={attributes.loadMoreText}
            onChange={(value) => setAttributes({ loadMoreText: value })}
            placeholder="デフォルト:Load More"
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => setAttributes({ apiVersion: value })}
          />
        </PanelBody>
      </InspectorControls>
    );
  };

  const getToolbarControls = () => {
    return (
      <BlockControls>
        <ToolbarGroup>
          <ToolbarButton
            text={isEditMode ? "プレビューを表示" : "プレビューを終了"}
            icon={isEditMode ? "format-gallery" : "yes"}
            label={isEditMode ? "Show Preview" : "Hide Preview"}
            className="edit-preview-button"
            onClick={() =>
              setEditMode(!isEditMode)
            }
          />
        </ToolbarGroup>
      </BlockControls>
    );
  };

  return [
    getToolbarControls(),
    getInspectorControls(),
    <>
      {isEditMode && (
        <div {...useBlockProps()}>
          <div className="my-insta-block">
            <h3>Instagram Timeline 表示ブロック</h3>
            <p>設定/変更はサイドバーのインスペクターで行います。</p>
            { isError && (
              <p style={ {color:'#fff', backgroundColor:'tomato', padding: '10px 20px'} } > エラー発生</p>
            )}
          </div>
        </div>
      )}
      {!isEditMode && (
        <div {...useBlockProps()}>
          <div className="my-insta-block">
            <h3>Instagram Timeline 表示ブロック</h3>
            <p>設定/変更はサイドバーのインスペクターで行います。</p>
          </div>
          <div className="insta-div" ref={instaWrapperRef}></div>
        </div>
      )}
    </>,
  ];
}

ビルド

問題なく表示されれば(開発が終了したら)、変更がすべて保存されていることを確認して、control + c を押して npm start コマンドを終了します。

そして npm run build コマンドをターミナルで実行して本番環境用にビルドします。

ビルド実行後は、例えば以下のようなファイルが build ディレクトリに出力されています。

build
├── block.json
├── index-rtl.css
├── index.asset.php
├── index.css
├── index.js
├── style-index-rtl.css
├── style-index.css
├── view.asset.php
└── view.js

ビルドを実行すると、ファイルは圧縮され、最適化されます。

変更や修正が必要になったら、再度 npm start を実行して必要な編集を行い、終了したら control + c で開発を終了し、再度 npm run build でビルドを実行します。

plugin-zip

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

% npm run plugin-zip

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

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

Using Plugin Handbook best practices to discover files:

  Adding `my-insta-block.php`.
  Adding `readme.txt`.
  Adding `build/block.json`.
  Adding `build/index-rtl.css`.
  Adding `build/index.asset.php`.
  Adding `build/index.css`.
  Adding `build/index.js`.
  Adding `build/style-index-rtl.css`.
  Adding `build/style-index.css`.
  Adding `build/view.asset.php`.
  Adding `build/view.js`.

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

npm run plugin-zip を実行すると、レスポンスに表示されているファイルを含む zip ファイル(この例の場合は my-insta-block.zip)がプラグインのディレクトリに作成されます。

zip ファイルを使ってプラグインをインストール

作成された zip ファイルを使って、別のサイトでプラグインとしてインストールすることができます。

「新規プラグインを追加」をクリック

「プラグインをアップロード」をクリック

ファイルを選択して「今すぐインストール」をクリック

「プラグインを有効化」をクリック

プラグインが有効化されれば完了です。

上書きインストール

プラグインを変更後、再度ビルドしたものから作成した zip ファイルで上書きインストールすることもできます。

その場合、例えば、以下のように表示されるので、「アップロードしたもので現在のものを置き換える」をクリックして上書きインストールできます。

zip ファイルに src フォルダも含める

デフォルトでは npm run plugin-zip を実行すると src フォルダのファイルは含まれません。

もし、src フォルダのファイルも含めたい場合は、package.json に files フィールドの行を追加し、zip ファイルに含めるファイルやディレクトリを指定します。

以下は files フィールドに build と src ディレクトリ及び my-insta-block.php(プラグインファイル)を指定しています。

{
  "name": "my-insta-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": [ "build", "src", "my-insta-block.php" ],
  "devDependencies": {
    "@wordpress/scripts": "^27.9.0"
  }
}

上記のように package.json を変更して、npm run plugin-zip を実行すると、例えば以下のように src ディレクトリのファイルも zip ファイルに含まれます。

% npm run plugin-zip

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

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

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

  Adding `build/index-rtl.css`.
  Adding `build/index.css`.
  Adding `build/style-index-rtl.css`.
  Adding `build/style-index.css`.
  Adding `src/edit.js`.
  Adding `src/ig-acct.js`.
  Adding `build/index.js`.
  Adding `src/index.js`.
  Adding `src/save.js`.
  Adding `build/view.js`.
  Adding `src/view.js`.
  Adding `build/block.json`.
  Adding `src/block.json`.
  Adding `package.json`.
  Adding `build/index.asset.php`.
  Adding `my-insta-block.php`.
  Adding `src/render.php`.
  Adding `build/view.asset.php`.
  Adding `src/editor.scss`.
  Adding `src/style.scss`.
  Adding `readme.txt`.

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

view.js を使わない例

以下は、edit.js でグラフ API にアクセスして取得したデータを属性として保存して、そのデータをもとにフロントエンド側の出力を作成する例で、view.js を使いません。

また、必要に応じてインスペクターで Instagram ビジネスアカウント ID とアクセストークンを指定できるようにしています。

注意点としては、以下の場合、投稿を保存した時点でのデータが取得されて保存されるので、前述の例と異なり、表示される一覧は最新のものではありません(その時点で取得したデータが削除されている場合は、その部分は表示されないなど問題となる可能性があります)。

WP-Cron(cron ジョブ)を使えば更新できるかもしれませんが、試したことがありません。

また、以下では静的ブロックとして作成しているので、プラグインを無効化したり削除した場合に、「HTML として保存」を選択して HTML を残すと、CSS が適用されないためおかしな表示になってしまいます。

block.json

block.json では Instagram ビジネスアカウント ID とアクセストークン、API から取得したデータを保存する属性(accountId、accessToken、mediaData)を追加します。

この例では追加で読み込むボタンを表示しないので loadMoreText は削除します。

また、view.js は使わないので、"viewScript": "file:./view.js" の行は削除します。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl/my-insta-block",
  "version": "0.1.0",
  "title": "My Insta Block",
  "category": "widgets",
  "icon": "instagram",
  "description": "Instagram Timeline Block.",
  "example": {},
  "attributes": {
    "limit": {
      "type": "number",
      "default": 4
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "headingTag": {
      "type": "string",
      "default": "h3"
    },
    "apiVersion": {
      "type": "string",
      "default": "v20.0"
    },
    "accountId": {
      "type": "string",
      "default": ""
    },
    "accessToken": {
      "type": "string",
      "default": ""
    },
    "mediaData": {
      "type": "array",
      "default": []
    }
  },
  "supports": {
    "html": false
  },
  "textdomain": "my-insta-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css"
}

edit.js

この例では ビジネスアカウント ID とアクセストークンは、インスペクターで入力がなければ、デフォルトの値を使用するようにしています。

useEffect を使って、属性の limit, apiVersion, accountId, accessToken の値が変更された場合は、API にアクセスしてレスポンス instaResponse のデータ(.media.data)を属性 mediaData に代入しています。レスポンスがエラーであれば属性 mediaData を空の配列にします。

import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl } from "@wordpress/components";
import "./editor.scss";
import { useEffect } from "@wordpress/element";

export default function Edit({ attributes, setAttributes }) {

  // Instagram ビジネスアカウント ID(デフォルト値 xxxxxx を設定)
  const baid = attributes.accountId ? attributes.accountId : "xxxxxx";
  // アクセストークン(デフォルト値 xxxxxx を設定)
  const uat  = attributes.accessToken ? attributes.accessToken : "xxxxxx";

  //グラフAPI ホストURL(ルートエンドポイント)
  const api = "https://graph.facebook.com/";
  //API のバージョン
  const version = attributes.apiVersion;
  //表示件数
  const limit = attributes.limit;

  //取得するフィールド
  const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
  // fields パラメータ
  const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
  // パラメーターを表すオブジェクト(フィールドとトークン)
  const params = {
    fields,  // fields: fields, と同じこと
    access_token: uat,
  };
  // オブジェクト形式のパラメータをクエリ文字列に変換
  const query = new URLSearchParams(params);
  // fetch() に渡す URL の組み立て
  const url = `${api}/${version}/${baid}?${query}`;

  useEffect(() => {
    // fetch() でデータを取得
    fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`,
          );
        }
      })
      .then((instaResponse) => {
        // レスポンス instaResponse のデータを属性 mediaData に代入
        attributes.mediaData = instaResponse.media.data;
      })
      .catch((error) => {
        // エラーがあれば attributes.mediaData を空の配列(初期値)に
        attributes.mediaData = [];
        // コンソールにエラーを出力
        console.warn(error.message);
      });
  }, [attributes.limit, attributes.apiVersion, attributes.accountId, attributes.accessToken]);

  // インスペクターを出力する関数を定義
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            onChange={(value) => {
              setAttributes({ limit: parseInt(value) });
            }}
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => {
              setAttributes({ apiVersion: value });
            }}
          />
          <TextControl
            label="ビジネスアカウントID"
            value={attributes.accountId}
            onChange={(value) => {
              setAttributes({ accountId: value });
            }}
          />
          <TextControl
            label="アクセストークン"
            value={attributes.accessToken}
            onChange={(value) => {
              setAttributes({ accessToken: value });
            }}
          />
        </PanelBody>
      </InspectorControls>
    );
  };

  // インスペクターを出力する関数と表示する要素を配列で指定して返す
  return [
    getInspectorControls(),
    <div {...useBlockProps()}>
      <div className="my-insta-block">
        <h3>Instagram Timeline 表示ブロック</h3>
        <p>設定/変更はサイドバーのインスペクターで行います。</p>
      </div>
    </div>
  ];
}

エラーをエディター上に表示

上記の場合、無効なアカウント ID やアクセストークンが入力された場合、コンソールにしかエラーは表示されませんが、以下はエディター上にもエラーを表示する例です。

また、アカウント ID やアクセストークンを入力する際、上記の場合、1文字入力するごとにリクエストが発生してしまうので、以下では useDebounce を使って入力と入力の間に一定の時間が経過した場合にのみリクエストするようにしています。

この例では、useState を使って state 変数とその更新関数を定義し、useDebounce を使って更新関数に Debounce を適用して、その関数を onChange で使用しています。

import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { PanelBody, TextControl, SelectControl, __experimentalNumberControl as NumberControl } from "@wordpress/components";
import "./editor.scss";
// useState を追加でインポート
import { useEffect, useState  } from "@wordpress/element";
// useDebounce を追加
import { useDebounce } from '@wordpress/compose';
// useRef を追加でインポート
import { useRef } from "@wordpress/element";

export default function Edit({ attributes, setAttributes }) {

  // Instagram ビジネスアカウント ID
  const baid = attributes.accountId ? attributes.accountId : "xxxxxx";
  // アクセストークン
  const uat  = attributes.accessToken ? attributes.accessToken : "xxxxxx";

  //グラフAPI ホストURL(ルートエンドポイント)
  const api = "https://graph.facebook.com/";
  //API のバージョン
  const version = attributes.apiVersion;
  //表示件数
  const limit = attributes.limit;

  //取得するフィールド
  const field = "caption,media_url,thumbnail_url,permalink,like_count,comments_count,media_type";
  // fields パラメータ
  const fields = limit ? `media.limit(${limit}){${field}}` : `media{${field}}`;
  // パラメーターを表すオブジェクト(フィールドとトークン)
  const params = {
    fields,  // fields: fields, と同じこと
    access_token: uat,
  };
  // オブジェクト形式のパラメータをクエリ文字列に変換
  const query = new URLSearchParams(params);
  // fetch() に渡す URL の組み立て
  const url = `${api}/${version}/${baid}?${query}`;

  // 入力された表示件数を格納する state 変数(limitValue)とその更新関数(setLimitValue)
  const [limitValue, setLimitValue] = useState("");
  // setLimitValue に Debounce を適用した関数
  const debouncedSeLimitValue = useDebounce(setLimitValue, 600);
  // 以下上記と同様
  const [apiVersionValue, setApiVersionValue] = useState("");
  const debouncedSetApiVersionValue = useDebounce(setApiVersionValue, 800);
  const [accountIdValue, setAccountIdValue] = useState("");
  const debouncedSetAccountIdValue = useDebounce(setAccountIdValue, 800);
  const [accessTokenValue, setAccessTokenValue] = useState("");
  const debouncedSetAccessTokenValue = useDebounce(setAccessTokenValue, 800);

  // ref を宣言してエラー表示の要素に指定
  const errorRef = useRef(null);

  useEffect(() => {
    if(baid && uat) {
      // fetch() でデータを取得
      fetch(url)
      .then((response) => {
        // ステータスが ok であればレスポンスを JSON として解析
        if (response.ok) {
          return response.json();
        } else {
          // ステータスが ok でなければエラーにする
          throw new Error(
            `リクエスト失敗:${response.status} ${response.statusText}`,
          );
        }
      })
      .then((instaResponse) => {
        attributes.mediaData = instaResponse.media.data;
        errorRef.current.innerHTML = '';
      })
      .catch((error) => {
        attributes.mediaData = [];
        console.warn(error.message);
        // エラーがあればエディター上にも出力
        errorRef.current.innerHTML = `<span style="color:red">エラー: ${error.message}</span>`
      });
    }

  }, [limitValue, apiVersionValue, accountIdValue, accessTokenValue]);

  // インスペクターを出力する関数を定義
  const getInspectorControls = () => {
    return (
      <InspectorControls>
        <PanelBody title="表示設定">
          <NumberControl
            label="表示件数"
            min="1"
            max="36"
            value={attributes.limit}
            onChange={(value) => {
              setAttributes({ limit: parseInt(value) });
              // Debounce を適用した関数
              debouncedSeLimitValue( parseInt(value));
            }}
          />
          <TextControl
            label="見出しテキスト"
            value={attributes.headingText}
            onChange={(value) => setAttributes({ headingText: value })}
          />
          <SelectControl
            label="見出しタグ"
            value={attributes.headingTag}
            options={[
              { label: "h1", value: "h1" },
              { label: "h2", value: "h2" },
              { label: "h3", value: "h3" },
              { label: "h4", value: "h4" },
              { label: "h5", value: "h5" },
              { label: "h6", value: "h6" },
            ]}
            onChange={(value) => setAttributes({ headingTag: value })}
            __nextHasNoMarginBottom
          />
        </PanelBody>
        <PanelBody title="グラフ API 設定" initialOpen={false}>
          <TextControl
            label="API バージョン"
            value={attributes.apiVersion}
            onChange={(value) => {
              setAttributes({ apiVersion: value });
              debouncedSetApiVersionValue(value);
            }}
          />
          <TextControl
            label="ビジネスアカウントID"
            value={attributes.accountId}
            onChange={(value) => {
              setAttributes({ accountId: value });
              debouncedSetAccountIdValue(value);
            }}
          />
          <TextControl
            label="アクセストークン"
            value={attributes.accessToken}
            onChange={(value) => {
              setAttributes({ accessToken: value });
              debouncedSetAccessTokenValue(value);
            }}
          />
        </PanelBody>
      </InspectorControls>
    );
  };

  // インスペクターを出力する関数と表示する要素を配列で指定して返す
  return [
    getInspectorControls(),
    <div {...useBlockProps()}>
      <div className="my-insta-block">
        <h3>Instagram Timeline 表示ブロック</h3>
        <p>設定/変更はサイドバーのインスペクターで行います。</p>
        <p ref={errorRef}></p>
      </div>
    </div>
  ];
}

例えば、無効なアカウント ID を入力すると、以下のようにエラーが表示され、正しい値を入力するとエラーは消えます。

editor.scss / style.scss

editor.scss と style.scss は前述の例と同じです。環境に合わせて変更します。

.wp-block-wdl-my-insta-block {
  border: 1px solid #666;
  background-color: #eee;

  .my-insta-block {
    padding: 20px;
  }

  h3 {
    font-size: 24px;
  }

  p {
    color: #999;
  }
}
.insta-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  grid-auto-rows: minmax(100px, auto);
  gap: 20px;
}

.insta-media {
  position: relative;
}

.insta-media img {
  max-width: 100%;
  aspect-ratio: 1/1;
  object-fit: cover;
  display: block;
  width: 100%;
  height: auto;
}

.like-count,
.comments-count {
  position: absolute;
  bottom: 10px;
  color: #fff;
}

.like-count {
  right: 35%;
}

.comments-count {
  right: 10%;
}

.like-count::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  margin-right: 5px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath d='M8.864.046C7.908-.193 7.02.53 6.956 1.466c-.072 1.051-.23 2.016-.428 2.59-.125.36-.479 1.013-1.04 1.639-.557.623-1.282 1.178-2.131 1.41C2.685 7.288 2 7.87 2 8.72v4.001c0 .845.682 1.464 1.448 1.545 1.07.114 1.564.415 2.068.723l.048.03c.272.165.578.348.97.484.397.136.861.217 1.466.217h3.5c.937 0 1.599-.477 1.934-1.064a1.86 1.86 0 0 0 .254-.912c0-.152-.023-.312-.077-.464.201-.263.38-.578.488-.901.11-.33.172-.762.004-1.149.069-.13.12-.269.159-.403.077-.27.113-.568.113-.857 0-.288-.036-.585-.113-.856a2.144 2.144 0 0 0-.138-.362 1.9 1.9 0 0 0 .234-1.734c-.206-.592-.682-1.1-1.2-1.272-.847-.282-1.803-.276-2.516-.211a9.84 9.84 0 0 0-.443.05 9.365 9.365 0 0 0-.062-4.509A1.38 1.38 0 0 0 9.125.111L8.864.046zM11.5 14.721H8c-.51 0-.863-.069-1.14-.164-.281-.097-.506-.228-.776-.393l-.04-.024c-.555-.339-1.198-.731-2.49-.868-.333-.036-.554-.29-.554-.55V8.72c0-.254.226-.543.62-.65 1.095-.3 1.977-.996 2.614-1.708.635-.71 1.064-1.475 1.238-1.978.243-.7.407-1.768.482-2.85.025-.362.36-.594.667-.518l.262.066c.16.04.258.143.288.255a8.34 8.34 0 0 1-.145 4.725.5.5 0 0 0 .595.644l.003-.001.014-.003.058-.014a8.908 8.908 0 0 1 1.036-.157c.663-.06 1.457-.054 2.11.164.175.058.45.3.57.65.107.308.087.67-.266 1.022l-.353.353.353.354c.043.043.105.141.154.315.048.167.075.37.075.581 0 .212-.027.414-.075.582-.05.174-.111.272-.154.315l-.353.353.353.354c.047.047.109.177.005.488a2.224 2.224 0 0 1-.505.805l-.353.353.353.354c.006.005.041.05.041.17a.866.866 0 0 1-.121.416c-.165.288-.503.56-1.066.56z'/%3E%3C/svg%3E");
}

.comments-count::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  margin-right: 5px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath d='M2.678 11.894a1 1 0 0 1 .287.801 10.97 10.97 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 .71-.074A8.06 8.06 0 0 0 8 14c3.996 0 7-2.807 7-6 0-3.192-3.004-6-7-6S1 4.808 1 8c0 1.468.617 2.83 1.678 3.894zm-.493 3.905a21.682 21.682 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a9.68 9.68 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9.06 9.06 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105z'/%3E%3C/svg%3E");
}

.video-icon {
  position: absolute;
  top: 3px;
  right: 5px;
}

.video-icon::before {
  content: "";
  display: inline-block;
  height: 18px;
  width: 18px;
  vertical-align: -3px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ffffff' viewBox='0 0 16 16'%3E  %3Cpath fill-rule='evenodd' d='M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2V5zm11.5 5.175 3.5 1.556V4.269l-3.5 1.556v4.35zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H2z'/%3E%3C/svg%3E");
}

.insta-icon::before {
  content: "";
  display: inline-block;
  height: 30px;
  width: 30px;
  vertical-align: -6px;
  background-repeat: no-repeat;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23a41867' viewBox='0 0 448 512'%3E%3Cpath d='M194.4 211.7a53.3 53.3 0 1 0 59.3 88.7 53.3 53.3 0 1 0 -59.3-88.7zm142.3-68.4c-5.2-5.2-11.5-9.3-18.4-12c-18.1-7.1-57.6-6.8-83.1-6.5c-4.1 0-7.9 .1-11.2 .1c-3.3 0-7.2 0-11.4-.1c-25.5-.3-64.8-.7-82.9 6.5c-6.9 2.7-13.1 6.8-18.4 12s-9.3 11.5-12 18.4c-7.1 18.1-6.7 57.7-6.5 83.2c0 4.1 .1 7.9 .1 11.1s0 7-.1 11.1c-.2 25.5-.6 65.1 6.5 83.2c2.7 6.9 6.8 13.1 12 18.4s11.5 9.3 18.4 12c18.1 7.1 57.6 6.8 83.1 6.5c4.1 0 7.9-.1 11.2-.1c3.3 0 7.2 0 11.4 .1c25.5 .3 64.8 .7 82.9-6.5c6.9-2.7 13.1-6.8 18.4-12s9.3-11.5 12-18.4c7.2-18 6.8-57.4 6.5-83c0-4.2-.1-8.1-.1-11.4s0-7.1 .1-11.4c.3-25.5 .7-64.9-6.5-83l0 0c-2.7-6.9-6.8-13.1-12-18.4zm-67.1 44.5A82 82 0 1 1 178.4 324.2a82 82 0 1 1 91.1-136.4zm29.2-1.3c-3.1-2.1-5.6-5.1-7.1-8.6s-1.8-7.3-1.1-11.1s2.6-7.1 5.2-9.8s6.1-4.5 9.8-5.2s7.6-.4 11.1 1.1s6.5 3.9 8.6 7s3.2 6.8 3.2 10.6c0 2.5-.5 5-1.4 7.3s-2.4 4.4-4.1 6.2s-3.9 3.2-6.2 4.2s-4.8 1.5-7.3 1.5l0 0c-3.8 0-7.5-1.1-10.6-3.2zM448 96c0-35.3-28.7-64-64-64H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96zM357 389c-18.7 18.7-41.4 24.6-67 25.9c-26.4 1.5-105.6 1.5-132 0c-25.6-1.3-48.3-7.2-67-25.9s-24.6-41.4-25.8-67c-1.5-26.4-1.5-105.6 0-132c1.3-25.6 7.1-48.3 25.8-67s41.5-24.6 67-25.8c26.4-1.5 105.6-1.5 132 0c25.6 1.3 48.3 7.1 67 25.8s24.6 41.4 25.8 67c1.5 26.3 1.5 105.4 0 131.9c-1.3 25.6-7.1 48.3-25.8 67z'/%3E%3C/svg%3E");
  margin-right: 5px;
}

h3.insta-icon::before {
  height: 26px;
  width: 26px;
}

h4.insta-icon::before {
  height: 24px;
  width: 24px;
  vertical-align: -5px;
}

h5.insta-icon::before {
  height: 23px;
  width: 23px;
  vertical-align: -5px;
}

h6.insta-icon::before {
  height: 22px;
  width: 22px;
  vertical-align: -5px;
}

.insta-media {
  animation: fadeInThumbs .6s;
}

@keyframes fadeInThumbs {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

#load-more-insta-feeds {
  background-color: #111;
  padding: 10px 20px;
  color: #fff;
  margin: 30px 0;
  cursor: pointer;
  border: none;
  transition: background-color .3s;
}

#load-more-insta-feeds:hover {
  background-color: #444;
}

save.js

以下が save.js です。

edit.js で API から取得したデータは属性 mediaData に配列で格納されているので、map() を使って一覧表示の JSX を作成しています。

JSX の記述で map() などを使ったループ処理で繰り返し生成される要素には key 属性を設定する必要があるので、データの id プロパティをその値に指定しています。

また、見出しの JSX は、React.createElement() を使っています(React. は省略可能)。

そして、return では属性 mediaData が空の配列でなければ(データが取得されていれば)一覧と見出しを表示しています。データが空であれば何も表示されません。

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

export default function save({ attributes }) {

  // 見出しのテキスト
  const headingText =  attributes.headingText;
  // 見出しのタグ
  const headingTag = attributes.headingTag;

  let heading;
  // 見出しの JSX を作成
  if(headingText && headingTag) {
    heading =
      React.createElement(
        headingTag,
        null,
        headingText
    );
  }

  // 属性 mediaData(API から取得したデータ)から一覧表示の JSX を作成
  const instaFeeds = attributes.mediaData.map((data) => (
    <div key={data.id} className="insta-media">
      <a href={data.permalink} target="_blank" rel="noopener">
        <img
        src={
          data.media_type === "VIDEO"
              ? data.thumbnail_url
              : data.media_url
          }
          alt={
            data.caption ? data.caption : ""
          }
        />
        {data.media_type === "VIDEO" ? <span className="video-icon"></span> : '' }
        <span className="like-count">{data.like_count}</span>
        <span className="comments-count">{data.comments_count}</span>
      </a>
    </div>
  ));

  return (
      <>
        { attributes.mediaData.length > 0 &&
          <div { ...useBlockProps.save() }>
            <div className="insta-wrapper">
              { heading }
              <div className="insta-container">
                { instaFeeds }
              </div>
            </div>
          </div>
        }
      </>
  );
}

編集画面でコードエディターに切り替えると、以下のように属性 mediaData にデータの配列が、また JSX により出力される HTML が保存されているのが確認できます。

※ プラグインを無効化したり削除した場合に、HTML として保存することができますが、その場合、CSS が適用されずおかしな表示になってしまいます。