Blog

ブログ

WP REST APIを使ったAstroサイトの作り方

前田 大地

先日、Javascriptマスターと会話していたら「アイランドなんちゃら」みたいな単語が出たので、後でこっそり調べたところ、「Astro」にたどり着きました。試しにWordPressで構築してある当サイトをAstroでヘッドレス化してみることに。

事前知識ゼロだったので、公式に掲載されているチュートリアルを律儀にこなし、雰囲気をつかんでからいざスタート!

AstroによるWordPressのヘッドレス化をネットで調べても、シンプルなブログサイトに関する情報が多く、いかんせん参考にできる具体的なコードが少なくて苦労しました。せっかくなので、備忘録としてAstroサイトのルーティングまわりとWP REST APIの活用方法について残しておきたいと思います。

目次

実際に作ってみたサイト

当サイト(WordPress)をAstroで(ほぼ)完コピしました。

元サイト:https://www.6666666.jp
Astroサイト:https://astro.6666666.jp

おわび

今回は、稼働中のWordPressサイトを稼働させたままの状態で、別のサブドメインにAstroサイトを構築しています。通常、WordPress側のサイトアドレス設定をAstroで出力したサイトのURLに合わせると思うんですけど、そうなっていません。

そのため、WP REST APIで取得したデータには、元のWordPressサイトのURLが多々含まれています。例えば、ブログ記事内から別の記事へリンクを貼ってある箇所などは、リンク先のURLが元のWordPressサイトのものになっています。

また、Astroサイトで使用しているCSSやJS、画像など静的なリソースはほぼ元サイトに直リンクでそのまま使っています。

そのへんを踏まえた上で、記事をご覧いただければなあと思います。

おさらい

解説をはじめる前に、基本的なことをいくつかおさらいしておきます。

WordPressとは?

みんな知ってると思いますが、WordPressはPHPで作られた人気のCMSで、世の中の多くのブログやWebサイトがWordPressで作られています。使いやすい管理画面や、プラグインによる機能拡張、テーマによる表示のカスタマイズなどが特長です。一般的な企業サイトなら、たいてい制作できます。

https://wordpress.org/

ヘッドレスWordPressとは?

ざっくり言うと、WordPressのフロント(公開される)部分を、別のなにかで実装することです。WordPressにはもともと、コンテンツを管理する機能と、それを出力するテーマ機能がセットになっていますが、このテーマ機能の部分をWP REST APIなどを利用して実装したものがヘッドレスWordPressと呼ばれます。

WP REST APIとは?

WP REST APIは、WordPressの外部からデータを取得できる仕組みです。ヘッドレス化する場合、WP REST API経由でサイトの情報を取得して、それをもとにフロントを構築します。また、WordPressの内部でも、ブロックエディタとかはWP REST APIが使われています。

https://developer.wordpress.org/rest-api/

Astroとは?

Astroは、Webサイトを構築するためのフレームワークです。ReactやVueなど、多くのフレームワーク(ライブラリ?)はWebアプリケーションの構築に特化していますが、AstroはWebサイトの構築に特化しています。つまり、WordPressで作るようなWebサイトにピッタリです。

https://astro.build/

なぜWordPressをヘッドレス化するの?

WordPressサイトの最大のネックは、表示速度の遅さです。ページにアクセスすると、裏側で一生懸命処理をしてからHTMLを吐き出しているので、どうしても表示が遅くなります。Astroだと、あらかじめHTMLを生成しておけるので、裏側の処理がなくなり、表示速度が格段にアップします。また、WordPress最大のメリットである管理画面の使いやすさもそのまま引き継げます。

ヘッドレス化のデメリットは?

普通にWordPress単体でWebサイトを作るよりもいろいろな面でコストがかかります。コストに見合えば良いと思いますが、私が普段手掛けているような規模の小さい企業サイトとかだったら、普通にWordPressで良いような気がします。

じゃあなんでヘッドレス化したの?

どんなものか、いちどやってみたかったからです。あと、WordPressと全く同じ内容のAstroサイトを作って両者を比較してみたかったのもあります。

Astroサイトのルーティング前編

Astroでは、src/pagesディレクトリ内のファイル階層に合わせてページが生成されます。

今回は、WordPressサイトの完コピを目指しているので、既存のWordPressと同じ構造になるよう、pagesディレクトリ内にファイルを作っていきます。

まずは細かいことは置いといて、必要となるファイルとディレクトリを、中身は空の状態で作成していきます。

固定ページ

WordPressの固定ページは、そのまま同名のディレクトリを作ってその直下にindex.astroファイルを置けばOKです。トップページは、pages直下にindex.astroを置きます。

例えば、親ページのslugが「color」で、その下に「red」「blue」「yellow」という子ページがあったとします。この場合、

  • pages/
    • color/
      • index.astro
      • blue/
        • index.astro
      • red/
        • index.astro
      • yellow/
        • index.astro

と作ってもいいですし、

  • pages/
    • color/
      • index.astro
      • blue.astro
      • red.astro
      • yellow.astro

と作ってもOKです。どちらも出力されるファイルは同じになるので、自分が管理しやすいように作ればいいと思います。

投稿ページ(ブログホーム)

WordPressでいうところの投稿ページ(ブログホーム、home.phpが適用されるページ)は、すべての記事一覧を表示するページです。こちらは、固定ページに比べるとちょっとだけ複雑です。なぜなら、記事数が増えたときに「2ページ目」「3ページ目」と、ページが増えていくからです。

WordPressの場合、2ページ目以降は、1ページ目のURLの末尾に「page/ページ番号/」が付与されます。例えばブログホームが「/blog/」だったら、2ページ目は「/blog/page/2/」です。

今回、元になるWordPressサイトのブログホームのスラッグは「blog」なので、作成するAstroのファイルは以下のとおりです。

  • pages/
    • blog/
      • index.astro
      • page/
        • [paged].astro

ファイル名やディレクトリ名を角カッコで囲った文字列にすると、その部分を動的に変化させることができます(動的ルーティングと言います)。上記の「[paged].astro」のケースでは、[paged]のところにページ番号が入ることになります。

※角カッコの部分に具体的に何が入るかは、ファイル内で定義する必要があります。が、今は一旦置いておいて、空のファイルだけ作ります。

※Astroには、自動でページを分割してくれるページネーション機能が組み込まれていますが、今回は使いません。この件についても後ほど説明します。

個別投稿ページ

元サイトの個別投稿ページのURLは、スラッグを使用しています。ちょこっとカスタマイズしてあって、記事スラッグの前に「blog/post/」が付いています。

  • /blog/post/記事のスラッグ/

これをAstroで出力するには、先ほど登場した動的ルーティングを使って、以下のようになります。

  • pages/
    • blog/
      • post/
        • [slug].astro

幸いなことに、個別記事をさらにページ分割するような使い方はしていませんので、これで完了です。

カテゴリアーカイブ

ブログ記事はカテゴリーで分類されています。カテゴリーごとにアーカイブページが存在して、さらに投稿数が増えると「2ページ目」「3ページ目」とページが増えていきます。元となるWordPressサイトだと以下のようなURLになります。

  • /blog/category/カテゴリスラッグ/
  • /blog/category/カテゴリスラッグ/page/ページ番号/

上記を、Astro上で出力したいので、

  • pages/
    • blog/
      • category/
        • [category]/
          • index.astro
          • page/
            • [paged].astro

上記のようにディレクトリとファイルを追加します。だんだんややこしくなってまいりました。

カスタム投稿タイプアーカイブ

カスタム投稿タイプのアーカイブページも、基本的には投稿ページ(ブログホーム)と同じ要領でOKです。元サイトでは、worksというカスタム投稿タイプを追加していますので、WordPress上のURLは以下のとおりです。

  • /works/
  • /works/page/ページ番号/

これをAstroで出力するには、

  • pages/
    • works/
      • index.astro
      • page/
        • [paged].astro

こうなります。ブログホームと同じ感じですね。

カスタム投稿タイプの個別記事

カスタム投稿タイプ「works」の個別記事ページのURLは、元サイトだと以下の通りです。

  • /works/記事のスラッグ/

なので、Astroだとこうなります。

  • pages/
    • works/
      • [slug].astro

これもブログと同様、個別記事をページ分割してはいないので、これで完了です。

カスタム分類アーカイブ

これも考え方としてはカテゴリーと同じです。元サイトでは、投稿タイプ「works」に対して、カスタム分類「works_category」が設定できるようになっています。で、アーカイブページのURLは以下のようになっています。

  • /works/category/分類のスラッグ/
  • /works/category/分類のスラッグ/page/ページ番号/

Astroだとこうなります。

  • pages/
    • works
      • category
        • [category]/
          • index.astro
          • page/
            • [paged].astro

動的なやつが2つ以上入ると、やっぱりややこしいですね。

特殊なページ

元サイトには、カスタム投稿タイプの記事をさらにカスタムフィールドの値で絞り込んで表示する、ちょっと特殊なアーカイブページも存在しています。アーカイブなので2ページ目以降も存在します。元サイトのURLは以下の通りです。

  • /works-recommend/
  • /works-recommend/page/ページ番号/

Astroだと以下のようになります。

  • pages/
    • works-recommend/
      • index.astro
      • page/
        • [paged].astro

「カスタムフィールドの値で絞り込んだ記事」というのが、後々やっかいなことになりそうですが、この段階ではシンプルですね。

Astroのページまとめ

というわけで、これまでの内容をまとめるとAstroは以下のようになります。ややこしくなるので、固定ページはTOPページのみ掲載します。

  • pages/
    • index.astro
    • blog/
      • index.astro
      • page/
        • [paged].astro
      • post/
        • [slug].astro
      • category/
        • [category]
          • index.astro
          • page/
            • [paged].astro
    • works/
      • index.astro
      • page/
        • [paged].astro
      • [slug].astro
      • category/
        • [category]
          • index.astro
          • page/
            • [paged].astro
    • works-recommend/
      • index.astro
      • page/
        • [paged].astro

とりあえず、これで全てのページの準備が整いました!やったー

Astroサイトのルーティング後編

ルーティングに必要なすべてのastroファイルとディレクトリをpages配下に作りました。次は、動的ルーティングに必要なgetStaticPathsを設定していきます。

getStaticPathsとは?

Astroは、ビルド時にすべてのページが出力されます。そのため、例えば「[slug].astro」というファイルがあった場合、この[slug]部分に入るものをすべて伝えなければそのページを生成してくれません。生成対象となるパスを指定するために使うのが、getStaticPaths関数です。

.envファイルの準備

プロジェクトディレクトリの直下に.envという名前のファイルを作って、WP REST APIに使うwp-jsonまでのURLを記載しておきます。これを書いておくと、別のプロジェクトでコードを使い回そうと思ったときに、URL部分をいちいち書き直さなくてすみます。

API_URL = https://WordPressアドレス/wp-json

投稿ページ(ブログホーム)の2ページ目以降

pages/blog/page/[paged].astro

---
// ルート生成
export async function getStaticPaths() {
  const perPage = 12; // 1ページあたりの表示数
  const result = []; // 返却するやつ
  
  // 総ページ数を取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=${perPage}`);
  const total = res.headers.get('x-wp-totalpages');
  
  // 2ページ目以降があれば処理
  if(Number(total) > 1){
    for(let i = 2; i <= Number(total); i++){
      const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=${perPage}&page=${i}`);
      const posts = await res.json();
      result.push( { params: { paged: i }, props: { posts: posts, total: total } } );
    }
  }
  return result;
}

// 各ページで使うデータ
const { paged } = Astro.params;
const { posts, total } = Astro.props;
---

いきなり説明が難しくなりました。

paramsとprops

getStaticPaths関数は、動的ルートのパスと、そのパスのとき使うデータをセットにして渡せます。動的ルートで使うパスはparamsに入れて、そのページで使用するデータはpropsに入れます。

先ほどのコードから該当箇所を抜粋したものが以下です。

{ params: { paged: i }, props: { posts: posts, total: total } }

paramsには、ファイル名の角カッコ内に使用した文字列に対して、どんな値が入るのかを指定します。今回のケースでは、pagedにはページ番号が入ります。

propsには、そのページで使用するデータを入れます。今回のケースでは、postsにそのページで表示する投稿のデータを入れてあります(例えば、2ページ目だったら、13〜24件目の記事が入っています)。totalは、総ページ数です。totalは、あとでページ送りを実装するときに使います。

paramsとpropsをセットにしたオブジェクトを、生成するページの数だけ配列にして返せばOKです。

paramsとpropsの受け取り

// 各ページで使うデータ
const { paged } = Astro.params;
const { posts, total } = Astro.props;

上記の部分は、個々のページを生成するときに、getStaticPaths関数で返したデータを受取るための記述です。ここで受け取ったデータをもとに、記事一覧ページを作ります。

Astro標準のページネーション機能について

Astroには、標準でページ分割してくれる機能が備わっています。それを使えば「1ページあたり10件ずつ表示」みたいなことが簡単にできます。

ですが、今回は使いません。なぜなら、もし今回のケースで使用したら1ページ目のパスが「blog/page/1/」となってしまうからです。今回はWordPressサイトの完コピなので、1ページ目を「blog/」にするため、Astroのページネーション機能は使いませんでした。

個別投稿ページ

pages/blog/post/[slug].astro

---
// ルート生成
export async function getStaticPaths() {

  // 最初の100件取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=100`);
  let posts = await res.json();
  const total = Number(res.headers.get('x-wp-totalpages'));
  
  // 101件目以降があれば取得
  if(total > 1) {
    for(let i = 2; i <= total; i++) {
      const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=100&page=${i}`);
      const morePosts = await res.json();
      posts = posts.concat(morePosts);
    }
  }
  return posts.map((post:any)=>{
    return {
      params: { slug: post.slug },
      props: { post: post },
    }
  });
}

// 各ページで使うデータ
const { slug } = Astro.params;
const { post } = Astro.props;
---

個別投稿ページに必要なのは、全ての記事のデータです。WP REST APIは、最大で100件しか記事が取得できないので、101件目以降の記事があれば追加で取得させます。件数が多いととんでもないデータ量になりますね。

記事を全件取得するため、WP REST APIに独自のエンドポイントを新規で追加する方法もあります。既存のエンドポイントだと、どうしても余計な情報が大半を占めてしまうので。ただし今回は、可能な限り標準のエンドポイントを使う方針で進めます。

カテゴリアーカイブの1ページ目

pages/blog/category/[category]/index.astro

---
// ルート生成
export async function getStaticPaths() {
  const perPage = 12; // 1ページあたりの表示数
  const result = []; //返却するやつ

  // すべてのカテゴリ取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/categories?per_page=100`);
  const categories = await res.json();
  
  // カテゴリごとに処理
  for(const cat of categories) {
    const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=${perPage}&categories=${cat.id}`);
    const posts = await res.json();
    const total = Number(res.headers.get('x-wp-totalpages'));
    result.push({ params: { category: cat.slug }, props: { posts: posts, total: total } });
  }
  return result;
}

// 各ページで使うデータ
const { category } = Astro.params;
const { posts, total } = Astro.props;
---

すべてのカテゴリを取得して、カテゴリごとに返却する値を処理します。元サイトは、カテゴリ数が少なかったので、1回ですべてのカテゴリを取得できましたが、もしカテゴリ数が多い場合は追加で読み込む処理を追記しましょう。また、記事が1件もないカテゴリを除外したい場合は「hide_empty」パラメータが使えます。

今回、すべてのアーカイブページにページ送りを実装する必要があるので、カテゴリごとの総ページ数もpropsに渡しておきます。

カテゴリアーカイブの2ページ目以降

pages/blog/category/[category]/page/[paged].astro

---
// ルート生成
export async function getStaticPaths() {
  const perPage = 12; // 1ページあたりの表示数
  const result = []; // 返却するやつ

  // すべてのカテゴリ取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/categories?per_page=100`);
  const categories = await res.json();

  // カテゴリごとに処理
  for( const cat of categories ){
    // 総ページ数を取得
    const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=${perPage}&categories=${cat.id}`);
    const total = Number(res.headers.get('x-wp-totalpages'));

    // 2ページ目以降があれば処理
    if(total>1){
      for(let i = 2; i <= total; i++){
        const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=${perPage}&categories=${cat.id}&page=${i}`);
        const posts = await res.json();
        result.push( { params: { category: cat.slug, paged: i }, props: { posts: posts, total: total } } );
      }
    }
  }
  return result;
}

// 各ページで使うデータ
const { category, paged } = Astro.params;
const { posts, total } = Astro.props;
---

カテゴリアーカイブの2ページ目以降には、動的ルートのための角カッコが2つ登場します。なので、すべての組み合わせを用意しなければなりません。getStaticPaths関数で返却するparamsも、category(カテゴリのスラッグ)とpaged(ページ番号)の2つです。

カスタム投稿アーカイブの2ページ目以降

pages/works/page/[paged].astro

---
// ルート生成
export async function getStaticPaths() {
  const perPage = 24; // 1ページあたりの表示数
  const result = [];//返却するやつ

  // 総ページ数の取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=${perPage}`);
  const total = Number(res.headers.get('x-wp-totalpages'));
  
  // 2ページ目以降があれば処理
  if(total > 1){
    for(let i = 2; i <= total; i++){
      const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=${perPage}&acf_format=standard&page=${i}`);
      const posts = await res.json();
      result.push( { params: { paged: i }, props: { posts: posts, total: total } } );
    }
  }
  return result;
}

// 各ページで使うデータ
const { paged } = Astro.params;
const { posts, total } = Astro.props;
---

似たようなコードばかりで書くのが面倒になってまいりました。カスタム投稿タイプの情報をWP REST APIで取得するには「posts」だったところを「投稿タイプ名」に変えればOKです。

ACFのデータを取得するときの注意点

カスタムフィールドを追加できるACF(Advanced Custom Fieldsプラグイン)は、WP REST APIに対応しています。ACFの管理画面からカスタムフィールドを作成する際、REST APIのオプションを有効にすることで、カスタムフィールドの名前と値がレスポンスに追加されます。

また、カスタムフィールドの値を受け取る際には、パラメータに「acf_format=standard」を足さないと、ACFの完全な情報が返ってきませんのでご注意ください。

ACF公式のWP REST APIページ

カスタム分類アーカイブの1ページ目

pages/works/category/[category]/index.astro

---
// ルート生成
export async function getStaticPaths() {
  const perPage = 24; // 1ページあたりの表示数
  const result = [];//返却するやつ

  // すべてのカテゴリ取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works_category?per_page=100`);
  const categories = await res.json();

  // カテゴリごとに処理
  for(const cat of categories) {
    const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=${perPage}&works_category=${cat.id}&acf_format=standard`);
    const posts = await res.json();
    const total = Number(res.headers.get('x-wp-totalpages'));
    result.push({ params: { category: cat.slug }, props: { posts: posts, total: total } });
  }
  return result;
}

// 各ページで使うデータ
const { category } = Astro.params;
const { posts, total } = Astro.props;
---

カスタム分類の情報をWP REST APIで取得するには「categories」だったところを「カスタム分類名」に変えればOKです。あとは、カテゴリページとだいたい似たような感じです。

カスタム分類アーカイブの2ページ目以降

pages/works/category/[category]/page/[paged].astro

---
// ルート生成
export async function getStaticPaths() {
  const perPage = 24; // 1ページあたりの表示数
  const result = [];//返却するやつ
  
  // すべてのカテゴリ取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works_category?per_page=100`);
  const categories = await res.json();

  // カテゴリごとに処理
  for( const cat of categories ){
		// 総ページ数を取得
    const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=${perPage}&works_category=${cat.id}`);
    const total = Number(res.headers.get('x-wp-totalpages'));
    // 2ページ目以降があれば処理
    if(total>1){
      for(let i = 2; i <= total; i++){
        const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=${perPage}&works_category=${cat.id}&page=${i}&acf_format=standard`);
        const posts = await res.json();
        result.push( { params: { category: cat.slug, paged: i }, props: { posts: posts, total: total } } );
      }
    }
  }
  return result;
}

// 各ページで使うデータ
const { category, paged } = Astro.params;
const { posts, total } = Astro.props;
---

これもカテゴリアーカイブの2ページ目以降とだいたいおんなじかんじでごぜえやす。

カスタム投稿タイプの個別記事ページ

pages/works/[slug].astro

---
// ルート生成
export async function getStaticPaths() {

  // 最初の100件取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=100&acf_format=standard`);
  let posts = await res.json();
  const total = Number(res.headers.get('x-wp-totalpages'));
  
  // 101件目以降があれば取得
  if(Number(total) > 1) {
    for(let i = 2; i <= total; i++) {
      const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=100&page=${i}&acf_format=standard`);
      const morePosts = await res.json();
      posts = posts.concat(morePosts);
    }
  }

  const publicPosts = []; //公開するページ

  // 公開するページをフィルタリング
  for(const post of posts){
    if( post.acf.voice === true || post.acf.imgs !== false ) {
      publicPosts.push(post);
    }
  }

  return publicPosts.map((post:any)=>{
    return {
      params: { slug: post.slug },
      props: { post: post },
    }
  });

}

// 各ページで使うデータ
const { slug } = Astro.params;
const { post } = Astro.props;
---

途中までは、個別投稿ページと同じような感じです。

今回、元サイトでのカスタム投稿「works」は、一覧ページにだけ表示するものと、ちゃんと個別ページを作るものの2種類が混在しています。個別ページを作るかどうかは、カスタムフィールドの値で判断しています。なので、まずは一旦全件を取得した後、個別ページを作るものだけ抽出する処理をはさんでいます。

特殊なページの2ページ目以降

pages/works-recommend/page/[paged].astro

---
// ルート生成
export async function getStaticPaths() {
  const perPage = 12; // 1ページあたりの表示数
  const result = []; // 返却するやつ

  // 総ページ数を取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=${perPage}&voice=1`);
  const total = Number(res.headers.get('x-wp-totalpages'));
  
  // 2ページ目以降があれば処理
  if(total > 1){
    for(let i = 2; i <= total; i++){
      const res = await fetch(`${import.meta.env.API_URL}/wp/v2/works?per_page=${perPage}&voice=1&acf_format=standard&page=${i}`);
      const posts = await res.json();
      const total = res.headers.get('x-wp-totalpages');
      result.push( {
        params: { paged: i },
        props: { posts: posts, total: total },
      } );
    }
  }
  return result;
}

// 各ページで使うデータ
const { paged } = Astro.params;
const { posts, total } = Astro.props;
---

このページは、カスタム投稿タイプ「works」の中からカスタムフィールド「voice」の値が「1」の記事だけを抽出したアーカイブページです。これを実現するには、ひと手間必要です。

カスタムフィールドの値で記事をフィルタリング

WP REST APIは、そのままだとカスタムフィールドの値で記事をフィルタリングすることができません(もしできたらごめんなさい)。なので、WordPressのフィルターフック「rest_xxxxx_query」を利用して、記事を絞り込むためのパラメータを渡せるようにカスタマイズします。

functions.phpに以下を追記します。

// worksをカスタムフィールド「voice」の値で絞り込めるようにする
add_filter('rest_works_query', function ($args, $request) {
  if ($request['voice']) {
    $args['meta_query'][] = [
      'key' => 'voice',
      'value' => esc_sql($request['voice']),
    ];
  }
  return $args;
}, 10, 2);

これで、WP REST APIの「works」エンドポイントにパラメータ「voice=1」を渡すと、カスタムフィールド「voice」の値が「1」の記事だけ取得してくれるようになりました。

せっかくなので、上記のコードを解説したいと思います。

add_filter('rest_カスタム投稿タイプ名_query', function ($args, $request) {
  if ($request['受け取るパラメータのキー']) {
    $args['meta_query'][] = [
      'key' => 'カスタムフィールド名',
      'value' => esc_sql($request['受け取るパラメータのキー']),
    ];
  }
  return $args;
}, 10, 2);

と、思ったけど、説明するのが難しいので、同じようなことをやりたい人は、上記のカタカナの部分をなんかうまくやってもらえればいいかと思います。てきとうですまん。

全てのページが出力された

ここまでで、とりあえず全てのページがビルド時にhtmlとして出力されるようになりました。まだ中身は何もない真っ白なページですけどね。やったー

WP-PagenaviをAstroで再現する(ページネーションの実装)

元サイトでは、WP-PageNaviというプラグインを使ってページネーションを実装していました。これをAstroサイトで再現するには、自分で作るしかありません。仕方ないので、完コピのためにも自作したいと思います。

WP-PageNaviで出力されるHTML

<div class='wp-pagenavi' role='navigation'>
	<span class='pages'> /16</span>
	<a class="previouspostslink" rel="prev" aria-label="前のページ" href="https://www.6666666.jp/blog/page/5/">PREV</a>
	<span class='extend'>…</span>
	<a class="page smaller" title="ページ 2" href="https://www.6666666.jp/blog/page/2/">2</a>
	<a class="page smaller" title="ページ 3" href="https://www.6666666.jp/blog/page/3/">3</a>
	<a class="page smaller" title="ページ 4" href="https://www.6666666.jp/blog/page/4/">4</a>
	<a class="page smaller" title="ページ 5" href="https://www.6666666.jp/blog/page/5/">5</a>
	<span aria-current='page' class='current'>6</span><a class="page larger" title="ページ 7" href="https://www.6666666.jp/blog/page/7/">7</a>
	<a class="page larger" title="ページ 8" href="https://www.6666666.jp/blog/page/8/">8</a>
	<a class="page larger" title="ページ 9" href="https://www.6666666.jp/blog/page/9/">9</a>
	<a class="page larger" title="ページ 10" href="https://www.6666666.jp/blog/page/10/">10</a>
	<a class="page larger" title="ページ 11" href="https://www.6666666.jp/blog/page/11/">11</a>
	<span class='extend'>…</span>
	<a class="nextpostslink" rel="next" aria-label="次のページ" href="https://www.6666666.jp/blog/page/7/">NEXT</a>
</div>

上記は、元サイトのWP-PageNaviで出力されているHTMLです。項目ごと見やすいように改行を入れました。WP-PageNavi側の設定に合わせて出力されるコードが若干変わりますので、参考にされる場合はご注意ください。

これをCSSでスタイリングしたものが下記の画像です。PCとスマホで表示を変えています。

仕様を考える

そもそも、ページネーションを自作した経験がないので、手掛かりになるのは元サイトの挙動だけです。元サイトのCSSを流用する関係で、スタイリングに使用したclass名などはそのまま残して同じようなHTMLを生成させます。元サイトの挙動を見ながら仕様をまとめてみます。

  • 総ページ数を出力
  • 前のページがあれば、前のページへのリンクを出力
  • 次のページがあれば、次のページへのリンクを出力
  • 各ページを表す数字は最大10個出力、現在のページ番号の前に4つ、後ろに5つ
  • 前後のページ数が少ない場合は、合計10個になるように数を調整する(例えば、現在2ページ目だったら前に1ページしかないので、合計10個になるよう後ろの数を8個にする)
  • 前後にさらにページがある場合は「…」を出力
  • 現在のページ番号には、aタグではなくspanを使う(自身のページにリンクを貼らない)
  • 1ページ目のリンク先URLには「page/ページ番号/」がつかない
  • 総ページ数が1ページだけのときはページネーションを出力しない

この時点で、ノンプログラマな私は頭痛がします。でも、やればきっと出来る、、はず。

完成したコード

一生懸命考えた後に出来上がったのが以下のコードです。コンポーネントとして別ファイルで保存します。

---
const { page, total, base } = Astro.props;

// 現在のページ
const p = Number(page);

// 総ページ数
const t = Number(total);

// 前のページ
const prev = p - 1;
const prevUrl = (()=>{
  if(prev){
    return prev === 1 ? base : base + `page/${prev}/`;
  }
  else {
    return '';
  }
})();

// 次のページ
const next = p + 1;
const nextUrl = (()=>{
  if(next <= t){
    return base + `page/${next}/`;
  }
  else {
    return '';
  }
})();


let html = ''; //出力するHTML
const max = 10; //表示する最大個数
const before = 4; //前に表示する個数
let count = 0; //個数カウント用

// 判定開始ページ
const start = Math.min(t - (max - 1), p - before);

if(start > 1) {
  html = html.concat('<span class="extend">…</span>');
}

for ( let i = start; i <= t; i++ ) {
  if( i < 1) continue;
  count ++;
  if(count > max) {
    html = html.concat('<span class="extend">…</span>');
    break;
  }
  if( i === p ){
    const h = '<span aria-current="page" class="current">' + i + '</span>';
    html = html.concat(h);
  }
  else {
    const url = i === 1 ? base : base + 'page/' + i + '/';
    const h = '<a class="page" title="ページ' + i + '" href="' + url + '">' + i + '</a>';
    html = html.concat(h);
  }
}

---
{ t !== 1 && (
  <div class='wp-pagenavi' role='navigation'>
    <span class='pages'> /{t}</span>
    {prevUrl && <a class="previouspostslink" rel="prev" aria-label="前のページ" href={prevUrl}>PREV</a>}
    <Fragment set:html={html} />
    {nextUrl && <a class="nextpostslink" rel="next" aria-label="次のページ" href={nextUrl}>NEXT</a>}
  </div>
)}

で、各アーカイブページから読み込んで使い回します。例えば投稿ページ(ブログホーム)の1ページ目で使う場合は、下記のように呼び出します。

---
import Pagination from '@components/pagination.astro';

const perPage = 12; // 1ページあたりの表示数

// 1ページ目の投稿取得
const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=${perPage}`);
const posts = await res.json();
const total = Number( res.headers.get('x-wp-totalpages') );
const paged = 1;
---
{/* ページネーションを表示 */}
<Pagination page={paged} total={total} base="/blog/" />

「page」には現在のページ番号、「total」には総ページ数、「base」には基準となるパス(1ページ目のパス)を入れます。

importするファイルの@マークについて

プロジェクトディレクトリ直下にある「tsconfig.json」ファイルに、ディレクトリのパスを登録しておくと、@マーク付きの名前でアクセスできます。自身のファイルの階層によって読み込むファイルまでのパスが「../」とか「../../」とか、いろいろ変わるのを無視できます。

{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@public/*": ["public/*"],
      "@src/*": ["src/*"],
      "@layouts/*": ["src/layouts/*"],
      "@components/*": ["src/components/*"],
    }
  }
}

作ってみた感想

いや、これ作るのに半日かかりましたよ。我ながらすごくがんばったんじゃないかと自分を褒めてあげたいです。ちゃんと動いているので、もし書き方がおかしいと感じるJavaScriptマスターさんがいても優しく見守ってくださいね。

Yoastの情報を引っ張ってくる

ページタイトル、ディスクリプション、OGP、Twitterカード、構造化パンくず、、、このあたりの情報はすべてYoastプラグインで管理しているので、それらの情報を取得して掲載します。

標準のWP REST APIを使う

Yoastの情報は、WP REST APIのデフォルトエンドポイントに含まれます。

  • /wp-json/wp/v2/posts
  • /wp-json/wp/v2/pages
  • /wp-json/wp/v2/categories
  • その他、カスタム投稿タイプやカスタム分類のエンドポイント

そのページに対応するエンドポイントからSEO情報を読み込んで、headタグ内に「yoast_head」を出力するのが一般的な実装方法かなと思われます。

例えば、スラッグが「maypage」という固定ページの場合、下記のようになります。

---
const res = await fetch(`${import.meta.env.API_URL}/wp/v2/pages?slug=mypage`);
const posts = await res.json();
const post = posts[0];
---

<Frangemnt set:html={post.yoast_head} />

ただし、今回は、公開中のWordPressサイトの複製をAstroで構築する関係上、そのままのSEO情報は出せない(元サイトの情報になってしまう)ので、この方法は使いません。

Yoastのエンドポイントを使う

Yoastが用意してくれている/wp-json/yoast/v1/get_head?url=エンドポイントというものがあります。やれ投稿だやれ固定ページだやれカテゴリーだやれカスタム投稿タイプだやれカスタム分類アーカイブだ、、、と、ページによって処理を変えるのが面倒だったので、直接URLを打ち込めばYoastの情報がぱぱっと取得できる/wp-json/yoast/v1/get_head?url=を採用することに。横着してすみません。

しかし、今回は複製サイトのため、canonicalurlを元サイトのURLにしたり、noindexを付けたり、いろいろ調整が必要だったので、HTMLを一括で出力することはせず、最低限表示したいものだけ個別に設定しました。

---
const url = new URL(Astro.url.pathname.replace(/\/page\/\d+\//,'/'), Astro.site);
const res = await fetch(`${import.meta.env.API_URL}/yoast/v1/get_head?url=${url}`);
const yoast = await res.json();

// canonical URLは、元サイトのURLにする
const canonicalURL = new URL(Astro.url.pathname, 'https://www.6666666.jp');
---
<title>{yoast.json.title}</title>
<meta name="description" content={yoast.json.description} />
<meta name='robots' content='noindex, nofollow' />
{seoMeta.status == 200 && (
	<link rel="canonical" href={canonicalURL} />
  <meta property="og:locale" content={yoast.json.og_locale} />
  <meta property="og:title" content={yoast.json.og_title} />
  <meta property="og:site_name" content={yoast.json.og_site_name} />
  <meta property="og:type" content={yoast.json.og_type} />
  <meta property="og:description" content={yoast.json.og_description} />
  <meta property="og:url" content={yoast.json.og_url} />
  <meta property="article:publisher" content={yoast.json.article_publisher} />
  <meta property="article:modified_time" content={yoast.json.article_modified_time} />
  <meta property="og:image" content={yoast.json.og_image[0].url} />
  <meta property="og:image:width" content={yoast.json.og_image[0].width} />
  <meta property="og:image:height" content={yoast.json.og_image[0].height} />
  <meta property="og:image:type" content={yoast.json.og_image[0].type} />
  <meta name="twitter:card" content={yoast.json.twitter_card} />
  <meta name="twitter:image" content={yoast.json.twitter_image} />
  <meta name="twitter:site" content={yoast.json.twitter_site} />
)}

2ページ目以降が404になるのを回避

/wp-json/yoast/v1/get_head?url=エンドポイントを使うと、アーカイブの2ページ目以降(/page/xxx/)のステータスが404となり、SEO情報が取得できません。なので、下記のように/page/xxx/を取り除いてからYoast情報を取得する必要がありました。

const url = new URL(Astro.url.pathname.replace(/\/page\/\d+\//,'/'), Astro.site);
const res = await fetch(`${import.meta.env.API_URL}/yoast/v1/get_head?url=${url}`);
const yoast = await res.json();

当然、このままだと1ページ目の情報なので、ページタイトルなどの書き換えが必要になります。でも今回は面倒だったので、とりあえず1ページ目の情報をそのまま使ってます。横着ですまん。

YARPPの関連記事を表示

元サイトのブログ記事の下部には、関連する投稿が6件表示されています。ここでは、YARPP(Yet Another Related Posts Plugin)というプラグインを使って、自動的に関連記事が表示されるようにしてあります。

YARPPには、エンドポイントが用意されていますので、それを使って関連記事が取得できます。関連記事を表示するためのコンポーネントファイルを作って、

---
const { id } = Astro.props;

const res = await fetch(`${import.meta.env.API_URL}/yarpp/v1/related/${id}/`);
const posts = await res.json();
---
{posts.map((post:any) => (
  各記事を表示するコード
))}

先ほど作ってあった個別投稿用のファイルから呼び出します。

---
import Yarpp from '@components/yarpp.astro';

// ルート生成
export async function getStaticPaths() {

  // 最初の100件取得
  const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=100`);
  let posts = await res.json();
  const total = Number(res.headers.get('x-wp-totalpages'));
  
  // 101件目以降があれば取得
  if(total > 1) {
    for(let i = 2; i <= total; i++) {
      const res = await fetch(`${import.meta.env.API_URL}/wp/v2/posts?per_page=100&page=${i}`);
      const morePosts = await res.json();
      posts = posts.concat(morePosts);
    }
  }
  return posts.map((post:any)=>{
    return {
      params: { slug: post.slug },
      props: { post: post },
    }
  });
}

// 各ページで使うデータ
const { slug } = Astro.params;
const { post } = Astro.props;
---
{/* 関連記事を表示 */}
<Yarpp id={post.id} />

各ページの中身を作る

必要なデータをWP REST APIから取得できたら、あとは中身のHTMLを組み立てていくだけです。特筆すべきことは正直あんまりありません。コンポーネントを駆使したりして、楽しくパズルを組み立てればいいんじゃないかと思います。

ユーザー情報の取得

元サイトのブログ記事には、著者の情報を表示しています。postsエンドポイントでは、著者のIDのみ取得できますが、これだけでは足りません。

著者情報はusersエンドポイントで取得できます。が、元サイトはusersエンドポイントに外部からアクセスできないよう設定しています。ですので、必要となる項目をpostsエンドポイントに追加します。

WordPress側のfunctions.phpに追記。

add_action( 'rest_api_init', function() {
  // 投稿者名
  register_rest_field( 'post', 'author_name', [
    'get_callback'    => function( $post, $name ){
      if( !$post["author"] ) {
        return false;
      }
      $author = get_userdata( $post["author"] );
      return $author->display_name;
    },
    'update_callback' => null,
    'schema'          => null,
  ]);
});

エンドポイントに項目を追加するには、rest_api_initアクションフックと、register_rest_field関数を使います。

他にも追加したい項目があるので足していきます。

アイキャッチ画像は、一覧表示用と個別投稿ページ用で使用するサイズが異なるので、両方の情報を追加します。画像の縦横のサイズもほしいです。あと、ついでにカテゴリ情報(カテゴリアーカイブへのリンクを貼るためにslugなど)も取得できるようにしちゃいましょう。何度もAPIにアクセスするより、必要な情報がまとめて取得できたほうが嬉しいですからね。

add_action( 'rest_api_init', function() {
  // アイキャッチ情報
  register_rest_field( 'post', 'thumbnail', [
    'get_callback'    => function( $post, $name ) {
      $img_id = get_post_thumbnail_id( $post['id'] );
      if( !$img_id ) {
        return [
          'id'      => false,
        ];
      }
      $img = wp_get_attachment_image_src( $img_id );
      $img2 = wp_get_attachment_image_src( $img_id, 'post-thumbnail' );
      return [
        'id'        => $img_id,
        'thumbnail' => [
          'url'     => $img[0],
          'width'   => $img[1],
          'height'  => $img[2],
        ],
        'postThumbnail' => [
          'url'     => $img2[0],
          'width'   => $img2[1],
          'height'  => $img2[2],
        ],
      ];
    },
    'update_callback' => null,
    'schema'          => null,
  ]);

  // すべてのカテゴリ情報
  register_rest_field( 'post', 'categories_info', [
    'get_callback'    => function( $post, $name ) {
      $categories = get_the_category($post['id']);
      $data = [];
      if( !empty( $categories ) ){
        foreach ( $categories as $category ) {
          $data[] = [
            'id'    => $category->term_id,
            'slug'  => $category->slug,
            'name'  => $category->name,
          ];
        }
      }
      return $data;
    },
    'update_callback' => null,
    'schema'          => null,
  ]);

  // 投稿者名
  register_rest_field( 'post', 'author_name', [
    'get_callback'    => function( $post, $name ){
      if( !$post["author"] ) {
        return false;
      }
      $author = get_userdata( $post["author"] );
      return $author->display_name;
    },
    'update_callback' => null,
    'schema'          => null,
  ]);

});

こんな感じになりました。

デフォルトのエンドポイントの項目は削除しない!

項目を追加する分には問題ありませんが、使わないからと項目を削除するのは厳禁です。なぜなら、デフォルトのエンドポイントは、ブロックエディタやプラグインなど、色々なタイミングで呼び出される可能性があるからです。

必要な情報だけを返したい場合は、自分でオリジナルのエンドポイントを作りましょう。

その他

Astroサイト上ではWordPressプラグインが使えないので、代替となるものに差し替えます。

完成!

というわけで、紆余曲折を経てAstroサイトが完成しました。元となる当サイトに比べて明らかに表示速度が向上していますね。

https://astro.6666666.jp/

やってみた感想

はじめてだったので、新しいおもちゃで遊ぶ子どもの気持ちで楽しくできました。Astroは、専門のエンジニアでなくてもサイトが構築できることを売りにしています。確かに、とくにユーザー側の操作を必要としない普通の「ホームページ」であれば、難しい知識は必要ありません。普段、WordPressのテーマを作っている人にもとっつきやすいんじゃないかと思います。今回、Astroの能力を存分に発揮するような事例ではありませんが、最初のステップとして勉強になりました!

Web Designer / Developer

前田 大地

沼津高専中退。デザイン会社、システム開発会社を経てセブンシックスを設立。マーケティング、デザイン、テクノロジーに精通するオールラウンダーとして、県内の中小企業に向けた戦略型ホームページ制作を開始。一方で、都内の広告代理店からの要請で大企業案件にも多数参加。企業が本当に必要とするホームページ制作とは何か、を日々探求している。

Blog top