Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 61 additions & 14 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
79 changes: 79 additions & 0 deletions lib/translator/filter.js
Original file line number Diff line number Diff line change
@@ -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.<string, string>} obj
* @param {string} keyPrefix
* @returns {Object.<string, string>}
*/
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.<string, string|Object>} language
* @param {string} keyFilter
* @returns {Object.<string, string|Object>}
*/
function filterLanguageObject(language, keyFilter) {
return {
locale: language.locale,
locales: filterByKeyPrefix(language.locales, keyFilter),
translations: filterByKeyPrefix(language.translations, keyFilter),
};
}

module.exports = {
filterByKey: filterLanguageObject,
};
177 changes: 177 additions & 0 deletions lib/translator/index.js
Original file line number Diff line number Diff line change
@@ -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.<string, string|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.<string, MessageFormat>}
*/
this._formatters = {};

/**
* @private
* @type {Object.<string, function>}
*/
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;
Loading