php ムスタッシュ風テンプレートエンジン 自作

参考

https://qiita.com/tak-solder/items/1718cc91daefad41efed
https://qiita.com/tak-solder/items/87bc4dd4803654c0c84a

やりたいこと

  • ムスタッシュはよく見るので、そんな感じをイメージして実装。
  • phpテンプレートのように使う。
  • 拡張子はムスタッシュのmを2回重ねて、index.mm.phpのようにする。
  • cacheも使えるようにする。
  • 特定のphpキーワードを実行可能。

フォルダ構成

  • / (ルート)
    • /cache (キャッシュフォルダ)
    • /views (テンプレート用フォルダ)
      • /layout
      • index.mm.php
    • ViewMustache.php (ムスタッシュ風テンプレートエンジン)
    • index.php

index.php

<?php
declare(strict_types=1);

ini_set('display_errors', '1');
error_reporting(-1);

include __DIR__ . '/ViewMustache.php';
const VIEW_DIR = __DIR__ . '/views/';
const CACHE_DIR = __DIR__ . '/cache/';

$view = new ViewMustache(VIEW_DIR, CACHE_DIR);
echo $view->render('index', [
  'title' => 'Articles',
  'articles' => [
    'article1',
    'article2',
    'article3',
  ],
  'hoge' => 0,
  'base_url' => 'http://example.com'
]);

ViewMustache.php

<?php
declare(strict_types=1);

/**
 * @link https://qiita.com/tak-solder/items/1718cc91daefad41efed
 * @link https://qiita.com/tak-solder/items/87bc4dd4803654c0c84a
 */
class ViewMustache {
  private $view_dir = '';
  private $cache_dir ='';
  private $param = [];
  private $use_cache = true;
  private const MUSTACHE_EXTENSION = '.mm.php';
  private const PHP_KEYWORD = [
    'require',
    'foreach',
    'endforeach',
    'if',
    'elseif',
    'else',
    'endif',
  ];

  public function __construct(string $view_dir, string $cache_dir) {
    $this->view_dir = $view_dir;
    $this->cache_dir = $cache_dir;
  }

  public function render(string $file_name, array $param = []): string {
    $this->param = array_merge($this->param, $param);
    $view = $this->makeCache($file_name);
    extract($this->param);

    ob_start();
    ob_implicit_flush(0);
    require $view;
    return ob_get_clean();
  }

  private function searchPHPKeyword($word): bool {  return preg_match('/^(' . join('|', self::PHP_KEYWORD) . ')/', $word) === 1; }

  /**
   * sha1_file()でファイルのハッシュ値の計算をし、
   * テンプレートファイルが更新される毎に値が変わるので、
   * 変更された場合、新規のキャッシュファイルが生成される。
   */
  private function makeCache(string $file_name): string {
    $view_file = $this->view_dir . $file_name . self::MUSTACHE_EXTENSION;
    $cache_file = $this->cache_dir . sha1_file($view_file);

    if ($this->checkCache($view_file, $cache_file)) { return $cache_file; }

    $this->write($view_file, $cache_file);
    return $cache_file;
  }

  private function write(string $view_file, string $cache_file) {
    $source = file_get_contents($view_file);
    // 改行削除
    $source = str_replace(PHP_EOL, '', $source);
    // ムスタッシュキーワード検索
    $source = preg_replace_callback('#\{\{(.*?)\}\}#', function ($m) {
      $v = trim($m[1]);

      // 予約語なら命令を実行する
      if ($this->searchPHPKeyword($v)) {
        // requireの場合、親ファイル呼び出し
        if (strpos($v, 'require') === 0) {
          $file = preg_split("/\s/", $v)[1];
          $file = trim($file, "\'\"");
          return $this->render($file);
        }

        return '<?php ' . $v . ' ?>';
      }

      // そうでないなら変数として扱う
      return '<?= ' . $v . ' ?>';
    }, $source);

    file_put_contents($cache_file, $source, LOCK_EX);
  }

  /**
   * 下記の条件が全てtrueの場合のみキャッシュを使う
   * 
   * requireで呼び出しているファイルのキャッシュが存在する
   * 現在のビューのキャッシュファイルが存在する
   * キャッシュを使う
   */
  private function checkCache(string $view_file, string $cache_file): bool {
    if (!$this->checkRequireCache($view_file)) {  return false; }
    if (!is_file($cache_file)) { return false; }
    if (!$this->use_cache) { return false; }

    return true;
  }

  /**
   * require命令から呼び出されているファイルのキャッシュを確認する
   */
  private function checkRequireCache(string $file_name): bool {
    $source = file_get_contents($file_name);
    preg_match_all('#\{\{(.*require .*)\}\}#', $source, $match_list);
    $requires = $match_list[1];

    foreach ($requires as $require) {
      $str = preg_split("/\s/", $require)[2];
      $require_file = trim($str, "\"\'");

      $cache_file = $this->cache_dir . sha1_file($this->view_dir . $require_file . self::MUSTACHE_EXTENSION);
      if (!is_file($cache_file)) { 
        return false;
      }
    }

    return true;
  }
}

index.mm.php

<h2>Hello Index.php {{ $title }}</h2>

{{ require 'layout/dummy' }}
{{ require 'layout/base' }}

<h2>{{ $title }}</h2>
<ul>
  {{ foreach ($articles as $article): }}
  <li>{{ $article }}</li>
  {{ endforeach; }}
</ul>

{{ if ($hoge === 0): }}
<p>hoge = 0</p>
{{ elseif ($hoge === 1): }}
<p>hoge = 1</p>
{{ else: }}
<p>hoge = else</p>
{{ endif; }}

dummy.mm.php

<h3>++++ dummy ++++</h3>

base.mm.php

<h3>++++ BASE ++++</h3>
<p>{{ $base_url }}</p>

出力 phpテンプレート

<h2>Hello Index.php <?= $title ?></h2>

<h3>++++ dummy ++++</h3>
<h3>++++ BASE ++++</h3>
<p>http://example.com</p>

<h2><?= $title ?></h2>
<ul>
  <?php foreach ($articles as $article): ?>
  <li><?= $article ?></li>
  <?php endforeach; ?>
</ul>

<?php if ($hoge === 0): ?>
<p>hoge = 0</p>
<?php elseif ($hoge === 1): ?>
<p>hoge = 1</p>
<?php else: ?>
<p>hoge = else</p>
<?php endif; ?>