diff --git a/Gemfile b/Gemfile index 49e172ee1..af8bf1163 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,7 @@ gem "honeybadger", "~> 4.9" # Used for tracking application errors gem "honeycomb-beeline", "~> 2.7.0" # Monitoring and Observability gem gem "html_truncator", "~> 0.4" # Truncate an HTML string properly gem "htmlentities", "~> 4.3", ">= 4.3.4" # A module for encoding and decoding (X)HTML entities +gem "i18n-js", "~> 3.9.0" # Helps with internationalization in Rails. gem "httparty", "~> 0.19" # Makes http fun! Also, makes consuming restful web services dead easy gem "imgproxy", "~> 2.0" # A gem that easily generates imgproxy URLs for your images gem "inline_svg", "~> 1.7" # Embed SVG documents in your Rails views and style them with CSS @@ -122,9 +123,11 @@ group :development do gem "brakeman", "~> 5.1", require: false # Brakeman detects security vulnerabilities in Ruby on Rails applications via static analysis gem "bundler-audit", "~> 0.9" # bundler-audit provides patch-level verification for Bundled apps gem "derailed_benchmarks", "~> 2.1", require: false # A series of things you can use to benchmark a Rails or Ruby app + gem "easy_translate", "~> 0.5.1" # Google translate tie-in to be used with i18n tasks gem "erb_lint", "~> 0.0.37", require: false # ERB Linter tool gem "guard", "~> 2.18", require: false # Guard is a command line tool to easily handle events on file system modifications gem "guard-livereload", "~> 2.5", require: false # Guard::LiveReload automatically reloads your browser when 'view' files are modified + gem "i18n-tasks", "~> 0.9.34" # Helpers to find and manage missing and unused translations gem "listen", "~> 3.7", require: false # Helps 'listen' to file system modifications events (also used by other gems like guard) gem "memory_profiler", "~> 1.0", require: false # Memory profiling routines for Ruby 2.3+ gem "web-console", "~> 4.1" # Rails Console on the Browser diff --git a/Gemfile.lock b/Gemfile.lock index e4c125f97..ff7359890 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -218,6 +218,9 @@ GEM dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) + easy_translate (0.5.1) + thread + thread_safe em-websocket (0.5.2) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) @@ -347,6 +350,7 @@ GEM hashie (4.1.0) heapy (0.2.0) thor + highline (2.0.3) hkdf (0.3.0) honeybadger (4.9.0) honeycomb-beeline (2.7.0) @@ -376,6 +380,18 @@ GEM activerecord (>= 5) i18n (1.8.10) concurrent-ruby (~> 1.0) + i18n-js (3.9.0) + i18n (>= 0.6.6) + i18n-tasks (0.9.34) + activesupport (>= 4.0.2) + ast (>= 2.1.0) + erubi + highline (>= 2.0.0) + i18n + parser (>= 2.2.3.0) + rails-i18n + rainbow (>= 2.2.2, < 4.0) + terminal-table (>= 1.5.1) ice_nine (0.11.2) image_processing (1.12.1) mini_magick (>= 4.9.5, < 5) @@ -569,6 +585,9 @@ GEM nokogiri (>= 1.6) rails-html-sanitizer (1.4.2) loofah (~> 2.3) + rails-i18n (6.0.0) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 7) railties (6.1.4.1) actionpack (= 6.1.4.1) activesupport (= 6.1.4.1) @@ -769,8 +788,11 @@ GEM stripe (> 5, < 6) strong_migrations (0.7.8) activerecord (>= 5) + terminal-table (3.0.1) + unicode-display_width (>= 1.1.1, < 3) test-prof (1.0.7) thor (1.1.0) + thread (0.2.2) thread_safe (0.3.6) tilt (2.0.10) timecop (0.9.4) @@ -878,6 +900,7 @@ DEPENDENCIES dogstatsd-ruby (~> 4.8) doorkeeper (~> 5.5) dotenv-rails (~> 2.7.6) + easy_translate (~> 0.5.1) email_validator (~> 2.2) emoji_regex (~> 3.2) erb_lint (~> 0.0.37) @@ -906,6 +929,8 @@ DEPENDENCIES htmlentities (~> 4.3, >= 4.3.4) httparty (~> 0.19) hypershield (~> 0.2.2) + i18n-js (~> 3.9.0) + i18n-tasks (~> 0.9.34) imgproxy (~> 2.0) inline_svg (~> 1.7) jbuilder (~> 2.11) diff --git a/app/assets/javascripts/base.js.erb b/app/assets/javascripts/base.js.erb index a4a56931e..96e06ba51 100644 --- a/app/assets/javascripts/base.js.erb +++ b/app/assets/javascripts/base.js.erb @@ -4,8 +4,12 @@ //= require initializePage //= require utilities/getImageForLink //= require honeybadger-js/dist/honeybadger.js +//= require i18n //= require ahoy.js/dist/ahoy.js +I18n.defaultLocale = 'en'; +I18n.locale = document.body.dataset.locale; +I18n.translations = JSON.parse(document.getElementById('i18n-translations').dataset.translations); var instantClick , InstantClick = instantClick = function(document, location, $userAgent) { // Internal variables diff --git a/app/assets/javascripts/initializers/initializeCommentsPage.js.erb b/app/assets/javascripts/initializers/initializeCommentsPage.js.erb index 1024dec15..5f4295778 100644 --- a/app/assets/javascripts/initializers/initializeCommentsPage.js.erb +++ b/app/assets/javascripts/initializers/initializeCommentsPage.js.erb @@ -54,9 +54,9 @@ function initializeCommentsPage() { if (publicReactionCounts[i].count > 0) { if (publicReactionCounts[i].count > 1) { - reactionsLabelWrapper.innerHTML = " likes"; + reactionsLabelWrapper.innerHTML = ` ${I18n.t('core.like').toLowerCase()}s`; } else { - reactionsLabelWrapper.innerHTML = " like"; + reactionsLabelWrapper.innerHTML = ` ${I18n.t('core.like').toLowerCase()}`; } reactionsCountWrapper.id = 'reactions-count-' + publicReactionCounts[i].id; diff --git a/app/assets/javascripts/utilities/buildCommentHTML.js.erb b/app/assets/javascripts/utilities/buildCommentHTML.js.erb index 0619469a3..3e9aba1c5 100644 --- a/app/assets/javascripts/utilities/buildCommentHTML.js.erb +++ b/app/assets/javascripts/utilities/buildCommentHTML.js.erb @@ -68,9 +68,9 @@ function buildCommentHTML(comment) { @@ -471,7 +471,7 @@ Object { diff --git a/app/javascript/articles/components/CommentsCount.jsx b/app/javascript/articles/components/CommentsCount.jsx index 967dc2003..641b1f539 100644 --- a/app/javascript/articles/components/CommentsCount.jsx +++ b/app/javascript/articles/components/CommentsCount.jsx @@ -1,6 +1,8 @@ import { h } from 'preact'; import PropTypes from 'prop-types'; import { Button } from '../../crayons/Button'; +import { locale } from '../../utilities/locale'; + export const CommentsCount = ({ count, articlePath }) => { const commentsSVG = () => ( @@ -28,7 +30,7 @@ export const CommentsCount = ({ count, articlePath }) => { {count}   - {`${count > 1 ? 'comments' : 'comment'}`} + {`${count > 1 ? `${locale('core.comment')}s` : locale('core.comment') }`} @@ -46,7 +48,7 @@ export const CommentsCount = ({ count, articlePath }) => { data-testid="add-a-comment" > 0 - Add comment + {locale('core.add_comment')} ); } diff --git a/app/javascript/articles/components/ReactionsCount.jsx b/app/javascript/articles/components/ReactionsCount.jsx index fb0c3dbe0..6da4c139e 100644 --- a/app/javascript/articles/components/ReactionsCount.jsx +++ b/app/javascript/articles/components/ReactionsCount.jsx @@ -1,6 +1,7 @@ import { h } from 'preact'; import { articlePropTypes } from '../../common-prop-types'; import { Button } from '../../crayons/Button'; +import { locale } from '../../utilities/locale'; export const ReactionsCount = ({ article }) => { const totalReactions = article.public_reactions_count || 0; @@ -32,7 +33,7 @@ export const ReactionsCount = ({ article }) => { {totalReactions}   - {`${totalReactions == 1 ? 'reaction' : 'reactions'}`} + {`${totalReactions == 1 ? locale('core.reaction') : `${locale('core.reaction')}s`}`} diff --git a/app/javascript/listings/singleListing/DropdownMenu.jsx b/app/javascript/listings/singleListing/DropdownMenu.jsx index 7fe9ca0e4..60a9b60f2 100644 --- a/app/javascript/listings/singleListing/DropdownMenu.jsx +++ b/app/javascript/listings/singleListing/DropdownMenu.jsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import { h, Component, createRef } from 'preact'; import { listingPropTypes } from './listingPropTypes'; import { Button, Dropdown } from '@crayons'; +import { locale } from '@utilities/locale'; const Icon = () => ( ) : ( - Report Abuse + {locale('core.report_abuse')} )} diff --git a/app/javascript/packs/commentDropdowns.js b/app/javascript/packs/commentDropdowns.js index e05338a8c..a68d2f384 100644 --- a/app/javascript/packs/commentDropdowns.js +++ b/app/javascript/packs/commentDropdowns.js @@ -3,6 +3,7 @@ import { initializeDropdown, getDropdownRepositionListener, } from '@utilities/dropdownUtils'; +import { locale } from '@utilities/locale'; /* global Runtime */ @@ -57,7 +58,7 @@ const initializeArticlePageDropdowns = () => { '.report-abuse-link-wrapper', ); if (reportAbuseWrapper) { - reportAbuseWrapper.innerHTML = `Report abuse`; + reportAbuseWrapper.innerHTML = `${locale('core.report_abuse')}`; } // Initialize the "Copy link" functionality diff --git a/app/javascript/packs/followButtons.js b/app/javascript/packs/followButtons.js index cb5670b72..d0cd74f24 100644 --- a/app/javascript/packs/followButtons.js +++ b/app/javascript/packs/followButtons.js @@ -1,4 +1,5 @@ import { getInstantClick } from '../topNavigation/utilities'; +import { locale } from '@utilities/locale'; /* global showLoginModal userData showModalAfterError*/ @@ -8,6 +9,8 @@ import { getInstantClick } from '../topNavigation/utilities'; * @param {HTMLElement} button The Follow button to update * @param {string} style The style of the button from its "info" data attribute */ + + function addButtonFollowText(button, style) { const { name, className } = JSON.parse(button.dataset.info); @@ -28,7 +31,7 @@ function addButtonFollowText(button, style) { followType: className, style: 'follow-back', }); - button.textContent = 'Follow back'; + button.textContent = locale('core.follow_back'); break; default: addAriaLabelToButton({ @@ -37,7 +40,7 @@ function addButtonFollowText(button, style) { followType: className, style: 'follow', }); - button.textContent = 'Follow'; + button.textContent = locale('core.follow'); } } @@ -85,7 +88,7 @@ function addAriaLabelToButton({ button, followType, followName, style = '' }) { * @param {string} style The style of the button from its "info" data attribute */ function addButtonFollowingText(button, style) { - button.textContent = style === 'small' ? '✓' : 'Following'; + button.textContent = style === 'small' ? '✓' : locale('core.following'); } /** @@ -159,7 +162,7 @@ function updateFollowingButton(button, style) { */ function updateUserOwnFollowButton(button) { button.dataset.verb = 'self'; - button.textContent = 'Edit profile'; + button.textContent = locale('core.edit_profile'); addAriaLabelToButton({ button, followName: '', diff --git a/app/javascript/utilities/locale.js b/app/javascript/utilities/locale.js new file mode 100644 index 000000000..10efee1b6 --- /dev/null +++ b/app/javascript/utilities/locale.js @@ -0,0 +1,10 @@ +import I18n from "i18n-js" +const translationsDiv = document.getElementById('i18n-translations') +if (translationsDiv) { + I18n.translations = JSON.parse(translationsDiv.dataset.translations); +} +I18n.defaultLocale = 'en'; +I18n.locale = document.body.dataset.locale; +export function locale(term) { + return I18n.t(term); +} \ No newline at end of file diff --git a/app/lib/constants/settings/user_experience.rb b/app/lib/constants/settings/user_experience.rb index f8ddc12f9..f89b8a8b7 100644 --- a/app/lib/constants/settings/user_experience.rb +++ b/app/lib/constants/settings/user_experience.rb @@ -5,6 +5,9 @@ module Constants default_font: { description: "Determines the default reading font (registered users can change this in their UX settings)" }, + default_locale: { + description: "Determines the default language and lozalization for the Forem (currently in experimental beta)" + }, feed_strategy: { description: "Determines the main feed algorithm approach the app takes: basic or large_forem_experimental " \ "(which should only be used for 10k+ member communities)", diff --git a/app/models/settings/user_experience.rb b/app/models/settings/user_experience.rb index 736c8ce23..b801821db 100644 --- a/app/models/settings/user_experience.rb +++ b/app/models/settings/user_experience.rb @@ -22,6 +22,7 @@ module Settings # a public forem could have more fine-grained authentication (listings ar private etc.) in future setting :public, type: :boolean, default: 0 setting :tag_feed_minimum_score, type: :integer, default: 0 + setting :default_locale, type: :string, default: "en" setting :display_in_directory, type: :boolean, default: true end end diff --git a/app/views/admin/settings/forms/_user_experience.html.erb b/app/views/admin/settings/forms/_user_experience.html.erb index de7c1050e..3b761cc05 100644 --- a/app/views/admin/settings/forms/_user_experience.html.erb +++ b/app/views/admin/settings/forms/_user_experience.html.erb @@ -54,6 +54,17 @@ multiple: false, class: "selectpicker" %> +
+ <%= admin_config_label :default_locale, model: Settings::UserExperience %> + <%= admin_config_description Constants::Settings::UserExperience::DETAILS[:default_locale][:description] %> + <%= select_tag "settings_user_experience[default_locale]", + options_for_select( + [%w[English en], %w[Français fr]], + Settings::UserExperience.default_locale, + ), + multiple: false, + class: "selectpicker" %> +
<%= admin_config_label :primary_brand_color_hex, model: Settings::UserExperience %> <%= admin_config_description Constants::Settings::UserExperience::DETAILS[:primary_brand_color_hex][:description] %> diff --git a/app/views/articles/_actions.html.erb b/app/views/articles/_actions.html.erb index 3c42c1dc9..bcafe9584 100644 --- a/app/views/articles/_actions.html.erb +++ b/app/views/articles/_actions.html.erb @@ -39,7 +39,7 @@ id="copy-post-url-button" class="flex justify-between crayons-link crayons-link--block w-100 bg-transparent border-0" data-postUrl="<%= article_url(@article) %>"> - Copy Post URL + <%= t("core.copy_link") %> <%= inline_svg_tag("copy.svg", aria_hidden: true, id: "article-copy-icon", class: "crayons-icon mx-2 shrink-0", title: "Copy article link to the clipboard") %> @@ -89,7 +89,7 @@ Share Post via... - Report Abuse + <%= t("core.report_abuse") %>
diff --git a/app/views/articles/_bottom_content.html.erb b/app/views/articles/_bottom_content.html.erb index 8d8db2f03..62ed2f61e 100644 --- a/app/views/articles/_bottom_content.html.erb +++ b/app/views/articles/_bottom_content.html.erb @@ -1,6 +1,6 @@ <% if articles && articles.any? %> diff --git a/app/views/layouts/_sidebar_tags.html.erb b/app/views/layouts/_sidebar_tags.html.erb index 21445bac7..bb5f467f9 100644 --- a/app/views/layouts/_sidebar_tags.html.erb +++ b/app/views/layouts/_sidebar_tags.html.erb @@ -1,14 +1,14 @@