🍲

純粋なHTMLからRailsのビューを生成しよう

2023/12/22に公開

この記事は、Redmine Advent Calendar 2023 および Ruby on Rails Advent Calendar 2023 の21日目の記事です。

Redmine Advent Calender の前日の記事は、Susumu Yamasaki さんの「「rails console」を使ってRedmine上のスケジュールを一括変更する方法」でした。
Ruby on Rails Advent Calendar の前日の記事は、@chiku_devさんの「Rails7環境での最適なJavaScriptビルドツールの選択ガイド」でした。

要点

  • Railsのビューはヘルパーメソッドの多用もあってWebデザイナーの人たちにはとっつきづらいのでは。
  • 純粋な HTML を eRubyに変換する仕組みを作って開発者の間口を広げたい。
  • eRuby を作るスクリプトを作るところまでは来ました。コードは整理した上で近日中に公開します。

作ったもの

HTMLとrubyのコードからeRubyを生成するツールを作成しています。
現状、300行足らずのスクリプトです。nokogiriparser を利用しています。

とりあえず、以下の HTMLと rubyコードから eRubyテンプレートを生成できます。
まだ見せられるような状況ではありません。細かいところで楽をするために色々端折っています。

素材その1 HTML

login.html
<div id="main" class="nosidebar">
    <div id="sidebar">
    </div>

    <div id="content" data-eruby="strip">
        <div id="login-form">
            <form onsubmit="return keepAnchorOnSignIn(this);" action="#" accept-charset="UTF-8" name="form-19a243a8" method="post" data-eruby="signin_path">
                <input name="utf8" type="hidden" value="&#x2713;" autocomplete="off" data-eruby="dummy"/>
                <input type="hidden" name="authenticity_token" value="" autocomplete="off" data-eruby="dummy"/>
                <input type="hidden" name="back_url" value="/" autocomplete="off" data-eruby="dummy"/>

                <label for="username" data-eruby="field_login">ログインID</label>
                <input type="text" name="username" id="username" tabindex="1" data-eruby="username"/>

                <label for="password">
                    <span data-eruby="field_password">パスワード</span>
                    <a class="lost_password" href="#" data-eruby="lost_password">パスワードの再設定</a>
                </label>
                <input type="password" name="password" id="password" tabindex="2" data-eruby="password_input"/>

                <span data-eruby="autologin">
                <label for="autologin"><input type="checkbox" name="autologin" id="autologin" value="1" tabindex="4" data-eruby="checkbox"/> <span data-eruby="stay_logged_in">ログインを維持</span></label>
                </span>
                <input type="submit" name="login" value="ログイン" tabindex="5" id="login-submit" data-eruby="submit"/>
            </form>
        </div>

        <script data-eruby="dummy">
          //<![CDATA[
          $('#username').focus();
          //]]>
        </script>

        <div style="clear:both;" data-eruby="dummy"></div>
    </div>
</div>

素材その2 ruby

login.rb
call_hook :view_account_login_top

segment 'signin_path' do
  form_tag(signin_path, onsubmit: 'return keepAnchorOnSignIn(this);') do
    back_url_hidden_field_tag

    element('field_login') { l(:field_login) }
    segment('username') { text_field_tag 'username', params[:username], tabindex: '1', autofocus: params[:username].blank? }

    segment('field_password') { l(:field_password) }
    segment('lost_password') { link_to l(:label_password_lost), lost_password_path, class: 'lost_password' if Setting.lost_password? }

    segment('password_input') { password_field_tag 'password', nil, tabindex: '2', autofocus: params[:username].present? }

    segment 'autologin', logic: true do
      if Setting.autologin?
        segment('checkbox') { check_box_tag 'autologin', 1, false, tabindex: 4 }
        segment('stay_logged_in') { l(:label_stay_logged_in) }
      end
    end
    segment('submit'){ submit_tag l(:button_login), name: "login", tabindex: 5, id: 'login-submit' }
  end
end

call_hook :view_account_login_bottom

結果 eRuby

login.html.erb
<%= call_hook :view_account_login_top %>

<div id="login-form">
  <%= form_tag(signin_path, onsubmit: 'return keepAnchorOnSignIn(this);') do %>
  <%= back_url_hidden_field_tag %>

  <label for="username"><%=l(:field_login)%></label>
  <%= text_field_tag 'username', params[:username], :tabindex => '1', :autofocus => params[:username].blank? %>

  <label for="password">
    <%=l(:field_password)%>
    <%= link_to l(:label_password_lost), lost_password_path, :class => "lost_password" if Setting.lost_password? %>
  </label>
  <%= password_field_tag 'password', nil, :tabindex => '2', :autofocus => params[:username].present? %>

  <% if Setting.autologin? %>
    <label for="autologin"><%= check_box_tag 'autologin', 1, false, :tabindex => 4 %> <%= l(:label_stay_logged_in) %></label>
  <% end %>

  <%= submit_tag l(:button_login), name: "login", tabindex: 5, id: 'login-submit' %>
  <% end %>
</div>

<%= call_hook :view_account_login_bottom %>

仕組み

  • HTMLのうち、アプリケーションで値が置き換えになる要素に対してdata-eruby属性でマークを付けておきます。
    • 属性にdummyとある要素は単純に消去されます。stripとある要素より外側の要素は無視されます。
  • Rails 側のロジックは 疑似rubyコードで書きます。
    • 「疑似rubyコード」と書いたのは、このコードがそのまま実行される訳ではないからです。parser でrubyのコードをASTとして抽出した後、HTMLにテンプレートとして埋め込む方式です。
    • segmentメソッドとelementメソッドの部分が、HTML要素のdata-eruby属性で名前が一致するところに埋め込まれます。
      • element メソッドは要素の中身をコードで置き換えます。
      • segmentメソッドは要素そのものをコードで置き換えます。
      • segmentelement とも式の結果がビューの出力となる <%= %>の eRuby に変換されます。if文や変数の代入など、ビューの出力として利用されないものは <% %> 形式の eRuby に変換されます。

作成動機

RailsのWebアプリケーション開発では、MVCのViewの部分はテンプレート言語で記述します。

一方でWebデザイナーと協業するといったケースでは必ずしもデザイナーがRailsに通じているとは限りません。
アプリにもよると思いますが、ともかくヘルパーだらけでどこでHTMLを定義しているか良くわからん、という嘆きの声も聞きます。

Web上の記事では「デザイナーの人にRailsのビューを覚えてもらいました! 」とかRailsの事例ではないけど「弊社のデザイナーは優秀でReactのコンポーネントを使いこなしています」とか「デザイナーもそろそろ TypeScript とか覚えましょう」とかいった内容を見ることもあります。

それで上手く行っているケースはそれで良いんですが、案件が変わるごとに相手側のテンプレート言語やフロントエンドライブラリが変更になったりするとデザイナー側もたまったもんじゃないだろうな、とも思います。

一方で、プログラマーとデザイナーが御互いの領分にまったく立ち入らない、となるとそれも問題です。

お仕事の場合、スケジュールというものがあるのでデザイナーの作成が終るまでプログラマーが待たなければならない、あるいはその逆、という形でどちらかにスケジュールの皺寄せが行くことも考えられます。
OSSの開発という観点から言うと、デザインの知見を持った人から意見がほしい、というときに開発環境をセットアップして起動してデザインを確認してください、というのはハードル高すぎではという懸念があります。

最低限表示するデータが1件なのか複数件なのか、どのような種類のデータなのか、といったところを抑えた上で純粋なHTMLを叩き台として共有する。CSSやレイアウトのデザインとアプリケーションの実装をある程度並行で進める。アプリケーションの仕様変更のためにHTML側に追加・削除が必要、あるいはデザインの仕様変更にともなってアプリケーション側に機能の追加が必要、といったことが機械的に把握できれば望ましいのではないかと思います。

  • デザイナーはRailsの仕様に立ち入ることなく、HTML、CSS、JavaScript でUIを記述する
  • プログラマーは、HTMLの素案をベースとしてヘルパーやロジックを開発する
  • HTMLから eRuby を生成し、Rails のビューとして活用する。

先走った話ですが、同様な仕組みが PHPやJavaなどの他のフレームワークで実現できれば、デザイナーは開発案件ごとのフレームワークの仕様の違いに振り回されなくなるかもしれません。

そういう問題を解消するためにフロントエンドのフレームワークがあるんだろうが、と言われそうですが、こちらはこちらで百家争鳴というか戦国時代になっています。

いったん純粋なHTMLとCSSに立ち返ってはどうだろうか、ということです。

先行事例 kwartz

実はこの記事は kwartz というライブラリに影響されています。というかコンセプトはほとんどそのままです。

https://www.slideshare.net/kwatch/html-5079716

もともと2003年ごろから開発されていたようですが現在、kwartzの開発は停止しているようです。
上の資料では「デザイナー受難の時代」とありますが更に酷くなっている気が……。
なお上記の資料では、見た目を管理するのがプレゼンテーションロジック、業務にまつわるものがビジネスロジックと区分けして論じていますが、実際には両者は重なりあうケースも多々あると思っています。[1]

スクリプトで未実装だけど今後欲しい機能

  • 属性だけを変更する機能。ハッシュを引数にしてHTML側の属性を上書きする。
    • eRubyそのもので属性の追加はできても変更や削除はできないので railsのヘルパーを自動で追加するような形になると思います。
  • segmentメソッドで、指定した要素を包みこむようなコードも記述できるようにする。また、HTML側の要素を表示するかどうかを選択できるようにする(つまり、segmentメソッドをelement メソッドの上位互換とする)
  • ruby では、if も戻り値を持つ。このため、if が出たら戻り値なしの “<% %>” を使う、というのは本来おかしい。segmentメソッド、element メソッドの引数で挙動を指定できるようにしたい。
  • テンプレートのコードに元のrubyファイルの行数をコメントとして追加する。
    • デバッグの時とか、この機能がないと辛いと思う。
  • 部分テンプレートを切り出す仕組み
    • Rails ではテンプレートを小さく分割して部分テンプレートとして使うことが多い。
    • ロジック側の指定で別ファイルに切り出すような方法が欲しい。
  • ruby側とHTML側で属性指定の齟齬があった場合に警告を出す。
    • CIを利用することで、プログラマとデザイナーの行き違いを最小限にできれば理想的ですね。
  • eRubyのインデント機能。本稿の仕組みではeRubyは出力結果扱いとなるけれどそれでも見栄えは良い方がありがたい。
  • rails のジェネレータ機能。
  • すでに動いているアプリケーションのeRubyをHTMLとrubyのコードに分割する。
    • 手作業は辛い。半自動でも良いから何とかしたい。
    • 以下のRedmineの事例では神がいてHTMLの抽出を全てやってくれたが……。

事例: Redmine

Redmine は開発開始が2006年となる老舗Railsアプリです。
CSSを自由にカスタマイズできるテーマ機能があります。

当たり前ですが、バージョンアップごとにビューは少しずつ変更されており、テーマ作者は追従にいつも苦労しています。
ビューはeRubyで記載されています。独自のヘルパーが多用されているため、少しだけ挙動を変えたい、といった利用者がビューのコードを見ると心を折られるケースが多い(気がします)。
「UIが改善されると良いですね」「そうですね」というのがコミュニティ定番の話題となり続けてはや幾年。[2]

そんな中、昨年tsurushimaさんが ライトニングトーク とともに Redmineの Storybook を公開しコミュニティに衝撃が走りました。

  • そもそも Storybook というアプリの存在を初めて知った コミュニティメンバーも多かったような? [3]
  • Storybook のパーツ群は 純粋なHTMLで書かれており、テーマの挙動をわかりやすく確認できるようになっています。
    • 「Docker とか用意して Rails を立ち上げられるようになってくださいね」とか言わずに済む。
    • StorybookはRedmineのテーマに適用可能です(というか、そのために開発された)
    • デフォルトテーマ以外の事例としてファーエンドテクノロジーのfarend bleuclairテーマのStorybookがあります。

一方で筆者は「Storybook はすばらしいが、Redmine のバージョンアップのたびに手動で HTML を切り出してStorybook をのリポジトリにコミットするのは辛いのでは?」とも思いました。
むしろ、StorybookのHTMLを原本として、eRubyを生成する仕組みとした方が良いのでは? で、俺が産まれたってわけ ということでこの記事を書くことになりました。

ただ、Storybookもバージョンアップが結構激しいので、この仕組みでは Storybook に依存するのではなく、純粋なHTMLに依存する方向で行きたいと考えています。

ちなみに上記のeRubyの変換事例は、Redmine のソースコードの一部です。現状ではスクリプトに未実装の仕様がいろいろあるため、変換が簡単になるようにHTML側に細工をしたりしています。

Ruby以外の事例

kwartz には PHP実装や Java実装もありましたが同じようなコンセプトのライブラリはあまり見つかりませんでした。
唯一Reactにはそれらしいものがありました。

https://github.com/roman01la/html-to-react-components

所感

  • 初めてRubyのパーサというものをさわった。DSLともいえないような仕組みだけど仕様を頭の中で考えるのは楽しい。
  • Nokogiri はやはり便利だった。
  • 自分の言語のパーサーとHTMLパーサーを組み合わせれば他の言語、フレームワークでも似たようなことはできるような気がする。

脚注
  1. 本稿ではビジネスロジックかプレゼンテーションロジックか、に関わらず、テンプレート上のロジック(表示内容の切り替え、条件分岐、繰り返し等)をまとめて単にロジックと呼んで特に区別しません。 ↩︎

  2. 実際にRedmineの挙動を変えてみて「こうなった方がうれしいですよね」と提案するだけでも利用者目線では結構ハードルが高いと思う。 ↩︎

  3. 少なくとも私はそうでした ↩︎

Discussion