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.csrf = new CsrfClass()
webRouter.use(webRouter.csrf.middleware) webRouter.use(webRouter.csrf.middleware)
webRouter.use(translations.i18nMiddleware)
webRouter.use(translations.setLangBasedOnDomainMiddleware) webRouter.use(translations.setLangBasedOnDomainMiddleware)
if (Settings.cookieRollingSession) { if (Settings.cookieRollingSession) {

View File

@@ -1,12 +1,48 @@
import i18n from 'i18next' 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 Settings from '@overleaf/settings'
import { URL } from 'node:url' import { URL } from 'node:url'
import pug from 'pug-runtime' import pug from 'pug-runtime'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
import SafeHTMLSubstitution from '../Features/Helpers/SafeHTMLSubstitution.mjs' 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 fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
const availableLanguageCodes = [] const availableLanguageCodes = []
@@ -31,28 +67,19 @@ if (!availableLanguageCodes.includes(fallbackLanguageCode)) {
availableLanguageCodes.push(fallbackLanguageCode) availableLanguageCodes.push(fallbackLanguageCode)
} }
// The "node --watch" flag is not easy to detect. const resources = Object.fromEntries(
if (process.argv.includes('--watch-locales')) { Object.entries(locales)
// Dummy imports for setting up watching of locales files. .filter(([lngCode]) => availableLanguageCodes.includes(lngCode))
for (const lngCode of availableLanguageCodes) { .map(([lngCode, translations]) => [lngCode, { translation: translations }])
await import(`../../../locales/${lngCode}.json`, { with: { type: 'json' } }) )
}
}
i18n i18n
.use(fsBackend)
.use(middleware.LanguageDetector)
.init({ .init({
backend: { resources,
loadPath: path.join(import.meta.dirname, '../../../locales/__lng__.json'),
},
// still using the v3 plural suffixes // still using the v3 plural suffixes
compatibilityJSON: 'v3', 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 // We use the legacy v1 JSON format, so configure interpolator to use
// underscores instead of curly braces // underscores instead of curly braces
interpolation: { interpolation: {
@@ -72,7 +99,6 @@ i18n
}, },
}, },
preload: availableLanguageCodes,
supportedLngs: availableLanguageCodes, supportedLngs: availableLanguageCodes,
fallbackLng: fallbackLanguageCode, fallbackLng: fallbackLanguageCode,
}) })
@@ -80,25 +106,26 @@ i18n
logger.error({ err }, 'failed to initialize i18next library') 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) { function setLangBasedOnDomainMiddleware(req, res, next) {
// Determine language from subdomain // Determine language from subdomain
const lang = availableHosts.get(req.headers.host) const lang = availableHosts.get(req.headers.host) ?? fallbackLanguageCode
if (lang) {
req.i18n.changeLanguage(lang) req.i18n = {
language: lang,
} }
// expose the language code to pug req.language =
res.locals.currentLngCode = 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 // If the set language is different from the language detection (based on
// the Accept-Language header), then set flag which will show a banner // the Accept-Language header), then set flag which will show a banner
// offering to switch to the appropriate library // offering to switch to the appropriate library
const detectedLanguageCode = headerLangDetector.detect(req, res) const detectedLanguageCode =
req.acceptsLanguage(availableLanguageCodes) || fallbackLanguageCode
if (req.language !== detectedLanguageCode) { if (req.language !== detectedLanguageCode) {
res.locals.suggestedLanguageSubdomainConfig = res.locals.suggestedLanguageSubdomainConfig =
subdomainConfigs.get(detectedLanguageCode) subdomainConfigs.get(detectedLanguageCode)
@@ -106,32 +133,35 @@ function setLangBasedOnDomainMiddleware(req, res, next) {
// Decorate req.i18n with translate function alias for backwards // Decorate req.i18n with translate function alias for backwards
// compatibility usage in requests // compatibility usage in requests
req.i18n.translate = (key, vars, components) => { req.i18n.translate =
vars = vars || {} res.locals.t =
req.i18n.t =
(key, vars, components) => {
vars = { lng: lang, ...(vars ?? {}) }
if (Settings.i18n.checkForHTMLInVars) { if (Settings.i18n.checkForHTMLInVars) {
Object.entries(vars).forEach(([field, value]) => { Object.entries(vars).forEach(([field, value]) => {
if (pug.escape(value) !== value) { if (pug.escape(value) !== value) {
const violationsKey = key + field const violationsKey = key + field
// do not flood the logs, log one sample per pod + key + field // do not flood the logs, log one sample per pod + key + field
if (!I18N_HTML_INJECTIONS.has(violationsKey)) { if (!I18N_HTML_INJECTIONS.has(violationsKey)) {
logger.warn( logger.warn(
{ key, field, value }, { key, field, value },
'html content in translations context vars' 'html content in translations context vars'
) )
I18N_HTML_INJECTIONS.add(violationsKey) I18N_HTML_INJECTIONS.add(violationsKey)
} }
}
})
} }
})
}
const locale = req.i18n.t(key, vars) const locale = i18n.t(key, vars)
if (components) { if (components) {
return SafeHTMLSubstitution.render(locale, components) return SafeHTMLSubstitution.render(locale, components)
} else { } else {
return locale return locale
} }
} }
next() next()
} }
@@ -141,7 +171,6 @@ function setLangBasedOnDomainMiddleware(req, res, next) {
i18n.translate = i18n.t i18n.translate = i18n.t
export default { export default {
i18nMiddleware: middleware.handle(i18n),
setLangBasedOnDomainMiddleware, setLangBasedOnDomainMiddleware,
i18n, i18n,
} }

View File

@@ -141,8 +141,6 @@
"helmet": "^6.0.1", "helmet": "^6.0.1",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"i18next": "^23.10.0", "i18next": "^23.10.0",
"i18next-fs-backend": "2.6.1",
"i18next-http-middleware": "^3.5.0",
"jose": "^4.3.8", "jose": "^4.3.8",
"json2csv": "^4.3.3", "json2csv": "^4.3.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",

View File

@@ -1,19 +1,18 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import express from 'express'
const MODULE_PATH = '../../../../app/src/infrastructure/Translations.mjs' const MODULE_PATH = '../../../../app/src/infrastructure/Translations.mjs'
describe('Translations', function () { describe('Translations', function () {
let req, res, translations let req, res, translations
async function runMiddlewares(cb) { async function runMiddlewares() {
return await new Promise((resolve, reject) => return await new Promise((resolve, reject) =>
translations.i18nMiddleware(req, res, () => { translations.setLangBasedOnDomainMiddleware(req, res, (err, result) => {
translations.setLangBasedOnDomainMiddleware(req, res, (err, result) => { if (err) {
if (err) { reject(err)
reject(err) } else {
} else { resolve(result)
resolve(result) }
}
})
}) })
) )
} }
@@ -39,6 +38,7 @@ describe('Translations', function () {
headers: { headers: {
'accept-language': '', 'accept-language': '',
}, },
acceptsLanguage: express.request.acceptsLanguages,
} }
res = { res = {
locals: {}, locals: {},
@@ -59,6 +59,15 @@ describe('Translations', function () {
it('has translate alias', function () { it('has translate alias', function () {
expect(req.i18n.translate('give_feedback')).to.equal('Give feedback') 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 () { describe('interpolation', function () {

View File

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

View File

@@ -7371,8 +7371,6 @@ __metadata:
html-webpack-plugin: "npm:^5.5.3" html-webpack-plugin: "npm:^5.5.3"
https-proxy-agent: "npm:^7.0.6" https-proxy-agent: "npm:^7.0.6"
i18next: "npm:^23.10.0" 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" i18next-scanner: "npm:4.4.0"
idb: "npm:^8.0.0" idb: "npm:^8.0.0"
inversify: "npm:^6.2.2" inversify: "npm:^6.2.2"
@@ -20737,20 +20735,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "i18next-scanner@npm:4.4.0":
version: 4.4.0 version: 4.4.0
resolution: "i18next-scanner@npm:4.4.0" resolution: "i18next-scanner@npm:4.4.0"