TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user