PHP
twig
静的サイトジェネレーター

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

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

日本語の記事があまり無いようだったので、紹介を兼ねて一通りのページを作成してみました。

またテンプレートエンジンのTwigの動きもなんとなくわかるように、{% block xxx %}{{ include() }}を使った内容にしてあります。

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


準備

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


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


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/* は削除。


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によるレイアウトの指定ができなくなります。


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で自動的に使い分けられます。


sculpin_site_dev.yml

imports:

- sculpin_site.yml
locale: ja


sculpin_site_prod.yml

imports:

- sculpin_site.yml
locale: ja


content-type

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

今回はdevelopmentsとしました。


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] }} でページ内から参照できます。


sculpin_site_dev.yml

  ...

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


app/config/sculpin_services.yml

TwigExtensionを使えるようにします。TwigExtension自体は sculpin/sculpin に含まれているので、設定するだけで使えるようになります。


sculpin_services.yml

parameters:

services:
twig.extension.text:
class: Twig_Extensions_Extension_Text
tags:
- { name: twig.extension }


レイアウトの作成


source/templates/base.twig.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を引き継いだテンプレートを作成します。


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 %}



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>



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件の投稿が表示されるページとして作成。


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 を読み込んでリンクの前に表示するようにしてあります。


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

タグ一覧のページ。


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から生成されるページからここに飛んできます。


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形式のデータを指定できます。


about.md

---

title: About this site

layout: default

---

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



記事ファイルの作成

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


bash

$ touch source/developments/1st.md && vim $_



1st.md

---

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

tags: [initial]

---

hi.
this is my first post.

thanks.



出力

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



$ php vendor/bin/sculpin cache:clear && \

php vendor/bin/sculpin generate --env=dev --verbose --clean --watch



  • cache:clear でキャッシュクリア


  • --env[ENV] を指定


  • --cleanoutput_[ENV]の中身を消去


  • --watch でファイルに変更があるたび自動で生成

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

また php vendor/bin/sculpin generate --port=[PORT] でポートも指定できます。


おまけ


簡易サーバーを使用する

すでに生成されたファイルがあるなら、簡易サーバーを利用できます。

URLは http://localhost 固定(?)のようです。



$ php vendor/bin/sculpin serve --port=8000 --env=dev



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

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


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とおなじ?