haml_i18n_lintでHamlテンプレートがi18nされているか調べる

Ruby on Railsを使ったウェブアプリケーションを開発していてテンプレートをHamlで書いています。
今までi18nせずに日本語で書いていたテンプレートをi18nしたくなりました。
[Rails Internationalization (I18n) API](http://guides.rubyonrails.org/i18n.html)を参考に、viewの中の日本語の文字列を[#t](http://api.rubyonrails.org/classes/ActionView/Helpers/TranslationHelper.html#method-i-t)ヘルパーを使って置き換えます。

継続的にi18nしたい
----

i18nは一度全部置き換えただけでは終わりません。置き換えた後もテンプレートは書き換えられていきます。
テンプレートに変更があってもi18nされた状態を維持したいです。
コードレビューでi18nされているか確認することは出来ますが人間なので見落とします。
なのでlintをかけます。

lintをかけるためにhaml_i18n_lint gemを作りました。

haml_i18n_lintを使う
----

[haml_i18n_lint](https://github.com/okinawarb/haml_i18n_lint)を使うとhamlテンプレート中にi18nが必要な文字列が残されているか確認できます。使い方を説明します。

### インストール

```
% gem i haml_i18n_lint
```

### 実行

何もオプションを指定せずに実行してみます。今回はmastodonに対して実行してみます。

```
% ghq get tootsuite/mastodon
% cd ~/src/github.com/tootsuite/mastodon
% haml_i18n_lint


app/views/kaminari/_prev_page.html.haml:9
8:  %span.prev
9:    = link_to_unless current_page.first?, safe_join([fa_icon('chevron-left'), t('pagination.prev')], ' '), url, rel: 'prev', remote: remote
----------------


app/views/kaminari/_next_page.html.haml:9
8:  %span.next
9:    = link_to_unless current_page.last?, safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), url, rel: 'next', remote: remote
----------------


app/views/authorize_follows/_card.html.haml:7
6:      %span.display-name
7:        = link_to TagManager.instance.url_for(account), class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do
8:          %strong.emojify= display_name(account)
----------------


app/views/accounts/_header.html.haml:17
16:        %span= "@#{account.username}"
17:        = fa_icon('lock') if account.locked?
18:    .details
----------------


app/views/accounts/_og.html.haml:1
1:  %meta{ property: 'og:url', content: url }/
2:  %meta{ property: 'og:site_name', content: site_title }/
----------------
app/views/accounts/_og.html.haml:2
1:  %meta{ property: 'og:url', content: url }/
2:  %meta{ property: 'og:site_name', content: site_title }/
3:  %meta{ property: 'og:title', content: [yield(:page_title).strip.presence, site_title].compact.join(' - ') }/
----------------
app/views/accounts/_og.html.haml:3
2:  %meta{ property: 'og:site_name', content: site_title }/
3:  %meta{ property: 'og:title', content: [yield(:page_title).strip.presence, site_title].compact.join(' - ') }/
4:  %meta{ property: 'og:description', content: account.note }/
----------------
app/views/accounts/_og.html.haml:4
3:  %meta{ property: 'og:title', content: [yield(:page_title).strip.presence, site_title].compact.join(' - ') }/
4:  %meta{ property: 'og:description', content: account.note }/
5:  %meta{ property: 'og:image', content: full_asset_url(account.avatar.url(:original)) }/
----------------
app/views/accounts/_og.html.haml:5
4:  %meta{ property: 'og:description', content: account.note }/
5:  %meta{ property: 'og:image', content: full_asset_url(account.avatar.url(:original)) }/
6:  %meta{ property: 'og:image:width', content: '120' }/
----------------
app/views/accounts/_og.html.haml:6
5:  %meta{ property: 'og:image', content: full_asset_url(account.avatar.url(:original)) }/
6:  %meta{ property: 'og:image:width', content: '120' }/
7:  %meta{ property: 'og:image:height', content: '120' }/
----------------
app/views/accounts/_og.html.haml:7
6:  %meta{ property: 'og:image:width', content: '120' }/
7:  %meta{ property: 'og:image:height', content: '120' }/
8:  %meta{ property: 'twitter:card', content: 'summary' }/
----------------
app/views/accounts/_og.html.haml:8
7:  %meta{ property: 'og:image:height', content: '120' }/
8:  %meta{ property: 'twitter:card', content: 'summary' }/
----------------


app/views/accounts/show.html.haml:8
7:  
8:    %meta{ property: 'og:type', content: 'profile' }/
9:    = render 'og', account: @account, url: account_url(@account, only_path: false)
----------------
app/views/accounts/show.html.haml:15
14:  .h-feed
15:    %data.p-name{ value: "#{@account.username} on #{site_hostname}" }/
16:  
----------------
app/views/accounts/show.html.haml:28
27:      - if @statuses.size == 20
28:        = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next', rel: 'next'
----------------

(長いので省略)

lib/templates/haml/scaffold/_form.html.haml:6: Illegal nesting: nesting within plain text is illegal. (Haml::SyntaxError)
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml-5.0.1/lib/haml/parser.rb:301:in `plain'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml-5.0.1/lib/haml/parser.rb:285:in `process_line'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml-5.0.1/lib/haml/parser.rb:133:in `block in call'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml-5.0.1/lib/haml/parser.rb:119:in `loop'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml-5.0.1/lib/haml/parser.rb:119:in `call'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml_i18n_lint-0.10.0/lib/haml_i18n_lint/linter5.rb:8:in `parse'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml_i18n_lint-0.10.0/lib/haml_i18n_lint/linter.rb:63:in `lint'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml_i18n_lint-0.10.0/lib/haml_i18n_lint/runner.rb:33:in `lint'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml_i18n_lint-0.10.0/lib/haml_i18n_lint/runner.rb:18:in `block in run'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml_i18n_lint-0.10.0/lib/haml_i18n_lint/runner.rb:17:in `map'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml_i18n_lint-0.10.0/lib/haml_i18n_lint/runner.rb:17:in `run'
        from /home/sei/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/haml_i18n_lint-0.10.0/exe/haml_i18n_lint:28:in `<top (required)>'
        from /home/sei/.rbenv/versions/2.4.1/bin/haml_i18n_lint:22:in `load'
        from /home/sei/.rbenv/versions/2.4.1/bin/haml_i18n_lint:22:in `<main>'
```

`app/views/accounts/show.html.haml:15`の`%data.p-name{ value: "#{@account.username} on #{site_hostname}" }/`のような、直接文字列が書かれている部分が検知されました。

一方、`fa_icon`に渡している文字列や、`link_to`やmetaタグの属性に渡している文字列が誤って検知されているようです。
そして残念ながらv0.9.0時点ではパースに失敗すると全体的に落ちてしまうようです。

### オプション

オプションで設定ファイルを指定したり、対象ファイルを絞り込めます。

```
% haml_i18n_lint --help
Usage: haml_i18n_lint [OPTION]... [FILE]...
    -c, --config=FILE                configuration file
    -f, --files=PATTERN              pattern to find Haml template files, default: -f '**/*.haml'
```

### 誤検知を無視する設定を追加する

haml_i18n_lintでは`-c`で設定ファイルを指定できます。
以下のような設定を`haml_i18n_lint.rb`として保存しました。

```haml_i18n_lint.rb
# fa_iconは引数でFontAwesomeの名前の文字列を取り、i18nの必要がないので無視する
def ignore_methods
  super + %w(fa_icon)
end

# link_toのtargetオプションやmetaタグのproperty/content属性はi18nの必要がないものとして無視する
def ignore_keys
  super + %w(target property content)
end

# パースに失敗するファイルを弾く
def files
  super.reject { |path| path == 'lib/templates/haml/scaffold/_form.html.haml' }
end
```

#### ignore_methods: 引数をi18nする必要がないメソッド名の配列

Railsでは引数で文字列を受け取るヘルパーメソッドをよく使います。ヘルパーメソッドによっては引数に渡す文字列についてi18nが必要ない場合があります。

```haml
/ 例: render
= render 'foos/bar', bar: @bar
```
引数をi18nする必要がないメソッド名の配列を`ignore_methods`で指定すると、それらのメソッドはhaml_i18n_lintで検知されなくなります。

デフォルトの設定だと`ignore_methods`には`t`や`render`やRailsの一部のヘルパーなどが含まれています。
独自のヘルパーなどを追加したい場合、`super`に追加したいメソッド名を足します。

#### ignore_keys: 値をi18nする必要がない属性のキー

Hamlでは以下のようにハッシュでid属性やclass属性を指定できますが、これらの属性はi18nする必要がありません。

```haml
%p{id: 'foo', class: 'bar'}
```

i18nが必要ない属性の名前を`ignore_keys`で指定すると、それらの属性はhaml_i18n_lintで検知されなくなります。

#### files: lint対象のファイル

`files`ではlint対象のファイルパスの配列を返します。
デフォルトでは`**/*.haml`にマッチするファイルの配列が返ってきます。


`-f`オプションや引数でファイルを指定することもできますが、例えば特定のディレクトリのファイルのみ無視したい場合のように、対象ファイルの絞り込みが複雑な場合はRubyで書いたほうが楽です。


### 設定ファイルを指定して実行

`-c`オプションで今書いた設定ファイルを指定して実行してみます。

```
% haml_i18n_lint -c haml_i18n_lint.rb

app/views/accounts/show.html.haml:15
14:  .h-feed
15:    %data.p-name{ value: "#{@account.username} on #{site_hostname}" }/
16:  
----------------

(長いので省略)
```

パースに失敗するファイルを対象から外したので無事最後まで実行できました :smiley: 
`fa_icon`が誤検知されなくなりました :tada:
metaタグの属性やlink_toのオプションが検知されなくなりました :+1:


### 件数を確認する

設定ファイルに以下を追加します。

```haml_i18n_lint.rb
result_count = 0
define_method(:report) do |result_set|
  result_count += result_set.count
  super(result_set)
end

# 終了時に総検知数を出す
at_exit do
  puts "Total: #{result_count}"
end
```

実行してみると172箇所検知されました。(誤検知もあると思います)

```
% haml_i18n_lint -c haml_i18n_lint.rb | tail
38:        = link_to t('admin.reports.mark_as_resolved'), admin_report_path(@report, outcome: 'resolve'), method: :put, class: 'button'
39:  - elsif !@report.action_taken_by_account.nil?
----------------
app/views/admin/reports/show.html.haml:43
42:    %p
43:      %strong Action taken by:
44:      = @report.action_taken_by_account.acct
----------------

172
```

### i18nが必要かどうか判断する処理をカスタマイズする

設定ファイルに以下を追加します。`need_i18n?(str)`メソッドを定義すると、文字列のi18nが必要かどうか判断する処理をカスタマイズできます。デフォルトだと文字列が`/\p{Alpha}/`にマッチするかどうかで判断しています。

```rb
# スペースが全く無い文字列は機械向けかも?
def need_i18n?(str)
  super && str.include?(' ')
end
```

実行してみると結構減りました。

```
----------------


app/views/admin/reports/show.html.haml:43
42:    %p
43:      %strong Action taken by:
44:      = @report.action_taken_by_account.acct
----------------

85
```

### その他の設定

examplesディレクトリ以下に上記のような設定例のほか、i18n用のYAMLを出力する例などがまとまっています。
https://github.com/okinawarb/haml_i18n_lint/tree/master/examples


まとめ
----

haml_i18n_lintを使うとガッとi18nしたあとも継続的にi18nできそう。