242 lines
9.7 KiB
PHP
242 lines
9.7 KiB
PHP
<?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;
|
|
}
|
|
}
|