PHPで動くSymfony系SSG Sculpinを使ってみた

Sculpinは静的サイトジェネレータ（Static Site Generator, SSG）と呼ばれるもので、PHPで動きます。Markdown、Twigを使って静的ページを作成することができます。

日本語の記事があまり無いようだったので、紹介を兼ねて一通りのページを作成してみました。
またテンプレートエンジンのTwigの動きもなんとなくわかるように、`{% block xxx %}`や`{{ include() }}`を使った内容にしてあります。

普段Symfonyを使っている自分には馴染みやすく、使いやすいものでした。


## 準備

インストールにはcomposerを使用するので、composerが使える環境が必要です。

```sh:bash
$ php -v
PHP 7.3.4 (cli) (built: Apr  2 2019 13:48:50) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.3.4, Copyright (c) 1998-2018 Zend Technologies

$ curl -S https://getcomposer.org/installer | php
```


## インストール

公式で紹介されているものは以下の2つ。

* sculpin/sculpin-blog-skeleton
* sculpin/sculpin

主な違いは sculpin/sculpin-blog-skeleton の場合ブログなどを作るのに便利なwebpackなどが同時にインストールされます。
今回は sculpin/sculpin を使って進めます。

### sculpin/sculpin

```sh:bash
$ mkdir ~/sculpin && cp ./composer.phar ~/sculpin/composer.phar
$ cd ~/sculpin
$ php composer.phar require sculpin/sculpin
$ php vendor/bin/sculpin init
```

初期状態の構成は以下のようになっています。

```
project_root/
|-- app/
|   |-- config/
|   |   |-- sculpin_kernel.yml
|   |   `-- sculpin_site.yml
|   `-- SculpinKernel.php
|-- composer.json
|-- composer.lock
|-- composer.phar
|-- source/
|   |-- index.md
|   `-- _views/
|       `-- default.html
`-- vendor/
    |-- bin/
    |   |-- sculpin
    |   `-- ...
    `-- ...
```


あとで作るので、 `source/*` は削除。

```sh:bash
$ rm -r source/*
```


## 設定

ref: https://sculpin.io/documentation/
ref: https://sculpin.io/documentation/bundles/SculpinPostsBundle/

初期状態では以下のような設定になっています。

* `project_root/source/_layout/` にページの基本レイアウトファイルを配置。Twig形式
* `project_root/source/_views/` に各レイアウトファイルを配置。Twig形式。`layout: xxx`で指定する際はここのレイアウトファイルを指定する
* `project_root/source/assets/` にcss、js、画像等のファイルを配置
* `project_root/source/_*` 内に記事ファイルを配置。`source/_blog`だったり、`source/_posts`だったり

`project_root/source/_*/` 内の各ファイルはTwig形式またはMarkdown形式で記述します。
Markdown形式で記述された場合、本文は `page.blocks.content` として扱われます。

### app/config/sculpin_kernel.yml

Symfony4.*系と構造を近づけたかったので、以下のような設定にします。
`sculpin_twig.extensions`で拡張子を指定する際の注意として、空（`''`）を指定しておかないと後述の`layout: xxx`によるレイアウトの指定ができなくなります。

```yaml:sculpin_kernel.yml
sculpin_twig:
    source_view_paths: ['templates']    # sourceからの相対パス。レイアウトファイルはここに配置
    extensions: ['', 'twig.html']       # Twigファイルの拡張子を指定

sculpin_markdown:
    extensions: ['md']  # Markdownファイルの拡張子を指定
```

`sculpin_site_[ENV]`の形式で設定ファイルをわけることもできます。
これも後述しますが、 `php vendor/bin/sculpin generate` でファイルを生成する際に指定したenvで自動的に使い分けられます。

```yaml:sculpin_site_dev.yml
imports:
    - sculpin_site.yml
locale: ja
```

```yaml:sculpin_site_prod.yml
imports:
    - sculpin_site.yml
locale: ja
```

#### content-type

次にcontent-typeを設定します。

今回は`developments`としました。

```yaml:sculpin_kernel.yml
  ...
sculpin_content_types:
    posts:              # sculpin/sculpinに同梱されている
        enabled: false  # 使用しないcontent-typeは無効化できる
    developments:
        enabled: true
        type: path
        path: developments  # 記事ファイルを配置する場所。sourceからの相対パス
        singular_name: developments
        layout: default     # デフォルトのレイアウト。拡張子を除いて指定する
        permalink: :year/:month/:day/:basename/index.html   # 出力時のディレクトリ構成を指定
        taxonomies:
            - tags
```

### app/config/sculpin_site_dev.yml

ここで設定した値はTwig形式で記述した際に `{{ site.[KEY] }}` でページ内から参照できます。

```yaml:sculpin_site_dev.yml
  ...
title: the DEVELOPMENTS
subtitle: ~
url: http://localhost   # ここで指定せず引数で渡すこともできる
```

### app/config/sculpin_services.yml

[TwigExtension](https://twig-extensions.readthedocs.io/en/latest/index.html)を使えるようにします。TwigExtension自体は sculpin/sculpin に含まれているので、設定するだけで使えるようになります。

```yaml:sculpin_services.yml
parameters:
services:
    twig.extension.text:
        class: Twig_Extensions_Extension_Text
        tags:
            - { name: twig.extension }
```


## レイアウトの作成

### source/templates/base.twig.html

まずは基礎になるレイアウトを作成します。

```html:base.twig.html
<!doctype html>
<html lang="{{ site.locale }}">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    {% block meta %}{% endblock %}

    <title>{% if page.title is not empty %}{{ page.title }} | {% endif %}{{ site.title }}{% if site.subtitle is not empty %} - {{ site.subtitle }}{% endif %}</title>

    {% block stylesheets %}{% endblock %}
  </head>
  <body>
    {% block header %}{% endblock %}

    {% block main %}{% endblock %}

    {% block footer %}{% endblock %}

    {% block javascripts %}{% endblock %}
  </body>
</html>
```

### source/templates/default.twig.html

`base.twig.html`を引き継いだテンプレートを作成します。

```html:default.twig.html
{% extends 'base' %}

{% block stylesheets %}
  <style>
    :root {
      --header-back-color: #fff;
      --header-fore-color: #444;
      --header-border-color: #ddd;
      --header-height: auto;
    }

    header {
      height: auto;
      color: var(--header-fore-color);
      background: var(--header-back-color);
      white-space: nowrap;
      overflow-x: auto;
      overflow-y: hidden;
    }

    header.sticky {
      height: var(--header-height);
      border-bottom: var(--header-border-color) solid 0.0625rem;
      position: sticky;
      z-index: 1101;
      top: 0;
    }

    nav > .nav-list {
      list-style: none;
      margin-bottom: 0;
    }

    .nav-list > .nav-item {
      position: relative;
      float: left;
      margin-bottom: 0;
    }

    .nav-item > .nav-link {
      font-size: 1.6rem;
      font-weight: 600;
      letter-spacing: .2rem;
      line-height: 6.5rem;
      margin-right: 4rem;
      text-decoration: none;
      text-transform: uppercase;
    }
  </style>
{% endblock %}

{% block header %}
  <header class="sticky">
    <h1 style="text-align: center;">{{ site.title }}</h1>
    <nav>
      <ul class="nav-list">
        <li class="nav-item"><a class="nav-link" href="{{ site.url }}">home</a>
        <li class="nav-item"><a class="nav-link" href="/archives">archives</a>
        <li class="nav-item"><a class="nav-link" href="/tags">tags</a>
        <li class="nav-item"><a class="nav-link" href="/about">about</a>
      </ul>
    </nav>
  </header>
{% endblock %}

{% block main %}
  <article>
    <header>
      <h2>{{ page.title }}</h2>
      {% if page.date is defined %}
        {{ include('_publishedOn.twig.html', {
          timestamp: page.date,
        }, with_context = false) }}
      {% endif %}
      <br>
      {% if page.tags is not empty %}
        {{ include('_tags.twig.html', {
          tags: page.tags,
        }, with_context = false) }}
      {% endif %}
    </header>
    <main>
      {% block content %}{% endblock %}
    </main>
  </article>
  <section class="paginator">
    {% if page.previous_developments or page.next_developments is not empty %}
      {% if page.next_developments is not empty %}
        Next: <a href="{{ page.next_developments.url }}">{{ page.next_developments.title }}</a>
      {% endif %}
      {% if page.previous_developments is not empty %}
        Prev: <a href="{{ page.previous_developments.url }}">{{ page.previous_developments.title }}</a>
      {% endif %}
    {% endif %}
  </section>
{% endblock %}

{% block javascripts %}
  <script>
    (function() {
      const headerHeight = document.querySelector('header').offsetHeight;

      document.documentElement.style.setProperty('--header-height', headerHeight + 'px');
    }());
  </script>
{% endblock %}
```

```html:source/templates/_publishedOn.twig.html
<time datetime="{{ timestamp|date("Y-m-d") }}T{{ timestamp|date("H") }}:{{ timestamp|date("i") }}+0900">{{ timestamp|date("F") }} {{ timestamp|date("j") }}, {{ timestamp|date("Y") }}</time>
```

```html:source/templates/_tags.html.twig
Tags:
{% for tag in tags %}
  <a href="/tags/{{ tag|url_encode(true) }}">{{ tag }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
```


## 各ページの作成

一覧ページと個別の投稿を作成。

各ファイルの冒頭でYAML形式のデータを指定すると、 `{{ page.[KEY] }}` で参照できます。

### source/index.twig.html

最初に表示されるページ。

今回は最新5件の投稿が表示されるページとして作成。

```html:source/index.twig.html
---
title: Home

layout: default     # source/developments 配下なら指定はいらない
use: [developments] # sculpin_kernel.ymlのsculpin_content_typesのキー
---

{% block main %}
  <main>
    <h2>Recent Posts</h2>
    {% for post in data.developments|slice(0, 5) %}
      <article>
        <h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
        {% if post.tags is not empty %}
          {{ include('_tags.twig.html', {
            tags: post.tags,
          }, with_context = false) }}
        {% endif %}
        <p>{{ post.blocks.content|striptags|truncate(140) }}  {# truncate()がTwigExtensionに含まれます #}
      </article>
    {% endfor %}
  </main>
{% endblock %}
```

### source/archives.twig.html

過去の記事を一覧表示するページ。

少し複雑ですが、それぞれの記事の `date` を読み込んでリンクの前に表示するようにしてあります。

```html:archives.twig.html
---
title: Archives

layout: default
generator: pagination
pagination:
    provider: data.developments
    max_per_page: 30
use: [developments]
---

{% block main %}
  <h2>{{ page.title }}</h2>
  {% set year = '' %}

  {% for post in page.pagination.items %}
    <section>
      <time datetime="{{ post.date|date("Y-m-d") }}T{{ post.date|date("H") }}:{{ post.date|date("i") }}+0900">
        {% set postedYear %}{{ post.date|date("Y") }}{% endset %}
        <span{% if year == postedYear %} style="visibility: hidden;"{% endif %}>
          {% if year != postedYear %}
            {% set month = '' %}
            {% set year = postedYear %}
          {% endif %}
          {{ year }}
        </span>

        {% set postedMonth %}{{ post.date|date("m") }}{% endset %}
        <span{% if month == postedMonth %} style="visibility: hidden;"{% endif %}>
          {% if month != postedMonth %}
            {% set day = '' %}
            {% set month = postedMonth %}
          {% endif %}
          {{ month }}
        </span>

        {% set postedDay %}{{ post.date|date("d") }}{% endset %}
        <span{% if day == postedDay %} style="visibility: hidden;"{% endif %}>
          {% if day != postedDay %}
            {% set day = postedDay %}
          {% endif %}
          {{ day }}
        </span>
      </time>
      <a href="{{ post.url }}">{{ post.title }}</a>
    </section>
  {% endfor %}

  <section class="paginator">
    {% if page.pagination.previous_page or page.pagination.next_page is not empty %}
      {% if page.pagination.previous_page is not empty is not empty %}
        <a href="{{ page.pagination.previous_page.url }}">Newer</a>
      {% endif %}
      {% if page.pagination.next_page is not empty is not empty %}
        <a href="{{ page.pagination.next_page.url }}">Older</a>
      {% endif %}
    {% endif %}
  </section>
{% endblock %}
```

### source/tags.twig.html

タグ一覧のページ。

```html:tags.twig.html
---
title: Tags

layout: default
generator: pagination
pagination:
    provider: data.developments_tags
    max_per_page: 30
use: [developments_tags]
---

{% block main %}
  <h2>{{ page.title }}</h2>
  <ul>
    {% for tag, posts in page.pagination.items %}
      <li><a href="/tags/{{ tag|url_encode(true) }}">{{ tag }}</a> ({{ max(posts|keys) + 1 }})
    {% endfor %}
  </ul>

  <section class="paginator">
    {% if page.pagination.previous_page or page.pagination.next_page is not empty %}
      {% if page.pagination.previous_page is not empty %}
        <a href="{{ page.pagination.previous_page.url }}">Previous</a>
      {% endif %}
      {% if page.pagination.next_page is not empty %}
        <a href="{{ page.pagination.next_page.url }}">Next</a>
      {% endif %}
    {% endif %}
  </section>
{% endblock %}
```

### source/tags/tag.twig.html

そのタグがつけられている記事の一覧ページ。

`source/tags.twig.html`から生成されるページからここに飛んできます。

```html:tag.twig.html
---
title: Tag

layout: developments
generator: [developments_tag_index, pagination]
pagination:
    provider: page.tag_developments
    max_per_page: 30
---

{% block main %}
  <h2>{{ page.tag }}</h2>
  <ul>
    {% for post in page.pagination.items %}
      <li><a href="{{ post.url }}">{{ post.title }}</a>
    {% endfor %}
  </ul>

  <section>
    {% if page.pagination.previous_page or page.pagination.next_page is not empty %}
      {% if page.pagination.previous_page is not empty %}
        <a href="{{ page.pagination.previous_page.url }}">Previous</a>
      {% endif %}
      {% if page.pagination.next_page is not empty %}
        <a href="{{ page.pagination.next_page.url }}">Next</a>
      {% endif %}
    {% endif %}
  </section>
{% endblock %}
```

### source/about.md

よくあるアバウトページ。

内容が簡単なものであればMarkdown形式でも書けます。
またTwig形式のファイルと同様、冒頭でYAML形式のデータを指定できます。

```md:about.md
---
title: About this site

layout: default

---

generated by [sculpin](https://github.com/sculpin/sculpin)
```

### 記事ファイルの作成

一覧ページではない、個別の投稿は `sculpin_kernel.yml` の `sculpin_content_types.[CONTENT_TYPE].path` で指定したディレクトリ内に配置します。

```sh:bash
$ touch source/developments/1st.md && vim $_
```

```md:1st.md
---
title: 1st post
date: 2019-05-31 12:34

tags: [initial]

---

hi.
this is my first post.

thanks.
```

## 出力

最後に、ページを生成します。

```sh:例
$ php vendor/bin/sculpin cache:clear && \
  php vendor/bin/sculpin generate --env=dev --verbose --clean --watch
```

* `cache:clear` でキャッシュクリア
* `--env` で `[ENV]` を指定
* `--clean` で `output_[ENV]`の中身を消去
* `--watch` でファイルに変更があるたび自動で生成

`php vendor/bin/sculpin generate --url=[URL]` を指定すると、ファイル生成時に `{{ site.url }}` で参照する値を渡すことができます（ `sculpin_site.yml` で指定がある場合はそちらが優先されます）。
また `php vendor/bin/sculpin generate --port=[PORT]` でポートも指定できます。

## おまけ

### 簡易サーバーを使用する

すでに生成されたファイルがあるなら、簡易サーバーを利用できます。
URLは `http://localhost` 固定（？）のようです。

```sh:例
$ php vendor/bin/sculpin serve --port=8000 --env=dev
```

### テストファイルを作成する

paginationの動きを確認するためのテストファイルを作りたいときは以下のコマンドで作成できます。が、bash以外で動くかはわかりません……。

```sh:bash
# source/developments/dummy_xxx.md を100個作る
$ for i in `seq -w 001 100`; do cp source/developments/1st.md source/developments/dummy_$i.md; done
```

### 変数表

一部ですが、使用頻度の高そうなものを書いておきます。
Twigファイルからアクセスするときは `{{ page.title }}` のように参照します。

* page
   * title （そのページで設定していれば）
   * date （そのページで設定していれば）
   * tag （そのページで設定していれば）
   * generator （そのページで設定していれば）
   * pagination （そのページで設定していれば）
      * provider
      * items
         * FileSource:FilesystemDataSource:...
      * page
      * total_pages
      * total_items
      * previous_page
      * next_page
   * use （そのページで設定していれば）
      * ?
   * calculated_date
      * dateとおなじ？
   * layout
   * permalink
   * next_[CONTENT_TYPE]
      * 該当タイプの次の記事
      * title
      * url
   * previous_[CONTENT_TYPE]
      * 該当タイプの前の記事
      * title
      * url
   * url
   * relative_pathname
      * project_root/sourceからの相対パス
   * filename
   * blocks
      * twig形式で指定した`{% block ... %}`
   * [KEY]
      * その他yaml形式で埋め込んだ変数
        titleなど
* data
   * page.useが設定されていれば使える
   * [CONTENT_TYPE]
      * FileSource:FilesystemDataSource:...
      * page.pagination.itemsとおなじ？