🌶️

Grav CMS の Flex Objects でマイクロブログを作る

に公開

前回の記事 では、 Grav の開発環境を作ってみました。
今回は、その開発環境で、 Flex Objects というデータ型を作ってみます。

Flex Objects とは

Grav 1.7 以降で使える、大量のデータを管理する仕組みです。
各データには、データ型の制約がつけられます。

WordPress でいうところのカスタムフィールド、 Astro でいうところの Content collection、 Hugo でいうところの Data source に近い仕組みです。

Flex Objects の利点のひとつとして、 blueprint という YAML 設定ファイルを書くだけで、管理パネルから CRUD 操作できるようになります。

セットアップ

セットアップ方法は、以前の記事で紹介したとおりです。
環境によって、多少違うかもしれませんが、おおむね、以下のようになるはずです:

bash
cd ~/your/develop/directory/
wget -O mygrav.zip https://getgrav.org/download/core/grav/latest
unzip mygrav.zip
rm mygrav.zip
mv grav mydevelop
cd ./mydevelop/
php bin/gpm version  # 2.0 以上を確認
php bin/grav server  # 開発サーバーが動くことを確認
mkdir -p ./user/env/localhost/config/
touch ./user/env/localhost/config/system.yaml
vi ./user/env/localhost/config/system.yaml  # 開発用 system.yaml 設定
php bin/gpm install admin2
php bin/plugin login new-user  # スーパーユーザー作成
php bin/gpm install devtools

flex-objects プラグインのインストール

さらに今回は、 Flex Objects を Admin2 プラグインで動かせるようにする flex-objects プラグインをインストールします。
このプラグインは、本番環境でも必要になるプラグインです。

bash
php bin/gpm install flex-objects

データ型を設計する

ここまでで、開発環境の準備は整ったので、データ型を考えていきます。
データベースでいうところの、スキーマを設計するような段階です。

今回は、マイクロブログ(つぶやきブログ)を作ってみたいと思います。
そこで、以下のようなデータ型を考えました。

  • 投稿本文( body ) 必須項目、テキストエリアで書きたい
  • 投稿日時( time ) 必須項目
  • 添付画像( images ) 任意項目、4枚くらいアップロードしたい

つまり、この時点では、以下のような json で管理したいなと想像しています。
(後ほど実際のデータをお見せしますが、実際には Flex Objects の仕様で管理されることになり、以下の形式とは異なります。しかし、スキーマ設計の段階では、これくらいの見通しがあれば十分です)

{
    "body": "つぶやき本文 ... 。",
    "time": "2026-07-21 12:34:56",
    "images": [
        "/path/to/image1.jpg",
        "/path/to/image2.jpg",
        "/path/to/image3.jpg",
        "/path/to/image4.jpg"
    ]
}

カスタム Flex Objects プラグインを作成する

上記で考えたスキーマを実現するためのプラグインを作成しましょう:

bash
php bin/plugin devtools new-plugin

すると、この記事の執筆時点(2026年7月)では、以下のような内容を聞かれます:

  1. プラグイン名( microblog にしました)
  2. プラグインの説明
  3. 開発者の名前
  4. GitHub の ID
  5. 開発者の Eメール
  6. 対応する Grav のバージョン( 2.0 を選択)
  7. プラグインテンプレートの種類( flex を選択)
  8. Flex Object の名前(これも microblog にしました)
  9. Flex Objects のストレージの種類( folder にしました)

その後、 composer install するか聞かれるので、 yes を回答します。
もし忘れた場合は、そのディレクトリまで移動し、 composer install を実行します。

bash
cd ./user/plugins/microblog
composer install
cd ../../../

Blueprint を編集する

先ほど考えたデータ型を、 blueprint に反映させます。
デフォルトで使える入力欄の設定方法は、公式ドキュメントにリファレンスがあります。

先ほど考えた設計を、フォームの設定にすると、おおよそ以下のようになります。

yaml
form:
    validation: loose
    fields:
        body:
            type: textarea
            label: '投稿本文'
            validate:
                required: true
        time:
            type: datetime
            label: '投稿日時'
            validate:
                required: true
        images:
            type: file
            label: '添付画像'
            multiple: true
            destination: 'self@'
            limit: 4
            accept:
                - image/*

これを、先ほど作成したプラグインの flex objects の blueprint に反映させます。
合わせて、諸設定もしておきます。

管理パネルのサイドバーに表示されるアイコンを編集:

user/plugins/microblog/blueprints/flex-objects/microblog.yaml
config:
    admin:
        menu:
            list:
                icon: fa-comment

パーミッションを設定:

user/plugins/microblog/blueprints/flex-objects/microblog.yaml
config:
    admin:
        permissions:
            api.microblog:
                type: crudl

リスト表示での表示内容を設定:

user/plugins/microblog/blueprints/flex-objects/microblog.yaml
config:
    admin:
        list:
            title: name
            fields:
                body:
                time:

管理パネルでの検索に投稿本文を追加:

user/plugins/microblog/blueprints/flex-objects/microblog.yaml
config:
    data:
        search:
            fields:
                - body

先ほど考えたデータ型を定義:

user/plugins/microblog/blueprints/flex-objects/microblog.yaml
form:
    validation: loose
    fields:
        body:
            type: textarea
            label: '投稿本文'
            validate:
                required: true
        time:
            type: datetime
            label: '投稿日時'
            validate:
                required: true
        images:
            type: file
            label: '添付画像'
            multiple: true
            destination: 'self@'
            limit: 4
            accept:
                - image/*

ここまでで、最低限の Flex Objects ができています。
もし、ユーザーのパーミッションのスーパーユーザーを false にしていた場合は、下記のように、アカウントにパーミッションを与えてください。
今回は、開発環境なので、スーパーユーザーのままでも全く問題ありません。
将来的に、本番環境では、下記のパーミッション設定が必要になります。

user/accounts/user-name.yaml
access:
    api:
        super: false
        access: true
        microblog:
            list: true
            read: true
            create: true
            update: true
            delete: true

開発サーバーを立ち上げて、 https://127.0.0.1:8000/admin/ にアクセスし、ログインしてください。

bash
php bin/grav server

右側に、 Microblog が現れ、入力画面が表示されます。

+ Add ボタンで、データが作成できます。
とりあえず、ひとつ、つぶやいてみます。

すると、たとえば、以下のようなファイルが作成されるはずです。

user/data/microblog/.../item.json
{"body":"\u30c6\u30b9\u30c8\u672c\u6587\u3002\n\u30c6\u30b9\u30c8\u672c\u6587\u3002","time":"21-07-2026 12:34","images":{"image1.jpg":{"name":"image1.jpg","type":"image\/jpeg","size":72707,"path":"image1.jpg"}}}

ここまでで、Flex Object の CRUD 操作(フォルダの作成・編集・削除、及び一覧表示)ができるようになっているはずです。

入力フィールドの改善

とりあえず、管理パネルでの CRUD 操作ができるようになりました。
しかし、デフォルトのままでは、使いにくいところもあります。
少し、改修してみましょう。

日付をフォーマットしたい

初期設定では、 datetime のフォーマットが、 mm-dd-yyyy HH:MM です。しかし、これは日本ではあまり馴染みのないフォーマットですし、1分間に2つ以上つぶやくことがあるかもしれないので、 yyyy-mm-ddTHH:MM:SS に変更したいです。

Grav 2.0 で、管理パネルでの入力フォームが作りやすくなりました。
JavaScript のカスタムエレメントを作るだけです。

具体的には、以下のようなファイルを作り、その中に js ファイルを作成します:

bash
mkdir -p ./user/plugins/microblog/admin-next/fields/
touch ./user/plugins/microblog/admin-next/fields/tweetdatetime.js
user/plugins/microblog/admin-next/fields/tweetdatetime.js
const TAG = window.__GRAV_FIELD_TAG;

customElements.define(TAG, class extends HTMLElement {
  set field(f) { this._field = f; this._render(); }
  set value(v) { this._value = v; this._render(); }
  get value() { return this._value; }
  
  #dateInput = document.createElement('input');
  #timeInput = document.createElement('input');
  #nowButton = document.createElement('button');

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.#dateInput.type = 'date';
    this.#timeInput.type = 'time';
    this.#timeInput.step = '1';
    this.#nowButton.type = 'button';
    this.#nowButton.textContent = 'Now';
    this.shadowRoot.appendChild(this.#dateInput);
    this.shadowRoot.appendChild(this.#timeInput);
    this.shadowRoot.appendChild(this.#nowButton);
    this._render();
    
    this.#dateInput.addEventListener('change', ()=>{
      this.value = `${this.#dateInput.value}T${this.#timeInput.value}`;
      this._emitChange(this.value);
    });
    this.#timeInput.addEventListener('change', ()=>{
      this.value = `${this.#dateInput.value}T${this.#timeInput.value}`;
      this._emitChange(this.value);
    });
    this.#nowButton.addEventListener('click', ()=>{
      this.value = this.#formatDate(new Date());
      this._emitChange(this.value);
    });
  }

  #formatDate(dateObj) {
    const year = dateObj.getFullYear();
    const month = String(dateObj.getMonth()+1).padStart(2, '0');
    const date = String(dateObj.getDate()).padStart(2, '0');
    const hour = String(dateObj.getHours()).padStart(2, '0');
    const minute = String(dateObj.getMinutes()).padStart(2, '0');
    const second = String(dateObj.getSeconds()).padStart(2, '0');
    return `${year}-${month}-${date}T${hour}:${minute}:${second}`;
  }

  #validDate(dateString) {
    const exp = /[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}/;
    return exp.test(dateString);
  }

  #splitDate(dateString) {
    const empty = ['', ''];
    if(typeof dateString !== 'string') return empty;
    const split = dateString.split('T');
    return split.length === 2 ? split : empty;
  }

  _render() {
    if (!this.shadowRoot) return;
    const [dateString, timeString] = this.#splitDate(this.value);
    this.#dateInput.value = dateString;
    this.#timeInput.value = timeString;
  }

  _emitChange(newValue) {
    if(!this.#validDate(newValue)) {
      newValue = '';
    }
    this.dispatchEvent(new CustomEvent('change', {
      detail: newValue,
      bubbles: true
    }));
  }
});

具体的な書き方は、 公式ドキュメント を参照してください。
処理だけ AI に書いてもらって、 style を自分で当てるなどでも十分動作すると思います。

作成したフォーム入力欄を、管理画面に対応させるには、 Flex Objects の blueprint を編集します:

user/plugins/microblog/blueprints/flex-objects/microblog.yaml
form:
    fields:
        time:
            type: tweetdatetime

この状態で、開発サーバーから管理パネルを見ると、フォーマットが変わっているはずです。
さらに、保存してみると、期待したフォーマットで保存されていることが分かります:

user/data/microblog/.../item.json
{"body":"\u30c6\u30b9\u30c8\u672c\u6587\u3002","time":"2026-07-21T12:34:56"}

作成したデータをフロントエンドに表示する

管理パネル側の操作ができたので、次に、フロントエンド(サイトの公開側)を作っていきます。

ページを作成

ルーティングの起点になるページを作成します:

bash
mkdir ./user/pages/03.microblog/
touch ./user/pages/03.microblog/microblog.md
user/pages/03.microblog/microblog.md
---
title: 'つぶやきブログ'
flex:
    directory: microblog
---

マイクロブログ用のテンプレートを作成

各テンプレートを作っていきます。

bash
mkdir -p ./user/plugins/microblog/templates/
touch ./user/plugins/microblog/templates/microblog.html.twig
mkdir -p ./user/plugins/microblog/templates/flex/microblog/collection/
touch ./user/plugins/microblog/templates/flex/microblog/collection/default.html.twig
mkdir -p ./user/plugins/microblog/templates/flex/microblog/object/
touch ./user/plugins/microblog/templates/flex/microblog/object/default.html.twig
user/plugins/microblog/templates/microblog.html.twig
<!DOCTYPE html>
<html lang="ja">
<head><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
{% render tweets layout: 'default' %}
{% if tweets_meta.hasPrev %}
    <a href="?p={{ tweets_meta.prevPage }}">前のページ</a>
{% endif %}
{% if tweets_meta.hasNext %}
    <a href="?p={{ tweets_meta.nextPage }}">次のページ</a>
{% endif %}
</body>
</html>
user/plugins/microblog/templates/flex/microblog/collection/default.html.twig
<div>
{% for object in collection %}
    <div>
    {% render object layout: default %}
    </div>
{% endfor %}
</div>
user/plugins/microblog/templates/flex/microblog/object/default.html.twig
<p style="white-space: pre-wrap;">{{ object.body }}</p>
<p><time>{{ object.time }}</time></p>
{% if object.images %}
    {% set folder = object.getMediaFolder() %}
    {% for image in object.images %}
        {% set url = '/' ~ folder ~ '/' ~ image.name %}
        <a href="{{ url }}">
            <img style="width:150px;height:150px;object-fit:cover;object-position:center"
                src="{{ url }}" alt="{{ image.name }}"/>
        </a>
    {% endfor %}
{% endif %}

これらのテンプレートを読み込ませるために、 php ファイルでは、以下のように登録します:

user/plugins/microblog/microblog.php
    public function onPluginsInitialized(): void
    {
        $this->enable([
            'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
        ]);
    }
    public function onTwigTemplatePaths(): void
    {
        $twig = $this->grav['twig'];
        $twig->twig_paths[] = __DIR__ . '/templates';
    }

PHP で処理をして Twig 変数に代入

Twig 変数への代入などは、以前紹介した onTwigSiteVariables イベントで制御できます。
イベントを登録したうえで、たとえば、以下のように書けます:

user/plugins/microblog/microblog.php
    public function onTwigSiteVariables(): void
    {
        $page = $this->grav['page'];
        if($page->template() !== 'microblog') {
            return;
        }

        // flex collection を取得
        $tweets = $this->grav->get('flex')->getCollection('microblog');
        $tweets = $tweets->sort(['time' => 'DESC']);

        // 各サービスを取得
        $twig = $this->grav['twig'];
        $uri = $this->grav['uri'];

        // ページネーション
        $limit = 3;

        $max_count = count($tweets);
        $max_page = intdiv($max_count + $limit - 1, $limit);

        $current_page = filter_var($uri->query('p'), FILTER_VALIDATE_INT);
        if($current_page === false || $current_page < 1 || $max_page < $current_page) {
            $current_page = 1;
        }
        
        $start = ($current_page - 1) * $limit;
        $tweets = $tweets->limit($start, $limit);

        $twig->twig_vars['tweets'] = $tweets;
        $twig->twig_vars['tweets_meta'] = [
            'hasPrev' => 1 < $current_page,
            'prevPage' => $current_page - 1,
            'hasNext' => $current_page < $max_page,
            'nextPage' => $current_page + 1,
        ];
    }

開発サーバーを立ち上げて、 /microblog?p=2 などを表示すると、ページネーション付きのマイクロブログが出てくるはずです。

まとめ

今回は、 Grav の Flex Objects 機能を使って、データ操作(CRUD)からフロントエンド表示までを一気に紹介しました。
CRUD 操作が blueprint の設定だけでできるところも便利ですし、 PHP 部分を追記していけば、検索やハッシュタグなどの追加実装も、簡単にできるはずです。
とても便利な機能だと思うので、ぜひ使ってみてください。

Discussion