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, {});
+ });
+ });
});
+