これは Riot.js Advent Calendar 2020の 6 日目の記事 🎉 です。
動機
いままで、HTML を書くときは共通部分を一元化したりするために EJS とか Nunjucks などの HTML を生成するテンプレートエンジンを使っていました。
そんな折に、Riot.js の公式 Twitter アカウントの以下のツイートを目にします。
https://twitter.com/riotjs_/status/1291366895271190529
この水辺を楽しげにスキップしているおばあさん(?)が何者なのかわかりませんが、
ツイートのリンク先、GitHub のリリースを見てみると、Riot.js の SSR 用モジュールで、こんなサンプルが書いてありました。
<html><head><title>{ state.message }</title><metaeach={metainstate.meta}{...meta}/></head><body><p>{ state.message }</p><script src='path/to/a/script.js'></script></body><script>exportdefault{state:{message:'hello',meta:[{name:'description',content:'a description'}]}}</script></html>
これをみて最初は「あ、head タグとかも生成できるんだー、へー、まあ SSR にはそりゃ必要だよねー」くらいの感想だったんですが、あとでよくよく考えたら、これって Nunjucks とかでやってることと一緒じゃね?じゃあ Numjucks でやってることも Riot.js でできるんじゃないの?と思いました。
やれるかも
SSR 用のモジュール、デザイナーのぼくにはこれまであんまり馴染みがなかったんですが、
もともと、なるべく Webpack とか Gulp みたいなものに依存しない環境を作りたいという気持ちから Riot.js を使う環境も riot/cli という公式のプリコンパイラーを npm-scripts から使うようにしててて、 node.js で実行するコードも簡単なものは少し書いたりしてたので、デザイナーの自分でもやれるかもとおもいました。
やりたいこと
とりあえず、SSR 用のモジュールは、Node.js が動くサーバーで、リクエストに対して .riot
をコンパイルした結果を返すというものだろうとおもってたので、
その機能を npm-scripts から実行して、リクエストにコンパイル結果を返す代わりにローカルに HTML としてファイルを保存すればいいだろう、というのがやりたかったことです。(これができれば使っていた Nunjucks の代わりになりそうという発想)
やってみた。
ディレクトリ構造
ちょっと省略しますが、だいたいこんな感じです。
dist ← ここに HTML が保存される
node_scripts
└ html.js ← これを実行すると .riot → .html
src
└ html
├ components ← HTML 生成する際の共通パーツを置く
└ pages ← 生成する HTML に対応する .riot を置く
package.json ← 必要な npm-scripts を記述
.riot → .html の処理
SSR モジュールは Node.js から呼び出して使わないとなので、npm-scripts で呼び出すJSファイル node_scripts/html.js
を作って、そこで @riot/ssr を使った処理を書いていきました。
constfs=require('fs')// Node.js のファイル管理モジュールconstpath=require('path')// Node.js のパス扱うモジュールconstglob=require('glob')// /**/*.js みたいにファイルを複数取得するために必要constmkdirp=require('mkdirp')// ファイル保存時のディレクトリ作成に使用const{render}=require('@riotjs/ssr')// Riot.jsのSSRモジュールconstregister=require('@riotjs/ssr/register')// Riot.js のおまじないに必要constsrcDirFromRoot='./src/html/pages'// HTML のテンプレートとなる .riot ファイルを置くディレクトリconstoutputDir='./dist'// 生成した HTML を保存するディレクトリ// Riot コンポーネントを require できるようにregister()// HTML 生成のテンプレートとなる .riot ファイルを読み込みんで、HTMLを生成glob(`${srcDirFromRoot}/**/*.riot`,(err,files)=>{if(err)returnerrgenerateHtml(files)})// HTMLを生成する関数constgenerateHtml=(files)=>{/*
* 引数で渡された配列から取り出したファイルパスごとに .riot → .html を実行。
* Riot.js の公式のサンプルリポジトリの中の SSR のサンプルを参考にした。
* https://github.com/riot/examples/blob/gh-pages/ssr/index.js
*/files.forEach((file)=>{constRoot=require(`.${file}`).defaultconsthtml=render('html',Root)constdir=path.join(outputDir,file.replace(srcDirFromRoot,'').replace(/riot$/,'html'))/*
* 書き出すディレクトリが存在しないとエラーになっちゃうので
* mkdirp でディレクトリ作成してそこに HTML を保存。
*/mkdirp(path.parse(dir).dir).then(()=>{fs.writeFile(dir,html,(err)=>{if(err)throwerr})})})}
HTML に変換される .riot ファイルの中身
HTML に変換される .riot ファイルは、いくつかの共通パーツに分かれています。
まず全体の雛形を設定した src/html/components/html.riot
はこんな感じです。
<html><head><title>{ props.title ? props.title : state.title }</title><metaif="{ props.meta }"each="{ meta in props.meta }"{...meta}/><metaif="{ !props.meta }"each="{ meta in state.meta }"{...meta}/><metaname="viewport"content="width=device-width,initial-scale=1"><linkrel="stylesheet"href="{ this.props.toRoot }css/main.css"><script src="{ this.props.toRoot }js/main.js"defer></script></head><body><h1>{ props.title ? props.title : state.title }</h1><static-component></static-component><slotname="default"></template></body><script>importStaticComponentfrom'./static-component.riot'exportdefault{components:{StaticComponent},state:{title:'Static',meta:[{name:'description',content:'a description',},{property:'og:title',content:'ogp title',},],},}</script></html>
riot/ssr を使うことで、head タグとかにも Riot.js の props などが使えるので、それを使って、ページタイトルとか OGP なんかもページごとに設定できるようになっています。
また、あらかじめ import したコンポーネントは展開された状態で HTML ファイルが生成されます。
で、ページに対応した src/html/pages/index.riot
の中身はこんな感じです。
<html><templateis="html"title="{ state.title }"meta="{ state.meta }"><static-header></static-header><p>page content</p></template><script>importHtmlfrom'../components/html.riot'importStaticHeaderfrom'../components/static-header.riot'exportdefault{components:{Html,StaticHeader},state:{title:'title from props',meta:[{name:'description',content:'description from props',},],},}</script></html>
ベースとなる src/html/components/html.riot
を読み込み、head タグなどに入れたい情報は、props として渡します。
HTML の保存先の dist
の中にディレクトリを切って HTML を保存したい場合は、src/html/pages/
の中に任意のディレクトリを切って .riot ファイルを置きます。
src/html/pages/child/index.riot
というファイル作ったとしたら以下のような内容に。
<html><templateis="html"title="{ state.title }"meta="{ state.meta }"to-root="../"><static-header></static-header><p>page content</p></template><script>importHtmlfrom'../../components/html.riot'importStaticHeaderfrom'../../components/static-header.riot'exportdefault{components:{Html,StaticHeader},state:{title:'Child page: title from props',meta:[{name:'description',content:'child page: description from props',},],},}</script></html>
npm-scripts
あとは、最初の node_scripts/html.js
を Node.js で実行する npm-scripts を用意すれば完成。
"script":{"start":"run-s html start-watch","html":"node node_scripts/html.js","watch:html":"onchange src/html -- npm run html"}
とりあえず、これでやりたいことができました。
ちょっと駆け足になりましたが、静的な HTML を生成する書き方と、動的に JS で展開される Riot.js のコンポーネントがどちらも同じ書式でかけるようになって個人的には大満足。
ちょっとまだざっとやりたいことやれるようにしただけなので、なにか問題とかあるかもしれません💦
デザイナーが頑張って作ってみた環境なので、なにかおかしいところとか改善できそうなことがあったらぜひやさしくおしえてください🙇♂️
今回の記事は、個人的な Web 制作用のボイラープレートの中でやったことをかいつまんで書いてみました。
あまりリポジトリとして整理できてなくてちょっとお恥ずかしい感じですが、なにか「こうしたらいいよー」みたいなこととかがあれば issue とかで(やさしく)お知らせいただくのも大歓迎です。
↓
https://github.com/nibushibu/Getup