diff --git a/index.js b/index.js index bb26f961..2ebf62cd 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const HandlebarsV3 = require('handlebars'); const HandlebarsV4 = require('@bigcommerce/handlebars-v4'); const helpers = require('./helpers'); +const Translator = require('./lib/translator'); const AppError = require('./lib/appError'); class CompileError extends AppError {}; // Error compiling template @@ -207,27 +208,33 @@ class HandlebarsRenderer { }; _tryRestoringPrecompiled(precompiled) { - // Let's analyze the string to make sure it at least looks - // something like a handlebars precompiled template. It should - // be a string representation of an object containing a `main` - // function and a `compiler` array. We do this because the next - // step is a potentially dangerous eval. - const re = /"compiler":\[.*\],"main":function/; - if (!re.test(precompiled)) { + let template; + + if (typeof precompiled == 'string') { + // Let's analyze the string to make sure it at least looks + // something like a handlebars precompiled template. It should + // be a string representation of an object containing a `main` + // function and a `compiler` array. We do this because the next + // step is a potentially dangerous eval. + const re = /"compiler":\[.*\],"main":function/; + if (!re.test(precompiled)) { // This is not a valid precompiled template, so this is // a raw template that can be registered directly. return precompiled; + } + + // We need to take the string representation and turn it into a + // valid JavaScript object. eval is evil, but necessary in this case. + + eval(`template = ${precompiled}`); + } else if (typeof precompiled == 'object' && typeof precompiled.main == 'function') { + template = precompiled; } - - // We need to take the string representation and turn it into a - // valid JavaScript object. eval is evil, but necessary in this case. - let template; - eval(`template = ${precompiled}`); - + // Take the precompiled object and get the actual function out of it, // after first testing for runtime version compatibility. return this.handlebars.template(template); - } + } /** * Detect whether a given template has been loaded. @@ -303,6 +310,41 @@ class HandlebarsRenderer { }); }; + renderSync(path, context) { + context = context || {}; + + // Add some data to the context + context.template = path; + if (this._translator) { + context.locale_name = this._translator.getLocale(); + } + + // Look up the template + const template = this.handlebars.partials[path]; + if (typeof template === 'undefined') { + throw new TemplateNotFoundError(`template not found: ${path}`); + } + + // Render the template + let result; + try { + result = template(context); + } catch (e) { + throw new RenderError(e.message); + } + + // Apply decorators + try { + for (let i = 0; i < this._decorators.length; i++) { + result = this._decorators[i](result); + } + } catch (e) { + throw new DecoratorError(e.message); + } + + return result; + }; + /** * Renders a string with the given context * @@ -363,6 +405,11 @@ class HandlebarsRenderer { setLoggerLevel(level) { this.handlebars.logger.level = level; } + + loadTranslations(acceptLanguage, translations, translator_logger = logger) { + let translator = Translator.create(acceptLanguage, translations, translator_logger); + this.setTranslator(translator); + } } module.exports = HandlebarsRenderer; diff --git a/lib/translator/filter.js b/lib/translator/filter.js new file mode 100644 index 00000000..5c46a2fc --- /dev/null +++ b/lib/translator/filter.js @@ -0,0 +1,79 @@ +'use strict'; + +/** + * @module paper/lib/translator/filter + * + * This should be considered an internal concern of Translator, and not a + * public interface. +*/ + +/* +* Internal method to filter an object containing string keys using the given keyPrefix +* +* @private +* @param {Object.} obj +* @param {string} keyPrefix +* @returns {Object.} +*/ +function filterByKeyPrefix(obj, keyPrefix) { + const result = {}; + for (const key in obj) { + if (typeof key === 'string' && key.startsWith(keyPrefix)) { + result[key] = obj[key]; + } + } + return result; +} + + +/** + * Filter translation and locales of the given language object by translation key prefix. + * This is used by the `langJson` helper. This method expects an object in the format + * returned by `translator/transformer.js:transform()`. + * + * The incoming language object looks like this: + * { + * locale: 'en', + * locales: { + * 'salutations.welcome': 'en', + * 'salutations.hello': 'en', + * 'salutations.bye': 'en', + * items: 'en', + * }, + * translations: { + * 'salutations.welcome': 'Welcome', + * 'salutations.hello': 'Hello {name}', + * 'salutations.bye': 'Bye bye', + * items: '{count, plural, one{1 Item} other{# Items}}', + * } + * } + * + * The return value, assuming `keyFilter` of `salutations`, would look like this: + * { + * locale: 'en', + * locales: { + * 'salutations.welcome': 'en', + * 'salutations.hello': 'en', + * 'salutations.bye': 'en', + * }, + * translations: { + * 'salutations.welcome': 'Welcome', + * 'salutations.hello': 'Hello {name}', + * 'salutations.bye': 'Bye bye', + * } + * } + * @param {Object.} language + * @param {string} keyFilter + * @returns {Object.} + */ +function filterLanguageObject(language, keyFilter) { + return { + locale: language.locale, + locales: filterByKeyPrefix(language.locales, keyFilter), + translations: filterByKeyPrefix(language.translations, keyFilter), + }; +} + +module.exports = { + filterByKey: filterLanguageObject, +}; \ No newline at end of file diff --git a/lib/translator/index.js b/lib/translator/index.js new file mode 100644 index 00000000..7b907a2a --- /dev/null +++ b/lib/translator/index.js @@ -0,0 +1,177 @@ +'use strict'; + +// This file was copied from @bigcommerce/paper + +/** + * @module paper/lib/translator + */ + +const MessageFormat = require('messageformat'); +const Filter = require('./filter'); +const LocaleParser = require('./locale-parser'); +const Transformer = require('./transformer'); + + +/** + * Default locale + * @private + * @type {string} + */ +const FALLBACK_LOCALE = 'en'; + +/** + * Translator constructor + * + * @constructor + * @param {string} acceptLanguage The Accept-Language header to be parsed to determine language to use + * @param {Object} allTranslations Object containing all translations coming from theme + * @param {Object} logger + */ +function Translator(acceptLanguage, allTranslations, logger = console) { + /** + * @private + * @type {Object} + */ + this._logger = logger; + + /** + * @private + * @type {string[]} + */ + this._preferredLocales = LocaleParser.getPreferredLocales(acceptLanguage, Object.keys(allTranslations), FALLBACK_LOCALE); + + /** + * @private + * @type {Object.} + * + * Looks like this: + * { + * locale: 'en', + * locales: { + * 'salutations.welcome': 'en', + * 'salutations.hello': 'en', + * 'salutations.bye': 'en', + * items: 'en', + * } + * translations: { + * 'salutations.welcome': 'Welcome', + * 'salutations.hello': 'Hello {name}', + * 'salutations.bye': 'Bye bye', + * items: '{count, plural, one{1 Item} other{# Items}}', + * } + * } + */ + this._language = Transformer.transform(allTranslations, this._preferredLocales, this._logger) || {}; + + /** + * @private + * @type {Object.} + */ + this._formatters = {}; + + /** + * @private + * @type {Object.} + */ + this._formatFunctions = {}; +} + +/** + * Translator factory method + * + * @static + * @param {string} acceptLanguage + * @param {Object} allTranslations + * @param {Object} logger + * @returns {Translator} + */ +Translator.create = function (acceptLanguage, allTranslations, logger = console) { + return new Translator(acceptLanguage, allTranslations, logger); +}; + +/** + * Get translated string + * + * @param {string} key + * @param {Object} parameters + * @returns {string} + */ +Translator.prototype.translate = function (key, parameters) { + if (!this._language.translations || !this._language.translations[key]) { + return key; + } + + if (typeof this._formatFunctions[key] === 'undefined') { + this._formatFunctions[key] = this._compileTemplate(key); + } + + try { + return this._formatFunctions[key](parameters); + } catch (err) { + this._logger.warn(err.message); + return ''; + } +}; + +/** + * Get primary locale name + * + * @returns {string} Primary locale + */ +Translator.prototype.getLocale = function () { + return this._preferredLocales[0]; +}; + +/** + * Get language object + * + * @param {string} [keyFilter] + * @returns {Object} Language object + */ +Translator.prototype.getLanguage = function (keyFilter) { + if (keyFilter) { + return Filter.filterByKey(this._language, keyFilter); + } + + return this._language; +}; + +/** + * Get formatter + * + * @private + * @param {string} locale + * @returns {MessageFormat} Return cached or new MessageFormat + */ +Translator.prototype._getFormatter = function (locale) { + if (!this._formatters[locale]) { + this._formatters[locale] = new MessageFormat(locale); + } + + return this._formatters[locale]; +}; + +/** + * Compile a translation template and return a formatter function + * + * @private + * @param {string} key + * @return {Function} + */ +Translator.prototype._compileTemplate = function (key) { + const locale = this._language.locales[key]; + const formatter = this._getFormatter(locale); + + try { + return formatter.compile(this._language.translations[key]); + } catch (err) { + if (err.name === 'SyntaxError') { + this._logger.warn(`Language File Syntax Error: ${err.message} for key "${key}"`, err.expected); + return () => ''; + } + + throw err; + } +}; + +module.exports = Translator; \ No newline at end of file diff --git a/lib/translator/locale-parser.js b/lib/translator/locale-parser.js new file mode 100644 index 00000000..b230f50d --- /dev/null +++ b/lib/translator/locale-parser.js @@ -0,0 +1,73 @@ +'use strict'; + +/** + * @module paper/lib/translator/locale-parser + * + * This should be considered an internal concern of Translator, and not a + * public interface. + */ +const AcceptLanguageParser = require('accept-language-parser'); +const MessageFormat = require('messageformat'); + +/** + * Parse the Accept-Language header and return the list of preferred locales + * filtered based on the list of supported locales and making sure that MessageFormat + * supports it as well. + * + * @param {string} acceptLanguageHeader The Accept-Language header + * @param {Array} availableLocales A list of available locales from translations file + * @param {string} fallbackLocale The default fallback locale + * @returns {string[]} List of preferred+supported locales + */ +function getPreferredLocales(acceptLanguageHeader, availableLocales, fallbackLocale) { + // Parse header + const acceptableLocales = parseLocales(acceptLanguageHeader, fallbackLocale); + + // Filter based on list of available locales + const preferredLocales = acceptableLocales.filter(locale => availableLocales.includes(locale)); + + // Filter list based on what MessageFormat actually supports + return preferredLocales.filter(locale => { + try { + new MessageFormat(locale); + return true; + } catch (err) { + return false; + } + }); +} + +/** + * Parse Accept-Language header and return a list of locales + * + * @param {string} acceptLanguageHeader The Accept-Language header + * @param {string} fallbackLocale The default fallback locale + * @returns {string[]} Ordered list of locale identifiers + */ +function parseLocales(acceptLanguageHeader, fallbackLocale) { + // Parse the header, adding fallback to the very end of the list (via low quality) + const parsed = AcceptLanguageParser.parse(`${acceptLanguageHeader},${fallbackLocale};q=0`); + + // Iterate through the parsed locales, pushing into a Set to deduplicate as we go along + const locales = new Set(); + for (let i = 0; i < parsed.length; i++) { + const locale = parsed[i]; + if (locale.region && locale.code) { + locales.add(`${locale.code}-${locale.region}`); + } + + // Insert regionless fallbacks into the chain. As an example, if fr-FR is in the chain, + // but fr is not, add it. This enables appropriate fallback logic for the translations file. + // If we have already seen it previously, it will not be added to the Set. + if (locale.code) { + locales.add(locale.code); + } + } + + // Return an array based on insertion order of the Set + return [...locales]; +} + +module.exports = { + getPreferredLocales, +}; \ No newline at end of file diff --git a/lib/translator/transformer.js b/lib/translator/transformer.js new file mode 100644 index 00000000..d3aed7bd --- /dev/null +++ b/lib/translator/transformer.js @@ -0,0 +1,247 @@ +'use strict'; + +/** + * @module paper/lib/translator/transformer + * + * This should be considered an internal concern of Translator, and not a + * public interface. + */ + +/** + * Transform translations from the representation provided by stencil bundle + * into something that enables fast lookups in the `lang` helper. + * + * The `allTranslations` object looks like this: + * { + * en: { + * salutations: { + * welcome: 'Welcome', + * hello: 'Hello {name}', + * bye: 'Bye bye', + * } + * items: '{count, plural, one{1 Item} other{# Items}}', + * }, + * fr: { + * salutations: { + * hello: 'Bonjour {name}', + * bye: 'au revoir', + * } + * }, + * 'fr-CA': { + * salutations: { + * hello: 'Salut {name}', + * }, + * }, + * } + * + * The return value looks like this, assuming preferredLocales of ['fr-CA', 'en']: + * { + * locale: 'fr-CA', + * locales: { + * 'salutations.welcome': 'en', + * 'salutations.hello': 'fr-CA', + * 'salutations.bye': 'fr', + * items: 'en', + * }, + * translations: { + * 'salutations.welcome': 'Welcome', + * 'salutations.hello': 'Salut {name}', + * 'salutations.bye': 'au revoir', + * items: '{count, plural, one{1 Item} other{# Items}}', + * } + * } + * + * @param {Object.} allTranslations + * @param {string[]} preferredLocales + * @param {Object} logger + * @returns {Object.} Transformed translations + */ +function transform(allTranslations, preferredLocales, logger = console) { + const flattened = flatten(allTranslations, preferredLocales, logger); + return cascade(flattened, preferredLocales); +} + +/** + * Flatten translation keys to a single top-level namespace, keeping only necessary + * languages based on preferredLocales. + * + * The `allTranslations` object looks like this: + * { + * en: { + * salutations: { + * welcome: 'Welcome', + * hello: 'Hello {name}', + * bye: 'Bye bye', + * formal: { + * bye: 'Farewell', + * } + * } + * items: '{count, plural, one{1 Item} other{# Items}}', + * }, + * fr: { + * salutations: { + * hello: 'Bonjour {name}', + * bye: 'au revoir', + * } + * }, + * 'fr-CA': { + * salutations: { + * hello: 'Salut {name}', + * }, + * }, + * 'de': { + * salutations: { + * hello: 'Hallo {name}', + * }, + * }, + * } + * + * The return value looks like this, assuming preferredLocales of ['fr-CA', 'en']: + * { + * en: { + * 'salutations.welcome': 'Welcome', + * 'salutations.hello': 'Hello {name}', + * 'salutations.bye': 'Bye bye', + * 'salutations.formal.bye': 'Farewell', + * items: '{count, plural, one{1 Item} other{# Items}}', + * }, + * fr: { + * 'salutations.hello': 'Bonjour {name}', + * 'salutations.bye': 'au revoir', + * }, + * 'fr-CA': { + * 'salutations.hello': 'Salut {name}', + * }, + * } + * @param {Object.} translations + * @param {string[]} preferredLocales + * @param {Object} logger + * @returns {Object.} Flattened translations + */ +function flatten(translations, preferredLocales, logger = console) { + const result = {}; + for (let i = 0; i < preferredLocales.length; i++) { + const locale = preferredLocales[i]; + try { + result[locale] = flattenObject(translations[locale]); + } catch (err) { + logger.warn(`Failed to flatten ${locale} - Error: ${err}`); + result[locale] = {}; + } + } + return result; +} + +/** + * Cascade translations by providing appropriate fallback values. For example, if 'fr-CA' + * is requested but the translation override doesn't exist, we fallback first to 'fr', then + * to the defaultLocale (usually 'en'). + * + * flattenedTranslations looks like this: + * { + * en: { + * 'salutations.welcome': 'Welcome', + * 'salutations.hello': 'Hello {name}', + * 'salutations.bye': 'Bye bye', + * items: '{count, plural, one{1 Item} other{# Items}}', + * }, + * fr: { + * 'salutations.hello': 'Bonjour {name}', + * 'salutations.bye': 'au revoir', + * }, + * 'fr-CA': { + * 'salutations.hello': 'Salut {name}', + * }, + * } + * + * The return value looks like this, assuming preferredLocales of ['fr-CA', 'en']: + * { + * locale: 'fr-CA', + * locales: { + * 'salutations.welcome': 'en', + * 'salutations.hello': 'fr-CA', + * 'salutations.bye': 'fr', + * items: 'en', + * }, + * translations: { + * 'salutations.welcome': 'Welcome', + * 'salutations.hello': 'Salut {name}', + * 'salutations.bye': 'au revoir', + * items: '{count, plural, one{1 Item} other{# Items}}', + * } + * } + * + * @param {Object.} translations Flattened translations + * @param {string[]} preferredLocales Ordered list of preferred locales + * @returns {Object.} Cascaded translations spec + */ +function cascade(translations, preferredLocales) { + const result = { locale: preferredLocales[0], locales: {}, translations: {} }; + + // Process the list of locales in reverse order of preference for proper layering + const localeList = preferredLocales.slice().reverse(); + + // Build the layered set of translations + for (let i = 0; i < localeList.length; i++) { + const locale = localeList[i]; + for (const key in translations[locale]) { + result.locales[key] = locale; + result.translations[key] = translations[locale][key]; + } + } + + return result; +} + +/** + * Internal method to flatten nested JSON to the top level, transforming nested keys + * using dot syntax. + * + * If the object looks like this: + * { + * salutations: { + * welcome: 'Welcome', + * hello: 'Hello {name}', + * bye: 'Bye bye', + * formal: { + * bye: 'Farewell', + * } + * } + * } + * + * Then the return value would look like this: + * { + * 'salutations.welcome': 'Welcome', + * 'salutations.hello': 'Hello {name}', + * 'salutations.bye': 'Bye bye', + * 'salutations.formal.bye': 'Farewell', + * }, + * + * @private + * @param {Object} object The object to process + * @param {Object} [result] Caller is expected to pass in an empty object to store the result + * @param {string} [parentKey] An optional parent key, used in recursive calls + * @returns {Object} Object with flattened keys + */ +function flattenObject(object, result, parentKey) { + result = result || {}; + parentKey = parentKey || ''; + + const keys = Object.keys(object); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const flattenedKey = parentKey !== '' ? `${parentKey}.${key}` : key; + if (typeof object[key] === 'object') { + flattenObject(object[key], result, flattenedKey); + } else { + result[flattenedKey] = object[key]; + } + } + return result; +} + +module.exports = { + cascade, + flatten, + transform, +}; \ No newline at end of file diff --git a/package.json b/package.json index 062ad24a..c174995f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "dependencies": { "@bigcommerce/handlebars-v4": "4.7.6", "date.js": "^0.3.3", + "accept-language-parser": "^1.5.0", + "messageformat": "^2.3.0", "handlebars": "3.0.8", "he": "^1.2.0", "lodash": "^4.17.21", diff --git a/webpack.config.js b/webpack.config.js index 151ac355..e4140275 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,7 +2,7 @@ const path = require('path'); module.exports = { entry: './index.js', - mode: 'production', + mode: 'development', devtool: false, output: { filename: 'blackbird-handlebars.js',