mirror of
https://github.com/flarum/framework.git
synced 2024-11-22 06:33:27 +08:00
Switch to ICU MessageFormat (#2759)
This commit is contained in:
parent
edaf45d133
commit
b45519974a
|
@ -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",
|
||||
|
|
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();
|
||||
|
|
|
@ -201,7 +201,7 @@ core:
|
|||
# These translations are used in the dropdown menus on the Permissions page.
|
||||
permissions_controls:
|
||||
allow_indefinitely_button: Indefinitely
|
||||
allow_some_minutes_button: "For {count} minute|For {count} minutes"
|
||||
allow_some_minutes_button: "{count, plural, one {For # minute} other {For # minutes}}"
|
||||
allow_ten_minutes_button: For 10 minutes
|
||||
allow_until_reply_button: Until next reply
|
||||
everyone_button: Everyone
|
||||
|
@ -415,7 +415,7 @@ core:
|
|||
now_link: Now
|
||||
original_post_link: Original Post
|
||||
unread_text: "{count} unread"
|
||||
viewing_text: "{index} of {count} post|{index} of {count} posts"
|
||||
viewing_text: "{count, plural, one {{index} of {formattedCount} post} other {{index} of {formattedCount} posts}}"
|
||||
|
||||
# These translations are displayed between posts in the post stream.
|
||||
post_stream:
|
||||
|
@ -688,7 +688,7 @@ core:
|
|||
save_changes: Save Changes # Referenced by flarum-suspend.yml, flarum-tags.yml
|
||||
settings: Settings
|
||||
sign_up: Sign Up
|
||||
some_others: "{count} other|{count} others" # Referenced by flarum-likes.yml, flarum-mentions.yml
|
||||
some_others: "{count, plural, one {# other} other {# others}}" # Referenced by flarum-likes.yml, flarum-mentions.yml
|
||||
start_a_discussion: Start a Discussion
|
||||
username: Username
|
||||
users: Users # Referenced by flarum-statistics.yml
|
||||
|
|
|
@ -35,7 +35,7 @@ class SendTestMailController implements RequestHandlerInterface
|
|||
$actor = RequestUtil::getActor($request);
|
||||
$actor->assertAdmin();
|
||||
|
||||
$body = $this->translator->trans('core.email.send_test.body', ['{username}' => $actor->username]);
|
||||
$body = $this->translator->trans('core.email.send_test.body', ['username' => $actor->username]);
|
||||
|
||||
$this->mailer->raw($body, function (Message $message) use ($actor) {
|
||||
$message->to($actor->email);
|
||||
|
|
|
@ -17,6 +17,7 @@ use Illuminate\Contracts\Container\Container;
|
|||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Translation\MessageCatalogueInterface;
|
||||
|
||||
class LanguagePack implements ExtenderInterface, LifecycleInterface
|
||||
{
|
||||
|
@ -107,6 +108,9 @@ class LanguagePack implements ExtenderInterface, LifecycleInterface
|
|||
// extension) with the list of known names and all extension IDs.
|
||||
$slug = $file->getBasename(".{$file->getExtension()}");
|
||||
|
||||
// Ignore ICU MessageFormat suffixes.
|
||||
$slug = str_replace(MessageCatalogueInterface::INTL_DOMAIN_SUFFIX, '', $slug);
|
||||
|
||||
if (in_array($slug, self::CORE_LOCALE_FILES, true)) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ use DirectoryIterator;
|
|||
use Flarum\Extension\Extension;
|
||||
use Flarum\Locale\LocaleManager;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Symfony\Component\Translation\MessageCatalogueInterface;
|
||||
|
||||
class Locales implements ExtenderInterface, LifecycleInterface
|
||||
{
|
||||
|
@ -38,8 +39,14 @@ class Locales implements ExtenderInterface, LifecycleInterface
|
|||
continue;
|
||||
}
|
||||
|
||||
$intlIcu = false;
|
||||
$locale = $file->getBasename(".$extension");
|
||||
|
||||
// Ignore ICU MessageFormat suffixes.
|
||||
$locale = str_replace(MessageCatalogueInterface::INTL_DOMAIN_SUFFIX, '', $locale);
|
||||
|
||||
$locales->addTranslations(
|
||||
$file->getBasename(".$extension"),
|
||||
$locale,
|
||||
$file->getPathname()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ class ViewFormatter implements HttpFormatter
|
|||
private function getTranslationIfExists(string $errorType)
|
||||
{
|
||||
$key = "core.views.error.$errorType";
|
||||
$translation = $this->translator->trans($key, ['{forum}' => $this->settings->get('forum_title')]);
|
||||
$translation = $this->translator->trans($key, ['forum' => $this->settings->get('forum_title')]);
|
||||
|
||||
return $translation === $key ? null : $translation;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
namespace Flarum\Locale;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Symfony\Component\Translation\MessageCatalogueInterface;
|
||||
|
||||
class LocaleManager
|
||||
{
|
||||
|
@ -64,7 +65,11 @@ class LocaleManager
|
|||
{
|
||||
$prefix = $module ? $module.'::' : '';
|
||||
|
||||
$this->translator->addResource('prefixed_yaml', compact('file', 'prefix'), $locale);
|
||||
// `messages` is the default domain, and we want to support MessageFormat
|
||||
// for all translations.
|
||||
$domain = 'messages'.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
|
||||
|
||||
$this->translator->addResource('prefixed_yaml', compact('file', 'prefix'), $locale, $domain);
|
||||
}
|
||||
|
||||
public function addJsFile(string $locale, string $js)
|
||||
|
|
|
@ -30,6 +30,8 @@ class LocaleServiceProvider extends AbstractServiceProvider
|
|||
);
|
||||
|
||||
$locales->addLocale($this->getDefaultLocale($container), 'Default');
|
||||
$locales->addTranslations('en', __DIR__.'/../../locale/core.yml');
|
||||
$locales->addTranslations('en', __DIR__.'/../../locale/validation.yml');
|
||||
|
||||
return $locales;
|
||||
});
|
||||
|
@ -46,8 +48,6 @@ class LocaleServiceProvider extends AbstractServiceProvider
|
|||
|
||||
$translator->setFallbackLocales(['en']);
|
||||
$translator->addLoader('prefixed_yaml', new PrefixedYamlFileLoader());
|
||||
$translator->addResource('prefixed_yaml', ['file' => __DIR__.'/../../locale/core.yml', 'prefix' => null], 'en');
|
||||
$translator->addResource('prefixed_yaml', ['file' => __DIR__.'/../../locale/validation.yml', 'prefix' => null], 'en');
|
||||
|
||||
return $translator;
|
||||
});
|
||||
|
|
|
@ -24,7 +24,8 @@ class Translator extends BaseTranslator implements TranslatorContract
|
|||
|
||||
public function choice($key, $number, array $replace = [], $locale = null)
|
||||
{
|
||||
return $this->transChoice($key, $number, $replace, nil, $locale);
|
||||
// Symfony's translator uses ICU MessageFormat, which pluralizes based on arguments.
|
||||
return $this->trans($key, $replace, null, $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -36,9 +36,9 @@ trait AccountActivationMailerTrait
|
|||
protected function getEmailData(User $user, EmailToken $token)
|
||||
{
|
||||
return [
|
||||
'{username}' => $user->display_name,
|
||||
'{url}' => $this->url->to('forum')->route('confirmEmail', ['token' => $token->token]),
|
||||
'{forum}' => $this->settings->get('forum_title')
|
||||
'username' => $user->display_name,
|
||||
'url' => $this->url->to('forum')->route('confirmEmail', ['token' => $token->token]),
|
||||
'forum' => $this->settings->get('forum_title')
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -104,13 +104,13 @@ class RequestPasswordResetHandler
|
|||
$token->save();
|
||||
|
||||
$data = [
|
||||
'{username}' => $user->display_name,
|
||||
'{url}' => $this->url->to('forum')->route('resetPassword', ['token' => $token->token]),
|
||||
'{forum}' => $this->settings->get('forum_title'),
|
||||
'username' => $user->display_name,
|
||||
'url' => $this->url->to('forum')->route('resetPassword', ['token' => $token->token]),
|
||||
'forum' => $this->settings->get('forum_title'),
|
||||
];
|
||||
|
||||
$body = $this->translator->trans('core.email.reset_password.body', $data);
|
||||
$subject = '['.$data['{forum}'].'] '.$this->translator->trans('core.email.reset_password.subject');
|
||||
$subject = '['.$data['forum'].'] '.$this->translator->trans('core.email.reset_password.subject');
|
||||
|
||||
$this->queue->push(new SendRawEmailJob($user->email, $subject, $body));
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ class EmailConfirmationMailer
|
|||
$data = $this->getEmailData($event->user, $email);
|
||||
|
||||
$body = $this->translator->trans('core.email.confirm_email.body', $data);
|
||||
$subject = '['.$data['{forum}'].'] '.$this->translator->trans('core.email.confirm_email.subject');
|
||||
$subject = '['.$data['forum'].'] '.$this->translator->trans('core.email.confirm_email.subject');
|
||||
|
||||
$this->queue->push(new SendRawEmailJob($email, $subject, $body));
|
||||
}
|
||||
|
@ -82,9 +82,9 @@ class EmailConfirmationMailer
|
|||
$token = $this->generateToken($user, $email);
|
||||
|
||||
return [
|
||||
'{username}' => $user->display_name,
|
||||
'{url}' => $this->url->to('forum')->route('confirmEmail', ['token' => $token->token]),
|
||||
'{forum}' => $this->settings->get('forum_title')
|
||||
'username' => $user->display_name,
|
||||
'url' => $this->url->to('forum')->route('confirmEmail', ['token' => $token->token]),
|
||||
'forum' => $this->settings->get('forum_title')
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
2
tests/fixtures/locales/en+intl-icu.yml
vendored
Normal file
2
tests/fixtures/locales/en+intl-icu.yml
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
test:
|
||||
hello-intl: World-intl {name}
|
23
tests/fixtures/locales/en.yml
vendored
Normal file
23
tests/fixtures/locales/en.yml
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
test:
|
||||
hello: World {name}
|
||||
party-invitation: | # From https://symfony.com/doc/current/translation/message_format.html#pluralization
|
||||
{gender_of_host, select,
|
||||
female {{num_guests, plural, offset:1
|
||||
=0 {{host} does not give a party.}
|
||||
=1 {{host} invites {guest} to her party.}
|
||||
=2 {{host} invites {guest} and one other person to her party.}
|
||||
other {{host} invites {guest} and # other people to her party.}
|
||||
}}
|
||||
male {{num_guests, plural, offset:1
|
||||
=0 {{host} does not give a party.}
|
||||
=1 {{host} invites {guest} to his party.}
|
||||
=2 {{host} invites {guest} and one other person to his party.}
|
||||
other {{host} invites {guest} and # other people to his party.}
|
||||
}}
|
||||
other {{num_guests, plural, offset:1
|
||||
=0 {{host} does not give a party.}
|
||||
=1 {{host} invites {guest} to their party.}
|
||||
=2 {{host} invites {guest} and one other person to their party.}
|
||||
other {{host} invites {guest} and # other people to their party.}
|
||||
}}
|
||||
}
|
105
tests/integration/extenders/LocalesTest.php
Normal file
105
tests/integration/extenders/LocalesTest.php
Normal file
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tests\integration\extenders;
|
||||
|
||||
use Flarum\Extend;
|
||||
use Flarum\Locale\Translator;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
|
||||
class LocalesTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
array_map('unlink', glob($this->tmpDir().'/storage/locale/*'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function custom_translation_does_not_exist_by_default()
|
||||
{
|
||||
$this->app()->getContainer()->make('flarum.locales');
|
||||
$translator = $this->app()->getContainer()->make(Translator::class);
|
||||
|
||||
$this->assertEquals('test.hello', $translator->trans('test.hello', ['name' => 'ACME']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function custom_translation_exists_if_added()
|
||||
{
|
||||
$this->extend(
|
||||
(new Extend\Locales(dirname(__FILE__, 3).'/fixtures/locales'))
|
||||
);
|
||||
|
||||
$this->app()->getContainer()->make('flarum.locales');
|
||||
$translator = $this->app()->getContainer()->make(Translator::class);
|
||||
|
||||
$this->assertEquals('World ACME', $translator->trans('test.hello', ['name' => 'ACME']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function custom_translation_exists_if_added_with_intl_suffix()
|
||||
{
|
||||
$this->extend(
|
||||
(new Extend\Locales(dirname(__FILE__, 3).'/fixtures/locales'))
|
||||
);
|
||||
|
||||
$this->app()->getContainer()->make('flarum.locales');
|
||||
$translator = $this->app()->getContainer()->make(Translator::class);
|
||||
|
||||
$this->assertEquals('World-intl ACME', $translator->trans('test.hello-intl', ['name' => 'ACME']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function messageformat_works_in_translations()
|
||||
{
|
||||
$this->extend(
|
||||
(new Extend\Locales(dirname(__FILE__, 3).'/fixtures/locales'))
|
||||
);
|
||||
|
||||
$this->app()->getContainer()->make('flarum.locales');
|
||||
$translator = $this->app()->getContainer()->make(Translator::class);
|
||||
|
||||
$this->assertEquals('ACME invites ACME2 and one other person to her party.', $translator->trans('test.party-invitation', ['gender_of_host' => 'female', 'host' => 'ACME', 'num_guests' => 2, 'guest' => 'ACME2']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function laravel_interface_methods_work()
|
||||
{
|
||||
$this->extend(
|
||||
(new Extend\Locales(dirname(__FILE__, 3).'/fixtures/locales'))
|
||||
);
|
||||
|
||||
$this->app()->getContainer()->make('flarum.locales');
|
||||
$translator = $this->app()->getContainer()->make(Translator::class);
|
||||
|
||||
$args = ['gender_of_host' => 'female', 'host' => 'ACME', 'num_guests' => 2, 'guest' => 'ACME2'];
|
||||
|
||||
$this->assertEquals('ACME invites ACME2 and one other person to her party.', $translator->get('test.party-invitation', $args));
|
||||
// Number doesn't matter
|
||||
$this->assertEquals('ACME invites ACME2 and one other person to her party.', $translator->choice('test.party-invitation', 2, $args));
|
||||
$this->assertEquals('ACME invites ACME2 and one other person to her party.', $translator->choice('test.party-invitation', 50, $args));
|
||||
$this->assertEquals('ACME invites ACME2 and one other person to her party.', $translator->choice('test.party-invitation', -1000, $args));
|
||||
$this->assertEquals('ACME invites ACME2 and one other person to her party.', $translator->choice('test.party-invitation', null, $args));
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@
|
|||
</p>
|
||||
<p>
|
||||
<a href="{{ $url->to('forum')->base() }}">
|
||||
{{ $translator->trans('core.views.error.not_found_return_link', ['{forum}' => $settings->get('forum_title')]) }}
|
||||
{{ $translator->trans('core.views.error.not_found_return_link', ['forum' => $settings->get('forum_title')]) }}
|
||||
</a>
|
||||
</p>
|
||||
@endsection
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
@section('title', $translator->trans('core.views.log_out.title'))
|
||||
|
||||
@section('content')
|
||||
<p>{{ $translator->trans('core.views.log_out.log_out_confirmation', ['{forum}' => $settings->get('forum_title')]) }}</p>
|
||||
<p>{{ $translator->trans('core.views.log_out.log_out_confirmation', ['forum' => $settings->get('forum_title')]) }}</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ $url }}" class="button">
|
||||
|
|
Loading…
Reference in New Issue
Block a user