├── CHANGELOG.md ├── Catalogue ├── AbstractOperation.php ├── MergeOperation.php ├── OperationInterface.php └── TargetOperation.php ├── CatalogueMetadataAwareInterface.php ├── Command ├── TranslationLintCommand.php ├── TranslationPullCommand.php ├── TranslationPushCommand.php ├── TranslationTrait.php └── XliffLintCommand.php ├── DataCollector └── TranslationDataCollector.php ├── DataCollectorTranslator.php ├── DependencyInjection ├── DataCollectorTranslatorPass.php ├── LoggingTranslatorPass.php ├── TranslationDumperPass.php ├── TranslationExtractorPass.php ├── TranslatorPass.php └── TranslatorPathsPass.php ├── Dumper ├── CsvFileDumper.php ├── DumperInterface.php ├── FileDumper.php ├── IcuResFileDumper.php ├── IniFileDumper.php ├── JsonFileDumper.php ├── MoFileDumper.php ├── PhpFileDumper.php ├── PoFileDumper.php ├── QtFileDumper.php ├── XliffFileDumper.php └── YamlFileDumper.php ├── Exception ├── ExceptionInterface.php ├── IncompleteDsnException.php ├── InvalidArgumentException.php ├── InvalidResourceException.php ├── LogicException.php ├── MissingRequiredOptionException.php ├── NotFoundResourceException.php ├── ProviderException.php ├── ProviderExceptionInterface.php ├── RuntimeException.php └── UnsupportedSchemeException.php ├── Extractor ├── AbstractFileExtractor.php ├── ChainExtractor.php ├── ExtractorInterface.php ├── PhpAstExtractor.php └── Visitor │ ├── AbstractVisitor.php │ ├── ConstraintVisitor.php │ ├── TransMethodVisitor.php │ └── TranslatableMessageVisitor.php ├── Formatter ├── IntlFormatter.php ├── IntlFormatterInterface.php ├── MessageFormatter.php └── MessageFormatterInterface.php ├── IdentityTranslator.php ├── LICENSE ├── Loader ├── ArrayLoader.php ├── CsvFileLoader.php ├── FileLoader.php ├── IcuDatFileLoader.php ├── IcuResFileLoader.php ├── IniFileLoader.php ├── JsonFileLoader.php ├── LoaderInterface.php ├── MoFileLoader.php ├── PhpFileLoader.php ├── PoFileLoader.php ├── QtFileLoader.php ├── XliffFileLoader.php └── YamlFileLoader.php ├── LocaleSwitcher.php ├── LoggingTranslator.php ├── MessageCatalogue.php ├── MessageCatalogueInterface.php ├── MetadataAwareInterface.php ├── Provider ├── AbstractProviderFactory.php ├── Dsn.php ├── FilteringProvider.php ├── NullProvider.php ├── NullProviderFactory.php ├── ProviderFactoryInterface.php ├── ProviderInterface.php ├── TranslationProviderCollection.php └── TranslationProviderCollectionFactory.php ├── PseudoLocalizationTranslator.php ├── README.md ├── Reader ├── TranslationReader.php └── TranslationReaderInterface.php ├── Resources ├── bin │ └── translation-status.php ├── data │ └── parents.json ├── functions.php └── schemas │ ├── xliff-core-1.2-transitional.xsd │ ├── xliff-core-2.0.xsd │ └── xml.xsd ├── Test ├── AbstractProviderFactoryTestCase.php ├── IncompleteDsnTestTrait.php ├── ProviderFactoryTestCase.php └── ProviderTestCase.php ├── TranslatableMessage.php ├── Translator.php ├── TranslatorBag.php ├── TranslatorBagInterface.php ├── Util ├── ArrayConverter.php └── XliffUtils.php ├── Writer ├── TranslationWriter.php └── TranslationWriterInterface.php └── composer.json /Catalogue/AbstractOperation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Catalogue; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | use Symfony\Component\Translation\Exception\LogicException; 16 | use Symfony\Component\Translation\MessageCatalogue; 17 | use Symfony\Component\Translation\MessageCatalogueInterface; 18 | 19 | /** 20 | * Base catalogues binary operation class. 21 | * 22 | * A catalogue binary operation performs operation on 23 | * source (the left argument) and target (the right argument) catalogues. 24 | * 25 | * @author Jean-François Simon 26 | */ 27 | abstract class AbstractOperation implements OperationInterface 28 | { 29 | public const OBSOLETE_BATCH = 'obsolete'; 30 | public const NEW_BATCH = 'new'; 31 | public const ALL_BATCH = 'all'; 32 | 33 | protected MessageCatalogue $result; 34 | 35 | /** 36 | * This array stores 'all', 'new' and 'obsolete' messages for all valid domains. 37 | * 38 | * The data structure of this array is as follows: 39 | * 40 | * [ 41 | * 'domain 1' => [ 42 | * 'all' => [...], 43 | * 'new' => [...], 44 | * 'obsolete' => [...] 45 | * ], 46 | * 'domain 2' => [ 47 | * 'all' => [...], 48 | * 'new' => [...], 49 | * 'obsolete' => [...] 50 | * ], 51 | * ... 52 | * ] 53 | * 54 | * @var array The array that stores 'all', 'new' and 'obsolete' messages 55 | */ 56 | protected array $messages; 57 | 58 | private array $domains; 59 | 60 | /** 61 | * @throws LogicException 62 | */ 63 | public function __construct( 64 | protected MessageCatalogueInterface $source, 65 | protected MessageCatalogueInterface $target, 66 | ) { 67 | if ($source->getLocale() !== $target->getLocale()) { 68 | throw new LogicException('Operated catalogues must belong to the same locale.'); 69 | } 70 | 71 | $this->result = new MessageCatalogue($source->getLocale()); 72 | $this->messages = []; 73 | } 74 | 75 | public function getDomains(): array 76 | { 77 | if (!isset($this->domains)) { 78 | $domains = []; 79 | foreach ([$this->source, $this->target] as $catalogue) { 80 | foreach ($catalogue->getDomains() as $domain) { 81 | $domains[$domain] = $domain; 82 | 83 | if ($catalogue->all($domainIcu = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX)) { 84 | $domains[$domainIcu] = $domainIcu; 85 | } 86 | } 87 | } 88 | 89 | $this->domains = array_values($domains); 90 | } 91 | 92 | return $this->domains; 93 | } 94 | 95 | public function getMessages(string $domain): array 96 | { 97 | if (!\in_array($domain, $this->getDomains(), true)) { 98 | throw new InvalidArgumentException(\sprintf('Invalid domain: "%s".', $domain)); 99 | } 100 | 101 | if (!isset($this->messages[$domain][self::ALL_BATCH])) { 102 | $this->processDomain($domain); 103 | } 104 | 105 | return $this->messages[$domain][self::ALL_BATCH]; 106 | } 107 | 108 | public function getNewMessages(string $domain): array 109 | { 110 | if (!\in_array($domain, $this->getDomains(), true)) { 111 | throw new InvalidArgumentException(\sprintf('Invalid domain: "%s".', $domain)); 112 | } 113 | 114 | if (!isset($this->messages[$domain][self::NEW_BATCH])) { 115 | $this->processDomain($domain); 116 | } 117 | 118 | return $this->messages[$domain][self::NEW_BATCH]; 119 | } 120 | 121 | public function getObsoleteMessages(string $domain): array 122 | { 123 | if (!\in_array($domain, $this->getDomains(), true)) { 124 | throw new InvalidArgumentException(\sprintf('Invalid domain: "%s".', $domain)); 125 | } 126 | 127 | if (!isset($this->messages[$domain][self::OBSOLETE_BATCH])) { 128 | $this->processDomain($domain); 129 | } 130 | 131 | return $this->messages[$domain][self::OBSOLETE_BATCH]; 132 | } 133 | 134 | public function getResult(): MessageCatalogueInterface 135 | { 136 | foreach ($this->getDomains() as $domain) { 137 | if (!isset($this->messages[$domain])) { 138 | $this->processDomain($domain); 139 | } 140 | } 141 | 142 | return $this->result; 143 | } 144 | 145 | /** 146 | * @param self::*_BATCH $batch 147 | */ 148 | public function moveMessagesToIntlDomainsIfPossible(string $batch = self::ALL_BATCH): void 149 | { 150 | // If MessageFormatter class does not exists, intl domains are not supported. 151 | if (!class_exists(\MessageFormatter::class)) { 152 | return; 153 | } 154 | 155 | foreach ($this->getDomains() as $domain) { 156 | $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; 157 | $messages = match ($batch) { 158 | self::OBSOLETE_BATCH => $this->getObsoleteMessages($domain), 159 | self::NEW_BATCH => $this->getNewMessages($domain), 160 | self::ALL_BATCH => $this->getMessages($domain), 161 | default => throw new \InvalidArgumentException(\sprintf('$batch argument must be one of ["%s", "%s", "%s"].', self::ALL_BATCH, self::NEW_BATCH, self::OBSOLETE_BATCH)), 162 | }; 163 | 164 | if (!$messages || (!$this->source->all($intlDomain) && $this->source->all($domain))) { 165 | continue; 166 | } 167 | 168 | $result = $this->getResult(); 169 | $allIntlMessages = $result->all($intlDomain); 170 | $currentMessages = array_diff_key($messages, $result->all($domain)); 171 | $result->replace($currentMessages, $domain); 172 | $result->replace($allIntlMessages + $messages, $intlDomain); 173 | } 174 | } 175 | 176 | /** 177 | * Performs operation on source and target catalogues for the given domain and 178 | * stores the results. 179 | * 180 | * @param string $domain The domain which the operation will be performed for 181 | */ 182 | abstract protected function processDomain(string $domain): void; 183 | } 184 | -------------------------------------------------------------------------------- /Catalogue/MergeOperation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Catalogue; 13 | 14 | use Symfony\Component\Translation\MessageCatalogueInterface; 15 | 16 | /** 17 | * Merge operation between two catalogues as follows: 18 | * all = source ∪ target = {x: x ∈ source ∨ x ∈ target} 19 | * new = all ∖ source = {x: x ∈ target ∧ x ∉ source} 20 | * obsolete = source ∖ all = {x: x ∈ source ∧ x ∉ source ∧ x ∉ target} = ∅ 21 | * Basically, the result contains messages from both catalogues. 22 | * 23 | * @author Jean-François Simon 24 | */ 25 | class MergeOperation extends AbstractOperation 26 | { 27 | protected function processDomain(string $domain): void 28 | { 29 | $this->messages[$domain] = [ 30 | 'all' => [], 31 | 'new' => [], 32 | 'obsolete' => [], 33 | ]; 34 | $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; 35 | 36 | foreach ($this->target->getCatalogueMetadata('', $domain) ?? [] as $key => $value) { 37 | if (null === $this->result->getCatalogueMetadata($key, $domain)) { 38 | $this->result->setCatalogueMetadata($key, $value, $domain); 39 | } 40 | } 41 | 42 | foreach ($this->target->getCatalogueMetadata('', $intlDomain) ?? [] as $key => $value) { 43 | if (null === $this->result->getCatalogueMetadata($key, $intlDomain)) { 44 | $this->result->setCatalogueMetadata($key, $value, $intlDomain); 45 | } 46 | } 47 | 48 | foreach ($this->source->all($domain) as $id => $message) { 49 | $this->messages[$domain]['all'][$id] = $message; 50 | $d = $this->source->defines($id, $intlDomain) ? $intlDomain : $domain; 51 | $this->result->add([$id => $message], $d); 52 | if (null !== $keyMetadata = $this->source->getMetadata($id, $d)) { 53 | $this->result->setMetadata($id, $keyMetadata, $d); 54 | } 55 | } 56 | 57 | foreach ($this->target->all($domain) as $id => $message) { 58 | if (!$this->source->has($id, $domain)) { 59 | $this->messages[$domain]['all'][$id] = $message; 60 | $this->messages[$domain]['new'][$id] = $message; 61 | $d = $this->target->defines($id, $intlDomain) ? $intlDomain : $domain; 62 | $this->result->add([$id => $message], $d); 63 | if (null !== $keyMetadata = $this->target->getMetadata($id, $d)) { 64 | $this->result->setMetadata($id, $keyMetadata, $d); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Catalogue/OperationInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Catalogue; 13 | 14 | use Symfony\Component\Translation\MessageCatalogueInterface; 15 | 16 | /** 17 | * Represents an operation on catalogue(s). 18 | * 19 | * An instance of this interface performs an operation on one or more catalogues and 20 | * stores intermediate and final results of the operation. 21 | * 22 | * The first catalogue in its argument(s) is called the 'source catalogue' or 'source' and 23 | * the following results are stored: 24 | * 25 | * Messages: also called 'all', are valid messages for the given domain after the operation is performed. 26 | * 27 | * New Messages: also called 'new' (new = all ∖ source = {x: x ∈ all ∧ x ∉ source}). 28 | * 29 | * Obsolete Messages: also called 'obsolete' (obsolete = source ∖ all = {x: x ∈ source ∧ x ∉ all}). 30 | * 31 | * Result: also called 'result', is the resulting catalogue for the given domain that holds the same messages as 'all'. 32 | * 33 | * @author Jean-François Simon 34 | */ 35 | interface OperationInterface 36 | { 37 | /** 38 | * Returns domains affected by operation. 39 | */ 40 | public function getDomains(): array; 41 | 42 | /** 43 | * Returns all valid messages ('all') after operation. 44 | */ 45 | public function getMessages(string $domain): array; 46 | 47 | /** 48 | * Returns new messages ('new') after operation. 49 | */ 50 | public function getNewMessages(string $domain): array; 51 | 52 | /** 53 | * Returns obsolete messages ('obsolete') after operation. 54 | */ 55 | public function getObsoleteMessages(string $domain): array; 56 | 57 | /** 58 | * Returns resulting catalogue ('result'). 59 | */ 60 | public function getResult(): MessageCatalogueInterface; 61 | } 62 | -------------------------------------------------------------------------------- /Catalogue/TargetOperation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Catalogue; 13 | 14 | use Symfony\Component\Translation\MessageCatalogueInterface; 15 | 16 | /** 17 | * Target operation between two catalogues: 18 | * intersection = source ∩ target = {x: x ∈ source ∧ x ∈ target} 19 | * all = intersection ∪ (target ∖ intersection) = target 20 | * new = all ∖ source = {x: x ∈ target ∧ x ∉ source} 21 | * obsolete = source ∖ all = source ∖ target = {x: x ∈ source ∧ x ∉ target} 22 | * Basically, the result contains messages from the target catalogue. 23 | * 24 | * @author Michael Lee 25 | */ 26 | class TargetOperation extends AbstractOperation 27 | { 28 | protected function processDomain(string $domain): void 29 | { 30 | $this->messages[$domain] = [ 31 | 'all' => [], 32 | 'new' => [], 33 | 'obsolete' => [], 34 | ]; 35 | $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; 36 | 37 | foreach ($this->target->getCatalogueMetadata('', $domain) ?? [] as $key => $value) { 38 | if (null === $this->result->getCatalogueMetadata($key, $domain)) { 39 | $this->result->setCatalogueMetadata($key, $value, $domain); 40 | } 41 | } 42 | 43 | foreach ($this->target->getCatalogueMetadata('', $intlDomain) ?? [] as $key => $value) { 44 | if (null === $this->result->getCatalogueMetadata($key, $intlDomain)) { 45 | $this->result->setCatalogueMetadata($key, $value, $intlDomain); 46 | } 47 | } 48 | 49 | // For 'all' messages, the code can't be simplified as ``$this->messages[$domain]['all'] = $target->all($domain);``, 50 | // because doing so will drop messages like {x: x ∈ source ∧ x ∉ target.all ∧ x ∈ target.fallback} 51 | // 52 | // For 'new' messages, the code can't be simplified as ``array_diff_assoc($this->target->all($domain), $this->source->all($domain));`` 53 | // because doing so will not exclude messages like {x: x ∈ target ∧ x ∉ source.all ∧ x ∈ source.fallback} 54 | // 55 | // For 'obsolete' messages, the code can't be simplified as ``array_diff_assoc($this->source->all($domain), $this->target->all($domain))`` 56 | // because doing so will not exclude messages like {x: x ∈ source ∧ x ∉ target.all ∧ x ∈ target.fallback} 57 | 58 | foreach ($this->source->all($domain) as $id => $message) { 59 | if ($this->target->has($id, $domain)) { 60 | $this->messages[$domain]['all'][$id] = $message; 61 | $d = $this->source->defines($id, $intlDomain) ? $intlDomain : $domain; 62 | $this->result->add([$id => $message], $d); 63 | if (null !== $keyMetadata = $this->source->getMetadata($id, $d)) { 64 | $this->result->setMetadata($id, $keyMetadata, $d); 65 | } 66 | } else { 67 | $this->messages[$domain]['obsolete'][$id] = $message; 68 | } 69 | } 70 | 71 | foreach ($this->target->all($domain) as $id => $message) { 72 | if (!$this->source->has($id, $domain)) { 73 | $this->messages[$domain]['all'][$id] = $message; 74 | $this->messages[$domain]['new'][$id] = $message; 75 | $d = $this->target->defines($id, $intlDomain) ? $intlDomain : $domain; 76 | $this->result->add([$id => $message], $d); 77 | if (null !== $keyMetadata = $this->target->getMetadata($id, $d)) { 78 | $this->result->setMetadata($id, $keyMetadata, $d); 79 | } 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /CatalogueMetadataAwareInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | /** 15 | * This interface is used to get, set, and delete metadata about the Catalogue. 16 | * 17 | * @author Hugo Alliaume 18 | */ 19 | interface CatalogueMetadataAwareInterface 20 | { 21 | /** 22 | * Gets catalogue metadata for the given domain and key. 23 | * 24 | * Passing an empty domain will return an array with all catalogue metadata indexed by 25 | * domain and then by key. Passing an empty key will return an array with all 26 | * catalogue metadata for the given domain. 27 | * 28 | * @return mixed The value that was set or an array with the domains/keys or null 29 | */ 30 | public function getCatalogueMetadata(string $key = '', string $domain = 'messages'): mixed; 31 | 32 | /** 33 | * Adds catalogue metadata to a message domain. 34 | */ 35 | public function setCatalogueMetadata(string $key, mixed $value, string $domain = 'messages'): void; 36 | 37 | /** 38 | * Deletes catalogue metadata for the given key and domain. 39 | * 40 | * Passing an empty domain will delete all catalogue metadata. Passing an empty key will 41 | * delete all metadata for the given domain. 42 | */ 43 | public function deleteCatalogueMetadata(string $key = '', string $domain = 'messages'): void; 44 | } 45 | -------------------------------------------------------------------------------- /Command/TranslationLintCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Command; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Completion\CompletionInput; 17 | use Symfony\Component\Console\Completion\CompletionSuggestions; 18 | use Symfony\Component\Console\Input\InputInterface; 19 | use Symfony\Component\Console\Input\InputOption; 20 | use Symfony\Component\Console\Output\OutputInterface; 21 | use Symfony\Component\Console\Style\SymfonyStyle; 22 | use Symfony\Component\Translation\Exception\ExceptionInterface; 23 | use Symfony\Component\Translation\TranslatorBagInterface; 24 | use Symfony\Contracts\Translation\TranslatorInterface; 25 | 26 | /** 27 | * Lint translations files syntax and outputs encountered errors. 28 | * 29 | * @author Hugo Alliaume 30 | */ 31 | #[AsCommand(name: 'lint:translations', description: 'Lint translations files syntax and outputs encountered errors')] 32 | class TranslationLintCommand extends Command 33 | { 34 | private SymfonyStyle $io; 35 | 36 | public function __construct( 37 | private TranslatorInterface&TranslatorBagInterface $translator, 38 | private array $enabledLocales = [], 39 | ) { 40 | parent::__construct(); 41 | } 42 | 43 | public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void 44 | { 45 | if ($input->mustSuggestOptionValuesFor('locale')) { 46 | $suggestions->suggestValues($this->enabledLocales); 47 | } 48 | } 49 | 50 | protected function configure(): void 51 | { 52 | $this 53 | ->setDefinition([ 54 | new InputOption('locale', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Specify the locales to lint.', $this->enabledLocales), 55 | ]) 56 | ->setHelp(<<<'EOF' 57 | The %command.name% command lint translations. 58 | 59 | php %command.full_name% 60 | EOF 61 | ); 62 | } 63 | 64 | protected function initialize(InputInterface $input, OutputInterface $output): void 65 | { 66 | $this->io = new SymfonyStyle($input, $output); 67 | } 68 | 69 | protected function execute(InputInterface $input, OutputInterface $output): int 70 | { 71 | $locales = $input->getOption('locale'); 72 | 73 | /** @var array> $errors */ 74 | $errors = []; 75 | $domainsByLocales = []; 76 | 77 | foreach ($locales as $locale) { 78 | $messageCatalogue = $this->translator->getCatalogue($locale); 79 | 80 | foreach ($domainsByLocales[$locale] = $messageCatalogue->getDomains() as $domain) { 81 | foreach ($messageCatalogue->all($domain) as $id => $translation) { 82 | try { 83 | $this->translator->trans($id, [], $domain, $messageCatalogue->getLocale()); 84 | } catch (ExceptionInterface $e) { 85 | $errors[$locale][$domain][$id] = $e; 86 | } 87 | } 88 | } 89 | } 90 | 91 | if (!$domainsByLocales) { 92 | $this->io->error('No translation files were found.'); 93 | 94 | return Command::SUCCESS; 95 | } 96 | 97 | $this->io->table( 98 | ['Locale', 'Domains', 'Valid?'], 99 | array_map( 100 | static fn (string $locale, array $domains) => [ 101 | $locale, 102 | implode(', ', $domains), 103 | !\array_key_exists($locale, $errors) ? 'Yes' : 'No', 104 | ], 105 | array_keys($domainsByLocales), 106 | $domainsByLocales 107 | ), 108 | ); 109 | 110 | if ($errors) { 111 | foreach ($errors as $locale => $domains) { 112 | foreach ($domains as $domain => $domainsErrors) { 113 | $this->io->section(\sprintf('Errors for locale "%s" and domain "%s"', $locale, $domain)); 114 | 115 | foreach ($domainsErrors as $id => $error) { 116 | $this->io->text(\sprintf('Translation key "%s" is invalid:', $id)); 117 | $this->io->error($error->getMessage()); 118 | } 119 | } 120 | } 121 | 122 | return Command::FAILURE; 123 | } 124 | 125 | $this->io->success('All translations are valid.'); 126 | 127 | return Command::SUCCESS; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Command/TranslationPushCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Command; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Completion\CompletionInput; 17 | use Symfony\Component\Console\Completion\CompletionSuggestions; 18 | use Symfony\Component\Console\Exception\InvalidArgumentException; 19 | use Symfony\Component\Console\Input\InputArgument; 20 | use Symfony\Component\Console\Input\InputInterface; 21 | use Symfony\Component\Console\Input\InputOption; 22 | use Symfony\Component\Console\Output\OutputInterface; 23 | use Symfony\Component\Console\Style\SymfonyStyle; 24 | use Symfony\Component\Translation\Provider\FilteringProvider; 25 | use Symfony\Component\Translation\Provider\TranslationProviderCollection; 26 | use Symfony\Component\Translation\Reader\TranslationReaderInterface; 27 | use Symfony\Component\Translation\TranslatorBag; 28 | 29 | /** 30 | * @author Mathieu Santostefano 31 | */ 32 | #[AsCommand(name: 'translation:push', description: 'Push translations to a given provider.')] 33 | final class TranslationPushCommand extends Command 34 | { 35 | use TranslationTrait; 36 | 37 | public function __construct( 38 | private TranslationProviderCollection $providers, 39 | private TranslationReaderInterface $reader, 40 | private array $transPaths = [], 41 | private array $enabledLocales = [], 42 | ) { 43 | parent::__construct(); 44 | } 45 | 46 | public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void 47 | { 48 | if ($input->mustSuggestArgumentValuesFor('provider')) { 49 | $suggestions->suggestValues($this->providers->keys()); 50 | 51 | return; 52 | } 53 | 54 | if ($input->mustSuggestOptionValuesFor('domains')) { 55 | $provider = $this->providers->get($input->getArgument('provider')); 56 | 57 | if (method_exists($provider, 'getDomains')) { 58 | $domains = $provider->getDomains(); 59 | $suggestions->suggestValues($domains); 60 | } 61 | 62 | return; 63 | } 64 | 65 | if ($input->mustSuggestOptionValuesFor('locales')) { 66 | $suggestions->suggestValues($this->enabledLocales); 67 | } 68 | } 69 | 70 | protected function configure(): void 71 | { 72 | $keys = $this->providers->keys(); 73 | $defaultProvider = 1 === \count($keys) ? $keys[0] : null; 74 | 75 | $this 76 | ->setDefinition([ 77 | new InputArgument('provider', null !== $defaultProvider ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The provider to push translations to.', $defaultProvider), 78 | new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with local ones (it will delete not synchronized messages).'), 79 | new InputOption('delete-missing', null, InputOption::VALUE_NONE, 'Delete translations available on provider but not locally.'), 80 | new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to push.'), 81 | new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to push.', $this->enabledLocales), 82 | ]) 83 | ->setHelp(<<<'EOF' 84 | The %command.name% command pushes translations to the given provider. Only new 85 | translations are pushed, existing ones are not overwritten. 86 | 87 | You can overwrite existing translations by using the --force flag: 88 | 89 | php %command.full_name% --force provider 90 | 91 | You can delete provider translations which are not present locally by using the --delete-missing flag: 92 | 93 | php %command.full_name% --delete-missing provider 94 | 95 | Full example: 96 | 97 | php %command.full_name% provider --force --delete-missing --domains=messages --domains=validators --locales=en 98 | 99 | This command pushes all translations associated with the messages and validators domains for the en locale. 100 | Provider translations for the specified domains and locale are deleted if they're not present locally and overwritten if it's the case. 101 | Provider translations for others domains and locales are ignored. 102 | EOF 103 | ) 104 | ; 105 | } 106 | 107 | protected function execute(InputInterface $input, OutputInterface $output): int 108 | { 109 | $provider = $this->providers->get($input->getArgument('provider')); 110 | 111 | if (!$this->enabledLocales) { 112 | throw new InvalidArgumentException(\sprintf('You must define "framework.enabled_locales" or "framework.translator.providers.%s.locales" config key in order to work with translation providers.', parse_url($provider, \PHP_URL_SCHEME))); 113 | } 114 | 115 | $io = new SymfonyStyle($input, $output); 116 | $domains = $input->getOption('domains'); 117 | $locales = $input->getOption('locales'); 118 | $force = $input->getOption('force'); 119 | $deleteMissing = $input->getOption('delete-missing'); 120 | 121 | if (!$domains && $provider instanceof FilteringProvider) { 122 | $domains = $provider->getDomains(); 123 | } 124 | 125 | // Reading local translations must be done after retrieving the domains from the provider 126 | // in order to manage only translations from configured domains 127 | $localTranslations = $this->readLocalTranslations($locales, $domains, $this->transPaths); 128 | 129 | if (!$domains) { 130 | $domains = $this->getDomainsFromTranslatorBag($localTranslations); 131 | } 132 | 133 | if (!$deleteMissing && $force) { 134 | $provider->write($localTranslations); 135 | 136 | $io->success(\sprintf('All local translations has been sent to "%s" (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains))); 137 | 138 | return 0; 139 | } 140 | 141 | $providerTranslations = $provider->read($domains, $locales); 142 | 143 | if ($deleteMissing) { 144 | $provider->delete($providerTranslations->diff($localTranslations)); 145 | 146 | $io->success(\sprintf('Missing translations on "%s" has been deleted (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains))); 147 | 148 | // Read provider translations again, after missing translations deletion, 149 | // to avoid push freshly deleted translations. 150 | $providerTranslations = $provider->read($domains, $locales); 151 | } 152 | 153 | $translationsToWrite = $localTranslations->diff($providerTranslations); 154 | 155 | if ($force) { 156 | $translationsToWrite->addBag($localTranslations->intersect($providerTranslations)); 157 | } 158 | 159 | $provider->write($translationsToWrite); 160 | 161 | $io->success(\sprintf('%s local translations has been sent to "%s" (for "%s" locale(s), and "%s" domain(s)).', $force ? 'All' : 'New', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains))); 162 | 163 | return 0; 164 | } 165 | 166 | private function getDomainsFromTranslatorBag(TranslatorBag $translatorBag): array 167 | { 168 | $domains = []; 169 | 170 | foreach ($translatorBag->getCatalogues() as $catalogue) { 171 | $domains += $catalogue->getDomains(); 172 | } 173 | 174 | return array_unique($domains); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Command/TranslationTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Command; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | use Symfony\Component\Translation\MessageCatalogueInterface; 16 | use Symfony\Component\Translation\TranslatorBag; 17 | 18 | /** 19 | * @internal 20 | */ 21 | trait TranslationTrait 22 | { 23 | private function readLocalTranslations(array $locales, array $domains, array $transPaths): TranslatorBag 24 | { 25 | $bag = new TranslatorBag(); 26 | 27 | foreach ($locales as $locale) { 28 | $catalogue = new MessageCatalogue($locale); 29 | foreach ($transPaths as $path) { 30 | $this->reader->read($path, $catalogue); 31 | } 32 | 33 | if ($domains) { 34 | foreach ($domains as $domain) { 35 | $bag->addCatalogue($this->filterCatalogue($catalogue, $domain)); 36 | } 37 | } else { 38 | $bag->addCatalogue($catalogue); 39 | } 40 | } 41 | 42 | return $bag; 43 | } 44 | 45 | private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue 46 | { 47 | $filteredCatalogue = new MessageCatalogue($catalogue->getLocale()); 48 | 49 | // extract intl-icu messages only 50 | $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; 51 | if ($intlMessages = $catalogue->all($intlDomain)) { 52 | $filteredCatalogue->add($intlMessages, $intlDomain); 53 | } 54 | 55 | // extract all messages and subtract intl-icu messages 56 | if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { 57 | $filteredCatalogue->add($messages, $domain); 58 | } 59 | foreach ($catalogue->getResources() as $resource) { 60 | $filteredCatalogue->addResource($resource); 61 | } 62 | 63 | if ($metadata = $catalogue->getMetadata('', $intlDomain)) { 64 | foreach ($metadata as $k => $v) { 65 | $filteredCatalogue->setMetadata($k, $v, $intlDomain); 66 | } 67 | } 68 | 69 | if ($metadata = $catalogue->getMetadata('', $domain)) { 70 | foreach ($metadata as $k => $v) { 71 | $filteredCatalogue->setMetadata($k, $v, $domain); 72 | } 73 | } 74 | 75 | return $filteredCatalogue; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /DataCollector/TranslationDataCollector.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\DataCollector; 13 | 14 | use Symfony\Component\HttpFoundation\Request; 15 | use Symfony\Component\HttpFoundation\Response; 16 | use Symfony\Component\HttpKernel\DataCollector\DataCollector; 17 | use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; 18 | use Symfony\Component\Translation\DataCollectorTranslator; 19 | use Symfony\Component\VarDumper\Cloner\Data; 20 | 21 | /** 22 | * @author Abdellatif Ait boudad 23 | * 24 | * @final 25 | */ 26 | class TranslationDataCollector extends DataCollector implements LateDataCollectorInterface 27 | { 28 | public function __construct( 29 | private DataCollectorTranslator $translator, 30 | ) { 31 | } 32 | 33 | public function lateCollect(): void 34 | { 35 | $messages = $this->sanitizeCollectedMessages($this->translator->getCollectedMessages()); 36 | 37 | $this->data += $this->computeCount($messages); 38 | $this->data['messages'] = $messages; 39 | 40 | $this->data = $this->cloneVar($this->data); 41 | } 42 | 43 | public function collect(Request $request, Response $response, ?\Throwable $exception = null): void 44 | { 45 | $this->data['locale'] = $this->translator->getLocale(); 46 | $this->data['fallback_locales'] = $this->translator->getFallbackLocales(); 47 | $this->data['global_parameters'] = $this->translator->getGlobalParameters(); 48 | } 49 | 50 | public function reset(): void 51 | { 52 | $this->data = []; 53 | } 54 | 55 | public function getMessages(): array|Data 56 | { 57 | return $this->data['messages'] ?? []; 58 | } 59 | 60 | public function getCountMissings(): int 61 | { 62 | return $this->data[DataCollectorTranslator::MESSAGE_MISSING] ?? 0; 63 | } 64 | 65 | public function getCountFallbacks(): int 66 | { 67 | return $this->data[DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK] ?? 0; 68 | } 69 | 70 | public function getCountDefines(): int 71 | { 72 | return $this->data[DataCollectorTranslator::MESSAGE_DEFINED] ?? 0; 73 | } 74 | 75 | public function getLocale(): ?string 76 | { 77 | return !empty($this->data['locale']) ? $this->data['locale'] : null; 78 | } 79 | 80 | /** 81 | * @internal 82 | */ 83 | public function getFallbackLocales(): Data|array 84 | { 85 | return (isset($this->data['fallback_locales']) && \count($this->data['fallback_locales']) > 0) ? $this->data['fallback_locales'] : []; 86 | } 87 | 88 | /** 89 | * @internal 90 | */ 91 | public function getGlobalParameters(): Data|array 92 | { 93 | return $this->data['global_parameters'] ?? []; 94 | } 95 | 96 | public function getName(): string 97 | { 98 | return 'translation'; 99 | } 100 | 101 | private function sanitizeCollectedMessages(array $messages): array 102 | { 103 | $result = []; 104 | foreach ($messages as $key => $message) { 105 | $messageId = $message['locale'].$message['domain'].$message['id']; 106 | 107 | if (!isset($result[$messageId])) { 108 | $message['count'] = 1; 109 | $message['parameters'] = !empty($message['parameters']) ? [$message['parameters']] : []; 110 | $messages[$key]['translation'] = $this->sanitizeString($message['translation']); 111 | $result[$messageId] = $message; 112 | } else { 113 | if (!empty($message['parameters'])) { 114 | $result[$messageId]['parameters'][] = $message['parameters']; 115 | } 116 | 117 | ++$result[$messageId]['count']; 118 | } 119 | 120 | unset($messages[$key]); 121 | } 122 | 123 | return $result; 124 | } 125 | 126 | private function computeCount(array $messages): array 127 | { 128 | $count = [ 129 | DataCollectorTranslator::MESSAGE_DEFINED => 0, 130 | DataCollectorTranslator::MESSAGE_MISSING => 0, 131 | DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK => 0, 132 | ]; 133 | 134 | foreach ($messages as $message) { 135 | ++$count[$message['state']]; 136 | } 137 | 138 | return $count; 139 | } 140 | 141 | private function sanitizeString(string $string, int $length = 80): string 142 | { 143 | $string = trim(preg_replace('/\s+/', ' ', $string)); 144 | 145 | if (false !== $encoding = mb_detect_encoding($string, null, true)) { 146 | if (mb_strlen($string, $encoding) > $length) { 147 | return mb_substr($string, 0, $length - 3, $encoding).'...'; 148 | } 149 | } elseif (\strlen($string) > $length) { 150 | return substr($string, 0, $length - 3).'...'; 151 | } 152 | 153 | return $string; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /DataCollectorTranslator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | use Symfony\Component\HttpKernel\CacheWarmer\WarmableInterface; 15 | use Symfony\Contracts\Translation\LocaleAwareInterface; 16 | use Symfony\Contracts\Translation\TranslatorInterface; 17 | 18 | /** 19 | * @author Abdellatif Ait boudad 20 | * 21 | * @final since Symfony 7.1 22 | */ 23 | class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInterface, LocaleAwareInterface, WarmableInterface 24 | { 25 | public const MESSAGE_DEFINED = 0; 26 | public const MESSAGE_MISSING = 1; 27 | public const MESSAGE_EQUALS_FALLBACK = 2; 28 | 29 | private array $messages = []; 30 | 31 | public function __construct( 32 | private TranslatorInterface&TranslatorBagInterface&LocaleAwareInterface $translator, 33 | ) { 34 | } 35 | 36 | public function trans(?string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string 37 | { 38 | $trans = $this->translator->trans($id = (string) $id, $parameters, $domain, $locale); 39 | $this->collectMessage($locale, $domain, $id, $trans, $parameters); 40 | 41 | return $trans; 42 | } 43 | 44 | public function setLocale(string $locale): void 45 | { 46 | $this->translator->setLocale($locale); 47 | } 48 | 49 | public function getLocale(): string 50 | { 51 | return $this->translator->getLocale(); 52 | } 53 | 54 | public function getCatalogue(?string $locale = null): MessageCatalogueInterface 55 | { 56 | return $this->translator->getCatalogue($locale); 57 | } 58 | 59 | public function getCatalogues(): array 60 | { 61 | return $this->translator->getCatalogues(); 62 | } 63 | 64 | public function warmUp(string $cacheDir, ?string $buildDir = null): array 65 | { 66 | if ($this->translator instanceof WarmableInterface) { 67 | return $this->translator->warmUp($cacheDir, $buildDir); 68 | } 69 | 70 | return []; 71 | } 72 | 73 | /** 74 | * Gets the fallback locales. 75 | */ 76 | public function getFallbackLocales(): array 77 | { 78 | if ($this->translator instanceof Translator || method_exists($this->translator, 'getFallbackLocales')) { 79 | return $this->translator->getFallbackLocales(); 80 | } 81 | 82 | return []; 83 | } 84 | 85 | public function getGlobalParameters(): array 86 | { 87 | if ($this->translator instanceof Translator || method_exists($this->translator, 'getGlobalParameters')) { 88 | return $this->translator->getGlobalParameters(); 89 | } 90 | 91 | return []; 92 | } 93 | 94 | public function __call(string $method, array $args): mixed 95 | { 96 | return $this->translator->{$method}(...$args); 97 | } 98 | 99 | public function getCollectedMessages(): array 100 | { 101 | return $this->messages; 102 | } 103 | 104 | private function collectMessage(?string $locale, ?string $domain, string $id, string $translation, ?array $parameters = []): void 105 | { 106 | $domain ??= 'messages'; 107 | 108 | $catalogue = $this->translator->getCatalogue($locale); 109 | $locale = $catalogue->getLocale(); 110 | $fallbackLocale = null; 111 | if ($catalogue->defines($id, $domain)) { 112 | $state = self::MESSAGE_DEFINED; 113 | } elseif ($catalogue->has($id, $domain)) { 114 | $state = self::MESSAGE_EQUALS_FALLBACK; 115 | 116 | $fallbackCatalogue = $catalogue->getFallbackCatalogue(); 117 | while ($fallbackCatalogue) { 118 | if ($fallbackCatalogue->defines($id, $domain)) { 119 | $fallbackLocale = $fallbackCatalogue->getLocale(); 120 | break; 121 | } 122 | $fallbackCatalogue = $fallbackCatalogue->getFallbackCatalogue(); 123 | } 124 | } else { 125 | $state = self::MESSAGE_MISSING; 126 | } 127 | 128 | $this->messages[] = [ 129 | 'locale' => $locale, 130 | 'fallbackLocale' => $fallbackLocale, 131 | 'domain' => $domain, 132 | 'id' => $id, 133 | 'translation' => $translation, 134 | 'parameters' => $parameters, 135 | 'state' => $state, 136 | 'transChoiceNumber' => isset($parameters['%count%']) && is_numeric($parameters['%count%']) ? $parameters['%count%'] : null, 137 | ]; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /DependencyInjection/DataCollectorTranslatorPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\Translation\TranslatorBagInterface; 17 | 18 | /** 19 | * @author Christian Flothmann 20 | */ 21 | class DataCollectorTranslatorPass implements CompilerPassInterface 22 | { 23 | public function process(ContainerBuilder $container): void 24 | { 25 | if (!$container->has('translator')) { 26 | return; 27 | } 28 | 29 | $translatorClass = $container->getParameterBag()->resolveValue($container->findDefinition('translator')->getClass()); 30 | 31 | if (!is_subclass_of($translatorClass, TranslatorBagInterface::class)) { 32 | $container->removeDefinition('translator.data_collector'); 33 | $container->removeDefinition('data_collector.translation'); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /DependencyInjection/LoggingTranslatorPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; 17 | use Symfony\Component\Translation\TranslatorBagInterface; 18 | use Symfony\Contracts\Translation\TranslatorInterface; 19 | 20 | /** 21 | * @author Abdellatif Ait boudad 22 | */ 23 | class LoggingTranslatorPass implements CompilerPassInterface 24 | { 25 | public function process(ContainerBuilder $container): void 26 | { 27 | if (!$container->hasAlias('logger') || !$container->hasAlias('translator')) { 28 | return; 29 | } 30 | 31 | if (!$container->hasParameter('translator.logging') || !$container->getParameter('translator.logging')) { 32 | return; 33 | } 34 | 35 | $translatorAlias = $container->getAlias('translator'); 36 | $definition = $container->getDefinition((string) $translatorAlias); 37 | $class = $container->getParameterBag()->resolveValue($definition->getClass()); 38 | 39 | if (!$r = $container->getReflectionClass($class)) { 40 | throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $translatorAlias)); 41 | } 42 | 43 | if (!$r->isSubclassOf(TranslatorInterface::class) || !$r->isSubclassOf(TranslatorBagInterface::class)) { 44 | return; 45 | } 46 | 47 | $container->getDefinition('translator.logging')->setDecoratedService('translator'); 48 | $warmer = $container->getDefinition('translation.warmer'); 49 | $subscriberAttributes = $warmer->getTag('container.service_subscriber'); 50 | $warmer->clearTag('container.service_subscriber'); 51 | 52 | foreach ($subscriberAttributes as $k => $v) { 53 | if ((!isset($v['id']) || 'translator' !== $v['id']) && (!isset($v['key']) || 'translator' !== $v['key'])) { 54 | $warmer->addTag('container.service_subscriber', $v); 55 | } 56 | } 57 | $warmer->addTag('container.service_subscriber', ['key' => 'translator', 'id' => 'translator.logging.inner']); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /DependencyInjection/TranslationDumperPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Reference; 17 | 18 | /** 19 | * Adds tagged translation.formatter services to translation writer. 20 | */ 21 | class TranslationDumperPass implements CompilerPassInterface 22 | { 23 | public function process(ContainerBuilder $container): void 24 | { 25 | if (!$container->hasDefinition('translation.writer')) { 26 | return; 27 | } 28 | 29 | $definition = $container->getDefinition('translation.writer'); 30 | 31 | foreach ($container->findTaggedServiceIds('translation.dumper', true) as $id => $attributes) { 32 | $definition->addMethodCall('addDumper', [$attributes[0]['alias'], new Reference($id)]); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /DependencyInjection/TranslationExtractorPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Exception\RuntimeException; 17 | use Symfony\Component\DependencyInjection\Reference; 18 | 19 | /** 20 | * Adds tagged translation.extractor services to translation extractor. 21 | */ 22 | class TranslationExtractorPass implements CompilerPassInterface 23 | { 24 | public function process(ContainerBuilder $container): void 25 | { 26 | if (!$container->hasDefinition('translation.extractor')) { 27 | return; 28 | } 29 | 30 | $definition = $container->getDefinition('translation.extractor'); 31 | 32 | foreach ($container->findTaggedServiceIds('translation.extractor', true) as $id => $attributes) { 33 | if (!isset($attributes[0]['alias'])) { 34 | throw new RuntimeException(\sprintf('The alias for the tag "translation.extractor" of service "%s" must be set.', $id)); 35 | } 36 | 37 | $definition->addMethodCall('addExtractor', [$attributes[0]['alias'], new Reference($id)]); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /DependencyInjection/TranslatorPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\DependencyInjection\Reference; 18 | 19 | class TranslatorPass implements CompilerPassInterface 20 | { 21 | public function process(ContainerBuilder $container): void 22 | { 23 | if (!$container->hasDefinition('translator.default')) { 24 | return; 25 | } 26 | 27 | $loaders = []; 28 | $loaderRefs = []; 29 | foreach ($container->findTaggedServiceIds('translation.loader', true) as $id => $attributes) { 30 | $loaderRefs[$id] = new Reference($id); 31 | $loaders[$id][] = $attributes[0]['alias']; 32 | if (isset($attributes[0]['legacy-alias'])) { 33 | $loaders[$id][] = $attributes[0]['legacy-alias']; 34 | } 35 | } 36 | 37 | if ($container->hasDefinition('translation.reader')) { 38 | $definition = $container->getDefinition('translation.reader'); 39 | foreach ($loaders as $id => $formats) { 40 | foreach ($formats as $format) { 41 | $definition->addMethodCall('addLoader', [$format, $loaderRefs[$id]]); 42 | } 43 | } 44 | } 45 | 46 | $container 47 | ->findDefinition('translator.default') 48 | ->replaceArgument(0, ServiceLocatorTagPass::register($container, $loaderRefs)) 49 | ->replaceArgument(3, $loaders) 50 | ; 51 | 52 | if ($container->hasDefinition('validator') && $container->hasDefinition('translation.extractor.visitor.constraint')) { 53 | $constraintVisitorDefinition = $container->getDefinition('translation.extractor.visitor.constraint'); 54 | $constraintClassNames = []; 55 | 56 | foreach ($container->getDefinitions() as $definition) { 57 | if (!$definition->hasTag('validator.constraint_validator')) { 58 | continue; 59 | } 60 | // Resolve constraint validator FQCN even if defined as %foo.validator.class% parameter 61 | $className = $container->getParameterBag()->resolveValue($definition->getClass()); 62 | // Extraction of the constraint class name from the Constraint Validator FQCN 63 | $constraintClassNames[] = str_replace('Validator', '', substr(strrchr($className, '\\'), 1)); 64 | } 65 | 66 | $constraintVisitorDefinition->setArgument(0, $constraintClassNames); 67 | } 68 | 69 | if (!$container->hasParameter('twig.default_path')) { 70 | return; 71 | } 72 | 73 | $paths = array_keys($container->getDefinition('twig.template_iterator')->getArgument(1)); 74 | if ($container->hasDefinition('console.command.translation_debug')) { 75 | $definition = $container->getDefinition('console.command.translation_debug'); 76 | $definition->replaceArgument(4, $container->getParameter('twig.default_path')); 77 | 78 | if (\count($definition->getArguments()) > 6) { 79 | $definition->replaceArgument(6, $paths); 80 | } 81 | } 82 | if ($container->hasDefinition('console.command.translation_extract')) { 83 | $definition = $container->getDefinition('console.command.translation_extract'); 84 | $definition->replaceArgument(5, $container->getParameter('twig.default_path')); 85 | 86 | if (\count($definition->getArguments()) > 7) { 87 | $definition->replaceArgument(7, $paths); 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /DependencyInjection/TranslatorPathsPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\AbstractRecursivePass; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Definition; 17 | use Symfony\Component\DependencyInjection\Reference; 18 | use Symfony\Component\DependencyInjection\ServiceLocator; 19 | use Symfony\Component\HttpKernel\Controller\ArgumentResolver\TraceableValueResolver; 20 | 21 | /** 22 | * @author Yonel Ceruto 23 | */ 24 | class TranslatorPathsPass extends AbstractRecursivePass 25 | { 26 | protected bool $skipScalars = true; 27 | 28 | private int $level = 0; 29 | 30 | /** 31 | * @var array 32 | */ 33 | private array $paths = []; 34 | 35 | /** 36 | * @var array 37 | */ 38 | private array $definitions = []; 39 | 40 | /** 41 | * @var array> 42 | */ 43 | private array $controllers = []; 44 | 45 | public function process(ContainerBuilder $container): void 46 | { 47 | if (!$container->hasDefinition('translator')) { 48 | return; 49 | } 50 | 51 | foreach ($this->findControllerArguments($container) as $controller => $argument) { 52 | $id = substr($controller, 0, strpos($controller, ':') ?: \strlen($controller)); 53 | if ($container->hasDefinition($id)) { 54 | [$locatorRef] = $argument->getValues(); 55 | $this->controllers[(string) $locatorRef][$container->getDefinition($id)->getClass()] = true; 56 | } 57 | } 58 | 59 | try { 60 | parent::process($container); 61 | 62 | $paths = []; 63 | foreach ($this->paths as $class => $_) { 64 | if (($r = $container->getReflectionClass($class)) && !$r->isInterface()) { 65 | $paths[] = $r->getFileName(); 66 | foreach ($r->getTraits() as $trait) { 67 | $paths[] = $trait->getFileName(); 68 | } 69 | } 70 | } 71 | if ($paths) { 72 | if ($container->hasDefinition('console.command.translation_debug')) { 73 | $definition = $container->getDefinition('console.command.translation_debug'); 74 | $definition->replaceArgument(6, array_merge($definition->getArgument(6), $paths)); 75 | } 76 | if ($container->hasDefinition('console.command.translation_extract')) { 77 | $definition = $container->getDefinition('console.command.translation_extract'); 78 | $definition->replaceArgument(7, array_merge($definition->getArgument(7), $paths)); 79 | } 80 | } 81 | } finally { 82 | $this->level = 0; 83 | $this->paths = []; 84 | $this->definitions = []; 85 | } 86 | } 87 | 88 | protected function processValue(mixed $value, bool $isRoot = false): mixed 89 | { 90 | if ($value instanceof Reference) { 91 | if ('translator' === (string) $value) { 92 | for ($i = $this->level - 1; $i >= 0; --$i) { 93 | $class = $this->definitions[$i]->getClass(); 94 | 95 | if (ServiceLocator::class === $class) { 96 | if (!isset($this->controllers[$this->currentId])) { 97 | continue; 98 | } 99 | foreach ($this->controllers[$this->currentId] as $class => $_) { 100 | $this->paths[$class] = true; 101 | } 102 | } else { 103 | $this->paths[$class] = true; 104 | } 105 | 106 | break; 107 | } 108 | } 109 | 110 | return $value; 111 | } 112 | 113 | if ($value instanceof Definition) { 114 | $this->definitions[$this->level++] = $value; 115 | $value = parent::processValue($value, $isRoot); 116 | unset($this->definitions[--$this->level]); 117 | 118 | return $value; 119 | } 120 | 121 | return parent::processValue($value, $isRoot); 122 | } 123 | 124 | private function findControllerArguments(ContainerBuilder $container): array 125 | { 126 | if (!$container->has('argument_resolver.service')) { 127 | return []; 128 | } 129 | $resolverDef = $container->findDefinition('argument_resolver.service'); 130 | 131 | if (TraceableValueResolver::class === $resolverDef->getClass()) { 132 | $resolverDef = $container->getDefinition($resolverDef->getArgument(0)); 133 | } 134 | 135 | $argument = $resolverDef->getArgument(0); 136 | if ($argument instanceof Reference) { 137 | $argument = $container->getDefinition($argument); 138 | } 139 | 140 | return $argument->getArgument(0); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Dumper/CsvFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * CsvFileDumper generates a csv formatted string representation of a message catalogue. 18 | * 19 | * @author Stealth35 20 | */ 21 | class CsvFileDumper extends FileDumper 22 | { 23 | private string $delimiter = ';'; 24 | private string $enclosure = '"'; 25 | 26 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 27 | { 28 | $handle = fopen('php://memory', 'r+'); 29 | 30 | foreach ($messages->all($domain) as $source => $target) { 31 | fputcsv($handle, [$source, $target], $this->delimiter, $this->enclosure, '\\'); 32 | } 33 | 34 | rewind($handle); 35 | $output = stream_get_contents($handle); 36 | fclose($handle); 37 | 38 | return $output; 39 | } 40 | 41 | /** 42 | * Sets the delimiter and escape character for CSV. 43 | */ 44 | public function setCsvControl(string $delimiter = ';', string $enclosure = '"'): void 45 | { 46 | $this->delimiter = $delimiter; 47 | $this->enclosure = $enclosure; 48 | } 49 | 50 | protected function getExtension(): string 51 | { 52 | return 'csv'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Dumper/DumperInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * DumperInterface is the interface implemented by all translation dumpers. 18 | * There is no common option. 19 | * 20 | * @author Michel Salib 21 | */ 22 | interface DumperInterface 23 | { 24 | /** 25 | * Dumps the message catalogue. 26 | * 27 | * @param array $options Options that are used by the dumper 28 | */ 29 | public function dump(MessageCatalogue $messages, array $options = []): void; 30 | } 31 | -------------------------------------------------------------------------------- /Dumper/FileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | use Symfony\Component\Translation\Exception\RuntimeException; 16 | use Symfony\Component\Translation\MessageCatalogue; 17 | 18 | /** 19 | * FileDumper is an implementation of DumperInterface that dump a message catalogue to file(s). 20 | * 21 | * Options: 22 | * - path (mandatory): the directory where the files should be saved 23 | * 24 | * @author Michel Salib 25 | */ 26 | abstract class FileDumper implements DumperInterface 27 | { 28 | /** 29 | * A template for the relative paths to files. 30 | */ 31 | protected string $relativePathTemplate = '%domain%.%locale%.%extension%'; 32 | 33 | /** 34 | * Sets the template for the relative paths to files. 35 | */ 36 | public function setRelativePathTemplate(string $relativePathTemplate): void 37 | { 38 | $this->relativePathTemplate = $relativePathTemplate; 39 | } 40 | 41 | public function dump(MessageCatalogue $messages, array $options = []): void 42 | { 43 | if (!\array_key_exists('path', $options)) { 44 | throw new InvalidArgumentException('The file dumper needs a path option.'); 45 | } 46 | 47 | // save a file for each domain 48 | foreach ($messages->getDomains() as $domain) { 49 | $fullpath = $options['path'].'/'.$this->getRelativePath($domain, $messages->getLocale()); 50 | if (!file_exists($fullpath)) { 51 | $directory = \dirname($fullpath); 52 | if (!file_exists($directory) && !@mkdir($directory, 0777, true)) { 53 | throw new RuntimeException(\sprintf('Unable to create directory "%s".', $directory)); 54 | } 55 | } 56 | 57 | $intlDomain = $domain.MessageCatalogue::INTL_DOMAIN_SUFFIX; 58 | $intlMessages = $messages->all($intlDomain); 59 | 60 | if ($intlMessages) { 61 | $intlPath = $options['path'].'/'.$this->getRelativePath($intlDomain, $messages->getLocale()); 62 | file_put_contents($intlPath, $this->formatCatalogue($messages, $intlDomain, $options)); 63 | 64 | $messages->replace([], $intlDomain); 65 | 66 | try { 67 | if ($messages->all($domain)) { 68 | file_put_contents($fullpath, $this->formatCatalogue($messages, $domain, $options)); 69 | } 70 | continue; 71 | } finally { 72 | $messages->replace($intlMessages, $intlDomain); 73 | } 74 | } 75 | 76 | file_put_contents($fullpath, $this->formatCatalogue($messages, $domain, $options)); 77 | } 78 | } 79 | 80 | /** 81 | * Transforms a domain of a message catalogue to its string representation. 82 | */ 83 | abstract public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string; 84 | 85 | /** 86 | * Gets the file extension of the dumper. 87 | */ 88 | abstract protected function getExtension(): string; 89 | 90 | /** 91 | * Gets the relative file path using the template. 92 | */ 93 | private function getRelativePath(string $domain, string $locale): string 94 | { 95 | return strtr($this->relativePathTemplate, [ 96 | '%domain%' => $domain, 97 | '%locale%' => $locale, 98 | '%extension%' => $this->getExtension(), 99 | ]); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Dumper/IcuResFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * IcuResDumper generates an ICU ResourceBundle formatted string representation of a message catalogue. 18 | * 19 | * @author Stealth35 20 | */ 21 | class IcuResFileDumper extends FileDumper 22 | { 23 | protected string $relativePathTemplate = '%domain%/%locale%.%extension%'; 24 | 25 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 26 | { 27 | $data = $indexes = $resources = ''; 28 | 29 | foreach ($messages->all($domain) as $source => $target) { 30 | $indexes .= pack('v', \strlen($data) + 28); 31 | $data .= $source."\0"; 32 | } 33 | 34 | $data .= $this->writePadding($data); 35 | 36 | $keyTop = $this->getPosition($data); 37 | 38 | foreach ($messages->all($domain) as $source => $target) { 39 | $resources .= pack('V', $this->getPosition($data)); 40 | 41 | $data .= pack('V', \strlen($target)) 42 | .mb_convert_encoding($target."\0", 'UTF-16LE', 'UTF-8') 43 | .$this->writePadding($data) 44 | ; 45 | } 46 | 47 | $resOffset = $this->getPosition($data); 48 | 49 | $data .= pack('v', \count($messages->all($domain))) 50 | .$indexes 51 | .$this->writePadding($data) 52 | .$resources 53 | ; 54 | 55 | $bundleTop = $this->getPosition($data); 56 | 57 | $root = pack('V7', 58 | $resOffset + (2 << 28), // Resource Offset + Resource Type 59 | 6, // Index length 60 | $keyTop, // Index keys top 61 | $bundleTop, // Index resources top 62 | $bundleTop, // Index bundle top 63 | \count($messages->all($domain)), // Index max table length 64 | 0 // Index attributes 65 | ); 66 | 67 | $header = pack('vC2v4C12@32', 68 | 32, // Header size 69 | 0xDA, 0x27, // Magic number 1 and 2 70 | 20, 0, 0, 2, // Rest of the header, ..., Size of a char 71 | 0x52, 0x65, 0x73, 0x42, // Data format identifier 72 | 1, 2, 0, 0, // Data version 73 | 1, 4, 0, 0 // Unicode version 74 | ); 75 | 76 | return $header.$root.$data; 77 | } 78 | 79 | private function writePadding(string $data): ?string 80 | { 81 | $padding = \strlen($data) % 4; 82 | 83 | return $padding ? str_repeat("\xAA", 4 - $padding) : null; 84 | } 85 | 86 | private function getPosition(string $data): float|int 87 | { 88 | return (\strlen($data) + 28) / 4; 89 | } 90 | 91 | protected function getExtension(): string 92 | { 93 | return 'res'; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Dumper/IniFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * IniFileDumper generates an ini formatted string representation of a message catalogue. 18 | * 19 | * @author Stealth35 20 | */ 21 | class IniFileDumper extends FileDumper 22 | { 23 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 24 | { 25 | $output = ''; 26 | 27 | foreach ($messages->all($domain) as $source => $target) { 28 | $escapeTarget = str_replace('"', '\"', $target); 29 | $output .= $source.'="'.$escapeTarget."\"\n"; 30 | } 31 | 32 | return $output; 33 | } 34 | 35 | protected function getExtension(): string 36 | { 37 | return 'ini'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Dumper/JsonFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * JsonFileDumper generates an json formatted string representation of a message catalogue. 18 | * 19 | * @author singles 20 | */ 21 | class JsonFileDumper extends FileDumper 22 | { 23 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 24 | { 25 | $flags = $options['json_encoding'] ?? \JSON_PRETTY_PRINT; 26 | 27 | return json_encode($messages->all($domain), $flags); 28 | } 29 | 30 | protected function getExtension(): string 31 | { 32 | return 'json'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Dumper/MoFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\Loader\MoFileLoader; 15 | use Symfony\Component\Translation\MessageCatalogue; 16 | 17 | /** 18 | * MoFileDumper generates a gettext formatted string representation of a message catalogue. 19 | * 20 | * @author Stealth35 21 | */ 22 | class MoFileDumper extends FileDumper 23 | { 24 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 25 | { 26 | $sources = $targets = $sourceOffsets = $targetOffsets = ''; 27 | $offsets = []; 28 | $size = 0; 29 | 30 | foreach ($messages->all($domain) as $source => $target) { 31 | $offsets[] = array_map('strlen', [$sources, $source, $targets, $target]); 32 | $sources .= "\0".$source; 33 | $targets .= "\0".$target; 34 | ++$size; 35 | } 36 | 37 | $header = [ 38 | 'magicNumber' => MoFileLoader::MO_LITTLE_ENDIAN_MAGIC, 39 | 'formatRevision' => 0, 40 | 'count' => $size, 41 | 'offsetId' => MoFileLoader::MO_HEADER_SIZE, 42 | 'offsetTranslated' => MoFileLoader::MO_HEADER_SIZE + (8 * $size), 43 | 'sizeHashes' => 0, 44 | 'offsetHashes' => MoFileLoader::MO_HEADER_SIZE + (16 * $size), 45 | ]; 46 | 47 | $sourcesSize = \strlen($sources); 48 | $sourcesStart = $header['offsetHashes'] + 1; 49 | 50 | foreach ($offsets as $offset) { 51 | $sourceOffsets .= $this->writeLong($offset[1]) 52 | .$this->writeLong($offset[0] + $sourcesStart); 53 | $targetOffsets .= $this->writeLong($offset[3]) 54 | .$this->writeLong($offset[2] + $sourcesStart + $sourcesSize); 55 | } 56 | 57 | return implode('', array_map($this->writeLong(...), $header)) 58 | .$sourceOffsets 59 | .$targetOffsets 60 | .$sources 61 | .$targets; 62 | } 63 | 64 | protected function getExtension(): string 65 | { 66 | return 'mo'; 67 | } 68 | 69 | private function writeLong(mixed $str): string 70 | { 71 | return pack('V*', $str); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Dumper/PhpFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * PhpFileDumper generates PHP files from a message catalogue. 18 | * 19 | * @author Michel Salib 20 | */ 21 | class PhpFileDumper extends FileDumper 22 | { 23 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 24 | { 25 | return "all($domain), true).";\n"; 26 | } 27 | 28 | protected function getExtension(): string 29 | { 30 | return 'php'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Dumper/PoFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * PoFileDumper generates a gettext formatted string representation of a message catalogue. 18 | * 19 | * @author Stealth35 20 | */ 21 | class PoFileDumper extends FileDumper 22 | { 23 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 24 | { 25 | $output = 'msgid ""'."\n"; 26 | $output .= 'msgstr ""'."\n"; 27 | $output .= '"Content-Type: text/plain; charset=UTF-8\n"'."\n"; 28 | $output .= '"Content-Transfer-Encoding: 8bit\n"'."\n"; 29 | $output .= '"Language: '.$messages->getLocale().'\n"'."\n"; 30 | $output .= "\n"; 31 | 32 | $newLine = false; 33 | foreach ($messages->all($domain) as $source => $target) { 34 | if ($newLine) { 35 | $output .= "\n"; 36 | } else { 37 | $newLine = true; 38 | } 39 | $metadata = $messages->getMetadata($source, $domain); 40 | 41 | if (isset($metadata['comments'])) { 42 | $output .= $this->formatComments($metadata['comments']); 43 | } 44 | if (isset($metadata['flags'])) { 45 | $output .= $this->formatComments(implode(',', (array) $metadata['flags']), ','); 46 | } 47 | if (isset($metadata['sources'])) { 48 | $output .= $this->formatComments(implode(' ', (array) $metadata['sources']), ':'); 49 | } 50 | 51 | $sourceRules = $this->getStandardRules($source); 52 | $targetRules = $this->getStandardRules($target); 53 | if (2 == \count($sourceRules) && [] !== $targetRules) { 54 | $output .= \sprintf('msgid "%s"'."\n", $this->escape($sourceRules[0])); 55 | $output .= \sprintf('msgid_plural "%s"'."\n", $this->escape($sourceRules[1])); 56 | foreach ($targetRules as $i => $targetRule) { 57 | $output .= \sprintf('msgstr[%d] "%s"'."\n", $i, $this->escape($targetRule)); 58 | } 59 | } else { 60 | $output .= \sprintf('msgid "%s"'."\n", $this->escape($source)); 61 | $output .= \sprintf('msgstr "%s"'."\n", $this->escape($target)); 62 | } 63 | } 64 | 65 | return $output; 66 | } 67 | 68 | private function getStandardRules(string $id): array 69 | { 70 | // Partly copied from TranslatorTrait::trans. 71 | $parts = []; 72 | if (preg_match('/^\|++$/', $id)) { 73 | $parts = explode('|', $id); 74 | } elseif (preg_match_all('/(?:\|\||[^\|])++/', $id, $matches)) { 75 | $parts = $matches[0]; 76 | } 77 | 78 | $intervalRegexp = <<<'EOF' 79 | /^(?P 80 | ({\s* 81 | (\-?\d+(\.\d+)?[\s*,\s*\-?\d+(\.\d+)?]*) 82 | \s*}) 83 | 84 | | 85 | 86 | (?P[\[\]]) 87 | \s* 88 | (?P-Inf|\-?\d+(\.\d+)?) 89 | \s*,\s* 90 | (?P\+?Inf|\-?\d+(\.\d+)?) 91 | \s* 92 | (?P[\[\]]) 93 | )\s*(?P.*?)$/xs 94 | EOF; 95 | 96 | $standardRules = []; 97 | foreach ($parts as $part) { 98 | $part = trim(str_replace('||', '|', $part)); 99 | 100 | if (preg_match($intervalRegexp, $part)) { 101 | // Explicit rule is not a standard rule. 102 | return []; 103 | } 104 | 105 | $standardRules[] = $part; 106 | } 107 | 108 | return $standardRules; 109 | } 110 | 111 | protected function getExtension(): string 112 | { 113 | return 'po'; 114 | } 115 | 116 | private function escape(string $str): string 117 | { 118 | return addcslashes($str, "\0..\37\42\134"); 119 | } 120 | 121 | private function formatComments(string|array $comments, string $prefix = ''): ?string 122 | { 123 | $output = null; 124 | 125 | foreach ((array) $comments as $comment) { 126 | $output .= \sprintf('#%s %s'."\n", $prefix, $comment); 127 | } 128 | 129 | return $output; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Dumper/QtFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * QtFileDumper generates ts files from a message catalogue. 18 | * 19 | * @author Benjamin Eberlei 20 | */ 21 | class QtFileDumper extends FileDumper 22 | { 23 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 24 | { 25 | $dom = new \DOMDocument('1.0', 'utf-8'); 26 | $dom->formatOutput = true; 27 | $ts = $dom->appendChild($dom->createElement('TS')); 28 | $context = $ts->appendChild($dom->createElement('context')); 29 | $context->appendChild($dom->createElement('name', $domain)); 30 | 31 | foreach ($messages->all($domain) as $source => $target) { 32 | $message = $context->appendChild($dom->createElement('message')); 33 | $metadata = $messages->getMetadata($source, $domain); 34 | if (isset($metadata['sources'])) { 35 | foreach ((array) $metadata['sources'] as $location) { 36 | $loc = explode(':', $location, 2); 37 | $location = $message->appendChild($dom->createElement('location')); 38 | $location->setAttribute('filename', $loc[0]); 39 | if (isset($loc[1])) { 40 | $location->setAttribute('line', $loc[1]); 41 | } 42 | } 43 | } 44 | $message->appendChild($dom->createElement('source', $source)); 45 | $message->appendChild($dom->createElement('translation', $target)); 46 | } 47 | 48 | return $dom->saveXML(); 49 | } 50 | 51 | protected function getExtension(): string 52 | { 53 | return 'ts'; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Dumper/YamlFileDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Dumper; 13 | 14 | use Symfony\Component\Translation\Exception\LogicException; 15 | use Symfony\Component\Translation\MessageCatalogue; 16 | use Symfony\Component\Translation\Util\ArrayConverter; 17 | use Symfony\Component\Yaml\Yaml; 18 | 19 | /** 20 | * YamlFileDumper generates yaml files from a message catalogue. 21 | * 22 | * @author Michel Salib 23 | */ 24 | class YamlFileDumper extends FileDumper 25 | { 26 | public function __construct( 27 | private string $extension = 'yml', 28 | ) { 29 | } 30 | 31 | public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string 32 | { 33 | if (!class_exists(Yaml::class)) { 34 | throw new LogicException('Dumping translations in the YAML format requires the Symfony Yaml component.'); 35 | } 36 | 37 | $data = $messages->all($domain); 38 | 39 | if (isset($options['as_tree']) && $options['as_tree']) { 40 | $data = ArrayConverter::expandToTree($data); 41 | } 42 | 43 | if (isset($options['inline']) && ($inline = (int) $options['inline']) > 0) { 44 | return Yaml::dump($data, $inline); 45 | } 46 | 47 | return Yaml::dump($data); 48 | } 49 | 50 | protected function getExtension(): string 51 | { 52 | return $this->extension; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | /** 15 | * Exception interface for all exceptions thrown by the component. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/IncompleteDsnException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | class IncompleteDsnException extends InvalidArgumentException 15 | { 16 | public function __construct(string $message, ?string $dsn = null, ?\Throwable $previous = null) 17 | { 18 | if ($dsn) { 19 | $message = \sprintf('Invalid "%s" provider DSN: ', $dsn).$message; 20 | } 21 | 22 | parent::__construct($message, 0, $previous); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | /** 15 | * Base InvalidArgumentException for the Translation component. 16 | * 17 | * @author Abdellatif Ait boudad 18 | */ 19 | class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/InvalidResourceException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | /** 15 | * Thrown when a resource cannot be loaded. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | class InvalidResourceException extends \InvalidArgumentException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/LogicException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | /** 15 | * Base LogicException for Translation component. 16 | * 17 | * @author Abdellatif Ait boudad 18 | */ 19 | class LogicException extends \LogicException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/MissingRequiredOptionException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | /** 15 | * @author Oskar Stark 16 | */ 17 | class MissingRequiredOptionException extends IncompleteDsnException 18 | { 19 | public function __construct(string $option, ?string $dsn = null, ?\Throwable $previous = null) 20 | { 21 | $message = \sprintf('The option "%s" is required but missing.', $option); 22 | 23 | parent::__construct($message, $dsn, $previous); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Exception/NotFoundResourceException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | /** 15 | * Thrown when a resource does not exist. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | class NotFoundResourceException extends \InvalidArgumentException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/ProviderException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\ResponseInterface; 15 | 16 | /** 17 | * @author Fabien Potencier 18 | */ 19 | class ProviderException extends RuntimeException implements ProviderExceptionInterface 20 | { 21 | private string $debug; 22 | 23 | public function __construct( 24 | string $message, 25 | private ResponseInterface $response, 26 | int $code = 0, 27 | ?\Exception $previous = null, 28 | ) { 29 | $this->debug = $response->getInfo('debug') ?? ''; 30 | 31 | parent::__construct($message, $code, $previous); 32 | } 33 | 34 | public function getResponse(): ResponseInterface 35 | { 36 | return $this->response; 37 | } 38 | 39 | public function getDebug(): string 40 | { 41 | return $this->debug; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Exception/ProviderExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | interface ProviderExceptionInterface extends ExceptionInterface 18 | { 19 | /* 20 | * Returns debug info coming from the Symfony\Contracts\HttpClient\ResponseInterface 21 | */ 22 | public function getDebug(): string; 23 | } 24 | -------------------------------------------------------------------------------- /Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | /** 15 | * Base RuntimeException for the Translation component. 16 | * 17 | * @author Abdellatif Ait boudad 18 | */ 19 | class RuntimeException extends \RuntimeException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/UnsupportedSchemeException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Exception; 13 | 14 | use Symfony\Component\Translation\Bridge; 15 | use Symfony\Component\Translation\Provider\Dsn; 16 | 17 | class UnsupportedSchemeException extends LogicException 18 | { 19 | private const SCHEME_TO_PACKAGE_MAP = [ 20 | 'crowdin' => [ 21 | 'class' => Bridge\Crowdin\CrowdinProviderFactory::class, 22 | 'package' => 'symfony/crowdin-translation-provider', 23 | ], 24 | 'loco' => [ 25 | 'class' => Bridge\Loco\LocoProviderFactory::class, 26 | 'package' => 'symfony/loco-translation-provider', 27 | ], 28 | 'lokalise' => [ 29 | 'class' => Bridge\Lokalise\LokaliseProviderFactory::class, 30 | 'package' => 'symfony/lokalise-translation-provider', 31 | ], 32 | 'phrase' => [ 33 | 'class' => Bridge\Phrase\PhraseProviderFactory::class, 34 | 'package' => 'symfony/phrase-translation-provider', 35 | ], 36 | ]; 37 | 38 | public function __construct(Dsn $dsn, ?string $name = null, array $supported = []) 39 | { 40 | $provider = $dsn->getScheme(); 41 | if (false !== $pos = strpos($provider, '+')) { 42 | $provider = substr($provider, 0, $pos); 43 | } 44 | $package = self::SCHEME_TO_PACKAGE_MAP[$provider] ?? null; 45 | if ($package && !class_exists($package['class'])) { 46 | parent::__construct(\sprintf('Unable to synchronize translations via "%s" as the provider is not installed. Try running "composer require %s".', $provider, $package['package'])); 47 | 48 | return; 49 | } 50 | 51 | $message = \sprintf('The "%s" scheme is not supported', $dsn->getScheme()); 52 | if ($name && $supported) { 53 | $message .= \sprintf('; supported schemes for translation provider "%s" are: "%s"', $name, implode('", "', $supported)); 54 | } 55 | 56 | parent::__construct($message.'.'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Extractor/AbstractFileExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Extractor; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | 16 | /** 17 | * Base class used by classes that extract translation messages from files. 18 | * 19 | * @author Marcos D. Sánchez 20 | */ 21 | abstract class AbstractFileExtractor 22 | { 23 | protected function extractFiles(string|iterable $resource): iterable 24 | { 25 | if (is_iterable($resource)) { 26 | $files = []; 27 | foreach ($resource as $file) { 28 | if ($this->canBeExtracted($file)) { 29 | $files[] = $this->toSplFileInfo($file); 30 | } 31 | } 32 | } elseif (is_file($resource)) { 33 | $files = $this->canBeExtracted($resource) ? [$this->toSplFileInfo($resource)] : []; 34 | } else { 35 | $files = $this->extractFromDirectory($resource); 36 | } 37 | 38 | return $files; 39 | } 40 | 41 | private function toSplFileInfo(string $file): \SplFileInfo 42 | { 43 | return new \SplFileInfo($file); 44 | } 45 | 46 | /** 47 | * @throws InvalidArgumentException 48 | */ 49 | protected function isFile(string $file): bool 50 | { 51 | if (!is_file($file)) { 52 | throw new InvalidArgumentException(\sprintf('The "%s" file does not exist.', $file)); 53 | } 54 | 55 | return true; 56 | } 57 | 58 | abstract protected function canBeExtracted(string $file): bool; 59 | 60 | abstract protected function extractFromDirectory(string|array $resource): iterable; 61 | } 62 | -------------------------------------------------------------------------------- /Extractor/ChainExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Extractor; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * ChainExtractor extracts translation messages from template files. 18 | * 19 | * @author Michel Salib 20 | */ 21 | class ChainExtractor implements ExtractorInterface 22 | { 23 | /** 24 | * The extractors. 25 | * 26 | * @var ExtractorInterface[] 27 | */ 28 | private array $extractors = []; 29 | 30 | /** 31 | * Adds a loader to the translation extractor. 32 | */ 33 | public function addExtractor(string $format, ExtractorInterface $extractor): void 34 | { 35 | $this->extractors[$format] = $extractor; 36 | } 37 | 38 | public function setPrefix(string $prefix): void 39 | { 40 | foreach ($this->extractors as $extractor) { 41 | $extractor->setPrefix($prefix); 42 | } 43 | } 44 | 45 | public function extract(string|iterable $directory, MessageCatalogue $catalogue): void 46 | { 47 | foreach ($this->extractors as $extractor) { 48 | $extractor->extract($directory, $catalogue); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Extractor/ExtractorInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Extractor; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * Extracts translation messages from a directory or files to the catalogue. 18 | * New found messages are injected to the catalogue using the prefix. 19 | * 20 | * @author Michel Salib 21 | */ 22 | interface ExtractorInterface 23 | { 24 | /** 25 | * Extracts translation messages from files, a file or a directory to the catalogue. 26 | * 27 | * @param string|iterable $resource Files, a file or a directory 28 | * 29 | * @return void 30 | */ 31 | public function extract(string|iterable $resource, MessageCatalogue $catalogue); 32 | 33 | /** 34 | * Sets the prefix that should be used for new found messages. 35 | * 36 | * @return void 37 | */ 38 | public function setPrefix(string $prefix); 39 | } 40 | -------------------------------------------------------------------------------- /Extractor/PhpAstExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Extractor; 13 | 14 | use PhpParser\NodeTraverser; 15 | use PhpParser\NodeVisitor; 16 | use PhpParser\Parser; 17 | use PhpParser\ParserFactory; 18 | use Symfony\Component\Finder\Finder; 19 | use Symfony\Component\Translation\Extractor\Visitor\AbstractVisitor; 20 | use Symfony\Component\Translation\MessageCatalogue; 21 | 22 | /** 23 | * PhpAstExtractor extracts translation messages from a PHP AST. 24 | * 25 | * @author Mathieu Santostefano 26 | */ 27 | final class PhpAstExtractor extends AbstractFileExtractor implements ExtractorInterface 28 | { 29 | private Parser $parser; 30 | 31 | public function __construct( 32 | /** 33 | * @param iterable $visitors 34 | */ 35 | private readonly iterable $visitors, 36 | private string $prefix = '', 37 | ) { 38 | if (!class_exists(ParserFactory::class)) { 39 | throw new \LogicException(\sprintf('You cannot use "%s" as the "nikic/php-parser" package is not installed. Try running "composer require nikic/php-parser".', static::class)); 40 | } 41 | 42 | $this->parser = (new ParserFactory())->createForHostVersion(); 43 | } 44 | 45 | public function extract(iterable|string $resource, MessageCatalogue $catalogue): void 46 | { 47 | foreach ($this->extractFiles($resource) as $file) { 48 | $traverser = new NodeTraverser(); 49 | 50 | // This is needed to resolve namespaces in class methods/constants. 51 | $nameResolver = new NodeVisitor\NameResolver(); 52 | $traverser->addVisitor($nameResolver); 53 | 54 | /** @var AbstractVisitor&NodeVisitor $visitor */ 55 | foreach ($this->visitors as $visitor) { 56 | $visitor->initialize($catalogue, $file, $this->prefix); 57 | $traverser->addVisitor($visitor); 58 | } 59 | 60 | $nodes = $this->parser->parse(file_get_contents($file)); 61 | $traverser->traverse($nodes); 62 | } 63 | } 64 | 65 | public function setPrefix(string $prefix): void 66 | { 67 | $this->prefix = $prefix; 68 | } 69 | 70 | protected function canBeExtracted(string $file): bool 71 | { 72 | return 'php' === pathinfo($file, \PATHINFO_EXTENSION) 73 | && $this->isFile($file) 74 | && preg_match('/\bt\(|->trans\(|TranslatableMessage|Symfony\\\\Component\\\\Validator\\\\Constraints/i', file_get_contents($file)); 75 | } 76 | 77 | protected function extractFromDirectory(array|string $resource): iterable|Finder 78 | { 79 | if (!class_exists(Finder::class)) { 80 | throw new \LogicException(\sprintf('You cannot use "%s" as the "symfony/finder" package is not installed. Try running "composer require symfony/finder".', static::class)); 81 | } 82 | 83 | return (new Finder())->files()->name('*.php')->in($resource); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Extractor/Visitor/AbstractVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Extractor\Visitor; 13 | 14 | use PhpParser\Node; 15 | use Symfony\Component\Translation\MessageCatalogue; 16 | 17 | /** 18 | * @author Mathieu Santostefano 19 | */ 20 | abstract class AbstractVisitor 21 | { 22 | private MessageCatalogue $catalogue; 23 | private \SplFileInfo $file; 24 | private string $messagePrefix; 25 | 26 | public function initialize(MessageCatalogue $catalogue, \SplFileInfo $file, string $messagePrefix): void 27 | { 28 | $this->catalogue = $catalogue; 29 | $this->file = $file; 30 | $this->messagePrefix = $messagePrefix; 31 | } 32 | 33 | protected function addMessageToCatalogue(string $message, ?string $domain, int $line): void 34 | { 35 | $domain ??= 'messages'; 36 | $this->catalogue->set($message, $this->messagePrefix.$message, $domain); 37 | $metadata = $this->catalogue->getMetadata($message, $domain) ?? []; 38 | $normalizedFilename = preg_replace('{[\\\\/]+}', '/', $this->file); 39 | $metadata['sources'][] = $normalizedFilename.':'.$line; 40 | $this->catalogue->setMetadata($message, $metadata, $domain); 41 | } 42 | 43 | protected function getStringArguments(Node\Expr\CallLike|Node\Attribute|Node\Expr\New_ $node, int|string $index, bool $indexIsRegex = false): array 44 | { 45 | if (\is_string($index)) { 46 | return $this->getStringNamedArguments($node, $index, $indexIsRegex); 47 | } 48 | 49 | $args = $node instanceof Node\Expr\CallLike ? $node->getRawArgs() : $node->args; 50 | 51 | if (!($arg = $args[$index] ?? null) instanceof Node\Arg) { 52 | return []; 53 | } 54 | 55 | return (array) $this->getStringValue($arg->value); 56 | } 57 | 58 | protected function hasNodeNamedArguments(Node\Expr\CallLike|Node\Attribute|Node\Expr\New_ $node): bool 59 | { 60 | $args = $node instanceof Node\Expr\CallLike ? $node->getRawArgs() : $node->args; 61 | 62 | foreach ($args as $arg) { 63 | if ($arg instanceof Node\Arg && null !== $arg->name) { 64 | return true; 65 | } 66 | } 67 | 68 | return false; 69 | } 70 | 71 | protected function nodeFirstNamedArgumentIndex(Node\Expr\CallLike|Node\Attribute|Node\Expr\New_ $node): int 72 | { 73 | $args = $node instanceof Node\Expr\CallLike ? $node->getRawArgs() : $node->args; 74 | 75 | foreach ($args as $i => $arg) { 76 | if ($arg instanceof Node\Arg && null !== $arg->name) { 77 | return $i; 78 | } 79 | } 80 | 81 | return \PHP_INT_MAX; 82 | } 83 | 84 | private function getStringNamedArguments(Node\Expr\CallLike|Node\Attribute $node, ?string $argumentName = null, bool $isArgumentNamePattern = false): array 85 | { 86 | $args = $node instanceof Node\Expr\CallLike ? $node->getArgs() : $node->args; 87 | $argumentValues = []; 88 | 89 | foreach ($args as $arg) { 90 | if (!$isArgumentNamePattern && $arg->name?->toString() === $argumentName) { 91 | $argumentValues[] = $this->getStringValue($arg->value); 92 | } elseif ($isArgumentNamePattern && preg_match($argumentName, $arg->name?->toString() ?? '') > 0) { 93 | $argumentValues[] = $this->getStringValue($arg->value); 94 | } 95 | } 96 | 97 | return array_filter($argumentValues); 98 | } 99 | 100 | private function getStringValue(Node $node): ?string 101 | { 102 | if ($node instanceof Node\Scalar\String_) { 103 | return $node->value; 104 | } 105 | 106 | if ($node instanceof Node\Expr\BinaryOp\Concat) { 107 | if (null === $left = $this->getStringValue($node->left)) { 108 | return null; 109 | } 110 | 111 | if (null === $right = $this->getStringValue($node->right)) { 112 | return null; 113 | } 114 | 115 | return $left.$right; 116 | } 117 | 118 | if ($node instanceof Node\Expr\Assign && $node->expr instanceof Node\Scalar\String_) { 119 | return $node->expr->value; 120 | } 121 | 122 | if ($node instanceof Node\Expr\ClassConstFetch) { 123 | try { 124 | $reflection = new \ReflectionClass($node->class->toString()); 125 | $constant = $reflection->getReflectionConstant($node->name->toString()); 126 | if (false !== $constant && \is_string($constant->getValue())) { 127 | return $constant->getValue(); 128 | } 129 | } catch (\ReflectionException) { 130 | } 131 | } 132 | 133 | return null; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Extractor/Visitor/ConstraintVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Extractor\Visitor; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * @author Mathieu Santostefano 19 | * 20 | * Code mostly comes from https://github.com/php-translation/extractor/blob/master/src/Visitor/Php/Symfony/Constraint.php 21 | */ 22 | final class ConstraintVisitor extends AbstractVisitor implements NodeVisitor 23 | { 24 | public function __construct( 25 | private readonly array $constraintClassNames = [], 26 | ) { 27 | } 28 | 29 | public function beforeTraverse(array $nodes): ?Node 30 | { 31 | return null; 32 | } 33 | 34 | public function enterNode(Node $node): ?Node 35 | { 36 | return null; 37 | } 38 | 39 | public function leaveNode(Node $node): ?Node 40 | { 41 | if (!$node instanceof Node\Expr\New_ && !$node instanceof Node\Attribute) { 42 | return null; 43 | } 44 | 45 | $className = $node instanceof Node\Attribute ? $node->name : $node->class; 46 | if (!$className instanceof Node\Name) { 47 | return null; 48 | } 49 | 50 | $parts = $className->getParts(); 51 | $isConstraintClass = false; 52 | 53 | foreach ($parts as $part) { 54 | if (\in_array($part, $this->constraintClassNames, true)) { 55 | $isConstraintClass = true; 56 | 57 | break; 58 | } 59 | } 60 | 61 | if (!$isConstraintClass) { 62 | return null; 63 | } 64 | 65 | $arg = $node->args[0] ?? null; 66 | if (!$arg instanceof Node\Arg) { 67 | return null; 68 | } 69 | 70 | if ($this->hasNodeNamedArguments($node)) { 71 | $messages = $this->getStringArguments($node, '/message/i', true); 72 | } else { 73 | if (!$arg->value instanceof Node\Expr\Array_) { 74 | // There is no way to guess which argument is a message to be translated. 75 | return null; 76 | } 77 | 78 | $messages = []; 79 | $options = $arg->value; 80 | 81 | /** @var Node\Expr\ArrayItem $item */ 82 | foreach ($options->items as $item) { 83 | if (!$item->key instanceof Node\Scalar\String_) { 84 | continue; 85 | } 86 | 87 | if (false === stripos($item->key->value ?? '', 'message')) { 88 | continue; 89 | } 90 | 91 | if (!$item->value instanceof Node\Scalar\String_) { 92 | continue; 93 | } 94 | 95 | $messages[] = $item->value->value; 96 | 97 | break; 98 | } 99 | } 100 | 101 | foreach ($messages as $message) { 102 | $this->addMessageToCatalogue($message, 'validators', $node->getStartLine()); 103 | } 104 | 105 | return null; 106 | } 107 | 108 | public function afterTraverse(array $nodes): ?Node 109 | { 110 | return null; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Extractor/Visitor/TransMethodVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Extractor\Visitor; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * @author Mathieu Santostefano 19 | */ 20 | final class TransMethodVisitor extends AbstractVisitor implements NodeVisitor 21 | { 22 | public function beforeTraverse(array $nodes): ?Node 23 | { 24 | return null; 25 | } 26 | 27 | public function enterNode(Node $node): ?Node 28 | { 29 | return null; 30 | } 31 | 32 | public function leaveNode(Node $node): ?Node 33 | { 34 | if (!$node instanceof Node\Expr\MethodCall && !$node instanceof Node\Expr\FuncCall) { 35 | return null; 36 | } 37 | 38 | if (!\is_string($node->name) && !$node->name instanceof Node\Identifier && !$node->name instanceof Node\Name) { 39 | return null; 40 | } 41 | 42 | $name = $node->name instanceof Node\Name ? $node->name->getLast() : (string) $node->name; 43 | 44 | if ('trans' === $name || 't' === $name) { 45 | $firstNamedArgumentIndex = $this->nodeFirstNamedArgumentIndex($node); 46 | 47 | if (!$messages = $this->getStringArguments($node, 0 < $firstNamedArgumentIndex ? 0 : 'id')) { 48 | return null; 49 | } 50 | 51 | $domain = $this->getStringArguments($node, 2 < $firstNamedArgumentIndex ? 2 : 'domain')[0] ?? null; 52 | 53 | foreach ($messages as $message) { 54 | $this->addMessageToCatalogue($message, $domain, $node->getStartLine()); 55 | } 56 | } 57 | 58 | return null; 59 | } 60 | 61 | public function afterTraverse(array $nodes): ?Node 62 | { 63 | return null; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Extractor/Visitor/TranslatableMessageVisitor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Extractor\Visitor; 13 | 14 | use PhpParser\Node; 15 | use PhpParser\NodeVisitor; 16 | 17 | /** 18 | * @author Mathieu Santostefano 19 | */ 20 | final class TranslatableMessageVisitor extends AbstractVisitor implements NodeVisitor 21 | { 22 | public function beforeTraverse(array $nodes): ?Node 23 | { 24 | return null; 25 | } 26 | 27 | public function enterNode(Node $node): ?Node 28 | { 29 | return null; 30 | } 31 | 32 | public function leaveNode(Node $node): ?Node 33 | { 34 | if (!$node instanceof Node\Expr\New_) { 35 | return null; 36 | } 37 | 38 | if (!($className = $node->class) instanceof Node\Name) { 39 | return null; 40 | } 41 | 42 | if (!\in_array('TranslatableMessage', $className->getParts(), true)) { 43 | return null; 44 | } 45 | 46 | $firstNamedArgumentIndex = $this->nodeFirstNamedArgumentIndex($node); 47 | 48 | if (!$messages = $this->getStringArguments($node, 0 < $firstNamedArgumentIndex ? 0 : 'message')) { 49 | return null; 50 | } 51 | 52 | $domain = $this->getStringArguments($node, 2 < $firstNamedArgumentIndex ? 2 : 'domain')[0] ?? null; 53 | 54 | foreach ($messages as $message) { 55 | $this->addMessageToCatalogue($message, $domain, $node->getStartLine()); 56 | } 57 | 58 | return null; 59 | } 60 | 61 | public function afterTraverse(array $nodes): ?Node 62 | { 63 | return null; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Formatter/IntlFormatter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Formatter; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | use Symfony\Component\Translation\Exception\LogicException; 16 | 17 | /** 18 | * @author Guilherme Blanco 19 | * @author Abdellatif Ait boudad 20 | */ 21 | class IntlFormatter implements IntlFormatterInterface 22 | { 23 | private bool $hasMessageFormatter; 24 | private array $cache = []; 25 | 26 | public function formatIntl(string $message, string $locale, array $parameters = []): string 27 | { 28 | // MessageFormatter constructor throws an exception if the message is empty 29 | if ('' === $message) { 30 | return ''; 31 | } 32 | 33 | if (!$formatter = $this->cache[$locale][$message] ?? null) { 34 | if (!$this->hasMessageFormatter ??= class_exists(\MessageFormatter::class)) { 35 | throw new LogicException('Cannot parse message translation: please install the "intl" PHP extension or the "symfony/polyfill-intl-messageformatter" package.'); 36 | } 37 | try { 38 | $this->cache[$locale][$message] = $formatter = new \MessageFormatter($locale, $message); 39 | } catch (\IntlException $e) { 40 | throw new InvalidArgumentException(\sprintf('Invalid message format (error #%d): ', intl_get_error_code()).intl_get_error_message(), 0, $e); 41 | } 42 | } 43 | 44 | foreach ($parameters as $key => $value) { 45 | if (\in_array($key[0] ?? null, ['%', '{'], true)) { 46 | unset($parameters[$key]); 47 | $parameters[trim($key, '%{ }')] = $value; 48 | } 49 | } 50 | 51 | if (false === $message = $formatter->format($parameters)) { 52 | throw new InvalidArgumentException(\sprintf('Unable to format message (error #%s): ', $formatter->getErrorCode()).$formatter->getErrorMessage()); 53 | } 54 | 55 | return $message; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Formatter/IntlFormatterInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Formatter; 13 | 14 | /** 15 | * Formats ICU message patterns. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface IntlFormatterInterface 20 | { 21 | /** 22 | * Formats a localized message using rules defined by ICU MessageFormat. 23 | * 24 | * @see http://icu-project.org/apiref/icu4c/classMessageFormat.html#details 25 | */ 26 | public function formatIntl(string $message, string $locale, array $parameters = []): string; 27 | } 28 | -------------------------------------------------------------------------------- /Formatter/MessageFormatter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Formatter; 13 | 14 | use Symfony\Component\Translation\IdentityTranslator; 15 | use Symfony\Contracts\Translation\TranslatorInterface; 16 | 17 | // Help opcache.preload discover always-needed symbols 18 | class_exists(IntlFormatter::class); 19 | 20 | /** 21 | * @author Abdellatif Ait boudad 22 | */ 23 | class MessageFormatter implements MessageFormatterInterface, IntlFormatterInterface 24 | { 25 | private TranslatorInterface $translator; 26 | private IntlFormatterInterface $intlFormatter; 27 | 28 | /** 29 | * @param TranslatorInterface|null $translator An identity translator to use as selector for pluralization 30 | */ 31 | public function __construct(?TranslatorInterface $translator = null, ?IntlFormatterInterface $intlFormatter = null) 32 | { 33 | $this->translator = $translator ?? new IdentityTranslator(); 34 | $this->intlFormatter = $intlFormatter ?? new IntlFormatter(); 35 | } 36 | 37 | public function format(string $message, string $locale, array $parameters = []): string 38 | { 39 | return $this->translator->trans($message, $parameters, null, $locale); 40 | } 41 | 42 | public function formatIntl(string $message, string $locale, array $parameters = []): string 43 | { 44 | return $this->intlFormatter->formatIntl($message, $locale, $parameters); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Formatter/MessageFormatterInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Formatter; 13 | 14 | /** 15 | * @author Guilherme Blanco 16 | * @author Abdellatif Ait boudad 17 | */ 18 | interface MessageFormatterInterface 19 | { 20 | /** 21 | * Formats a localized message pattern with given arguments. 22 | * 23 | * @param string $message The message (may also be an object that can be cast to string) 24 | * @param string $locale The message locale 25 | * @param array $parameters An array of parameters for the message 26 | */ 27 | public function format(string $message, string $locale, array $parameters = []): string; 28 | } 29 | -------------------------------------------------------------------------------- /IdentityTranslator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | use Symfony\Contracts\Translation\LocaleAwareInterface; 15 | use Symfony\Contracts\Translation\TranslatorInterface; 16 | use Symfony\Contracts\Translation\TranslatorTrait; 17 | 18 | /** 19 | * IdentityTranslator does not translate anything. 20 | * 21 | * @author Fabien Potencier 22 | */ 23 | class IdentityTranslator implements TranslatorInterface, LocaleAwareInterface 24 | { 25 | use TranslatorTrait; 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2004-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Loader/ArrayLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * ArrayLoader loads translations from a PHP array. 18 | * 19 | * @author Fabien Potencier 20 | */ 21 | class ArrayLoader implements LoaderInterface 22 | { 23 | public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue 24 | { 25 | $resource = $this->flatten($resource); 26 | $catalogue = new MessageCatalogue($locale); 27 | $catalogue->add($resource, $domain); 28 | 29 | return $catalogue; 30 | } 31 | 32 | /** 33 | * Flattens an nested array of translations. 34 | * 35 | * The scheme used is: 36 | * 'key' => ['key2' => ['key3' => 'value']] 37 | * Becomes: 38 | * 'key.key2.key3' => 'value' 39 | */ 40 | private function flatten(array $messages): array 41 | { 42 | $result = []; 43 | foreach ($messages as $key => $value) { 44 | if (\is_array($value)) { 45 | foreach ($this->flatten($value) as $k => $v) { 46 | if (null !== $v) { 47 | $result[$key.'.'.$k] = $v; 48 | } 49 | } 50 | } elseif (null !== $value) { 51 | $result[$key] = $value; 52 | } 53 | } 54 | 55 | return $result; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Loader/CsvFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Translation\Exception\NotFoundResourceException; 15 | 16 | /** 17 | * CsvFileLoader loads translations from CSV files. 18 | * 19 | * @author Saša Stamenković 20 | */ 21 | class CsvFileLoader extends FileLoader 22 | { 23 | private string $delimiter = ';'; 24 | private string $enclosure = '"'; 25 | /** 26 | * @deprecated since Symfony 7.2, to be removed in 8.0 27 | */ 28 | private string $escape = ''; 29 | 30 | protected function loadResource(string $resource): array 31 | { 32 | $messages = []; 33 | 34 | try { 35 | $file = new \SplFileObject($resource, 'rb'); 36 | } catch (\RuntimeException $e) { 37 | throw new NotFoundResourceException(\sprintf('Error opening file "%s".', $resource), 0, $e); 38 | } 39 | 40 | $file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY); 41 | $file->setCsvControl($this->delimiter, $this->enclosure, $this->escape); 42 | 43 | foreach ($file as $data) { 44 | if (false === $data) { 45 | continue; 46 | } 47 | 48 | if (!str_starts_with($data[0], '#') && isset($data[1]) && 2 === \count($data)) { 49 | $messages[$data[0]] = $data[1]; 50 | } 51 | } 52 | 53 | return $messages; 54 | } 55 | 56 | /** 57 | * Sets the delimiter, enclosure, and escape character for CSV. 58 | */ 59 | public function setCsvControl(string $delimiter = ';', string $enclosure = '"', string $escape = ''): void 60 | { 61 | $this->delimiter = $delimiter; 62 | $this->enclosure = $enclosure; 63 | if ('' !== $escape) { 64 | trigger_deprecation('symfony/translation', '7.2', 'The "escape" parameter of the "%s" method is deprecated. It will be removed in 8.0.', __METHOD__); 65 | } 66 | 67 | $this->escape = $escape; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Loader/FileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Config\Resource\FileResource; 15 | use Symfony\Component\Translation\Exception\InvalidResourceException; 16 | use Symfony\Component\Translation\Exception\NotFoundResourceException; 17 | use Symfony\Component\Translation\MessageCatalogue; 18 | 19 | /** 20 | * @author Abdellatif Ait boudad 21 | */ 22 | abstract class FileLoader extends ArrayLoader 23 | { 24 | public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue 25 | { 26 | if (!stream_is_local($resource)) { 27 | throw new InvalidResourceException(\sprintf('This is not a local file "%s".', $resource)); 28 | } 29 | 30 | if (!file_exists($resource)) { 31 | throw new NotFoundResourceException(\sprintf('File "%s" not found.', $resource)); 32 | } 33 | 34 | $messages = $this->loadResource($resource); 35 | 36 | // empty resource 37 | $messages ??= []; 38 | 39 | // not an array 40 | if (!\is_array($messages)) { 41 | throw new InvalidResourceException(\sprintf('Unable to load file "%s".', $resource)); 42 | } 43 | 44 | $catalogue = parent::load($messages, $locale, $domain); 45 | 46 | if (class_exists(FileResource::class)) { 47 | $catalogue->addResource(new FileResource($resource)); 48 | } 49 | 50 | return $catalogue; 51 | } 52 | 53 | /** 54 | * @throws InvalidResourceException if stream content has an invalid format 55 | */ 56 | abstract protected function loadResource(string $resource): array; 57 | } 58 | -------------------------------------------------------------------------------- /Loader/IcuDatFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Config\Resource\FileResource; 15 | use Symfony\Component\Translation\Exception\InvalidResourceException; 16 | use Symfony\Component\Translation\Exception\NotFoundResourceException; 17 | use Symfony\Component\Translation\MessageCatalogue; 18 | 19 | /** 20 | * IcuResFileLoader loads translations from a resource bundle. 21 | * 22 | * @author stealth35 23 | */ 24 | class IcuDatFileLoader extends IcuResFileLoader 25 | { 26 | public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue 27 | { 28 | if (!stream_is_local($resource.'.dat')) { 29 | throw new InvalidResourceException(\sprintf('This is not a local file "%s".', $resource)); 30 | } 31 | 32 | if (!file_exists($resource.'.dat')) { 33 | throw new NotFoundResourceException(\sprintf('File "%s" not found.', $resource)); 34 | } 35 | 36 | try { 37 | $rb = new \ResourceBundle($locale, $resource); 38 | } catch (\Exception) { 39 | $rb = null; 40 | } 41 | 42 | if (!$rb) { 43 | throw new InvalidResourceException(\sprintf('Cannot load resource "%s".', $resource)); 44 | } elseif (intl_is_failure($rb->getErrorCode())) { 45 | throw new InvalidResourceException($rb->getErrorMessage(), $rb->getErrorCode()); 46 | } 47 | 48 | $messages = $this->flatten($rb); 49 | $catalogue = new MessageCatalogue($locale); 50 | $catalogue->add($messages, $domain); 51 | 52 | if (class_exists(FileResource::class)) { 53 | $catalogue->addResource(new FileResource($resource.'.dat')); 54 | } 55 | 56 | return $catalogue; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Loader/IcuResFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Config\Resource\DirectoryResource; 15 | use Symfony\Component\Translation\Exception\InvalidResourceException; 16 | use Symfony\Component\Translation\Exception\NotFoundResourceException; 17 | use Symfony\Component\Translation\MessageCatalogue; 18 | 19 | /** 20 | * IcuResFileLoader loads translations from a resource bundle. 21 | * 22 | * @author stealth35 23 | */ 24 | class IcuResFileLoader implements LoaderInterface 25 | { 26 | public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue 27 | { 28 | if (!stream_is_local($resource)) { 29 | throw new InvalidResourceException(\sprintf('This is not a local file "%s".', $resource)); 30 | } 31 | 32 | if (!is_dir($resource)) { 33 | throw new NotFoundResourceException(\sprintf('File "%s" not found.', $resource)); 34 | } 35 | 36 | try { 37 | $rb = new \ResourceBundle($locale, $resource); 38 | } catch (\Exception) { 39 | $rb = null; 40 | } 41 | 42 | if (!$rb) { 43 | throw new InvalidResourceException(\sprintf('Cannot load resource "%s".', $resource)); 44 | } elseif (intl_is_failure($rb->getErrorCode())) { 45 | throw new InvalidResourceException($rb->getErrorMessage(), $rb->getErrorCode()); 46 | } 47 | 48 | $messages = $this->flatten($rb); 49 | $catalogue = new MessageCatalogue($locale); 50 | $catalogue->add($messages, $domain); 51 | 52 | if (class_exists(DirectoryResource::class)) { 53 | $catalogue->addResource(new DirectoryResource($resource)); 54 | } 55 | 56 | return $catalogue; 57 | } 58 | 59 | /** 60 | * Flattens an ResourceBundle. 61 | * 62 | * The scheme used is: 63 | * key { key2 { key3 { "value" } } } 64 | * Becomes: 65 | * 'key.key2.key3' => 'value' 66 | * 67 | * This function takes an array by reference and will modify it 68 | * 69 | * @param \ResourceBundle $rb The ResourceBundle that will be flattened 70 | * @param array $messages Used internally for recursive calls 71 | * @param string|null $path Current path being parsed, used internally for recursive calls 72 | */ 73 | protected function flatten(\ResourceBundle $rb, array &$messages = [], ?string $path = null): array 74 | { 75 | foreach ($rb as $key => $value) { 76 | $nodePath = $path ? $path.'.'.$key : $key; 77 | if ($value instanceof \ResourceBundle) { 78 | $this->flatten($value, $messages, $nodePath); 79 | } else { 80 | $messages[$nodePath] = $value; 81 | } 82 | } 83 | 84 | return $messages; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Loader/IniFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | /** 15 | * IniFileLoader loads translations from an ini file. 16 | * 17 | * @author stealth35 18 | */ 19 | class IniFileLoader extends FileLoader 20 | { 21 | protected function loadResource(string $resource): array 22 | { 23 | return parse_ini_file($resource, true); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Loader/JsonFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidResourceException; 15 | 16 | /** 17 | * JsonFileLoader loads translations from an json file. 18 | * 19 | * @author singles 20 | */ 21 | class JsonFileLoader extends FileLoader 22 | { 23 | protected function loadResource(string $resource): array 24 | { 25 | $messages = []; 26 | if ($data = file_get_contents($resource)) { 27 | $messages = json_decode($data, true); 28 | 29 | if (0 < $errorCode = json_last_error()) { 30 | throw new InvalidResourceException('Error parsing JSON: '.$this->getJSONErrorMessage($errorCode)); 31 | } 32 | } 33 | 34 | return $messages; 35 | } 36 | 37 | /** 38 | * Translates JSON_ERROR_* constant into meaningful message. 39 | */ 40 | private function getJSONErrorMessage(int $errorCode): string 41 | { 42 | return match ($errorCode) { 43 | \JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', 44 | \JSON_ERROR_STATE_MISMATCH => 'Underflow or the modes mismatch', 45 | \JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', 46 | \JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', 47 | \JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded', 48 | default => 'Unknown error', 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Loader/LoaderInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidResourceException; 15 | use Symfony\Component\Translation\Exception\NotFoundResourceException; 16 | use Symfony\Component\Translation\MessageCatalogue; 17 | 18 | /** 19 | * LoaderInterface is the interface implemented by all translation loaders. 20 | * 21 | * @author Fabien Potencier 22 | */ 23 | interface LoaderInterface 24 | { 25 | /** 26 | * Loads a locale. 27 | * 28 | * @throws NotFoundResourceException when the resource cannot be found 29 | * @throws InvalidResourceException when the resource cannot be loaded 30 | */ 31 | public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue; 32 | } 33 | -------------------------------------------------------------------------------- /Loader/MoFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidResourceException; 15 | 16 | /** 17 | * @copyright Copyright (c) 2010, Union of RAD http://union-of-rad.org (http://lithify.me/) 18 | */ 19 | class MoFileLoader extends FileLoader 20 | { 21 | /** 22 | * Magic used for validating the format of an MO file as well as 23 | * detecting if the machine used to create that file was little endian. 24 | */ 25 | public const MO_LITTLE_ENDIAN_MAGIC = 0x950412DE; 26 | 27 | /** 28 | * Magic used for validating the format of an MO file as well as 29 | * detecting if the machine used to create that file was big endian. 30 | */ 31 | public const MO_BIG_ENDIAN_MAGIC = 0xDE120495; 32 | 33 | /** 34 | * The size of the header of an MO file in bytes. 35 | */ 36 | public const MO_HEADER_SIZE = 28; 37 | 38 | /** 39 | * Parses machine object (MO) format, independent of the machine's endian it 40 | * was created on. Both 32bit and 64bit systems are supported. 41 | */ 42 | protected function loadResource(string $resource): array 43 | { 44 | $stream = fopen($resource, 'r'); 45 | 46 | $stat = fstat($stream); 47 | 48 | if ($stat['size'] < self::MO_HEADER_SIZE) { 49 | throw new InvalidResourceException('MO stream content has an invalid format.'); 50 | } 51 | $magic = unpack('V1', fread($stream, 4)); 52 | $magic = hexdec(substr(dechex(current($magic)), -8)); 53 | 54 | if (self::MO_LITTLE_ENDIAN_MAGIC == $magic) { 55 | $isBigEndian = false; 56 | } elseif (self::MO_BIG_ENDIAN_MAGIC == $magic) { 57 | $isBigEndian = true; 58 | } else { 59 | throw new InvalidResourceException('MO stream content has an invalid format.'); 60 | } 61 | 62 | // formatRevision 63 | $this->readLong($stream, $isBigEndian); 64 | $count = $this->readLong($stream, $isBigEndian); 65 | $offsetId = $this->readLong($stream, $isBigEndian); 66 | $offsetTranslated = $this->readLong($stream, $isBigEndian); 67 | // sizeHashes 68 | $this->readLong($stream, $isBigEndian); 69 | // offsetHashes 70 | $this->readLong($stream, $isBigEndian); 71 | 72 | $messages = []; 73 | 74 | for ($i = 0; $i < $count; ++$i) { 75 | $pluralId = null; 76 | $translated = null; 77 | 78 | fseek($stream, $offsetId + $i * 8); 79 | 80 | $length = $this->readLong($stream, $isBigEndian); 81 | $offset = $this->readLong($stream, $isBigEndian); 82 | 83 | if ($length < 1) { 84 | continue; 85 | } 86 | 87 | fseek($stream, $offset); 88 | $singularId = fread($stream, $length); 89 | 90 | if (str_contains($singularId, "\000")) { 91 | [$singularId, $pluralId] = explode("\000", $singularId); 92 | } 93 | 94 | fseek($stream, $offsetTranslated + $i * 8); 95 | $length = $this->readLong($stream, $isBigEndian); 96 | $offset = $this->readLong($stream, $isBigEndian); 97 | 98 | if ($length < 1) { 99 | continue; 100 | } 101 | 102 | fseek($stream, $offset); 103 | $translated = fread($stream, $length); 104 | 105 | if (str_contains($translated, "\000")) { 106 | $translated = explode("\000", $translated); 107 | } 108 | 109 | $ids = ['singular' => $singularId, 'plural' => $pluralId]; 110 | $item = compact('ids', 'translated'); 111 | 112 | if (!empty($item['ids']['singular'])) { 113 | $id = $item['ids']['singular']; 114 | if (isset($item['ids']['plural'])) { 115 | $id .= '|'.$item['ids']['plural']; 116 | } 117 | $messages[$id] = stripcslashes(implode('|', (array) $item['translated'])); 118 | } 119 | } 120 | 121 | fclose($stream); 122 | 123 | return array_filter($messages); 124 | } 125 | 126 | /** 127 | * Reads an unsigned long from stream respecting endianness. 128 | * 129 | * @param resource $stream 130 | */ 131 | private function readLong($stream, bool $isBigEndian): int 132 | { 133 | $result = unpack($isBigEndian ? 'N1' : 'V1', fread($stream, 4)); 134 | $result = current($result); 135 | 136 | return (int) substr($result, -8); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Loader/PhpFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | /** 15 | * PhpFileLoader loads translations from PHP files returning an array of translations. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | class PhpFileLoader extends FileLoader 20 | { 21 | private static ?array $cache = []; 22 | 23 | protected function loadResource(string $resource): array 24 | { 25 | if ([] === self::$cache && \function_exists('opcache_invalidate') && filter_var(\ini_get('opcache.enable'), \FILTER_VALIDATE_BOOL) && (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true) || filter_var(\ini_get('opcache.enable_cli'), \FILTER_VALIDATE_BOOL))) { 26 | self::$cache = null; 27 | } 28 | 29 | if (null === self::$cache) { 30 | return require $resource; 31 | } 32 | 33 | return self::$cache[$resource] ??= require $resource; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Loader/PoFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | /** 15 | * @copyright Copyright (c) 2010, Union of RAD https://github.com/UnionOfRAD/lithium 16 | * @copyright Copyright (c) 2012, Clemens Tolboom 17 | */ 18 | class PoFileLoader extends FileLoader 19 | { 20 | /** 21 | * Parses portable object (PO) format. 22 | * 23 | * From https://www.gnu.org/software/gettext/manual/gettext.html#PO-Files 24 | * we should be able to parse files having: 25 | * 26 | * white-space 27 | * # translator-comments 28 | * #. extracted-comments 29 | * #: reference... 30 | * #, flag... 31 | * #| msgid previous-untranslated-string 32 | * msgid untranslated-string 33 | * msgstr translated-string 34 | * 35 | * extra or different lines are: 36 | * 37 | * #| msgctxt previous-context 38 | * #| msgid previous-untranslated-string 39 | * msgctxt context 40 | * 41 | * #| msgid previous-untranslated-string-singular 42 | * #| msgid_plural previous-untranslated-string-plural 43 | * msgid untranslated-string-singular 44 | * msgid_plural untranslated-string-plural 45 | * msgstr[0] translated-string-case-0 46 | * ... 47 | * msgstr[N] translated-string-case-n 48 | * 49 | * The definition states: 50 | * - white-space and comments are optional. 51 | * - msgid "" that an empty singleline defines a header. 52 | * 53 | * This parser sacrifices some features of the reference implementation the 54 | * differences to that implementation are as follows. 55 | * - No support for comments spanning multiple lines. 56 | * - Translator and extracted comments are treated as being the same type. 57 | * - Message IDs are allowed to have other encodings as just US-ASCII. 58 | * 59 | * Items with an empty id are ignored. 60 | */ 61 | protected function loadResource(string $resource): array 62 | { 63 | $stream = fopen($resource, 'r'); 64 | 65 | $defaults = [ 66 | 'ids' => [], 67 | 'translated' => null, 68 | ]; 69 | 70 | $messages = []; 71 | $item = $defaults; 72 | $flags = []; 73 | 74 | while ($line = fgets($stream)) { 75 | $line = trim($line); 76 | 77 | if ('' === $line) { 78 | // Whitespace indicated current item is done 79 | if (!\in_array('fuzzy', $flags, true)) { 80 | $this->addMessage($messages, $item); 81 | } 82 | $item = $defaults; 83 | $flags = []; 84 | } elseif (str_starts_with($line, '#,')) { 85 | $flags = array_map('trim', explode(',', substr($line, 2))); 86 | } elseif (str_starts_with($line, 'msgid "')) { 87 | // We start a new msg so save previous 88 | // TODO: this fails when comments or contexts are added 89 | $this->addMessage($messages, $item); 90 | $item = $defaults; 91 | $item['ids']['singular'] = substr($line, 7, -1); 92 | } elseif (str_starts_with($line, 'msgstr "')) { 93 | $item['translated'] = substr($line, 8, -1); 94 | } elseif ('"' === $line[0]) { 95 | $continues = isset($item['translated']) ? 'translated' : 'ids'; 96 | 97 | if (\is_array($item[$continues])) { 98 | end($item[$continues]); 99 | $item[$continues][key($item[$continues])] .= substr($line, 1, -1); 100 | } else { 101 | $item[$continues] .= substr($line, 1, -1); 102 | } 103 | } elseif (str_starts_with($line, 'msgid_plural "')) { 104 | $item['ids']['plural'] = substr($line, 14, -1); 105 | } elseif (str_starts_with($line, 'msgstr[')) { 106 | $size = strpos($line, ']'); 107 | $item['translated'][(int) substr($line, 7, 1)] = substr($line, $size + 3, -1); 108 | } 109 | } 110 | // save last item 111 | if (!\in_array('fuzzy', $flags, true)) { 112 | $this->addMessage($messages, $item); 113 | } 114 | fclose($stream); 115 | 116 | return $messages; 117 | } 118 | 119 | /** 120 | * Save a translation item to the messages. 121 | * 122 | * A .po file could contain by error missing plural indexes. We need to 123 | * fix these before saving them. 124 | */ 125 | private function addMessage(array &$messages, array $item): void 126 | { 127 | if (!empty($item['ids']['singular'])) { 128 | $id = stripcslashes($item['ids']['singular']); 129 | if (isset($item['ids']['plural'])) { 130 | $id .= '|'.stripcslashes($item['ids']['plural']); 131 | } 132 | 133 | $translated = (array) $item['translated']; 134 | // PO are by definition indexed so sort by index. 135 | ksort($translated); 136 | // Make sure every index is filled. 137 | end($translated); 138 | $count = key($translated); 139 | // Fill missing spots with '-'. 140 | $empties = array_fill(0, $count + 1, '-'); 141 | $translated += $empties; 142 | ksort($translated); 143 | 144 | $messages[$id] = stripcslashes(implode('|', $translated)); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Loader/QtFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Config\Resource\FileResource; 15 | use Symfony\Component\Config\Util\XmlUtils; 16 | use Symfony\Component\Translation\Exception\InvalidResourceException; 17 | use Symfony\Component\Translation\Exception\NotFoundResourceException; 18 | use Symfony\Component\Translation\Exception\RuntimeException; 19 | use Symfony\Component\Translation\MessageCatalogue; 20 | 21 | /** 22 | * QtFileLoader loads translations from QT Translations XML files. 23 | * 24 | * @author Benjamin Eberlei 25 | */ 26 | class QtFileLoader implements LoaderInterface 27 | { 28 | public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue 29 | { 30 | if (!class_exists(XmlUtils::class)) { 31 | throw new RuntimeException('Loading translations from the QT format requires the Symfony Config component.'); 32 | } 33 | 34 | if (!stream_is_local($resource)) { 35 | throw new InvalidResourceException(\sprintf('This is not a local file "%s".', $resource)); 36 | } 37 | 38 | if (!file_exists($resource)) { 39 | throw new NotFoundResourceException(\sprintf('File "%s" not found.', $resource)); 40 | } 41 | 42 | try { 43 | $dom = XmlUtils::loadFile($resource); 44 | } catch (\InvalidArgumentException $e) { 45 | throw new InvalidResourceException(\sprintf('Unable to load "%s".', $resource), $e->getCode(), $e); 46 | } 47 | 48 | $internalErrors = libxml_use_internal_errors(true); 49 | libxml_clear_errors(); 50 | 51 | $xpath = new \DOMXPath($dom); 52 | $nodes = $xpath->evaluate('//TS/context/name[text()="'.$domain.'"]'); 53 | 54 | $catalogue = new MessageCatalogue($locale); 55 | if (1 == $nodes->length) { 56 | $translations = $nodes->item(0)->nextSibling->parentNode->parentNode->getElementsByTagName('message'); 57 | foreach ($translations as $translation) { 58 | $translationValue = (string) $translation->getElementsByTagName('translation')->item(0)->nodeValue; 59 | 60 | if ($translationValue) { 61 | $catalogue->set( 62 | (string) $translation->getElementsByTagName('source')->item(0)->nodeValue, 63 | $translationValue, 64 | $domain 65 | ); 66 | } 67 | } 68 | 69 | if (class_exists(FileResource::class)) { 70 | $catalogue->addResource(new FileResource($resource)); 71 | } 72 | } 73 | 74 | libxml_use_internal_errors($internalErrors); 75 | 76 | return $catalogue; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Loader/YamlFileLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Loader; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidResourceException; 15 | use Symfony\Component\Translation\Exception\LogicException; 16 | use Symfony\Component\Yaml\Exception\ParseException; 17 | use Symfony\Component\Yaml\Parser as YamlParser; 18 | use Symfony\Component\Yaml\Yaml; 19 | 20 | /** 21 | * YamlFileLoader loads translations from Yaml files. 22 | * 23 | * @author Fabien Potencier 24 | */ 25 | class YamlFileLoader extends FileLoader 26 | { 27 | private YamlParser $yamlParser; 28 | 29 | protected function loadResource(string $resource): array 30 | { 31 | if (!isset($this->yamlParser)) { 32 | if (!class_exists(YamlParser::class)) { 33 | throw new LogicException('Loading translations from the YAML format requires the Symfony Yaml component.'); 34 | } 35 | 36 | $this->yamlParser = new YamlParser(); 37 | } 38 | 39 | try { 40 | $messages = $this->yamlParser->parseFile($resource, Yaml::PARSE_CONSTANT); 41 | } catch (ParseException $e) { 42 | throw new InvalidResourceException(\sprintf('The file "%s" does not contain valid YAML: ', $resource).$e->getMessage(), 0, $e); 43 | } 44 | 45 | if (null !== $messages && !\is_array($messages)) { 46 | throw new InvalidResourceException(\sprintf('Unable to load file "%s".', $resource)); 47 | } 48 | 49 | return $messages ?: []; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /LocaleSwitcher.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | use Symfony\Component\Routing\RequestContext; 15 | use Symfony\Contracts\Translation\LocaleAwareInterface; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | class LocaleSwitcher implements LocaleAwareInterface 21 | { 22 | private string $defaultLocale; 23 | 24 | /** 25 | * @param LocaleAwareInterface[] $localeAwareServices 26 | */ 27 | public function __construct( 28 | private string $locale, 29 | private iterable $localeAwareServices, 30 | private ?RequestContext $requestContext = null, 31 | ) { 32 | $this->defaultLocale = $locale; 33 | } 34 | 35 | public function setLocale(string $locale): void 36 | { 37 | // Silently ignore if the intl extension is not loaded 38 | try { 39 | if (class_exists(\Locale::class, false)) { 40 | \Locale::setDefault($locale); 41 | } 42 | } catch (\Exception) { 43 | } 44 | 45 | $this->locale = $locale; 46 | $this->requestContext?->setParameter('_locale', $locale); 47 | 48 | foreach ($this->localeAwareServices as $service) { 49 | $service->setLocale($locale); 50 | } 51 | } 52 | 53 | public function getLocale(): string 54 | { 55 | return $this->locale; 56 | } 57 | 58 | /** 59 | * Switch to a new locale, execute a callback, then switch back to the original. 60 | * 61 | * @template T 62 | * 63 | * @param callable(string $locale):T $callback 64 | * 65 | * @return T 66 | */ 67 | public function runWithLocale(string $locale, callable $callback): mixed 68 | { 69 | $original = $this->getLocale(); 70 | $this->setLocale($locale); 71 | 72 | try { 73 | return $callback($locale); 74 | } finally { 75 | $this->setLocale($original); 76 | } 77 | } 78 | 79 | public function reset(): void 80 | { 81 | $this->setLocale($this->defaultLocale); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /LoggingTranslator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | use Psr\Log\LoggerInterface; 15 | use Symfony\Contracts\Translation\LocaleAwareInterface; 16 | use Symfony\Contracts\Translation\TranslatorInterface; 17 | 18 | /** 19 | * @author Abdellatif Ait boudad 20 | */ 21 | class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface, LocaleAwareInterface 22 | { 23 | public function __construct( 24 | private TranslatorInterface&TranslatorBagInterface&LocaleAwareInterface $translator, 25 | private LoggerInterface $logger, 26 | ) { 27 | } 28 | 29 | public function trans(?string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string 30 | { 31 | $trans = $this->translator->trans($id = (string) $id, $parameters, $domain, $locale); 32 | $this->log($id, $domain, $locale); 33 | 34 | return $trans; 35 | } 36 | 37 | public function setLocale(string $locale): void 38 | { 39 | $prev = $this->translator->getLocale(); 40 | $this->translator->setLocale($locale); 41 | if ($prev === $locale) { 42 | return; 43 | } 44 | 45 | $this->logger->debug(\sprintf('The locale of the translator has changed from "%s" to "%s".', $prev, $locale)); 46 | } 47 | 48 | public function getLocale(): string 49 | { 50 | return $this->translator->getLocale(); 51 | } 52 | 53 | public function getCatalogue(?string $locale = null): MessageCatalogueInterface 54 | { 55 | return $this->translator->getCatalogue($locale); 56 | } 57 | 58 | public function getCatalogues(): array 59 | { 60 | return $this->translator->getCatalogues(); 61 | } 62 | 63 | /** 64 | * Gets the fallback locales. 65 | */ 66 | public function getFallbackLocales(): array 67 | { 68 | if ($this->translator instanceof Translator || method_exists($this->translator, 'getFallbackLocales')) { 69 | return $this->translator->getFallbackLocales(); 70 | } 71 | 72 | return []; 73 | } 74 | 75 | public function __call(string $method, array $args): mixed 76 | { 77 | return $this->translator->{$method}(...$args); 78 | } 79 | 80 | /** 81 | * Logs for missing translations. 82 | */ 83 | private function log(string $id, ?string $domain, ?string $locale): void 84 | { 85 | $domain ??= 'messages'; 86 | 87 | $catalogue = $this->translator->getCatalogue($locale); 88 | if ($catalogue->defines($id, $domain)) { 89 | return; 90 | } 91 | 92 | if ($catalogue->has($id, $domain)) { 93 | $this->logger->debug('Translation use fallback catalogue.', ['id' => $id, 'domain' => $domain, 'locale' => $catalogue->getLocale()]); 94 | } else { 95 | $this->logger->warning('Translation not found.', ['id' => $id, 'domain' => $domain, 'locale' => $catalogue->getLocale()]); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /MessageCatalogueInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | use Symfony\Component\Config\Resource\ResourceInterface; 15 | 16 | /** 17 | * MessageCatalogueInterface. 18 | * 19 | * @author Fabien Potencier 20 | */ 21 | interface MessageCatalogueInterface 22 | { 23 | public const INTL_DOMAIN_SUFFIX = '+intl-icu'; 24 | 25 | /** 26 | * Gets the catalogue locale. 27 | */ 28 | public function getLocale(): string; 29 | 30 | /** 31 | * Gets the domains. 32 | */ 33 | public function getDomains(): array; 34 | 35 | /** 36 | * Gets the messages within a given domain. 37 | * 38 | * If $domain is null, it returns all messages. 39 | */ 40 | public function all(?string $domain = null): array; 41 | 42 | /** 43 | * Sets a message translation. 44 | * 45 | * @param string $id The message id 46 | * @param string $translation The messages translation 47 | * @param string $domain The domain name 48 | */ 49 | public function set(string $id, string $translation, string $domain = 'messages'): void; 50 | 51 | /** 52 | * Checks if a message has a translation. 53 | * 54 | * @param string $id The message id 55 | * @param string $domain The domain name 56 | */ 57 | public function has(string $id, string $domain = 'messages'): bool; 58 | 59 | /** 60 | * Checks if a message has a translation (it does not take into account the fallback mechanism). 61 | * 62 | * @param string $id The message id 63 | * @param string $domain The domain name 64 | */ 65 | public function defines(string $id, string $domain = 'messages'): bool; 66 | 67 | /** 68 | * Gets a message translation. 69 | * 70 | * @param string $id The message id 71 | * @param string $domain The domain name 72 | */ 73 | public function get(string $id, string $domain = 'messages'): string; 74 | 75 | /** 76 | * Sets translations for a given domain. 77 | * 78 | * @param array $messages An array of translations 79 | * @param string $domain The domain name 80 | */ 81 | public function replace(array $messages, string $domain = 'messages'): void; 82 | 83 | /** 84 | * Adds translations for a given domain. 85 | * 86 | * @param array $messages An array of translations 87 | * @param string $domain The domain name 88 | */ 89 | public function add(array $messages, string $domain = 'messages'): void; 90 | 91 | /** 92 | * Merges translations from the given Catalogue into the current one. 93 | * 94 | * The two catalogues must have the same locale. 95 | */ 96 | public function addCatalogue(self $catalogue): void; 97 | 98 | /** 99 | * Merges translations from the given Catalogue into the current one 100 | * only when the translation does not exist. 101 | * 102 | * This is used to provide default translations when they do not exist for the current locale. 103 | */ 104 | public function addFallbackCatalogue(self $catalogue): void; 105 | 106 | /** 107 | * Gets the fallback catalogue. 108 | */ 109 | public function getFallbackCatalogue(): ?self; 110 | 111 | /** 112 | * Returns an array of resources loaded to build this collection. 113 | * 114 | * @return ResourceInterface[] 115 | */ 116 | public function getResources(): array; 117 | 118 | /** 119 | * Adds a resource for this collection. 120 | */ 121 | public function addResource(ResourceInterface $resource): void; 122 | } 123 | -------------------------------------------------------------------------------- /MetadataAwareInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | /** 15 | * This interface is used to get, set, and delete metadata about the translation messages. 16 | * 17 | * @author Fabien Potencier 18 | */ 19 | interface MetadataAwareInterface 20 | { 21 | /** 22 | * Gets metadata for the given domain and key. 23 | * 24 | * Passing an empty domain will return an array with all metadata indexed by 25 | * domain and then by key. Passing an empty key will return an array with all 26 | * metadata for the given domain. 27 | * 28 | * @return mixed The value that was set or an array with the domains/keys or null 29 | */ 30 | public function getMetadata(string $key = '', string $domain = 'messages'): mixed; 31 | 32 | /** 33 | * Adds metadata to a message domain. 34 | */ 35 | public function setMetadata(string $key, mixed $value, string $domain = 'messages'): void; 36 | 37 | /** 38 | * Deletes metadata for the given key and domain. 39 | * 40 | * Passing an empty domain will delete all metadata. Passing an empty key will 41 | * delete all metadata for the given domain. 42 | */ 43 | public function deleteMetadata(string $key = '', string $domain = 'messages'): void; 44 | } 45 | -------------------------------------------------------------------------------- /Provider/AbstractProviderFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\Exception\IncompleteDsnException; 15 | 16 | abstract class AbstractProviderFactory implements ProviderFactoryInterface 17 | { 18 | public function supports(Dsn $dsn): bool 19 | { 20 | return \in_array($dsn->getScheme(), $this->getSupportedSchemes(), true); 21 | } 22 | 23 | /** 24 | * @return string[] 25 | */ 26 | abstract protected function getSupportedSchemes(): array; 27 | 28 | protected function getUser(Dsn $dsn): string 29 | { 30 | return $dsn->getUser() ?? throw new IncompleteDsnException('User is not set.', $dsn->getScheme().'://'.$dsn->getHost()); 31 | } 32 | 33 | protected function getPassword(Dsn $dsn): string 34 | { 35 | return $dsn->getPassword() ?? throw new IncompleteDsnException('Password is not set.', $dsn->getOriginalDsn()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Provider/Dsn.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | use Symfony\Component\Translation\Exception\MissingRequiredOptionException; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | * @author Oskar Stark 20 | */ 21 | final class Dsn 22 | { 23 | private ?string $scheme; 24 | private ?string $host; 25 | private ?string $user; 26 | private ?string $password; 27 | private ?int $port; 28 | private ?string $path; 29 | private array $options = []; 30 | private string $originalDsn; 31 | 32 | public function __construct(#[\SensitiveParameter] string $dsn) 33 | { 34 | $this->originalDsn = $dsn; 35 | 36 | if (false === $params = parse_url($dsn)) { 37 | throw new InvalidArgumentException('The translation provider DSN is invalid.'); 38 | } 39 | 40 | if (!isset($params['scheme'])) { 41 | throw new InvalidArgumentException('The translation provider DSN must contain a scheme.'); 42 | } 43 | $this->scheme = $params['scheme']; 44 | 45 | if (!isset($params['host'])) { 46 | throw new InvalidArgumentException('The translation provider DSN must contain a host (use "default" by default).'); 47 | } 48 | $this->host = $params['host']; 49 | 50 | $this->user = '' !== ($params['user'] ?? '') ? rawurldecode($params['user']) : null; 51 | $this->password = '' !== ($params['pass'] ?? '') ? rawurldecode($params['pass']) : null; 52 | $this->port = $params['port'] ?? null; 53 | $this->path = $params['path'] ?? null; 54 | parse_str($params['query'] ?? '', $this->options); 55 | } 56 | 57 | public function getScheme(): string 58 | { 59 | return $this->scheme; 60 | } 61 | 62 | public function getHost(): string 63 | { 64 | return $this->host; 65 | } 66 | 67 | public function getUser(): ?string 68 | { 69 | return $this->user; 70 | } 71 | 72 | public function getPassword(): ?string 73 | { 74 | return $this->password; 75 | } 76 | 77 | public function getPort(?int $default = null): ?int 78 | { 79 | return $this->port ?? $default; 80 | } 81 | 82 | public function getOption(string $key, mixed $default = null): mixed 83 | { 84 | return $this->options[$key] ?? $default; 85 | } 86 | 87 | public function getRequiredOption(string $key): mixed 88 | { 89 | if (!\array_key_exists($key, $this->options) || '' === trim($this->options[$key])) { 90 | throw new MissingRequiredOptionException($key); 91 | } 92 | 93 | return $this->options[$key]; 94 | } 95 | 96 | public function getOptions(): array 97 | { 98 | return $this->options; 99 | } 100 | 101 | public function getPath(): ?string 102 | { 103 | return $this->path; 104 | } 105 | 106 | public function getOriginalDsn(): string 107 | { 108 | return $this->originalDsn; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Provider/FilteringProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\TranslatorBag; 15 | use Symfony\Component\Translation\TranslatorBagInterface; 16 | 17 | /** 18 | * Filters domains and locales between the Translator config values and those specific to each provider. 19 | * 20 | * @author Mathieu Santostefano 21 | */ 22 | class FilteringProvider implements ProviderInterface 23 | { 24 | public function __construct( 25 | private ProviderInterface $provider, 26 | private array $locales, 27 | private array $domains = [], 28 | ) { 29 | } 30 | 31 | public function __toString(): string 32 | { 33 | return (string) $this->provider; 34 | } 35 | 36 | public function write(TranslatorBagInterface $translatorBag): void 37 | { 38 | $this->provider->write($translatorBag); 39 | } 40 | 41 | public function read(array $domains, array $locales): TranslatorBag 42 | { 43 | $domains = !$this->domains ? $domains : array_intersect($this->domains, $domains); 44 | $locales = array_intersect($this->locales, $locales); 45 | 46 | return $this->provider->read($domains, $locales); 47 | } 48 | 49 | public function delete(TranslatorBagInterface $translatorBag): void 50 | { 51 | $this->provider->delete($translatorBag); 52 | } 53 | 54 | public function getDomains(): array 55 | { 56 | return $this->domains; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Provider/NullProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\TranslatorBag; 15 | use Symfony\Component\Translation\TranslatorBagInterface; 16 | 17 | /** 18 | * @author Mathieu Santostefano 19 | */ 20 | class NullProvider implements ProviderInterface 21 | { 22 | public function __toString(): string 23 | { 24 | return 'null'; 25 | } 26 | 27 | public function write(TranslatorBagInterface $translatorBag, bool $override = false): void 28 | { 29 | } 30 | 31 | public function read(array $domains, array $locales): TranslatorBag 32 | { 33 | return new TranslatorBag(); 34 | } 35 | 36 | public function delete(TranslatorBagInterface $translatorBag): void 37 | { 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Provider/NullProviderFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\Exception\UnsupportedSchemeException; 15 | 16 | /** 17 | * @author Mathieu Santostefano 18 | */ 19 | final class NullProviderFactory extends AbstractProviderFactory 20 | { 21 | public function create(Dsn $dsn): ProviderInterface 22 | { 23 | if ('null' === $dsn->getScheme()) { 24 | return new NullProvider(); 25 | } 26 | 27 | throw new UnsupportedSchemeException($dsn, 'null', $this->getSupportedSchemes()); 28 | } 29 | 30 | protected function getSupportedSchemes(): array 31 | { 32 | return ['null']; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Provider/ProviderFactoryInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\Exception\IncompleteDsnException; 15 | use Symfony\Component\Translation\Exception\UnsupportedSchemeException; 16 | 17 | interface ProviderFactoryInterface 18 | { 19 | /** 20 | * @throws UnsupportedSchemeException 21 | * @throws IncompleteDsnException 22 | */ 23 | public function create(Dsn $dsn): ProviderInterface; 24 | 25 | public function supports(Dsn $dsn): bool; 26 | } 27 | -------------------------------------------------------------------------------- /Provider/ProviderInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\TranslatorBag; 15 | use Symfony\Component\Translation\TranslatorBagInterface; 16 | 17 | interface ProviderInterface extends \Stringable 18 | { 19 | /** 20 | * Translations available in the TranslatorBag only must be created. 21 | * Translations available in both the TranslatorBag and on the provider 22 | * must be overwritten. 23 | * Translations available on the provider only must be kept. 24 | */ 25 | public function write(TranslatorBagInterface $translatorBag): void; 26 | 27 | public function read(array $domains, array $locales): TranslatorBag; 28 | 29 | public function delete(TranslatorBagInterface $translatorBag): void; 30 | } 31 | -------------------------------------------------------------------------------- /Provider/TranslationProviderCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | 16 | /** 17 | * @author Mathieu Santostefano 18 | */ 19 | final class TranslationProviderCollection 20 | { 21 | /** 22 | * @var array 23 | */ 24 | private array $providers; 25 | 26 | /** 27 | * @param array $providers 28 | */ 29 | public function __construct(iterable $providers) 30 | { 31 | $this->providers = \is_array($providers) ? $providers : iterator_to_array($providers); 32 | } 33 | 34 | public function __toString(): string 35 | { 36 | return '['.implode(',', array_keys($this->providers)).']'; 37 | } 38 | 39 | public function has(string $name): bool 40 | { 41 | return isset($this->providers[$name]); 42 | } 43 | 44 | public function get(string $name): ProviderInterface 45 | { 46 | if (!$this->has($name)) { 47 | throw new InvalidArgumentException(\sprintf('Provider "%s" not found. Available: "%s".', $name, (string) $this)); 48 | } 49 | 50 | return $this->providers[$name]; 51 | } 52 | 53 | public function keys(): array 54 | { 55 | return array_keys($this->providers); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Provider/TranslationProviderCollectionFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Provider; 13 | 14 | use Symfony\Component\Translation\Exception\UnsupportedSchemeException; 15 | 16 | /** 17 | * @author Mathieu Santostefano 18 | */ 19 | class TranslationProviderCollectionFactory 20 | { 21 | /** 22 | * @param iterable $factories 23 | */ 24 | public function __construct( 25 | private iterable $factories, 26 | private array $enabledLocales, 27 | ) { 28 | } 29 | 30 | public function fromConfig(array $config): TranslationProviderCollection 31 | { 32 | $providers = []; 33 | foreach ($config as $name => $currentConfig) { 34 | $providers[$name] = $this->fromDsnObject( 35 | new Dsn($currentConfig['dsn']), 36 | !$currentConfig['locales'] ? $this->enabledLocales : $currentConfig['locales'], 37 | !$currentConfig['domains'] ? [] : $currentConfig['domains'] 38 | ); 39 | } 40 | 41 | return new TranslationProviderCollection($providers); 42 | } 43 | 44 | public function fromDsnObject(Dsn $dsn, array $locales, array $domains = []): ProviderInterface 45 | { 46 | foreach ($this->factories as $factory) { 47 | if ($factory->supports($dsn)) { 48 | return new FilteringProvider($factory->create($dsn), $locales, $domains); 49 | } 50 | } 51 | 52 | throw new UnsupportedSchemeException($dsn); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Translation Component 2 | ===================== 3 | 4 | The Translation component provides tools to internationalize your application. 5 | 6 | Getting Started 7 | --------------- 8 | 9 | ```bash 10 | composer require symfony/translation 11 | ``` 12 | 13 | ```php 14 | use Symfony\Component\Translation\Translator; 15 | use Symfony\Component\Translation\Loader\ArrayLoader; 16 | 17 | $translator = new Translator('fr_FR'); 18 | $translator->addLoader('array', new ArrayLoader()); 19 | $translator->addResource('array', [ 20 | 'Hello World!' => 'Bonjour !', 21 | ], 'fr_FR'); 22 | 23 | echo $translator->trans('Hello World!'); // outputs « Bonjour ! » 24 | ``` 25 | 26 | Sponsor 27 | ------- 28 | 29 | The Translation component for Symfony 7.1 is [backed][1] by: 30 | 31 | * [Crowdin][2], a cloud-based localization management software helping teams to go global and stay agile. 32 | 33 | Help Symfony by [sponsoring][3] its development! 34 | 35 | Resources 36 | --------- 37 | 38 | * [Documentation](https://symfony.com/doc/current/translation.html) 39 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 40 | * [Report issues](https://github.com/symfony/symfony/issues) and 41 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 42 | in the [main Symfony repository](https://github.com/symfony/symfony) 43 | 44 | [1]: https://symfony.com/backers 45 | [2]: https://crowdin.com 46 | [3]: https://symfony.com/sponsor 47 | -------------------------------------------------------------------------------- /Reader/TranslationReader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Reader; 13 | 14 | use Symfony\Component\Finder\Finder; 15 | use Symfony\Component\Translation\Loader\LoaderInterface; 16 | use Symfony\Component\Translation\MessageCatalogue; 17 | 18 | /** 19 | * TranslationReader reads translation messages from translation files. 20 | * 21 | * @author Michel Salib 22 | */ 23 | class TranslationReader implements TranslationReaderInterface 24 | { 25 | /** 26 | * Loaders used for import. 27 | * 28 | * @var array 29 | */ 30 | private array $loaders = []; 31 | 32 | /** 33 | * Adds a loader to the translation extractor. 34 | * 35 | * @param string $format The format of the loader 36 | */ 37 | public function addLoader(string $format, LoaderInterface $loader): void 38 | { 39 | $this->loaders[$format] = $loader; 40 | } 41 | 42 | public function read(string $directory, MessageCatalogue $catalogue): void 43 | { 44 | if (!is_dir($directory)) { 45 | return; 46 | } 47 | 48 | foreach ($this->loaders as $format => $loader) { 49 | // load any existing translation files 50 | $finder = new Finder(); 51 | $extension = $catalogue->getLocale().'.'.$format; 52 | $files = $finder->files()->name('*.'.$extension)->in($directory); 53 | foreach ($files as $file) { 54 | $domain = substr($file->getFilename(), 0, -1 * \strlen($extension) - 1); 55 | $catalogue->addCatalogue($loader->load($file->getPathname(), $catalogue->getLocale(), $domain)); 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Reader/TranslationReaderInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Reader; 13 | 14 | use Symfony\Component\Translation\MessageCatalogue; 15 | 16 | /** 17 | * TranslationReader reads translation messages from translation files. 18 | * 19 | * @author Tobias Nyholm 20 | */ 21 | interface TranslationReaderInterface 22 | { 23 | /** 24 | * Reads translation messages from a directory to the catalogue. 25 | */ 26 | public function read(string $directory, MessageCatalogue $catalogue): void; 27 | } 28 | -------------------------------------------------------------------------------- /Resources/data/parents.json: -------------------------------------------------------------------------------- 1 | { 2 | "az_Cyrl": "root", 3 | "bs_Cyrl": "root", 4 | "en_150": "en_001", 5 | "en_AG": "en_001", 6 | "en_AI": "en_001", 7 | "en_AT": "en_150", 8 | "en_AU": "en_001", 9 | "en_BB": "en_001", 10 | "en_BE": "en_150", 11 | "en_BM": "en_001", 12 | "en_BS": "en_001", 13 | "en_BW": "en_001", 14 | "en_BZ": "en_001", 15 | "en_CC": "en_001", 16 | "en_CH": "en_150", 17 | "en_CK": "en_001", 18 | "en_CM": "en_001", 19 | "en_CX": "en_001", 20 | "en_CY": "en_001", 21 | "en_CZ": "en_150", 22 | "en_DE": "en_150", 23 | "en_DG": "en_001", 24 | "en_DK": "en_150", 25 | "en_DM": "en_001", 26 | "en_ER": "en_001", 27 | "en_ES": "en_150", 28 | "en_FI": "en_150", 29 | "en_FJ": "en_001", 30 | "en_FK": "en_001", 31 | "en_FM": "en_001", 32 | "en_FR": "en_150", 33 | "en_GB": "en_001", 34 | "en_GD": "en_001", 35 | "en_GG": "en_001", 36 | "en_GH": "en_001", 37 | "en_GI": "en_001", 38 | "en_GM": "en_001", 39 | "en_GS": "en_001", 40 | "en_GY": "en_001", 41 | "en_HK": "en_001", 42 | "en_HU": "en_150", 43 | "en_ID": "en_001", 44 | "en_IE": "en_001", 45 | "en_IL": "en_001", 46 | "en_IM": "en_001", 47 | "en_IN": "en_001", 48 | "en_IO": "en_001", 49 | "en_IT": "en_150", 50 | "en_JE": "en_001", 51 | "en_JM": "en_001", 52 | "en_KE": "en_001", 53 | "en_KI": "en_001", 54 | "en_KN": "en_001", 55 | "en_KY": "en_001", 56 | "en_LC": "en_001", 57 | "en_LR": "en_001", 58 | "en_LS": "en_001", 59 | "en_MG": "en_001", 60 | "en_MO": "en_001", 61 | "en_MS": "en_001", 62 | "en_MT": "en_001", 63 | "en_MU": "en_001", 64 | "en_MV": "en_001", 65 | "en_MW": "en_001", 66 | "en_MY": "en_001", 67 | "en_NA": "en_001", 68 | "en_NF": "en_001", 69 | "en_NG": "en_001", 70 | "en_NL": "en_150", 71 | "en_NO": "en_150", 72 | "en_NR": "en_001", 73 | "en_NU": "en_001", 74 | "en_NZ": "en_001", 75 | "en_PG": "en_001", 76 | "en_PK": "en_001", 77 | "en_PL": "en_150", 78 | "en_PN": "en_001", 79 | "en_PT": "en_150", 80 | "en_PW": "en_001", 81 | "en_RO": "en_150", 82 | "en_RW": "en_001", 83 | "en_SB": "en_001", 84 | "en_SC": "en_001", 85 | "en_SD": "en_001", 86 | "en_SE": "en_150", 87 | "en_SG": "en_001", 88 | "en_SH": "en_001", 89 | "en_SI": "en_150", 90 | "en_SK": "en_150", 91 | "en_SL": "en_001", 92 | "en_SS": "en_001", 93 | "en_SX": "en_001", 94 | "en_SZ": "en_001", 95 | "en_TC": "en_001", 96 | "en_TK": "en_001", 97 | "en_TO": "en_001", 98 | "en_TT": "en_001", 99 | "en_TV": "en_001", 100 | "en_TZ": "en_001", 101 | "en_UG": "en_001", 102 | "en_VC": "en_001", 103 | "en_VG": "en_001", 104 | "en_VU": "en_001", 105 | "en_WS": "en_001", 106 | "en_ZA": "en_001", 107 | "en_ZM": "en_001", 108 | "en_ZW": "en_001", 109 | "es_AR": "es_419", 110 | "es_BO": "es_419", 111 | "es_BR": "es_419", 112 | "es_BZ": "es_419", 113 | "es_CL": "es_419", 114 | "es_CO": "es_419", 115 | "es_CR": "es_419", 116 | "es_CU": "es_419", 117 | "es_DO": "es_419", 118 | "es_EC": "es_419", 119 | "es_GT": "es_419", 120 | "es_HN": "es_419", 121 | "es_MX": "es_419", 122 | "es_NI": "es_419", 123 | "es_PA": "es_419", 124 | "es_PE": "es_419", 125 | "es_PR": "es_419", 126 | "es_PY": "es_419", 127 | "es_SV": "es_419", 128 | "es_US": "es_419", 129 | "es_UY": "es_419", 130 | "es_VE": "es_419", 131 | "ff_Adlm": "root", 132 | "hi_Latn": "en_IN", 133 | "ks_Deva": "root", 134 | "nb": "no", 135 | "nn": "no", 136 | "pa_Arab": "root", 137 | "pt_AO": "pt_PT", 138 | "pt_CH": "pt_PT", 139 | "pt_CV": "pt_PT", 140 | "pt_GQ": "pt_PT", 141 | "pt_GW": "pt_PT", 142 | "pt_LU": "pt_PT", 143 | "pt_MO": "pt_PT", 144 | "pt_MZ": "pt_PT", 145 | "pt_ST": "pt_PT", 146 | "pt_TL": "pt_PT", 147 | "sd_Deva": "root", 148 | "sr_Latn": "root", 149 | "uz_Arab": "root", 150 | "uz_Cyrl": "root", 151 | "zh_Hant": "root", 152 | "zh_Hant_MO": "zh_Hant_HK" 153 | } 154 | -------------------------------------------------------------------------------- /Resources/functions.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | if (!\function_exists(t::class)) { 15 | /** 16 | * @author Nate Wiebe 17 | */ 18 | function t(string $message, array $parameters = [], ?string $domain = null): TranslatableMessage 19 | { 20 | return new TranslatableMessage($message, $parameters, $domain); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Test/AbstractProviderFactoryTestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Test; 13 | 14 | use PHPUnit\Framework\Attributes\DataProvider; 15 | use PHPUnit\Framework\TestCase; 16 | use Symfony\Component\Translation\Exception\UnsupportedSchemeException; 17 | use Symfony\Component\Translation\Provider\Dsn; 18 | use Symfony\Component\Translation\Provider\ProviderFactoryInterface; 19 | 20 | abstract class AbstractProviderFactoryTestCase extends TestCase 21 | { 22 | abstract public function createFactory(): ProviderFactoryInterface; 23 | 24 | /** 25 | * @return iterable 26 | */ 27 | abstract public static function supportsProvider(): iterable; 28 | 29 | /** 30 | * @return iterable 31 | */ 32 | abstract public static function createProvider(): iterable; 33 | 34 | /** 35 | * @return iterable 36 | */ 37 | abstract public static function unsupportedSchemeProvider(): iterable; 38 | 39 | /** 40 | * @dataProvider supportsProvider 41 | */ 42 | #[DataProvider('supportsProvider')] 43 | public function testSupports(bool $expected, string $dsn) 44 | { 45 | $factory = $this->createFactory(); 46 | 47 | $this->assertSame($expected, $factory->supports(new Dsn($dsn))); 48 | } 49 | 50 | /** 51 | * @dataProvider createProvider 52 | */ 53 | #[DataProvider('createProvider')] 54 | public function testCreate(string $expected, string $dsn) 55 | { 56 | $factory = $this->createFactory(); 57 | $provider = $factory->create(new Dsn($dsn)); 58 | 59 | $this->assertSame($expected, (string) $provider); 60 | } 61 | 62 | /** 63 | * @dataProvider unsupportedSchemeProvider 64 | */ 65 | #[DataProvider('unsupportedSchemeProvider')] 66 | public function testUnsupportedSchemeException(string $dsn, ?string $message = null) 67 | { 68 | $factory = $this->createFactory(); 69 | 70 | $dsn = new Dsn($dsn); 71 | 72 | $this->expectException(UnsupportedSchemeException::class); 73 | if (null !== $message) { 74 | $this->expectExceptionMessage($message); 75 | } 76 | 77 | $factory->create($dsn); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Test/IncompleteDsnTestTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Test; 13 | 14 | use PHPUnit\Framework\Attributes\DataProvider; 15 | use Symfony\Component\Translation\Exception\IncompleteDsnException; 16 | use Symfony\Component\Translation\Provider\Dsn; 17 | 18 | trait IncompleteDsnTestTrait 19 | { 20 | /** 21 | * @return iterable 22 | */ 23 | abstract public static function incompleteDsnProvider(): iterable; 24 | 25 | /** 26 | * @dataProvider incompleteDsnProvider 27 | */ 28 | #[DataProvider('incompleteDsnProvider')] 29 | public function testIncompleteDsnException(string $dsn, ?string $message = null) 30 | { 31 | $factory = $this->createFactory(); 32 | 33 | $dsn = new Dsn($dsn); 34 | 35 | $this->expectException(IncompleteDsnException::class); 36 | if (null !== $message) { 37 | $this->expectExceptionMessage($message); 38 | } 39 | 40 | $factory->create($dsn); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Test/ProviderFactoryTestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Test; 13 | 14 | use PHPUnit\Framework\MockObject\MockObject; 15 | use Psr\Log\LoggerInterface; 16 | use Symfony\Component\HttpClient\MockHttpClient; 17 | use Symfony\Component\Translation\Dumper\XliffFileDumper; 18 | use Symfony\Component\Translation\Loader\LoaderInterface; 19 | use Symfony\Component\Translation\TranslatorBagInterface; 20 | use Symfony\Contracts\HttpClient\HttpClientInterface; 21 | 22 | /** 23 | * A test case to ease testing a translation provider factory. 24 | * 25 | * @author Mathieu Santostefano 26 | * 27 | * @deprecated since Symfony 7.2, use AbstractProviderFactoryTestCase instead 28 | */ 29 | abstract class ProviderFactoryTestCase extends AbstractProviderFactoryTestCase 30 | { 31 | use IncompleteDsnTestTrait; 32 | 33 | protected HttpClientInterface $client; 34 | protected LoggerInterface|MockObject $logger; 35 | protected string $defaultLocale; 36 | protected LoaderInterface|MockObject $loader; 37 | protected XliffFileDumper|MockObject $xliffFileDumper; 38 | protected TranslatorBagInterface|MockObject $translatorBag; 39 | 40 | /** 41 | * @return iterable 42 | */ 43 | public static function unsupportedSchemeProvider(): iterable 44 | { 45 | return []; 46 | } 47 | 48 | /** 49 | * @return iterable 50 | */ 51 | public static function incompleteDsnProvider(): iterable 52 | { 53 | return []; 54 | } 55 | 56 | protected function getClient(): HttpClientInterface 57 | { 58 | return $this->client ??= new MockHttpClient(); 59 | } 60 | 61 | protected function getLogger(): LoggerInterface 62 | { 63 | return $this->logger ??= $this->createMock(LoggerInterface::class); 64 | } 65 | 66 | protected function getDefaultLocale(): string 67 | { 68 | return $this->defaultLocale ??= 'en'; 69 | } 70 | 71 | protected function getLoader(): LoaderInterface 72 | { 73 | return $this->loader ??= $this->createMock(LoaderInterface::class); 74 | } 75 | 76 | protected function getXliffFileDumper(): XliffFileDumper 77 | { 78 | return $this->xliffFileDumper ??= $this->createMock(XliffFileDumper::class); 79 | } 80 | 81 | protected function getTranslatorBag(): TranslatorBagInterface 82 | { 83 | return $this->translatorBag ??= $this->createMock(TranslatorBagInterface::class); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Test/ProviderTestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Test; 13 | 14 | use PHPUnit\Framework\Attributes\DataProvider; 15 | use PHPUnit\Framework\MockObject\MockObject; 16 | use PHPUnit\Framework\TestCase; 17 | use Psr\Log\LoggerInterface; 18 | use Symfony\Component\HttpClient\MockHttpClient; 19 | use Symfony\Component\Translation\Dumper\XliffFileDumper; 20 | use Symfony\Component\Translation\Loader\LoaderInterface; 21 | use Symfony\Component\Translation\Provider\ProviderInterface; 22 | use Symfony\Component\Translation\TranslatorBagInterface; 23 | use Symfony\Contracts\HttpClient\HttpClientInterface; 24 | 25 | /** 26 | * A test case to ease testing a translation provider. 27 | * 28 | * @author Mathieu Santostefano 29 | */ 30 | abstract class ProviderTestCase extends TestCase 31 | { 32 | protected HttpClientInterface $client; 33 | protected LoggerInterface|MockObject $logger; 34 | protected string $defaultLocale; 35 | protected LoaderInterface|MockObject $loader; 36 | protected XliffFileDumper|MockObject $xliffFileDumper; 37 | protected TranslatorBagInterface|MockObject $translatorBag; 38 | 39 | abstract public static function createProvider(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint): ProviderInterface; 40 | 41 | /** 42 | * @return iterable 43 | */ 44 | abstract public static function toStringProvider(): iterable; 45 | 46 | /** 47 | * @dataProvider toStringProvider 48 | */ 49 | #[DataProvider('toStringProvider')] 50 | public function testToString(ProviderInterface $provider, string $expected) 51 | { 52 | $this->assertSame($expected, (string) $provider); 53 | } 54 | 55 | protected function getClient(): MockHttpClient 56 | { 57 | return $this->client ??= new MockHttpClient(); 58 | } 59 | 60 | protected function getLoader(): LoaderInterface 61 | { 62 | return $this->loader ??= $this->createMock(LoaderInterface::class); 63 | } 64 | 65 | protected function getLogger(): LoggerInterface 66 | { 67 | return $this->logger ??= $this->createMock(LoggerInterface::class); 68 | } 69 | 70 | protected function getDefaultLocale(): string 71 | { 72 | return $this->defaultLocale ??= 'en'; 73 | } 74 | 75 | protected function getXliffFileDumper(): XliffFileDumper 76 | { 77 | return $this->xliffFileDumper ??= $this->createMock(XliffFileDumper::class); 78 | } 79 | 80 | protected function getTranslatorBag(): TranslatorBagInterface 81 | { 82 | return $this->translatorBag ??= $this->createMock(TranslatorBagInterface::class); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /TranslatableMessage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | use Symfony\Contracts\Translation\TranslatableInterface; 15 | use Symfony\Contracts\Translation\TranslatorInterface; 16 | 17 | /** 18 | * @author Nate Wiebe 19 | */ 20 | class TranslatableMessage implements TranslatableInterface 21 | { 22 | public function __construct( 23 | private string $message, 24 | private array $parameters = [], 25 | private ?string $domain = null, 26 | ) { 27 | } 28 | 29 | public function __toString(): string 30 | { 31 | return $this->getMessage(); 32 | } 33 | 34 | public function getMessage(): string 35 | { 36 | return $this->message; 37 | } 38 | 39 | public function getParameters(): array 40 | { 41 | return $this->parameters; 42 | } 43 | 44 | public function getDomain(): ?string 45 | { 46 | return $this->domain; 47 | } 48 | 49 | public function trans(TranslatorInterface $translator, ?string $locale = null): string 50 | { 51 | return $translator->trans($this->getMessage(), array_map( 52 | static fn ($parameter) => $parameter instanceof TranslatableInterface ? $parameter->trans($translator, $locale) : $parameter, 53 | $this->getParameters() 54 | ), $this->getDomain(), $locale); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /TranslatorBag.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | use Symfony\Component\Translation\Catalogue\AbstractOperation; 15 | use Symfony\Component\Translation\Catalogue\TargetOperation; 16 | 17 | final class TranslatorBag implements TranslatorBagInterface 18 | { 19 | /** @var MessageCatalogue[] */ 20 | private array $catalogues = []; 21 | 22 | public function addCatalogue(MessageCatalogue $catalogue): void 23 | { 24 | if (null !== $existingCatalogue = $this->getCatalogue($catalogue->getLocale())) { 25 | $catalogue->addCatalogue($existingCatalogue); 26 | } 27 | 28 | $this->catalogues[$catalogue->getLocale()] = $catalogue; 29 | } 30 | 31 | public function addBag(TranslatorBagInterface $bag): void 32 | { 33 | foreach ($bag->getCatalogues() as $catalogue) { 34 | $this->addCatalogue($catalogue); 35 | } 36 | } 37 | 38 | public function getCatalogue(?string $locale = null): MessageCatalogueInterface 39 | { 40 | if (null === $locale || !isset($this->catalogues[$locale])) { 41 | $this->catalogues[$locale] = new MessageCatalogue($locale); 42 | } 43 | 44 | return $this->catalogues[$locale]; 45 | } 46 | 47 | public function getCatalogues(): array 48 | { 49 | return array_values($this->catalogues); 50 | } 51 | 52 | public function diff(TranslatorBagInterface $diffBag): self 53 | { 54 | $diff = new self(); 55 | 56 | foreach ($this->catalogues as $locale => $catalogue) { 57 | if (null === $diffCatalogue = $diffBag->getCatalogue($locale)) { 58 | $diff->addCatalogue($catalogue); 59 | 60 | continue; 61 | } 62 | 63 | $operation = new TargetOperation($diffCatalogue, $catalogue); 64 | $operation->moveMessagesToIntlDomainsIfPossible(AbstractOperation::NEW_BATCH); 65 | $newCatalogue = new MessageCatalogue($locale); 66 | 67 | foreach ($catalogue->getDomains() as $domain) { 68 | $newCatalogue->add($operation->getNewMessages($domain), $domain); 69 | } 70 | 71 | $diff->addCatalogue($newCatalogue); 72 | } 73 | 74 | return $diff; 75 | } 76 | 77 | public function intersect(TranslatorBagInterface $intersectBag): self 78 | { 79 | $diff = new self(); 80 | 81 | foreach ($this->catalogues as $locale => $catalogue) { 82 | if (null === $intersectCatalogue = $intersectBag->getCatalogue($locale)) { 83 | continue; 84 | } 85 | 86 | $operation = new TargetOperation($catalogue, $intersectCatalogue); 87 | $operation->moveMessagesToIntlDomainsIfPossible(AbstractOperation::OBSOLETE_BATCH); 88 | $obsoleteCatalogue = new MessageCatalogue($locale); 89 | 90 | foreach ($operation->getDomains() as $domain) { 91 | $obsoleteCatalogue->add( 92 | array_diff($operation->getMessages($domain), $operation->getNewMessages($domain)), 93 | $domain 94 | ); 95 | } 96 | 97 | $diff->addCatalogue($obsoleteCatalogue); 98 | } 99 | 100 | return $diff; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /TranslatorBagInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | 16 | /** 17 | * @author Abdellatif Ait boudad 18 | */ 19 | interface TranslatorBagInterface 20 | { 21 | /** 22 | * Gets the catalogue by locale. 23 | * 24 | * @param string|null $locale The locale or null to use the default 25 | * 26 | * @throws InvalidArgumentException If the locale contains invalid characters 27 | */ 28 | public function getCatalogue(?string $locale = null): MessageCatalogueInterface; 29 | 30 | /** 31 | * Returns all catalogues of the instance. 32 | * 33 | * @return MessageCatalogueInterface[] 34 | */ 35 | public function getCatalogues(): array; 36 | } 37 | -------------------------------------------------------------------------------- /Util/ArrayConverter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Util; 13 | 14 | /** 15 | * ArrayConverter generates tree like structure from a message catalogue. 16 | * e.g. this 17 | * 'foo.bar1' => 'test1', 18 | * 'foo.bar2' => 'test2' 19 | * converts to follows: 20 | * foo: 21 | * bar1: test1 22 | * bar2: test2. 23 | * 24 | * @author Gennady Telegin 25 | */ 26 | class ArrayConverter 27 | { 28 | /** 29 | * Converts linear messages array to tree-like array. 30 | * For example: ['foo.bar' => 'value'] will be converted to ['foo' => ['bar' => 'value']]. 31 | * 32 | * @param array $messages Linear messages array 33 | */ 34 | public static function expandToTree(array $messages): array 35 | { 36 | $tree = []; 37 | 38 | foreach ($messages as $id => $value) { 39 | $referenceToElement = &self::getElementByPath($tree, self::getKeyParts($id)); 40 | 41 | $referenceToElement = $value; 42 | 43 | unset($referenceToElement); 44 | } 45 | 46 | return $tree; 47 | } 48 | 49 | private static function &getElementByPath(array &$tree, array $parts): mixed 50 | { 51 | $elem = &$tree; 52 | $parentOfElem = null; 53 | 54 | foreach ($parts as $i => $part) { 55 | if (isset($elem[$part]) && \is_string($elem[$part])) { 56 | /* Process next case: 57 | * 'foo': 'test1', 58 | * 'foo.bar': 'test2' 59 | * 60 | * $tree['foo'] was string before we found array {bar: test2}. 61 | * Treat new element as string too, e.g. add $tree['foo.bar'] = 'test2'; 62 | */ 63 | $elem = &$elem[implode('.', \array_slice($parts, $i))]; 64 | break; 65 | } 66 | 67 | $parentOfElem = &$elem; 68 | $elem = &$elem[$part]; 69 | } 70 | 71 | if ($elem && \is_array($elem) && $parentOfElem) { 72 | /* Process next case: 73 | * 'foo.bar': 'test1' 74 | * 'foo': 'test2' 75 | * 76 | * $tree['foo'] was array = {bar: 'test1'} before we found string constant `foo`. 77 | * Cancel treating $tree['foo'] as array and cancel back it expansion, 78 | * e.g. make it $tree['foo.bar'] = 'test1' again. 79 | */ 80 | self::cancelExpand($parentOfElem, $part, $elem); 81 | } 82 | 83 | return $elem; 84 | } 85 | 86 | private static function cancelExpand(array &$tree, string $prefix, array $node): void 87 | { 88 | $prefix .= '.'; 89 | 90 | foreach ($node as $id => $value) { 91 | if (\is_string($value)) { 92 | $tree[$prefix.$id] = $value; 93 | } else { 94 | self::cancelExpand($tree, $prefix.$id, $value); 95 | } 96 | } 97 | } 98 | 99 | /** 100 | * @return string[] 101 | */ 102 | private static function getKeyParts(string $key): array 103 | { 104 | $parts = explode('.', $key); 105 | $partsCount = \count($parts); 106 | 107 | $result = []; 108 | $buffer = ''; 109 | 110 | foreach ($parts as $index => $part) { 111 | if (0 === $index && '' === $part) { 112 | $buffer = '.'; 113 | 114 | continue; 115 | } 116 | 117 | if ($index === $partsCount - 1 && '' === $part) { 118 | $buffer .= '.'; 119 | $result[] = $buffer; 120 | 121 | continue; 122 | } 123 | 124 | if (isset($parts[$index + 1]) && '' === $parts[$index + 1]) { 125 | $buffer .= $part; 126 | 127 | continue; 128 | } 129 | 130 | if ($buffer) { 131 | $result[] = $buffer.$part; 132 | $buffer = ''; 133 | 134 | continue; 135 | } 136 | 137 | $result[] = $part; 138 | } 139 | 140 | return $result; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Util/XliffUtils.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Util; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | use Symfony\Component\Translation\Exception\InvalidResourceException; 16 | 17 | /** 18 | * Provides some utility methods for XLIFF translation files, such as validating 19 | * their contents according to the XSD schema. 20 | * 21 | * @author Fabien Potencier 22 | */ 23 | class XliffUtils 24 | { 25 | /** 26 | * Gets xliff file version based on the root "version" attribute. 27 | * 28 | * Defaults to 1.2 for backwards compatibility. 29 | * 30 | * @throws InvalidArgumentException 31 | */ 32 | public static function getVersionNumber(\DOMDocument $dom): string 33 | { 34 | /** @var \DOMNode $xliff */ 35 | foreach ($dom->getElementsByTagName('xliff') as $xliff) { 36 | $version = $xliff->attributes->getNamedItem('version'); 37 | if ($version) { 38 | return $version->nodeValue; 39 | } 40 | 41 | $namespace = $xliff->attributes->getNamedItem('xmlns'); 42 | if ($namespace) { 43 | if (0 !== substr_compare('urn:oasis:names:tc:xliff:document:', $namespace->nodeValue, 0, 34)) { 44 | throw new InvalidArgumentException(\sprintf('Not a valid XLIFF namespace "%s".', $namespace)); 45 | } 46 | 47 | return substr($namespace, 34); 48 | } 49 | } 50 | 51 | // Falls back to v1.2 52 | return '1.2'; 53 | } 54 | 55 | /** 56 | * Validates and parses the given file into a DOMDocument. 57 | * 58 | * @throws InvalidResourceException 59 | */ 60 | public static function validateSchema(\DOMDocument $dom): array 61 | { 62 | $xliffVersion = static::getVersionNumber($dom); 63 | $internalErrors = libxml_use_internal_errors(true); 64 | if ($shouldEnable = self::shouldEnableEntityLoader()) { 65 | $disableEntities = libxml_disable_entity_loader(false); 66 | } 67 | try { 68 | $isValid = @$dom->schemaValidateSource(self::getSchema($xliffVersion)); 69 | if (!$isValid) { 70 | return self::getXmlErrors($internalErrors); 71 | } 72 | } finally { 73 | if ($shouldEnable) { 74 | libxml_disable_entity_loader($disableEntities); 75 | } 76 | } 77 | 78 | $dom->normalizeDocument(); 79 | 80 | libxml_clear_errors(); 81 | libxml_use_internal_errors($internalErrors); 82 | 83 | return []; 84 | } 85 | 86 | private static function shouldEnableEntityLoader(): bool 87 | { 88 | static $dom, $schema; 89 | if (null === $dom) { 90 | $dom = new \DOMDocument(); 91 | $dom->loadXML(''); 92 | 93 | $tmpfile = tempnam(sys_get_temp_dir(), 'symfony'); 94 | register_shutdown_function(static function () use ($tmpfile) { 95 | @unlink($tmpfile); 96 | }); 97 | $schema = ' 98 | 99 | 100 | '; 101 | file_put_contents($tmpfile, ' 102 | 103 | 104 | 105 | '); 106 | } 107 | 108 | return !@$dom->schemaValidateSource($schema); 109 | } 110 | 111 | public static function getErrorsAsString(array $xmlErrors): string 112 | { 113 | $errorsAsString = ''; 114 | 115 | foreach ($xmlErrors as $error) { 116 | $errorsAsString .= \sprintf("[%s %s] %s (in %s - line %d, column %d)\n", 117 | \LIBXML_ERR_WARNING === $error['level'] ? 'WARNING' : 'ERROR', 118 | $error['code'], 119 | $error['message'], 120 | $error['file'], 121 | $error['line'], 122 | $error['column'] 123 | ); 124 | } 125 | 126 | return $errorsAsString; 127 | } 128 | 129 | private static function getSchema(string $xliffVersion): string 130 | { 131 | if ('1.2' === $xliffVersion) { 132 | $schemaSource = file_get_contents(__DIR__.'/../Resources/schemas/xliff-core-1.2-transitional.xsd'); 133 | $xmlUri = 'http://www.w3.org/2001/xml.xsd'; 134 | } elseif ('2.0' === $xliffVersion) { 135 | $schemaSource = file_get_contents(__DIR__.'/../Resources/schemas/xliff-core-2.0.xsd'); 136 | $xmlUri = 'informativeCopiesOf3rdPartySchemas/w3c/xml.xsd'; 137 | } else { 138 | throw new InvalidArgumentException(\sprintf('No support implemented for loading XLIFF version "%s".', $xliffVersion)); 139 | } 140 | 141 | return self::fixXmlLocation($schemaSource, $xmlUri); 142 | } 143 | 144 | /** 145 | * Internally changes the URI of a dependent xsd to be loaded locally. 146 | */ 147 | private static function fixXmlLocation(string $schemaSource, string $xmlUri): string 148 | { 149 | $newPath = str_replace('\\', '/', __DIR__).'/../Resources/schemas/xml.xsd'; 150 | $parts = explode('/', $newPath); 151 | $locationstart = 'file:///'; 152 | if (0 === stripos($newPath, 'phar://')) { 153 | $tmpfile = tempnam(sys_get_temp_dir(), 'symfony'); 154 | if ($tmpfile) { 155 | copy($newPath, $tmpfile); 156 | $parts = explode('/', str_replace('\\', '/', $tmpfile)); 157 | } else { 158 | array_shift($parts); 159 | $locationstart = 'phar:///'; 160 | } 161 | } 162 | 163 | $drive = '\\' === \DIRECTORY_SEPARATOR ? array_shift($parts).'/' : ''; 164 | $newPath = $locationstart.$drive.implode('/', array_map('rawurlencode', $parts)); 165 | 166 | return str_replace($xmlUri, $newPath, $schemaSource); 167 | } 168 | 169 | /** 170 | * Returns the XML errors of the internal XML parser. 171 | */ 172 | private static function getXmlErrors(bool $internalErrors): array 173 | { 174 | $errors = []; 175 | foreach (libxml_get_errors() as $error) { 176 | $errors[] = [ 177 | 'level' => \LIBXML_ERR_WARNING == $error->level ? 'WARNING' : 'ERROR', 178 | 'code' => $error->code, 179 | 'message' => trim($error->message), 180 | 'file' => $error->file ?: 'n/a', 181 | 'line' => $error->line, 182 | 'column' => $error->column, 183 | ]; 184 | } 185 | 186 | libxml_clear_errors(); 187 | libxml_use_internal_errors($internalErrors); 188 | 189 | return $errors; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Writer/TranslationWriter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Writer; 13 | 14 | use Symfony\Component\Translation\Dumper\DumperInterface; 15 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 16 | use Symfony\Component\Translation\Exception\RuntimeException; 17 | use Symfony\Component\Translation\MessageCatalogue; 18 | 19 | /** 20 | * TranslationWriter writes translation messages. 21 | * 22 | * @author Michel Salib 23 | */ 24 | class TranslationWriter implements TranslationWriterInterface 25 | { 26 | /** 27 | * @var array 28 | */ 29 | private array $dumpers = []; 30 | 31 | /** 32 | * Adds a dumper to the writer. 33 | */ 34 | public function addDumper(string $format, DumperInterface $dumper): void 35 | { 36 | $this->dumpers[$format] = $dumper; 37 | } 38 | 39 | /** 40 | * Obtains the list of supported formats. 41 | */ 42 | public function getFormats(): array 43 | { 44 | return array_keys($this->dumpers); 45 | } 46 | 47 | /** 48 | * Writes translation from the catalogue according to the selected format. 49 | * 50 | * @param string $format The format to use to dump the messages 51 | * @param array $options Options that are passed to the dumper 52 | * 53 | * @throws InvalidArgumentException 54 | */ 55 | public function write(MessageCatalogue $catalogue, string $format, array $options = []): void 56 | { 57 | if (!isset($this->dumpers[$format])) { 58 | throw new InvalidArgumentException(\sprintf('There is no dumper associated with format "%s".', $format)); 59 | } 60 | 61 | // get the right dumper 62 | $dumper = $this->dumpers[$format]; 63 | 64 | if (isset($options['path']) && !is_dir($options['path']) && !@mkdir($options['path'], 0777, true) && !is_dir($options['path'])) { 65 | throw new RuntimeException(\sprintf('Translation Writer was not able to create directory "%s".', $options['path'])); 66 | } 67 | 68 | // save 69 | $dumper->dump($catalogue, $options); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Writer/TranslationWriterInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Translation\Writer; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | use Symfony\Component\Translation\MessageCatalogue; 16 | 17 | /** 18 | * TranslationWriter writes translation messages. 19 | * 20 | * @author Michel Salib 21 | */ 22 | interface TranslationWriterInterface 23 | { 24 | /** 25 | * Writes translation from the catalogue according to the selected format. 26 | * 27 | * @param string $format The format to use to dump the messages 28 | * @param array $options Options that are passed to the dumper 29 | * 30 | * @throws InvalidArgumentException 31 | */ 32 | public function write(MessageCatalogue $catalogue, string $format, array $options = []): void; 33 | } 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/translation", 3 | "type": "library", 4 | "description": "Provides tools to internationalize your application", 5 | "keywords": [], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fabien Potencier", 11 | "email": "fabien@symfony.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "symfony/polyfill-mbstring": "~1.0", 21 | "symfony/translation-contracts": "^2.5|^3.0", 22 | "symfony/deprecation-contracts": "^2.5|^3" 23 | }, 24 | "require-dev": { 25 | "nikic/php-parser": "^5.0", 26 | "symfony/config": "^6.4|^7.0", 27 | "symfony/console": "^6.4|^7.0", 28 | "symfony/dependency-injection": "^6.4|^7.0", 29 | "symfony/http-client-contracts": "^2.5|^3.0", 30 | "symfony/http-kernel": "^6.4|^7.0", 31 | "symfony/intl": "^6.4|^7.0", 32 | "symfony/polyfill-intl-icu": "^1.21", 33 | "symfony/routing": "^6.4|^7.0", 34 | "symfony/service-contracts": "^2.5|^3", 35 | "symfony/yaml": "^6.4|^7.0", 36 | "symfony/finder": "^6.4|^7.0", 37 | "psr/log": "^1|^2|^3" 38 | }, 39 | "conflict": { 40 | "nikic/php-parser": "<5.0", 41 | "symfony/config": "<6.4", 42 | "symfony/dependency-injection": "<6.4", 43 | "symfony/http-client-contracts": "<2.5", 44 | "symfony/http-kernel": "<6.4", 45 | "symfony/service-contracts": "<2.5", 46 | "symfony/twig-bundle": "<6.4", 47 | "symfony/yaml": "<6.4", 48 | "symfony/console": "<6.4" 49 | }, 50 | "provide": { 51 | "symfony/translation-implementation": "2.3|3.0" 52 | }, 53 | "autoload": { 54 | "files": [ "Resources/functions.php" ], 55 | "psr-4": { "Symfony\\Component\\Translation\\": "" }, 56 | "exclude-from-classmap": [ 57 | "/Tests/" 58 | ] 59 | }, 60 | "minimum-stability": "dev" 61 | } 62 | --------------------------------------------------------------------------------