Skip to content

Latest commit

 

History

History
217 lines (172 loc) · 5.74 KB

i18n.md

File metadata and controls

217 lines (172 loc) · 5.74 KB

I18n

Switching language

The following determines the used locale by the following priorities:

  1. locale parameter
  2. Http-Accept header
  3. application default locale
# app/application_controller.rb
class ApplicationController < ActionController::Base
  around_action :switch_locale

  def default_url_options(options = {})
    { locale: I18n.locale }.merge(options)
  end

  private

  def switch_locale(&action)
    locale = extract_params_locale || extract_accept_header_locale || I18n.default_locale
    I18n.with_locale(locale, &action)
  end

  def extract_params_locale
    I18n.locale_available?(params[:locale]) ? params[:locale] : nil
  end

  def extract_accept_header_locale
    accepting_locales = (request.env['HTTP_ACCEPT_LANGUAGE'] || '').split(',').map do |part|
      part.strip.scan(/^[a-z]{2}/).first
    end

    accepting_locales.find { |locale| I18n.locale_available?(locale) }
  end
end

# spec/controllers/application_controller_spec.rb
RSpec.describe ApplicationController do
  controller do
    def fake_action
      render plain: 'fake'
    end
  end

  before do
    custom_routes = proc do
      scope '(:locale)' do
        get 'fake_action' => 'anonymous#fake_action'
      end
    end

    routes.draw(&custom_routes)
  end

  describe '#set_locale' do
    before do
      allow(I18n).to receive(:with_locale)
    end

    context 'when locale param AND header are specified' do
      let(:params) { { locale: 'fr' } }

      before do
        request.headers['HTTP_ACCEPT_LANGUAGE'] = 'it-CH, it;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'
      end

      it 'locale param takes precedence' do
        get :fake_action, params: params
        expect(I18n).to have_received(:with_locale).with('fr')
      end
    end

    context 'when only the locale param is specified' do
      let(:params) { { locale: 'fr' } }

      it 'uses it' do
        get :fake_action, params: params
        expect(I18n).to have_received(:with_locale).with('fr')
      end
    end

    context 'when only the header is specified' do
      before do
        request.headers['HTTP_ACCEPT_LANGUAGE'] = 'en, fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'
      end

      it 'the first supported locale from the header is taken' do
        get :fake_action
        expect(I18n).to have_received(:with_locale).with('fr')
      end
    end

    context 'when neither locale param nor header are specified' do
      it 'uses the default one' do
        get :fake_action
        expect(I18n).to have_received(:with_locale).with(I18n.default_locale)
      end
    end
  end
end

Don't forget to add a system (or request) spec to glue together the abstraction you just added with the real world:

require 'rails_helper'

RSpec.describe 'Multilanguage support' do
  it 'can switch the locale per navigation link' do
    visit root_path(locale: 'fr')
    expect(page).to have_content(I18n.t('lobby.start_button', locale: 'fr'))
    click_link('IT', class: 'nav-link')
    expect(page).to have_content(I18n.t('lobby.start_button', locale: 'it'))
  end
end

HTTP Headers

The following sets Content-Language and Link headers (canonical and hreflang):

# app/controllers/concerns/set_response_headers_concern.rb
module SetResponseHeadersConcern
  extend ActiveSupport::Concern

  def set_response_headers
    set_content_language_header
    set_link_header
  end

  private

  def set_content_language_header
    response.set_header('Content-Language', I18n.available_locales.join(', '))
  end

  def set_link_header
    hreflangs = I18n.available_locales.map(&method(:hreflang_link_value))
    canonical = params[:locale].present? ? nil : canonical_link_value(I18n.locale)
    response.set_header('Link', [*hreflangs, canonical].compact.join(', '))
  end

  def hreflang_link_value(locale)
    "<#{url_for(locale: locale, only_path: false)}>; rel=\"alternate\"; hreflang=\"#{locale}\""
  end

  def canonical_link_value(locale)
    "<#{url_for(locale: locale, only_path: false)}>; rel=\"canonical\""
  end
end

# app/application_controller.rb
class ApplicationController < ActionController::Base
  include SetResponseHeadersConcern
  after_action :set_response_headers
end

# spec/controllers/application_controller_spec.rb
RSpec.describe ApplicationController do
  controller do
    def fake_action
      render plain: 'fake'
    end
  end

  before do
    custom_routes = proc do
      scope '(:locale)' do
        get 'fake_action' => 'anonymous#fake_action'
      end
    end

    Rails.application.routes.draw(&custom_routes) # needed for url_for in SetResponseHeadersConcern
    routes.draw(&custom_routes) # needed for RSpec
  end

  after do
    Rails.application.reload_routes!
  end

  describe '#set_response_headers' do
    it { expect(described_class.ancestors).to include(SetResponseHeadersConcern) }

    it 'sets the canonical header for unlocalized requests' do
      get :fake_action, params: {}
      expect(response.headers['Link']).to match(/canonical/)
    end

    it 'sets the canonical header for explicitly unlocalized requests' do
      get :fake_action, params: { locale: nil }
      expect(response.headers['Link']).to match(/canonical/)
    end

    it 'does NOT set the canonical header for localized requests' do
      get :fake_action, params: { locale: 'de' }
      expect(response.headers['Link']).not_to match(/canonical/)
    end

    it 'sets the hreflang headers', :aggregate_failures do
      get :fake_action
      expect(response.headers['Link']).to match(/hreflang="de"/)
      expect(response.headers['Link']).to match(/hreflang="fr"/)
    end

    it 'sets content language header', :aggregate_failures do
      get :fake_action
      expect(response.headers['Content-Language']).to match(/de/)
      expect(response.headers['Content-Language']).to match(/fr/)
    end
  end
end