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/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',
+ ],
+ ],
+ ],
+ ];
+ }
+}