最近Vue.jsが楽しくて仕方がない。WordPressのサイト開発にもVue.js使っている今日この頃だ。今時のWordPressと云えばReactなのだが、どうもReactには私的に嫌悪感が付きまとうのだ……なぜだろう(うーん、公式ドキュメントが悪いんだろうか……Reactっていちいちわかりづらい書き方してる感じが……)。
 ま、そんな個人的嗜好性もあって、特にVuetify使ってWEBサイト作るのが自分的に生産性抜群なのだ。ちゅーわけで、何度かVuetifyでWordPressのテーマを作ったので、その知見をナレッジ化していこうと思った次第だ。

初期設定

テーマディレクトリに、WordPressテーマとしてVueのプロジェクトを追加する。
※VuetifyはまだVue 3に対応していないので、Vue 2のプリセット(Default ([Vue 2]...))を選択する必要がある。

cd {WORDPRESS_THEME_DIR}
vue create vuetify-theme
? Please pick a preset: Default ([Vue 2] babel, eslint)

VueプロジェクトにVuetifyを追加する。

cd vuetify-theme
vue add vuetify
? Choose a preset: Default (recommended)

Nuxt.jsを使う場合

ページ遷移型のサイトを構築する場合や、特に投稿コンテンツを閲覧者毎に出しわける等の処理が必要でなく静的なHTMLとして高速配信したい場合などは、SSR(サーバーサイドレンダリング)が利用できるNuxt.jsをインストールするのが便利だ。
Nuxt.jsのインストールは下記のようになる。

yarn add @nuxtjs/vuetify -D
vim nuxt.config.js

設定ファイル:nuxt.config.js

{
  buidModules: [
    '@nuxtjs/vuetify',
    ['@nuxtjs/vuetify', {
      /* options */
    }]
  ]
}

VuetifyテーマでNuxt.jsを利用したテーマ開発については、別の機会に解説しようと思う。

Webpackを使う場合

Vuetifyテーマでビルドされるリソース群をWebpackでバンドルすることで、WordPressテーマ上でのアセット読み込みを最適化することができる。
Webpackをインストールする場合は下記の手順を実施しよう。

yarn add vuetify
yarn add sass sass-loader deepmerge -D
vim webpack.config.js

設定ファイル:webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\s(c|a)ss$/,
        use: [
          'vue-style-loader',
          'css-loader',
          {
            loader: 'sass-loader',
            options: {
              implementation: require('sass'),
              indentedSyntax: true
            },
            options: {
              implementation: require('sass'),
              sassOptions: {
                indentedSyntax: true
              }
            }
          }
        ]
      }
    ]
  }
}

VuetifyテーマでWebpackを利用してバンドルビルドする方法は、別の機会に解説しようと思う。

その他の設定

Vuetify用プラグインファイルを更新する。

vim src/plugins/vuetify.js

中身は以下の通り。

import Vue from 'vue'
import Vuetify from 'vuetify/lib'

Vue.use(Vuetify)

const opts = {}

export default new Vuetify(opts)

以上で、Vue+Vuetifyの導入が完了だ。
まずは動作確認をする。

yarn serve

ブラウザでlocalhost:8080にアクセスしてページが表示されればOKだ。

WordPressテーマとしての設定

次に、WordPressのテーマ用に必要なファイルを作成する。

touch style.css
touch screenshot.png
touch functions.php
cp public/index.html index.php

screenshot.pngはWordPressのテーマ管理ページで表示されるサムネイル画像となる。特になくても問題ないので、今回はファイルだけ作って中身は空としておく。
その他の作成した各ファイルの中身の例は、下記の通りだ。

style.css(WordPressテーマのシグネチャー兼メインのスタイルシート)

/*
Theme Name: WordPress Vuetify Theme
Text Domain: wpvt
Version: 0.1.0
Description: WordPress theme that built by Vue.js with Vuetify.
Tags: blog
Author: YOUR_NAME
License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html
*/

コピーして来たindex.htmlは、下記のようにWordPressのテーマ用のディスパッチャ・ファイルとしてPHPソースに書き換える。

<?php
/**
 * Base Template: index.php
 */
?><!DOCTYPE html>
<html <?php language_attributes() ?>>
  <head>
    <meta charset="<?php bloginfo( 'charset' ) ?>">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <?php wp_head() ?>
  </head>
  <body <?php body_class() ?>>
    <noscript>
      <strong><?php _e( "We're sorry but this page doesn't work properly without JavaScript enabled. Please enable it to continue.", 'wpvt' ) ?></strong>
    </noscript>
    <div id="app"></div>
    <?php wp_footer() ?>
  </body>
</html>

functions.phpにVuetifyテーマ用の処理を追加する。

<?php
/**
 * Custom Theme Functions: functions.php
 */

add_theme_support( 'title-tag' );

define( 'DIST_DIR_PATH', get_template_directory() . '/dist' );
define( 'DIST_DIR_URL', get_template_directory_uri() . '/dist' );

function get_assets( $search_path, $extension = null ) {
    $assets = [];
    if ( ! empty( $search_path ) && is_dir( $search_path ) ) {
        if ( $dh = opendir( $search_path ) ) {
            while ( ( $file = readdir( $dh ) ) !== false ) {
                if ( filetype( $search_path .'/'. $file ) !== 'dir' ) {
                    $finfo = new \SplFileInfo( $search_path .'/'. $file );
                    $_ext = $finfo->getExtension();
                    if ( empty( $extension ) || $_ext === strtolower( $extension ) ) {
                        $assets[] = [ 'filename' => $finfo->getFilename(), 'extension' => $_ext ];
                    }
                }
            }
            closedir( $dh );
        }
    }
    return $assets;
}

add_action( 'wp_enqueue_scripts', function() {
    // Enqueue Styles
    wp_enqueue_style( 'font-roboto', 'https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900', [], null );
    wp_enqueue_style( 'md-icons', 'https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css', [], null );
    if ( ! empty( $css_files = get_assets( DIST_DIR_PATH . '/css', 'css' ) ) ) {
        $handle_name = 'chunk-vendors';
        foreach( preg_grep( "@{$handle_name}@", array_column( $css_files, 'filename' ) ) as $_filename ) {
            wp_enqueue_style( $handle_name, DIST_DIR_URL .'/css/'. $_filename, [], null );
        }
    }

    // Enqueue Scrips
    if ( ! empty( $js_files = get_assets( DIST_DIR_PATH . '/js', 'js' ) ) ) {
        $handle_name = 'chunk-vendors';
        foreach( preg_grep( "@(chunk-vendors|app)@", array_column( $js_files, 'filename' ) ) as $_filename ) {
            $handle_name = substr( $_filename, 0, strpos( $_filename, '.', 0 ) );
            $depend = $handle_name === 'app' ? [ 'chunk-vendors' ] : [];
            wp_enqueue_script( $handle_name, DIST_DIR_URL .'/js/'. $_filename, $depend, null, true );
        }
    }
});

バンドルビルドされるjsやcssを走査して自動で読み込む処理を wp_enqueue_scripts アクションフックに持たせるのがミソだ。これで、チェックサム値が付与されたビルド済みファイルを読み込んでWordPressにテーマを適用してくれるようになる。

これで、基本的な準備はOKだ。

WordPressの管理画面にログインして、テーマを「WordPress Vuetify Theme」に変更しよう。
その後、Vueアプリをビルドする。

yarn build

ビルドが完了したら、ブラウザで表示を確認してみよう。

中央のVuetifyのロゴ画像が表示されないものの、ビルドされたページは表示されるはずだ。

Vuetifyビルド後のリソースパスをリライトする

Vuetifyテーマではビルドされたリソース(jsやcss、画像ファイル等)は全てdistディレクトリ内に生成される。distディレクトリ自体のルーティングはURL上はドキュメントルート直下となる。そのため、dist/img/下にビルドされた画像ファイルは、WordPress上でテーマとしてページが表示されると {ドメイン名}/img/****.jpg のURIで読み込まれることになる。
そこで、distディレクトリ配下のjs・css・img等の各フォルダやファイルはドキュメントルート下にあるものとして取り扱ってあげる必要がある。
というわけで、ドキュメントルートに下記設定の.htaccessを設置しよう。

# BEGIN WpVuetifyTheme Rules
<IfModule mod_rewrite.c>
RewriteCond %{HTTP_HOST} ^vuetify.example.com$
RewriteCond %{REQUEST_URI} ^/((js|css|img)/.*|favicon\.ico)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /app/wp-content/themes/vuetify-theme/dist/$1 [L]
</IfModule>
# END WpVuetifyTheme Rules

リライト条件のHTTP_HOST名は、サイトのホスト(ドメイン)名に書き換えること。
なお、上記# BEGIN WpVuetifyTheme Rules# END WpVuetifyTheme Rules のディレクティブはWordPressによって追加されるリライトディレクティブ(# BEGIN WordPress# END WordPress)よりもに記述する必要があるので注意。

.htaccessを更新したら、再度ブラウザで表示を確認してみよう。

WordPress Vuetify Theme

無事Vuetifyのロゴ画像も表示されるようになり、上記のようにページが表示される。

あとは、通常のVuetifyアプリの開発と同じように、yarn serveでwatchビルドしながらsrcディレクトリ下のApp.vueや各コンポーネントを作り込んで行くことになる。

Vuetifyテーマを開発する際のヒント

テーマのモックアップ・レビュー

Vuetifyテーマではビルドされたリソースはdistディレクトリ下に展開される。そこにはWordPressのテーマとしては使用しないindex.htmlが出力されているので、これらのビルドファイル一式を抽出することで、WordPressがない環境下でもモックアップとしてテーマをレビューできるという素晴らしいメリットがある。
開発途中のテーマのUIやUXを、都度、静的HTMLとしてステークホルダーにレビューできるのは恩恵しかないだろう。

やり方としては、dist内のindex.htmlを開き、各種リソースパスを絶対パスから相対パスに書き換えてから、そのままdistディレクトリ自体をレビューイに配るとか、確認用のサーバにアップロードするなりして、index.htmlをブラウザで開いてもらうだけだ。
難点としては、dist内に出力されるindex.htmlがミニファイされていてコードが読みにくくて相対パス変換がしづらいぐらいか。まぁ、それもエディタで><>\n<に一括変換してあげれば問題なくなるだろう。

開発時のwatchビルド中の確認用にダミーデータを準備する

yarn serve中のテーマの確認はlocalhost:8080で行われるため、WordPressとのデータを連携するような処理はすべて失敗してしまう。このままだと、特にWordPressの投稿データを取得して表示するような、テーマとしての核心部分の処理を作るのが面倒である。そこで、Vueコンポーネント側で実行中のホストを判別して、localhostであればダミーデータを利用するようにしておくのが良いだろう。

  • App.vue:
data: () => ({
  debug: false,
}),

created() {
  this.isLocalhost()
},

methods: {
  isLocalhost: function() {
    let hostName = document.location.hostname
    this.debug = hostName === 'localhost' || hostName === '127.0.0.1'
  }
},

上記のようなメソッドをApp.vueに仕込んでおけば、変数debugの真偽値でホスト環境を判別できるようになる。そのうえで、App.vueからは子コンポーネントに:debug="debug"でdebug値をバインドし、子コンポーネント側からはそのdebug値をpropsで受け取って参照すれば良い。
例えば、投稿を表示する子コンポーネント等での<script>は下記のようになる。

  • components/*.vue
import { testPosts } from '../../public/test_data'

export default {

  props: {
    debug: {
      Type: Boolean,
      default: false
    },
  },

  data: () => ({
    debug: this.$props.debug,
    post: null,
  }),

  mounted () {
    this.getPost()
  },

  methods: {
    getPost: async function() {
      const ajax = this.axios.create({ baseURL: WORDPRESS_URL_TO_GET_POST })
      await ajax.get()
      .then(response => {
        this.post = response.data.post
      })
      .catch(error => {
        if (this.debug) {
          this.post = testPost[this.$route.path.replace('/', '')]
        }
      })
    },
  },
}

処理内容としては、非同期でWodPressの投稿データを取得する処理でエラーになった場合(localhost:8080では必ずエラーになる)に、ホスト判断を行って、localhostならダミーデータを利用するというコードの例だ。ダミーデータは、publicディレクトリにファイルとして準備しておくことになる。以下がダミーデータの例だ。

  • public/test_data.js
const testPosts = {
  'about': {
    'ID': 1,// int
    'post_author': 1,// int
    'post_date': '2020-11-20 15:00:00',// string: 'YYYY-MM-DD HH:MM:SS'
    'post_date_gmt': '2020-11-20 06:00:00',// string: 'YYYY-MM-DD HH:MM:SS'
    'post_content': '本文が入ります。',// string
    'post_title': 'このサイトについて',// string
    'post_category': 0,// int: always 0
    'post_excerpt': '抜粋',// string
    'post_status': 'publish',// string: (publish|pending|draft|private|static|object|attachment|inherit|future)
    'comment_status': 'closed',// string: (open|closed|registered_only)
    'ping_status': 'closed',// string: (open|closed)
    'post_password': '',// string
    'post_name': 'slug',// string
    'to_ping': '',// string: url
    'pinged': '',// string: url
    'post_modified': '2020-11-20 15:00:00',// string: 'YYYY-MM-DD HH:MM:SS'
    'post_modified_gmt': '2020-11-20 06:00:00',// string: 'YYYY-MM-DD HH:MM:SS'
    'post_content_filtered': '',// string
    'post_parent': 0,// int
    'guid': 'slug',// string
    'menu_order': 0,// int: Order Views of Static Page
    'post_type': 'post',// string: (post|page|attachment)
    'post_mime_type': '',// string: (image/png, etc.)
    'comment_count': 0,// int
  },
  ...
}

なお、開発が完了したら、これらのデバッグ処理はレンダリング時のオーバーヘッドになり得るので削除するのが望ましい。が、再開発時のことも考慮して残しておきたい場合は、ダミーデータのimport文とif (this.debug)内の処理をそれぞれコメントアウトしておけば良いだろう。