diff --git a/Classes/Client.php b/Classes/Client.php index 9f2a75e5..55f6931b 100644 --- a/Classes/Client.php +++ b/Classes/Client.php @@ -7,6 +7,7 @@ use Psr\Http\Message\ResponseInterface; use TYPO3\CMS\Core\Http\RequestFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; +use WebVision\WvDeepltranslate\Domain\Dto\TranslateOptions; use WebVision\WvDeepltranslate\Exception\ClientNotValidUrlException; final class Client @@ -31,15 +32,15 @@ public function __construct() $this->requestFactory = GeneralUtility::makeInstance(RequestFactory::class); } - public function translate(string $content, string $sourceLang, string $targetLang, string $glossary = ''): ResponseInterface - { + public function translate( + string $content, + TranslateOptions $options, + string $glossary = '' + ): ResponseInterface { $baseUrl = $this->buildBaseUrl('translate'); $postFields = [ 'text' => $content, - 'source_lang' => $sourceLang, - 'target_lang' => $targetLang, - 'tag_handling' => 'xml', ]; if (!empty($glossary)) { @@ -48,6 +49,8 @@ public function translate(string $content, string $sourceLang, string $targetLan $postFields['formality'] = $this->configuration->getFormality(); + $postFields = array_merge($postFields, $options->toArray()); + return $this->requestFactory->request($baseUrl, 'POST', $this->mergeRequiredRequestOptions([ 'form_params' => $postFields, ])); diff --git a/Classes/Domain/Dto/TranslateOptions.php b/Classes/Domain/Dto/TranslateOptions.php new file mode 100644 index 00000000..bf5aeb15 --- /dev/null +++ b/Classes/Domain/Dto/TranslateOptions.php @@ -0,0 +1,124 @@ +splitSentences; + } + + public function setSplitSentences(string $splitSentences): void + { + $this->splitSentences = $splitSentences; + } + + public function isOutlineDetection(): bool + { + return $this->outlineDetection; + } + + public function setOutlineDetection(bool $outlineDetection): void + { + $this->outlineDetection = $outlineDetection; + } + + public function getSplittingTags(): array + { + return $this->splittingTags; + } + + public function setSplittingTags(array $splittingTags): void + { + $this->splittingTags = $splittingTags; + } + + public function getNonSplittingTags(): array + { + return $this->nonSplittingTags; + } + + public function setNonSplittingTags(array $nonSplittingTags): void + { + $this->nonSplittingTags = $nonSplittingTags; + } + + public function getIgnoreTags(): array + { + return $this->ignoreTags; + } + + public function setIgnoreTags(array $ignoreTags): void + { + $this->ignoreTags = $ignoreTags; + } + + public function getTagHandling(): string + { + return $this->tagHandling; + } + + public function setTagHandling(string $tagHandling): void + { + $this->tagHandling = $tagHandling; + } + + public function getTargetLanguage(): string + { + return $this->targetLanguage; + } + + public function setTargetLanguage(string $targetLanguage): void + { + $this->targetLanguage = $targetLanguage; + } + + public function getSourceLanguage(): string + { + return $this->sourceLanguage; + } + + public function setSourceLanguage(string $sourceLanguage): void + { + $this->sourceLanguage = $sourceLanguage; + } + + public function toArray(): array + { + $param = []; + $param['tag_handling'] = $this->tagHandling; + + if (!empty($this->splittingTags)) { + $param['outlineDetection'] = $this->outlineDetection; + $param['split_sentences'] = $this->splitSentences; + $param['splitting_tags'] = implode(',', $this->splittingTags); + } + + $param['source_lang'] = $this->sourceLanguage; + $param['target_lang'] = $this->targetLanguage; + + return $param; + } +} diff --git a/Classes/Hooks/TranslateHook.php b/Classes/Hooks/TranslateHook.php index c0a33ddc..ce89fd3d 100644 --- a/Classes/Hooks/TranslateHook.php +++ b/Classes/Hooks/TranslateHook.php @@ -8,11 +8,11 @@ use TYPO3\CMS\Core\Messaging\FlashMessage; use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Extbase\Object\ObjectManager; +use WebVision\WvDeepltranslate\Domain\Dto\TranslateOptions; use WebVision\WvDeepltranslate\Domain\Repository\PageRepository; -use WebVision\WvDeepltranslate\Domain\Repository\SettingsRepository; use WebVision\WvDeepltranslate\Exception\LanguageIsoCodeNotFoundException; use WebVision\WvDeepltranslate\Exception\LanguageRecordNotFoundException; +use WebVision\WvDeepltranslate\Resolver\RichtextAllowTagsResolver; use WebVision\WvDeepltranslate\Service\DeeplService; use WebVision\WvDeepltranslate\Service\GoogleTranslateService; use WebVision\WvDeepltranslate\Service\LanguageService; @@ -24,31 +24,31 @@ class TranslateHook protected GoogleTranslateService $googleService; - protected SettingsRepository $deeplSettingsRepository; - protected PageRepository $pageRepository; private LanguageService $languageService; public function __construct( - ?SettingsRepository $settingsRepository = null, ?PageRepository $pageRepository = null, ?DeeplService $deeplService = null, - ?GoogleTranslateService $googleService = null + ?GoogleTranslateService $googleService = null, + ?LanguageService $languageService = null ) { - $objectManager = GeneralUtility::makeInstance(ObjectManager::class); - $this->deeplSettingsRepository = $settingsRepository ?? $objectManager->get(SettingsRepository::class); - $this->deeplService = $deeplService ?? $objectManager->get(DeeplService::class); - $this->googleService = $googleService ?? $objectManager->get(GoogleTranslateService::class); + $this->deeplService = $deeplService ?? GeneralUtility::makeInstance(DeeplService::class); $this->pageRepository = $pageRepository ?? GeneralUtility::makeInstance(PageRepository::class); - $this->languageService = GeneralUtility::makeInstance(LanguageService::class); + $this->languageService = $languageService ?? GeneralUtility::makeInstance(LanguageService::class); + $this->googleService = $googleService ?? GeneralUtility::makeInstance(GoogleTranslateService::class); } /** * @param array{uid: int} $languageRecord */ - public function processTranslateTo_copyAction(string &$content, array $languageRecord, DataHandler $dataHandler): string - { + public function processTranslateTo_copyAction( + string &$content, + array $languageRecord, + DataHandler $dataHandler, + string $columnName + ): string { $tableName = ''; $currentRecordId = ''; @@ -62,12 +62,7 @@ public function processTranslateTo_copyAction(string &$content, array $languageR break; } - if (!isset($cmdmap['localization']['custom']['srcLanguageId'])) { - $cmdmap['localization']['custom']['srcLanguageId'] = ''; - } - $customMode = $cmdmap['localization']['custom']['mode'] ?? null; - [$sourceLanguage,] = explode('-', (string)$cmdmap['localization']['custom']['srcLanguageId']); //translation mode set to deepl or google translate if ($customMode === null) { @@ -79,21 +74,28 @@ public function processTranslateTo_copyAction(string &$content, array $languageR $translatedContent = ''; $targetLanguageRecord = []; + $translateOptions = GeneralUtility::makeInstance(TranslateOptions::class); + $richtextAllowTagsResolver = GeneralUtility::makeInstance(RichtextAllowTagsResolver::class); + $translateOptions->setSplittingTags( + $richtextAllowTagsResolver->resolve($tableName, $currentRecordId, $columnName) + ); + try { $sourceLanguageRecord = $this->languageService->getSourceLanguage( $siteInformation['site'] ); + $translateOptions->setSourceLanguage($sourceLanguageRecord['language_isocode']); $targetLanguageRecord = $this->languageService->getTargetLanguage( $siteInformation['site'], (int)$languageRecord['uid'] ); + $translateOptions->setTargetLanguage($targetLanguageRecord['language_isocode']); $translatedContent = $this->translateContent( $content, - $targetLanguageRecord, + $translateOptions, $customMode, - $sourceLanguageRecord ); } catch (LanguageIsoCodeNotFoundException|LanguageRecordNotFoundException $e) { $flashMessage = GeneralUtility::makeInstance( @@ -123,22 +125,17 @@ public function processTranslateTo_copyAction(string &$content, array $languageR /** * These logics were outsourced to test them and later to resolve them in a service - * - * @param array{uid: int, language_isocode: string} $targetLanguageRecord - * @param array{uid: int, language_isocode: string} $sourceLanguageRecord */ public function translateContent( string $content, - array $targetLanguageRecord, - string $customMode, - array $sourceLanguageRecord + TranslateOptions $translateOptions, + string $customMode ): string { // mode deepl if ($customMode == 'deepl') { $response = $this->deeplService->translateRequest( $content, - $targetLanguageRecord['language_isocode'], - $sourceLanguageRecord['language_isocode'] + $translateOptions ); if (!empty($response) && isset($response['translations'])) { @@ -152,8 +149,8 @@ public function translateContent( } //mode google elseif ($customMode == 'google') { $response = $this->googleService->translate( - $sourceLanguageRecord['language_isocode'], - $targetLanguageRecord['language_isocode'], + $translateOptions->getSourceLanguage(), + $translateOptions->getTargetLanguage(), $content ); diff --git a/Classes/Resolver/RichtextAllowTagsResolver.php b/Classes/Resolver/RichtextAllowTagsResolver.php new file mode 100644 index 00000000..4f7ec112 --- /dev/null +++ b/Classes/Resolver/RichtextAllowTagsResolver.php @@ -0,0 +1,57 @@ +richtext = $richtext ?? GeneralUtility::makeInstance(Richtext::class); + } + + public function resolve(string $tableName, int $recordId, string $fieldName): array + { + if (!isset($GLOBALS['TCA'][$tableName]['columns'])) { + throw new \RuntimeException('TCA Columns ist not defined', 1689950520561); + } + + $field = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]; + + if (!isset($field['config']['type'])) { + return []; + } + + if ($field['config']['type'] !== 'text') { + return []; + } + + if (isset($field['config']['enableRichtext'])) { + if ($field['config']['enableRichtext'] === false) { + return []; + } + } elseif (isset($GLOBALS['TCA'][$tableName]['types']['columnsOverrides'][$fieldName]['config']['enableRichtext'])) { + if ($GLOBALS['TCA'][$tableName]['types']['columnsOverrides'][$fieldName]['config']['enableRichtext'] === false) { + return []; + } + } + + $record = BackendUtility::getRecord($tableName, $recordId); + + $allowTags = []; + $rteConfig = $this->richtext->getConfiguration($tableName, $fieldName, $record['pid'], $record['CType'], $field['config']); + if (isset($rteConfig['processing']['allowTags'])) { + $allowTags = array_unique(array_merge($allowTags, $rteConfig['processing']['allowTags']), SORT_REGULAR); + } + + return $allowTags; + } +} diff --git a/Classes/Service/DeeplService.php b/Classes/Service/DeeplService.php index 4afa7882..22cd7368 100644 --- a/Classes/Service/DeeplService.php +++ b/Classes/Service/DeeplService.php @@ -13,6 +13,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Object\ObjectManager; use WebVision\WvDeepltranslate\Client; +use WebVision\WvDeepltranslate\Domain\Dto\TranslateOptions; use WebVision\WvDeepltranslate\Domain\Repository\GlossaryRepository; use WebVision\WvDeepltranslate\Domain\Repository\SettingsRepository; use WebVision\WvDeepltranslate\Utility\DeeplBackendUtility; @@ -63,26 +64,27 @@ public function __construct( * Deepl Api Call for retrieving translation. * @return array */ - public function translateRequest(string $content, string $targetLanguage, string $sourceLanguage): array + public function translateRequest(string $content, TranslateOptions $translateOptions): array { // If the source language is set to Autodetect, no glossary can be detected. - if ($sourceLanguage === 'auto') { - $sourceLanguage = ''; + if ($translateOptions->getSourceLanguage() === 'auto') { + $translateOptions->setSourceLanguage(''); $glossary['glossary_id'] = ''; } else { // TODO make glossary findable by current site $glossary = $this->glossaryRepository->getGlossaryBySourceAndTarget( - $sourceLanguage, - $targetLanguage, + $translateOptions->getSourceLanguage(), + $translateOptions->getSourceLanguage(), DeeplBackendUtility::detectCurrentPage() ); } try { - if(!isset($glossary['glossary_id'])) { + if (!isset($glossary['glossary_id'])) { $glossary['glossary_id'] = ''; } - $response = $this->client->translate($content, $sourceLanguage, $targetLanguage, $glossary['glossary_id']); + + $response = $this->client->translate($content, $translateOptions, $glossary['glossary_id']); } catch (ClientException $e) { $flashMessage = GeneralUtility::makeInstance( FlashMessage::class, diff --git a/Classes/Utility/DeeplBackendUtility.php b/Classes/Utility/DeeplBackendUtility.php index d311800f..1ee37185 100644 --- a/Classes/Utility/DeeplBackendUtility.php +++ b/Classes/Utility/DeeplBackendUtility.php @@ -121,8 +121,8 @@ public static function loadConfiguration(): void self::$apiKey = $extensionConfiguration['apiKey']; self::$deeplFormality = $extensionConfiguration['deeplFormality']; self::$apiUrl = $extensionConfiguration['apiUrl']; - self::$googleApiUrl = $extensionConfiguration['googleapiUrl']; - self::$googleApiKey = $extensionConfiguration['googleapiKey']; + self::$googleApiUrl = $extensionConfiguration['googleapiUrl'] ?? ''; + self::$googleApiKey = $extensionConfiguration['googleapiKey'] ?? ''; self::$configurationLoaded = true; } diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index ead1c142..151048e1 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -30,6 +30,10 @@ services: arguments: $cache: '@cache.wvdeepltranslate' + WebVision\WvDeepltranslate\Resolver\RichtextAllowTagsResolver: + arguments: + $richtext: '@TYPO3\CMS\Core\Configuration\Richtext' + cache.wvdeepltranslate: class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface factory: [ '@TYPO3\CMS\Core\Cache\CacheManager', 'getCache' ] diff --git a/Tests/Functional/ClientTest.php b/Tests/Functional/ClientTest.php index 23809069..f885a702 100644 --- a/Tests/Functional/ClientTest.php +++ b/Tests/Functional/ClientTest.php @@ -11,6 +11,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use WebVision\WvDeepltranslate\Client; use WebVision\WvDeepltranslate\Configuration; +use WebVision\WvDeepltranslate\Domain\Dto\TranslateOptions; /** * @covers \WebVision\WvDeepltranslate\Client @@ -49,12 +50,15 @@ public function checkResponseFromTranslateContent(): void if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { $translateContent = 'proton beam'; } + + $translateOptions = new TranslateOptions(); + $translateOptions->setSourceLanguage('EN'); + $translateOptions->setTargetLanguage('DE'); + $client = new Client(); $response = $client->translate( $translateContent, - 'EN', - 'DE', - '' + $translateOptions, ); static::assertSame(200, $response->getStatusCode()); @@ -68,17 +72,20 @@ public function checkResponseFromTranslateContent(): void public function checkJsonTranslateContentIsValid(): void { $translateContent = 'I would like to be translated!'; - $expectedTranslation = 'Ich möchte gern übersetzt werden!'; + $expectedTranslation = 'Ich möchte gerne übersetzt werden!'; if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { $translateContent = 'proton beam'; $expectedTranslation = 'Protonenstrahl'; } + + $translateOptions = new TranslateOptions(); + $translateOptions->setSourceLanguage('EN'); + $translateOptions->setTargetLanguage('DE'); + $client = new Client(); $response = $client->translate( $translateContent, - 'EN', - 'DE', - '' + $translateOptions, ); $content = $response->getBody()->getContents(); diff --git a/Tests/Functional/Hooks/TranslateHookTest.php b/Tests/Functional/Hooks/TranslateHookTest.php index d69bbaaa..28832422 100644 --- a/Tests/Functional/Hooks/TranslateHookTest.php +++ b/Tests/Functional/Hooks/TranslateHookTest.php @@ -8,6 +8,7 @@ use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\DataHandling\DataHandler; use TYPO3\CMS\Core\Utility\GeneralUtility; +use WebVision\WvDeepltranslate\Domain\Dto\TranslateOptions; use WebVision\WvDeepltranslate\Hooks\TranslateHook; use WebVision\WvDeepltranslate\Service\LanguageService; @@ -61,14 +62,15 @@ public function contentTranslateWithDeepl(): void $languageService = GeneralUtility::makeInstance(LanguageService::class); $siteConfig = $languageService->getCurrentSite('pages', 1); $sourceLanguageRecord = $languageService->getSourceLanguage($siteConfig['site']); + + $translateOptions = new TranslateOptions(); + $translateOptions->setSourceLanguage($sourceLanguageRecord['language_isocode']); + $translateOptions->setTargetLanguage('DE'); + $content = $translateHook->translateContent( $translateContent, - [ - 'uid' => 2, - 'language_isocode' => 'DE', - ], + $translateOptions, 'deepl', - $sourceLanguageRecord ); static::assertSame($expectedTranslation, $content); @@ -86,15 +88,15 @@ public function contentNotTranslateWithDeeplWhenLanguageNotSupported(): void $languageService = GeneralUtility::makeInstance(LanguageService::class); $siteConfig = $languageService->getCurrentSite('pages', 1); $sourceLanguageRecord = $languageService->getSourceLanguage($siteConfig['site']); + + $translateOptions = new TranslateOptions(); + $translateOptions->setSourceLanguage($sourceLanguageRecord['language_isocode']); + $translateOptions->setTargetLanguage('BS'); + $content = $translateHook->translateContent( 'Hello I would like to be translated', - [ - 'uid' => 3, // This ist the LanguageID its was Configure in SiteConfig - 'title' => 'not supported language', - 'language_isocode' => 'BS', - ], + $translateOptions, 'deepl', - $sourceLanguageRecord ); static::assertSame('Hello I would like to be translated', $content); diff --git a/Tests/Functional/Resolver/Fixtures/Pages.xml b/Tests/Functional/Resolver/Fixtures/Pages.xml new file mode 100644 index 00000000..259c542e --- /dev/null +++ b/Tests/Functional/Resolver/Fixtures/Pages.xml @@ -0,0 +1,24 @@ + + + + 1 + 0 + DeepL-Functional-Tests + 1 + 1 + + + 3 + 0 + DeepL-Functional-Tests BS + 1 + 1 + + + 1 + 1 +
DeepL-Functional-Test Element
+ text + +
+
diff --git a/Tests/Functional/Resolver/RichtextAllowTagsResolverTest.php b/Tests/Functional/Resolver/RichtextAllowTagsResolverTest.php new file mode 100644 index 00000000..4aa6eaf3 --- /dev/null +++ b/Tests/Functional/Resolver/RichtextAllowTagsResolverTest.php @@ -0,0 +1,50 @@ +importDataSet(__DIR__ . '/Fixtures/Pages.xml'); + } + + /** + * @test + */ + public function findRteConfigurationAllowTagsByRecords(): void + { + $richtextAllowTagsResolver = GeneralUtility::makeInstance(RichtextAllowTagsResolver::class); + $allowTags = $richtextAllowTagsResolver->resolve('tt_content', 1, 'bodytext'); + + static::assertTrue((bool)array_search('em', $allowTags)); + + $yamlLoader = GeneralUtility::makeInstance(YamlFileLoader::class); + $config = $yamlLoader->load('EXT:rte_ckeditor/Configuration/RTE/Processing.yaml'); + + static::assertSame($config['processing']['allowTags'], $allowTags); + } +} diff --git a/Tests/Functional/Services/DeeplServiceTest.php b/Tests/Functional/Services/DeeplServiceTest.php index 338e4787..222c3683 100644 --- a/Tests/Functional/Services/DeeplServiceTest.php +++ b/Tests/Functional/Services/DeeplServiceTest.php @@ -6,6 +6,7 @@ use Nimut\TestingFramework\TestCase\FunctionalTestCase; use TYPO3\CMS\Core\Utility\GeneralUtility; +use WebVision\WvDeepltranslate\Domain\Dto\TranslateOptions; use WebVision\WvDeepltranslate\Service\DeeplService; /** @@ -43,34 +44,65 @@ public function translateContentFromDeToEn(): void } $deeplService = GeneralUtility::makeInstance(DeeplService::class); + $translateOptions = new TranslateOptions(); + $translateOptions->setSourceLanguage('DE'); + $translateOptions->setTargetLanguage('EN'); + $responseObject = $deeplService->translateRequest( - 'Ich möchte gern übersetzt werden!', - 'EN', - 'DE', - '' + 'Ich möchte gerne übersetzt werden!', + $translateOptions ); static::assertSame('I would like to be translated!', $responseObject['translations'][0]['text']); } + /** + * @test + */ + public function translateContentAndRespectHtmlTags(): void + { + if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { + static::markTestSkipped(__METHOD__ . ' skipped, because DEEPL MOCKSERVER do not support the test with HTML Tags'); + } + + $deeplService = GeneralUtility::makeInstance(DeeplService::class); + + $translateOptions = new TranslateOptions(); + $translateOptions->setSourceLanguage('EN'); + $translateOptions->setTargetLanguage('DE'); + $translateOptions->setSplittingTags(['em', 'p', 'span']); + + $responseObject = $deeplService->translateRequest( + '

Important species in blueberry (include) the Western flower thrips (Frankliniella occidentalis) and Chilli thrips (Scirtothrips dorsalis).

', + $translateOptions + ); + + static::assertSame( + '

Wichtige Arten in der Heidelbeere (gehören) der Westliche Blütenthrips (Frankliniella occidentalis) und Chili-Thripse (Scirtothrips dorsalis).

', + $responseObject['translations'][0]['text'] + ); + } + /** * @test */ public function translateContentFromEnToDe(): void { $translateContent = 'I would like to be translated!'; - $expectedTranslation = 'Ich möchte gern übersetzt werden!'; + $expectedTranslation = 'Ich möchte gerne übersetzt werden!'; if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { $translateContent = 'proton beam'; $expectedTranslation = 'Protonenstrahl'; } $deeplService = GeneralUtility::makeInstance(DeeplService::class); + $translateOptions = new TranslateOptions(); + $translateOptions->setSourceLanguage('EN'); + $translateOptions->setTargetLanguage('DE'); + $responseObject = $deeplService->translateRequest( $translateContent, - 'DE', - 'EN', - '' + $translateOptions ); static::assertSame($expectedTranslation, $responseObject['translations'][0]['text']); @@ -82,18 +114,20 @@ public function translateContentFromEnToDe(): void public function translateContentWithAutoDetectSourceParam(): void { $translateContent = 'I would like to be translated!'; - $expectedTranslation = 'Ich möchte gern übersetzt werden!'; + $expectedTranslation = 'Ich möchte gerne übersetzt werden!'; if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { $translateContent = 'proton beam'; $expectedTranslation = 'Protonenstrahl'; } $deeplService = GeneralUtility::makeInstance(DeeplService::class); + $translateOptions = new TranslateOptions(); + $translateOptions->setSourceLanguage('auto'); + $translateOptions->setTargetLanguage('DE'); + $responseObject = $deeplService->translateRequest( $translateContent, - 'DE', - 'auto', - '' + $translateOptions ); static::assertSame($expectedTranslation, $responseObject['translations'][0]['text']); diff --git a/composer.json b/composer.json index eaf00720..6df3da66 100644 --- a/composer.json +++ b/composer.json @@ -91,6 +91,7 @@ "typo3/cms-frontend": "^9.5 || ^10.4 || ^11.5", "typo3/cms-info": "^9.5 || ^10.4 || ^11.5", "typo3/cms-lowlevel": "^9.5 || ^10.4 || ^11.5", + "typo3/cms-rte-ckeditor": "^9.5 || ^10.4 || ^11.5", "typo3/cms-tstemplate": "^9.5 || ^10.4 || ^11.5", "typo3/cms-workspaces": "^9.5 || ^10.4 || ^11.5", "typo3/coding-standards": "^0.5"