diff --git a/.gitignore b/.gitignore index a28f227..347b328 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,4 @@ composer.phar # ignores the cache files .php-cs-fixer.cache -/.phpunit.cache - -# env files -../app/.env -../app/.env.example \ No newline at end of file +/.phpunit.cache \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3622f6a..0d22108 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -33,7 +33,7 @@ @see https://webifycms.com/extension/base @copyright Copyright (c) 2023 - Present WebifyCMS - @license https://webifycms.com/extension/base/license + @license https://webifycms.com/license @author Mohammed Shifreen HEADER; $rules = [ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..50d84b6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# WebifyCMS Base Extension (shared-kernel) + +Package `webifycms/ext-base` (composer type `webifycms-extension`). The foundation +layer of WebifyCMS. Every other extension depends on it. It is NOT a standalone +application — it provides the bootstrap lifecycle, domain building blocks, the +infrastructure stack, and the contracts that make extensions composable. + +## Layout + +``` +src/Application/ # application-layer service contracts (ConfigInterface) +src/Contract/ # shared contracts: Collection, Translation, Exception, helpers +src/Domain/ + Contract/ # Authorization, Authentication, Identity + Entity/AggregateRoot.php + Event/ # DomainEventInterface, DomainEventPublisherInterface + Exception/ + Service/ # SlugifyInterface, UlidGeneratorInterface + ValueObject/ # AggregateId, Email, Slug, DateTime, SecureToken +src/Infrastructure/ + Container/ # PhpDiContainerBuilder + Container/Definitions/# Console, Event, Http, Logging definition groups + Contract/ # ExtensionInterface, ServiceProviderInterface, bootstrap, + # definition groups, error handler, escaper + Environment/ + Event/ # LeagueDomainEventPublisher (PSR-14 bridge) + Helper/ # Escaper + Kernel/ # Http + Console kernels + Provider/ # BaseServiceProvider + Service/ # Application, Config, SymfonyAsciiSlugify, SymfonyUlidGenerator +``` + +## Conventions + +- Namespace `Webify\Base\*` (autoload `Webify\Base\` → `src/`); tests `Webify\Base\Test\` → `test/`. +- Two provider styles: `ServiceProviderInterface::getDefinitions()` (pre-build) and + `BootstrapServiceProviderInterface::bootstrap()` (post-build). +- Extension entry points implement `ExtensionInterface`. +- ARCHITECTURE IS ENFORCED by PHPat (`test/Architecture/ArchitectureSpec.php`): + dependency direction must be Domain → Application → Infrastructure. +- VOs immutable + `fromNative()`/`toNative()` (string-backed enums allowed); aggregates extend `AggregateRoot`. + +## Notes + +- The theming system (template resolution, theme inheritance, rendering, + assets) is designed but **not implemented** in this package yet — see + `docs/theming/architecture.md` for the design pending contract implementation. + +## Verification + +```bash +composer test # PHPUnit (incl. architecture spec) +composer analyse # PHPStan level 8 +composer sniff # PHP-CS-Fixer dry-run +composer code-check # analyse + sniff +composer code-format # PHP-CS-Fixer fix +``` \ No newline at end of file diff --git a/composer.json b/composer.json index c78279f..b391827 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,7 @@ "name": "webifycms/ext-base", "description": "The WebifyCMS base extension serves as the foundation for the entire application, holding abstractions and reusable components. It is adjusted as needed when new features are introduced.", "license": "MIT", + "version": "0.1.0-alpha", "type": "webifycms-extension", "authors": [ { @@ -13,6 +14,7 @@ "prefer-stable": true, "require": { "php": ">=8.4", + "psr/cache": "^3.0", "psr/http-message": "^2.0", "psr/http-factory": "^1.1", "psr/http-server-handler": "^1.0", @@ -29,6 +31,7 @@ "league/event": "^3.0", "illuminate/database": "^13.12", "symfony/cache": "^8.1", + "symfony/filesystem": "^8.1", "symfony/translation": "^8.1", "symfony/console": "^8.1", "symfony/uid": "^8.1", @@ -52,8 +55,7 @@ }, "autoload-dev": { "psr-4": { - "Webify\\Base\\Test\\": "test/", - "Webify\\Base\\Dev\\": "dev/" + "Webify\\Base\\Test\\": "test/" } }, "optimize-autoloader": true, diff --git a/composer.lock b/composer.lock index 901ddf4..ff20d54 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8fdfc5ac27aeb6133d6cbb9893f66c9c", + "content-hash": "019b69f33054f91700fe7480efb5e173", "packages": [ { "name": "brick/math", - "version": "0.17.2", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", - "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "shasum": "" }, "require": { @@ -55,7 +55,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.2" + "source": "https://github.com/brick/math/tree/0.18.0" }, "funding": [ { @@ -63,7 +63,7 @@ "type": "github" } ], - "time": "2026-05-25T20:34:43+00:00" + "time": "2026-06-14T18:21:03+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -288,16 +288,16 @@ }, { "name": "illuminate/collections", - "version": "v13.12.0", + "version": "v13.25.0", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "1dbd2611b2cd025d3526e64bd7670033ff883173" + "reference": "e89523328db4947d0a239de5607823402c414825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/1dbd2611b2cd025d3526e64bd7670033ff883173", - "reference": "1dbd2611b2cd025d3526e64bd7670033ff883173", + "url": "https://api.github.com/repos/illuminate/collections/zipball/e89523328db4947d0a239de5607823402c414825", + "reference": "e89523328db4947d0a239de5607823402c414825", "shasum": "" }, "require": { @@ -344,11 +344,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-19T14:10:53+00:00" + "time": "2026-08-07T13:46:56+00:00" }, { "name": "illuminate/conditionable", - "version": "v13.12.0", + "version": "v13.25.0", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -394,16 +394,16 @@ }, { "name": "illuminate/container", - "version": "v13.12.0", + "version": "v13.25.0", "source": { "type": "git", "url": "https://github.com/illuminate/container.git", - "reference": "be0ad39a3abf4d9a498952e58908c5d6da0f5ede" + "reference": "8d28410dcefce414b2f159a23c59c28f63362520" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/container/zipball/be0ad39a3abf4d9a498952e58908c5d6da0f5ede", - "reference": "be0ad39a3abf4d9a498952e58908c5d6da0f5ede", + "url": "https://api.github.com/repos/illuminate/container/zipball/8d28410dcefce414b2f159a23c59c28f63362520", + "reference": "8d28410dcefce414b2f159a23c59c28f63362520", "shasum": "" }, "require": { @@ -452,20 +452,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-13T13:58:52+00:00" + "time": "2026-08-05T13:59:39+00:00" }, { "name": "illuminate/contracts", - "version": "v13.12.0", + "version": "v13.25.0", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", - "reference": "dc805389d17f13821726423bf4fc90d3162bf90d" + "reference": "52fd3118b71254dd083eca6acba39b3f7325ec9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/dc805389d17f13821726423bf4fc90d3162bf90d", - "reference": "dc805389d17f13821726423bf4fc90d3162bf90d", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/52fd3118b71254dd083eca6acba39b3f7325ec9a", + "reference": "52fd3118b71254dd083eca6acba39b3f7325ec9a", "shasum": "" }, "require": { @@ -500,26 +500,27 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-21T13:18:48+00:00" + "time": "2026-08-10T17:21:54+00:00" }, { "name": "illuminate/database", - "version": "v13.12.0", + "version": "v13.25.0", "source": { "type": "git", "url": "https://github.com/illuminate/database.git", - "reference": "b906694200284bea640c6014534473426ec83dd8" + "reference": "2145c57aaf9487eb0188a57566a8d85b689875d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/database/zipball/b906694200284bea640c6014534473426ec83dd8", - "reference": "b906694200284bea640c6014534473426ec83dd8", + "url": "https://api.github.com/repos/illuminate/database/zipball/2145c57aaf9487eb0188a57566a8d85b689875d1", + "reference": "2145c57aaf9487eb0188a57566a8d85b689875d1", "shasum": "" }, "require": { - "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17", + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18", "ext-pdo": "*", "illuminate/collections": "^13.0", + "illuminate/conditionable": "^13.0", "illuminate/container": "^13.0", "illuminate/contracts": "^13.0", "illuminate/macroable": "^13.0", @@ -573,11 +574,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-25T23:05:07+00:00" + "time": "2026-08-06T14:26:51+00:00" }, { "name": "illuminate/macroable", - "version": "v13.12.0", + "version": "v13.25.0", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -623,16 +624,16 @@ }, { "name": "illuminate/reflection", - "version": "v13.12.0", + "version": "v13.25.0", "source": { "type": "git", "url": "https://github.com/illuminate/reflection.git", - "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c" + "reference": "2b6ba33970dd84849322a6a9857647ca96c1bb66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/reflection/zipball/4fe1659f068ab2b50131cf906c5d8bba4e34df0c", - "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/2b6ba33970dd84849322a6a9857647ca96c1bb66", + "reference": "2b6ba33970dd84849322a6a9857647ca96c1bb66", "shasum": "" }, "require": { @@ -670,20 +671,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-10T20:04:12+00:00" + "time": "2026-08-03T17:56:24+00:00" }, { "name": "illuminate/support", - "version": "v13.12.0", + "version": "v13.25.0", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "6f6d7e1743490638c2e0d62bdbae4cf05fbf577a" + "reference": "096840740d89aa2439b02c910c2b1c7cff76d8e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/6f6d7e1743490638c2e0d62bdbae4cf05fbf577a", - "reference": "6f6d7e1743490638c2e0d62bdbae4cf05fbf577a", + "url": "https://api.github.com/repos/illuminate/support/zipball/096840740d89aa2439b02c910c2b1c7cff76d8e5", + "reference": "096840740d89aa2439b02c910c2b1c7cff76d8e5", "shasum": "" }, "require": { @@ -749,7 +750,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-25T23:04:25+00:00" + "time": "2026-08-10T13:19:41+00:00" }, { "name": "laminas/laminas-httphandlerrunner", @@ -820,16 +821,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.13", + "version": "v2.0.15", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", "shasum": "" }, "require": { @@ -877,7 +878,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-04-16T14:03:50+00:00" + "time": "2026-07-21T16:49:22+00:00" }, { "name": "league/event", @@ -1133,16 +1134,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.4", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", - "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", "shasum": "" }, "require": { @@ -1234,20 +1235,20 @@ "type": "tidelift" } ], - "time": "2026-04-07T09:57:54+00:00" + "time": "2026-08-08T11:40:35+00:00" }, { "name": "nikic/fast-route", - "version": "v1.3.0", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/nikic/FastRoute.git", - "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + "reference": "7476684f39bb9124b3be69ed3d84b66723920298" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", - "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/7476684f39bb9124b3be69ed3d84b66723920298", + "reference": "7476684f39bb9124b3be69ed3d84b66723920298", "shasum": "" }, "require": { @@ -1282,9 +1283,9 @@ ], "support": { "issues": "https://github.com/nikic/FastRoute/issues", - "source": "https://github.com/nikic/FastRoute/tree/master" + "source": "https://github.com/nikic/FastRoute/tree/1.3.1" }, - "time": "2018-02-13T20:26:39+00:00" + "time": "2026-07-09T19:38:47+00:00" }, { "name": "nyholm/psr7", @@ -2157,16 +2158,16 @@ }, { "name": "symfony/cache", - "version": "v8.1.0", + "version": "v8.1.4", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed" + "reference": "77aaed12441eff519d7028cb851f893d7969f768" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", - "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", + "url": "https://api.github.com/repos/symfony/cache/zipball/77aaed12441eff519d7028cb851f893d7969f768", + "reference": "77aaed12441eff519d7028cb851f893d7969f768", "shasum": "" }, "require": { @@ -2232,7 +2233,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.1.0" + "source": "https://github.com/symfony/cache/tree/v8.1.4" }, "funding": [ { @@ -2252,20 +2253,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-01T16:22:31+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "225e8a254166bd3442e370c6f50145465db63831" + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", - "reference": "225e8a254166bd3442e370c6f50145465db63831", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { @@ -2312,7 +2313,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -2332,7 +2333,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:33:14+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/clock", @@ -2413,16 +2414,16 @@ }, { "name": "symfony/console", - "version": "v8.1.0", + "version": "v8.1.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" + "reference": "68efa2ebfd9a362951eb5a8b09fd177c66ddec24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", - "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "url": "https://api.github.com/repos/symfony/console/zipball/68efa2ebfd9a362951eb5a8b09fd177c66ddec24", + "reference": "68efa2ebfd9a362951eb5a8b09fd177c66ddec24", "shasum": "" }, "require": { @@ -2489,7 +2490,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.0" + "source": "https://github.com/symfony/console/tree/v8.1.4" }, "funding": [ { @@ -2509,20 +2510,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-31T12:43:13+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -2560,7 +2561,78 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -2580,7 +2652,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2667,16 +2739,16 @@ }, { "name": "symfony/polyfill-deepclone", - "version": "v1.37.0", + "version": "v1.40.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/dca4ccba5f360070b574414dce4c1e7a559844fa", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa", "shasum": "" }, "require": { @@ -2730,7 +2802,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.40.0" }, "funding": [ { @@ -2750,20 +2822,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:03:27+00:00" + "time": "2026-06-12T07:27:17+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -2812,7 +2884,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -2832,7 +2904,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-normalizer", @@ -2921,16 +2993,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -2982,7 +3054,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -3002,7 +3074,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -3170,16 +3242,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -3226,7 +3298,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -3246,20 +3318,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php86", - "version": "v1.38.0", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php86.git", - "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad" + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", - "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", "shasum": "" }, "require": { @@ -3306,7 +3378,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" }, "funding": [ { @@ -3326,7 +3398,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T11:52:35+00:00" + "time": "2026-07-02T13:42:24+00:00" }, { "name": "symfony/polyfill-uuid", @@ -3413,16 +3485,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -3476,7 +3548,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -3496,20 +3568,20 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { @@ -3566,7 +3638,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -3586,20 +3658,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { "name": "symfony/translation", - "version": "v8.1.0", + "version": "v8.1.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "url": "https://api.github.com/repos/symfony/translation/zipball/c0955eb4aa417a110e65c8162237b8a5c7d910bf", + "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf", "shasum": "" }, "require": { @@ -3659,7 +3731,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.0" + "source": "https://github.com/symfony/translation/tree/v8.1.4" }, "funding": [ { @@ -3679,20 +3751,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-30T12:40:56+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -3741,7 +3813,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3761,20 +3833,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", - "version": "v8.1.0", + "version": "v8.1.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" + "reference": "50e98f8bc4c3fcdaf925545a65150fb42cf0caf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", - "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", + "url": "https://api.github.com/repos/symfony/uid/zipball/50e98f8bc4c3fcdaf925545a65150fb42cf0caf2", + "reference": "50e98f8bc4c3fcdaf925545a65150fb42cf0caf2", "shasum": "" }, "require": { @@ -3819,7 +3891,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.1.0" + "source": "https://github.com/symfony/uid/tree/v8.1.4" }, "funding": [ { @@ -3839,26 +3911,26 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-02T11:29:46+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.1.0", + "version": "v8.1.4", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" + "reference": "6efa89335ab6d336643a807fcc89ae4f8a7a17e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", - "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/6efa89335ab6d336643a807fcc89ae4f8a7a17e9", + "reference": "6efa89335ab6d336643a807fcc89ae4f8a7a17e9", "shasum": "" }, "require": { "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-deepclone": "^1.37" + "symfony/polyfill-deepclone": "^1.40" }, "require-dev": { "symfony/property-access": "^7.4|^8.0", @@ -3902,7 +3974,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.0" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.4" }, "funding": [ { @@ -3922,20 +3994,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-30T12:40:56+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -3994,7 +4066,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -4006,7 +4078,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" }, { "name": "voku/portable-ascii", @@ -4150,28 +4222,29 @@ }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -4209,7 +4282,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -4219,13 +4292,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "composer/semver", @@ -4683,16 +4752,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.3", + "version": "v3.95.19", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "3d681493acc0e93283481b1c63c263737df78687" + "reference": "8a06c4ad6bd580b7920bd5ff81b271ca1158507e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3d681493acc0e93283481b1c63c263737df78687", - "reference": "3d681493acc0e93283481b1c63c263737df78687", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/8a06c4ad6bd580b7920bd5ff81b271ca1158507e", + "reference": "8a06c4ad6bd580b7920bd5ff81b271ca1158507e", "shasum": "" }, "require": { @@ -4710,7 +4779,7 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", @@ -4726,16 +4795,16 @@ "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.11.0", "infection/infection": "^0.32.7", - "justinrainbow/json-schema": "^6.8.0", + "justinrainbow/json-schema": "^6.10.0", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.37", - "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11" + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -4776,7 +4845,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.3" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.19" }, "funding": [ { @@ -4784,7 +4853,7 @@ "type": "github" } ], - "time": "2026-05-29T20:35:26+00:00" + "time": "2026-08-17T16:24:07+00:00" }, { "name": "middlewares/utils", @@ -4903,20 +4972,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -4951,32 +5020,31 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -5015,9 +5083,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -5196,11 +5264,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.1", + "version": "2.2.8", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dea9c8f2d25cc849391042b71e429c1a4bf82660", - "reference": "dea9c8f2d25cc849391042b71e429c1a4bf82660", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", + "reference": "e285254e60f33c21902efef4a926ca0987c06804", "shasum": "" }, "require": { @@ -5256,20 +5324,20 @@ "type": "github" } ], - "time": "2026-05-28T14:44:12+00:00" + "time": "2026-08-04T22:21:45+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", - "version": "2.0.4", + "version": "2.0.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", - "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc" + "reference": "67bedd65c24bc72840afc45aed48b1059dd44bec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/6b5571001a7f04fa0422254c30a0017ec2f2cacc", - "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/67bedd65c24bc72840afc45aed48b1059dd44bec", + "reference": "67bedd65c24bc72840afc45aed48b1059dd44bec", "shasum": "" }, "require": { @@ -5279,7 +5347,8 @@ "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" }, "type": "phpstan-extension", "extra": { @@ -5304,27 +5373,28 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", - "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.4" + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.5" }, - "time": "2026-02-09T13:21:14+00:00" + "time": "2026-07-22T06:50:43+00:00" }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.16", + "version": "2.0.18", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", - "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30", + "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30", "shasum": "" }, "require": { + "phar-io/version": "^3.2", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.2.3" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -5334,7 +5404,8 @@ "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" }, "type": "phpstan-extension", "extra": { @@ -5360,33 +5431,34 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18" }, - "time": "2026-02-14T09:05:21+00:00" + "time": "2026-07-04T12:16:09+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.0.11", + "version": "2.0.12", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "9b000a578b85b32945b358b172c7b20e91189024" + "reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024", - "reference": "9b000a578b85b32945b358b172c7b20e91189024", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/2bc5ae19ae965663b62ac907ee6342c3903ec93b", + "reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.39" + "phpstan/phpstan": "^2.1.52" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" }, "type": "phpstan-extension", "extra": { @@ -5411,22 +5483,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.12" }, - "time": "2026-05-02T06:54:10+00:00" + "time": "2026-07-19T07:24:06+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.1.10", + "version": "14.3.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "3719c5b6c045761798238ebacfee1fe06e4ce5be" + "reference": "6ce313bb110384148d1dc7695a99175f59529069" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/3719c5b6c045761798238ebacfee1fe06e4ce5be", - "reference": "3719c5b6c045761798238ebacfee1fe06e4ce5be", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6ce313bb110384148d1dc7695a99175f59529069", + "reference": "6ce313bb110384148d1dc7695a99175f59529069", "shasum": "" }, "require": { @@ -5434,18 +5506,18 @@ "ext-libxml": "*", "ext-mbstring": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", + "nikic/php-parser": "^5.8.0", "php": ">=8.4", "phpunit/php-text-template": "^6.0", "sebastian/complexity": "^6.0", "sebastian/environment": "^9.3.2", "sebastian/git-state": "^1.0", - "sebastian/lines-of-code": "^5.0.1", + "sebastian/lines-of-code": "^5.0.2", "sebastian/version": "^7.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.1.13" + "phpunit/phpunit": "^13.3.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -5454,7 +5526,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "14.1.x-dev" + "dev-main": "14.3.x-dev" } }, "autoload": { @@ -5483,7 +5555,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.10" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.1" }, "funding": [ { @@ -5503,27 +5575,27 @@ "type": "tidelift" } ], - "time": "2026-06-01T13:26:42+00:00" + "time": "2026-08-16T05:23:47+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "7.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + "reference": "3ccaa29123548190af12fee7af078dcd7f3ddfab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3ccaa29123548190af12fee7af078dcd7f3ddfab", + "reference": "3ccaa29123548190af12fee7af078dcd7f3ddfab", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { @@ -5556,7 +5628,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.1" }, "funding": [ { @@ -5576,7 +5648,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:33:26+00:00" + "time": "2026-08-10T06:43:12+00:00" }, { "name": "phpunit/php-invoker", @@ -5800,44 +5872,45 @@ }, { "name": "phpunit/phpunit", - "version": "13.1.13", + "version": "13.3.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ddf7f25d9ee9652b464475d7f3bacde2613e355e" + "reference": "fc024931d6ad047404e9d86536735923fe63a06b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddf7f25d9ee9652b464475d7f3bacde2613e355e", - "reference": "ddf7f25d9ee9652b464475d7f3bacde2613e355e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc024931d6ad047404e9d86536735923fe63a06b", + "reference": "fc024931d6ad047404e9d86536735923fe63a06b", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.4", + "myclabs/deep-copy": "^1.14.0", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.1.9", - "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-code-coverage": "^14.3", + "phpunit/php-file-iterator": "^7.0.1", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", "phpunit/php-timer": "^9.0.0", - "sebastian/cli-parser": "^5.0.0", - "sebastian/comparator": "^8.2.1", - "sebastian/diff": "^8.3.0", + "sebastian/cli-parser": "^5.0.1", + "sebastian/comparator": "^8.4", + "sebastian/diff": "^9.0", "sebastian/environment": "^9.3.2", - "sebastian/exporter": "^8.1.0", + "sebastian/exporter": "^8.2.1", + "sebastian/file-filter": "^1.0", "sebastian/git-state": "^1.0", - "sebastian/global-state": "^9.0.0", - "sebastian/object-enumerator": "^8.0.0", - "sebastian/recursion-context": "^8.0.0", - "sebastian/type": "^7.0.1", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.1.0", + "sebastian/recursion-context": "^8.0.1", + "sebastian/type": "^7.0.2", "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -5847,7 +5920,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "13.1-dev" + "dev-main": "13.3-dev" } }, "autoload": { @@ -5879,7 +5952,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.13" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.1" }, "funding": [ { @@ -5887,7 +5960,7 @@ "type": "other" } ], - "time": "2026-05-27T14:03:08+00:00" + "time": "2026-08-13T13:14:23+00:00" }, { "name": "react/cache", @@ -6417,21 +6490,21 @@ }, { "name": "rector/rector", - "version": "2.4.5", + "version": "2.6.2", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" + "reference": "03cd615cdd5648abb5f10ff3a684fdb976687190" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", - "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/03cd615cdd5648abb5f10ff3a684fdb976687190", + "reference": "03cd615cdd5648abb5f10ff3a684fdb976687190", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.56" + "phpstan/phpstan": "^2.2.6" }, "conflict": { "rector/rector-doctrine": "*", @@ -6465,7 +6538,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.5" + "source": "https://github.com/rectorphp/rector/tree/2.6.2" }, "funding": [ { @@ -6473,7 +6546,7 @@ "type": "github" } ], - "time": "2026-05-26T21:03:22+00:00" + "time": "2026-08-12T06:23:05+00:00" }, { "name": "roave/security-advisories", @@ -6481,18 +6554,19 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "e87a6b92f5a78dab4061c1c933a8ada5a88d569e" + "reference": "52fa31ed9e3b6f830bec2e0443041750d2bd73e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/e87a6b92f5a78dab4061c1c933a8ada5a88d569e", - "reference": "e87a6b92f5a78dab4061c1c933a8ada5a88d569e", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/52fa31ed9e3b6f830bec2e0443041750d2bd73e1", + "reference": "52fa31ed9e3b6f830bec2e0443041750d2bd73e1", "shasum": "" }, "conflict": { "3f/pygmentize": "<1.2", "adaptcms/adaptcms": "<=1.3", - "admidio/admidio": "<=5.0.9", + "adawolfa/isdoc": "<1.4.3|>=1.5,<1.5.1|>=1.6,<1.6.1", + "admidio/admidio": "<=5.0.11", "adodb/adodb-php": "<=5.22.9", "aheinze/cockpit": "<2.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", @@ -6503,6 +6577,7 @@ "aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7", "aimeos/aimeos-laravel": "==2021.10", "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", + "aimeos/pagible": "<0.10.4", "airesvsg/acf-to-rest-api": "<=3.1", "akaunting/akaunting": "<2.1.13", "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", @@ -6525,8 +6600,10 @@ "aoe/restler": "<1.7.1", "apache-solr-for-typo3/solr": "<2.8.3", "apereo/phpcas": "<1.6", - "api-platform/core": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", + "api-platform/core": "<4.1.30|>=4.2,<4.2.26|>=4.3,<4.3.12", "api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", + "api-platform/hal": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", + "api-platform/json-api": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", "appwrite/server-ce": "<=1.2.1", "arc/web": "<3", "area17/twill": "<1.2.5|>=2,<2.5.3", @@ -6539,7 +6616,7 @@ "austintoddj/canvas": "<=3.4.2", "auth0/auth0-php": ">=3.3,<=8.18", "auth0/login": "<=7.20", - "auth0/symfony": "<=5.7", + "auth0/symfony": "<=5.8", "auth0/wordpress": "<=5.5", "automad/automad": "<=2.0.0.0-beta27", "automattic/jetpack": "<9.8", @@ -6549,7 +6626,7 @@ "azuracast/azuracast": "<=0.23.5", "b13/seo_basics": "<0.8.2", "backdrop/backdrop": "<=1.32", - "backpack/crud": "<3.4.9", + "backpack/crud": "<4.0.63|>=4.1,<4.1.69|>=5,<5.0.13", "backpack/filemanager": "<2.0.2|>=3,<3.0.9", "bacula-web/bacula-web": "<9.7.1", "badaso/core": "<=2.9.11", @@ -6566,6 +6643,7 @@ "bedita/bedita": "<4", "bednee/cooluri": "<1.0.30", "bigfork/silverstripe-form-capture": ">=3,<3.1.1", + "billabear/billabear": "<=2025.01.03", "billz/raspap-webgui": "<3.3.6", "binarytorch/larecipe": "<2.8.1", "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", @@ -6580,19 +6658,20 @@ "brotkrueml/codehighlight": "<2.7", "brotkrueml/schema": "<1.13.1|>=2,<2.5.1", "brotkrueml/typo3-matomo-integration": "<1.3.2", - "buddypress/buddypress": "<7.2.1", + "buddypress/buddypress": "<14.5", "bugsnag/bugsnag-laravel": ">=2,<2.0.2", "bvbmedia/multishop": "<2.0.39", "bytefury/crater": "<6.0.2", "cachethq/cachet": "<2.5.1", "cadmium-org/cadmium-cms": "<=0.4.9", - "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10|>=5.2.10,<5.2.12|==5.3", + "cakephp/authentication": "<2.11.1|>=3,<3.3.6|>=4,<4.1.1", + "cakephp/cakephp": "<4.5.11|>=4.6,<4.6.4|>=5,<5.1.7|>=5.2,<5.2.13|>=5.3,<5.3.6", "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cardgate/magento2": "<2.0.33", "cardgate/woocommerce": "<=3.1.15", - "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4", "cart2quote/module-quotation-encoded": ">=4.1.6,<=4.4.5|>=5,<5.4.4", - "cartalyst/sentry": "<=2.1.6", + "cartalyst/sentry": "<2.1.7", "catfan/medoo": "<1.7.5", "causal/oidc": "<4", "cecil/cecil": "<7.47.1", @@ -6607,35 +6686,36 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22", + "code16/sharp": "<9.22.3", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", - "codeigniter4/framework": "<4.6.2", + "codeigniter4/framework": "<4.7.4", "codeigniter4/shield": "<1.0.0.0-beta8", "codiad/codiad": "<=2.8.4", "codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9", "codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5", "commerceteam/commerce": ">=0.9.6,<0.9.9", "components/jquery": ">=1.0.3,<3.5", - "composer/composer": "<2.2.28|>=2.3,<2.9.8", - "concrete5/concrete5": "<9.4.8", + "composer/composer": "<2.2.29|>=2.3,<2.10.2", + "concrete5/concrete5": "<9.5.2", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", - "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<4.13.56|>=5,<5.3.38|>=5.4.0.0-RC1-dev,<5.6.1", + "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<5.3.48|>=5.4,<5.7.9", "contao/core": "<3.5.39", - "contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5", + "contao/core-bundle": "<5.3.48|>=5.4,<5.7.9", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "coreshop/core-shop": "<4.1.9|==5", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", + "cotonti/cotonti": "<=1", "couleurcitron/tarteaucitron-wp": "<0.3", "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", "craftcms/aws-s3": ">=2.0.2,<=2.2.4", "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", - "craftcms/cms": "<4.17.12|>=5,<5.9.18", - "craftcms/commerce": ">=4,<4.11|>=5,<5.6", + "craftcms/cms": "<4.18.3|>=5,<5.10.8", + "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", @@ -6676,7 +6756,7 @@ "doctrine/mongodb-odm-bundle": "<3.0.1", "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", "dolibarr/dolibarr": "<=23.0.2", - "dompdf/dompdf": "<2.0.4", + "dompdf/dompdf": "<3.1.6", "doublethreedigital/guest-entries": "<3.1.2", "dreamfactory/df-core": "<1.0.4", "drupal-pattern-lab/unified-twig-extensions": "<=0.1", @@ -6690,7 +6770,7 @@ "drupal/commerce_alphabank_redirect": "<1.0.3", "drupal/commerce_eurobank_redirect": "<2.1.1", "drupal/config_split": "<1.10|>=2,<2.0.2", - "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.4.9|>=10.5,<10.5.6|>=11,<11.1.9|>=11.2,<11.2.8", + "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.5.10|>=10.6,<10.6.9|>=11,<11.2.12|>=11.3,<11.3.10", "drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", "drupal/currency": "<3.5", "drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", @@ -6717,10 +6797,11 @@ "drupal/umami_analytics": "<1.0.1", "duncanmcclean/guest-entries": "<3.1.2", "dweeves/magmi": "<=0.7.24", + "easycorp/easyadmin-bundle": ">=4,<4.29.16|>=5,<5.5.1", "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1", "ecodev/newsletter": "<=4", "ectouch/ectouch": "<=2.7.2", - "egroupware/egroupware": "<23.1.20260113|>=26.0.20251208,<26.0.20260113", + "egroupware/egroupware": "<23.1.20260601|>=26.0.20251208,<26.5.20260507", "elefant/cms": "<2.0.7", "elgg/elgg": "<3.3.24|>=4,<4.0.5", "elijaa/phpmemcacheadmin": "<=1.3", @@ -6755,15 +6836,16 @@ "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<=4.2", "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", - "facturascripts/facturascripts": "<=2025.92|>=2026,<=2026.1", + "facturascripts/facturascripts": "<=2026.2", "fastly/magento2": "<1.2.26", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", "fenom/fenom": "<=2.12.1", - "filament/actions": ">=3.2,<3.2.123", - "filament/filament": ">=4,<4.3.1", - "filament/infolists": ">=3,<3.2.115", - "filament/tables": ">=3,<3.2.115|>=4,<4.8.5|>=5,<5.3.5", + "filament/actions": ">=3.2,<3.2.123|>=4,<=4.11.3|>=5,<=5.6.3", + "filament/filament": ">=3,<=3.3.51|>=4,<4.11.5|>=5,<5.6.5", + "filament/forms": ">=3,<=3.3.52", + "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", + "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", "filegator/filegator": "<7.8", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", @@ -6800,19 +6882,19 @@ "friendsoftypo3/tt-address": "<8.1.2|>=9,<9.1.1|>=10,<10.0.1", "froala/wysiwyg-editor": "<=4.3", "frosh/adminer-platform": "<2.2.1", - "froxlor/froxlor": "<=2.3.6", + "froxlor/froxlor": "<2.3.7", "frozennode/administrator": "<=5.0.12", "fuel/core": "<1.8.1", "funadmin/funadmin": "<=7.1.0.0-RC6", "gaoming13/wechat-php-sdk": "<=1.10.2", "genix/cms": "<=1.1.11", - "georgringer/news": "<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", + "georgringer/news": "<10.0.4|>=11,<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", - "getgrav/grav": "<=2.0.0.0-RC1", + "getgrav/grav": "<=2.0.0.0-RC8", "getgrav/grav-plugin-api": "<1.0.0.0-beta15", "getgrav/grav-plugin-form": "<9.1", - "getkirby/cms": "<=4.9|>=5,<=5.4", + "getkirby/cms": "<=4.9.3|>=5,<=5.4.3", "getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1", "getkirby/panel": "<2.5.14", "getkirby/starterkit": "<=3.7.0.2", @@ -6827,11 +6909,12 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", - "grumpydictator/firefly-iii": "<6.1.17|>=6.4.23,<=6.5", + "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", - "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", + "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", + "guzzlehttp/guzzle-services": "<1.5.4", "guzzlehttp/oauth-subscriber": "<0.8.1", - "guzzlehttp/psr7": "<1.9.1|>=2,<2.4.5", + "guzzlehttp/psr7": "<2.12.3", "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", "handcraftedinthealps/goodby-csv": "<1.4.3", "harvesthq/chosen": "<1.8.7", @@ -6860,6 +6943,7 @@ "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4", "illuminate/database": "<6.20.26|>=7,<7.30.5|>=8,<8.40", "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", + "illuminate/mail": ">=9,<12.60|>=13,<13.10", "illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75", "imdbphp/imdbphp": "<=5.1.1", "impresscms/impresscms": "<=1.4.5", @@ -6873,7 +6957,7 @@ "inter-mediator/inter-mediator": "==5.5", "intercom/intercom-php": "==5.0.2", "invoiceninja/invoiceninja": "<5.13.4", - "ipl/web": "<=0.13", + "ipl/web": "<=0.10.2|>=0.11,<=0.13", "islandora/crayfish": "<4.1", "islandora/islandora": ">=2,<2.4.1", "ivankristianto/phpwhois": "<=4.3", @@ -6885,6 +6969,7 @@ "jasig/phpcas": "<1.3.3", "jbartels/wec-map": "<3.0.3", "jcbrand/converse.js": "<3.3.3", + "jleehr/canto-saas-api": "<=2", "joedolson/my-calendar": "<3.7.7", "joelbutcher/socialstream": "<5.6|>=6,<6.2", "johnbillion/query-monitor": "<3.20.4", @@ -6911,7 +6996,7 @@ "kelvinmo/simplexrd": "<3.1.1", "kevinpapst/kimai2": "<1.16.7", "khodakhah/nodcms": "<=3.4.1", - "kimai/kimai": "<=2.55", + "kimai/kimai": "<2.59", "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", "klaviyo/magento2-extension": ">=1,<3", "knplabs/knp-snappy": "<=1.7", @@ -6928,7 +7013,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<10.48.29|>=11,<11.44.1|>=12,<12.1.1", + "laravel/framework": "<12.61.1|>=13,<13.12", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -6938,16 +7023,16 @@ "lavalite/cms": "<=10.1", "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", - "league/commonmark": "<=2.8.1", + "league/commonmark": "<2.9", "league/flysystem": "<1.1.4|>=2,<2.1.1", "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", "leantime/leantime": "<3.3", "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", "libreform/libreform": ">=2,<=2.0.8", - "librenms/librenms": "<26.3", + "librenms/librenms": "<=26.4", "liftkit/database": "<2.13.2", "lightsaml/lightsaml": "<1.3.5", - "limesurvey/limesurvey": "<6.15.4", + "limesurvey/limesurvey": "<=7.0.0.0-beta1", "livehelperchat/livehelperchat": "<=3.91", "livewire-filemanager/filemanager": "<=1.0.4", "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4", @@ -6970,22 +7055,24 @@ "maikuolan/phpmussel": ">=1,<1.6", "mainwp/mainwp": "<=4.4.3.3", "manogi/nova-tiptap": "<=3.2.6", - "mantisbt/mantisbt": "<2.28.2", + "mantisbt/mantisbt": "<=2.28.3", "marcwillmann/turn": "<0.3.3", "markhuot/craftql": "<=1.3.7", "marshmallow/nova-tiptap": "<5.7", "matomo/matomo": "<1.11", "matyhtf/framework": "<3.0.6", - "mautic/core": "<5.2.10|>=6,<6.0.8|>=7.0.0.0-alpha,<7.0.1", + "mautic/core": "<5.2.11|>=6,<6.0.9|>=7,<7.1.2", "mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1", "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", + "mcp/sdk": ">=0.5,<0.7.1", "mdanter/ecc": "<2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", "mediawiki/cargo": "<3.8.3", "mediawiki/core": "<1.39.5|==1.40", "mediawiki/data-transfer": ">=1.39,<1.39.11|>=1.41,<1.41.3|>=1.42,<1.42.2", + "mediawiki/maps": "<12.1.3", "mediawiki/matomo": "<2.4.3", "mediawiki/semantic-media-wiki": "<4.0.2", "mehrwert/phpmyadmin": "<3.2", @@ -7019,6 +7106,7 @@ "movim/moxl": ">=0.8,<=0.10", "movingbytes/social-network": "<=1.2.1", "mpdf/mpdf": "<=7.1.7", + "mtdowling/jmespath.php": "<2.9.1", "munkireport/comment": "<4", "munkireport/managedinstalls": "<2.6", "munkireport/munki_facts": "<1.5", @@ -7046,11 +7134,11 @@ "nilsteampassnet/teampass": "<3.1.3.1-dev", "nitsan/ns-backup": "<13.0.1", "nonfiction/nterchange": "<4.1.1", - "notrinos/notrinos-erp": "<=0.7", + "notrinos/notrinos-erp": "<=1", "noumo/easyii": "<=0.9", "novaksolutions/infusionsoft-php-sdk": "<1", "novosga/novosga": "<=2.2.12", - "nukeviet/nukeviet": "<4.5.02", + "nukeviet/nukeviet": "<4.6.00", "nyholm/psr7": "<1.6.1", "nystudio107/craft-seomatic": "<3.4.12", "nzedb/nzedb": "<0.8", @@ -7078,8 +7166,10 @@ "oro/customer-portal": ">=4.1,<=4.1.13|>=4.2,<=4.2.10|>=5,<=5.0.11|>=5.1,<=5.1.3", "oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<=4.2.10|>=5,<=5.0.12|>=5.1,<=5.1.3", "oveleon/contao-cookiebar": "<1.16.3|>=2,<2.1.3", - "oxid-esales/oxideshop-ce": "<=7.0.5", + "oxid-esales/oxideshop-ce": "<4.5|>=6,<6.14.4", + "oxid-esales/oxideshop-metapackage-ce": ">=6,<6.5.5", "oxid-esales/paymorrow-module": ">=1,<1.0.2|>=2,<2.0.1", + "oxid-esales/smarty-component": "<1.0.1", "packbackbooks/lti-1-3-php-library": "<5", "padraic/humbug_get_contents": "<1.1.2", "pagarme/pagarme-php": "<3", @@ -7088,6 +7178,7 @@ "paragonie/random_compat": "<2", "paragonie/sodium_compat": "<1.24|>=2,<2.5", "passbolt/passbolt_api": "<4.6.2", + "paymenter/paymenter": "<=1.5.4", "paypal/adaptivepayments-sdk-php": "<=3.9.2", "paypal/invoice-sdk-php": "<=3.9", "paypal/merchant-sdk-php": "<3.12", @@ -7100,23 +7191,27 @@ "pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1", "personnummer/personnummer": "<3.0.2", "ph7software/ph7builder": "<=17.9.1", - "phanan/koel": "<=9.3.4", + "phanan/koel": "<=9.7", + "pheditor/pheditor": "<2.0.8", "phenx/php-svg-lib": "<0.5.2", "php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5", "php-mod/curl": "<2.3.2", - "phpbb/phpbb": "<3.3.11", + "php-standard-library/h2": ">=6.1,<6.1.2|>=6.2,<6.2.1", + "php-standard-library/php-standard-library": ">=6.1,<6.1.2|>=6.2,<6.2.1", + "phpbb/phpbb": "<3.3.16|==4.0.0.0-alpha1", + "phpcsstandards/phpcsutils": ">=1.0.0.0-alpha1,<1.2.3", "phpems/phpems": ">=6,<=6.1.3", "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<4.1.3", + "phpmyfaq/phpmyfaq": "<4.1.4", "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", - "phpoffice/phpspreadsheet": "<=1.30.3|>=2,<=2.1.15|>=2.2,<=2.4.4|>=3,<=3.10.4|>=4,<=5.6", + "phpoffice/phpspreadsheet": "<=1.30.5|>=2,<=2.1.17|>=2.2,<=2.4.6|>=3,<=3.10.6|>=4,<=5.8", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<=2.0.53|>=3,<=3.0.51", + "phpseclib/phpseclib": "<=2.0.54|>=3,<=3.0.53", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<3.4.3", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6", @@ -7125,21 +7220,24 @@ "phpxmlrpc/phpxmlrpc": "<4.9.2", "phraseanet/phraseanet": "==4.0.3", "pi/pi": "<=2.5", - "pimcore/admin-ui-classic-bundle": "<=2.3.5", + "pimcore/admin-ui-classic-bundle": "<1.7.18|>=2.0.0.0-RC1-dev,<=2.3.5", "pimcore/customer-management-framework-bundle": "<4.2.1", "pimcore/data-hub": "<1.2.4", "pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3", "pimcore/demo": "<10.3", "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<=12.3.6", + "pimcore/pimcore": "<12.3.9|>=2026.1,<=2026.1.4", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", + "plank/laravel-mediable": "<7", "plotly/plotly.js": "<2.25.2", "pocketmine/bedrock-protocol": "<8.0.2", "pocketmine/pocketmine-mp": "<5.42.1", "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", + "pontedilana/php-weasyprint": "<=2.5.1", + "poweradmin/poweradmin": "<4.2.5|>=4.3,<4.3.4", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", "prestashop/blockreassurance": "<=5.1.3", @@ -7151,14 +7249,14 @@ "prestashop/ps_checkout": "<5.3", "prestashop/ps_contactinfo": "<=3.3.2", "prestashop/ps_emailsubscription": "<2.6.1", - "prestashop/ps_facetedsearch": "<3.4.1", + "prestashop/ps_facetedsearch": "<4.0.4", "prestashop/ps_linklist": "<3.1", "privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3", "processwire/processwire": "<=3.0.255", - "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", - "propel/propel1": ">=1,<=1.7.1", + "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8", + "propel/propel1": ">=1,<1.7.2", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", - "pterodactyl/panel": "<1.12.3", + "pterodactyl/panel": "<=1.12.4", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", "ptrofimov/beanstalk_console": "<1.7.14", "pubnub/pubnub": "<6.1", @@ -7178,7 +7276,7 @@ "rap2hpoutre/laravel-log-viewer": "<0.13", "react/http": ">=0.7,<1.9", "really-simple-plugins/complianz-gdpr": "<6.4.2", - "redaxo/source": "<5.21", + "redaxo/source": "<5.21.1", "remdex/livehelperchat": "<4.29", "renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1", "reportico-web/reportico": "<=8.1", @@ -7206,10 +7304,10 @@ "sheng/yiicms": "<1.2.1", "shopper/cart": "<2.8", "shopper/framework": "<2.8", - "shopware/core": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev", - "shopware/platform": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev", + "shopware/core": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev", + "shopware/platform": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev", "shopware/production": "<=6.3.5.2", - "shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev", + "shopware/shopware": "<=6.3.5.2|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev", "shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", "shopxo/shopxo": "<=6.4", "showdoc/showdoc": "<3.8.1", @@ -7217,10 +7315,10 @@ "silverstripe-australia/advancedreports": ">=1,<=2", "silverstripe/admin": "<1.13.19|>=2,<2.1.8", "silverstripe/assets": "<2.4.5|>=3,<3.1.3", - "silverstripe/cms": "<4.11.3", + "silverstripe/cms": "<6.2.1", "silverstripe/comments": ">=1.3,<3.1.1", - "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", - "silverstripe/framework": "<5.3.23", + "silverstripe/forum": "<0.6.2|>=0.7,<0.7.4", + "silverstripe/framework": "<6.2.2", "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3", "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", "silverstripe/recipe-cms": ">=4.5,<4.5.3", @@ -7230,13 +7328,14 @@ "silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1", "silverstripe/subsites": ">=2,<2.6.1", "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", - "silverstripe/userforms": "<3|>=5,<5.4.2", + "silverstripe/userforms": "<6.4.9|>=7,<7.0.7|>=7.1,<7.1.1", + "silverstripe/versioned": "<3.2.1", "silverstripe/versioned-admin": ">=1,<1.11.1", "simogeo/filemanager": "<=2.5", "simple-updates/phpwhois": "<=1", - "simplesamlphp/saml2": "<=4.16.15|>=5.0.0.0-alpha1,<=5.0.0.0-alpha19", - "simplesamlphp/saml2-legacy": "<=4.16.15", - "simplesamlphp/simplesamlphp": "<1.18.6", + "simplesamlphp/saml2": "<4.19.3|>=4.20,<=4.20.2|>=5,<5.0.6|>=6,<6.2.1", + "simplesamlphp/saml2-legacy": "<4.19.3|>=4.20,<=4.20.2", + "simplesamlphp/simplesamlphp": "<=2.4.6|>=2.5,<=2.5.1", "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2", "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", "simplesamlphp/simplesamlphp-module-openid": "<1", @@ -7249,29 +7348,32 @@ "sjbr/sr-freecap": "<2.4.6|>=2.5,<2.5.3", "sjbr/static-info-tables": "<2.3.1", "slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1", - "slim/slim": "<2.6", + "slim/slim": "<2.6|>=4.4,<=4.15.1", "slub/slub-events": "<3.0.3", - "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<8.4.1", + "smarty/smarty": "<4.5.7|>=5,<5.8.4", + "snipe/snipe-it": "<=8.6.1", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", + "solidinvoice/solidinvoice": "<=2.3.15", "solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6", "soosyze/soosyze": "<=2", "spatie/browsershot": "<5.0.5", "spatie/image-optimizer": "<1.7.3", + "spatie/laravel-medialibrary": "<11.23", "spatie/schema-org": ">=3.23.1,<3.23.2|>=4,<4.0.2", "spencer14420/sp-php-email-handler": "<1", "spipu/html2pdf": "<5.2.8", "spiral/roadrunner": "<2025.1", + "spomky-labs/otphp": "<11.4.3", "spoon/library": "<1.4.1", "spoonity/tcpdf": "<6.2.22", - "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", + "squizlabs/php_codesniffer": "<3.13.6|>=4,<4.0.2", "ssddanbrown/bookstack": "<24.05.1", "starcitizentools/citizen-skin": ">=1.9.4,<3.9", "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", - "statamic/cms": "<5.73.22|>=6,<6.18.1", + "statamic/cms": "<5.74.3|>=6,<6.24.2", "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<=2.1.67", "studiomitte/friendlycaptcha": "<0.1.4", @@ -7288,9 +7390,11 @@ "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2", "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", "sylius/grid-bundle": "<1.10.1", + "sylius/mollie-plugin": "<2.2.8|>=3,<3.2.4|>=3.3,<3.3.1", "sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2", "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", - "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<=2.0.15|>=2.1,<=2.1.11|>=2.2,<=2.2.2", + "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<2.0.18|>=2.1,<2.1.15|>=2.2,<2.2.6", + "symbiote/silverstripe-advancedworkflow": "<6.4.5|>=7,<7.1.3|>=7.2,<7.2.1", "symbiote/silverstripe-multivaluefield": ">=3,<3.1", "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", "symbiote/silverstripe-seed": "<6.0.3", @@ -7336,7 +7440,9 @@ "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/ux-autocomplete": "<2.36|>=3,<3.1", + "symfony/ux-icons": ">=2.17,<2.36.1|>=3,<3.2", "symfony/ux-live-component": "<2.36|>=3,<3.1", + "symfony/ux-toolkit": ">=2.32,<2.36.1|>=3,<3.2", "symfony/ux-twig-component": "<2.25.1", "symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", @@ -7353,13 +7459,13 @@ "tecnickcom/tcpdf": "<6.8", "terminal42/contao-tablelookupwizard": "<3.3.5", "thelia/backoffice-default-template": ">=2.1,<2.1.2", - "thelia/thelia": ">=2.1,<2.1.3", + "thelia/thelia": ">=2.0.0.0-beta1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<4.1.3", + "thorsten/phpmyfaq": "<4.2.0.0-alpha", "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", - "tinymce/tinymce": "<7.2", + "tinymce/tinymce": "<7.9.3|>=8,<8.5.1", "tinymighty/wiki-seo": "<1.2.2", "titon/framework": "<9.9.99", "tltneon/lgsl": "<7", @@ -7378,24 +7484,25 @@ "twig/intl-extra": "<3.26", "twig/markdown-extra": "<3.26", "twig/twig": "<3.27", - "typicms/core": "<16.1.7", + "typicms/core": "<12.0.5|>=13,<13.0.9|>=14,<14.0.27|>=15,<15.0.29|>=16,<16.1.7", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", - "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1|==14.2", + "typo3/cms-backend": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", - "typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", + "typo3/cms-core": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.34|>=14,<14.3.6", "typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1", "typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-felogin": ">=4.2,<4.2.3", - "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1", - "typo3/cms-form": ">=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", + "typo3/cms-filelist": ">=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1|>=8,<8.7.23|>=9,<9.5.4", + "typo3/cms-form": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.5", "typo3/cms-frontend": "<4.3.9|>=4.4,<4.4.5", - "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", + "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<13.4.31|>=14,<14.3.3", "typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2", "typo3/cms-lowlevel": ">=11,<=11.5.41", "typo3/cms-recordlist": ">=11,<11.5.48", - "typo3/cms-recycler": ">=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", + "typo3/cms-recycler": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", "typo3/cms-redirects": ">=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", "typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30", "typo3/cms-scheduler": ">=11,<=11.5.41", @@ -7403,7 +7510,7 @@ "typo3/cms-webhooks": ">=12,<=12.4.30|>=13,<=13.4.11", "typo3/cms-workspaces": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", - "typo3/html-sanitizer": ">=1,<=1.5.2|>=2,<=2.1.3", + "typo3/html-sanitizer": "<2.3.2", "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3", "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1", "typo3/swiftmailer": ">=4.1,<4.1.99|>=5.4,<5.4.5", @@ -7419,7 +7526,7 @@ "uvdesk/core-framework": "<=1.1.1", "vanilla/safecurl": "<0.9.2", "verbb/comments": "<1.5.5", - "verbb/formie": "<2.2.21|>=3,<3.1.26", + "verbb/formie": "<3.1.28", "verbb/image-resizer": "<2.0.9", "verbb/knock-knock": "<1.2.8", "verot/class.upload.php": "<=2.1.6", @@ -7434,9 +7541,13 @@ "wanglelecc/laracms": "<=1.0.3", "wapplersystems/a21glossary": "<=0.4.10", "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4|>=5.3,<5.3.1", - "web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4", - "web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4", + "web-auth/webauthn-lib": ">=4.5,<5.3.5", + "web-auth/webauthn-symfony-bundle": "<5.3.4", "web-feet/coastercms": "==5.5", + "web-token/jwt-bundle": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", + "web-token/jwt-experimental": "<4.1.7", + "web-token/jwt-framework": "<4.1.7", + "web-token/jwt-library": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", "web-tp3/wec_map": "<3.0.3", "webbuilders-group/silverstripe-kapost-bridge": "<0.4", "webcoast/deferred-image-processing": "<1.0.2", @@ -7448,15 +7559,17 @@ "wikibase/wikibase": "<=1.39.3", "wikimedia/parsoid": "<0.12.2", "willdurand/js-translation-bundle": "<2.1.1", - "winter/wn-backend-module": "<1.2.12", - "winter/wn-cms-module": "<=1.2.9", + "winter/wn-backend-module": "<1.2.13", + "winter/wn-cms-module": "<=1.2.12", "winter/wn-dusk-plugin": "<2.1", "winter/wn-system-module": "<1.2.4", "wintercms/winter": "<=1.2.3", "wireui/wireui": "<1.19.3|>=2,<2.1.3", + "wnx/laravel-backup-restore": "<=1.9.3", "woocommerce/woocommerce": "<6.6|>=8.8,<8.8.5|>=8.9,<8.9.3", "wp-cli/wp-cli": ">=0.12,<2.5", - "wp-graphql/wp-graphql": "<=1.14.5", + "wp-coding-standards/wpcs": ">=0.14.1,<3.4.1", + "wp-graphql/wp-graphql": "<=2.6", "wp-premium/gravityforms": "<2.4.21", "wpanel/wpanel4-cms": "<=4.3.1", "wpcloud/wp-stateless": "<3.2", @@ -7467,7 +7580,7 @@ "xpressengine/xpressengine": "<3.0.15", "yab/quarx": "<2.4.5", "yansongda/pay": "<=3.7.19", - "yeswiki/yeswiki": "<4.6.4", + "yeswiki/yeswiki": "<4.6.6", "yetiforce/yetiforce-crm": "<6.5", "yidashi/yii2cmf": "<=2", "yii2mod/yii2-cms": "<1.9.2", @@ -7562,27 +7675,27 @@ "type": "tidelift" } ], - "time": "2026-05-31T20:27:34+00:00" + "time": "2026-08-14T18:52:44+00:00" }, { "name": "sebastian/cli-parser", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { @@ -7611,7 +7724,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1" }, "funding": [ { @@ -7631,31 +7744,31 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:39:44+00:00" + "time": "2026-08-01T04:27:14+00:00" }, { "name": "sebastian/comparator", - "version": "8.2.1", + "version": "8.4.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", - "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/3b070e608146cba00fd6fd1f0ffba89e5a8897fb", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", "php": ">=8.4", - "sebastian/diff": "^8.3", - "sebastian/exporter": "^8.0.3" + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.2" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.3" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -7663,7 +7776,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.2-dev" + "dev-main": "8.4-dev" } }, "autoload": { @@ -7703,7 +7816,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/8.2.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.4.0" }, "funding": [ { @@ -7723,7 +7836,7 @@ "type": "tidelift" } ], - "time": "2026-05-21T04:46:40+00:00" + "time": "2026-08-07T07:23:13+00:00" }, { "name": "sebastian/complexity", @@ -7797,29 +7910,29 @@ }, { "name": "sebastian/diff", - "version": "8.3.0", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.3-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -7852,7 +7965,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" }, "funding": [ { @@ -7872,7 +7985,7 @@ "type": "tidelift" } ], - "time": "2026-05-15T04:58:09+00:00" + "time": "2026-06-05T03:04:51+00:00" }, { "name": "sebastian/environment", @@ -7952,30 +8065,30 @@ }, { "name": "sebastian/exporter", - "version": "8.1.0", + "version": "8.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/24a3b69bba4a12ab615fca9d34680c5598d9ab7a", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.4", - "sebastian/recursion-context": "^8.0" + "sebastian/recursion-context": "^8.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "8.2-dev" } }, "autoload": { @@ -8018,7 +8131,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.2.1" }, "funding": [ { @@ -8038,7 +8151,76 @@ "type": "tidelift" } ], - "time": "2026-05-21T11:50:56+00:00" + "time": "2026-08-07T07:22:06+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" }, { "name": "sebastian/git-state", @@ -8185,24 +8367,24 @@ }, { "name": "sebastian/lines-of-code", - "version": "5.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "shasum": "" }, "require": { - "nikic/php-parser": "^5.7.0", + "nikic/php-parser": "^5.8.0", "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.2.4" }, "type": "library", "extra": { @@ -8231,7 +8413,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" }, "funding": [ { @@ -8251,34 +8433,33 @@ "type": "tidelift" } ], - "time": "2026-05-19T16:23:37+00:00" + "time": "2026-07-09T08:42:34+00:00" }, { "name": "sebastian/object-enumerator", - "version": "8.0.0", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/511064ecde82bd747e2ba2fab3dda8d977b59576", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576", "shasum": "" }, "require": { "php": ">=8.4", - "sebastian/object-reflector": "^6.0", - "sebastian/recursion-context": "^8.0" + "sebastian/recursion-context": "^8.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -8301,7 +8482,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.1.0" }, "funding": [ { @@ -8321,27 +8502,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:46:36+00:00" + "time": "2026-08-13T07:05:05+00:00" }, { "name": "sebastian/object-reflector", - "version": "6.0.0", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f71bbcdc4f95456b4622810bec64eb06372e25b2", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { @@ -8369,7 +8550,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.1.0" }, "funding": [ { @@ -8389,27 +8570,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:47:13+00:00" + "time": "2026-08-13T06:34:36+00:00" }, { "name": "sebastian/recursion-context", - "version": "8.0.0", + "version": "8.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { @@ -8445,7 +8626,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1" }, "funding": [ { @@ -8465,27 +8646,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:51:28+00:00" + "time": "2026-08-03T05:58:12+00:00" }, { "name": "sebastian/type", - "version": "7.0.1", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fee0309275847fefd7636167085e379c1dbf6990" + "reference": "bd1df467864cb95140414059a535b2d906173fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", - "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/bd1df467864cb95140414059a535b2d906173fcf", + "reference": "bd1df467864cb95140414059a535b2d906173fcf", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { @@ -8514,7 +8695,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/type/tree/7.0.2" }, "funding": [ { @@ -8534,7 +8715,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T06:49:11+00:00" + "time": "2026-08-10T08:00:57+00:00" }, { "name": "sebastian/version", @@ -8656,16 +8837,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", "shasum": "" }, "require": { @@ -8718,7 +8899,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.2" }, "funding": [ { @@ -8738,20 +8919,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -8798,7 +8979,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -8818,91 +8999,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" - }, - { - "name": "symfony/filesystem", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "58d2e767a66052c1487356f953445634a8194c64" + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", - "reference": "58d2e767a66052c1487356f953445634a8194c64", + "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", "shasum": "" }, "require": { @@ -8937,7 +9047,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.0" + "source": "https://github.com/symfony/finder/tree/v8.1.1" }, "funding": [ { @@ -8957,7 +9067,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-27T09:05:56+00:00" }, { "name": "symfony/options-resolver", @@ -9243,16 +9353,16 @@ }, { "name": "symfony/var-dumper", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" + "reference": "865103cf742a039f34645b971fc3ace308d6c167" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", - "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/865103cf742a039f34645b971fc3ace308d6c167", + "reference": "865103cf742a039f34645b971fc3ace308d6c167", "shasum": "" }, "require": { @@ -9268,7 +9378,7 @@ "symfony/http-kernel": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/uid": "^7.4|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -9306,7 +9416,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.0" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.2" }, "funding": [ { @@ -9326,7 +9436,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "theseer/tokenizer", @@ -9441,5 +9551,5 @@ "php": ">=8.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/phpstan.dist.neon b/phpstan.dist.neon index d6d8158..4e87866 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -11,6 +11,7 @@ parameters: - test excludePaths: - vendor + - test/Fixture (?) services: - diff --git a/rector.php b/rector.php index 2548359..21fae12 100644 --- a/rector.php +++ b/rector.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Application/Service/ConfigInterface.php b/src/Application/Service/ConfigInterface.php index 9972343..9cac084 100644 --- a/src/Application/Service/ConfigInterface.php +++ b/src/Application/Service/ConfigInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -21,52 +21,60 @@ interface ConfigInterface extends KeyValueReaderInterface { /** - * The application cache and log directories. + * The application default directories. */ - public const string CACHE_DIR = 'cache'; - public const string LOG_DIR = 'log'; + public const string CONFIG_DIR = 'config'; + public const string PUBLIC_DIR = 'public'; + public const string RUNTIME_DIR = 'runtime'; + public const string EXTENSIONS_DIR = 'extensions'; + public const string THEMES_DIR = 'themes'; + public const string CACHE_DIR = 'cache'; + public const string LOG_DIR = 'log'; /** * @var string the base path of the application and support getter */ - public string $basePath { - get; - } + public string $basePath { get; } /** * @var string the runtime path of the application and support getter */ - public string $runtimePath { - get; - } + public string $runtimePath { get; } /** * @var string the config path of the application and support getter */ - public string $configPath { - get; - } + public string $configPath { get; } /** * @var string the cache path of the application and support getter */ - public string $cachePath { - get; - } + public string $cachePath { get; } /** * @var string the log path of the application and support getter */ - public string $logPath { - get; - } + public string $logPath { get; } /** - * @var string the application base URL and support getter + * @var string the extensions installed path and support getter */ - public string $baseUrl { - get; - } + public string $extensionsPath { get; } + + /** + * @var string the themes installed path and support getter + */ + public string $themesPath { get; } + + /** + * @var string the public path which is the web root + */ + public string $publicPath { get; } + + /** + * @var string the base URL of the application and support getter + */ + public string $baseUrl { get; } /** * Create a new instance with the given configuration merged in. diff --git a/src/Contract/ArraySearchHelper.php b/src/Contract/ArraySearchHelper.php index 7362c33..c86c840 100644 --- a/src/Contract/ArraySearchHelper.php +++ b/src/Contract/ArraySearchHelper.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -40,7 +40,11 @@ public function search(string $path, array $array, mixed $default = null): mixed $segments = explode('.', $path); - if (array_key_exists($segments[0], $array) && !is_array($array[$segments[0]])) { + if (!array_key_exists($segments[0], $array)) { + return $default; + } + + if (!is_array($array[$segments[0]])) { return $default; } diff --git a/src/Contract/Collection/Collection.php b/src/Contract/Collection/Collection.php index 5751744..198acb9 100644 --- a/src/Contract/Collection/Collection.php +++ b/src/Contract/Collection/Collection.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Contract/EscaperInterface.php b/src/Contract/EscaperInterface.php new file mode 100644 index 0000000..7b4e2dd --- /dev/null +++ b/src/Contract/EscaperInterface.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Contract; + +/** + * EscaperInterface escapes untrusted strings for safe HTML output. + * + * It is a common, layer-agnostic contract — not owned by theming — so any + * renderer, helper, or extension can rely on it without touching the theme + * stack. + */ +interface EscaperInterface +{ + /** + * Escapes a string for safe embedding into HTML. + * + * @param string $string the raw string to escape + */ + public function escape(string $string): string; +} diff --git a/src/Contract/Exception/InvalidCollectionIndexException.php b/src/Contract/Exception/InvalidCollectionIndexException.php index 5c2adb1..3cdba79 100644 --- a/src/Contract/Exception/InvalidCollectionIndexException.php +++ b/src/Contract/Exception/InvalidCollectionIndexException.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Contract/Exception/InvalidCollectionItemTypeException.php b/src/Contract/Exception/InvalidCollectionItemTypeException.php index 041aac9..64c4886 100644 --- a/src/Contract/Exception/InvalidCollectionItemTypeException.php +++ b/src/Contract/Exception/InvalidCollectionItemTypeException.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Contract/Exception/TranslatableExceptionInterface.php b/src/Contract/Exception/TranslatableExceptionInterface.php index 9400152..41e2346 100644 --- a/src/Contract/Exception/TranslatableExceptionInterface.php +++ b/src/Contract/Exception/TranslatableExceptionInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Contract/KeyValueReaderInterface.php b/src/Contract/KeyValueReaderInterface.php index 03264aa..f2c19e7 100644 --- a/src/Contract/KeyValueReaderInterface.php +++ b/src/Contract/KeyValueReaderInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Contract/Translation/ExceptionTranslation.php b/src/Contract/Translation/ExceptionTranslation.php index d5c0ff7..90f5606 100644 --- a/src/Contract/Translation/ExceptionTranslation.php +++ b/src/Contract/Translation/ExceptionTranslation.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authentication/AuthenticatedUser.php b/src/Domain/Contract/Authentication/AuthenticatedUser.php index 7a8b002..46fb850 100644 --- a/src/Domain/Contract/Authentication/AuthenticatedUser.php +++ b/src/Domain/Contract/Authentication/AuthenticatedUser.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authentication/CredentialsInterface.php b/src/Domain/Contract/Authentication/CredentialsInterface.php index bcf029e..6a12f9a 100644 --- a/src/Domain/Contract/Authentication/CredentialsInterface.php +++ b/src/Domain/Contract/Authentication/CredentialsInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authentication/Service/ChallengeBasedInterface.php b/src/Domain/Contract/Authentication/Service/ChallengeBasedInterface.php index 8e3ad1b..993836d 100644 --- a/src/Domain/Contract/Authentication/Service/ChallengeBasedInterface.php +++ b/src/Domain/Contract/Authentication/Service/ChallengeBasedInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authentication/Service/PasswordBaseInterface.php b/src/Domain/Contract/Authentication/Service/PasswordBaseInterface.php index b0041d8..44085ef 100644 --- a/src/Domain/Contract/Authentication/Service/PasswordBaseInterface.php +++ b/src/Domain/Contract/Authentication/Service/PasswordBaseInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authentication/Service/StrategyRegisterInterface.php b/src/Domain/Contract/Authentication/Service/StrategyRegisterInterface.php index f88cc2f..c066bb9 100644 --- a/src/Domain/Contract/Authentication/Service/StrategyRegisterInterface.php +++ b/src/Domain/Contract/Authentication/Service/StrategyRegisterInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authentication/StrategyInterface.php b/src/Domain/Contract/Authentication/StrategyInterface.php index f8fa9df..6498c4a 100644 --- a/src/Domain/Contract/Authentication/StrategyInterface.php +++ b/src/Domain/Contract/Authentication/StrategyInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authentication/UserCredentials.php b/src/Domain/Contract/Authentication/UserCredentials.php index f74e5ca..8a69ee9 100644 --- a/src/Domain/Contract/Authentication/UserCredentials.php +++ b/src/Domain/Contract/Authentication/UserCredentials.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authentication/UserCredentialsLookupInterface.php b/src/Domain/Contract/Authentication/UserCredentialsLookupInterface.php index b233dc1..0c32a0f 100644 --- a/src/Domain/Contract/Authentication/UserCredentialsLookupInterface.php +++ b/src/Domain/Contract/Authentication/UserCredentialsLookupInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authorization/AuthorizableResourceInterface.php b/src/Domain/Contract/Authorization/AuthorizableResourceInterface.php index dec3950..60e6f11 100644 --- a/src/Domain/Contract/Authorization/AuthorizableResourceInterface.php +++ b/src/Domain/Contract/Authorization/AuthorizableResourceInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authorization/AuthorizableSubjectInterface.php b/src/Domain/Contract/Authorization/AuthorizableSubjectInterface.php index d060438..0aa31f6 100644 --- a/src/Domain/Contract/Authorization/AuthorizableSubjectInterface.php +++ b/src/Domain/Contract/Authorization/AuthorizableSubjectInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authorization/Guard/AccessGuardInterface.php b/src/Domain/Contract/Authorization/Guard/AccessGuardInterface.php index 7dbd3f5..660bbf0 100644 --- a/src/Domain/Contract/Authorization/Guard/AccessGuardInterface.php +++ b/src/Domain/Contract/Authorization/Guard/AccessGuardInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authorization/Policy/AccessPolicyInterface.php b/src/Domain/Contract/Authorization/Policy/AccessPolicyInterface.php index cd9bd7d..bd8b12d 100644 --- a/src/Domain/Contract/Authorization/Policy/AccessPolicyInterface.php +++ b/src/Domain/Contract/Authorization/Policy/AccessPolicyInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authorization/RuleInterface.php b/src/Domain/Contract/Authorization/RuleInterface.php index ca2e3ea..761f0e0 100644 --- a/src/Domain/Contract/Authorization/RuleInterface.php +++ b/src/Domain/Contract/Authorization/RuleInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Authorization/Service/RuleRegistryInterface.php b/src/Domain/Contract/Authorization/Service/RuleRegistryInterface.php index 85fb48f..3ff185e 100644 --- a/src/Domain/Contract/Authorization/Service/RuleRegistryInterface.php +++ b/src/Domain/Contract/Authorization/Service/RuleRegistryInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Contract/Identity/Service/PasswordHasherInterface.php b/src/Domain/Contract/Identity/Service/PasswordHasherInterface.php index 28ae797..b6bf835 100644 --- a/src/Domain/Contract/Identity/Service/PasswordHasherInterface.php +++ b/src/Domain/Contract/Identity/Service/PasswordHasherInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Entity/AggregateRoot.php b/src/Domain/Entity/AggregateRoot.php index c27fc31..36fe446 100644 --- a/src/Domain/Entity/AggregateRoot.php +++ b/src/Domain/Entity/AggregateRoot.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Event/DomainEventInterface.php b/src/Domain/Event/DomainEventInterface.php index 55e7ec5..17743c4 100644 --- a/src/Domain/Event/DomainEventInterface.php +++ b/src/Domain/Event/DomainEventInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Event/DomainEventPublisherInterface.php b/src/Domain/Event/DomainEventPublisherInterface.php index 5ccf498..332e744 100644 --- a/src/Domain/Event/DomainEventPublisherInterface.php +++ b/src/Domain/Event/DomainEventPublisherInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Exception/AccessDeniedException.php b/src/Domain/Exception/AccessDeniedException.php index 9f8bc99..90792fd 100644 --- a/src/Domain/Exception/AccessDeniedException.php +++ b/src/Domain/Exception/AccessDeniedException.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Exception/DateTimeException.php b/src/Domain/Exception/DateTimeException.php index ebab354..f4b08d9 100644 --- a/src/Domain/Exception/DateTimeException.php +++ b/src/Domain/Exception/DateTimeException.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Exception/InvalidExtensionSlugException.php b/src/Domain/Exception/InvalidExtensionSlugException.php new file mode 100644 index 0000000..a737f14 --- /dev/null +++ b/src/Domain/Exception/InvalidExtensionSlugException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when an invalid extension slug is encountered. + */ +final class InvalidExtensionSlugException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create an exception for an invalid extension slug. + * + * @param string $slug the slug string that was deemed invalid + */ + public static function forInvalid(string $slug): InvalidExtensionSlugException + { + return new self( + new ExceptionTranslation( + 'base.extension_slug', + 'extension_slug', + ['slug' => $slug] + ), + sprintf('Invalid slug "%s" given for normalization', $slug) + ); + } +} diff --git a/src/Domain/Exception/InvalidHashException.php b/src/Domain/Exception/InvalidHashException.php new file mode 100644 index 0000000..7b6ea61 --- /dev/null +++ b/src/Domain/Exception/InvalidHashException.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when an invalid hash value is encountered. + */ +final class InvalidHashException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create an exception for an empty hash. + */ + public static function forInvalidHash(): self + { + return new self( + new ExceptionTranslation( + 'base.hash', + 'invalid_hash' + ), + 'Hash is invalid.' + ); + } +} diff --git a/src/Domain/Exception/InvalidThemeManifestException.php b/src/Domain/Exception/InvalidThemeManifestException.php new file mode 100644 index 0000000..51cf430 --- /dev/null +++ b/src/Domain/Exception/InvalidThemeManifestException.php @@ -0,0 +1,80 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when an invalid theme manifest is encountered. + */ +final class InvalidThemeManifestException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create an exception for an empty theme name. + */ + public static function forEmptyName(): InvalidThemeManifestException + { + return new self( + new ExceptionTranslation( + 'base.theme_manifest', + 'theme_name' + ), + 'Theme name is not defined.' + ); + } + + /** + * Factory method to create an exception for an empty theme version. + */ + public static function forEmptyVersion(): InvalidThemeManifestException + { + return new self( + new ExceptionTranslation( + 'base.theme_manifest', + 'theme_version' + ), + 'Theme version is not defined.' + ); + } + + /** + * Factory method to create an exception for an invalid template path is given. + */ + public static function forInvalidTemplatePath(string $path): InvalidThemeManifestException + { + return new self( + new ExceptionTranslation( + 'base.theme_manifest', + 'template_path', + ['path' => $path] + ), + sprintf('Template path "%s" is not a valid directory.', $path) + ); + } +} diff --git a/src/Domain/Exception/InvalidThemeSlugException.php b/src/Domain/Exception/InvalidThemeSlugException.php new file mode 100644 index 0000000..2840f2b --- /dev/null +++ b/src/Domain/Exception/InvalidThemeSlugException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\Exception; + +use InvalidArgumentException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Thrown when an invalid theme slug is encountered. + */ +final class InvalidThemeSlugException extends InvalidArgumentException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to create an exception for an invalid theme slug. + * + * @param string $slug the slug string that was deemed invalid + */ + public static function forInvalid(string $slug): InvalidThemeSlugException + { + return new self( + new ExceptionTranslation( + 'base.theme_slug', + 'theme_slug', + ['slug' => $slug] + ), + sprintf('Invalid slug "%s" given for normalization', $slug) + ); + } +} diff --git a/src/Domain/Exception/InvalidUlidException.php b/src/Domain/Exception/InvalidUlidException.php index 74d6bc9..ab9645f 100644 --- a/src/Domain/Exception/InvalidUlidException.php +++ b/src/Domain/Exception/InvalidUlidException.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -40,7 +40,7 @@ private function __construct( * * @param string $ulid the ULID string that was deemed invalid */ - public static function forInvalidUlid(string $ulid): InvalidUlidException + public static function forInvalid(string $ulid): InvalidUlidException { return new self( new ExceptionTranslation( diff --git a/src/Domain/Exception/SecureTokenGenerationFailedException.php b/src/Domain/Exception/SecureTokenGenerationFailedException.php index 0c7e2b6..9853b94 100644 --- a/src/Domain/Exception/SecureTokenGenerationFailedException.php +++ b/src/Domain/Exception/SecureTokenGenerationFailedException.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Service/SlugifyInterface.php b/src/Domain/Service/SlugifyInterface.php index 199c901..7295ec1 100644 --- a/src/Domain/Service/SlugifyInterface.php +++ b/src/Domain/Service/SlugifyInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/Service/UlidGeneratorInterface.php b/src/Domain/Service/UlidGeneratorInterface.php index 5ac532c..55b592c 100644 --- a/src/Domain/Service/UlidGeneratorInterface.php +++ b/src/Domain/Service/UlidGeneratorInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/ValueObject/AggregateId.php b/src/Domain/ValueObject/AggregateId.php index 1507d09..826e4e8 100644 --- a/src/Domain/ValueObject/AggregateId.php +++ b/src/Domain/ValueObject/AggregateId.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/ValueObject/DateTime.php b/src/Domain/ValueObject/DateTime.php index 5955596..bab1148 100644 --- a/src/Domain/ValueObject/DateTime.php +++ b/src/Domain/ValueObject/DateTime.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/ValueObject/Email.php b/src/Domain/ValueObject/Email.php index 2c04051..5050d7b 100644 --- a/src/Domain/ValueObject/Email.php +++ b/src/Domain/ValueObject/Email.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/ValueObject/ExtensionSlug.php b/src/Domain/ValueObject/ExtensionSlug.php new file mode 100644 index 0000000..dbaa17e --- /dev/null +++ b/src/Domain/ValueObject/ExtensionSlug.php @@ -0,0 +1,30 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\ValueObject; + +use Webify\Base\Domain\Exception\InvalidExtensionSlugException; + +/** + * Value object represents extension slug. + */ +final readonly class ExtensionSlug extends Slug +{ + /** + * {@inheritDoc} + */ + protected function throwException(string $value): never + { + throw InvalidExtensionSlugException::forInvalid($value); + } +} diff --git a/src/Domain/ValueObject/Hash.php b/src/Domain/ValueObject/Hash.php new file mode 100644 index 0000000..cb2f6a2 --- /dev/null +++ b/src/Domain/ValueObject/Hash.php @@ -0,0 +1,98 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\ValueObject; + +use Webify\Base\Domain\Exception\InvalidHashException; + +/** + * Hash value object. + * + * Generates a compact, deterministic hash from an algorithm identifier and input data. + */ +final readonly class Hash +{ + /** + * The default maximum allowed length of the generated hash. + */ + private const int DEFAULT_MAX_LENGTH = 6; + + /** + * The constructor. + * + * @throws InvalidHashException if the hash is not valid + */ + private function __construct(private string $value) + { + if (!$this->isValid()) { + throw InvalidHashException::forInvalidHash(); + } + } + + /** + * String representation of the hash. + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Generates a compact, deterministic hash from the given algorithm and data. + * + * The hash is produced using PHP's `hash()` function and truncated to the + * requested length. + * + * @param string $algorithm the hash algorithm identifier (e.g. "crc32", "sha256") + * @param string $value the value to be hashed + * @param int $length the maximum length of the hash, defauts to 6 + */ + public static function generate(string $algorithm, string $value, int $length = self::DEFAULT_MAX_LENGTH): self + { + return new self( + substr(hash($algorithm, $value), 0, $length) + ); + } + + /** + * Factory method to create an instance from a known hash string. + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * Equality check based on the value. + */ + public function equals(self $other): bool + { + return $this->toNative() === $other->toNative(); + } + + /** + * Get the native string value of the hash. + */ + public function toNative(): string + { + return $this->value; + } + + /** + * Validates the value. + */ + private function isValid(): bool + { + return '' !== $this->value; + } +} diff --git a/src/Domain/ValueObject/ResourcePath.php b/src/Domain/ValueObject/ResourcePath.php new file mode 100644 index 0000000..4e152ca --- /dev/null +++ b/src/Domain/ValueObject/ResourcePath.php @@ -0,0 +1,117 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\ValueObject; + +/** + * ResourcePath value object base class. + * + * Represents the public-facing address of a domain resource as a path segment — + * deliberately independent of scheme, host, or route prefix. The domain only knows + * "where a resource lives within the site's structure"; Infrastructure is + * responsible for turning this into an absolute URL. + * + * @phpstan-consistent-constructor + */ +abstract readonly class ResourcePath +{ + /** + * Resource path pattern format: + * - Must be lowercase. + * - Segments separated by a single forward slash. + * - Each segment contains only letters, numbers, and hyphens (same rules as Slug). + * - Must not start or end with a slash. + * - Must not contain consecutive slashes or consecutive hyphens within a segment. + */ + private const string FORMAT = '/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/'; + + /** + * The protected constructor enforces the use of factory methods to initiate this object. + */ + protected function __construct( + private string $value + ) { + if (!$this->isValid()) { + $this->throwException($this->value); + } + } + + /** + * Converts the object to its string representation. + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Factory method to create a canonical path value object from a string. + */ + public static function fromString(string $value): static + { + return new static($value); + } + + /** + * Converts the object to its native string representation. + */ + public function toNative(): string + { + return $this->value; + } + + /** + * Returns the individual segments of the path, e.g. "blog/my-post" -> ['blog', 'my-post']. + * + * @return string[] + */ + public function segments(): array + { + return explode('/', $this->value); + } + + /** + * Returns the last segment of the path, e.g. "blog/my-post" -> "my-post". + */ + public function leaf(): string + { + $segments = $this->segments(); + + return $segments[count($segments) - 1]; + } + + /** + * Compares the current object with another instance for equality. + * + * @param ResourcePath $other the instance to compare with the current object + * + * @return bool true if both instances are considered equal, false otherwise + */ + public function equals(self $other): bool + { + return $this->toNative() === $other->toNative(); + } + + /** + * Throws an exception as part of the implementation detail in derived classes. + */ + abstract protected function throwException(string $value): never; + + /** + * Validates the value against the predefined format pattern. + */ + private function isValid(): bool + { + return (bool) preg_match(self::FORMAT, $this->value); + } +} diff --git a/src/Domain/ValueObject/SecureToken.php b/src/Domain/ValueObject/SecureToken.php index 5085618..b684d0f 100644 --- a/src/Domain/ValueObject/SecureToken.php +++ b/src/Domain/ValueObject/SecureToken.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/ValueObject/Slug.php b/src/Domain/ValueObject/Slug.php index 6b0708e..37b5f0d 100644 --- a/src/Domain/ValueObject/Slug.php +++ b/src/Domain/ValueObject/Slug.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Domain/ValueObject/TemplatePath.php b/src/Domain/ValueObject/TemplatePath.php new file mode 100644 index 0000000..a5e4909 --- /dev/null +++ b/src/Domain/ValueObject/TemplatePath.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\ValueObject; + +/** + * TemplatePath value object class. + * + * Represents the template path. + */ +final readonly class TemplatePath +{ + /** + * The constructor. + */ + private function __construct(private string $value) {} + + /** + * Converts the object to its string representation. + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Factory method to create a template path value object from a string. + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * Converts the object to its native string representation. + */ + public function toNative(): string + { + return $this->value; + } +} diff --git a/src/Domain/ValueObject/ThemeSlug.php b/src/Domain/ValueObject/ThemeSlug.php new file mode 100644 index 0000000..4d8c0f1 --- /dev/null +++ b/src/Domain/ValueObject/ThemeSlug.php @@ -0,0 +1,30 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Domain\ValueObject; + +use Webify\Base\Domain\Exception\InvalidThemeSlugException; + +/** + * Value object represents theme slug. + */ +final readonly class ThemeSlug extends Slug +{ + /** + * {@inheritDoc} + */ + protected function throwException(string $value): never + { + throw InvalidThemeSlugException::forInvalid($value); + } +} diff --git a/src/Infrastructure/Container/Definitions/ConsoleDefinitions.php b/src/Infrastructure/Container/Definitions/ConsoleDefinitions.php new file mode 100644 index 0000000..5883f95 --- /dev/null +++ b/src/Infrastructure/Container/Definitions/ConsoleDefinitions.php @@ -0,0 +1,48 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Container\Definitions; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Console\Application as ConsoleApplication; +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Contract\DefinitionsInterface; +use Webify\Base\Infrastructure\Kernel\Console; + +use function DI\{autowire, factory}; + +/** + * The console definitions: the Symfony console application and the console + * kernel the commands are added to during each provider's boot() phase. + */ +final class ConsoleDefinitions implements DefinitionsInterface +{ + /** + * {@inheritDoc} + */ + public static function getDefinitions(): array + { + return [ + ConsoleApplication::class => factory( + static function (ConfigInterface $config, ContainerInterface $container): ConsoleApplication { + return new ConsoleApplication( + $config->get('name', 'WebifyCMS'), + $config->get('version', '0.0.1'), + $container + ); + } + ), + 'consoleKernel' => autowire(Console::class), + ]; + } +} diff --git a/src/Infrastructure/Container/Definitions/EventDefinitions.php b/src/Infrastructure/Container/Definitions/EventDefinitions.php new file mode 100644 index 0000000..8040439 --- /dev/null +++ b/src/Infrastructure/Container/Definitions/EventDefinitions.php @@ -0,0 +1,42 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Container\Definitions; + +use League\Event\EventDispatcher; +use Psr\EventDispatcher\EventDispatcherInterface; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Infrastructure\Contract\DefinitionsInterface; +use Webify\Base\Infrastructure\Event\LeagueDomainEventPublisher; + +use function DI\{create, get}; + +/** + * The eventing definitions: the PSR-14 dispatcher and the domain event + * publisher that bridges the domain contract to the League dispatcher. + */ +final class EventDefinitions implements DefinitionsInterface +{ + /** + * {@inheritDoc} + */ + public static function getDefinitions(): array + { + return [ + EventDispatcher::class => create(EventDispatcher::class), + EventDispatcherInterface::class => get(EventDispatcher::class), + DomainEventPublisherInterface::class => create(LeagueDomainEventPublisher::class) + ->constructor(get(EventDispatcherInterface::class)), + ]; + } +} diff --git a/src/Infrastructure/Container/Definitions/HttpDefinitions.php b/src/Infrastructure/Container/Definitions/HttpDefinitions.php new file mode 100644 index 0000000..48e4828 --- /dev/null +++ b/src/Infrastructure/Container/Definitions/HttpDefinitions.php @@ -0,0 +1,97 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Container\Definitions; + +use Laminas\HttpHandlerRunner\Emitter\{EmitterInterface, SapiEmitter}; +use League\Route\Router; +use League\Route\Strategy\ApplicationStrategy; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\{ServerRequestCreator, ServerRequestCreatorInterface}; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\{ + RequestFactoryInterface, + ResponseFactoryInterface, + ServerRequestFactoryInterface, + StreamFactoryInterface, + UploadedFileFactoryInterface, + UriFactoryInterface +}; +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Contract\EscaperInterface; +use Webify\Base\Infrastructure\Contract\{BaseUrlInterface, DefinitionsInterface, UrlGeneratorInterface}; +use Webify\Base\Infrastructure\Helper\Escaper; +use Webify\Base\Infrastructure\Kernel\Http; +use Webify\Base\Infrastructure\Service\{BaseUrl, UrlGenerator}; + +use function DI\{autowire, create, factory, get}; + +/** + * The HTTP stack definitions: PSR-17 factories, the request creator, router, + * response emitter, and the HTTP kernel. + */ +final class HttpDefinitions implements DefinitionsInterface +{ + /** + * {@inheritDoc} + */ + public static function getDefinitions(): array + { + return [ + // The common HTML escaper shared by every renderer and helper. + EscaperInterface::class => create(Escaper::class), + // Nyholm\Psr17Factory implements all PSR-17 factory interfaces in + // one class. Each interface resolves to the same shared instance. + Psr17Factory::class => create(Psr17Factory::class), + RequestFactoryInterface::class => get(Psr17Factory::class), + ResponseFactoryInterface::class => get(Psr17Factory::class), + ServerRequestFactoryInterface::class => get(Psr17Factory::class), + StreamFactoryInterface::class => get(Psr17Factory::class), + UriFactoryInterface::class => get(Psr17Factory::class), + UploadedFileFactoryInterface::class => get(Psr17Factory::class), + // Explicit constructor wiring because all four parameters share + // the same concrete type (Psr17Factory) despite resolving to + // different interfaces. + ServerRequestCreatorInterface::class => create(ServerRequestCreator::class) + ->constructor( + get(ServerRequestFactoryInterface::class), + get(UriFactoryInterface::class), + get(UploadedFileFactoryInterface::class), + get(StreamFactoryInterface::class), + ), + // Route definitions and middleware are added during bootstrapping. + Router::class => factory( + static function (ContainerInterface $container): Router { + $strategy = new ApplicationStrategy(); + $router = new Router(); + + $strategy->setContainer($container); + $router->setStrategy($strategy); + + return $router; + } + ), + EmitterInterface::class => create(SapiEmitter::class), + 'httpKernel' => autowire(Http::class), + // BaseUrl configuration and Url generator + BaseUrlInterface::class => factory( + static fn (ConfigInterface $config) => BaseUrl::fromConfig($config) + ), + UrlGeneratorInterface::class => create(UrlGenerator::class) + ->constructor( + get(Router::class), + get(BaseUrlInterface::class) + ), + ]; + } +} diff --git a/src/Infrastructure/Container/Definitions/LoggingDefinitions.php b/src/Infrastructure/Container/Definitions/LoggingDefinitions.php new file mode 100644 index 0000000..fcc5cf7 --- /dev/null +++ b/src/Infrastructure/Container/Definitions/LoggingDefinitions.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Container\Definitions; + +use Monolog\Formatter\JsonFormatter; +use Monolog\{Handler\RotatingFileHandler, Level, Logger}; +use Psr\Log\LoggerInterface; +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Contract\DefinitionsInterface; +use Webify\Base\Infrastructure\Environment\Environment; + +use function DI\factory; + +/** + * The logging definitions: a JSON-on-disk PSR-3 logger whose verbosity is + * determined by the environment's debug flag. + */ +final class LoggingDefinitions implements DefinitionsInterface +{ + /** + * {@inheritDoc} + */ + public static function getDefinitions(): array + { + return [ + LoggerInterface::class => factory( + static function (ConfigInterface $config, Environment $environment): Logger { + $handler = new RotatingFileHandler( + $config->logPath . '/app.log', + 7, + $environment->isDebugEnabled() ? Level::Debug : Level::Warning, + ); + $handler->setFormatter(new JsonFormatter()); + + return new Logger($config->get('id'))->pushHandler($handler); + } + ), + ]; + } +} diff --git a/src/Infrastructure/Container/Definitions/ServiceDefinitions.php b/src/Infrastructure/Container/Definitions/ServiceDefinitions.php new file mode 100644 index 0000000..2aca8fe --- /dev/null +++ b/src/Infrastructure/Container/Definitions/ServiceDefinitions.php @@ -0,0 +1,81 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Container\Definitions; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Filesystem\Filesystem; +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Contract\{ + AssetsPipelineInterface, + AssetsPublisherInterface, + DefinitionsInterface, + TemplateMapOverridesInterface, + TemplateRendererInterface, + TemplateResolverInterface, + ThemeResolverInterface, + UrlGeneratorInterface, + ViewInterface, +}; +use Webify\Base\Infrastructure\Service\{ + AssetsPublisher, + TemplateMapOverrides, + TemplateResolver, + View, +}; + +use function DI\{autowire, factory, get}; + +/** + * Theme related definitions. + */ +final class ServiceDefinitions implements DefinitionsInterface +{ + /** + * {@inheritDoc} + */ + public static function getDefinitions(): array + { + return [ + CacheItemPoolInterface::class => factory( + fn (ConfigInterface $config) => new FilesystemAdapter( + 'webify.theme', + 0, + $config->cachePath + ) + ), + AssetsPublisherInterface::class => autowire(AssetsPublisher::class) + ->constructor(get(ConfigInterface::class), get(Filesystem::class)), + TemplateMapOverridesInterface::class => factory( + fn ( + ConfigInterface $config, + CacheItemPoolInterface $cache, + ThemeResolverInterface $resolver + ) => new TemplateMapOverrides( + $config->get('templates.map', []), + $resolver, + $cache + ) + ), + TemplateResolverInterface::class => autowire(TemplateResolver::class) + ->constructor(get(TemplateMapOverridesInterface::class)), + ViewInterface::class => autowire(View::class) + ->constructor( + get(UrlGeneratorInterface::class), + get(AssetsPipelineInterface::class), + get(TemplateRendererInterface::class) + ), + ]; + } +} diff --git a/src/Infrastructure/Container/PhpDiContainerBuilder.php b/src/Infrastructure/Container/PhpDiContainerBuilder.php index 2e3c4b2..5951d93 100644 --- a/src/Infrastructure/Container/PhpDiContainerBuilder.php +++ b/src/Infrastructure/Container/PhpDiContainerBuilder.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -15,10 +15,18 @@ use DI\{Container, ContainerBuilder}; use Psr\Container\ContainerInterface; +use Throwable; use Webify\Base\Application\Service\ConfigInterface; -use Webify\Base\Infrastructure\Contract\{BootstrapServiceProviderInterface, ExtensionInterface, ServiceProviderInterface}; +use Webify\Base\Infrastructure\Contract\{ + BootstrapServiceProviderInterface, + ContainerBuilderInterface, + ExtensionInterface, + ServiceProviderInterface, +}; use Webify\Base\Infrastructure\Environment\Environment; -use Webify\Base\Infrastructure\Service\Application; +use Webify\Base\Infrastructure\Exception\ContainerException; +use Webify\Base\Infrastructure\Service\{Application, ThemeLoader, ThemeResolver}; +use Webify\Base\Infrastructure\Theme\ThemeFactory; /** * Factory for building a PHP-Di container. @@ -43,10 +51,19 @@ public function build(Application $app): ContainerInterface // so providers can reference it during the build phase if needed. $builder->addDefinitions([ConfigInterface::class => $app->getConfig()]); $builder->addDefinitions([Environment::class => $app->getEnvironment()]); - $this->addProvidersDefinition($app, $builder); - $this->addExtensionProvidersDefinition($app, $builder); - - return $builder->build(); + $this->addDefinitionsFromConfig($app, $builder); + $this->addExtensionDefinitions($app, $builder); + + // Load the configured themes + new ThemeLoader( + new ThemeResolver($app->getConfig(), new ThemeFactory($app->getConfig())) + )->load($app, $builder); + + try { + return $builder->build(); + } catch (Throwable $exception) { + throw ContainerException::buildFailed($exception); + } } /** @@ -54,7 +71,7 @@ public function build(Application $app): ContainerInterface * * @param ContainerBuilder $builder */ - private function addProvidersDefinition(Application $app, ContainerBuilder $builder): void + private function addDefinitionsFromConfig(Application $app, ContainerBuilder $builder): void { /** @var array $providerList */ $providerList = $app->getConfig()->get('providers', []); @@ -76,7 +93,7 @@ private function addProvidersDefinition(Application $app, ContainerBuilder $buil * * @param ContainerBuilder $builder */ - private function addExtensionProvidersDefinition(Application $app, ContainerBuilder $builder): void + private function addExtensionDefinitions(Application $app, ContainerBuilder $builder): void { /** @var array $extensionList */ $extensionList = $app->getConfig()->get('extensions', []); @@ -90,6 +107,8 @@ private function addExtensionProvidersDefinition(Application $app, ContainerBuil continue; } + $builder->addDefinitions([$extension::class => $extension]); + foreach ($extension->getProviders() as $provider) { /** @var BootstrapServiceProviderInterface|ServiceProviderInterface $provider */ $provider = new $provider(); diff --git a/src/Infrastructure/Contract/AssetsPipelineInterface.php b/src/Infrastructure/Contract/AssetsPipelineInterface.php new file mode 100644 index 0000000..2b26754 --- /dev/null +++ b/src/Infrastructure/Contract/AssetsPipelineInterface.php @@ -0,0 +1,37 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * The asset pipeline contract. + * + * Given the set of asset entry points registered by extensions, the app, and the theme itself, + * produce servable output. + */ +interface AssetsPipelineInterface +{ + /** + * Resolves the full HTML tags (script + associated CSS links) needed to + * load a JS/TS entry point, since a single entry can pull in CSS and + * dependency chunks that must be emitted alongside it. + */ + public function entrypoint(string $entry): string; + + /** + * Resolves the URL for a single static asset (image, font, etc.). + * + * @param string $entry the asset path + */ + public function url(string $entry): string; +} diff --git a/src/Infrastructure/Contract/AssetsProviderInterface.php b/src/Infrastructure/Contract/AssetsProviderInterface.php new file mode 100644 index 0000000..5df15ae --- /dev/null +++ b/src/Infrastructure/Contract/AssetsProviderInterface.php @@ -0,0 +1,42 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * The asset provider contract. + * + * Implemented (optionally) by an extension or theme to declare the frontend + * assets it ships. When implemented, the asset publisher publishes the assets + * into the public webroot under `public/assets//`, where `` is + * derived from the provider's {@see getHash()}. + */ +interface AssetsProviderInterface +{ + /** + * Returns the hash that determines the published directory name. + * + * The hash must be deterministic for a given provider identity and version, + * so that republishing the same version is a no-op. + */ + public function getHash(): string; + + /** + * Returns the path to the directory containing the built assets. + * The path should have given from the application root, + * use `ConfigInterface::$themesPath` to construct the directory. + * + * The directory contents are copied into `public/assets//` on publish. + */ + public function getDirectory(): string; +} diff --git a/src/Infrastructure/Contract/AssetsPublisherInterface.php b/src/Infrastructure/Contract/AssetsPublisherInterface.php new file mode 100644 index 0000000..bdcbc9d --- /dev/null +++ b/src/Infrastructure/Contract/AssetsPublisherInterface.php @@ -0,0 +1,35 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * The asset publisher contract. + * + * Publishes asset bundles declared by an {@see AssetsProviderInterface} (an + * extension or theme that ships frontend assets) from their source directory + * into the public webroot under `public/assets//`. + */ +interface AssetsPublisherInterface +{ + /** + * Publishes the assets of the given provider. + * + * Copies the provider's assets directory contents into + * `public/assets//`. Publishing is a no-op when the target + * directory already holds a full mirror of the current source contents + * (verified by a completion marker); any missing, partial or stale target is + * reconciled with the source before the request continues. + */ + public function publish(AssetsProviderInterface $provider): void; +} diff --git a/src/Infrastructure/Contract/BaseUrlInterface.php b/src/Infrastructure/Contract/BaseUrlInterface.php new file mode 100644 index 0000000..0d93f55 --- /dev/null +++ b/src/Infrastructure/Contract/BaseUrlInterface.php @@ -0,0 +1,45 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * The contract for base URL. + */ +interface BaseUrlInterface +{ + /** + * Get the native string value of the base URL. + */ + public function toNative(?string $scheme = null): string; + + /** + * Check weather has scheme. + */ + public function hasScheme(): bool; + + /** + * Returns the scheme. + */ + public function getScheme(): ?string; + + /** + * Returns the host. + */ + public function getHost(): string; + + /** + * Returns the path prefix. + */ + public function getPathPrefix(): string; +} diff --git a/src/Infrastructure/Contract/BootstrapServiceProviderInterface.php b/src/Infrastructure/Contract/BootstrapServiceProviderInterface.php index 1ad1bf0..bb27db0 100644 --- a/src/Infrastructure/Contract/BootstrapServiceProviderInterface.php +++ b/src/Infrastructure/Contract/BootstrapServiceProviderInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Container/ContainerBuilderInterface.php b/src/Infrastructure/Contract/ContainerBuilderInterface.php similarity index 84% rename from src/Infrastructure/Container/ContainerBuilderInterface.php rename to src/Infrastructure/Contract/ContainerBuilderInterface.php index fabd23a..ab532d0 100644 --- a/src/Infrastructure/Container/ContainerBuilderInterface.php +++ b/src/Infrastructure/Contract/ContainerBuilderInterface.php @@ -6,12 +6,12 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); -namespace Webify\Base\Infrastructure\Container; +namespace Webify\Base\Infrastructure\Contract; use Psr\Container\ContainerInterface; use Webify\Base\Infrastructure\Service\Application; diff --git a/src/Infrastructure/Contract/DefinitionsInterface.php b/src/Infrastructure/Contract/DefinitionsInterface.php new file mode 100644 index 0000000..3de0467 --- /dev/null +++ b/src/Infrastructure/Contract/DefinitionsInterface.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * DefinitionsInterface groups related container definitions into one class. + * + * Every definition group (HTTP, logging, eventing, theming, persistence, …) + * exposes them through `getDefinitions()` so the composition root stays a + * thin merge and each group is isolated and testable on its own. + */ +interface DefinitionsInterface +{ + /** + * Get the container definitions of the group. + * + * @return array the PHP-DI definitions + */ + public static function getDefinitions(): array; +} diff --git a/src/Infrastructure/Contract/ErrorHandlerInterface.php b/src/Infrastructure/Contract/ErrorHandlerInterface.php index c2c5eed..7567deb 100644 --- a/src/Infrastructure/Contract/ErrorHandlerInterface.php +++ b/src/Infrastructure/Contract/ErrorHandlerInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Contract/ExtensionInterface.php b/src/Infrastructure/Contract/ExtensionInterface.php index ce853fa..5147874 100644 --- a/src/Infrastructure/Contract/ExtensionInterface.php +++ b/src/Infrastructure/Contract/ExtensionInterface.php @@ -6,15 +6,17 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); namespace Webify\Base\Infrastructure\Contract; +use Webify\Base\Domain\ValueObject\TemplatePath; + /** - * The contract that holds the extension package information. + * The contract that holds the extension information. */ interface ExtensionInterface { @@ -23,6 +25,13 @@ interface ExtensionInterface */ public function getId(): string; + /** + * Returns the slug for this extension. + * + * Note: Don't rely on slug for uniqueness, instead use the `getId()` + */ + public function getSlug(): string; + /** * Returns the name of the extension. */ @@ -31,7 +40,7 @@ public function getName(): string; /** * Returns the list of service provider class names for this extension. * - * @return array> + * @return list> */ public function getProviders(): array; @@ -39,4 +48,16 @@ public function getProviders(): array; * Returns the version of the extension. */ public function getVersion(): string; + + /** + * Returns the templates path mapped to the extension. + */ + public function getTemplatePath(): TemplatePath; + + /** + * Returns the extension's asset provider if extension built its own assets for publishing. + * + * @return null|AssetsProviderInterface if not assets to publish, you can return null + */ + public function getAssetsProvider(): ?AssetsProviderInterface; } diff --git a/src/Infrastructure/Contract/ServiceProviderInterface.php b/src/Infrastructure/Contract/ServiceProviderInterface.php index a2c5c8f..d8663be 100644 --- a/src/Infrastructure/Contract/ServiceProviderInterface.php +++ b/src/Infrastructure/Contract/ServiceProviderInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Contract/TemplateMapOverridesInterface.php b/src/Infrastructure/Contract/TemplateMapOverridesInterface.php new file mode 100644 index 0000000..f02fdc9 --- /dev/null +++ b/src/Infrastructure/Contract/TemplateMapOverridesInterface.php @@ -0,0 +1,32 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +use Webify\Base\Domain\ValueObject\TemplatePath; + +/** + * Contract for template map overrides. + */ +interface TemplateMapOverridesInterface +{ + /** + * Override template map for the extension by its ID. + * + * @return TemplatePath[] override candidates for the given extension id, + * ordered highest priority first. Does NOT include + * the extension's own default — callers append that + * themselves as the final fallback. + */ + public function forExtension(string $extensionId): array; +} diff --git a/src/Infrastructure/Contract/TemplateRendererInterface.php b/src/Infrastructure/Contract/TemplateRendererInterface.php new file mode 100644 index 0000000..8038e04 --- /dev/null +++ b/src/Infrastructure/Contract/TemplateRendererInterface.php @@ -0,0 +1,32 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * The rendering contract. + * + * Callers hand over which extension's view they want and a relative path; resolution + * and priority are the implementation's business. + */ +interface TemplateRendererInterface +{ + /** + * Renders a view for the given extension. + * + * @param string $templatePath e.g. 'post/view' + * @param array $data variables available to the view + * @param null|ExtensionInterface $extension provide the extension if the template is for extension + */ + public function render(string $templatePath, array $data = [], ?ExtensionInterface $extension = null): string; +} diff --git a/src/Infrastructure/Contract/TemplateResolverInterface.php b/src/Infrastructure/Contract/TemplateResolverInterface.php new file mode 100644 index 0000000..5d8fa0d --- /dev/null +++ b/src/Infrastructure/Contract/TemplateResolverInterface.php @@ -0,0 +1,27 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * Contract for template resolver. + */ +interface TemplateResolverInterface +{ + /** + * Resolved the template. + * + * @return array + */ + public function resolve(ExtensionInterface $extension, string $template): array; +} diff --git a/src/Infrastructure/Contract/ThemeConfigInterface.php b/src/Infrastructure/Contract/ThemeConfigInterface.php new file mode 100644 index 0000000..9d65a8c --- /dev/null +++ b/src/Infrastructure/Contract/ThemeConfigInterface.php @@ -0,0 +1,21 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +use Webify\Base\Contract\KeyValueReaderInterface; + +/** + * The contract for theme configurations. + */ +interface ThemeConfigInterface extends KeyValueReaderInterface {} diff --git a/src/Infrastructure/Contract/ThemeInterface.php b/src/Infrastructure/Contract/ThemeInterface.php new file mode 100644 index 0000000..43156dc --- /dev/null +++ b/src/Infrastructure/Contract/ThemeInterface.php @@ -0,0 +1,73 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +use Webify\Base\Domain\ValueObject\TemplatePath; + +/** + * The contract for theme. + * + * @todo Think about adding a `build(ContainerInterface $container)` method to `ThemeInterface`, so if the class has any dependencies it can be resolved via the container. + */ +interface ThemeInterface +{ + /** + * Returns the ID of the theme. + */ + public function getId(): string; + + /** + * Returns the name of the theme. + */ + public function getName(): string; + + /** + * Returns the version of the theme. + */ + public function getVersion(): string; + + /** + * Returns the theme configuration which should be the `theme.json` data. + */ + public function getConfig(): ThemeConfigInterface; + + /** + * Returns the absolute path to the root directory of the theme package. + */ + public function getRootPath(): string; + + /** + * Returns the templates path mapped to the extension. + */ + public function getTemplatePath(): TemplatePath; + + /** + * Returns the templates path mapped to the extension. + * + * @return array + */ + public function getTemplateMap(): array; + + /** + * Returns the service provider instance of the theme, in case no service provider should return `null`. + */ + public function getProvider(): ?ServiceProviderInterface; + + /** + * Returns the theme's asset provider if extension built its own assets for publishing. + * + * @return null|AssetsProviderInterface if not assets to publish, you can return null + */ + public function getAssetsProvider(): ?AssetsProviderInterface; +} diff --git a/src/Infrastructure/Contract/ThemeResolverInterface.php b/src/Infrastructure/Contract/ThemeResolverInterface.php new file mode 100644 index 0000000..7c6df73 --- /dev/null +++ b/src/Infrastructure/Contract/ThemeResolverInterface.php @@ -0,0 +1,61 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +use Webify\Base\Domain\ValueObject\TemplatePath; + +/** + * Contract for resolving the effective themes from the app configuration. + * + * Services should depend on this contract instead of injecting `ThemeInterface` + * directly, since a single `ThemeInterface` cannot represent the default-vs-active + * fallback distinction. + */ +interface ThemeResolverInterface +{ + /** + * Returns the currently active theme, falling back to the default theme + * when no active theme is configured. + */ + public function active(): ThemeInterface; + + /** + * Returns the default theme. + */ + public function default(): ThemeInterface; + + /** + * Returns the effective themes in resolution order: the default theme first, + * then the active theme when it differs, so the active theme can override. + * + * @return list + */ + public function themes(): array; + + /** + * Returns the template paths of the effective themes, default first and + * active last, so the active theme can override the default theme. + * + * @return list + */ + public function themeTemplatePaths(): array; + + /** + * Returns the template maps of the effective themes, default first and + * active last, so the active theme can override the default theme. + * + * @return list> + */ + public function themeTemplateMaps(): array; +} diff --git a/src/Infrastructure/Contract/UrlGeneratorInterface.php b/src/Infrastructure/Contract/UrlGeneratorInterface.php new file mode 100644 index 0000000..d258b56 --- /dev/null +++ b/src/Infrastructure/Contract/UrlGeneratorInterface.php @@ -0,0 +1,45 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +use Webify\Base\Domain\ValueObject\ResourcePath; + +/** + * The contract for Url generation. + */ +interface UrlGeneratorInterface +{ + /** + * Gets the route for the given name. + * + * @param string $name the name to get the route + * @param array $parameters query parametrs + * @param UrlType $type the url type, default relative + */ + public function route(string $name, array $parameters = [], UrlType $type = UrlType::Relative): string; + + /** + * Resolve the URL for the given resource path. + */ + public function to(ResourcePath $path, UrlType $type = UrlType::Relative): string; + + /** + * Resolve a plain path (e.g. a static asset or a direct URL) against the + * base URL, without requiring a route name or a `ResourcePath` value object. + * + * @param string $path the path to resolve, typically starting with `/` + * @param UrlType $type the URL type, default relative + */ + public function path(string $path, UrlType $type = UrlType::Relative): string; +} diff --git a/src/Infrastructure/Contract/UrlType.php b/src/Infrastructure/Contract/UrlType.php new file mode 100644 index 0000000..6c68e20 --- /dev/null +++ b/src/Infrastructure/Contract/UrlType.php @@ -0,0 +1,35 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * The type of URL that can be used to generate. + */ +enum UrlType +{ + /** + * "/blog/my-post" — no host, always available regardless of config. + */ + case Relative; + + /** + * "https://example.com/blog/my-post" — fully qualified url. + */ + case Absolute; + + /** + * "//example.com/blog/my-post" — protocol-relative, browser inherits the current scheme. + */ + case NetworkPath; +} diff --git a/src/Infrastructure/Contract/ViewInterface.php b/src/Infrastructure/Contract/ViewInterface.php new file mode 100644 index 0000000..05a206b --- /dev/null +++ b/src/Infrastructure/Contract/ViewInterface.php @@ -0,0 +1,67 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Contract; + +/** + * Contract for view. + */ +interface ViewInterface +{ + /** + * Can set and get the layout. + * This is useful to change the layout per view. + */ + public string $layout { get; set; } + + /** + * Can set and get the extension, if the view service is called within the extension context this can be useful. + */ + public ?ExtensionInterface $extension { get; set; } + + /** + * Renders a template. + * + * @param string $template the template path or name, file extension not required + * @param array $data the data should pass to the templates + * @param null|string $layout optional layout, if `null` default will be used + */ + public function render(string $template, array $data = [], ?string $layout = null): string; + + /** + * Helper method to resolve the Url. + * + * @param string $name the name to get the route + * @param array $parameters query parametrs + * @param UrlType $type the url type, default relative + */ + public function url(string $name, array $parameters = [], UrlType $type = UrlType::Relative): string; + + /** + * Helper method to resolve asset file Url. + * + * @param string $file asset file or file path + */ + public function asset(string $file): string; + + /** + * Helper method to resolve asset entrypoint Url. + */ + public function assetEntrypoint(string $entry): string; + + /** + * Helper method to resolve a plain path-based URL (e.g. a static asset or a + * direct path), in contrast to {@link self::url()} which resolves a named route. + */ + public function path(string $path, UrlType $type = UrlType::Relative): string; +} diff --git a/src/Infrastructure/Environment/Environment.php b/src/Infrastructure/Environment/Environment.php index 65a40bc..3fddad1 100644 --- a/src/Infrastructure/Environment/Environment.php +++ b/src/Infrastructure/Environment/Environment.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Environment/Type.php b/src/Infrastructure/Environment/Type.php index 547a201..19ad10e 100644 --- a/src/Infrastructure/Environment/Type.php +++ b/src/Infrastructure/Environment/Type.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Event/LeagueDomainEventPublisher.php b/src/Infrastructure/Event/LeagueDomainEventPublisher.php index 540c7a9..eb1a2a5 100644 --- a/src/Infrastructure/Event/LeagueDomainEventPublisher.php +++ b/src/Infrastructure/Event/LeagueDomainEventPublisher.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Exception/ApplicationEnvironmentException.php b/src/Infrastructure/Exception/ApplicationEnvironmentException.php index e414963..56bd8a4 100644 --- a/src/Infrastructure/Exception/ApplicationEnvironmentException.php +++ b/src/Infrastructure/Exception/ApplicationEnvironmentException.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Exception/ApplicationException.php b/src/Infrastructure/Exception/ApplicationException.php index 36527d7..24708ba 100644 --- a/src/Infrastructure/Exception/ApplicationException.php +++ b/src/Infrastructure/Exception/ApplicationException.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -36,21 +36,21 @@ private function __construct( } /** - * Factory method to initiate when the application base path is not defined in the configurations. + * Factory method to initiate when the application base path is not defined. */ - public static function basePathNotDefined(): self + public static function publicPathNotDefined(): self { return new self( new ExceptionTranslation( 'base.config', - 'bath_path_not_defined' + 'base_path_not_defined' ), 'Application base path not defined in the configurations.' ); } /** - * Factory method to initiate when the application runtime path is not defined in the configurations. + * Factory method to initiate when the application runtime path is not defined. */ public static function runtimePathNotDefined(): self { diff --git a/src/Infrastructure/Exception/AssetPublishException.php b/src/Infrastructure/Exception/AssetPublishException.php new file mode 100644 index 0000000..de0181e --- /dev/null +++ b/src/Infrastructure/Exception/AssetPublishException.php @@ -0,0 +1,87 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Exception; + +use RuntimeException; +use Throwable; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown for asset publishing errors. + */ +final class AssetPublishException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + * @param null|Throwable $previous the previous exception (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '', + ?Throwable $previous = null + ) { + parent::__construct($message, 0, $previous); + } + + /** + * Factory method for when the source directory does not exist. + */ + public static function sourceNotFound(string $path): self + { + return new self( + new ExceptionTranslation( + 'base.asset_publish', + 'source_not_found', + ['path' => $path] + ), + sprintf('Asset source directory "%s" does not exist.', $path) + ); + } + + /** + * Factory method for when the target directory cannot be created. + */ + public static function targetNotWritable(string $path, ?Throwable $previous = null): self + { + return new self( + new ExceptionTranslation( + 'base.asset_publish', + 'target_not_writable', + ['path' => $path] + ), + sprintf('Asset target directory "%s" is not writable.', $path), + $previous + ); + } + + /** + * Factory method for when a file copy operation fails. + */ + public static function copyFailed(string $source, string $target, ?Throwable $previous = null): self + { + return new self( + new ExceptionTranslation( + 'base.asset_publish', + 'copy_failed', + ['source' => $source, 'target' => $target] + ), + sprintf('Failed to copy asset from "%s" to "%s".', $source, $target), + $previous + ); + } +} diff --git a/src/Infrastructure/Exception/BaseUrlConfigurationException.php b/src/Infrastructure/Exception/BaseUrlConfigurationException.php new file mode 100644 index 0000000..d782c60 --- /dev/null +++ b/src/Infrastructure/Exception/BaseUrlConfigurationException.php @@ -0,0 +1,68 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown when base URL configuration errors. + */ +final class BaseUrlConfigurationException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method for when the configured base URL string cannot be parsed. + * + * @param string $value the invalid base URL value from the configuration + */ + public static function forInvalid(string $value): BaseUrlConfigurationException + { + return new self( + new ExceptionTranslation( + 'base.configuration', + 'invalid_base_url', + ['baseUrl' => $value] + ), + sprintf('Unable to parse base URL "%s" from configuration.', $value) + ); + } + + /** + * Factory method for when an absolute or network-path URL is requested but no base URL was configured. + */ + public static function notConfigured(): BaseUrlConfigurationException + { + return new self( + new ExceptionTranslation( + 'base.configuration', + 'base_url_not_configured' + ), + 'Cannot generate an absolute URL because no base URL is configured (config key "baseUrl").' + ); + } +} diff --git a/src/Infrastructure/Exception/ContainerException.php b/src/Infrastructure/Exception/ContainerException.php new file mode 100644 index 0000000..9c837ce --- /dev/null +++ b/src/Infrastructure/Exception/ContainerException.php @@ -0,0 +1,55 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Exception; + +use RuntimeException; +use Throwable; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown for container errors. + */ +final class ContainerException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + * @param null|Throwable $previous the previous exception + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '', + ?Throwable $previous = null + ) { + parent::__construct($message, 0, $previous); + } + + /** + * Factory method to initiate when the container failed to build. + */ + public static function buildFailed(Throwable $exception): self + { + return new self( + new ExceptionTranslation( + 'base.container', + 'container_build_failed' + ), + 'Container build failed.', + $exception + ); + } +} diff --git a/src/Infrastructure/Exception/TemplateNotFoundException.php b/src/Infrastructure/Exception/TemplateNotFoundException.php new file mode 100644 index 0000000..b48b2a4 --- /dev/null +++ b/src/Infrastructure/Exception/TemplateNotFoundException.php @@ -0,0 +1,54 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; + +/** + * Exception thrown for when view file is not found. + */ +final class TemplateNotFoundException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate when the view file is not found. + * + * @param string $view the given view which is not found + */ + public static function forView(string $view): self + { + return new self( + new ExceptionTranslation( + 'base.view', + 'view_not_found', + ['view' => $view] + ), + sprintf('View %s not found', $view) + ); + } +} diff --git a/src/Infrastructure/Exception/ThemeException.php b/src/Infrastructure/Exception/ThemeException.php new file mode 100644 index 0000000..b70cea2 --- /dev/null +++ b/src/Infrastructure/Exception/ThemeException.php @@ -0,0 +1,121 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Exception; + +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; +use Webify\Base\Infrastructure\Contract\ThemeInterface; + +/** + * Exception thrown for theme related errors. + */ +final class ThemeException extends RuntimeException implements TranslatableExceptionInterface +{ + /** + * Private constructor enforces the use of the factory methods to initiate this exception. + * + * @param ExceptionTranslation $translation the translation object for this exception + * @param string $message the exception message (optional) + */ + private function __construct( + public readonly ExceptionTranslation $translation, + string $message = '' + ) { + parent::__construct($message); + } + + /** + * Factory method to initiate when the invalid theme manifest JSON found. + * + * @param string $themeId the theme ID which is not found + */ + public static function configuredThemeNotFound(string $themeId): self + { + return new self( + new ExceptionTranslation( + 'base.theme', + 'configred_theme_not_found', + ['themeId' => $themeId] + ), + sprintf('Theme %s not found', $themeId) + ); + } + + /** + * Factory method to initiate when no default theme is configured. + */ + public static function missingDefault(): self + { + return new self( + new ExceptionTranslation( + 'base.theme', + 'missing_default_theme' + ), + 'No default theme is configured in the application config.' + ); + } + + /** + * Factory method to initiate when the configured theme class does not + * implement the `ThemeInterface`. + * + * @param string $class the configured theme class name + */ + public static function notATheme(string $class): self + { + return new self( + new ExceptionTranslation( + 'base.theme', + 'not_a_theme', + ['class' => $class] + ), + sprintf('Configured theme class "%s" must implement %s.', $class, ThemeInterface::class) + ); + } + + /** + * Factory method to initiate when a theme manifest is missing or unable to read. + * + * @param string $path the path to the invalid `theme.json` file + */ + public static function manifestNotFound(string $path): self + { + return new self( + new ExceptionTranslation( + 'base.theme', + 'theme_manifest', + ['path' => $path] + ), + sprintf('Unable to read theme manifest from "%s"', $path) + ); + } + + /** + * Factory method to initiate when a theme manifest is missing or invalid. + * + * @param string $path the path to the invalid `theme.json` file + */ + public static function invalidManifest(string $path): self + { + return new self( + new ExceptionTranslation( + 'base.theme', + 'invalid_theme_manifest', + ['path' => $path] + ), + sprintf('Invalid manifest data from "%s"', $path) + ); + } +} diff --git a/src/Infrastructure/Helper/Escaper.php b/src/Infrastructure/Helper/Escaper.php new file mode 100644 index 0000000..54f3c61 --- /dev/null +++ b/src/Infrastructure/Helper/Escaper.php @@ -0,0 +1,33 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Helper; + +use Webify\Base\Contract\EscaperInterface; + +/** + * The default HTML escaper. + * + * Converts special characters to HTML entities with `htmlspecialchars`, + * covering both single and double quotes (ENT_QUOTES) in UTF-8. + */ +final readonly class Escaper implements EscaperInterface +{ + /** + * {@inheritDoc} + */ + public function escape(string $string): string + { + return htmlspecialchars($string, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/src/Infrastructure/Kernel/Console.php b/src/Infrastructure/Kernel/Console.php index 140f778..505fc73 100644 --- a/src/Infrastructure/Kernel/Console.php +++ b/src/Infrastructure/Kernel/Console.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Kernel/Http.php b/src/Infrastructure/Kernel/Http.php index e71721b..3926f25 100644 --- a/src/Infrastructure/Kernel/Http.php +++ b/src/Infrastructure/Kernel/Http.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Provider/BaseService.php b/src/Infrastructure/Provider/BaseService.php new file mode 100644 index 0000000..195dea7 --- /dev/null +++ b/src/Infrastructure/Provider/BaseService.php @@ -0,0 +1,83 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Provider; + +use Psr\Container\ContainerInterface; +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Container\Definitions\{ + ConsoleDefinitions, + EventDefinitions, + HttpDefinitions, + LoggingDefinitions, + ServiceDefinitions, +}; +use Webify\Base\Infrastructure\Contract\{ + AssetsPublisherInterface, + BootstrapServiceProviderInterface, + ExtensionInterface, + ServiceProviderInterface, + ThemeResolverInterface, +}; + +/** + * Base service provider that merges the grouped container definitions. + */ +final class BaseService implements ServiceProviderInterface, BootstrapServiceProviderInterface +{ + /** + * {@inheritDoc} + */ + public function getDefinitions(): array + { + return [ + ...HttpDefinitions::getDefinitions(), + ...ConsoleDefinitions::getDefinitions(), + ...EventDefinitions::getDefinitions(), + ...LoggingDefinitions::getDefinitions(), + ...ServiceDefinitions::getDefinitions(), + ]; + } + + /** + * {@inheritDoc} + */ + public function bootstrap(ContainerInterface $container): void + { + // Let's publish built assets of all + $config = $container->get(ConfigInterface::class); + $publisher = $container->get(AssetsPublisherInterface::class); + $resolver = $container->get(ThemeResolverInterface::class); + $themeAssetsProvider = $resolver->active()->getAssetsProvider(); + + if (null !== $themeAssetsProvider) { + $publisher->publish($themeAssetsProvider); + } + + /** @var array $extensions */ + $extensions = $config->get('extensions', []); + + foreach ($extensions as $extension) { + if (!$container->has($extension)) { + continue; + } + + /** @var ExtensionInterface $extensionInstance */ + $extensionInstance = $container->get($extension); + + if (null !== $extensionInstance->getAssetsProvider()) { + $publisher->publish($extensionInstance->getAssetsProvider()); + } + } + } +} diff --git a/src/Infrastructure/Provider/BaseServiceProvider.php b/src/Infrastructure/Provider/BaseServiceProvider.php deleted file mode 100644 index 871b74a..0000000 --- a/src/Infrastructure/Provider/BaseServiceProvider.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -declare(strict_types=1); - -namespace Webify\Base\Infrastructure\Provider; - -use Webify\Base\Infrastructure\Contract\ServiceProviderInterface; - -/** - * Base service provider. - */ -final readonly class BaseServiceProvider implements ServiceProviderInterface -{ - /** - * {@inheritDoc} - */ - public function getDefinitions(): array - { - return require __DIR__ . '/../definitions.php'; - } -} diff --git a/src/Infrastructure/Service/Application.php b/src/Infrastructure/Service/Application.php index 192f87c..4751deb 100644 --- a/src/Infrastructure/Service/Application.php +++ b/src/Infrastructure/Service/Application.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -15,8 +15,8 @@ use Psr\Container\ContainerInterface; use Webify\Base\Application\Service\ConfigInterface; -use Webify\Base\Infrastructure\Container\ContainerBuilderInterface; use Webify\Base\Infrastructure\Contract\{BootstrapServiceProviderInterface, ServiceProviderInterface}; +use Webify\Base\Infrastructure\Contract\ContainerBuilderInterface; use Webify\Base\Infrastructure\Environment\Environment; use Webify\Base\Infrastructure\Exception\ApplicationException; @@ -58,8 +58,10 @@ public function __construct( private readonly ConfigInterface $config, private readonly Environment $environment ) { - if ('' === $this->config->basePath) { - throw ApplicationException::basePathNotDefined(); + $basePath = $this->config->basePath; + + if ('' === $basePath) { + throw ApplicationException::publicPathNotDefined(); } $runtimePath = $this->config->runtimePath; diff --git a/src/Infrastructure/Service/AssetsPublisher.php b/src/Infrastructure/Service/AssetsPublisher.php new file mode 100644 index 0000000..9e774c1 --- /dev/null +++ b/src/Infrastructure/Service/AssetsPublisher.php @@ -0,0 +1,198 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Contract\{AssetsProviderInterface, AssetsPublisherInterface}; +use Webify\Base\Infrastructure\Exception\AssetPublishException; + +use function file_get_contents; +use function hash; +use function implode; +use function is_dir; +use function is_file; +use function rtrim; +use function sort; + +/** + * Asset publisher service. + * + * Publishes the assets declared by an {@see AssetsProviderInterface} into the + * public webroot under `public/assets//`. The provider supplies its own + * deterministic hash, making republishing the same version a no-op. + * + * To never serve stale or partial assets, the source directory is fingerprinted + * on every publish call. An existing hashed target directory is only trusted + * when it carries a completion marker matching the current fingerprint; a + * missing, partial or stale target is reconciled by mirroring the source on top + * of it (pruning files that no longer exist in the source). + */ +final class AssetsPublisher implements AssetsPublisherInterface +{ + /** + * The assets directory name inside the public directory. + */ + private const string ASSETS_DIR = 'assets'; + + /** + * Name of the marker file written after a full mirror completed. + */ + private const string COMPLETION_MARKER = '.webify-assets-completed'; + + /** + * Hashing algorithm used to fingerprint the source directory contents. + */ + private const string DIGEST_ALGO = 'sha256'; + + /** + * The set of provider hashes that have been published during this request. + * + * Used to avoid redundant copy operations when {@see publish()} is called + * multiple times for the same provider. + * + * @var array + */ + private array $written = []; + + /** + * The constructor. + */ + public function __construct( + private readonly ConfigInterface $config, + private readonly Filesystem $filesystem + ) {} + + /** + * {@inheritDoc} + */ + public function publish(AssetsProviderInterface $provider): void + { + $key = $provider->getHash(); + + if (isset($this->written[$key])) { + return; + } + + $sourcePath = rtrim($provider->getDirectory(), '/'); + + if (!is_dir($sourcePath)) { + throw AssetPublishException::sourceNotFound($sourcePath); + } + + $targetPath = $this->resolveTargetPath($key); + $digest = $this->digest($sourcePath); + + if ($this->isUpToDate($targetPath, $digest)) { + $this->written[$key] = true; + + return; + } + + try { + $this->filesystem->mkdir($targetPath); + } catch (IOException $exception) { + throw AssetPublishException::targetNotWritable($targetPath, $exception); + } + + try { + // `delete` prunes files that no longer exist in the source and + // `override` refreshes existing ones, so a stale or partial target + // is fully reconciled with the source. + $this->filesystem->mirror( + $sourcePath, + $targetPath, + null, + ['delete' => true, 'override' => true] + ); + } catch (IOException $exception) { + throw AssetPublishException::copyFailed($sourcePath, $targetPath, $exception); + } + + try { + $this->filesystem->dumpFile( + $targetPath . DIRECTORY_SEPARATOR . self::COMPLETION_MARKER, + $digest + ); + } catch (IOException $exception) { + throw AssetPublishException::targetNotWritable( + $targetPath . DIRECTORY_SEPARATOR . self::COMPLETION_MARKER, + $exception + ); + } + + $this->written[$key] = true; + } + + /** + * Returns the absolute filesystem path for a published provider directory. + */ + private function resolveTargetPath(string $hash): string + { + return $this->config->publicPath . DIRECTORY_SEPARATOR . self::ASSETS_DIR . DIRECTORY_SEPARATOR . $hash; + } + + /** + * Checks whether the target already holds a full mirror of the given source digest. + * + * The completion marker guarantees the target was fully mirrored in an + * earlier publish; without it, a pre-existing directory could be a partial + * mirror from an interrupted deploy and is never trusted. + */ + private function isUpToDate(string $targetPath, string $digest): bool + { + if (!is_dir($targetPath)) { + return false; + } + + $markerPath = $targetPath . DIRECTORY_SEPARATOR . self::COMPLETION_MARKER; + + if (!is_file($markerPath)) { + return false; + } + + return (string) file_get_contents($markerPath) === $digest; + } + + /** + * Builds a deterministic content digest of every file under the source directory. + * + * Relative paths are included and the entries sorted, so renames, removals, + * additions and content changes all produce a different digest. + */ + private function digest(string $sourcePath): string + { + $files = []; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($sourcePath, FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + /** @var SplFileInfo $file */ + if ($file->isFile()) { + $files[] = $iterator->getSubPathname() . ':' . (string) file_get_contents($file->getPathname()); + } + } + + sort($files); + + return hash(self::DIGEST_ALGO, implode('|', $files)); + } +} diff --git a/src/Infrastructure/Service/BaseUrl.php b/src/Infrastructure/Service/BaseUrl.php new file mode 100644 index 0000000..65f9f6e --- /dev/null +++ b/src/Infrastructure/Service/BaseUrl.php @@ -0,0 +1,146 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Contract\BaseUrlInterface; +use Webify\Base\Infrastructure\Exception\BaseUrlConfigurationException; + +use function parse_url; +use function sprintf; +use function str_contains; +use function str_starts_with; +use function trim; + +/** + * The implementation for the base URL. + * + * Parses a loosely specified base URL configuration value into its structured + * parts, so scheme/host/port/path ambiguity is resolved once, at boot, + * rather than reparsed on every URL generation call. + * + * Accepts any of: + * '' -> not configured + * 'example.com' -> host only, no scheme + * 'example.com:8080' -> host with port + * '//example.com' -> explicit protocol-relative + * 'https://example.com' -> full, with scheme + * 'https://example.com/blog' -> full, with a subdirectory prefix + */ +final readonly class BaseUrl implements BaseUrlInterface +{ + /** + * The default scheme. + */ + private const string DEFAULT_SCHEME = 'https'; + + /** + * Private constructor enforces the use of the factory method. + */ + private function __construct( + private ?string $scheme, + private string $host, + private ?int $port, + private string $path + ) {} + + /** + * Factory method to initialize this from the config. + * + * @throws BaseUrlConfigurationException if the configurred base URL is not valid + */ + public static function fromConfig(ConfigInterface $config): self + { + $baseUrl = (string) $config->get('baseUrl', ''); + $scheme = (string) $config->get('defaultUrlScheme', self::DEFAULT_SCHEME); + // parse_url() only extracts the host correctly when a scheme or a + // leading "//" is present, so normalize bare host values first. + $normalized = trim($baseUrl); + + if ('' === $normalized) { + return new self(null, '', null, ''); + } + + if (!str_contains($normalized, '://') && !str_starts_with($normalized, '//')) { + $normalized = '//' . $normalized; + } + + $parts = parse_url($normalized); + + if (false === $parts || !isset($parts['host']) || '' === $parts['host']) { + throw BaseUrlConfigurationException::forInvalid($baseUrl); + } + + return new self( + $parts['scheme'] ?? trim($scheme), + $parts['host'], + $parts['port'] ?? null, + rtrim($parts['path'] ?? '', '/') + ); + } + + /** + * Get the native string value of the base URL. + * + * @throws BaseUrlConfigurationException if base URL is not configurred + */ + public function toNative(?string $scheme = null): string + { + if ('' === $this->host) { + throw BaseUrlConfigurationException::notConfigured(); + } + + $url = null !== $this->port ? sprintf('%s:%s', $this->host, $this->port) : $this->host; + $affectiveScheme = $scheme ?? $this->scheme; + + // An empty (or null) effective scheme yields a protocol-relative URL, + // while a concrete base URL must not end with a trailing slash so that + // consumers can safely append a "/path" segment. + return '' === (string) $affectiveScheme + ? sprintf('//%s%s', $url, $this->path) + : sprintf('%s://%s%s', $affectiveScheme, $url, $this->path); + } + + /** + * {@inheritdoc} + */ + public function hasScheme(): bool + { + return null !== $this->scheme; + } + + /** + * {@inheritdoc} + */ + public function getScheme(): ?string + { + return $this->scheme; + } + + /** + * {@inheritdoc} + */ + public function getHost(): string + { + return $this->host; + } + + /** + * {@inheritdoc} + */ + public function getPathPrefix(): string + { + return $this->path; + } +} diff --git a/src/Infrastructure/Service/Config.php b/src/Infrastructure/Service/Config.php index 8640793..ec22cd0 100644 --- a/src/Infrastructure/Service/Config.php +++ b/src/Infrastructure/Service/Config.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -63,6 +63,27 @@ final class Config implements ConfigInterface get => $this->logPath; } + /** + * {@inheritdoc} + */ + public string $extensionsPath { + get => $this->extensionsPath; + } + + /** + * {@inheritdoc} + */ + public string $themesPath { + get => $this->themesPath; + } + + /** + * {@inheritdoc} + */ + public string $publicPath { + get => $this->publicPath; + } + /** * {@inheritdoc} */ @@ -85,12 +106,26 @@ final class Config implements ConfigInterface public function __construct( private readonly array $config ) { - $this->basePath = rtrim($this->get('basePath'), '/'); - $this->runtimePath = rtrim($this->get('runtimePath'), '/'); - $this->configPath = rtrim($this->get('configPath'), '/'); - $this->cachePath = $this->runtimePath . DIRECTORY_SEPARATOR . self::CACHE_DIR; - $this->logPath = $this->runtimePath . DIRECTORY_SEPARATOR . self::LOG_DIR; - $this->baseUrl = rtrim($this->get('baseUrl', ''), '/'); + $this->basePath = rtrim($this->get('basePath', dirname(__DIR__, 3)), '/'); + $this->configPath = rtrim( + $this->get('configPath', $this->basePath . DIRECTORY_SEPARATOR . self::CONFIG_DIR), + '/' + ); + $this->publicPath = rtrim( + $this->get('publicPath', $this->basePath . DIRECTORY_SEPARATOR . self::PUBLIC_DIR), + '/' + ); + $this->baseUrl = rtrim((string) $this->get('baseUrl', ''), '/'); + $this->runtimePath = rtrim( + $this->get('runtimePath', $this->basePath . DIRECTORY_SEPARATOR . self::RUNTIME_DIR), + '/' + ); + // The "extensions" and "themes" are not configurable + $this->extensionsPath = $this->basePath . DIRECTORY_SEPARATOR . self::EXTENSIONS_DIR; + $this->themesPath = $this->basePath . DIRECTORY_SEPARATOR . self::THEMES_DIR; + // Subdirectories and not configurable too + $this->cachePath = $this->runtimePath . DIRECTORY_SEPARATOR . self::CACHE_DIR; + $this->logPath = $this->runtimePath . DIRECTORY_SEPARATOR . self::LOG_DIR; } /** diff --git a/src/Infrastructure/Service/SymfonyAsciiSlugify.php b/src/Infrastructure/Service/SymfonyAsciiSlugify.php index dd60278..86dd146 100644 --- a/src/Infrastructure/Service/SymfonyAsciiSlugify.php +++ b/src/Infrastructure/Service/SymfonyAsciiSlugify.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/src/Infrastructure/Service/SymfonyUlidGenerator.php b/src/Infrastructure/Service/SymfonyUlidGenerator.php index 2fd2434..c3d45c8 100644 --- a/src/Infrastructure/Service/SymfonyUlidGenerator.php +++ b/src/Infrastructure/Service/SymfonyUlidGenerator.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -38,7 +38,7 @@ public function generate(): string public function normalize(string $value): string { if (!Ulid::isValid($value)) { - throw InvalidUlidException::forInvalidUlid($value); + throw InvalidUlidException::forInvalid($value); } return Ulid::fromString($value)->toBase32(); diff --git a/src/Infrastructure/Service/TemplateMapOverrides.php b/src/Infrastructure/Service/TemplateMapOverrides.php new file mode 100644 index 0000000..f83c529 --- /dev/null +++ b/src/Infrastructure/Service/TemplateMapOverrides.php @@ -0,0 +1,118 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use Psr\Cache\CacheItemPoolInterface; +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Contract\{TemplateMapOverridesInterface, ThemeResolverInterface}; + +use function array_key_exists; +use function hash; +use function sprintf; + +/** + * Concrete implementation of the `TemplateMapOverridesInterface`. + * + * The generated override lists are cached per extension id. Because the cache + * pool is shared and long lived (e.g. the filesystem adapter with no expiry), + * the cache key is bound to the inputs that determine the result — the app + * override for the extension and the current theme template maps. A + * configuration change therefore produces a fresh cache entry instead of + * serving stale candidates from a previous deployment. + */ +final readonly class TemplateMapOverrides implements TemplateMapOverridesInterface +{ + /** + * Cache namespace prefix used to isolate the override-map entries. + */ + private const string CACHE_NAMESPACE = 'theme.override-map.'; + + /** + * Hashing algorithm used to fingerprint the override inputs into the key. + */ + private const string SIGNATURE_ALGO = 'xxh64'; + + /** + * The constructor. + * + * @param array $appOverrides + */ + public function __construct( + private array $appOverrides, + private ThemeResolverInterface $resolver, + private CacheItemPoolInterface $cacheItemPool + ) {} + + /** + * {@inheritDoc} + */ + public function forExtension(string $extensionId): array + { + $themeMaps = $this->resolver->themeTemplateMaps(); + + $item = $this->cacheItemPool->getItem($this->cacheKey($extensionId, $themeMaps)); + + if ($item->isHit()) { + return $item->get(); + } + + $pathMap = []; + + if (array_key_exists($extensionId, $this->appOverrides)) { + $pathMap[] = TemplatePath::fromString($this->appOverrides[$extensionId]); + } + + foreach ($themeMaps as $themeMap) { + if ([] !== $themeMap && array_key_exists($extensionId, $themeMap)) { + $pathMap[] = $themeMap[$extensionId]; + } + } + + $item->set($pathMap); + $this->cacheItemPool->save($item); + + // When there are no overrides, an empty list is returned so the caller + // falls back to the extension's own default template path. + return $pathMap; + } + + /** + * Builds a content-addressed cache key for the given extension. + * + * The key binds the extension id, this extension's app override and the + * current theme template maps, so a change in any of these inputs produces + * a different key and therefore a fresh (non-stale) cache value. + * + * @param array> $themeMaps + */ + private function cacheKey(string $extensionId, array $themeMaps): string + { + $appSignature = hash(self::SIGNATURE_ALGO, $this->appOverrides[$extensionId] ?? ''); + + $themeSignature = ''; + foreach ($themeMaps as $themeMap) { + foreach ($themeMap as $mapExtensionId => $path) { + $themeSignature .= $mapExtensionId . '=' . $path->toNative() . "\n"; + } + } + + return sprintf( + '%s%s.%s.%s', + self::CACHE_NAMESPACE, + $extensionId, + $appSignature, + hash(self::SIGNATURE_ALGO, $themeSignature) + ); + } +} diff --git a/src/Infrastructure/Service/TemplateResolver.php b/src/Infrastructure/Service/TemplateResolver.php new file mode 100644 index 0000000..afe7ad8 --- /dev/null +++ b/src/Infrastructure/Service/TemplateResolver.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use Webify\Base\Infrastructure\Contract\{ExtensionInterface, TemplateMapOverridesInterface, TemplateResolverInterface}; + +/** + * The service implementing the template resolver. + * + * Try to resolve the template for the given extension using the templates map. + */ +final readonly class TemplateResolver implements TemplateResolverInterface +{ + /** + * The constructor. + */ + public function __construct( + private TemplateMapOverridesInterface $templateMap + ) {} + + /** + * {@inheritDoc} + */ + public function resolve(ExtensionInterface $extension, string $template): array + { + $candidates = $this->templateMap->forExtension($extension->getId()); + $trimmed = ltrim($template, '/'); + $paths = []; + + foreach ($candidates as $path) { + $paths[] = $path->toNative() . '/' . $trimmed; + } + + // The extension's own default template path always participates as the + // final fallback, whether or not any override candidates exist. + $paths[] = $extension->getTemplatePath()->toNative() . '/' . $trimmed; + + return $paths; + } +} diff --git a/src/Infrastructure/Service/ThemeAssetsProvider.php b/src/Infrastructure/Service/ThemeAssetsProvider.php new file mode 100644 index 0000000..abd532b --- /dev/null +++ b/src/Infrastructure/Service/ThemeAssetsProvider.php @@ -0,0 +1,60 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use Webify\Base\Domain\ValueObject\Hash; +use Webify\Base\Infrastructure\Contract\{AssetsProviderInterface, ThemeInterface}; + +/** + * The default assets provider for a generic theme. + * + * It derives a stable hash from the theme identity and serves the theme's `dist` + * directory. Any theme that ships pre-built assets under `dist` can use this + * provider; themes with bespoke publishing needs can supply their own. + */ +final readonly class ThemeAssetsProvider implements AssetsProviderInterface +{ + /** + * The hash object. + */ + private Hash $hash; + + /** + * The constructor. + */ + public function __construct( + private ThemeInterface $theme + ) { + $this->hash = Hash::generate( + 'crc32', + $this->theme->getId() . $this->theme->getName() . $this->theme->getVersion() + ); + } + + /** + * {@inheritDoc} + */ + public function getHash(): string + { + return $this->hash->toNative(); + } + + /** + * {@inheritDoc} + */ + public function getDirectory(): string + { + return $this->theme->getRootPath() . '/dist'; + } +} diff --git a/src/Infrastructure/Service/ThemeConfig.php b/src/Infrastructure/Service/ThemeConfig.php new file mode 100644 index 0000000..e89d634 --- /dev/null +++ b/src/Infrastructure/Service/ThemeConfig.php @@ -0,0 +1,81 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use Webify\Base\Contract\ArraySearchHelper; +use Webify\Base\Infrastructure\Contract\ThemeConfigInterface; + +/** + * A generic, read-only view over a theme manifest (`theme.json`) data. + * + * This is the default `ThemeConfigInterface` implementation used by manifest + * driven (PHP-less) themes that carry no dedicated configuration class. + */ +final class ThemeConfig implements ThemeConfigInterface +{ + use ArraySearchHelper; + + /** + * Used to accept `null` as a valid value. + */ + private const string NULL_VALUE = 'null'; + + /** + * Used to store the found key and value pair when using the `has()` method. + * + * @var array + */ + private array $cache = []; + + /** + * The constructor. + * + * @param array $data the theme manifest data + */ + public function __construct( + private readonly array $data = [] + ) {} + + /** + * {@inheritDoc} + */ + public function has(int|string $key): bool + { + if (array_key_exists($key, $this->cache)) { + return true; + } + + $found = $this->search((string) $key, $this->data, self::NULL_VALUE); + + if (self::NULL_VALUE !== $found) { + $this->cache[(string) $key] = $found; + + return true; + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function get(int|string $key, mixed $default = null): mixed + { + if ($this->has($key)) { + return $this->cache[(string) $key]; + } + + return $default; + } +} diff --git a/src/Infrastructure/Service/ThemeLoader.php b/src/Infrastructure/Service/ThemeLoader.php new file mode 100644 index 0000000..09ee305 --- /dev/null +++ b/src/Infrastructure/Service/ThemeLoader.php @@ -0,0 +1,79 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use DI\{Container, ContainerBuilder}; +use Webify\Base\Infrastructure\Contract\{ThemeInterface, ThemeResolverInterface}; + +/** + * Loads the theme(s) into the container. + * + * The default theme's provider is always registered first — it supplies the + * fallback renderer/asset pipeline/behaviour. When an active theme differs from + * the default, its provider is registered afterwards so its bindings win via + * ordinary last-registration-wins DI semantics (per-active-theme bindings). + * + * The resolved theme resolver and the active theme are bound so services can + * depend on the resolver contract (the active/default fallback) rather than + * injecting `ThemeInterface` directly. + */ +final readonly class ThemeLoader +{ + /** + * The constructor. + */ + public function __construct( + private ThemeResolverInterface $resolver + ) {} + + /** + * Load the default and (if configured and different) active theme providers + * and definitions. + * + * @param ContainerBuilder $builder + */ + public function load(Application $app, ContainerBuilder $builder): void + { + $default = $this->resolver->default(); + $active = $this->resolver->active(); + + $this->registerTheme($app, $builder, $default); + + if ($active->getId() !== $default->getId()) { + $this->registerTheme($app, $builder, $active); + } + + $builder->addDefinitions([ + ThemeResolverInterface::class => $this->resolver, + ThemeInterface::class => $active, + ]); + } + + /** + * Registers a theme's provider and container definitions when it has one. + * + * @param ContainerBuilder $builder + */ + private function registerTheme(Application $app, ContainerBuilder $builder, ThemeInterface $theme): void + { + $provider = $theme->getProvider(); + + if (null === $provider) { + return; + } + + $app->registerProvider($provider); + $builder->addDefinitions($provider->getDefinitions()); + } +} diff --git a/src/Infrastructure/Service/ThemeResolver.php b/src/Infrastructure/Service/ThemeResolver.php new file mode 100644 index 0000000..837e776 --- /dev/null +++ b/src/Infrastructure/Service/ThemeResolver.php @@ -0,0 +1,154 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Contract\{ThemeInterface, ThemeResolverInterface}; +use Webify\Base\Infrastructure\Exception\ThemeException; +use Webify\Base\Infrastructure\Theme\ThemeFactory; + +use function array_map; + +/** + * Resolves the active theme from the app configuration. + * + * The `themes` config is a map: + * + * ```php + * 'themes' => [ + * 'default' => Canvas::class, // always present + * 'active' => MyTheme::class|'theme-id', // optional — falls back to `default` + * ], + * ``` + * + * This resolver is the single place that turns the configured theme class names + * (or manifest theme ids/paths) into {@see ThemeInterface} instances, and it is + * what services should depend on instead of injecting `ThemeInterface` directly + * (which cannot represent the default-vs-active/fallback distinction). Resolved + * themes are memoized so each configured theme is built at most once per + * application lifetime. + */ +final class ThemeResolver implements ThemeResolverInterface +{ + /** + * Memorize resolved themes by config key so the same class is never built twice. + * + * @var array + */ + private array $cache = []; + + /** + * The constructor. + */ + public function __construct( + private readonly ConfigInterface $config, + private readonly ThemeFactory $factory + ) {} + + /** + * Returns the currently active theme, falling back to the default theme + * when no active theme is configured. + * + * @throws ThemeException when neither `default` nor `active` is set + */ + public function active(): ThemeInterface + { + return $this->resolve('active') ?? $this->default(); + } + + /** + * Returns the default theme. + * + * @throws ThemeException when `default` is not set + */ + public function default(): ThemeInterface + { + $theme = $this->resolve('default'); + + if (null === $theme) { + throw ThemeException::missingDefault(); + } + + return $theme; + } + + /** + * Returns the effective themes in resolution order: the default theme first, + * then the active theme when it differs, so the active theme can override. + * + * @return list + */ + public function themes(): array + { + $default = $this->default(); + $active = $this->active(); + + if ($active->getId() === $default->getId()) { + return [$default]; + } + + return [$default, $active]; + } + + /** + * {@inheritDoc} + */ + public function themeTemplatePaths(): array + { + return array_map( + fn (ThemeInterface $theme) => $theme->getTemplatePath()->toNative(), + $this->themes() + ); + } + + /** + * {@inheritDoc} + */ + public function themeTemplateMaps(): array + { + return array_map( + fn (ThemeInterface $theme) => $theme->getTemplateMap(), + $this->themes() + ); + } + + /** + * Resolves and memorizes the theme for the given config key. + */ + private function resolve(string $key): ?ThemeInterface + { + if (array_key_exists($key, $this->cache)) { + return $this->cache[$key]; + } + + /** @var array $themes */ + $themes = $this->config->get('themes', []); + $value = $themes[$key] ?? null; + + if (!is_string($value) || '' === $value) { + return null; + } + + return $this->cache[$key] = $this->build($value); + } + + /** + * Builds a {@see ThemeInterface} from a configured value via + * {@see ThemeFactory::fromConfig()}. + */ + private function build(string $value): ThemeInterface + { + return $this->factory->fromConfig($value); + } +} diff --git a/src/Infrastructure/Service/UrlGenerator.php b/src/Infrastructure/Service/UrlGenerator.php new file mode 100644 index 0000000..91f80f3 --- /dev/null +++ b/src/Infrastructure/Service/UrlGenerator.php @@ -0,0 +1,84 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use League\Route\Router; +use Webify\Base\Domain\ValueObject\ResourcePath; +use Webify\Base\Infrastructure\Contract\{BaseUrlInterface, UrlGeneratorInterface, UrlType}; + +/** + * Implementation for UrlGenerator. + */ +final readonly class UrlGenerator implements UrlGeneratorInterface +{ + /** + * The constructor. + */ + public function __construct( + private Router $router, + private BaseUrlInterface $baseUrl, + private string $defaultScheme = 'https' + ) {} + + /** + * {@inheritDoc} + */ + public function route(string $name, array $parameters = [], UrlType $type = UrlType::Relative): string + { + return $this->build( + $this->normalizePath($this->router->getNamedRoute($name)->getPath($parameters)), + $type + ); + } + + /** + * {@inheritDoc} + */ + public function to(ResourcePath $path, UrlType $type = UrlType::Relative): string + { + return $this->build($this->normalizePath('/' . $path->toNative()), $type); + } + + /** + * {@inheritDoc} + */ + public function path(string $path, UrlType $type = UrlType::Relative): string + { + return $this->build($this->normalizePath($path), $type); + } + + /** + * Build thr URL for the given path and type. + */ + private function build(string $path, UrlType $type): string + { + return match ($type) { + UrlType::Relative => $path, + UrlType::Absolute => $this->baseUrl + ->toNative($this->baseUrl->hasScheme() ? null : $this->defaultScheme) . $path, + // A network-path URL is protocol-relative, so the scheme must be + // dropped regardless of the configured (or default) scheme. + UrlType::NetworkPath => $this->baseUrl + ->toNative('') . $path + }; + } + + /** + * Normalize the given path. + */ + private function normalizePath(string $path): string + { + return '/' . ltrim($path, '/'); + } +} diff --git a/src/Infrastructure/Service/View.php b/src/Infrastructure/Service/View.php new file mode 100644 index 0000000..adffedf --- /dev/null +++ b/src/Infrastructure/Service/View.php @@ -0,0 +1,100 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Service; + +use Webify\Base\Infrastructure\Contract\{ + AssetsPipelineInterface, + ExtensionInterface, + TemplateRendererInterface, + UrlGeneratorInterface, + UrlType, + ViewInterface, +}; + +/** + * View service. + */ +final class View implements ViewInterface +{ + /** + * {@inheritdoc} + */ + public string $layout { + get => $this->layout; + set => $value; + } + + /** + * {@inheritdoc} + */ + public ?ExtensionInterface $extension = null { + get => $this->extension; + set => $value; + } + + /** + * The constructor. + */ + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + private readonly AssetsPipelineInterface $assetPipeline, + private readonly TemplateRendererInterface $renderer + ) {} + + /** + * {@inheritdoc} + */ + public function render(string $template, array $data = [], ?string $layout = null): string + { + if (null !== $layout) { + $this->layout = $layout; + } + + $data['view'] = $this; + + return $this->renderer->render($template, $data, $this->extension); + } + + /** + * {@inheritdoc} + */ + public function url(string $name, array $parameters = [], UrlType $type = UrlType::Relative): string + { + return $this->urlGenerator->route($name, $parameters, $type); + } + + /** + * {@inheritdoc} + */ + public function asset(string $file): string + { + return $this->assetPipeline->url($file); + } + + /** + * {@inheritDoc} + */ + public function assetEntrypoint(string $entry): string + { + return $this->assetPipeline->entrypoint($entry); + } + + /** + * {@inheritdoc} + */ + public function path(string $path, UrlType $type = UrlType::Relative): string + { + return $this->urlGenerator->path($path, $type); + } +} diff --git a/src/Infrastructure/Theme/Theme.php b/src/Infrastructure/Theme/Theme.php new file mode 100644 index 0000000..ec443f5 --- /dev/null +++ b/src/Infrastructure/Theme/Theme.php @@ -0,0 +1,147 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Theme; + +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Contract\{ + AssetsProviderInterface, + ServiceProviderInterface, + ThemeConfigInterface, + ThemeInterface, +}; +use Webify\Base\Infrastructure\Service\ThemeAssetsProvider; + +use function is_dir; +use function is_string; + +/** + * A generic, manifest driven (PHP-less) theme. + * + * Its identity (id/name/version), template path and template overrides are all + * declared in `theme.json`. It has no service provider and ships its built + * assets (if any) under `dist/`. An active theme configured as a plain id/path + * resolves to this concrete class. + */ +final readonly class Theme implements ThemeInterface +{ + /** + * The constructor. + */ + public function __construct( + private ThemeConfigInterface $config, + private string $rootPath + ) {} + + /** + * {@inheritDoc} + */ + public function getId(): string + { + return (string) $this->config->get('id'); + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return (string) $this->config->get('name', $this->getId()); + } + + /** + * {@inheritDoc} + */ + public function getVersion(): string + { + return (string) $this->config->get('version', '0.0.0'); + } + + /** + * {@inheritDoc} + */ + public function getConfig(): ThemeConfigInterface + { + return $this->config; + } + + /** + * {@inheritDoc} + */ + public function getRootPath(): string + { + return $this->rootPath; + } + + /** + * {@inheritDoc} + */ + public function getTemplatePath(): TemplatePath + { + return TemplatePath::fromString($this->rootPath . '/templates'); + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getTemplateMap(): array + { + $map = []; + + /** @var array $templates */ + $templates = $this->config->get('templates', []); + + foreach ($templates as $extensionId => $path) { + if (is_string($extensionId) && is_string($path)) { + $map[$extensionId] = TemplatePath::fromString( + $this->resolvePath($path) + ); + } + } + + return $map; + } + + /** + * A data-only theme has no service provider. + */ + public function getProvider(): ?ServiceProviderInterface + { + return null; + } + + /** + * {@inheritDoc} + */ + public function getAssetsProvider(): ?AssetsProviderInterface + { + return is_dir($this->rootPath . '/dist') + ? new ThemeAssetsProvider($this) + : null; + } + + /** + * Resolves a manifest path to an absolute path, relative paths being + * anchored at the theme root. + */ + private function resolvePath(string $path): string + { + if ('' !== $path && '/' === $path[0]) { + return $path; + } + + return rtrim($this->rootPath, '/') . '/' . ltrim($path, '/'); + } +} diff --git a/src/Infrastructure/Theme/ThemeFactory.php b/src/Infrastructure/Theme/ThemeFactory.php new file mode 100644 index 0000000..0071af6 --- /dev/null +++ b/src/Infrastructure/Theme/ThemeFactory.php @@ -0,0 +1,119 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Theme; + +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Contract\ThemeInterface; +use Webify\Base\Infrastructure\Exception\ThemeException; +use Webify\Base\Infrastructure\Service\ThemeConfig; + +use function file_exists; +use function file_get_contents; +use function is_array; +use function is_subclass_of; +use function json_decode; + +/** + * Builds a theme from a configured value. + * + * When the configured value is a class name (a full PHP theme), the factory + * instantiates it directly with a zero-argument constructor. A PHP theme + * class resolves its own configuration and root path (e.g. by reading the + * `theme.json` next to the class file), so the factory does not wire any of + * that up itself. The configured class must implement {@see ThemeInterface}. + * + * When the value is a plain string (an id or a directory path), a manifest + * driven (PHP-less) generic {@see Theme} is built: the value is resolved to a + * root directory, its `theme.json` is read and validated, and a {@see Theme} + * instance is constructed with the manifest data and the resolved root path. + */ +final readonly class ThemeFactory +{ + /** + * The constructor. + */ + public function __construct( + private ConfigInterface $config, + private ThemeManifestValidator $manifestValidator = new ThemeManifestValidator() + ) {} + + /** + * Builds a theme for the given configured value. + * + * @param string $value a theme class name, a theme id or a theme directory path + * + * @throws ThemeException when the value is a class that does not implement + * `ThemeInterface`, or when the theme root or + * manifest cannot be resolved + */ + public function fromConfig(string $value): ThemeInterface + { + if (!class_exists($value)) { + return $this->fromManifest($value); + } + + if (!is_subclass_of($value, ThemeInterface::class)) { + throw ThemeException::notATheme($value); + } + + /** @var class-string $class */ + $class = $value; + + return new $class(); + } + + /** + * Builds the generic theme for a manifest-driven id or path. + */ + private function fromManifest(string $value): ThemeInterface + { + $themePath = $value; + + if (!is_dir($themePath)) { + $themePath = $this->config->themesPath . DIRECTORY_SEPARATOR . $value; + } + + if (!is_dir($themePath)) { + throw ThemeException::configuredThemeNotFound($themePath); + } + + $data = $this->readManifest($themePath . DIRECTORY_SEPARATOR . 'theme.json'); + + return new Theme(new ThemeConfig($data), $themePath); + } + + /** + * Reads and validates a `theme.json` manifest. + * + * @return array the parsed manifest data + * + * @throws ThemeException when the file is missing, contains invalid JSON, + * or lacks a valid `id` or `name` + */ + private function readManifest(string $path): array + { + if (!file_exists($path)) { + throw ThemeException::manifestNotFound($path); + } + + /** @var array $decoded */ + $decoded = json_decode((string) file_get_contents($path), true); + + if (!is_array($decoded)) { + throw ThemeException::invalidManifest($path); + } + + return $this->manifestValidator->validate($decoded, $path); + } +} diff --git a/src/Infrastructure/Theme/ThemeManifestValidator.php b/src/Infrastructure/Theme/ThemeManifestValidator.php new file mode 100644 index 0000000..fbe0127 --- /dev/null +++ b/src/Infrastructure/Theme/ThemeManifestValidator.php @@ -0,0 +1,63 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Infrastructure\Theme; + +use Webify\Base\Infrastructure\Exception\ThemeException; + +use function is_string; + +/** + * Validates a theme manifest (`theme.json`) payload. + * + * A manifest is considered valid only when it declares both an `id` and a + * `name`, each a non-empty string. + */ +final class ThemeManifestValidator +{ + /** + * Validates the given theme manifest data. + * + * @param array $data the raw `theme.json` data + * + * @return array the validated manifest data + * + * @throws ThemeException when the manifest lacks a valid `id` or `name` + */ + public function validate(array $data, string $path): array + { + if ($this->isMissingRequiredField($data, 'id')) { + throw ThemeException::invalidManifest($path); + } + + if ($this->isMissingRequiredField($data, 'name')) { + throw ThemeException::invalidManifest($path); + } + + return $data; + } + + /** + * Determines whether the given manifest field is absent, not a string, or + * an empty string. + * + * @param array $data the manifest data + * @param string $field the field name to check + */ + private function isMissingRequiredField(array $data, string $field): bool + { + $value = $data[$field] ?? null; + + return !is_string($value) || '' === $value; + } +} diff --git a/src/Infrastructure/definitions.php b/src/Infrastructure/definitions.php deleted file mode 100644 index 2ad870e..0000000 --- a/src/Infrastructure/definitions.php +++ /dev/null @@ -1,139 +0,0 @@ - - */ -declare(strict_types=1); - -use Laminas\HttpHandlerRunner\Emitter\{EmitterInterface, SapiEmitter}; -use League\Event\EventDispatcher; -use League\Route\Router; -use League\Route\Strategy\ApplicationStrategy; -use Monolog\Formatter\JsonFormatter; -use Monolog\{Handler\RotatingFileHandler, Level, Logger}; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\{ServerRequestCreator, ServerRequestCreatorInterface}; -use Psr\Container\ContainerInterface; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Http\Message\{ - RequestFactoryInterface, - ResponseFactoryInterface, - ServerRequestFactoryInterface, - StreamFactoryInterface, - UploadedFileFactoryInterface, - UriFactoryInterface -}; -use Psr\Log\LoggerInterface; -use Symfony\Component\Console\Application as ConsoleApplication; -use Webify\Base\Application\Service\ConfigInterface; -use Webify\Base\Domain\Event\DomainEventPublisherInterface; -use Webify\Base\Infrastructure\Environment\Environment; -use Webify\Base\Infrastructure\Event\LeagueDomainEventPublisher; -use Webify\Base\Infrastructure\Kernel\{Console, Http}; - -use function DI\{autowire, create, factory, get}; - -return [ - // ------------------------------------------------------------------------- - // PSR-17 HTTP Factories - // Nyholm\Psr17Factory implements all PSR-17 factory interfaces in one class. - // Each interface resolves to the same shared instance. - // ------------------------------------------------------------------------- - Psr17Factory::class => create(Psr17Factory::class), - RequestFactoryInterface::class => get(Psr17Factory::class), - ResponseFactoryInterface::class => get(Psr17Factory::class), - ServerRequestFactoryInterface::class => get(Psr17Factory::class), - StreamFactoryInterface::class => get(Psr17Factory::class), - UriFactoryInterface::class => get(Psr17Factory::class), - UploadedFileFactoryInterface::class => get(Psr17Factory::class), - - // ------------------------------------------------------------------------- - // PSR-7 Server Request Creator - // Explicit constructor wiring because all four parameters share the same - // concrete type (Psr17Factory) despite resolving to different interfaces. - // ------------------------------------------------------------------------- - ServerRequestCreatorInterface::class => create(ServerRequestCreator::class) - ->constructor( - get(ServerRequestFactoryInterface::class), - get(UriFactoryInterface::class), - get(UploadedFileFactoryInterface::class), - get(StreamFactoryInterface::class), - ), - - // ------------------------------------------------------------------------- - // HTTP Router - // league/route — route definitions and middleware are added during bootstrapping. - // ------------------------------------------------------------------------- - Router::class => factory( - static function (ContainerInterface $container): Router { - $strategy = new ApplicationStrategy(); - $router = new Router(); - - $strategy->setContainer($container); - $router->setStrategy($strategy); - - return $router; - } - ), - - // ------------------------------------------------------------------------- - // Response Emitter - // ------------------------------------------------------------------------- - EmitterInterface::class => create(SapiEmitter::class), - - // ------------------------------------------------------------------------- - // PSR-3 Logger - // JSON format on disk; verbosity determined by the debug flag. - // ------------------------------------------------------------------------- - LoggerInterface::class => factory( - static function (ConfigInterface $config, Environment $environment): Logger { - $handler = new RotatingFileHandler( - $config->logPath . '/app.log', - 7, - $environment->isDebugEnabled() ? Level::Debug : Level::Warning, - ); - $handler->setFormatter(new JsonFormatter()); - - return new Logger($config->get('id'))->pushHandler($handler); - } - ), - - // ------------------------------------------------------------------------- - // PSR-14 Event Dispatcher - // ------------------------------------------------------------------------- - EventDispatcher::class => create(EventDispatcher::class), - EventDispatcherInterface::class => get(EventDispatcher::class), - - // ------------------------------------------------------------------------- - // Domain Event Publisher - // Bridges the domain contract to the League dispatcher infrastructure. - // ------------------------------------------------------------------------- - DomainEventPublisherInterface::class => create(LeagueDomainEventPublisher::class) - ->constructor(get(EventDispatcherInterface::class)), - - // ------------------------------------------------------------------------- - // Symfony Console Application - // Commands are added during each provider's boot() phase. - // ------------------------------------------------------------------------- - ConsoleApplication::class => factory( - static function (ConfigInterface $config, ContainerInterface $container): ConsoleApplication { - return new ConsoleApplication( - $config->get('name', 'WebifyCMS'), - $config->get('version', '0.0.1'), - $container - ); - } - ), - - // ------------------------------------------------------------------------- - // Kernels - // ------------------------------------------------------------------------- - 'httpKernel' => autowire(Http::class), - 'consoleKernel' => autowire(Console::class), -]; diff --git a/test/Architecture/ArchitectureSpec.php b/test/Architecture/ArchitectureSpec.php index 0df89f6..ed282ab 100644 --- a/test/Architecture/ArchitectureSpec.php +++ b/test/Architecture/ArchitectureSpec.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Contract/ArraySearchHelperTest.php b/test/Unit/Contract/ArraySearchHelperTest.php index 6c41573..b47c228 100644 --- a/test/Unit/Contract/ArraySearchHelperTest.php +++ b/test/Unit/Contract/ArraySearchHelperTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Contract/Collection/CollectionTest.php b/test/Unit/Contract/Collection/CollectionTest.php index e399b3b..88d3c67 100644 --- a/test/Unit/Contract/Collection/CollectionTest.php +++ b/test/Unit/Contract/Collection/CollectionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Contract/Collection/Fixture/Item.php b/test/Unit/Contract/Collection/Fixture/Item.php index 1afb280..3db8466 100644 --- a/test/Unit/Contract/Collection/Fixture/Item.php +++ b/test/Unit/Contract/Collection/Fixture/Item.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Contract/Collection/Fixture/ItemCollection.php b/test/Unit/Contract/Collection/Fixture/ItemCollection.php index 41f6462..5eb703a 100644 --- a/test/Unit/Contract/Collection/Fixture/ItemCollection.php +++ b/test/Unit/Contract/Collection/Fixture/ItemCollection.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Contract/Collection/Fixture/ItemInterface.php b/test/Unit/Contract/Collection/Fixture/ItemInterface.php index 5d48d09..3910634 100644 --- a/test/Unit/Contract/Collection/Fixture/ItemInterface.php +++ b/test/Unit/Contract/Collection/Fixture/ItemInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/Entity/AggregateRootTest.php b/test/Unit/Domain/Entity/AggregateRootTest.php index 96886b7..897d953 100644 --- a/test/Unit/Domain/Entity/AggregateRootTest.php +++ b/test/Unit/Domain/Entity/AggregateRootTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/Entity/Fixture/TestableAggregateRoot.php b/test/Unit/Domain/Entity/Fixture/TestableAggregateRoot.php index 243570c..d9e4bac 100644 --- a/test/Unit/Domain/Entity/Fixture/TestableAggregateRoot.php +++ b/test/Unit/Domain/Entity/Fixture/TestableAggregateRoot.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/Exception/AccessDeniedExceptionTest.php b/test/Unit/Domain/Exception/AccessDeniedExceptionTest.php index 6c2af76..d8fbd3d 100644 --- a/test/Unit/Domain/Exception/AccessDeniedExceptionTest.php +++ b/test/Unit/Domain/Exception/AccessDeniedExceptionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/Exception/DateTimeExceptionTest.php b/test/Unit/Domain/Exception/DateTimeExceptionTest.php index be8fd45..ab7d42a 100644 --- a/test/Unit/Domain/Exception/DateTimeExceptionTest.php +++ b/test/Unit/Domain/Exception/DateTimeExceptionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/Exception/InvalidCollectionIndexExceptionTest.php b/test/Unit/Domain/Exception/InvalidCollectionIndexExceptionTest.php index cd8258b..241ca1f 100644 --- a/test/Unit/Domain/Exception/InvalidCollectionIndexExceptionTest.php +++ b/test/Unit/Domain/Exception/InvalidCollectionIndexExceptionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/Exception/InvalidCollectionItemTypeExceptionTest.php b/test/Unit/Domain/Exception/InvalidCollectionItemTypeExceptionTest.php index 00c1df8..9773978 100644 --- a/test/Unit/Domain/Exception/InvalidCollectionItemTypeExceptionTest.php +++ b/test/Unit/Domain/Exception/InvalidCollectionItemTypeExceptionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/Exception/InvalidUlidExceptionTest.php b/test/Unit/Domain/Exception/InvalidUlidExceptionTest.php index a3fae70..922df7b 100644 --- a/test/Unit/Domain/Exception/InvalidUlidExceptionTest.php +++ b/test/Unit/Domain/Exception/InvalidUlidExceptionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -24,7 +24,7 @@ * @internal */ #[CoversClass(InvalidUlidException::class)] -#[CoversMethod(InvalidUlidException::class, 'forInvalidUlid')] +#[CoversMethod(InvalidUlidException::class, 'forInvalid')] #[UsesClass(ExceptionTranslation::class)] final class InvalidUlidExceptionTest extends TestCase { @@ -34,7 +34,7 @@ final class InvalidUlidExceptionTest extends TestCase #[Test] public function testForInvalidUlid(): void { - $exception = InvalidUlidException::forInvalidUlid('invalid-ulid'); + $exception = InvalidUlidException::forInvalid('invalid-ulid'); self::assertInstanceOf(InvalidUlidException::class, $exception); self::assertSame( diff --git a/test/Unit/Domain/Exception/SecureTokenGenerationFailedExceptionTest.php b/test/Unit/Domain/Exception/SecureTokenGenerationFailedExceptionTest.php index 4dae32b..2d42a97 100644 --- a/test/Unit/Domain/Exception/SecureTokenGenerationFailedExceptionTest.php +++ b/test/Unit/Domain/Exception/SecureTokenGenerationFailedExceptionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/ValueObject/AggregateIdTest.php b/test/Unit/Domain/ValueObject/AggregateIdTest.php index 5ca6988..595b57d 100644 --- a/test/Unit/Domain/ValueObject/AggregateIdTest.php +++ b/test/Unit/Domain/ValueObject/AggregateIdTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/ValueObject/DateTimeTest.php b/test/Unit/Domain/ValueObject/DateTimeTest.php index ac20551..f32cbfc 100644 --- a/test/Unit/Domain/ValueObject/DateTimeTest.php +++ b/test/Unit/Domain/ValueObject/DateTimeTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/ValueObject/EmailTest.php b/test/Unit/Domain/ValueObject/EmailTest.php index c5370c6..5e2025a 100644 --- a/test/Unit/Domain/ValueObject/EmailTest.php +++ b/test/Unit/Domain/ValueObject/EmailTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -227,7 +227,7 @@ public function testFromStringThrowsForEmailExceedingMaxLength(): void public function testBlockedDomainTempmail(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Disposable email addresses are not allowed.'); + $this->expectExceptionMessageIsOrContains('Disposable email addresses are not allowed.'); ExampleEmail::fromString('test@tempmail.com'); } @@ -239,7 +239,7 @@ public function testBlockedDomainTempmail(): void public function testBlockedDomainMailinator(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Disposable email addresses are not allowed.'); + $this->expectExceptionMessageIsOrContains('Disposable email addresses are not allowed.'); ExampleEmail::fromString('test@mailinator.com'); } @@ -251,7 +251,7 @@ public function testBlockedDomainMailinator(): void public function testBlockedDomainGuerrillamail(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Disposable email addresses are not allowed.'); + $this->expectExceptionMessageIsOrContains('Disposable email addresses are not allowed.'); ExampleEmail::fromString('test@guerrillamail.com'); } diff --git a/test/Unit/Domain/ValueObject/Example/ExampleAggregateId.php b/test/Unit/Domain/ValueObject/Example/ExampleAggregateId.php index f0afe0f..9dfa440 100644 --- a/test/Unit/Domain/ValueObject/Example/ExampleAggregateId.php +++ b/test/Unit/Domain/ValueObject/Example/ExampleAggregateId.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/ValueObject/Example/ExampleEmail.php b/test/Unit/Domain/ValueObject/Example/ExampleEmail.php index 9579c57..c758136 100644 --- a/test/Unit/Domain/ValueObject/Example/ExampleEmail.php +++ b/test/Unit/Domain/ValueObject/Example/ExampleEmail.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/ValueObject/Example/ExampleSecureToken.php b/test/Unit/Domain/ValueObject/Example/ExampleSecureToken.php index f7b211d..e622098 100644 --- a/test/Unit/Domain/ValueObject/Example/ExampleSecureToken.php +++ b/test/Unit/Domain/ValueObject/Example/ExampleSecureToken.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/ValueObject/Example/ExampleSlug.php b/test/Unit/Domain/ValueObject/Example/ExampleSlug.php index 1636856..448c539 100644 --- a/test/Unit/Domain/ValueObject/Example/ExampleSlug.php +++ b/test/Unit/Domain/ValueObject/Example/ExampleSlug.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/ValueObject/HashTest.php b/test/Unit/Domain/ValueObject/HashTest.php new file mode 100644 index 0000000..e620654 --- /dev/null +++ b/test/Unit/Domain/ValueObject/HashTest.php @@ -0,0 +1,181 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Domain\ValueObject; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\Exception\InvalidHashException; +use Webify\Base\Domain\ValueObject\Hash; + +/** + * HashTest tests the functionality of the Hash value object. + * + * @internal + */ +#[CoversClass(Hash::class)] +#[CoversMethod(Hash::class, 'generate')] +#[CoversMethod(Hash::class, 'fromString')] +#[CoversMethod(Hash::class, 'toNative')] +#[CoversMethod(Hash::class, 'equals')] +final class HashTest extends TestCase +{ + /** + * Test that generate produces a non-empty string. + */ + #[Test] + public function testGenerateReturnsNonEmptyString(): void + { + $hash = Hash::generate('crc32', 'ext/blog@1.0.0'); + + self::assertNotEmpty($hash->toNative()); + } + + /** + * Test that generate is deterministic: same inputs produce the same output. + */ + #[Test] + public function testGenerateIsDeterministic(): void + { + $hash1 = Hash::generate('crc32', 'ext/blog@1.0.0'); + $hash2 = Hash::generate('crc32', 'ext/blog@1.0.0'); + + self::assertTrue($hash1->equals($hash2)); + self::assertSame($hash1->toNative(), $hash2->toNative()); + } + + /** + * Test that generate uses the default length of 6 characters. + */ + #[Test] + public function testGenerateUsesDefaultLength(): void + { + $hash = Hash::generate('crc32', 'ext/blog@1.0.0'); + + self::assertSame(6, strlen($hash->toNative())); + } + + /** + * Test that generate honors a custom length. + */ + #[Test] + public function testGenerateHonorsCustomLength(): void + { + $hash = Hash::generate('sha256', 'ext/blog@1.0.0', 12); + + self::assertSame(12, strlen($hash->toNative())); + } + + /** + * Test that generate returns an instance of the Hash value object. + */ + #[Test] + public function testGenerateReturnsHashInstance(): void + { + $hash = Hash::generate('crc32', 'ext/blog@1.0.0'); + + self::assertInstanceOf(Hash::class, $hash); + } + + /** + * Test that different algorithms produce different hashes. + */ + #[Test] + public function testDifferentAlgorithmsProduceDifferentHashes(): void + { + $hash1 = Hash::generate('crc32', 'ext/blog@1.0.0'); + $hash2 = Hash::generate('sha256', 'ext/blog@1.0.0'); + + self::assertNotSame($hash1->toNative(), $hash2->toNative()); + } + + /** + * Test that different data produce different hashes. + */ + #[Test] + public function testDifferentDataProduceDifferentHashes(): void + { + $hash1 = Hash::generate('sha256', 'ext/blog@1.0.0'); + $hash2 = Hash::generate('sha256', 'ext/blog@1.0.1'); + + self::assertNotSame($hash1->toNative(), $hash2->toNative()); + } + + /** + * Test that fromString creates an instance from a valid string. + */ + #[Test] + public function testFromStringWithValidString(): void + { + $hash = Hash::fromString('abc123'); + + self::assertSame('abc123', $hash->toNative()); + } + + /** + * Test that __toString delegates to toNative. + */ + #[Test] + public function testToStringDelegatesToToNative(): void + { + $hash = Hash::fromString('k7x9m2'); + + self::assertSame('k7x9m2', (string) $hash); + } + + /** + * Test that equals returns true for identical values. + */ + #[Test] + public function testEqualsReturnsTrueForIdenticalValues(): void + { + $hash1 = Hash::fromString('abc123'); + $hash2 = Hash::fromString('abc123'); + + self::assertTrue($hash1->equals($hash2)); + } + + /** + * Test that equals returns false for different values. + */ + #[Test] + public function testEqualsReturnsFalseForDifferentValues(): void + { + $hash1 = Hash::fromString('abc123'); + $hash2 = Hash::fromString('xyz789'); + + self::assertFalse($hash1->equals($hash2)); + } + + /** + * Test that the generated hash is always lowercase. + */ + #[Test] + public function testGenerateProducesLowercaseHash(): void + { + $hash = Hash::generate('sha256', 'theme/starter@2.1.0'); + + self::assertSame($hash->toNative(), strtolower($hash->toNative())); + } + + /** + * Test that fromString throws exception for empty string. + */ + #[Test] + public function testFromStringThrowsExceptionForEmptyString(): void + { + $this->expectException(InvalidHashException::class); + + Hash::fromString(''); + } +} diff --git a/test/Unit/Domain/ValueObject/SecureTokenTest.php b/test/Unit/Domain/ValueObject/SecureTokenTest.php index 6d4b540..d8c659f 100644 --- a/test/Unit/Domain/ValueObject/SecureTokenTest.php +++ b/test/Unit/Domain/ValueObject/SecureTokenTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Domain/ValueObject/SlugTest.php b/test/Unit/Domain/ValueObject/SlugTest.php index 7f700fc..cbd4ff0 100644 --- a/test/Unit/Domain/ValueObject/SlugTest.php +++ b/test/Unit/Domain/ValueObject/SlugTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Container/Definitions/ConsoleDefinitionsTest.php b/test/Unit/Infrastructure/Container/Definitions/ConsoleDefinitionsTest.php new file mode 100644 index 0000000..5046207 --- /dev/null +++ b/test/Unit/Infrastructure/Container/Definitions/ConsoleDefinitionsTest.php @@ -0,0 +1,50 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Container\Definitions; + +use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Application as ConsoleApplication; +use Webify\Base\Infrastructure\Container\Definitions\ConsoleDefinitions; +use Webify\Base\Infrastructure\Contract\DefinitionsInterface; + +/** + * ConsoleDefinitionsTest tests the console definitions group. + * + * @internal + */ +#[CoversClass(ConsoleDefinitions::class)] +final class ConsoleDefinitionsTest extends TestCase +{ + /** + * Tests that the group implements the definitions contract. + */ + #[Test] + public function testImplementsDefinitionsInterface(): void + { + self::assertInstanceOf(DefinitionsInterface::class, new ConsoleDefinitions()); + } + + /** + * Tests that the console application and kernel are wired. + */ + #[Test] + public function testGetDefinitionsWiresConsoleStack(): void + { + $definitions = ConsoleDefinitions::getDefinitions(); + + self::assertArrayHasKey(ConsoleApplication::class, $definitions); + self::assertArrayHasKey('consoleKernel', $definitions); + } +} diff --git a/test/Unit/Infrastructure/Container/Definitions/EventDefinitionsTest.php b/test/Unit/Infrastructure/Container/Definitions/EventDefinitionsTest.php new file mode 100644 index 0000000..0f5b06f --- /dev/null +++ b/test/Unit/Infrastructure/Container/Definitions/EventDefinitionsTest.php @@ -0,0 +1,51 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Container\Definitions; + +use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Infrastructure\Container\Definitions\EventDefinitions; +use Webify\Base\Infrastructure\Contract\DefinitionsInterface; + +/** + * EventDefinitionsTest tests the eventing definitions group. + * + * @internal + */ +#[CoversClass(EventDefinitions::class)] +final class EventDefinitionsTest extends TestCase +{ + /** + * Tests that the group implements the definitions contract. + */ + #[Test] + public function testImplementsDefinitionsInterface(): void + { + self::assertInstanceOf(DefinitionsInterface::class, new EventDefinitions()); + } + + /** + * Tests that the dispatcher and domain event publisher are wired. + */ + #[Test] + public function testGetDefinitionsWiresEventStack(): void + { + $definitions = EventDefinitions::getDefinitions(); + + self::assertArrayHasKey(EventDispatcherInterface::class, $definitions); + self::assertArrayHasKey(DomainEventPublisherInterface::class, $definitions); + } +} diff --git a/test/Unit/Infrastructure/Container/Definitions/HttpDefinitionsTest.php b/test/Unit/Infrastructure/Container/Definitions/HttpDefinitionsTest.php new file mode 100644 index 0000000..255f91c --- /dev/null +++ b/test/Unit/Infrastructure/Container/Definitions/HttpDefinitionsTest.php @@ -0,0 +1,58 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Container\Definitions; + +use Laminas\HttpHandlerRunner\Emitter\EmitterInterface; +use League\Route\Router; +use Nyholm\Psr7Server\ServerRequestCreatorInterface; +use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestFactoryInterface; +use Webify\Base\Contract\EscaperInterface; +use Webify\Base\Infrastructure\Container\Definitions\HttpDefinitions; +use Webify\Base\Infrastructure\Contract\DefinitionsInterface; + +/** + * HttpDefinitionsTest tests the HTTP stack definitions group. + * + * @internal + */ +#[CoversClass(HttpDefinitions::class)] +final class HttpDefinitionsTest extends TestCase +{ + /** + * Tests that the group implements the definitions contract. + */ + #[Test] + public function testImplementsDefinitionsInterface(): void + { + self::assertInstanceOf(DefinitionsInterface::class, new HttpDefinitions()); + } + + /** + * Tests that the HTTP stack is wired. + */ + #[Test] + public function testGetDefinitionsWiresHttpStack(): void + { + $definitions = HttpDefinitions::getDefinitions(); + + self::assertArrayHasKey(EscaperInterface::class, $definitions); + self::assertArrayHasKey(RequestFactoryInterface::class, $definitions); + self::assertArrayHasKey(ServerRequestCreatorInterface::class, $definitions); + self::assertArrayHasKey(Router::class, $definitions); + self::assertArrayHasKey(EmitterInterface::class, $definitions); + self::assertArrayHasKey('httpKernel', $definitions); + } +} diff --git a/test/Unit/Infrastructure/Container/Definitions/LoggingDefinitionsTest.php b/test/Unit/Infrastructure/Container/Definitions/LoggingDefinitionsTest.php new file mode 100644 index 0000000..e8a67f8 --- /dev/null +++ b/test/Unit/Infrastructure/Container/Definitions/LoggingDefinitionsTest.php @@ -0,0 +1,49 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Container\Definitions; + +use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Webify\Base\Infrastructure\Container\Definitions\LoggingDefinitions; +use Webify\Base\Infrastructure\Contract\DefinitionsInterface; + +/** + * LoggingDefinitionsTest tests the logging definitions group. + * + * @internal + */ +#[CoversClass(LoggingDefinitions::class)] +final class LoggingDefinitionsTest extends TestCase +{ + /** + * Tests that the group implements the definitions contract. + */ + #[Test] + public function testImplementsDefinitionsInterface(): void + { + self::assertInstanceOf(DefinitionsInterface::class, new LoggingDefinitions()); + } + + /** + * Tests that the logger is wired. + */ + #[Test] + public function testGetDefinitionsWiresLogger(): void + { + $definitions = LoggingDefinitions::getDefinitions(); + + self::assertArrayHasKey(LoggerInterface::class, $definitions); + } +} diff --git a/test/Unit/Infrastructure/Container/Definitions/ServiceDefinitionsTest.php b/test/Unit/Infrastructure/Container/Definitions/ServiceDefinitionsTest.php new file mode 100644 index 0000000..1c611ef --- /dev/null +++ b/test/Unit/Infrastructure/Container/Definitions/ServiceDefinitionsTest.php @@ -0,0 +1,48 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Container\Definitions; + +use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Infrastructure\Container\Definitions\ServiceDefinitions; +use Webify\Base\Infrastructure\Contract\{AssetsPublisherInterface, DefinitionsInterface}; + +/** + * ServiceDefinitionsTest tests the theme and asset publishing definitions group. + * + * @internal + */ +#[CoversClass(ServiceDefinitions::class)] +final class ServiceDefinitionsTest extends TestCase +{ + /** + * Tests that the group implements the definitions contract. + */ + #[Test] + public function testImplementsDefinitionsInterface(): void + { + self::assertInstanceOf(DefinitionsInterface::class, new ServiceDefinitions()); + } + + /** + * Tests that the asset publisher is wired. + */ + #[Test] + public function testGetDefinitionsWiresAssetPublisher(): void + { + $definitions = ServiceDefinitions::getDefinitions(); + + self::assertArrayHasKey(AssetsPublisherInterface::class, $definitions); + } +} diff --git a/test/Unit/Infrastructure/Container/Example/ExampleExtension.php b/test/Unit/Infrastructure/Container/Example/ExampleExtension.php index 96c60f3..51c460d 100644 --- a/test/Unit/Infrastructure/Container/Example/ExampleExtension.php +++ b/test/Unit/Infrastructure/Container/Example/ExampleExtension.php @@ -6,14 +6,15 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); namespace Webify\Base\Test\Unit\Infrastructure\Container\Example; -use Webify\Base\Infrastructure\Contract\{ExtensionInterface, ServiceProviderInterface}; +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Contract\{AssetsProviderInterface, ExtensionInterface}; /** * Example extension class for testing purposes. @@ -41,7 +42,7 @@ public function getName(): string /** * {@inheritdoc} * - * @return array> + * @return list> */ public function getProviders(): array { @@ -55,4 +56,28 @@ public function getVersion(): string { return '1.0.0'; } + + /** + * {@inheritdoc} + */ + public function getSlug(): string + { + return 'example-extension'; + } + + /** + * {@inheritdoc} + */ + public function getTemplatePath(): TemplatePath + { + return TemplatePath::fromString(__DIR__ . '/../templates'); + } + + /** + * {@inheritdoc} + */ + public function getAssetsProvider(): ?AssetsProviderInterface + { + return null; + } } diff --git a/test/Unit/Infrastructure/Container/Example/ExampleExtensionServiceProvider.php b/test/Unit/Infrastructure/Container/Example/ExampleExtensionServiceProvider.php index 3071d5f..08e7a9b 100644 --- a/test/Unit/Infrastructure/Container/Example/ExampleExtensionServiceProvider.php +++ b/test/Unit/Infrastructure/Container/Example/ExampleExtensionServiceProvider.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Container/Example/ExampleServiceProvider.php b/test/Unit/Infrastructure/Container/Example/ExampleServiceProvider.php index 0ab8485..08979af 100644 --- a/test/Unit/Infrastructure/Container/Example/ExampleServiceProvider.php +++ b/test/Unit/Infrastructure/Container/Example/ExampleServiceProvider.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Container/Example/ExampleTheme.php b/test/Unit/Infrastructure/Container/Example/ExampleTheme.php new file mode 100644 index 0000000..6573207 --- /dev/null +++ b/test/Unit/Infrastructure/Container/Example/ExampleTheme.php @@ -0,0 +1,117 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Container\Example; + +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Contract\{ + AssetsProviderInterface, + ServiceProviderInterface, + ThemeConfigInterface, + ThemeInterface, +}; +use Webify\Base\Infrastructure\Service\ThemeConfig; + +/** + * Example theme class for testing purposes. + * + * @internal + */ +final readonly class ExampleTheme implements ThemeInterface +{ + /** + * The constructor. + */ + public function __construct( + private ThemeConfigInterface $config = new ThemeConfig([ + 'id' => 'example', + 'name' => 'Example Theme', + 'version' => '1.0.0', + ]), + private string $rootPath = '/app/themes/example' + ) {} + + /** + * {@inheritDoc} + */ + public function getId(): string + { + return (string) $this->config->get('id'); + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return (string) $this->config->get('name', $this->getId()); + } + + /** + * {@inheritDoc} + */ + public function getVersion(): string + { + return (string) $this->config->get('version', '0.0.0'); + } + + /** + * {@inheritDoc} + */ + public function getConfig(): ThemeConfigInterface + { + return $this->config; + } + + /** + * {@inheritDoc} + */ + public function getRootPath(): string + { + return $this->rootPath; + } + + /** + * {@inheritDoc} + */ + public function getTemplatePath(): TemplatePath + { + return TemplatePath::fromString($this->rootPath . '/templates'); + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getTemplateMap(): array + { + return []; + } + + /** + * {@inheritDoc} + */ + public function getProvider(): ?ServiceProviderInterface + { + return null; + } + + /** + * {@inheritDoc} + */ + public function getAssetsProvider(): ?AssetsProviderInterface + { + return null; + } +} diff --git a/test/Unit/Infrastructure/Container/Example/theme.json b/test/Unit/Infrastructure/Container/Example/theme.json new file mode 100644 index 0000000..dd096fb --- /dev/null +++ b/test/Unit/Infrastructure/Container/Example/theme.json @@ -0,0 +1,5 @@ +{ + "id": "example", + "name": "Example Theme", + "version": "1.0.0" +} \ No newline at end of file diff --git a/test/Unit/Infrastructure/Container/PhpDiContainerBuilderTest.php b/test/Unit/Infrastructure/Container/PhpDiContainerBuilderTest.php index 1d2519c..c58f25e 100644 --- a/test/Unit/Infrastructure/Container/PhpDiContainerBuilderTest.php +++ b/test/Unit/Infrastructure/Container/PhpDiContainerBuilderTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -21,7 +21,7 @@ use Webify\Base\Infrastructure\Environment\Environment; use Webify\Base\Infrastructure\Exception\ApplicationException; use Webify\Base\Infrastructure\Service\{Application, Config}; -use Webify\Base\Test\Unit\Infrastructure\Container\Example\{ExampleExtension, ExampleServiceProvider}; +use Webify\Base\Test\Unit\Infrastructure\Container\Example\{ExampleExtension, ExampleServiceProvider, ExampleTheme}; /** * PhpDiContainerBuilderTest tests the functionality of the PhpDiContainerBuilder class. @@ -160,6 +160,9 @@ private function createApplication(array $extraConfig = [], string $env = 'devel 'basePath' => '/app', 'runtimePath' => $this->tempRuntimePath, 'configPath' => '/app/config', + 'themes' => [ + 'default' => ExampleTheme::class, + ], ], $extraConfig)); $envConfig = self::createStub(ConfigInterface::class); $envConfig->method('has')->willReturn(true); diff --git a/test/Unit/Infrastructure/Environment/EnvironmentTest.php b/test/Unit/Infrastructure/Environment/EnvironmentTest.php index a7d861c..ada21e2 100644 --- a/test/Unit/Infrastructure/Environment/EnvironmentTest.php +++ b/test/Unit/Infrastructure/Environment/EnvironmentTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -80,7 +80,7 @@ public function testPrepareWithDevelopmentEnvironment(): void public function testPrepareThrowsExceptionWhenEnvironmentNotDefined(): void { $this->expectException(ApplicationEnvironmentException::class); - $this->expectExceptionMessage('Application environment not defined in the configurations.'); + $this->expectExceptionMessageIsOrContains('Application environment not defined in the configurations.'); $config = $this->createMock(ConfigInterface::class); $config->expects(self::once())->method('has')->with('environment')->willReturn(false); diff --git a/test/Unit/Infrastructure/Environment/TypeTest.php b/test/Unit/Infrastructure/Environment/TypeTest.php index 2ca29b1..4749153 100644 --- a/test/Unit/Infrastructure/Environment/TypeTest.php +++ b/test/Unit/Infrastructure/Environment/TypeTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Event/LeagueDomainEventPublisherTest.php b/test/Unit/Infrastructure/Event/LeagueDomainEventPublisherTest.php index e6d73a5..c78d2d7 100644 --- a/test/Unit/Infrastructure/Event/LeagueDomainEventPublisherTest.php +++ b/test/Unit/Infrastructure/Event/LeagueDomainEventPublisherTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Exception/ApplicationEnvironmentExceptionTest.php b/test/Unit/Infrastructure/Exception/ApplicationEnvironmentExceptionTest.php index bd1ac15..98793f0 100644 --- a/test/Unit/Infrastructure/Exception/ApplicationEnvironmentExceptionTest.php +++ b/test/Unit/Infrastructure/Exception/ApplicationEnvironmentExceptionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Exception/ApplicationExceptionTest.php b/test/Unit/Infrastructure/Exception/ApplicationExceptionTest.php index 5fa2e1c..27417a1 100644 --- a/test/Unit/Infrastructure/Exception/ApplicationExceptionTest.php +++ b/test/Unit/Infrastructure/Exception/ApplicationExceptionTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -24,7 +24,7 @@ * @internal */ #[CoversClass(ApplicationException::class)] -#[CoversMethod(ApplicationException::class, 'basePathNotDefined')] +#[CoversMethod(ApplicationException::class, 'publicPathNotDefined')] #[CoversMethod(ApplicationException::class, 'runtimePathNotDefined')] #[CoversMethod(ApplicationException::class, 'runtimePathIsNotWritable')] #[CoversMethod(ApplicationException::class, 'unableToCreateRuntimePaths')] @@ -37,12 +37,12 @@ final class ApplicationExceptionTest extends TestCase #[Test] public function testBasePathNotDefined(): void { - $exception = ApplicationException::basePathNotDefined(); + $exception = ApplicationException::publicPathNotDefined(); self::assertInstanceOf(ApplicationException::class, $exception); self::assertSame('Application base path not defined in the configurations.', $exception->getMessage()); self::assertSame('base.config', $exception->translation->group); - self::assertSame('bath_path_not_defined', $exception->translation->key); + self::assertSame('base_path_not_defined', $exception->translation->key); } /** diff --git a/test/Unit/Infrastructure/Exception/AssetPublishExceptionTest.php b/test/Unit/Infrastructure/Exception/AssetPublishExceptionTest.php new file mode 100644 index 0000000..d33690b --- /dev/null +++ b/test/Unit/Infrastructure/Exception/AssetPublishExceptionTest.php @@ -0,0 +1,111 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Exception; + +use PHPUnit\Framework\Attributes\{CoversClass, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Webify\Base\Contract\Exception\TranslatableExceptionInterface; +use Webify\Base\Contract\Translation\ExceptionTranslation; +use Webify\Base\Infrastructure\Exception\AssetPublishException; + +/** + * AssetPublishExceptionTest tests the functionality of the AssetPublishException class. + * + * @internal + */ +#[CoversClass(AssetPublishException::class)] +#[CoversClass(ExceptionTranslation::class)] +#[UsesClass(TranslatableExceptionInterface::class)] +final class AssetPublishExceptionTest extends TestCase +{ + /** + * Test that the exception implements the TranslatableExceptionInterface. + */ + #[Test] + public function testImplementsTranslatableExceptionInterface(): void + { + $exception = AssetPublishException::sourceNotFound('/some/path'); + + self::assertInstanceOf(TranslatableExceptionInterface::class, $exception); + } + + /** + * Test that the translation DTO is populated for every factory method. + */ + #[Test] + public function testTranslationIsExceptionTranslation(): void + { + $exception = AssetPublishException::sourceNotFound('/some/path'); + + self::assertInstanceOf(ExceptionTranslation::class, $exception->translation); + self::assertSame('base.asset_publish', $exception->translation->group); + self::assertSame('source_not_found', $exception->translation->key); + self::assertSame(['path' => '/some/path'], $exception->translation->params); + } + + /** + * Test that sourceNotFound contains the path and has no previous exception. + */ + #[Test] + public function testSourceNotFound(): void + { + $path = '/var/www/public/assets/abc123'; + $exception = AssetPublishException::sourceNotFound($path); + + self::assertStringContainsString($path, $exception->getMessage()); + self::assertNull($exception->getPrevious()); + self::assertSame('base.asset_publish', $exception->translation->group); + self::assertSame('source_not_found', $exception->translation->key); + self::assertSame(['path' => $path], $exception->translation->params); + } + + /** + * Test that targetNotWritable contains the path and chains the previous exception. + */ + #[Test] + public function testTargetNotWritable(): void + { + $path = '/var/www/public/assets/abc123'; + $previous = new RuntimeException('previous failure'); + $exception = AssetPublishException::targetNotWritable($path, $previous); + + self::assertStringContainsString($path, $exception->getMessage()); + self::assertMatchesRegularExpression('/not writable/', $exception->getMessage()); + self::assertSame($previous, $exception->getPrevious()); + self::assertSame('base.asset_publish', $exception->translation->group); + self::assertSame('target_not_writable', $exception->translation->key); + self::assertSame(['path' => $path], $exception->translation->params); + } + + /** + * Test that copyFailed contains the source and target paths and chains the previous exception. + */ + #[Test] + public function testCopyFailed(): void + { + $source = '/var/www/extensions/ext/src/dist'; + $target = '/var/www/public/assets/abc123'; + $previous = new RuntimeException('previous failure'); + $exception = AssetPublishException::copyFailed($source, $target, $previous); + + self::assertStringContainsString('Failed to copy asset', $exception->getMessage()); + self::assertStringContainsString($source, $exception->getMessage()); + self::assertStringContainsString($target, $exception->getMessage()); + self::assertSame($previous, $exception->getPrevious()); + self::assertSame('base.asset_publish', $exception->translation->group); + self::assertSame('copy_failed', $exception->translation->key); + self::assertSame(['source' => $source, 'target' => $target], $exception->translation->params); + } +} diff --git a/test/Unit/Infrastructure/Helper/EscaperTest.php b/test/Unit/Infrastructure/Helper/EscaperTest.php new file mode 100644 index 0000000..497df7d --- /dev/null +++ b/test/Unit/Infrastructure/Helper/EscaperTest.php @@ -0,0 +1,63 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Helper; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Contract\EscaperInterface; +use Webify\Base\Infrastructure\Helper\Escaper; + +/** + * EscaperTest tests the default HTML escaper implementation. + * + * @internal + */ +#[CoversClass(Escaper::class)] +#[CoversMethod(Escaper::class, 'escape')] +final class EscaperTest extends TestCase +{ + /** + * Tests that the escaper implements the common contract. + */ + #[Test] + public function testImplementsEscaperInterface(): void + { + self::assertInstanceOf(EscaperInterface::class, new Escaper()); + } + + /** + * Tests that HTML special characters are escaped, quotes included. + */ + #[Test] + public function testEscapesHtmlSpecialCharacters(): void + { + $escaper = new Escaper(); + + self::assertSame( + '<script>alert("xss")&'done'</script>', + $escaper->escape('') + ); + } + + /** + * Tests that a plain string is returned unchanged. + */ + #[Test] + public function testLeavesPlainStringUntouched(): void + { + $escaper = new Escaper(); + + self::assertSame('Hello, WebifyCMS!', $escaper->escape('Hello, WebifyCMS!')); + } +} diff --git a/test/Unit/Infrastructure/Kernel/ConsoleKernelTest.php b/test/Unit/Infrastructure/Kernel/ConsoleKernelTest.php index 56a4ffb..7323e4b 100644 --- a/test/Unit/Infrastructure/Kernel/ConsoleKernelTest.php +++ b/test/Unit/Infrastructure/Kernel/ConsoleKernelTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Kernel/HttpKernelTest.php b/test/Unit/Infrastructure/Kernel/HttpKernelTest.php index aa897f0..c27ee05 100644 --- a/test/Unit/Infrastructure/Kernel/HttpKernelTest.php +++ b/test/Unit/Infrastructure/Kernel/HttpKernelTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -118,7 +118,7 @@ public function testHandleCatchesExceptionAndUsesErrorHandler(): void public function testHandleRethrowsNonNotFoundExceptionInDebugMode(): void { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Something went wrong'); + $this->expectExceptionMessageIsOrContains('Something went wrong'); $request = self::createStub(ServerRequestInterface::class); $requestCreator = $this->createMock(ServerRequestCreatorInterface::class); @@ -185,6 +185,9 @@ public function testHandleDoesNotRethrowNotFoundExceptionInDebugMode(): void $kernel->handle(); } + /** + * Create an environment instance with the given debug flag. + */ private function createEnvironment(bool $debug): Environment { $config = self::createStub(ConfigInterface::class); diff --git a/test/Unit/Infrastructure/Provider/BaseServiceProviderTest.php b/test/Unit/Infrastructure/Provider/BaseServiceProviderTest.php deleted file mode 100644 index 0eec6f6..0000000 --- a/test/Unit/Infrastructure/Provider/BaseServiceProviderTest.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ -declare(strict_types=1); - -namespace Webify\Base\Test\Unit\Infrastructure\Provider; - -use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; -use PHPUnit\Framework\TestCase; -use Webify\Base\Infrastructure\Contract\ServiceProviderInterface; -use Webify\Base\Infrastructure\Provider\BaseServiceProvider; - -/** - * BaseServiceProviderTest tests the functionality of the BaseServiceProvider class. - * - * @internal - */ -#[CoversClass(BaseServiceProvider::class)] -#[CoversMethod(BaseServiceProvider::class, 'getDefinitions')] -final class BaseServiceProviderTest extends TestCase -{ - /** - * Test that the provider implements ServiceProviderInterface. - */ - #[Test] - public function testImplementsInterfaces(): void - { - $provider = new BaseServiceProvider(); - - self::assertInstanceOf(ServiceProviderInterface::class, $provider); - } - - /** - * Test that getDefinitions returns an array of definitions. - */ - #[Test] - public function testGetDefinitionsReturnsArray(): void - { - $provider = new BaseServiceProvider(); - - self::assertIsArray($provider->getDefinitions()); - } -} diff --git a/test/Unit/Infrastructure/Provider/BaseServiceTest.php b/test/Unit/Infrastructure/Provider/BaseServiceTest.php new file mode 100644 index 0000000..259bc41 --- /dev/null +++ b/test/Unit/Infrastructure/Provider/BaseServiceTest.php @@ -0,0 +1,75 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Provider; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Application as ConsoleApplication; +use Webify\Base\Domain\Event\DomainEventPublisherInterface; +use Webify\Base\Infrastructure\Contract\{ + AssetsPublisherInterface, + ServiceProviderInterface, + TemplateMapOverridesInterface +}; +use Webify\Base\Infrastructure\Provider\BaseService; + +/** + * BaseServiceTest tests the functionality of the BaseService class. + * + * @internal + */ +#[CoversClass(BaseService::class)] +#[CoversMethod(BaseService::class, 'getDefinitions')] +final class BaseServiceTest extends TestCase +{ + /** + * Test that the provider implements ServiceProviderInterface. + */ + #[Test] + public function testImplementsInterfaces(): void + { + $provider = new BaseService(); + + self::assertInstanceOf(ServiceProviderInterface::class, $provider); + } + + /** + * Test that getDefinitions returns an array of definitions. + */ + #[Test] + public function testGetDefinitionsReturnsArray(): void + { + $provider = new BaseService(); + + self::assertIsArray($provider->getDefinitions()); + } + + /** + * Test that getDefinitions merges every definition group. + */ + #[Test] + public function testGetDefinitionsMergesEveryGroup(): void + { + $definitions = new BaseService()->getDefinitions(); + + self::assertArrayHasKey(RequestFactoryInterface::class, $definitions); + self::assertArrayHasKey(ConsoleApplication::class, $definitions); + self::assertArrayHasKey(DomainEventPublisherInterface::class, $definitions); + self::assertArrayHasKey(LoggerInterface::class, $definitions); + self::assertArrayHasKey(AssetsPublisherInterface::class, $definitions); + self::assertArrayHasKey(TemplateMapOverridesInterface::class, $definitions); + } +} diff --git a/test/Unit/Infrastructure/Provider/Fixture/routes.php b/test/Unit/Infrastructure/Provider/Fixture/routes.php index 51a54d7..497f161 100644 --- a/test/Unit/Infrastructure/Provider/Fixture/routes.php +++ b/test/Unit/Infrastructure/Provider/Fixture/routes.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Service/ApplicationTest.php b/test/Unit/Infrastructure/Service/ApplicationTest.php index 2b9cbde..5ad3dd0 100644 --- a/test/Unit/Infrastructure/Service/ApplicationTest.php +++ b/test/Unit/Infrastructure/Service/ApplicationTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); @@ -17,8 +17,8 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Webify\Base\Application\Service\ConfigInterface; -use Webify\Base\Infrastructure\Container\ContainerBuilderInterface; use Webify\Base\Infrastructure\Contract\{BootstrapServiceProviderInterface, ServiceProviderInterface}; +use Webify\Base\Infrastructure\Contract\ContainerBuilderInterface; use Webify\Base\Infrastructure\Environment\{Environment, Type}; use Webify\Base\Infrastructure\Exception\ApplicationException; use Webify\Base\Infrastructure\Service\{Application, Config}; @@ -74,7 +74,7 @@ protected function tearDown(): void public function testConstructorThrowsExceptionWhenBasePathNotDefined(): void { $this->expectException(ApplicationException::class); - $this->expectExceptionMessage('Application base path not defined in the configurations.'); + $this->expectExceptionMessageIsOrContains('Application base path not defined in the configurations.'); $config = new Config([ 'basePath' => '', @@ -93,7 +93,7 @@ public function testConstructorThrowsExceptionWhenBasePathNotDefined(): void public function testConstructorThrowsExceptionWhenRuntimePathNotDefined(): void { $this->expectException(ApplicationException::class); - $this->expectExceptionMessage('Application runtime path not defined in the configurations.'); + $this->expectExceptionMessageIsOrContains('Application runtime path not defined in the configurations.'); $config = new Config([ 'basePath' => '/app', @@ -112,7 +112,7 @@ public function testConstructorThrowsExceptionWhenRuntimePathNotDefined(): void public function testConstructorThrowsExceptionWhenRuntimePathNotWritable(): void { $this->expectException(ApplicationException::class); - $this->expectExceptionMessage('is not writable'); + $this->expectExceptionMessageIsOrContains('is not writable'); $nonWritablePath = $this->tempRuntimePath . '/non_writable'; mkdir($nonWritablePath, 0o444, true); diff --git a/test/Unit/Infrastructure/Service/AssetsPublisherTest.php b/test/Unit/Infrastructure/Service/AssetsPublisherTest.php new file mode 100644 index 0000000..8dc39e9 --- /dev/null +++ b/test/Unit/Infrastructure/Service/AssetsPublisherTest.php @@ -0,0 +1,464 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Domain\ValueObject\Hash; +use Webify\Base\Infrastructure\Contract\{AssetsProviderInterface, AssetsPublisherInterface}; +use Webify\Base\Infrastructure\Exception\AssetPublishException; +use Webify\Base\Infrastructure\Service\{AssetsPublisher, Config}; + +/** + * AssetPublisherTest tests the functionality of the AssetPublisher service. + * + * @internal + */ +#[CoversClass(AssetsPublisher::class)] +#[CoversClass(AssetPublishException::class)] +#[UsesClass(Config::class)] +#[UsesClass(Hash::class)] +#[UsesClass(IOException::class)] +final class AssetsPublisherTest extends TestCase +{ + /** + * Temporary directory to isolate the test filesystem activity. + */ + private string $tempDir; + + /** + * The public path (web root) used as the publishing target. + */ + private string $publicPath; + + /** + * The test configuration instance. + */ + private ConfigInterface $config; + + /** + * The asset publisher under test. + */ + private AssetsPublisher $publisher; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/webify_asset_test_' . uniqid('', true); + $this->publicPath = $this->tempDir . '/public'; + + mkdir($this->publicPath, 0o755, true); + + $this->config = new Config([ + 'basePath' => $this->tempDir, + 'publicPath' => $this->publicPath, + 'runtimePath' => $this->tempDir . '/runtime', + 'configPath' => $this->tempDir . '/config', + ]); + $this->publisher = new AssetsPublisher($this->config, new Filesystem()); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + /** + * Test that the publisher implements the AssetPublisherInterface. + */ + #[Test] + public function testImplementsAssetPublisherInterface(): void + { + self::assertInstanceOf(AssetsPublisherInterface::class, $this->publisher); + } + + /** + * Test that publish copies the source files to a hashed directory under the public path. + */ + #[Test] + public function testPublishCopiesFilesToTargetDirectory(): void + { + $source = $this->createSourceDirectory([ + 'app.js' => 'console.log("hello");', + 'app.css' => 'body { margin: 0; }', + ]); + $provider = $this->createProvider('abc123', $source); + + $this->publisher->publish($provider); + self::assertDirectoryExists($this->publicPath . '/assets/abc123'); + self::assertFileExists($this->publicPath . '/assets/abc123/app.js'); + self::assertFileExists($this->publicPath . '/assets/abc123/app.css'); + self::assertSame( + 'console.log("hello");', + file_get_contents($this->publicPath . '/assets/abc123/app.js') + ); + } + + /** + * Test that publish preserves the nested directory structure of the source. + */ + #[Test] + public function testPublishCopiesNestedDirectories(): void + { + $source = $this->createSourceDirectory([ + 'js' => [ + 'main.js' => '// main', + ], + 'css' => [ + 'style.css' => '/* style */', + ], + ]); + $provider = $this->createProvider('abc123', $source); + + $this->publisher->publish($provider); + self::assertFileExists($this->publicPath . '/assets/abc123/js/main.js'); + self::assertFileExists($this->publicPath . '/assets/abc123/css/style.css'); + self::assertSame( + '// main', + file_get_contents($this->publicPath . '/assets/abc123/js/main.js') + ); + } + + /** + * Test that publishing the same provider twice does not fail. + */ + #[Test] + public function testPublishIsIdempotentForSameProvider(): void + { + $source = $this->createSourceDirectory([ + 'app.js' => 'console.log("hello");', + ]); + $provider = $this->createProvider('abc123', $source); + + $this->publisher->publish($provider); + $this->publisher->publish($provider); + self::assertFileExists($this->publicPath . '/assets/abc123/app.js'); + } + + /** + * Test that publish handles an empty source directory. + */ + #[Test] + public function testPublishEmptyDirectory(): void + { + $source = $this->tempDir . '/source_empty'; + + mkdir($source, 0o755, true); + + $provider = $this->createProvider('abc123', $source); + + $this->publisher->publish($provider); + self::assertDirectoryExists($this->publicPath . '/assets/abc123'); + } + + /** + * Test that publish throws an exception when the source directory does not exist. + */ + #[Test] + public function testPublishThrowsExceptionForMissingSourceDirectory(): void + { + $this->expectException(AssetPublishException::class); + + $provider = $this->createProvider('abc123', '/nonexistent/path'); + + $this->publisher->publish($provider); + } + + /** + * Test that different hashes publish to different directories. + */ + #[Test] + public function testDifferentHashesPublishToDifferentDirectories(): void + { + $source1 = $this->createSourceDirectory(['app.js' => '// app']); + $source2 = $this->createSourceDirectory(['ext.js' => '// ext']); + $provider1 = $this->createProvider('abc123', $source1); + $provider2 = $this->createProvider('xyz789', $source2); + + $this->publisher->publish($provider1); + $this->publisher->publish($provider2); + self::assertFileExists($this->publicPath . '/assets/abc123/app.js'); + self::assertFileExists($this->publicPath . '/assets/xyz789/ext.js'); + } + + /** + * Test that publish throws an AssetPublishException when mkdir fails because + * the target directory is not writable. + */ + #[Test] + public function testPublishThrowsTargetNotWritableWhenMkdirFails(): void + { + $source = $this->createSourceDirectory(['app.js' => 'console.log("hello");']); + $provider = $this->createProvider('abc123', $source); + + $publisher = new AssetsPublisher($this->config, new FailingMkdirFilesystem()); + + try { + $publisher->publish($provider); + self::fail('Expected an AssetPublishException to be thrown when mkdir fails.'); + } catch (AssetPublishException $exception) { + self::assertMatchesRegularExpression('/not writable/', $exception->getMessage()); + self::assertInstanceOf(IOException::class, $exception->getPrevious()); + self::assertSame('target_not_writable', $exception->translation->key); + } + } + + /** + * Test that publish throws an AssetPublishException when the mirror operation + * fails while copying the asset files. + */ + #[Test] + public function testPublishThrowsCopyFailedWhenMirrorFails(): void + { + $source = $this->createSourceDirectory(['app.js' => 'console.log("hello");']); + $provider = $this->createProvider('abc123', $source); + + $publisher = new AssetsPublisher($this->config, new FailingMirrorFilesystem()); + + try { + $publisher->publish($provider); + self::fail('Expected an AssetPublishException to be thrown when mirror fails.'); + } catch (AssetPublishException $exception) { + self::assertMatchesRegularExpression('/Failed to copy asset/', $exception->getMessage()); + self::assertInstanceOf(IOException::class, $exception->getPrevious()); + self::assertSame('copy_failed', $exception->translation->key); + } + } + + /** + * Test that an existing, up-to-date target is not mirrored again. + * + * A fresh publisher instance simulates a new request: the second publish is + * a verified no-op, so even a mirror that would always fail must not run. + */ + #[Test] + public function testPublishSkipsUpToDateTarget(): void + { + $source = $this->createSourceDirectory(['app.js' => '// app']); + $provider = $this->createProvider('abc123', $source); + + $this->publisher->publish($provider); + + $publisher = new AssetsPublisher($this->config, new FailingMirrorFilesystem()); + $publisher->publish($provider); + + self::assertFileExists($this->publicPath . '/assets/abc123/app.js'); + self::assertFileExists($this->publicPath . '/assets/abc123/.webify-assets-completed'); + } + + /** + * Test that the completion marker is not trusted when the source content + * changed under the same hash, and the target is refreshed. + */ + #[Test] + public function testPublishRefreshesTargetWhenSourceContentChanges(): void + { + $source = $this->createSourceDirectory(['app.js' => '// version 1']); + $provider = $this->createProvider('abc123', $source); + + $this->publisher->publish($provider); + + // Simulate a rebuilt dist with the same version/hash on a new request. + file_put_contents($source . '/app.js', '// version 2'); + + $publisher = new AssetsPublisher($this->config, new Filesystem()); + $publisher->publish($provider); + + self::assertSame( + '// version 2', + file_get_contents($this->publicPath . '/assets/abc123/app.js') + ); + } + + /** + * Test that a partial target from an interrupted earlier publish is repaired. + */ + #[Test] + public function testPublishRepairsPartialTarget(): void + { + $source = $this->createSourceDirectory(['app.js' => '// complete']); + $provider = $this->createProvider('abc123', $source); + + $target = $this->publicPath . '/assets/abc123'; + mkdir($target, 0o755, true); + file_put_contents($target . '/app.js', '// partial'); + + $this->publisher->publish($provider); + + self::assertSame('// complete', file_get_contents($target . '/app.js')); + self::assertFileExists($target . '/.webify-assets-completed'); + } + + /** + * Test that files removed from the source are pruned from the target. + */ + #[Test] + public function testPublishPrunesRemovedSourceFiles(): void + { + $source = $this->createSourceDirectory([ + 'app.js' => '// app', + 'old.js' => '// old', + ]); + $provider = $this->createProvider('abc123', $source); + + $this->publisher->publish($provider); + + unlink($source . '/old.js'); + + $publisher = new AssetsPublisher($this->config, new Filesystem()); + $publisher->publish($provider); + + self::assertFileDoesNotExist($this->publicPath . '/assets/abc123/old.js'); + self::assertFileExists($this->publicPath . '/assets/abc123/app.js'); + } + + /** + * Creates an anonymous asset provider for the given hash and source directory. + */ + private function createProvider(string $hash, string $sourcePath): AssetsProviderInterface + { + return new readonly class(Hash::fromString($hash), $sourcePath) implements AssetsProviderInterface { + /** + * The constructor. + */ + public function __construct( + private Hash $hash, + private string $assetsDirectory + ) {} + + /** + * {@inheritDoc} + */ + public function getHash(): string + { + return $this->hash->toNative(); + } + + /** + * {@inheritDoc} + */ + public function getDirectory(): string + { + return $this->assetsDirectory; + } + }; + } + + /** + * Creates a source directory with the given file structure. + * + * @param array|string> $files a flat map of filename => content + * or nested map of dirname => [filename => content] + */ + private function createSourceDirectory(array $files): string + { + $source = $this->tempDir . '/source_' . uniqid('', true); + + mkdir($source, 0o755, true); + + foreach ($files as $name => $content) { + if (is_array($content)) { + $dir = $source . DIRECTORY_SEPARATOR . $name; + + mkdir($dir, 0o755, true); + + foreach ($content as $fileName => $fileContent) { + file_put_contents($dir . DIRECTORY_SEPARATOR . $fileName, $fileContent); + } + } else { + file_put_contents($source . DIRECTORY_SEPARATOR . $name, $content); + } + } + + return $source; + } + + /** + * Recursively removes a directory and its contents. + */ + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = scandir($directory); + + if (false === $items) { + return; + } + + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $item; + + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + + rmdir($directory); + } +} + +/** + * A Filesystem subclass that always fails on mkdir. + * + * @internal + */ +final class FailingMkdirFilesystem extends Filesystem +{ + /** + * {@inheritDoc} + * + * @param iterable|string $dirs the directories to create + * @param int $mode the permission mode + */ + public function mkdir(iterable|string $dirs, int $mode = 0o777): void + { + throw new IOException('mkdir failed'); + } +} + +/** + * A Filesystem subclass that always fails on mirror. + * + * @internal + */ +final class FailingMirrorFilesystem extends Filesystem +{ + /** + * {@inheritDoc} + * + * @param mixed $iterator the iterator + * @param array $options the mirror options + */ + public function mirror(string $originDir, string $targetDir, mixed $iterator = null, array $options = []): void + { + throw new IOException('mirror failed'); + } +} diff --git a/test/Unit/Infrastructure/Service/BaseUrlTest.php b/test/Unit/Infrastructure/Service/BaseUrlTest.php new file mode 100644 index 0000000..c7bb5be --- /dev/null +++ b/test/Unit/Infrastructure/Service/BaseUrlTest.php @@ -0,0 +1,162 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Infrastructure\Exception\BaseUrlConfigurationException; +use Webify\Base\Infrastructure\Service\{BaseUrl, Config}; + +/** + * BaseUrlTest tests the functionality of the BaseUrl service. + * + * @internal + */ +#[CoversClass(BaseUrl::class)] +#[CoversMethod(BaseUrl::class, 'fromConfig')] +#[CoversMethod(BaseUrl::class, 'toNative')] +#[CoversMethod(BaseUrl::class, 'hasScheme')] +#[CoversMethod(BaseUrl::class, 'getScheme')] +#[CoversMethod(BaseUrl::class, 'getHost')] +#[CoversMethod(BaseUrl::class, 'getPathPrefix')] +#[UsesClass(Config::class)] +#[UsesClass(BaseUrlConfigurationException::class)] +final class BaseUrlTest extends TestCase +{ + /** + * Test that an empty base URL is treated as not configured. + */ + #[Test] + public function testEmptyBaseUrlIsNotConfigured(): void + { + $baseUrl = BaseUrl::fromConfig(new Config(['baseUrl' => ''])); + + self::assertFalse($baseUrl->hasScheme()); + self::assertSame('', $baseUrl->getHost()); + self::assertSame('', $baseUrl->getPathPrefix()); + + $this->expectException(BaseUrlConfigurationException::class); + $this->expectExceptionMessageMatches('/no base URL is configured/'); + + $baseUrl->toNative(); + } + + /** + * Test that a bare host uses the configured default scheme. + */ + #[Test] + public function testBareHostUsesDefaultScheme(): void + { + $baseUrl = BaseUrl::fromConfig(new Config([ + 'baseUrl' => 'example.com', + 'defaultUrlScheme' => 'http', + ])); + + self::assertSame('example.com', $baseUrl->getHost()); + self::assertTrue($baseUrl->hasScheme()); + self::assertSame('http', $baseUrl->getScheme()); + self::assertSame('http://example.com', $baseUrl->toNative('http')); + } + + /** + * Test that a protocol-relative base URL still resolves to the default scheme. + */ + #[Test] + public function testProtocolRelativeBaseUrlUsesDefaultScheme(): void + { + $baseUrl = BaseUrl::fromConfig(new Config([ + 'baseUrl' => '//example.com', + 'defaultUrlScheme' => 'http', + ])); + + self::assertSame('example.com', $baseUrl->getHost()); + self::assertTrue($baseUrl->hasScheme()); + self::assertSame('http', $baseUrl->getScheme()); + self::assertSame('http://example.com', $baseUrl->toNative()); + } + + /** + * Test that an invalid base URL throws a configuration exception. + */ + #[Test] + public function testInvalidBaseUrlThrowsConfigurationException(): void + { + $this->expectException(BaseUrlConfigurationException::class); + + BaseUrl::fromConfig(new Config(['baseUrl' => 'http://'])); + } + + /** + * Test that an invalid base URL exception has a translation payload. + */ + #[Test] + public function testInvalidBaseUrlHasTranslation(): void + { + try { + BaseUrl::fromConfig(new Config(['baseUrl' => 'http://'])); + self::fail('Expected a BaseUrlConfigurationException to be thrown.'); + } catch (BaseUrlConfigurationException $exception) { + self::assertSame('base.configuration', $exception->translation->group); + self::assertSame('invalid_base_url', $exception->translation->key); + } + } + + /** + * Test parsing a full URL with scheme, host, port and path prefix. + */ + #[Test] + public function testFullUrlWithSchemeHostPortAndPath(): void + { + $baseUrl = BaseUrl::fromConfig(new Config(['baseUrl' => 'https://example.com:8080/blog'])); + + self::assertSame('https', $baseUrl->getScheme()); + self::assertSame('example.com', $baseUrl->getHost()); + self::assertSame('/blog', $baseUrl->getPathPrefix()); + self::assertSame('https://example.com:8080/blog', $baseUrl->toNative()); + } + + /** + * Test that a port is formatted into the native URL. + */ + #[Test] + public function testPortIsFormattedIntoNativeUrl(): void + { + $baseUrl = BaseUrl::fromConfig(new Config(['baseUrl' => 'http://example.com:8080'])); + + self::assertSame('http://example.com:8080', $baseUrl->toNative()); + } + + /** + * Test that an explicit scheme overrides the stored scheme when generating a URL. + */ + #[Test] + public function testExplicitSchemeOverridesStoredScheme(): void + { + $baseUrl = BaseUrl::fromConfig(new Config(['baseUrl' => 'http://example.com'])); + + self::assertSame('http', $baseUrl->getScheme()); + self::assertSame('https://example.com', $baseUrl->toNative('https')); + } + + /** + * Test that an empty effective scheme yields a protocol-relative URL. + */ + #[Test] + public function testEmptySchemeYieldsNetworkPath(): void + { + $baseUrl = BaseUrl::fromConfig(new Config(['baseUrl' => 'https://example.com:8080/blog'])); + + self::assertSame('//example.com:8080/blog', $baseUrl->toNative('')); + } +} diff --git a/test/Unit/Infrastructure/Service/ConfigTest.php b/test/Unit/Infrastructure/Service/ConfigTest.php index 3e352fe..d91ebe1 100644 --- a/test/Unit/Infrastructure/Service/ConfigTest.php +++ b/test/Unit/Infrastructure/Service/ConfigTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Service/Example/ExampleHttpKernelInterface.php b/test/Unit/Infrastructure/Service/Example/ExampleHttpKernelInterface.php index 3c9dcc5..eeb5e87 100644 --- a/test/Unit/Infrastructure/Service/Example/ExampleHttpKernelInterface.php +++ b/test/Unit/Infrastructure/Service/Example/ExampleHttpKernelInterface.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Service/Example/ExampleResourcePath.php b/test/Unit/Infrastructure/Service/Example/ExampleResourcePath.php new file mode 100644 index 0000000..dbdebe1 --- /dev/null +++ b/test/Unit/Infrastructure/Service/Example/ExampleResourcePath.php @@ -0,0 +1,31 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service\Example; + +use InvalidArgumentException; +use Webify\Base\Domain\ValueObject\ResourcePath; + +/** + * ExampleResourcePath is a concrete implementation of the ResourcePath value object for testing purposes. + */ +final readonly class ExampleResourcePath extends ResourcePath +{ + /** + * {@inheritDoc} + */ + protected function throwException(string $value): never + { + throw new InvalidArgumentException('Invalid example resource path: ' . $value); + } +} diff --git a/test/Unit/Infrastructure/Service/SymfonyAsciiSlugifyTest.php b/test/Unit/Infrastructure/Service/SymfonyAsciiSlugifyTest.php index c49af89..bf16e14 100644 --- a/test/Unit/Infrastructure/Service/SymfonyAsciiSlugifyTest.php +++ b/test/Unit/Infrastructure/Service/SymfonyAsciiSlugifyTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Service/SymfonyUlidGeneratorTest.php b/test/Unit/Infrastructure/Service/SymfonyUlidGeneratorTest.php index e29b476..7204057 100644 --- a/test/Unit/Infrastructure/Service/SymfonyUlidGeneratorTest.php +++ b/test/Unit/Infrastructure/Service/SymfonyUlidGeneratorTest.php @@ -6,7 +6,7 @@ * @see https://webifycms.com/extension/base * * @copyright Copyright (c) 2023 - Present WebifyCMS - * @license https://webifycms.com/extension/base/license + * @license https://webifycms.com/license * @author Mohammed Shifreen */ declare(strict_types=1); diff --git a/test/Unit/Infrastructure/Service/TemplateMapOverridesTest.php b/test/Unit/Infrastructure/Service/TemplateMapOverridesTest.php new file mode 100644 index 0000000..0f46626 --- /dev/null +++ b/test/Unit/Infrastructure/Service/TemplateMapOverridesTest.php @@ -0,0 +1,222 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Cache\{CacheItemInterface, CacheItemPoolInterface}; +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Contract\ThemeResolverInterface; +use Webify\Base\Infrastructure\Service\TemplateMapOverrides; + +/** + * TemplateMapOverridesTest tests the functionality of the TemplateMapOverrides service. + * + * @internal + */ +#[CoversClass(TemplateMapOverrides::class)] +#[CoversMethod(TemplateMapOverrides::class, 'forExtension')] +#[UsesClass(TemplatePath::class)] +final class TemplateMapOverridesTest extends TestCase +{ + /** + * Test that cached values are returned without rebuilding or saving. + * + * The theme maps are still consulted to compute the content-addressed key, + * but the assembled candidates are neither rebuilt nor re-saved. + */ + #[Test] + public function testForExtensionReturnsCachedValuesOnHit(): void + { + $expected = [TemplatePath::fromString('/cached/templates/blog')]; + + $item = self::createStub(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn($expected); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + $pool->expects(self::never())->method('save'); + + $resolver = self::createStub(ThemeResolverInterface::class); + $resolver->method('themeTemplateMaps')->willReturn([]); + + $overrides = new TemplateMapOverrides(['blog' => '/app/templates/blog'], $resolver, $pool); + + self::assertSame($expected, $overrides->forExtension('blog')); + } + + /** + * Test that on a cache miss the overrides are ordered app first then theme maps. + */ + #[Test] + public function testForExtensionBuildsOrderedCandidatesOnMiss(): void + { + /** @var CacheItemInterface&MockObject $item */ + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + + $resolver = self::createStub(ThemeResolverInterface::class); + $resolver->method('themeTemplateMaps')->willReturn([ + ['blog' => TemplatePath::fromString('/theme/templates/blog')], + ['blog' => TemplatePath::fromString('/active/templates/blog')], + ]); + + $expected = [ + TemplatePath::fromString('/app/templates/blog'), + TemplatePath::fromString('/theme/templates/blog'), + TemplatePath::fromString('/active/templates/blog'), + ]; + + $item->expects(self::once())->method('set')->with($expected); + $pool->expects(self::once())->method('save')->with($item); + + $overrides = new TemplateMapOverrides(['blog' => '/app/templates/blog'], $resolver, $pool); + + $result = $overrides->forExtension('blog'); + + self::assertCount(3, $result); + self::assertSame('/app/templates/blog', $result[0]->toNative()); + self::assertSame('/theme/templates/blog', $result[1]->toNative()); + self::assertSame('/active/templates/blog', $result[2]->toNative()); + } + + /** + * Test that the cache key changes when the app override for the extension changes. + */ + #[Test] + public function testCacheKeyTracksAppOverrides(): void + { + $keys = []; + + $item = self::createStub(CacheItemInterface::class); + + $pool = self::createStub(CacheItemPoolInterface::class); + $pool->method('getItem') + ->willReturnCallback(static function (string $key) use ($item, &$keys): CacheItemInterface { + $keys[] = $key; + + return $item; + }) + ; + + $resolver = self::createStub(ThemeResolverInterface::class); + $resolver->method('themeTemplateMaps')->willReturn([]); + + new TemplateMapOverrides(['blog' => '/app/templates/blog'], $resolver, $pool)->forExtension('blog'); + new TemplateMapOverrides(['blog' => '/custom/templates/blog'], $resolver, $pool)->forExtension('blog'); + + self::assertCount(2, $keys); + self::assertNotSame($keys[0], $keys[1]); + } + + /** + * Test that the cache key changes when the theme template maps change. + */ + #[Test] + public function testCacheKeyTracksThemeTemplateMaps(): void + { + $keys = []; + + $item = self::createStub(CacheItemInterface::class); + + $pool = self::createStub(CacheItemPoolInterface::class); + $pool->method('getItem') + ->willReturnCallback(static function (string $key) use ($item, &$keys): CacheItemInterface { + $keys[] = $key; + + return $item; + }) + ; + + $firstResolver = self::createStub(ThemeResolverInterface::class); + $firstResolver->method('themeTemplateMaps')->willReturn([ + ['blog' => TemplatePath::fromString('/theme-a/templates/blog')], + ]); + + $secondResolver = self::createStub(ThemeResolverInterface::class); + $secondResolver->method('themeTemplateMaps')->willReturn([ + ['blog' => TemplatePath::fromString('/theme-b/templates/blog')], + ]); + + new TemplateMapOverrides(['blog' => '/app/templates/blog'], $firstResolver, $pool)->forExtension('blog'); + new TemplateMapOverrides(['blog' => '/app/templates/blog'], $secondResolver, $pool)->forExtension('blog'); + + self::assertCount(2, $keys); + self::assertNotSame($keys[0], $keys[1]); + } + + /** + * Test that an empty candidate list is returned and cached when there are no overrides. + */ + #[Test] + public function testForExtensionReturnsEmptyWhenNoOverrides(): void + { + /** @var CacheItemInterface&MockObject $item */ + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + + $resolver = self::createStub(ThemeResolverInterface::class); + $resolver->method('themeTemplateMaps')->willReturn([]); + + $item->expects(self::once())->method('set')->with([]); + $pool->expects(self::once())->method('save')->with($item); + + $overrides = new TemplateMapOverrides([], $resolver, $pool); + + self::assertSame([], $overrides->forExtension('blog')); + } + + /** + * Test that theme map entries are used when the extension is absent from the app overrides. + */ + #[Test] + public function testForExtensionFallsBackToThemeMapsOnly(): void + { + /** @var CacheItemInterface&MockObject $item */ + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + + $pool = self::createStub(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + + $resolver = self::createStub(ThemeResolverInterface::class); + $resolver->method('themeTemplateMaps')->willReturn([ + ['blog' => TemplatePath::fromString('/theme/templates/blog')], + ['blog' => TemplatePath::fromString('/active/templates/blog')], + ]); + + $expected = [ + TemplatePath::fromString('/theme/templates/blog'), + TemplatePath::fromString('/active/templates/blog'), + ]; + + $item->expects(self::once())->method('set')->with($expected); + + $overrides = new TemplateMapOverrides(['shop' => '/app/templates/shop'], $resolver, $pool); + + $result = $overrides->forExtension('blog'); + + self::assertCount(2, $result); + self::assertSame('/theme/templates/blog', $result[0]->toNative()); + self::assertSame('/active/templates/blog', $result[1]->toNative()); + } +} diff --git a/test/Unit/Infrastructure/Service/TemplateResolverTest.php b/test/Unit/Infrastructure/Service/TemplateResolverTest.php new file mode 100644 index 0000000..9ef6731 --- /dev/null +++ b/test/Unit/Infrastructure/Service/TemplateResolverTest.php @@ -0,0 +1,106 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Contract\{ExtensionInterface, TemplateMapOverridesInterface}; +use Webify\Base\Infrastructure\Service\TemplateResolver; + +/** + * TemplateResolverTest tests the functionality of the TemplateResolver service. + * + * @internal + */ +#[CoversClass(TemplateResolver::class)] +#[CoversMethod(TemplateResolver::class, 'resolve')] +#[UsesClass(TemplatePath::class)] +final class TemplateResolverTest extends TestCase +{ + /** + * Test that resolve returns the override candidates followed by the extension fallback. + */ + #[Test] + public function testResolveReturnsCandidatesThenExtensionFallback(): void + { + $templateMap = $this->createMock(TemplateMapOverridesInterface::class); + $templateMap->expects(self::once())->method('forExtension')->with('blog')->willReturn([ + TemplatePath::fromString('/theme-a/tpl'), + TemplatePath::fromString('/theme-b/tpl'), + ]); + + $extension = self::createStub(ExtensionInterface::class); + $extension->method('getId')->willReturn('blog'); + $extension->method('getTemplatePath')->willReturn(TemplatePath::fromString('/ext/default/templates')); + + $resolver = new TemplateResolver($templateMap); + + self::assertSame( + [ + '/theme-a/tpl/post', + '/theme-b/tpl/post', + '/ext/default/templates/post', + ], + $resolver->resolve($extension, 'post') + ); + } + + /** + * Test that a leading slash on the template name is trimmed. + */ + #[Test] + public function testResolveTrimsLeadingSlash(): void + { + $templateMap = $this->createMock(TemplateMapOverridesInterface::class); + $templateMap->expects(self::once())->method('forExtension')->with('blog')->willReturn([ + TemplatePath::fromString('/theme-a/tpl'), + ]); + + $extension = self::createStub(ExtensionInterface::class); + $extension->method('getId')->willReturn('blog'); + $extension->method('getTemplatePath')->willReturn(TemplatePath::fromString('/ext/default/templates')); + + $resolver = new TemplateResolver($templateMap); + + self::assertSame( + [ + '/theme-a/tpl/post', + '/ext/default/templates/post', + ], + $resolver->resolve($extension, '/post') + ); + } + + /** + * Test that resolve falls back to the extension path when there are no candidates. + */ + #[Test] + public function testResolveFallsBackToExtensionWhenNoCandidates(): void + { + $templateMap = $this->createMock(TemplateMapOverridesInterface::class); + $templateMap->expects(self::once())->method('forExtension')->with('blog')->willReturn([]); + + $extension = self::createStub(ExtensionInterface::class); + $extension->method('getId')->willReturn('blog'); + $extension->method('getTemplatePath')->willReturn(TemplatePath::fromString('/ext/default/templates')); + + $resolver = new TemplateResolver($templateMap); + + self::assertSame( + ['/ext/default/templates/post'], + $resolver->resolve($extension, 'post') + ); + } +} diff --git a/test/Unit/Infrastructure/Service/ThemeAssetsProviderTest.php b/test/Unit/Infrastructure/Service/ThemeAssetsProviderTest.php new file mode 100644 index 0000000..23c8123 --- /dev/null +++ b/test/Unit/Infrastructure/Service/ThemeAssetsProviderTest.php @@ -0,0 +1,73 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\ValueObject\Hash; +use Webify\Base\Infrastructure\Service\ThemeAssetsProvider; +use Webify\Base\Test\Unit\Infrastructure\Theme\Example\Default\DefaultTheme; + +use function dirname; + +/** + * ThemeAssetsProviderTest tests the functionality of the ThemeAssetsProvider service. + * + * @internal + */ +#[CoversClass(ThemeAssetsProvider::class)] +#[CoversMethod(ThemeAssetsProvider::class, 'getHash')] +#[CoversMethod(ThemeAssetsProvider::class, 'getDirectory')] +#[UsesClass(Hash::class)] +#[UsesClass(DefaultTheme::class)] +final class ThemeAssetsProviderTest extends TestCase +{ + /** + * Test that the hash matches the crc32 hash of the theme identity. + */ + #[Test] + public function testGetHashMatchesThemeIdentityHash(): void + { + $provider = new ThemeAssetsProvider(new DefaultTheme()); + + self::assertSame( + Hash::generate('crc32', 'example-defaultExample Default Theme1.0.0')->toNative(), + $provider->getHash() + ); + } + + /** + * Test that the directory points to the theme dist directory. + */ + #[Test] + public function testGetDirectoryPointsToDist(): void + { + $provider = new ThemeAssetsProvider(new DefaultTheme()); + + self::assertSame(dirname(__DIR__) . '/Theme/Example/Default/dist', $provider->getDirectory()); + } + + /** + * Test that the generated hash is deterministic across instances. + */ + #[Test] + public function testHashIsDeterministic(): void + { + $first = new ThemeAssetsProvider(new DefaultTheme()); + $second = new ThemeAssetsProvider(new DefaultTheme()); + + self::assertSame($first->getHash(), $second->getHash()); + self::assertSame($first->getDirectory(), $second->getDirectory()); + } +} diff --git a/test/Unit/Infrastructure/Service/ThemeConfigTest.php b/test/Unit/Infrastructure/Service/ThemeConfigTest.php new file mode 100644 index 0000000..61339a6 --- /dev/null +++ b/test/Unit/Infrastructure/Service/ThemeConfigTest.php @@ -0,0 +1,101 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Contract\ArraySearchHelper; +use Webify\Base\Infrastructure\Service\ThemeConfig; + +/** + * ThemeConfigTest tests the functionality of the ThemeConfig service class. + * + * @internal + */ +#[CoversClass(ThemeConfig::class)] +#[CoversMethod(ThemeConfig::class, 'has')] +#[CoversMethod(ThemeConfig::class, 'get')] +#[UsesClass(ArraySearchHelper::class)] +final class ThemeConfigTest extends TestCase +{ + /** + * The theme configuration under test. + */ + private ThemeConfig $config; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->config = new ThemeConfig([ + 'name' => 'Example Theme', + 'database' => [ + 'host' => 'localhost', + 'port' => 3306, + ], + 'nullable' => null, + ]); + } + + /** + * Test retrieving a nested value using dot notation. + */ + #[Test] + public function testGetNestedKey(): void + { + self::assertSame('localhost', $this->config->get('database.host')); + self::assertSame(3306, $this->config->get('database.port')); + } + + /** + * Test that get returns the default value when the key does not exist. + */ + #[Test] + public function testGetMissingKeyReturnsDefault(): void + { + self::assertNull($this->config->get('missing')); + self::assertSame('fallback', $this->config->get('missing', 'fallback')); + } + + /** + * Test that a key holding a null value exists and returns null. + */ + #[Test] + public function testNullValueExists(): void + { + self::assertTrue($this->config->has('nullable')); + self::assertNull($this->config->get('nullable')); + } + + /** + * Test that has returns false for a non-existent key. + */ + #[Test] + public function testHasReturnsFalseForMissingKey(): void + { + self::assertFalse($this->config->has('missing')); + } + + /** + * Test that repeated lookups return the cached value. + */ + #[Test] + public function testValuesAreCached(): void + { + self::assertSame('localhost', $this->config->get('database.host')); + self::assertTrue($this->config->has('database.host')); + self::assertSame('localhost', $this->config->get('database.host')); + } +} diff --git a/test/Unit/Infrastructure/Service/ThemeLoaderTest.php b/test/Unit/Infrastructure/Service/ThemeLoaderTest.php new file mode 100644 index 0000000..5ef6fca --- /dev/null +++ b/test/Unit/Infrastructure/Service/ThemeLoaderTest.php @@ -0,0 +1,190 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use DI\ContainerBuilder; +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Application\Service\ConfigInterface; +use Webify\Base\Infrastructure\Contract\{ServiceProviderInterface, ThemeInterface, ThemeResolverInterface}; +use Webify\Base\Infrastructure\Environment\{Environment, Type}; +use Webify\Base\Infrastructure\Service\{Application, Config, ThemeLoader}; +use Webify\Base\Test\Unit\Infrastructure\Theme\Example\{Active\ActiveTheme, Default\DefaultTheme}; + +use function is_dir; +use function mkdir; +use function rmdir; +use function scandir; +use function sys_get_temp_dir; +use function uniqid; +use function unlink; + +/** + * ThemeLoaderTest tests the functionality of the ThemeLoader service. + * + * @internal + */ +#[CoversClass(ThemeLoader::class)] +#[CoversMethod(ThemeLoader::class, 'load')] +#[UsesClass(Application::class)] +#[UsesClass(Config::class)] +#[UsesClass(Environment::class)] +#[UsesClass(Type::class)] +#[UsesClass(ContainerBuilder::class)] +#[UsesClass(ServiceProviderInterface::class)] +#[UsesClass(ThemeInterface::class)] +#[UsesClass(DefaultTheme::class)] +#[UsesClass(ActiveTheme::class)] +final class ThemeLoaderTest extends TestCase +{ + /** + * Temporary runtime path for testing. + */ + private string $tempDir; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/webify_theme_loader_' . uniqid('', true); + + mkdir($this->tempDir . '/runtime', 0o755, true); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + /** + * Test that load registers the active theme provider and container bindings. + */ + #[Test] + public function testLoadRegistersActiveThemeProviderAndBindings(): void + { + $activeTheme = new ActiveTheme(); + $resolver = $this->createMock(ThemeResolverInterface::class); + $resolver->expects(self::once())->method('default')->willReturn(new DefaultTheme()); + $resolver->expects(self::once())->method('active')->willReturn($activeTheme); + + $app = $this->createApplication(); + $builder = new ContainerBuilder(); + + new ThemeLoader($resolver)->load($app, $builder); + $container = $builder->build(); + + self::assertCount(1, $app->getProviders()); + self::assertInstanceOf(ServiceProviderInterface::class, $app->getProviders()[0]); + self::assertSame(['active.definition' => 'active-value'], $app->getProviders()[0]->getDefinitions()); + self::assertSame($resolver, $container->get(ThemeResolverInterface::class)); + self::assertSame($activeTheme, $container->get(ThemeInterface::class)); + self::assertSame('active-value', $container->get('active.definition')); + } + + /** + * Test that load skips registration when the active theme matches the default. + */ + #[Test] + public function testLoadSkipsRegistrationWhenActiveSameAsDefault(): void + { + $default = new DefaultTheme(); + $resolver = $this->createMock(ThemeResolverInterface::class); + $resolver->expects(self::once())->method('default')->willReturn($default); + $resolver->expects(self::once())->method('active')->willReturn($default); + + $app = $this->createApplication(); + $builder = new ContainerBuilder(); + + new ThemeLoader($resolver)->load($app, $builder); + $container = $builder->build(); + + self::assertCount(0, $app->getProviders()); + self::assertSame($default, $container->get(ThemeInterface::class)); + } + + /** + * Test that load does not fail when neither theme provides a service provider. + */ + #[Test] + public function testLoadDoesNotFailWithoutProviders(): void + { + $default = new DefaultTheme(); + $active = self::createStub(ThemeInterface::class); + $resolver = $this->createMock(ThemeResolverInterface::class); + $resolver->expects(self::once())->method('default')->willReturn($default); + $resolver->expects(self::once())->method('active')->willReturn($active); + + $app = $this->createApplication(); + $builder = new ContainerBuilder(); + + new ThemeLoader($resolver)->load($app, $builder); + $container = $builder->build(); + + self::assertCount(0, $app->getProviders()); + self::assertSame($resolver, $container->get(ThemeResolverInterface::class)); + self::assertSame($active, $container->get(ThemeInterface::class)); + } + + /** + * Create a real application instance backed by a temporary runtime path. + */ + private function createApplication(): Application + { + $config = new Config([ + 'basePath' => $this->tempDir, + 'runtimePath' => $this->tempDir . '/runtime', + 'configPath' => $this->tempDir . '/config', + ]); + $envConfig = self::createStub(ConfigInterface::class); + $envConfig->method('has')->willReturn(true); + $envConfig->method('get')->willReturnCallback(function (string $key, mixed $default = null): mixed { + return match ($key) { + 'environment' => 'development', + 'debug' => false, + default => $default, + }; + }); + + return new Application($config, Environment::prepare($envConfig)); + } + + /** + * Recursively remove a directory and its contents. + */ + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $scandir = scandir($path); + $items = array_diff(false === $scandir ? [] : $scandir, ['.', '..']); + + foreach ($items as $item) { + $itemPath = $path . '/' . $item; + + if (is_dir($itemPath)) { + $this->removeDirectory($itemPath); + } else { + unlink($itemPath); + } + } + + rmdir($path); + } +} diff --git a/test/Unit/Infrastructure/Service/ThemeResolverTest.php b/test/Unit/Infrastructure/Service/ThemeResolverTest.php new file mode 100644 index 0000000..946b11d --- /dev/null +++ b/test/Unit/Infrastructure/Service/ThemeResolverTest.php @@ -0,0 +1,420 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Exception\ThemeException; +use Webify\Base\Infrastructure\Service\{Config, ThemeResolver}; +use Webify\Base\Infrastructure\Theme\{Theme, ThemeFactory}; +use Webify\Base\Test\Unit\Infrastructure\Theme\Example\{Active\ActiveTheme, Default\DefaultTheme}; + +use function dirname; +use function file_put_contents; +use function is_dir; +use function json_encode; +use function mkdir; +use function rmdir; +use function scandir; +use function sys_get_temp_dir; +use function uniqid; +use function unlink; + +/** + * ThemeResolverTest tests the functionality of the ThemeResolver service. + * + * @internal + */ +#[CoversClass(ThemeResolver::class)] +#[CoversMethod(ThemeResolver::class, 'active')] +#[CoversMethod(ThemeResolver::class, 'default')] +#[CoversMethod(ThemeResolver::class, 'themes')] +#[CoversMethod(ThemeResolver::class, 'themeTemplatePaths')] +#[CoversMethod(ThemeResolver::class, 'themeTemplateMaps')] +#[UsesClass(Config::class)] +#[UsesClass(Theme::class)] +#[UsesClass(ThemeFactory::class)] +#[UsesClass(ThemeException::class)] +#[UsesClass(TemplatePath::class)] +#[UsesClass(DefaultTheme::class)] +#[UsesClass(ActiveTheme::class)] +final class ThemeResolverTest extends TestCase +{ + /** + * Temporary directory used to host manifest driven themes. + */ + private string $tempDir; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/webify_theme_resolver_' . uniqid('', true); + + mkdir($this->tempDir, 0o755, true); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + /** + * Test that the default theme class is resolved from the configuration. + */ + #[Test] + public function testDefaultResolvesConfiguredThemeClass(): void + { + $resolver = $this->createResolver([ + 'themes' => [ + 'default' => DefaultTheme::class, + 'active' => ActiveTheme::class, + ], + ]); + + $theme = $resolver->default(); + + self::assertInstanceOf(DefaultTheme::class, $theme); + self::assertSame('example-default', $theme->getId()); + self::assertSame('Example Default Theme', $theme->getName()); + self::assertSame('1.0.0', $theme->getVersion()); + } + + /** + * Test that the active theme class is resolved from the configuration. + */ + #[Test] + public function testActiveResolvesConfiguredThemeClass(): void + { + $resolver = $this->createResolver([ + 'themes' => [ + 'default' => DefaultTheme::class, + 'active' => ActiveTheme::class, + ], + ]); + + $theme = $resolver->active(); + + self::assertInstanceOf(ActiveTheme::class, $theme); + self::assertSame('example-active', $theme->getId()); + self::assertSame('Example Active Theme', $theme->getName()); + self::assertSame('2.0.0', $theme->getVersion()); + } + + /** + * Test that active falls back to the default theme when no active is configured. + */ + #[Test] + public function testActiveFallsBackToDefault(): void + { + $resolver = $this->createResolver([ + 'themes' => ['default' => DefaultTheme::class], + ]); + + self::assertSame('example-default', $resolver->active()->getId()); + } + + /** + * Test that active falls back to the default when the configured value is empty. + */ + #[Test] + public function testActiveFallsBackWhenConfiguredValueIsEmpty(): void + { + $resolver = $this->createResolver([ + 'themes' => [ + 'default' => DefaultTheme::class, + 'active' => '', + ], + ]); + + self::assertSame('example-default', $resolver->active()->getId()); + } + + /** + * Test that active falls back to the default when the configured value is not a string. + */ + #[Test] + public function testActiveFallsBackWhenConfiguredValueIsNotString(): void + { + $resolver = $this->createResolver([ + 'themes' => [ + 'default' => DefaultTheme::class, + 'active' => 42, + ], + ]); + + self::assertSame('example-default', $resolver->active()->getId()); + } + + /** + * Test that default throws when the theme configuration is absent. + */ + #[Test] + public function testDefaultThrowsWhenNotConfigured(): void + { + $resolver = $this->createResolver([]); + + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('No default theme is configured in the application config.'); + + $resolver->default(); + } + + /** + * Test that default throws when the configured value is empty. + */ + #[Test] + public function testDefaultThrowsWhenConfiguredValueIsEmpty(): void + { + $resolver = $this->createResolver(['themes' => ['default' => '']]); + + $this->expectException(ThemeException::class); + + $resolver->default(); + } + + /** + * Test that default throws when the configured value is not a string. + */ + #[Test] + public function testDefaultThrowsWhenConfiguredValueIsNotString(): void + { + $resolver = $this->createResolver(['themes' => ['default' => 42]]); + + $this->expectException(ThemeException::class); + + $resolver->default(); + } + + /** + * Test that the same default theme instance is returned on repeated calls. + */ + #[Test] + public function testDefaultIsMemoized(): void + { + $resolver = $this->createResolver([ + 'themes' => ['default' => DefaultTheme::class], + ]); + + self::assertSame($resolver->default(), $resolver->default()); + } + + /** + * Test that the same active theme instance is returned on repeated calls. + */ + #[Test] + public function testActiveIsMemoized(): void + { + $resolver = $this->createResolver([ + 'themes' => [ + 'default' => DefaultTheme::class, + 'active' => ActiveTheme::class, + ], + ]); + + self::assertSame($resolver->active(), $resolver->active()); + } + + /** + * Test that themes returns the default first and the active last. + */ + #[Test] + public function testThemesReturnsDefaultAndActive(): void + { + $resolver = $this->createResolver([ + 'themes' => [ + 'default' => DefaultTheme::class, + 'active' => ActiveTheme::class, + ], + ]); + + $themes = $resolver->themes(); + + self::assertCount(2, $themes); + self::assertSame('example-default', $themes[0]->getId()); + self::assertSame('example-active', $themes[1]->getId()); + } + + /** + * Test that themes returns a single entry when the active falls back to the default. + */ + #[Test] + public function testThemesReturnsSingleWhenActiveSameAsDefault(): void + { + $resolver = $this->createResolver([ + 'themes' => ['default' => DefaultTheme::class], + ]); + + $themes = $resolver->themes(); + + self::assertCount(1, $themes); + self::assertSame('example-default', $themes[0]->getId()); + } + + /** + * Test that themeTemplatePaths returns the template paths default first. + */ + #[Test] + public function testThemeTemplatePaths(): void + { + $resolver = $this->createResolver([ + 'themes' => [ + 'default' => DefaultTheme::class, + 'active' => ActiveTheme::class, + ], + ]); + + self::assertSame( + [ + $this->fixtureThemeRoot('Default') . '/templates', + $this->fixtureThemeRoot('Active') . '/templates', + ], + $resolver->themeTemplatePaths() + ); + } + + /** + * Test that themeTemplateMaps returns the template maps default first. + */ + #[Test] + public function testThemeTemplateMaps(): void + { + $resolver = $this->createResolver([ + 'themes' => [ + 'default' => DefaultTheme::class, + 'active' => ActiveTheme::class, + ], + ]); + + $maps = $resolver->themeTemplateMaps(); + + self::assertCount(2, $maps); + self::assertSame($this->fixtureThemeRoot('Default') . '/blog', $maps[0]['blog']->toNative()); + self::assertSame($this->fixtureThemeRoot('Active') . '/blog', $maps[1]['blog']->toNative()); + } + + /** + * Test that a manifest theme id is resolved under the configured themes path. + */ + #[Test] + public function testDefaultResolvesManifestThemeById(): void + { + $this->createManifestTheme('mytheme', [ + 'id' => 'mytheme', + 'name' => 'My Theme', + 'version' => '1.2.3', + ]); + + $resolver = $this->createResolver([ + 'basePath' => $this->tempDir, + 'themes' => ['default' => 'mytheme'], + ]); + + $theme = $resolver->default(); + + self::assertInstanceOf(Theme::class, $theme); + self::assertSame('mytheme', $theme->getId()); + self::assertSame('My Theme', $theme->getName()); + self::assertSame('1.2.3', $theme->getVersion()); + self::assertSame($this->tempDir . '/themes/mytheme', $theme->getRootPath()); + } + + /** + * Test that a manifest theme directory path is resolved directly. + */ + #[Test] + public function testDefaultResolvesManifestThemeByPath(): void + { + $themeDir = $this->createManifestTheme('path-theme', [ + 'id' => 'path-theme', + 'name' => 'Path Theme', + 'version' => '2.0.0', + ]); + + $resolver = $this->createResolver([ + 'basePath' => $this->tempDir, + 'themes' => ['default' => $themeDir], + ]); + + $theme = $resolver->default(); + + self::assertInstanceOf(Theme::class, $theme); + self::assertSame('path-theme', $theme->getId()); + self::assertSame($themeDir, $theme->getRootPath()); + } + + /** + * Creates a theme resolver backed by a real config instance. + * + * @param array $config the config data + */ + private function createResolver(array $config): ThemeResolver + { + return new ThemeResolver(new Config($config), new ThemeFactory(new Config($config))); + } + + /** + * Returns the theme root of a fixture theme directory. + */ + private function fixtureThemeRoot(string $name): string + { + return dirname(__DIR__) . '/Theme/Example/' . $name; + } + + /** + * Writes a manifest driven theme under the temporary themes directory. + * + * @param array $data the manifest data + */ + private function createManifestTheme(string $id, array $data): string + { + $dir = $this->tempDir . '/themes/' . $id; + + mkdir($dir, 0o755, true); + + file_put_contents($dir . '/theme.json', (string) json_encode($data, JSON_UNESCAPED_SLASHES)); + + return $dir; + } + + /** + * Recursively remove a directory and its contents. + */ + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $scandir = scandir($path); + $items = array_diff(false === $scandir ? [] : $scandir, ['.', '..']); + + foreach ($items as $item) { + $itemPath = $path . '/' . $item; + + if (is_dir($itemPath)) { + $this->removeDirectory($itemPath); + } else { + unlink($itemPath); + } + } + + rmdir($path); + } +} diff --git a/test/Unit/Infrastructure/Service/UrlGeneratorTest.php b/test/Unit/Infrastructure/Service/UrlGeneratorTest.php new file mode 100644 index 0000000..3126a5e --- /dev/null +++ b/test/Unit/Infrastructure/Service/UrlGeneratorTest.php @@ -0,0 +1,206 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use League\Route\Router; +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Infrastructure\Contract\{BaseUrlInterface, UrlType}; +use Webify\Base\Infrastructure\Service\{BaseUrl, Config, UrlGenerator}; +use Webify\Base\Test\Unit\Infrastructure\Service\Example\ExampleResourcePath; + +/** + * UrlGeneratorTest tests the functionality of the UrlGenerator service. + * + * @internal + */ +#[CoversClass(UrlGenerator::class)] +#[CoversMethod(UrlGenerator::class, 'route')] +#[CoversMethod(UrlGenerator::class, 'path')] +#[CoversMethod(UrlGenerator::class, 'to')] +#[UsesClass(Router::class)] +#[UsesClass(BaseUrl::class)] +#[UsesClass(Config::class)] +#[UsesClass(ExampleResourcePath::class)] +final class UrlGeneratorTest extends TestCase +{ + /** + * The router used to build named routes. + */ + private Router $router; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->router = new Router(); + $this->router->map('GET', '/post/{id}', static fn (): string => '')->setName('post'); + } + + /** + * Test that a route generates a relative path. + */ + #[Test] + public function testRouteGeneratesRelativePath(): void + { + $generator = new UrlGenerator($this->router, self::createStub(BaseUrlInterface::class)); + + self::assertSame('/post/5', $generator->route('post', ['id' => 5], UrlType::Relative)); + } + + /** + * Test that an absolute URL uses the configured base URL with the default scheme. + */ + #[Test] + public function testRouteGeneratesAbsoluteUrlWithDefaultScheme(): void + { + /** @var BaseUrlInterface&MockObject $baseUrl */ + $baseUrl = $this->createMock(BaseUrlInterface::class); + $baseUrl->method('hasScheme')->willReturn(false); + $baseUrl->expects(self::once()) + ->method('toNative') + ->with('https') + ->willReturn('https://example.com') + ; + + $generator = new UrlGenerator($this->router, $baseUrl); + + self::assertSame('https://example.com/post/5', $generator->route('post', ['id' => 5], UrlType::Absolute)); + } + + /** + * Test that an absolute URL uses the base URL's scheme when it already has one. + */ + #[Test] + public function testRouteGeneratesAbsoluteUrlWithExistingScheme(): void + { + /** @var BaseUrlInterface&MockObject $baseUrl */ + $baseUrl = $this->createMock(BaseUrlInterface::class); + $baseUrl->method('hasScheme')->willReturn(true); + $baseUrl->expects(self::once()) + ->method('toNative') + ->with(null) + ->willReturn('http://example.com') + ; + + $generator = new UrlGenerator($this->router, $baseUrl); + + self::assertSame('http://example.com/post/5', $generator->route('post', ['id' => 5], UrlType::Absolute)); + } + + /** + * Test that a network-path URL delegates to toNative without a scheme. + */ + #[Test] + public function testRouteGeneratesNetworkPathUrl(): void + { + /** @var BaseUrlInterface&MockObject $baseUrl */ + $baseUrl = $this->createMock(BaseUrlInterface::class); + $baseUrl->expects(self::once()) + ->method('toNative') + ->with('') + ->willReturn('//example.com') + ; + + $generator = new UrlGenerator($this->router, $baseUrl); + + self::assertSame('//example.com/post/5', $generator->route('post', ['id' => 5], UrlType::NetworkPath)); + } + + /** + * Test that path resolves a relative path. + */ + #[Test] + public function testPathGeneratesRelativePath(): void + { + $generator = new UrlGenerator($this->router, self::createStub(BaseUrlInterface::class)); + + self::assertSame('/foo?bar=1', $generator->path('/foo?bar=1', UrlType::Relative)); + } + + /** + * Test that path resolves an absolute URL. + */ + #[Test] + public function testPathGeneratesAbsoluteUrl(): void + { + /** @var BaseUrlInterface&MockObject $baseUrl */ + $baseUrl = $this->createMock(BaseUrlInterface::class); + $baseUrl->method('hasScheme')->willReturn(false); + $baseUrl->expects(self::once()) + ->method('toNative') + ->with('https') + ->willReturn('https://example.com') + ; + + $generator = new UrlGenerator($this->router, $baseUrl); + + self::assertSame('https://example.com/foo?bar=1', $generator->path('/foo?bar=1', UrlType::Absolute)); + } + + /** + * Test that to resolves a resource path to a relative path. + */ + #[Test] + public function testToGeneratesRelativePathFromResourcePath(): void + { + $generator = new UrlGenerator($this->router, self::createStub(BaseUrlInterface::class)); + + self::assertSame('/about', $generator->to(ExampleResourcePath::fromString('about'), UrlType::Relative)); + } + + /** + * Test that the default scheme is used only when the base URL has no scheme. + */ + #[Test] + public function testDefaultSchemeIsUsedOnlyWhenBaseUrlLacksScheme(): void + { + $schemes = []; + + $baseUrl = self::createStub(BaseUrlInterface::class); + $baseUrl->method('hasScheme')->willReturn(false); + $baseUrl->method('toNative') + ->willReturnCallback(static function (?string $scheme) use (&$schemes): string { + $schemes[] = $scheme; + + return '//example.com'; + }) + ; + + $generator = new UrlGenerator($this->router, $baseUrl, 'http'); + + $generator->route('post', ['id' => 5], UrlType::Absolute); + + self::assertSame(['http'], $schemes); + } + + /** + * Test that a real base URL composes with generated paths without double slashes. + */ + #[Test] + public function testRealBaseUrlDoesNotProduceDoubleSlashes(): void + { + $generator = new UrlGenerator( + $this->router, + BaseUrl::fromConfig(new Config(['baseUrl' => 'http://localhost'])) + ); + + self::assertSame('http://localhost/post/5', $generator->route('post', ['id' => 5], UrlType::Absolute)); + self::assertSame('//localhost/post/5', $generator->route('post', ['id' => 5], UrlType::NetworkPath)); + self::assertSame('/post/5', $generator->route('post', ['id' => 5], UrlType::Relative)); + self::assertSame('http://localhost/foo?bar=1', $generator->path('/foo?bar=1', UrlType::Absolute)); + } +} diff --git a/test/Unit/Infrastructure/Service/ViewTest.php b/test/Unit/Infrastructure/Service/ViewTest.php new file mode 100644 index 0000000..cc586ec --- /dev/null +++ b/test/Unit/Infrastructure/Service/ViewTest.php @@ -0,0 +1,205 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Service; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test}; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Webify\Base\Infrastructure\Contract\{ + AssetsPipelineInterface, + ExtensionInterface, + TemplateRendererInterface, + UrlGeneratorInterface, + UrlType, +}; +use Webify\Base\Infrastructure\Service\View; + +/** + * ViewTest tests the functionality of the View service. + * + * @internal + */ +#[CoversClass(View::class)] +#[CoversMethod(View::class, 'render')] +#[CoversMethod(View::class, 'url')] +#[CoversMethod(View::class, 'path')] +#[CoversMethod(View::class, 'asset')] +final class ViewTest extends TestCase +{ + /** + * Test that render passes the view instance in the data to the renderer. + */ + #[Test] + public function testRenderAddsViewToData(): void + { + /** @var MockObject&TemplateRendererInterface $renderer */ + $renderer = $this->createMock(TemplateRendererInterface::class); + $renderer->expects(self::once()) + ->method('render') + ->with( + 'post/view', + self::callback(static fn (array $data): bool => $data['view'] instanceof View), + null + ) + ->willReturn('

Hello

') + ; + + $view = new View( + self::createStub(UrlGeneratorInterface::class), + self::createStub(AssetsPipelineInterface::class), + $renderer + ); + + self::assertSame('

Hello

', $view->render('post/view', ['title' => 'Hello'])); + } + + /** + * Test that render passes the current extension to the renderer. + */ + #[Test] + public function testRenderPassesExtensionToRenderer(): void + { + /** @var ExtensionInterface&MockObject $extension */ + $extension = self::createStub(ExtensionInterface::class); + + /** @var MockObject&TemplateRendererInterface $renderer */ + $renderer = $this->createMock(TemplateRendererInterface::class); + $renderer->expects(self::once()) + ->method('render') + ->with('post/view', self::anything(), $extension) + ->willReturn('') + ; + + $view = new View( + self::createStub(UrlGeneratorInterface::class), + self::createStub(AssetsPipelineInterface::class), + $renderer + ); + $view->extension = $extension; + + $view->render('post/view'); + } + + /** + * Test that render sets the layout when provided. + */ + #[Test] + public function testRenderSetsLayoutWhenProvided(): void + { + /** @var MockObject&TemplateRendererInterface $renderer */ + $renderer = self::createStub(TemplateRendererInterface::class); + $renderer->method('render')->willReturn(''); + + $view = new View( + self::createStub(UrlGeneratorInterface::class), + self::createStub(AssetsPipelineInterface::class), + $renderer + ); + + $view->render('post/view', [], 'layouts/main'); + + self::assertSame('layouts/main', $view->layout); + } + + /** + * Test that render leaves the layout unchanged when not provided. + */ + #[Test] + public function testRenderLeavesLayoutUnchangedWhenNull(): void + { + /** @var MockObject&TemplateRendererInterface $renderer */ + $renderer = self::createStub(TemplateRendererInterface::class); + $renderer->method('render')->willReturn(''); + + $view = new View( + self::createStub(UrlGeneratorInterface::class), + self::createStub(AssetsPipelineInterface::class), + $renderer + ); + $view->layout = 'layouts/base'; + + $view->render('post/view', [], null); + + self::assertSame('layouts/base', $view->layout); + } + + /** + * Test that url delegates to the url generator. + */ + #[Test] + public function testUrlDelegatesToUrlGenerator(): void + { + /** @var MockObject&UrlGeneratorInterface $urlGenerator */ + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('route') + ->with('post', ['id' => 5], UrlType::Absolute) + ->willReturn('https://example.com/post/5') + ; + + $view = new View( + $urlGenerator, + self::createStub(AssetsPipelineInterface::class), + self::createStub(TemplateRendererInterface::class) + ); + + self::assertSame('https://example.com/post/5', $view->url('post', ['id' => 5], UrlType::Absolute)); + } + + /** + * Test that path delegates to the url generator. + */ + #[Test] + public function testPathDelegatesToUrlGenerator(): void + { + /** @var MockObject&UrlGeneratorInterface $urlGenerator */ + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('path') + ->with('/about', UrlType::Relative) + ->willReturn('/about') + ; + + $view = new View( + $urlGenerator, + self::createStub(AssetsPipelineInterface::class), + self::createStub(TemplateRendererInterface::class) + ); + + self::assertSame('/about', $view->path('/about')); + } + + /** + * Test that asset delegates to the asset pipeline. + */ + #[Test] + public function testAssetDelegatesToAssetPipeline(): void + { + /** @var AssetsPipelineInterface&MockObject $assetPipeline */ + $assetPipeline = $this->createMock(AssetsPipelineInterface::class); + $assetPipeline->expects(self::once()) + ->method('url') + ->with('app') + ->willReturn('/assets/app.js') + ; + + $view = new View( + self::createStub(UrlGeneratorInterface::class), + $assetPipeline, + self::createStub(TemplateRendererInterface::class) + ); + + self::assertSame('/assets/app.js', $view->asset('app')); + } +} diff --git a/test/Unit/Infrastructure/Theme/Example/Active/ActiveTheme.php b/test/Unit/Infrastructure/Theme/Example/Active/ActiveTheme.php new file mode 100644 index 0000000..25721ab --- /dev/null +++ b/test/Unit/Infrastructure/Theme/Example/Active/ActiveTheme.php @@ -0,0 +1,127 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Theme\Example\Active; + +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Contract\{ + AssetsProviderInterface, + ServiceProviderInterface, + ThemeConfigInterface, + ThemeInterface, +}; +use Webify\Base\Infrastructure\Service\ThemeConfig; + +/** + * Example active theme fixture for testing purposes. + * + * @internal + */ +final readonly class ActiveTheme implements ThemeInterface +{ + /** + * The constructor. + */ + public function __construct( + private ThemeConfigInterface $config = new ThemeConfig([ + 'id' => 'example-active', + 'name' => 'Example Active Theme', + 'version' => '2.0.0', + ]), + // The theme class resolves its own root path, anchored at its own + // location — mirroring how a real PHP theme (e.g. Canvas) resolves it. + private string $rootPath = __DIR__ + ) {} + + /** + * {@inheritDoc} + */ + public function getId(): string + { + return (string) $this->config->get('id'); + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return (string) $this->config->get('name', $this->getId()); + } + + /** + * {@inheritDoc} + */ + public function getVersion(): string + { + return (string) $this->config->get('version', '0.0.0'); + } + + /** + * {@inheritDoc} + */ + public function getConfig(): ThemeConfigInterface + { + return $this->config; + } + + /** + * {@inheritDoc} + */ + public function getRootPath(): string + { + return $this->rootPath; + } + + /** + * {@inheritDoc} + */ + public function getTemplatePath(): TemplatePath + { + return TemplatePath::fromString($this->rootPath . '/templates'); + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getTemplateMap(): array + { + return ['blog' => TemplatePath::fromString($this->rootPath . '/blog')]; + } + + /** + * {@inheritDoc} + */ + public function getProvider(): ServiceProviderInterface + { + return new class implements ServiceProviderInterface { + /** + * {@inheritDoc} + */ + public function getDefinitions(): array + { + return ['active.definition' => 'active-value']; + } + }; + } + + /** + * {@inheritDoc} + */ + public function getAssetsProvider(): ?AssetsProviderInterface + { + return null; + } +} diff --git a/test/Unit/Infrastructure/Theme/Example/Active/theme.json b/test/Unit/Infrastructure/Theme/Example/Active/theme.json new file mode 100644 index 0000000..c773ee0 --- /dev/null +++ b/test/Unit/Infrastructure/Theme/Example/Active/theme.json @@ -0,0 +1,5 @@ +{ + "id": "example-active", + "name": "Example Active Theme", + "version": "2.0.0" +} \ No newline at end of file diff --git a/test/Unit/Infrastructure/Theme/Example/Default/DefaultTheme.php b/test/Unit/Infrastructure/Theme/Example/Default/DefaultTheme.php new file mode 100644 index 0000000..91ef66d --- /dev/null +++ b/test/Unit/Infrastructure/Theme/Example/Default/DefaultTheme.php @@ -0,0 +1,119 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Theme\Example\Default; + +use Webify\Base\Domain\ValueObject\TemplatePath; +use Webify\Base\Infrastructure\Contract\{ + AssetsProviderInterface, + ServiceProviderInterface, + ThemeConfigInterface, + ThemeInterface, +}; +use Webify\Base\Infrastructure\Service\ThemeConfig; + +/** + * Example default theme fixture for testing purposes. + * + * @internal + */ +final readonly class DefaultTheme implements ThemeInterface +{ + /** + * The constructor. + */ + public function __construct( + private ThemeConfigInterface $config = new ThemeConfig([ + 'id' => 'example-default', + 'name' => 'Example Default Theme', + 'version' => '1.0.0', + ]), + // The theme class resolves its own root path, anchored at its own + // location — mirroring how a real PHP theme (e.g. Canvas) resolves it. + private string $rootPath = __DIR__ + ) {} + + /** + * {@inheritDoc} + */ + public function getId(): string + { + return (string) $this->config->get('id'); + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return (string) $this->config->get('name', $this->getId()); + } + + /** + * {@inheritDoc} + */ + public function getVersion(): string + { + return (string) $this->config->get('version', '0.0.0'); + } + + /** + * {@inheritDoc} + */ + public function getConfig(): ThemeConfigInterface + { + return $this->config; + } + + /** + * {@inheritDoc} + */ + public function getRootPath(): string + { + return $this->rootPath; + } + + /** + * {@inheritDoc} + */ + public function getTemplatePath(): TemplatePath + { + return TemplatePath::fromString($this->rootPath . '/templates'); + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getTemplateMap(): array + { + return ['blog' => TemplatePath::fromString($this->rootPath . '/blog')]; + } + + /** + * {@inheritDoc} + */ + public function getProvider(): ?ServiceProviderInterface + { + return null; + } + + /** + * {@inheritDoc} + */ + public function getAssetsProvider(): ?AssetsProviderInterface + { + return null; + } +} diff --git a/test/Unit/Infrastructure/Theme/Example/Default/theme.json b/test/Unit/Infrastructure/Theme/Example/Default/theme.json new file mode 100644 index 0000000..9dc7273 --- /dev/null +++ b/test/Unit/Infrastructure/Theme/Example/Default/theme.json @@ -0,0 +1,5 @@ +{ + "id": "example-default", + "name": "Example Default Theme", + "version": "1.0.0" +} \ No newline at end of file diff --git a/test/Unit/Infrastructure/Theme/ThemeFactoryTest.php b/test/Unit/Infrastructure/Theme/ThemeFactoryTest.php new file mode 100644 index 0000000..a398b8f --- /dev/null +++ b/test/Unit/Infrastructure/Theme/ThemeFactoryTest.php @@ -0,0 +1,248 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Theme; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Infrastructure\Exception\ThemeException; +use Webify\Base\Infrastructure\Service\{Config, ThemeConfig}; +use Webify\Base\Infrastructure\Theme\{Theme, ThemeFactory, ThemeManifestValidator}; +use Webify\Base\Test\Unit\Infrastructure\Theme\Example\Default\DefaultTheme; + +use function file_put_contents; +use function is_dir; +use function json_encode; +use function mkdir; +use function rmdir; +use function scandir; +use function sys_get_temp_dir; +use function uniqid; +use function unlink; + +/** + * ThemeFactoryTest tests the functionality of the ThemeFactory class. + * + * @internal + */ +#[CoversClass(ThemeFactory::class)] +#[CoversMethod(ThemeFactory::class, 'fromConfig')] +#[UsesClass(Theme::class)] +#[UsesClass(ThemeConfig::class)] +#[UsesClass(ThemeException::class)] +#[UsesClass(ThemeManifestValidator::class)] +#[UsesClass(Config::class)] +#[UsesClass(DefaultTheme::class)] +final class ThemeFactoryTest extends TestCase +{ + /** + * Temporary directory acting as the application base path. + */ + private string $tempDir; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/webify_theme_factory_' . uniqid('', true); + + mkdir($this->tempDir, 0o755, true); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + /** + * Test that fromConfig resolves an existing theme directory path. + */ + #[Test] + public function testFromConfigResolvesDirectoryPath(): void + { + $themeDir = $this->createTheme('mytheme'); + + $theme = $this->createFactory()->fromConfig($themeDir); + + self::assertInstanceOf(Theme::class, $theme); + self::assertSame('mytheme', $theme->getId()); + self::assertSame($themeDir, $theme->getRootPath()); + } + + /** + * Test that fromConfig resolves a theme id under the configured themes path. + */ + #[Test] + public function testFromConfigResolvesThemeId(): void + { + $themeDir = $this->createTheme('mytheme'); + + $theme = $this->createFactory()->fromConfig('mytheme'); + + self::assertInstanceOf(Theme::class, $theme); + self::assertSame('mytheme', $theme->getId()); + self::assertSame($themeDir, $theme->getRootPath()); + } + + /** + * Test that fromConfig resolves a configured theme class. + */ + #[Test] + public function testFromConfigResolvesConfiguredClass(): void + { + $theme = $this->createFactory()->fromConfig(DefaultTheme::class); + + self::assertInstanceOf(DefaultTheme::class, $theme); + self::assertSame('example-default', $theme->getId()); + self::assertSame('Example Default Theme', $theme->getName()); + self::assertSame('1.0.0', $theme->getVersion()); + } + + /** + * Test that a theme class resolves its configuration from the theme itself. + */ + #[Test] + public function testFromConfigClassThemeResolvesItsOwnConfig(): void + { + $theme = $this->createFactory()->fromConfig(DefaultTheme::class); + + self::assertSame('example-default', $theme->getConfig()->get('id')); + } + + /** + * Test that fromConfig throws when the configured class is not a theme. + */ + #[Test] + public function testFromConfigThrowsWhenClassIsNotATheme(): void + { + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('must implement'); + + $this->createFactory()->fromConfig(self::class); + } + + /** + * Test that fromConfig throws when the theme manifest is missing. + */ + #[Test] + public function testFromConfigThrowsWhenManifestMissing(): void + { + mkdir($this->tempDir . '/themes/nomanifest', 0o755, true); + + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Unable to read theme manifest from'); + + $this->createFactory()->fromConfig('nomanifest'); + } + + /** + * Test that fromConfig throws when the manifest lacks an id. + */ + #[Test] + public function testFromConfigThrowsWhenManifestIdMissing(): void + { + $dir = $this->tempDir . '/themes/noid'; + + mkdir($dir, 0o755, true); + file_put_contents($dir . '/theme.json', (string) json_encode(['name' => 'No Id Theme'])); + + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Invalid manifest data from'); + + $this->createFactory()->fromConfig('noid'); + } + + /** + * Test that fromConfig throws when the manifest lacks a name. + */ + #[Test] + public function testFromConfigThrowsWhenManifestNameMissing(): void + { + $dir = $this->tempDir . '/themes/noname'; + + mkdir($dir, 0o755, true); + file_put_contents($dir . '/theme.json', (string) json_encode(['id' => 'noname'])); + + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Invalid manifest data from'); + + $this->createFactory()->fromConfig('noname'); + } + + /** + * Create a real config instance backed by the temporary base path. + */ + private function createConfig(): Config + { + return new Config([ + 'basePath' => $this->tempDir, + 'runtimePath' => $this->tempDir . '/runtime', + 'configPath' => $this->tempDir . '/config', + ]); + } + + /** + * Create a theme factory backed by a real config instance. + */ + private function createFactory(): ThemeFactory + { + return new ThemeFactory($this->createConfig()); + } + + /** + * Writes a manifest driven theme under the temporary themes directory. + */ + private function createTheme(string $id): string + { + $dir = $this->tempDir . '/themes/' . $id; + + mkdir($dir, 0o755, true); + + file_put_contents($dir . '/theme.json', (string) json_encode([ + 'id' => $id, + 'name' => 'Example Theme', + 'version' => '1.0.0', + ], JSON_UNESCAPED_SLASHES)); + + return $dir; + } + + /** + * Recursively remove a directory and its contents. + */ + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $scandir = scandir($path); + $items = array_diff(false === $scandir ? [] : $scandir, ['.', '..']); + + foreach ($items as $item) { + $itemPath = $path . '/' . $item; + + if (is_dir($itemPath)) { + $this->removeDirectory($itemPath); + } else { + unlink($itemPath); + } + } + + rmdir($path); + } +} diff --git a/test/Unit/Infrastructure/Theme/ThemeManifestValidatorTest.php b/test/Unit/Infrastructure/Theme/ThemeManifestValidatorTest.php new file mode 100644 index 0000000..18ae3fd --- /dev/null +++ b/test/Unit/Infrastructure/Theme/ThemeManifestValidatorTest.php @@ -0,0 +1,121 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Theme; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Infrastructure\Exception\ThemeException; +use Webify\Base\Infrastructure\Theme\ThemeManifestValidator; + +/** + * ThemeManifestValidatorTest tests the functionality of the ThemeManifestValidator class. + * + * @internal + */ +#[CoversClass(ThemeManifestValidator::class)] +#[CoversMethod(ThemeManifestValidator::class, 'validate')] +#[UsesClass(ThemeException::class)] +final class ThemeManifestValidatorTest extends TestCase +{ + /** + * Test that a manifest with an id and name validates. + */ + #[Test] + public function testValidateAcceptsValidManifest(): void + { + $data = ['id' => 'mytheme', 'name' => 'My Theme']; + + self::assertSame($data, $this->validator()->validate($data, '/path/to/theme.json')); + } + + /** + * Test that a manifest without an id is invalid. + */ + #[Test] + public function testValidateThrowsWhenIdMissing(): void + { + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Invalid manifest data from'); + + $this->validator()->validate(['name' => 'My Theme'], '/path/to/theme.json'); + } + + /** + * Test that a manifest with an empty id is invalid. + */ + #[Test] + public function testValidateThrowsWhenIdEmpty(): void + { + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Invalid manifest data from'); + + $this->validator()->validate(['id' => '', 'name' => 'My Theme'], '/path/to/theme.json'); + } + + /** + * Test that a manifest with a non-string id is invalid. + */ + #[Test] + public function testValidateThrowsWhenIdNotString(): void + { + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Invalid manifest data from'); + + $this->validator()->validate(['id' => 42, 'name' => 'My Theme'], '/path/to/theme.json'); + } + + /** + * Test that a manifest without a name is invalid. + */ + #[Test] + public function testValidateThrowsWhenNameMissing(): void + { + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Invalid manifest data from'); + + $this->validator()->validate(['id' => 'mytheme'], '/path/to/theme.json'); + } + + /** + * Test that a manifest with an empty name is invalid. + */ + #[Test] + public function testValidateThrowsWhenNameEmpty(): void + { + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Invalid manifest data from'); + + $this->validator()->validate(['id' => 'mytheme', 'name' => ''], '/path/to/theme.json'); + } + + /** + * Test that a manifest with a non-string name is invalid. + */ + #[Test] + public function testValidateThrowsWhenNameNotString(): void + { + $this->expectException(ThemeException::class); + $this->expectExceptionMessageIsOrContains('Invalid manifest data from'); + + $this->validator()->validate(['id' => 'mytheme', 'name' => false], '/path/to/theme.json'); + } + + /** + * Creates a theme manifest validator instance. + */ + private function validator(): ThemeManifestValidator + { + return new ThemeManifestValidator(); + } +} diff --git a/test/Unit/Infrastructure/Theme/ThemeTest.php b/test/Unit/Infrastructure/Theme/ThemeTest.php new file mode 100644 index 0000000..3e55f7b --- /dev/null +++ b/test/Unit/Infrastructure/Theme/ThemeTest.php @@ -0,0 +1,247 @@ + + */ +declare(strict_types=1); + +namespace Webify\Base\Test\Unit\Infrastructure\Theme; + +use PHPUnit\Framework\Attributes\{CoversClass, CoversMethod, Test, UsesClass}; +use PHPUnit\Framework\TestCase; +use Webify\Base\Domain\ValueObject\{Hash, TemplatePath}; +use Webify\Base\Infrastructure\Service\{ThemeAssetsProvider, ThemeConfig}; +use Webify\Base\Infrastructure\Theme\Theme; + +use function is_dir; +use function mkdir; +use function rmdir; +use function scandir; +use function sys_get_temp_dir; +use function uniqid; +use function unlink; + +/** + * ThemeTest tests the functionality of the Theme class. + * + * @internal + */ +#[CoversClass(Theme::class)] +#[CoversMethod(Theme::class, 'getId')] +#[CoversMethod(Theme::class, 'getName')] +#[CoversMethod(Theme::class, 'getVersion')] +#[CoversMethod(Theme::class, 'getConfig')] +#[CoversMethod(Theme::class, 'getRootPath')] +#[CoversMethod(Theme::class, 'getTemplatePath')] +#[CoversMethod(Theme::class, 'getTemplateMap')] +#[CoversMethod(Theme::class, 'getProvider')] +#[CoversMethod(Theme::class, 'getAssetsProvider')] +#[UsesClass(ThemeConfig::class)] +#[UsesClass(ThemeAssetsProvider::class)] +#[UsesClass(TemplatePath::class)] +#[UsesClass(Hash::class)] +final class ThemeTest extends TestCase +{ + /** + * Temporary directory hosting the theme root. + */ + private string $tempDir; + + /** + * The theme root path. + */ + private string $rootPath; + + /** + * The theme under test. + */ + private Theme $theme; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/webify_theme_' . uniqid('', true); + $this->rootPath = $this->tempDir . '/theme'; + + mkdir($this->rootPath, 0o755, true); + + $this->theme = new Theme( + new ThemeConfig([ + 'id' => 'mytheme', + 'name' => 'My Theme', + 'version' => '1.2.3', + 'templates' => [ + 'blog' => 'blog/post', + 'admin' => '/abs/path', + ], + ]), + $this->rootPath + ); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + /** + * Test that the theme identity is exposed. + */ + #[Test] + public function testGetIdNameAndVersion(): void + { + self::assertSame('mytheme', $this->theme->getId()); + self::assertSame('My Theme', $this->theme->getName()); + self::assertSame('1.2.3', $this->theme->getVersion()); + } + + /** + * Test that a missing name defaults to the theme id. + */ + #[Test] + public function testGetNameDefaultsToId(): void + { + $theme = $this->createTheme(['id' => 'mytheme']); + + self::assertSame('mytheme', $theme->getName()); + } + + /** + * Test that a missing version defaults to 0.0.0. + */ + #[Test] + public function testGetVersionDefaultsToZeroZeroZero(): void + { + $theme = $this->createTheme(['id' => 'mytheme']); + + self::assertSame('0.0.0', $theme->getVersion()); + } + + /** + * Test that the config view exposes the raw manifest data. + */ + #[Test] + public function testGetConfig(): void + { + $config = $this->theme->getConfig(); + + self::assertInstanceOf(ThemeConfig::class, $config); + self::assertSame('My Theme', $config->get('name')); + } + + /** + * Test that the template path points at the templates directory under the root. + */ + #[Test] + public function testGetTemplatePath(): void + { + self::assertSame($this->rootPath . '/templates', $this->theme->getTemplatePath()->toNative()); + } + + /** + * Test that relative manifest paths are anchored at the theme root. + */ + #[Test] + public function testGetTemplateMapAnchorsRelativePaths(): void + { + $map = $this->theme->getTemplateMap(); + + self::assertSame($this->rootPath . '/blog/post', $map['blog']->toNative()); + self::assertSame('/abs/path', $map['admin']->toNative()); + } + + /** + * Test that non-string template entries are skipped. + */ + #[Test] + public function testSkipsNonStringTemplateEntries(): void + { + $theme = $this->createTheme([ + 'templates' => [ + 'blog' => 'blog/post', + 'admin' => 123, + 'other' => null, + ], + ]); + + $map = $theme->getTemplateMap(); + + self::assertCount(1, $map); + self::assertSame($this->rootPath . '/blog/post', $map['blog']->toNative()); + } + + /** + * Test that a generic theme has no service provider. + */ + #[Test] + public function testGetProviderIsNull(): void + { + self::assertNull($this->theme->getProvider()); + } + + /** + * Test that no assets provider is returned when the dist directory is missing. + */ + #[Test] + public function testGetAssetsProviderIsNullWithoutDistDirectory(): void + { + self::assertNull($this->theme->getAssetsProvider()); + } + + /** + * Test that an assets provider is returned when the dist directory exists. + */ + #[Test] + public function testGetAssetsProviderWhenDistDirectoryExists(): void + { + mkdir($this->rootPath . '/dist', 0o755, true); + + self::assertInstanceOf(ThemeAssetsProvider::class, $this->theme->getAssetsProvider()); + } + + /** + * Creates a theme from the given manifest data. + * + * @param array $data the theme manifest data + */ + private function createTheme(array $data): Theme + { + return new Theme(new ThemeConfig($data), $this->rootPath); + } + + /** + * Recursively remove a directory and its contents. + */ + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $scandir = scandir($path); + $items = array_diff(false === $scandir ? [] : $scandir, ['.', '..']); + + foreach ($items as $item) { + $itemPath = $path . '/' . $item; + + if (is_dir($itemPath)) { + $this->removeDirectory($itemPath); + } else { + unlink($itemPath); + } + } + + rmdir($path); + } +}