From f434b1fc28da89b2c7af7a58567eabb528b94e4f Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Tue, 5 May 2026 09:22:21 +0100 Subject: [PATCH] Merge pull request #33149 from overleaf/ar-ja-remove-i18next-additional-packages [web] remove i18next additional libraries GitOrigin-RevId: 98fc17b409090db32b02bb66953f1c2e6efee608 --- .../web/app/src/infrastructure/Server.mjs | 1 - .../app/src/infrastructure/Translations.mjs | 137 +++++++++++------- services/web/package.json | 2 - .../src/infrastructure/Translations.test.mjs | 27 ++-- .../web/types/backend/express/request.d.ts | 7 + yarn.lock | 16 -- 6 files changed, 108 insertions(+), 82 deletions(-) diff --git a/services/web/app/src/infrastructure/Server.mjs b/services/web/app/src/infrastructure/Server.mjs index 2da657f4a3..4eeec8bcb0 100644 --- a/services/web/app/src/infrastructure/Server.mjs +++ b/services/web/app/src/infrastructure/Server.mjs @@ -234,7 +234,6 @@ await Modules.applyNonCsrfRouter(webRouter, privateApiRouter, publicApiRouter) webRouter.csrf = new CsrfClass() webRouter.use(webRouter.csrf.middleware) -webRouter.use(translations.i18nMiddleware) webRouter.use(translations.setLangBasedOnDomainMiddleware) if (Settings.cookieRollingSession) { diff --git a/services/web/app/src/infrastructure/Translations.mjs b/services/web/app/src/infrastructure/Translations.mjs index aca7bd299e..d6e01910a3 100644 --- a/services/web/app/src/infrastructure/Translations.mjs +++ b/services/web/app/src/infrastructure/Translations.mjs @@ -1,12 +1,48 @@ import i18n from 'i18next' -import fsBackend from 'i18next-fs-backend' -import middleware from 'i18next-http-middleware' -import path from 'node:path' import Settings from '@overleaf/settings' import { URL } from 'node:url' import pug from 'pug-runtime' import logger from '@overleaf/logger' import SafeHTMLSubstitution from '../Features/Helpers/SafeHTMLSubstitution.mjs' +import cs from '../../../locales/cs.json' with { type: 'json' } +import da from '../../../locales/da.json' with { type: 'json' } +import de from '../../../locales/de.json' with { type: 'json' } +import en from '../../../locales/en.json' with { type: 'json' } +import es from '../../../locales/es.json' with { type: 'json' } +import fi from '../../../locales/fi.json' with { type: 'json' } +import fr from '../../../locales/fr.json' with { type: 'json' } +import it from '../../../locales/it.json' with { type: 'json' } +import ja from '../../../locales/ja.json' with { type: 'json' } +import ko from '../../../locales/ko.json' with { type: 'json' } +import nl from '../../../locales/nl.json' with { type: 'json' } +import no from '../../../locales/no.json' with { type: 'json' } +import pl from '../../../locales/pl.json' with { type: 'json' } +import pt from '../../../locales/pt.json' with { type: 'json' } +import ru from '../../../locales/ru.json' with { type: 'json' } +import sv from '../../../locales/sv.json' with { type: 'json' } +import tr from '../../../locales/tr.json' with { type: 'json' } +import zhCN from '../../../locales/zh-CN.json' with { type: 'json' } + +const locales = { + cs, + da, + de, + en, + es, + fi, + fr, + it, + ja, + ko, + nl, + no, + pl, + pt, + ru, + sv, + tr, + 'zh-CN': zhCN, +} const fallbackLanguageCode = Settings.i18n.defaultLng || 'en' const availableLanguageCodes = [] @@ -31,28 +67,19 @@ if (!availableLanguageCodes.includes(fallbackLanguageCode)) { availableLanguageCodes.push(fallbackLanguageCode) } -// The "node --watch" flag is not easy to detect. -if (process.argv.includes('--watch-locales')) { - // Dummy imports for setting up watching of locales files. - for (const lngCode of availableLanguageCodes) { - await import(`../../../locales/${lngCode}.json`, { with: { type: 'json' } }) - } -} +const resources = Object.fromEntries( + Object.entries(locales) + .filter(([lngCode]) => availableLanguageCodes.includes(lngCode)) + .map(([lngCode, translations]) => [lngCode, { translation: translations }]) +) i18n - .use(fsBackend) - .use(middleware.LanguageDetector) .init({ - backend: { - loadPath: path.join(import.meta.dirname, '../../../locales/__lng__.json'), - }, + resources, // still using the v3 plural suffixes compatibilityJSON: 'v3', - // Load translation files synchronously: https://www.i18next.com/overview/configuration-options#initimmediate - initImmediate: false, - // We use the legacy v1 JSON format, so configure interpolator to use // underscores instead of curly braces interpolation: { @@ -72,7 +99,6 @@ i18n }, }, - preload: availableLanguageCodes, supportedLngs: availableLanguageCodes, fallbackLng: fallbackLanguageCode, }) @@ -80,25 +106,26 @@ i18n logger.error({ err }, 'failed to initialize i18next library') }) -// Make custom language detector for Accept-Language header -const headerLangDetector = new middleware.LanguageDetector(i18n.services, { - order: ['header'], -}) - function setLangBasedOnDomainMiddleware(req, res, next) { // Determine language from subdomain - const lang = availableHosts.get(req.headers.host) - if (lang) { - req.i18n.changeLanguage(lang) + const lang = availableHosts.get(req.headers.host) ?? fallbackLanguageCode + + req.i18n = { + language: lang, } - // expose the language code to pug - res.locals.currentLngCode = req.language + req.language = + req.locale = + req.lng = + res.locals.currentLngCode = + res.locals.language = + lang // If the set language is different from the language detection (based on // the Accept-Language header), then set flag which will show a banner // offering to switch to the appropriate library - const detectedLanguageCode = headerLangDetector.detect(req, res) + const detectedLanguageCode = + req.acceptsLanguage(availableLanguageCodes) || fallbackLanguageCode if (req.language !== detectedLanguageCode) { res.locals.suggestedLanguageSubdomainConfig = subdomainConfigs.get(detectedLanguageCode) @@ -106,32 +133,35 @@ function setLangBasedOnDomainMiddleware(req, res, next) { // Decorate req.i18n with translate function alias for backwards // compatibility usage in requests - req.i18n.translate = (key, vars, components) => { - vars = vars || {} + req.i18n.translate = + res.locals.t = + req.i18n.t = + (key, vars, components) => { + vars = { lng: lang, ...(vars ?? {}) } - if (Settings.i18n.checkForHTMLInVars) { - Object.entries(vars).forEach(([field, value]) => { - if (pug.escape(value) !== value) { - const violationsKey = key + field - // do not flood the logs, log one sample per pod + key + field - if (!I18N_HTML_INJECTIONS.has(violationsKey)) { - logger.warn( - { key, field, value }, - 'html content in translations context vars' - ) - I18N_HTML_INJECTIONS.add(violationsKey) - } + if (Settings.i18n.checkForHTMLInVars) { + Object.entries(vars).forEach(([field, value]) => { + if (pug.escape(value) !== value) { + const violationsKey = key + field + // do not flood the logs, log one sample per pod + key + field + if (!I18N_HTML_INJECTIONS.has(violationsKey)) { + logger.warn( + { key, field, value }, + 'html content in translations context vars' + ) + I18N_HTML_INJECTIONS.add(violationsKey) + } + } + }) } - }) - } - const locale = req.i18n.t(key, vars) - if (components) { - return SafeHTMLSubstitution.render(locale, components) - } else { - return locale - } - } + const locale = i18n.t(key, vars) + if (components) { + return SafeHTMLSubstitution.render(locale, components) + } else { + return locale + } + } next() } @@ -141,7 +171,6 @@ function setLangBasedOnDomainMiddleware(req, res, next) { i18n.translate = i18n.t export default { - i18nMiddleware: middleware.handle(i18n), setLangBasedOnDomainMiddleware, i18n, } diff --git a/services/web/package.json b/services/web/package.json index 1cb392da50..daa38813dc 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -141,8 +141,6 @@ "helmet": "^6.0.1", "https-proxy-agent": "^7.0.6", "i18next": "^23.10.0", - "i18next-fs-backend": "2.6.1", - "i18next-http-middleware": "^3.5.0", "jose": "^4.3.8", "json2csv": "^4.3.3", "jsonwebtoken": "^9.0.3", diff --git a/services/web/test/unit/src/infrastructure/Translations.test.mjs b/services/web/test/unit/src/infrastructure/Translations.test.mjs index b13b319a76..bf8f2df0ee 100644 --- a/services/web/test/unit/src/infrastructure/Translations.test.mjs +++ b/services/web/test/unit/src/infrastructure/Translations.test.mjs @@ -1,19 +1,18 @@ import { describe, expect, it, vi } from 'vitest' +import express from 'express' const MODULE_PATH = '../../../../app/src/infrastructure/Translations.mjs' describe('Translations', function () { let req, res, translations - async function runMiddlewares(cb) { + async function runMiddlewares() { return await new Promise((resolve, reject) => - translations.i18nMiddleware(req, res, () => { - translations.setLangBasedOnDomainMiddleware(req, res, (err, result) => { - if (err) { - reject(err) - } else { - resolve(result) - } - }) + translations.setLangBasedOnDomainMiddleware(req, res, (err, result) => { + if (err) { + reject(err) + } else { + resolve(result) + } }) ) } @@ -39,6 +38,7 @@ describe('Translations', function () { headers: { 'accept-language': '', }, + acceptsLanguage: express.request.acceptsLanguages, } res = { locals: {}, @@ -59,6 +59,15 @@ describe('Translations', function () { it('has translate alias', function () { expect(req.i18n.translate('give_feedback')).to.equal('Give feedback') }) + + it('does not persist across different languages', function () { + expect([ + req.i18n.translate('log_in', { lng: 'fr' }), + req.i18n.translate('log_in', { lng: 'en' }), + req.i18n.translate('log_in', { lng: 'da' }), + req.i18n.translate('log_in'), + ]).to.deep.equal(['Se connecter', 'Log in', 'Log ind', 'Log in']) + }) }) describe('interpolation', function () { diff --git a/services/web/types/backend/express/request.d.ts b/services/web/types/backend/express/request.d.ts index 42b4524750..69d23d581a 100644 --- a/services/web/types/backend/express/request.d.ts +++ b/services/web/types/backend/express/request.d.ts @@ -19,5 +19,12 @@ declare module 'express' { userRestrictions?: Set oauth_user?: OAuth2Server.User logger: RequestLogger + i18n: { + translate( + key: string, + vars?: Record, + components?: any + ): string + } } } diff --git a/yarn.lock b/yarn.lock index 87e34425d0..e35ecfe591 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7371,8 +7371,6 @@ __metadata: html-webpack-plugin: "npm:^5.5.3" https-proxy-agent: "npm:^7.0.6" i18next: "npm:^23.10.0" - i18next-fs-backend: "npm:2.6.1" - i18next-http-middleware: "npm:^3.5.0" i18next-scanner: "npm:4.4.0" idb: "npm:^8.0.0" inversify: "npm:^6.2.2" @@ -20737,20 +20735,6 @@ __metadata: languageName: node linkType: hard -"i18next-fs-backend@npm:2.6.1": - version: 2.6.1 - resolution: "i18next-fs-backend@npm:2.6.1" - checksum: 10c0/9751745d40a9f5e57fd6144129dba11f120372f5dd9e906d025c3f96c569d65aeeddfd1a36cdfcfe7acda9c624d80ef9610917544485174a363793ca00cf8636 - languageName: node - linkType: hard - -"i18next-http-middleware@npm:3.5.0": - version: 3.5.0 - resolution: "i18next-http-middleware@npm:3.5.0" - checksum: 10c0/fc3cb67c6984c0eb29c7456ecf320230eb361534f7d58983f514e66f14addbf082d5aa7af04b4c0bbad45b696faf4709aac64840fcd5db02038f5870b7285ea9 - languageName: node - linkType: hard - "i18next-scanner@npm:4.4.0": version: 4.4.0 resolution: "i18next-scanner@npm:4.4.0"