496 lines
20 KiB
PHP
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';
|
|
}
|
|
}
|