mirror of
https://github.com/flarum/framework.git
synced 2025-06-26 21:21:21 +08:00
Switch to ICU MessageFormat (#2759)
This commit is contained in:

committed by
GitHub

parent
edaf45d133
commit
b45519974a
21992
js/package-lock.json
generated
21992
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
@ -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: [
|
||||
@ -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: [
|
||||
|
@ -163,7 +163,7 @@ export default class Application {
|
||||
|
||||
load(payload) {
|
||||
this.data = payload;
|
||||
this.translator.locale = payload.locale;
|
||||
this.translator.setLocale(payload.locale);
|
||||
}
|
||||
|
||||
boot() {
|
||||
|
@ -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() {
|
||||
/**
|
||||
@ -18,288 +13,53 @@ export default class Translator {
|
||||
*/
|
||||
this.translations = {};
|
||||
|
||||
this.locale = null;
|
||||
this.formatter = new RichMessageFormatter(null, this.formatterTypeHandlers(), mithrilRichHandler);
|
||||
}
|
||||
|
||||
formatterTypeHandlers() {
|
||||
return {
|
||||
plural: pluralTypeHandler,
|
||||
select: selectTypeHandler,
|
||||
};
|
||||
}
|
||||
|
||||
setLocale(locale) {
|
||||
this.formatter.locale = locale;
|
||||
}
|
||||
|
||||
addTranslations(translations) {
|
||||
Object.assign(this.translations, translations);
|
||||
}
|
||||
|
||||
trans(id, parameters) {
|
||||
const translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 = [];
|
||||
trans(id, parameters) {
|
||||
const translation = this.translations[id];
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
if (translation) {
|
||||
parameters = this.preprocessParameters(parameters || {});
|
||||
return this.formatter.rich(translation, parameters);
|
||||
}
|
||||
|
||||
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;
|
||||
return id;
|
||||
}
|
||||
|
||||
convertNumber(number) {
|
||||
if ('-Inf' === number) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
} else if ('+Inf' === number || 'Inf' === number) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return parseInt(number, 10);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
|
Reference in New Issue
Block a user