Herb gem対応: バンドルERBテンプレートのHTML5違反を修正
Rails組み込みのERBテンプレートに潜在していたHTML5仕様違反と2013年来のレンダリングバグを修正しました。これにより、HTML-aware ERBコンパイラである Herb gem(0.10+)を採用したアプリケーションが、Railsのバンドルテンプレートを読み込む際に発生していた起動エラーを解消します。
背景
Herb 0.10は、コンパイル時にHTML5ネスト検証とERB属性位置検証を行うstrict modeを導入しました。この検証器はERBテンプレートのコンパイル時に Herb::Engine::InvalidNestingError や Herb::Engine::SecurityError を発生させます。Rails 8.1.xでHerb 0.10を採用したアプリが起動できない問題として表面化しました。
すでにコミット 84023dfd が rescues/routing_error.html.erb の <p> 内に <h2>/<ol> を配置するHTML5違反を修正していましたが、さらに3件の違反と1件の潜在的なレンダリングバグが残存していました。Herbのリポジトリには、バンドルテンプレートをバリデーション対象から除外するper-pathスコープ設定機能を求めるIssue(marcoroth/herb#1744)も上がっていましたが、Rails側で無効なマークアップを修正するのが本質的な解決策と判断されました。
本PRは、リポジトリ内の全バンドルERBテンプレート66ファイル(actionpack/、actionmailbox/、actiontext/、railties/、guides/、tools/、tasks/)を herb compile(herb 0.10.1)で検証した結果を踏まえており、修正前は3件の失敗(MissingOpeningTag 1件、SecurityError 2件)があったものを0件にしています。
技術的な変更
本PRは4ファイルにわたる修正で、問題のカテゴリは「孤立タグ」と「ERB出力式を属性名スロットに配置するパターン」の2種類に分類できます。
2013年来のレンダリングバグ: 孤立した </code> タグの除去
routing_error.text.erb には、2013年のコミット a725a453 でテキストテンプレートが作成された際から、対応する開きタグのない </code> が含まれていました。この閉じタグは、XHRやテキストエラーレスポンスに文字列 </code> としてそのままレンダリングされ続けていたものです。
変更前:
- <%= route.inspect.delete('\\') %></code> failed because <%= reason.downcase %>
変更後:
- <%= route.inspect.delete('\\') %> failed because <%= reason.downcase %>
HerbのHTML-aware parserがこの孤立タグを MissingOpeningTag として検出したことで、10年以上見逃されてきたバグが発見されました。
ERB出力式の属性名スロット配置を制御フロー式へ変換
email.html.erb と guides/source/layout.html.erb では、selected 属性を <%= cond ? 'selected' : '' %> のようにERB出力式(<%= %>)で属性名スロットに動的に埋め込むパターンが使われていました。Herbはこれを SecurityError(属性名位置にERB出力式)として拒否します。制御フロー式(<% if %>)を使った静的テキスト出力に変換することで解消しています。
変更前(email.html.erb):
<option <%= request.format == Mime[:html] ? 'selected' : '' %> value="<%= part_query('text/html') %>">View as HTML email</option>
<option <%= request.format == Mime[:text] ? 'selected' : '' %> value="<%= part_query('text/plain') %>">View as plain-text email</option>
変更後(email.html.erb):
<option<% if request.format == Mime[:html] %> selected<% end %> value="<%= part_query('text/html') %>">View as HTML email</option>
<option<% if request.format == Mime[:text] %> selected<% end %> value="<%= part_query('text/plain') %>">View as plain-text email</option>
変更前(layout.html.erb):
<option value="https://edgeguides.rubyonrails.org/"<%= " selected" if @edge %>>Edge</option>
<option value="https://guides.rubyonrails.org/v<%= version %>/<%= @path %>"<%= " selected" if @version&.start_with?("v#{version}") %>><%= version %></option>
変更後(layout.html.erb):
<option value="https://edgeguides.rubyonrails.org/"<% if @edge %> selected<% end %>>Edge</option>
<option value="https://guides.rubyonrails.org/v<%= version %>/<%= @path %>"<% if @version&.start_with?("v#{version}") %> selected<% end %>><%= version %></option>
この変換には副作用として、非選択時に <%= '' %> が出力していた二重スペース(<option value=...>)が解消される効果もあります。railties/test/application/mailer_previews_test.rb 内の6箇所のアサーション '<option value="..."'(二重スペース)が '<option value="..."'(シングルスペース)に更新されています。
設計判断
制御フロー式への変換アプローチ が採用されました。レンダリング結果は意味的に同一であり、選択時の出力(<option selected value="...">)は変わりません。
HerbがERB出力式を属性名スロットへの配置を SecurityError とする設計は、HTML5仕様の観点から正当です。属性名は静的なトークンであり、動的コンテンツを属性名として展開すると属性インジェクション脆弱性の温床になりえます。一方で、<% if %> selected<% end %> 構文は属性の「値」ではなく「存在そのもの」を制御する表現として、HTML5のboolean属性(selected、disabled、checked など)の意味論とも自然に整合します。
下流への回避策(Herbのper-pathスコープ設定)ではなくRails本体のテンプレートを修正する判断は、根本的な原因への対処として適切といえます。
まとめ
本PRはHerb 0.10との互換性確保を目的としながら、2013年から存在したレンダリングバグの除去と、HTML5 boolean属性の正しい記述への統一という複合的な改善をもたらしています。ERB出力式を属性名スロットに置くパターンは一見無害に見えますが、HTMLの意味論的な正確性とコンパイラの検証可能性の両面で問題を持つことが改めて示されました。