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
108 changes: 108 additions & 0 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,114 @@ To replace a bundled package:

---

## Engine-Specific Template Siblings

### The Problem

UI packages that ship templates must pick a template engine — and that choice locks every consumer into that engine. Naive alternatives don't scale:

- **Bundle all engines in one package**: One package grows indefinitely, ships code most users never need, and every engine update touches the same repo.
- **Subdirectory-per-engine**: Still one install, still one dependency graph, and the "which templates does my engine actually use?" question becomes implicit rather than explicit.

The framework needs a first-class answer.

### The Pattern

Split UI packages into two layers:

- **`marko/{module}`** — PHP code only (controllers, services, routes). Engine-agnostic. Ships no templates.
- **`marko/{module}-{engine}`** — Templates only, one package per supported engine. For example: `marko/admin-panel-latte`, `marko/admin-panel-twig`.

Engine siblings declare their relationship in `composer.json`:

```json
{
"name": "marko/admin-panel-latte",
"extra": {
"marko": {
"templates_for": "marko/admin-panel"
}
},
"require": {
"marko/admin-panel": "*",
"marko/view-latte": "*"
}
}
```

The `extra.marko.templates_for` key is the canonical signal. `ModuleTemplateResolver` reads it during boot to build the resolution map.

Engine siblings do **not** need Composer `conflict` declarations against each other. Installing both `marko/admin-panel-latte` and `marko/admin-panel-twig` is harmless — the resolver routes to the right templates based on the configured view extension, and Marko's DI-level detection handles any actual interface conflicts at the driver layer.

### How Resolution Works

`ModuleTemplateResolver` searches two locations when a controller asks for a template:

1. The parent module's own `resources/views/` directory.
2. Any installed module that declares `templates_for: marko/{parent}`.

Controllers reference templates using the parent module's namespace regardless of which engine sibling is installed:

```php
// In marko/admin-panel — works whether latte or twig sibling is installed
return $this->view->render('admin-panel::dashboard/index');
```

The abstraction is preserved. Controllers never know which engine is in use.

### When to Use This Pattern

Use the engine-sibling pattern for **reusable, shipped UI modules** where the maintainer cannot predict the consumer's engine choice.

Good candidates:

- `marko/admin-panel` — ships an admin UI usable by any Marko application
- Future admin dashboards, form builders, debug bars
- Any package intended for Packagist where consumers are unknown

The test is simple: *could this package end up in 10 different applications that each chose a different template engine?* If yes, use the sibling pattern.

### When NOT to Use This Pattern

**application-specific modules should hard-depend on their engine and ship templates directly.**

```
app/blog/ — your team chose Latte; use Latte, ship .latte files, done
app/admin/ — same project, same engine choice, same answer
```

Multi-engine packaging is overhead with zero benefit when there is only one consumer and that consumer has already chosen an engine. Introducing siblings here adds Composer packages, CI pipelines, and template translation work for no practical gain.

Rule of thumb: if the module lives in `app/` or `modules/`, don't use the sibling pattern.

### Trade-offs

**Costs:**

- Each engine sibling is a separate Composer package to maintain (its own `composer.json`, tests, release cycle).
- Adopting a new core template engine requires writing template translations for every existing UI module before the new engine is usable with shipped UI packages.

**Benefits:**

- Genuine engine choice for consumers — they install the sibling that matches their stack.
- Upholds the framework principle of "explicit over implicit": the dependency on a specific engine lives in the sibling, not buried inside the core UI module.

**Realistic scale:**

The mainstream PHP template engine ecosystem is small. Expect 2–3 engines (Twig, Latte, and possibly Blade) as a realistic ceiling. Beyond that, the maintenance math becomes a liability. The `CrossEngineTemplateParityTest` makes this concrete — it is a real commitment, not a suggestion.

### The Parity Test

`marko/view` ships `CrossEngineTemplateParityTest`, a mechanical build gate that enforces template parity across all registered core engines.

**What it does:** For every UI module that ships an engine sibling for *any* core engine, the test asserts that siblings exist for *all* core engines. Adopting a new engine without translating templates for every existing UI module fails the build.

**Why this matters:** Parity is not optional. A consumer choosing Twig must be able to install every shipped UI module, not just the ones someone happened to translate. The test turns "we should do this" into "we must do this before merging."

Contributors adding a new core engine driver should expect to write sibling templates for all existing UI modules — the test will tell them exactly which ones are missing.

---

## Dependency Injection

### Constructor Injection Everywhere
Expand Down
48 changes: 48 additions & 0 deletions .claude/plans/admin-panel-engine-siblings/001-add-known-engines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Task 001: Add known-engines.php to marko/view

**Status**: completed
**Depends on**: none
**Retry count**: 0

## Description
Create `packages/view/known-engines.php` — the source of truth listing every core view engine the framework promises to maintain template parity for. Read by `CrossEngineTemplateParityTest` (task 010) to enforce that every template provider package has siblings for every registered engine.

## Context
- New file: `packages/view/known-engines.php`
- New test file: `packages/view/tests/KnownEnginesTest.php`
- Format: keyed by short name; value contains extension and driver package
- This is distinct from `known-drivers.php` (PR #91 — lists installable driver packages). `known-engines.php` is the parity-test registry of core engines whose templates must be kept in sync across UI modules.

**File contents:**
```php
<?php

declare(strict_types=1);

return [
'twig' => [
'extension' => '.twig',
'driver' => 'marko/view-twig',
],
'latte' => [
'extension' => '.latte',
'driver' => 'marko/view-latte',
],
];
```

Ordering: Twig first (broader ecosystem familiarity — matches the established Marko default).

## Requirements (Test Descriptions)
- [ ] `it ships a known-engines.php file in marko/view`
- [ ] `it registers twig with extension .twig and driver marko/view-twig`
- [ ] `it registers latte with extension .latte and driver marko/view-latte`
- [ ] `it lists twig first as the recommended engine`
- [ ] `it returns an array keyed by short engine name with extension and driver fields`
- [ ] `it uses declare strict_types`

## Acceptance Criteria
- `packages/view/known-engines.php` exists with the specified contents
- File returns an array; every entry has `extension` and `driver` keys
- Test file verifies all requirements
- Code follows code standards (strict_types, no magic methods)
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Task 002: Enhance ModuleTemplateResolver to honor templates_for metadata

**Status**: completed
**Depends on**: none
**Retry count**: 0

## Description
Extend `Marko\View\ModuleTemplateResolver::getSearchedPaths()` to ALSO search modules declaring `extra.marko.templates_for` in their composer.json. When resolving `admin-panel::path`, the resolver currently finds only modules whose basename equals `admin-panel`. After this change, it also finds modules declaring `templates_for: marko/admin-panel`. This is the load-bearing change that enables engine-sibling packages to provide templates for their parent module.

## Context
- Files to modify:
- `packages/view/src/ModuleTemplateResolver.php` — add second match path in `getSearchedPaths()`
- `packages/view/tests/ModuleTemplateResolverTest.php` — extend tests
- **Cross-package change required (marko/core):** `ModuleManifest` currently does NOT expose `extra` from composer.json. The resolver needs this metadata. This task must extend the manifest pipeline:
- `packages/core/src/Module/ModuleManifest.php` — add `public array $extra = []` constructor-promoted property (typed `array<string, mixed>`)
- `packages/core/src/Module/ManifestParser.php::parse()` — pass `extra: $composerData['extra'] ?? []` into the manifest
- `packages/core/src/Module/ModuleDiscovery.php::withPathAndSource()` — include `extra: $manifest->extra` when reconstructing the manifest (otherwise the field is dropped after discovery)
- `packages/core/tests/Unit/Module/ManifestParserTest.php` (if exists) and `packages/core/tests/Unit/Module/ModuleDiscoveryTest.php` — extend to cover the new `extra` field
- `ModuleRepositoryInterface::all()` returns `ModuleManifest[]` — after the change above, callers can read `$module->extra` directly.

**Resolver logic shape (after change):**
```php
public function getSearchedPaths(string $template): array
{
[$moduleName, $templatePath] = $this->parseTemplate($template);
$extension = $this->viewConfig->extension();

$paths = [];

foreach ($this->moduleRepository->all() as $module) {
// Existing match: module basename equals requested name
if ($this->matchesModuleName($module->name, $moduleName)) {
$paths[] = $module->path . '/resources/views/' . $templatePath . $extension;
continue;
}

// New match: module declares templates_for for the requested module
if ($this->matchesTemplatesFor($module, $moduleName)) {
$paths[] = $module->path . '/resources/views/' . $templatePath . $extension;
}
}

return $paths;
}

private function matchesTemplatesFor(ModuleManifest $module, string $shortModuleName): bool
{
$templatesFor = $module->extra['marko']['templates_for'] ?? null;
if (!is_string($templatesFor)) {
return false;
}

// templates_for is a full package name like "marko/admin-panel" — match by basename
return $this->matchesModuleName($templatesFor, $shortModuleName);
}
```

**Ordering matters:** The parent module is checked first (`matchesModuleName`). If found, its path is added first. This means if `marko/admin-panel` itself ever ships templates again in the future, those take priority over sibling-provider templates — matching Marko's "later directories win" override semantics for templates within the same family.

## Requirements (Test Descriptions)
- [ ] `ModuleManifest exposes the extra array from composer.json`
- [ ] `ManifestParser populates the extra field from composer.json data`
- [ ] `ModuleDiscovery preserves the extra field when setting path and source`
- [ ] `ModuleManifest defaults extra to an empty array when not specified`
- [ ] `it resolves a template via the parent module name match (existing behavior preserved)`
- [ ] `it resolves a template via a templates_for declaration on a sibling module`
- [ ] `it includes both the parent and the sibling paths in getSearchedPaths when both exist`
- [ ] `it puts the parent path before the sibling path in the search order`
- [ ] `it ignores modules without a templates_for declaration when looking for sibling providers`
- [ ] `it does not throw when a module's extra.marko key is missing entirely`
- [ ] `it does not throw when templates_for is present but not a string`
- [ ] `it matches templates_for by basename (marko/admin-panel matches admin-panel::path)`

## Acceptance Criteria
- `ModuleManifest::$extra` is a new public readonly array property; defaults to `[]`
- `ManifestParser` populates `extra` from composer.json
- `ModuleDiscovery::withPathAndSource()` preserves `extra` when reconstructing manifests
- `ModuleTemplateResolver` finds templates from both parent modules and `templates_for` siblings
- Existing template resolution behavior preserved — all existing tests pass without modification (including the existing `ModuleManifest` constructor call sites in tests, since the new `extra` parameter is defaulted)
- Module metadata (specifically `extra.marko.templates_for`) is accessible from the resolver via the module repository
- New tests cover the templates_for resolution path
- Code follows code standards (strict_types, typed params/returns, `@throws` on methods that throw, readonly class preserved)
- No final classes; `ModuleManifest` remains readonly
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Task 003: Document engine-sibling pattern in architecture.md

**Status**: completed
**Depends on**: none
**Retry count**: 0

## Description
Add a new section to `.claude/architecture.md` documenting the `marko/{module}-{engine}` engine-sibling pattern as the canonical approach for UI packages that want multi-engine support. Explains the `templates_for` composer-extra key, when to use the pattern (reusable UI packages) vs. when not to (single-application modules), and the trade-offs.

## Context
- File to modify: `.claude/architecture.md`
- Section placement: after the existing "Package Architecture" section, before "Dependency Injection". The new section is conceptually about package structure, so it sits with other package-level architecture documentation.
- New section title: "Engine-Specific Template Siblings"

**Content outline (write in the architecture.md voice — opinionated, instructive, with examples):**

1. **The problem.** UI packages that ship templates must pick a template engine — locking consumers into that engine. Naive solutions (shipping templates for all engines in one package, ship N versions in subdirectories) don't scale.

2. **The pattern.** Split UI packages into:
- `marko/{module}` — PHP code only (controllers, services, routes). Engine-agnostic.
- `marko/{module}-{engine}` — templates only, one per supported engine (e.g., `marko/admin-panel-latte`, `marko/admin-panel-twig`)
- Engine siblings declare `extra.marko.templates_for: marko/{module}` in composer.json
- Siblings `require` their corresponding view driver
- Siblings do NOT need Composer `conflict` declarations — both being installed is harmless (the resolver routes to the right templates based on `view.extension`), and Marko's DI-level detection handles any actual interface conflicts at the driver layer

3. **How resolution works.** `ModuleTemplateResolver` searches both the parent module's `resources/views/` AND any module declaring `templates_for: marko/{parent}`. Controllers reference templates via the parent's namespace (`{module}::path`) regardless of which engine is in use — abstraction preserved.

4. **When to use this pattern.** For reusable, shipped UI modules where the maintainer can't predict the consumer's engine. Examples: `marko/admin-panel`, future admin dashboards, form builders, debug bars.

5. **When NOT to use it.** Application-specific modules (`app/blog`, `app/admin-customizations`) — these are written for one team's chosen engine. Hard-dep on the engine, ship templates directly. Multi-engine packaging is overhead with no benefit when there's only one consumer.

6. **Trade-offs.**
- Cost: each engine sibling is a separate Composer package to maintain; adopting a new core engine requires writing template translations for every existing UI module
- Benefit: genuine engine choice for consumers; framework principle of "explicit over implicit" upheld
- Scaling: realistic ceiling is 2-3 engines (mainstream PHP options: Twig, Latte, maybe Blade). Beyond that the maintenance math gets ugly — `CrossEngineTemplateParityTest` enforces this as a real commitment.

7. **The parity test.** `marko/view`'s `CrossEngineTemplateParityTest` mechanically enforces parity. Adopting a new core engine fails the build until templates are written for every existing UI module. Document this so contributors know the bar.

## Requirements (Test Descriptions)
- [ ] `architecture.md contains a section titled Engine-Specific Template Siblings`
- [ ] `the section is placed between Package Architecture and Dependency Injection`
- [ ] `the section explains the marko/{module}-{engine} naming pattern`
- [ ] `the section documents the templates_for composer-extra key`
- [ ] `the section explains when to use the pattern (reusable UI packages)`
- [ ] `the section explains when NOT to use the pattern (application-specific modules)`
- [ ] `the section mentions the CrossEngineTemplateParityTest as the enforcement mechanism`

## Acceptance Criteria
- New section in `.claude/architecture.md` covers all outline points above
- Section follows the voice and formatting of surrounding architecture sections
- Tests assert key passages appear in the document (use `file_get_contents` + `toContain` assertions, mirroring how the codebase verifies architecture/docs content elsewhere)
- Test file: `tests/Documentation/ArchitectureDocTest.php` if a similar pattern exists, otherwise an ad-hoc test verifying the section is present
Loading