From b326de1db9e46adef55c166091a175e550807faf Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Fri, 29 Mar 2024 06:54:37 +0100 Subject: [PATCH] [FEATURE] Introduce site sets with setting definitions Site sets ship parts of site configuration as composable pieces. They are intended to deliver settings, TypoScript, TSConfig and reference enabled content blocks for the scope of a site. Extensions can provide multiple sets in order to ship presets for different sites or subsets (think of frameworks) where selected features are exposed as a subset (example: `typo3/seo-xml-sitemap`). A set is defined in an extensions subfolder in :file:`Configuration/Sets/`, for example :file:`EXT:my_extension/Configuration/Sets/MySet/config.yaml`. The folder name in :file:`Configuration/Sets/` is arbitrary, significant is the `name` defined in :file:`config.yaml`. The `name` uses a `vendor/name` scheme by convention, and *should* use the same vendor as the containing extension. It may differ if needed for compatibility reasons (e.g. when sets are moved to other extensions). TypoScript and PageTS providers will be added in subsequent changes (#103439, #103522). Technical notes: * Sets whose dependencies can not be fullfilled (are unavailable) will log an error in the TYPO3 error log and will be ignored. This is to ensure a broken set of one site does not break another site, if their dependencies can be resolved. * All settings interfaces are marked @internal for now, since they may change until v13 LTS. It is planned to remove the @internal annotation prior to the LTS release. Releases: main Resolves: #103437 Related: #103439 Related: #103522 Change-Id: I01cf60a837a69ea2216c9d8cd5ec9fbacacb5ece Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/82191 Tested-by: Benjamin Kott Reviewed-by: Benjamin Franzke Tested-by: Benni Mack Reviewed-by: Benni Mack Tested-by: Oliver Bartsch Tested-by: Benjamin Franzke Tested-by: core-ci Reviewed-by: Benjamin Kott Reviewed-by: Oliver Bartsch --- .../TCA/ItemsProcessorFunctions.php | 12 ++ .../SiteConfigurationController.php | 16 +- .../FormDataProvider/SiteDatabaseEditRow.php | 7 +- .../Configuration/SiteConfiguration/site.php | 12 +- .../locallang_siteconfiguration_tca.xlf | 3 + .../SiteDatabaseEditRowTest.php | 5 + .../Classes/Command/SiteSetsListCommand.php | 74 +++++++++ .../Configuration/SiteConfiguration.php | 32 +--- .../Package/AbstractServiceProvider.php | 37 +++++ .../Classes/Settings/SettingDefinition.php | 45 +++++ .../sysext/core/Classes/Settings/Settings.php | 51 ++++++ .../Classes/Settings/SettingsInterface.php | 32 ++++ .../Settings/SettingsTypeInterface.php | 31 ++++ .../Classes/Settings/SettingsTypeRegistry.php | 42 +++++ .../core/Classes/Settings/Type/BoolType.php | 77 +++++++++ .../core/Classes/Settings/Type/ColorType.php | 145 +++++++++++++++++ .../core/Classes/Settings/Type/IntType.php | 55 +++++++ .../core/Classes/Settings/Type/NumberType.php | 67 ++++++++ .../Classes/Settings/Type/StringListType.php | 63 +++++++ .../core/Classes/Settings/Type/StringType.php | 54 ++++++ .../core/Classes/Settings/Type/TextType.php | 23 +++ .../sysext/core/Classes/Site/Entity/Site.php | 25 +++ .../core/Classes/Site/Entity/SiteSettings.php | 17 +- .../Classes/Site/Set/InvalidSetException.php | 20 +++ .../core/Classes/Site/Set/SetCollector.php | 40 +++++ .../core/Classes/Site/Set/SetDefinition.php | 46 ++++++ .../core/Classes/Site/Set/SetRegistry.php | 154 ++++++++++++++++++ .../Site/Set/YamlSetDefinitionProvider.php | 110 +++++++++++++ .../core/Classes/Site/SiteSettingsFactory.php | 138 ++++++++++++++++ .../core/Classes/Site/TcaSiteSetCollector.php | 42 +++++ typo3/sysext/core/Configuration/Services.yaml | 12 +- .../13.1/Feature-103437-IntroduceSiteSets.rst | 119 ++++++++++++++ .../Sets/InvalidDependency/config.yaml | 5 + .../Configuration/Sets/Set1/config.yaml | 9 + .../Configuration/Sets/Set2/config.yaml | 5 + .../Configuration/Sets/Set3/config.yaml | 5 + .../Configuration/Sets/Set4/config.yaml | 2 + .../Configuration/Sets/Set5/config.yaml | 2 + .../Extensions/test_sets/composer.json | 14 ++ .../Extensions/test_sets/ext_emconf.php | 21 +++ .../Functional/Site/Set/SetRegistryTest.php | 86 ++++++++++ .../Configuration/SiteConfigurationTest.php | 10 ++ .../Tests/Unit/Settings/Type/BoolTypeTest.php | 97 +++++++++++ .../Unit/Settings/Type/ColorTypeTest.php | 100 ++++++++++++ .../Tests/Unit/Settings/Type/IntTypeTest.php | 97 +++++++++++ .../Unit/Settings/Type/NumberTypeTest.php | 99 +++++++++++ .../Unit/Settings/Type/StringListTypeTest.php | 120 ++++++++++++++ .../Unit/Settings/Type/StringTypeTest.php | 106 ++++++++++++ 48 files changed, 2342 insertions(+), 42 deletions(-) create mode 100644 typo3/sysext/core/Classes/Command/SiteSetsListCommand.php create mode 100644 typo3/sysext/core/Classes/Settings/SettingDefinition.php create mode 100644 typo3/sysext/core/Classes/Settings/Settings.php create mode 100644 typo3/sysext/core/Classes/Settings/SettingsInterface.php create mode 100644 typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php create mode 100644 typo3/sysext/core/Classes/Settings/SettingsTypeRegistry.php create mode 100644 typo3/sysext/core/Classes/Settings/Type/BoolType.php create mode 100644 typo3/sysext/core/Classes/Settings/Type/ColorType.php create mode 100644 typo3/sysext/core/Classes/Settings/Type/IntType.php create mode 100644 typo3/sysext/core/Classes/Settings/Type/NumberType.php create mode 100644 typo3/sysext/core/Classes/Settings/Type/StringListType.php create mode 100644 typo3/sysext/core/Classes/Settings/Type/StringType.php create mode 100644 typo3/sysext/core/Classes/Settings/Type/TextType.php create mode 100644 typo3/sysext/core/Classes/Site/Set/InvalidSetException.php create mode 100644 typo3/sysext/core/Classes/Site/Set/SetCollector.php create mode 100644 typo3/sysext/core/Classes/Site/Set/SetDefinition.php create mode 100644 typo3/sysext/core/Classes/Site/Set/SetRegistry.php create mode 100644 typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php create mode 100644 typo3/sysext/core/Classes/Site/SiteSettingsFactory.php create mode 100644 typo3/sysext/core/Classes/Site/TcaSiteSetCollector.php create mode 100644 typo3/sysext/core/Documentation/Changelog/13.1/Feature-103437-IntroduceSiteSets.rst create mode 100644 typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/InvalidDependency/config.yaml create mode 100644 typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set1/config.yaml create mode 100644 typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set2/config.yaml create mode 100644 typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set3/config.yaml create mode 100644 typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set4/config.yaml create mode 100644 typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set5/config.yaml create mode 100644 typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/composer.json create mode 100644 typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/ext_emconf.php create mode 100644 typo3/sysext/core/Tests/Functional/Site/Set/SetRegistryTest.php create mode 100644 typo3/sysext/core/Tests/Unit/Settings/Type/BoolTypeTest.php create mode 100644 typo3/sysext/core/Tests/Unit/Settings/Type/ColorTypeTest.php create mode 100644 typo3/sysext/core/Tests/Unit/Settings/Type/IntTypeTest.php create mode 100644 typo3/sysext/core/Tests/Unit/Settings/Type/NumberTypeTest.php create mode 100644 typo3/sysext/core/Tests/Unit/Settings/Type/StringListTypeTest.php create mode 100644 typo3/sysext/core/Tests/Unit/Settings/Type/StringTypeTest.php diff --git a/typo3/sysext/backend/Classes/Configuration/TCA/ItemsProcessorFunctions.php b/typo3/sysext/backend/Classes/Configuration/TCA/ItemsProcessorFunctions.php index 8852dfdb24e4..ab1d1a8230ca 100644 --- a/typo3/sysext/backend/Classes/Configuration/TCA/ItemsProcessorFunctions.php +++ b/typo3/sysext/backend/Classes/Configuration/TCA/ItemsProcessorFunctions.php @@ -17,6 +17,7 @@ namespace TYPO3\CMS\Backend\Configuration\TCA; +use TYPO3\CMS\Core\Site\Set\SetCollector; use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -120,4 +121,15 @@ public function populateFallbackLanguages(array &$fieldDefinition): void $fieldDefinition['items'] = array_values($fieldDefinition['items']); } + + public function populateSiteSets(array &$fieldConfiguration): void + { + $sets = GeneralUtility::makeInstance(SetCollector::class)->getSetDefinitions(); + foreach ($sets as $set) { + $fieldConfiguration['items'][] = [ + 'label' => $set->label, + 'value' => $set->name, + ]; + } + } } diff --git a/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php b/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php index 83b40ffed1de..c9f85a6ea4e9 100644 --- a/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php +++ b/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php @@ -260,6 +260,7 @@ public function saveAction(ServerRequestInterface $request): ResponseInterface $newSysSiteData['rootPageId'] = $pageId; foreach ($sysSiteRow as $fieldName => $fieldValue) { $type = $siteTca['site']['columns'][$fieldName]['config']['type']; + $renderType = $siteTca['site']['columns'][$fieldName]['config']['renderType'] ?? ''; switch ($type) { case 'input': case 'number': @@ -380,13 +381,18 @@ public function saveAction(ServerRequestInterface $request): ResponseInterface break; case 'select': - if (MathUtility::canBeInterpretedAsInteger($fieldValue)) { - $fieldValue = (int)$fieldValue; - } elseif (is_array($fieldValue)) { - $fieldValue = implode(',', $fieldValue); + if ($renderType === 'selectMultipleSideBySide') { + $fieldValues = is_array($fieldValue) ? $fieldValue : GeneralUtility::trimExplode(',', $fieldValue, true); + $newSysSiteData[$fieldName] = $fieldValues; + } else { + if (MathUtility::canBeInterpretedAsInteger($fieldValue)) { + $fieldValue = (int)$fieldValue; + } elseif (is_array($fieldValue)) { + $fieldValue = implode(',', $fieldValue); + } + $newSysSiteData[$fieldName] = $fieldValue; } - $newSysSiteData[$fieldName] = $fieldValue; break; case 'check': diff --git a/typo3/sysext/backend/Classes/Form/FormDataProvider/SiteDatabaseEditRow.php b/typo3/sysext/backend/Classes/Form/FormDataProvider/SiteDatabaseEditRow.php index 7d65dc9cf6df..61b51fbde2e9 100644 --- a/typo3/sysext/backend/Classes/Form/FormDataProvider/SiteDatabaseEditRow.php +++ b/typo3/sysext/backend/Classes/Form/FormDataProvider/SiteDatabaseEditRow.php @@ -90,6 +90,11 @@ protected function getRawConfigurationForSiteWithRootPageId(SiteFinder $siteFind { $site = $siteFinder->getSiteByRootPageId($rootPageId); // load config as it is stored on disk (without replacements) - return $this->siteConfiguration->load($site->getIdentifier()); + $configuration = $this->siteConfiguration->load($site->getIdentifier()); + // @todo parse pseudo TCA and react on type==select and renderType==selectMultipleSideBySide + if (is_array($configuration['dependencies'] ?? null)) { + $configuration['dependencies'] = implode(',', $configuration['dependencies']); + } + return $configuration; } } diff --git a/typo3/sysext/backend/Configuration/SiteConfiguration/site.php b/typo3/sysext/backend/Configuration/SiteConfiguration/site.php index 6dec17d0505f..59bdbbb5ff9c 100644 --- a/typo3/sysext/backend/Configuration/SiteConfiguration/site.php +++ b/typo3/sysext/backend/Configuration/SiteConfiguration/site.php @@ -64,6 +64,16 @@ 'default' => '', ], ], + 'dependencies' => [ + 'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.dependencies', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'itemsProcFunc' => \TYPO3\CMS\Core\Site\TcaSiteSetCollector::class . '->populateSiteSets', + 'size' => 5, + 'maxitems' => 9999, + ], + ], 'languages' => [ 'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.languages', 'description' => 'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.languages.description', @@ -100,7 +110,7 @@ ], 'types' => [ '0' => [ - 'showitem' => '--palette--;;default,--palette--;;base, + 'showitem' => '--palette--;;default,--palette--;;base,dependencies, --div--;LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.tab.languages, languages, --div--;LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.tab.errorHandling, errorHandling, --div--;LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.tab.routes, routes', diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf index 9d74680f5266..f64fb39d9234 100644 --- a/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf +++ b/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf @@ -21,6 +21,9 @@ Variants for the Entry Point + + Sets for this Site + Available Languages for this Site diff --git a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/SiteDatabaseEditRowTest.php b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/SiteDatabaseEditRowTest.php index 6eb6ea55d9fb..5519569fc9a3 100644 --- a/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/SiteDatabaseEditRowTest.php +++ b/typo3/sysext/backend/Tests/Unit/Form/FormDataProvider/SiteDatabaseEditRowTest.php @@ -102,6 +102,10 @@ public function addDataSetsDataForSysSite(): void 'someArray' => [ 'foo' => 'bar', ], + 'dependencies' => [ + 'foo/bar', + 'baz', + ], ]; $siteFinderMock = $this->createMock(SiteFinder::class); GeneralUtility::addInstance(SiteFinder::class, $siteFinderMock); @@ -118,6 +122,7 @@ public function addDataSetsDataForSysSite(): void 'rootPageId' => 42, 'pid' => 0, 'foo' => 'bar', + 'dependencies' => 'foo/bar,baz', ]; self::assertEquals($expected, (new SiteDatabaseEditRow($siteConfiguration))->addData($input)); diff --git a/typo3/sysext/core/Classes/Command/SiteSetsListCommand.php b/typo3/sysext/core/Classes/Command/SiteSetsListCommand.php new file mode 100644 index 000000000000..f65c3db7c187 --- /dev/null +++ b/typo3/sysext/core/Classes/Command/SiteSetsListCommand.php @@ -0,0 +1,74 @@ +setCollector->getSetDefinitions(); + + if ($sets === []) { + $io->title('No site sets configured'); + $io->note('Configure new sites by placing a Configuration/Sets/MySetName/config.yaml in an extension.'); + return Command::SUCCESS; + } + + $io->title('All configured site sets'); + $table = new Table($output); + $table->setHeaders([ + 'Name', + 'Label', + 'Dependencies', + ]); + foreach ($sets as $set) { + $table->addRow( + [ + '' . $set->name . '', + $set->label, + implode(', ', [ + ...$set->dependencies, + ...array_map(static fn(string $d): string => '(' . $d . ')', $set->optionalDependencies), + ]), + ] + ); + } + $table->render(); + return Command::SUCCESS; + } +} diff --git a/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php b/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php index 91a5eeb13838..d5743dd7d85e 100644 --- a/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php +++ b/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php @@ -31,6 +31,7 @@ use TYPO3\CMS\Core\SingletonInterface; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\Entity\SiteSettings; +use TYPO3\CMS\Core\Site\SiteSettingsFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -49,13 +50,6 @@ class SiteConfiguration implements SingletonInterface */ protected string $configFileName = 'config.yaml'; - /** - * YAML file name with all settings. - * - * @internal - */ - protected string $settingsFileName = 'settings.yaml'; - /** * YAML file name with all settings related to Content-Security-Policies. * @@ -81,6 +75,7 @@ class SiteConfiguration implements SingletonInterface public function __construct( #[Autowire('%env(TYPO3:configPath)%/sites')] protected string $configPath, + protected SiteSettingsFactory $siteSettingsFactory, protected EventDispatcherInterface $eventDispatcher, #[Autowire(service: 'cache.core')] protected PhpFrontend $cache @@ -111,7 +106,7 @@ public function resolveAllExistingSites(bool $useCache = true): array foreach ($siteConfiguration as $identifier => $configuration) { // cast $identifier to string, as the identifier can potentially only consist of (int) digit numbers $identifier = (string)$identifier; - $siteSettings = $this->getSiteSettings($identifier, $configuration); + $siteSettings = $this->siteSettingsFactory->getSettings($identifier, $configuration); $configuration['contentSecurityPolicies'] = $this->getContentSecurityPolicies($identifier); $rootPageId = (int)($configuration['rootPageId'] ?? 0); @@ -218,27 +213,6 @@ public function load(string $siteIdentifier): array return $loader->load(GeneralUtility::fixWindowsFilePath($fileName), YamlFileLoader::PROCESS_IMPORTS); } - /** - * Fetch the settings for a specific site and return the parsed Site Settings object. - * - * @todo This method resolves placeholders during the loading, which is okay as this is only used in context where - * the replacement is needed. However, this may change in the future, for example if loading is needed for - * implementing a GUI for the settings - which should either get a dedicated method or a flag to control if - * placeholder should be resolved during yaml file loading or not. The SiteConfiguration save action currently - * avoid calling this method. - */ - protected function getSiteSettings(string $siteIdentifier, array $siteConfiguration): SiteSettings - { - $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName; - if (file_exists($fileName)) { - $loader = GeneralUtility::makeInstance(YamlFileLoader::class); - $settings = $loader->load(GeneralUtility::fixWindowsFilePath($fileName)); - } else { - $settings = $siteConfiguration['settings'] ?? []; - } - return new SiteSettings($settings); - } - protected function getContentSecurityPolicies(string $siteIdentifier): array { $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->contentSecurityFileName; diff --git a/typo3/sysext/core/Classes/Package/AbstractServiceProvider.php b/typo3/sysext/core/Classes/Package/AbstractServiceProvider.php index ce67b7eddf5a..56a4c3d323db 100644 --- a/typo3/sysext/core/Classes/Package/AbstractServiceProvider.php +++ b/typo3/sysext/core/Classes/Package/AbstractServiceProvider.php @@ -19,12 +19,15 @@ use Psr\Container\ContainerInterface; use Psr\Log\LoggerAwareInterface; +use Symfony\Component\Finder\Finder; use TYPO3\CMS\Core\DependencyInjection\ServiceProviderInterface; use TYPO3\CMS\Core\Log\LogManager; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationOrigin; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationOriginType; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Site\Set\SetCollector; +use TYPO3\CMS\Core\Site\Set\YamlSetDefinitionProvider; use TYPO3\CMS\Core\Type\Map; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -57,6 +60,7 @@ public function getExtensions(): array 'backend.modules' => [ static::class, 'configureBackendModules' ], 'content.security.policies' => [ static::class, 'configureContentSecurityPolicies' ], 'icons' => [ static::class, 'configureIcons' ], + SetCollector::class => [ static::class, 'configureSetCollector' ], ]; } @@ -173,6 +177,39 @@ public static function configureIcons(ContainerInterface $container, \ArrayObjec return $icons; } + public static function configureSetCollector(ContainerInterface $container, SetCollector $setCollector, string $path = null): SetCollector + { + $path = $path ?? static::getPackagePath(); + $setPath = $path . 'Configuration/Sets'; + + try { + $finder = Finder::create() + ->files() + ->sortByName() + ->depth(1) + ->name('config.yaml') + ->in($setPath); + } catch (\InvalidArgumentException) { + // No such directory in this package + return $setCollector; + } + + $setProvider = new YamlSetDefinitionProvider(); + foreach ($finder as $fileInfo) { + try { + $setCollector->add($setProvider->get($fileInfo)); + } catch (\RuntimeException $e) { + $logger = $container->get(LogManager::class)->getLogger(self::class); + $logger->error('Invalid set in {file}: {reason}', [ + 'file' => $fileInfo->getPathname(), + 'reason' => $e->getMessage(), + ]); + } + } + + return $setCollector; + } + /** * Create an instance of a class. Supports auto injection of the logger. * diff --git a/typo3/sysext/core/Classes/Settings/SettingDefinition.php b/typo3/sysext/core/Classes/Settings/SettingDefinition.php new file mode 100644 index 000000000000..99238f17ebf1 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/SettingDefinition.php @@ -0,0 +1,45 @@ + $value !== null && $value !== []); + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/typo3/sysext/core/Classes/Settings/Settings.php b/typo3/sysext/core/Classes/Settings/Settings.php new file mode 100644 index 000000000000..f11dfed9a209 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Settings.php @@ -0,0 +1,51 @@ +settings[$identifier]); + } + + public function get(string $identifier): mixed + { + if (!$this->has($identifier)) { + throw new \InvalidArgumentException('Setting does not exist', 1709555772); + } + return $this->settings[$identifier]; + } + + public function getIdentifiers(): array + { + return array_keys($this->settings); + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/typo3/sysext/core/Classes/Settings/SettingsInterface.php b/typo3/sysext/core/Classes/Settings/SettingsInterface.php new file mode 100644 index 000000000000..4f21008f1fcb --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/SettingsInterface.php @@ -0,0 +1,32 @@ +types->has($type); + } + + public function get(string $type): SettingsTypeInterface + { + return $this->types->get($type); + } +} diff --git a/typo3/sysext/core/Classes/Settings/Type/BoolType.php b/typo3/sysext/core/Classes/Settings/Type/BoolType.php new file mode 100644 index 000000000000..3306cc3ee992 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Type/BoolType.php @@ -0,0 +1,77 @@ + */ + private array $stringMap; + + public function __construct( + protected LoggerInterface $logger, + ) { + $this->stringMap = [ + '0' => false, + '1' => true, + 'false' => false, + 'true' => true, + 'off' => false, + 'on' => true, + 'no' => false, + 'yes' => true, + ]; + } + + public function validate(mixed $value, SettingDefinition $definition): bool + { + if (is_bool($value)) { + return true; + } + if (is_int($value) && ($value === 0 || $value === 1)) { + return true; + } + if (is_string($value) && isset($this->stringMap[$value])) { + return true; + } + return false; + } + + public function transformValue(mixed $value, SettingDefinition $definition): bool + { + if (!$this->validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return (bool)$value; + } + if (is_string($value)) { + return $this->stringMap[$value] ?? false; + } + return false; + } +} diff --git a/typo3/sysext/core/Classes/Settings/Type/ColorType.php b/typo3/sysext/core/Classes/Settings/Type/ColorType.php new file mode 100644 index 000000000000..170d2c2343af --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Type/ColorType.php @@ -0,0 +1,145 @@ +logger); + if (!$stringType->validate($value, $definition)) { + return false; + } + + $value = $stringType->transformValue($value, $definition); + return $this->doColorNormalization($value) !== null; + } + + public function transformValue(mixed $value, SettingDefinition $definition): string + { + $stringType = new StringType($this->logger); + if (!$stringType->validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + $value = $stringType->transformValue($value, $definition); + return $this->doColorNormalization($value) ?? $definition->default; + } + + private function doColorNormalization(string $value): ?string + { + if (str_starts_with($value, 'rgb(') && str_ends_with($value, ')')) { + $values = GeneralUtility::trimExplode(',', substr(substr($value, 0, -1), 4)); + return $this->normalizeRgb($values); + } + if (str_starts_with($value, 'rgba(') && str_ends_with($value, ')')) { + $values = GeneralUtility::trimExplode(',', substr(substr($value, 0, -1), 5)); + return $this->normalizeRgba($values); + } + + if (str_starts_with($value, '#')) { + $values = GeneralUtility::trimExplode(',', substr(substr($value, 0, -1), 5)); + return $this->normalizeHex(substr($value, 1)); + } + + return null; + } + + private function normalizeRgb(array $values): ?string + { + if (count($values) === 1) { + $values = GeneralUtility::trimExplode('/', $values[0]); + if (count($values) === 2) { + return $this->normalizeRgba([...GeneralUtility::trimExplode(' ', $values[0]), $values[1]]); + } + $values = GeneralUtility::trimExplode(' ', $values[0]); + } + if (count($values) !== 3) { + return null; + } + foreach ($values as $value) { + if (!MathUtility::canBeInterpretedAsInteger($value)) { + return null; + } + $value = (int)$value; + if ($value < 0 || $value > 255) { + return null; + } + } + return 'rgb(' . implode(',', $values) . ')'; + } + + private function normalizeRgba(array $values): ?string + { + if (count($values) !== 4) { + return null; + } + + $a = array_pop($values); + if (!MathUtility::canBeInterpretedAsFloat($a)) { + return null; + } + + if ((float)$a < 0 || (float)$a > 1) { + return null; + } + + foreach ($values as $value) { + if (!MathUtility::canBeInterpretedAsInteger($value)) { + return null; + } + $value = (int)$value; + if ($value < 0 || $value > 255) { + return null; + } + } + $values[] = $a; + return 'rgba(' . implode(',', $values) . ')'; + } + + private function normalizeHex(string $values): ?string + { + $len = strlen($values); + if ($len !== 3 && $len !== 6 && $len !== 8) { + return null; + } + + if (!preg_match('/^[0-9a-f]+$/', $values)) { + return null; + } + + return '#' . $values; + } +} diff --git a/typo3/sysext/core/Classes/Settings/Type/IntType.php b/typo3/sysext/core/Classes/Settings/Type/IntType.php new file mode 100644 index 000000000000..ecda4f255d12 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Type/IntType.php @@ -0,0 +1,55 @@ +validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + return (int)$value; + } +} diff --git a/typo3/sysext/core/Classes/Settings/Type/NumberType.php b/typo3/sysext/core/Classes/Settings/Type/NumberType.php new file mode 100644 index 000000000000..1b76dbc46e45 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Type/NumberType.php @@ -0,0 +1,67 @@ +validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + if (is_string($value)) { + if (MathUtility::canBeInterpretedAsInteger($value)) { + return (int)$value; + } + return (float)$value; + } + return $value; + } +} diff --git a/typo3/sysext/core/Classes/Settings/Type/StringListType.php b/typo3/sysext/core/Classes/Settings/Type/StringListType.php new file mode 100644 index 000000000000..fb96faa66ca2 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Type/StringListType.php @@ -0,0 +1,63 @@ +doValidate(new StringType($this->logger), $value, $definition); + } + + public function transformValue(mixed $value, SettingDefinition $definition): array + { + $stringType = new StringType($this->logger); + if (!is_array($value) || !$this->doValidate($stringType, $value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + + return array_map(static fn(mixed $v): string => $stringType->transformValue($v, $definition), $value); + } + + public function doValidate(StringType $stringType, array $value, SettingDefinition $definition): bool + { + if (!array_is_list($value)) { + return false; + } + foreach ($value as $v) { + if (!$stringType->validate($v, $definition)) { + return false; + } + } + return true; + } +} diff --git a/typo3/sysext/core/Classes/Settings/Type/StringType.php b/typo3/sysext/core/Classes/Settings/Type/StringType.php new file mode 100644 index 000000000000..4ee3f7bd5207 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Type/StringType.php @@ -0,0 +1,54 @@ +validate($value, $definition)) { + $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); + return $definition->default; + } + if (is_bool($value)) { + if ($value) { + return 'true'; + } + return 'false'; + } + return (string)$value; + } +} diff --git a/typo3/sysext/core/Classes/Settings/Type/TextType.php b/typo3/sysext/core/Classes/Settings/Type/TextType.php new file mode 100644 index 000000000000..d19d9d1055e1 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Type/TextType.php @@ -0,0 +1,23 @@ + + */ + protected array $sets; + /** * @var array */ @@ -106,6 +111,7 @@ public function __construct(string $identifier, int $rootPageId, array $configur ); $this->base = new Uri($this->sanitizeBaseUrl($baseUrl)); + $this->sets = $configuration['dependencies'] ?? []; foreach ($configuration['languages'] as $languageConfiguration) { $languageUid = (int)$languageConfiguration['languageId']; // site language has defined its own base, this is the case most of the time. @@ -213,6 +219,16 @@ public function getLanguages(): array return $languages; } + /** + * Returns configured sets of this site + * + * @return list + */ + public function getSets(): array + { + return $this->sets; + } + /** * Returns all available languages of this site, even the ones disabled for frontend usages * @@ -307,6 +323,15 @@ public function getSettings(): SiteSettings return $this->settings; } + /** + * @internal + */ + public function isTypoScriptRoot(): bool + { + return $this->sets !== []; + + } + /** * Returns a single configuration attribute * diff --git a/typo3/sysext/core/Classes/Site/Entity/SiteSettings.php b/typo3/sysext/core/Classes/Site/Entity/SiteSettings.php index 770224d9f0b6..c16bd81205ad 100644 --- a/typo3/sysext/core/Classes/Site/Entity/SiteSettings.php +++ b/typo3/sysext/core/Classes/Site/Entity/SiteSettings.php @@ -17,6 +17,7 @@ namespace TYPO3\CMS\Core\Site\Entity; +use TYPO3\CMS\Core\Settings\Settings; use TYPO3\CMS\Core\Utility\ArrayUtility; /** @@ -24,18 +25,19 @@ * with TypoScript settings / constants which happens in the TypoScript Parser * for a specific page. */ -final class SiteSettings implements \JsonSerializable +final readonly class SiteSettings extends Settings implements \JsonSerializable { private array $flatSettings; - public function __construct( - private readonly array $settings - ) { + + public function __construct(array $settings) + { + parent::__construct($settings); $this->flatSettings = $this->isEmpty() ? [] : ArrayUtility::flattenPlain($settings); } public function has(string $identifier): bool { - return isset($this->settings[$identifier]); + return isset($this->settings[$identifier]) || isset($this->flatSettings[$identifier]); } public function isEmpty(): bool @@ -62,4 +64,9 @@ public function jsonSerialize(): mixed { return json_encode($this->settings); } + + public static function __set_state(array $state): self + { + return new self($state['settings'] ?? []); + } } diff --git a/typo3/sysext/core/Classes/Site/Set/InvalidSetException.php b/typo3/sysext/core/Classes/Site/Set/InvalidSetException.php new file mode 100644 index 000000000000..9c5beded4670 --- /dev/null +++ b/typo3/sysext/core/Classes/Site/Set/InvalidSetException.php @@ -0,0 +1,20 @@ + */ + protected array $sets = []; + + /** + * @return array + */ + public function getSetDefinitions(): array + { + return $this->sets; + } + + public function add(SetDefinition $set): void + { + $this->sets[$set->name] = $set; + } +} diff --git a/typo3/sysext/core/Classes/Site/Set/SetDefinition.php b/typo3/sysext/core/Classes/Site/Set/SetDefinition.php new file mode 100644 index 000000000000..b1477c60695e --- /dev/null +++ b/typo3/sysext/core/Classes/Site/Set/SetDefinition.php @@ -0,0 +1,46 @@ + $dependencies + * @param SettingDefinition[] $settingsDefinitions + */ + public function __construct( + public string $name, + public string $label, + public array $dependencies = [], + public array $optionalDependencies = [], + public array $settingsDefinitions = [], + public array $settings = [], + ) {} + + public function toArray(): array + { + return array_filter(get_object_vars($this), fn(mixed $value) => $value !== null && $value !== []); + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/typo3/sysext/core/Classes/Site/Set/SetRegistry.php b/typo3/sysext/core/Classes/Site/Set/SetRegistry.php new file mode 100644 index 000000000000..a2805374dec0 --- /dev/null +++ b/typo3/sysext/core/Classes/Site/Set/SetRegistry.php @@ -0,0 +1,154 @@ +|null */ + protected ?array $orderedSets = null; + + public function __construct( + protected DependencyOrderingService $dependencyOrderingService, + #[Autowire(expression: 'service("package-dependent-cache-identifier").withPrefix("Sets").toString()')] + protected readonly string $cacheIdentifier, + #[Autowire(service: 'cache.core')] + protected readonly PhpFrontend $cache, + #[Autowire(lazy: true)] + protected SetCollector $setCollector, + protected LoggerInterface $logger, + ) {} + + /** + * Retrieve list of ordered sets, matched by + * $setNames, including their dependencies (recursive) + * + * @return list + */ + public function getSets(string ...$setNames): array + { + return array_values(array_filter( + $this->getOrderedSets(), + fn(SetDefinition $set): bool => + in_array($set->name, $setNames, true) || + $this->hasDependency($setNames, $set->name) + )); + } + + public function hasSet(string $setName): bool + { + return isset($this->getOrderedSets()[$setName]); + } + + public function getSet(string $setName): ?SetDefinition + { + return $this->getOrderedSets()[$setName] ?? null; + } + + /** + * @return array + */ + protected function getOrderedSets(): array + { + return $this->orderedSets ?? $this->getFromCache() ?? $this->computeOrderedSets(); + } + + /** + * @return array + */ + protected function getFromCache(): ?array + { + if (!$this->cache->has($this->cacheIdentifier)) { + return null; + } + try { + $this->orderedSets = $this->cache->require($this->cacheIdentifier); + } catch (\Error) { + return null; + } + return $this->orderedSets; + } + + /** + * @return array + */ + protected function computeOrderedSets(): array + { + $tmp = []; + $sets = $this->setCollector->getSetDefinitions(); + foreach ($sets as $set) { + foreach ($set->dependencies as $dependencyName) { + if (isset($sets[$dependencyName])) { + continue; + } + $this->logger->error('Invalid set "{name}": Missing dependency "{dependency}"', [ + 'name' => $set->name, + 'dependency' => $dependencyName, + ]); + continue 2; + } + $tmp[$set->name] = [ + 'set' => $set, + 'after' => $set->dependencies, + 'after-resilient' => $set->optionalDependencies, + ]; + } + + $this->orderedSets = array_map( + static fn(array $data): SetDefinition => $data['set'], + $this->dependencyOrderingService->orderByDependencies($tmp) + ); + $this->cache->set($this->cacheIdentifier, 'return ' . var_export($this->orderedSets, true) . ';'); + return $this->orderedSets; + } + + protected function hasDependency(array $setNames, string $dependency): bool + { + foreach ($setNames as $setName) { + $set = $this->getSet($setName); + if ($set === null) { + continue; + } + + if (in_array($dependency, $set->dependencies, true)) { + return true; + } + + if ($this->hasDependency($set->dependencies, $dependency)) { + return true; + } + } + return false; + } + + #[AsEventListener('typo3-core/set-registry')] + public function warmupCaches(CacheWarmupEvent $event): void + { + if ($event->hasGroup('system')) { + $this->computeOrderedSets(); + } + } +} diff --git a/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php b/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php new file mode 100644 index 000000000000..a57f467df8ac --- /dev/null +++ b/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php @@ -0,0 +1,110 @@ + */ + protected array $sets = []; + + /** + * @return array + */ + public function getSetDefinitions(): array + { + return $this->sets; + } + + public function addSet(SetDefinition $set): void + { + $this->sets[$set->name] = $set; + } + + public function get(\SplFileInfo $fileInfo): SetDefinition + { + $filename = $fileInfo->getPathname(); + // No placeholders or imports processed on purpose + // Use dependencies for shared sets + try { + $set = Yaml::parseFile($filename); + } catch (ParseException $e) { + throw new InvalidSetException('Invalid set definition. Filename: ' . $filename, 1711024370, $e); + } + $path = dirname($filename); + + $settingsDefinitionsFile = $path . '/settings.definitions.yaml'; + if (is_file($settingsDefinitionsFile)) { + try { + $settingsDefinitions = Yaml::parseFile($settingsDefinitionsFile); + } catch (ParseException $e) { + throw new InvalidSetException('Invalid settings definition. Filename: ' . $settingsDefinitionsFile, 1711024374, $e); + } + $version = (int)($settingsDefinitions['version'] ?? 0); + if (!is_array($settingsDefinitions['settings'] ?? null)) { + throw new \RuntimeException('Missing "settings" key in settings definitions. Filename: ' . $settingsDefinitionsFile, 1711024378); + } + $set['settingsDefinitions'] = $settingsDefinitions['settings']; + } + + $settingsFile = $path . '/settings.yaml'; + if (is_file($settingsFile)) { + try { + $settings = Yaml::parseFile($settingsFile); + } catch (ParseException $e) { + throw new InvalidSetException('Invalid settings format. Filename: ' . $settingsFile, 1711024380, $e); + } + if (!is_array($settings)) { + throw new \RuntimeException('Invalid settings format. Filename: ' . $settingsFile, 1711024382); + } + $set['settings'] = $settings; + } + + return $this->createDefinition($set, $path); + } + + protected function createDefinition(array $set, string $basePath): SetDefinition + { + try { + $settingsDefinitions = []; + foreach (($set['settingsDefinitions'] ?? []) as $setting => $options) { + try { + $definition = new SettingDefinition(...[...['key' => $setting], ...$options]); + } catch (\Error $e) { + throw new \Exception('Invalid setting definition: ' . json_encode($options), 1702623312, $e); + } + $settingsDefinitions[] = $definition; + } + $setData = [ + ...$set, + 'settingsDefinitions' => $settingsDefinitions, + ]; + return new SetDefinition(...$setData); + } catch (\Error $e) { + throw new \Exception('Invalid set definition: ' . json_encode($set), 1170859526, $e); + } + } +} diff --git a/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php b/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php new file mode 100644 index 000000000000..4d2f06367460 --- /dev/null +++ b/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php @@ -0,0 +1,138 @@ +cacheIdentifier->withAdditionalHashedIdentifier( + $siteIdentifier . '_' . json_encode($siteConfiguration) + )->toString(); + + try { + $settings = $this->cache->require($cacheIdentifier); + if ($settings instanceof SiteSettings) { + return $settings; + } + } catch (\Error) { + } + + $settings = $this->createSettings($siteIdentifier, $siteConfiguration); + $this->cache->set($cacheIdentifier, 'return ' . var_export($settings, true) . ';'); + return $settings; + } + + /** + * Fetch the settings for a specific site and return the parsed Site Settings object. + * + * @todo This method resolves placeholders during the loading, which is okay as this is only used in context where + * the replacement is needed. However, this may change in the future, for example if loading is needed for + * implementing a GUI for the settings - which should either get a dedicated method or a flag to control if + * placeholder should be resolved during yaml file loading or not. The SiteConfiguration save action currently + * avoid calling this method. + */ + public function createSettings(string $siteIdentifier, array $siteConfiguration): SiteSettings + { + $sets = $siteConfiguration['dependencies'] ?? []; + $settings = []; + + $definitions = []; + $activeSets = []; + if (is_array($sets) && $sets !== []) { + $activeSets = $this->setRegistry->getSets(...$sets); + } + + foreach ($activeSets as $set) { + foreach ($set->settingsDefinitions as $settingDefinition) { + $definitions[] = $settingDefinition; + } + } + + foreach ($definitions as $settingDefinition) { + $settings = ArrayUtility::setValueByPath($settings, $settingDefinition->key, $settingDefinition->default, '.'); + } + + foreach ($activeSets as $set) { + ArrayUtility::mergeRecursiveWithOverrule($settings, $this->validateSettings($set->settings, $definitions)); + } + + $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName; + if (file_exists($fileName)) { + $siteSettings = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName)); + } else { + $siteSettings = $siteConfiguration['settings'] ?? []; + } + + ArrayUtility::mergeRecursiveWithOverrule($settings, $this->validateSettings($siteSettings, $definitions)); + + return new SiteSettings($settings); + } + + protected function validateSettings(array $settings, array $definitions): array + { + foreach ($definitions as $definition) { + try { + $value = ArrayUtility::getValueByPath($settings, $definition->key, '.'); + } catch (MissingArrayPathException) { + continue; + } + if (!$this->settingsTypeRegistry->has($definition->type)) { + throw new \RuntimeException('Setting type ' . $definition->type . ' is not defined.', 1712437727); + } + $type = $this->settingsTypeRegistry->get($definition->type); + if (!$type->validate($value, $definition)) { + $settings = ArrayUtility::removeByPath($settings, $definition->key, '.'); + } + + $newValue = $type->transformValue($value, $definition); + if ($newValue !== $value) { + ArrayUtility::setValueByPath($settings, $definition->key, $newValue, '.'); + } + } + + return $settings; + } + +} diff --git a/typo3/sysext/core/Classes/Site/TcaSiteSetCollector.php b/typo3/sysext/core/Classes/Site/TcaSiteSetCollector.php new file mode 100644 index 000000000000..011831227079 --- /dev/null +++ b/typo3/sysext/core/Classes/Site/TcaSiteSetCollector.php @@ -0,0 +1,42 @@ +setCollector->getSetDefinitions() as $set) { + $fieldConfiguration['items'][] = [ + 'label' => $set->label, + 'value' => $set->name, + ]; + } + } +} diff --git a/typo3/sysext/core/Configuration/Services.yaml b/typo3/sysext/core/Configuration/Services.yaml index 55091672a1bc..9a4932dbbda7 100644 --- a/typo3/sysext/core/Configuration/Services.yaml +++ b/typo3/sysext/core/Configuration/Services.yaml @@ -31,7 +31,6 @@ services: alias: true schedulable: false - TYPO3\CMS\Core\Command\SiteListCommand: tags: - name: 'console.command' @@ -46,6 +45,13 @@ services: description: 'Shows the configuration of the specified site' schedulable: false + TYPO3\CMS\Core\Command\SiteSetsListCommand: + tags: + - name: 'console.command' + command: 'site:sets:list' + description: 'Shows the list of available site sets' + schedulable: false + TYPO3\CMS\Core\Command\SetupExtensionsCommand: tags: - name: 'console.command' @@ -312,6 +318,10 @@ services: TYPO3\CMS\Core\Utility\DiffUtility: shared: false + package-dependent-cache-identifier: + public: true + alias: TYPO3\CMS\Core\Package\Cache\PackageDependentCacheIdentifier + # Core caches, cache.core and cache.assets are injected as early # entries in TYPO3\CMS\Core\Core\Bootstrap and therefore omitted here cache.hash: diff --git a/typo3/sysext/core/Documentation/Changelog/13.1/Feature-103437-IntroduceSiteSets.rst b/typo3/sysext/core/Documentation/Changelog/13.1/Feature-103437-IntroduceSiteSets.rst new file mode 100644 index 000000000000..0546f96618fe --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/13.1/Feature-103437-IntroduceSiteSets.rst @@ -0,0 +1,119 @@ +.. include:: /Includes.rst.txt + +.. _feature-103437-1712062105: + +====================================== +Feature: #103437 - Introduce Site Sets +====================================== + +See :issue:`103437` + +Description +=========== + +Site sets ship parts of site configuration as composable pieces. They are +intended to deliver settings, TypoScript, TSConfig and reference enabled content +blocks for the scope of a site. + +Extensions can provide multiple sets in order to ship presets for different +sites or subsets (think of frameworks) where selected features are exposed +as a subset (example: `typo3/seo-xml-sitemap`). + +A set is defined in an extensions subfolder in :file:`Configuration/Sets/`, for +example :file:`EXT:my_extension/Configuration/Sets/MySet/config.yaml`. + +The folder name in :file:`Configuration/Sets/` is arbitrary, significant +is the `name` defined in :file:`config.yaml`. The `name` uses a `vendor/name` +scheme by convention, and *should* use the same vendor as the containing +extension. It may differ if needed for compatibility reasons (e.g. when sets are +moved to other extensions). If an extension provides exactly one set that should +have the same `name` as defined in :file:`composer.json`. + +The :file:`config.yaml` for a set that is composed of three subsets looks as +follows: + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Sets/MySet/config.yaml + + name: my-vendor/my-set + label: My Set + + # Load TypoScript, TSconfig and settings from dependencies + dependencies: + - typo3/fluid-styled-content + - typo3/felogin + - typo3/seo-xml-sitemap + + +Sets are applied to sites via `dependencies` array in site configuration: + +.. code-block:: yaml + :caption: config/sites/my-site/config.yaml + + base: 'http://example.com/' + rootPageId: 1 + dependencies: + - typo3/styleguide + +Site sets can also be edited via the backend module `Site Management > Sites`. + + +Settings Definitions +-------------------- + +Sets can define settings definitions which contain more metadata than just a +value: They contain UI relevant options like `label`, `description`, `category` +and `tags` and types like `int`, `bool`, `string`, `stringlist`, `text` or +`color`. These definitions are are placed in a :file:`settings.definitions.yaml` +next to the site set file :file:`config.yaml`. + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Sets/MySet/settings.definitions.yaml + + settings: + foo.bar.baz: + label: 'My example baz setting' + description: 'Configure baz to be used in bar' + type: int + default: 5 + + +Settings for subsets +-------------------- + +Settings for subsets (e.g. to configure settings in declared dependencies) +can be shipped via :file:`settings.yaml` when placed next to the set file +:file:`config.yaml`. + +Note that default values for settings provided by the set do not need to be +defined here, as defaults are to be provided within +:file:`settings.definitions.yaml`) + +Here is an example where the setting `styles.content.defaultHeaderType` — as +provided by `typo3/fluid-styled-content` — is configured via +:file:`settings.yaml`. + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Sets/MySet/settings.yaml + + styles: + content: + defaultHeaderType: 1 + + +This setting will be exposed as site setting whenever the set +`my-vendor/my-set` is applied to a site config. + + +Impact +====== + +Sites can be composed of sets where relevant configuration, templates, assets +and setting definitions are combined in a central place and applied to sites as +one logical volume. + +Sets have dependency management and therefore allow sharing code between +multiple TYPO3 sites and extensions in a flexible way. + + +.. index:: Backend, Frontend, PHP-API, YAML, ext:core diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/InvalidDependency/config.yaml b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/InvalidDependency/config.yaml new file mode 100644 index 000000000000..c9e53e315617 --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/InvalidDependency/config.yaml @@ -0,0 +1,5 @@ +name: typo3tests/invalid-dependency +label: Test Set with an invalid dependency + +dependencies: + - typo3tests/not-available diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set1/config.yaml b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set1/config.yaml new file mode 100644 index 000000000000..762c2635be67 --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set1/config.yaml @@ -0,0 +1,9 @@ +name: typo3tests/set-1 +label: Test Set Fixture 1 + +dependencies: + - typo3tests/set-2 + - typo3tests/set-3 + +optionalDependencies: + - typo3tests/set-5 diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set2/config.yaml b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set2/config.yaml new file mode 100644 index 000000000000..a1e1fadca946 --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set2/config.yaml @@ -0,0 +1,5 @@ +name: typo3tests/set-2 +label: Test Set Fixture 2 + +dependencies: + - typo3tests/set-4 diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set3/config.yaml b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set3/config.yaml new file mode 100644 index 000000000000..1b1da78a2976 --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set3/config.yaml @@ -0,0 +1,5 @@ +name: typo3tests/set-3 +label: Test Set Fixture 3 + +dependencies: + - typo3tests/set-4 diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set4/config.yaml b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set4/config.yaml new file mode 100644 index 000000000000..b097bfd7cb34 --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set4/config.yaml @@ -0,0 +1,2 @@ +name: typo3tests/set-4 +label: Test Set Fixture 4 diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set5/config.yaml b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set5/config.yaml new file mode 100644 index 000000000000..5562645c1dd1 --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/Configuration/Sets/Set5/config.yaml @@ -0,0 +1,2 @@ +name: typo3tests/set-5 +label: Test Set Fixture 5 diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/composer.json b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/composer.json new file mode 100644 index 000000000000..6f0f5ac8430b --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/composer.json @@ -0,0 +1,14 @@ +{ + "name": "typo3tests/test-sets", + "type": "typo3-cms-extension", + "description": "This extension contains set fixtures.", + "license": "GPL-2.0-or-later", + "require": { + "typo3/cms-core": "13.1.*@dev" + }, + "extra": { + "typo3/cms": { + "extension-key": "test_sets" + } + } +} diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/ext_emconf.php b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/ext_emconf.php new file mode 100644 index 000000000000..4c4801689d5a --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_sets/ext_emconf.php @@ -0,0 +1,21 @@ + 'This extension contains set fixtures.', + 'description' => 'This extension contains set fixture.', + 'category' => 'example', + 'version' => '13.1.0', + 'state' => 'beta', + 'author' => 'Benjamin Franzke', + 'author_email' => 'ben@bnf.dev', + 'author_company' => '', + 'constraints' => [ + 'depends' => [ + 'typo3' => '13.1.0', + ], + 'conflicts' => [], + 'suggests' => [], + ], +]; diff --git a/typo3/sysext/core/Tests/Functional/Site/Set/SetRegistryTest.php b/typo3/sysext/core/Tests/Functional/Site/Set/SetRegistryTest.php new file mode 100644 index 000000000000..5cbf1bfae61d --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Site/Set/SetRegistryTest.php @@ -0,0 +1,86 @@ +get(SetRegistry::class); + + self::assertTrue($setRegistry->hasSet('typo3tests/set-1')); + self::assertInstanceOf(SetDefinition::class, $setRegistry->getSet('typo3tests/set-1')); + } + + #[Test] + public function setDependenciesAreResolvedWithOrdering(): void + { + $setRegistry = $this->get(SetRegistry::class); + + $expected = [ + // set-2 and set-3 depend on set-4, therefore set-4 needs to be ordered before 2 and 3. + 'typo3tests/set-4', + // set-1 depends on set-2 and set-3 + 'typo3tests/set-2', + 'typo3tests/set-3', + 'typo3tests/set-1', + ]; + $setDefinitions = $setRegistry->getSets('typo3tests/set-1'); + $setDefinitionsNames = array_map(static fn(SetDefinition $d): string => $d->name, $setDefinitions); + + self::assertEquals($expected, $setDefinitionsNames); + } + + #[Test] + public function optionalSetDependenciesAreResolvedWithOrdering(): void + { + $setRegistry = $this->get(SetRegistry::class); + + $expected = [ + // set-2 and set-3 depend on set-4, therefore set-4 needs to be ordered before 2 and 3. + 'typo3tests/set-4', + // set-1 depends on set-2 and set-3 + 'typo3tests/set-2', + 'typo3tests/set-3', + // set-5 is an optional dependency of set-1 + 'typo3tests/set-5', + 'typo3tests/set-1', + ]; + $setDefinitions = $setRegistry->getSets('typo3tests/set-1', 'typo3tests/set-5'); + $setDefinitionsNames = array_map(static fn(SetDefinition $d): string => $d->name, $setDefinitions); + + self::assertEquals($expected, $setDefinitionsNames); + } + + #[Test] + public function invalidSetsAreSkipped(): void + { + $setRegistry = $this->get(SetRegistry::class); + self::assertFalse($setRegistry->hasSet('typo3tests/invalid-dependency')); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Configuration/SiteConfigurationTest.php b/typo3/sysext/core/Tests/Unit/Configuration/SiteConfigurationTest.php index c02f46f10b81..99d9138cdd4a 100644 --- a/typo3/sysext/core/Tests/Unit/Configuration/SiteConfigurationTest.php +++ b/typo3/sysext/core/Tests/Unit/Configuration/SiteConfigurationTest.php @@ -18,12 +18,18 @@ namespace TYPO3\CMS\Core\Tests\Unit\Configuration; use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Yaml\Yaml; use TYPO3\CMS\Core\Cache\Frontend\NullFrontend; +use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader; use TYPO3\CMS\Core\Configuration\SiteConfiguration; use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\EventDispatcher\NoopEventDispatcher; use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Package\Cache\PackageDependentCacheIdentifier; +use TYPO3\CMS\Core\Settings\SettingsTypeRegistry; +use TYPO3\CMS\Core\Site\Set\SetRegistry; +use TYPO3\CMS\Core\Site\SiteSettingsFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; @@ -48,8 +54,12 @@ protected function setUp(): void GeneralUtility::mkdir_deep($this->fixturePath); } $this->testFilesToDelete[] = $basePath; + $setRegistry = $this->createMock(SetRegistry::class); + $packageDependentCacheIdentifier = $this->createMock(PackageDependentCacheIdentifier::class); + $settingsTypeRegistry = new SettingsTypeRegistry($this->createMock(ServiceLocator::class)); $this->siteConfiguration = new SiteConfiguration( $this->fixturePath, + new SiteSettingsFactory($this->fixturePath, $setRegistry, $settingsTypeRegistry, new YamlFileLoader(), new NullFrontend('test'), $packageDependentCacheIdentifier), new NoopEventDispatcher(), new NullFrontend('test') ); diff --git a/typo3/sysext/core/Tests/Unit/Settings/Type/BoolTypeTest.php b/typo3/sysext/core/Tests/Unit/Settings/Type/BoolTypeTest.php new file mode 100644 index 000000000000..350f3f7319ee --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Settings/Type/BoolTypeTest.php @@ -0,0 +1,97 @@ + [true, true], + 'false' => [false, false], + 'true as string' => ['true', true], + 'false as string' => ['false', false], + '1 as string' => ['1', true], + '0 as string' => ['0', false], + 'on as string' => ['on', true], + 'off as string' => ['off', false], + 'yes as string' => ['yes', true], + 'no as string' => ['no', false], + ]; + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreVerified(mixed $value): void + { + $boolType = new BoolType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'bool', + default: true, + label: 'Unit Test setting', + ); + self::assertTrue($boolType->validate($value, $setting)); + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreTransformed(mixed $value, bool $expected): void + { + $boolType = new BoolType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'bool', + default: true, + label: 'Unit Test setting', + ); + self::assertEquals($expected, $boolType->transformValue($value, $setting)); + } + + public static function disallowedValuesDataProvider(): array + { + return [ + 'random string' => ['1337'], + 'random int' => [1337], + 'object' => [new \stdClass()], + ]; + } + + #[DataProvider('disallowedValuesDataProvider')] + #[Test] + public function disallowedValuesAreVerified(mixed $value): void + { + $boolType = new BoolType(new NullLogger()); + $setting = new SettingDefinition( + key: 'unit.test', + type: 'bool', + default: true, + label: 'Unit Test setting', + ); + self::assertFalse($boolType->validate($value, $setting)); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Settings/Type/ColorTypeTest.php b/typo3/sysext/core/Tests/Unit/Settings/Type/ColorTypeTest.php new file mode 100644 index 000000000000..e5040816137e --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Settings/Type/ColorTypeTest.php @@ -0,0 +1,100 @@ + ['#0246ef', '#0246ef'], + '8 char hex' => ['#0246efcc', '#0246efcc'], + '3 char hex' => ['#fff', '#fff'], + 'rgb' => ['rgb(10, 20,30)', 'rgb(10,20,30)'], + 'rgb with space separator' => ['rgb(10 20 30)', 'rgb(10,20,30)'], + 'rgb with alpha' => ['rgb(10 20 30 / 0.3)', 'rgba(10,20,30,0.3)'], + 'rgba' => ['rgba(10, 20,30, 0.3)', 'rgba(10,20,30,0.3)'], + ]; + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreVerified(mixed $value): void + { + $colorType = new ColorType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'color', + default: '#000', + label: 'Unit Test setting', + ); + self::assertTrue($colorType->validate($value, $setting)); + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreTransformed(mixed $value, string $expected): void + { + $colorType = new ColorType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'color', + default: '#000', + label: 'Unit Test setting', + ); + self::assertEquals($expected, $colorType->transformValue($value, $setting)); + } + + public static function disallowedValuesDataProvider(): array + { + return [ + '6 char hex with invalid chars' => ['#0246eg'], + '8 char hex with invalid chars' => ['#0246efcz'], + '5 char hex' => ['#ff444'], + '4 char hex' => ['#ff43'], + '3 char hex with invalid chars' => ['#fi4'], + 'rgb with invalid numbers' => ['rgb(10, 257,30)'], + 'rgba with invalid numbers' => ['rgba(10, 20,30, 1.3)'], + 'invalid string' => ['# [new \stdClass()], + ]; + } + + #[DataProvider('disallowedValuesDataProvider')] + #[Test] + public function disallowedValuesAreVerified(mixed $value): void + { + $colorType = new ColorType(new NullLogger()); + $setting = new SettingDefinition( + key: 'unit.test', + type: 'color', + default: '#000', + label: 'Unit Test setting', + ); + self::assertFalse($colorType->validate($value, $setting)); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Settings/Type/IntTypeTest.php b/typo3/sysext/core/Tests/Unit/Settings/Type/IntTypeTest.php new file mode 100644 index 000000000000..c02d1dfb665c --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Settings/Type/IntTypeTest.php @@ -0,0 +1,97 @@ + [-10, -10], + 'int' => [32425, 32425], + 'negative int' => [-32425, -32425], + 'largest int' => [PHP_INT_MAX, PHP_INT_MAX], + 'int as string' => ['32425', 32425], + 'negative int as string' => ['-32425', -32425], + 'zero' => [0, 0], + 'zero as string' => ['0', 0], + ]; + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreVerified(mixed $value): void + { + $intType = new IntType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'int', + default: 1337, + label: 'Unit Test setting', + ); + self::assertTrue($intType->validate($value, $setting)); + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreTransformed(mixed $value, int $expected): void + { + $intType = new IntType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'int', + default: 1337, + label: 'Unit Test setting', + ); + self::assertEquals($expected, $intType->transformValue($value, $setting)); + } + + public static function disallowedValuesDataProvider(): array + { + return [ + 'true' => [true], + 'false' => [false], + 'float as string' => ['32.325'], + 'number with prefixes' => ['0032425'], + 'objects' => [new \stdClass()], + ]; + } + + #[DataProvider('disallowedValuesDataProvider')] + #[Test] + public function disallowedValuesAreVerified(mixed $value): void + { + $intType = new IntType(new NullLogger()); + $setting = new SettingDefinition( + key: 'unit.test', + type: 'int', + default: 1337, + label: 'Unit Test setting', + ); + self::assertFalse($intType->validate($value, $setting)); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Settings/Type/NumberTypeTest.php b/typo3/sysext/core/Tests/Unit/Settings/Type/NumberTypeTest.php new file mode 100644 index 000000000000..cc3ad68d59b6 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Settings/Type/NumberTypeTest.php @@ -0,0 +1,99 @@ + [-10, -10], + 'int' => [32425, 32425], + 'negative int' => [-32425, -32425], + 'largest int' => [PHP_INT_MAX, PHP_INT_MAX], + 'int as string' => ['32425', 32425], + 'negative int as string' => ['-32425', -32425], + 'float' => [32.325, 32.325], + 'float as string' => ['32.325', 32.325], + 'zero' => [0, 0], + 'zero as string' => ['0', 0], + ]; + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreVerified(mixed $value): void + { + $intType = new NumberType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'number', + default: 13.37, + label: 'Unit Test setting', + ); + self::assertTrue($intType->validate($value, $setting)); + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreTransformed(mixed $value, int|float $expected): void + { + $intType = new NumberType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'number', + default: 13.37, + label: 'Unit Test setting', + ); + self::assertEquals($expected, $intType->transformValue($value, $setting)); + } + + public static function disallowedValuesDataProvider(): array + { + return [ + 'true' => [true], + 'false' => [false], + // @todo: MathUtility::canBeInterpretedAsFloat incorrectly marks this as valid + //'number with prefixes' => ['0032425'], + 'objects' => [new \stdClass()], + ]; + } + + #[DataProvider('disallowedValuesDataProvider')] + #[Test] + public function disallowedValuesAreVerified(mixed $value): void + { + $intType = new NumberType(new NullLogger()); + $setting = new SettingDefinition( + key: 'unit.test', + type: 'number', + default: 13.37, + label: 'Unit Test setting', + ); + self::assertFalse($intType->validate($value, $setting)); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Settings/Type/StringListTypeTest.php b/typo3/sysext/core/Tests/Unit/Settings/Type/StringListTypeTest.php new file mode 100644 index 000000000000..e5e1da136871 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Settings/Type/StringListTypeTest.php @@ -0,0 +1,120 @@ + [ + ['foobar', $stringable, 'null'], + ['foobar', 'string-from-stringable', 'null'], + ], + 'numbers' => [ + ['1337', -10, 32425, PHP_INT_MAX, -10, 13.37, 0, '0'], + ['1337', '-10', '32425', (string)PHP_INT_MAX, '-10', '13.37', '0', '0'], + ], + 'bool' => [ + [true, false, 'true', 'false'], + ['true', 'false', 'true', 'false'], + ], + ]; + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreVerified(mixed $value): void + { + $stringListType = new StringListType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'stringlist', + default: [], + label: 'Unit Test setting', + ); + self::assertTrue($stringListType->validate($value, $setting)); + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreTransformed(mixed $value, array $expected): void + { + $stringListType = new StringListType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'stringlist', + default: [], + label: 'Unit Test setting', + ); + self::assertEquals($expected, $stringListType->transformValue($value, $setting)); + } + + public static function disallowedValuesDataProvider(): array + { + return [ + 'non list array' => [ + [1 => 'foo'], + ], + 'sting key array' => [ + ['foo' => 'bar'], + ], + 'string' => [ + 'foobar', + ], + 'int' => [ + 1337, + ], + 'float' => [ + 13.37, + ], + 'objects' => [ + new \stdClass(), + ], + ]; + } + + #[DataProvider('disallowedValuesDataProvider')] + #[Test] + public function disallowedValuesAreVerified(mixed $value): void + { + $stringListType = new StringListType(new NullLogger()); + $setting = new SettingDefinition( + key: 'unit.test', + type: 'stringlist', + default: [], + label: 'Unit Test setting', + ); + self::assertFalse($stringListType->validate($value, $setting)); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Settings/Type/StringTypeTest.php b/typo3/sysext/core/Tests/Unit/Settings/Type/StringTypeTest.php new file mode 100644 index 000000000000..d74ace77cb6f --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Settings/Type/StringTypeTest.php @@ -0,0 +1,106 @@ + ['foobar', 'foobar'], + 'negativeValue' => [-10, '-10'], + 'int' => [32425, '32425'], + 'negative int' => [-32425, '-32425'], + 'largest int' => [PHP_INT_MAX, (string)PHP_INT_MAX], + 'float' => [13.37, '13.37'], + 'int as string' => ['32425', '32425'], + 'negative int as string' => ['-32425', '-32425'], + 'float as string' => ['13.37', '13.37'], + 'zero' => [0, '0'], + 'zero as string' => ['0', '0'], + 'null as string' => ['null', 'null'], + 'stringable' => [ + new class () implements \Stringable { + public function __toString(): string + { + return 'string-from-stringable'; + } + }, + 'string-from-stringable', + ], + ]; + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreVerified(mixed $value): void + { + $stringType = new StringType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'string', + default: 'foobar', + label: 'Unit Test setting', + ); + self::assertTrue($stringType->validate($value, $setting)); + } + + #[DataProvider('allowedValuesDataProvider')] + #[Test] + public function allowedValuesAreTransformed(mixed $value, string $expected): void + { + $stringType = new StringType(new NullLogger()); + + $setting = new SettingDefinition( + key: 'unit.test', + type: 'string', + default: 'foobar', + label: 'Unit Test setting', + ); + self::assertEquals($expected, $stringType->transformValue($value, $setting)); + } + + public static function disallowedValuesDataProvider(): array + { + return [ + 'objects' => [new \stdClass()], + ]; + } + + #[DataProvider('disallowedValuesDataProvider')] + #[Test] + public function disallowedValuesAreVerified(mixed $value): void + { + $stringType = new StringType(new NullLogger()); + $setting = new SettingDefinition( + key: 'unit.test', + type: 'string', + default: 'foobar', + label: 'Unit Test setting', + ); + self::assertFalse($stringType->validate($value, $setting)); + } +}