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
@@ -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;
}
}