Craft CMS を Headless CMS として使う場合のプレビューは色々試していた。

とりあえず Gatsbyjs でためしていたのだけどプレビューのビルドを作ったりする関係で若干遅いのが気になっていた。
気になっていたというより、これではだめだろうという感じで。

Next.js の Preview Mode の方でためしてみてあれこれやってみてイマイチうまく動かせないところがあったので、そのへんは @BUN にやってもらってようやく納得する形になった。
素晴らしー!感謝感謝。

Craft CMS では管理画面での Live preview(管理画面上の日本語表記はプレビュー) とプレビュー(説明がむずかしいが個別のURLで見れるプレビュー)があるので、それぞれの対応をしておく。
Live preview は管理画面内でのプレビューで、編集をリアルタイムで確認できる。

プレビューの方は、外部の人にも見てもらえるようなプレビューと考えると良さそう。

Craft CMS 側の設定

Craft CMS 側の GraphQL の設定として、有効なスキーマの設定と、それに対応するトークンの設定は完了してある状態。

config/general.php に Headless mode の設定。

'headlessMode' => true,
General Config Settings | Craft CMS Documentation | 3.x
https://craftcms.com/docs/3.x/...

config/roots.php にGraphQL用のルーティングの設定も。 

return [
    'api' => 'graphql/api',
    // ...
];
GraphQL API | Craft CMS Documentation | 3.x
https://craftcms.com/docs/3.x/...

.env の設定

.env にエンドポイントとトークンの設定をしておく。

CMS_ENDPOINT=https://example.com/api
AUTH_BEARER_CODE=hogehogehoge

next.config.js の設定

next.config.js にエンドポイント周りの設定を入れておく

module.exports = {
  reactStrictMode: true,
  env: {
    CMS_ENDPOINT: process.env.CMS_ENDPOINT,
    AUTH_BEARER_CODE: `Bearer ${process.env.AUTH_BEARER_CODE}`
  }

必要なパッケージのインストール

必要なパッケージは @apollo/client, graphql あたりなので、これらをインストールしておく。

Query

GraphQL 用の Query を用意する。

gql/entries.js

公開されている全エントリ取得用の gql/entries.js 。

import { gql } from '@apollo/client'

// 全エントリデータ取得用クエリ
export const ENTRIES_DATA = gql`
  query ($sectionHandle: [String]) {
    entries(section: $sectionHandle, status: "live", limit: null){
      id
      title
      slug
      uid
      sourceUid
      canonicalUid
      sectionHandle
    }
  }
`;

limitvariable で渡してもいいかもしれないし、そのへんは使い方にあわせて要調整。

gql/entry.js

単一のエントリを取得するところ。ステータスがちがうのでpreview用と公開用で別に。

import { gql } from '@apollo/client'

// エントリデータ取得用クエリ:プレビュー用
export const ENTRY_PREVIEW_DATA = gql`
  query ($sectionHandle: [String!], $uid: [String]) {
    entry(section: $sectionHandle, uid: $uid, status: null, drafts: null) {
      id
      title
      slug
      status
      draftId
      uid
      sourceUid
      canonicalUid
      postDate
      sectionHandle
      ... on news_news_Entry {
        id
        text_kana
        redactor
      }
    }
  }
`;

// エントリデータ取得用クエリ:公開データのみ
export const ENTRY_DATA = gql`
  query ($sectionHandle: [String!], $id: [QueryArgument]) {
    entry(section: $sectionHandle, id: $id, status: "live") {
      id
      title
      slug
      status
      draftId
      uid
      sourceUid
      canonicalUid
      postDate
      sectionHandle
      ... on news_news_Entry {
        id
        text_kana
        redactor
      }
    }
  }
`;

canonicalUid はちょうど3.7.11で追加されたもの。
https://github.com/craftcms/cm...

この辺の sourceUid と Uid とかも正確に把握し切れていないところがあるが、、

status null ですべてのステータスがとれるというのを教えてもらった。

Apolloの設定

config/apollo.js の設定

import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

async function apollo(gqlQuery, gqlParams, previewToken = false) {
  // set GraphQL URI
  const httpLink = createHttpLink({
    uri: previewToken ?
      process.env.CMS_ENDPOINT + '?token=' + previewToken :
      process.env.CMS_ENDPOINT
  });

  // set Authorization header
  const authLink = setContext((_, { headers }) => {
    return {
      headers: {
        ...headers,
        authorization: process.env.AUTH_BEARER_CODE,
      }
    }
  });

  // Apollo Client initialization
  const client = new ApolloClient({
    link: authLink.concat(httpLink),
    cache: new InMemoryCache()
  });

  // execute GraphQL query
  return await client.query({
    // the GQL query
    query: gqlQuery,
    // pass through query params e.g. { slug: "boop", uid: "2dff-344d-dfdd...", token: "..." }
    variables: gqlParams,
    // display errors from GraphQL (useful for debugging)
    errorPolicy: 'all'
  }).then(data => {
    return {
      // return the data or NULL if no data is returned
      data: (typeof(data.data) != "undefined") ? data.data : null ,
      // return any errors
      errors: (data.errors) ? data.errors : []
    }
  });
}

export { apollo }

サイトトップ

pages/index.js の設定。
どのセクションのデータを取り出すかを渡す。この場合は news セクション。
プレビューの確認としてはいらないけど、表示側の確認用もかねて用意する。

import { apollo } from '@/config/apollo'
import { ENTRIES_DATA } from '@/gql/entries';

// 静的生成のためのコンテンツデータの取得
export async function getStaticProps() {
  var { data, errors } = await apollo(
    ENTRIES_DATA,
    {
      sectionHandle: 'news'
    }
  );

  return {
    props: {
      entries: data.entries
    },
  };
}

// ページの出力
export default function Index({ entries }) {
  return (
    <div>
     〜〜略〜〜
    </div>
  )
}

詳細ページ

詳細ページ用 /pages/news/[id].js の調整。
id からエントリを探す。

import { apollo } from '@/config/apollo'
import { ENTRY_DATA } from '@/gql/entry';
import { ENTRIES_DATA } from '@/gql/entries';

// 静的生成のためのコンテンツデータの取得
export async function getStaticProps(context) {
  // エントリを取得
  var { data, errors } = await apollo(
    ENTRY_DATA,
    {
      id: context.params.id
    },
    null
  );
  
  // 公開エントリが見つからなければ、404 表示
  if (errors.length > 0 || !data.entry) return { notFound: true }

  // エントリ、および、プレビューデータを返却
  return {
    props: {
      entry: data.entry
    }
  }
}

// 静的生成のためのパスを指定
export async function getStaticPaths() {
  var { data, errors } = await apollo(
    ENTRIES_DATA,
    {
      sectionHandle: 'news'
    },
    null
  );

  // 出力対象のエントリのパスをセット
  const paths = data.entries.map((entry) => `/news/${entry.id}`);

  // セットしたパス以外は 404
  return { paths, fallback: false };
}


// ページの出力
export default function Detail({ entry }) {
  return (
    <div>
〜〜略〜〜
    </div>
  )
}

Preview用の詳細ページ

Preview Mode でリダイレクトするプレビュー用も別 pages/news/preview.js  で用意しておく

import { apollo } from '@/config/apollo'
import { ENTRY_PREVIEW_DATA } from '@/gql/entry';

// 静的生成のためのコンテンツデータの取得
export async function getStaticProps(context) {
  if (context.preview && context.previewData.token) {
    // Preview Mode なら、uid と token をセットしてエントリを取得
    var { data, errors } = await apollo(
      ENTRY_PREVIEW_DATA,
      {
        uid: context.previewData.uid
      },
      context.previewData.token
    );

    // ページが見つからなければ、404 表示
    if (errors.length > 0 || !data.entry) return { notFound: true }
  } else {
    // Preview Mode でなければ、404 表示
    return { notFound: true }
  }

  // エントリ、および、プレビューデータを返却
  return {
    props: {
      entry: data.entry,
      preview: context.preview ? context.previewData : []
    }
  }
}

// ページの出力
export default function Preview({ entry }) {
  return (
    <div>
〜〜略〜〜
    </div>
  )
}

API設定

/pages/api/preview.js でプレビューモード部分の api 設定をする

import { apollo } from '@/config/apollo'
import { ENTRY_PREVIEW_DATA } from '@/gql/entry';

export default async (req, res) => {
    // URL パラメータに token と canonicalUid がセットされていなければ、401 表示
    if (!req.query.token || !req.query.canonicalUid) {
      return res.status(401).json({ message: 'Invalid preview request' });
    }

    // URL パラメータの canonicalUid を uid に持つエントリを取得
    const entry = await apollo(ENTRY_PREVIEW_DATA, {
      uid: req.query.canonicalUid,
      sectionHandle: 'news'
    });

    // Preview Mode で表示可能なエントリがなければ、404 表示
    if (!entry) {
      return res.status(404).json({ message: 'Page not found' });
    }

    // プレビュー用の uid と token を cookie にセット
    res.setPreviewData({
      uid: req.query.canonicalUid,
      token: req.query.token
    });

    // Live Preview の iframe 表示に対応させるため cookie を調整
    const previous = res.getHeader('Set-Cookie');
    previous.forEach((cookie, index) => {
      previous[index] = cookie.replace('SameSite=Lax', 'SameSite=None;Secure');
    })
    res.setHeader(`Set-Cookie`, previous);

    // 任意の条件によって、リダイレクト先を分岐
    if (entry.data.entry.sectionHandle == 'sitetop') {
      // トップページ
      res.redirect('/preview?uid=' + entry.data.entry.canonicalUid + '&token=' + req.query.token);
    } else {
      // それ以外
      res.redirect('/' + entry.data.entry.sectionHandle + '/preview?uid=' + entry.data.entry.canonicalUid + '&token=' + req.query.token);
    }
}

こんな感じで各テンプレートを用意することで、記事のステータス、下書き等にかかわらずライブプレビューができる!

リダイレクトの所は、今回の場合は /news/preview?uid=xxx にリダイレクトさせて内容を取得する。

cookie を渡さないとライブプレビューが動かないので、そこも注意する。

Next.js Preview in iframes - Storyblok
https://www.storyblok.com/faq/...

Storyblok のエントリが参考になった。

ライブプレビューの様子。

コンテンツを編集すれば、ライブプレビューの内容に反映される。

プレビュー。

Vercel に置いて確認してみたけど、ページとしてプレビューも確認できる。

あとはどこかで cookie 消す処理を入れておくと良さそう。


Craft CMS で Live Preview を試した記事とか、他の Headless CMS でプレビューを試しているエントリが以下のものだけじゃなく色々と参考になった。

参考リンク