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/README.md b/README.md index 20d3d28..4d56e1c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # webware/webware-theme -Theme support for Webware applications: a theme is a directory under a component's -`templates/`, resolved by name through laminas-view, with per-template fallback to the -`default` theme that every component ships. +Theme support for Webware applications. A theme is a set of template and asset overrides; the active theme +is consulted first and `default` answers for everything it does not override. Template lookups are a +single array read, with no filesystem walk. [![PHP Version](https://img.shields.io/packagist/php-v/webware/webware-theme)](https://packagist.org/packages/webware/webware-theme) [![Latest Version](https://img.shields.io/packagist/v/webware/webware-theme)](https://packagist.org/packages/webware/webware-theme) @@ -11,45 +11,64 @@ Theme support for Webware applications: a theme is a directory under a component [![codecov](https://codecov.io/gh/webinertia/webware-theme/graph/badge.svg)](https://codecov.io/gh/webinertia/webware-theme) [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fwebinertia%2Fwebware-theme%2F1.0.x)](https://dashboard.stryker-mutator.io/reports/github.com/webinertia/webware-theme/1.0.x) -## The convention +## Quickstart -A theme is a directory named for the theme, directly inside a module's `templates/` directory, holding one -subdirectory per template namespace: +1. Install it and register the provider **after** `Mezzio\LaminasView\ConfigProvider`: -``` -src/App/templates/ -├── default/ # every module that ships templates ships this one -│ ├── app/home-page.phtml -> app::home-page.phtml -│ ├── layout/default.phtml -> layout::default -│ └── admin/dashboard.phtml -> admin::dashboard.phtml (a vendor template, overridden here) -└── acme/ # a theme, same layout of files - └── layout/default.phtml -> layout::default +```bash +composer require webware/webware-theme ``` -Addresses stay mezzio's namespaced ones — `::` — and the theme selects the first -directory segment, so it is never part of the address and a component's references to its own templates -survive a theme change. Resolution consults the paths a namespace is served from, the active theme's -directory before `default`, and falls back **per template**, so a theme that overrides one layout leaves -every other template coming from `default`. A module overrides another module's template by placing that -namespace under its own theme directory — which is how an application restyles a vendor template without -touching the package that ships it. - -That is the whole mechanism: a redesign is a directory of templates plus the assets that go with it, not -a fork of every package. Templates resolve from `templates/`; assets are served from `public/theme//` -(a theme's `templates/` directory is never a served location); nothing is compiled, published or watched. +```php +Mezzio\LaminasView\ConfigProvider::class, +Webware\Theme\ConfigProvider::class, +``` -## Installation +2. Describe a theme in any autoloaded config file, for example `config/autoload/theme.acme.global.php`: -```bash -composer require webware/webware-theme +```php +use Webware\Theme\ConfigProvider; + +return [ + ConfigProvider::THEME => [ + ConfigProvider::ACTIVE => 'acme', + ConfigProvider::THEMES => [ + 'acme' => [ + // only the addresses this theme overrides + 'layout::default' => __DIR__ . '/../../templates/acme/layout/default.phtml', + ], + ], + ConfigProvider::ASSETS => [ + 'acme' => ['theme.css' => 'css/acme.css'], + ], + ], +]; ``` -Register the `ConfigProvider` in your Mezzio application config aggregator: +3. Put the asset file at `public/theme/acme/css/acme.css`, and use it in a template: ```php -Webware\Theme\ConfigProvider::class, + ``` +`layout::default` now comes from the `acme` theme and every other address from `default`; +`asset('theme.css')` returns `/theme/acme/css/acme.css`, and a name `acme` does not define falls back to the +`default` theme's file. Anything not listed in a theme falls back to `default` per address and per name. +Addresses stay mezzio's namespaced ones (`::`) and never contain a theme name, so a +component's references to its own templates survive a theme change. + +## Documentation + +| Document | Contents | +|---|---| +| [Installation](docs/v1/installation.md) | Requirements, provider order, what the provider registers | +| [Configuration](docs/v1/configuration.md) | The `theme.active`, `theme.themes` and `theme.assets` keys and their rules | +| [Template resolution](docs/v1/template-resolution.md) | How `ThemeResolver` answers, and the resolver order | +| [Assets](docs/v1/assets.md) | The theme-aware `asset()` helper, URL layout, theme-name rules | + +Not built yet: publishing assets into `public/`, the command that creates and switches themes, and the +admin widget. They are specified in `specs/001-theme-resolution/`. + ## Design The design and its measurements live with the component, in `specs/001-theme-resolution/`: 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/docs/v1/assets.md b/docs/v1/assets.md new file mode 100644 index 0000000..ceb9187 --- /dev/null +++ b/docs/v1/assets.md @@ -0,0 +1,50 @@ +# Assets + +laminas-view's `asset()` helper maps a fixed name to a URL and throws on a name it does not know. It is +`final readonly` and has no idea of a theme, so this package replaces its **factory**, not the helper. +`Webware\Theme\View\Helper\Container\AssetFactory` is registered under `view_helpers.factories` and +returns the stock `Laminas\View\Helper\Asset`. + +```php + +``` + +## How the map is built + +1. Start from `view_helper_config.asset.resource_map`, so an application's existing entries keep working. +2. Apply `theme.assets.default`, then `theme.assets.` over it. +3. For each value: + - starting with `https://`, `http://` or `//`: used unchanged; + - anything else: `/theme//`, with leading slashes on the value removed. + +The `` in the URL is the theme that **defined** the value, so a name the active theme does not +define still points at the `default` theme's file. Fallback is per name. + +## Where the files go + +``` +public/theme//css/... +public/theme//js/... +public/theme//img/... +public/theme//fonts/... +``` + +The prefix `/theme/` is deliberate: the web server serves existing files and directories before it routes, +so a directory named after a theme at the web root could shadow a route of the same name. + +A component's own assets are planned per theme under `public/theme//component//` +(decision D-011). Publishing files into `public/` is the composer plugin installer's job +(`webinertia/project-tracking#6`) and is not part of this package; until it exists, place the files by +hand. + +## Theme names + +A theme name that becomes part of a URL must be a single path segment: it matches +`^[A-Za-z0-9][A-Za-z0-9_-]*$`. Anything else, for example `..`, `a/b` or `.hidden`, makes the helper's +factory throw `Webware\Theme\Exception\InvalidThemeNameException`. Both the `default` theme and the active +theme are checked. + +## Unknown names + +The stock helper throws `Laminas\View\Exception\InvalidArgumentException` for a name no theme defines. +That is intended: an asset a template asks for must exist. diff --git a/docs/v1/configuration.md b/docs/v1/configuration.md new file mode 100644 index 0000000..51e9561 --- /dev/null +++ b/docs/v1/configuration.md @@ -0,0 +1,46 @@ +# Configuration + +Everything sits under one key. The keys have constants on `Webware\Theme\ConfigProvider`. + +```php +use Webware\Theme\ConfigProvider; + +return [ + ConfigProvider::THEME => [ // 'theme' + ConfigProvider::ACTIVE => 'acme', // 'active' the theme to use; absent or empty means 'default' + ConfigProvider::THEMES => [ // 'themes' template overrides per theme + 'acme' => [ + 'layout::default' => __DIR__ . '/../../templates/acme/layout/default.phtml', + 'app::home-page.phtml' => __DIR__ . '/../../templates/acme/app/home-page.phtml', + ], + ], + ConfigProvider::ASSETS => [ // 'assets' asset values per theme + 'default' => ['theme.css' => 'css/theme.css'], + 'acme' => ['theme.css' => 'css/acme.css'], + ], + ], +]; +``` + +| Key | Type | Meaning | +|---|---|---| +| `theme.active` | non-empty string | The active theme. A missing or empty value means `default`. | +| `theme.themes..
` | absolute path | A template address the theme overrides. | +| `theme.assets..` | string | An asset value: a path relative to the theme's directory, or an absolute URL. | + +## Rules + +- Addresses are mezzio's namespaced ones (`namespace::name`) and never contain a theme name. +- Anything a theme does not list falls back to the `default` theme, address by address (templates) and + name by name (assets). +- A path in `theme.themes` is trusted: it is used as written and not checked on disk. +- Configuration merges with the usual config aggregator rules: string keys are later-wins, so an + application's autoload file overrides a package's defaults. +- The active theme is read once, when the factories run. Changing it means clearing the aggregated config + cache. + +## Where the keys are planned to live + +Per the decisions log (`specs/001-theme-resolution/decisions.md`, D-012 and D-020): a future command will write +each theme's `theme.themes` map to `config/autoload/theme.{theme-name}.global.php` and `theme.active` to +`config/autoload/theme.settings.global.php`. Until then, write them by hand in any autoload file. diff --git a/docs/v1/installation.md b/docs/v1/installation.md new file mode 100644 index 0000000..6b10f45 --- /dev/null +++ b/docs/v1/installation.md @@ -0,0 +1,37 @@ +# Installation + +```bash +composer require webware/webware-theme +``` + +Requires PHP `~8.4.1 || ~8.5.0`, `laminas/laminas-view` and `mezzio/mezzio-laminasviewrenderer`. + +## Register the config provider + +`Webware\Theme\ConfigProvider` is declared under `extra.laminas.config-provider`, so a config aggregator +that reads composer's providers picks it up. If you list providers by hand, put it +**after** `Mezzio\LaminasView\ConfigProvider`: + +```php +$aggregator = new ConfigAggregator([ + Mezzio\LaminasView\ConfigProvider::class, + Webware\Theme\ConfigProvider::class, // must come after + // ... +]); +``` + +Both providers define `Laminas\View\Resolver\AggregateResolver`, and the later one wins. This package's +factory attaches the theme resolver ahead of mezzio's own, which is the whole point of the package. + +## What the provider registers + +| Where | Entry | +|---|---| +| `dependencies.factories` | `ThemeResolver` and `AggregateResolver` (replacing mezzio's factory) | +| `view_helpers.factories` | `Laminas\View\Helper\Asset` built by `AssetFactory` | + +## Not part of this package yet + +Installing a theme's assets into `public/`, and the command and admin widget that create and switch +themes, are specified in `specs/001-theme-resolution/` (tasks T032 to T034 and T065 to T068) and are not +built. diff --git a/docs/v1/template-resolution.md b/docs/v1/template-resolution.md new file mode 100644 index 0000000..49c73ac --- /dev/null +++ b/docs/v1/template-resolution.md @@ -0,0 +1,37 @@ +# Template resolution + +`Webware\Theme\Resolver\ThemeResolver` is a `Laminas\View\Resolver\ResolverInterface`. It does one array +lookup and never touches the filesystem: + +``` +maps[active][address] ?? maps['default'][address] ?? false +``` + +Returning `false` lets the aggregate resolver continue to the next resolver. + +## Order + +`AggregateResolverFactory` builds the aggregate with the resolvers mezzio would have had, and attaches the +theme resolver in front: + +| Priority | Resolver | +|---|---| +| 100 | `ThemeResolver` | +| 50 | `Laminas\View\Resolver\TemplateMapResolver` (`templates.map`) | +| 1 | `Mezzio\LaminasView\NamespacedPathStackResolver` (`templates.paths`) | + +So a theme override wins, a component's own `templates.map` entry comes next, and its path stack is the +last resort. A component therefore keeps working with no theme configuration at all. + +## Writing a theme + +1. Put the theme's templates anywhere; the convention is `templates///.phtml`. +2. List each overridden address in `theme.themes.` (see [configuration](configuration.md)). +3. Set `theme.active`. + +Only the addresses you list change. For the rest, `default` is used. + +## What it is not + +- It does not stack directories or scan for files, so a missing file is not detected at resolution time. +- It does not select a theme per request. The active theme is configuration. diff --git a/specs/001-theme-resolution/data-model.md b/specs/001-theme-resolution/data-model.md index d4da691..d7fa5a2 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