🔥

2024 年を目の前にして、あえて Riot.js の .riot ファイルから静的な .html を生成する環境をつくってみる

2023/12/13に公開

この記事は 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 本体と同じデザインテイストでロゴを用意しました。

それら公式モジュールロゴも含めて、一通り並べるとこんな感じです。

https://github.com/riot/branding

SSR 用のモジュールを使ってローカルに HTML を生成

今回は、その中から @riotjs/ssr という SSR (サーバーサイドレンダリング)用のモジュールなどを使って、.riot という Riot.js のファイルを元に、ローカルの環境に .html を生成するということをやってみます。

サンプルプロジェクト

簡単なサンプルをつくってみました。
このサンプルの中身を、かいつまんで紹介していこうと思います。

https://github.com/nibushibu/riot2html-template

ディレクトリ構造はだいたいこんな感じです。

├── 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.htmlsrc/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 watchpreview の実行

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 ファイル変更を監視して任意のコマンドを実行

Node.js で scripts/main.mjs を実行する

結局、今回やりたいことのほとんどは、この 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