Skip to content

Commit

Permalink
[FEATURE] Introduce site sets with setting definitions
Browse files Browse the repository at this point in the history
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 <benjamin.kott@outlook.com>
Reviewed-by: Benjamin Franzke <ben@bnf.dev>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Oliver Bartsch <bo@cedev.de>
Tested-by: Benjamin Franzke <ben@bnf.dev>
Tested-by: core-ci <typo3@b13.com>
Reviewed-by: Benjamin Kott <benjamin.kott@outlook.com>
Reviewed-by: Oliver Bartsch <bo@cedev.de>
  • Loading branch information
bnf committed Apr 7, 2024
1 parent 1d6079a commit b326de1
Show file tree
Hide file tree
Showing 48 changed files with 2,342 additions and 42 deletions.
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
];
}
}
}
Expand Up @@ -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':
Expand Down Expand Up @@ -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':
Expand Down
Expand Up @@ -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;
}
}
12 changes: 11 additions & 1 deletion typo3/sysext/backend/Configuration/SiteConfiguration/site.php
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Expand Up @@ -21,6 +21,9 @@
<trans-unit id="site.baseVariants" resname="site.baseVariants">
<source>Variants for the Entry Point</source>
</trans-unit>
<trans-unit id="site.dependencies" resname="site.dependencies">
<source>Sets for this Site</source>
</trans-unit>
<trans-unit id="site.languages" resname="site.languages">
<source>Available Languages for this Site</source>
</trans-unit>
Expand Down
Expand Up @@ -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);
Expand All @@ -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));
Expand Down
74 changes: 74 additions & 0 deletions typo3/sysext/core/Classes/Command/SiteSetsListCommand.php
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/

namespace TYPO3\CMS\Core\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\Site\Set\SetCollector;

/**
* Command for listing all configured sites
*/
class SiteSetsListCommand extends Command
{
public function __construct(
protected readonly SetCollector $setCollector
) {
parent::__construct();
}

/**
* Shows a table with all configured sites
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$sets = $this->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(
[
'<options=bold>' . $set->name . '</>',
$set->label,
implode(', ', [
...$set->dependencies,
...array_map(static fn(string $d): string => '(' . $d . ')', $set->optionalDependencies),
]),
]
);
}
$table->render();
return Command::SUCCESS;
}
}
32 changes: 3 additions & 29 deletions typo3/sysext/core/Classes/Configuration/SiteConfiguration.php
Expand Up @@ -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;

/**
Expand All @@ -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.
*
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions typo3/sysext/core/Classes/Package/AbstractServiceProvider.php
Expand Up @@ -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;

Expand Down Expand Up @@ -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' ],
];
}

Expand Down Expand Up @@ -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.
*
Expand Down
45 changes: 45 additions & 0 deletions typo3/sysext/core/Classes/Settings/SettingDefinition.php
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/

namespace TYPO3\CMS\Core\Settings;

/**
* @internal
*/
readonly class SettingDefinition
{
public function __construct(
public string $key,
public string $type,
public string|int|float|bool|array|null $default,
public string $label,
public ?string $description = null,
public array $enum = [],
public array $categories = [],
public array $tags = [],
) {}

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

0 comments on commit b326de1

Please sign in to comment.