🤖

Faust.js+WPGraphQLで、カスタムタクソノミーを扱う

2022/05/01に公開

WPGraphQLプラグインとFaust.jsを組み合わせれば、クエリを書く手間を省きながら、WordPressをHeadless CMS化できます。しかしカスタムタクソノミーを使おうとすると少し手間がかかるので、手順をまとめました。

WordPress側

WPGraphQLの準備

Composer使うので環境用意して下さい。sage/bedrockがおすすめです。

composer require wp-graphql/wp-graphql
wp plugin activate wp-graphql

タクソノミーを作成し、GraphQLに露出

カスタムタクソノミー「ゲーム」を作るとします。

function custom_register_taxes_games() {
  $labels = [
    'name' => __( 'ゲーム' ),
    'singular_name' => __( 'ゲームズ' ),
  ];

  $args = [
    'label' => __( 'ゲーム' ),
    'labels' => $labels,
    'public' => true,
    'publicly_queryable' => true,
    'hierarchical' => false,
    'show_ui' => true,
    'show_in_menu' => true,
    'show_in_nav_menus' => true,
    'query_var' => true,
    'rewrite' => [ 'slug' => 'games', 'with_front' => true, ],
    'show_admin_column' => true,
    'show_in_rest' => true,
    'show_tagcloud' => false,
    'rest_base' => 'games',
    'rest_controller_class' => 'WP_REST_Terms_Controller',
    'show_in_quick_edit' => true,
    'sort' => false,
    'show_in_graphql' => true,
    'graphql_single_name' => 'Game',
    'graphql_plural_name' => 'Games',
  ];
  register_taxonomy( 'games', [ 'post' ], $args );
}

add_action( 'init', 'custom_register_taxes_games' );

まあこれはCPTUIで追加してもいいのですが、いずれにせよ設定はコードに書いて下さい。(WordPress詳しくないからCPTUIで作ってコード生成させた。無駄なオプションが多いかも)

    'show_in_graphql' => true,
    'graphql_single_name' => 'Game',
    'graphql_plural_name' => 'Games',

重要なのはここで、これをGraphQL側で使います。

query game {
  game(id: "minecraft", idType: SLUG) {
    id
    name
  }
}

例えばminecraftというスラッグのゲームはこうやって取得できます。

タクソノミープラグインのインストールと有効化

https://faustjs.org/docs/next/reference/hooks/usePosts

2022年4月30日現在のFaust.jsのusePostsでは、whereにカスタムタクソノミーが使えません。

https://github.com/wp-graphql/wp-graphql-tax-query

そこでこちらのプラグインを使います。2022年4月30日現在、WordPress Packagistに登録されてないので、リポジトリをcomposerに追加してください。

composer config repositories.wp-graphql/wp-graphql-tax-query \
    vcs https://github.com/wp-graphql/wp-graphql-tax-query.git
composer require wp-graphql/wp-graphql-tax-query:master
wp plugin activate wp-graphql-tax-query

参考: https://scrapbox.io/namaozi/composerでGitHubのリポジトリからパッケージ追加する方法

Next.js側

https://faustjs.org/docs/next/getting-started

ここから環境を用意すれば、最速でWPGraphQLの環境を構築できます。

Schemaの生成

npx gqty generate

sage/bedrockを使っているなどの理由で/wpがURLに含まれる場合、適宜src/faust.config.jsを編集する必要があります。

記事一覧ページの用意

Faust.jsはApollo ClientではなくGQtyで通信しています。

https://gqty.dev/docs/intro/how-it-works#skeleton-render--values

日本語情報が全然ないのですが、このライブラリでは単にuseQueryしただけではSymbol(gqty-proxy)が返されます。これによりクライアント側では一旦スケルトンを描画し、データ到着後に表示の更新が行われます。

サーバーサイドでデータを扱う場合はややこしくなります。client.useQuery().game(...)で取得すると、gqty-proxyが返ってくるせいで404の判定やビルド時のパス生成ができません。

https://faustjs.org/docs/next/guides/ssr-ssg#statically-generate-some-of-your-pages-at-build-time-based-on-a-query

https://github.com/wpengine/faustjs/discussions/515#discussioncomment-1411394

そこで、GQtyClientinlineResolvedという関数を使えばいいそうです。

mkdir src/utils
touch src/utils/game.ts
src/utils/game.ts
import { client, GameIdType } from '@/client';
import { getFields, getArrayFields } from 'gqty';

/**
 * ゲームが存在するか否か
 * @param slug スラッグ
 */
const checkGameExistsBySlug = async (slug: string) => {
  const { inlineResolved, query } = client.client;
  const game = await inlineResolved(() => {
    return getFields(
      query.game({
        id: slug,
        idType: GameIdType.SLUG,
      }),
      'id'
    );
  });
  return game !== null;
};

/**
 * getStaticPaths用のpathsを返す
 * @param paramName ルートパラメータの名前
 */
const getAllGameSlugParams = async (paramName: string) => {
  const games = await client.client.inlineResolved(() => {
    return getArrayFields(client.client.query.games()?.nodes, 'slug');
  });
  return games
    ? games.map((game) => {
        return {
          params: {
            [paramName]: game.slug,
          },
        };
      })
    : [];
};

export { checkGameExistsBySlug, getAllGameSlugParams };

これらのユーティリティを、カスタムタクソノミーがルーティングに絡んでくる場所で使います。

inlineResolvedのコールバック関数でgetFieldsまたはgetArrayFieldsを使うことで、データの実体を取得します。これらを用意することでやっと、適切な404判定やパスの生成ができます。

DIR=src/pages/games/[gameSlug]
mkdir -p $DIR && touch $DIR/index.tsx
src/pages/games/[gameSlug]/index.tsx
import { getNextStaticProps } from '@faustjs/next';
import { GetStaticPropsContext } from 'next';
import { useRouter } from 'next/router';
import {
  client,
  GameIdType,
  RelationEnum,
  RootQueryToPostConnectionWhereArgsTaxQueryField,
  RootQueryToPostConnectionWhereArgsTaxQueryOperator,
  TaxonomyEnum,
} from 'client';
import { checkGameExistsBySlug, getAllGameSlugParams } from '@/utils/game';

const POSTS_PER_PAGE = 6;

export default function Page() {
  const { useQuery, usePosts } = client;
  const { query = {} } = useRouter();
  const { gameSlug, paginationTerm, gameCursor } = query;
  const game = useQuery().game({ id: gameSlug.toString(), idType: GameIdType.SLUG });
  const isBefore = paginationTerm === 'before';
  const posts = usePosts({
    after: !isBefore ? (gameCursor as string) : undefined,
    before: isBefore ? (gameCursor as string) : undefined,
    first: !isBefore ? POSTS_PER_PAGE : undefined,
    last: isBefore ? POSTS_PER_PAGE : undefined,
    /**
     * wp-graphql-tax-queryプラグインで絞り込む
     * @see https://github.com/wp-graphql/wp-graphql-tax-query
     */
    where: {
      taxQuery: {
        relation: RelationEnum.AND,
        taxArray: [
          {
            terms: [game?.slug],
            taxonomy: TaxonomyEnum.GAME,
            operator: RootQueryToPostConnectionWhereArgsTaxQueryOperator.IN,
            field: RootQueryToPostConnectionWhereArgsTaxQueryField.SLUG,
          },
        ],
      },
    },
  });

  return (
    // 省略
  );
}

export async function getStaticProps(context: GetStaticPropsContext) {
  const notFound = !(await checkGameExistsBySlug(context.params.gameSlug.toString()));
  return getNextStaticProps(context, {
    Page,
    client,
    notFound,
  });
}

export async function getStaticPaths() {
  const paths = await getAllGameSlugParams('gameSlug');
  return {
    paths,
    fallback: 'blocking',
  };
}

なお、クライアント側では普通にusePostsを使い、taxQueryで絞り込みます。これは2022年4月30日現在、wp-graphql-tax-queryプラグインがないと使えないため注意して下さい。

カーソルを使ったページネーションについては公式のexampleをご参照下さい。

ページネーション

DIR=$DIR/[paginationTerm]
mkdir -p $DIR && touch "$DIR/[gameCursor].tsx"
src/pages/games/[gameSlug]/[paginationTerm]/[gameCursor].tsx
import { GetStaticPropsContext } from 'next';
import Page from '@/pages/games/[gameSlug]';
import { getNextStaticProps } from '@faustjs/next';
import { client } from 'client';
import { checkGameExistsBySlug } from '@/utils/game';

export default Page;

export async function getStaticProps(context: GetStaticPropsContext) {
  const { paginationTerm, gameSlug } = context.params;
  const notFound = !(paginationTerm === 'after' || paginationTerm === 'before') || !(await checkGameExistsBySlug(gameSlug.toString()));
  return getNextStaticProps(context, {
    Page,
    client,
    notFound,
  });
}

export function getStaticPaths() {
  return {
    paths: [],
    fallback: 'blocking',
  };
}

これはFaust.jsのチュートリアルと同じですが、独自にゲームの存在判定を追加しています。 これがないと存在しないスラッグでも404になりません。

なお、ここではパスを生成しません。随時変化するカーソルをビルド時に使う意義は薄いでしょう。


├ ● /games/[gameSlug] (ISR: 900 Seconds) (9804 ms)              1.76 kB         184 kB
├   ├ /games/minecraft-dungeons (1532 ms)
├   ├ /games/minecraft-console (1519 ms)
├   ├ /games/minecraft-je (1427 ms)
├   ├ /games/hytale (1330 ms)
├   └ /games/minecraft (1225 ms)

上記の実装だと、こんな感じでパスが生成されます。

以上、Faust.jsでカスタムタクソノミーを扱う手順でした。GQtyの情報がなさすぎてつらい。

Discussion