TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent;
use TYPO3\CMS\Core\Package\PackageManager;
/**
* @internal
*/
readonly class CacheWarmer
{
public function __construct(
protected PackageManager $packageManager,
protected LabelFileResolver $labelFileResolver,
protected LocalizationFactory $localizationFactory,
protected Locales $locales,
) {}
#[AsEventListener]
public function warmupCaches(CacheWarmupEvent $event): void
{
if ($event->hasGroup('system')) {
$activeLanguages = $this->locales->getActiveLanguages();
$locales = [];
foreach ($activeLanguages as $language) {
$locales[$language] = $this->locales->createLocale($language);
}
// Collect all label files from all packages first to avoid repeated filesystem scans.
$allBaseLocaleResources = [];
$packages = $this->packageManager->getActivePackages();
foreach ($packages as $package) {
$baseLocaleResources = $this->labelFileResolver->getAllLabelFilesOfPackage($package->getPackageKey(), true)['default'] ?? [];
foreach ($baseLocaleResources as $fileReference) {
$allBaseLocaleResources[] = $fileReference;
}
}
// Phase 1: Add all resources to Symfony Translator without retrieving catalogues.
// This avoids O(n²) behaviour where each getCatalogue() rebuilds from all previously
// added resources. By batching all addResource() calls first, catalogue building
// only happens once per locale in phase 2.
foreach ($locales as $locale) {
foreach ($allBaseLocaleResources as $fileReference) {
$this->localizationFactory->warmupTranslatorResource($fileReference, $locale);
}
}
// Phase 2: Retrieve catalogues and write to system cache.
// Resources are already loaded, so this just builds catalogues once per locale.
foreach ($locales as $locale) {
foreach ($allBaseLocaleResources as $fileReference) {
$this->localizationFactory->getParsedData($fileReference, $locale, true);
}
}
}
}
}
+373
View File
@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Wrapper for dealing with ICU-based (php-intl) date formatting
* see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
*/
readonly class DateFormatter
{
/**
* Formats any given input ($date) into a localized, formatted result
*
* @param mixed $date could be a DateTime object, a string or a number (Unix Timestamp)
* @param string|int $format the pattern, as defined by the ICU - see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
* @param string|Locale $locale the locale to be used, e.g. "nl-NL"
* @return string the formatted output, such as "Tuesday at 12:40:20"
*/
public function format(mixed $date, string|int $format, string|Locale $locale): string
{
$locale = (string)$locale;
// Use fallback locale if 'C' is provided.
if ($locale === 'C') {
$locale = 'en-US';
}
if (is_int($format) || MathUtility::canBeInterpretedAsInteger($format)) {
$dateFormatter = new \IntlDateFormatter($locale, (int)$format, (int)$format);
} else {
$dateFormatter = match (strtoupper($format)) {
'FULL' => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::FULL),
'FULLDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::NONE),
'FULLTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::FULL),
'LONG' => new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::LONG),
'LONGDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::NONE),
'LONGTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::LONG),
'MEDIUM' => new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::MEDIUM),
'MEDIUMDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::NONE),
'MEDIUMTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::MEDIUM),
'SHORT' => new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT),
'SHORTDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE),
'SHORTTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT),
// Use a custom pattern
default => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::FULL, null, null, $format),
};
}
return $dateFormatter->format($date) ?: '';
}
/**
* Locale-formatted strftime using IntlDateFormatter (PHP 8.1 compatible)
* This provides a cross-platform alternative to strftime() for when it will be removed from PHP.
* Note that output can be slightly different between libc sprintf and this function as it is using ICU.
*
* Original author BohwaZ <https://bohwaz.net/>
* Adapted from https://github.com/alphp/strftime
* MIT licensed
*/
public function strftime(string $format, int|string|\DateTimeInterface|null $timestamp, string|Locale|null $locale = null, $useUtcTimeZone = false): string
{
if (!$timestamp instanceof \DateTimeInterface) {
$timestamp = MathUtility::canBeInterpretedAsInteger($timestamp) ? '@' . $timestamp : (string)$timestamp;
try {
$timestamp = new \DateTime($timestamp);
} catch (\Exception $e) {
throw new \InvalidArgumentException('$timestamp argument is neither a valid UNIX timestamp, a valid date-time string or a DateTime object.', 1679091446, $e);
}
$timestamp->setTimezone(new \DateTimeZone($useUtcTimeZone ? 'UTC' : date_default_timezone_get()));
}
if (empty($locale)) {
// get current locale
$locale = (string)setlocale(LC_TIME, '0');
} else {
$locale = (string)$locale;
}
// Use fallback locale if 'C' is provided.
if ($locale === 'C') {
$locale = 'en-US';
}
// remove trailing part not supported by ext-intl locale
$locale = preg_replace('/[^\w-].*$/', '', $locale);
$intl_formats = [
'%a' => 'EEE', // An abbreviated textual representation of the day Sun through Sat
'%A' => 'EEEE', // A full textual representation of the day Sunday through Saturday
'%b' => 'MMM', // Abbreviated month name, based on the locale Jan through Dec
'%B' => 'MMMM', // Full month name, based on the locale January through December
'%h' => 'MMM', // Abbreviated month name, based on the locale (an alias of %b) Jan through Dec
];
$intl_formatter = function (\DateTimeInterface $timestamp, string $format) use ($intl_formats, $locale): string {
$tz = $timestamp->getTimezone();
$date_type = \IntlDateFormatter::FULL;
$time_type = \IntlDateFormatter::FULL;
$pattern = '';
switch ($format) {
// %c = Preferred date and time stamp based on locale
// Example: Tue Feb 5 00:45:10 2009 for February 5, 2009 at 12:45:10 AM
case '%c':
$date_type = \IntlDateFormatter::LONG;
$time_type = \IntlDateFormatter::SHORT;
break;
// %x = Preferred date representation based on locale, without the time
// Example: 02/05/09 for February 5, 2009
case '%x':
$date_type = \IntlDateFormatter::SHORT;
$time_type = \IntlDateFormatter::NONE;
break;
// Localized time format
case '%X':
$date_type = \IntlDateFormatter::NONE;
$time_type = \IntlDateFormatter::MEDIUM;
break;
default:
$pattern = $intl_formats[$format];
}
// In October 1582, the Gregorian calendar replaced the Julian in much of Europe, and
// the 4th October was followed by the 15th October.
// ICU (including IntlDateFormattter) interprets and formats dates based on this cutover.
// Posix (including strftime) and timelib (including DateTimeImmutable) instead use
// a "proleptic Gregorian calendar" - they pretend the Gregorian calendar has existed forever.
// This leads to the same instants in time, as expressed in Unix time, having different representations
// in formatted strings.
// To adjust for this, a custom calendar can be supplied with a cutover date arbitrarily far in the past.
$calendar = new \IntlGregorianCalendar();
$calendar->setGregorianChange(PHP_INT_MIN);
return (new \IntlDateFormatter($locale, $date_type, $time_type, $tz, $calendar, $pattern))->format($timestamp) ?: '';
};
// Same order as https://www.php.net/manual/en/function.strftime.php
$translation_table = [
// Day
'%a' => $intl_formatter,
'%A' => $intl_formatter,
'%d' => 'd',
'%e' => function (\DateTimeInterface $timestamp, string $_): string {
return sprintf('% 2u', $timestamp->format('j'));
},
'%j' => function (\DateTimeInterface $timestamp, string $_): string {
// Day number in year, 001 to 366
return sprintf('%03d', (int)($timestamp->format('z')) + 1);
},
'%u' => 'N',
'%w' => 'w',
// Week
'%U' => function (\DateTimeInterface $timestamp, string $_): string {
// Number of weeks between date and first Sunday of year
$day = new \DateTime(sprintf('%d-01 Sunday', $timestamp->format('Y')));
return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7);
},
'%V' => 'W',
'%W' => function (\DateTimeInterface $timestamp, string $_): string {
// Number of weeks between date and first Monday of year
$day = new \DateTime(sprintf('%d-01 Monday', $timestamp->format('Y')));
return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7);
},
// Month
'%b' => $intl_formatter,
'%B' => $intl_formatter,
'%h' => $intl_formatter,
'%m' => 'm',
// Year
'%C' => function (\DateTimeInterface $timestamp, string $_): string {
// Century (-1): 19 for 20th century
return (string)floor($timestamp->format('Y') / 100);
},
'%g' => function (\DateTimeInterface $timestamp, string $_): string {
return substr($timestamp->format('o'), -2);
},
'%G' => 'o',
'%y' => 'y',
'%Y' => 'Y',
// Time
'%H' => 'H',
'%k' => function (\DateTimeInterface $timestamp, string $_): string {
return sprintf('% 2u', $timestamp->format('G'));
},
'%I' => 'h',
'%l' => function (\DateTimeInterface $timestamp, string $_): string {
return sprintf('% 2u', $timestamp->format('g'));
},
'%M' => 'i',
'%p' => 'A', // AM PM (this is reversed on purpose!)
'%P' => 'a', // am pm
'%r' => 'h:i:s A', // %I:%M:%S %p
'%R' => 'H:i', // %H:%M
'%S' => 's',
'%T' => 'H:i:s', // %H:%M:%S
'%X' => $intl_formatter, // Preferred time representation based on locale, without the date
// Timezone
'%z' => 'O',
'%Z' => 'T',
// Time and Date Stamps
'%c' => $intl_formatter,
'%D' => 'm/d/Y',
'%F' => 'Y-m-d',
'%s' => 'U',
'%x' => $intl_formatter,
];
$out = preg_replace_callback('/(?<!%)%([_#-]?)([a-zA-Z])/', function ($match) use ($translation_table, $timestamp) {
$prefix = $match[1];
$char = $match[2];
$pattern = '%' . $char;
if ($pattern == '%n') {
return "\n";
}
if ($pattern == '%t') {
return "\t";
}
if (!isset($translation_table[$pattern])) {
throw new \InvalidArgumentException(sprintf('Format "%s" is unknown in time format', $pattern), 1679091475);
}
$replace = $translation_table[$pattern];
if (is_string($replace)) {
$result = $timestamp->format($replace);
} else {
$result = $replace($timestamp, $pattern);
}
return match ($prefix) {
'_' => preg_replace('/\G0(?=.)/', ' ', $result),
'#', '-' => preg_replace('/^0+(?=.)/', '', $result),
default => $result,
};
}, $format);
return str_replace('%%', '%', $out);
}
/**
* @internal
*/
public function convertPhpFormatToLuxon(string $phpFormat): string
{
$phpToLuxonMapping = [
// Day
'd' => 'dd',
'D' => 'EEE',
'j' => 'd',
'l' => 'EEEE',
'N' => 'E',
'S' => 'o', // Note: Luxon doesn't directly support ordinal suffixes
'w' => 'E', // Luxon uses 1-7 for day of the week (1 = Monday, 7 = Sunday)
'z' => 'o',
// Week
'W' => 'WW',
// Month
'F' => 'MMMM',
'm' => 'MM',
'M' => 'MMM',
'n' => 'M',
't' => '', // Luxon doesn't have a token for the number of days in the month
// Year
'L' => '', // Luxon doesn't have a token for leap year
'o' => 'GGGG',
'Y' => 'yyyy',
'y' => 'yy',
// Time
'a' => 'a',
'A' => 'a',
'B' => '', // Luxon doesn't support Swatch Internet time
'g' => 'h',
'G' => 'H',
'h' => 'hh',
'H' => 'HH',
'i' => 'mm',
's' => 'ss',
'u' => 'SSS', // Note: Luxon uses SSS for milliseconds
// Timezone
'e' => 'ZZZZ',
'I' => '', // Luxon doesn't have a token for DST
'O' => 'ZZ',
'P' => 'ZZ',
'p' => 'ZZ',
'T' => 'ZZZ',
'Z' => 'ZZ',
// Full Date/Time
'c' => 'yyyy-MM-dd\'T\'HH:mm:ssZZ',
'r' => 'EEE, dd MMM yyyy HH:mm:ss ZZ',
'U' => 'X',
];
$luxonFormat = '';
$length = strlen($phpFormat);
for ($i = 0; $i < $length; $i++) {
$char = $phpFormat[$i];
$luxonFormat .= $phpToLuxonMapping[$char] ?? ("'" . $char . "'");
}
return $luxonFormat;
}
/**
* @param \DateInterval $interval Date interval as generated by \DateTimeInterface::diff()
* @param string $labels Translated label string, e.g. `$GLOBALS['LANG']->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.minutesHoursDaysYears')`
*/
public static function formatDateInterval(
\DateInterval $interval,
?string $labels = null,
): string {
$labels = GeneralUtility::trimExplode('|', $labels, true);
if (count($labels) === 4) {
$labels = array_merge($labels, $labels);
} elseif (count($labels) !== 8) {
$labels = ['min', 'hrs', 'days', 'yrs', 'min', 'hour', 'day', 'year'];
}
$sign = $interval->invert ? '-' : '';
if ($interval->y > 0) {
$years = $interval->y;
$singular = $years === 1;
return $sign . $years . ' ' . ($singular ? $labels[7] : $labels[3]);
}
$days = $interval->days !== false && $interval->days > 0 ? $interval->days : $interval->d;
if ($days > 0) {
$singular = $days === 1;
return $sign . $days . ' ' . ($singular ? $labels[6] : $labels[2]);
}
if ($interval->h > 0) {
$hours = $interval->h;
$singular = $hours === 1;
return $sign . $hours . ' ' . ($singular ? $labels[5] : $labels[1]);
}
$minutes = $interval->i;
$singular = $minutes === 1;
if ($minutes === 0) {
$sign = '';
}
return $sign . $minutes . ' ' . ($singular ? $labels[4] : $labels[0]);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* 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\Event;
/**
* Event that is fired after a file name has been mapped to a translation domain for all files
* of a package.
*
* E.g. "vendor/myext.messages" is mapped to "EXT:myext/Resources/Private/Language/locallang.xlf"
*
* If you want this file to be a different location, e.g. for historical reasons, you can do this here.
*/
final class BeforeLabelResourceResolvedEvent
{
public function __construct(
public readonly string $packageKey,
public array $domains
) {}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* 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\Event;
use Psr\Http\Message\UriInterface;
/**
* Event to modify the main URL of a language
*/
final class ModifyLanguagePackRemoteBaseUrlEvent
{
public function __construct(private UriInterface $baseUrl, private readonly string $packageKey) {}
public function getBaseUrl(): UriInterface
{
return $this->baseUrl;
}
public function setBaseUrl(UriInterface $baseUrl): void
{
$this->baseUrl = $baseUrl;
}
public function getPackageKey(): string
{
return $this->packageKey;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* 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\Event;
/**
* Event to modify the language pack array
*/
final class ModifyLanguagePacksEvent
{
public function __construct(private array $extensions) {}
public function getExtensions(): array
{
return $this->extensions;
}
public function removeExtension(string $extension): void
{
unset($this->extensions[$extension]);
}
public function removeIsoFromExtension(string $iso, string $extension): void
{
unset($this->extensions[$extension]['packs'][$iso]);
}
}
@@ -0,0 +1,21 @@
<?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\Exception;
/**
* File not found exception
*/
class FileNotFoundException extends \RuntimeException {}
@@ -0,0 +1,21 @@
<?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\Exception;
/**
* Invalid Parser exception
*/
class InvalidParserException extends \RuntimeException {}
@@ -0,0 +1,21 @@
<?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\Exception;
/**
* Invalid XML file exception
*/
class InvalidXmlFileException extends \RuntimeException {}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* 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 Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* @internal
*/
final readonly class JavaScriptLanguageDomainProvider
{
public function __construct(
private LanguageServiceFactory $languageServiceFactory,
private TranslationDomainResolver $translationDomainResolver,
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
) {}
public function createLanguageDomainResponse(string $domain, string $locale): ResponseInterface
{
$languageService = $this->languageServiceFactory->create($locale);
$error = '';
$allLabels = [];
if (!$this->translationDomainResolver->isValidDomainName($domain)) {
// @todo check for unavailable domain name and deprecation state
$error = 'throw new Error("Invalid domain name");';
} else {
$allLabels = $languageService->getLabelsFromResource($domain);
}
$javaScriptModuleContents = implode("\n", [
'import { LabelProvider } from "@typo3/backend/localization/label-provider.js";',
$error,
'export default new LabelProvider(',
' ' . json_encode((object)$allLabels),
');',
]);
$lifetime = 3600 * 24 * 365;
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'text/javascript')
->withHeader('Content-Length', (string)strlen($javaScriptModuleContents))
->withHeader('Expires', gmdate('D, d M Y H:i:s T', (min($GLOBALS['EXEC_TIME'] + $lifetime, PHP_INT_MAX))))
->withHeader('Cache-Control', 'private, max-age=' . $lifetime)
->withBody(
$this->streamFactory->createStream($javaScriptModuleContents)
);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* 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;
/**
* @internal
*/
final class LabelBag
{
/**
* @var list<string>
*/
public readonly array $arguments;
/**
* @param string $key e.g. `LLL:EXT:core/Resources/Private/Language/Labels.xlf:HelloWorld`
* @param string ...$arguments optional label arguments to be substituted
*/
public function __construct(
public readonly string $key,
string ...$arguments
) {
$this->arguments = $arguments;
}
/**
* Compiles the given label key and substituted label arguments if given.
*/
public function compile(TranslatorInterface $translator): string
{
try {
return $translator->label($this->key, $this->arguments, $this->key);
} catch (\ValueError $e) {
return sprintf(
'Error: could not translate key "%s" and %d argument(s)!',
$this->key,
count($this->arguments)
);
}
}
}
+349
View File
@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
/*
* 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\Autoconfigure;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Localization\Exception\FileNotFoundException;
use TYPO3\CMS\Core\Package\Exception\UnknownPackageException;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Service class for resolving label file paths and determining loading order.
*
* This class handles:
* - Path resolving for localization files
* - File name resolving with language variants
* - Detecting resource override files via $GLOBALS['TYPO3_CONF_VARS']['LANG']['resourceOverrides']
* - Determining file loading order without merging content
*
* This class does not handle reading of file contents, and also returns the full path,
* so it does not care about caching. Should be handled at a more outer stage.
*
* @internal not part of TYPO3's public API.
*/
#[Autoconfigure(public: true)]
readonly class LabelFileResolver
{
public function __construct(
protected PackageManager $packageManager,
protected TranslationDomainResolver $translationDomainResolver,
) {}
/**
* Find all label files in a package, but also find overrides.
* All files are returned in the order they should be loaded.
*/
public function getAllLabelFilesOfPackage(string $packageKey, $defaultLocaleOnlyForCacheWarmup = false): array
{
$result = [];
try {
$packagePath = $this->packageManager->getPackage($packageKey)->getPackagePath();
} catch (UnknownPackageException) {
throw new \InvalidArgumentException(sprintf('Package with key "%s" not found', $packageKey), 1760479988);
}
$directoriesToSearch = [
'Resources/Private/Language/',
'Configuration/Sets/',
];
$allowedFileExtensions = $this->getSupportedExtensions();
$allowedFileExtensions = implode(',', $allowedFileExtensions);
foreach ($directoriesToSearch as $searchPath) {
$searchPath = $packagePath . $searchPath;
$files = GeneralUtility::getAllFilesAndFoldersInPath([], $searchPath, $allowedFileExtensions);
foreach ($files as $file) {
$fileName = PathUtility::basename($file);
$locale = $this->translationDomainResolver->getLocaleFromLanguageFile($fileName);
if ($locale === null) {
$locale = 'default';
}
if ($defaultLocaleOnlyForCacheWarmup && $locale !== 'default') {
continue;
}
$relativeFilePath = substr($file, strlen($packagePath));
$fileReference = 'EXT:' . $packageKey . '/' . $relativeFilePath;
if ($defaultLocaleOnlyForCacheWarmup) {
$result[$locale][] = $fileReference;
continue;
}
try {
$orderedFiles = $this->getOrderedFileResources($fileReference, $locale);
if ($orderedFiles !== []) {
if (!isset($result[$locale])) {
$result[$locale] = [];
}
$result[$locale] = array_merge($result[$locale], $orderedFiles);
}
} catch (FileNotFoundException $e) {
}
}
}
return $result;
}
/**
* Finds the actual files needed to resolve a file resource.
* This should be used later-on directly in LocalizationFactory.
*/
protected function getOrderedFileResources(string $fileReference, string $locale): array
{
$result = [];
try {
$baseFile = $this->resolveFileReference($fileReference, $locale);
if ($baseFile !== null) {
$result[] = $baseFile;
}
} catch (FileNotFoundException $e) {
}
$overrideFiles = $this->getOverrideFilePaths($fileReference, $locale);
if ($overrideFiles !== []) {
$result = array_merge($result, $overrideFiles);
}
return $result;
}
/**
* @throws FileNotFoundException
*/
public function resolveFileReference(string $fileReference, string $locale): ?string
{
$actualSourcePath = $this->getAbsoluteFileReference($fileReference);
if (PathUtility::isExtensionPath($fileReference)) {
$actualSourcePath = $this->resolveExtensionResourcePath($actualSourcePath, $locale, $fileReference);
}
if ($locale === 'default') {
// The "default" (=base) locale must not contain other language entries.
// Otherwise, when checking for a base locale file, it will hold an array of ALL
// other language variants, and then due to alphabetical sorting, any language with a
// first character AFTER "l" (locallang) would be regarded as the base entry.
return $actualSourcePath;
}
// Find localized file. If no localized version exists, return null.
$localizedSourcePath = $this->resolveLocalizedFilePath($actualSourcePath, $locale);
return $localizedSourcePath;
}
/**
* Get override file paths for localization
*
* This method returns an array of override file paths that should be loaded
* for the given file reference and language key. It supports both file path syntax
* (e.g., 'EXT:core/Resources/Private/Language/locallang.xlf') and domain syntax
* (e.g., 'core.messages').
*
* @return array<string> Array of absolute file paths to override files
*/
public function getOverrideFilePaths(string $fileReference, string $locale): array
{
if (!isset($GLOBALS['TYPO3_CONF_VARS']['LANG']['resourceOverrides'])) {
return [];
}
$validOverrideFiles = [];
$fileReferenceWithoutExtension = $this->getFileReferenceWithoutExtension($fileReference);
$overrideFiles = $GLOBALS['TYPO3_CONF_VARS']['LANG']['resourceOverrides'];
$supportedExtensions = $this->getSupportedExtensions();
// Build list of keys to check: file paths (with various extensions)
$keysToCheck = [];
foreach ($supportedExtensions as $extension) {
$keysToCheck[] = $fileReferenceWithoutExtension . '.' . $extension;
}
// Also add domain key for domain-syntax override support
$domain = $this->translationDomainResolver->mapFileNameToDomain($fileReference);
if ($domain !== $fileReference && $this->translationDomainResolver->isValidDomainName($domain)) {
$keysToCheck[] = $domain;
}
foreach ($keysToCheck as $key) {
// Check language-specific overrides first
if (isset($overrideFiles[$locale][$key]) && is_array($overrideFiles[$locale][$key])) {
$validOverrideFiles = array_merge($validOverrideFiles, $overrideFiles[$locale][$key]);
}
// Check general overrides (applies to all languages)
elseif (isset($overrideFiles[$key]) && is_array($overrideFiles[$key])) {
$validOverrideFiles = array_merge($validOverrideFiles, $overrideFiles[$key]);
}
}
$validOverrideFiles = array_unique($validOverrideFiles);
// Convert relative paths to absolute paths
$absoluteOverrideFiles = [];
foreach ($validOverrideFiles as $overrideFile) {
if (PathUtility::isExtensionPath($overrideFile)) {
$absoluteOverrideFiles[] = $overrideFile;
} else {
$absolutePath = GeneralUtility::getFileAbsFileName($overrideFile);
if ($absolutePath) {
$absoluteOverrideFiles[] = $absolutePath;
}
}
}
$absoluteOverrideFiles = array_unique($absoluteOverrideFiles);
return $absoluteOverrideFiles;
}
/**
* Get absolute file reference
*
* @throws FileNotFoundException Source localization file not found.
*/
protected function getAbsoluteFileReference(string $fileReference): string
{
$fileReferenceWithoutExtension = $this->getFileReferenceWithoutExtension($fileReference);
$supportedExtensions = $this->getSupportedExtensions();
foreach ($supportedExtensions as $extension) {
$fullPath = GeneralUtility::getFileAbsFileName($fileReferenceWithoutExtension . '.' . $extension);
if (@is_file($fullPath)) {
return $fullPath;
}
}
throw new FileNotFoundException(sprintf('Source localization file (%s) not found', $fileReference), 1306410755);
}
public function getFileReferenceWithoutExtension(string $fileReference): string
{
return $this->translationDomainResolver->getFileReferenceWithoutExtension($fileReference);
}
/**
* Get localized labels path pattern for extensions
*/
protected function getLocalizedLabelsPathPattern(string $fileReference): string
{
if (!PathUtility::isExtensionPath($fileReference)) {
throw new \InvalidArgumentException(sprintf('Invalid file reference configuration for the current file (%s)', $fileReference), 1635863703);
}
$packageKey = $this->packageManager->extractPackageKeyFromPackagePath($fileReference);
$relativeFileName = substr($fileReference, strlen($packageKey) + 5);
$directory = dirname($relativeFileName);
$fileName = basename($relativeFileName);
return sprintf(
'/%%1$s/%s/%s%%1$s.%s',
$packageKey,
($directory !== '.' ? $directory . '/' : ''),
$fileName
);
}
protected function getSupportedExtensions(): array
{
if (isset($GLOBALS['TYPO3_CONF_VARS']['LANG']['format']['priority']) && trim($GLOBALS['TYPO3_CONF_VARS']['LANG']['format']['priority']) !== '') {
return GeneralUtility::trimExplode(',', $GLOBALS['TYPO3_CONF_VARS']['LANG']['format']['priority']);
}
return ['xlf'];
}
protected function resolveExtensionResourcePath(string $sourcePath, string $locale, string $fileReference): string
{
$localizedLabelsPathPattern = $this->getLocalizedLabelsPathPattern($fileReference);
$fileName = Environment::getLabelsPath() . sprintf($localizedLabelsPathPattern, $locale);
if (@is_file($fileName)) {
return $fileName;
}
// Fallback to source path if localized version doesn't exist
return $sourcePath;
}
/**
* Resolve localized file path by trying multiple location strategies.
*
* This method attempts to find localized versions of files in the following order:
* 1. Check if the file already has the correct locale prefix (early return)
* 2. Try same directory with locale prefix: "de.locallang.xlf" in same folder
* 3. Try TYPO3 labels directory structure: "/var/labels/de/extension_key/path/de.filename.xlf"
*
* Language variant handling:
* - Supports both underscore and hyphen variants: "de_CH" <-> "de-CH"
* - Tests both formats when resolving files
*
* Examples:
* - Source: "/ext/core/Resources/Private/Language/locallang.xlf"
* - For locale "de": tries "de.locallang.xlf" in same dir, then "/var/labels/de/core/Resources/Private/Language/de.locallang.xlf"
* - For locale "de-CH": tries both "de-CH.locallang.xlf" and "de_CH.locallang.xlf" variants
*
* @param string $sourcePath Absolute path to the source localization file
* @param string $locale Language locale (e.g., "de", "de-CH", "de_AT")
* @return ?string Absolute path to the localized file, or null if no localized version found
*/
protected function resolveLocalizedFilePath(string $sourcePath, string $locale): ?string
{
$possiblePrefixes = [$locale];
if (str_contains($locale, '_')) {
$possiblePrefixes[] = str_replace('_', '-', $locale);
} elseif (str_contains($locale, '-')) {
$possiblePrefixes[] = str_replace('-', '_', $locale);
}
$packageRootPaths = [];
foreach ($this->packageManager->getActivePackages() as $package) {
$packageRootPaths[$package->getPackageKey()] = $package->getPackagePath();
}
foreach ($possiblePrefixes as $fileNamePrefix) {
$fileName = PathUtility::basename($sourcePath);
if (str_starts_with($fileName, $fileNamePrefix . '.')) {
return $sourcePath;
}
// Try same location first
$sameLocationPath = str_replace($fileName, $fileNamePrefix . '.' . $fileName, $sourcePath);
if (@is_file($sameLocationPath)) {
return $sameLocationPath;
}
// Try labels directory structure
$relativePathInPackagePath = '';
$extensionKey = null;
foreach ($packageRootPaths as $packageKey => $packageRootPath) {
if (str_starts_with($sourcePath, $packageRootPath)) {
$relativePathInPackagePath = substr($sourcePath, strlen($packageRootPath));
$extensionKey = $packageKey;
break;
}
}
if ($relativePathInPackagePath === '' || $extensionKey === null) {
continue;
}
[$relativePathInPackagePath, $baseName] = GeneralUtility::revExplode('/', $relativePathInPackagePath, 2);
$localizedPath = Environment::getLabelsPath() . '/' . $fileNamePrefix . '/' . $extensionKey . '/' . ($relativePathInPackagePath ? $relativePathInPackagePath . '/' : '') . $fileNamePrefix . '.' . $baseName;
if (@is_file($localizedPath)) {
return $localizedPath;
}
}
return null;
}
}
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
/*
* 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 Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\Finder\Finder;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Localization\Event\ModifyLanguagePackRemoteBaseUrlEvent;
use TYPO3\CMS\Core\Localization\Event\ModifyLanguagePacksEvent;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Service\Archive\ZipService;
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Service class handling language pack details
* Used by 'manage language packs' module and 'language packs command'
*
* @internal This class is only meant to be used within EXT:core and is not part of the TYPO3 Core API.
*/
#[Autoconfigure(public: true)]
readonly class LanguagePackService
{
private const string LANGUAGE_PACK_URL = 'https://localize.typo3.org/xliff/';
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private RequestFactory $requestFactory,
private LoggerInterface $logger,
private SystemResourceFactory $resourceFactory,
private SystemResourcePublisherInterface $resourcePublisher,
private Locales $locales,
private Registry $registry,
) {}
/**
* Get list of available languages
*
* @return array iso=>name
*/
public function getAvailableLanguages(): array
{
return $this->locales->getLanguages();
}
/**
* List of languages active in this instance
* @return list<non-empty-string>
*/
public function getActiveLanguages(): array
{
$availableLanguages = $GLOBALS['TYPO3_CONF_VARS']['LANG']['availableLocales'] ?? [];
return array_values(array_filter($availableLanguages));
}
/**
* Create an array with language details: active or not, iso codes, last update, ...
*/
public function getLanguageDetails(): array
{
$availableLanguages = $this->getAvailableLanguages();
$activeLanguages = $this->getActiveLanguages();
$languages = [];
foreach ($availableLanguages as $iso => $name) {
if ($iso === 'en') {
continue;
}
$lastUpdate = $this->registry->get('languagePacks', $iso);
$languages[] = [
'iso' => $iso,
'name' => $name,
'active' => in_array($iso, $activeLanguages, true),
'lastUpdate' => $this->getFormattedDate($lastUpdate),
'dependencies' => $this->locales->getLocaleDependencies($iso),
];
}
usort($languages, static function ($a, $b) {
// Sort languages by name
if ($a['name'] === $b['name']) {
return 0;
}
return $a['name'] < $b['name'] ? -1 : 1;
});
return $languages;
}
/**
* Create a list of loaded extensions and their language packs details
*/
public function getExtensionLanguagePackDetails(): array
{
$activeLanguages = $this->getActiveLanguages();
$packageManager = GeneralUtility::makeInstance(PackageManager::class);
$activePackages = $packageManager->getActivePackages();
$extensions = [];
foreach ($activePackages as $package) {
$path = $package->getPackagePath();
$finder = new Finder();
try {
$files = $finder->files()->ignoreUnreadableDirs()->in($path . 'Resources/Private/Language/')->name('*.xlf');
if (!$files->hasResults()) {
continue;
}
} catch (\InvalidArgumentException $e) {
// Dir does not exist
continue;
}
$key = $package->getPackageKey();
$metaData = $package->getPackageMetaData();
if ($metaData->isExcludedFromUpdates()) {
continue;
}
$extension = [
'key' => $key,
'title' => $metaData->getTitle(),
'type' => $metaData->getPackageType(),
];
$packageIcon = $package->getResources()->getPackageIcon();
if ($packageIcon !== null) {
$iconResource = $this->resourceFactory->createPublicResource($packageIcon);
$extension['icon'] = (string)$this->resourcePublisher->generateUri($iconResource, null);
}
$extension['packs'] = [];
foreach ($activeLanguages as $iso) {
$isLanguagePackDownloaded = is_dir(Environment::getLabelsPath() . '/' . $iso . '/' . $key . '/');
$lastUpdate = $this->registry->get('languagePacks', $iso . '-' . $key);
$extension['packs'][$iso] = [
'iso' => $iso,
'exists' => $isLanguagePackDownloaded,
'lastUpdate' => $this->getFormattedDate($lastUpdate),
];
}
$extensions[$key] = $extension;
}
ksort($extensions);
$event = $this->eventDispatcher->dispatch(new ModifyLanguagePacksEvent($extensions));
return $event->getExtensions();
}
/**
* Download and unpack a single language pack of one extension.
*
* @param string $key Extension key
* @param string $iso Language iso code
* @return string One of 'update', 'new', 'skipped' or 'failed'
* @throws \RuntimeException
*/
public function languagePackDownload(string $key, string $iso): string
{
// Sanitize extension and iso code
$availableLanguages = $this->getAvailableLanguages();
$activeLanguages = $this->getActiveLanguages();
if (!array_key_exists($iso, $availableLanguages) || !in_array($iso, $activeLanguages, true)) {
throw new \RuntimeException('Language iso code ' . (string)$iso . ' not available or active', 1520117054);
}
$packageManager = GeneralUtility::makeInstance(PackageManager::class);
$package = $packageManager->getActivePackages()[$key] ?? null;
if (!$package) {
throw new \RuntimeException('Extension ' . (string)$key . ' not loaded', 1520117245);
}
$languagePackBaseUrl = self::LANGUAGE_PACK_URL;
// Allow to modify the base url on the fly
$event = $this->eventDispatcher->dispatch(new ModifyLanguagePackRemoteBaseUrlEvent(new Uri($languagePackBaseUrl), $key));
$languagePackBaseUrl = $event->getBaseUrl();
$majorVersion = GeneralUtility::makeInstance(Typo3Version::class)->getMajorVersion();
if ($package->getPackageMetaData()->isFrameworkType()) {
// This is a system extension and the package URL should be adapted to have different packs per core major version
// https://localize.typo3.org/xliff/b/a/backend-l10n/backend-l10n-fr.v9.zip
$packageUrl = $key[0] . '/' . $key[1] . '/' . $key . '-l10n/' . $key . '-l10n-' . $iso . '.v' . $majorVersion . '.zip';
} else {
// Typical non sysext path, Hungarian:
// https://localize.typo3.org/xliff/a/n/anextension-l10n/anextension-l10n-hu.zip
$packageUrl = $key[0] . '/' . $key[1] . '/' . $key . '-l10n/' . $key . '-l10n-' . $iso . '.zip';
}
$absoluteLanguagePath = Environment::getLabelsPath() . '/' . $iso . '/';
$absoluteExtractionPath = $absoluteLanguagePath . $key . '/';
$absolutePathToZipFile = Environment::getVarPath() . '/transient/' . $key . '-l10n-' . $iso . '.zip';
$packExists = is_dir($absoluteExtractionPath);
$packResult = $packExists ? 'update' : 'new';
$operationResult = false;
$response = $this->requestFactory->request($languagePackBaseUrl . $packageUrl, 'GET', ['http_errors' => false]);
if ($response->getStatusCode() === 200) {
$languagePackContent = $response->getBody()->getContents();
if (!empty($languagePackContent)) {
$operationResult = true;
if ($packExists) {
$operationResult = GeneralUtility::rmdir($absoluteExtractionPath, true);
}
if ($operationResult) {
GeneralUtility::mkdir_deep(Environment::getVarPath() . '/transient/');
$operationResult = GeneralUtility::writeFileToTypo3tempDir($absolutePathToZipFile, $languagePackContent) === null;
}
$this->unzipTranslationFile($absolutePathToZipFile, $absoluteLanguagePath);
if ($operationResult) {
$operationResult = unlink($absolutePathToZipFile);
}
}
} else {
$this->logger->warning('Requesting {request} was not successful, got status code {status} ({reason})', [
'request' => $languagePackBaseUrl . $packageUrl,
'status' => $response->getStatusCode(),
'reason' => $response->getReasonPhrase(),
]);
}
if (!$operationResult) {
$packResult = 'failed';
$this->registry->set('languagePacks', $iso . '-' . $key, time());
}
return $packResult;
}
/**
* Set 'last update' timestamp in registry for a series of iso codes.
*
* @param string[] $isos List of iso code timestamps to set
* @throws \RuntimeException
*/
public function setLastUpdatedIsoCode(array $isos): void
{
$activeLanguages = $GLOBALS['TYPO3_CONF_VARS']['LANG']['availableLocales'] ?? [];
foreach ($isos as $iso) {
if (!in_array($iso, $activeLanguages, true)) {
throw new \RuntimeException('Language iso code ' . (string)$iso . ' not available or active', 1520176318);
}
$this->registry->set('languagePacks', $iso, time());
}
}
/**
* Format a timestamp to a formatted date string
*/
private function getFormattedDate(?int $timestamp): ?string
{
if (is_int($timestamp)) {
$date = (new \DateTime())->setTimestamp($timestamp);
$format = $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'];
$timestamp = $date->format($format);
}
return $timestamp;
}
/**
* Unzip a language zip file
*
* @param string $file path to zip file
* @param string $path path to extract to
*/
private function unzipTranslationFile(string $file, string $path): void
{
if (!is_dir($path)) {
GeneralUtility::mkdir_deep($path);
}
$zipService = GeneralUtility::makeInstance(ZipService::class);
if ($zipService->verify($file)) {
$zipService->extract($file, $path);
}
}
}
+495
View File
@@ -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';
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
readonly class LanguageServiceFactory
{
public function __construct(
protected Locales $locales,
protected LocalizationFactory $localizationFactory,
protected FrontendInterface $runtimeCache
) {}
/**
* Factory method to create a language service object.
*
* @param Locale|string $locale the locale
*/
public function create(Locale|string $locale): LanguageService
{
$obj = new LanguageService($this->locales, $this->localizationFactory, $this->runtimeCache);
$obj->init($locale instanceof Locale ? $locale : $this->locales->createLocale($locale));
return $obj;
}
public function createFromUserPreferences(?AbstractUserAuthentication $user): LanguageService
{
return $this->create($this->locales->createLocaleFromUserPreferences($user));
}
public function createFromSiteLanguage(SiteLanguage $language): LanguageService
{
// createLocale from a string takes care of resolving the automatic dependencies of e.g. "de_AT" to also check for "de"
// and also validates if TYPO3 supports the original language (at least in TYPO3 v12, there is a fixed list of
// allowed language keys)
return $this->create((string)$language->getLocale());
}
/**
* Creates a LanguageService based on the current backend user's preferences.
*
* @internal don't use this in your own code, as we implicitly have a global dependency here.
*/
public function createForBackendUser(): LanguageService
{
return $this->createFromUserPreferences($GLOBALS['BE_USER'] ?? null);
}
}
+371
View File
@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
/*
* 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\Loader;
use Symfony\Component\Translation\Exception\InvalidResourceException;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\MessageCatalogue;
use TYPO3\CMS\Core\Localization\Exception\InvalidXmlFileException;
/**
* TYPO3-specific XLIFF file loader that implements Symfony's LoaderInterface
* while maintaining TYPO3's specific features like approval states and plurals.
*
* This loader only converts ONE SINGLE FILE. It is not responsible for detecting the file location of
* translation! This is done - currently - by the LocalizationFactory and should be moved to a dedicated class.
*
* This loader expects that the source is always english, and the translation is something else.
*
* @internal This class is not part of the TYPO3 Core API.
*/
final readonly class XliffLoader implements LoaderInterface
{
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
{
if (!is_string($resource)) {
throw new InvalidXmlFileException('XliffLoader only accepts string types (file names, XML content).', 1757588428);
}
if (!$this->isXmlString($resource)) {
if (!file_exists($resource)) {
throw new InvalidXmlFileException('File "' . $resource . '" not found.', 1757588429);
}
if (!is_file($resource)) {
throw new InvalidResourceException('The given resource is neither a file nor an XLIFF string "' . $resource . '".', 1757588430);
}
}
$rootXmlNode = $this->convertResourceToXml($resource);
return $this->parseXliffFromRoot($rootXmlNode, $locale, $domain);
}
/**
* Parse XLIFF file and return TYPO3-compatible data structure
*/
private function convertResourceToXml(string $resource): \SimpleXMLElement
{
if ($this->isXmlString($resource)) {
$xmlContent = $resource;
} else {
$xmlContent = @file_get_contents($resource);
if ($xmlContent === false) {
throw new InvalidXmlFileException('The path provided does not point to an existing and accessible file.', 1757537341);
}
}
$rootXmlNode = @simplexml_load_string($xmlContent, \SimpleXMLElement::class, LIBXML_NOWARNING);
if ($rootXmlNode === false) {
$xmlError = libxml_get_last_error();
throw new InvalidXmlFileException(
'The path provided does not point to an existing and accessible well-formed XML file. Reason: ' . $xmlError->message . ' in ' . $resource . ', line ' . $xmlError->line,
1757537342
);
}
return $rootXmlNode;
}
/**
* Parse XLIFF content from root element and build a catalogue
*/
private function parseXliffFromRoot(\SimpleXMLElement $root, string $locale, string $domain): MessageCatalogue
{
$catalogue = new MessageCatalogue($locale);
$version = $this->getXliffVersion($root);
if ($version === '2.0') {
$this->parseXliff2($root, $catalogue, $domain);
} else {
// Default to XLIFF 1.2 parsing
$this->parseXliff1($root, $catalogue, $domain);
}
return $catalogue;
}
/**
* Detect XLIFF version from the root element
*/
private function getXliffVersion(\SimpleXMLElement $root): string
{
$namespaces = $root->getNamespaces(true);
// Check if XLIFF 2.x namespace is present (matches 2.0, 2.1, 2.2, etc.)
foreach ($namespaces as $namespace) {
if (str_starts_with($namespace, 'urn:oasis:names:tc:xliff:document:2.')) {
return '2.0';
}
}
// Check version attribute
$version = (string)$root['version'];
if (str_starts_with($version, '2.')) {
return '2.0';
}
// Default to 1.2
return '1.2';
}
/**
* Parse XLIFF 1.2 format
*/
private function parseXliff1(\SimpleXMLElement $root, MessageCatalogue $catalogue, string $domain): void
{
$fileTag = $root->file;
$isDefaultLanguage = !isset($fileTag['target-language']); // Default language from XLIFF template (no target element)
$bodyOfFileTag = $root->file->body;
$requireApprovedLocalizations = (bool)($GLOBALS['TYPO3_CONF_VARS']['LANG']['requireApprovedLocalizations'] ?? true);
if ($bodyOfFileTag instanceof \SimpleXMLElement) {
foreach ($bodyOfFileTag->children() as $translationElement) {
$deprecated = false;
/** @var \SimpleXMLElement $translationElement */
if ($translationElement->getName() === 'trans-unit' && !isset($translationElement['restype'])) {
// Regular translation unit
$id = (string)$translationElement['id'];
$deprecated = isset($translationElement['x-unused-since']);
if ($isDefaultLanguage) {
// Default language from XLIFF template (no target element)
$sourceElement = $translationElement->source;
$translation = $this->extractText($sourceElement, $translationElement);
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $translation, $domain);
} else {
$approved = (string)($translationElement['approved'] ?? 'yes');
if (!$requireApprovedLocalizations || $approved === 'yes') {
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $this->extractText($translationElement->target, $translationElement), $domain);
}
}
} elseif ($translationElement->getName() === 'group' && isset($translationElement['restype']) && (string)$translationElement['restype'] === 'x-gettext-plurals') {
// Translation with plural forms
$parsedTranslationElement = [];
foreach ($translationElement->children() as $translationPluralForm) {
/** @var \SimpleXMLElement $translationPluralForm */
if ($translationPluralForm->getName() === 'trans-unit') {
$deprecated = isset($translationPluralForm['x-unused-since']);
// Extract plural form index from ID like "1[0]", "1[1]"
$formIndex = substr((string)$translationPluralForm['id'], strpos((string)$translationPluralForm['id'], '[') + 1, -1);
if ($isDefaultLanguage) {
// Default language from XLIFF template (no target element)
$parsedTranslationElement[(int)$formIndex] = $this->extractText($translationPluralForm->source, $translationPluralForm);
} else {
$approved = (string)($translationPluralForm['approved'] ?? 'yes');
if (!$requireApprovedLocalizations || $approved === 'yes') {
$parsedTranslationElement[(int)$formIndex] = $this->extractText($translationPluralForm->target, $translationPluralForm);
}
}
}
}
if ($parsedTranslationElement !== []) {
if (isset($translationElement['id'])) {
$id = (string)$translationElement['id'];
} else {
$id = (string)$translationElement->{'trans-unit'}[0]['id'];
$id = substr($id, 0, (int)strpos($id, '['));
}
// Handle plurals - Symfony uses ICU format
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $this->convertToIcuPlural(array_values($parsedTranslationElement)), $domain);
}
}
}
}
}
/**
* Parse XLIFF 2.0 format
*/
private function parseXliff2(\SimpleXMLElement $root, MessageCatalogue $catalogue, string $domain): void
{
$requireApprovedLocalizations = (bool)($GLOBALS['TYPO3_CONF_VARS']['LANG']['requireApprovedLocalizations'] ?? true);
$ns = $root->getDocNamespaces();
$ns = reset($ns) ?: 'urn:oasis:names:tc:xliff:document:2.0';
// Register the XLIFF 2.0 namespace
$root->registerXPathNamespace('xliff', $ns);
$isDefaultLanguage = !isset($root['trgLang']); // Default language from XLIFF template (no target element)
// Get all file elements
$files = $root->xpath('//xliff:file');
if ($files === false) {
return;
}
foreach ($files as $file) {
$file->registerXPathNamespace('xliff', $ns);
// Get all unit elements within this file
$units = $file->xpath('.//xliff:unit');
if ($units === false) {
continue;
}
foreach ($units as $unit) {
$unit->registerXPathNamespace('xliff', $ns);
$unitId = (string)$unit['id'];
// Check if this is a plural unit (contains multiple segments)
$segments = $unit->xpath('.//xliff:segment');
if ($segments === false) {
continue;
}
if (count($segments) === 1) {
// Regular translation unit
$segment = $segments[0];
$segment->registerXPathNamespace('xliff', $ns);
$source = $segment->xpath('.//xliff:source');
$target = $segment->xpath('.//xliff:target');
$deprecated = ((string)($segment['subState'] ?? '')) === 'deprecated';
if ($isDefaultLanguage) {
// Default language from XLIFF template (no target element)
$translation = $this->extractText($source[0], $segment);
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $translation, $domain);
} else {
// Check approval state (XLIFF 2.0 uses 'state' attribute on segment)
$approved = 'yes';
if (isset($segment['state'])) {
$state = (string)$segment['state'];
// XLIFF 2.0 states: initial, translated, reviewed, final
// We consider 'final' as approved, others depend on config
if ($state === 'initial' || $state === 'translated') {
$approved = 'no';
}
}
if (!$requireApprovedLocalizations || $approved === 'yes') {
if ($target !== false && isset($target[0])) {
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $this->extractText($target[0], $segment), $domain);
}
}
}
} else {
// Plural forms (multiple segments)
$parsedTranslationElement = [];
$formIndex = 0;
$deprecated = false;
foreach ($segments as $segment) {
$segment->registerXPathNamespace('xliff', $ns);
$source = $segment->xpath('.//xliff:source');
$target = $segment->xpath('.//xliff:target');
$deprecated = $deprecated || ((string)($segment['subState'] ?? '')) === 'deprecated';
if ($isDefaultLanguage) {
// Default language from XLIFF template (no target element)
$parsedTranslationElement[$formIndex] = $this->extractText($source[0], $segment);
} else {
$approved = 'yes';
if (isset($segment['state'])) {
$state = (string)$segment['state'];
if ($state === 'initial' || $state === 'translated') {
$approved = 'no';
}
}
if (!$requireApprovedLocalizations || $approved === 'yes') {
if ($target !== false && isset($target[0])) {
$parsedTranslationElement[$formIndex] = $this->extractText($target[0], $segment);
}
}
}
$formIndex++;
}
if ($parsedTranslationElement !== []) {
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $this->convertToIcuPlural(array_values($parsedTranslationElement)), $domain);
}
}
}
}
}
/**
* Convert plural forms to ICU plural format
* This is a simplified conversion - could be enhanced based on actual usage
*/
private function convertToIcuPlural(array $pluralValues): string
{
if (count($pluralValues) === 1) {
return $pluralValues[0];
}
// Simple mapping: [0] = one, [1] = other
$icuFormat = '';
if (isset($pluralValues[0])) {
$icuFormat .= '{0, plural, one {' . $pluralValues[0] . '}';
}
if (isset($pluralValues[1])) {
$icuFormat .= ' other {' . $pluralValues[1] . '}';
}
$icuFormat .= '}';
return $icuFormat;
}
private function isXmlString(string $resource): bool
{
return str_starts_with($resource, '<?xml');
}
/**
* Extract text content from an XML element, respecting xml:space attribute.
* Per XML spec: xml:space="preserve" keeps whitespace as-is,
* otherwise whitespace should be normalized (multiple spaces/newlines collapsed to single space).
* see https://www.w3.org/TR/xml/#sec-white-space
*/
private function extractText(\SimpleXMLElement $element, ?\SimpleXMLElement $parentElement = null): string
{
$text = (string)$element;
// Check xml:space on the element itself or parent (trans-unit/unit/segment)
$xmlSpace = $this->getXmlSpaceAttribute($element, $parentElement);
if ($xmlSpace !== 'preserve') {
// Normalize whitespace: collapse multiple whitespace characters to single space
$text = preg_replace('/\s+/', ' ', $text);
$text = trim($text);
}
return $text;
}
/**
* Get the xml:space attribute value, checking element and parent (attribute is inherited per XML spec).
*/
private function getXmlSpaceAttribute(\SimpleXMLElement $element, ?\SimpleXMLElement $parent): string
{
// Check element's xml:space attribute
$attributes = $element->attributes('xml', true);
if (isset($attributes['space'])) {
return (string)$attributes['space'];
}
// Check parent's xml:space attribute (inherited per XML spec)
if ($parent !== null) {
$parentAttributes = $parent->attributes('xml', true);
if (isset($parentAttributes['space'])) {
return (string)$parentAttributes['space'];
}
}
return 'default';
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
/*
* 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;
/**
* A representation of
* language key (based on ISO 639-1 / ISO 639-2)
* - the optional four-letter script code that can follow the language code according to the Unicode ISO 15924 Registry (e.g. Hans in zh_Hans)
* - region / country (based on ISO 3166-1)
* separated with a "-".
*
* This conforms to IETF - RFC 5646 (see https://datatracker.ietf.org/doc/rfc5646/) in a simplified form.
*/
class Locale implements \Stringable
{
// taken from https://meta.wikimedia.org/wiki/Template:List_of_language_names_ordered_by_code
protected const RIGHT_TO_LEFT_LANGUAGE_CODES = [
'ar', // Arabic
'arc', // Aramaic
'arz', // Egyptian Arabic
'ckb', // Kurdish (Sorani)
'dv', // Divehi
'fa', // Persian
'ha', // Hausa
'he', // Hebrew
'khw', // Khowar
'ks', // Kashmiri
'ps', // Pashto
'sd', // Sindhi
'ur', // Urdu
'uz-AF', // Uzbeki Afghanistan
'yi', // Yiddish
];
protected string $locale;
protected string $languageCode;
protected ?string $languageScript = null;
protected ?string $countryCode = null;
protected ?string $codeSet = null;
// see https://wiki.archlinux.org/title/locale#Generating_locales
protected ?string $charsetModifier = null;
/**
* List of language dependencies for an actual language. This setting is used for local variants of a language
* that depend on their "main" language, like Brazilian Portuguese or Canadian French.
*
* @var array<int, string>
*/
protected array $dependencies = [];
/**
* Only use the constructor directly if you know what you are doing and want to create custom locales with custom dependencies.
* Otherwise, instantiate a new Locale object via Locales->createLocale() as it deals with registered dependencies automatically.
*/
public function __construct(
string $locale = 'en',
array $dependencies = []
) {
$locale = $this->normalize($locale);
if (str_contains($locale, '@')) {
[$locale, $this->charsetModifier] = explode('@', $locale);
}
if (str_contains($locale, '.')) {
[$locale, $this->codeSet] = explode('.', $locale);
}
if (strtolower($locale) === 'c') {
$this->codeSet = 'C';
$locale = 'en';
} elseif (strtolower($locale) === 'posix') {
$this->codeSet = 'POSIX';
$locale = 'en';
}
if (str_contains($locale, '-')) {
[$this->languageCode, $tail] = explode('-', $locale, 2);
if (str_contains($tail, '-')) {
[$this->languageScript, $this->countryCode] = explode('-', $tail);
} elseif (strlen($tail) === 4) {
$this->languageScript = $tail;
} else {
$this->countryCode = $tail ?: null;
}
$this->languageCode = strtolower($this->languageCode);
$this->languageScript = $this->languageScript ? ucfirst(strtolower($this->languageScript)) : null;
$this->countryCode = $this->countryCode ? strtoupper($this->countryCode) : null;
} else {
$this->languageCode = strtolower($locale);
}
$this->locale = $this->languageCode . ($this->languageScript ? '-' . $this->languageScript : '') . ($this->countryCode ? '-' . $this->countryCode : '');
$this->dependencies = array_map($this->normalize(...), $dependencies);
}
public function getName(): string
{
return $this->locale;
}
public function getLanguageCode(): string
{
return $this->languageCode;
}
public function isRightToLeftLanguageDirection(): bool
{
return in_array($this->languageCode, self::RIGHT_TO_LEFT_LANGUAGE_CODES, true) || in_array($this->locale, self::RIGHT_TO_LEFT_LANGUAGE_CODES, true);
}
public function getLanguageScriptCode(): ?string
{
return $this->languageScript;
}
public function getCountryCode(): ?string
{
return $this->countryCode;
}
/**
* Return the locale as ISO/IEC 15897 format, including a possible POSIX charset
* "cs_CZ.UTF-8"
* see https://en.wikipedia.org/wiki/ISO/IEC_15897
* https://en.wikipedia.org/wiki/Locale_(computer_software)#POSIX_platforms
* @internal
*/
public function posixFormatted(): string
{
$charsetModifier = $this->charsetModifier ? '@' . $this->charsetModifier : '';
if ($this->codeSet === 'C' || $this->codeSet === 'POSIX') {
return $this->codeSet . $charsetModifier;
}
$formatted = $this->languageCode;
if ($this->countryCode) {
$formatted .= '_' . $this->countryCode;
}
if ($this->codeSet) {
$formatted .= '.' . $this->codeSet;
}
return $formatted . $charsetModifier;
}
/**
* @internal
*/
public function getPosixCodeSet(): ?string
{
return $this->codeSet;
}
public function getDependencies(): array
{
return $this->dependencies;
}
protected function normalize(string $locale): string
{
if ($locale === 'default') {
return 'en';
}
if (str_contains($locale, '_')) {
$locale = str_replace('_', '-', $locale);
}
return $locale;
}
public function __toString(): string
{
return $this->locale;
}
}
+415
View File
@@ -0,0 +1,415 @@
<?php
declare(strict_types=1);
/*
* 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 Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\ApplicationType;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Utility\CommandUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Defines all available TYPO3 system languages, as they differ from actual ISO 639-1 codes.
* User-defined system languages can be added to $GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['user']
*
* These system languages are used for determining the proper language labels of XLF files.
*/
class Locales implements SingletonInterface
{
/**
* Supported TYPO3 languages with locales
*
* @var array<non-empty-string, non-empty-string>
*/
protected array $languages = [
'en' => 'English',
'af' => 'Afrikaans',
'ar' => 'Arabic',
'bs' => 'Bosnian',
'bg' => 'Bulgarian',
'ca' => 'Catalan',
'ch' => 'Chinese (Simple)',
'cs' => 'Czech',
'cy' => 'Welsh',
'da' => 'Danish',
'de' => 'German',
'el' => 'Greek',
'eo' => 'Esperanto',
'es' => 'Spanish',
'et' => 'Estonian',
'eu' => 'Basque',
'fa' => 'Persian',
'fi' => 'Finnish',
'fo' => 'Faroese',
'fr' => 'French',
'fr_CA' => 'French (Canada)',
'ga' => 'Irish Gaelic',
'gd' => 'Scottish Gaelic',
'gl' => 'Galician',
'he' => 'Hebrew',
'hi' => 'Hindi',
'hr' => 'Croatian',
'hu' => 'Hungarian',
'is' => 'Icelandic',
'it' => 'Italian',
'ja' => 'Japanese',
'ka' => 'Georgian',
'kl' => 'Greenlandic',
'km' => 'Khmer',
'ko' => 'Korean',
'lb' => 'Luxembourgish',
'lo' => 'Lao',
'lt' => 'Lithuanian',
'lv' => 'Latvian',
'mi' => 'Maori',
'mk' => 'Macedonian',
'ms' => 'Malay',
'mt' => 'Maltese',
'nl' => 'Dutch',
'no' => 'Norwegian',
'pl' => 'Polish',
'pt' => 'Portuguese',
'pt_BR' => 'Brazilian Portuguese',
'ro' => 'Romanian',
'ru' => 'Russian',
'rw' => 'Kinyarwanda',
'sk' => 'Slovak',
'sl' => 'Slovenian',
'sn' => 'Shona (Bantu)',
'sq' => 'Albanian',
'sr' => 'Serbian',
'sv' => 'Swedish',
'th' => 'Thai',
'tr' => 'Turkish',
'uk' => 'Ukrainian',
'vi' => 'Vietnamese',
'zh' => 'Chinese (Traditional)',
'zh_CN' => 'Chinese (Simplified)',
'zh_HK' => 'Chinese (Simplified Hong Kong)',
'zh_Hans_CN' => 'Chinese (Simplified Han)',
];
/**
* Dependencies for locales.
* By default, locales with a country/region suffix such as "de_AT" will automatically have the "de"
* locale as fallback. This way TYPO3 only needs to know about the actual "base" language, however
* also allows to use country-specific languages.
* However, when a specific locale such as "lb" has a dependency to a different "de" suffix, this should
* is defined here.
* With
* $GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['dependencies']
* it is possible to extend the dependency list.
*
* Example:
* If "lb" is chosen, but no label was found, a fallback to the label in "de" is used.
*/
protected array $localeDependencies = [
'lb' => ['de'],
];
public function __construct()
{
// Allow user-defined locales
foreach ($GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['user'] ?? [] as $locale => $name) {
if (!is_string($locale) || $locale === '') {
continue;
}
if (!isset($this->languages[$locale])) {
$this->languages[$locale] = $name;
}
}
// Merge user-provided locale dependencies
if (is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['dependencies'] ?? null)) {
$this->localeDependencies = array_replace_recursive(
$this->localeDependencies,
$GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['dependencies']
);
}
}
public function createLocale(string $localeKey, ?array $alternativeDependencies = null): Locale
{
if (strpos($localeKey, '.')) {
[$sanitizedLocaleKey] = explode('.', $localeKey);
}
// Find the requested language in this list based on the $languageKey
// Language is found. Configure it:
if ($localeKey === 'en' || $this->isValidLanguageKey($sanitizedLocaleKey ?? $localeKey)) {
return new Locale($localeKey, $alternativeDependencies ?? $this->getLocaleDependencies($sanitizedLocaleKey ?? $localeKey));
}
return new Locale();
}
/**
* Returns the locales.
* @return array<int, non-empty-string>
*/
public function getLocales(): array
{
return array_keys($this->languages);
}
public function isValidLanguageKey(string $locale): bool
{
// "en" implicitly equals "default", so this is OK
if ($locale === 'en' || $locale === 'default') {
return true;
}
if (!isset($this->languages[$locale])) {
// the given locale is not found in the current locales, let us see if
// the base language (iso-639-1) is in the list of supported locales.
if (str_contains($locale, '_')) {
[$baseIsoCodeLanguageKey] = explode('_', $locale);
return $this->isValidLanguageKey($baseIsoCodeLanguageKey);
}
if (str_contains($locale, '-')) {
[$baseIsoCodeLanguageKey] = explode('-', $locale);
return $this->isValidLanguageKey($baseIsoCodeLanguageKey);
}
return false;
}
return true;
}
/**
* Returns the supported languages indexed by their corresponding locale.
* @return array<non-empty-string, non-empty-string>
*/
public function getLanguages(): array
{
return $this->languages;
}
/**
* Returns a list of all ISO codes / TYPO3 languages that have active language packs, including "en" (English).
* @return array<int, non-empty-string>
*/
public function getActiveLanguages(): array
{
return array_merge(
['en'],
array_filter(array_values($GLOBALS['TYPO3_CONF_VARS']['LANG']['availableLocales'] ?? []))
);
}
public function isLanguageKeyAvailable(string $languageKey): bool
{
return in_array($languageKey, $this->getActiveLanguages()) || is_dir(Environment::getLabelsPath() . '/' . $languageKey);
}
/**
* Returns the dependencies of a given locale, if any.
*
* @return array<int, non-empty-string>
*/
public function getLocaleDependencies(string $locale): array
{
$dependencies = [];
if (isset($this->localeDependencies[$locale])) {
$dependencies = $this->localeDependencies[$locale];
// Search for dependencies recursively
$localeDependencies = $dependencies;
foreach ($localeDependencies as $dependency) {
if (isset($this->localeDependencies[$dependency])) {
$dependencies = array_merge($dependencies, $this->getLocaleDependencies($dependency));
}
}
}
// Use automatic dependency resolving.
// "de_AT" automatically has a dependency on "de".
// but only do this if the actual "de_AT" does not have a custom dependency already defined in
// $this->localeDependencies
if ($dependencies === [] && str_contains($locale, '_')) {
[$languageIsoCode] = explode('_', $locale);
// "en" and "default" is always implicitly the default fallback dependency
if ($languageIsoCode !== 'en') {
$dependencies[] = $languageIsoCode;
$dependencies = array_merge($dependencies, $this->getLocaleDependencies($languageIsoCode));
}
} elseif ($dependencies === [] && str_contains($locale, '-')) {
[$languageIsoCode] = explode('-', $locale);
// "en" and "default" is always implicitly the default fallback dependency
if ($languageIsoCode !== 'en') {
$dependencies[] = $languageIsoCode;
$dependencies = array_merge($dependencies, $this->getLocaleDependencies($languageIsoCode));
}
}
return array_unique($dependencies);
}
/**
* Converts the language codes that we get from the client (usually HTTP_ACCEPT_LANGUAGE)
* into a TYPO3-readable language code
*
* @param string $languageCodesList List of language codes. something like 'de,en-us;q=0.9,de-de;q=0.7,es-cl;q=0.6,en;q=0.4,es;q=0.3,zh;q=0.1'
* @return non-empty-string A preferred language that TYPO3 supports, or "en" as fallback if none found
*/
public function getPreferredClientLanguage(string $languageCodesList): string
{
$allLanguageCodesFromLocales = [];
foreach ($this->languages as $locale => $localeTitle) {
$locale = str_replace('_', '-', $locale);
$allLanguageCodesFromLocales[$locale] = $locale;
}
$selectedLanguage = 'en';
$preferredLanguages = GeneralUtility::trimExplode(',', $languageCodesList);
// Order the preferred languages after they key
$sortedPreferredLanguages = [];
foreach ($preferredLanguages as $preferredLanguage) {
$quality = 1.0;
if (str_contains($preferredLanguage, ';q=')) {
[$preferredLanguage, $quality] = explode(';q=', $preferredLanguage);
}
$sortedPreferredLanguages[$preferredLanguage] = $quality;
}
// Loop through the languages, with the highest priority first
arsort($sortedPreferredLanguages, SORT_NUMERIC);
foreach ($sortedPreferredLanguages as $preferredLanguage => $quality) {
if (isset($allLanguageCodesFromLocales[$preferredLanguage])) {
$selectedLanguage = $allLanguageCodesFromLocales[$preferredLanguage];
break;
}
// Strip the country code from the end
[$preferredLanguage] = explode('-', $preferredLanguage);
if (isset($allLanguageCodesFromLocales[$preferredLanguage])) {
$selectedLanguage = $allLanguageCodesFromLocales[$preferredLanguage];
break;
}
}
return str_replace('-', '_', $selectedLanguage);
}
/**
* Setting locale based on a SiteLanguage's defined locale. Used for frontend rendering.
*
* @return bool whether the locale was found on the system (and could be set properly) or not
*/
public static function setSystemLocaleFromSiteLanguage(SiteLanguage $siteLanguage): bool
{
$locale = $siteLanguage->getLocale()->posixFormatted();
if ($locale === '') {
return false;
}
return self::setLocale($locale, $locale);
}
/**
* @internal not part of TYPO3 Core API, don't use outside of TYPO3 Core
*/
public static function getAllSystemLocales(): array
{
$disabledFunctions = GeneralUtility::trimExplode(',', (string)ini_get('disable_functions'), true);
if (in_array('exec', $disabledFunctions, true)) {
return [];
}
$rawOutput = [];
CommandUtility::exec('locale -a', $rawOutput);
sort($rawOutput, SORT_NATURAL);
$locales = [];
$usedLocaleBasenames = [];
foreach ($rawOutput as $item) {
// do not show C/POSIX in the list of locales, as this is the default anyway
$obj = new Locale($item);
if ($obj->getPosixCodeSet() === 'C' || $obj->getPosixCodeSet() === 'POSIX') {
continue;
}
// Skip locales with appended language or country code (e.g. "de_DE.UTF-8", "de_DE.ISO8859-1").
// The user should only choose "de_DE".
if (in_array($obj->getName(), $usedLocaleBasenames, true)) {
continue;
}
$locales[] = $item;
$usedLocaleBasenames[] = $obj->getName();
}
return $locales;
}
/**
* Internal method, which calls itself again, in order to avoid multiple logging issues.
* The main reason for this method is that it calls itself again by trying again to set
* the locale. Due to sensible defaults, people used the locale "de_AT.utf-8" with the POSIX platform
* (see https://en.wikipedia.org/wiki/Locale_(computer_software)#POSIX_platforms) in their site configuration,
* even though the target system has "de_AT" and not "de_AT.UTF-8" defined.
* "setLocale()" is now called again without the POSIX platform suffix and is checked again if the locale
* is then available, and then logs the failed information.
*/
protected static function setLocale(string $locale, string $localeStringForTrigger): bool
{
$incomingLocale = $locale;
$availableLocales = GeneralUtility::trimExplode(',', $locale, true);
// If LC_NUMERIC is set e.g. to 'de_DE' PHP parses float values locale-aware resulting in strings with comma
// as decimal point which causes problems with value conversions - so we set all locale types except LC_NUMERIC
// @see https://bugs.php.net/bug.php?id=53711
$locale = setlocale(LC_COLLATE, ...$availableLocales);
if ($locale) {
// As str_* methods are locale aware and turkish has no upper case I
// Class autoloading and other checks depending on case changing break with turkish locale LC_CTYPE
// @see http://bugs.php.net/bug.php?id=35050
if (!str_starts_with($locale, 'tr')) {
setlocale(LC_CTYPE, ...$availableLocales);
}
setlocale(LC_MONETARY, ...$availableLocales);
setlocale(LC_TIME, ...$availableLocales);
} else {
// Retry again without the "utf-8" POSIX platform suffix if this is given.
if (str_contains($incomingLocale, '.')) {
[$localeWithoutModifier] = explode('.', $incomingLocale);
return self::setLocale($localeWithoutModifier, $incomingLocale);
}
if ($localeStringForTrigger === $locale) {
GeneralUtility::makeInstance(LogManager::class)
->getLogger(__CLASS__)
->error('Locale "' . htmlspecialchars($localeStringForTrigger) . '" not found.');
} else {
GeneralUtility::makeInstance(LogManager::class)
->getLogger(__CLASS__)
->error('Locale "' . htmlspecialchars($localeStringForTrigger) . '" and "' . htmlspecialchars($incomingLocale) . '" not found.');
}
return false;
}
return true;
}
public function createLocaleFromRequest(?ServerRequestInterface $request): Locale
{
$languageServiceFactory = GeneralUtility::makeInstance(LanguageServiceFactory::class);
if ($request !== null && ApplicationType::fromRequest($request)->isFrontend()) {
// @todo: the string conversion is needed for the time being, as long as SiteLanguage does not contain
// the full locale with all fallbacks, then getTypo3Language() also needs to be removed.
$localeString = (string)($request->getAttribute('language')?->getTypo3Language()
?? $request->getAttribute('site')->getDefaultLanguage()->getTypo3Language());
return $this->createLocale($localeString);
}
return $languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER'] ?? null)->getLocale();
}
public function createLocaleFromUserPreferences(?AbstractUserAuthentication $user): Locale
{
if ($user && ($user->user['lang'] ?? false)) {
return $this->createLocale($user->user['lang']);
}
return $this->createLocale('en');
}
}
@@ -0,0 +1,335 @@
<?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\Autowire;
use Symfony\Component\Translation\MessageCatalogueInterface;
use Symfony\Component\Translation\Translator;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Localization\Exception\FileNotFoundException;
/**
* This class acts currently as facade around SymfonyTranslator.
* User-land code should use LanguageService for the time being, and this class should not be exposed directly.
*
* Ideally, consider using a runtime cache if needed, if not using LanguageService.
*
* Hand in the locale to load, or english ("en").
*
* What it does:
* - Caches on a system-level cache
* - Handles loading default (= english) before translated files
* - Handles file name juggling of translated files.
* - Handles localization overrides via $GLOBALS['TYPO3_CONF_VARS']['LANG']['resourceOverrides']
*
* This class only deals with full files, "resources" a.k.a. "translation domains" right now. It does not care about
* the actual identifier WITHIN this label bag.
*
* The main issue with this class is that it does not resolve proper dependencies, thus the fallback logic
* is marode. You can see this when checking for ArrayUtility both here and in LanguageService.
*
* @phpstan-type TranslationPlural array<int, string>
* @phpstan-type TranslationLabel array<string, string|TranslationPlural>
*/
readonly class LocalizationFactory
{
protected const MOVED_FILES = [
// Files that have been moved to a new location.
// Add entries as: 'EXT:old/path/file.xlf' => 'EXT:new/path/file.xlf'
];
protected const DEPRECATED_FILES = [
// Files that are deprecated and should no longer be referenced.
// Add entries as: 'EXT:ext/Resources/Private/Language/file.xlf'
];
public function __construct(
protected Translator $translator,
#[Autowire(service: 'cache.l10n')]
protected FrontendInterface $systemCache,
#[Autowire(service: 'cache.runtime')]
protected FrontendInterface $runtimeCache,
protected TranslationDomainMapper $translationDomainMapper,
protected LabelFileResolver $labelFileResolver,
protected TranslationDomainResolver $translationDomainResolver,
) {
foreach ($GLOBALS['TYPO3_CONF_VARS']['LANG']['loader'] ?? [] as $key => $loader) {
if (class_exists($loader)) {
$this->translator->addLoader($key, new $loader());
}
}
$this->translator->setFallbackLocales(['default']);
}
/**
* @internal Not part of TYPO3 Core API. Do not use outside of TYPO3 Core as this method may vanish at any time.
*/
public function isLanguageFileDeprecated(string $fileReference): bool
{
return in_array($fileReference, self::DEPRECATED_FILES)
// @phpstan-ignore isset.offset (MOVED_FILES is intentionally empty for now, remove this once a first entry is added)
|| isset(self::MOVED_FILES[$fileReference]);
}
/**
* Preload files into Symfony Translator without retrieving catalogues.
*
* This is used during cache warmup to batch all addResource() calls before
* any getCatalogue() calls, avoiding O(n²) catalogue rebuilds.
*
* @internal
*/
public function warmupTranslatorResource(string $fileReference, Locale $locale): void
{
[$fileReference, $domainName, $allLanguageKeysAsOrderedFallback] = $this->computeFileDomainAndFallbacks($fileReference, $locale);
$this->loadLanguagesIntoSymfonyTranslator($fileReference, $domainName, $allLanguageKeysAsOrderedFallback);
}
/**
* Returns parsed data from a given file and language key.
*
* @param string $fileReference Input is a file-reference (see \TYPO3\CMS\Core\Utility\GeneralUtility::getFileAbsFileName). That file is expected to be a supported locallang file format
* @param Locale|string|null $locale Locale with dependencies or language key. Null value is set to 'en' with fallback 'default'. @internal Language key as string loads language data with "en" and "default" as the default fallback dependency.
* @param bool $renewCache Recompute data and renew cache entry.
*
* @return TranslationLabel
*/
public function getParsedData(string $fileReference, Locale|string|null $locale, bool $renewCache = false): array
{
if ($locale === null) {
$locale = new Locale('en', ['default']);
}
if (is_string($locale)) {
// Load language data with fallback "en" and "default", as these are always implicitly the default fallback dependencies.
$locale = new Locale($locale);
}
$languageKey = $locale->getName();
[$fileReference, $domainName, $allLanguageKeysAsOrderedFallback] = $this->computeFileDomainAndFallbacks($fileReference, $locale);
$systemCacheIdentifier = md5($domainName . $languageKey . serialize($allLanguageKeysAsOrderedFallback));
// If the content is in system cache, put it in runtime cache and use it
if (!$renewCache) {
$labels = $this->systemCache->get($systemCacheIdentifier);
if (is_array($labels)) {
return $labels;
}
}
// Add files for all locales to Symfony Translator catalogue - order does not matter here.
$this->loadLanguagesIntoSymfonyTranslator($fileReference, $domainName, $allLanguageKeysAsOrderedFallback);
// Set order of fallback locales in Symfony Translator.
if ($this->translator->getFallbackLocales() !== $allLanguageKeysAsOrderedFallback) {
// Performance: Setting fallbacks clears all catalogues, which results in computational expensive regeneration of catalogues!
$this->translator->setFallbackLocales($allLanguageKeysAsOrderedFallback);
}
$labels = $this->loadWithSymfonyTranslator($languageKey, $domainName);
// Cache processed data
$this->systemCache->set($systemCacheIdentifier, $labels);
return $labels;
}
/**
* Prepares file reference, domain, language fallbacks
*
* @return array{string, string, array<string>}
*/
protected function computeFileDomainAndFallbacks(string $fileReference, Locale $locale): array
{
if (in_array($fileReference, self::DEPRECATED_FILES)) {
trigger_error(
sprintf('The file "%s" is deprecated. Please use a label from a different language file instead.', $fileReference),
E_USER_DEPRECATED
);
}
// @phpstan-ignore isset.offset (MOVED_FILES is intentionally empty for now, remove this once a first entry is added)
if (isset(self::MOVED_FILES[$fileReference])) {
trigger_error('The file ' . $fileReference . ' has been moved to ' . self::MOVED_FILES[$fileReference] . '. Please update your code accordingly.', E_USER_DEPRECATED);
$fileReference = self::MOVED_FILES[$fileReference];
}
$fileReference = $this->translationDomainMapper->mapDomainToFileName($fileReference);
$domainName = $this->translationDomainResolver->mapFileNameToDomain($fileReference);
$allLanguageKeysAsOrderedFallback = $this->computeAllLanguageKeys($locale);
return [$fileReference, $domainName, $allLanguageKeysAsOrderedFallback];
}
protected function computeAllLanguageKeys(Locale $locale): array
{
if ($locale->getName() === 'default') {
return ['default'];
}
$mainLocales = [$locale->getName()];
$dependencyLocales = $locale->getDependencies();
// Firstly, remove 'default' if exists. 'en' must be added before 'default'.
if (($keyDefault = array_search('default', $dependencyLocales, true)) !== false) {
unset($dependencyLocales[$keyDefault]);
}
// 'en' and 'default' is always added as the default fallback dependency
$allLocales = array_merge($mainLocales, $dependencyLocales, ['en', 'default']);
$allLocales = array_unique($allLocales);
return $allLocales;
}
/**
* Load languages into Symfony Translator
*/
protected function loadLanguagesIntoSymfonyTranslator(string $fileReference, string $domainName, array $allLanguageKeysAsOrderedFallback): void
{
// Add files for all locales to Symfony Translator catalogue - order does not matter here.
foreach ($allLanguageKeysAsOrderedFallback as $currentLanguageKey) {
$this->loadFilesIntoSymfonyTranslator($fileReference, $currentLanguageKey, $domainName);
}
}
/**
* Load files into Symfony Translator
*/
protected function loadFilesIntoSymfonyTranslator(string $fileReference, string $languageKey, string $domainName): void
{
// Early exit if this file+locale combination has already been fully processed (including overrides).
// This avoids redundant resolveFileReference() and getOverrideFilePaths() calls when processing
// fallback locales that have already been loaded for previous files.
$loadedCacheIdentifier = 'localization-factory-loaded-' . md5($fileReference . '-' . $languageKey . '-' . $domainName);
if ($this->runtimeCache->has($loadedCacheIdentifier)) {
return;
}
// Firstly, load language into catalogue.
try {
$this->addFileReferenceToTranslator($fileReference, $languageKey, $domainName);
} catch (FileNotFoundException) {
// Run localization override, regardless of file reference not found.
}
// Finally, apply localization overrides.
$overrideFiles = $this->labelFileResolver->getOverrideFilePaths($fileReference, $languageKey);
foreach ($overrideFiles as $overrideFile) {
try {
$this->addFileReferenceToTranslator($overrideFile, $languageKey, $domainName);
} catch (FileNotFoundException) {
}
}
$this->runtimeCache->set($loadedCacheIdentifier, true);
}
/**
* Get the catalogue and convert to TYPO3 format
*
* @return TranslationLabel
*/
protected function loadWithSymfonyTranslator(string $languageKey, string $domainName): array
{
$catalogue = $this->getMessageCatalogue($languageKey);
return $this->convertCatalogueToLegacyFormat($catalogue, $domainName);
}
/**
* Load complete catalogue for locale using Symfony Translator
*/
protected function getMessageCatalogue(string $locale): MessageCatalogueInterface
{
return $this->translator->getCatalogue($locale);
}
/**
* Adds translations of one resource to Symfony Translator
*
* @throws FileNotFoundException
*/
protected function addFileReferenceToTranslator(string $fileReference, string $locale, string $domainName): void
{
$actualSourcePath = $this->labelFileResolver->resolveFileReference($fileReference, $locale);
if ($actualSourcePath === null) {
// No file found. This might be the case if there is no localized version.
return;
}
// Add the resource to Symfony Translator, if not added yet.
$cacheIdentifier = 'symfony-translator-localization-factory-' . md5($actualSourcePath . '-' . $locale . '-' . $domainName);
if (!$this->runtimeCache->has($cacheIdentifier)) {
// @todo: we need to be more flexible with the file ending here.
$fileExtension = (string)pathinfo($actualSourcePath, PATHINFO_EXTENSION);
$this->translator->addResource($fileExtension ?: 'xlf', $actualSourcePath, $locale, $domainName);
$this->runtimeCache->set($cacheIdentifier, true);
}
}
/**
* Convert Symfony MessageCatalogue to TYPO3's legacy format
*
* @return TranslationLabel
*/
protected function convertCatalogueToLegacyFormat(MessageCatalogueInterface $catalogue, string $domain): array
{
$result = [];
$fallbackCatalogue = $catalogue->getFallbackCatalogue();
if ($fallbackCatalogue !== null) {
$result = $this->convertCatalogueToLegacyFormat($fallbackCatalogue, $domain);
}
foreach ($catalogue->all($domain) as $key => $value) {
// Check if this is a plural form (contains ICU format)
if (str_contains($value, '{0, plural,')) {
$result[$key] = $this->parseIcuPlural($value);
} else {
// Regular translation
$result[$key] = $value;
}
}
return $result;
}
/**
* Simple parser for ICU plural format - extracts plural values
*
* @return TranslationPlural
*/
protected function parseIcuPlural(string $icuString): array
{
$plurals = [];
// Extract content within plural braces
if (preg_match('/\{0, plural,(.+)\}$/', $icuString, $matches)) {
$content = trim($matches[1]);
// Parse forms like "one {text1} other {text2}"
if (preg_match_all('/(\w+)\s*\{([^}]+)\}/', $content, $formMatches, PREG_SET_ORDER)) {
foreach ($formMatches as $match) {
$form = $match[1];
$text = $match[2];
// Map ICU forms to indices (simplified mapping)
$index = match ($form) {
'one' => 0,
'other' => 1,
default => count($plurals)
};
$plurals[$index] = $text;
}
}
}
return $plurals;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/*
* 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\Autoconfigure;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Site\Entity\NullSite;
use TYPO3\CMS\Core\Site\SiteFinder;
/**
* Provides ItemsProcFunc fields for special population of available TYPO3 system languages
* @internal
*/
#[Autoconfigure(public: true)]
final readonly class TcaSystemLanguageCollector
{
public function __construct(
private Locales $locales,
private SiteFinder $siteFinder,
) {}
/**
* Populate languages and group by available languages of the Language packs
*/
public function populateAvailableSystemLanguagesForBackend(array &$fieldInformation): void
{
$languageItems = $this->locales->getLanguages();
$availableLanguages = [];
$unavailableLanguages = [];
foreach ($languageItems as $languageKey => $name) {
if ($this->locales->isLanguageKeyAvailable($languageKey)) {
$availableLanguages[] = ['label' => $name, 'value' => $languageKey, 'group' => 'installed'];
} else {
$unavailableLanguages[] = ['label' => $name, 'value' => $languageKey, 'group' => 'unavailable'];
}
}
// Ensure ordering of the items
$fieldInformation['items'] = array_merge($availableLanguages, $unavailableLanguages);
}
/**
* Provides a list of all languages available for ALL sites.
* In case no site configuration can be found in the system,
* a fallback is used to add at least the default language.
*
* Used by be_users and be_groups for their `allowed_languages` column.
*/
public function populateAvailableSiteLanguages(array &$fieldInformation): void
{
$allLanguages = [];
foreach ($this->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $language) {
$languageId = $language->getLanguageId();
if (isset($allLanguages[$languageId])) {
// Language already provided by another site, just add the label separately
$allLanguages[$languageId]['label'] .= ', ' . $language->getTitle() . ' [Site: ' . $site->getIdentifier() . ']';
continue;
}
$allLanguages[$languageId] = [
'label' => $language->getTitle() . ' [Site: ' . $site->getIdentifier() . ']',
'value' => $languageId,
'icon' => $language->getFlagIdentifier(),
];
}
}
if ($allLanguages !== []) {
ksort($allLanguages);
foreach ($allLanguages as $item) {
$fieldInformation['items'][] = $item;
}
return;
}
// Fallback if no site configuration exists
$recordPid = (int)($fieldInformation['row']['pid'] ?? 0);
$languages = (new NullSite())->getAvailableLanguages($this->getBackendUser(), false, $recordPid);
foreach ($languages as $languageId => $language) {
$fieldInformation['items'][] = [
'label' => $language->getTitle(),
'value' => $languageId,
'icon' => $language->getFlagIdentifier(),
];
}
}
private function getAllSites(): array
{
return $this->siteFinder->getAllSites();
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
/*
* 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 Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Localization\Event\BeforeLabelResourceResolvedEvent;
use TYPO3\CMS\Core\Package\PackageInterface;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Maps between translation domains and label resource file paths.
*
* Translation domains provide a shorter, semantic alternative to verbose file paths for
* referencing label resources (XLF files).
*
* Domain Format: package[.subdomain[.subdomain...]].resource
*
* Domain Generation Rules:
* - Extension language files: [package].[subdir].[filename]
* - Site Set files: [package].sets.[setName]
* - Subdirectories are represented as dot-separated parts
* - Special handling for locale-prefixed files
*
* - subdirectory "Resources/Private/Language" is omitted
* - subdirectory "Configuration/Sets/{set-name}/labels.xlf" is replaced with "sets.{set-name}"
*
* UpperCamelCase is converted to snake_case.
*
* "locallang.xlf" is mapped to "messages"
* "locallang_{mysuffix}.xlf" is replaced via "mysuffix" (keeping underscores)
*
* Locale prefixes (e.g. "de.locallang.xlf") are ignored in the domain name.
*
* Package Identifier Resolution:
* - Uses extension keys: "core", "backend"
* - Composer package names can be used as input and will be resolved to extension keys
*
* Once a domain for a package is requested, the cache for this package is built.
*
* @internal not part of TYPO3's public API.
*/
readonly class TranslationDomainMapper
{
public function __construct(
protected PackageManager $packageManager,
protected LabelFileResolver $labelFileResolver,
protected TranslationDomainResolver $translationDomainResolver,
#[Autowire(service: 'cache.l10n')]
protected FrontendInterface $labelCache,
protected EventDispatcherInterface $eventDispatcher,
) {}
/**
* Fetches all found label files, this way, we can use the domain to populate the label cache
* of a Package, as it also COULD include the package.
*
* When multiple files map to the same domain (e.g., locallang.xlf and messages.xlf both map
* to ".messages"), files without the "locallang" prefix/suffix have precedence.
*
* Precedence order:
* 1. Files without "locallang" prefix (e.g., messages.xlf, tabs.xlf)
* 2. Files with "locallang_" prefix (e.g., locallang_toolbar.xlf)
* 3. Plain locallang.xlf
*/
public function findLabelResourcesInPackage(string $packageKey): array
{
$packageKey = $this->packageManager->getPackageKeyFromComposerName($packageKey);
$cacheIdentifier = 'translation-domains-of-package-' . $packageKey;
$allLabelFilesOfPackage = $this->labelCache->get($cacheIdentifier);
if (is_array($allLabelFilesOfPackage)) {
return $allLabelFilesOfPackage;
}
$allLabelFilesOfPackage = $this->labelFileResolver->getAllLabelFilesOfPackage($packageKey);
$domains = [];
$domainPriorities = [];
$package = $this->packageManager->getPackage($packageKey);
foreach ($allLabelFilesOfPackage['default'] ?? [] as $file) {
// Make path relative
$file = $this->getRelativePath($file, $packageKey, $package);
$domain = $this->translationDomainResolver->mapFileNameToDomain($file);
$priority = $this->getFilePriority($file);
// Only set/overwrite if this file has higher or equal priority
if (!isset($domains[$domain]) || $priority >= $domainPriorities[$domain]) {
$domains[$domain] = $file;
$domainPriorities[$domain] = $priority;
}
}
$event = new BeforeLabelResourceResolvedEvent(
$packageKey,
$domains
);
$event = $this->eventDispatcher->dispatch($event);
$this->labelCache->set($cacheIdentifier, $event->domains);
return $event->domains;
}
/**
* Find label resources in a package grouped by locale.
*
* Returns an array where keys are locale codes and values are arrays of domain => resource mappings.
* Applies the same precedence rules as findLabelResourcesInPackage().
*
* @return array<string, array<string, string>> Array grouped by locale, then domain => resource
*/
public function findLabelResourcesInPackageGroupedByLocale(string $packageKey): array
{
$packageKey = $this->packageManager->getPackageKeyFromComposerName($packageKey);
$package = $this->packageManager->getPackage($packageKey);
$allLabelFilesOfPackage = $this->labelFileResolver->getAllLabelFilesOfPackage($packageKey);
$domainsByLocale = [];
$prioritiesByLocale = [];
foreach ($allLabelFilesOfPackage as $locale => $files) {
$domainsByLocale[$locale] = [];
$prioritiesByLocale[$locale] = [];
foreach ($files as $file) {
$file = $this->getRelativePath($file, $packageKey, $package);
$domain = $this->translationDomainResolver->mapFileNameToDomain($file);
$priority = $this->getFilePriority($file);
// Only set/overwrite if this file has higher or equal priority
if (!isset($domainsByLocale[$locale][$domain]) || $priority >= $prioritiesByLocale[$locale][$domain]) {
$domainsByLocale[$locale][$domain] = $file;
$prioritiesByLocale[$locale][$domain] = $priority;
}
}
}
return $domainsByLocale;
}
/**
* Determine file priority for domain collision resolution.
*
* Higher priority wins when multiple files map to the same domain.
*
* Priority levels:
* - 3: Files without "locallang" prefix (messages.xlf, tabs.xlf)
* - 2: Files with "locallang_" prefix (locallang_toolbar.xlf)
* - 1: Plain locallang.xlf
*
* This ensures modern naming (messages.xlf) takes precedence over legacy (locallang.xlf).
*/
protected function getFilePriority(string $filePath): int
{
$fileName = basename($filePath);
$fileNameWithoutExtension = $this->translationDomainResolver->getFileReferenceWithoutExtension($fileName);
// Remove locale prefix if present (e.g., "de.locallang.xlf" -> "locallang.xlf")
$locale = $this->translationDomainResolver->getLocaleFromLanguageFile($fileName);
if ($locale !== null) {
$fileNameWithoutExtension = substr($fileNameWithoutExtension, strlen($locale) + 1);
}
// Priority 3: Files without "locallang" prefix (e.g., messages.xlf, tabs.xlf)
if (!str_contains($fileNameWithoutExtension, 'locallang')) {
return 3;
}
// Priority 2: Files with "locallang_" prefix (e.g., locallang_toolbar.xlf)
if (str_starts_with($fileNameWithoutExtension, 'locallang_')) {
return 2;
}
// Priority 1: Plain locallang.xlf
return 1;
}
/**
* Maps a translation domain to a label resource file reference.
*
* Examples:
* - "core.messages" -> "EXT:core/Resources/Private/Language/locallang.xlf"
* - "backend.toolbar" -> "EXT:backend/Resources/Private/Language/locallang_toolbar.xlf"
* - "core.form.tabs" -> "EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf"
* - "felogin.sets.felogin" -> "EXT:felogin/Configuration/Sets/Felogin/labels.xlf"
*
* Invalid domain names return their values directly.
* 1. More than one ":" included
* 2. Domain Name contains other than [a-z0-9_.] characters
*/
public function mapDomainToFileName(string $domain): string
{
// In order to be deterministic, we need to find the proper file name to reference
// now. It IS possible that the incoming domain is actually valid, so we skip it.
// If it's already an EXT: reference or absolute path, return as-is
if (str_starts_with($domain, 'EXT:') || GeneralUtility::isAllowedAbsPath($domain)) {
return $domain;
}
if (!$this->translationDomainResolver->isValidDomainName($domain)) {
return $domain;
}
// Parse domain into extension key part and resource part
[$extensionKey, $resourcePart] = explode('.', $domain, 2);
try {
$allDomainsInPackage = $this->findLabelResourcesInPackage($extensionKey);
} catch (\InvalidArgumentException) {
return $domain;
}
// Fall back to domain, in case it's just a file reference.
return $allDomainsInPackage[$domain] ?? $domain;
}
protected function getRelativePath(string $file, string $packageKey, PackageInterface $package): string
{
if (str_starts_with($file, $package->getPackagePath())) {
$file = substr($file, strlen($package->getPackagePath()));
$file = 'EXT:' . $packageKey . '/' . $file;
} elseif (str_starts_with($file, Environment::getProjectPath())) {
$file = substr($file, strlen(Environment::getProjectPath()) + 1);
}
return $file;
}
}
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
/*
* 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;
/**
* Pure utility class for resolving translation domains from file paths.
*
* This class contains stateless methods for:
* - Converting file paths to translation domains
* - Validating domain names
* - Extracting locale prefixes from file names
* - Stripping file extensions
*
* It has no dependencies on other localization classes, making it safe to
* inject into both LabelFileResolver and TranslationDomainMapper without
* creating circular dependencies.
*
* @internal not part of TYPO3's public API.
*/
readonly class TranslationDomainResolver
{
/**
* Maps a label resource file reference to a translation domain.
*
* Examples:
* - "EXT:core/Resources/Private/Language/locallang.xlf" -> "core.messages"
* - "EXT:backend/Resources/Private/Language/locallang_toolbar.xlf" -> "backend.toolbar"
* - "EXT:felogin/Configuration/Sets/Felogin/labels.xlf" -> "felogin.sets.felogin"
*/
public function mapFileNameToDomain(string $fileName): string
{
// Extract extension key from EXT: path
try {
$extensionKey = $this->extractExtensionKey($fileName);
} catch (\InvalidArgumentException) {
return $fileName;
}
// Transform file path to resource name
$resourceName = $this->transformFilePathToResource($fileName, $extensionKey);
return $extensionKey . '.' . $resourceName;
}
/**
* A valid domain consists of lowercase letters, numbers, underscores only
* and at least one dot in between.
*/
public function isValidDomainName(string $domain): bool
{
return (bool)preg_match('/^[a-z0-9_]+(\.[a-z0-9_]+)+$/', $domain);
}
/**
* Strips the file extension from a file reference.
*
* Examples:
* - "EXT:core/Resources/Private/Language/locallang.xlf" -> "EXT:core/Resources/Private/Language/locallang"
* - "locallang.xlf" -> "locallang"
*/
public function getFileReferenceWithoutExtension(string $fileReference): string
{
return preg_replace('/\\.[a-z0-9]+$/i', '', $fileReference) ?? $fileReference;
}
/**
* Extracts the locale prefix from a language file name.
*
* If a file is called "de_AT.locallang.xlf", this method returns "de_AT".
* If there is no locale prefix, NULL is returned.
*
* However, for files like "db.xlf", "db" should not be detected as locale.
*
* Examples:
* - "de.locallang.xlf" -> "de"
* - "de_AT.locallang.xlf" -> "de_AT"
* - "fr-CA.messages.xlf" -> "fr-CA"
* - "locallang.xlf" -> null
* - "db.xlf" -> null
*/
public function getLocaleFromLanguageFile(string $fileName): ?string
{
if (substr_count($fileName, '.') > 1 && preg_match('/^[a-z]{2}([_-][A-z]{2,3})?\./', $fileName)) {
return substr($fileName, 0, strpos($fileName, '.'));
}
return null;
}
/**
* Transforms a file path to a resource name (for domain).
*
* Examples:
* - "Resources/Private/Language/locallang.xlf" -> "messages"
* - "Resources/Private/Language/locallang_toolbar.xlf" -> "toolbar"
* - "Resources/Private/Language/Form/locallang_tabs.xlf" -> "form.tabs"
* - "Configuration/Sets/Felogin/labels.xlf" -> "sets.felogin"
* - "EXT:example/ContentBlocks/ContentElements/simple-relation/language/labels.xlf" -> "content_blocks.content_elements.simple_relation.language.labels"
*/
protected function transformFilePathToResource(string $filePath, string $extensionKey): string
{
$isExtensionPath = false;
// Remove EXT:extensionKey/ prefix
$prefix = 'EXT:' . $extensionKey . '/';
if (str_starts_with($filePath, $prefix)) {
$isExtensionPath = true;
$filePath = substr($filePath, strlen($prefix));
}
// Remove locale prefix if present (e.g., "de.locallang.xlf" -> "locallang.xlf")
$fileName = basename($filePath);
$locale = $this->getLocaleFromLanguageFile($fileName);
if ($locale !== null) {
$fileName = substr($fileName, strlen($locale) + 1);
$filePath = dirname($filePath) . '/' . $fileName;
}
// Handle site sets: Configuration/Sets/{Name}/labels.xlf -> sets.{name}
if (preg_match('#Configuration/Sets/([^/]+)/labels\.xlf$#', $filePath, $matches)) {
$setName = $this->upperCamelCaseToSnakeCase($matches[1]);
return 'sets.' . $setName;
}
// Clean standard language files: Resources/Private/Language/...
if (str_starts_with($filePath, 'Resources/Private/Language/')) {
$isExtensionPath = true;
$filePath = substr($filePath, strlen('Resources/Private/Language/'));
}
// Handle extension paths
if ($isExtensionPath) {
// Split into directory and filename
$pathParts = explode('/', $filePath);
$fileName = array_pop($pathParts);
// Convert directories from UpperCamelCase to snake_case
$resourceParts = array_map($this->upperCamelCaseToSnakeCase(...), $pathParts);
// Transform filename
$fileNameWithoutExtension = $this->getFileReferenceWithoutExtension($fileName);
if ($fileNameWithoutExtension === 'locallang') {
$resourceParts[] = 'messages';
} elseif (str_starts_with($fileNameWithoutExtension, 'locallang_')) {
// Remove locallang_ prefix (keep snake_case as-is)
$suffix = substr($fileNameWithoutExtension, strlen('locallang_'));
$suffix = $this->upperCamelCaseToSnakeCase($suffix);
$resourceParts[] = $suffix;
} else {
// Use filename as-is, converted to snake_case
$resourceParts[] = $this->upperCamelCaseToSnakeCase($fileNameWithoutExtension);
}
return implode('.', $resourceParts);
}
// Fallback: use filename without extension
$fileNameWithoutExtension = $this->getFileReferenceWithoutExtension(basename($filePath));
return $this->upperCamelCaseToSnakeCase($fileNameWithoutExtension);
}
/**
* Extracts the extension key from an EXT: file reference.
*/
protected function extractExtensionKey(string $filePath): string
{
if (!str_starts_with($filePath, 'EXT:')) {
throw new \InvalidArgumentException('File path must start with "EXT:"', 1729000001);
}
$withoutPrefix = substr($filePath, 4);
$slashPos = strpos($withoutPrefix, '/');
if ($slashPos === false) {
throw new \InvalidArgumentException('Invalid EXT: reference format', 1729000002);
}
return substr($withoutPrefix, 0, $slashPos);
}
/**
* Converts UpperCamelCase to snake_case.
*
* Examples: "SudoMode" -> "sudo_mode", "Form" -> "form"
*/
protected function upperCamelCaseToSnakeCase(string $input): string
{
// Insert underscores before uppercase letters (except at start) and convert to lowercase
$result = preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $input);
$result = strtolower($result ?? $input);
return str_replace('-', '_', $result);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/*
* 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;
/**
* Interface for translation services.
*
* This interface provides a clean abstraction for translating labels in TYPO3.
* It uses ICU MessageFormat style where pluralization and other complex formatting
* logic is embedded in the message string itself, not in the API signature.
*
* Example usage:
*
* // Simple translation
* $translator->translate('button.save', 'backend.messages');
*
* // Translation with arguments (sprintf-style placeholders)
* $translator->translate('record.count', 'backend.messages', [5]);
*
* // ICU MessageFormat style for plurals (message: "{count, plural, one {# item} other {# items}}")
* $translator->translate('items.count', 'backend.messages+intl-icu', ['count' => 5]);
*
* @see https://unicode-org.github.io/icu/userguide/format_parse/messages/ ICU MessageFormat
*/
interface TranslatorInterface
{
/**
* Translate a label by its identifier and domain.
*
* @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;
/**
* Translate a label by its full reference string.
*
* Resolves TYPO3 label reference strings in the formats:
*
* 'core.messages:labels.depth_0'
* 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'
* 'EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'
*
* The LLL: prefix is optional and stripped before resolution.
*
* Example usage:
*
* // Simple reference
* $translator->label('myext.messages:button.save');
*
* // Simple reference (discouraged with file reference)
* $translator->label('LLL:EXT:my_ext/Resources/Private/Language/locallang.xlf:button.save');
*
* // With arguments
* $translator->label('core.messages:record.count', [5]);
*
* // With default value
* $translator->label('core.messages:missing.key', [], 'Fallback text');
*
* @param string $reference The label reference string (with or without LLL: prefix)
* @param array $arguments Optional arguments for placeholder replacement
* @param string|null $default Optional default value returned when the label is not found
* @param Locale|string|null $locale Optional locale override. If null, uses the service's configured locale.
* @return string|\Stringable|null The translated string, the default value, or null if the label was not found
*/
public function label(string $reference, array $arguments = [], ?string $default = null, Locale|string|null $locale = null): string|\Stringable|null;
}