Files
cms-core/Classes/Localization/LanguageService.php
T

496 lines
20 KiB
PHP

<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Localization;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\TypoScript\FrontendTypoScript;
use TYPO3\CMS\Core\TypoScript\TypoScriptService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Main API to fetch labels from XLF (label files) based on the current system
* language of TYPO3. It is able to resolve references to files + their pointers to the
* proper language. If you see something about "LLL", this class does the trick for you. It
* is not related to language handling of content, but rather of labels for plugins.
*
* Usually this is injected into $GLOBALS['LANG'] when in backend or CLI context, and
* populated by the current backend user. Do not rely on $GLOBAL['LANG'] in frontend, as it is only
* available under certain circumstances!
*
* As TYPO3 internally does not match the proper ISO locale standard, the "locale" here
* is actually a list of supported language keys, (see Locales class), whereas "English"
* is always the fallback ("default language").
*
* Further usages on setting up your own LanguageService in BE:
*
* ```
* $languageService = GeneralUtility::makeInstance(LanguageServiceFactory::class)
* ->createFromUserPreferences($GLOBALS['BE_USER']);
* ```
*
* @phpstan-import-type TranslationLabel from LocalizationFactory
* @phpstan-type TranslationFile array<string, TranslationLabel>
* @phpstan-type LabelOverrides array<string, string>
* @phpstan-type TypoScriptLabels array<string, LabelOverrides>
*/
#[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<string, TranslationFile>
*/
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:
*
* ```
* <trans-unit id="downloaded_times">
* <source>downloaded %d times from %s locations</source>
* </trans-unit>
* ```
*
* 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<string, string>
* @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';
}
}