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

2012 July 14, 22:39 h - tags: tutorial i18n learning rails

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:

1
2
3
CREATE DATABASE dbname 
  CHARACTER SET utf8 
  COLLATE utf8_general_ci;

E no PostgreSQL faça:

1
2
3
4
5
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
2
3
4
# encoding: UTF-8
# coding: UTF-8
# -*- coding: UTF-8 -*-
# -*- 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:

1
rails new i18n_demo

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

1
rm public/index.html

Adicione as seguintes gems no seu Gemfile:

1
2
3
4
5
6
7
8
9
10
11
...
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
2
3
4
5
6
7
8
9
10
11
# Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
# Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
config.time_zone = 'Brasilia'

# 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"

# 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:

1
2
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.yml

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

1
rails g controller welcome index

Adicione no seu arquivo de rotas:

1
2
3
4
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

1
2
3
4
5
6
7
8
9
10
11
12
13
<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:

1
2
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.

1
2
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ 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:

1
2
3
4
5
6
7
8
9
10
11
12
<h2>Resend confirmation instructions</h2>

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

  <div><%= f.label :email %><br />
  <%= f.email_field :email %></div>

  <div><%= f.submit "Resend confirmation instructions" %></div>
<% 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.

1
2
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):

1
2
3
4
5
6
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:

1
2
3
4
5
6
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:

1
2
3
# 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:

1
2
<h1><%= t(".title") %></h1>
...

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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.
1
2
3
4
5
6
7
8
9
10
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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:

1
gem 'globalize3'

Para demonstrar como funciona, vamos criar um novo model:

1
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

1
2
3
4
5
6
7
8
9
10
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:

1
add_index :articles, :slug, unique: true

Feito isso adicione a gem no Gemfile:

1
gem 'friendly_id'

Execute bundle para instalar e modifique seu modelo:

1
2
3
4
5
6
7
8
9
10
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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.

Comments

comentários deste blog disponibilizados por Disqus