Skip to content

🌵 [SPIKE] Can we use url paths to choose a locale? #23163

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

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 1 addition & 1 deletion ghost/core/core/frontend/helpers/date.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ module.exports = function (...attrs) {
format = 'll',
timeago,
timezone = options.data.site.timezone,
locale = options.data.site.locale
locale = options.data?.root?.locale || options.data.site.locale
} = options.hash;

const timeNow = moment().tz(timezone);
Expand Down
9 changes: 6 additions & 3 deletions ghost/core/core/frontend/helpers/ghost_head.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ function getMembersHelper(data, frontendKey, excludeList) {
return membersHelper;
}

function getSearchHelper(frontendKey) {
function getSearchHelper(frontendKey, locale) {
const adminUrl = urlUtils.getAdminUrl() || urlUtils.getSiteUrl();
const {scriptUrl, stylesUrl} = getFrontendAppConfig('sodoSearch');

Expand All @@ -95,7 +95,7 @@ function getSearchHelper(frontendKey) {
key: frontendKey,
styles: stylesUrl,
'sodo-search': adminUrl,
locale: labs.isSet('i18n') ? (settingsCache.get('locale') || 'en') : undefined
locale: labs.isSet('i18n') ? (locale || settingsCache.get('locale') || 'en') : undefined
};
const dataAttrs = getDataAttributes(attrs);
let helper = `<script defer src="${scriptUrl}" ${dataAttrs} crossorigin="anonymous"></script>`;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand Down
119 changes: 118 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,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) {
Expand All @@ -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);
}
};
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
103 changes: 103 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,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;
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;
2 changes: 1 addition & 1 deletion ghost/core/core/server/web/parent/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion ghost/core/core/server/web/parent/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
};
15 changes: 15 additions & 0 deletions ghost/core/core/server/web/parent/middleware/locale-from-url.js
Original file line number Diff line number Diff line change
@@ -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();
};
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
Loading
Loading