2024 年を目の前にして、あえて Riot.js の .riot ファイルから静的な .html を生成する環境をつくってみる
この記事は Riot.js Advent Calendar 2023 の 9 日目(公開遅刻!)の記事です。
なぜ Riot.js なのか
まず冒頭からネタバレですが、「あえて Riot.js で〜」というタイトル、なにか Riot.js という選択についてなにか意外な狙いや含みがありそうですが、、、
とくに、なにもありません!!
ただ、好きだから!
Next.js はバージョンアップのたびに SNS 上で話題に上がり、最近では Astro が人気を集めていたり(なお、Astro では Riot.js は使えない 🥲)、また Riot.js の初期バージョンを作っていたエンジニアさん(Riot.js が一番流行ってた v3 くらい〜現在までのメンテナーさんとは別の人)が新たに Nue.js というフレームワークを発表したり、という 2023 年の終わり。
「今の Riot.js はこんな感じだよ!こんなことができるよ!」っていう話は、多分自分くらいしかする人いないかなという変な義務感も生まれつつあり、ただ 好き! という気持ちからこの記事を書いてます。
まず Riot.js をあらためて紹介
というわけで、今や Riot.js を知らない人も多いと思うので、まずは簡単に紹介から。
Riot.js は、Web フロントエンド の UI をコンポーネントベースで手軽に作るための JavaScript のライブラリです。
ざっくりですが、時代としては React とか Vue とかと同じ時期にスタートした結構古株の JS ライブラリです。
Riot.js は、Web 標準のコンポーネント を意識して作られていて、onclick
や <template>
<slot>
など Web 標準にもともと存在する仕組みをなるべく活かして作られているため、独自構文が非常に少ないのが特徴です。
独自構文が少ないということは、つまり新しく覚えることが少ないということ。
HTML/CSS/JavaScript の知識があれば、.riot
ファイルを見ればすぐに、だいたい何をしているのかわかると思います。
(ちなみに僕は .riot
ファイルを触るときのエディターのコードシンタックスは HTML にしています。なぜなら .riot
の書式はほぼ、素の HTML と、素の JavaScript なので ✊)
そんな Riot.js のこれまでの歩みなどについては、少し古い記事ですが 2021 年に Riot.js のこれまでを振り返った記事 を書いた(半分自分の思い出語りですが)ので、もしご興味があればそちらをお読みいただけたらと思います。
Riot.js には、いろんな関連モジュールがある
Riot.js には、Riot.js 本体以外にも、関連するモジュールがいろいろ存在します。
昨年のアドベントカレンダー記事 でも書いたとおり、現在の Riot.js のロゴは、僕がデザインさせてもらったものなので、個人的な思い入れもあって関連モジュールを紹介してプロジェクトを応援するために、主要な公式モジュールにも Riot.js 本体と同じデザインテイストでロゴを用意しました。
それら公式モジュールロゴも含めて、一通り並べるとこんな感じです。
SSR 用のモジュールを使ってローカルに HTML を生成
今回は、その中から @riotjs/ssr
という SSR (サーバーサイドレンダリング)用のモジュールなどを使って、.riot
という Riot.js のファイルを元に、ローカルの環境に .html
を生成するということをやってみます。
サンプルプロジェクト
簡単なサンプルをつくってみました。
このサンプルの中身を、かいつまんで紹介していこうと思います。
ディレクトリ構造はだいたいこんな感じです。
├── package.json
├── public <- 公開領域
│ └── css
│ └── style.css
├── scripts
│ └── html.mjs <- これを Node.js で実行する
└── src
└── html
├── components
│ ├── html-base.riot
│ └── static-header.riot
└── pages <- この中の .riot を .html にしたい
├── about.riot
├── child
│ └── index.riot
└── index.riot
やりたいこととしては src/html/pages/**/*.riot
を元に @riotjs/ssr
を使って public/**/*.html
を生成する、みたいなイメージです。
Node.js の LTS 版あたりがインストールされていれば、上記リポジトリをクローンして、
npm instal
npm run start
をすると、public の中身に生成された HTML をローカルで確認することができます。(http-server でローカルサーバーが立ち上がります)
.riot
ファイルの構成
└── src
└── html
├── components <- HTML 内の共通パーツは一元化できる
│ ├── html-base.riot
│ └── static-header.riot
└── pages <- この中の .riot を .html にしたい
├── about.riot
├── child
│ └── index.riot
└── index.riot
.riot
ファイルは大きく分けて 2 種類あります。
ファイル名 | 役割 |
---|---|
components/**/*.riot |
共通パーツ。pages/**/*.riot からインポートして使う |
pages/**/*.riot |
pages 内のディレクトリ構造とかファイル名を維持して public/**/*.html にコンパイルする |
src/html/components/html-base.riot の中身
複数の HTML を生成するにあたって、タイトルなどページごとに変わるものは差し込み可能にしつつ、基本的な head
タグの中身の構成などは一元管理したいので、ベースとなる HTML のひな形を html-base
コンポーネントとして用意します。
中身はこんな感じです。
<!DOCTYPE html>
<html-base>
<head>
<meta charset="utf-8">
<title>{ props.title ? props.title : state.title }</title>
<meta if="{ props.meta }" each="{ meta in props.meta }" {...meta}>
<meta if="{ !props.meta }" each="{ meta in state.meta }" {...meta}>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="{ this.props.toRoot }css/style.css">
<script src="{ this.props.toRoot }js/main.js" defer></script>
</head>
<body>
<div class="c-page">
<slot name="default"></slot>
</div>
</body>
<script>
function HtmlBase() {
return {
state: {
title: 'Static',
meta: [
{
name: 'description',
content: 'a description',
},
{
property: 'og:title',
content: 'ogp title',
},
],
},
}
}
export default HtmlBase
</script>
</html-base>
飽くまでサンプルなので、最低限の構成になっていますが、ページタイトルや meta
タグは、props
としてオブジェクトデータを渡せるようになっていて、それがなければ上記の state.meta
が展開される仕組みになっています。
Riot.js では、
<meta each="{ meta in props.meta }" {...meta}>
こういう風に、...
(スプレッド構文)でオブジェクトを渡すと属性値と値に展開してくれるので地味に便利です。
src/html/component/static-header.riot の中身
もう一つのコンポーネントは、Web サイトの共通ヘッダーのようなパーツのコンポーネントです。
シンプルなナビゲーションのリストですが、せっかく Riot を使っているので、リスト部分の情報は JS 側で持つようにしてみました。
<static-header>
<nav>
<ul class="c-nav-list">
<li each="{ item in nav }">
<a class="c-nav-list__link" href="{item.link}" aria-current="{props.current === item.name ? 'page' : null}">{item.name}</a>
</li>
</ul>
</nav>
<script>
export default function StaticHeader(){
return {
nav: [
{
name: 'Home',
link: '/'
},
{
name: 'About',
link: '/about.html'
},
{
name: 'Child',
link: '/child/index.html'
}
],
}
}
</script>
</static-header>
なお、このファイルに記載されている script
の中の JavaScript は、ブラウザではなく Node.js で実行するので、最終的には静的な HTML 部分だけが静的なファイルとして保存されます。
なので今回の環境では、ブラウザで実行させたい JavaScript(たとえば Google Analytics とか)を HTML 内の script
タグにインラインで JavaScript を書く、ということはできません(インラインで書くと Node.js で実行されてしまう)。
そういったブラウザで実行するべき JavaScript は、インラインではなく .js ファイルとして保存して script
タグで読み込ませる必要があります。
src/html/pages/index.riot の中身
今回は src/html/pages
のディレクトリの中に .riot
ファイルを作ると、そのファイル名・ディレクトリ構造を維持して public
に HTML ファイルが作られる、という感じにしてみました。
(くわしくは、後述の package.json
の記述内容と、scripts/main.mjs
をご参照ください)
これら HTML に変換する .riot
ファイルの中身についてのポイントは、ざっくり以下の通りです。
-
template
タグ にis="html-base"
という属性値をつけて、html-base
コンポーネントをマウントする-
template
を使うとhtml-base
という一番外のタグを消して、その中身だけをマウントできる -
to-root
属性にはそのページから見たルートディレクトリへの相対パスを入れておく- これは、
head
の中の css の読み込みのパスとかに反映されるんですが、スラッシュ始まりとかにしておけばそもそも不要ですね。(サーバー立ち上げずにローカルで HTML を開いてもデッドリンクにならないように一応こうしてるけど好み問題 & もっと良い解決方法がありそう)
- これは、
-
-
script
タグの中で、必要な.riot
コンポーネントの import して、components: {}
の中に定義しておく
<html>
<template is="html-base" title="{ state.title }" meta="{ state.meta }" to-root="./">
<static-header current="Home"></static-header>
<p>welcome home!</p>
</template>
<script>
import HtmlBase from '../components/html-base.riot'
import StaticHeader from '../components/static-header.riot'
export default {
components: {
HtmlBase,
StaticHeader,
},
state: {
title: 'Home',
meta: [
{
name: 'description',
content: 'welcome my site',
},
],
},
}
</script>
</html>
その他、src/html/pages/about.html
と src/html/pages/child/index.html
というファイルもありますが、中身はほとんど一緒なので割愛。
package.json の中身
scripts
npm scripts をタスクランナー代わりに使います。
内容は以下の通り。
"scripts": {
"preview": "npx http-server public -o / -c-1 -d false",
"riot2html": "node --loader @riotjs/register scripts/html.mjs",
"start": "run-s riot2html watch",
"watch:html": "onchange 'src/html' -- npm run riot2html",
"watch": "run-p watch:* preview"
},
コマンド | やってること |
---|---|
preview |
http-server でローカルサーバーが立ち上げる |
riot2html |
scripts/html.mjs を Node.js で実行する。今回やりたいことの主要な処理はここにある
|
start |
riot2html の初回実行とソースデータの変更監視 |
watch:html |
src/html の中のファイルの変更を監視して riot2html を再実行 |
watch |
watch と preview の実行 |
riot2html
の --loader
オプションについて
riot2html
の内容をみると、実行するファイル名の前に --loader @riotjs/register
というオプションがついています。
このオプションがなぜ必要かというと、最新の Riot.js の最新版(v9)が Node Custom Loaders という Node.js の新しめの機能(まだ実験的機能?)に依存しているためです。(Node.js 上で .riot
ファイルを扱うには、このオプションが必要)
また scripts/html.mjs
の拡張子を .mjs
というモジュール JS にしているのも、同じ理由です。
インストールしているパッケージ
"devDependencies": {
"@riotjs/register": "^9.0.0",
"@riotjs/ssr": "^9.0.0",
"glob": "^10.3.10",
"mkdirp": "^3.0.1",
"npm-run-all": "^4.1.5",
"onchange": "^7.1.0"
}
パッケージ名 | なにするため? |
---|---|
@riotjs/register |
.riot を Node.js でインポートして使えるように |
@riotjs/ssr |
Node.js で .riot を HTML にコンパイル |
glob , mkdirp
|
Node.js ファイルの一括取得とかディレクトリを作ったり |
npm-run-all |
ワンライナーでコマンドの同時実行。&& とかでつなげればいいんだけど、以前 Windows 環境で & つなぎが動かくておまじない的にこれ使っています。 |
onchange |
ファイル変更を監視して任意のコマンドを実行 |
scripts/main.mjs
を実行する
Node.js で 結局、今回やりたいことのほとんどは、この scripts/main.mjs
の中身に書いてあります。
とはいっても、行数もそれほど多くないので、内容にコメントを追加してそのまま転載します。
// 必要なモジュールのインポート
import fs from 'fs'
import path from 'path'
import { glob } from 'glob'
import { mkdirp } from 'mkdirp'
import render from '@riotjs/ssr'
// 読み込み先と書き出し先のパス
const srcDirPathFromProjectRoot = 'src/html/pages'
const outputDir = 'public'
// 対象になるソースファイルを取得
const riotFiles = await glob(`${srcDirPathFromProjectRoot}/**/*.riot`)
// 古い生成ファイルを取得
const oldFiles = await glob(`${outputDir}/**/*.html`)
// 古い生成ファイルは削除
for await (const file of oldFiles) {
fs.unlink(file, err => {
if (err) throw err
})
}
// ソースファイル(.riot)を HTML にコンパイルして /public 配下に保存
for await (const file of riotFiles) {
fs.readFile(file, 'utf-8', (err, data) => {
if (err) throw err
import(`../${file}`).then((riotHtml) => {
// .riot コンパイルした HTML を定数(renderedHtml)に格納
const renderedHtml = render('html', riotHtml.default)
// 取得した HTML の保存先パスを定数(dir)に格納
const dir = path.join(
outputDir,
file
.replace(new RegExp(`${srcDirPathFromProjectRoot}/`), '')
.replace(/riot$/, 'html')
)
// HTML ファイルを保存
mkdirp(path.parse(dir).dir).then(() => {
fs.writeFile(dir, renderedHtml, (err) => {
if (err) throw err
})
})
})
})
}
まとめ
と、こんな感じの仕組みで、Riot.js と Node.js を使って、コンポーネントベースでサクッと HTML を生成することが出来ました。
比較的シンプルな JavaScript なので、ここからもう少し複雑な処理を追記することも出来そうです。
たとえば、riotjs/ssr の README のコードにもある通り
import MyComponent from './my-component.js'
import render from '@riotjs/ssr'
const html = render('my-component', MyComponent, { some: 'initial props' })
こういう風に render()
の第三引数に props を渡すことが出来るので、今回は .riot
ファイルと生成される HTML ファイルが一対一の関係でしたが、たとえば CMS などからとってきた情報を props
で渡すことで、一つの .riot
ファイルを元に複数のページを生成することも出来そうです。(たぶん
また、今回は <static-header>
というコンポーネントを、あらかじめ HTML 生成時にコンパイル・マウントしましたが、特定のコンポーネントは Node.js ではマウントせずにブラウザ側で動的にマウントすることもできます。
冒頭でも書いた通り Astro など便利でシンプルなフレームワークも出てきているし、Next.js をはじめ、まるっと乗っかることが出来るソリューションがたくさんある今となっては、こういう自前の環境構築にそれほど大きな需要はないかもしれませんが、
単純に、僕のようなデザイナー兼任のフロントエンドエンジニアでも書けるレベルのシンプルな JavaScript と Node.js の小さなパッケージの組み合わせだけで、こういう環境が作れるというのが Riot.js の面白いところかなと思います。
この記事をみて Riot.js にもし興味をもったり、はたまた、はるか昔に使っててなつかしー!という人とか、いらっしゃいましたら、ぜひコメントいただけたらうれしいです!!✊🔥
ではでは!
Discussion