Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/v1/assets.md
Original file line number Diff line number Diff line change
@@ -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
<link rel="stylesheet" href="<?= $this->asset('theme.css') ?>">
```

## 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.<active>` over it.
3. For each value:
- starting with `https://`, `http://` or `//`: used unchanged;
- anything else: `/theme/<theme>/<value>`, with leading slashes on the value removed.

The `<theme>` 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/<theme>/css/...
public/theme/<theme>/js/...
public/theme/<theme>/img/...
public/theme/<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/<theme>/component/<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.
46 changes: 46 additions & 0 deletions docs/v1/configuration.md
Original file line number Diff line number Diff line change
@@ -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.<theme>.<address>` | absolute path | A template address the theme overrides. |
| `theme.assets.<theme>.<name>` | 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.
37 changes: 37 additions & 0 deletions docs/v1/installation.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions docs/v1/template-resolution.md
Original file line number Diff line number Diff line change
@@ -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/<theme>/<namespace>/<name>.phtml`.
2. List each overridden address in `theme.themes.<theme>` (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.
2 changes: 1 addition & 1 deletion specs/001-theme-resolution/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
16 changes: 8 additions & 8 deletions specs/001-theme-resolution/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<theme>.<name> => 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/<theme>/<value>` 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/<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)
Expand Down Expand Up @@ -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.<theme>` and resolve to `/theme/<theme>/component/<component>/…`; document the
Expand Down
38 changes: 38 additions & 0 deletions src/ConfigPath.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

/**
* This file is part of the Webware Theme package.
*
* Copyright (c) 2026 Joey Smith <jsmith@webinertia.net>
* 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<array-key, mixed>
*/
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 : [];
}
}
21 changes: 21 additions & 0 deletions src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,17 +34,31 @@
* @type ThemeConfig array{
* active?: non-empty-string,
* themes?: array<non-empty-string, array<non-empty-string, non-empty-string>>,
* assets?: array<non-empty-string, array<non-empty-string, non-empty-string>>,
* }
* @type DependenciesConfig array{
* factories: array<class-string, class-string>,
* }
* @type ProviderConfig array{
* dependencies: DependenciesConfig,
* view_helpers: array{factories: array<class-string, class-string>},
* }
* @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
{
Expand All @@ -59,6 +75,11 @@ public function __invoke(): array
{
return [
'dependencies' => $this->getDependencies(),
'view_helpers' => [
'factories' => [
Asset::class => AssetFactory::class,
],
],
];
}
}
24 changes: 24 additions & 0 deletions src/Exception/InvalidThemeNameException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

/**
* This file is part of the Webware Theme package.
*
* Copyright (c) 2026 Joey Smith <jsmith@webinertia.net>
* 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 {}
6 changes: 3 additions & 3 deletions src/Resolver/Container/ThemeResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<non-empty-string, array<non-empty-string, non-empty-string>> $maps */
$maps = is_array($maps) ? $maps : [];

/** @var mixed $active */
$active = $theme['active'] ?? null;
$active = $theme[ConfigProvider::ACTIVE] ?? null;

return new ThemeResolver(
$maps,
Expand Down
Loading