Skip to content

Commit 36b3e1c

Browse files
authored
Changed theme i18n to use i18next (#23161)
- i18n support was added to themes a _really_ long time ago, and at the time there weren't great libraries - Since then, i18n has been added to more parts of Ghost, but leveraging the awesome i18next library - This PR brings theme i18n into line with the rest of Ghost by swapping out the custom code with i18next - The change exists behind a labs flag, with duplicated logic to make it easier to clean up later
1 parent 585bef6 commit 36b3e1c

File tree

12 files changed

+569
-10
lines changed

12 files changed

+569
-10
lines changed

apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ const BetaFeatures: React.FC = () => {
7171
action={<FeatureToggle flag="additionalPaymentMethods" />}
7272
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></>}
7373
title='Additional payment methods' />
74+
<LabItem
75+
action={<FeatureToggle flag="themeTranslation" />}
76+
detail={<>Enable theme translation using i18next instead of the old translation package.</>}
77+
title='Updated theme Translation (beta)' />
7478
<LabItem
7579
action={<div className='flex flex-col items-end gap-1'>
7680
<FileUpload

ghost/core/core/frontend/helpers/t.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
// {{tags prefix=(t " on ")}}
1212

1313
const {themeI18n} = require('../services/handlebars');
14+
const {themeI18next} = require('../services/handlebars');
15+
const labs = require('../../shared/labs');
16+
const config = require('../../shared/config');
17+
const settingsCache = require('../../shared/settings-cache');
1418

1519
module.exports = function t(text, options = {}) {
1620
if (!text || text.length === 0) {
@@ -26,5 +30,29 @@ module.exports = function t(text, options = {}) {
2630
}
2731
}
2832

29-
return themeI18n.t(text, bindings);
33+
if (labs.isSet('themeTranslation')) {
34+
// Use the new translation package when feature flag is enabled
35+
36+
// Initialize only if needed
37+
if (!themeI18next._i18n) {
38+
themeI18next.init({
39+
activeTheme: settingsCache.get('active_theme'),
40+
locale: config.get('locale')
41+
});
42+
}
43+
44+
return themeI18next.t(text, bindings);
45+
} else {
46+
// Use the existing translation package when feature flag is disabled
47+
48+
// Initialize only if needed
49+
if (!themeI18n._strings) {
50+
themeI18n.init({
51+
activeTheme: settingsCache.get('active_theme'),
52+
locale: config.get('locale')
53+
});
54+
}
55+
56+
return themeI18n.t(text, bindings);
57+
}
3058
};

ghost/core/core/frontend/services/handlebars.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ module.exports = {
1919
// Theme i18n
2020
// @TODO: this should live somewhere else...
2121
themeI18n: require('./theme-engine/i18n'),
22-
22+
themeI18next: require('./theme-engine/i18next'),
2323
// TODO: these need a more sensible home
2424
localUtils: require('./theme-engine/handlebars/utils')
2525
};

ghost/core/core/frontend/services/theme-engine/active.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const themeConfig = require('./config');
1818
const config = require('../../../shared/config');
1919
const engine = require('./engine');
2020
const themeI18n = require('./i18n');
21+
const themeI18next = require('./i18next');
22+
const labs = require('../../../shared/labs');
2123

2224
// Current instance of ActiveTheme
2325
let currentActiveTheme;
@@ -101,7 +103,13 @@ class ActiveTheme {
101103
options.activeTheme = options.activeTheme || this._name;
102104
options.locale = options.locale || this._locale;
103105

104-
themeI18n.init(options);
106+
if (labs.isSet('themeTranslation')) {
107+
// Initialize the new translation service
108+
themeI18next.init(options);
109+
} else {
110+
// Initialize the legacy translation service
111+
themeI18n.init(options);
112+
}
105113
}
106114

107115
mount(siteApp) {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const errors = require('@tryghost/errors');
2+
const i18nLib = require('@tryghost/i18n');
3+
const path = require('path');
4+
const fs = require('fs-extra');
5+
6+
class ThemeI18n {
7+
/**
8+
* @param {object} options
9+
* @param {string} options.basePath - the base path for the translation directory (e.g. where themes live)
10+
* @param {string} [options.locale] - a locale string
11+
*/
12+
constructor(options) {
13+
if (!options || !options.basePath) {
14+
throw new errors.IncorrectUsageError({message: 'basePath is required'});
15+
}
16+
this._basePath = options.basePath;
17+
this._locale = options.locale || 'en';
18+
this._activeTheme = null;
19+
this._i18n = null;
20+
}
21+
22+
/**
23+
* BasePath getter & setter used for testing
24+
*/
25+
set basePath(basePath) {
26+
this._basePath = basePath;
27+
}
28+
29+
get basePath() {
30+
return this._basePath;
31+
}
32+
33+
/**
34+
* Setup i18n support for themes:
35+
* - Load correct language file into memory
36+
*
37+
* @param {object} options
38+
* @param {string} options.activeTheme - name of the currently loaded theme
39+
* @param {string} options.locale - name of the currently loaded locale
40+
*/
41+
async init(options) {
42+
if (!options || !options.activeTheme) {
43+
throw new errors.IncorrectUsageError({message: 'activeTheme is required'});
44+
}
45+
46+
this._locale = options.locale || this._locale;
47+
this._activeTheme = options.activeTheme;
48+
49+
const themeLocalesPath = path.join(this._basePath, this._activeTheme, 'locales');
50+
51+
// Check if the theme path exists
52+
const themePathExists = await fs.pathExists(themeLocalesPath);
53+
54+
if (!themePathExists) {
55+
// If the theme path doesn't exist, use the key as the translation
56+
this._i18n = {
57+
t: key => key
58+
};
59+
return;
60+
}
61+
62+
// Initialize i18n with the theme path
63+
// Note: @tryghost/i18n uses synchronous file operations internally
64+
// This is fine in production but in tests we need to ensure the files exist first
65+
const localePath = path.join(themeLocalesPath, `${this._locale}.json`);
66+
const localeExists = await fs.pathExists(localePath);
67+
68+
if (localeExists) {
69+
this._i18n = i18nLib(this._locale, 'theme', {themePath: themeLocalesPath});
70+
return;
71+
}
72+
73+
// If the requested locale doesn't exist, try English as fallback
74+
const enPath = path.join(themeLocalesPath, 'en.json');
75+
const enExists = await fs.pathExists(enPath);
76+
77+
if (enExists) {
78+
this._i18n = i18nLib('en', 'theme', {themePath: themeLocalesPath});
79+
return;
80+
}
81+
82+
// If both fail, use the key as the translation
83+
this._i18n = {
84+
t: key => key
85+
};
86+
}
87+
88+
/**
89+
* Helper method to find and compile the given data context with a proper string resource.
90+
*
91+
* @param {string} key - The translation key
92+
* @param {object} [bindings] - Optional bindings for the translation
93+
* @returns {string}
94+
*/
95+
t(key, bindings) {
96+
if (!this._i18n) {
97+
throw new errors.IncorrectUsageError({message: `Theme translation was used before it was initialised with key ${key}`});
98+
}
99+
const result = this._i18n.t(key, bindings);
100+
return typeof result === 'string' ? result : String(result);
101+
}
102+
}
103+
104+
module.exports = ThemeI18n;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const config = require('../../../../shared/config');
2+
3+
const ThemeI18n = require('./ThemeI18n');
4+
5+
module.exports = new ThemeI18n({basePath: config.getContentPath('themes')});
6+
module.exports.ThemeI18n = ThemeI18n;

ghost/core/core/shared/labs.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ const PUBLIC_BETA_FEATURES = [
3434
'ActivityPub',
3535
'superEditors',
3636
'editorExcerpt',
37-
'additionalPaymentMethods'
37+
'additionalPaymentMethods',
38+
'themeTranslation'
3839
];
3940

4041
// These features are considered private they live in the private tab of the labs settings page

ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Object {
2727
"stripeAutomaticTax": true,
2828
"superEditors": true,
2929
"themeErrorsNotification": true,
30+
"themeTranslation": true,
3031
"trafficAnalytics": true,
3132
"ui60": true,
3233
"urlCache": true,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const should = require('should');
2+
const path = require('path');
3+
const sinon = require('sinon');
4+
const t = require('../../../../core/frontend/helpers/t');
5+
const themeI18next = require('../../../../core/frontend/services/theme-engine/i18next');
6+
const labs = require('../../../../core/shared/labs');
7+
8+
describe('NEW{{t}} helper', function () {
9+
let ogBasePath = themeI18next.basePath;
10+
11+
before(function () {
12+
sinon.stub(labs, 'isSet').withArgs('themeTranslation').returns(true);
13+
themeI18next.basePath = path.join(__dirname, '../../../utils/fixtures/themes/');
14+
});
15+
16+
after(function () {
17+
sinon.restore();
18+
themeI18next.basePath = ogBasePath;
19+
});
20+
21+
beforeEach(async function () {
22+
// Reset the i18n instance before each test
23+
themeI18next._i18n = null;
24+
});
25+
26+
it('theme translation is DE', async function () {
27+
await themeI18next.init({activeTheme: 'locale-theme', locale: 'de'});
28+
29+
let rendered = t.call({}, 'Top left Button', {
30+
hash: {}
31+
});
32+
33+
rendered.should.eql('Oben Links.');
34+
});
35+
36+
it('theme translation is EN', async function () {
37+
await themeI18next.init({activeTheme: 'locale-theme', locale: 'en'});
38+
39+
let rendered = t.call({}, 'Top left Button', {
40+
hash: {}
41+
});
42+
43+
rendered.should.eql('Left Button on Top');
44+
});
45+
46+
it('[fallback] no theme translation file found for FR', async function () {
47+
await themeI18next.init({activeTheme: 'locale-theme', locale: 'fr'});
48+
49+
let rendered = t.call({}, 'Top left Button', {
50+
hash: {}
51+
});
52+
53+
rendered.should.eql('Left Button on Top');
54+
});
55+
56+
it('[fallback] no theme files at all, use key as translation', async function () {
57+
await themeI18next.init({activeTheme: 'locale-theme-1.4', locale: 'de'});
58+
59+
let rendered = t.call({}, 'Top left Button', {
60+
hash: {}
61+
});
62+
63+
rendered.should.eql('Top left Button');
64+
});
65+
66+
it('returns an empty string if translation key is an empty string', function () {
67+
let rendered = t.call({}, '', {
68+
hash: {}
69+
});
70+
71+
rendered.should.eql('');
72+
});
73+
74+
it('returns an empty string if translation key is missing', function () {
75+
let rendered = t.call({}, undefined, {
76+
hash: {}
77+
});
78+
79+
rendered.should.eql('');
80+
});
81+
82+
it('returns a translated string even if no options are passed', async function () {
83+
await themeI18next.init({activeTheme: 'locale-theme', locale: 'en'});
84+
85+
let rendered = t.call({}, 'Top left Button');
86+
87+
rendered.should.eql('Left Button on Top');
88+
});
89+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const should = require('should');
2+
const sinon = require('sinon');
3+
const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18next/ThemeI18n');
4+
const path = require('path');
5+
6+
describe('NEW i18nextThemeI18n Class behavior', function () {
7+
let i18n;
8+
const testBasePath = path.join(__dirname, '../../../../utils/fixtures/themes/');
9+
10+
beforeEach(async function () {
11+
i18n = new ThemeI18n({basePath: testBasePath});
12+
});
13+
14+
afterEach(function () {
15+
sinon.restore();
16+
});
17+
18+
it('defaults to en', function () {
19+
i18n._locale.should.eql('en');
20+
});
21+
22+
it('can have a different locale set', async function () {
23+
await i18n.init({activeTheme: 'locale-theme', locale: 'fr'});
24+
i18n._locale.should.eql('fr');
25+
});
26+
27+
it('initializes with theme path', async function () {
28+
await i18n.init({activeTheme: 'locale-theme', locale: 'de'});
29+
const result = i18n.t('Top left Button');
30+
result.should.eql('Oben Links.');
31+
});
32+
33+
it('falls back to en when translation not found', async function () {
34+
await i18n.init({activeTheme: 'locale-theme', locale: 'fr'});
35+
const result = i18n.t('Top left Button');
36+
result.should.eql('Left Button on Top');
37+
});
38+
39+
it('uses key as fallback when no translation files exist', async function () {
40+
await i18n.init({activeTheme: 'locale-theme-1.4', locale: 'de'});
41+
const result = i18n.t('Top left Button');
42+
result.should.eql('Top left Button');
43+
});
44+
45+
it('returns empty string for empty key', async function () {
46+
await i18n.init({activeTheme: 'locale-theme', locale: 'en'});
47+
const result = i18n.t('');
48+
result.should.eql('');
49+
});
50+
51+
it('throws error if used before initialization', function () {
52+
should(function () {
53+
i18n.t('some key');
54+
}).throw('Theme translation was used before it was initialised with key some key');
55+
});
56+
57+
it('uses key fallback correctly', async function () {
58+
await i18n.init({activeTheme: 'locale-theme', locale: 'en'});
59+
const result = i18n.t('unknown string');
60+
result.should.eql('unknown string');
61+
});
62+
});

0 commit comments

Comments
 (0)