Wagtailのすすめ(5) StreamFieldに独自ブロックを追加しよう

# はじめに

Wagtailが用意してくれているフィールドの中に`StreamField`と呼ばれるものがある，これを用いると，複数の異なるブロックを好きな順序で組み合わせてコンテンツを構成していくことができる．例えば，Google Colaboratoryでコードブロックとテキストブロックを組み合わせていくようなイメージだ．

今回は，`PostPage`クラスを追加し，その中でこの`StreamField`を使ってみることにしよう．

# StreamFieldをもつページクラスの追加

ということで，最初に`StreamField`をもつ新しいページクラス`PostPage`をcms/models.pyの中で下記のように定義する．

```python
from django.db import models

from modelcluster.fields import ParentalKey

from wagtail.core.models import Page, Orderable
from wagtail.core.fields import RichTextField, StreamField
from wagtail.core.blocks import RawHTMLBlock, RichTextBlock

from wagtail.admin.edit_handlers import FieldPanel, InlinePanel, PageChooserPanel, StreamFieldPanel
from wagtail.images.edit_handlers import ImageChooserPanel

class TopPage(Page):
    ...

class PlainPage(Page):
    ...

class ListPage(Page):
    ...
    subpage_types = ['cms.ListPage', 'cms.PostPage']
    ...

class PostPage(Page):
    cover_image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+'
    )
    intro = models.CharField(max_length=250)
    main_body = StreamField([
        ('rich_text', RichTextBlock(icon='doc-full', label='Rich Text')),
        ('html', RawHTMLBlock(icon='code', label='Raw Html')),
        ('code', CodeBlock(icon='code', label='Pretty Code')),
    ])

    content_panels = Page.content_panels + [
        ImageChooserPanel('cover_image'),
        FieldPanel('intro'),
        StreamFieldPanel('main_body'),
        InlinePanel('related_pages', label="Related Pages"),
    ]

    parent_page_types = ['cms.ListPage']
    subpage_types = []

    def get_top_page(self):
        ...

    def get_breads(self):
        ...

    def get_context(self, request):
        ...

class NavItems(Orderable):
    ...

class RelatedPages(Orderable):
    ...
```

これまでに定義してきた`TopPage`，`PlainPage`，`ListPage`などとよく似ていることがわかる．なお，テンプレートと同じように，例えば`PlainPage`をベースにして，他のページクラスをそれを継承したサブクラスとして定義する方法も考えられるが，ここではそれには立ち入らない．

これまでのページとの主な相違点として，`main_body`が`RichTextField`ではなく`StreamField`で定義されていることを確認しよう．そして，`content_panels`を見ると，`StreamField`の編集パネルは`StreamFieldPanel`と指定しておけばよさそうなこともわかる．これらの`SteamField`に関係する箇所以外は特に説明の必要はないと思う．

`StreamField`でコンテンツを構成していく際に利用可能なブロックは，引数の中のリストに列挙されている．それらのうち，`RichTextBlock`と`RawHTMLBlock`は，Wagtailがデフォルトで用意してくれているものであり，それらの引数`icon`や`label`は管理サイトの編集画面に表示されるアイコンやラベルを指定している．`RichTextBlock`は，リッチテキスト形式で文書を投入するためのブロック，`RawHTMLBlock`は，htmlのコードを直接そのまま投入するためのブロックである．

一方，`CodeBlock`は（`RawHTMLBlock`を継承して）独自に定義したブロックである．これについては，次の節で少し詳しく説明しよう．

# 独自ブロックの追加

`StreamField`に独自ブロックを追加する例として，[Google Code Prettify](https://github.com/googlearchive/code-prettify)を用いてシンタックスハイライトされたプログラムコードを表示するためのブロック`CodeBlock`を追加してみよう．自作のブロックを含めるためには，そのブロックのクラスと，それを表示するためのテンプレートを用意する必要がある．

まず，`CodeBlock`クラスを定義しよう．cms/blocks.pyというファイルを作成し，その中に下記のコードを書き込む．

```python
from wagtail.core.blocks import RawHTMLBlock

class CodeBlock(RawHTMLBlock):
    class Meta:
        template = 'cms/blocks/code.html'
```

これを見ればわかるように，`CodeBlock`は実質的にはデフォルトの`RawHTMLBlock`と同じであるが，`Meta`に指定してあるように，レンダリングの際に利用するテンプレートが変更されている．

次にこのテンプレートを用意する．templates/cms/の下にblocks/というディレクトリを追加し，その中にcode.htmlというファイルを作成して，下記のコードを書き込む．

```html
<pre class="prettyprint">
<code>{{ value }}</code>
</pre>
```

このように，`<pre>`タグに`class="prettyprint"`を指定しておくことでGoogle Code Prettifyが適用されるはずである．ただし，Google Code PrettifyのためのスクリプトをCDNから読み込んでおく必要がある．これは，templates/cms/base.htmlに下記のように追加した．

```jinja
...
{% block extra_js %}
    ...
    <script src="https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js"></script>
{% endblock %}
```

この段階で，管理サイトにログインして，いずれかの`ListPage`クラスのページの下に`PostPage`のページをいくつか追加しておこう．

# ポストページのテンプレート

以上で準備が整ったので，`PostPage`クラスのページの表示に進む．`PostPage`はブログポストをイメージしたページなので，ジャンボトロンの中に投稿日時，もしくは更新日時を表示させるようにしてみよう．そこで，まずtemplates/cms/plain_page.htmlのheaderブロック内のテキスト部分を下記のようにheader_textブロックで囲んでおく．

```jinja
...
{% block header %}
    ...
        <div class="container">
            {% block header_text %}
                <h1 class="display-4">{{ page.title }}</h1>
                <p class="lead">{{ page.intro }}</p>
            {% endblock %}
        </div>
    </div>
{% endblock %}
...
```

次に，このtemplates/cms/plain_page.htmlを継承して，templates/cms/post_page.htmlを次のように作成した．

```jinja
{% extends 'cms/plain_page.html' %}
{% load wagtailcore_tags %}

{% block header_text %}
    {{ block.super }}
    {% if page.last_published_at > page.first_published_at %}
        <span>Last modified on {{ page.last_published_at|date }}</span>
    {% else %}
        <span>Posted on {{ page.first_published_at|date }}</span>
    {% endif %}
{% endblock %}


{% block main_body %}
    <div class="rich-text my-5">
        {% for block in page.main_body %}
            {% include_block block %}
        {% endfor %}
    </div>

    {% for item_page in page_list %}
        <hr>
        <div class="my-4">
            <a href="{% pageurl item_page %}">
                <h4>{{ item_page.title }}</h4>
                {{ item_page.specific.intro }}
            </a>
            {% if item_page.last_published_at <= item_page.first_published_at %}
                <p>Posted on {{ item_page.first_published_at|date }}</p>
            {% else %}
                <p>Last modified on {{ item_page.last_published_at|date }}</p>
            {% endif %}
        </div>
    {% endfor %}
    <hr>
{% endblock %}
```
header_textブロックの中を見ると，何度か更新されているページは最終更新日，そうでないページは投稿日がそれぞれ表示されるようになっていることが見て取れる．

また，main_bodyブロックの中の最初の`<div>`を見ると，テンプレートの中で`StreamField`を処理する方法がわかる．具体的には，`for`文でブロックを順に取り出して，`include_block`すればよい．

実際にブラウザに表示させてみて，プログラムコードに無事シンタックスハイライトが適用されていることを確認しておこう．

# RichTextフィールドでのコードハイライト

上では`StreamField`に独自ブロックを追加することでコードハイライトを行ってみたが，別の方法として，`RichTextField`にコードハイライトの機能を盛り込むこともできる．ここでは，おまけとして，その方法も簡単に紹介しておこう．

Wagtailで用いられているリッチテキストエディタDraftailには，[ここ](https://docs.wagtail.io/en/stable/advanced_topics/customisation/extending_draftail.html?highlight=register_rich_text_features)に説明されているように，独自の編集機能を追加できるようになっている．

ここでは，コードハイライトのための編集機能を追加してみる．具体的には，cms/wagtail_hooks.pyというファイルを作成し，下記の内容を書き込めばよい．この結果，リッチテキストエディタのツールバーにCodeというボタンが追加される．そして，ある範囲を選択してこのボタンを押すと，その範囲のテキストにこの編集機能が適用される．すなわち，そのテキストは，`tag`に指定に従って，`<pre class="prettyprint">`というタグで囲まれることになる．

```python
import wagtail.admin.rich_text.editors.draftail.features as draftail_features
from wagtail.admin.rich_text.converters.html_to_contentstate import BlockElementHandler
from wagtail.core import hooks

@hooks.register('register_rich_text_features')
def register_code_feature(features):
    feature_name = 'code'
    type_ = 'code'
    tag = 'pre class="prettyprint"'

    control = {
        'type': type_,
        'label': 'Code',
        'description': 'code',
        'element': 'code',
    }

    features.register_editor_plugin(
        'draftail', feature_name, draftail_features.BlockFeature(control)
    )

    features.register_converter_rule('contentstate', feature_name, {
        'from_database_format': {tag: BlockElementHandler(type_)},
        'to_database_format': {'block_map': {type_: tag}},
    })

    features.default_features.append(feature_name)
```

# おわりに

今回は，`StreamField`の便利さを紹介するとともに，`RichTextField`に簡単な編集機能を追加する方法にも触れてみた．

次回は，公式サイトのチュートリアル[Your first Wagtail site](https://docs.wagtail.io/en/stable/getting_started/tutorial.html)でも扱われている，タグとカテゴリの機能を追加してみたいと思う．

# リンク

- [Wagtailのすすめ(1) DjangoベースのCMSを比較・検討してWagtailを選択した話](https://qiita.com/j54854/items/96396f24fe8ce9b60d05)
- [Wagtailのすすめ(2) ページを定義し，編集し，表示してみよう](https://qiita.com/j54854/items/3dcc7f6ddf54daf1a6b6)
- [Wagtailのすすめ(3) ページのツリー構造を理解し，利用しよう](https://qiita.com/j54854/items/f8c2cd6fb02bbc36a43d)
- [Wagtailのすすめ(4) テンプレートにコンテキストを渡そう](https://qiita.com/j54854/items/c5ae6b0d6e3520af8999)
- [Wagtailのすすめ(6) カテゴリとタグを追加しよう](https://qiita.com/j54854/items/111fb99eadfac6a26c29)