From d1c05dc382896b8cf2f3e27114be23bd84ea96cb Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Tue, 29 Sep 2026 13:15:03 -0500 Subject: [PATCH 01/11] feat(prototype): theme resolver, theme-keyed maps with the active theme injected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver prototype for Phase 2b: ThemeResolver takes theme => address => path maps and the active theme name, resolves the active theme's entry for an address, falls back to default per address, and returns false so the aggregate keeps walking. A factory reads theme.active/theme.themes from configuration, so the active theme enters at construction. AggregateResolverFactory states the order outright — theme 100, mezzio's map 50, path stack 1 — because mezzio's own factory attaches the last two at equal priority, which leaves their order to the queue. Composing that aggregate is also why mezzio/mezzio-laminasviewrenderer becomes a dependency: NamespacedPathStackResolver is its class. Whether that dependency is worth the guaranteed ordering is a finding for the prototype to weigh. Tests: unit covers hit, per-address fallback, unknown address, unknown active theme and empty maps; integration uses real files and proves the theme wins an address mezzio's map also carries, and that an address it lacks still resolves through the map. Signed-off-by: Joey Smith --- composer.json | 1 + composer.lock | 707 ++++++++++++++++-- src/ConfigProvider.php | 32 +- .../Container/AggregateResolverFactory.php | 57 ++ .../Container/ThemeResolverFactory.php | 69 ++ src/Resolver/ThemeResolver.php | 58 ++ .../themes/acme/app/home-page.phtml | 16 + .../themes/acme/layout/default.phtml | 16 + .../themes/default/app/home-page.phtml | 16 + .../themes/default/layout/default.phtml | 16 + test/integration/ThemeResolutionTest.php | 114 +++ test/unit/ConfigProviderTest.php | 13 +- test/unit/Resolver/ThemeResolverTest.php | 97 +++ 13 files changed, 1137 insertions(+), 75 deletions(-) create mode 100644 src/Resolver/Container/AggregateResolverFactory.php create mode 100644 src/Resolver/Container/ThemeResolverFactory.php create mode 100644 src/Resolver/ThemeResolver.php create mode 100644 test/integration/TestAssets/themes/acme/app/home-page.phtml create mode 100644 test/integration/TestAssets/themes/acme/layout/default.phtml create mode 100644 test/integration/TestAssets/themes/default/app/home-page.phtml create mode 100644 test/integration/TestAssets/themes/default/layout/default.phtml create mode 100644 test/integration/ThemeResolutionTest.php create mode 100644 test/unit/Resolver/ThemeResolverTest.php diff --git a/composer.json b/composer.json index 6d875cd..a136e80 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "require": { "php": "~8.4.1 || ~8.5.0", "laminas/laminas-view": "^3.0", + "mezzio/mezzio-laminasviewrenderer": "^3.0", "psr/container": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 4aa7fe9..4f7920d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "82485d307068fd00125d2028195b3b95", + "content-hash": "e8e4c3632803b8156ef05080cf22b040", "packages": [ { "name": "brick/varexporter", @@ -55,6 +55,62 @@ ], "time": "2025-02-20T17:42:39+00:00" }, + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, { "name": "laminas/laminas-escaper", "version": "2.18.0", @@ -396,6 +452,302 @@ ], "time": "2026-06-02T22:39:05+00:00" }, + { + "name": "mezzio/mezzio-helpers", + "version": "5.20.0", + "source": { + "type": "git", + "url": "https://github.com/mezzio/mezzio-helpers.git", + "reference": "a26ba04bd449d5cdb5ad38b17ce672365dbc9d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mezzio/mezzio-helpers/zipball/a26ba04bd449d5cdb5ad38b17ce672365dbc9d90", + "reference": "a26ba04bd449d5cdb5ad38b17ce672365dbc9d90", + "shasum": "" + }, + "require": { + "mezzio/mezzio-router": "^3.18 || ^4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-message": "^1.0.1 || ^2.0.0", + "psr/http-server-middleware": "^1.0" + }, + "conflict": { + "amphp/amp": "<2.6.4", + "amphp/dns": "<2.1.2", + "amphp/socket": "<2.3.1", + "zendframework/zend-expressive-helpers": "*" + }, + "require-dev": { + "ext-json": "*", + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "ext-json": "If you wish to use the JsonStrategy with BodyParamsMiddleware" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Mezzio\\Helper\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Mezzio\\Helper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Helper/Utility classes for Mezzio", + "homepage": "https://mezzio.dev", + "keywords": [ + "http", + "laminas", + "mezzio", + "middleware", + "psr", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.mezzio.dev/mezzio/features/helpers/intro/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/mezzio/mezzio-helpers/issues", + "rss": "https://github.com/mezzio/mezzio-helpers/releases.atom", + "source": "https://github.com/mezzio/mezzio-helpers" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-11T08:40:34+00:00" + }, + { + "name": "mezzio/mezzio-laminasviewrenderer", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/mezzio/mezzio-laminasviewrenderer.git", + "reference": "ac78584ea1c417f4f4289616add5ca8d96c78f62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mezzio/mezzio-laminasviewrenderer/zipball/ac78584ea1c417f4f4289616add5ca8d96c78f62", + "reference": "ac78584ea1c417f4f4289616add5ca8d96c78f62", + "shasum": "" + }, + "require": { + "laminas/laminas-servicemanager": "^4.4.0", + "laminas/laminas-view": "^3.0.1", + "mezzio/mezzio-helpers": "^5.15.0", + "mezzio/mezzio-router": "^3.16.1 || ^4.0.0", + "mezzio/mezzio-template": "^3.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-message": "^1.0.1 || ^2.0.0" + }, + "require-dev": { + "laminas/laminas-coding-standard": "^3.1.0", + "mezzio/mezzio-fastroute": "^3.14", + "phpunit/phpunit": "^11.5.44", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Mezzio\\LaminasView\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Mezzio\\LaminasView\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "laminas-view PhpRenderer integration for Mezzio", + "homepage": "https://mezzio.dev", + "keywords": [ + "http", + "laminas", + "mezzio", + "middleware", + "psr", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.mezzio.dev/mezzio/features/template/laminas-view/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/mezzio/mezzio-laminasviewrenderer/issues", + "rss": "https://github.com/mezzio/mezzio-laminasviewrenderer/releases.atom", + "source": "https://github.com/mezzio/mezzio-laminasviewrenderer" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-11-26T16:34:39+00:00" + }, + { + "name": "mezzio/mezzio-router", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/mezzio/mezzio-router.git", + "reference": "e7a6505d7e460ce30c4135e146241d7d24d81c27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mezzio/mezzio-router/zipball/e7a6505d7e460ce30c4135e146241d7d24d81c27", + "reference": "e7a6505d7e460ce30c4135e146241d7d24d81c27", + "shasum": "" + }, + "require": { + "fig/http-message-util": "^1.1.5", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.1.2 || ^2.0", + "psr/http-factory": "^1.0.2", + "psr/http-message": "^1.0.1 || ^2.0.0", + "psr/http-server-middleware": "^1.0.2", + "webmozart/assert": "^1.11.0 || ^2.1.1" + }, + "conflict": { + "mezzio/mezzio": "<3.5", + "zendframework/zend-expressive-router": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6.0", + "laminas/laminas-servicemanager": "^4.4.0", + "laminas/laminas-stratigility": "^4.2.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "mezzio/mezzio-fastroute": "^3.0 to use the FastRoute routing adapter", + "mezzio/mezzio-laminasrouter": "^3.0 to use the laminas-router routing adapter" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Mezzio\\Router\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Mezzio\\Router\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Router subcomponent for Mezzio", + "homepage": "https://mezzio.dev", + "keywords": [ + "http", + "laminas", + "mezzio", + "middleware", + "psr", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.mezzio.dev/mezzio/features/router/intro/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/mezzio/mezzio-router/issues", + "rss": "https://github.com/mezzio/mezzio-router/releases.atom", + "source": "https://github.com/mezzio/mezzio-router" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2026-01-09T11:44:24+00:00" + }, + { + "name": "mezzio/mezzio-template", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/mezzio/mezzio-template.git", + "reference": "49a797e19b167b208327854c81eb079937f0b161" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mezzio/mezzio-template/zipball/49a797e19b167b208327854c81eb079937f0b161", + "reference": "49a797e19b167b208327854c81eb079937f0b161", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-expressive-template": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "mezzio/mezzio-laminasviewrenderer": "^2.0 to use the laminas-view PhpRenderer template renderer", + "mezzio/mezzio-platesrenderer": "^2.0 to use the Plates template renderer", + "mezzio/mezzio-twigrenderer": "^2.0 to use the Twig template renderer" + }, + "type": "library", + "autoload": { + "psr-4": { + "Mezzio\\Template\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Template subcomponent for Mezzio", + "homepage": "https://mezzio.dev", + "keywords": [ + "laminas", + "mezzio", + "template" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.mezzio.dev/mezzio/features/template/intro/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/mezzio/mezzio-template/issues", + "rss": "https://github.com/mezzio/mezzio-template/releases.atom", + "source": "https://github.com/mezzio/mezzio-template" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-11T09:18:27+00:00" + }, { "name": "nikic/php-parser", "version": "v5.9.0", @@ -505,6 +857,293 @@ "source": "https://github.com/php-fig/container/tree/2.0.2" }, "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ @@ -9617,72 +10256,6 @@ ], "time": "2025-12-08T11:19:18+00:00" }, - { - "name": "webmozart/assert", - "version": "2.4.1", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "psalm": { - "pluginClass": "Webmozart\\Assert\\PsalmPlugin" - }, - "branch-alias": { - "dev-master": "2.0-dev", - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.1" - }, - "time": "2026-06-15T15:31:57+00:00" - }, { "name": "webmozart/glob", "version": "4.7.0", diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index 6a9a623..f0b8195 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -14,25 +14,47 @@ namespace Webware\Theme; +use Laminas\View\Resolver\AggregateResolver; +use Webware\Theme\Resolver\Container\AggregateResolverFactory; +use Webware\Theme\Resolver\Container\ThemeResolverFactory; +use Webware\Theme\Resolver\ThemeResolver; + /** * Wiring entry point for the package. * - * Declared under `extra.laminas.config-provider` in composer.json, so a consumer's - * config aggregator merges this without any further registration. + * Declared under `extra.laminas.config-provider` in composer.json, so a consumer's config aggregator + * merges this without any further registration. + * + * Merge this provider **after** `Mezzio\LaminasView\ConfigProvider`: both own + * `AggregateResolver::class`, the later provider wins, and the theme being consulted first is the + * whole point of the package. * + * @type ThemeConfig array{ + * active?: non-empty-string, + * themes?: array>, + * } + * @type DependenciesConfig array{ + * factories: array, + * } + * @type ProviderConfig array{ + * dependencies: DependenciesConfig, + * } * @internal */ final class ConfigProvider { - /** @return array */ + /** @return DependenciesConfig */ private function getDependencies(): array { return [ - 'factories' => [], + 'factories' => [ + ThemeResolver::class => ThemeResolverFactory::class, + AggregateResolver::class => AggregateResolverFactory::class, + ], ]; } - /** @return array */ + /** @return ProviderConfig */ public function __invoke(): array { return [ diff --git a/src/Resolver/Container/AggregateResolverFactory.php b/src/Resolver/Container/AggregateResolverFactory.php new file mode 100644 index 0000000..698825c --- /dev/null +++ b/src/Resolver/Container/AggregateResolverFactory.php @@ -0,0 +1,57 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Theme\Resolver\Container; + +use Laminas\View\Resolver\AggregateResolver; +use Laminas\View\Resolver\ResolverInterface; +use Laminas\View\Resolver\TemplateMapResolver; +use Mezzio\LaminasView\NamespacedPathStackResolver; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Webware\Theme\Resolver\ThemeResolver; + +/** + * Composes the resolver aggregate with the theme consulted first, then mezzio's map, then its path + * stack. + * + * mezzio's own factory attaches the map and the path stack at equal priority, which leaves their + * relative order to the queue. Here the order is stated outright, which is what makes an address the + * active theme carries win and every other address resolve exactly as it did before this package was + * installed. + * + * Registering this factory replaces `Mezzio\LaminasView\TemplateResolverFactory` through the + * `AggregateResolver::class` service key, so the theme package's provider must be merged after + * mezzio's. + * + * @internal + */ +final class AggregateResolverFactory +{ + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container): ResolverInterface + { + $aggregate = new AggregateResolver(); + + $aggregate->attach($container->get(ThemeResolver::class), 100); + $aggregate->attach($container->get(TemplateMapResolver::class), 50); + $aggregate->attach($container->get(NamespacedPathStackResolver::class), 1); + + return $aggregate; + } +} diff --git a/src/Resolver/Container/ThemeResolverFactory.php b/src/Resolver/Container/ThemeResolverFactory.php new file mode 100644 index 0000000..a29d27e --- /dev/null +++ b/src/Resolver/Container/ThemeResolverFactory.php @@ -0,0 +1,69 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Theme\Resolver\Container; + +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Webware\Theme\ConfigProvider; +use Webware\Theme\Resolver\ThemeResolver; + +use function is_array; +use function is_string; + +/** + * Builds the theme resolver from the merged `theme` configuration. + * + * This is where the active theme enters: the resolver is not told which theme to use at call time, so + * the configuration is read once, here, and the resolver holds the answer. + * + * @import-type ThemeConfig from ConfigProvider + * @internal + */ +final class ThemeResolverFactory +{ + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container): ThemeResolver + { + /** @var mixed $raw */ + $raw = $container->has('config') ? $container->get('config') : []; + + /** @var array $config */ + $config = is_array($raw) ? $raw : []; + + /** @var mixed $theme */ + $theme = $config['theme'] ?? []; + + /** @var ThemeConfig $theme */ + $theme = is_array($theme) ? $theme : []; + + /** @var mixed $maps */ + $maps = $theme['themes'] ?? []; + + /** @var array> $maps */ + $maps = is_array($maps) ? $maps : []; + + /** @var mixed $active */ + $active = $theme['active'] ?? null; + + return new ThemeResolver( + $maps, + is_string($active) && '' !== $active ? $active : ThemeResolver::DEFAULT_THEME, + ); + } +} diff --git a/src/Resolver/ThemeResolver.php b/src/Resolver/ThemeResolver.php new file mode 100644 index 0000000..75ec31d --- /dev/null +++ b/src/Resolver/ThemeResolver.php @@ -0,0 +1,58 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Theme\Resolver; + +use Laminas\View\Resolver\ResolverInterface; +use Override; + +/** + * Resolves a namespaced template address against the active theme's map. + * + * The name handed in is the address the renderer asks for — `app::home-page`, `layout::default`, + * `admin::dashboard` — and a map is keyed by exactly that, so a hit is an `isset` and nothing + * touches the filesystem. An address the active theme does not carry falls back to the `default` + * theme's entry for the same address, and a name neither theme carries returns `false` so the + * aggregate resolver continues to the next resolver. + * + * @internal + */ +final class ThemeResolver implements ResolverInterface +{ + /** + * The theme every other theme falls back to. Module directories are named for their theme, so + * this is also the directory name a module ships when it ships one theme. + */ + final public const string DEFAULT_THEME = 'default'; + + /** + * @param array> $maps + * theme name => template address => absolute path + * @param non-empty-string $active + */ + public function __construct( + private readonly array $maps, + private readonly string $active = self::DEFAULT_THEME, + ) {} + + #[Override] + public function resolve(string $name): string|false + { + return ( + $this->maps[$this->active][$name] + ?? $this->maps[self::DEFAULT_THEME][$name] + ?? false + ); + } +} diff --git a/test/integration/TestAssets/themes/acme/app/home-page.phtml b/test/integration/TestAssets/themes/acme/app/home-page.phtml new file mode 100644 index 0000000..cf27f63 --- /dev/null +++ b/test/integration/TestAssets/themes/acme/app/home-page.phtml @@ -0,0 +1,16 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +?> + diff --git a/test/integration/TestAssets/themes/acme/layout/default.phtml b/test/integration/TestAssets/themes/acme/layout/default.phtml new file mode 100644 index 0000000..afce5fd --- /dev/null +++ b/test/integration/TestAssets/themes/acme/layout/default.phtml @@ -0,0 +1,16 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +?> + diff --git a/test/integration/TestAssets/themes/default/app/home-page.phtml b/test/integration/TestAssets/themes/default/app/home-page.phtml new file mode 100644 index 0000000..1762926 --- /dev/null +++ b/test/integration/TestAssets/themes/default/app/home-page.phtml @@ -0,0 +1,16 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +?> + diff --git a/test/integration/TestAssets/themes/default/layout/default.phtml b/test/integration/TestAssets/themes/default/layout/default.phtml new file mode 100644 index 0000000..68ebcaa --- /dev/null +++ b/test/integration/TestAssets/themes/default/layout/default.phtml @@ -0,0 +1,16 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +?> + diff --git a/test/integration/ThemeResolutionTest.php b/test/integration/ThemeResolutionTest.php new file mode 100644 index 0000000..1c9e195 --- /dev/null +++ b/test/integration/ThemeResolutionTest.php @@ -0,0 +1,114 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTestIntegration\Theme; + +use Laminas\View\Resolver\AggregateResolver; +use Laminas\View\Resolver\TemplateMapResolver; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Webware\Theme\Resolver\ThemeResolver; + +use function is_file; + +/** + * Exercises resolution against real files: the active theme wins an address it carries, an address + * it does not carry comes from `default`, and both hold when the theme resolver sits in a real + * aggregate ahead of mezzio's map. + */ +#[CoversClass(ThemeResolver::class)] +#[CoversMethod(ThemeResolver::class, 'resolve')] +final class ThemeResolutionTest extends TestCase +{ + private const ASSETS = __DIR__ . '/TestAssets/themes'; + + #[Test] + public function anAddressTheThemeDoesNotCarryStillResolvesThroughTheMap(): void + { + $mezzio = new TemplateMapResolver([ + 'admin::dashboard' => self::ASSETS . '/default/app/home-page.phtml', + ]); + + $aggregate = new AggregateResolver(); + $aggregate->attach(new ThemeResolver($this->maps(), 'acme'), 100); + $aggregate->attach($mezzio, 50); + + static::assertSame( + self::ASSETS . '/default/app/home-page.phtml', + $aggregate->resolve('admin::dashboard'), + ); + } + + #[Test] + public function fallsBackToTheDefaultThemesFileForAnAddressTheActiveThemeLacks(): void + { + $maps = $this->maps(); + unset($maps['acme']['layout::default']); + + $resolver = new ThemeResolver($maps, 'acme'); + + $path = $resolver->resolve('layout::default'); + + static::assertSame(self::ASSETS . '/default/layout/default.phtml', $path); + static::assertIsString($path); + static::assertTrue(is_file($path)); + } + + #[Test] + public function resolvesTheActiveThemesFileFromDisk(): void + { + $resolver = new ThemeResolver($this->maps(), 'acme'); + + $path = $resolver->resolve('app::home-page'); + + static::assertIsString($path); + static::assertSame(self::ASSETS . '/acme/app/home-page.phtml', $path); + static::assertTrue(is_file($path)); + } + + #[Test] + public function theThemeWinsAnAddressMezziosMapAlsoCarries(): void + { + // The same address, published by the module the old way, through mezzio's own map. + $mezzio = new TemplateMapResolver([ + 'app::home-page' => self::ASSETS . '/default/app/home-page.phtml', + ]); + + $aggregate = new AggregateResolver(); + $aggregate->attach(new ThemeResolver($this->maps(), 'acme'), 100); + $aggregate->attach($mezzio, 50); + + static::assertSame( + self::ASSETS . '/acme/app/home-page.phtml', + $aggregate->resolve('app::home-page'), + ); + } + + /** @return array> */ + private function maps(): array + { + return [ + 'default' => [ + 'app::home-page' => self::ASSETS . '/default/app/home-page.phtml', + 'layout::default' => self::ASSETS . '/default/layout/default.phtml', + ], + 'acme' => [ + 'app::home-page' => self::ASSETS . '/acme/app/home-page.phtml', + 'layout::default' => self::ASSETS . '/acme/layout/default.phtml', + ], + ]; + } +} diff --git a/test/unit/ConfigProviderTest.php b/test/unit/ConfigProviderTest.php index 56e3611..c720d4c 100644 --- a/test/unit/ConfigProviderTest.php +++ b/test/unit/ConfigProviderTest.php @@ -14,25 +14,32 @@ namespace WebwareTest\Theme; +use Laminas\View\Resolver\AggregateResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Webware\Theme\ConfigProvider; +use Webware\Theme\Resolver\Container\AggregateResolverFactory; +use Webware\Theme\Resolver\Container\ThemeResolverFactory; +use Webware\Theme\Resolver\ThemeResolver; #[CoversClass(ConfigProvider::class)] #[CoversMethod(ConfigProvider::class, '__invoke')] final class ConfigProviderTest extends TestCase { #[Test] - public function providesAnEmptyDependencyFactoryMap(): void + public function providesTheResolverAndTheAggregateFactories(): void { $expected = [ 'dependencies' => [ - 'factories' => [], + 'factories' => [ + ThemeResolver::class => ThemeResolverFactory::class, + AggregateResolver::class => AggregateResolverFactory::class, + ], ], ]; - self::assertSame($expected, new ConfigProvider()->__invoke()); + static::assertSame($expected, new ConfigProvider()->__invoke()); } } diff --git a/test/unit/Resolver/ThemeResolverTest.php b/test/unit/Resolver/ThemeResolverTest.php new file mode 100644 index 0000000..cfb873c --- /dev/null +++ b/test/unit/Resolver/ThemeResolverTest.php @@ -0,0 +1,97 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTest\Theme\Resolver; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Webware\Theme\Resolver\ThemeResolver; + +#[CoversClass(ThemeResolver::class)] +#[CoversMethod(ThemeResolver::class, 'resolve')] +final class ThemeResolverTest extends TestCase +{ + /** @var array> */ + private const MAPS = [ + 'default' => [ + 'app::home-page' => '/app/templates/default/app/home-page.phtml', + 'layout::default' => '/app/templates/default/layout/default.phtml', + ], + 'acme' => [ + 'app::home-page' => '/app/templates/acme/app/home-page.phtml', + ], + ]; + + #[Test] + public function defaultsToTheDefaultThemeWhenNoneIsConfigured(): void + { + $resolver = new ThemeResolver(self::MAPS); + + static::assertSame( + '/app/templates/default/app/home-page.phtml', + $resolver->resolve('app::home-page'), + ); + } + + #[Test] + public function fallsBackToDefaultForAnAddressTheActiveThemeDoesNotCarry(): void + { + $resolver = new ThemeResolver(self::MAPS, 'acme'); + + static::assertSame( + '/app/templates/default/layout/default.phtml', + $resolver->resolve('layout::default'), + ); + } + + #[Test] + public function fallsBackToDefaultWhenTheActiveThemeHasNoMapAtAll(): void + { + $resolver = new ThemeResolver(self::MAPS, 'a-theme-nobody-shipped'); + + static::assertSame( + '/app/templates/default/app/home-page.phtml', + $resolver->resolve('app::home-page'), + ); + } + + #[Test] + public function resolvesAnAddressTheActiveThemeCarries(): void + { + $resolver = new ThemeResolver(self::MAPS, 'acme'); + + static::assertSame( + '/app/templates/acme/app/home-page.phtml', + $resolver->resolve('app::home-page'), + ); + } + + #[Test] + public function returnsFalseForAnAddressNoThemeCarries(): void + { + $resolver = new ThemeResolver(self::MAPS, 'acme'); + + static::assertFalse($resolver->resolve('admin::dashboard')); + } + + #[Test] + public function returnsFalseWhenNoMapsAreConfigured(): void + { + $resolver = new ThemeResolver([]); + + static::assertFalse($resolver->resolve('app::home-page')); + } +} From 25127ad385673813b3c473457adf5f28c4d39f36 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Thu, 1 Oct 2026 01:06:26 -0500 Subject: [PATCH 02/11] docs: record the prototype state, decisions and asset requirements Adds decisions.md (state, standing rules, decision log D-001 to D-019), brings spec, plan, tasks, data-model, research and quickstart in line with the built resolver, adds the asset helper, default theme and template port phases, and a handoff note. Signed-off-by: Joey Smith --- .github/instructions/memory.instructions.md | 41 +++++ specs/001-theme-resolution/data-model.md | 34 +++-- specs/001-theme-resolution/decisions.md | 101 +++++++++++++ specs/001-theme-resolution/plan.md | 57 ++++--- specs/001-theme-resolution/quickstart.md | 26 ++-- specs/001-theme-resolution/research.md | 64 ++++++-- specs/001-theme-resolution/spec.md | 118 ++++++++++++--- specs/001-theme-resolution/tasks.md | 157 +++++++++++++++----- 8 files changed, 492 insertions(+), 106 deletions(-) create mode 100644 .github/instructions/memory.instructions.md create mode 100644 specs/001-theme-resolution/decisions.md diff --git a/.github/instructions/memory.instructions.md b/.github/instructions/memory.instructions.md new file mode 100644 index 0000000..a64190d --- /dev/null +++ b/.github/instructions/memory.instructions.md @@ -0,0 +1,41 @@ +--- +description: Session handoff for webware-theme — theme resolver state, spec-kit location, and where the decision log and standing rules live, as of 2026-10-01. +applyTo: '**/*' +--- + +# Webware Theme Memory + +Tagline: Theme resolution by lookup, not by path stacking. The resolver is built; the asset helper, the +default theme and the template port are open. Everything needed to continue is in `specs/001-theme-resolution/`. + +## Start here + +1. `specs/001-theme-resolution/decisions.md` — current state, the owner's standing rules, the decision log + (D-001 onwards) with statuses, next actions, gotchas. **Read it first.** +2. `specs/001-theme-resolution/tasks.md` — what is done and what is left; the next free task ID is stated at + the top. +3. `spec.md` (requirements), `plan.md` (design, with a built-versus-planned table), `data-model.md` + (configuration shape), `research.md` (measurements), `quickstart.md`. + +Spec-kit, `.specify/` and `specs/` are tracked in this repository on purpose; do not propose removing them. + +## What exists + +- `Webware\Theme\Resolver\ThemeResolver` (final, `ResolverInterface`): `maps[active][name] ?? maps['default'][name] + ?? false`, no filesystem call. `ThemeResolverFactory` reads `theme.themes` and `theme.active`. + `AggregateResolverFactory` attaches theme at 100, `TemplateMapResolver` at 50, the namespaced path stack at 1. + `ConfigProvider` must be merged after `Mezzio\LaminasView\ConfigProvider`. +- The asset helper key is `view_helper_config.asset.resource_map` (not `view_manager.asset`). +- Assets are served from `/theme//{css,js,img,fonts}`, never from a bare `//` (`.htaccess` would let a + directory shadow a route). + +## Rules that bite + +- Every change goes through a pull request; never push to `1.0.x`. Releases and tags belong to the owner. +- Never delete, rename or move a file without approval. Never edit the IMS repository (read-only). +- Named arguments always; never change form field names. +- Never run application code while a debug session may be attached (`ss -ltnp | grep -E ':(9000|9003)'`). +- Check Mago gates by exit code, not by the last line of output. +- Commits: `git commit -s` as `Joey Smith `; merge commits by default. + +The full list is `decisions.md` section 2 and `vendor/webware/webware-tools/agent-working-agreements.md`. diff --git a/specs/001-theme-resolution/data-model.md b/specs/001-theme-resolution/data-model.md index d4da691..eeb91f4 100644 --- a/specs/001-theme-resolution/data-model.md +++ b/specs/001-theme-resolution/data-model.md @@ -13,7 +13,7 @@ Identity is the directory name. There is no registry and no per-theme metadata f | Field | Type | Rules | |---|---|---| | `name` | `non-empty-string` | A single path segment: no directory separators, no `..`, not empty. It is used as a directory name under `templates/`, so anything else is a path-escape (T002) | -| `root` | `non-empty-string` | Absolute path to the theme's directory in one module: `/templates/`. A theme usually has one root per module that ships or overrides templates | +| `root` | — | **Superseded (D-006):** there is no root field. A theme is a key in `theme.themes` (template overrides) and in `theme.assets` (asset values) | `default` is the reserved name: every module that ships templates ships `default`, and it is the fallback consulted after the active theme. It cannot collide with mezzio's internal unnamespaced bucket, which is @@ -52,7 +52,7 @@ within each module the active theme's directory before `default` — and address Per-template fallback falls out of the ordering: a template the active theme does not ship resolves from `default`, and the framework's own resolvers take anything not theme-owned at all. -Two things are decisions rather than open code: +Both questions below are **settled** (D-002, D-003). The list is kept so the alternatives stay on record: - **Module order** for a namespace served by more than one module — presumably the application's own module first. @@ -73,7 +73,7 @@ Two things are decisions rather than open code: point today; the key of a path list is the namespace, and its integer-keyed lists append in provider order, so the first-registered path wins a same-named file. -## Theme root +## Theme root *(superseded, D-006; kept for history)* A directory a theme resolves against in one module, plus where it came from. @@ -96,16 +96,17 @@ by controlling provider order. ``` theme ├── active # the theme name; absent means `default` -├── roots # list, ordered -└── templates # map