Merge pull request #3134 from overleaf/as-react-i18n

Load translations in the frontend using react-i18next

GitOrigin-RevId: 4e6ab1befcd783db2b3255bb4d04dc18e710a3dc
This commit is contained in:
Alasdair Smith 2020-09-03 10:23:34 +01:00 committed by Copybot
parent 473e9d32f2
commit 617fe024bc
12 changed files with 208 additions and 14 deletions

View file

@ -75,6 +75,11 @@ html(
}; };
window.ab = {}; window.ab = {};
window.user_id = '#{getLoggedInUserId()}'; window.user_id = '#{getLoggedInUserId()}';
//- Internationalisation settings
window.i18n = {
currentLangCode: '#{currentLngCode}'
}
//- Expose some settings globally to the frontend
window.ExposedSettings = JSON.parse('!{StringHelper.stringifyJsonForScript(ExposedSettings)}'); window.ExposedSettings = JSON.parse('!{StringHelper.stringifyJsonForScript(ExposedSettings)}');
- if (typeof(settings.algolia) != "undefined") - if (typeof(settings.algolia) != "undefined")

View file

@ -0,0 +1,6 @@
[
"file_outline",
"the_file_outline_is_a_new_feature_click_the_icon_to_learn_more",
"we_cant_find_any_sections_or_subsections_in_this_file",
"find_out_more_about_the_file_outline"
]

View file

@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import classNames from 'classnames' import classNames from 'classnames'
import { useTranslation, Trans } from 'react-i18next'
import OutlineRoot from './outline-root' import OutlineRoot from './outline-root'
import localStorage from '../../../modules/localStorage' import localStorage from '../../../modules/localStorage'
@ -14,6 +16,8 @@ function OutlinePane({
eventTracking, eventTracking,
highlightedLine highlightedLine
}) { }) {
const { t } = useTranslation()
const storageKey = `file_outline.expanded.${projectId}` const storageKey = `file_outline.expanded.${projectId}`
const [expanded, setExpanded] = useState(() => { const [expanded, setExpanded] = useState(() => {
const storedExpandedState = localStorage(storageKey) !== false const storedExpandedState = localStorage(storageKey) !== false
@ -45,6 +49,17 @@ function OutlinePane({
} }
} }
const infoContent = (
<>
<Trans
i18nKey="the_file_outline_is_a_new_feature_click_the_icon_to_learn_more"
components={[<strong />]}
/>
.
</>
)
const tooltip = <Tooltip id="outline-info-tooltip">{infoContent}</Tooltip>
return ( return (
<div className={headerClasses}> <div className={headerClasses}>
<header className="outline-header"> <header className="outline-header">
@ -54,7 +69,7 @@ function OutlinePane({
onClick={handleExpandCollapseClick} onClick={handleExpandCollapseClick}
> >
<i className={expandCollapseIconClasses} /> <i className={expandCollapseIconClasses} />
<h4 className="outline-header-name">File outline</h4> <h4 className="outline-header-name">{t('file_outline')}</h4>
{expanded ? ( {expanded ? (
<OverlayTrigger placement="top" overlay={tooltip} delayHide={100}> <OverlayTrigger placement="top" overlay={tooltip} delayHide={100}>
<a <a
@ -83,14 +98,6 @@ function OutlinePane({
) )
} }
const infoContent = (
<>
The <strong>File outline</strong> is a new feature. Click the icon to learn
more.
</>
)
const tooltip = <Tooltip id="outline-info-tooltip">{infoContent}</Tooltip>
OutlinePane.propTypes = { OutlinePane.propTypes = {
isTexFile: PropTypes.bool.isRequired, isTexFile: PropTypes.bool.isRequired,
outline: PropTypes.array.isRequired, outline: PropTypes.array.isRequired,

View file

@ -1,8 +1,12 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import OutlineList from './outline-list' import OutlineList from './outline-list'
function OutlineRoot({ outline, jumpToLine, highlightedLine }) { function OutlineRoot({ outline, jumpToLine, highlightedLine }) {
const { t } = useTranslation()
return ( return (
<div> <div>
{outline.length ? ( {outline.length ? (
@ -14,14 +18,14 @@ function OutlineRoot({ outline, jumpToLine, highlightedLine }) {
/> />
) : ( ) : (
<div className="outline-body-no-elements"> <div className="outline-body-no-elements">
We cant find any sections or subsections in this file.{' '} {t('we_cant_find_any_sections_or_subsections_in_this_file')}.{' '}
<a <a
href="/learn/how-to/Using_the_File_Outline_feature" href="/learn/how-to/Using_the_File_Outline_feature"
className="outline-body-link" className="outline-body-link"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Find out more about the file outline {t('find_out_more_about_the_file_outline')}
</a> </a>
</div> </div>
)} )}

View file

@ -0,0 +1,42 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
const LANG = window.i18n.currentLangCode
// Since we are rendering React from Angular, the initialisation is
// synchronous on page load (but hidden behind the loading screen). This
// means that translations must be initialised without any actual
// translations strings, and load those manually ourselves later
i18n.use(initReactI18next).init({
lng: LANG,
react: {
// Since we are manually waiting on the translations data to
// load, we don't need to use Suspense
useSuspense: false
},
interpolation: {
// We use the legacy v1 JSON format, so configure interpolator to use
// underscores instead of curly braces
prefix: '__',
suffix: '__',
unescapeSuffix: 'HTML',
// Disable nesting in interpolated values, preventing user input
// injection via another nested value
skipOnVariables: true
}
})
// The webpackChunkName here will name this chunk (and thus the requested
// script) according to the file name. See https://webpack.js.org/api/module-methods/#magic-comments
// for details
const localesPromise = import(/* webpackChunkName: "[request]" */ `../../locales/${LANG}.json`).then(
lang => {
i18n.addResourceBundle(LANG, 'translation', lang)
}
)
export default localesPromise

View file

@ -18,6 +18,7 @@
*/ */
import App from './base' import App from './base'
import FileTreeManager from './ide/file-tree/FileTreeManager' import FileTreeManager from './ide/file-tree/FileTreeManager'
import LoadingManager from './ide/LoadingManager'
import ConnectionManager from './ide/connection/ConnectionManager' import ConnectionManager from './ide/connection/ConnectionManager'
import EditorManager from './ide/editor/EditorManager' import EditorManager from './ide/editor/EditorManager'
import OnlineUsersManager from './ide/online-users/OnlineUsersManager' import OnlineUsersManager from './ide/online-users/OnlineUsersManager'
@ -180,6 +181,7 @@ App.controller('IdeController', function(
ide.$scope = $scope ide.$scope = $scope
ide.referencesSearchManager = new ReferencesManager(ide, $scope) ide.referencesSearchManager = new ReferencesManager(ide, $scope)
ide.loadingManager = new LoadingManager($scope)
ide.connectionManager = new ConnectionManager(ide, $scope) ide.connectionManager = new ConnectionManager(ide, $scope)
ide.fileTreeManager = new FileTreeManager(ide, $scope) ide.fileTreeManager = new FileTreeManager(ide, $scope)
ide.editorManager = new EditorManager(ide, $scope, localStorage) ide.editorManager = new EditorManager(ide, $scope, localStorage)

View file

@ -0,0 +1,36 @@
import i18n from '../i18n'
// Control the editor loading screen. We want to show the loading screen until
// both the websocket connection has been established (so that the editor is in
// the correct state) and the translations have been loaded (so we don't see a
// flash of untranslated text).
class LoadingManager {
constructor($scope) {
this.$scope = $scope
const socketPromise = new Promise(resolve => {
this.resolveSocketPromise = resolve
})
Promise.all([socketPromise, i18n])
.then(() => {
this.$scope.$apply(() => {
this.$scope.state.load_progress = 100
this.$scope.state.loading = false
})
})
// Note: this will only catch errors in from i18n setup. ConnectionManager
// handles errors for the socket connection
.catch(() => {
this.$scope.$apply(() => {
this.$scope.state.error = 'Could not load translations.'
})
})
}
socketLoaded() {
this.resolveSocketPromise()
}
}
export default LoadingManager

View file

@ -188,7 +188,7 @@ export default (ConnectionManager = (function() {
this.$scope.$apply(() => { this.$scope.$apply(() => {
if (this.$scope.state.loading) { if (this.$scope.state.loading) {
return (this.$scope.state.load_progress = 70) this.$scope.state.load_progress = 70
} }
}) })
@ -439,8 +439,7 @@ Something went wrong connecting to your project. Please refresh if this continue
this.$scope.protocolVersion = protocolVersion this.$scope.protocolVersion = protocolVersion
this.$scope.project = project this.$scope.project = project
this.$scope.permissionsLevel = permissionsLevel this.$scope.permissionsLevel = permissionsLevel
this.$scope.state.load_progress = 100 this.ide.loadingManager.socketLoaded()
this.$scope.state.loading = false
this.$scope.$broadcast('project:joined') this.$scope.$broadcast('project:joined')
}) })
} }

View file

@ -0,0 +1,65 @@
/*
* Custom webpack loader for i18next locale JSON files.
*
* It extracts translations used in the frontend (based on the list of keys in
* extracted-locales.json), and merges them with the fallback language (English)
*
* This means that we only load minimal translations data used in the frontend.
*/
const fs = require('fs').promises
const Path = require('path')
const SOURCE_PATH = Path.join(__dirname, '../locales')
const EXTRACTED_TRANSLATIONS_PATH = Path.join(
__dirname,
'./extracted-translation-keys.json'
)
module.exports = function translationsLoader() {
// Mark the loader as asynchronous, and get the done callback function
const callback = this.async()
// Mark the extracted keys file and English translations as a "dependency", so
// that it gets watched for changes in dev
this.addDependency(EXTRACTED_TRANSLATIONS_PATH)
this.addDependency(`${SOURCE_PATH}/en.json`)
const [, locale] = this.resourcePath.match(/(\w{2}(-\w{2})?)\.json$/)
run(locale)
.then(translations => {
callback(null, JSON.stringify(translations))
})
.catch(err => callback(err))
}
async function run(locale) {
const json = await fs.readFile(EXTRACTED_TRANSLATIONS_PATH)
const keys = JSON.parse(json)
const fallbackTranslations = await extract('en', keys)
return extract(locale, keys, fallbackTranslations)
}
async function extract(locale, keys, fallbackTranslations = null) {
const allTranslations = await getAllTranslations(locale)
const extractedTranslations = extractByKeys(keys, allTranslations)
return Object.assign({}, fallbackTranslations, extractedTranslations)
}
async function getAllTranslations(locale) {
const content = await fs.readFile(Path.join(SOURCE_PATH, `${locale}.json`))
return JSON.parse(content)
}
function extractByKeys(keys, translations) {
return keys.reduce((acc, key) => {
const foundString = translations[key]
if (foundString) {
acc[key] = foundString
}
return acc
}, {})
}

View file

@ -12639,6 +12639,14 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true "dev": true
}, },
"html-parse-stringify2": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz",
"integrity": "sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o=",
"requires": {
"void-elements": "^2.0.1"
}
},
"htmlparser2": { "htmlparser2": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.0.tgz",
@ -20165,6 +20173,15 @@
"scheduler": "^0.19.1" "scheduler": "^0.19.1"
} }
}, },
"react-i18next": {
"version": "11.7.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.7.1.tgz",
"integrity": "sha512-K7qWaQ03Nc25BqSqdKz1iGU5inwNQnDVcen/tpiILEXyd0w/z+untrsuUy5Y3PqAkwJ7m1FACwBttSSitxDKQA==",
"requires": {
"@babel/runtime": "^7.3.1",
"html-parse-stringify2": "2.0.1"
}
},
"react-is": { "react-is": {
"version": "16.9.0", "version": "16.9.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz",

View file

@ -122,6 +122,7 @@
"react": "^16.13.1", "react": "^16.13.1",
"react-bootstrap": "^0.33.1", "react-bootstrap": "^0.33.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-i18next": "^11.7.1",
"react2angular": "^4.0.6", "react2angular": "^4.0.6",
"redis-sharelatex": "^1.0.13", "redis-sharelatex": "^1.0.13",
"request": "^2.88.2", "request": "^2.88.2",

View file

@ -140,6 +140,16 @@ module.exports = {
runtimePath: 'handlebars/runtime' runtimePath: 'handlebars/runtime'
} }
}, },
{
// Load translations files with custom loader, to extract and apply
// fallbacks
test: /locales\/(\w{2}(-\w{2})?)\.json$/,
use: [
{
loader: path.resolve('frontend/translations-loader.js')
}
]
},
// Allow for injection of modules dependencies by reading contents of // Allow for injection of modules dependencies by reading contents of
// modules directory and adding necessary dependencies // modules directory and adding necessary dependencies
{ {