Skip to content

✨ Theme i18n converted to use i18next (with feature flag) #23161

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const BetaFeatures: React.FC = () => {
action={<FeatureToggle flag="additionalPaymentMethods" />}
detail={<>Enable support for CashApp, iDEAL, Bancontact, and others. <a className='text-green' href="https://ghost.org/help/payment-methods" rel="noopener noreferrer" target="_blank">Learn more &rarr;</a></>}
title='Additional payment methods' />
<LabItem
action={<FeatureToggle flag="themeTranslation" />}
detail={<>Enable theme translation using i18next instead of the old translation package.</>}
title='Updated theme Translation (beta)' />
<LabItem
action={<div className='flex flex-col items-end gap-1'>
<FileUpload
Expand Down
30 changes: 29 additions & 1 deletion ghost/core/core/frontend/helpers/t.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
// {{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');

module.exports = function t(text, options = {}) {
if (!text || text.length === 0) {
Expand All @@ -26,5 +30,29 @@ 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

// Initialize only if needed
if (!themeI18next._i18n) {
themeI18next.init({
activeTheme: settingsCache.get('active_theme'),
locale: config.get('locale')
});
}

return themeI18next.t(text, bindings);
} 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: config.get('locale')
});
}

return themeI18n.t(text, bindings);
}
};
2 changes: 1 addition & 1 deletion ghost/core/core/frontend/services/handlebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
};
10 changes: 9 additions & 1 deletion ghost/core/core/frontend/services/theme-engine/active.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
104 changes: 104 additions & 0 deletions ghost/core/core/frontend/services/theme-engine/i18next/ThemeI18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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
const localePath = path.join(themeLocalesPath, `${this._locale}.json`);
const localeExists = await fs.pathExists(localePath);

if (localeExists) {
this._i18n = i18nLib(this._locale, 'theme', {themePath: themeLocalesPath});
return;
}

// If the requested locale doesn't exist, try English as fallback
const enPath = path.join(themeLocalesPath, 'en.json');
const enExists = await fs.pathExists(enPath);

if (enExists) {
this._i18n = i18nLib('en', 'theme', {themePath: themeLocalesPath});
return;
}

// 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;
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion ghost/core/core/shared/labs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Object {
"stripeAutomaticTax": true,
"superEditors": true,
"themeErrorsNotification": true,
"themeTranslation": true,
"trafficAnalytics": true,
"trafficAnalyticsAlpha": true,
"urlCache": true,
Expand Down
89 changes: 89 additions & 0 deletions ghost/core/test/unit/frontend/helpers/t-new.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading