Skip to content

DOM overlays for fluent-react #101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Dec 12, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions fluent-react/examples/html-markup/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "fluent-react-example-html-markup",
"version": "0.1.0",
"private": true,
"devDependencies": {
"react-scripts": "1.0.17"
},
"dependencies": {
"fluent": "file:../../../fluent",
"fluent-intl-polyfill": "file:../../../fluent-intl-polyfill",
"fluent-langneg": "file:../../../fluent-langneg",
"fluent-react": "file:../../../fluent-react",
"react": "^16.1.1",
"react-dom": "^16.1.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build"
}
}
19 changes: 19 additions & 0 deletions fluent-react/examples/html-markup/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HTML Markup in translations - a fluent-react example</title>
<style>
button.text {
background: none;
border: none;
padding: 0;
color: blue;
}
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>
29 changes: 29 additions & 0 deletions fluent-react/examples/html-markup/src/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import { Localized, withLocalization } from 'fluent-react';

function App(props) {
function showAlert(id) {
const { getString } = props;
alert(getString(id));
}

return (
<div>
<Localized id="sign-in-or-cancel"
signin={<button onClick={() => showAlert('clicked-sign-in')}></button>}
cancel={<button className="text" onClick={() => showAlert('clicked-cancel')}></button>}
>
<p>{'<signin>Sign in</signin> or <cancel>cancel</cancel>.'}</p>
</Localized>

<Localized id="agree-prompt"
input={<input type="text" />}
button={<button onClick={() => showAlert('agree-alert')}></button>}
>
<p>{'My name is <input/> and <button>I agree</button>.'}</p>
</Localized>
</div>
);
}

export default withLocalization(App);
13 changes: 13 additions & 0 deletions fluent-react/examples/html-markup/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { LocalizationProvider } from 'fluent-react';

import { generateMessages } from './l10n';
import App from './App';

ReactDOM.render(
<LocalizationProvider messages={generateMessages(navigator.languages)}>
<App />
</LocalizationProvider>,
document.getElementById('root')
);
37 changes: 37 additions & 0 deletions fluent-react/examples/html-markup/src/l10n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'fluent-intl-polyfill';
import { MessageContext } from 'fluent';
import { negotiateLanguages } from 'fluent-langneg';

const MESSAGES_ALL = {
'pl': `
sign-in-or-cancel = <signin>Zaloguj</signin> albo <cancel>anuluj</cancel>.
clicked-sign-in = Brawo!
clicked-cancel = OK, nieważne.

agree-prompt = Nazywam się <input/> i <button>zgadzam się</button>.
agree-alert = Fajnie, zgadzamy się.
`,
'en-US': `
sign-in-or-cancel = <signin>Sign in</signin> or <cancel>cancel</cancel>.
clicked-sign-in = You are now signed in.
clicked-cancel = OK, nevermind.

agree-prompt = My name is <input/> and <button>I agree</button>.
agree-alert = Cool, agreed.
`,
};

export function* generateMessages(userLocales) {
// Choose locales that are best for the user.
const currentLocales = negotiateLanguages(
userLocales,
['en-US', 'pl'],
{ defaultLocale: 'en-US' }
);

for (const locale of currentLocales) {
const cx = new MessageContext(locale);
cx.addMessages(MESSAGES_ALL[locale]);
yield cx;
}
}
7 changes: 2 additions & 5 deletions fluent-react/src/localization.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,7 @@ export default class ReactLocalization {
}

formatCompound(mcx, msg, args) {
const rawParts = mcx.formatToParts(msg, args) || [];

// Format the parts using the current `MessageContext` instance.
const parts = rawParts.map(part => part.valueOf(mcx));
const value = mcx.format(msg, args);

if (msg.attrs) {
var attrs = {};
Expand All @@ -62,7 +59,7 @@ export default class ReactLocalization {
}
}

return { parts, attrs };
return { value, attrs };
}

/*
Expand Down
84 changes: 41 additions & 43 deletions fluent-react/src/localized.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,26 @@
import { isValidElement, cloneElement, Component, Children } from 'react';
import PropTypes from 'prop-types';
import { MessageArgument } from 'fluent/compat';

import { isReactLocalization } from './localization';
import { parseMarkup } from './markup';

/*
* A Fluent argument type for React elements.
*
* When `MessageContext`'s `formatToParts` method is used, any interpolations
* which are valid `MessageArgument` instances are returned unformatted. The
* parts can then be `valueOf`'ed and concatenated to create the final
* translation.
*
* With `ElementArgument` it becomes possible to pass React elements as
* arguments to translations. This may be useful for passing links or buttons,
* or in general: elements with logic which should be defined in the app.
*/
class ElementArgument extends MessageArgument {
valueOf() {
return this.value;
}
}

/*
* Prepare props passed to `Localized` for formetting.
*
* Filter props which are not intended for formatting and turn arguments which
* are React elements into `ElementArgument` instances.
*
* Prepare props passed to `Localized` for formatting.
*/
function toArguments(props) {
const args = {};

for (const propname of Object.keys(props)) {
if (!propname.startsWith('$')) {
continue;
}

const arg = props[propname];
const name = propname.substr(1);

if (isValidElement(arg)) {
args[name] = new ElementArgument(arg);
} else {
args[name] = arg;
const elems = {};

for (const [propname, propval] of Object.entries(props)) {
if (propname.startsWith('$')) {
const name = propname.substr(1);
args[name] = propval;
} else if (isValidElement(propval)) {
elems[propname] = propval;
}
}

return args;
return [args, elems];
}

/*
Expand Down Expand Up @@ -116,12 +89,37 @@ export default class Localized extends Component {
}

const msg = mcx.getMessage(id);
const args = toArguments(this.props);
const { parts, attrs } = l10n.formatCompound(mcx, msg, args);
const [args, elems] = toArguments(this.props);
const { value, attrs } = l10n.formatCompound(mcx, msg, args);

if (value === null || !value.includes('<')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose there are no unexpected side-effects if the string inlcudes the < character outside of the context of a markup element. For example The result is that 0 < 3 ....

It looks like the parseMarkup that comes next will just leverage the DOM API to split out the different parts, and so this step is more of an optimization in case the translation string does not contain react components. Just thinking out loud and checking my understanding - does that sound about right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. In the rare scenario of a false-positive (like in the example you gave) we'll pay a bit extra to parse HTML which is not there. It will parse as a text node and things should work just fine. I'll add a test.

return cloneElement(elem, attrs, value);
}

// The formatted parts can be passed to `cloneElements` as arguments. They
// will be used as children of the cloned element.
return cloneElement(elem, attrs, ...parts);
const translationNodes = Array.from(parseMarkup(value).childNodes);
const translatedChildren = translationNodes.map(childNode => {
if (childNode.nodeType === childNode.TEXT_NODE) {
return childNode.textContent;
}

// If the child is not expected just take its textContent.
if (!elems.hasOwnProperty(childNode.localName)) {
return childNode.textContent;
}

return cloneElement(
elems[childNode.localName],
// XXX Explicitly ignore any attributes defined in the translation.
null,
// XXX React breaks if we try to pass non-null children to void elements
// (like <input>). At the same time, textContent of such elements is an
// empty string, so we explicitly pass null instead.
// See https://github.com/projectfluent/fluent.js/issues/105.
childNode.textContent || null
);
});

return cloneElement(elem, attrs, ...translatedChildren);
}
}

Expand Down
8 changes: 8 additions & 0 deletions fluent-react/src/markup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* eslint-env browser */

const TEMPLATE = document.createElement('template');

export function parseMarkup(str) {
TEMPLATE.innerHTML = str;
return TEMPLATE.content;
}
12 changes: 9 additions & 3 deletions fluent-react/test/localized_change_test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React from 'react';
import assert from 'assert';
import sinon from 'sinon';
import { mount } from 'enzyme';
import MessageContext from './message_context_stub';
import { MessageContext } from '../../fluent/src';
import ReactLocalization from '../src/localization';
import { Localized } from '../src/index';

Expand All @@ -11,6 +10,10 @@ suite('Localized - change messages', function() {
const mcx1 = new MessageContext();
const l10n = new ReactLocalization([mcx1]);

mcx1.addMessages(`
foo = FOO
`);

const wrapper = mount(
<Localized id="foo">
<div />
Expand All @@ -23,7 +26,10 @@ suite('Localized - change messages', function() {
));

const mcx2 = new MessageContext();
sinon.stub(mcx2, 'getMessage').returns('BAR');
mcx2.addMessages(`
foo = BAR
`);

l10n.setMessages([mcx2]);

assert.ok(wrapper.contains(
Expand Down
20 changes: 16 additions & 4 deletions fluent-react/test/localized_fallback_test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React from 'react';
import assert from 'assert';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import MessageContext from './message_context_stub';
import { MessageContext } from '../../fluent/src';
import ReactLocalization from '../src/localization';
import { Localized } from '../src/index';

Expand All @@ -11,6 +10,10 @@ suite('Localized - fallback', function() {
const mcx1 = new MessageContext();
const l10n = new ReactLocalization([mcx1]);

mcx1.addMessages(`
foo = FOO
`);

const wrapper = shallow(
<Localized id="foo">
<div>Bar</div>
Expand All @@ -25,10 +28,16 @@ suite('Localized - fallback', function() {

test('message id in the second context', function() {
const mcx1 = new MessageContext();
sinon.stub(mcx1, 'hasMessage').returns(false);
const mcx2 = new MessageContext();
const l10n = new ReactLocalization([mcx1, mcx2]);

mcx1.addMessages(`
not-foo = NOT FOO
`);
mcx2.addMessages(`
foo = FOO
`);

const wrapper = shallow(
<Localized id="foo">
<div>Bar</div>
Expand All @@ -43,9 +52,12 @@ suite('Localized - fallback', function() {

test('missing message', function() {
const mcx1 = new MessageContext();
sinon.stub(mcx1, 'hasMessage').returns(false);
const l10n = new ReactLocalization([mcx1]);

mcx1.addMessages(`
not-foo = NOT FOO
`);

const wrapper = shallow(
<Localized id="foo">
<div>Bar</div>
Expand Down
Loading