mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 07:03:37 -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.user_id = '#{getLoggedInUserId()}';
|
||||
//- Internationalisation settings
|
||||
window.i18n = {
|
||||
currentLangCode: '#{currentLngCode}'
|
||||
}
|
||||
//- Expose some settings globally to the frontend
|
||||
window.ExposedSettings = JSON.parse('!{StringHelper.stringifyJsonForScript(ExposedSettings)}');
|
||||
|
||||
- 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 { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
|
||||
import OutlineRoot from './outline-root'
|
||||
import localStorage from '../../../modules/localStorage'
|
||||
|
||||
|
@ -14,6 +16,8 @@ function OutlinePane({
|
|||
eventTracking,
|
||||
highlightedLine
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const storageKey = `file_outline.expanded.${projectId}`
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
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 (
|
||||
<div className={headerClasses}>
|
||||
<header className="outline-header">
|
||||
|
@ -54,7 +69,7 @@ function OutlinePane({
|
|||
onClick={handleExpandCollapseClick}
|
||||
>
|
||||
<i className={expandCollapseIconClasses} />
|
||||
<h4 className="outline-header-name">File outline</h4>
|
||||
<h4 className="outline-header-name">{t('file_outline')}</h4>
|
||||
{expanded ? (
|
||||
<OverlayTrigger placement="top" overlay={tooltip} delayHide={100}>
|
||||
<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 = {
|
||||
isTexFile: PropTypes.bool.isRequired,
|
||||
outline: PropTypes.array.isRequired,
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import OutlineList from './outline-list'
|
||||
|
||||
function OutlineRoot({ outline, jumpToLine, highlightedLine }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{outline.length ? (
|
||||
|
@ -14,14 +18,14 @@ function OutlineRoot({ outline, jumpToLine, highlightedLine }) {
|
|||
/>
|
||||
) : (
|
||||
<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
|
||||
href="/learn/how-to/Using_the_File_Outline_feature"
|
||||
className="outline-body-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Find out more about the file outline
|
||||
{t('find_out_more_about_the_file_outline')}
|
||||
</a>
|
||||
</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 FileTreeManager from './ide/file-tree/FileTreeManager'
|
||||
import LoadingManager from './ide/LoadingManager'
|
||||
import ConnectionManager from './ide/connection/ConnectionManager'
|
||||
import EditorManager from './ide/editor/EditorManager'
|
||||
import OnlineUsersManager from './ide/online-users/OnlineUsersManager'
|
||||
|
@ -180,6 +181,7 @@ App.controller('IdeController', function(
|
|||
ide.$scope = $scope
|
||||
|
||||
ide.referencesSearchManager = new ReferencesManager(ide, $scope)
|
||||
ide.loadingManager = new LoadingManager($scope)
|
||||
ide.connectionManager = new ConnectionManager(ide, $scope)
|
||||
ide.fileTreeManager = new FileTreeManager(ide, $scope)
|
||||
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(() => {
|
||||
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.project = project
|
||||
this.$scope.permissionsLevel = permissionsLevel
|
||||
this.$scope.state.load_progress = 100
|
||||
this.$scope.state.loading = false
|
||||
this.ide.loadingManager.socketLoaded()
|
||||
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==",
|
||||
"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": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.0.tgz",
|
||||
|
@ -20165,6 +20173,15 @@
|
|||
"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": {
|
||||
"version": "16.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz",
|
||||
|
|
|
@ -122,6 +122,7 @@
|
|||
"react": "^16.13.1",
|
||||
"react-bootstrap": "^0.33.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-i18next": "^11.7.1",
|
||||
"react2angular": "^4.0.6",
|
||||
"redis-sharelatex": "^1.0.13",
|
||||
"request": "^2.88.2",
|
||||
|
|
|
@ -140,6 +140,16 @@ module.exports = {
|
|||
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
|
||||
// modules directory and adding necessary dependencies
|
||||
{
|
||||
|
|
Loading…
Reference in a new issue