mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 09:43:38 -05:00
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:
parent
473e9d32f2
commit
617fe024bc
12 changed files with 208 additions and 14 deletions
|
@ -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")
|
||||||
|
|
6
services/web/frontend/extracted-translation-keys.json
Normal file
6
services/web/frontend/extracted-translation-keys.json
Normal 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"
|
||||||
|
]
|
|
@ -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,
|
||||||
|
|
|
@ -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 can’t 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>
|
||||||
)}
|
)}
|
||||||
|
|
42
services/web/frontend/js/i18n.js
Normal file
42
services/web/frontend/js/i18n.js
Normal 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
|
|
@ -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)
|
||||||
|
|
36
services/web/frontend/js/ide/LoadingManager.js
Normal file
36
services/web/frontend/js/ide/LoadingManager.js
Normal 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
|
|
@ -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')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
65
services/web/frontend/translations-loader.js
Normal file
65
services/web/frontend/translations-loader.js
Normal 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
|
||||||
|
}, {})
|
||||||
|
}
|
17
services/web/package-lock.json
generated
17
services/web/package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in a new issue