Headless CMSとしてのa-blog cms

この記事は、a-blog cms Advent Calendar 2019 の17日目の記事です。

Headless CMSとは

これまでのCMSはフロントエンド(表示画面)とバックエンド(入力画面やプログラム)が同居したものがほとんどでしたが、セキュリティ意識の高まりやサーバ環境の変化(NetlifyなどのCDNやサーバレス環境の浸透)に伴って、APIをコールしてデータを取得する、バックエンドのみでCMSを運用するヘッドレスCMSの機運が高まってきました。

2019年は国産のヘッドレスCMS「microCMS」のリリースを皮切りに、「JAMstack」という言葉も注目を集めるようになり、「ヘッドレス元年」と言っても差し支えのないくらい、ヘッドレスの構成がメジャーになってきた一年でした。

Headless CMSについて

Headless CMSの代表例

今回はa-blog cmsでヘッドレスの構成を実現できないか検証してみましたので紹介します。

a-blog cmsでJSONを出力するテンプレートを用意する

まずはAPIに該当するところを作成します。a-blog cmsではAPIは用意されていないものの、APIのようにテンプレートを記載することで同様の構成が実現できます。

1. モジュールID作成

まず管理画面より記事一覧を出力するモジュールID を作成します。記事一覧を出力する Entry_Summary は標準では全記事の最新10件までしか表示されないため、URLによるカテゴリの絞り込み(URLコンテキスト)が反映されるモジュールIDを作成します。

2. 記事一覧(/themes/○○○○○/index.json)

次に記事一覧を出力する Entry_Summary にさきほど作成したモジュールIDを紐付けて、記事一覧出力用のテンプレート(API)を作成します。

トップページは https://gradation.me/index.json 、カテゴリーページは https://gradation.me/technology/index.json のようにリクエストします。

<!-- BEGIN_MODULE Entry_Summary id="common_index" -->\{
  "entries": [
    <!-- BEGIN unit:loop --><!-- BEGIN entry:loop -->
    \{
      "eid": "{eid}",
      "url": "{url}[abs2rel]",
      "thumbnail": "<!-- BEGIN image:veil -->%{ROOT_DIR}{path}<!-- END image:veil -->",
      "title": "{title}",
      "summary": "{summary}"
    \}<!-- BEGIN glue -->,<!-- END glue -->
    <!-- END entry:loop --><!-- END unit:loop -->
  ]
\}
<!-- END_MODULE Entry_Summary -->

3. 記事詳細(/themes/○○○○○/entry.json)

記事詳細を出力する Entry_Body を使って、記事詳細出力用のテンプレート(API)を作成します。

https://gradation.me/technology/a-blog-cms/png2jpg.html/tpl/entry.json のように記事詳細のURLの末尾に /tpl/entry.json をつけてテンプレートを指定してリクエストします。

a-blog cmsでは記事詳細の本文が「ユニット」としてブロックごとに保存されるため /themes/system/include/unit.html から必要な部分だけを抜粋して配列で出力するようにテンプレートを作成します。なお、Entry_BodyやユニットのループではEntry_Summaryでは使える <!-- BEGIN glue -->,<!-- END glue --> が使えないので注意が必要です。

<!-- BEGIN_MODULE Entry_Body id="common_entry" -->\{
  "entries": [
    <!-- BEGIN entry:loop -->
    \{
      "title": "{title}",
      "body": [
        <!-- BEGIN unit:loop -->
        \{
        <!-- BEGIN unit#text -->
        <!-- BEGIN p --> "type": "text", "tag": "p", "class": "{class}", "text": {text}[nl2br|json_encode]<!-- END p -->
        <!-- BEGIN h2 --> "type": "text", "tag": "h2", "class": "{class}", "text": {text}[nl2br|json_encode]<!-- END h2 -->
        <!-- BEGIN h3 --> "type": "text", "tag": "h3", "class": "{class}", "text": {text}[nl2br|json_encode]<!-- END h3 -->
        <!-- BEGIN h4 --> "type": "text", "tag": "h4", "class": "{class}", "text": {text}[nl2br|json_encode]<!-- END h4 -->
        <!-- BEGIN h5 --> "type": "text", "tag": "h5", "class": "{class}", "text": {text}[nl2br|json_encode]<!-- END h5 -->
        <!-- BEGIN ul --> "type": "text", "tag": "ul", "class": "{class}", "text": {text}[list|json_encode]<!-- END ul -->
        <!-- BEGIN ol --> "type": "text", "tag": "ol", "class": "{class}", "text": {text}[list|json_encode]<!-- END ol -->
        <!-- BEGIN dl --> "type": "text", "tag": "dl", "class": "{class}", "text": {text}[definition_list|json_encode]<!-- END dl -->
        <!-- BEGIN blockquote --> "type": "text", "tag": "blockquote", "class": "{class}", "text": {text}[nl2br|json_encode]<!-- END blockquote -->
        <!-- BEGIN table --> "type": "text", "tag": "table", "class": "{class}", "text": {text}[table|json_encode]<!-- END table -->
        <!-- BEGIN pre --> "type": "text", "tag": "pre", "class": "{class}", "text": {text}[json_encode]<!-- END pre -->
        <!-- BEGIN none --> "type": "text", "tag": "", "class": "", "text": {text}[raw|json_encode]<!-- END none -->
        <!-- BEGIN markdown --> "type": "text", "tag": "", "class": "", "text": {text}[markdown|json_encode]<!-- END markdown -->
        <!-- BEGIN wysiwyg --> "type": "text", "tag": "", "class": "", "text": {text}[raw|json_encode]<!-- END wysiwyg -->
        <!-- END unit#text -->

        <!-- BEGIN unit#table -->
        "type": "text", "tag": "table", "class": "", "text": {table}[raw|json_encode]
        <!-- END unit#table -->

        <!-- BEGIN unit#media -->
         "outerClass": "column-media-{align}{display_size_class}",
        <!-- BEGIN type#image -->"type": "image", "href": "{url}", "src": "%{HTTP_ROOT}{path}[resizeImg({x})]", "caption": "{caption}[nl2br|delnl]"<!-- END type#image -->
        <!-- BEGIN type#file -->"type": "file", "href": "{url}", "caption": "{caption}[nl2br|delnl]"<!-- END type#file -->
        <!-- END unit#media -->
        \}
        <!-- END unit:loop -->
      ]
    \},
    <!-- END entry:loop -->
  ]
\}
<!-- END_MODULE Entry_Body -->

4. /extension/acms/Corrector.php

URLをルート相対パスにしたり、ユニット中のダブルクォーテーションや改行などをJSON形式にするため、校正オプション を追加します。

<?php

namespace Acms\Custom;

/**
 * 校正オプションにユーザー定義のメソッドを追加します。
 * ユーザー定義のメソッドが優先されます。
 */
class Corrector
{
    /**
     * json_encode
     * 文字列をJSONエンコードする
     *
     * @param  string $txt  - 校正オプションが適用されている文字列
     * @return string       - 校正後の文字列
     */
    public function json_encode($txt)
    {
        return json_encode($txt);
    }

    /**
     * abs2rel
     * 絶対パスをルート相対パスに置換する
     *
     * @param  string $txt  - 校正オプションが適用されている文字列
     * @return string       - 校正後の文字列
     */
    public function abs2rel($txt)
    {
        $domain = ((empty($_SERVER["HTTPS"]) ? "http://" : "https://") . $_SERVER['SERVER_NAME']);
        return str_replace($domain, '', $txt);
    }
}

5. /.htaccess

外部からJSONをリクエストするため .htaccess でAccess-Control-Allow-Originを有効にしておきます。

Header set Access-Control-Allow-Origin: "*"

nuxt.jsでJSONを読み込むSPAを構築する

次にフロントエンド側で作成したJSONファイルをコールして、ページを作成してみたいと思います。

nuxt.js のインストール

今回はシンプルにSPA(Single Page Application)で構成します。まずは create-nuxt-app でnuxtのアプリケーションを作成します。サーバのフレームワークはExpressを選択、ajax通信を行うのでAxiosを有効にします。

$ npx create-nuxt-app gradation.me

create-nuxt-app v2.12.0
✨  Generating Nuxt.js project in gradation.me
? Project name gradation.me
? Project description My blog project
? Author name Terasaki Genovese
? Choose the package manager Npm
? Choose UI framework None
? Choose custom server framework Express
? Choose Nuxt.js modules Axios
? Choose linting tools (Press <space> to select, <a> to toggle all, <i> to inver
t selection)
? Choose test framework None
? Choose rendering mode Single Page App
? Choose development tools (Press <space> to select, <a> to toggle all, <i> to i
nvert selection)

インストールに成功したら開発を開始します。http://localhost:3000 などでアクセスできるようになります。

npm run dev

nuxt.config.js

次にnuxt.config.jsでルーティングを設定します。

a-blog cmsではテーマディレクトリの中から、トップページであれば top.html 、一覧ページであれば index.html 、詳細ページであれば entry.html を表示しますが、同様にトップページは index.vue、一覧ページは category.vue、詳細ページはentry.vueを表示するようにします。

Nuxt.js では動的なルーティング機能が備わっていますが、2階層以上のカテゴリ構造に対応していないため、nuxt.config.jsに記載します。一覧ページと詳細ページはファイル名に.htmlがあるかないかで判別を行うようにしました。設定が終わったら一度 npm run dev し直します。

module.exports = {
  mode: 'spa',
  router: {
    extendRoutes (routes, resolve) {
      routes.push(
        {
          name: 'custom',
          path: '/:category/',
          component: resolve(__dirname, 'pages/category.vue')
        },
        {
          name: 'custom',
          path: '/:category/*.html',
          component: resolve(__dirname, 'pages/entry.vue')
        },
        {
          name: 'custom',
          path: '/:category/:subCategory/',
          component: resolve(__dirname, 'pages/category.vue')
        },
        {
          name: 'custom',
          path: '/:category/:subCategory/*.html',
          component: resolve(__dirname, 'pages/entry.vue')
        },
      )
    }
  },
// (中略)

pages/index.vue

トップページを作成します。axiosで一覧ページ用のJSONを読み込んで出力します。

<template>
  <div class="container">
    <h1>Latest Post</h1>
    <ul>
      <li v-for="entry in entries" :key="entry.eid">
        <nuxt-link :to="entry.url">{{entry.title}}</nuxt-link>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  async asyncData({$axios}){
    const response = await $axios.$get(`https://gradation.me/index.json?timestamp=${new Date().getTime()}`)
      return { entries: response.entries }
  }
}
</script>

pages/category.vue

次にカテゴリページを作成します。params.categoryparams.subCategory でカテゴリ名を受け取り、一覧ページ用のJSONを読み込んで出力します。

<template>
  <div class="container">
    <h1>{{$route.params.category}}</h1>
    <ul>
      <li v-for="entry in entries" :key="entry.eid">
        <nuxt-link :to="entry.url">{{entry.title}}</nuxt-link>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  async asyncData({$axios, params}){
    const path = params.subCategory ? params.category + '/' + params.subCategory : params.category;
    const response = await $axios.$get(`https://gradation.me/${path}/index.json?timestamp=${new Date().getTime()}`)
      return { entries: response.entries }
  }
}
</script>

pages/entry.vue

最後に詳細ページを作成します。params.categoryparams.pathMatch でURLを受け取り詳細ページ用のJSONを読み込んで出力します。

<template>
  <div class="container">
    <div v-for="entry in entries" :key="entry.eid">
      <h1>{{entry.title}}</h1>
      <div v-for="unit in entry.body" :key="unit.utid" :class="unit.outerClass">
        <!-- text -->
        <p v-if="unit.type === 'text' && unit.tag === 'p'" v-html="unit.text" :class="unit.class"></p>
        <h2 v-if="unit.type === 'text' && unit.tag === 'h2'" v-html="unit.text" :class="unit.class"></h2>
        <h3 v-if="unit.type === 'text' && unit.tag === 'h3'" v-html="unit.text" :class="unit.class"></h3>
        <h4 v-if="unit.type === 'text' && unit.tag === 'h4'" v-html="unit.text" :class="unit.class"></h4>
        <h5 v-if="unit.type === 'text' && unit.tag === 'h5'" v-html="unit.text" :class="unit.class"></h5>
        <ul v-if="unit.type === 'text' && unit.tag === 'ul'" v-html="unit.text" :class="unit.class"></ul>
        <ol v-if="unit.type === 'text' && unit.tag === 'ol'" v-html="unit.text" :class="unit.class"></ol>
        <dl v-if="unit.type === 'text' && unit.tag === 'dl'" v-html="unit.text" :class="unit.class"></dl>
        <blockquote v-if="unit.type === 'text' && unit.tag === 'blockquote'" v-html="unit.text" :class="unit.class"></blockquote>
        <table v-if="unit.type === 'text' && unit.tag === 'table'" v-html="unit.text" :class="unit.class"></table>
        <pre v-if="unit.type === 'text' && unit.tag === 'pre'" v-html="unit.text" :class="unit.class"></pre>
        <div v-if="unit.type === 'text' && unit.tag === ''" v-html="unit.text" :class="unit.class"></div>

        <!-- media -->
        <div v-if="unit.type === 'image'">
          <figure>
            <a :href="unit.href">
              <img :src="unit.src" alt="">
            </a>
            <figcaption v-if="unit.caption">{{unit.caption}}</figcaption>
          </figure>
        </div>

      </div>
    </div>
    <p><nuxt-link to="/">トップページへ</nuxt-link></p>
  </div>
</template>

<script>
export default {
  async asyncData({$axios, params}){
    const response = await $axios.$get(`https://gradation.me/${params.category}/${params.pathMatch}.html/tpl/entry.json?timestamp=${new Date().getTime()}`)
      return { entries: response.entries }
  }
}
</script>

デプロイする

開発が完了したらデプロイします。distにファイルが出力されます。

npm run generate

デモページ

ブログサイトとしては、メタ、OGP、カテゴリの一覧やアーカイブの一覧なども出力し、CSSでデザインや既存のプログラムを調整したりする必要がありますが、このようにa-blog cmsの出力するJSONを使ってSPAを実装することができました。

今回Netlifyにアップロードしましたので、SSR構成にしてa-blog cmsのpingショット機能を用いて更新ごとにビルドすることも可能です。

デモページはこちら

a-blog cmsでHeadlessの構成を採用するメリット、デメリット

今回の構成を採用することで下記のメリット、デメリットがあります。

メリット

  • プロフェッショナルライセンスの静的書き出しを使用しなくても静的ファイルでの運用が可能。
  • 他のサイトやCMS領域外で記事一覧を出力するなど、新着情報の表示など部分的に使うことが可能。
  • SPAやSSRとして運用することで、サーバサイドでのプログラム実行が最小限になるため、パフォーマンスが向上。
  • フロントエンドの知見がある場合は、a-blog cmsの実装に依存しない実装が可能。デザインテンプレートの利用や、a-blog cmsを知らないエンジニアとの協業を行う場合に有効。

デメリット

  • a-blog cmsにもともと備わっている、強力なURLコンテキスト機能(ルーティング)、テーマ機能(テンプレート)の恩恵を利用することができず、車輪の再発明を行うことになる。
  • a-blog cmsの記事のダイレクト編集や、モジュールの編集機能を利用することができないため、a-blog cmsの記事編集画面と表示画面が一体になった操作性を捨てることになる。
  • Ver 2.1.22時点でheadlessでの運用を想定したCMSではないため、JSONを出力するようになるまでにある程度のカスタマイズや知識が必要。

a-blog cmsは非常に魅力的なCMSですが、組み込みができる方はまだまだ少ない印象です。a-blog cmsの組み込みをJSONという共通のフォーマットにすることで、開発コストを下げられる場面もあるのではないかと思い、今回検証を行いました。個人的にはもともとa-blog cmsに強力な機能が備わっているのでそれを活かしつつ、足りないところを今回のようなアプローチで補うのが良いかと思います。今後、WordPressのREST APIのように標準機能として使えるプラグインやテーマなども期待したいと思います。


コメント

お名前 必須

名前を入力してください。

メールアドレス

正しいメールアドレスを入力してください。

URL

正しいURLを入力してください。

タイトル

タイトルを入力してください。

タイトルに不適切な言葉が含まれています。

コメント必須

コメントを入力してください。

コメントに不適切な言葉が含まれています

パスワード必須

パスワードを入力してください。

パスワードは半角小文字英数字で入力してください。

Cookie

関連記事

この記事のハッシュタグに関連する記事が見つかりませんでした。