diff --git a/Neos.Neos/Classes/Domain/Model/Site.php b/Neos.Neos/Classes/Domain/Model/Site.php index 1d15d90a342..15c278beaa9 100644 --- a/Neos.Neos/Classes/Domain/Model/Site.php +++ b/Neos.Neos/Classes/Domain/Model/Site.php @@ -19,6 +19,7 @@ use Doctrine\ORM\Mapping as ORM; use Neos\Flow\Annotations as Flow; use Neos\Media\Domain\Model\AssetCollection; +use Neos\Utility\Arrays; /** * Domain model of a site @@ -35,12 +36,19 @@ class Site public const STATE_OFFLINE = 2; /** - * @Flow\InjectConfiguration(path="sites") * @var array * @phpstan-var array> */ + #[Flow\InjectConfiguration(path: 'sites')] protected $sitesConfiguration = []; + /** + * @var array + * @phpstan-var array + */ + #[Flow\InjectConfiguration(path: 'sitePresets')] + protected $sitePresetsConfiguration = []; + /** * Name of the site * @@ -372,7 +380,25 @@ public function emitSiteChanged() public function getConfiguration(): SiteConfiguration { - // we DO NOT want recursive merge here - return SiteConfiguration::fromArray($this->sitesConfiguration[$this->nodeName] ?? $this->sitesConfiguration['*']); + if (array_key_exists($this->nodeName, $this->sitesConfiguration)) { + $siteSettingsPath = $this->nodeName; + } else { + if (!array_key_exists('*', $this->sitesConfiguration)) { + throw new \RuntimeException(sprintf('Missing configuration for "Neos.Neos.sites.%s" or fallback "Neos.Neos.sites.*"', $this->nodeName), 1714230658); + } + $siteSettingsPath = '*'; + } + $siteSettings = $this->sitesConfiguration[$siteSettingsPath]; + if (isset($siteSettings['preset'])) { + if (!is_string($siteSettings['preset'])) { + throw new \RuntimeException(sprintf('Invalid "preset" configuration for "Neos.Neos.sites.%s". Expected string, got: %s', $siteSettingsPath, get_debug_type($siteSettings['preset'])), 1699785648); + } + if (!isset($this->sitePresetsConfiguration[$siteSettings['preset']]) || !is_array($this->sitePresetsConfiguration[$siteSettings['preset']])) { + throw new \RuntimeException(sprintf('Site settings "Neos.Neos.sites.%s" refer to a preset "%s", but no corresponding preset is configured', $siteSettingsPath, $siteSettings['preset']), 1699785736); + } + $siteSettings = Arrays::arrayMergeRecursiveOverrule($this->sitePresetsConfiguration[$siteSettings['preset']], $siteSettings); + unset($siteSettings['preset']); + } + return SiteConfiguration::fromArray($siteSettings); } } diff --git a/Neos.Neos/Configuration/Settings.Sites.yaml b/Neos.Neos/Configuration/Settings.Sites.yaml new file mode 100755 index 00000000000..3c62589e72e --- /dev/null +++ b/Neos.Neos/Configuration/Settings.Sites.yaml @@ -0,0 +1,18 @@ +# # +# Site specific settings # +# # +# This file contains settings specific to Neos Sites # + +Neos: + + Neos: + sitePresets: + 'default': + uriPathSuffix: '.html' + contentRepository: default + contentDimensions: + resolver: + factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\AutoUriPathResolverFactory + sites: + '*': + preset: 'default' diff --git a/Neos.Neos/Configuration/Settings.yaml b/Neos.Neos/Configuration/Settings.yaml index 11ca11829ba..76419846d02 100755 --- a/Neos.Neos/Configuration/Settings.yaml +++ b/Neos.Neos/Configuration/Settings.yaml @@ -11,9 +11,6 @@ Neos: Neos: - contentDimensions: - resolution: - uriPathSegmentDelimiter: '_' fusion: # if set to true, Fusion is cached on a per-site basis. @@ -54,19 +51,6 @@ Neos: More information and contribution opportunities at https://www.neos.io --> - routing: - # Setting this to true allows to use an empty uriSegment for default dimensions. - # The only limitation is that all segments must be unique across all dimenions. - supportEmptySegmentForDimensions: true - - sites: - '*': - uriPathSuffix: '.html' - contentRepository: default - contentDimensions: - resolver: - factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\AutoUriPathResolverFactory - nodeTypes: groups: general: diff --git a/Neos.Neos/Migrations/Code/Version20230801154834.php b/Neos.Neos/Migrations/Code/Version20230801154834.php index 68118a7648c..f31c5610840 100644 --- a/Neos.Neos/Migrations/Code/Version20230801154834.php +++ b/Neos.Neos/Migrations/Code/Version20230801154834.php @@ -4,7 +4,7 @@ /** - * Replace defaultUriSuffix configuration with uriPathSuffix in default site configuration + * Replace defaultUriSuffix configuration with uriPathSuffix in default site preset configuration */ class Version20230801154834 extends AbstractMigration { @@ -16,6 +16,6 @@ public function getIdentifier(): string public function up(): void { - $this->moveSettingsPaths(['Neos', 'Flow', 'mvc', 'routes', 'Neos.Neos', 'variables', 'defaultUriSuffix'], ['Neos', 'Neos', 'sites', '*', 'uriPathSuffix']); + $this->moveSettingsPaths(['Neos', 'Flow', 'mvc', 'routes', 'Neos.Neos', 'variables', 'defaultUriSuffix'], ['Neos', 'Neos', 'sitePresets', 'default', 'uriPathSuffix']); } } diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Basic.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Basic.feature index 42fdfa0c5d4..b8034963389 100644 --- a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Basic.feature +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Basic.feature @@ -59,8 +59,9 @@ Feature: Basic routing functionality (match & resolve document nodes in one dime Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: 'default' + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Dimensions.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Dimensions.feature index 4e4fcc69784..ea4f8bc969b 100644 --- a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Dimensions.feature +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Dimensions.feature @@ -71,8 +71,9 @@ Feature: Routing functionality with multiple content dimensions Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: defaultDimensionSpacePoint: market: DE @@ -111,8 +112,9 @@ Feature: Routing functionality with multiple content dimensions Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: defaultDimensionSpacePoint: market: DE @@ -170,8 +172,9 @@ Feature: Routing functionality with multiple content dimensions Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\UriPathResolverFactory @@ -239,8 +242,9 @@ Feature: Routing functionality with multiple content dimensions Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\UriPathResolverFactory @@ -306,8 +310,9 @@ Feature: Routing functionality with multiple content dimensions Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\UriPathResolverFactory @@ -367,8 +372,9 @@ Feature: Routing functionality with multiple content dimensions Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\UriPathResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/DisableNodes.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/DisableNodes.feature index 786be2fb737..5715f273bb2 100644 --- a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/DisableNodes.feature +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/DisableNodes.feature @@ -59,8 +59,9 @@ Feature: Routing behavior of removed, disabled and re-enabled nodes Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/MultiSiteLinking.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/MultiSiteLinking.feature index b054eb1f25d..a86d7b7f861 100644 --- a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/MultiSiteLinking.feature +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/MultiSiteLinking.feature @@ -65,8 +65,15 @@ Feature: Linking between multiple websites Neos: Neos: sites: - '*': - contentRepository: default + 'site-1': + preset: default + uriPathSuffix: '' + contentDimensions: + resolver: + factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory + 'site-2': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/RouteCache.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/RouteCache.feature index 046c965289e..ba1cee6fff5 100644 --- a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/RouteCache.feature +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/RouteCache.feature @@ -58,8 +58,9 @@ Feature: Route cache invalidation Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Shortcuts.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Shortcuts.feature index 8b315048502..5a2f920db40 100644 --- a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Shortcuts.feature +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/Shortcuts.feature @@ -87,8 +87,9 @@ Feature: Routing behavior of shortcut nodes Neos: Neos: sites: - '*': - contentRepository: default + 'node1': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/TetheredSiteChildDocuments.feature b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/TetheredSiteChildDocuments.feature index df5b10e9ccc..59d78d15a6d 100644 --- a/Neos.Neos/Tests/Behavior/Features/FrontendRouting/TetheredSiteChildDocuments.feature +++ b/Neos.Neos/Tests/Behavior/Features/FrontendRouting/TetheredSiteChildDocuments.feature @@ -46,8 +46,9 @@ Feature: Tests for site node child documents. These are special in that they hav Neos: Neos: sites: - '*': - contentRepository: default + 'site': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCase.feature b/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCase.feature index d73a80cf0cf..b40c3da18f8 100644 --- a/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCase.feature +++ b/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCase.feature @@ -49,8 +49,9 @@ Feature: Tests for the "Neos.Neos:ContentCase" Fusion prototype Neos: Neos: sites: - '*': - contentRepository: default + 'a': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCollection.feature b/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCollection.feature index f68a96cf08b..a75d67a0c3d 100644 --- a/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCollection.feature +++ b/Neos.Neos/Tests/Behavior/Features/Fusion/ContentCollection.feature @@ -56,8 +56,9 @@ Feature: Tests for the "Neos.Neos:ContentCollection" Fusion prototype Neos: Neos: sites: - '*': - contentRepository: default + 'a': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/Fusion/ConvertUris.feature b/Neos.Neos/Tests/Behavior/Features/Fusion/ConvertUris.feature index fccf8d719b6..db389b0ad3c 100644 --- a/Neos.Neos/Tests/Behavior/Features/Fusion/ConvertUris.feature +++ b/Neos.Neos/Tests/Behavior/Features/Fusion/ConvertUris.feature @@ -47,8 +47,9 @@ Feature: Tests for the "Neos.Neos:ConvertUris" Fusion prototype Neos: Neos: sites: - '*': - contentRepository: default + 'a': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Behavior/Features/Fusion/Menu.feature b/Neos.Neos/Tests/Behavior/Features/Fusion/Menu.feature index d137b1b77ec..54870e7901b 100644 --- a/Neos.Neos/Tests/Behavior/Features/Fusion/Menu.feature +++ b/Neos.Neos/Tests/Behavior/Features/Fusion/Menu.feature @@ -71,8 +71,9 @@ Feature: Tests for the "Neos.Neos:Menu" and related Fusion prototypes Neos: Neos: sites: - '*': - contentRepository: default + 'a': + preset: default + uriPathSuffix: '' contentDimensions: resolver: factoryClassName: Neos\Neos\FrontendRouting\DimensionResolution\Resolver\NoopResolverFactory diff --git a/Neos.Neos/Tests/Unit/Domain/Model/SiteTest.php b/Neos.Neos/Tests/Unit/Domain/Model/SiteTest.php index b7054ba7a3e..70de94487e1 100644 --- a/Neos.Neos/Tests/Unit/Domain/Model/SiteTest.php +++ b/Neos.Neos/Tests/Unit/Domain/Model/SiteTest.php @@ -12,6 +12,7 @@ */ use Neos\Flow\Tests\UnitTestCase; use Neos\Neos\Domain\Model\Site; +use Neos\Utility\ObjectAccess; /** * Testcase for the "Site" domain model @@ -57,4 +58,51 @@ public function theSiteResourcesPackageKeyCanBeSetAndRetrieved() $site->setSiteResourcesPackageKey('Foo'); self::assertSame('Foo', $site->getSiteResourcesPackageKey()); } + + public static function getConfigurationFailingDataProvider(): iterable + { + yield 'no matching nor default site config' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => [], 'sitePresetConfiguration' => [], 'expectedExceptionMessage' => 'Missing configuration for "Neos.Neos.sites.siteNodeName" or fallback "Neos.Neos.sites.*"']; + yield 'referring non-string preset' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['siteNodeName' => ['preset' => false]], 'sitePresetConfiguration' => [], 'expectedExceptionMessage' => 'Invalid "preset" configuration for "Neos.Neos.sites.siteNodeName". Expected string, got: bool']; + yield 'referring non-existing preset' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['siteNodeName' => ['preset' => 'nonExistingPreset']], 'sitePresetConfiguration' => [], 'expectedExceptionMessage' => 'Site settings "Neos.Neos.sites.siteNodeName" refer to a preset "nonExistingPreset"']; + yield 'missing content repository identifier' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['siteNodeName' => []], 'sitePresetConfiguration' => [], 'expectedExceptionMessage' => 'There is no content repository identifier configured in Sites configuration in Settings.yaml: Neos.Neos.sites.*.contentRepository']; + yield 'missing content dimension resolver factory' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['siteNodeName' => ['contentRepository' => 'default']], 'sitePresetConfiguration' => [], 'expectedExceptionMessage' => 'No Dimension Resolver Factory configured at Neos.Neos.sites.*.contentDimensions.resolver.factoryClassName']; + } + + /** + * @test + * @dataProvider getConfigurationFailingDataProvider + */ + public function getConfigurationFailingTests(string $nodeTypeName, array $sitesConfiguration, array $sitePresetsConfiguration, string $expectedExceptionMessage): void + { + $site = new Site($nodeTypeName); + ObjectAccess::setProperty($site, 'sitesConfiguration', $sitesConfiguration, true); + ObjectAccess::setProperty($site, 'sitePresetsConfiguration', $sitePresetsConfiguration, true); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + $site->getConfiguration(); + } + + public static function getConfigurationSucceedingDataProvider(): iterable + { + yield 'minimal configuration' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['siteNodeName' => ['contentRepository' => 'default', 'contentDimensions' => ['resolver' => ['factoryClassName' => 'Foo']]]], 'sitePresetConfiguration' => [], 'expectedConfiguration' => ['contentRepositoryId' => 'default', 'contentDimensionResolverFactoryClassName' => 'Foo', 'contentDimensionResolverOptions' => [], 'defaultDimensionSpacePoint' => [], 'uriPathSuffix' => '']]; + yield 'full configuration' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['siteNodeName' => ['contentRepository' => 'custom_repo', 'contentDimensions' => ['resolver' => ['factoryClassName' => 'Bar', 'options' => ['some' => 'options']], 'defaultDimensionSpacePoint' => ['language' => 'de']], 'uriPathSuffix' => 'some-suffix']], 'sitePresetConfiguration' => [], 'expectedConfiguration' => ['contentRepositoryId' => 'custom_repo', 'contentDimensionResolverFactoryClassName' => 'Bar', 'contentDimensionResolverOptions' => ['some' => 'options'], 'defaultDimensionSpacePoint' => ['language' => 'de'], 'uriPathSuffix' => 'some-suffix']]; + yield 'full configuration from fallback' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['*' => ['contentRepository' => 'custom_repo', 'contentDimensions' => ['resolver' => ['factoryClassName' => 'Bar', 'options' => ['some' => 'options']], 'defaultDimensionSpacePoint' => ['language' => 'de']], 'uriPathSuffix' => 'some-suffix']], 'sitePresetConfiguration' => [], 'expectedConfiguration' => ['contentRepositoryId' => 'custom_repo', 'contentDimensionResolverFactoryClassName' => 'Bar', 'contentDimensionResolverOptions' => ['some' => 'options'], 'defaultDimensionSpacePoint' => ['language' => 'de'], 'uriPathSuffix' => 'some-suffix']]; + yield 'full configuration merged with preset' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['siteNodeName' => ['preset' => 'somePreset', 'contentDimensions' => ['defaultDimensionSpacePoint' => ['country' => 'DE']], 'uriPathSuffix' => 'some-overridden-suffix']], 'sitePresetConfiguration' => ['somePreset' => ['contentRepository' => 'custom_repo', 'contentDimensions' => ['resolver' => ['factoryClassName' => 'Bar', 'options' => ['some' => 'options']], 'defaultDimensionSpacePoint' => ['language' => 'de']], 'uriPathSuffix' => 'some-default-suffix']], 'expectedConfiguration' => ['contentRepositoryId' => 'custom_repo', 'contentDimensionResolverFactoryClassName' => 'Bar', 'contentDimensionResolverOptions' => ['some' => 'options'], 'defaultDimensionSpacePoint' => ['language' => 'de', 'country' => 'DE'], 'uriPathSuffix' => 'some-overridden-suffix']]; + yield 'full configuration from fallback merged with preset' => ['nodeTypeName' => 'siteNodeName', 'sitesConfiguration' => ['*' => ['preset' => 'somePreset', 'contentDimensions' => ['defaultDimensionSpacePoint' => ['country' => 'DE']], 'uriPathSuffix' => 'some-overridden-suffix']], 'sitePresetConfiguration' => ['somePreset' => ['contentRepository' => 'custom_repo', 'contentDimensions' => ['resolver' => ['factoryClassName' => 'Bar', 'options' => ['some' => 'options']], 'defaultDimensionSpacePoint' => ['language' => 'de']], 'uriPathSuffix' => 'some-default-suffix']], 'expectedConfiguration' => ['contentRepositoryId' => 'custom_repo', 'contentDimensionResolverFactoryClassName' => 'Bar', 'contentDimensionResolverOptions' => ['some' => 'options'], 'defaultDimensionSpacePoint' => ['language' => 'de', 'country' => 'DE'], 'uriPathSuffix' => 'some-overridden-suffix']]; + } + + /** + * @test + * @dataProvider getConfigurationSucceedingDataProvider + */ + public function getConfigurationSucceedingTests(string $nodeTypeName, array $sitesConfiguration, array $sitePresetsConfiguration, array $expectedConfiguration): void + { + $site = new Site($nodeTypeName); + ObjectAccess::setProperty($site, 'sitesConfiguration', $sitesConfiguration, true); + ObjectAccess::setProperty($site, 'sitePresetsConfiguration', $sitePresetsConfiguration, true); + + $configuration = $site->getConfiguration(); + self::assertSame($expectedConfiguration, json_decode(json_encode($configuration), true)); + } }