Merge pull request #33149 from overleaf/ar-ja-remove-i18next-additional-packages

[web] remove i18next additional libraries

GitOrigin-RevId: 98fc17b409090db32b02bb66953f1c2e6efee608
This commit is contained in:
Andrew Rumble
2026-05-05 09:22:21 +01:00
committed by Copybot
parent 6a6bb625db
commit f434b1fc28
6 changed files with 108 additions and 82 deletions

View File

@@ -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) {

View File

@@ -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,
}

View File

@@ -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",

View File

@@ -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 () {

View File

@@ -19,5 +19,12 @@ declare module 'express' {
userRestrictions?: Set
oauth_user?: OAuth2Server.User
logger: RequestLogger
i18n: {
translate(
key: string,
vars?: Record<string, any>,
components?: any
): string
}
}
}