Switch to ICU MessageFormat (#2759)

This commit is contained in:
Alexander Skvortsov 2021-04-30 12:44:39 -04:00 committed by GitHub
parent edaf45d133
commit b45519974a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 19152 additions and 3351 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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: [

View File

@ -163,7 +163,7 @@ export default class Application {
load(payload) {
this.data = payload;
this.translator.locale = payload.locale;
this.translator.setLocale(payload.locale);
}
boot() {

View File

@ -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);
}
}

View File

@ -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);
}
/**

View File

@ -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();

View File

@ -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

View File

@ -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);

View File

@ -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;
}

View File

@ -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()
);
}

View File

@ -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;
}

View File

@ -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)

View File

@ -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;
});

View File

@ -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);
}
/**

View File

@ -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')
];
}

View File

@ -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));

View File

@ -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')
];
}
}

View File

@ -0,0 +1,2 @@
test:
hello-intl: World-intl {name}

23
tests/fixtures/locales/en.yml vendored Normal file
View 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.}
}}
}

View 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));
}
}

View File

@ -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

View File

@ -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">