diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx index 67091cffeef..345167b494d 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx @@ -36,6 +36,10 @@ const BetaFeatures: React.FC = () => { action={} detail={<>Enable support for CashApp, iDEAL, Bancontact, and others. Learn more →} title='Additional payment methods' /> + } + detail={<>Enable theme translation using i18next instead of the old translation package.} + title='Updated theme Translation (beta)' /> `; @@ -213,6 +213,9 @@ function getTinybirdTrackerScript(dataRoot) { */ // We use the name ghost_head to match the helper for consistency: module.exports = async function ghost_head(options) { // eslint-disable-line camelcase + // Get the locale from the template context + const locale = options?.data?.root?.locale || settingsCache.get('locale'); + console.log('=======locale in ghost_head', locale); debug('begin'); // if server error page do nothing if (options.data.root.statusCode >= 500) { @@ -308,7 +311,7 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam if (!_.includes(context, 'amp')) { head.push(getMembersHelper(options.data, frontendKey, excludeList)); // controlling for excludes within the function if (!excludeList.has('search')) { - head.push(getSearchHelper(frontendKey)); + head.push(getSearchHelper(frontendKey, locale)); } if (!excludeList.has('announcement')) { head.push(getAnnouncementBarHelper(options.data)); diff --git a/ghost/core/core/frontend/helpers/t.js b/ghost/core/core/frontend/helpers/t.js index 281c18c08c4..e96767a183e 100644 --- a/ghost/core/core/frontend/helpers/t.js +++ b/ghost/core/core/frontend/helpers/t.js @@ -11,6 +11,78 @@ // {{tags prefix=(t " on ")}} const {themeI18n} = require('../services/handlebars'); +const {themeI18next} = require('../services/handlebars'); +const labs = require('../../shared/labs'); +const config = require('../../shared/config'); +const settingsCache = require('../../shared/settings-cache'); + +// Cache of i18n instances per locale +const i18nInstances = new Map(); +// Cache of initialization promises per locale +const initPromises = new Map(); +// Track initialization state +const initState = new Map(); + +// Get the site's configured locale +const defaultLocale = settingsCache.get('locale') || 'en'; +console.log('Pre-initializing default locale:', defaultLocale); + +// Initialize the default instance +const defaultInstance = new themeI18next.ThemeI18n({basePath: themeI18next.basePath}); +i18nInstances.set(defaultLocale, defaultInstance); +initState.set(defaultLocale, 'initializing'); + +// Initialize the default instance immediately +defaultInstance.init({ + activeTheme: settingsCache.get('active_theme'), + locale: defaultLocale +}).then(() => { + initState.set(defaultLocale, 'initialized'); +}).catch(err => { + initState.set(defaultLocale, 'error'); + throw err; +}); + +// Helper to ensure an instance is initialized +function ensureInitialized(locale) { + + // If no locale specified, use default + if (!locale) { + locale = defaultLocale; + } + + let instance = i18nInstances.get(locale); + const state = initState.get(locale); + + if (!instance) { + console.log('No instance found for locale:', locale, '- creating new instance'); + // Create new instance for this locale + instance = new themeI18next.ThemeI18n({basePath: themeI18next.basePath}); + i18nInstances.set(locale, instance); + initState.set(locale, 'initializing'); + + // Start initialization + console.log('Starting initialization for locale:', locale); + instance.init({ + activeTheme: settingsCache.get('active_theme'), + locale: locale + }).then(() => { + console.log('Initialization completed for locale:', locale); + initState.set(locale, 'initialized'); + }).catch(err => { + console.error('Failed to initialize locale:', locale, err); + initState.set(locale, 'error'); + }); + } else if (state === 'initializing') { + console.log('Instance exists but still initializing for locale:', locale); + } else if (state === 'error') { + console.log('Instance exists but had initialization error for locale:', locale); + } else { + console.log('Instance already initialized for locale:', locale); + } + + return instance; +} module.exports = function t(text, options = {}) { if (!text || text.length === 0) { @@ -26,5 +98,50 @@ module.exports = function t(text, options = {}) { } } - return themeI18n.t(text, bindings); + if (labs.isSet('themeTranslation')) { + // Use the new translation package when feature flag is enabled + const locale = options.data?.root?.locale || defaultLocale; + + // Get the instance, ensuring it's initialized + let instance = i18nInstances.get(locale); + let usingDefault = false; + + if (!instance || initState.get(locale) !== 'initialized') { + console.log('Locale not initialized:', locale, '- using default locale:', defaultLocale); + instance = i18nInstances.get(defaultLocale); + usingDefault = true; + + // Start initialization in the background if needed + if (!i18nInstances.get(locale)) { + console.log('Starting initialization for locale:', locale); + ensureInitialized(locale); + } + } else { + console.log('Found initialized instance for locale:', locale); + } + + try { + const result = instance.t(text, bindings); + return result; + } catch (err) { + // If translation fails, try the default locale as a last resort + if (!usingDefault) { + return i18nInstances.get(defaultLocale).t(text, bindings); + } + // If we're already using the default locale, return the original text + return text; + } + } else { + // Use the existing translation package when feature flag is disabled + + // Initialize only if needed + if (!themeI18n._strings) { + themeI18n.init({ + activeTheme: settingsCache.get('active_theme'), + locale: defaultLocale + }); + } + + return themeI18n.t(text, bindings); + } }; diff --git a/ghost/core/core/frontend/services/handlebars.js b/ghost/core/core/frontend/services/handlebars.js index 03adf443ffa..a9ceac959af 100644 --- a/ghost/core/core/frontend/services/handlebars.js +++ b/ghost/core/core/frontend/services/handlebars.js @@ -19,7 +19,7 @@ module.exports = { // Theme i18n // @TODO: this should live somewhere else... themeI18n: require('./theme-engine/i18n'), - + themeI18next: require('./theme-engine/i18next'), // TODO: these need a more sensible home localUtils: require('./theme-engine/handlebars/utils') }; diff --git a/ghost/core/core/frontend/services/theme-engine/active.js b/ghost/core/core/frontend/services/theme-engine/active.js index 34fa33bc867..aea2becc395 100644 --- a/ghost/core/core/frontend/services/theme-engine/active.js +++ b/ghost/core/core/frontend/services/theme-engine/active.js @@ -18,6 +18,8 @@ const themeConfig = require('./config'); const config = require('../../../shared/config'); const engine = require('./engine'); const themeI18n = require('./i18n'); +const themeI18next = require('./i18next'); +const labs = require('../../../shared/labs'); // Current instance of ActiveTheme let currentActiveTheme; @@ -101,7 +103,13 @@ class ActiveTheme { options.activeTheme = options.activeTheme || this._name; options.locale = options.locale || this._locale; - themeI18n.init(options); + if (labs.isSet('themeTranslation')) { + // Initialize the new translation service + themeI18next.init(options); + } else { + // Initialize the legacy translation service + themeI18n.init(options); + } } mount(siteApp) { diff --git a/ghost/core/core/frontend/services/theme-engine/i18next/ThemeI18n.js b/ghost/core/core/frontend/services/theme-engine/i18next/ThemeI18n.js new file mode 100644 index 00000000000..ca1e294c33f --- /dev/null +++ b/ghost/core/core/frontend/services/theme-engine/i18next/ThemeI18n.js @@ -0,0 +1,103 @@ +const errors = require('@tryghost/errors'); +const i18nLib = require('@tryghost/i18n'); +const path = require('path'); +const fs = require('fs-extra'); + +class ThemeI18n { + /** + * @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) { + 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; + } + + /** + * Setup i18n support for themes: + * - 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 + */ + async init(options) { + if (!options || !options.activeTheme) { + throw new errors.IncorrectUsageError({message: 'activeTheme is required'}); + } + + this._locale = options.locale || this._locale; + this._activeTheme = options.activeTheme; + + const themeLocalesPath = path.join(this._basePath, this._activeTheme, 'locales'); + + // Check if the theme path exists + const themePathExists = await fs.pathExists(themeLocalesPath); + + if (!themePathExists) { + // If the theme path doesn't exist, use the key as the translation + this._i18n = { + t: key => key + }; + return; + } + + // 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('en', 'theme', {themePath: themeLocalesPath}); + } catch (enErr) { + // If both fail, use the key as the translation + this._i18n = { + t: 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); + } +} + +module.exports = ThemeI18n; \ No newline at end of file diff --git a/ghost/core/core/frontend/services/theme-engine/i18next/index.js b/ghost/core/core/frontend/services/theme-engine/i18next/index.js new file mode 100644 index 00000000000..a1cd3b07041 --- /dev/null +++ b/ghost/core/core/frontend/services/theme-engine/i18next/index.js @@ -0,0 +1,6 @@ +const config = require('../../../../shared/config'); + +const ThemeI18n = require('./ThemeI18n'); + +module.exports = new ThemeI18n({basePath: config.getContentPath('themes')}); +module.exports.ThemeI18n = ThemeI18n; diff --git a/ghost/core/core/server/web/parent/app.js b/ghost/core/core/server/web/parent/app.js index 289eb44eac8..9e362ac0f4c 100644 --- a/ghost/core/core/server/web/parent/app.js +++ b/ghost/core/core/server/web/parent/app.js @@ -13,7 +13,7 @@ module.exports = function setupParentApp() { parentApp.use(mw.requestId); parentApp.use(mw.logRequest); - + parentApp.use(mw.localeFromUrl); // Register event emitter on req/res to trigger cache invalidation webhook event parentApp.use(mw.emitEvents); diff --git a/ghost/core/core/server/web/parent/middleware/index.js b/ghost/core/core/server/web/parent/middleware/index.js index 2717886c9c4..03c4b7b3d21 100644 --- a/ghost/core/core/server/web/parent/middleware/index.js +++ b/ghost/core/core/server/web/parent/middleware/index.js @@ -3,5 +3,6 @@ module.exports = { ghostLocals: require('./ghost-locals'), logRequest: require('./log-request'), queueRequest: require('./queue-request'), - requestId: require('./request-id') + requestId: require('./request-id'), + localeFromUrl: require('./locale-from-url') }; diff --git a/ghost/core/core/server/web/parent/middleware/locale-from-url.js b/ghost/core/core/server/web/parent/middleware/locale-from-url.js new file mode 100644 index 00000000000..b737f9c91da --- /dev/null +++ b/ghost/core/core/server/web/parent/middleware/locale-from-url.js @@ -0,0 +1,15 @@ +/** + * Middleware to extract locale from the URL prefix (e.g., /en/about) + * Sets res.locals.locale and strips the prefix from req.url for downstream routing. + */ +module.exports = function localeFromUrl(req, res, next) { + const match = req.path.match(/^\/([a-z]{2})(\/|$)/); + if (match) { + res.locals.locale = match[1]; + // Remove the locale prefix for downstream routing + req.url = req.url.replace(/^\/[a-z]{2}/, '') || '/'; + } else { + // no locale detected, not setting a default here so that we can use the site's configured locale + } + next(); +}; \ No newline at end of file diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index de5bbc09229..f6ad436aa60 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -34,7 +34,8 @@ const PUBLIC_BETA_FEATURES = [ 'ActivityPub', 'superEditors', 'editorExcerpt', - 'additionalPaymentMethods' + 'additionalPaymentMethods', + 'themeTranslation' ]; // These features are considered private they live in the private tab of the labs settings page diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index aeaf83bde41..1bc30666771 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -28,6 +28,7 @@ Object { "stripeAutomaticTax": true, "superEditors": true, "themeErrorsNotification": true, + "themeTranslation": true, "trafficAnalytics": true, "trafficAnalyticsAlpha": true, "urlCache": true, diff --git a/ghost/core/test/unit/frontend/helpers/t-new.test.js b/ghost/core/test/unit/frontend/helpers/t-new.test.js new file mode 100644 index 00000000000..367b9378dd8 --- /dev/null +++ b/ghost/core/test/unit/frontend/helpers/t-new.test.js @@ -0,0 +1,89 @@ +const should = require('should'); +const path = require('path'); +const sinon = require('sinon'); +const t = require('../../../../core/frontend/helpers/t'); +const themeI18next = require('../../../../core/frontend/services/theme-engine/i18next'); +const labs = require('../../../../core/shared/labs'); + +describe('NEW{{t}} helper', function () { + let ogBasePath = themeI18next.basePath; + + before(function () { + sinon.stub(labs, 'isSet').withArgs('themeTranslation').returns(true); + themeI18next.basePath = path.join(__dirname, '../../../utils/fixtures/themes/'); + }); + + after(function () { + sinon.restore(); + themeI18next.basePath = ogBasePath; + }); + + beforeEach(async function () { + // Reset the i18n instance before each test + themeI18next._i18n = null; + }); + + it('theme translation is DE', async function () { + await themeI18next.init({activeTheme: 'locale-theme', locale: 'de'}); + + let rendered = t.call({}, 'Top left Button', { + hash: {} + }); + + rendered.should.eql('Oben Links.'); + }); + + it('theme translation is EN', async function () { + await themeI18next.init({activeTheme: 'locale-theme', locale: 'en'}); + + let rendered = t.call({}, 'Top left Button', { + hash: {} + }); + + rendered.should.eql('Left Button on Top'); + }); + + it('[fallback] no theme translation file found for FR', async function () { + await themeI18next.init({activeTheme: 'locale-theme', locale: 'fr'}); + + let rendered = t.call({}, 'Top left Button', { + hash: {} + }); + + rendered.should.eql('Left Button on Top'); + }); + + it('[fallback] no theme files at all, use key as translation', async function () { + await themeI18next.init({activeTheme: 'locale-theme-1.4', locale: 'de'}); + + let rendered = t.call({}, 'Top left Button', { + hash: {} + }); + + rendered.should.eql('Top left Button'); + }); + + it('returns an empty string if translation key is an empty string', function () { + let rendered = t.call({}, '', { + hash: {} + }); + + rendered.should.eql(''); + }); + + it('returns an empty string if translation key is missing', function () { + let rendered = t.call({}, undefined, { + hash: {} + }); + + rendered.should.eql(''); + }); + + it('returns a translated string even if no options are passed', async function () { + await themeI18next.init({activeTheme: 'locale-theme', locale: 'en'}); + + let rendered = t.call({}, 'Top left Button'); + + rendered.should.eql('Left Button on Top'); + }); +}); \ No newline at end of file diff --git a/ghost/core/test/unit/frontend/services/theme-engine/i18next.test.js b/ghost/core/test/unit/frontend/services/theme-engine/i18next.test.js new file mode 100644 index 00000000000..95b2ebfe654 --- /dev/null +++ b/ghost/core/test/unit/frontend/services/theme-engine/i18next.test.js @@ -0,0 +1,62 @@ +const should = require('should'); +const sinon = require('sinon'); +const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18next/ThemeI18n'); +const path = require('path'); + +describe('NEW i18nextThemeI18n Class behavior', function () { + let i18n; + const testBasePath = path.join(__dirname, '../../../../utils/fixtures/themes/'); + + beforeEach(async function () { + i18n = new ThemeI18n({basePath: testBasePath}); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('defaults to en', function () { + i18n._locale.should.eql('en'); + }); + + 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.'); + }); + + 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'); + }); + + 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('returns empty string for empty key', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'en'}); + const result = i18n.t(''); + result.should.eql(''); + }); + + 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', async function () { + await i18n.init({activeTheme: 'locale-theme', locale: 'en'}); + const result = i18n.t('unknown string'); + result.should.eql('unknown string'); + }); +}); \ No newline at end of file diff --git a/ghost/i18n/lib/i18n.js b/ghost/i18n/lib/i18n.js index 57fbc24c709..47d1f49464a 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'] @@ -127,4 +181,4 @@ module.exports = (lng = 'en', ns = 'portal') => { }; module.exports.SUPPORTED_LOCALES = SUPPORTED_LOCALES; -module.exports.generateResources = generateResources; +module.exports.generateResources = generateResources; \ No newline at end of file diff --git a/ghost/i18n/test/i18n.test.js b/ghost/i18n/test/i18n.test.js index 83ce3dbcf8f..a4bf91ce3ba 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,210 @@ describe('i18n', function () { assert.deepEqual(resources.xx, englishResources.en); }); }); + + // i18n theme translations when feature flag is enabled + 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, {}); + }); + }); }); +