# RouteTranslator [![Gem Version](https://badge.fury.io/rb/route_translator.svg)](https://badge.fury.io/rb/route_translator) [![Build Status](https://github.com/enriclluelles/route_translator/actions/workflows/ruby.yml/badge.svg)](https://github.com/enriclluelles/route_translator/actions) [![Maintainability](https://qlty.sh/gh/enriclluelles/projects/route_translator/maintainability.svg)](https://qlty.sh/gh/enriclluelles/projects/route_translator) [![Coverage Status](https://coveralls.io/repos/github/enriclluelles/route_translator/badge.svg?branch=master)](https://coveralls.io/github/enriclluelles/route_translator?branch=master) RouteTranslator is a gem to allow you to manage the translations of your app routes with a simple dictionary format. It started as a fork of the awesome [translate_routes](https://github.com/raul/translate_routes) plugin by [Raúl Murciano](https://github.com/raul). Right now it works with Rails 7.2 and 8.x ## Quick Start 1. If you have this `routes.rb` file originally: ```ruby Rails.application.routes.draw do namespace :admin do resources :cars end resources :cars end ``` The output of `bundle exec rails routes` would be: ``` admin_cars GET /admin/cars(.:format) admin/cars#index POST /admin/cars(.:format) admin/cars#create new_admin_car GET /admin/cars/new(.:format) admin/cars#new edit_admin_car GET /admin/cars/:id/edit(.:format) admin/cars#edit admin_car GET /admin/cars/:id(.:format) admin/cars#show PUT /admin/cars/:id(.:format) admin/cars#update DELETE /admin/cars/:id(.:format) admin/cars#destroy cars GET /cars(.:format) cars#index POST /cars(.:format) cars#create new_car GET /cars/new(.:format) cars#new edit_car GET /cars/:id/edit(.:format) cars#edit car GET /cars/:id(.:format) cars#show PUT /cars/:id(.:format) cars#update DELETE /cars/:id(.:format) cars#destroy ``` 2. Add the gem to your `Gemfile`: ```ruby gem 'route_translator' ``` And execute `bundle install` 3. Generate the default initializer: ```sh bundle exec rails g route_translator:install ``` 4. Wrap the groups of routes that you want to translate inside a `localized` block: ```ruby Rails.application.routes.draw do namespace :admin do resources :cars end localized do resources :cars get 'pricing', to: 'home#pricing', as: :pricing end end ``` And add the translations to your locale files, for example: ```yml es: routes: cars: coches new: nuevo pricing: precios fr: routes: cars: voitures new: nouveau pricing: prix ``` 5. Your routes are translated! Here's the output of your `bundle exec rails routes` now: ``` Prefix Verb URI Pattern Controller#Action admin_cars GET /admin/cars(.:format) admin/cars#index POST /admin/cars(.:format) admin/cars#create new_admin_car GET /admin/cars/new(.:format) admin/cars#new edit_admin_car GET /admin/cars/:id/edit(.:format) admin/cars#edit admin_car GET /admin/cars/:id(.:format) admin/cars#show PATCH /admin/cars/:id(.:format) admin/cars#update PUT /admin/cars/:id(.:format) admin/cars#update DELETE /admin/cars/:id(.:format) admin/cars#destroy cars_fr GET /fr/voitures(.:format) cars#index {:locale=>"fr"} cars_es GET /es/coches(.:format) cars#index {:locale=>"es"} cars_en GET /cars(.:format) cars#index {:locale=>"en"} POST /fr/voitures(.:format) cars#create {:locale=>"fr"} POST /es/coches(.:format) cars#create {:locale=>"es"} POST /cars(.:format) cars#create {:locale=>"en"} new_car_fr GET /fr/voitures/nouveau(.:format) cars#new {:locale=>"fr"} new_car_es GET /es/coches/nuevo(.:format) cars#new {:locale=>"es"} new_car_en GET /cars/new(.:format) cars#new {:locale=>"en"} edit_car_fr GET /fr/voitures/:id/edit(.:format) cars#edit {:locale=>"fr"} edit_car_es GET /es/coches/:id/edit(.:format) cars#edit {:locale=>"es"} edit_car_en GET /cars/:id/edit(.:format) cars#edit {:locale=>"en"} car_fr GET /fr/voitures/:id(.:format) cars#show {:locale=>"fr"} car_es GET /es/coches/:id(.:format) cars#show {:locale=>"es"} car_en GET /cars/:id(.:format) cars#show {:locale=>"en"} PATCH /fr/voitures/:id(.:format) cars#update {:locale=>"fr"} PATCH /es/coches/:id(.:format) cars#update {:locale=>"es"} PATCH /cars/:id(.:format) cars#update {:locale=>"en"} PUT /fr/voitures/:id(.:format) cars#update {:locale=>"fr"} PUT /es/coches/:id(.:format) cars#update {:locale=>"es"} PUT /cars/:id(.:format) cars#update {:locale=>"en"} DELETE /fr/voitures/:id(.:format) cars#destroy {:locale=>"fr"} DELETE /es/coches/:id(.:format) cars#destroy {:locale=>"es"} DELETE /cars/:id(.:format) cars#destroy {:locale=>"en"} pricing_fr GET /fr/prix(.:format) home#pricing {:locale=>"fr"} pricing_es GET /es/precios(.:format) home#pricing {:locale=>"es"} pricing_en GET /pricing(.:format) home#pricing {:locale=>"en"} ``` Note that only the routes inside a `localized` block are translated. In :development environment, I18n is configured by default to not use fallback language. When a translation is missing, it uses the translation key last segment as fallback (`cars` and `new` in this example). In :production environment, you should either set `config.i18n.fallbacks = false` or set up translations for your routes in every languages. 6. If you want to set `I18n.locale` from the url parameter locale, add the following line in your `ApplicationController` or in the controllers that have translated content: ```ruby around_action :set_locale_from_url ``` Note: you might be tempted to use `before_action` instead of `around_action`: just don't. That could lead to [thread-related issues](https://github.com/enriclluelles/route_translator/issues/44). ### Changing the Language To change the language and reload the appropriate route while staying on the same page, use the following code snippet: ```ruby link_to url_for(locale: 'es'), hreflang: 'es', rel: 'alternate' ``` Although locales are stored by Rails as a symbol (`:es`), when linking to a page in a different locale you need to use a string (`'es'`). Otherwise, instead of a namespaced route (`/es/my-route`) you will get a parameterized route (`/my-route?locale=es`). If the page contains a localized slug, the above snippet does not work and a custom implementation is needed. More information at [Generating translated URLs](https://github.com/enriclluelles/route_translator/wiki/Generating-translated-URLs) ### Namespaces You can translate a namespace route by either its `name` or `path` option: 1. Wrap the namespaces that you want to translate inside a `localized` block: ```ruby Rails.application.routes.draw do localized do namespace :admin do resources :cars, only: :index end namespace :sold_cars, path: :sold do resources :cars, only: :index end end end ``` And add the translations to your locale files, for example: ```yml es: routes: admin: administrador cars: coches new: nuevo pricing: precios sold: vendidos fr: routes: admin: administrateur cars: voitures new: nouveau pricing: prix sold: vendues ``` 4. Your namespaces are translated! Here's the output of your `bundle exec rails routes` now: ``` Prefix Verb URI Pattern Controller#Action admin_cars_fr GET /fr/administrateur/voitures(.:format) admin/cars#index {:locale=>"fr"} admin_cars_es GET /es/administrador/coches(.:format) admin/cars#index {:locale=>"es"} admin_cars_en GET /admin/cars(.:format) admin/cars#index {:locale=>"en"} sold_cars_cars_fr GET /fr/vendues/voitures(.:format) sold_cars/cars#index {:locale=>"fr"} sold_cars_cars_es GET /es/vendidos/coches(.:format) sold_cars/cars#index {:locale=>"es"} sold_cars_cars_en GET /sold/cars(.:format) sold_cars/cars#index {:locale=>"en"} ``` ### Inflections At the moment inflections are not supported, but you can use the following workaround: ```ruby localized do resources :categories, path_names: { new: 'new_category' } end ``` ```yml en: routes: category: category new_category: new es: routes: category: categoria new_category: nueva ``` ``` Prefix Verb URI Pattern Controller#Action categories_es GET /es/categorias(.:format) categories#index {:locale=>"es"} categories_en GET /categories(.:format) categories#index {:locale=>"en"} POST /es/categorias(.:format) categories#create {:locale=>"es"} POST /categories(.:format) categories#create {:locale=>"en"} new_category_es GET /es/categorias/nueva(.:format) categories#new {:locale=>"es"} new_category_en GET /categories/new(.:format) categories#new {:locale=>"en"} edit_category_es GET /es/categorias/:id/edit(.:format) categories#edit {:locale=>"es"} edit_category_en GET /categories/:id/edit(.:format) categories#edit {:locale=>"en"} category_es GET /es/categorias/:id(.:format) categories#show {:locale=>"es"} category_en GET /categories/:id(.:format) categories#show {:locale=>"en"} PATCH /es/categorias/:id(.:format) categories#update {:locale=>"es"} PATCH /categories/:id(.:format) categories#update {:locale=>"en"} PUT /es/categorias/:id(.:format) categories#update {:locale=>"es"} PUT /categories/:id(.:format) categories#update {:locale=>"en"} DELETE /es/categorias/:id(.:format) categories#destroy {:locale=>"es"} DELETE /categories/:id(.:format) categories#destroy {:locale=>"en"} ``` ## Configuration You can configure RouteTranslator via an initializer or using the different environment config files. ```ruby RouteTranslator.config do |config| config.force_locale = true config.locale_param_key = :my_locale end ``` ### Available Configurations | Option | Description | Default | | ------ | ----------- |-------- | | `available_locales` | Limit the locales for which URLs should be generated for. Accepts an array of strings or symbols. When empty, translations will be generated for all `I18n.available_locales` | `[]` | | `disable_fallback` | Create routes only for locales that have translations. For example, if we have `/examples` and a translation is not provided for `es`, the route helper of `examples_es` will not be created. Useful when one uses this with a locale route constraint, so non-`es` routes can return a `404` on a Spanish website | `false` | | `force_locale` | Force the locale to be added to all generated route paths, even for the default locale | `false` | | `generate_unlocalized_routes` | Add translated routes without deleting original unlocalized versions. **Note:** Autosets `force_locale` to `true` | `false` | | `generate_unnamed_unlocalized_routes` | Adds per-locale named routes plus the original unlocalized mapping as an unnamed action route; it does not redirect. Like `force_locale`, it still shows the locale segment even for the default locale (for example, `/en/...`). Flat helpers (`people_path`) still dispatch to the per-locale variant via `I18n.locale`. Safe for legacy bare paths; avoid in multi-locale SEO setups unless you handle canonical URLs or redirects yourself, since both bare and locale-prefixed paths serve the same action. See [#110](https://github.com/enriclluelles/route_translator/issues/110) | `false` | | `hide_locale` | Force the locale to be hidden on generated route paths | `false` | | `host_locales` | Set `I18n.locale` based on `request.host`. Useful for apps accepting requests from more than one domain. The key is a host pattern (supports wildcards for domains, subdomains, and TLDs), and the value is the locale symbol or string to use. See below for more details and examples. | `{}` | | `locale_param_key` | The param key used to set the locale to the newly generated routes | `:locale` | | `locale_segment_proc` | The locale segment of the url will by default be `locale.to_s.downcase`. You can supply your own mechanism via a Proc that takes `locale` as an argument, e.g. `->(locale) { locale.to_s.upcase }` | `false` | ### Host-based Locale If you have an application serving requests from more than one domain, you might want to set `I18n.locale` dynamically based on which domain the request is coming from. The `host_locales` option is a hash mapping hosts to locales, with full wildcard support to allow matching multiple domains/subdomains/tlds. Host matching is case insensitive. When a request hits your app from a domain matching one of the wild-card matchers defined in `host_locales`, the locale will be set to the specified locale. Unless you specified the `force_locale` configuration option to `true`, that locale will be hidden from routes (acting like a dynamic `hide_locale` option). Here are a few examples of possible mappings: ```ruby RouteTranslator.config.host_locales = { # Matches: '*.es' => :es, # TLD: ['domain.es', 'subdomain.domain.es', 'www.long.string.of.subdomains.es'] etc. 'ru.wikipedia.*' => :ru, # Subdomain: ['ru.wikipedia.org', 'ru.wikipedia.net', 'ru.wikipedia.com'] etc. '*.subdomain.domain.*' => :ru, # Mixture: ['subdomain.domain.org', 'www.subdomain.domain.net'] etc. 'news.bbc.co.uk' => :en, # Exact match: ['news.bbc.co.uk'] only } ``` In the case of a host matching more than once, the order in which the matchers are defined will be taken into account, like so: ```ruby RouteTranslator.config.host_locales = { 'russia.*' => :ru, '*.com' => :en } # 'russia.com' will have locale :ru RouteTranslator.config.host_locales = { '*.com' => :en, 'russia.*' => :ru } # 'russia.com' will have locale :en ``` If `host_locales` option is set, the following options will be forced: ```ruby @config.force_locale = false @config.generate_unlocalized_routes = false @config.generate_unnamed_unlocalized_routes = false @config.hide_locale = true ``` This is to avoid odd behaviour brought about by route conflicts and because `host_locales` forces and hides the host-locale dynamically. NOTE: locale from parameters has priority over the one from hosts. ### Translations for similar routes with different namespaces If you have routes that (partially) share names in one locale, but must be translated differently in another locale, for example: ```ruby get 'people/favourites', to: 'people/products#favourites' get 'favourites', to: 'products#favourites' ``` Then it is possible to provide different translations for common parts of those routes by scoping translations by a controller's namespace: ```yml es: routes: favourites: favoritos controllers: people/products: favourites: fans ``` Routes will be translated as in: ``` people_products_favourites_es GET /people/products/fans(.:format) people/products#favourites {:locale=>"es"} products_favourites_es GET /products/favoritos(.:format) products#favourites {:locale=>"es"} ``` It is also possible to translated resources scoped into a namespace. Example: ```ruby namespace :people do resources :products, only: :index end ``` ```yml es: routes: controllers: people/products: products: productos_favoritos ``` Routes will be translated as in: ``` people_products_es GET /people/productos_favoritos(.:format) people/products#index {:locale=>"es"} ``` The gem will lookup translations under `controllers` scope first and then lookup translations under `routes` scope. ### Change locale parameter position in the path If you need complex routing as `/:country/:locale/path/to/some/pages`, you can specify the position of your locale parameter in the following way: ```rb scope ':country/:locale' do localized do root to: 'content#homepage' end end ``` ## Using RouteTranslator with Devise When using RouteTranslator with Devise, you may notice that some authentication-related flash messages (such as errors after failed logins) are shown in the wrong language. This happens because Devise builds some messages in middleware, after your controller’s actions have completed. If you use RouteTranslator’s `around_action` (or legacy `around_filter`) to set the locale, the locale is reset after the controller yields, and middleware like Devise will see the default locale. This only affects you if Devise is mounted inside a localized block (for example, `localized { devise_for :users }`). If you do not use localized Devise routes, you do not need to change anything. To ensure Devise’s middleware uses the correct locale, some users suggest replacing RouteTranslator’s `around_action` with a `before_action`. However, this is discouraged, as it can leave your application in the wrong locale after the request and may cause subtle bugs. A better approach is to explicitly set the locale for Devise’s failure responses. Starting from Devise version 4.9.4, you can customize how Devise determines the locale for error messages, thanks to the ability to override the `i18n_locale` method in the failure app. This allows you to set the locale for Devise’s middleware without changing RouteTranslator’s recommended usage. Here’s an example: ```rb # config/initializers/devise.rb Devise.setup do |config| # ... ActiveSupport.on_load(:devise_failure_app) do def i18n_locale RouteTranslator.locale_from_request(request) end end end ``` ### Summary - You only need this workaround if Devise is mounted in a localized block. - Do not replace RouteTranslator’s `around_action` with a `before_action`. - Instead, set the locale for Devise failure responses as shown above. - The solution above requires Devise version 4.9.4 or higher, which allows customizing the `i18n_locale` method in the failure app. ## Testing Testing your controllers with routes-translator is easy, just add a locale parameter as `String` for your localized routes. Otherwise, an `ActionController::UrlGenerationError` will raise. ```ruby describe 'GET index' do it 'should respond with success' do # Remember to pass the locale param as String get :index, locale: 'fr' expect(response).to be_success end end ``` ## Contributing Please read through our [contributing guidelines](CONTRIBUTING.md). Included are directions for opening issues, coding standards, and notes on development. More over, if your pull request contains patches or features, you must include relevant unit tests.