Skip to content
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

Switch to ICU MessageFormat #2759

Merged
merged 18 commits into from
Apr 30, 2021
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"symfony/console": "^5.2.2",
"symfony/event-dispatcher": "^5.2.2",
"symfony/mime": "^5.2.0",
"symfony/polyfill-intl-messageformatter": "^1.22.0",
"symfony/translation": "^5.1.5",
"symfony/yaml": "^5.2.2",
"tobscure/json-api": "^0.3.0",
Expand Down
28,922 changes: 22,406 additions & 6,516 deletions js/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"private": true,
"name": "@flarum/core",
"dependencies": {
"@askvortsov/rich-icu-message-formatter": "^0.1.0",
"@ultraq/icu-message-formatter": "^0.10.0",
"bootstrap": "^3.4.1",
"clsx": "^1.1.1",
"color-thief-browser": "^2.0.2",
Expand Down
4 changes: 2 additions & 2 deletions js/src/admin/components/PermissionGrid.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export default class PermissionGrid extends Component {

return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_renaming',
options: [
Expand Down Expand Up @@ -224,7 +224,7 @@ export default class PermissionGrid extends Component {

return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_post_editing',
options: [
Expand Down
2 changes: 1 addition & 1 deletion js/src/common/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export default class Application {

load(payload) {
this.data = payload;
this.translator.locale = payload.locale;
this.translator.setLocale(payload.locale);
}

boot() {
Expand Down
296 changes: 28 additions & 268 deletions js/src/common/Translator.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter';
import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter';
import username from './helpers/username';
import extract from './utils/extract';

/**
* Translator with the same API as Symfony's.
*
* Derived from https://github.com/willdurand/BazingaJsTranslationBundle
* which is available under the MIT License.
* Copyright (c) William Durand <william.durand1@gmail.com>
*/
export default class Translator {
constructor() {
/**
Expand All @@ -18,288 +13,53 @@ export default class Translator {
*/
this.translations = {};

this.locale = null;
this.formatter = new RichMessageFormatter(null, this.formatterTypeHandlers(), mithrilRichHandler);
}

addTranslations(translations) {
Object.assign(this.translations, translations);
formatterTypeHandlers() {
return {
plural: pluralTypeHandler,
select: selectTypeHandler,
};
}

trans(id, parameters) {
const translation = this.translations[id];

if (translation) {
return this.apply(translation, parameters || {});
}

return id;
setLocale(locale) {
this.formatter.locale = locale;
}

transChoice(id, number, parameters) {
let translation = this.translations[id];

if (translation) {
number = parseInt(number, 10);

translation = this.pluralize(translation, number);

return this.apply(translation, parameters || {});
}

return id;
addTranslations(translations) {
Object.assign(this.translations, translations);
}

apply(translation, input) {
preprocessParameters(parameters) {
// If we've been given a user model as one of the input parameters, then
// we'll extract the username and use that for the translation. In the
// future there should be a hook here to inspect the user and change the
// translation key. This will allow a gender property to determine which
// translation key is used.
if ('user' in input) {
const user = extract(input, 'user');
if ('user' in parameters) {
const user = extract(parameters, 'user');

if (!input.username) input.username = username(user);
if (!parameters.username) parameters.username = username(user);
}

translation = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));

const hydrated = [];
const open = [hydrated];

translation.forEach((part) => {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));

if (match) {
// Either an opening or closing tag.
if (match[1]) {
open[0].push(input[match[1]]);
} else if (match[3]) {
if (match[2]) {
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
// further text will be added to the full set of returned elements.
const rawChildren = open[0].splice(0, open[0].length);
open[0].push(...m.fragment(rawChildren).children);
open.shift();
} else {
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag);
// Insert the tag's children array as the first element of open, so that text in between the opening
// and closing tags will be added to the tag's children, not to the full set of returned elements.
open.unshift(tag.children || tag);
}
}
} else {
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
open[0].push(part);
}
});

return hydrated.filter((part) => part);
return parameters;
}

pluralize(translation, number) {
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
cPluralRegex = new RegExp(/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/),
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
standardRules = [],
explicitRules = [];

translation.split('|').forEach((part) => {
if (cPluralRegex.test(part)) {
const matches = part.match(cPluralRegex);
explicitRules[matches[0]] = matches[matches.length - 1];
} else if (sPluralRegex.test(part)) {
const matches = part.match(sPluralRegex);
standardRules.push(matches[1]);
} else {
standardRules.push(part);
}
});

explicitRules.forEach((rule, e) => {
if (iPluralRegex.test(e)) {
const matches = e.match(iPluralRegex);

if (matches[1]) {
const ns = matches[2].split(',');

for (let n in ns) {
if (number == ns[n]) {
return explicitRules[e];
}
}
} else {
var leftNumber = this.convertNumber(matches[4]);
var rightNumber = this.convertNumber(matches[5]);

if (
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
) {
return explicitRules[e];
}
}
}
});

return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
}
trans(id, parameters) {
const translation = this.translations[id];

convertNumber(number) {
if ('-Inf' === number) {
return Number.NEGATIVE_INFINITY;
} else if ('+Inf' === number || 'Inf' === number) {
return Number.POSITIVE_INFINITY;
if (translation) {
parameters = this.preprocessParameters(parameters || {});
return this.formatter.rich(translation, parameters);
}

return parseInt(number, 10);
return id;
}

pluralPosition(number, locale) {
if ('pt_BR' === locale) {
locale = 'xbr';
}

if (locale.length > 3) {
locale = locale.split('_')[0];
}

switch (locale) {
case 'bo':
case 'dz':
case 'id':
case 'ja':
case 'jv':
case 'ka':
case 'km':
case 'kn':
case 'ko':
case 'ms':
case 'th':
case 'vi':
case 'zh':
return 0;

case 'af':
case 'az':
case 'bn':
case 'bg':
case 'ca':
case 'da':
case 'de':
case 'el':
case 'en':
case 'eo':
case 'es':
case 'et':
case 'eu':
case 'fa':
case 'fi':
case 'fo':
case 'fur':
case 'fy':
case 'gl':
case 'gu':
case 'ha':
case 'he':
case 'hu':
case 'is':
case 'it':
case 'ku':
case 'lb':
case 'ml':
case 'mn':
case 'mr':
case 'nah':
case 'nb':
case 'ne':
case 'nl':
case 'nn':
case 'no':
case 'om':
case 'or':
case 'pa':
case 'pap':
case 'ps':
case 'pt':
case 'so':
case 'sq':
case 'sv':
case 'sw':
case 'ta':
case 'te':
case 'tk':
case 'tr':
case 'ur':
case 'zu':
return number == 1 ? 0 : 1;

case 'am':
case 'bh':
case 'fil':
case 'fr':
case 'gun':
case 'hi':
case 'ln':
case 'mg':
case 'nso':
case 'xbr':
case 'ti':
case 'wa':
return number === 0 || number == 1 ? 0 : 1;

case 'be':
case 'bs':
case 'hr':
case 'ru':
case 'sr':
case 'uk':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;

case 'cs':
case 'sk':
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;

case 'ga':
return number == 1 ? 0 : number == 2 ? 1 : 2;

case 'lt':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;

case 'sl':
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;

case 'mk':
return number % 10 == 1 ? 0 : 1;

case 'mt':
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;

case 'lv':
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;

case 'pl':
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;

case 'cy':
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;

case 'ro':
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;

case 'ar':
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;

default:
return 0;
}
/**
* @deprecated, remove before stable
*/
transChoice(id, number, parameters) {
return this.trans(id, parameters);
}
}
2 changes: 1 addition & 1 deletion js/src/forum/components/EventPost.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default class EventPost extends Post {
* @return {String|Object} The description to render in the DOM
*/
description(data) {
return app.translator.transChoice(this.descriptionKey(), data.count, data);
return app.translator.trans(this.descriptionKey(), data);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions js/src/forum/components/PostStreamScrubber.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ export default class PostStreamScrubber extends Component {
const count = this.stream.count();

// Index is left blank for performance reasons, it is filled in in updateScubberValues
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
const viewing = app.translator.trans('core.forum.post_scrubber.viewing_text', {
count,
index: <span className="Scrubber-index"></span>,
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
formattedCount: <span className="Scrubber-count">{formatNumber(count)}</span>,
});

const unreadCount = this.stream.discussion.unreadCount();
Expand Down
Loading