diff --git a/config.js b/config.js index 497573ab2..9637611fc 100644 --- a/config.js +++ b/config.js @@ -20,6 +20,13 @@ const CONFIG = { // WEBPACK indicates when webpack is currently building. WEBPACK: process.env.WEBPACK === 'TRUE', + // EMAIL_SUBJECT_PREFIX is the string before emails in the subject. + EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX || '[Talk]', + + // DEFAULT_LANG is the default language used for server sent emails and + // rendered text. + DEFAULT_LANG: process.env.TALK_DEFAULT_LANG || 'en', + // When TRUE, it ensures that database indexes created in core will not add // indexes. CREATE_MONGO_INDEXES: process.env.DISABLE_CREATE_MONGO_INDEXES !== 'TRUE', diff --git a/docs/_docs/02-02-advanced-configuration.md b/docs/_docs/02-02-advanced-configuration.md index 94a6b402d..47be44fcc 100644 --- a/docs/_docs/02-02-advanced-configuration.md +++ b/docs/_docs/02-02-advanced-configuration.md @@ -460,4 +460,10 @@ Could be read as: ## TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF When `TRUE`, staff members will have their accounts and comments moderated the -same as any other user in the system. (Default `FALSE`) \ No newline at end of file +same as any other user in the system. (Default `FALSE`) + +## TALK_EMAIL_SUBJECT_PREFIX + +The prefix for the subject of emails sent. An email with the specified subject +of `Email Confirmation` would then be sent as `[Talk] Email Confirmation`. +(Default `[Talk]`) \ No newline at end of file diff --git a/locales/en.yml b/locales/en.yml index a2197652e..9257ecdcf 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -166,6 +166,11 @@ en: minute: "minute" minutes_plural: "minutes" email: + suspended: + subject: "Your account has been suspended" + banned: + subject: "Your account has been banned" + body: "In accordance with The Coral Project’s community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community." confirm: has_been_requested: "A email confirmation has been requested for the following account:" to_confirm: "To confirm the account, please visit the following link:" diff --git a/middleware/i18n.js b/middleware/i18n.js new file mode 100644 index 000000000..c2ca2436a --- /dev/null +++ b/middleware/i18n.js @@ -0,0 +1,6 @@ +const i18n = require('../services/i18n'); + +module.exports = (req, res, next) => { + res.locals.t = i18n.request(req); + next(); +}; diff --git a/routes/index.js b/routes/index.js index 8fc9e8f3f..454e32f58 100644 --- a/routes/index.js +++ b/routes/index.js @@ -7,7 +7,7 @@ const debug = require('debug')('talk:routes'); const enabled = require('debug').enabled; const errors = require('../errors'); const express = require('express'); -const i18n = require('../services/i18n'); +const i18n = require('../middleware/i18n'); const path = require('path'); const plugins = require('../services/plugins'); const pubsub = require('../middleware/pubsub'); @@ -64,6 +64,9 @@ if (!DISABLE_STATIC_SERVER) { router.get('/embed.js.map', serveFile('../dist/embed.js.map')); } +// Add the i18n middleware to all routes. +router.use(i18n); + //============================================================================== // STATIC ROUTES //============================================================================== @@ -116,19 +119,19 @@ if (process.env.NODE_ENV !== 'production') { }); }); - // GraphQL documention. + // GraphQL documentation. router.get('/admin/docs', (req, res) => { res.render('admin/docs'); }); } +router.use('/api/v1', require('./api')); + //============================================================================== // ROUTES //============================================================================== -router.use('/api/v1', require('./api')); - // Development routes. if (process.env.NODE_ENV !== 'production') { router.use('/assets', staticTemplate, require('./assets')); @@ -184,8 +187,6 @@ router.use('/', (err, req, res, next) => { console.error(err); } - i18n.init(req); - if (err instanceof errors.APIError) { res.status(err.status); res.render('error', { diff --git a/services/email/email-confirm.html.ejs b/services/email/email-confirm.html.ejs index 8a1bd05f7..76a8ea47b 100644 --- a/services/email/email-confirm.html.ejs +++ b/services/email/email-confirm.html.ejs @@ -1,3 +1,3 @@

<%= t('email.confirm.has_been_requested') %> <%= email %>.

-

<%= t('email.confirm.to_confirm') %> Confirm Email

+

<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>

<%= t('email.confirm.if_you_did_not') %>

diff --git a/services/i18n.js b/services/i18n.js index 4c65ed1d4..cd1bacd93 100644 --- a/services/i18n.js +++ b/services/i18n.js @@ -1,57 +1,91 @@ -const has = require('lodash/has'); -const get = require('lodash/get'); - -const yaml = require('yamljs'); - -const da = yaml.load('./locales/da.yml'); -const es = yaml.load('./locales/es.yml'); -const en = yaml.load('./locales/en.yml'); -const fr = yaml.load('./locales/fr.yml'); -const pt_BR = yaml.load('./locales/pt_BR.yml'); - +const fs = require('fs'); +const path = require('path'); +const debug = require('debug')('talk:services:i18n'); const accepts = require('accepts'); +const _ = require('lodash'); +const yaml = require('yamljs'); +const plugins = require('./plugins'); +const {DEFAULT_LANG} = require('../config'); -// default language -let defaultLanguage = 'en'; -let language = defaultLanguage; -const languages = ['en', 'da', 'es', 'fr', 'pt_BR']; +const resolve = (...paths) => path.resolve(path.join(__dirname, '..', 'locales', ...paths)); -const translations = Object.assign(en, es, fr, pt_BR, da); +// Load all the translations. +let translations = fs.readdirSync(resolve()) + + // Resolve all the filenames relative the the locales directory. + .map((filename) => resolve(filename)) + + // Translations are only yml/yaml files. + .filter((filename) => /\.(yaml|yml)$/.test(filename)) + + // 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); + }, {}); + +// Create a list of all supported translations. +const languages = Object.keys(translations); + +let loadedPluginTranslations = false; +const loadPluginTranslations = () => { + if (loadedPluginTranslations) { + return; + } + + // Load the plugin translations. + plugins.get('server', 'translations').forEach(({plugin, translations: filename}) => { + debug(`added plugin '${plugin.name}'`); + + const pack = yaml.parse(fs.readFileSync(filename, 'utf8')); + + translations = _.merge(translations, pack); + }); + + loadedPluginTranslations = true; +}; + +const t = (language) => (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(); + + // Check if the translation exists on the object. + if (_.has(translations[language], key)) { + + // Get the translation value. + let translation = _.get(translations[language], key); + + // Replace any {n} with the arguments passed to this method. + replacements.forEach((str, n) => { + translation = translation.replace(new RegExp(`\\{${n}\\}`, 'g'), str); + }); + + return translation; + } else { + console.warn(`${key} language key not set`); + return key; + } +}; /** * Exposes a service object to allow translations. * @type {Object} */ const i18n = { - - /** - * Create the new Task kue. - */ - init(req) { + request(req) { const lang = accepts(req).language(languages); - language = lang ? lang : defaultLanguage; - }, + const language = lang ? lang : DEFAULT_LANG; - /** - * Translates a key. - */ - t(key, ...replacements) { - - if (has(translations[language], key)) { - - let translation = get(translations[language], key); - - // 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(`${key} language key not set`); - return key; - } + return t(language); }, + t: t(DEFAULT_LANG), }; module.exports = i18n; diff --git a/services/mailer.js b/services/mailer.js index a4f694b30..4433b9ef5 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -13,7 +13,8 @@ const { SMTP_USERNAME, SMTP_PORT, SMTP_PASSWORD, - SMTP_FROM_ADDRESS + SMTP_FROM_ADDRESS, + EMAIL_SUBJECT_PREFIX, } = require('../config'); // load all the templates as strings @@ -95,12 +96,12 @@ const mailer = module.exports = { } // Prefix the subject with `[Talk]`. - subject = `[Talk] ${subject}`; + subject = `${EMAIL_SUBJECT_PREFIX} ${subject}`; attachLocals(locals); - // Attach the templating function. - locals['t'] = i18n.t; + // Attach the translation function. + locals.t = i18n.t; return Promise.all([ @@ -112,7 +113,7 @@ const mailer = module.exports = { ]) .then(([html, text]) => { - // Create the job. + // Create the job. return mailer.task.create({ title: 'Mail', message: { diff --git a/services/users.js b/services/users.js index fbaf89609..ef3cf7a34 100644 --- a/services/users.js +++ b/services/users.js @@ -24,6 +24,7 @@ const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha const ActionsService = require('./actions'); const MailerService = require('./mailer'); const Wordlist = require('./wordlist'); +const i18n = require('./i18n'); const Domainlist = require('./domainlist'); const {escapeRegExp} = require('./regex'); @@ -430,16 +431,14 @@ module.exports = class UsersService { if (status === 'BANNED') { let localProfile = user.profiles.find((profile) => profile.provider === 'local'); if (localProfile) { - const options = - { - template: 'banned', // needed to know which template to render! - locals: { // specifies the template locals. - body: 'In accordance with The Coral Project’s community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community.' - }, - subject: 'Your account has been banned', - to: localProfile.id // This only works if the user has registered via e-mail. - // We may want a standard way to access a user's e-mail address in the future - }; + const options = { + template: 'banned', + locals: { + body: i18n.t('email.banned.body'), + }, + subject: i18n.t('email.banned.subject'), + to: localProfile.id + }; await MailerService.sendSimple(options); } } @@ -467,16 +466,14 @@ module.exports = class UsersService { if (message) { let localProfile = user.profiles.find((profile) => profile.provider === 'local'); if (localProfile) { - const options = - { - template: 'suspension', // needed to know which template to render! - locals: { // specifies the template locals. - body: message - }, - subject: 'Your account has been suspended', - to: localProfile.id // This only works if the user has registered via e-mail. - // We may want a standard way to access a user's e-mail address in the future - }; + const options = { + template: 'suspension', + locals: { + body: message + }, + subject: i18n.t('email.suspended.subject'), + to: localProfile.id, + }; await MailerService.sendSimple(options); } @@ -509,7 +506,7 @@ module.exports = class UsersService { locals: { // specifies the template locals. body: message }, - subject: 'Email Suspension', + subject: i18n.t('email.suspended.subject'), to: localProfile.id // This only works if the user has registered via e-mail. // We may want a standard way to access a user's e-mail address in the future };