From c73f3327980bc6c6781d337e12386770261d92df Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Thu, 1 Oct 2026 02:27:32 -0500 Subject: [PATCH 1/2] feat: theme-aware asset helper and config key constants AssetFactory builds the stock Asset helper from theme.assets, default first then the active theme, with per-name fallback. Relative values are served from /theme//, absolute URLs pass through, and theme names must be a single path segment. ConfigProvider gains THEME, ACTIVE, THEMES and ASSETS constants. Signed-off-by: Joey Smith --- specs/001-theme-resolution/decisions.md | 2 +- specs/001-theme-resolution/tasks.md | 16 +- src/ConfigPath.php | 38 ++++ src/ConfigProvider.php | 21 ++ src/Exception/InvalidThemeNameException.php | 24 +++ .../Container/ThemeResolverFactory.php | 6 +- src/View/Helper/Container/AssetFactory.php | 107 ++++++++++ test/integration/AssetHelperTest.php | 67 ++++++ test/unit/ConfigPathTest.php | 54 +++++ test/unit/ConfigProviderTest.php | 7 + .../Helper/Container/AssetFactoryTest.php | 194 ++++++++++++++++++ 11 files changed, 524 insertions(+), 12 deletions(-) create mode 100644 src/ConfigPath.php create mode 100644 src/Exception/InvalidThemeNameException.php create mode 100644 src/View/Helper/Container/AssetFactory.php create mode 100644 test/integration/AssetHelperTest.php create mode 100644 test/unit/ConfigPathTest.php create mode 100644 test/unit/View/Helper/Container/AssetFactoryTest.php diff --git a/specs/001-theme-resolution/decisions.md b/specs/001-theme-resolution/decisions.md index 8c43b3c..046d328 100644 --- a/specs/001-theme-resolution/decisions.md +++ b/specs/001-theme-resolution/decisions.md @@ -12,7 +12,7 @@ row and add a dated line; never delete a row. | Area | State | |---|---| | `ThemeResolver`, `ThemeResolverFactory`, `AggregateResolverFactory`, `ConfigProvider` | Built on `prototype/resolver` (commit `d1c05dc`). 7 unit tests, 5 integration tests. Mago `format`, `lint`, `analyze`, `guard` clean. Not merged: there is no PR for this branch yet. | -| Theme-aware `asset()` helper | Not built. Specified in `spec.md` FR-009 and `tasks.md` Phase 10. | +| Theme-aware `asset()` helper | Built on `feat/asset-helper` (stacked on `prototype/resolver`, Phase 10 T042–T046, T048; T047 docs open). `ConfigProvider` now has the key constants and registers `AssetFactory` under `view_helpers.factories`. | | Asset publishing (installer) | Not built. Requirements live in `webinertia/project-tracking#6` (the Webware component installer RFC). | | Default theme visuals | Mockup open as `webinertia/default-theme#1` (CSS tokens plus `mockup/index.html`). | | Port of webware-acl, webware-admin, webware-usermanager templates to a neutral `default` theme | Started: usermanager PR #69 open, acl moved on an uncommitted branch. See `tasks.md` Phase 12. | diff --git a/specs/001-theme-resolution/tasks.md b/specs/001-theme-resolution/tasks.md index 8376fdb..ce9fb4f 100644 --- a/specs/001-theme-resolution/tasks.md +++ b/specs/001-theme-resolution/tasks.md @@ -162,30 +162,30 @@ Everything else in webinertia/webware-htmx#21 is work the convention *enables* b Why: laminas-view's `Asset` helper is `final readonly` and is built once from `view_helper_config.asset.resource_map`, with no idea of a theme. The fix is a factory, not a subclass. -- [ ] T042 Define the asset configuration shape as an `@type ThemeAssetsConfig` in `ConfigProvider`: +- [X] T042 **Done:** the shape is `assets?` in the existing `@type ThemeConfig`, not a separate alias. Define the asset configuration shape as an `@type ThemeAssetsConfig` in `ConfigProvider`: `theme.assets.. => value`, where `value` is a path relative to that theme's directory (`css/theme.css`) or an absolute URL (`https://`, `//`). Names are a fixed vocabulary defined by the components; a theme re-values them -- [ ] T043 Implement `Webware\Theme\View\Helper\Container\AssetFactory` (`final`, returns a stock +- [X] T043 **Done.** Also adds `Webware\Theme\ConfigPath` (nested-array reader) and `Exception\InvalidThemeNameException`; entries already under `view_helper_config.asset.resource_map` are kept as the base; only the `default` and the active theme names are validated (`^[A-Za-z0-9][A-Za-z0-9_-]*$`). Implement `Webware\Theme\View\Helper\Container\AssetFactory` (`final`, returns a stock `Laminas\View\Helper\Asset`). Merge `default` first, then the active theme over it. A relative value becomes `/theme//` using the theme that **defined** the name, so fallback is per name; absolute URLs pass through unchanged. Validate every theme name as a single path segment (no `/`, `\`, `..`, empty) and throw a package exception otherwise. Never throw for an unknown name here: the stock `Asset` does that at call time, which is wanted -- [ ] T044 Register it for `Laminas\View\Helper\Asset::class` in the **view helper** factories, not the +- [X] T044 **Done:** `view_helpers.factories` (verified against `laminas-view`'s `ConfigProvider`, which has `view_helpers` as the top-level plugin-manager key). Register it for `Laminas\View\Helper\Asset::class` in the **view helper** factories, not the container's `dependencies`. Verify the exact config key in `laminas-view/src/ConfigProvider.php` before writing it (`view_helper_config` holds helper options; the plugin-manager key is a different one). Merge order matters: this provider must come after `Mezzio\LaminasView\ConfigProvider` (D-004, `project-tracking#6`) -- [ ] T045 Unit tests for the factory: active theme wins; per-name fallback to `default`; absolute URL +- [X] T045 **Done:** `test/unit/View/Helper/Container/AssetFactoryTest.php`. Unit tests for the factory: active theme wins; per-name fallback to `default`; absolute URL unchanged; unknown name throws from the helper; a traversal theme name is rejected; an empty config yields a helper whose every call throws -- [ ] T046 Integration test through a real `HelperPluginManager` (or the `ServiceManager` the other +- [X] T046 **Done:** `test/integration/AssetHelperTest.php`. Integration test through a real `HelperPluginManager` (or the `ServiceManager` the other integration tests use) that `asset('theme.css')` returns `/theme/default/css/theme.css`, then the active theme's value after switching `theme.active` - [ ] T047 Document in `quickstart.md` how a theme author adds an asset and where the file goes (`public/theme//…`, published by the installer, never hand-written) -- [ ] T048 Record the final key paths in `data-model.md` and `research.md` (they currently describe +- [X] T048 **Done earlier** (`data-model.md`, `research.md` carry `view_helper_config.asset.resource_map`). Record the final key paths in `data-model.md` and `research.md` (they currently describe `view_manager.asset.resource_map`, which is wrong, D-009) ## Phase 11 — The default theme's assets (repository `webinertia/default-theme`; D-015, D-016) @@ -243,10 +243,10 @@ none lives in a component (standing rule 6). ## Phase 14 — Owner decisions of 2026-10-01 (D-005, D-011, D-012) -- [ ] T062 Add constants for the known config keys in `ConfigProvider` (`THEME = 'theme'`, `ACTIVE`, +- [X] T062 **Done:** `ConfigProvider::THEME`, `ACTIVE`, `THEMES`, `ASSETS`, used by both factories. Add constants for the known config keys in `ConfigProvider` (`THEME = 'theme'`, `ACTIVE`, `THEMES`, `ASSETS`) and use them in `ThemeResolverFactory` and the tests instead of the string literals (D-005). No new classes -- [ ] T063 Validate a theme name as a single path segment (no separators, no `..`) at the one place it +- [X] T063 **Done** in `AssetFactory` (the one place a name becomes a path today); the command (T066) must reuse the same rule. Validate a theme name as a single path segment (no separators, no `..`) at the one place it becomes a path, the asset factory (T043); test it. Do not add a value object (D-005) - [ ] T064 Component assets are per theme (D-011): in T042–T043 the asset values for a component live under `theme.assets.` and resolve to `/theme//component//…`; document the diff --git a/src/ConfigPath.php b/src/ConfigPath.php new file mode 100644 index 0000000..4aa5e5c --- /dev/null +++ b/src/ConfigPath.php @@ -0,0 +1,38 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Theme; + +use function is_array; + +/** + * Reads a nested array out of merged configuration, treating anything that is not an array as absent. + * + * @internal + */ +final class ConfigPath +{ + /** + * @return array + */ + public static function array(mixed $source, string ...$keys): array + { + foreach ($keys as $key) { + /** @var mixed $source */ + $source = is_array($source) ? $source[$key] ?? null : null; + } + + return is_array($source) ? $source : []; + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index 91dc1ab..d97e13c 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -14,10 +14,12 @@ namespace Webware\Theme; +use Laminas\View\Helper\Asset; use Laminas\View\Resolver\AggregateResolver; use Webware\Theme\Resolver\Container\AggregateResolverFactory; use Webware\Theme\Resolver\Container\ThemeResolverFactory; use Webware\Theme\Resolver\ThemeResolver; +use Webware\Theme\View\Helper\Container\AssetFactory; /** * Wiring entry point for the package. @@ -32,17 +34,31 @@ * @type ThemeConfig array{ * active?: non-empty-string, * themes?: array>, + * assets?: array>, * } * @type DependenciesConfig array{ * factories: array, * } * @type ProviderConfig array{ * dependencies: DependenciesConfig, + * view_helpers: array{factories: array}, * } * @internal */ final class ConfigProvider { + /** The top-level configuration key every theme setting sits under. */ + final public const string THEME = 'theme'; + + /** The name of the active theme, under {@see self::THEME}. */ + final public const string ACTIVE = 'active'; + + /** Template overrides per theme, under {@see self::THEME}. */ + final public const string THEMES = 'themes'; + + /** Asset values per theme, under {@see self::THEME}. */ + final public const string ASSETS = 'assets'; + /** @return DependenciesConfig */ public function getDependencies(): array { @@ -59,6 +75,11 @@ public function __invoke(): array { return [ 'dependencies' => $this->getDependencies(), + 'view_helpers' => [ + 'factories' => [ + Asset::class => AssetFactory::class, + ], + ], ]; } } diff --git a/src/Exception/InvalidThemeNameException.php b/src/Exception/InvalidThemeNameException.php new file mode 100644 index 0000000..ae9d73a --- /dev/null +++ b/src/Exception/InvalidThemeNameException.php @@ -0,0 +1,24 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Theme\Exception; + +use InvalidArgumentException; + +/** + * A theme name that is not a single path segment, so it could not be used to build a safe URL. + * + * @internal + */ +final class InvalidThemeNameException extends InvalidArgumentException {} diff --git a/src/Resolver/Container/ThemeResolverFactory.php b/src/Resolver/Container/ThemeResolverFactory.php index a29d27e..2af2088 100644 --- a/src/Resolver/Container/ThemeResolverFactory.php +++ b/src/Resolver/Container/ThemeResolverFactory.php @@ -47,19 +47,19 @@ public function __invoke(ContainerInterface $container): ThemeResolver $config = is_array($raw) ? $raw : []; /** @var mixed $theme */ - $theme = $config['theme'] ?? []; + $theme = $config[ConfigProvider::THEME] ?? []; /** @var ThemeConfig $theme */ $theme = is_array($theme) ? $theme : []; /** @var mixed $maps */ - $maps = $theme['themes'] ?? []; + $maps = $theme[ConfigProvider::THEMES] ?? []; /** @var array> $maps */ $maps = is_array($maps) ? $maps : []; /** @var mixed $active */ - $active = $theme['active'] ?? null; + $active = $theme[ConfigProvider::ACTIVE] ?? null; return new ThemeResolver( $maps, diff --git a/src/View/Helper/Container/AssetFactory.php b/src/View/Helper/Container/AssetFactory.php new file mode 100644 index 0000000..61f15d9 --- /dev/null +++ b/src/View/Helper/Container/AssetFactory.php @@ -0,0 +1,107 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Theme\View\Helper\Container; + +use Laminas\View\Helper\Asset; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Webware\Theme\ConfigPath; +use Webware\Theme\ConfigProvider; +use Webware\Theme\Exception\InvalidThemeNameException; +use Webware\Theme\Resolver\ThemeResolver; + +use function is_string; +use function ltrim; +use function preg_match; +use function sprintf; +use function str_starts_with; + +/** + * Builds laminas-view's stock `Asset` helper from a map the active theme has been merged into. + * + * `Asset` is `final readonly` and knows nothing about themes, so the theme is applied here, once: the + * `default` theme's values first, the active theme's over them. A relative value becomes + * `/theme//` using the theme that defined the name, so the fallback is per asset name. + * Entries already under laminas-view's own `view_helper_config.asset.resource_map` are the base, so an + * application's existing map keeps working. + * + * @internal + */ +final class AssetFactory +{ + private const string URL_PREFIX = '/theme/'; + + /** @throws InvalidThemeNameException */ + private static function assertSegment(string $name): void + { + if (1 !== preg_match('/^[A-Za-z0-9][A-Za-z0-9_-]*$/', $name)) { + throw new InvalidThemeNameException(sprintf( + 'The theme name "%s" is not a single path segment.', + $name, + )); + } + } + + private static function url(string $theme, string $value): string + { + return match (true) { + str_starts_with($value, 'https://'), + str_starts_with($value, 'http://'), + str_starts_with($value, '//'), + => $value, + default => self::URL_PREFIX + . $theme + . '/' + . ltrim($value, characters: '/'), + }; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws InvalidThemeNameException + */ + public function __invoke(ContainerInterface $container): Asset + { + /** @var mixed $config */ + $config = $container->has('config') ? $container->get('config') : []; + $theme = ConfigPath::array($config, ConfigProvider::THEME); + $assets = ConfigPath::array($theme, ConfigProvider::ASSETS); + + /** @var mixed $active */ + $active = $theme[ConfigProvider::ACTIVE] ?? null; + $active = is_string($active) && '' !== $active ? $active : ThemeResolver::DEFAULT_THEME; + + $map = ConfigPath::array($config, 'view_helper_config', 'asset', 'resource_map'); + + foreach ([ThemeResolver::DEFAULT_THEME, $active] as $name) { + self::assertSegment(name: $name); + + /** @var array $values */ + $values = ConfigPath::array($assets, $name); + + foreach ($values as $asset => $value) { + $map[$asset] = self::url( + theme: $name, + value: $value, + ); + } + } + + /** @var array $map */ + return new Asset($map); + } +} diff --git a/test/integration/AssetHelperTest.php b/test/integration/AssetHelperTest.php new file mode 100644 index 0000000..59f2984 --- /dev/null +++ b/test/integration/AssetHelperTest.php @@ -0,0 +1,67 @@ + + * 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\ServiceManager\ServiceManager; +use Laminas\View\Helper\Asset; +use Laminas\View\HelperPluginManager; +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\View\Helper\Container\AssetFactory; + +/** + * The asset helper is resolved the way a view does, through the real plugin manager configured by this + * package's own provider, and switching the active theme changes what a name resolves to. + */ +#[CoversClass(AssetFactory::class)] +#[CoversMethod(AssetFactory::class, '__invoke')] +final class AssetHelperTest extends TestCase +{ + #[Test] + public function switchingTheActiveThemeChangesTheValue(): void + { + static::assertSame('/theme/acme/css/acme.css', $this->helper('acme')('theme.css')); + } + + #[Test] + public function theDefaultThemeServesTheDefaultValue(): void + { + static::assertSame('/theme/default/css/theme.css', $this->helper('default')('theme.css')); + } + + private function helper(string $active): Asset + { + $container = new ServiceManager(); + $container->setService('config', [ + ConfigProvider::THEME => [ + ConfigProvider::ACTIVE => $active, + ConfigProvider::ASSETS => [ + 'default' => ['theme.css' => 'css/theme.css'], + 'acme' => ['theme.css' => 'css/acme.css'], + ], + ], + ]); + + $plugins = new HelperPluginManager($container, new ConfigProvider()->__invoke()['view_helpers']); + + $asset = $plugins->get('asset'); + static::assertInstanceOf(Asset::class, $asset); + + return $asset; + } +} diff --git a/test/unit/ConfigPathTest.php b/test/unit/ConfigPathTest.php new file mode 100644 index 0000000..d4bd094 --- /dev/null +++ b/test/unit/ConfigPathTest.php @@ -0,0 +1,54 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTest\Theme; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Webware\Theme\ConfigPath; + +#[CoversClass(ConfigPath::class)] +#[CoversMethod(ConfigPath::class, 'array')] +final class ConfigPathTest extends TestCase +{ + #[Test] + public function aMissingKeyIsAnEmptyArray(): void + { + static::assertSame([], ConfigPath::array(['a' => []], 'a', 'missing')); + } + + #[Test] + public function aNonArrayAnywhereOnThePathIsAnEmptyArray(): void + { + static::assertSame([], ConfigPath::array(['a' => 'x'], 'a', 'b')); + static::assertSame([], ConfigPath::array('not an array', 'a')); + static::assertSame([], ConfigPath::array(['a' => 'x'], 'a')); + } + + #[Test] + public function returnsTheNestedArrayAtThePath(): void + { + $config = ['a' => ['b' => ['c' => 1]]]; + + static::assertSame(['c' => 1], ConfigPath::array($config, 'a', 'b')); + } + + #[Test] + public function withNoKeysTheSourceArrayIsReturned(): void + { + static::assertSame(['a' => 1], ConfigPath::array(['a' => 1])); + } +} diff --git a/test/unit/ConfigProviderTest.php b/test/unit/ConfigProviderTest.php index f0d20fb..6a5ee2f 100644 --- a/test/unit/ConfigProviderTest.php +++ b/test/unit/ConfigProviderTest.php @@ -14,6 +14,7 @@ namespace WebwareTest\Theme; +use Laminas\View\Helper\Asset; use Laminas\View\Resolver\AggregateResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversMethod; @@ -23,6 +24,7 @@ use Webware\Theme\Resolver\Container\AggregateResolverFactory; use Webware\Theme\Resolver\Container\ThemeResolverFactory; use Webware\Theme\Resolver\ThemeResolver; +use Webware\Theme\View\Helper\Container\AssetFactory; #[CoversClass(ConfigProvider::class)] #[CoversMethod(ConfigProvider::class, '__invoke')] @@ -52,6 +54,11 @@ public function providesTheResolverAndTheAggregateFactories(): void AggregateResolver::class => AggregateResolverFactory::class, ], ], + 'view_helpers' => [ + 'factories' => [ + Asset::class => AssetFactory::class, + ], + ], ]; static::assertSame($expected, new ConfigProvider()->__invoke()); diff --git a/test/unit/View/Helper/Container/AssetFactoryTest.php b/test/unit/View/Helper/Container/AssetFactoryTest.php new file mode 100644 index 0000000..8ff7b64 --- /dev/null +++ b/test/unit/View/Helper/Container/AssetFactoryTest.php @@ -0,0 +1,194 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTest\Theme\View\Helper\Container; + +use Laminas\View\Exception\InvalidArgumentException; +use Laminas\View\Helper\Asset; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Webware\Theme\Exception\InvalidThemeNameException; +use Webware\Theme\View\Helper\Container\AssetFactory; + +#[CoversClass(AssetFactory::class)] +#[CoversMethod(AssetFactory::class, '__invoke')] +final class AssetFactoryTest extends TestCase +{ + /** @return iterable */ + public static function absoluteUrls(): iterable + { + yield 'https' => ['bootstrap', 'https://cdn.example.com/bootstrap.css']; + yield 'http' => ['insecure', 'http://cdn.example.com/y.js']; + yield 'protocol relative' => ['protocol', '//cdn.example.com/x.js']; + } + + /** @return iterable */ + public static function badThemeNames(): iterable + { + yield 'traversal' => ['..']; + yield 'nested' => ['a/b']; + yield 'backslash' => ['a\\b']; + yield 'leading dot' => ['.hidden']; + yield 'space' => ['a b']; + } + + #[Test] + public function aLeadingSlashOnARelativeValueDoesNotEscapeTheThemeDirectory(): void + { + $asset = $this->build($this->config('default')); + + static::assertSame('/theme/default/css/leading.css', $asset('leading')); + } + + #[Test] + #[DataProvider('absoluteUrls')] + public function anAbsoluteUrlPassesThroughUnchanged(string $name, string $expected): void + { + $asset = $this->build($this->config('acme')); + + static::assertSame($expected, $asset($name)); + } + + #[Test] + public function aNameTheActiveThemeLacksFallsBackToDefaultByName(): void + { + $asset = $this->build($this->config('acme')); + + static::assertSame('/theme/default/js/app.js', $asset('app.js')); + } + + #[Test] + public function anEmptyActiveNameMeansDefault(): void + { + $asset = $this->build($this->config('')); + + static::assertSame('/theme/default/css/theme.css', $asset('theme.css')); + } + + #[Test] + public function anUnknownNameThrowsFromTheHelper(): void + { + $asset = $this->build($this->config('acme')); + + $this->expectException(InvalidArgumentException::class); + + $asset('missing.css'); + } + + #[Test] + #[DataProvider('badThemeNames')] + public function aThemeNameThatIsNotASingleSegmentIsRejected(string $name): void + { + $this->expectException(InvalidThemeNameException::class); + + $this->build($this->config($name)); + } + + #[Test] + public function entriesUnderLaminasViewsOwnKeyAreKept(): void + { + $config = $this->config('acme'); + $config['view_helper_config'] = ['asset' => ['resource_map' => ['legacy.css' => '/legacy.css']]]; + + $asset = $this->build($config); + + static::assertSame('/legacy.css', $asset('legacy.css')); + } + + #[Test] + public function malformedAssetEntriesYieldAnEmptyMap(): void + { + $asset = $this->build([ + 'theme' => ['assets' => 'x'], + 'view_helper_config' => ['asset' => 'y'], + ]); + + $this->expectException(InvalidArgumentException::class); + + $asset('theme.css'); + } + + #[Test] + public function malformedThemeConfigurationYieldsAnEmptyMap(): void + { + $asset = $this->build(['theme' => 'x', 'view_helper_config' => 'y']); + + $this->expectException(InvalidArgumentException::class); + + $asset('theme.css'); + } + + #[Test] + public function theActiveThemeWinsANameItDefines(): void + { + $asset = $this->build($this->config('acme')); + + static::assertSame('/theme/acme/css/acme.css', $asset('theme.css')); + } + + #[Test] + public function theDefaultThemeValuesAreServedFromItsOwnDirectory(): void + { + $asset = $this->build($this->config('default')); + + static::assertSame('/theme/default/css/theme.css', $asset('theme.css')); + } + + #[Test] + public function withoutConfigurationEveryNameThrowsFromTheHelper(): void + { + $asset = $this->build([], hasConfig: false); + + $this->expectException(InvalidArgumentException::class); + + $asset('theme.css'); + } + + /** @param array $config */ + private function build(array $config, bool $hasConfig = true): Asset + { + $container = $this->createStub(ContainerInterface::class); + $container->method('has')->willReturn($hasConfig); + $container->method('get')->willReturn($config); + + return new AssetFactory()->__invoke($container); + } + + /** @return array */ + private function config(string $active): array + { + return [ + 'theme' => [ + 'active' => $active, + 'assets' => [ + 'default' => [ + 'theme.css' => 'css/theme.css', + 'app.js' => 'js/app.js', + 'bootstrap' => 'https://cdn.example.com/bootstrap.css', + 'protocol' => '//cdn.example.com/x.js', + 'insecure' => 'http://cdn.example.com/y.js', + 'leading' => '/css/leading.css', + ], + 'acme' => [ + 'theme.css' => 'css/acme.css', + ], + ], + ], + ]; + } +} From 418bfd107477b6b742e801ba0258fb3044fb18bc Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Thu, 1 Oct 2026 02:31:51 -0500 Subject: [PATCH 2/2] docs: document the resolver, configuration and asset helper in docs/v1 Signed-off-by: Joey Smith --- docs/v1/assets.md | 50 ++++++++++++++++++++++++++++++++++ docs/v1/configuration.md | 46 +++++++++++++++++++++++++++++++ docs/v1/installation.md | 37 +++++++++++++++++++++++++ docs/v1/template-resolution.md | 37 +++++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 docs/v1/assets.md create mode 100644 docs/v1/configuration.md create mode 100644 docs/v1/installation.md create mode 100644 docs/v1/template-resolution.md 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.