Railsのビューテンプレートで使うcontent_forとyieldっぽいものを、Ruby標準のERBだけを使って自作する

## まえおき

完全に自分用メモ。
[80%くらい同じだけど20%くらい中身が異なるDockerfileを量産](https://github.com/YusukeIwaki/cloud-pine-workspaces)したかったので、

```Dockerfile.base.erb

FROM <%= yield(:base_image) || 'debian:buster' %>

RUN apt-get update && apt-get -y install ...
RUN ...
RUN ...

<%= yield(:additional_install) %>

USER user
RUN git clone ...
RUN ...
ENV ...

<%= yield(:additional_install_for_user) %>

RUN ...
RUN ...

CMD ["/bin/bash", "start_server.sh"]
```

```ruby2.6/Dockerfile.binding.erb
<% content_for(:base_image, 'ruby:2.6-buster') %>

<% content_for(:additional_install) do %>
RUN apt-get install libxslt-dev libsqlite-dev
<% end %>
```

みたいな感じのことをやりたかった。

![content_forとyieldでテンプレート](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/5bd571f9-6d51-d7ff-fda6-d313ad7b5668.png)


ただ、Railsを入れるほどではないので、Rails非依存で（Ruby標準のERBだけで）サクッと自作できないかなと考えた話。

**※ 結果だけを知りたい方は「まとめ」のところに一気にどうぞｗ**

## Railsの実装を参考にしてみる

content_forはどういう定義になっているかをちらっと見ると、

```/actionview/lib/action_view/helpers/capture_helper.rb
      def content_for(name, content = nil, options = {}, &block)
        if content || block_given?
          if block_given?
            options = content if content
            content = capture(&block)
          end
          if content
            options[:flush] ? @view_flow.set(name, content) : @view_flow.append(name, content)
          end
          nil
        else
          @view_flow.get(name).presence
        end
      end

         (中略)

      def content_for?(name)
        @view_flow.get(name).present?
      end
```

テンプレートのバインディングの方で使われる `content_for(:key)` は、単純に `@view_flow[:key]` 的な場所に値を入れているだけだ。

app/views/layouts/application.html.erb などテンプレート側は `yield` を使うだけなので、ブロックを何らかの形で渡してるんだろう。
https://medium.com/rubyinside/disassembling-rails-template-rendering-2-a99214c6fde8
あたりに詳しく説明があるが、 `template.render {|*name| ... }` みたいな感じでブロックを渡しているらしい。

## 複雑なことをしない、自分だけの yield, content_for の要件

今回の自分の用途では

* yieldは必ず１つのキーを指定される
* content_forは必ず１つのキーを指定され、valueもしくはブロックで値が指定される
* content_forを書くファイルには、content_for以外の内容は書かない

という、仕様を削ぎ落とした版のyield/content_forがあればよい。

この程度であれば、Ruby標準のERBでできそうだ。

### 実コードでいうと

冒頭で書いた `Dockerfile.base.erb` `ruby2.6/Dockerfile.binding.erb` を読み込んで、以下のようなRubyスクリプトでいい感じに ruby2.6/Dockerfile ができればいい。

```generate_dockerfile.rb

require 'erb'

def apply_template(template_erb, binding_erb)
  # どう書く？
end

template_erb = ERB.new(File.read("Dockerfile.base.erb")) # yield指定がある
binding_erb = ERB.new(File.read("ruby2.6/Dockerfile.binding.erb")) # content_for指定のみがある
dockerfile = apply_template(template_erb, binding_erb)
File.write("ruby2.6/Dockerfile", dockerfile)
```

さて、apply_templateをどうすればいいんだ？というのがこの記事の本題。

## 実装するぞ

### binding_erbにあるcontent_forの内容をどこかの変数に一時的に持っておく

Railsだとcapture_helper.rbで `@view_flow` に保持していた。

https://magazine.rubyist.net/articles/0017/0017-BundledLibraries.html をみるとなんとなく想像がつくが、ERBのバインディングはresultメソッドを叩いたその瞬間の評価コンテキストで行われる。（内部でevalをしているだけ）
なので、 `binding_erb.result` を呼ぶ時には、そこから見える場所に `content_for` メソッドが定義されている必要がある。

すっっごい雑にやるなら、

```generate_dockerfile.rb
def content_for(key, value = nil)
  @binding_values[key] = block_given? ? yield : value
end

def apply_template(template_erb, binding_erb)
  @binding_values = {}
  binding.erb.result(binding)

  puts @binding_values # => { "base_image" => "ruby:2.6-buster", "additional_install" => "..." }
end
```

こんな感じで、メソッドをもう１つ生やしてみるとよい。中身は、一時的なインスタンス変数にcontent_forで指定されたものをつっこむだけ。
（よいこのみんなはちゃんとクラス化して、プライベートメソッドで content_for を定義しましょう :sweat_smile: ）

### template_erbのyield呼び出し時に、保持していた値を入れ込む

template_erb側で

```
<%= yield(:hoge) %>
```

のような指定があった際に、 `@binding_values[:hoge]` を入れ込むようにする必要がある。

そもそもyieldってなんだっけ？というところがキモなのだが、雑に言うと「ブロックを評価せよ」という命令だ。

```rb:yield
def aaa(x)
  puts x, yield(:hoge)
end

data={hoge: 3, fuga: 4}
aaa(1) do |key|
  data[key]
end
# => 1, 3
```

　

さて、今回は何にブロックを渡せばいいんだっけ？ということになるが、

```rb
template_erb.result(binding) do |key|
  @binding_values[key]
end
```

これでいけるかな？と最初思ってやってみたんだけど、これではうまくいかない。
先にも書いたとおり、 `ERB#result` は内部的には単なるevalなので、resultを叩いた時点でブロックが見える必要がある、つまり極端に書くと

```rb
yield(:hoge)
#template_erb.result(binding)
```

このyield指定でも正しく動かないといけない。

そう、正解は

```generate_dockerfile.rb
def content_for(key, value = nil)
  @binding_values[key] = block_given? ? yield : value
end

def bind_to_template(template_erb)
  # templateの中でyieldされると、bind_to_templateに渡されたブロックが評価される
  template_erb.result(binding)
end

def apply_template(template_erb, binding_erb)
  @binding_values = {}
  binding.erb.result(binding)

  bind_to_template(template_erb) do |key|
    @binding_values[key]
  end
end
```

だ。

## まとめ

ここまでの内容だとまとまらないので、一応クラス化しとこうと思う。

```template_binder.rb
require 'erb'

class TemplateBinder
  def initialize(template_erb_string:, binding_erb_string:)
    template_erb = ERB.new(template_erb_string)
    binding_erb = ERB.new(binding_erb_string)
    evaluate_binding(binding_erb)
    @result = evaluate_template(template_erb) do |key|
      @binding_values[key]
    end
  end

  attr_reader :result

  private
  
  def content_for(key, value = nil)
    @binding_values[key] = block_given? ? yield.strip : value
  end

  def evaluate_binding(binding_erb)
    @binding_values = {}
    # binding_erbの中でcontent_forされたものが @binding_values に保持される
    binding_erb.result(binding)
  end

  def evaluate_template(template_erb)
    # template_erb の中でyieldされると、evaluate_templateに渡されたブロックが評価される。
    template_erb.result(binding)
  end
end
```

こんなクラスを１つ用意しておけば、

```rb
template = 'var1=[<%= yield(:var1) %>], var2=[<%= yield(:var2)%>]'

binding1 = <<~ERB
<% content_for(:var1, 3) %>
ERB

binding2 = <<~ERB
<% content_for(:var1) do %>
    hoge
<% end %>

<% content_for(:var2, :fuga) %>
ERB

TemplateBinder.new(
  template_erb_string: template,
  binding_erb_string: binding1
).result
# => var1=[3], var2=[]

TemplateBinder.new(
  template_erb_string: template,
  binding_erb_string: binding2
).result
# => var1=[hoge], var2=[fuga]
```

こんな感じで超簡易版 yield / content_for ができる。