From 10a786f84d9ba962fcbe762fc9ea076bafc07435 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 22 May 2018 14:21:32 -0600 Subject: [PATCH] simplified language negotiation --- client/coral-framework/services/i18n.js | 131 +++++++++++------------- locales/fi_FI.yml | 4 +- package.json | 2 +- services/i18n.js | 76 ++++++++------ yarn.lock | 6 +- 5 files changed, 110 insertions(+), 109 deletions(-) diff --git a/client/coral-framework/services/i18n.js b/client/coral-framework/services/i18n.js index 03a0b4712..02258a585 100644 --- a/client/coral-framework/services/i18n.js +++ b/client/coral-framework/services/i18n.js @@ -1,7 +1,10 @@ -import ta from 'timeago.js'; +import { negotiateLanguages } from 'fluent-langneg/compat'; + import has from 'lodash/has'; import get from 'lodash/get'; import merge from 'lodash/merge'; +import first from 'lodash/first'; +import isUndefined from 'lodash/isUndefined'; import moment from 'moment'; import 'moment/locale/ar'; @@ -12,8 +15,8 @@ import 'moment/locale/fr'; import 'moment/locale/nl'; import 'moment/locale/pt-br'; -import { createStorage } from 'coral-framework/services/storage'; - +// timeago +import ta from 'timeago.js'; import arTA from 'timeago.js/locales/ar'; import daTA from 'timeago.js/locales/da'; import deTA from 'timeago.js/locales/de'; @@ -24,6 +27,7 @@ import pt_BRTA from 'timeago.js/locales/pt_BR'; import zh_CNTA from 'timeago.js/locales/zh_CN'; import zh_TWTA from 'timeago.js/locales/zh_TW'; +// locales import ar from '../../../locales/ar.yml'; import en from '../../../locales/en.yml'; import da from '../../../locales/da.yml'; @@ -35,8 +39,9 @@ import pt_BR from '../../../locales/pt_BR.yml'; import zh_CN from '../../../locales/zh_CN.yml'; import zh_TW from '../../../locales/zh_TW.yml'; -const defaultLanguage = process.env.TALK_DEFAULT_LANG; -const translations = { +export const defaultLocale = process.env.TALK_DEFAULT_LANG.replace(/-/g, '_'); + +export const translations = { ...ar, ...en, ...da, @@ -49,84 +54,62 @@ const translations = { ...zh_TW, }; -let lang; -let timeagoInstance; +export const supportedLocales = Object.keys(translations); -function setLocale(storage, locale) { - storage.setItem('locale', locale); -} +let LOCALE; +let TIMEAGO_INSTANCE; // detectLanguage will try to get the locale from storage if available, // otherwise will try to get it from the navigator, otherwise, it will fallback // to the default language. -function detectLanguage(storage) { - try { - const lang = storage.getItem('locale') || navigator.language; - if (lang) { - return lang; - } - } catch (err) { - console.warn( - 'Error while trying to detect language, will fallback to', - err - ); - } - - console.warn('Could not detect language, will fallback to', defaultLanguage); - return defaultLanguage; -} - -// getLocale will get the users locale from the local detector and parse it to a -// format we can work with. -function getLocale(storage) { - // Get the language from the local detector. - const lang = detectLanguage(storage); - - // Some language strings come with additional subtags as defined in: - // - // https://www.ietf.org/rfc/bcp/bcp47.txt - // - // So we should strip that off if we find it. - return lang.split('-')[0]; -} +const detectLanguage = () => + first( + negotiateLanguages(navigator.languages, supportedLocales, { + defaultLocale, + strategy: 'lookup', + }) + ); export function setupTranslations() { - // Setup the translation framework with the storage. - const storage = createStorage('localStorage'); + // locale + LOCALE = detectLanguage(); - const locale = getLocale(storage); - setLocale(storage, locale); - - // Setting moment - moment.locale(locale); - - // Extract language key. - lang = locale.split('-')[0]; - - // Check if we have a translation in this language. - if (!(lang in translations)) { - lang = defaultLanguage; - } + // moment + moment.locale(LOCALE); + // timeago ta.register('ar', arTA); ta.register('es', esTA); ta.register('da', daTA); ta.register('de', deTA); ta.register('fr', frTA); - ta.register('nl_NL', nlTA); - ta.register('pt_BR', pt_BRTA); - ta.register('zh_CN', zh_CNTA); - ta.register('zh_TW', zh_TWTA); - - timeagoInstance = ta(); + ta.register('nl-NL', nlTA); + ta.register('pt-BR', pt_BRTA); + ta.register('zh-CN', zh_CNTA); + ta.register('zh-TW', zh_TWTA); + TIMEAGO_INSTANCE = ta(); } +/** + * loadTranslations will load the new language pack into the existing ones. + * + * @param {Object} newTranslations translation object to merge into the existing + * languages. + */ export function loadTranslations(newTranslations) { + // Merge the new translations into the existing translations. merge(translations, newTranslations); + + // Push new languages into the supportedLocales array. + Object.keys(newTranslations).forEach(language => { + if (!supportedLocales.includes(language)) { + supportedLocales.push(language); + } + }); } export function timeago(time) { - return timeagoInstance.format(new Date(time), lang); + return TIMEAGO_INSTANCE.format(new Date(time), LOCALE); } /** @@ -140,24 +123,24 @@ export function timeago(time) { */ export function t(key, ...replacements) { let translation; - if (has(translations[lang], key)) { - translation = get(translations[lang], key); + if (has(translations[LOCALE], key)) { + translation = get(translations[LOCALE], key); } else if (has(translations['en'], key)) { translation = get(translations['en'], key); - console.warn(`${lang}.${key} language key not set`); + console.warn(`${LOCALE}.${key} language key not set`); } - if (translation) { - // replace any {n} with the arguments passed to this method - replacements.forEach((str, i) => { - translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str); - }); - - return translation; - } else { - console.warn(`${lang}.${key} and en.${key} language key not set`); + if (!translation) { + console.warn(`${LOCALE}.${key} and en.${key} language key not set`); return key; } + + // Handle replacements in the translation string. + return translation.replace( + /{(\d+)}/g, + (match, number) => + !isUndefined(replacements[number]) ? replacements[number] : match + ); } export default t; diff --git a/locales/fi_FI.yml b/locales/fi_FI.yml index 71416b4ff..494a48cdb 100644 --- a/locales/fi_FI.yml +++ b/locales/fi_FI.yml @@ -413,7 +413,7 @@ fi_FI: title_reject: "Huomasimme sinun hylänneen käyttäjänimen" suspend_user: "Aseta väliaikainen käyttökielto" yes_suspend: "Kyllä, sulje väliaikaisesti" - email_message_reject: "Toinen yhteisön jäsen on ilmiantanut käyttäjänimesi ja sen perusteella nimi on hylätty. Et voi enää osallistua keskusteluun. Ole ystävällisesti yhteydessä meihin, jos sinulla on asiasta kysyttävää." + email_message_reject: "Toinen yhteisön jäsen on ilmiantanut käyttäjänimesi ja sen perusteella nimi on hylätty. Et voi enää osallistua keskusteluun. Ole ystävällisesti yhteydessä meihin, jos sinulla on asiasta kysyttävää." write_message: "Kirjoita viesti" send: Lähetä thank_you: "Arvostamme palautettasi. Moderaattorimme käy läpi tekemäsi ilmiannon." @@ -462,4 +462,4 @@ fi_FI: close: "Sulje asennusnäkymä" admin_sidebar: view_options: "Näytä asetukset" -sort_comments: "Järjestä kommentit" \ No newline at end of file + sort_comments: "Järjestä kommentit" diff --git a/package.json b/package.json index c387d6145..5dcb5c6ff 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "dependencies": { "@coralproject/gql-merge": "^0.1.0", "@coralproject/graphql-anywhere-optimized": "^0.1.0", - "accepts": "^1.3.4", "apollo-client": "^1.9.1", "apollo-engine": "^0.8.1", "apollo-server-express": "^1.2.0", @@ -108,6 +107,7 @@ "express-static-gzip": "^0.3.1", "extract-text-webpack-plugin": "^3.0.2", "file-loader": "^0.11.2", + "fluent-langneg": "^0.1.0", "form-data": "^2.3.1", "fs-extra": "^4.0.1", "graphql": "^0.10.1", diff --git a/services/i18n.js b/services/i18n.js index 666f15a59..52c34a4ed 100644 --- a/services/i18n.js +++ b/services/i18n.js @@ -1,8 +1,11 @@ const fs = require('fs'); const path = require('path'); const debug = require('debug')('talk:services:i18n'); -const accepts = require('accepts'); -const { get, has, merge } = require('lodash'); +const { + acceptedLanguages, + negotiateLanguages, +} = require('fluent-langneg/compat'); +const { first, get, has, merge, isUndefined } = require('lodash'); const yaml = require('yamljs'); const plugins = require('./plugins'); const { DEFAULT_LANG } = require('../config'); @@ -11,7 +14,7 @@ const resolve = (...paths) => path.resolve(path.join(__dirname, '..', 'locales', ...paths)); // Load all the translations. -let translations = fs +const translations = fs .readdirSync(resolve()) // Resolve all the filenames relative the the locales directory. @@ -23,26 +26,22 @@ let translations = fs // Load the translation files from disk. .map(filename => fs.readFileSync(filename, 'utf8')) - // Load the translation files. - .reduce((packs, contents) => { - const pack = yaml.parse(contents); - - return merge(packs, pack); - }, {}); + // Load the translation files and merge the yaml into the existing packs. + .reduce((packs, contents) => merge(packs, yaml.parse(contents)), {}); // Create a list of all supported translations. -const languages = Object.keys(translations); +const supportedLocales = Object.keys(translations); // Move the default language to the front. -if (languages.includes(DEFAULT_LANG)) { - const from = languages.indexOf(DEFAULT_LANG); - languages.splice(from, 1); - languages.splice(0, 0, DEFAULT_LANG); +if (supportedLocales.includes(DEFAULT_LANG)) { + const from = supportedLocales.indexOf(DEFAULT_LANG); + supportedLocales.splice(from, 1); + supportedLocales.splice(0, 0, DEFAULT_LANG); } -debug(`loaded language sets for ${languages}`); +debug(`loaded language sets for ${supportedLocales}`); let loadedPluginTranslations = false; -const loadPluginTranslations = () => { +const lazyLoadPluginTranslations = () => { if (loadedPluginTranslations) { return; } @@ -55,7 +54,15 @@ const loadPluginTranslations = () => { const pack = yaml.parse(fs.readFileSync(filename, 'utf8')); - translations = merge(translations, pack); + // Merge the translations into the system translations. + merge(translations, pack); + + // Push new languages into the supportedLocales array. + Object.keys(pack).forEach(language => { + if (!supportedLocales.includes(language)) { + supportedLocales.push(language); + } + }); }); loadedPluginTranslations = true; @@ -64,7 +71,7 @@ const loadPluginTranslations = () => { const t = lang => (key, ...replacements) => { // Loads the translations into the translations array from plugins. This is // done lazily to ensure that we don't have an import cycle. - loadPluginTranslations(); + lazyLoadPluginTranslations(); let translation; if (has(translations[lang], key)) { @@ -74,16 +81,17 @@ const t = lang => (key, ...replacements) => { console.warn(`${lang}.${key} language key not set`); } - if (translation) { - // replace any {n} with the arguments passed to this method - replacements.forEach((str, i) => { - translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str); - }); - return translation; - } else { + if (!translation) { console.warn(`${lang}.${key} and en.${key} language key not set`); return key; } + + // Handle replacements in the translation string. + return translation.replace( + /{(\d+)}/g, + (match, number) => + !isUndefined(replacements[number]) ? replacements[number] : match + ); }; /** @@ -92,13 +100,19 @@ const t = lang => (key, ...replacements) => { */ const i18n = { request(req) { - debug(`possible languages given request '${accepts(req).languages()}'`); - const lang = accepts(req).language(languages); - debug(`parsed request language as '${lang}'`); - const language = lang ? lang : DEFAULT_LANG; - debug(`decided language as '${language}'`); + const acceptsLanguages = acceptedLanguages(req.headers['accept-language']); + debug(`possible languages given request '${acceptsLanguages}'`); - return t(language); + // negotiate the language. + const lang = first( + negotiateLanguages(acceptsLanguages, supportedLocales, { + defaultLocale: DEFAULT_LANG, + strategy: 'lookup', + }) + ); + debug(`decided language as '${lang}'`); + + return t(lang); }, t: t(DEFAULT_LANG), }; diff --git a/yarn.lock b/yarn.lock index 13da4b2f7..dff8e4dab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -167,7 +167,7 @@ abbrev@1, abbrev@^1.0.7: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" -accepts@^1.3.4, accepts@~1.3.4: +accepts@~1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.4.tgz#86246758c7dd6d21a6474ff084a4740ec05eb21f" dependencies: @@ -3975,6 +3975,10 @@ flexbuffer@0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/flexbuffer/-/flexbuffer-0.0.6.tgz#039fdf23f8823e440c38f3277e6fef1174215b30" +fluent-langneg@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/fluent-langneg/-/fluent-langneg-0.1.0.tgz#aa12054fbfa4b728daec38331efc12f01faae93a" + flush-write-stream@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.2.tgz#c81b90d8746766f1a609a46809946c45dd8ae417"