createFromUserPreferences($GLOBALS['BE_USER']); * ``` * * @phpstan-import-type TranslationLabel from LocalizationFactory * @phpstan-type TranslationFile array * @phpstan-type LabelOverrides array * @phpstan-type TypoScriptLabels array */ #[Exclude] class LanguageService implements TranslatorInterface { /** * This is set to the language which is currently running for the user */ public string $lang = 'en'; protected ?Locale $locale = null; /** * @var array */ protected array $overrideLabels = []; /** * @internal use LanguageServiceFactory instead */ public function __construct( protected Locales $locales, protected readonly LocalizationFactory $localizationFactory, protected readonly FrontendInterface $runtimeCache ) {} /** * Initializes the language to fetch XLF labels for. * * ``` * $languageService = GeneralUtility::makeInstance(LanguageServiceFactory::class) * ->createFromUserPreferences($GLOBALS['BE_USER']); * ``` * * @throws \RuntimeException * @param Locale|string $languageKey The language key (two character string from backend users profile) * @internal use one of the factory methods instead */ public function init(Locale|string $languageKey): void { if ($languageKey instanceof Locale) { $this->locale = $languageKey; } else { $this->locale = $this->locales->createLocale($languageKey); } $this->lang = $this->getTypo3LanguageKey(); } /** * Returns the label with key $index from the $LOCAL_LANG array used as the second argument * * @param string $index Label key * @param TranslationFile $localLanguage $LOCAL_LANG array to get label key from */ protected function getLLL(string $index, array $localLanguage, bool $returnNullIfNotSet = false): ?string { if (isset($localLanguage[$this->lang][$index])) { $value = is_string($localLanguage[$this->lang][$index]) ? $localLanguage[$this->lang][$index] : $localLanguage[$this->lang][$index][0]; } else { $value = $returnNullIfNotSet ? null : ''; } return $value; } /** * Main and most often used method. * * Resolve strings like these: * * ``` * 'LLL:EXT:core/Resources/Private/Language/locallang_custom.xlf:labels.depth_0' * 'LLL:core.custom:labels.depth_0' * 'core.custom:labels.depth_0' // LLL: prefix is optional * ``` * * This looks up the given .xlf file path or translation domain in the 'core' extension for label labels.depth_0 * * The LLL: prefix is optional. If the input contains a colon (:), it will be treated as a label reference. * If no colon is found, the input string is returned as-is (constant non-localizable label). * * Only the plain string contents of a language key, like "Record title: %s" are returned. * Placeholder interpolation must be performed separately, for example via `sprintf()`, like * `LocalizationUtility::translate()` does internally (which should only be used in Extbase * context) * * Example: * Label is defined in `EXT:my_ext/Resources/Private/Language/locallang.xlf` as: * * ``` * * downloaded %d times from %s locations * * ``` * * The following code example assumes `$this->request` to hold the current request object. * There are several ways to create the LanguageService using the Factory, depending on the * context. Please adjust this example to your use case: * * ``` * $language = $this->request->getAttribute('language'); * $languageService = * GeneralUtility::makeInstance(LanguageServiceFactory::class) * ->createFromSiteLanguage($language); * $label = sprintf( * $languageService->sL( * 'LLL:EXT:my_ext/Resources/Private/Language/locallang.xlf:downloaded_times' * ), * 27, * 'several' * ); * ``` * * This will result in `$label` to contain `'downloaded 27 times from several locations'`. * * @param string $input Label key/reference * @see LocalizationUtility::translate() */ public function sL($input): string { $input = (string)$input; // early return for empty input to avoid cache and language file reading on first hit. if ($input === '') { return $input; } $trimmedInput = trim($input); $hasLLLPrefix = str_starts_with($trimmedInput, 'LLL:'); $restStr = $trimmedInput; // Remove the LLL: prefix if present if ($hasLLLPrefix) { $restStr = substr($trimmedInput, 4); } $extensionPrefix = ''; // Check if ll-file is referred to by extension path (EXT:) if (PathUtility::isExtensionPath(trim($restStr))) { $restStr = substr(trim($restStr), 4); $extensionPrefix = 'EXT:'; } $parts = explode(':', trim($restStr), 2); if (isset($parts[1])) { // Handle both domain references and file paths if ($extensionPrefix === '') { // This could be a domain reference (e.g., "core.tabs:general") // The file path resolution happens in LocalizationFactory $fileReference = $parts[0]; } else { // Traditional EXT: file path $fileReference = $extensionPrefix . $parts[0]; } $result = (string)$this->translate($parts[1], $fileReference); if ($hasLLLPrefix) { return $result; } // If LLL: prefix was not used, we return the input as-is if no translation was found return $result !== '' ? $result : $input; } // No colon found // If LLL: prefix was used, return empty string (original behavior for invalid references) // Otherwise, return input as-is (constant non-localizable label) return $hasLLLPrefix ? '' : $input; } /** * Translate a label by its full reference string. * * Resolves TYPO3 label reference strings in the formats: * * 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0' * 'EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0' * 'core.messages:labels.depth_0' * * The LLL: prefix is optional and stripped before resolution. * * Unlike sL(), this method: * - Returns null when the label reference cannot be resolved * - Supports argument interpolation (sprintf-style or ICU MessageFormat) * - Supports locale overrides per call * - Supports a default value fallback */ public function label(string $reference, array $arguments = [], ?string $default = null, Locale|string|null $locale = null): string|\Stringable|null { $reference = trim($reference); if ($reference === '') { return $default; } // Remove the LLL: prefix if present if (str_starts_with($reference, 'LLL:')) { $reference = substr($reference, 4); } $extensionPrefix = ''; if (PathUtility::isExtensionPath($reference)) { $reference = substr($reference, 4); $extensionPrefix = 'EXT:'; } $parts = explode(':', $reference, 2); if (!isset($parts[1])) { return $default; } $domain = $extensionPrefix !== '' ? $extensionPrefix . $parts[0] : $parts[0]; return $this->translate($parts[1], $domain, $arguments, $default, $locale); } /** * Translate a label by its identifier and domain. * * This is different from sL() as it can also return null, and expects a domain (can be a file reference as well). * NULL is returned when the "id" is not found. * * @param string $id The label identifier/key * @param string $domain The translation domain (file reference like 'EXT:core/Resources/Private/Language/locallang.xlf' * or semantic domain like 'core.messages'). For ICU MessageFormat, suffix with '+intl-icu'. * @param array $arguments Optional arguments for placeholder replacement. For sprintf-style messages, * pass indexed values. For ICU messages, pass named values (e.g., ['count' => 5]). * @param string|null $default Optional default value * @param Locale|string|null $locale Optional locale override. If null, uses the service's configured locale. * @return string|\Stringable|null The translated string, or null if the label was not found */ public function translate(string $id, string $domain, array $arguments = [], ?string $default = null, Locale|string|null $locale = null): string|\Stringable|null { $cacheIdentifier = 'labels_' . $this->locale . '_' . md5($domain . ':' . $id); $result = $this->runtimeCache->get($cacheIdentifier); if (!is_string($result) && !is_null($result)) { // Only log deprecations when the label is written to the cache for the first time if (str_ends_with($id, '.x-unused')) { trigger_error( 'Label reference ' . $id . ' in domain ' . $domain . ' is deprecated.', E_USER_DEPRECATED ); } $labelsFromDomain = $this->readLLfile($domain); if (is_array($this->overrideLabels[$domain] ?? null)) { $labelsFromDomain = array_replace_recursive($labelsFromDomain, $this->overrideLabels[$domain]); } $result = $this->getLLL($id, $labelsFromDomain, true); if ($result === null) { $result = $this->getLLL($id . '.x-unused', $labelsFromDomain, true); if ($result !== null) { // Only log deprecations when the label is written to the cache for the first time trigger_error( 'Label reference ' . $id . ' in domain ' . $domain . ' is deprecated.', E_USER_DEPRECATED ); } } // Check if a value was explicitly set to "" via TypoScript, if so, we need to ensure that this is "" and not null if (isset($this->overrideLabels[$domain][$id]) && $this->overrideLabels[$domain][$id] === '') { $result = ''; } $this->runtimeCache->set($cacheIdentifier, $result); } if ($result === '' || $result === null) { return $default !== null ? $default : $result; } if ($arguments !== []) { // Check if we should use ICU format (when using named arguments) if (!array_is_list($arguments)) { return $this->formatIcuMessage($result, $arguments); } // Use sprintf format (positional arguments with numeric keys) try { // We use vsprintf() over sprintf() here on purpose. // The reason is that only sprintf() will return an error message if the number of arguments does not match // the number of placeholders in the format string. Whereas, vsprintf would silently return nothing. return vsprintf($result, $arguments); } catch (\ValueError $e) { // @todo: we could at some point add a logger or a custom exception if needed, and hand over the $result differently throw new \ValueError($result, 1765396511, $e); } } return $result; } /** * Formats a message using ICU MessageFormat. * This supports plural forms, select patterns, and other ICU MessageFormat features. * * Example message: "{count, plural, one {# file} other {# files}}" * Example arguments: ['count' => 5] * Result: "5 files" */ private function formatIcuMessage(string $message, array $arguments): string { $locale = $this->locale?->posixFormatted() ?? 'en_US'; $formatted = \MessageFormatter::formatMessage($locale, $message, $arguments); if ($formatted === false) { // If formatting fails, return the original message // This can happen with invalid ICU patterns return $message; } return $formatted; } /** * Translates prepared labels which are handed in, and also uses the fallback if no language is given. * This is common in situations such as page TSconfig where labels or references to labels are used. * @internal not part of TYPO3 Core API for the time being. */ public function translateLabel(array|string $input, string $fallback): string { if (is_array($input) && isset($input[$this->lang])) { return $this->sL((string)$input[$this->lang]); } if (is_string($input)) { return $this->sL($input); } return $this->sL($fallback); } /** * Load all labels from a resource/file and returns them in a translated fashion. * @return array * @internal not part of TYPO3 Core API for the time being. */ public function getLabelsFromResource(string $fileReferenceOrDomain): array { $labelArray = []; $labelsFromFile = $this->readLLfile($fileReferenceOrDomain); foreach ($labelsFromFile['default'] as $key => $value) { $labelArray[$key] = $this->getLLL($key, $labelsFromFile); } return $labelArray; } /** * Includes a locallang file and returns the labels found inside. * * @param string $fileReferenceOrDomain Input is a file-reference to be a 'local_lang' file containing a $LOCAL_LANG array * @return TranslationFile value of $LOCAL_LANG found in the included file, empty if none found */ protected function readLLfile(string $fileReferenceOrDomain): array { // Translate a possible domain into a fileReference $cacheIdentifier = 'labels_file_' . md5($fileReferenceOrDomain . (string)$this->locale); $cacheEntry = $this->runtimeCache->get($cacheIdentifier); if (is_array($cacheEntry)) { return $cacheEntry; } $mainLanguageKey = $this->getTypo3LanguageKey(); $allLabels = [ $mainLanguageKey => $this->localizationFactory->getParsedData($fileReferenceOrDomain, $this->locale), ]; if (!isset($allLabels['default'])) { // Ensure default labels are additionally set. // @todo: Remove with use of Symfony Translator catalogue format. // Replace the use of 'array-keys' of 'default' in LanguageService::getLabelsFromResource() $allLabels['default'] = $this->localizationFactory->getParsedData($fileReferenceOrDomain, 'default'); } $this->runtimeCache->set($cacheIdentifier, $allLabels); return $allLabels; } /** * Define custom labels which can be overridden for a given file. This is typically * the case for TypoScript plugins. * * @param TypoScriptLabels $labels */ public function overrideLabels(string $fileRef, array $labels): void { /** @var TypoScriptLabels $localLanguage */ $localLanguage = [ // Default is kept for fallback purposes when coming from TypoScript 'en' => $labels['en'] ?? $labels['default'] ?? [], ]; $mainLanguageKey = $this->getTypo3LanguageKey(); // Special handling for legacy reasons: // Default and EN were historically the same. It is valid though to have an EN(-XX)-XLF translation. // Therefore, copy the overrides of "default" over to "en-*", if no specific overrides exist for this yet. if (str_starts_with($mainLanguageKey, 'en') && !isset($labels[$mainLanguageKey])) { $localLanguage[$mainLanguageKey] = $localLanguage['en']; } if ($mainLanguageKey !== 'default') { $allLocales = array_merge([$mainLanguageKey], $this->locale->getDependencies()); $allLocales = array_unique($allLocales); $allLocales = array_reverse($allLocales); foreach ($allLocales as $language) { if (isset($labels[$language])) { $localLanguage[$mainLanguageKey] = array_replace_recursive($localLanguage[$mainLanguageKey] ?? [], $labels[$language]); } } } $this->overrideLabels[$fileRef] = $localLanguage; } /** * Overwrites labels that are set via TypoScript. * * TS labels have to be configured like: * plugin.tx_myextension._LOCAL_LANG.languageKey.key = value * * @internal not part of TYPO3 Core API. * @return TypoScriptLabels */ public function loadTypoScriptLabelsFromExtension(string $extensionName, FrontendTypoScript $typoScript, string $pluginName = ''): array { $extensionName = str_replace('_', '', $extensionName); $extensionName = strtolower($extensionName); $allLabels = $typoScript->getSetupArray()['plugin.']['tx_' . $extensionName . '.']['_LOCAL_LANG.'] ?? []; if ($pluginName !== '') { $allLabels = array_replace_recursive( $allLabels, $typoScript->getSetupArray()['plugin.']['tx_' . $extensionName . '_' . strtolower($pluginName) . '.']['_LOCAL_LANG.'] ?? [], ); } $typoScriptService = GeneralUtility::makeInstance(TypoScriptService::class); $allLabels = $typoScriptService->convertTypoScriptArrayToPlainArray($allLabels); $finalLabels = []; foreach ($allLabels as $languageKey => $labels) { foreach ($labels ?? [] as $labelKey => $labelValue) { if (is_string($labelValue)) { $finalLabels[$languageKey][$labelKey] = $labelValue; } elseif (is_array($labelValue)) { $labelValue = $typoScriptService->flattenTypoScriptLabelArray($labelValue, $labelKey); foreach ($labelValue as $key => $value) { $finalLabels[$languageKey][$key] = $value; } } } } return $finalLabels; } public function getLocale(): ?Locale { return $this->locale; } private function getTypo3LanguageKey(): string { return $this->locale?->getName() ?? 'en'; } }