From 24a4d79bc0598d434cdd5c8973ae9ef5ccf2c65a Mon Sep 17 00:00:00 2001 From: jasonych99 Date: Sat, 31 Dec 2016 14:36:37 +0100 Subject: [PATCH] Automatically generate i18n javascript files for react-intl when the serve starts up --- CHANGELOG.md | 3 + README.md | 19 +-- docs/basics/i18n.md | 90 +++++++++++++ .../config/initializers/react_on_rails.rb.tt | 6 + lib/react_on_rails.rb | 1 + lib/react_on_rails/configuration.rb | 4 + lib/react_on_rails/locales_to_js.rb | 119 ++++++++++++++++++ .../config/initializers/react_on_rails.rb | 6 + .../fixtures/i18n/locales/de.yml | 2 + .../fixtures/i18n/locales/en.yml | 2 + spec/react_on_rails/locales_to_js_spec.rb | 53 ++++++++ 11 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 docs/basics/i18n.md create mode 100644 lib/react_on_rails/locales_to_js.rb create mode 100644 spec/react_on_rails/fixtures/i18n/locales/de.yml create mode 100644 spec/react_on_rails/fixtures/i18n/locales/en.yml create mode 100644 spec/react_on_rails/locales_to_js_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 557283328..f8015882b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Contributors: please follow the recommendations outlined at [keepachangelog.com] ### Fixed - Removed foreman as a dependency. [#678](https://github.com/shakacode/react_on_rails/pull/678) by [x2es](https://github.com/x2es). +### Changed +- Automatically generate __i18n__ javascript files for `react-intl` when the serve starts up. [#642](https://github.com/shakacode/react_on_rails/pull/642) by [JasonYCHuang](https://github.com/JasonYCHuang). + ## [6.3.5] - 2017-1-6 ### Fixed - The redux generator now creates a HelloWorld component that uses redux rather than local state. [#669](https://github.com/shakacode/react_on_rails/issues/669) by [justin808](https://github.com/justin808). diff --git a/README.md b/README.md index bd5759748..1fee79fdf 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ React on Rails integrates Facebook's [React](https://github.com/facebook/react) - [Installation Summary](#installation-summary) - [Initializer Configuration: config/initializers/react_on_rails.rb](#initializer-configuration) - [Including your React Component in your Rails Views](#including-your-react-component-in-your-rails-views) + - [I18n](#i18n) + [How it Works](#how-it-works) - [Client-Side Rendering vs. Server-Side Rendering](#client-side-rendering-vs-server-side-rendering) - [Building the Bundles](#building-the-bundles) @@ -170,7 +171,15 @@ Configure the `config/initializers/react_on_rails.rb`. You can adjust some neces // inside your React component this.props.name // "Stranger" ``` - + +### I18n + +You can enable the i18n functionality with [react-intl](https://github.com/yahoo/react-intl). + +React on Rails provides an option for automatic conversions of Rails `*.yml` locale files into `*.js` files for `react-intl`. + +See the [How to add I18n](docs/basics/i18n.md) for a summary of adding I18n. + ## NPM All JavaScript in React On Rails is loaded from npm: [react-on-rails](https://www.npmjs.com/package/react-on-rails). To manually install this (you did not use the generator), assuming you have a standard configuration, run this command: @@ -245,11 +254,6 @@ The `railsContext` has: (see implementation in file [react_on_rails_helper.rb](a pathname: uri.path, # /posts search: uri.query, # id=30&limit=5 - # Locale settings - i18nLocale: I18n.locale, - i18nDefaultLocale: I18n.default_locale, - httpAcceptLanguage: request.env["HTTP_ACCEPT_LANGUAGE"], - # Other serverSide: boolean # Are we being called on the server or client? NOTE, if you conditionally # render something different on the server than the client, then React will only show the @@ -261,9 +265,6 @@ The `railsContext` has: (see implementation in file [react_on_rails_helper.rb](a ##### Needing the current url path for server rendering Suppose you want to display a nav bar with the current navigation link highlighted by the URL. When you server render the code, you will need to know the current URL/path if that is what you want your logic to be based on. The new `railsContext` has this information so the application of an "active" class can be done server side. -##### Needing the I18n.locale -Suppose you want to server render your react components with localization applied given the current Rails locale. The `railsContext` contains the I18n.locale. - ##### Configuring different code for server side rendering Suppose you want to turn off animation when doing server side rendering. The `serverSide` value is just what you need. diff --git a/docs/basics/i18n.md b/docs/basics/i18n.md new file mode 100644 index 000000000..f3a96df4e --- /dev/null +++ b/docs/basics/i18n.md @@ -0,0 +1,90 @@ +# How to add I18n + +Here's a summary of adding the I18n functionality. + +You can refer to [react-webpack-rails-tutorial](https://github.com/shakacode/react-webpack-rails-tutorial) for a complete example. + +1. Add `react-intl` & `intl` to `client/package.json`, and remember to `bundle && npm install`. + + ```js + "dependencies": { + ... + "intl": "^1.2.5", + "react-intl": "^2.1.5", + ... + } + ``` + +2. In `client/webpack.client.base.config.js`, set `react-intl` as an entry point. + + ```js + module.exports = { + ... + entry: { + ... + vendor: [ + ... + 'react-intl', + ], + ... + ``` + +3. `react-intl` requires locale files in json format. React on Rails will help you to generate or update `translations.js` & `default.js` automatically after you configured the following settings. + + > `translations.js`: All your locales in json format. + > + > `default.js`: Default settings in json format. + > + > You can add them to `.gitignore` and `.eslintignore`. + + Update settings in `config/initializers/react_on_rails.rb` to what you need: + + ```ruby + # Replace the following line to the location where you keep translation.js & default.js. + config.i18n_dir = Rails.root.join("PATH_TO", "YOUR_JS_I18N_FOLDER") + ``` + + Add following lines to `config/application.rb`, this will help you to generate `translations.js` & `default.js` automatically when you starts the server. + + ```js + module YourModule + class Application < Rails::Application + ... + config.after_initialize do + ReactOnRails::LocalesToJs.new + end + end + end + ``` + +5. In React, you need to initialize `react-intl`, and set parameters for it. + + ```js + ... + import { addLocaleData } from 'react-intl'; + import en from 'react-intl/locale-data/en'; + import de from 'react-intl/locale-data/de'; + import { translations } from 'path_to/i18n/translations'; + import { defaultLocale } from 'path_to/i18n/default'; + ... + // Initizalize all locales for react-intl. + addLocaleData([...en, ...de]); + ... + // set locale and messages for IntlProvider. + const locale = method_to_get_current_locale() || defaultLocale; + const messages = translations[locale]; + ... + return ( + + + + ) + ``` + ```js + // In your component. + import { defaultMessages } from 'path_to/i18n/default'; + ... + return ( + { formatMessage(defaultMessages.yourLocaleKeyInCamelCase) } + ) + ``` diff --git a/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt b/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt index 2e967a740..20cf015c3 100644 --- a/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt +++ b/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt @@ -59,6 +59,12 @@ ReactOnRails.configure do |config| config.server_renderer_pool_size = 1 # increase if you're on JRuby config.server_renderer_timeout = 20 # seconds + ################################################################################ + # I18N OPTIONS + ################################################################################ + # Replace the following line to the location where you keep translation.js & default.js. + config.i18n_dir = Rails.root.join("client", "app", "libs", "i18n") + ################################################################################ # MISCELLANEOUS OPTIONS ################################################################################ diff --git a/lib/react_on_rails.rb b/lib/react_on_rails.rb index e8bc066f3..72c978699 100644 --- a/lib/react_on_rails.rb +++ b/lib/react_on_rails.rb @@ -16,3 +16,4 @@ require "react_on_rails/test_helper/webpack_assets_status_checker" require "react_on_rails/test_helper/ensure_assets_compiled" require "react_on_rails/test_helper/node_process_launcher" +require "react_on_rails/locales_to_js" diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index a967038ee..9e8e74c80 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -72,6 +72,7 @@ def self.configuration server_render_method: "", symlink_non_digested_assets_regex: /\.(png|jpg|jpeg|gif|tiff|woff|ttf|eot|svg|map)/, npm_build_test_command: "", + i18n_dir: "", npm_build_production_command: "" ) end @@ -84,6 +85,7 @@ class Configuration :skip_display_none, :generated_assets_dirs, :generated_assets_dir, :webpack_generated_files, :rendering_extension, :npm_build_test_command, :npm_build_production_command, + :i18n_dir, :server_render_method, :symlink_non_digested_assets_regex def initialize(server_bundle_js_file: nil, prerender: nil, replay_console: nil, @@ -94,12 +96,14 @@ def initialize(server_bundle_js_file: nil, prerender: nil, replay_console: nil, generated_assets_dir: nil, webpack_generated_files: nil, rendering_extension: nil, npm_build_test_command: nil, npm_build_production_command: nil, + i18n_dir: nil, server_render_method: nil, symlink_non_digested_assets_regex: nil) self.server_bundle_js_file = server_bundle_js_file self.generated_assets_dirs = generated_assets_dirs self.generated_assets_dir = generated_assets_dir self.npm_build_test_command = npm_build_test_command self.npm_build_production_command = npm_build_production_command + self.i18n_dir = i18n_dir self.prerender = prerender self.replay_console = replay_console diff --git a/lib/react_on_rails/locales_to_js.rb b/lib/react_on_rails/locales_to_js.rb new file mode 100644 index 000000000..334928961 --- /dev/null +++ b/lib/react_on_rails/locales_to_js.rb @@ -0,0 +1,119 @@ +require "erb" + +module ReactOnRails + class LocalesToJs + def initialize + return unless obsolete? + @translations, @defaults = generate_translations + convert + end + + private + + def obsolete? + return true if exist_js_files.empty? + js_files_are_outdated + end + + def exist_js_files + @exist_js_files ||= js_files.select(&File.method(:exist?)) + end + + def js_files_are_outdated + latest_yml = locale_files.map(&File.method(:mtime)).max + earliest_js = exist_js_files.map(&File.method(:mtime)).min + latest_yml > earliest_js + end + + def js_file_names + %w(translations default) + end + + def js_files + @js_files ||= js_file_names.map { |n| js_file(n) } + end + + def js_file(name) + "#{i18n_dir}/#{name}.js" + end + + def locale_files + @locale_files ||= Rails.application.config.i18n.load_path + end + + def i18n_dir + @i18n_dir ||= ReactOnRails.configuration.i18n_dir + end + + def default_locale + @default_locale ||= I18n.default_locale.to_s || "en" + end + + def convert + js_file_names.each do |name| + template = send("template_#{name}") + path = js_file(name) + generate_js_file(template, path) + end + end + + def generate_js_file(template, path) + result = ERB.new(template).result() + File.open(path, "w") do |f| + f.write(result) + end + end + + def generate_translations + translations = {} + defaults = {} + locale_files.each do |f| + translation = YAML.load(File.open(f)) + key = translation.keys[0] + val = flatten(translation[key]) + translations = translations.deep_merge(key => val) + defaults = defaults.deep_merge(flatten_defaults(val)) if key == default_locale + end + [translations.to_json, defaults.to_json] + end + + def format(input) + input.to_s.tr(".", "_").camelize(:lower).to_sym + end + + def flatten_defaults(val) + flatten(val).each_with_object({}) do |(k, v), h| + key = format(k) + h[key] = { id: k, defaultMessage: v } + end + end + + def flatten(translations) + translations.each_with_object({}) do |(k, v), h| + if v.is_a? Hash + flatten(v).map { |hk, hv| h["#{k}.#{hk}".to_sym] = hv } + else + h[k] = v + end + end + end + + def template_translations + <<-JS +export const translations = #{@translations}; + JS + end + + def template_default + <<-JS +import { defineMessages } from 'react-intl'; + +const defaultLocale = \'#{default_locale}\'; + +const defaultMessages = defineMessages(#{@defaults}); + +export { defaultMessages, defaultLocale }; + JS + end + end +end diff --git a/spec/dummy/config/initializers/react_on_rails.rb b/spec/dummy/config/initializers/react_on_rails.rb index d6fec053c..940b4a86b 100644 --- a/spec/dummy/config/initializers/react_on_rails.rb +++ b/spec/dummy/config/initializers/react_on_rails.rb @@ -71,6 +71,12 @@ def self.custom_context(view_context) config.server_renderer_pool_size = 1 # increase if you're on JRuby config.server_renderer_timeout = 20 # seconds + ################################################################################ + # I18N OPTIONS + ################################################################################ + # Replace the following line to the location where you keep translation.js & default.js. + config.i18n_dir = Rails.root.join("client", "app", "libs", "i18n") + ################################################################################ # MISCELLANEOUS OPTIONS ################################################################################ diff --git a/spec/react_on_rails/fixtures/i18n/locales/de.yml b/spec/react_on_rails/fixtures/i18n/locales/de.yml new file mode 100644 index 000000000..3f02f787e --- /dev/null +++ b/spec/react_on_rails/fixtures/i18n/locales/de.yml @@ -0,0 +1,2 @@ +de: + hello: "Hallo welt" diff --git a/spec/react_on_rails/fixtures/i18n/locales/en.yml b/spec/react_on_rails/fixtures/i18n/locales/en.yml new file mode 100644 index 000000000..a9f72ecc7 --- /dev/null +++ b/spec/react_on_rails/fixtures/i18n/locales/en.yml @@ -0,0 +1,2 @@ +en: + hello: "Hello world" diff --git a/spec/react_on_rails/locales_to_js_spec.rb b/spec/react_on_rails/locales_to_js_spec.rb new file mode 100644 index 000000000..4794e7835 --- /dev/null +++ b/spec/react_on_rails/locales_to_js_spec.rb @@ -0,0 +1,53 @@ +require_relative "spec_helper" +require "tmpdir" + +module ReactOnRails + RSpec.describe LocalesToJs do + let(:i18n_dir) { Pathname.new(Dir.mktmpdir) } + let(:locale_dir) { File.expand_path("../fixtures/i18n/locales", __FILE__) } + let(:translations_path) { "#{i18n_dir}/translations.js" } + let(:default_path) { "#{i18n_dir}/default.js" } + let(:en_path) { "#{locale_dir}/en.yml" } + + before do + allow_any_instance_of(ReactOnRails::LocalesToJs).to receive(:locale_files).and_return(Dir["#{locale_dir}/*"]) + ReactOnRails.configure do |config| + config.i18n_dir = i18n_dir + end + end + + context "with obsolete js files" do + before do + FileUtils.touch(translations_path, mtime: Time.now - 1.year) + FileUtils.touch(en_path, mtime: Time.now) + end + + it "updates files" do + ReactOnRails::LocalesToJs.new + + translations = File.read(translations_path) + default = File.read(default_path) + expect(translations).to include("{\"hello\":\"Hello world\"") + expect(translations).to include("{\"hello\":\"Hallo welt\"") + expect(default).to include("const defaultLocale = 'en';") + expect(default).to include("{\"hello\":{\"id\":\"hello\",\"defaultMessage\":\"Hello world\"}}") + + expect(File.mtime(translations_path)).to be >= File.mtime(en_path) + end + end + + context "with up-to-date js files" do + before do + ReactOnRails::LocalesToJs.new + end + + it "doesn't update files" do + mt = File.mtime(translations_path) + sleep 1 + + ReactOnRails::LocalesToJs.new + expect(File.mtime(translations_path)).to eq(mt) + end + end + end +end