diff --git a/.tools/phpstan/baseline/missingType.iterableValue.php b/.tools/phpstan/baseline/missingType.iterableValue.php index 5d2cc9b819..f87e478d7a 100644 --- a/.tools/phpstan/baseline/missingType.iterableValue.php +++ b/.tools/phpstan/baseline/missingType.iterableValue.php @@ -33,16 +33,6 @@ 'count' => 1, 'path' => __DIR__ . '/../../../addons/debug/lib/extensions/extension_debug.php', ]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Backend\\Controller::pageAddProperties() has parameter $properties with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../src/Backend/Controller.php', -]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Backend\\Controller::pageCreate() has parameter $page with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../src/Backend/Controller.php', -]; $ignoreErrors[] = [ 'rawMessage' => 'Method Redaxo\\Core\\Content\\ArticleHandler::addArticle() has parameter $data with no value type specified in iterable type array.', 'count' => 1, diff --git a/.tools/psalm/baseline.xml b/.tools/psalm/baseline.xml index dfc62ffdb0..2e95b81af8 100644 --- a/.tools/psalm/baseline.xml +++ b/.tools/psalm/baseline.xml @@ -1491,41 +1491,9 @@ - - getProperty('page')]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/debug/package.yml b/addons/debug/package.yml deleted file mode 100644 index 5b93349678..0000000000 --- a/addons/debug/package.yml +++ /dev/null @@ -1,8 +0,0 @@ -page: - title: translate:debug - perm: admin - live_mode: false - block: system - pjax: false - icon: rex-icon rex-icon-heartbeat - linkAttr: { target: _blank } diff --git a/addons/debug/src/DebugAddon.php b/addons/debug/src/DebugAddon.php index 3d9952ad5d..df363a03ca 100644 --- a/addons/debug/src/DebugAddon.php +++ b/addons/debug/src/DebugAddon.php @@ -5,6 +5,9 @@ use Override; use Redaxo\Core\Addon\Addon; use Redaxo\Core\Addon\LoadOrder; +use Redaxo\Core\Backend\MainPage; +use Redaxo\Core\Core; +use Redaxo\Core\Translation\I18n; final class DebugAddon extends Addon { @@ -16,6 +19,19 @@ public function boot(): void $this->includeFile('boot.php'); } + #[Override] + public function getPages(): iterable + { + if (Core::isLiveMode()) { + return; + } + + yield new MainPage('system', $this->name, I18n::msg('debug')) + ->setRequiredPermissions('admin') + ->setIcon('rex-icon rex-icon-heartbeat') + ->setLinkAttr('target', '_blank'); + } + #[Override] public function install(): void { diff --git a/schemas/package.json b/schemas/package.json index 558380f7f7..8f883e909d 100644 --- a/schemas/package.json +++ b/schemas/package.json @@ -4,204 +4,10 @@ "title": "JSON schema for REDAXO package.yml", "type": "object", "properties": { - "page": { - "description": "Main page", - "$ref": "#/definitions/main-page" - }, - "pages": { - "description": "Additional pages", - "type": "object", - "patternProperties": { - "^[^/]*$": { - "$ref": "#/definitions/main-page" - } - }, - "additionalProperties": { - "$ref": "#/definitions/page" - } - }, "default_config": { "description": "Default values for Redaxo\\Core\\Config", "type": "object" } }, - "additionalProperties": true, - "definitions": { - "page-base": { - "type": "object", - "required": ["title"], - "properties": { - "title": { - "description": "Page title", - "type": "string" - }, - "hidden": { - "description": "Whether the page is hidden", - "type": "boolean", - "default": true - }, - "hasLayout": { - "description": "Whether the page has layout", - "type": "boolean", - "default": false - }, - "hasNavigation": { - "description": "Whether the page has the navigation", - "type": "boolean", - "default": false - }, - "popup": { - "description": "Whether the page is a popup", - "oneOf": [ - { - "type": "boolean", - "default": true - }, - { - "description": "onclick attribute", - "type": "string" - } - ] - }, - "pjax": { - "description": "Whether the page uses pjax", - "type": "boolean", - "default": true - }, - "perm": { - "description": "Page permission", - "type": "string", - "default": "admin" - }, - "live_mode": { - "description": "Live mode", - "type": "boolean", - "default": true - }, - "icon": { - "description": "Icon class name(s)", - "type": "string" - }, - "href": { - "description": "href attribute", - "oneOf": [ - { - "type": "string" - }, - { - "description": "href params", - "type": "object" - } - ] - }, - "itemAttr": { - "description": "Attributes for the list item", - "type": "object" - }, - "itemClass": { - "description": "class attributes for the list item", - "type": "string" - }, - "linkAttr": { - "description": "Attributes for the link", - "type": "object" - }, - "linkClass": { - "description": "class attributes for the link", - "type": "string" - }, - "path": { - "description": "Path to the main page file", - "type": "string", - "pattern": "\\.php$" - }, - "subPath": { - "description": "Path to the subpage file", - "type": "string", - "pattern": "\\.(php|md)$" - }, - "subpages": { - "description": "Subpages", - "patternProperties": { - "^[^/]+$": { - "$ref": "#/definitions/page" - } - }, - "additionalProperties": false - } - } - }, - "page": { - "allOf": [ - { - "$ref": "#/definitions/page-base" - } - ], - "properties": { - "title": true, - "hidden": true, - "hasLayout": true, - "hasNavigation": true, - "popup": true, - "pjax": true, - "perm": true, - "icon": true, - "href": true, - "itemAttr": true, - "itemClass": true, - "linkAttr": true, - "linkClass": true, - "path": true, - "subPath": true, - "subpages": true - }, - "additionalProperties": false - }, - "main-page": { - "allOf": [ - { - "$ref": "#/definitions/page-base" - } - ], - "properties": { - "title": true, - "hidden": true, - "hasLayout": true, - "hasNavigation": true, - "popup": true, - "pjax": true, - "perm": true, - "icon": true, - "href": true, - "itemAttr": true, - "itemClass": true, - "linkAttr": true, - "linkClass": true, - "path": true, - "subPath": true, - "subpages": true, - "main": { - "description": "Whether it is a main page", - "type": "boolean", - "default": true - }, - "block": { - "description": "Block name", - "anyOf": [ - { - "enum": ["system", "addons"] - }, - { - "type": "string" - } - ] - }, - "prio": { - "description": "Page prio", - "type": "integer" - } - }, - "additionalProperties": false - } - } + "additionalProperties": true } diff --git a/src/Addon/Addon.php b/src/Addon/Addon.php index 8ec3f19c92..bb356d8ec0 100644 --- a/src/Addon/Addon.php +++ b/src/Addon/Addon.php @@ -5,6 +5,7 @@ use Composer\InstalledVersions; use OutOfBoundsException; use Redaxo\Core\Addon\ExtensionPoint\AddonCacheDeleted; +use Redaxo\Core\Backend\Page; use Redaxo\Core\Config; use Redaxo\Core\Core; use Redaxo\Core\Exception\RuntimeException; @@ -445,6 +446,22 @@ final public function enlist(): void /** Boot hook — runs on every request after all addons are enlisted. Override to register listeners etc. */ public function boot(): void {} + /** + * Backend page hook — override to register the addon's backend pages. + * + * Runs only in the backend, after the core pages and all earlier-loading addons have been registered, so + * Controller::getPageObject() can be used to attach subpages to existing core or addon pages. Top-level pages + * are typically MainPage instances (shown in the navigation); a plain Page can be used for hidden entry points + * that are reachable by key/URL but should not appear in the navigation. Any page without an explicit path falls + * back to the convention `pages/.php` (or `pages/index.php` for the main page whose key equals the addon name). + * + * @return iterable + */ + public function getPages(): iterable + { + return []; + } + /** * Install hook — runs on install/reinstall. Override for schema/data setup. Must be idempotent. * diff --git a/src/Backend/Controller.php b/src/Backend/Controller.php index 9e21ffd78e..09c2241d4f 100644 --- a/src/Backend/Controller.php +++ b/src/Backend/Controller.php @@ -18,12 +18,9 @@ use Redaxo\Core\Util\Type; use Redaxo\Core\View\Fragment; -use function call_user_func; use function count; use function ini_get; use function is_array; -use function is_callable; -use function is_string; use function sprintf; use const DIRECTORY_SEPARATOR; @@ -358,71 +355,30 @@ public static function appendLoggedInPages(): void public static function appendPackagePages(): void { - $insertPages = []; $addons = Core::isSafeMode() ? Addon::getSetupAddons() : Addon::getAvailableAddons(); foreach ($addons as $addon) { - $mainPage = self::pageCreate($addon->getProperty('page'), $addon, true); - - if (is_array($pages = $addon->getProperty('pages'))) { - foreach ($pages as $key => $page) { - if (str_contains($key, '/')) { - $insertPages[$key] = [$addon, $page]; - } else { - self::pageCreate($page, $addon, false, $mainPage, $key, true); - } - } - } - } - foreach ($insertPages as $key => $packagePage) { - [$package, $page] = $packagePage; - $key = explode('/', $key); - if (!isset(self::$pages[$key[0]])) { - continue; - } - $parentPage = self::$pages[$key[0]]; - for ($i = 1, $count = count($key) - 1; $i < $count && $parentPage; ++$i) { - $parentPage = $parentPage->getSubpage($key[$i]); - } - if ($parentPage) { - self::pageCreate($page, $package, false, $parentPage, $key[$i], strtr($parentPage->getFullKey(), '/', '.') . '.' . $key[$i] . '.'); + foreach ($addon->getPages() as $page) { + self::registerAddonPage($page, $addon); } } } - private static function pageCreate(Page|array|null $page, Addon $package, bool $createMainPage, ?Page $parentPage = null, ?string $pageKey = null, bool|string $prefix = false): ?Page + /** + * Registers a top-level addon page and applies the path convention to it and its subpages. + * + * A page without an explicit path falls back to `pages/.php`, or `pages/index.php` for the main page + * whose key equals the addon name. + */ + private static function registerAddonPage(Page $page, Addon $addon): void { - if (is_array($page) && isset($page['title']) && (false !== ($page['live_mode'] ?? null) || !Core::isLiveMode())) { - $pageArray = $page; - $pageKey = $pageKey ?: $package->name; - if ($createMainPage || isset($pageArray['main']) && $pageArray['main']) { - $page = new MainPage('addons', $pageKey, $pageArray['title']); - } else { - $page = new Page($pageKey, $pageArray['title']); - } - self::pageAddProperties($page, $pageArray, $package); - } + $prefix = $page->getKey() === $addon->name ? '' : $page->getKey() . '.'; - if ($page instanceof Page) { - if (!is_string($prefix)) { - $prefix = $prefix ? $page->getKey() . '.' : ''; - } - if ($page instanceof MainPage) { - if (!$page->hasPath()) { - $page->setPath($package->getPath('pages/' . ($prefix ?: 'index.') . 'php')); - } - self::$pages[$page->getKey()] = $page; - } else { - if (!$page->hasSubPath()) { - $page->setSubPath($package->getPath('pages/' . ($prefix ?: 'index.') . 'php')); - } - if ($parentPage) { - $parentPage->addSubpage($page); - } - } - self::pageSetSubPaths($page, $package, $prefix); - return $page; + if (!$page->hasPath()) { + $page->setPath($addon->getPath('pages/' . ($prefix ?: 'index.') . 'php')); } - return null; + self::$pages[$page->getKey()] = $page; + + self::pageSetSubPaths($page, $addon, $prefix); } private static function pageSetSubPaths(Page $page, Addon $package, string $prefix = ''): void @@ -435,56 +391,6 @@ private static function pageSetSubPaths(Page $page, Addon $package, string $pref } } - private static function pageAddProperties(Page $page, array $properties, Addon $package): void - { - foreach ($properties as $key => $value) { - switch (strtolower($key)) { - case 'subpages': - if (is_array($value)) { - foreach ($value as $pageKey => $subProperties) { - if (isset($subProperties['title']) && (false !== ($subProperties['live_mode'] ?? null) || !Core::isLiveMode())) { - $subpage = new Page($pageKey, $subProperties['title']); - $page->addSubpage($subpage); - self::pageAddProperties($subpage, $subProperties, $package); - } - } - } - break; - - case 'itemattr': - case 'linkattr': - $setter = [$page, 'set' . ucfirst($key)]; - foreach ($value as $k => $v) { - call_user_func($setter, $k, $v); - } - break; - - case 'perm': - $page->setRequiredPermissions($value); - break; - - case 'path': - case 'subpath': - if (is_file($path = $package->getPath($value))) { - $value = $path; - } - // no break - default: - $adder = [$page, 'add' . ucfirst($key)]; - if (is_callable($adder)) { - foreach ((array) $value as $v) { - call_user_func($adder, $v); - } - break; - } - $setter = [$page, 'set' . ucfirst($key)]; - if (is_callable($setter)) { - call_user_func($setter, $value); - } - } - } - } - public static function checkPagePermissions(User $user): void { $check = static function (Page $page) use (&$check, $user): bool {