Added Translation Providers

Co-authored-by: Olivier Dolbeau <github@a.bbnt.me>
5.3
Mathieu Santostefano 2020-04-27 01:21:26 +02:00
parent be384cf221
commit 6e55fa84b7
No known key found for this signature in database
GPG Key ID: EB610773AF2B5B5B
58 changed files with 3616 additions and 51 deletions

View File

@ -44,6 +44,8 @@ FrameworkBundle
* Deprecate the `KernelTestCase::$container` property, use `KernelTestCase::getContainer()` instead
* Rename the container parameter `profiler_listener.only_master_requests` to `profiler_listener.only_main_requests`
* Deprecate registering workflow services as public
* Deprecate option `--xliff-version` of the `translation:update` command, use e.g. `--format=xlf20` instead
* Deprecate option `--output-format` of the `translation:update` command, use e.g. `--format=xlf20` instead
HttpFoundation
--------------

View File

@ -86,6 +86,8 @@ FrameworkBundle
* Removed the `lock.RESOURCE_NAME` and `lock.RESOURCE_NAME.store` services and the `lock`, `LockInterface`, `lock.store` and `PersistingStoreInterface` aliases, use `lock.RESOURCE_NAME.factory`, `lock.factory` or `LockFactory` instead.
* Remove the `KernelTestCase::$container` property, use `KernelTestCase::getContainer()` instead
* Registered workflow services are now private
* Remove option `--xliff-version` of the `translation:update` command, use e.g. `--output-format=xlf20` instead
* Remove option `--output-format` of the `translation:update` command, use e.g. `--output-format=xlf20` instead
HttpFoundation
--------------

2
link
View File

@ -41,7 +41,7 @@ if (!is_dir("$pathToProject/vendor/symfony")) {
$sfPackages = array('symfony/symfony' => __DIR__);
$filesystem = new Filesystem();
$braces = array('Bundle', 'Bridge', 'Component', 'Component/Security', 'Component/Mailer/Bridge', 'Component/Messenger/Bridge', 'Component/Notifier/Bridge', 'Contracts');
$braces = array('Bundle', 'Bridge', 'Component', 'Component/Security', 'Component/Mailer/Bridge', 'Component/Messenger/Bridge', 'Component/Notifier/Bridge', 'Contracts', 'Component/Translation/Bridge');
$directories = array_merge(...array_values(array_map(function ($part) {
return glob(__DIR__.'/src/Symfony/'.$part.'/*', GLOB_ONLYDIR | GLOB_NOSORT);
}, $braces)));

View File

@ -20,6 +20,8 @@ CHANGELOG
* Rename the container parameter `profiler_listener.only_master_requests` to `profiler_listener.only_main_requests`
* Add service `fragment.uri_generator` to generate the URI of a fragment
* Deprecate registering workflow services as public
* Deprecate option `--xliff-version` of the `translation:update` command, use e.g. `--format=xlf20` instead
* Deprecate option `--output-format` of the `translation:update` command, use e.g. `--format=xlf20` instead
5.2.0
-----

View File

@ -77,12 +77,13 @@ class TranslationUpdateCommand extends Command
new InputArgument('locale', InputArgument::REQUIRED, 'The locale'),
new InputArgument('bundle', InputArgument::OPTIONAL, 'The bundle name or directory where to load the messages'),
new InputOption('prefix', null, InputOption::VALUE_OPTIONAL, 'Override the default prefix', '__'),
new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf'),
new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format (deprecated)'),
new InputOption('format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf12'),
new InputOption('dump-messages', null, InputOption::VALUE_NONE, 'Should the messages be dumped in the console'),
new InputOption('force', null, InputOption::VALUE_NONE, 'Should the update be done'),
new InputOption('clean', null, InputOption::VALUE_NONE, 'Should clean not found messages'),
new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'Specify the domain to update'),
new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version', '1.2'),
new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version (deprecated)'),
new InputOption('sort', null, InputOption::VALUE_OPTIONAL, 'Return list of messages sorted alphabetically', 'asc'),
new InputOption('as-tree', null, InputOption::VALUE_OPTIONAL, 'Dump the messages as a tree-like structure: The given value defines the level where to switch to inline YAML'),
])
@ -112,8 +113,8 @@ You can sort the output with the <comment>--sort</> flag:
You can dump a tree-like structure using the yaml format with <comment>--as-tree</> flag:
<info>php %command.full_name% --force --output-format=yaml --as-tree=3 en AcmeBundle</info>
<info>php %command.full_name% --force --output-format=yaml --sort=asc --as-tree=3 fr</info>
<info>php %command.full_name% --force --format=yaml --as-tree=3 en AcmeBundle</info>
<info>php %command.full_name% --force --format=yaml --sort=asc --as-tree=3 fr</info>
EOF
)
@ -135,13 +136,31 @@ EOF
return 1;
}
$format = $input->getOption('output-format') ?: $input->getOption('format');
$xliffVersion = $input->getOption('xliff-version') ?? '1.2';
if ($input->getOption('xliff-version')) {
trigger_deprecation('symfony/framework-bundle', '5.3', 'The "--xliff-version" option is deprecated, use "--format=xlf%d" instead.', 10 * $xliffVersion);
}
if ($input->getOption('output-format')) {
trigger_deprecation('symfony/framework-bundle', '5.3', 'The "--output-format" option is deprecated, use "--format=xlf%d" instead.', 10 * $xliffVersion);
}
switch ($format) {
case 'xlf20': $xliffVersion = '2.0';
// no break
case 'xlf12': $format = 'xlf';
}
// check format
$supportedFormats = $this->writer->getFormats();
if (!\in_array($input->getOption('output-format'), $supportedFormats, true)) {
$errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).'.']);
if (!\in_array($format, $supportedFormats, true)) {
$errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).', xlf12 and xlf20.']);
return 1;
}
/** @var KernelInterface $kernel */
$kernel = $this->getApplication()->getKernel();
@ -225,23 +244,7 @@ EOF
$resultMessage = 'Translation files were successfully updated';
// move new messages to intl domain when possible
if (class_exists(\MessageFormatter::class)) {
foreach ($operation->getDomains() as $domain) {
$intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
$newMessages = $operation->getNewMessages($domain);
if ([] === $newMessages || ([] === $currentCatalogue->all($intlDomain) && [] !== $currentCatalogue->all($domain))) {
continue;
}
$result = $operation->getResult();
$allIntlMessages = $result->all($intlDomain);
$currentMessages = array_diff_key($newMessages, $result->all($domain));
$result->replace($currentMessages, $domain);
$result->replace($allIntlMessages + $newMessages, $intlDomain);
}
}
$operation->moveMessagesToIntlDomainsIfPossible('new');
// show compiled list of messages
if (true === $input->getOption('dump-messages')) {
@ -284,8 +287,8 @@ EOF
$extractedMessagesCount += $domainMessagesCount;
}
if ('xlf' === $input->getOption('output-format')) {
$io->comment(sprintf('Xliff output version is <info>%s</info>', $input->getOption('xliff-version')));
if ('xlf' === $format) {
$io->comment(sprintf('Xliff output version is <info>%s</info>', $xliffVersion));
}
$resultMessage = sprintf('%d message%s successfully extracted', $extractedMessagesCount, $extractedMessagesCount > 1 ? 's were' : ' was');
@ -306,7 +309,7 @@ EOF
$bundleTransPath = end($transPaths);
}
$this->writer->write($operation->getResult(), $input->getOption('output-format'), ['path' => $bundleTransPath, 'default_locale' => $this->defaultLocale, 'xliff_version' => $input->getOption('xliff-version'), 'as_tree' => $input->getOption('as-tree'), 'inline' => $input->getOption('as-tree') ?? 0]);
$this->writer->write($operation->getResult(), $format, ['path' => $bundleTransPath, 'default_locale' => $this->defaultLocale, 'xliff_version' => $xliffVersion, 'as_tree' => $input->getOption('as-tree'), 'inline' => $input->getOption('as-tree') ?? 0]);
if (true === $input->getOption('dump-messages')) {
$resultMessage .= ' and translation files were updated';
@ -335,11 +338,13 @@ EOF
foreach ($catalogue->getResources() as $resource) {
$filteredCatalogue->addResource($resource);
}
if ($metadata = $catalogue->getMetadata('', $intlDomain)) {
foreach ($metadata as $k => $v) {
$filteredCatalogue->setMetadata($k, $v, $intlDomain);
}
}
if ($metadata = $catalogue->getMetadata('', $domain)) {
foreach ($metadata as $k => $v) {
$filteredCatalogue->setMetadata($k, $v, $domain);

View File

@ -85,6 +85,7 @@ class UnusedTagsPass implements CompilerPassInterface
'translation.dumper',
'translation.extractor',
'translation.loader',
'translation.provider_factory',
'twig.extension',
'twig.loader',
'twig.runtime',

View File

@ -785,6 +785,7 @@ class Configuration implements ConfigurationInterface
->fixXmlConfig('fallback')
->fixXmlConfig('path')
->fixXmlConfig('enabled_locale')
->fixXmlConfig('provider')
->children()
->arrayNode('fallbacks')
->info('Defaults to the value of "default_locale".')
@ -822,6 +823,27 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->arrayNode('providers')
->info('Translation providers you can read/write your translations from')
->useAttributeAsKey('name')
->prototype('array')
->fixXmlConfig('domain')
->fixXmlConfig('locale')
->children()
->scalarNode('dsn')->end()
->arrayNode('domains')
->prototype('scalar')->end()
->defaultValue([])
->end()
->arrayNode('locales')
->prototype('scalar')->end()
->defaultValue([])
->info('If not set, all locales listed under framework.translator.enabled_locales are used.')
->end()
->end()
->end()
->defaultValue([])
->end()
->end()
->end()
->end()

View File

@ -169,6 +169,7 @@ use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\String\LazyString;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProviderFactory;
use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand;
use Symfony\Component\Translation\PseudoLocalizationTranslator;
use Symfony\Component\Translation\Translator;
@ -1222,11 +1223,14 @@ class FrameworkExtension extends Extension
if (!$this->isConfigEnabled($container, $config)) {
$container->removeDefinition('console.command.translation_debug');
$container->removeDefinition('console.command.translation_update');
$container->removeDefinition('console.command.translation_pull');
$container->removeDefinition('console.command.translation_push');
return;
}
$loader->load('translation.php');
$loader->load('translation_providers.php');
// Use the "real" translator instead of the identity default
$container->setAlias('translator', 'translator.default')->setPublic(true);
@ -1348,6 +1352,46 @@ class FrameworkExtension extends Extension
$options,
]);
}
$classToServices = [
LocoProviderFactory::class => 'translation.provider_factory.loco',
];
$parentPackages = ['symfony/framework-bundle', 'symfony/translation', 'symfony/http-client'];
foreach ($classToServices as $class => $service) {
$package = sprintf('symfony/%s-translation', substr($service, \strlen('translation.provider_factory.')));
if (!$container->hasDefinition('http_client') || !ContainerBuilder::willBeAvailable($package, $class, $parentPackages)) {
$container->removeDefinition($service);
}
}
if (!$config['providers']) {
return;
}
foreach ($config['providers'] as $name => $provider) {
if (!$config['enabled_locales'] && !$provider['locales']) {
throw new LogicException(sprintf('You must specify one of "framework.translator.enabled_locales" or "framework.translator.providers.%s.locales" in order to use translation providers.', $name));
}
}
$container->getDefinition('console.command.translation_pull')
->replaceArgument(4, array_merge($transPaths, [$config['default_path']]))
->replaceArgument(5, $config['enabled_locales'])
;
$container->getDefinition('console.command.translation_push')
->replaceArgument(2, array_merge($transPaths, [$config['default_path']]))
->replaceArgument(3, $config['enabled_locales'])
;
$container->getDefinition('translation.provider_collection_factory')
->replaceArgument(1, $config['enabled_locales'])
;
$container->getDefinition('translation.provider_collection')->setArgument(0, $config['providers']);
}
private function registerValidationConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $propertyInfoEnabled)

View File

@ -46,6 +46,8 @@ use Symfony\Component\Messenger\Command\FailedMessagesRetryCommand;
use Symfony\Component\Messenger\Command\FailedMessagesShowCommand;
use Symfony\Component\Messenger\Command\SetupTransportsCommand;
use Symfony\Component\Messenger\Command\StopWorkersCommand;
use Symfony\Component\Translation\Command\TranslationPullCommand;
use Symfony\Component\Translation\Command\TranslationPushCommand;
use Symfony\Component\Translation\Command\XliffLintCommand;
use Symfony\Component\Validator\Command\DebugCommand as ValidatorDebugCommand;
@ -232,6 +234,26 @@ return static function (ContainerConfigurator $container) {
])
->tag('console.command')
->set('console.command.translation_pull', TranslationPullCommand::class)
->args([
service('translation.provider_collection'),
service('translation.writer'),
service('translation.reader'),
param('kernel.default_locale'),
[], // Translator paths
[], // Enabled locales
])
->tag('console.command', ['command' => 'translation:pull'])
->set('console.command.translation_push', TranslationPushCommand::class)
->args([
service('translation.provider_collection'),
service('translation.reader'),
[], // Translator paths
[], // Enabled locales
])
->tag('console.command', ['command' => 'translation:push'])
->set('console.command.workflow_dump', WorkflowDumpCommand::class)
->tag('console.command')

View File

@ -176,6 +176,7 @@
<xsd:element name="path" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="enabled-locale" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="pseudo-localization" type="pseudo_localization" minOccurs="0" maxOccurs="1" />
<xsd:element name="provider" type="translation_provider" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="enabled" type="xsd:boolean" />
<xsd:attribute name="fallback" type="xsd:string" />
@ -195,6 +196,15 @@
<xsd:attribute name="parse_html" type="xsd:boolean" />
</xsd:complexType>
<xsd:complexType name="translation_provider">
<xsd:sequence>
<xsd:element name="domain" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="locale" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="dsn" type="xsd:string" />
</xsd:complexType>
<xsd:complexType name="validation">
<xsd:choice minOccurs="0" maxOccurs="unbounded">
<xsd:element name="static-method" type="xsd:string" />

View File

@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProviderFactory;
use Symfony\Component\Translation\Provider\NullProviderFactory;
use Symfony\Component\Translation\Provider\TranslationProviderCollection;
use Symfony\Component\Translation\Provider\TranslationProviderCollectionFactory;
return static function (ContainerConfigurator $container) {
$container->services()
->set('translation.provider_collection', TranslationProviderCollection::class)
->factory([service('translation.provider_collection_factory'), 'fromConfig'])
->args([
[], // Providers
])
->set('translation.provider_collection_factory', TranslationProviderCollectionFactory::class)
->args([
tagged_iterator('translation.provider_factory'),
[], // Enabled locales
])
->set('translation.provider_factory.null', NullProviderFactory::class)
->tag('translation.provider_factory')
->set('translation.provider_factory.loco', LocoProviderFactory::class)
->args([
service('http_client'),
service('logger'),
param('kernel.default_locale'),
service('translation.loader.xliff'),
])
->tag('translation.provider_factory')
;
};

View File

@ -418,6 +418,7 @@ class ConfigurationTest extends TestCase
'parse_html' => false,
'localizable_html_attributes' => [],
],
'providers' => [],
],
'validation' => [
'enabled' => !class_exists(FullStack::class),

View File

@ -0,0 +1,4 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore

View File

@ -0,0 +1,4 @@
vendor/
composer.lock
phpunit.xml
.phpunit.result.cache

View File

@ -0,0 +1,7 @@
CHANGELOG
=========
5.3
---
* Create the bridge

View File

@ -0,0 +1,19 @@
Copyright (c) 2021 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,245 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Bridge\Loco\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Component\Translation\Exception\ProviderException;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\Provider\ProviderInterface;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\Component\Translation\TranslatorBagInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*
* In Loco:
* * Tags refers to Symfony's translation domains
* * Assets refers to Symfony's translation keys
* * Translations refers to Symfony's translated messages
*
* @experimental in 5.3
*/
final class LocoProvider implements ProviderInterface
{
private $client;
private $loader;
private $logger;
private $defaultLocale;
private $endpoint;
public function __construct(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint)
{
$this->client = $client;
$this->loader = $loader;
$this->logger = $logger;
$this->defaultLocale = $defaultLocale;
$this->endpoint = $endpoint;
}
public function __toString(): string
{
return sprintf('%s://%s', LocoProviderFactory::SCHEME, $this->endpoint);
}
public function write(TranslatorBagInterface $translatorBag): void
{
$catalogue = $translatorBag->getCatalogue($this->defaultLocale);
if (!$catalogue) {
$catalogue = $translatorBag->getCatalogues()[0];
}
// Create keys on Loco
foreach ($catalogue->all() as $domain => $messages) {
$ids = [];
foreach ($messages as $id => $message) {
$ids[] = $id;
$this->createAsset($id);
}
if ($ids) {
$this->tagsAssets($ids, $domain);
}
}
// Push translations in all locales and tag them with domain
foreach ($translatorBag->getCatalogues() as $catalogue) {
$locale = $catalogue->getLocale();
if (!\in_array($locale, $this->getLocales())) {
$this->createLocale($locale);
}
foreach ($catalogue->all() as $messages) {
foreach ($messages as $id => $message) {
$this->translateAsset($id, $message, $locale);
}
}
}
}
public function read(array $domains, array $locales): TranslatorBag
{
$domains = $domains ?: ['*'];
$translatorBag = new TranslatorBag();
foreach ($locales as $locale) {
foreach ($domains as $domain) {
$response = $this->client->request('GET', sprintf('export/locale/%s.xlf?filter=%s&status=translated', $locale, $domain));
if (404 === $response->getStatusCode()) {
$this->logger->error(sprintf('Locale "%s" for domain "%s" does not exist in Loco.', $locale, $domain));
continue;
}
$responseContent = $response->getContent(false);
if (200 !== $response->getStatusCode()) {
throw new ProviderException('Unable to read the Loco response: '.$responseContent, $response);
}
$translatorBag->addCatalogue($this->loader->load($responseContent, $locale, $domain));
}
}
return $translatorBag;
}
public function delete(TranslatorBagInterface $translatorBag): void
{
$deletedIds = [];
foreach ($translatorBag->getCatalogues() as $catalogue) {
foreach ($catalogue->all() as $messages) {
foreach ($messages as $id => $message) {
if (\in_array($id, $deletedIds, true)) {
continue;
}
$this->deleteAsset($id);
$deletedIds[] = $id;
}
}
}
}
private function createAsset(string $id): void
{
$response = $this->client->request('POST', 'assets', [
'body' => [
'name' => $id,
'id' => $id,
'type' => 'text',
'default' => 'untranslated',
],
]);
if (409 === $response->getStatusCode()) {
$this->logger->info(sprintf('Translation key "%s" already exists in Loco.', $id), [
'id' => $id,
]);
} elseif (201 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to add new translation key "%s" to Loco: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false)));
}
}
private function translateAsset(string $id, string $message, string $locale): void
{
$response = $this->client->request('POST', sprintf('translations/%s/%s', $id, $locale), [
'body' => $message,
]);
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to add translation message "%s" (for key: "%s" in locale "%s") to Loco: "%s".', $message, $id, $locale, $response->getContent(false)));
}
}
private function tagsAssets(array $ids, string $tag): void
{
$idsAsString = implode(',', array_unique($ids));
if (!\in_array($tag, $this->getTags(), true)) {
$this->createTag($tag);
}
$response = $this->client->request('POST', sprintf('tags/%s.json', $tag), [
'body' => $idsAsString,
]);
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to add tag "%s" on translation keys "%s" to Loco: "%s".', $tag, $idsAsString, $response->getContent(false)));
}
}
private function createTag(string $tag): void
{
$response = $this->client->request('POST', 'tags.json', [
'body' => [
'name' => $tag,
],
]);
if (201 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to create tag "%s" on Loco: "%s".', $tag, $response->getContent(false)));
}
}
private function getTags(): array
{
$response = $this->client->request('GET', 'tags.json');
$content = $response->toArray(false);
if (200 !== $response->getStatusCode()) {
throw new ProviderException(sprintf('Unable to get tags on Loco: "%s".', $response->getContent(false)), $response);
}
return $content ?: [];
}
private function createLocale(string $locale): void
{
$response = $this->client->request('POST', 'locales', [
'body' => [
'code' => $locale,
],
]);
if (201 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to create locale "%s" on Loco: "%s".', $locale, $response->getContent(false)));
}
}
private function getLocales(): array
{
$response = $this->client->request('GET', 'locales');
$content = $response->toArray(false);
if (200 !== $response->getStatusCode()) {
throw new ProviderException(sprintf('Unable to get locales on Loco: "%s".', $response->getContent(false)), $response);
}
return array_reduce($content, function ($carry, $locale) {
$carry[] = $locale['code'];
return $carry;
}, []);
}
private function deleteAsset(string $id): void
{
$response = $this->client->request('DELETE', sprintf('assets/%s.json', $id));
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to delete translation key "%s" to Loco: "%s".', $id, $response->getContent(false)));
}
}
}

View File

@ -0,0 +1,69 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Translation\Bridge\Loco\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Component\Translation\Exception\UnsupportedSchemeException;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\Provider\AbstractProviderFactory;
use Symfony\Component\Translation\Provider\Dsn;
use Symfony\Component\Translation\Provider\ProviderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Mathieu Santostefano <msantostefano@protonmail.com>
*
* @experimental in 5.3
*/
final class LocoProviderFactory extends AbstractProviderFactory
{
public const SCHEME = 'loco';
private const HOST = 'localise.biz/api/';
private $client;
private $logger;
private $defaultLocale;
private $loader;
public function __construct(HttpClientInterface $client, LoggerInterface $logger, string $defaultLocale, LoaderInterface $loader)
{
$this->client = $client;
$this->logger = $logger;
$this->defaultLocale = $defaultLocale;
$this->loader = $loader;
}
/**
* @return LocoProvider
*/
public function create(Dsn $dsn): ProviderInterface
{
if (self::SCHEME !== $dsn->getScheme()) {
throw new UnsupportedSchemeException($dsn, self::SCHEME, $this->getSupportedSchemes());
}
$endpoint = sprintf('%s%s', 'default' === $dsn->getHost() ? self::HOST : $dsn->getHost(), $dsn->getPort() ? ':'.$dsn->getPort() : '');
$client = $this->client->withOptions([
'base_uri' => 'https://'.$endpoint,
'headers' => [
'Authorization' => 'Loco '.$this->getUser($dsn),
],
]);
return new LocoProvider($client, $this->loader, $this->logger, $this->defaultLocale, $endpoint);
}
protected function getSupportedSchemes(): array
{
return [self::SCHEME];
}
}

View File

@ -0,0 +1,25 @@
Loco Translation Provider
=========================
Provides Loco integration for Symfony Translation.
DSN example
-----------
```
// .env file
LOCO_DSN=loco://API_KEY@default
```
where:
- `API_KEY` is your Loco project API key
[more information on Loco website](https://localise.biz/help/developers/api-keys)
Resources
---------
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)

View File

@ -0,0 +1,39 @@
<?php
namespace Symfony\Component\Translation\Bridge\Loco\Tests;
use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProviderFactory;
use Symfony\Component\Translation\Provider\ProviderFactoryInterface;
use Symfony\Component\Translation\Tests\ProviderFactoryTestCase;
class LocoProviderFactoryTest extends ProviderFactoryTestCase
{
public function supportsProvider(): iterable
{
yield [true, 'loco://API_KEY@default'];
yield [false, 'somethingElse://API_KEY@default'];
}
public function unsupportedSchemeProvider(): iterable
{
yield ['somethingElse://API_KEY@default'];
}
public function createProvider(): iterable
{
yield [
'loco://localise.biz/api/',
'loco://API_KEY@default',
];
}
public function incompleteDsnProvider(): iterable
{
yield ['loco://default'];
}
public function createFactory(): ProviderFactoryInterface
{
return new LocoProviderFactory($this->getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader());
}
}

View File

@ -0,0 +1,516 @@
<?php
namespace Symfony\Component\Translation\Bridge\Loco\Tests;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProvider;
use Symfony\Component\Translation\Loader\ArrayLoader;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\Loader\XliffFileLoader;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Provider\ProviderInterface;
use Symfony\Component\Translation\Tests\ProviderTestCase;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class LocoProviderTest extends ProviderTestCase
{
public function createProvider(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint): ProviderInterface
{
return new LocoProvider($client, $loader, $logger, $defaultLocale, $endpoint);
}
public function testCompleteWriteProcess()
{
$createAssetResponse = $this->createMock(ResponseInterface::class);
$createAssetResponse->expects($this->exactly(4))
->method('getStatusCode')
->willReturn(201);
$getLocalesResponse = $this->createMock(ResponseInterface::class);
$getLocalesResponse->expects($this->exactly(4))
->method('getStatusCode')
->willReturn(200);
$getLocalesResponse->expects($this->exactly(2))
->method('getContent')
->with(false)
->willReturn('[{"code":"en"}]');
$createLocaleResponse = $this->createMock(ResponseInterface::class);
$createLocaleResponse->expects($this->exactly(2))
->method('getStatusCode')
->willReturn(201);
$translateAssetResponse = $this->createMock(ResponseInterface::class);
$translateAssetResponse->expects($this->exactly(8))
->method('getStatusCode')
->willReturn(200);
$getTagsEmptyResponse = $this->createMock(ResponseInterface::class);
$getTagsEmptyResponse->expects($this->exactly(2))
->method('getStatusCode')
->willReturn(200);
$getTagsEmptyResponse->expects($this->once())
->method('getContent')
->with(false)
->willReturn('[]');
$getTagsNotEmptyResponse = $this->createMock(ResponseInterface::class);
$getTagsNotEmptyResponse->expects($this->exactly(2))
->method('getStatusCode')
->willReturn(200);
$getTagsNotEmptyResponse->expects($this->once())
->method('getContent')
->with(false)
->willReturn('["messages"]');
$createTagResponse = $this->createMock(ResponseInterface::class);
$createTagResponse->expects($this->exactly(4))
->method('getStatusCode')
->willReturn(201);
$tagAssetResponse = $this->createMock(ResponseInterface::class);
$tagAssetResponse->expects($this->exactly(4))
->method('getStatusCode')
->willReturn(200);
$expectedAuthHeader = 'Authorization: Loco API_KEY';
$responses = [
'createAsset1' => function (string $method, string $url, array $options = []) use ($createAssetResponse, $expectedAuthHeader): ResponseInterface {
$expectedBody = http_build_query([
'name' => 'a',
'id' => 'a',
'type' => 'text',
'default' => 'untranslated',
]);
$this->assertEquals('POST', $method);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals($expectedBody, $options['body']);
return $createAssetResponse;
},
'getTags1' => function (string $method, string $url, array $options = []) use ($getTagsEmptyResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('GET', $method);
$this->assertEquals('https://localise.biz/api/tags.json', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
return $getTagsEmptyResponse;
},
'createTag1' => function (string $method, string $url, array $options = []) use ($createTagResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/tags.json', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals(http_build_query(['name' => 'messages']), $options['body']);
return $createTagResponse;
},
'tagAsset1' => function (string $method, string $url, array $options = []) use ($tagAssetResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/tags/messages.json', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals('a', $options['body']);
return $tagAssetResponse;
},
'createAsset2' => function (string $method, string $url, array $options = []) use ($createAssetResponse, $expectedAuthHeader): ResponseInterface {
$expectedBody = http_build_query([
'name' => 'post.num_comments',
'id' => 'post.num_comments',
'type' => 'text',
'default' => 'untranslated',
]);
$this->assertEquals('POST', $method);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals($expectedBody, $options['body']);
return $createAssetResponse;
},
'getTags2' => function (string $method, string $url, array $options = []) use ($getTagsNotEmptyResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('GET', $method);
$this->assertEquals('https://localise.biz/api/tags.json', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
return $getTagsNotEmptyResponse;
},
'createTag2' => function (string $method, string $url, array $options = []) use ($createTagResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/tags.json', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals(http_build_query(['name' => 'validators']), $options['body']);
return $createTagResponse;
},
'tagAsset2' => function (string $method, string $url, array $options = []) use ($tagAssetResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/tags/validators.json', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals('post.num_comments', $options['body']);
return $tagAssetResponse;
},
'getLocales1' => function (string $method, string $url, array $options = []) use ($getLocalesResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('GET', $method);
$this->assertEquals('https://localise.biz/api/locales', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
return $getLocalesResponse;
},
'translateAsset1' => function (string $method, string $url, array $options = []) use ($translateAssetResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/translations/a/en', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals('trans_en_a', $options['body']);
return $translateAssetResponse;
},
'translateAsset2' => function (string $method, string $url, array $options = []) use ($translateAssetResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/translations/post.num_comments/en', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals('{count, plural, one {# comment} other {# comments}}', $options['body']);
return $translateAssetResponse;
},
'getLocales2' => function (string $method, string $url, array $options = []) use ($getLocalesResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('GET', $method);
$this->assertEquals('https://localise.biz/api/locales', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
return $getLocalesResponse;
},
'createLocale1' => function (string $method, string $url, array $options = []) use ($createLocaleResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/locales', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals('code=fr', $options['body']);
return $createLocaleResponse;
},
'translateAsset3' => function (string $method, string $url, array $options = []) use ($translateAssetResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/translations/a/fr', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals('trans_fr_a', $options['body']);
return $translateAssetResponse;
},
'translateAsset4' => function (string $method, string $url, array $options = []) use ($translateAssetResponse, $expectedAuthHeader): ResponseInterface {
$this->assertEquals('POST', $method);
$this->assertEquals('https://localise.biz/api/translations/post.num_comments/fr', $url);
$this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]);
$this->assertEquals('{count, plural, one {# commentaire} other {# commentaires}}', $options['body']);
return $translateAssetResponse;
},
];
$translatorBag = new TranslatorBag();
$translatorBag->addCatalogue(new MessageCatalogue('en', [
'messages' => ['a' => 'trans_en_a'],
'validators' => ['post.num_comments' => '{count, plural, one {# comment} other {# comments}}'],
]));
$translatorBag->addCatalogue(new MessageCatalogue('fr', [
'messages' => ['a' => 'trans_fr_a'],
'validators' => ['post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}'],
]));
$provider = $this->createProvider((new MockHttpClient($responses))->withOptions([
'base_uri' => 'https://localise.biz/api/',
'headers' => [
'Authorization' => 'Loco API_KEY',
],
]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/');
$provider->write($translatorBag);
}
/**
* @dataProvider getLocoResponsesForOneLocaleAndOneDomain
*/
public function testReadForOneLocaleAndOneDomain(string $locale, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag)
{
$response = $this->createMock(ResponseInterface::class);
$response->expects($this->once())
->method('getContent')
->willReturn($responseContent);
$response->expects($this->exactly(2))
->method('getStatusCode')
->willReturn(200);
$loader = $this->getLoader();
$loader->expects($this->once())
->method('load')
->willReturn($expectedTranslatorBag->getCatalogue($locale));
$locoProvider = $this->createProvider((new MockHttpClient($response))->withOptions([
'base_uri' => 'https://localise.biz/api/',
'headers' => [
'Authorization' => 'Loco API_KEY',
],
]), $loader, $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/');
$translatorBag = $locoProvider->read([$domain], [$locale]);
$this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues());
}
/**
* @dataProvider getLocoResponsesForManyLocalesAndManyDomains
*/
public function testReadForManyLocalesAndManyDomains(array $locales, array $domains, array $responseContents, array $expectedTranslatorBags)
{
foreach ($locales as $locale) {
foreach ($domains as $domain) {
$response = $this->createMock(ResponseInterface::class);
$response->expects($this->once())
->method('getContent')
->willReturn($responseContents[$domain][$locale]);
$response->expects($this->exactly(2))
->method('getStatusCode')
->willReturn(200);
$locoProvider = new LocoProvider((new MockHttpClient($response))->withOptions([
'base_uri' => 'https://localise.biz/api/',
'headers' => [
'Authorization' => 'Loco API_KEY',
],
]), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/');
$translatorBag = $locoProvider->read([$domain], [$locale]);
// We don't want to assert equality of metadata here, due to the ArrayLoader usage.
$translatorBag->getCatalogue($locale)->deleteMetadata('foo', '');
$this->assertEquals($expectedTranslatorBags[$domain]->getCatalogue($locale), $translatorBag->getCatalogue($locale));
}
}
}
public function toStringProvider(): iterable
{
yield [
new LocoProvider($this->getClient()->withOptions([
'base_uri' => 'https://localise.biz/api/',
'headers' => [
'Authorization' => 'Loco API_KEY',
],
]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'),
'loco://localise.biz/api/',
];
yield [
new LocoProvider($this->getClient()->withOptions([
'base_uri' => 'https://example.com',
'headers' => [
'Authorization' => 'Loco API_KEY',
],
]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'example.com'),
'loco://example.com',
];
yield [
new LocoProvider($this->getClient()->withOptions([
'base_uri' => 'https://example.com:99',
'headers' => [
'Authorization' => 'Loco API_KEY',
],
]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'example.com:99'),
'loco://example.com:99',
];
}
public function getLocoResponsesForOneLocaleAndOneDomain(): \Generator
{
$arrayLoader = new ArrayLoader();
$expectedTranslatorBagEn = new TranslatorBag();
$expectedTranslatorBagEn->addCatalogue($arrayLoader->load([
'index.hello' => 'Hello',
'index.greetings' => 'Welcome, {firstname}!',
], 'en', 'messages'));
yield ['en', 'messages', <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Hello</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="loco:format=icu">
<source>index.greetings</source>
<target state="translated">Welcome, {firstname}!</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
$expectedTranslatorBagEn,
];
$expectedTranslatorBagFr = new TranslatorBag();
$expectedTranslatorBagFr->addCatalogue($arrayLoader->load([
'index.hello' => 'Bonjour',
'index.greetings' => 'Bienvenue, {firstname} !',
], 'fr', 'messages'));
yield ['fr', 'messages', <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Bonjour</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="loco:format=icu">
<source>index.greetings</source>
<target state="translated">Bienvenue, {firstname} !</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
$expectedTranslatorBagFr,
];
}
public function getLocoResponsesForManyLocalesAndManyDomains(): \Generator
{
$arrayLoader = new ArrayLoader();
$expectedTranslatorBagMessages = new TranslatorBag();
$expectedTranslatorBagMessages->addCatalogue($arrayLoader->load([
'index.hello' => 'Hello',
'index.greetings' => 'Welcome, {firstname}!',
], 'en', 'messages'));
$expectedTranslatorBagMessages->addCatalogue($arrayLoader->load([
'index.hello' => 'Bonjour',
'index.greetings' => 'Bienvenue, {firstname} !',
], 'fr', 'messages'));
$expectedTranslatorBagValidators = new TranslatorBag();
$expectedTranslatorBagValidators->addCatalogue($arrayLoader->load([
'firstname.error' => 'Firstname must contains only letters.',
'lastname.error' => 'Lastname must contains only letters.',
], 'en', 'validators'));
$expectedTranslatorBagValidators->addCatalogue($arrayLoader->load([
'firstname.error' => 'Le prénom ne peut contenir que des lettres.',
'lastname.error' => 'Le nom de famille ne peut contenir que des lettres.',
], 'fr', 'validators'));
yield [
['en', 'fr'],
['messages', 'validators'],
[
'messages' => [
'en' => <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Hello</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="loco:format=icu">
<source>index.greetings</source>
<target state="translated">Welcome, {firstname}!</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
'fr' => <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Bonjour</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="loco:format=icu">
<source>index.greetings</source>
<target state="translated">Bienvenue, {firstname} !</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
],
'validators' => [
'en' => <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f68" resname="firstname.error" datatype="plaintext">
<source>firstname.error</source>
<target state="translated">Firstname must contains only letters.</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e3" resname="lastname.error" datatype="plaintext" extradata="loco:format=icu">
<source>lastname.error</source>
<target state="translated">Lastname must contains only letters.</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
'fr' => <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f68" resname="firstname.error" datatype="plaintext">
<source>firstname.error</source>
<target state="translated">Le prénom ne peut contenir que des lettres.</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e3" resname="lastname.error" datatype="plaintext" extradata="loco:format=icu">
<source>lastname.error</source>
<target state="translated">Le nom de famille ne peut contenir que des lettres.</target>
</trans-unit>