diff --git a/ghost/core/core/frontend/helpers/t.js b/ghost/core/core/frontend/helpers/t.js index 281c18c08c4b..0d684fd9a099 100644 --- a/ghost/core/core/frontend/helpers/t.js +++ b/ghost/core/core/frontend/helpers/t.js @@ -26,5 +26,7 @@ module.exports = function t(text, options = {}) { } } + // The helper should always return a string, not a SafeString + // HTML escaping is handled by the template engine based on whether {{ or {{{ was used return themeI18n.t(text, bindings); }; diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js deleted file mode 100644 index a6287fc47b30..000000000000 --- a/ghost/core/core/frontend/services/theme-engine/i18n/I18n.js +++ /dev/null @@ -1,312 +0,0 @@ -const errors = require('@tryghost/errors'); -const logging = require('@tryghost/logging'); -const fs = require('fs-extra'); -const path = require('path'); -const MessageFormat = require('intl-messageformat'); -const jp = require('jsonpath'); -const isString = require('lodash/isString'); -const isObject = require('lodash/isObject'); -const isEqual = require('lodash/isEqual'); -const isNil = require('lodash/isNil'); -const merge = require('lodash/merge'); -const get = require('lodash/get'); - -class I18n { - /** - * @param {object} [options] - * @param {string} options.basePath - the base path to the translations directory - * @param {string} [options.locale] - a locale string - * @param {string} [options.stringMode] - which mode our translation keys use - */ - constructor(options = {}) { - this._basePath = options.basePath || __dirname; - this._locale = options.locale || this.defaultLocale(); - this._stringMode = options.stringMode || 'dot'; - - this._strings = null; - } - - /** - * BasePath getter & setter used for testing - */ - set basePath(basePath) { - this._basePath = basePath; - } - - /** - * Need to call init after this - */ - get basePath() { - return this._basePath; - } - - /** - * English is our default locale - */ - defaultLocale() { - return 'en'; - } - - supportedLocales() { - return [this.defaultLocale()]; - } - - /** - * Exporting the current locale (e.g. "en") to make it available for other files as well, - * such as core/frontend/helpers/date.js and core/frontend/helpers/lang.js - */ - locale() { - return this._locale; - } - - /** - * Helper method to find and compile the given data context with a proper string resource. - * - * @param {string} translationPath Path within the JSON language file to desired string (ie: "errors.init.jsNotBuilt") - * @param {object} [bindings] - * @returns {string} - */ - t(translationPath, bindings) { - let string; - let msg; - - string = this._findString(translationPath); - - // If the path returns an array (as in the case with anything that has multiple paragraphs such as emails), then - // loop through them and return an array of translated/formatted strings. Otherwise, just return the normal - // translated/formatted string. - if (Array.isArray(string)) { - msg = []; - string.forEach(function (s) { - msg.push(this._formatMessage(s, bindings)); - }); - } else { - msg = this._formatMessage(string, bindings); - } - - return msg; - } - - /** - * Setup i18n support: - * - Load proper language file into memory - */ - init() { - this._strings = this._loadStrings(); - - this._initializeIntl(); - } - - /** - * Attempt to load strings from a file - * - * @param {string} [locale] - * @returns {object} strings - */ - _loadStrings(locale) { - locale = locale || this.locale(); - - try { - return this._readTranslationsFile(locale); - } catch (err) { - if (err.code === 'ENOENT') { - this._handleMissingFileError(locale); - - if (locale !== this.defaultLocale()) { - this._handleFallbackToDefault(); - return this._loadStrings(this.defaultLocale()); - } - } else if (err instanceof SyntaxError) { - this._handleInvalidFileError(locale, err); - } else { - throw err; - } - - // At this point we've done all we can and strings must be an object - return {}; - } - } - - /** - * Do the lookup within the JSON file using jsonpath - * - * @param {String} msgPath - */ - _getCandidateString(msgPath) { - // Our default string mode is "dot" for dot-notation, e.g. $.something.like.this used in the backend - // Both jsonpath's dot-notation and bracket-notation start with '$' E.g.: $.store.book.title or $['store']['book']['title'] - // While bracket-notation allows any Unicode characters in keys (i.e. for themes / fulltext mode) E.g. $['Read more'] - // dot-notation allows only word characters in keys for backend messages (that is \w or [A-Za-z0-9_] in RegExp) - let jsonPath = `$.${msgPath}`; - let fallback = null; - - if (this._stringMode === 'fulltext') { - jsonPath = jp.stringify(['$', msgPath]); - // In fulltext mode we can use the passed string as a fallback - fallback = msgPath; - } - - try { - return jp.value(this._strings, jsonPath) || fallback; - } catch (err) { - this._handleInvalidKeyError(msgPath, err); - } - } - - /** - * Parse JSON file for matching locale, returns string giving path. - * - * @param {string} msgPath Path with in the JSON language file to desired string (ie: "errors.init.jsNotBuilt") - * @returns {string} - */ - _findString(msgPath, opts) { - const options = merge({log: true}, opts || {}); - let candidateString; - let matchingString; - - // no path? no string - if (!msgPath || msgPath.length === 0 || !isString(msgPath)) { - this._handleEmptyKeyError(); - return ''; - } - - // If not in memory, load translations for core - if (isNil(this._strings)) { - this._handleUninitialisedError(msgPath); - } - - candidateString = this._getCandidateString(msgPath); - - matchingString = candidateString || {}; - - if (isObject(matchingString) || isEqual(matchingString, {})) { - if (options.log) { - this._handleMissingKeyError(msgPath); - } - - matchingString = this._fallbackError(); - } - - return matchingString; - } - - _translationFileDirs() { - return [this.basePath]; - } - - // If we are passed a locale, use that, else use this.locale - _translationFileName(locale) { - return `${locale || this.locale()}.json`; - } - - /** - * Read the translations file - * Error handling to be done by consumer - * - * @param {string} locale - */ - _readTranslationsFile(locale) { - const filePath = path.join(...this._translationFileDirs(), this._translationFileName(locale)); - const content = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(content); - } - - /** - * Format the string using the correct locale and applying any bindings - * @param {String} string - * @param {Object} bindings - */ - _formatMessage(string, bindings) { - let currentLocale = this.locale(); - let msg = new MessageFormat(string, currentLocale); - - try { - msg = msg.format(bindings); - } catch (err) { - this._handleFormatError(err); - - // fallback - msg = new MessageFormat(this._fallbackError(), currentLocale); - msg = msg.format(); - } - - return msg; - } - - /** - * [Private] Setup i18n support: - * - Polyfill node.js if it does not have Intl support or support for a particular locale - */ - _initializeIntl() { - let hasBuiltInLocaleData; - let IntlPolyfill; - - if (global.Intl) { - // Determine if the built-in `Intl` has the locale data we need. - hasBuiltInLocaleData = this.supportedLocales().every(function (locale) { - return Intl.NumberFormat.supportedLocalesOf(locale)[0] === locale && - Intl.DateTimeFormat.supportedLocalesOf(locale)[0] === locale; - }); - if (!hasBuiltInLocaleData) { - // `Intl` exists, but it doesn't have the data we need, so load the - // polyfill and replace the constructors with need with the polyfill's. - IntlPolyfill = require('intl'); - Intl.NumberFormat = IntlPolyfill.NumberFormat; - Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat; - } - } else { - // No `Intl`, so use and load the polyfill. - global.Intl = require('intl'); - } - } - - _handleUninitialisedError(key) { - logging.warn(`i18n was used before it was initialised with key ${key}`); - this.init(); - } - - _handleFormatError(err) { - logging.error(err.message); - } - - _handleFallbackToDefault() { - logging.warn(`i18n is falling back to ${this.defaultLocale()}.json.`); - } - - _handleMissingFileError(locale) { - logging.warn(`i18n was unable to find ${locale}.json.`); - } - - _handleInvalidFileError(locale, err) { - logging.error(new errors.IncorrectUsageError({ - err, - message: `i18n was unable to parse ${locale}.json. Please check that it is valid JSON.` - })); - } - - _handleEmptyKeyError() { - logging.warn('i18n.t() was called without a key'); - } - - _handleMissingKeyError(key) { - logging.error(new errors.IncorrectUsageError({ - message: `i18n.t() was called with a key that could not be found: ${key}` - })); - } - - _handleInvalidKeyError(key, err) { - throw new errors.IncorrectUsageError({ - err, - message: `i18n.t() called with an invalid key: ${key}` - }); - } - - /** - * A really basic error for if everything goes wrong - */ - _fallbackError() { - return get(this._strings, 'errors.errors.anErrorOccurred', 'An error occurred'); - } -} - -module.exports = I18n; diff --git a/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js b/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js index 4df8fa1a8543..3d59722307b6 100644 --- a/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js +++ b/ghost/core/core/frontend/services/theme-engine/i18n/ThemeI18n.js @@ -1,17 +1,33 @@ const errors = require('@tryghost/errors'); -const logging = require('@tryghost/logging'); -const I18n = require('./I18n'); +const i18nLib = require('@tryghost/i18n'); +const path = require('path'); +const fs = require('fs-extra'); -class ThemeI18n extends I18n { +class ThemeI18n { /** - * @param {object} [options] + * @param {object} options * @param {string} options.basePath - the base path for the translation directory (e.g. where themes live) * @param {string} [options.locale] - a locale string */ - constructor(options = {}) { - super(options); - // We don't care what gets passed in, themes use fulltext mode - this._stringMode = 'fulltext'; + constructor(options) { + if (!options || !options.basePath) { + throw new errors.IncorrectUsageError({message: 'basePath is required'}); + } + this._basePath = options.basePath; + this._locale = options.locale || 'en'; + this._activeTheme = null; + this._i18n = null; + } + + /** + * BasePath getter & setter used for testing + */ + set basePath(basePath) { + this._basePath = basePath; + } + + get basePath() { + return this._basePath; } /** @@ -19,56 +35,68 @@ class ThemeI18n extends I18n { * - Load correct language file into memory * * @param {object} options - * @param {String} options.activeTheme - name of the currently loaded theme - * @param {String} options.locale - name of the currently loaded locale - * + * @param {string} options.activeTheme - name of the currently loaded theme + * @param {string} options.locale - name of the currently loaded locale */ - init({activeTheme, locale} = {}) { - // This function is called during theme initialization, and when switching language or theme. - this._locale = locale || this._locale; - this._activetheme = activeTheme || this._activetheme; - - super.init(); - } - - _translationFileDirs() { - return [this.basePath, this._activetheme, 'locales']; - } + async init(options) { + if (!options || !options.activeTheme) { + throw new errors.IncorrectUsageError({message: 'activeTheme is required'}); + } - _handleUninitialisedError(key) { - throw new errors.IncorrectUsageError({message: `Theme translation was used before it was initialised with key ${key}`}); - } + this._locale = options.locale || this._locale; + this._activeTheme = options.activeTheme; - _handleFallbackToDefault() { - logging.warn(`Theme translations falling back to locales/${this.defaultLocale()}.json.`); - } + const themeLocalesPath = path.join(this._basePath, this._activeTheme, 'locales'); + + // Check if the theme path exists + const themePathExists = await fs.pathExists(themeLocalesPath); - _handleMissingFileError(locale) { - if (locale !== this.defaultLocale()) { - logging.warn(`Theme translations file locales/${locale}.json not found.`); + if (!themePathExists) { + // If the theme path doesn't exist, use the key as the translation + this._i18n = { + t: key => key + }; + return; } - } - _handleInvalidFileError(locale, err) { - logging.error(new errors.IncorrectUsageError({ - err, - message: `Theme translations unable to parse locales/${locale}.json. Please check that it is valid JSON.` - })); - } - - _handleEmptyKeyError() { - logging.warn('Theme translations {{t}} helper called without a translation key.'); - } - - _handleMissingKeyError() { - // This case cannot be reached in themes as we use the key as the fallback + // Initialize i18n with the theme path + // Note: @tryghost/i18n uses synchronous file operations internally + // This is fine in production but in tests we need to ensure the files exist first + try { + // Verify the locale file exists + const localePath = path.join(themeLocalesPath, `${this._locale}.json`); + await fs.access(localePath); + + // Initialize i18n + this._i18n = i18nLib(this._locale, 'theme', {themePath: themeLocalesPath}); + } catch (err) { + // If the requested locale fails, try English as fallback + try { + const enPath = path.join(themeLocalesPath, 'en.json'); + await fs.access(enPath); + this._i18n = i18nLib(this._locale, 'theme', {themePath: themeLocalesPath}); + } catch (enErr) { + // If both fail, use the key as the translation + this._i18n = { + t: key => key + }; + } + } } - _handleInvalidKeyError(key, err) { - throw new errors.IncorrectUsageError({ - err, - message: `Theme translations {{t}} helper called with an invalid translation key: ${key}` - }); + /** + * Helper method to find and compile the given data context with a proper string resource. + * + * @param {string} key - The translation key + * @param {object} [bindings] - Optional bindings for the translation + * @returns {string} + */ + t(key, bindings) { + if (!this._i18n) { + throw new errors.IncorrectUsageError({message: `Theme translation was used before it was initialised with key ${key}`}); + } + const result = this._i18n.t(key, bindings); + return typeof result === 'string' ? result : String(result); } } diff --git a/ghost/core/test/unit/frontend/helpers/t.test.js b/ghost/core/test/unit/frontend/helpers/t.test.js index 6fcb736d1f21..dfefdc282890 100644 --- a/ghost/core/test/unit/frontend/helpers/t.test.js +++ b/ghost/core/test/unit/frontend/helpers/t.test.js @@ -14,8 +14,13 @@ describe('{{t}} helper', function () { themeI18n.basePath = ogBasePath; }); - it('theme translation is DE', function () { - themeI18n.init({activeTheme: 'locale-theme', locale: 'de'}); + beforeEach(async function () { + // Reset the i18n instance before each test + themeI18n._i18n = null; + }); + + it('theme translation is DE', async function () { + await themeI18n.init({activeTheme: 'locale-theme', locale: 'de'}); let rendered = t.call({}, 'Top left Button', { hash: {} @@ -24,8 +29,8 @@ describe('{{t}} helper', function () { rendered.should.eql('Oben Links.'); }); - it('theme translation is EN', function () { - themeI18n.init({activeTheme: 'locale-theme', locale: 'en'}); + it('theme translation is EN', async function () { + await themeI18n.init({activeTheme: 'locale-theme', locale: 'en'}); let rendered = t.call({}, 'Top left Button', { hash: {} @@ -34,8 +39,8 @@ describe('{{t}} helper', function () { rendered.should.eql('Left Button on Top'); }); - it('[fallback] no theme translation file found for FR', function () { - themeI18n.init({activeTheme: 'locale-theme', locale: 'fr'}); + it('[fallback] no theme translation file found for FR', async function () { + await themeI18n.init({activeTheme: 'locale-theme', locale: 'fr'}); let rendered = t.call({}, 'Top left Button', { hash: {} @@ -44,8 +49,8 @@ describe('{{t}} helper', function () { rendered.should.eql('Left Button on Top'); }); - it('[fallback] no theme files at all, use key as translation', function () { - themeI18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); + it('[fallback] no theme files at all, use key as translation', async function () { + await themeI18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); let rendered = t.call({}, 'Top left Button', { hash: {} @@ -70,8 +75,8 @@ describe('{{t}} helper', function () { rendered.should.eql(''); }); - it('returns a translated string even if no options are passed', function () { - themeI18n.init({activeTheme: 'locale-theme', locale: 'en'}); + it('returns a translated string even if no options are passed', async function () { + await themeI18n.init({activeTheme: 'locale-theme', locale: 'en'}); let rendered = t.call({}, 'Top left Button'); diff --git a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js index c16c4e337f6e..6b9594c7e703 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/i18n.test.js @@ -1,93 +1,63 @@ const should = require('should'); const sinon = require('sinon'); +const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n/ThemeI18n'); +const path = require('path'); -const I18n = require('../../../../../core/frontend/services/theme-engine/i18n/I18n'); +describe('ThemeI18n Class behavior', function () { + let i18n; + const testBasePath = path.join(__dirname, '../../../../utils/fixtures/themes/'); -const logging = require('@tryghost/logging'); - -describe('I18n Class behavior', function () { - it('defaults to en', function () { - const i18n = new I18n(); - i18n.locale().should.eql('en'); + beforeEach(async function () { + i18n = new ThemeI18n({basePath: testBasePath}); }); - it('can have a different locale set', function () { - const i18n = new I18n({locale: 'fr'}); - i18n.locale().should.eql('fr'); + afterEach(function () { + sinon.restore(); }); - describe('file loading behavior', function () { - it('will fallback to en file correctly without changing locale', function () { - const i18n = new I18n({locale: 'fr'}); - - let fileSpy = sinon.spy(i18n, '_readTranslationsFile'); - - i18n.locale().should.eql('fr'); - i18n.init(); - - i18n.locale().should.eql('fr'); - fileSpy.calledTwice.should.be.true(); - fileSpy.secondCall.args[0].should.eql('en'); - }); + it('defaults to en', function () { + i18n._locale.should.eql('en'); }); - describe('translation key dot notation (default behavior)', function () { - const fakeStrings = { - test: {string: {path: 'I am correct'}} - }; - let i18n; - - beforeEach(function initBasicI18n() { - i18n = new I18n(); - sinon.stub(i18n, '_loadStrings').returns(fakeStrings); - i18n.init(); - }); - - it('correctly loads strings', function () { - i18n._strings.should.eql(fakeStrings); - }); - - it('correctly uses dot notation', function () { - i18n.t('test.string.path').should.eql('I am correct'); - }); - - it('uses key fallback correctly', function () { - const loggingStub = sinon.stub(logging, 'error'); - i18n.t('unknown.string').should.eql('An error occurred'); - sinon.assert.calledOnce(loggingStub); - }); - - it('errors for invalid strings', function () { - should(function () { - i18n.t('unknown string'); - }).throw('i18n.t() called with an invalid key: unknown string'); - }); + it('can have a different locale set', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'fr'}); + i18n._locale.should.eql('fr'); + }); + + it('initializes with theme path', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'de'}); + const result = i18n.t('Top left Button'); + result.should.eql('Oben Links.'); }); - describe('translation key fulltext notation (theme behavior)', function () { - const fakeStrings = {'Full text': 'I am correct'}; - let i18n; - - beforeEach(function initFulltextI18n() { - i18n = new I18n({stringMode: 'fulltext'}); - sinon.stub(i18n, '_loadStrings').returns(fakeStrings); - i18n.init(); - }); + it('falls back to en when translation not found', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'fr'}); + const result = i18n.t('Top left Button'); + result.should.eql('Left Button on Top'); + }); - afterEach(function () { - sinon.restore(); - }); + it('uses key as fallback when no translation files exist', async function () { + await i18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); + const result = i18n.t('Top left Button'); + result.should.eql('Top left Button'); + }); - it('correctly loads strings', function () { - i18n._strings.should.eql(fakeStrings); - }); + it('returns empty string for empty key', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'en'}); + const result = i18n.t(''); + result.should.eql(''); + }); - it('correctly uses fulltext with bracket notation', function () { - i18n.t('Full text').should.eql('I am correct'); - }); + it('throws error if used before initialization', function () { + should(function () { + i18n.t('some key'); + }).throw('Theme translation was used before it was initialised with key some key'); + }); - it('uses key fallback correctly', function () { - i18n.t('unknown string').should.eql('unknown string'); - }); + it('uses key fallback correctly', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'en'}); + const result = i18n.t('unknown string'); + result.should.eql('unknown string'); }); }); + diff --git a/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js b/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js deleted file mode 100644 index 4c990408f3fb..000000000000 --- a/ghost/core/test/unit/frontend/services/theme-engine/theme-i18n.test.js +++ /dev/null @@ -1,10 +0,0 @@ -const should = require('should'); - -const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n').ThemeI18n; - -describe('ThemeI18n Class behavior', function () { - it('defaults to en', function () { - const i18n = new ThemeI18n(); - i18n.locale().should.eql('en'); - }); -}); diff --git a/ghost/i18n/lib/i18n.js b/ghost/i18n/lib/i18n.js index 57fbc24c709e..ecd7e5bbd36d 100644 --- a/ghost/i18n/lib/i18n.js +++ b/ghost/i18n/lib/i18n.js @@ -1,4 +1,6 @@ const i18next = require('i18next'); +const fs = require('fs-extra'); +const path = require('path'); const SUPPORTED_LOCALES = [ 'af', // Afrikaans @@ -85,19 +87,69 @@ function generateResources(locales, ns) { /** * @param {string} [lng] - * @param {'ghost'|'portal'|'test'|'signup-form'|'comments'|'search'|'newsletter'} ns + * @param {'ghost'|'portal'|'test'|'signup-form'|'comments'|'search'|'newsletter'|'theme'} ns + * @param {object} [options] + * @param {string} [options.themePath] - Path to theme's locales directory for theme namespace */ -module.exports = (lng = 'en', ns = 'portal') => { +module.exports = (lng = 'en', ns = 'portal', options = {}) => { const i18nextInstance = i18next.createInstance(); - let interpolation = {}; - if (ns === 'newsletter') { + let interpolation = { + prefix: '{{', + suffix: '}}' + }; + + // Set single curly braces for theme and newsletter namespaces + if (ns === 'theme' || ns === 'newsletter') { interpolation = { prefix: '{', suffix: '}' }; } - let resources = generateResources(SUPPORTED_LOCALES, ns); + // Only disable HTML escaping for theme namespace + if (ns === 'theme') { + interpolation.escapeValue = false; + } + + let resources; + if (ns !== 'theme') { + resources = generateResources(SUPPORTED_LOCALES, ns); + } else { + // For theme namespace, we need to load translations from the theme's locales directory + resources = {}; + const themeLocalesPath = options.themePath; + + if (themeLocalesPath) { + // Try to load the requested locale first + try { + const localePath = path.join(themeLocalesPath, `${lng}.json`); + const content = fs.readFileSync(localePath, 'utf8'); + resources[lng] = { + theme: JSON.parse(content) + }; + } catch (err) { + // If the requested locale fails, try English as fallback + try { + const enPath = path.join(themeLocalesPath, 'en.json'); + const content = fs.readFileSync(enPath, 'utf8'); + resources[lng] = { + theme: JSON.parse(content) + }; + } catch (enErr) { + // If both fail, use an empty object + resources[lng] = { + theme: {} + }; + } + } + } else { + // If no theme path provided, use empty translations + resources[lng] = { + theme: {} + }; + } + } + i18nextInstance.init({ lng, @@ -106,9 +158,11 @@ module.exports = (lng = 'en', ns = 'portal') => { keySeparator: false, // if the value is an empty string, return the key + // this allows empty strings for the en files, and causes all other languages to fallback to en. returnEmptyString: false, - // do not load a fallback + // load en as the fallback for any missing language. + // load nb as the fallback for no for backwards compatibility fallbackLng: { no: ['nb', 'en'], default: ['en'] diff --git a/ghost/i18n/test/i18n.test.js b/ghost/i18n/test/i18n.test.js index 83ce3dbcf8f1..7c4ab41b21ba 100644 --- a/ghost/i18n/test/i18n.test.js +++ b/ghost/i18n/test/i18n.test.js @@ -1,6 +1,7 @@ const assert = require('assert/strict'); const fs = require('fs/promises'); const path = require('path'); +const fsExtra = require('fs-extra'); const i18n = require('../'); @@ -158,4 +159,208 @@ describe('i18n', function () { assert.deepEqual(resources.xx, englishResources.en); }); }); + + describe('theme resources', function () { + let themeLocalesPath; + let cleanup; + + beforeEach(async function () { + // Create a temporary theme locales directory + themeLocalesPath = path.join(__dirname, 'temp-theme-locales'); + await fsExtra.ensureDir(themeLocalesPath); + cleanup = async () => { + await fsExtra.remove(themeLocalesPath); + }; + }); + + afterEach(async function () { + await cleanup(); + }); + + it('loads translations from theme locales directory', async function () { + // Create test translation files + const enContent = { + 'Read more': 'Read more', + Subscribe: 'Subscribe' + }; + const frContent = { + 'Read more': 'Lire plus', + Subscribe: 'S\'abonner' + }; + + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + await fsExtra.writeJson(path.join(themeLocalesPath, 'fr.json'), frContent); + + const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Read more'), 'Lire plus'); + assert.equal(t('Subscribe'), 'S\'abonner'); + }); + + it('falls back to en when translation is missing', async function () { + // Create only English translation file + const enContent = { + 'Read more': 'Read more', + Subscribe: 'Subscribe' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Read more'), 'Read more'); + assert.equal(t('Subscribe'), 'Subscribe'); + }); + + it('uses empty translations when no files exist', async function () { + const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Read more'), 'Read more'); + assert.equal(t('Subscribe'), 'Subscribe'); + }); + + it('handles invalid JSON files gracefully', async function () { + // Create invalid JSON file + await fsExtra.writeFile(path.join(themeLocalesPath, 'fr.json'), 'invalid json'); + + const t = i18n('fr', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Read more'), 'Read more'); + assert.equal(t('Subscribe'), 'Subscribe'); + }); + + it('initializes i18next with correct configuration', async function () { + const enContent = { + 'Read more': 'Read more' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const instance = i18n('fr', 'theme', {themePath: themeLocalesPath}); + + // Verify i18next configuration + assert.equal(instance.language, 'fr'); + assert.deepEqual(instance.options.ns, ['theme']); + assert.equal(instance.options.defaultNS, 'theme'); + assert.equal(instance.options.fallbackLng.default[0], 'en'); + assert.equal(instance.options.returnEmptyString, false); + + // Verify resources are loaded correctly + const resources = instance.store.data; + assert(resources.fr); + assert(resources.fr.theme); + assert.equal(resources.fr.theme['Read more'], 'Read more'); + }); + + it('handles interpolation correctly', async function () { + const enContent = { + 'Welcome {name}': 'Welcome {name}' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Welcome {name}', {name: 'John'}), 'Welcome John'); + }); + + it('interpolates variables in theme translations', async function () { + const enContent = { + 'Welcome, {name}': 'Welcome, {name}', + 'Hello {firstName} {lastName}': 'Hello {firstName} {lastName}' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t; + + // Test simple interpolation + assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John'); + + // Test multiple variables + assert.equal( + t('Hello {firstName} {lastName}', {firstName: 'John', lastName: 'Doe'}), + 'Hello John Doe' + ); + }); + + it('uses single curly braces for theme namespace interpolation', async function () { + const enContent = { + 'Welcome, {name}': 'Welcome, {name}' + }; + await fsExtra.writeJson(path.join(themeLocalesPath, 'en.json'), enContent); + + const t = i18n('en', 'theme', {themePath: themeLocalesPath}).t; + assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John'); + }); + + it('uses double curly braces for portal namespace interpolation', async function () { + const t = i18n('en', 'portal').t; + assert.equal(t('Welcome, {{name}}', {name: 'John'}), 'Welcome, John'); + }); + + it('uses single curly braces for newsletter namespace interpolation', async function () { + const t = i18n('en', 'newsletter').t; + assert.equal(t('Welcome, {name}', {name: 'John'}), 'Welcome, John'); + }); + }); + + describe('i18next initialization', function () { + it('initializes with correct default configuration', function () { + const instance = i18n('en', 'portal'); + + // Verify basic configuration + assert.equal(instance.language, 'en'); + assert.deepEqual(instance.options.ns, ['portal']); + assert.equal(instance.options.defaultNS, 'portal'); + assert.equal(instance.options.fallbackLng.default[0], 'en'); + assert.equal(instance.options.returnEmptyString, false); + assert.equal(instance.options.nsSeparator, false); + assert.equal(instance.options.keySeparator, false); + + // Verify interpolation configuration for portal namespace + assert.equal(instance.options.interpolation.prefix, '{{'); + assert.equal(instance.options.interpolation.suffix, '}}'); + }); + + it('initializes with correct theme configuration', function () { + const instance = i18n('en', 'theme', {themePath: '/path/to/theme'}); + + // Verify basic configuration + assert.equal(instance.language, 'en'); + assert.deepEqual(instance.options.ns, ['theme']); + assert.equal(instance.options.defaultNS, 'theme'); + assert.equal(instance.options.fallbackLng.default[0], 'en'); + assert.equal(instance.options.returnEmptyString, false); + assert.equal(instance.options.nsSeparator, false); + assert.equal(instance.options.keySeparator, false); + + // Verify interpolation configuration for theme namespace + assert.equal(instance.options.interpolation.prefix, '{'); + assert.equal(instance.options.interpolation.suffix, '}'); + }); + + it('initializes with correct newsletter configuration', function () { + const instance = i18n('en', 'newsletter'); + + // Verify basic configuration + assert.equal(instance.language, 'en'); + assert.deepEqual(instance.options.ns, ['newsletter']); + assert.equal(instance.options.defaultNS, 'newsletter'); + assert.equal(instance.options.fallbackLng.default[0], 'en'); + assert.equal(instance.options.returnEmptyString, false); + assert.equal(instance.options.nsSeparator, false); + assert.equal(instance.options.keySeparator, false); + + // Verify interpolation configuration for newsletter namespace + assert.equal(instance.options.interpolation.prefix, '{'); + assert.equal(instance.options.interpolation.suffix, '}'); + }); + + it('initializes with correct fallback language configuration', function () { + const instance = i18n('no', 'portal'); + + // Verify Norwegian fallback chain + assert.deepEqual(instance.options.fallbackLng.no, ['nb', 'en']); + assert.deepEqual(instance.options.fallbackLng.default, ['en']); + }); + + it('initializes with empty theme resources when no theme path provided', function () { + const instance = i18n('en', 'theme'); + + // Verify empty theme resources + assert.deepEqual(instance.store.data.en.theme, {}); + }); + }); });