Internationalização (I18n) Mínima em Rails 3.2 - Parte 1

Aviso: Não se esqueçam que dias 30 e 31 de Agosto teremos a Rubyconf Brasil 2012! Já garantiu seu ingresso? Compre agora mesmo!

Este é um artigo que eu queria escrever há muito tempo, finalmente consegui formatá-lo como queria. Um dos assuntos que até hoje ainda é difícil de explicar para iniciantes é como utilizar o suporte de I18n do Rails. Todos sabem que o Rails possui uma excelente infraestrutura para internacionalização (i18n) e localização. Porém, a instalação básica do Rails fornece somente a infraestrutura, ou seja, o desenvolvedor é quem deve escolher quais componentes adicionais instalar sobre essa infraestrutura para retirar o máximo que o Rails pode oferecer.

I18n e L10n vai muito mais do que a simples tarefa de substituir strings de texto. Formatação de dados (data, hora, moeda) variam. Codificação dos dados (o padrão sempre tem que ser UTF8!). URLs sensíveis à localização. Modelos sensíveis à localização. Para começar, não pretendo repetir o que todo desenvolvedor Rails já deveria saber, portanto se ainda não leu o Guia Oficial sobre a API de Internacionalização do Rails sugiro que faça isso antes de continuar lendo este artigo.

Neste guia vou demonstrar o básico de i18n criando uma aplicação mínima, com Devise. Todo os códigos mostrados aqui estão no meu repositório no Github. Se quiser, pule diretamente para a seção que mais lhe interessa:

Banco de Dados e Codificação de Strings

Não pretendo repetir a antiga discussão sobre encodings, UTF8, e o suporte no Ruby 1.9. Se ainda não leu, recomendo que antes de continuar também leia os seguintes artigos do Yehuda Katz:

Além disso, uma coisa a se lembrar se você gosta de criar seu banco de dados via linha de comando sem usar o rake db:create é adicionar o character set e collate corretos. No MySQL sempre faça:


CREATE DATABASE dbname   
CHARACTER SET utf8   
COLLATE utf8_general_ci;  

E no PostgreSQL faça:


CREATE DATABASE dbname  
WITH OWNER postgres  
ENCODING UTF8  
 LC_COLLATE = en_US.UTF-8  
 LC_CTYPE = en_US.UTF-8;  

Obviamente, troque dbname e postgres de acordo com sua aplicação. Não tem coisa que eu abomine mais do que um banco de dados e uma aplicação web desenvolvida em “Latin1” (iso-8859-1) ou pior ainda, em Windows 1252. Apenas para refrescar a memória, até o Ruby 1.8 todo string de texto era nada mais do que uma cadeia de bytes, como em C (justamente mantendo compatibilidade com C). A partir do Ruby 1.9 foi adicionado um complexo sistema para lidar com todo tipo de linguagem, encoding e com isso o suporte a UTF-8 se tornou bem melhor e deve ser o padrão. Agora um string é uma cadeia de caracteres, que pode se double-byte associado a uma tabela de encoding para garantir que todas as operações de string, incluindo regular expressions, atuem da forma correta sem corromper o texto. O Rails atual também leva isso em consideração e tudo é, por padrão, UTF8. Nunca misture.

Sempre utilize um editor de texto decente que salve arquivos, por padrão, em UTF8. Se estiver em Mac ou Linux isso já é praticamente padrão. Se estiver em Windows, preocupe-se, muitos não gravam em UTF8.

Outra coisa que confunde muitas pessoas, alguns arquivos Ruby recebem um cabeçalho, logo na primeira linha, parecido com as linhas abaixo:


1. encoding: UTF-8
2. coding: UTF-8
3. * coding: UTF-8 *
4. * coding: utf-8 *  

Qualquer uma das opções acima tem o mesmo efeito. A regra é simples: se você tiver somente código Ruby, sem nenhuma string internacionalizada (com acentuações de português, ideogramas em chinês, símbolos) isso não é necessário. Se o arquivo conter texto, daí a checagem do Ruby vai exigir que você explicitamente diga que encoding está no string. Porém, use isso como um code smell no caso de uma aplicação web internacionalizada, pois todo texto deveria estar em arquivos de localização (como vamos mostrar). Somente em aplicativos não-internacionalizados, onde o texto às vezes estará misturado ao seu código, isso pode te ajudar.

Iniciando uma aplicação Rails

Assumindo que estamos usando o Rails 3.2 (3.2.6, para ser mais preciso). Criamos uma aplicação normalmente:


rails new i18n_demo

Agora vamos fazer algumas mudanças que você deveria fazer em toda aplicação:


rm public/index.HTML

Adicione as seguintes gems no seu Gemfile:



  
gem devise

group :development, :test do  
 gem rspec-rails  
 gem pry-rails

gem guard gem guard-rspec gem growl

end

Modifique seu config/application.rb, aproximadamente na linha 28, para ficar como no trecho a seguinte:


#1. Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
#2. Run “rake -D time” for a list of tasks for finding time zone names. Default is UTC.  
config.time_zone = Brasilia

#3. The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
config.i18n.load_path += Dir[Rails.root.join(my, locales, *.{rb,yml}).to_s]  
config.i18n.available_locales = [:en, :pt-BR]  
config.i18n.default_locale = :pt-BR

#4. Configure the default encoding used in templates for Ruby 1.9.  
config.encoding = utf-8  

Neste exemplo considero que estou apenas suportanto Inglês (en) e Português (pt-BR). Por padrão o Rails sempre procurará por arquivos de tradução a partir do diretório config/locales e sempre com arquivos no formato #{locale}.yml ou #{locale}rb, por exemplo, devise.pt-BR.yml.

Dica: nunca coloque tudo num único arquivo pt-BR.yml, separe suas traduções em múltiplos arquivos por assunto, como rails.pt-BR.yml para traduções relacionadas aos helpers do Rails e devise.pt-BR.yml para traduções específicas da gem Devise.

Eu particularmente prefiro utilizar arquivos YAML do que hashes em Ruby. Tecnicamente não faz diferença, mas eu recomendo se manter no YAML.

Um dos rubistas que há mais tempo vem auxiliando no suporte a i18n no Rails é Sven Fuchs, a grande maioria das técnicas, bibliotecas, e tudo mais que temos de I18n veio inicialmente dele, vale a pena conhecê-lo melhor. Além disso o suporte canônico a arquivos de localização está no seu projeto rails-i18n. Você vai encontrar traduções de praticamente todas as linguagens do mundo que importa. No nosso caso, queremos puxar a localização em Português. Faça assim:


curl <https://raw.github.com/svenfuchs/rails-i18n/master/rails/locale/pt-BR.yml> \> config/locales/rails.pt-BR.yml  
curl <https://raw.github.com/svenfuchs/rails-i18n/master/rails/locale/pt-BR.yml> \> config/locales/rails.en.yaml

Somente isso já lhe vai traduzir a maioria dos helpers do Rails. Para testar, vamos criar uma página simples:


rails g controller welcome_index

Adicione no seu arquivo de rotas:


I18nDemo::Application.routes.draw do  
 get welcome/index, as: welcome  
 root to: welcome#index’  
end

E coloque no seu arquivo app/views/welcome/index.html.erb


<dl>
  <dt>Number to Currency</dt>
  <dd><%= number_to_currency(123.56) %></dd>
  <dt>Number to Human</dt>
  <dd><%= number_to_human(100_555_123.15) %></dd>
  <dt>Date</dt>
  <dd><%=l Time.current, format: :long %></dd>
  <dt>Time</dt>
  <dd><%= distance_of_time_in_words(1.hour + 20.minutes) %></dd>
</dl>

Se subir o Webrick com rails s e consultar http://localhost:3000 verá uma página com o seguinte:

Estará em português porque configuramos a aplicação para que o padrão fosse esse: config.i18n.default_locale = :“pt-BR”. Neste momento ainda não temos como mudar a linguagem. Mas não se preocupe, em breve você poderá realizar a mudança. O visual estará diferente da imagem, veremos isso mais para frente.

Devise

Praticamente todo aplicativo web precisa de autenticação. Não somente login, mas confirmação de inscrição, email para reiniciar senha, etc. Não pense duas vezes: adicione o Devise. Novamente, não vou repetir o que já foi dito, para aprender mais sobre o assunto, assista o screencast que o Ryan Bates lançou recentemente:

Na prática, dado que já incluímos a gem no Gemfile anteriormente e já rodamos bundle para instalar as gems, o próximo passo é criar os arquivos que precisamos:


rails g devise:install  
rails g devise User

Da mesma forma como fizemos antes, vamos baixar as traduções que precisamos. O primeiro lugar a procurar é no Wiki oficial do Devise que, atualmente, sugere o projeto do Christopher Dell que tenta ser o repositório centralizado com todas as traduções em todas as linguagens para o Devise.


curl <https://raw.github.com/tigrish/devise-i18n/master/locales/en-US.yml> > config/locales/devise.en.yml  
curl <https://github.com/tigrish/devise-i18n/blob/master/locales/pt-BR.yml> > config/locales/devise.pt-BR.yml

Mas somente isso não é suficiente. Você pode utilizar as views que vem embutidas na Rails Engine do Devise ou pode precisar customizá-las e nesse caso precisa copiar essas views para dentro do seu projeto, desta forma:


rails g devise:views
 invoke Devise::Generators::SharedViewsGenerator  
 create app/views/devise/shared  
 create app/views/devise/shared_links.erb  
 invoke form_for  
 create app/views/devise/confirmations  
 create app/views/devise/confirmations/new.html.erb  
 create app/views/devise/passwords  
 create app/views/devise/passwords/edit.html.erb  
 create app/views/devise/passwords/new.html.erb  
 create app/views/devise/registrations  
 create app/views/devise/registrations/edit.html.erb  
 create app/views/devise/registrations/new.html.erb  
 create app/views/devise/sessions  
 create app/views/devise/sessions/new.html.erb  
 create app/views/devise/unlocks  
 create app/views/devise/unlocks/new.html.erb  
 invoke erb  
 create app/views/devise/mailer  
 create app/views/devise/mailer/confirmation_instructions.html.erb  
 create app/views/devise/mailer/reset_password_instructions.html.erb  
 create app/views/devise/mailer/unlock_ instructions.html.erb  

Veja que ele copia muitos arquivos. Vamos abrir um deles:


Resend confirmation instructions

<%= form_for(resource, :as => resource_name, :url => confirmation_path(resource_name), :html => { :method => :post }) do |f| \>  
 <= devise_error_messages! %\>

<%= f.label :email >  
 <= f.email_field :email %\>

<%= f.submit “Resend confirmation instructions” %\>

<% end %\>

<%= render “devise/shared/links” %\>  

Note que todos os textos estão embutidos: exatamente o que eu disse que numa aplicação I18n não devemos fazer. Ou seja, para tornar essas views traduzíveis, precisamos extrair uma a uma. Sim, será bastante trabalho. Ou você pode usar o caminho mais fácil e procurar alguém que já tenha feito isso – como eu fiz neste aplicativo de exemplo.


wget <https://raw.github.com/akitaonrails/Rails-3-I18n-Demonstration/master/config/locales/devise.views.en.yml> \> config/locales/devise.views.en.yml  
wget <https://raw.github.com/akitaonrails/Rails-3-I18n-Demonstration/master/config/locales/devise.views.pt-BR.yml> \> config/locales/devise.views.pt-BR.yml  

Isso vai copiar as traduções que você vai precisar. Agora faça um clone do meu projeto e copie sobre o seu (caso esteja fazendo este exercício enquanto lê o artigo):


cd ..  
git clone git://github.com/akitaonrails/Rails-3-I18n-Demonstration.git akitaonrails-i18n-demo  
cd akitaonrails-i18n  
git checkout 0ff207ed9ea58a14a16c14fc11272a3991918dab  
cd i18n_demo  
cp -R ../akitaonrails-i18n_demo/app/views/devise/* app/views/devise/  

Isso deve sobrescrever as views criadas pelo gerador padrão do Devise pela minha versão já com as strings substituídas. Apenas como dica note que no arquivo de tradução temos trechos como este:



pt-BR:  
 devise:  
 confirmations:  
 new:  
 title: Reenviar instruções de confirmação  
 submit: Reenviar instruções de confirmação  

E para acesssar a tradução podemos fazer assim:


I18n.locale = :pt-BR  
I18n.t(devise.confirmations.new.title)  
I18n.t(:title, scope: [:devise, :confirmations, :new])  

Qualquer dessas e outras variações funcionam igual e acham a string correta, mas na view que você baixou do meu projeto, temos o seguinte no arquivo app/views/devise/confirmations/new.html.erb:


# <%= t(“.title”) %\>

O Rails automaticamente usa a convenção {namespace}/{controllers}/{action} para gerar o escopo (scope) e então só precisamos complementar com .title para achar a chave que queremos. Isso diminui muito a quantidade de digitação para as chaves de tradução, basta seguir as convenções como sempre.

Outra coisa importante é traduzir os nomes dos modelos e seus atributos. Eles são usados pelos helpers de formulários do Rails. No arquivo config/locales/rails.pt-BR.yml adicione ao final do arquivo:


activemodel:  
 errors:  
 <<: *errors  
activerecord:  
 errors:  
 <<: *errors  
 models:  
 user: “Usuário”  
 article: “Artigo”  
 attributes:  
 user:  
 email: “E-mail”  
 password: “Senha”  
 password_confirmation: “Confirmar Senha”  
 current_password: “Senha Atual”  
 remember_me: “Lembre-se de mim”  
 article:  
 title: “Título”  
 body: “Conteúdo”  
 body_html: “Conteúdo em HTML”  

O modelo User já foi criado pelo Devise. Ainda não criamos o modelo Article mas já fica aqui para referência. No arquivo config/locales/rails.en.yml podemos simplificar porque quando o Rails não encontra a tradução ele usa o próprio nome dos atributos.

Sempre desenvolva todas as suas aplicações em inglês, é uma boa recomendação. E que seja português sempre a segunda linguagem. Usando essa convenção você não deve ter grandes problemas. Mas ao mesmo tempo não inclua no arquivo config/initializers/inflections.rb as regras de pluralização em português, pois isso vai causar problemas ao Rails para encontrar nomes de tabelas ao pluralizá-los usando a regra errada em português.


en:  
 hello: “Hello world”  
 site_name: “I18n Demonstration”  
 translation:  
 en: English  
 pt-BR: Portuguese  
 admin:  
 title: “Administration”  
 articles:  
 title: “Articles”  

Atributos Traduzidos de ActiveRecord com Globalize 3

O conceito é simples: queremos um suporte que me permita utilizar os mesmos nomes de atributos mas que devolvam valores diferntes dependendo da localização escolhida atualmente. Um código de teste seria assim:


require spec_helper

describe Article do  
 before(:each) do  
 I18n.locale = :en  
 @article = Article.create title: Hello World, body:  **Test**   
 I18n.locale = :pt-BR  
 @article.update_attributes(title: Ola Mundo, body: _Teste_)  
 end

context translations do 
    it should read the correct translation do 
      @article = Article.last I18n.locale = :en 
      @article.title.should == Hello World 
      @article.body.should ==  **Test**  
      I18n.locale = :pt-BR 
      @article.title.should == Ola Mundo 
      @article.body.should == _Teste_ 
    end 
  end
end  

A opção que escolhi foi novamente um projeto do Sven Fuchs, o Globalize 3. Esse projeto tem um longo histórico que volta desde, claro, Globalize 2 e Globalize. Obviamente, não use as versões antigas, coloquei os links apenas para referência. Como sempre, adicione ao seu Gemfile e execute bundle em seguida:


gem globalize3

Para demonstrar como funciona, vamos criar um novo model:


rails g model Article slug title body:text body_html:text  

Preste atenção no arquivo de migration criado por esse generator. Abra no seu editor e modifique para que ele fique da seguinte forma:



class CreateArticles < ActiveRecord::Migration  
 def up  
 create_table :articles do |t|  
 t.string :slug, null: false  
 t.timestamps  
 end  
 add_index :articles, :slug, unique: true

Article.create_translation_table! :title => :string, :body => :text end 
    def down_drop_table 
      :articles Article.drop_translation_table! 
    end
end  

Não use o método change da migration. Feita a mudança execute rake db:migrate para criar as tabelas. Agora, vamos alterar o arquivo app/model/article.rb:



class Article < ActiveRecord::Base  
 attr_accessible :slug, :title, :body, :body_html, :locale, :translations_attributes

translates :title, :body, :body_html accepts_nested_attributes_for :translations 
  class Translation attr_accessible :locale, :title, :body, :body_html 
  end
end 

Agora o model vai se comportar exatamente como descrito na spec acima. O truque é que a migration vai criar uma nova tabela articles e também article_translation e criará implicitamente por causa do método de classe translates uma associação parecida com has_many :translations, class_name: “article_translation”. Ele vai alterar o model para que os atributos passem a consultar o I18n.locale antes de ler ou gravar novos dados. Cada localização se tornar uma linha na tabela escondida de traduções e consultas no model Article procuram na tabela implícita usando o locale atual.

Seção Bônus: Friendly Id

Vamos fazer um pequeno desvio que não tem nada a ver com I18n para recomendar mais algumas coisas. A primeira é o problema de IDs e Slugs. Se você utilizar o Rails básico, todo Model terá um ID numérico incremental e toda URL será no formato /articles/123. A recomendação é nunca expor o ID único diretamente do banco de dados na sua aplicação. Dependendo do que sua aplicação fizer, o usuário pode começar a experimentar colocar valores numéricos e acabar achando algo que você não queria que ele visse.

Uma das formas de esconder esses IDs numéricos é usar um slug e transformar uma informação específica do seu Modelo – de preferência a segunda coisa depois do ID que seja mais único quanto possível – por exemplo, no caso do nosso modelo Article, o candidato seria o atributo title. Lembram que quando criamos o modelo já adicionei um campo slug? É para usá-lo aqui.

A gem que recomendo para gerenciar slugs é a Friendly Id do Norman Clark. A utilização é muito simples: primeiro garanta ter um campo slug no seu model, lembrando de adicionar também um índice que garanta sua unicidade no banco. Olhe novamente a migration anterior e vai encontrar esta linha:


add_index :articles, :slug, unique: true  

Feito isso adicione a gem no Gemfile:


gem friendly_id

Execute bundle para instalar e modifique seu modelo:


class Article < ActiveRecord::Base  
 attr_accessible :body, :body_html, :slug, :title, :locale, :translations_attributes  
 extend FriendlyId  
 friendly_id :title, use: :slugged  
   
 private

def should_generate_new_friendly_id? new_record? end

Se fizer tudo corretamente, o comportamento será de acordo com a seguinte spec:


require spec_helper

describe Article do  
 before(:each) do  
 I18n.locale = :en  
 @article = Article.create title: Hello World, body:  **Test**   
   
 end  
   
 context slug do  
 it should generate a slug do  
 @article.slug.should == hello-world  
 end  
 end  
end  

Não só isso mas helpers como article_path e mesmo o método find do model passam a aceitar tanto a chave primária numérica como o slug para procurar no banco de dados. Essa gem torna essa utilização transparente e você vai usá-la como se estivesse usando uma chave primária normal.

Um detalhe: note o método should_generate_new_friendly_id? que sobrescrevemos. No comportamento padrão, a gem vai atualizar o atributo slug sempre que atualizarmos o conteúdo do campo title. Mas não queremos que isso aconteça, especialmente porque se adicionarmos o conteúdo localizado do mesmo atributo, o slug vai sempre ficar mudando para cada novo título que acrescentarmos. Além disso, se um conteúdo já foi publicado, não convém mudar sua URL – isso é ruim para SEO. Portanto o gerador de slugs só vai rodar se for um registro novo, não se for uma atualização de um já existente.

Continua na Parte 2

Continue lendo este artigo na Parte 2.