The following determines the used locale by the following priorities:
locale
parameterHttp-Accept
header- 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
Add our rack-canonical-header
gem
to your Gemfile
in production to automatically patch the HTTP header tag Link
.
group :production do
gem 'rack-canonical-header'
end
This requires you to provide an env variable CANONICAL_HOST
in production.
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