[I18n] Add pseudo-localization to i18n engine (#24130) (#24635)

* [I18n] Add pseudo-localization to i18n engine

* Add pseudo-localization to React

* Remove lookbehind from regex

* Resolve comments

* Add comment
This commit is contained in:
Leanid Shutau 2018-10-29 13:54:26 +03:00 committed by GitHub
parent 94637b538f
commit 46b83db605
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 162 additions and 13 deletions

View file

@ -47,3 +47,5 @@ Error: The intl string context variable 'numPhotos' was not provided to the stri
other {# photos.}
}'"
`;
exports[`I18n engine translateUsingPseudoLocale should translate message using pseudo-locale 1`] = `"Ṁéšššàĝĝé ŵîîţĥ àà [ɱàŕŕķðôôŵñ ļļîñķķ](http://localhost:5601/url) àñðð àñ <strong>ĥĥţɱļļ éļééɱéññţ</strong>"`;

View file

@ -787,4 +787,19 @@ describe('I18n engine', () => {
});
});
});
describe('translateUsingPseudoLocale', () => {
test('should translate message using pseudo-locale', () => {
i18n.setLocale('en-xa');
const message = i18n.translate('namespace.id', {
defaultMessage:
'Message with a [markdown link](http://localhost:5601/url) and an {htmlElement}',
values: {
htmlElement: '<strong>html element</strong>',
},
});
expect(message).toMatchSnapshot();
});
});
});

View file

@ -24,6 +24,7 @@ import IntlRelativeFormat from 'intl-relativeformat';
import { Messages, PlainMessages } from '../messages';
import { Formats, formats as EN_FORMATS } from './formats';
import { hasValues, isObject, isString, mergeAll } from './helper';
import { isPseudoLocale, translateUsingPseudoLocale } from './pseudo_locale';
// Add all locale data to `IntlMessageFormat`.
import './locales.js';
@ -177,25 +178,28 @@ export function translate(
defaultMessage: '',
}
) {
const shouldUsePseudoLocale = isPseudoLocale(currentLocale);
if (!id || !isString(id)) {
throw new Error('[I18n] An `id` must be a non-empty string to translate a message.');
}
const message = getMessageById(id);
const message = shouldUsePseudoLocale ? defaultMessage : getMessageById(id);
if (!message && !defaultMessage) {
throw new Error(`[I18n] Cannot format message: "${id}". Default message must be provided.`);
}
if (!hasValues(values)) {
return message || defaultMessage;
}
if (message) {
try {
const msg = getMessageFormat(message, getLocale(), getFormats());
// We should call `format` even for messages without any value references
// to let it handle escaped curly braces `\\{` that are the part of the text itself
// and not value reference boundaries.
const formattedMessage = getMessageFormat(message, getLocale(), getFormats()).format(values);
return msg.format(values);
return shouldUsePseudoLocale
? translateUsingPseudoLocale(formattedMessage)
: formattedMessage;
} catch (e) {
throw new Error(
`[I18n] Error formatting message: "${id}" for locale: "${getLocale()}".\n${e}`

View file

@ -0,0 +1,104 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Matches every single [A-Za-z] character, `<tag attr="any > text">` and `](markdown-link-address)`
*/
const CHARS_FOR_PSEUDO_LOCALIZATION_REGEX = /[A-Za-z]|(\]\([\s\S]*?\))|(<([^"<>]|("[^"]*?"))*?>)/g;
const PSEUDO_ACCENTS_LOCALE = 'en-xa';
export function isPseudoLocale(locale: string) {
return locale.toLowerCase() === PSEUDO_ACCENTS_LOCALE;
}
/**
* Replaces every latin char by pseudo char and repeats every third char twice.
*/
function replacer() {
let count = 0;
return (match: string) => {
// if `match.length !== 1`, then `match` is html tag or markdown link address, so it should be ignored
if (match.length !== 1) {
return match;
}
const pseudoChar = pseudoAccentCharMap[match] || match;
return ++count % 3 === 0 ? pseudoChar.repeat(2) : pseudoChar;
};
}
export function translateUsingPseudoLocale(message: string) {
return message.replace(CHARS_FOR_PSEUDO_LOCALIZATION_REGEX, replacer());
}
const pseudoAccentCharMap: Record<string, string> = {
a: 'à',
b: 'ƀ',
c: 'ç',
d: 'ð',
e: 'é',
f: 'ƒ',
g: 'ĝ',
h: 'ĥ',
i: 'î',
l: 'ļ',
k: 'ķ',
j: 'ĵ',
m: 'ɱ',
n: 'ñ',
o: 'ô',
p: 'þ',
q: 'ǫ',
r: 'ŕ',
s: 'š',
t: 'ţ',
u: 'û',
v: 'ṽ',
w: 'ŵ',
x: 'ẋ',
y: 'ý',
z: 'ž',
A: 'À',
B: 'Ɓ',
C: 'Ç',
D: 'Ð',
E: 'É',
F: 'Ƒ',
G: 'Ĝ',
H: 'Ĥ',
I: 'Î',
L: 'Ļ',
K: 'Ķ',
J: 'Ĵ',
M: 'Ṁ',
N: 'Ñ',
O: 'Ô',
P: 'Þ',
Q: 'Ǫ',
R: 'Ŕ',
S: 'Š',
T: 'Ţ',
U: 'Û',
V: 'Ṽ',
W: 'Ŵ',
X: 'Ẋ',
Y: 'Ý',
Z: 'Ž',
};

View file

@ -19,9 +19,10 @@
import * as PropTypes from 'prop-types';
import * as React from 'react';
import { IntlProvider } from 'react-intl';
import { IntlProvider, intlShape } from 'react-intl';
import * as i18n from '../core';
import { isPseudoLocale, translateUsingPseudoLocale } from '../core/pseudo_locale';
/**
* The library uses the provider pattern to scope an i18n context to a tree
@ -29,12 +30,30 @@ import * as i18n from '../core';
* IntlProvider should wrap react app's root component (inside each react render method).
*/
export class I18nProvider extends React.PureComponent {
public static propTypes = {
children: PropTypes.object,
};
public static propTypes = { children: PropTypes.element.isRequired };
public static contextTypes = { intl: intlShape };
public static childContextTypes = { intl: intlShape };
public getChildContext() {
// if pseudo locale is set, default intl.formatMessage should be decorated
// with the pseudo localization function
if (this.context.intl && isPseudoLocale(i18n.getLocale())) {
const formatMessage = this.context.intl.formatMessage;
this.context.intl.formatMessage = (...args: any[]) => {
return translateUsingPseudoLocale(formatMessage.apply(this.context.intl, args));
};
}
return { intl: this.context.intl };
}
public render() {
const { children } = this.props;
const child = React.Children.only(this.props.children);
if (this.context.intl) {
// We can have IntlProvider somewhere within ancestors so we just reuse it
// and don't recreate with another IntlProvider
return child;
}
return (
<IntlProvider
@ -45,7 +64,12 @@ export class I18nProvider extends React.PureComponent {
defaultFormats={i18n.getFormats()}
textComponent={React.Fragment}
>
{children}
{
// We use `<I18nProvider>{child}</I18nProvider>` trick to decorate intl.formatMessage
// in `getChildContext()` method. I18nProdiver will have `this.context.intl` so the
// recursion won't be infinite
}
{isPseudoLocale(i18n.getLocale()) ? <I18nProvider>{child}</I18nProvider> : child}
</IntlProvider>
);
}