diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 23feb4cf30..ffa5e45249 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -19,7 +19,7 @@ import React from 'react'; import Matrix from 'matrix-js-sdk'; import sdk from '../../index'; import MatrixClientPeg from '../../MatrixClientPeg'; -import { _t, _tJsx } from '../../languageHandler'; +import { _t } from '../../languageHandler'; /* * Component which shows the filtered file using a TimelinePanel @@ -92,7 +92,10 @@ const FilePanel = React.createClass({ if (MatrixClientPeg.get().isGuest()) { return
- { _tJsx("You must register to use this functionality", /(.*?)<\/a>/, (sub) => { sub }) } + { _t("You must register to use this functionality", + {}, + { 'a': (sub) => { sub } }) + }
; } else if (this.noRoom) { diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 1b5ebb6b36..cba030c1cc 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -22,7 +22,7 @@ import MatrixClientPeg from '../../MatrixClientPeg'; import sdk from '../../index'; import dis from '../../dispatcher'; import { sanitizedHtmlNode } from '../../HtmlUtils'; -import { _t, _td, _tJsx } from '../../languageHandler'; +import { _t, _td } from '../../languageHandler'; import AccessibleButton from '../views/elements/AccessibleButton'; import Modal from '../../Modal'; import classnames from 'classnames'; @@ -932,12 +932,12 @@ export default React.createClass({ className="mx_GroupView_groupDesc_placeholder" onClick={this._onEditClick} > - { _tJsx( + { _t( 'Your community hasn\'t got a Long Description, a HTML page to show to community members.
' + 'Click here to open settings and give it one!', - [/
/], - [(sub) =>
]) - } + {}, + { 'br': () =>
}, + ) } ; } const groupDescEditingClasses = classnames({ diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index cc4783fdac..c669d7dd73 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -19,7 +19,7 @@ import PropTypes from 'prop-types'; import GeminiScrollbar from 'react-gemini-scrollbar'; import {MatrixClient} from 'matrix-js-sdk'; import sdk from '../../index'; -import { _t, _tJsx } from '../../languageHandler'; +import { _t } from '../../languageHandler'; import withMatrixClient from '../../wrappers/withMatrixClient'; import AccessibleButton from '../views/elements/AccessibleButton'; import dis from '../../dispatcher'; @@ -165,13 +165,13 @@ export default withMatrixClient(React.createClass({
{ _t('Join an existing community') }
- { _tJsx( + { _t( 'To join an existing community you\'ll have to '+ 'know its community identifier; this will look '+ 'something like +example:matrix.org.', - /(.*)<\/i>/, - (sub) => { sub }, - ) } + {}, + { 'i': (sub) => { sub } }) + } diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index cad55351d1..03859f522e 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -15,13 +15,12 @@ limitations under the License. */ import React from 'react'; -import { _t, _tJsx } from '../../languageHandler'; +import { _t } from '../../languageHandler'; import sdk from '../../index'; import WhoIsTyping from '../../WhoIsTyping'; import MatrixClientPeg from '../../MatrixClientPeg'; import MemberAvatar from '../views/avatars/MemberAvatar'; -const HIDE_DEBOUNCE_MS = 10000; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; @@ -272,12 +271,16 @@ module.exports = React.createClass({ { this.props.unsentMessageError }
- { _tJsx("Resend all or cancel all now. You can also select individual messages to resend or cancel.", - [/(.*?)<\/a>/, /(.*?)<\/a>/], - [ - (sub) => { sub }, - (sub) => { sub }, - ], + { + _t("Resend all or cancel all now. " + + "You can also select individual messages to resend or cancel.", + {}, + { + 'resendText': (sub) => + { sub }, + 'cancelText': (sub) => + { sub }, + }, ) }
@@ -322,12 +325,15 @@ module.exports = React.createClass({ if (this.props.sentMessageAndIsAlone) { return (
- { _tJsx("There's no one else here! Would you like to invite others or stop warning about the empty room?", - [/(.*?)<\/a>/, /(.*?)<\/a>/], - [ - (sub) => { sub }, - (sub) => { sub }, - ], + { _t("There's no one else here! Would you like to invite others " + + "or stop warning about the empty room?", + {}, + { + 'inviteText': (sub) => + { sub }, + 'nowarnText': (sub) => + { sub }, + }, ) }
); diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 8ee6eafad4..3b68234abd 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -18,7 +18,7 @@ limitations under the License. 'use strict'; import React from 'react'; -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; import * as languageHandler from '../../../languageHandler'; import sdk from '../../../index'; import Login from '../../../Login'; @@ -256,17 +256,19 @@ module.exports = React.createClass({ !this.state.enteredHomeserverUrl.startsWith("http")) ) { errorText = - { _tJsx("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + + { + _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + "Either use HTTPS or enable unsafe scripts.", - /(.*?)<\/a>/, - (sub) => { return { sub }; }, + {}, + { 'a': (sub) => { return { sub }; } }, ) } ; } else { errorText = - { _tJsx("Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.", - /(.*?)<\/a>/, - (sub) => { return { sub }; }, + { + _t("Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.", + {}, + { 'a': (sub) => { return { sub }; } }, ) } ; } @@ -277,7 +279,7 @@ module.exports = React.createClass({ componentForStep: function(step) { switch (step) { - case 'm.login.password': + case 'm.login.password': { const PasswordLogin = sdk.getComponent('login.PasswordLogin'); return ( ); - case 'm.login.cas': + } + case 'm.login.cas': { const CasLogin = sdk.getComponent('login.CasLogin'); return ( ); - default: + } + default: { if (!step) { return; } @@ -307,6 +311,7 @@ module.exports = React.createClass({ { _t('Sorry, this homeserver is using a login which is not recognised ') }({ step }) ); + } } }, diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index f404bdd975..75ae0eda17 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -18,7 +18,7 @@ import React from 'react'; import sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; export default React.createClass({ @@ -45,9 +45,10 @@ export default React.createClass({ if (SdkConfig.get().bug_report_endpoint_url) { bugreport = (

- { _tJsx( + { _t( "Otherwise, click here to send a bug report.", - /(.*?)<\/a>/, (sub) => { sub }, + {}, + { 'a': (sub) => { sub } }, ) }

); diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index 057609b344..53fdee20ff 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -21,7 +21,7 @@ import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import classnames from 'classnames'; import KeyCode from '../../../KeyCode'; -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; // The amount of time to wait for further changes to the input username before // sending a request to the server @@ -267,24 +267,21 @@ export default React.createClass({ { usernameIndicator }

- { _tJsx( + { _t( 'This will be your account name on the ' + 'homeserver, or you can pick a different server.', - [ - /<\/span>/, - /(.*?)<\/a>/, - ], - [ - (sub) => { this.props.homeserverUrl }, - (sub) => { sub }, - ], + {}, + { + 'span': () => { this.props.homeserverUrl }, + 'a': (sub) => { sub }, + }, ) }

- { _tJsx( + { _t( 'If you already have a Matrix account you can log in instead.', - /(.*?)<\/a>/, - [(sub) => { sub }], + {}, + { 'a': (sub) => { sub } }, ) }

{ auth } diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js index cf814b0a6e..21e5094b28 100644 --- a/src/components/views/login/CaptchaForm.js +++ b/src/components/views/login/CaptchaForm.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; const DIV_ID = 'mx_recaptcha'; @@ -67,10 +67,10 @@ module.exports = React.createClass({ // * jumping straight to a hosted captcha page (but we don't support that yet) // * embedding the captcha in an iframe (if that works) // * using a better captcha lib - ReactDOM.render(_tJsx( + ReactDOM.render(_t( "Robot check is currently unavailable on desktop - please use a web browser", - /(.*?)<\/a>/, - (sub) => { return { sub }; }), warning); + {}, + { 'a': (sub) => { return { sub }; }}), warning); this.refs.recaptchaContainer.appendChild(warning); } else { const scriptTag = document.createElement('script'); diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index 5f5a74ccd1..de8746230c 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -20,7 +20,7 @@ import url from 'url'; import classnames from 'classnames'; import sdk from '../../../index'; -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -256,7 +256,10 @@ export const EmailIdentityAuthEntry = React.createClass({ } else { return (
-

{ _tJsx("An email has been sent to %(emailAddress)s", /%\(emailAddress\)s/, (sub) => {this.props.inputs.emailAddress}) }

+

{ _t("An email has been sent to %(emailAddress)s", + { emailAddress: (sub) => { this.props.inputs.emailAddress } }, + ) } +

{ _t("Please check your email to continue registration.") }

); @@ -370,7 +373,10 @@ export const MsisdnAuthEntry = React.createClass({ }); return (
-

{ _tJsx("A text message has been sent to %(msisdn)s", /%\(msisdn\)s/, (sub) => {this._msisdn}) }

+

{ _t("A text message has been sent to %(msisdn)s", + { msisdn: () => this._msisdn }, + ) } +

{ _t("Please enter the code it contains:") }

diff --git a/src/components/views/messages/RoomAvatarEvent.js b/src/components/views/messages/RoomAvatarEvent.js index 995d5f8531..3a572d0cbe 100644 --- a/src/components/views/messages/RoomAvatarEvent.js +++ b/src/components/views/messages/RoomAvatarEvent.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import MatrixClientPeg from '../../../MatrixClientPeg'; import { ContentRepo } from 'matrix-js-sdk'; -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; import sdk from '../../../index'; import Modal from '../../../Modal'; import AccessibleButton from '../elements/AccessibleButton'; @@ -67,24 +67,17 @@ module.exports = React.createClass({ 'crop', ); - // it sucks that _tJsx doesn't support normal _t substitutions :(( return (
- { _tJsx('%(senderDisplayName)s changed the room avatar to ', - [ - /%\(senderDisplayName\)s/, - //, - ], - [ - (sub) => senderDisplayName, - (sub) => - - - , - ], - ) + { _t('%(senderDisplayName)s changed the room avatar to ', + { senderDisplayName: senderDisplayName }, + { + 'img': () => + + + , + }) }
); diff --git a/src/components/views/messages/SenderProfile.js b/src/components/views/messages/SenderProfile.js index afdb97272f..d5f78fe252 100644 --- a/src/components/views/messages/SenderProfile.js +++ b/src/components/views/messages/SenderProfile.js @@ -19,7 +19,7 @@ import React from 'react'; import sdk from '../../../index'; import Flair from '../elements/Flair.js'; -import { _tJsx } from '../../../languageHandler'; +import { _t, substitute } from '../../../languageHandler'; export default function SenderProfile(props) { const EmojiText = sdk.getComponent('elements.EmojiText'); @@ -42,22 +42,19 @@ export default function SenderProfile(props) { : null, ]; - let content = ''; - + let content; if(props.text) { - // Replace senderName, and wrap surrounding text in spans with the right class - content = _tJsx(props.text, /^(.*)\%\(senderName\)s(.*)$/m, (p1, p2) => [ - p1 ? { p1 } : null, - nameElem, - p2 ? { p2 } : null, - ]); + content = _t(props.text, { senderName: () => nameElem }); } else { - content = nameElem; + // There is nothing to translate here, so call substitute() instead + content = substitute('%(senderName)s', { senderName: () => nameElem }); } return (
- { content } + { content.props.children[0] ? { content.props.children[0] } : '' } + { content.props.children[1] } + { content.props.children[2] ? { content.props.children[2] } : '' }
); } diff --git a/src/components/views/room_settings/UrlPreviewSettings.js b/src/components/views/room_settings/UrlPreviewSettings.js index 56ae24e2f8..b9bf997009 100644 --- a/src/components/views/room_settings/UrlPreviewSettings.js +++ b/src/components/views/room_settings/UrlPreviewSettings.js @@ -14,13 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; const React = require('react'); const MatrixClientPeg = require('../../../MatrixClientPeg'); -const sdk = require("../../../index"); -const Modal = require("../../../Modal"); const UserSettingsStore = require('../../../UserSettingsStore'); -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ @@ -31,9 +28,6 @@ module.exports = React.createClass({ }, getInitialState: function() { - const cli = MatrixClientPeg.get(); - const roomState = this.props.room.currentState; - const roomPreviewUrls = this.props.room.currentState.getStateEvents('org.matrix.room.preview_urls', ''); const userPreviewUrls = this.props.room.getAccountData("org.matrix.room.preview_urls"); @@ -109,7 +103,6 @@ module.exports = React.createClass({ }, render: function() { - const self = this; const roomState = this.props.room.currentState; const cli = MatrixClientPeg.get(); @@ -133,11 +126,11 @@ module.exports = React.createClass({ let urlPreviewText = null; if (UserSettingsStore.getUrlPreviewsDisabled()) { urlPreviewText = ( - _tJsx("You have disabled URL previews by default.", /(.*?)<\/a>/, (sub)=>{ sub }) + _t("You have disabled URL previews by default.", {}, { 'a': (sub)=>{ sub } }) ); } else { urlPreviewText = ( - _tJsx("You have enabled URL previews by default.", /(.*?)<\/a>/, (sub)=>{ sub }) + _t("You have enabled URL previews by default.", {}, { 'a': (sub)=>{ sub } }) ); } diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index 271b0e48db..b8f31ef896 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -21,9 +21,7 @@ import sdk from '../../../index'; import dis from "../../../dispatcher"; import ObjectUtils from '../../../ObjectUtils'; import AppsDrawer from './AppsDrawer'; -import { _t, _tJsx} from '../../../languageHandler'; -import UserSettingsStore from '../../../UserSettingsStore'; - +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'AuxPanel', @@ -100,13 +98,13 @@ module.exports = React.createClass({ supportedText = _t(" (unsupported)"); } else { joinNode = ( - { _tJsx( + { _t( "Join as voice or video.", - [/(.*?)<\/voiceText>/, /(.*?)<\/videoText>/], - [ - (sub) => { this.onConferenceNotificationClick(event, 'voice');}} href="#">{ sub }, - (sub) => { this.onConferenceNotificationClick(event, 'video');}} href="#">{ sub }, - ], + {}, + { + 'voiceText': (sub) => { this.onConferenceNotificationClick(event, 'voice');}} href="#">{ sub }, + 'videoText': (sub) => { this.onConferenceNotificationClick(event, 'video');}} href="#">{ sub }, + }, ) } ); } diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 1a9fa5d4e9..c1e1ce2cb2 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -18,12 +18,10 @@ limitations under the License. 'use strict'; const React = require("react"); const ReactDOM = require("react-dom"); -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; const GeminiScrollbar = require('react-gemini-scrollbar'); const MatrixClientPeg = require("../../../MatrixClientPeg"); const CallHandler = require('../../../CallHandler'); -const RoomListSorter = require("../../../RoomListSorter"); -const Unread = require('../../../Unread'); const dis = require("../../../dispatcher"); const sdk = require('../../../index'); const rate_limited_func = require('../../../ratelimitedfunc'); @@ -486,28 +484,25 @@ module.exports = React.createClass({ const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton'); const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton'); - const TintableSvg = sdk.getComponent('elements.TintableSvg'); switch (section) { case 'im.vector.fake.direct': return
- { _tJsx( + { _t( "Press to start a chat with someone", - [//], - [ - (sub) => , - ], + {}, + { 'StartChatButton': () => }, ) }
; case 'im.vector.fake.recent': return
- { _tJsx( + { _t( "You're not in any rooms yet! Press to make a room or"+ " to browse the directory", - [//, //], - [ - (sub) => , - (sub) => , - ], + {}, + { + 'CreateRoomButton': () => , + 'RoomDirectoryButton': () => , + }, ) }
; } diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js index 0c0601a504..fe7948aeb3 100644 --- a/src/components/views/rooms/RoomPreviewBar.js +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -21,7 +21,7 @@ const React = require('react'); const sdk = require('../../../index'); const MatrixClientPeg = require('../../../MatrixClientPeg'); -import { _t, _tJsx } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'RoomPreviewBar', @@ -135,13 +135,13 @@ module.exports = React.createClass({ { _t('You have been invited to join this room by %(inviterName)s', {inviterName: this.props.inviterName}) }
- { _tJsx( + { _t( 'Would you like to accept or decline this invitation?', - [/(.*?)<\/acceptText>/, /(.*?)<\/declineText>/], - [ - (sub) => { sub }, - (sub) => { sub }, - ], + {}, + { + 'acceptText': (sub) => { sub }, + 'declineText': (sub) => { sub }, + }, ) }
{ emailMatchBlock } @@ -211,9 +211,9 @@ module.exports = React.createClass({
{ name ? _t('You are trying to access %(roomName)s.', {roomName: name}) : _t('You are trying to access a room.') }
- { _tJsx("Click here to join the discussion!", - /(.*?)<\/a>/, - (sub) => { sub }, + { _t("Click here to join the discussion!", + {}, + { 'a': (sub) => { sub } }, ) }
diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index c7e839ab40..be5fb0fe2f 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -17,7 +17,7 @@ limitations under the License. import Promise from 'bluebird'; import React from 'react'; -import { _t, _tJsx, _td } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; import Modal from '../../../Modal'; @@ -637,9 +637,7 @@ module.exports = React.createClass({ const ColorSettings = sdk.getComponent("room_settings.ColorSettings"); const UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings"); const RelatedGroupSettings = sdk.getComponent("room_settings.RelatedGroupSettings"); - const EditableText = sdk.getComponent('elements.EditableText'); const PowerSelector = sdk.getComponent('elements.PowerSelector'); - const Loader = sdk.getComponent("elements.Spinner"); const cli = MatrixClientPeg.get(); const roomState = this.props.room.currentState; @@ -760,7 +758,7 @@ module.exports = React.createClass({ var tagsSection = null; if (canSetTag || self.state.tags) { - var tagsSection = + tagsSection =
{ _t("Tagged as: ") }{ canSetTag ? (tags.map(function(tag, i) { @@ -790,10 +788,10 @@ module.exports = React.createClass({ if (this.state.join_rule === "public" && aliasCount == 0) { addressWarning =
- { _tJsx( + { _t( 'To link to a room it must have an address.', - /(.*?)<\/a>/, - (sub) => { sub }, + {}, + { 'a': (sub) => { sub } }, ) }
; } @@ -940,7 +938,7 @@ module.exports = React.createClass({ { Object.keys(events_levels).map(function(event_type, i) { let label = plEventsToLabels[event_type]; if (label) label = _t(label); - else label = _tJsx("To send events of type , you must be a", //, () => { event_type }); + else label = _t("To send events of type , you must be a", {}, { 'eventType': () => { event_type } }); return (
{ label } diff --git a/src/languageHandler.js b/src/languageHandler.js index da62bfee56..33ae229185 100644 --- a/src/languageHandler.js +++ b/src/languageHandler.js @@ -35,12 +35,9 @@ export function _td(s) { return s; } -// The translation function. This is just a simple wrapper to counterpart, -// but exists mostly because we must use the same counterpart instance -// between modules (ie. here (react-sdk) and the app (riot-web), and if we -// just import counterpart and use it directly, we end up using a different -// instance. -export function _t(...args) { +// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly +//Takes the same arguments as counterpart.translate() +function safe_counterpart_translate(...args) { // Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191 // The interpolation library that counterpart uses does not support undefined/null // values and instead will throw an error. This is a problem since everywhere else @@ -51,11 +48,11 @@ export function _t(...args) { if (args[1] && typeof args[1] === 'object') { Object.keys(args[1]).forEach((k) => { if (args[1][k] === undefined) { - console.warn("_t called with undefined interpolation name: " + k); + console.warn("safe_counterpart_translate called with undefined interpolation name: " + k); args[1][k] = 'undefined'; } if (args[1][k] === null) { - console.warn("_t called with null interpolation name: " + k); + console.warn("safe_counterpart_translate called with null interpolation name: " + k); args[1][k] = 'null'; } }); @@ -64,75 +61,112 @@ export function _t(...args) { } /* - * Translates stringified JSX into translated JSX. E.g - * _tJsx( - * "click here now", - * /(.*?)<\/a>/, - * (sub) => { return { sub }; } - * ); + * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components + * @param {string} text The untranslated text, e.g "click here now to %(foo)s". + * @param {object} variables Variable substitutions, e.g { foo: 'bar' } + * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} } * - * @param {string} jsxText The untranslated stringified JSX e.g "click here now". - * This will be translated by passing the string through to _t(...) + * The values to substitute with can be either simple strings, or functions that return the value to use in + * the substitution (e.g. return a React component). In case of a tag replacement, the function receives as + * the argument the text inside the element corresponding to the tag. * - * @param {RegExp|RegExp[]} patterns A regexp to match against the translated text. - * The captured groups from the regexp will be fed to 'sub'. - * Only the captured groups will be included in the output, the match itself is discarded. - * If multiple RegExps are provided, the function at the same position will be called. The - * match will always be done from left to right, so the 2nd RegExp will be matched against the - * remaining text from the first RegExp. - * - * @param {Function|Function[]} subs A function which will be called - * with multiple args, each arg representing a captured group of the matching regexp. - * This function must return a JSX node. - * - * @return a React component containing the generated text + * @return a React component if any non-strings were used in substitutions, otherwise a string */ -export function _tJsx(jsxText, patterns, subs) { - // convert everything to arrays - if (patterns instanceof RegExp) { - patterns = [patterns]; - } - if (subs instanceof Function) { - subs = [subs]; - } - // sanity checks - if (subs.length !== patterns.length || subs.length < 1) { - throw new Error(`_tJsx: programmer error. expected number of RegExps == number of Functions: ${subs.length} != ${patterns.length}`); - } - for (let i = 0; i < subs.length; i++) { - if (!(patterns[i] instanceof RegExp)) { - throw new Error(`_tJsx: programmer error. expected RegExp for text: ${jsxText}`); - } - if (!(subs[i] instanceof Function)) { - throw new Error(`_tJsx: programmer error. expected Function for text: ${jsxText}`); - } - } +export function _t(text, variables, tags) { + // Don't do subsitutions in counterpart. We hanle it ourselves so we can replace with React components + const args = Object.assign({ interpolate: false }, variables); // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution) - const tJsxText = _t(jsxText, {interpolate: false}); - const output = [tJsxText]; + const translated = safe_counterpart_translate(text, args); + + return substitute(translated, variables, tags); +} + +/* + * Similar to _t(), except only does substitutions, and no translation + * @param {string} text The text, e.g "click here now to %(foo)s". + * @param {object} variables Variable substitutions, e.g { foo: 'bar' } + * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} } + * + * The values to substitute with can be either simple strings, or functions that return the value to use in + * the substitution (e.g. return a React component). In case of a tag replacement, the function receives as + * the argument the text inside the element corresponding to the tag. + * + * @return a React component if any non-strings were used in substitutions, otherwise a string + */ +export function substitute(text, variables, tags) { + const regexpMapping = {}; + + if(variables !== undefined) { + for (const variable in variables) { + regexpMapping[`%\\(${variable}\\)s`] = variables[variable]; + } + } + + if(tags !== undefined) { + for (const tag in tags) { + regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag]; + } + } + return replaceByRegexes(text, regexpMapping); +} + +/* + * Replace parts of a text using regular expressions + * @param {string} text The text on which to perform substitutions + * @param {object} mapping A mapping from regular expressions in string form to replacement string or a + * function which will receive as the argument the capture groups defined in the regexp. E.g. + * { 'Hello (.?) World': (sub) => sub.toUpperCase() } + * + * @return a React component if any non-strings were used in substitutions, otherwise a string + */ +export function replaceByRegexes(text, mapping) { + const output = [text]; + + let wrap = false; // Remember if the output needs to be wrapped later + for (const regexpString in mapping) { + const regexp = new RegExp(regexpString); - for (let i = 0; i < patterns.length; i++) { // convert the last element in 'output' into 3 elements (pre-text, sub function, post-text). // Rinse and repeat for other patterns (using post-text). const inputText = output.pop(); - const match = inputText.match(patterns[i]); - if (!match) { - throw new Error(`_tJsx: translator error. expected translation to match regexp: ${patterns[i]}`); + const match = inputText.match(regexp); + if(!match) { + output.push(inputText); // Push back input + continue; // Missing matches is entirely possible, because translation might change things } - const capturedGroups = match.slice(1); + const capturedGroups = match.slice(2); // Return the raw translation before the *match* followed by the return value of sub() followed // by the raw translation after the *match* (not captured group). output.push(inputText.substr(0, match.index)); - output.push(subs[i].apply(null, capturedGroups)); + + let toPush; + // If substitution is a function, call it + if(mapping[regexpString] instanceof Function) { + toPush = mapping[regexpString].apply(null, capturedGroups); + } else { + toPush = mapping[regexpString]; + } + + output.push(toPush); + + // Check if we need to wrap the output into a span at the end + if(typeof toPush === 'object') { + wrap = true; + } + output.push(inputText.substr(match.index + match[0].length)); } - // this is a bit of a fudge to avoid the 'Each child in an array or iterator - // should have a unique "key" prop' error: we explicitly pass the generated - // nodes into React.createElement as children of a . - return React.createElement('span', null, ...output); + if(wrap) { + // this is a bit of a fudge to avoid the 'Each child in an array or iterator + // should have a unique "key" prop' error: we explicitly pass the generated + // nodes into React.createElement as children of a . + return React.createElement('span', null, ...output); + } else { + return output.join(''); + } } // Allow overriding the text displayed when no translation exists