Edited at
CureAppDay 8

静的サイトジェネレータ bulbo を作った話

More than 1 year has passed since last update.

この記事は、Node.js Advent CalendarCureApp Advent Calendar の8日目の記事です。

今日は静的サイトジェネレータの話です。


:pushpin: tl;dr

bulbo という静的サイトジェネレータを作った話です。既存のジェネレータに不満があって、gulp に馴染みのある方是非読んでみてください :smiley:


:bookmark: 静的サイトジェネレータのおさらい

静的サイトジェネレータというのは、静的サイトを作るツールのことです。例えば今流行りのジェネレータとして、jekyll, middleman, hugo などがあります。

これらのツールの特徴はミニマルな設定ファイルと、markdown などのメンテし易いファイル形式でコンテンツを記述しつつ、比較的規模が大きい静的サイトの html をまとめて出力できることです。

ひと昔前は、ブログといえば、ブログサービスとか、動的ページ生成のブログソフト(WordPress, MovableType など) を使うのが一般的でしたが、今では静的サイトとしてジェネレートされているブログを見かけることが多くなりました。(特にエンジニアのブログ)

ブログツールとして2012-2013年あたりから流行した静的サイトジェネレータですが、今では用途がかなり広がっているように思います。Slategitbook のようにドキュメンテーションを静的サイトジェネレータで出力するというものがあったり、自分の周辺では、web 開発の現場の裏でページのモックアップを静的サイトジェネレータで生成する例をよく見かけます。


:m: middleman

自分の好きな静的サイトジェネレータで middleman があります。middleman の機能で特に気に入っていたのが、Asset pipeline という機能 (v4 で削除されたようですが) で、内部的に rails のサブプロジェクトの Sprockets を使って、javascript / css の中で独自記法の require ディレクティブを書くことで、js / css の結合までを勝手にやってくれます。

例えば、下のような javascript を書いて:

//= require jquery

// ここに jquery が inline 展開される

$(function () {alert('hello')})

それを middleman を通して見ると、//= require jquery の部分が自動的に jquery のソースコードで置きかわります。この変換は再帰的に行われるため、require されたファイルの中でさらに require することでファイルを細かく分割することができます。

自分の用途では JS の規模が大きくなる (ファイル数 ~100 程度) ことが多かったため、このパイプライン機能はかなり便利に感じていました。


:warning: middleman の問題点

主に Asset pipeline が便利という理由で使っていた middleman ですが、ある時から逆に不便に感じるようになりました。

一つは外部モジュールが bower しか解決できない点です。2015年頃からフロントエンドの JS でも npm に publish するのが一般的になり、bower が衰退し始めるという流れの中で、bower しか解決できないモジュール解決システムはなんとなく不安があります。

もう一つは Sprockets の require が CommonJS / es6 module ではなく、単なるファイル結合(インライン展開)である点です。単なる結合であるということは、すべてのファイルの依存関係を自分の頭で考えてどういう順序で結合すれば矛盾が起きないかということを手動で担保する必要があります。ファイル数が 100を超える規模になると、新しいファイルが追加されるたびに、依存性をマニュアルで解決する作業が大きな手間となってきて開発がスケールできないと感じるようになりました。


:bulb: 必要なもの

この状況で必要なものは、CommonJS 準拠のバンドラ(つまり、browserify もしくは webpack) です。CommonJS 準拠のファイル結合ができれば、ファイル間の複雑な依存関係も require できちんと解決できます。また、npm とシームレスに接続できるのも心強いです。


middleman の browserify サポート :sob:

middleman コミュニティの中でも npm を require したいとか、browserify のバンドルをサポートしてほしいという issue がかなり以前から立っていて、いろいろな可能性が議論されていました。

しかし、最終的に middleman v4 でたどり着いた結論は、browserify / webpack を特にサポートするのではなく、外部コマンドを middleman が叩くという中途半端な仕組みでした。(External pipeline)

個人的に middleman の良さは設定ファイル (config.rb) にすべてのビルドの設定をまとめて、あとはコマンドを叩くだけで全てを任せられる点と感じていていたため、これでは middleman の設定の外にさらに自前で別のビルドパイプラインを構築する必要があるため、middleman の売りであった手軽さが失われていると感じます。

この時点で、自分は middleman の代替ツールを真剣に探し始めました。


:tropical_drink: gulp を静的サイトジェネレータとして使う

世の中にはすでに400個以上の静的サイトジェネレータがあって、JS で書かれたものだけでも数十あります。これだけあれば、browserify ができて、middleman と同じ使用感のジェネレータが見つかるだろうと思いましたが、意外と見つかりません。そもそも、markdown はビルドできるけど、js / css はビルドしないというものが多いです。

そんな中で、見つけたのがこの記事 "Considering a Static Site Tool? Learn Gulp." です。この記事の教えるところは、つまり、いわゆる静的サイトジェネレータを使うのはやめて、ただ gulp をきちんと使えば、それで用が足りる、という事です。

確かに、gulp なら、plugin の組み合わせでどういうビルドも理論上は組む事ができるはずです。ファイル監視 (watch) もできます。すでにそういう方向に向かいはじめていると思われる gulp プラグインもいくつか散見されます(gulp-front-matter, gulp-wrap など)。gulp はフロントエンドリソースビルドのデファクトになりつつあるツールでもあるため、エコシステムの豊富さが期待できるので、たとえ完全に欲しいビルドが既存のプラグインで組めなかったとしても、足りない部分を自分で補完すれば、残りの大部分はエコシステムのもので足りるという状況が期待できます。

ただし問題もあります。gulp はそもそも静的サイトジェネレータではないため、汎用的すぎるということです。つまり、今流行りのジェネレータが普通備えている機能を持っていません。例えば、ビルドタスクとサーブ/ウォッチタスクを自分で定義しなければなりません。複数のタスクで、ビルドソースは同じで、デスティネーションだけを変えるという事が gulp では自然には上手くできません。もちろん gulp は汎用ツールなので、それをするための関数などを自分でコツコツ書いていけば、出来ないことはないですが、いわゆる静的サイトジェネレータの手軽さが失われています。静的サイトを作るたびにビルドタスク/ウォッチタスクを手でプログラミングするようでは、手軽さがありません。

本当にやりたいことは、 gulp プラグインの結合で、ビルドパイプラインを宣言 すれば、そのパイプラインを ビルドするかサーブ/ウォッチするかはツールが自動的にやってくれる ようなものではないか?

そういう考え方のツールがあればそれが一番自分のニーズに合うはずだと思い、いろいろなジェネレータのドキュメントを読んでいきましたが、なかなか見つかりませんでした。

であればもう作るしかないと思って作ったのが、bulbo というツールです。


gulp を支える3つの技術

gulp に相当するようなツールを作るのは大変なのではないか、作ってもメンテナンスコストが高いのではないかと思うかもしれませんが、実は gulp の中身を再利用するのは意外と簡単です。

gulp は徹底して UNIX哲学 "Do one thing and one thing well" に従って開発されているツールです。gulp という名前のリポジトリは実はほとんど何の機能も実装していません。gulp はその下のツールをまとめて gulp という名前で publish しているだけです。

実質的に gulp の下ではタスク管理 (undertaker (gulp v4以降))、ファイルストリーム管理 (vinyl-fs)、ユーザ設定ファイル (gulp の場合、gulpfile.js) のハンドリング (js-liftoff) という主に3つのサブプロジェクトから成り立っています。それぞれが、gulp の要件にあまりひきづられず、かなり独立したデザインを持っていて、それらの機能の再利用が非常に簡単です。

bulbo の場合、ファイルストリーム管理と、ユーザ設定ファイル管理は、そのまま利用しています。タスクに関しては、gulp のように汎用的に定義できる必要性がないため利用していません。

bulbo でやっていることを大雑把に言えば、vinyl-fs でファイルストリームを発生させて (gulp.src(...) と同じ) 、js-liftoff でユーザに定義させたファイル (bulbo では bulbofile.js) からアセットの宣言を収集し、あとは、コマンドライン引数で、ビルド / サーブを出し分けるということをやっています。

以降では bulbo の使い方を紹介していきます。


bulbo


:dizzy: 使い方

まず、例として、pages/ というディレクトリの下にある、markdown ファイルを html にレンダリングして dist/ ディレクトリ以下に保存する、という例を考えてみましょう。

まず、bulbo をローカルに install してください。

npm install bulbo

次に bulbofile.js というファイル名で以下の内容のファイルを用意してください。


bulbofile.js

const { asset, dest } = require('bulbo')

const marked = require('gulp-marked')

asset('pages/**/*.md').pipe(marked())

dest('dist')


asset メソッドの引数にビルドしたいファイルの glob パターンを入れます。( asset('pages/**/*.md') )

.pipe(marked()) というメソッド呼び出しで、上の .md ファイルを marked() という gulp プラグインで変換するという宣言をしています。(gulp-marked は markdown パーサの gulp プラグインで、この変換で、markdown ファイルが html ファイルになります。)

すなわち、asset('pages/**/*.md').pipe(marked()) という表現で、pages/**/*.md に含まれるマークダウンファイルを marked() で変換してほしいという 宣言 をしたことになります。(なお、pipe は .pipe().pipe()... のように複数個チェーンすることが可能です。)

次に dest('dist') という呼び出しで、ビルド先は dist ディレクトリであるという宣言をしています。

次に、ビルド対象ファイルとして、pages/ 以下に .md ファイルを配置してください。例えば、下のようにマークダウンファイルを配置したとします。


pages

pages/

├── foo.md
├── bar.md
└── baz
   ├── ham.md
   └── spam.md

ここまで設定した状態で、./node_modules/.bin/bulbo build というコマンドを実行してください。コマンドが成功すると下のような出力が出ます。

$ ./node_modules/.bin/bulbo build

bulbo [11:01:58] Using: ./bulbofile.js
bulbo [11:01:58] building
bulbo [11:01:58] done

コマンド成功時は、./dist 以下に下のようなファイルが出力されます。


dist

dist/

├── foo.html
├── bar.html
└── baz
   ├── ham.html
   └── spam.html

以上で、dist/ に目的のファイルが生成され、サイトが生成されたことになります :star2:

また、ローカルマシンでサイトの動きを確認したい場合は、 ./node_modules/.bin/bulbo serve と実行してください。ローカルにサーバが立ち上がり、 次のように、localhost:7100/ 以下にビルドされたファイルがホストされます。

$ ./node_modules/.bin/bulbo serve

bulbo [20:23:36] Using: ./bulbofile.js
bulbo [20:23:37] serving
bulbo [20:23:37] Reading: 'pages/**/*.md'
bulbo [20:23:37] Server started at: http://0.0.0.0:7100/
bulbo [20:23:37] See debug info at: http://0.0.0.0:7100/__bulbo__
bulbo [20:23:37] Ready: 'pages/**/*.md'

デモレポジトリ

デバッグ用の URL http://0.0.0.0:7100/__bulbo__ にアクセスすると、下のようにブラウザ上で、ビルドされたファイルの一覧を見ることができます。

スクリーンショット 2016-12-06 11.06.57.png

また、serve 状態ではビルドしているファイルと同じファイルが自動的に watch されており、変更があった場合は、即座に変更を確認することができます。


:sunglasses: 少し複雑な例

もう少し複雑な例を考えてみましょう。

マークダウンファイルは上の例にプラスして、フロントマターのパースと、レイアウトテンプレート (エンジンは ejs とします) でラッピングすることを考えて見ましょう。js は browserify + babel で es2015 で書いたものをバンドルする例を考えて見ます。

この場合、bulbofile.js は例えば次のようになります。


bulbofile.js

const { asset, base } = require('bulbo')

const wrapper = require('layout-wrapper')
const marked = require('gulp-marked')
const frontMatter = require('gulp-front-matter')
const bundle = require('bundle-through')

base('source')

asset('source/js/*.js')
.watch('source/js/**/*.js')
.pipe(bundle({transform: 'babelify'}))

asset('source/pages/**/*.md')
.pipe(frontMatter({property: 'fm'}))
.pipe(marked())
.pipe(wrapper.ejs({
frontMatterProp: 'fm',
layout: 'source/layout'
}))


デモレポジトリ

説明していくと、base('source') で全ての asset の basepath が source であることを宣言しています。つまり、例えば、source/js/*.js という glob の asset は、[dest]/js/*.js という path にマッピングされます。この宣言がなければ、 source/js が basepath になってしまうため、[dest]/*.js にマッピングされます。

次に、asset('source/js/*.js') で、source/js 直下の js をビルドすると宣言しています。

.watch('source/js/**/*.js') で、source/js 以下の js を 再帰的に watch すると宣言しています。こうすることで、ビルドは、source/js/*.js のみされるが、ファイル監視は source/js/**/*.js 全体に対して行うことができます。browerify を使う場合のように、ビルドの起点と、監視すべきファイルの範囲が異なる場合に、.watch という指定が便利です。

.pipe(bundle({transform: 'babelify'})) で、browserify + babelify の変換をかけています。この変換で、require が適切にバンドルされながら、同時に es2015 の変換もされます。(詳しくは bundle-through のドキュメントを参照)

次に、asset('source/pages/**/*.md') でマークダウンファイルアセットの宣言をしています。

.pipe(frontMatter({property: 'fm'})) で、フロントマターをパースして、file の .fm プロパティにデータをセットしています。

.pipe(marked()) は先ほどと同様。

.pipe(wrapper.ejs({frontMatterProp: 'fm', layout: 'source/layout'})) で、ejs テンプレートを使って、ファイルコンテンツを、source/layout 以下にある、テンプレートファイルでラッピングするという変換をしています。概念的には、middleman のフロントマターの layout プロパティを見てテンプレートを選ぶ挙動と同等のものです。(詳しくは、layout-wrapper のドキュメント参照。)

以上の bulbofile.js の設定をした上で下のようにファイルを配置します。(ファイルの中身の詳細はデモレポジトリ を参照してください。)


files

source/

├── js
│   ├── bar.js
│   ├── foo.js
│   └── lib
│   └── baz.js
├── layout
│   └── default.ejs
└── pages
├── bar.md
└── foo.md

この状態で ./node_modules/.bin/bulbo build を実行すると下のような build/ ディレクトリが生成されます。(全てのアセットのビルド、すなわち、JS のビルドと、マークダウンファイルのビルドが同時に行われます。)

build/

├── js
│   ├── bar.js
│   └── foo.js
└── pages
├── bar.html
└── foo.html

また、./node_modules/.bin/bulbo serve で、同じ内容が自動的にローカルサーバーにホストされます。

$ ./node_modules/.bin/bulbo serve

bulbo [21:50:31] Using: /Users/kt3k/tmp/example-bulbo/advanced-example/
bulbofile.js
bulbo [21:50:32] serving
bulbo [21:50:32] Reading: source/js/*.js
bulbo [21:50:32] Reading: source/pages/**/*.md
bulbo [21:50:32] Server started at: http://0.0.0.0:7100/
bulbo [21:50:32] See debug info at: http://0.0.0.0:7100/__bulbo__
bulbo [21:50:34] Ready: source/pages/**/*.md
bulbo [21:50:34] Ready: source/js/*.js

駆け足に説明しましたが、以上の例で、CommonJS で JS をバンドルしながら、ワンストップの設定ファイルで、静的サイト全体をビルド/サーブ仕分けるという、当初の目的が実現できていることになります。


:mag: bulbo がやっていること

bulbo の アセットの宣言 が、どのように解釈/実行されているかのもう少し踏み込んだ説明をしてみます。

例えば、次の bulbo アセットの宣言があったとします。

asset('pages/**/*.md')

.pipe(foo()).pipe(bar())

上の表現を含んだ bulbofile.jsbulbo build コマンドで起動すると、実質的に次の gulp の表現に置き換えられたことと同義です。


build時

gulp.src("pages/**/*.md")

.pipe(foo())
.pipe(bar())
.pipe(gulp.dest(BULBO_DEST))

まず、アセットの glob パターンを gulp.srcvinyl (= gulp 的な仮想ファイルのようなもの) のストリームに変換します。次に、gulp プラグインのリストに流し込見ます。最後に、出てきた output を gulp.dest(BULBO_DEST) に流し込んでいます。(BULBO_DESTbulbo.dest() で宣言された path です。(デフォルトは ./build という path になっています。)

また、bulbo serve コマンドで起動された場合は、


serve時

gulp.watch('pages/**/*.md', () => {

return gulp.src('pages/**/*.md')
.pipe(foo())
.pipe(bar())
.pipe(vinylServe(BULBO_PORT))
})

と、ほぼ同義になります。アセットの glob パターンを watch しつつ、そのパターンから vinyl ストリームを作っています。そのストリームを、gulp プラグインのリストに流し込んでいます。(watch したいパターンとビルド起点にしたいパターンが違う場合は、asset(glob).watch(watchGlob) という呼び出しで、別々に設定することができます。詳しくはドキュメント参照)

最後に、出てきた output を vinyl-serve という gulp プラグインに流し込みます。vinyl-serve はファイルを作らずに直接ファイルを serve 出来る writable な vinyl ストリームです。

以上のように、bulbo は与えられたアセットの 宣言 から、状況に応じて 適切な gulp の stream を構築 して実行しています。


bulbo の良さ


JS エコシステムとの親和性の良さ

bulbo の良い点の一つは JS のエコシステムとの親和性の良さです。上の例では js を browserify でバンドルする例をあげましたが、webpack-stream を使えば、webpack でバンドルが出来ますし、rollup-stream を使えば rollup でバンドルすることも出来ます。css も sass でも postcss でも、gulp プラグインさえあれば、簡単にビルドに含めることが出来ます。今後新しいパラダイムの新しいツールが出てきたとしても、それが JS で実装されている限り簡単に組み込めることが見込めます。


gulp とのエコシステムの共有

もう1つ良い点は、gulp とエコシステムを共有している点です。今のところ bulbo ユーザーはほとんど居ませんが、gulp はかなりのユーザーが居て、プラグインエコシステムが豊富です。したがって、gulp の資産に乗っかれるという点が単純に利点ですが、逆に、bulbo を使っていて何か独自の問題を解決することがあったとしても、それは gulp プラグインとして npm に publish することが出来るため、gulp エコシステムに還元しながら作業を進めることが出来るため、OSS のユーザーとして気持ちよく作業ができます。


エンジンとしても使える

この記事では、bulbo を直接使って静的サイトを生成する例を紹介しましたが、bulbo はプログラムから使うための API も持っています。bulbo 単体は 汎用 の静的サイトジェネレータですが、bulbo に任意のビルドパイプラインをあらかじめ埋め込んだ状態で npm に publish することで、特定の目的のための静的サイトジェネレータを比較的楽に作成することが出来ます。例として domaindoc (ドメインモデルをドキュメントする専用の静的サイトジェネレータ) などがあります。


:globe_with_meridians: 実用例

bulbo を実用的な用途で利用している例として以下があります。


まとめ

今日は静的サイトジェネレータ bulbo を作った動機の話と、基本的な使い方の紹介、内部でやっていることの簡単な説明をしました。既存のサイトジェネレータに不満を持っている方、gulp や node.js のストリームに馴染みがある方、ぜひ bulbo を試してみてください!

Happy site building! :smiley:

明日は、