From 4c99dfa74541cdbcff8066a471e4d5b5eb319b44 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 09:41:16 -0400 Subject: [PATCH 1/4] docs(admin-panel-engine-siblings): add WIP plan for sibling template packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts admin-panel's .latte templates into marko/admin-panel-latte and introduces marko/admin-panel-twig (hand-translated equivalents). Adds templates_for composer-extra metadata so the resolver finds template provider packages. CrossEngineTemplateParityTest in marko/view enforces adopting a new core engine means shipping templates for every UI module. 10 tasks, ~4 parallel batches. WIP — depends on PR #92 (drop view conflicts) merging first so the symmetric-monorepo pattern is settled before this plan runs. Relates to #93 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../001-add-known-engines.md | 48 ++++++ .../002-enhance-module-template-resolver.md | 83 ++++++++++ .../003-document-engine-sibling-pattern.md | 52 ++++++ .../004-scaffold-admin-panel-latte.md | 92 +++++++++++ .../005-scaffold-admin-panel-twig.md | 91 +++++++++++ .../006-move-latte-templates.md | 46 ++++++ .../007-create-twig-templates.md | 57 +++++++ .../008-twig-layout-template-test.md | 46 ++++++ .../009-admin-panel-cleanup.md | 54 ++++++ .../010-cross-engine-template-parity-test.md | 122 ++++++++++++++ .../admin-panel-engine-siblings/_plan.md | 154 ++++++++++++++++++ 11 files changed, 845 insertions(+) create mode 100644 .claude/plans/admin-panel-engine-siblings/001-add-known-engines.md create mode 100644 .claude/plans/admin-panel-engine-siblings/002-enhance-module-template-resolver.md create mode 100644 .claude/plans/admin-panel-engine-siblings/003-document-engine-sibling-pattern.md create mode 100644 .claude/plans/admin-panel-engine-siblings/004-scaffold-admin-panel-latte.md create mode 100644 .claude/plans/admin-panel-engine-siblings/005-scaffold-admin-panel-twig.md create mode 100644 .claude/plans/admin-panel-engine-siblings/006-move-latte-templates.md create mode 100644 .claude/plans/admin-panel-engine-siblings/007-create-twig-templates.md create mode 100644 .claude/plans/admin-panel-engine-siblings/008-twig-layout-template-test.md create mode 100644 .claude/plans/admin-panel-engine-siblings/009-admin-panel-cleanup.md create mode 100644 .claude/plans/admin-panel-engine-siblings/010-cross-engine-template-parity-test.md create mode 100644 .claude/plans/admin-panel-engine-siblings/_plan.md diff --git a/.claude/plans/admin-panel-engine-siblings/001-add-known-engines.md b/.claude/plans/admin-panel-engine-siblings/001-add-known-engines.md new file mode 100644 index 00000000..b8934c04 --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/001-add-known-engines.md @@ -0,0 +1,48 @@ +# Task 001: Add known-engines.php to marko/view + +**Status**: pending +**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 + [ + '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) diff --git a/.claude/plans/admin-panel-engine-siblings/002-enhance-module-template-resolver.md b/.claude/plans/admin-panel-engine-siblings/002-enhance-module-template-resolver.md new file mode 100644 index 00000000..aaa9cf02 --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/002-enhance-module-template-resolver.md @@ -0,0 +1,83 @@ +# Task 002: Enhance ModuleTemplateResolver to honor templates_for metadata + +**Status**: pending +**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`) + - `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 diff --git a/.claude/plans/admin-panel-engine-siblings/003-document-engine-sibling-pattern.md b/.claude/plans/admin-panel-engine-siblings/003-document-engine-sibling-pattern.md new file mode 100644 index 00000000..ba8ad982 --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/003-document-engine-sibling-pattern.md @@ -0,0 +1,52 @@ +# Task 003: Document engine-sibling pattern in architecture.md + +**Status**: pending +**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 diff --git a/.claude/plans/admin-panel-engine-siblings/004-scaffold-admin-panel-latte.md b/.claude/plans/admin-panel-engine-siblings/004-scaffold-admin-panel-latte.md new file mode 100644 index 00000000..4b37f313 --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/004-scaffold-admin-panel-latte.md @@ -0,0 +1,92 @@ +# Task 004: Scaffold marko/admin-panel-latte package + +**Status**: pending +**Depends on**: none +**Retry count**: 0 + +## Description +Create the directory structure and Composer metadata for `marko/admin-panel-latte`. This task only scaffolds — task 006 moves the actual `.latte` files in. + +## Context +- New directory: `packages/admin-panel-latte/` +- Reference pattern: `packages/view-twig/` and `packages/view-latte/` (sibling driver packages already in the monorepo) +- Package name: `marko/admin-panel-latte` +- This package has NO PHP source code (no `src/` directory needed) — it's a template-only package. Use a `.gitkeep` in `resources/views/` so the empty directory commits. Tests directory is created since the moved test file will go there in task 006. + +**composer.json contents:** +```json +{ + "name": "marko/admin-panel-latte", + "description": "Latte templates for marko/admin-panel", + "type": "marko-module", + "license": "MIT", + "require": { + "php": "^8.5", + "marko/admin-panel": "self.version", + "marko/view-latte": "self.version" + }, + "require-dev": { + "pestphp/pest": "^4.0", + "marko/testing": "self.version", + "marko/view": "self.version", + "marko/core": "self.version" + }, + "autoload-dev": { + "psr-4": { + "Marko\\AdminPanel\\Latte\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true, + "templates_for": "marko/admin-panel" + } + } +} +``` + +Note: no `psr-4` under `autoload` (top-level), only `autoload-dev` — package ships no PHP code, only templates. No `conflict` block — see _plan.md's Architecture Notes on why engine template siblings don't need mutual-exclusion declarations. + +## Requirements (Test Descriptions) +- [ ] `it ships a composer.json with name marko/admin-panel-latte` +- [ ] `it requires marko/admin-panel at self.version` +- [ ] `it requires marko/view-latte at self.version` +- [ ] `it does not declare a Composer conflict block` +- [ ] `it declares extra.marko.templates_for as marko/admin-panel` +- [ ] `it marks the package as a Marko module via extra.marko.module` +- [ ] `it has a tests directory ready for the LayoutTemplateTest move` +- [ ] `it has a resources/views/ directory ready for template files` +- [ ] `the monorepo root composer.json registers packages/admin-panel-latte as a path repository` +- [ ] `the monorepo root composer.json requires marko/admin-panel-latte at self.version` +- [ ] `the monorepo root composer.json autoload-dev includes the Marko\AdminPanel\Latte\Tests namespace` +- [ ] `RootComposerJsonTest.$allPackages includes marko/admin-panel-latte` + +## Root monorepo integration (CRITICAL — do not skip) + +The monorepo's root `composer.json` and `packages/framework/tests/RootComposerJsonTest.php` track every package. After PR #92 dropped the view-latte ↔ view-twig conflict, every multi-driver/multi-sibling family follows the same shape: both siblings registered + both in `require`. Apply this pattern here: + +- Root `composer.json` `repositories[]`: add `{"type": "path", "url": "packages/admin-panel-latte"}` (alphabetical position — between `admin-panel` and `amphp`) +- Root `composer.json` `require`: add `"marko/admin-panel-latte": "self.version"` (alphabetical position) +- Root `composer.json` `autoload-dev.psr-4`: add `"Marko\\AdminPanel\\Latte\\Tests\\": "packages/admin-panel-latte/tests/"` +- `packages/framework/tests/RootComposerJsonTest.php` `$allPackages` array: add `'marko/admin-panel-latte'` (alphabetical) +- Update the package-count string in the test descriptions (currently "71 marko packages" after PR #92; task 005 will bump it to 72 when admin-panel-twig is added) + +Both admin-panel-latte AND admin-panel-twig will end up in root `require` simultaneously — fine because no Composer conflict exists. The template resolver picks the right files based on `view.extension`. + +## Acceptance Criteria +- `packages/admin-panel-latte/composer.json` exists with correct schema, no `conflict` block +- `packages/admin-panel-latte/LICENSE` exists (MIT, matching the repo) +- `packages/admin-panel-latte/.gitattributes` exists (mirroring view-latte's) +- `packages/admin-panel-latte/tests/Pest.php` exists (minimal Pest bootstrap mirroring `packages/view-latte/tests/Pest.php`) so Pest can run tests in this package standalone +- `packages/admin-panel-latte/phpunit.xml` exists (mirroring sibling packages) so `pest` can be invoked from the package root +- Directories created with `.gitkeep` files: `resources/views/`, `tests/` (the `.gitkeep` in `tests/` is removed once `Pest.php` lands; the `.gitkeep` in `resources/views/` is removed by task 006 when real templates land) +- `require-dev` includes `marko/view` and `marko/core` (needed by task 006's integration test that exercises `ModuleTemplateResolver`) +- No `src/` directory created (template-only package) +- No `module.php` in this task (templates don't need DI bindings) +- Composer validates the file (`composer validate`) +- Code follows code standards diff --git a/.claude/plans/admin-panel-engine-siblings/005-scaffold-admin-panel-twig.md b/.claude/plans/admin-panel-engine-siblings/005-scaffold-admin-panel-twig.md new file mode 100644 index 00000000..ab437882 --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/005-scaffold-admin-panel-twig.md @@ -0,0 +1,91 @@ +# Task 005: Scaffold marko/admin-panel-twig package + +**Status**: pending +**Depends on**: none +**Retry count**: 0 + +## Description +Create the directory structure and Composer metadata for `marko/admin-panel-twig`. This task only scaffolds — task 007 writes the actual `.twig` template files. After PR #92, twig siblings sit in the monorepo symmetrically with latte siblings — both in `repositories[]` and `require`. + +## Context +- New directory: `packages/admin-panel-twig/` +- Reference pattern: task 004's spec for `admin-panel-latte` (mirror it, swap engine). Also `packages/view-twig/` post-PR-#92 (fully registered monorepo package). +- Package name: `marko/admin-panel-twig` +- No PHP source code (template-only package) + +**composer.json contents:** +```json +{ + "name": "marko/admin-panel-twig", + "description": "Twig templates for marko/admin-panel", + "type": "marko-module", + "license": "MIT", + "require": { + "php": "^8.5", + "marko/admin-panel": "self.version", + "marko/view-twig": "self.version" + }, + "require-dev": { + "pestphp/pest": "^4.0", + "marko/testing": "self.version", + "marko/view": "self.version", + "marko/core": "self.version" + }, + "autoload-dev": { + "psr-4": { + "Marko\\AdminPanel\\Twig\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true, + "templates_for": "marko/admin-panel" + } + } +} +``` + +No `conflict` block. No `psr-4` under `autoload` (top-level) — template-only package. + +## Requirements (Test Descriptions) +- [ ] `it ships a composer.json with name marko/admin-panel-twig` +- [ ] `it requires marko/admin-panel at self.version` +- [ ] `it requires marko/view-twig at self.version` +- [ ] `it does not declare a Composer conflict block` +- [ ] `it declares extra.marko.templates_for as marko/admin-panel` +- [ ] `it marks the package as a Marko module via extra.marko.module` +- [ ] `it has a tests directory ready for the Twig LayoutTemplateTest` +- [ ] `it has a resources/views/ directory ready for template files` +- [ ] `the monorepo root composer.json registers packages/admin-panel-twig as a path repository` +- [ ] `the monorepo root composer.json requires marko/admin-panel-twig at self.version` +- [ ] `the monorepo root composer.json autoload-dev includes the Marko\AdminPanel\Twig\Tests namespace` +- [ ] `RootComposerJsonTest.$allPackages includes marko/admin-panel-twig` + +## Root monorepo integration (CRITICAL — do not skip) + +Symmetric with task 004 — both engine siblings are first-class monorepo packages now that PR #92 removed the conflict pattern: + +- Root `composer.json` `repositories[]`: add `{"type": "path", "url": "packages/admin-panel-twig"}` (alphabetical position — between `admin-panel-latte` and `amphp`) +- Root `composer.json` `require`: add `"marko/admin-panel-twig": "self.version"` (alphabetical position) +- Root `composer.json` `autoload-dev.psr-4`: add `"Marko\\AdminPanel\\Twig\\Tests\\": "packages/admin-panel-twig/tests/"` +- `packages/framework/tests/RootComposerJsonTest.php` `$allPackages`: add `'marko/admin-panel-twig'` (alphabetical, after `admin-panel-latte` from task 004) +- Update package-count strings: task 004 bumped to 72; this task bumps to 73 + +Verify: after task 004 + this task, `composer install` from the monorepo root succeeds with BOTH siblings installed simultaneously. + +## Acceptance Criteria +- `packages/admin-panel-twig/composer.json` exists with correct schema, no `conflict` block +- `packages/admin-panel-twig/LICENSE` exists (MIT) +- `packages/admin-panel-twig/.gitattributes` exists +- `packages/admin-panel-twig/tests/Pest.php` exists (minimal Pest bootstrap) +- `packages/admin-panel-twig/phpunit.xml` exists (so `pest` can run from the package root) +- Directories created with `.gitkeep` files: `resources/views/`, `tests/` (cleaned up once real files land) +- `require-dev` includes `marko/view` and `marko/core` (Twig templates are tested with file_get_contents — no resolver integration here; the deps are kept symmetric with admin-panel-latte for consistency and future integration tests) +- No `src/` or `module.php` +- Composer validates the file +- Code follows code standards diff --git a/.claude/plans/admin-panel-engine-siblings/006-move-latte-templates.md b/.claude/plans/admin-panel-engine-siblings/006-move-latte-templates.md new file mode 100644 index 00000000..a60bc8cb --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/006-move-latte-templates.md @@ -0,0 +1,46 @@ +# Task 006: Move .latte templates and LayoutTemplateTest into admin-panel-latte + +**Status**: pending +**Depends on**: 002, 004 +**Retry count**: 0 + +## Description +Physically move all 5 `.latte` template files and the `LayoutTemplateTest.php` test from `marko/admin-panel` into `marko/admin-panel-latte`. After this task, `marko/admin-panel/resources/views/` contains no templates — that cleanup happens in task 009. The resolver enhancement from task 002 makes admin-panel-latte's templates resolvable via `admin-panel::path` syntax. + +## Context +- Files to move (source → destination): + - `packages/admin-panel/resources/views/auth/login.latte` → `packages/admin-panel-latte/resources/views/auth/login.latte` + - `packages/admin-panel/resources/views/layout/base.latte` → `packages/admin-panel-latte/resources/views/layout/base.latte` + - `packages/admin-panel/resources/views/dashboard/index.latte` → `packages/admin-panel-latte/resources/views/dashboard/index.latte` + - `packages/admin-panel/resources/views/partials/sidebar.latte` → `packages/admin-panel-latte/resources/views/partials/sidebar.latte` + - `packages/admin-panel/resources/views/partials/flash.latte` → `packages/admin-panel-latte/resources/views/partials/flash.latte` + - `packages/admin-panel/tests/Unit/Template/LayoutTemplateTest.php` → `packages/admin-panel-latte/tests/LayoutTemplateTest.php` +- The test file uses `dirname(__DIR__, 3) . '/resources/views'`. After the move, the test is at `packages/admin-panel-latte/tests/LayoutTemplateTest.php`, so the path needs to become `dirname(__DIR__) . '/resources/views'` (1 level up, not 3). Verify and adjust. +- The existing `LayoutTemplateTest.php` is a Pest test file with no `namespace` declaration and no class — only top-level `it(...)` calls. Do NOT add a namespace; the file is namespace-less by design. The PSR-4 mapping in `admin-panel-latte/composer.json` (`Marko\AdminPanel\Latte\Tests\` → `tests/`) is for any future class-based tests; the moved Pest file simply lives in the directory. +- Use `git mv` (not file-by-file copy/delete) to preserve git history. +- Templates use `{include 'admin-panel::partials/sidebar'}` syntax — this continues to work because the resolver enhancement (task 002) finds admin-panel-latte via `templates_for`. + +**Verify the resolver enhancement works end-to-end:** +After moving, write a small integration test (in `packages/admin-panel-latte/tests/`) that: +1. Sets up a minimal container with a real `ModuleTemplateResolver` +2. Asks for `admin-panel::dashboard/index` +3. Asserts the resolved path is the new sibling location + +This catches the failure case where the resolver enhancement is incomplete. + +## Requirements (Test Descriptions) +- [ ] `all 5 .latte template files exist in packages/admin-panel-latte/resources/views/` +- [ ] `no .latte files remain in packages/admin-panel/resources/views/` +- [ ] `LayoutTemplateTest.php exists in packages/admin-panel-latte/tests/` +- [ ] `LayoutTemplateTest.php has been removed from packages/admin-panel/tests/Unit/Template/` +- [ ] `LayoutTemplateTest.php passes against the moved templates (path references updated)` +- [ ] `ModuleTemplateResolver resolves admin-panel::dashboard/index to the new admin-panel-latte path` +- [ ] `the moved Pest file uses dirname(__DIR__) for $viewsPath (one level up)` +- [ ] `the .gitkeep file in resources/views/ is removed once real templates land` + +## Acceptance Criteria +- All 5 template files moved (preserved byte-for-byte via `git mv`) +- LayoutTemplateTest moved with namespace and path adjustments +- Existing test assertions still pass against templates in the new location +- New integration test verifies end-to-end resolution via the resolver enhancement +- Code follows code standards diff --git a/.claude/plans/admin-panel-engine-siblings/007-create-twig-templates.md b/.claude/plans/admin-panel-engine-siblings/007-create-twig-templates.md new file mode 100644 index 00000000..ec30282f --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/007-create-twig-templates.md @@ -0,0 +1,57 @@ +# Task 007: Hand-translate .twig templates into admin-panel-twig + +**Status**: pending +**Depends on**: 002, 005 +**Retry count**: 0 + +## Description +Write 5 `.twig` template files in `marko/admin-panel-twig` that are functionally equivalent to the `.latte` templates moved by task 006. Translation is manual — Latte and Twig syntax diverge enough that mechanical conversion isn't viable. The HTML output produced by each Twig template must be equivalent to its Latte counterpart for the same input variables. + +## Context +- Files to create (mirror the structure in admin-panel-latte): + - `packages/admin-panel-twig/resources/views/auth/login.twig` + - `packages/admin-panel-twig/resources/views/layout/base.twig` + - `packages/admin-panel-twig/resources/views/dashboard/index.twig` + - `packages/admin-panel-twig/resources/views/partials/sidebar.twig` + - `packages/admin-panel-twig/resources/views/partials/flash.twig` +- Source-of-truth Latte templates: `packages/admin-panel-latte/resources/views/*.latte` (after task 006) + +**Syntax translation reference (apply per file):** + +| Latte construct | Twig equivalent | +|-----------------|-----------------| +| `{default $pageTitle = 'X'}` | `{% set pageTitle = pageTitle\|default('X') %}` (per variable) | +| `{$variable}` | `{{ variable }}` | +| `
...
` | `{% if x %}
...
{% endif %}` | +| `{include 'admin-panel::partials/sidebar'}` | `{% include 'admin-panel::partials/sidebar' %}` | +| `{layout 'admin-panel::layout/base'}` | `{% extends 'admin-panel::layout/base' %}` | +| `{block content}...{/block}` | `{% block content %}...{% endblock %}` | +| `{foreach $items as $item}...{/foreach}` | `{% for item in items %}...{% endfor %}` | +| `{$item->getLabel()}` | `{{ item.getLabel() }}` (or `{{ item.label }}` if Twig's property accessor finds it) | + +**Output equivalence contract:** +- Same DOCTYPE, same elements, same form structure, same data flow +- Same HTML class names, IDs, attributes +- Same conditional rendering behavior (e.g., the error div only renders when `error` is truthy) +- Same CSRF token output (`{$csrfToken}` → `{{ csrfToken }}`) +- Same iteration behavior in sidebar partial (renders each menu item with label/url) + +**Important — autoescape consideration:** +view-twig sets `strict_variables: true` and `autoescape: 'html'` by default. The Latte templates may rely on Latte's escaping behavior. When in doubt, default to escaped output (`{{ variable }}`) unless the Latte original explicitly uses an `|noescape` filter. + +## Requirements (Test Descriptions) +- [ ] `auth/login.twig exists and renders a form with email and password fields` +- [ ] `layout/base.twig exists and contains HTML doctype, sidebar include, and content block` +- [ ] `dashboard/index.twig exists and extends layout/base.twig with a content block` +- [ ] `partials/sidebar.twig exists and iterates menu items` +- [ ] `partials/flash.twig exists and renders success and error message states` +- [ ] `login.twig includes a CSRF hidden input with name _token` +- [ ] `the layout's content block can be overridden by child templates (verified via dashboard.twig)` + +## Acceptance Criteria +- All 5 `.twig` files exist at the specified paths +- Each template produces HTML structurally equivalent to its Latte counterpart for the same input data +- Templates use Twig 3 syntax (no Twig 1/2 deprecated constructs) +- No autoescape-disabling filters (`|raw`) unless the Latte original explicitly opted out of escaping +- Code follows project standards (Twig templates have no PHP standards to follow, but file extensions and naming match) +- Task 008 writes the test file separately; this task focuses purely on template creation diff --git a/.claude/plans/admin-panel-engine-siblings/008-twig-layout-template-test.md b/.claude/plans/admin-panel-engine-siblings/008-twig-layout-template-test.md new file mode 100644 index 00000000..9232a5f2 --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/008-twig-layout-template-test.md @@ -0,0 +1,46 @@ +# Task 008: Write LayoutTemplateTest equivalent for Twig templates + +**Status**: pending +**Depends on**: 007 +**Retry count**: 0 + +## Description +Create `packages/admin-panel-twig/tests/LayoutTemplateTest.php` mirroring the assertions in `packages/admin-panel-latte/tests/LayoutTemplateTest.php` but adapted for Twig syntax. Verifies each of the 5 Twig templates contains the expected structural elements (DOCTYPE, form fields, blocks, includes, etc.). + +## Context +- New file: `packages/admin-panel-twig/tests/LayoutTemplateTest.php` +- Reference: `packages/admin-panel-latte/tests/LayoutTemplateTest.php` (after task 006 moves it) +- Namespace: `Marko\AdminPanel\Twig\Tests` +- Path resolution: `$viewsPath = dirname(__DIR__) . '/resources/views'` (one level up since test is at `tests/LayoutTemplateTest.php`, templates at `resources/views/`) + +**Assertion translations:** + +| Latte test assertion | Twig equivalent | +|----------------------|-----------------| +| `->toContain('{include')` | `->toContain('{% include')` | +| `->toContain('{block content}')` | `->toContain('{% block content %}')` | +| `->toContain('{layout')` | `->toContain('{% extends')` | +| `->toContain('{foreach')` | `->toContain('{% for')` | +| `->toContain('$flashMessages')` | `->toContain('flashMessages')` (no `$` in Twig) | +| `->toContain('$csrfToken')` | `->toContain('csrfToken')` | +| `->toContain('{$pageTitle}')` | `->toContain('{{ pageTitle')` | + +**Pure HTML/attribute assertions (`
`, `method="post"`, `type="email"`, etc.) carry over unchanged** — those are not template-engine-specific. + +## Requirements (Test Descriptions) +- [ ] `it creates base layout template with html shell, sidebar, and content block` +- [ ] `it creates login template with email and password form fields` +- [ ] `it creates dashboard template extending base layout` +- [ ] `it creates sidebar partial with menu items loop` +- [ ] `it creates flash message partial for success and error messages` +- [ ] `it includes csrf-safe form structure in login template` +- [ ] `it has content block that child templates can override` + +(Same test names as the Latte version — the assertions inside differ to match Twig syntax.) + +## Acceptance Criteria +- `packages/admin-panel-twig/tests/LayoutTemplateTest.php` exists +- All 7 tests pass against the templates created in task 007 +- Test assertions are the Twig-syntax equivalent of the Latte test's assertions +- Namespace matches package: `Marko\AdminPanel\Twig\Tests` +- Code follows test standards (expectation chaining with `->and()`, `->toBeTrue()`, etc.) diff --git a/.claude/plans/admin-panel-engine-siblings/009-admin-panel-cleanup.md b/.claude/plans/admin-panel-engine-siblings/009-admin-panel-cleanup.md new file mode 100644 index 00000000..20df3cea --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/009-admin-panel-cleanup.md @@ -0,0 +1,54 @@ +# Task 009: Remove resources/views/ from marko/admin-panel; update composer.json suggest + +**Status**: pending +**Depends on**: 006, 007 +**Retry count**: 0 + +## Description +After task 006 moves the templates out of `marko/admin-panel`, this task cleans up. Remove the now-empty `resources/views/` directory, update admin-panel's `composer.json` `suggest` block to list both engine siblings so users discover them at install time, and verify all remaining admin-panel tests pass (controllers, config, menu builder — all engine-agnostic). + +## Context +- Files to modify/delete: + - DELETE: `packages/admin-panel/resources/views/` directory (recursively) + - MODIFY: `packages/admin-panel/composer.json` — add `suggest` block listing both engine siblings + - VERIFY: no orphaned files in `packages/admin-panel/tests/Unit/Template/` (LayoutTemplateTest moved in task 006; the `Template/` directory should be empty and removable) + +**composer.json `suggest` block to add:** +```json +"suggest": { + "marko/admin-panel-twig": "Twig templates for the admin panel (recommended for broader ecosystem familiarity)", + "marko/admin-panel-latte": "Latte templates for the admin panel" +} +``` + +This pairs with the resolver enhancement (task 002) — installing `marko/admin-panel` alone leaves the controllers without templates to render; the `suggest` block points users to fix this. + +**Existing tests to verify pass after cleanup:** +- `tests/Unit/Config/AdminPanelConfigTest.php` — config tests, no template dependency +- `tests/Unit/Controller/DashboardControllerTest.php` — mocks ViewInterface +- `tests/Unit/Controller/LoginControllerTest.php` — mocks ViewInterface +- `tests/Unit/Menu/AdminMenuBuilderTest.php` — no template dependency +- `tests/Unit/PackageStructureTest.php` — verified at plan time that it asserts only on composer.json fields, not on `resources/views/` presence. If a future contributor adds such an assertion before this task runs, remove it. + +**Integration verification:** +After cleanup, run `composer test` from the monorepo root. Expectations: +- admin-panel tests all pass +- admin-panel-latte tests all pass (its 7 LayoutTemplateTest assertions plus PackageTest) +- admin-panel-twig tests all pass (its 7 LayoutTemplateTest assertions plus PackageTest) + +## Requirements (Test Descriptions) +- [ ] `packages/admin-panel/resources/views/ no longer exists` +- [ ] `packages/admin-panel/composer.json includes a suggest block` +- [ ] `the suggest block lists marko/admin-panel-twig` +- [ ] `the suggest block lists marko/admin-panel-latte` +- [ ] `the suggest block does not place either engine sibling in require` +- [ ] `PackageStructureTest does not assert on resources/views/ presence` +- [ ] `all existing admin-panel unit tests continue to pass` + +## Acceptance Criteria +- `packages/admin-panel/resources/views/` is gone (directory removed) +- `tests/Unit/Template/` directory removed (since its only file was moved in task 006) +- composer.json `suggest` block added; admin-panel-twig listed first (recommended) +- Neither engine sibling appears in `require` or `require-dev` +- All admin-panel tests pass without templates present +- Code follows code standards diff --git a/.claude/plans/admin-panel-engine-siblings/010-cross-engine-template-parity-test.md b/.claude/plans/admin-panel-engine-siblings/010-cross-engine-template-parity-test.md new file mode 100644 index 00000000..0dbbe997 --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/010-cross-engine-template-parity-test.md @@ -0,0 +1,122 @@ +# Task 010: Add CrossEngineTemplateParityTest to marko/view + +**Status**: pending +**Depends on**: 001, 006, 007 +**Retry count**: 0 + +## Description +Create `packages/view/tests/Feature/CrossEngineTemplateParityTest.php` — the mechanical enforcement that adopting a new core view engine means shipping templates for every existing UI module. Scans all `marko/*` packages on disk, finds those declaring `extra.marko.templates_for`, and asserts that for every engine in `known-engines.php`, a corresponding template provider exists for every parent module. + +## Context +- New file: `packages/view/tests/Feature/CrossEngineTemplateParityTest.php` +- Reads: `packages/view/known-engines.php` (from task 001) +- Scans: `packages/*/composer.json` files looking for `extra.marko.templates_for` + +**Test logic shape:** + +**Path-depth note:** From `packages/view/tests/Feature/CrossEngineTemplateParityTest.php`: +- `__DIR__` = `packages/view/tests/Feature` +- `dirname(__DIR__, 1)` = `packages/view/tests` +- `dirname(__DIR__, 2)` = `packages/view` ← use for `known-engines.php` +- `dirname(__DIR__, 3)` = `packages` ← use this directly to glob sibling packages + +```php +test('every template provider has siblings for every registered core engine', function () { + $packagesDir = dirname(__DIR__, 3); // resolves to the `packages/` directory in the monorepo + $enginesPath = dirname(__DIR__, 2) . '/known-engines.php'; + + if (!file_exists($enginesPath)) { + $this->markTestSkipped('known-engines.php not found — marko/view not installed standalone?'); + } + + if (!is_dir($packagesDir) || basename($packagesDir) !== 'packages') { + $this->markTestSkipped('Not running inside the monorepo packages directory — skipping cross-engine parity check.'); + } + + $engines = require $enginesPath; + + // Find all template provider packages (NOTE: $packagesDir IS the packages dir, no extra /packages segment) + $providers = []; // shape: ['marko/admin-panel' => ['twig' => 'marko/admin-panel-twig', ...]] + foreach (glob($packagesDir . '/*/composer.json') as $composerPath) { + $composer = json_decode(file_get_contents($composerPath), associative: true); + if (!is_array($composer)) continue; + $templatesFor = $composer['extra']['marko']['templates_for'] ?? null; + if (!is_string($templatesFor)) continue; + + $packageName = $composer['name'] ?? null; + if (!is_string($packageName)) continue; + + // Derive engine suffix: marko/admin-panel-twig → 'twig' + $engineSuffix = extractEngineSuffix($packageName, $templatesFor); + if ($engineSuffix === null || !isset($engines[$engineSuffix])) continue; + + $providers[$templatesFor][$engineSuffix] = $packageName; + } + + if ($providers === []) { + $this->markTestSkipped('No template provider packages found — nothing to check.'); + } + + // Assert parity + foreach ($providers as $parent => $foundEngines) { + foreach ($engines as $engineName => $engineMeta) { + expect($foundEngines)->toHaveKey( + $engineName, + "Parent module '$parent' has template providers for [" . implode(', ', array_keys($foundEngines)) + . "] but is missing a provider for engine '$engineName'. " + . "Expected a package like 'marko/{basename}-{$engineName}' declaring " + . "extra.marko.templates_for: '$parent'." + ); + } + } +}); +``` + +**Skip behaviors (zero-dependency principle):** +1. If `known-engines.php` doesn't exist → skip (marko/view not installed standalone) +2. If `dirname(__DIR__, 3)` is not a directory named `packages` (running outside monorepo) → skip +3. If no template provider packages found → skip (nothing to validate; vacuously true) + +Note: This is a monorepo-only sanity check. When `marko/view` is installed in a downstream app under `vendor/`, the directory layout differs and the test will skip via the `basename === 'packages'` guard — by design. + +**Engine suffix extraction:** +The test must derive which engine a provider package targets. Two approaches: +- (a) Suffix matching: `marko/admin-panel-twig` targets parent `marko/admin-panel`, suffix is `twig` +- (b) Read `extra.marko.engine` if present (would require a new metadata key — out of scope) + +Use (a). Pest tests run at file scope (no class context) — `self::` is unavailable. Implement as a free function defined at the top of the test file: + +```php +function extractEngineSuffix(string $packageName, string $parentName): ?string +{ + $packageBase = basename($packageName); // 'admin-panel-twig' + $parentBase = basename($parentName); // 'admin-panel' + $prefix = $parentBase . '-'; + if (!str_starts_with($packageBase, $prefix)) { + return null; // doesn't follow convention; skip + } + return substr($packageBase, strlen($prefix)); // 'twig' +} +``` + +Use it as a plain function call: `extractEngineSuffix($packageName, $templatesFor)` (NOT `self::extractEngineSuffix(...)`). + +**Negative test (drift detection):** +Add a second test that simulates a missing engine: create a temporary fixture directory where `marko/admin-panel-latte` exists but no Twig provider; assert the parity check fails with a clear error message. Easiest implementation: extract the parity logic into a static method, call it with mocked input, assert the exception message. + +## Requirements (Test Descriptions) +- [ ] `it asserts every template provider has a sibling for every registered engine (passing case)` +- [ ] `it fails with a clear error message when an engine is missing a provider for some parent` +- [ ] `it skips gracefully when known-engines.php is not present` +- [ ] `it skips gracefully when the resolved packages directory is not the monorepo packages/ dir` +- [ ] `it skips gracefully when no template provider packages are found` +- [ ] `it correctly extracts engine suffix from package name (admin-panel-twig → twig)` +- [ ] `it ignores packages whose names do not follow the marko/{parent}-{engine} convention` +- [ ] `it ignores packages whose extracted suffix is not in known-engines (e.g., admin-panel-twig-extra produces suffix twig-extra and is skipped if not registered)` + +## Acceptance Criteria +- `CrossEngineTemplateParityTest.php` exists in `packages/view/tests/Feature/` +- Test passes against the current monorepo state (after tasks 006 and 007 ship both Latte and Twig admin-panel templates) +- Skip behaviors work correctly — verify by mentally walking through the skip logic (or by temporarily renaming known-engines.php and confirming the test skips) +- Error message names the missing engine, the parent module, and suggests the expected package name +- Code follows code standards (strict_types, expectation chaining) diff --git a/.claude/plans/admin-panel-engine-siblings/_plan.md b/.claude/plans/admin-panel-engine-siblings/_plan.md new file mode 100644 index 00000000..a603591b --- /dev/null +++ b/.claude/plans/admin-panel-engine-siblings/_plan.md @@ -0,0 +1,154 @@ +# Plan: admin-panel engine-specific sibling packages + +## Created +2026-05-27 + +## Status +planning + +## Objective +Extract template files from `marko/admin-panel` into engine-specific sibling packages (`marko/admin-panel-latte`, `marko/admin-panel-twig`). Establish the `marko/{module}-{engine}` pattern as the canonical approach for UI packages that want multi-engine support. Add a `CrossEngineTemplateParityTest` in `marko/view` to mechanically enforce template parity across registered core view engines. + +## Related Issues +Closes #93 + +## Discovery Notes + +**Existing admin-panel state:** +- Ships 5 `.latte` templates under `resources/views/`: `auth/login.latte`, `layout/base.latte`, `dashboard/index.latte`, `partials/sidebar.latte`, `partials/flash.latte` +- Controllers (`DashboardController`, `LoginController`) reference templates via `$this->view->render('admin-panel::dashboard/index', ...)` — the `admin-panel::` module-namespaced syntax +- Controllers' tests mock `ViewInterface` — already engine-agnostic; no changes needed there +- `tests/Unit/Template/LayoutTemplateTest.php` (one file) verifies template content via `file_get_contents` and string `toContain` assertions — Latte-specific. Must move to `admin-panel-latte`; a parallel Twig-flavored equivalent goes in `admin-panel-twig` + +**Critical architectural finding — template resolver namespace lookup:** + +`ModuleTemplateResolver::matchesModuleName()` resolves `admin-panel::path` by finding modules whose basename equals `admin-panel`. After moving templates into `marko/admin-panel-latte`, the basename is `admin-panel-latte` — the resolver would NOT find them. This must be solved before templates move, or every controller breaks. + +**Solution: `templates_for` composer-extra metadata.** + +Sibling packages declare: +```json +"extra": { + "marko": { + "module": true, + "templates_for": "marko/admin-panel" + } +} +``` + +The resolver gains a second pass: in addition to matching by module basename, it ALSO scans modules with a `templates_for` declaration. When resolving `admin-panel::path`, any module declaring `templates_for: marko/admin-panel` is searched alongside `marko/admin-panel` itself. This is explicit (Marko principle), composable (third-party packages can use it), and doesn't invent new naming rules. + +**`known-engines.php` — the parity test's source of truth:** + +`packages/view/known-engines.php` registers the engines whose siblings the framework promises to ship. Format: +```php +return [ + 'twig' => ['extension' => '.twig', 'driver' => 'marko/view-twig'], + 'latte' => ['extension' => '.latte', 'driver' => 'marko/view-latte'], +]; +``` + +`CrossEngineTemplateParityTest` reads this and asserts: for every package declaring `templates_for: marko/X`, there exists an equivalent template provider for every OTHER registered engine targeting the same parent. Adopting a new core engine = signing up to ship template providers for every `marko/*` UI module — caught mechanically at build time. + +**Twig templates must be hand-translated from Latte.** Latte and Twig syntax diverge enough that mechanical conversion isn't viable. Each of the 5 templates needs a from-scratch Twig version producing equivalent HTML. + +**Scope coordination with PR #91 (known-drivers-registry):** + +This plan is a dependency of PR #91. PR #91's task 025 (skeleton suggest consolidation) will pull in the two new packages from this plan once it merges. This plan does NOT touch skeleton's composer.json — that's PR #91's job. + +## Scope + +### In Scope + +**Phase A — Infrastructure (parallel):** +- Add `packages/view/known-engines.php` registering Twig and Latte +- Enhance `ModuleTemplateResolver` to honor `extra.marko.templates_for` metadata when resolving module-namespaced templates +- Document the engine-sibling pattern in `.claude/architecture.md` + +**Phase B — Scaffold new packages (parallel):** +- Create `marko/admin-panel-latte` package skeleton with `templates_for: marko/admin-panel` metadata; require `marko/view-latte` +- Create `marko/admin-panel-twig` package skeleton with `templates_for: marko/admin-panel` metadata; require `marko/view-twig` + +**Phase C — Move and create templates:** +- Move 5 `.latte` files + `LayoutTemplateTest.php` from `marko/admin-panel` to `marko/admin-panel-latte` +- Hand-translate 5 templates into `.twig` for `marko/admin-panel-twig` producing equivalent HTML output +- Write `LayoutTemplateTest.php` equivalent in `marko/admin-panel-twig` asserting Twig structure + +**Phase D — admin-panel cleanup:** +- Remove `resources/views/` directory from `marko/admin-panel` +- Update `marko/admin-panel`'s composer.json `suggest` block to list both engine siblings +- Verify all existing admin-panel tests still pass with no templates present +- Verify controllers continue to render via the resolver enhancement (integration test) + +**Phase E — Parity enforcement:** +- Add `CrossEngineTemplateParityTest` to `packages/view/tests/Feature/` — scans all `marko/*` packages on disk, finds those declaring `templates_for`, asserts every engine in `known-engines.php` has a corresponding provider for each parent module +- Test skips gracefully when scanned packages aren't on disk (zero-dependency principle) + +### Out of Scope + +- Skeleton `composer.json` `suggest` block updates for the new packages — handled by PR #91 task 025 (this plan is its dependency) +- Adopting additional view engines (Blade, Liquid, etc.) — separate concern; the infrastructure in this plan supports it but actual adoption is not scoped here +- `marker interface` or `extra.marko.driver_for` enforcement for *drivers* — that's the deferred follow-up from PR #91; orthogonal to this plan's `templates_for` for *template providers* +- Stylesheet/asset coupling — admin-panel templates inline minimal styles; no shared CSS file moves with the templates. If a future refactor introduces shared assets, they live in `marko/admin-panel` (engine-agnostic). + +## Success Criteria +- [ ] `composer require marko/admin-panel + marko/admin-panel-latte` works; admin panel renders correctly via Latte +- [ ] `composer require marko/admin-panel + marko/admin-panel-twig` works; admin panel renders correctly via Twig (HTML output is functionally equivalent to the Latte version) +- [ ] `composer require marko/admin-panel-latte marko/admin-panel-twig` (both installed) is harmless — the resolver routes to the right templates based on `view.extension` config; no conflict declaration enforces mutual exclusion +- [ ] `marko/admin-panel` has zero `.latte` or `.twig` files under `resources/views/` (directory removed entirely) +- [ ] `ModuleTemplateResolver` resolves `admin-panel::path` to templates in whichever sibling is installed +- [ ] `CrossEngineTemplateParityTest` passes (every template provider has siblings for every registered engine) and would fail if a `.twig` template is missing from `admin-panel-twig` +- [ ] `marko/view` tests pass standalone (no dependency on admin-panel or any specific engine) +- [ ] All existing admin-panel controller and config tests pass +- [ ] All tests passing (`composer test`) +- [ ] Code follows project standards + +## Task Overview +| Task | Description | Depends On | Status | +|------|-------------|------------|--------| +| 001 | Add `known-engines.php` to marko/view | - | pending | +| 002 | Expose `extra` on `ModuleManifest`; enhance `ModuleTemplateResolver` to honor `templates_for` metadata | - | pending | +| 003 | Document engine-sibling pattern in architecture.md | - | pending | +| 004 | Scaffold `marko/admin-panel-latte` package | - | pending | +| 005 | Scaffold `marko/admin-panel-twig` package | - | pending | +| 006 | Move `.latte` templates and LayoutTemplateTest from admin-panel to admin-panel-latte | 002, 004 | pending | +| 007 | Hand-translate `.twig` templates into admin-panel-twig | 002, 005 | pending | +| 008 | Write `LayoutTemplateTest` equivalent for Twig templates | 007 | pending | +| 009 | Remove `resources/views/` from marko/admin-panel; update composer.json suggest | 006, 007 | pending | +| 010 | Add `CrossEngineTemplateParityTest` to marko/view | 001, 006, 007 | pending | + +**Note on task 002 scope:** Task 002 spans two packages — `marko/core` (extending `ModuleManifest`, `ManifestParser`, `ModuleDiscovery` to surface `extra` from composer.json) and `marko/view` (the resolver enhancement). The core changes are a prerequisite for the view changes; both must ship together in this task. Worker should not attempt to split them. + +## Architecture Notes + +**Resolver enhancement signature.** `ModuleTemplateResolver::getSearchedPaths()` currently iterates `$this->moduleRepository->all()` and matches modules by basename. The enhancement adds a second match path: if a module's `extra.marko.templates_for` (read via `ModuleRepositoryInterface`) equals the requested template's parent, that module's `resources/views/` is also searched. Order matters — the parent module is searched first, the sibling provider second; this lets app-level overrides in `marko/admin-panel` (if any are ever added) win over the engine sibling. + +**Why `templates_for` lives in composer.json `extra`, not `module.php`:** It's discoverable at install time by Composer tooling and by automated parity tests, without requiring PHP execution. Marko's `module.php` is for runtime DI wiring; this is package metadata describing the package's relationship to another package. composer.json is the right home for package-relationship metadata. + +**Engine sibling vs driver — different patterns, different mutual-exclusion semantics:** +- *Drivers* (view-latte, view-twig) bind `ViewInterface` — installing both causes `BindingConflictException` at boot. The framework's loud-error path handles it. +- *Engine template siblings* (admin-panel-latte, admin-panel-twig) ship template files only. They do NOT bind any interface. Installing both is harmless: the `ModuleTemplateResolver` (after task 002) uses `view.extension` from config to know which extension to search for, so it naturally selects the right templates regardless of which siblings are installed. + +Neither mechanism uses Composer `conflict` declarations. Drivers rely on DI-level detection; engine template siblings need no mutual-exclusion enforcement at all (worst case is wasted disk space, no behavior break). This is symmetric with how every other Marko multi-driver family handles the same condition — see PR #92 for the broader pattern. + +**Parity test's contract.** The test answers: "if I install `marko/{X}-{engine}` for any engine, is every OTHER registered engine's sibling also available?" Failing the test means a contributor added a template provider without back-filling parity. This is the gate that makes adopting a new core engine a real commitment, not an aspiration. + +## Risks & Mitigations + +- **Risk:** Resolver enhancement is a behavior change in a load-bearing class (`ModuleTemplateResolver`). A regression could break template lookup for every controller using `module::path` syntax. + **Mitigation:** New behavior is additive (extra match path). Old behavior preserved. Test coverage extends existing `ModuleTemplateResolverTest`. Plus the admin-panel flow itself serves as an integration test — if controllers can't find templates after the move, this immediately surfaces. + +- **Risk:** Hand-translated Twig templates produce subtly-different HTML than the Latte originals (whitespace, attribute order, conditional rendering differences). + **Mitigation:** Task 008 writes content-equivalence assertions covering the same structural elements as the Latte tests (DOCTYPE, form elements, blocks, includes). Plus the controllers' integration with the rendered output exercises real behavior. Acceptable risk: Twig and Latte have minor output differences (e.g., Twig's autoescape rules); functional equivalence (same elements, same data flow) is the contract, not byte-equivalence. + +- **Risk:** Existing apps that installed `marko/admin-panel` standalone (assuming templates were bundled) will break after this refactor — admin routes will throw `TemplateNotFoundException`. + **Mitigation:** This IS a breaking change. Document in PR description with the install-pattern migration: `composer require marko/admin-panel-latte` (or `-twig`) alongside existing `marko/admin-panel`. Mark as a minor version bump since admin-panel is pre-1.0. The skeleton's `suggest` block (PR #91) will guide new installs. + +- **Risk:** The `templates_for` composer-extra key is novel — third parties don't yet know about it. + **Mitigation:** Architecture doc update (task 003) documents the pattern explicitly. Future community modules can adopt the pattern by example. + +- **Risk:** `CrossEngineTemplateParityTest` may produce noisy failures during plan rollout (e.g., if Twig templates aren't written before the test runs). + **Mitigation:** Task ordering — task 010 (parity test) depends on tasks 006 AND 007 (both engine siblings populated). Test only meaningful once both providers exist. + +- **Risk:** Removing `resources/views/` from `marko/admin-panel` may break the existing `LayoutTemplateTest.php` mid-migration if file moves and admin-panel cleanup are out of sync. + **Mitigation:** Task 006 explicitly moves the test file alongside the templates. Task 009 (admin-panel cleanup) verifies the directory is empty after the move, with no orphaned test references. From 4a4a633556b8391a112ee7e7187fab7fb44e05ba Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 10:00:03 -0400 Subject: [PATCH 2/4] docs(admin-panel-engine-siblings): mark plan status as ready plan-orchestrate expects 'ready' status to begin execution. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/plans/admin-panel-engine-siblings/_plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/plans/admin-panel-engine-siblings/_plan.md b/.claude/plans/admin-panel-engine-siblings/_plan.md index a603591b..530f58d8 100644 --- a/.claude/plans/admin-panel-engine-siblings/_plan.md +++ b/.claude/plans/admin-panel-engine-siblings/_plan.md @@ -4,7 +4,7 @@ 2026-05-27 ## Status -planning +ready ## Objective Extract template files from `marko/admin-panel` into engine-specific sibling packages (`marko/admin-panel-latte`, `marko/admin-panel-twig`). Establish the `marko/{module}-{engine}` pattern as the canonical approach for UI packages that want multi-engine support. Add a `CrossEngineTemplateParityTest` in `marko/view` to mechanically enforce template parity across registered core view engines. From 9e1b590cc4dc2aee4a678142867783884fa2b130 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 10:22:45 -0400 Subject: [PATCH 3/4] feat(admin-panel-engine-siblings): extract templates into engine-specific sibling packages Introduces marko/admin-panel-latte and marko/admin-panel-twig as template-only sibling packages, establishing the marko/{module}-{engine} pattern for UI packages that want multi-engine support. ModuleManifest gains an $extra field so ModuleTemplateResolver can resolve templates from packages declaring extra.marko.templates_for, making the admin-panel::path namespace work regardless of which engine sibling is installed. CrossEngineTemplateParityTest in marko/view mechanically enforces that adding a new core engine requires shipping templates for every existing UI module. Closes #93 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/architecture.md | 108 ++++++++ .../001-add-known-engines.md | 2 +- .../002-enhance-module-template-resolver.md | 2 +- .../003-document-engine-sibling-pattern.md | 2 +- .../004-scaffold-admin-panel-latte.md | 2 +- .../005-scaffold-admin-panel-twig.md | 2 +- .../006-move-latte-templates.md | 2 +- .../007-create-twig-templates.md | 2 +- .../008-twig-layout-template-test.md | 2 +- .../009-admin-panel-cleanup.md | 16 +- .../010-cross-engine-template-parity-test.md | 2 +- .../admin-panel-engine-siblings/_plan.md | 22 +- composer.json | 12 + .../docs/packages/admin-panel-latte.md | 53 ++++ .../content/docs/packages/admin-panel-twig.md | 53 ++++ docs/src/content/docs/packages/admin-panel.md | 2 +- packages/admin-panel-latte/.gitattributes | 6 + packages/admin-panel-latte/LICENSE | 21 ++ packages/admin-panel-latte/README.md | 24 ++ packages/admin-panel-latte/composer.json | 33 +++ packages/admin-panel-latte/phpunit.xml | 13 + .../resources/views/auth/login.latte | 0 .../resources/views/dashboard/index.latte | 0 .../resources/views/layout/base.latte | 0 .../resources/views/partials/flash.latte | 0 .../resources/views/partials/sidebar.latte | 0 .../tests}/LayoutTemplateTest.php | 2 +- .../tests/PackageStructureTest.php | 59 ++++ packages/admin-panel-latte/tests/Pest.php | 3 + .../tests/ResolverIntegrationTest.php | 32 +++ .../tests/TemplateMigrationTest.php | 51 ++++ packages/admin-panel-twig/.gitattributes | 5 + packages/admin-panel-twig/LICENSE | 21 ++ packages/admin-panel-twig/README.md | 24 ++ packages/admin-panel-twig/composer.json | 33 +++ packages/admin-panel-twig/phpunit.xml | 13 + .../resources/views/auth/login.twig | 36 +++ .../resources/views/dashboard/index.twig | 20 ++ .../resources/views/layout/base.twig | 24 ++ .../resources/views/partials/flash.twig | 11 + .../resources/views/partials/sidebar.twig | 15 + .../tests/LayoutTemplateTest.php | 102 +++++++ .../tests/PackageStructureTest.php | 59 ++++ packages/admin-panel-twig/tests/Pest.php | 3 + .../tests/TemplateExistenceTest.php | 98 +++++++ packages/admin-panel/README.md | 2 + packages/admin-panel/composer.json | 4 + .../tests/Unit/EngineSiblingCleanupTest.php | 56 ++++ packages/core/src/Module/ManifestParser.php | 1 + packages/core/src/Module/ModuleDiscovery.php | 1 + packages/core/src/Module/ModuleManifest.php | 2 + .../tests/Unit/Module/ModuleDiscoveryTest.php | 79 ++++++ .../framework/tests/ArchitectureDocTest.php | 53 ++++ .../framework/tests/RootComposerJsonTest.php | 6 +- packages/view/known-engines.php | 14 + packages/view/src/ModuleTemplateResolver.php | 32 ++- .../Feature/CrossEngineTemplateParityTest.php | 180 ++++++++++++ packages/view/tests/KnownEnginesTest.php | 49 ++++ .../view/tests/ModuleTemplateResolverTest.php | 261 ++++++++++++++++++ 59 files changed, 1697 insertions(+), 35 deletions(-) create mode 100644 docs/src/content/docs/packages/admin-panel-latte.md create mode 100644 docs/src/content/docs/packages/admin-panel-twig.md create mode 100644 packages/admin-panel-latte/.gitattributes create mode 100644 packages/admin-panel-latte/LICENSE create mode 100644 packages/admin-panel-latte/README.md create mode 100644 packages/admin-panel-latte/composer.json create mode 100644 packages/admin-panel-latte/phpunit.xml rename packages/{admin-panel => admin-panel-latte}/resources/views/auth/login.latte (100%) rename packages/{admin-panel => admin-panel-latte}/resources/views/dashboard/index.latte (100%) rename packages/{admin-panel => admin-panel-latte}/resources/views/layout/base.latte (100%) rename packages/{admin-panel => admin-panel-latte}/resources/views/partials/flash.latte (100%) rename packages/{admin-panel => admin-panel-latte}/resources/views/partials/sidebar.latte (100%) rename packages/{admin-panel/tests/Unit/Template => admin-panel-latte/tests}/LayoutTemplateTest.php (98%) create mode 100644 packages/admin-panel-latte/tests/PackageStructureTest.php create mode 100644 packages/admin-panel-latte/tests/Pest.php create mode 100644 packages/admin-panel-latte/tests/ResolverIntegrationTest.php create mode 100644 packages/admin-panel-latte/tests/TemplateMigrationTest.php create mode 100644 packages/admin-panel-twig/.gitattributes create mode 100644 packages/admin-panel-twig/LICENSE create mode 100644 packages/admin-panel-twig/README.md create mode 100644 packages/admin-panel-twig/composer.json create mode 100644 packages/admin-panel-twig/phpunit.xml create mode 100644 packages/admin-panel-twig/resources/views/auth/login.twig create mode 100644 packages/admin-panel-twig/resources/views/dashboard/index.twig create mode 100644 packages/admin-panel-twig/resources/views/layout/base.twig create mode 100644 packages/admin-panel-twig/resources/views/partials/flash.twig create mode 100644 packages/admin-panel-twig/resources/views/partials/sidebar.twig create mode 100644 packages/admin-panel-twig/tests/LayoutTemplateTest.php create mode 100644 packages/admin-panel-twig/tests/PackageStructureTest.php create mode 100644 packages/admin-panel-twig/tests/Pest.php create mode 100644 packages/admin-panel-twig/tests/TemplateExistenceTest.php create mode 100644 packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php create mode 100644 packages/framework/tests/ArchitectureDocTest.php create mode 100644 packages/view/known-engines.php create mode 100644 packages/view/tests/Feature/CrossEngineTemplateParityTest.php create mode 100644 packages/view/tests/KnownEnginesTest.php diff --git a/.claude/architecture.md b/.claude/architecture.md index 099a1084..c46cedb7 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -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 diff --git a/.claude/plans/admin-panel-engine-siblings/001-add-known-engines.md b/.claude/plans/admin-panel-engine-siblings/001-add-known-engines.md index b8934c04..24c1bf81 100644 --- a/.claude/plans/admin-panel-engine-siblings/001-add-known-engines.md +++ b/.claude/plans/admin-panel-engine-siblings/001-add-known-engines.md @@ -1,6 +1,6 @@ # Task 001: Add known-engines.php to marko/view -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/002-enhance-module-template-resolver.md b/.claude/plans/admin-panel-engine-siblings/002-enhance-module-template-resolver.md index aaa9cf02..94159f8d 100644 --- a/.claude/plans/admin-panel-engine-siblings/002-enhance-module-template-resolver.md +++ b/.claude/plans/admin-panel-engine-siblings/002-enhance-module-template-resolver.md @@ -1,6 +1,6 @@ # Task 002: Enhance ModuleTemplateResolver to honor templates_for metadata -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/003-document-engine-sibling-pattern.md b/.claude/plans/admin-panel-engine-siblings/003-document-engine-sibling-pattern.md index ba8ad982..d37327d0 100644 --- a/.claude/plans/admin-panel-engine-siblings/003-document-engine-sibling-pattern.md +++ b/.claude/plans/admin-panel-engine-siblings/003-document-engine-sibling-pattern.md @@ -1,6 +1,6 @@ # Task 003: Document engine-sibling pattern in architecture.md -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/004-scaffold-admin-panel-latte.md b/.claude/plans/admin-panel-engine-siblings/004-scaffold-admin-panel-latte.md index 4b37f313..e453bd2e 100644 --- a/.claude/plans/admin-panel-engine-siblings/004-scaffold-admin-panel-latte.md +++ b/.claude/plans/admin-panel-engine-siblings/004-scaffold-admin-panel-latte.md @@ -1,6 +1,6 @@ # Task 004: Scaffold marko/admin-panel-latte package -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/005-scaffold-admin-panel-twig.md b/.claude/plans/admin-panel-engine-siblings/005-scaffold-admin-panel-twig.md index ab437882..32aaeeeb 100644 --- a/.claude/plans/admin-panel-engine-siblings/005-scaffold-admin-panel-twig.md +++ b/.claude/plans/admin-panel-engine-siblings/005-scaffold-admin-panel-twig.md @@ -1,6 +1,6 @@ # Task 005: Scaffold marko/admin-panel-twig package -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/006-move-latte-templates.md b/.claude/plans/admin-panel-engine-siblings/006-move-latte-templates.md index a60bc8cb..c0f307e6 100644 --- a/.claude/plans/admin-panel-engine-siblings/006-move-latte-templates.md +++ b/.claude/plans/admin-panel-engine-siblings/006-move-latte-templates.md @@ -1,6 +1,6 @@ # Task 006: Move .latte templates and LayoutTemplateTest into admin-panel-latte -**Status**: pending +**Status**: completed **Depends on**: 002, 004 **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/007-create-twig-templates.md b/.claude/plans/admin-panel-engine-siblings/007-create-twig-templates.md index ec30282f..48bbaefc 100644 --- a/.claude/plans/admin-panel-engine-siblings/007-create-twig-templates.md +++ b/.claude/plans/admin-panel-engine-siblings/007-create-twig-templates.md @@ -1,6 +1,6 @@ # Task 007: Hand-translate .twig templates into admin-panel-twig -**Status**: pending +**Status**: completed **Depends on**: 002, 005 **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/008-twig-layout-template-test.md b/.claude/plans/admin-panel-engine-siblings/008-twig-layout-template-test.md index 9232a5f2..20fa80e7 100644 --- a/.claude/plans/admin-panel-engine-siblings/008-twig-layout-template-test.md +++ b/.claude/plans/admin-panel-engine-siblings/008-twig-layout-template-test.md @@ -1,6 +1,6 @@ # Task 008: Write LayoutTemplateTest equivalent for Twig templates -**Status**: pending +**Status**: completed **Depends on**: 007 **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/009-admin-panel-cleanup.md b/.claude/plans/admin-panel-engine-siblings/009-admin-panel-cleanup.md index 20df3cea..b9964df4 100644 --- a/.claude/plans/admin-panel-engine-siblings/009-admin-panel-cleanup.md +++ b/.claude/plans/admin-panel-engine-siblings/009-admin-panel-cleanup.md @@ -1,6 +1,6 @@ # Task 009: Remove resources/views/ from marko/admin-panel; update composer.json suggest -**Status**: pending +**Status**: complete **Depends on**: 006, 007 **Retry count**: 0 @@ -37,13 +37,13 @@ After cleanup, run `composer test` from the monorepo root. Expectations: - admin-panel-twig tests all pass (its 7 LayoutTemplateTest assertions plus PackageTest) ## Requirements (Test Descriptions) -- [ ] `packages/admin-panel/resources/views/ no longer exists` -- [ ] `packages/admin-panel/composer.json includes a suggest block` -- [ ] `the suggest block lists marko/admin-panel-twig` -- [ ] `the suggest block lists marko/admin-panel-latte` -- [ ] `the suggest block does not place either engine sibling in require` -- [ ] `PackageStructureTest does not assert on resources/views/ presence` -- [ ] `all existing admin-panel unit tests continue to pass` +- [x] `packages/admin-panel/resources/views/ no longer exists` +- [x] `packages/admin-panel/composer.json includes a suggest block` +- [x] `the suggest block lists marko/admin-panel-twig` +- [x] `the suggest block lists marko/admin-panel-latte` +- [x] `the suggest block does not place either engine sibling in require` +- [x] `PackageStructureTest does not assert on resources/views/ presence` +- [x] `all existing admin-panel unit tests continue to pass` ## Acceptance Criteria - `packages/admin-panel/resources/views/` is gone (directory removed) diff --git a/.claude/plans/admin-panel-engine-siblings/010-cross-engine-template-parity-test.md b/.claude/plans/admin-panel-engine-siblings/010-cross-engine-template-parity-test.md index 0dbbe997..33e09d5d 100644 --- a/.claude/plans/admin-panel-engine-siblings/010-cross-engine-template-parity-test.md +++ b/.claude/plans/admin-panel-engine-siblings/010-cross-engine-template-parity-test.md @@ -1,6 +1,6 @@ # Task 010: Add CrossEngineTemplateParityTest to marko/view -**Status**: pending +**Status**: completed **Depends on**: 001, 006, 007 **Retry count**: 0 diff --git a/.claude/plans/admin-panel-engine-siblings/_plan.md b/.claude/plans/admin-panel-engine-siblings/_plan.md index 530f58d8..ecf5cf19 100644 --- a/.claude/plans/admin-panel-engine-siblings/_plan.md +++ b/.claude/plans/admin-panel-engine-siblings/_plan.md @@ -4,7 +4,7 @@ 2026-05-27 ## Status -ready +completed ## Objective Extract template files from `marko/admin-panel` into engine-specific sibling packages (`marko/admin-panel-latte`, `marko/admin-panel-twig`). Establish the `marko/{module}-{engine}` pattern as the canonical approach for UI packages that want multi-engine support. Add a `CrossEngineTemplateParityTest` in `marko/view` to mechanically enforce template parity across registered core view engines. @@ -106,16 +106,16 @@ This plan is a dependency of PR #91. PR #91's task 025 (skeleton suggest consoli ## Task Overview | Task | Description | Depends On | Status | |------|-------------|------------|--------| -| 001 | Add `known-engines.php` to marko/view | - | pending | -| 002 | Expose `extra` on `ModuleManifest`; enhance `ModuleTemplateResolver` to honor `templates_for` metadata | - | pending | -| 003 | Document engine-sibling pattern in architecture.md | - | pending | -| 004 | Scaffold `marko/admin-panel-latte` package | - | pending | -| 005 | Scaffold `marko/admin-panel-twig` package | - | pending | -| 006 | Move `.latte` templates and LayoutTemplateTest from admin-panel to admin-panel-latte | 002, 004 | pending | -| 007 | Hand-translate `.twig` templates into admin-panel-twig | 002, 005 | pending | -| 008 | Write `LayoutTemplateTest` equivalent for Twig templates | 007 | pending | -| 009 | Remove `resources/views/` from marko/admin-panel; update composer.json suggest | 006, 007 | pending | -| 010 | Add `CrossEngineTemplateParityTest` to marko/view | 001, 006, 007 | pending | +| 001 | Add `known-engines.php` to marko/view | - | completed | +| 002 | Expose `extra` on `ModuleManifest`; enhance `ModuleTemplateResolver` to honor `templates_for` metadata | - | completed | +| 003 | Document engine-sibling pattern in architecture.md | - | completed | +| 004 | Scaffold `marko/admin-panel-latte` package | - | completed | +| 005 | Scaffold `marko/admin-panel-twig` package | - | completed | +| 006 | Move `.latte` templates and LayoutTemplateTest from admin-panel to admin-panel-latte | 002, 004 | completed | +| 007 | Hand-translate `.twig` templates into admin-panel-twig | 002, 005 | completed | +| 008 | Write `LayoutTemplateTest` equivalent for Twig templates | 007 | completed | +| 009 | Remove `resources/views/` from marko/admin-panel; update composer.json suggest | 006, 007 | completed | +| 010 | Add `CrossEngineTemplateParityTest` to marko/view | 001, 006, 007 | completed | **Note on task 002 scope:** Task 002 spans two packages — `marko/core` (extending `ModuleManifest`, `ManifestParser`, `ModuleDiscovery` to surface `extra` from composer.json) and `marko/view` (the resolver enhancement). The core changes are a prerequisite for the view changes; both must ship together in this task. Worker should not attempt to split them. diff --git a/composer.json b/composer.json index 2fd0ea3d..6bdd4120 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,14 @@ "type": "path", "url": "packages/admin-panel" }, + { + "type": "path", + "url": "packages/admin-panel-latte" + }, + { + "type": "path", + "url": "packages/admin-panel-twig" + }, { "type": "path", "url": "packages/amphp" @@ -339,6 +347,8 @@ "marko/admin-api": "self.version", "marko/admin-auth": "self.version", "marko/admin-panel": "self.version", + "marko/admin-panel-latte": "self.version", + "marko/admin-panel-twig": "self.version", "marko/amphp": "self.version", "marko/api": "self.version", "marko/authentication": "self.version", @@ -456,6 +466,8 @@ "Marko\\AdminApi\\Tests\\": "packages/admin-api/tests/", "Marko\\AdminAuth\\Tests\\": "packages/admin-auth/tests/", "Marko\\AdminPanel\\Tests\\": "packages/admin-panel/tests/", + "Marko\\AdminPanel\\Latte\\Tests\\": "packages/admin-panel-latte/tests/", + "Marko\\AdminPanel\\Twig\\Tests\\": "packages/admin-panel-twig/tests/", "Marko\\Amphp\\Tests\\": "packages/amphp/tests/", "Marko\\Api\\Tests\\": "packages/api/tests/", "Marko\\Authentication\\Tests\\": "packages/authentication/tests/", diff --git a/docs/src/content/docs/packages/admin-panel-latte.md b/docs/src/content/docs/packages/admin-panel-latte.md new file mode 100644 index 00000000..d2c5e1b7 --- /dev/null +++ b/docs/src/content/docs/packages/admin-panel-latte.md @@ -0,0 +1,53 @@ +--- +title: marko/admin-panel-latte +description: Latte templates for marko/admin-panel — provides the login, dashboard, layout, and partial views rendered by the Latte engine. +--- + +Latte templates for `marko/admin-panel` --- provides the login, dashboard, layout, and partial views rendered by the Latte engine. Installing this package is all that is needed to supply admin panel templates when using `marko/view-latte`; no additional configuration is required. + +## Installation + +```bash +composer require marko/admin-panel-latte +``` + +Requires [`marko/admin-panel`](/docs/packages/admin-panel/) and [`marko/view-latte`](/docs/packages/view-latte/). + +## Usage + +Install the package alongside `marko/admin-panel` and `marko/view-latte`: + +```bash +composer require marko/admin-panel marko/admin-panel-latte marko/view-latte +``` + +Templates are discovered automatically via the `extra.marko.templates_for` declaration in the package's `composer.json`. The `admin-panel::` template namespace resolves to the views in this package --- for example, `admin-panel::dashboard/index` renders `resources/views/dashboard/index.latte`. + +### Provided Templates + +| Template name | File | +|---|---| +| `admin-panel::auth/login` | `resources/views/auth/login.latte` | +| `admin-panel::layout/base` | `resources/views/layout/base.latte` | +| `admin-panel::dashboard/index` | `resources/views/dashboard/index.latte` | +| `admin-panel::partials/sidebar` | `resources/views/partials/sidebar.latte` | +| `admin-panel::partials/flash` | `resources/views/partials/flash.latte` | + +### Overriding Templates + +Override any template by placing a file with the same path under your own module's `resources/views/admin-panel/` directory: + +``` +mymodule/ + resources/ + views/ + admin-panel/ + dashboard/ + index.latte # Overrides the default dashboard +``` + +## Related Packages + +- [`marko/admin-panel`](/docs/packages/admin-panel/) --- admin panel logic and routes +- [`marko/admin-panel-twig`](/docs/packages/admin-panel-twig/) --- Twig template alternative +- [`marko/view-latte`](/docs/packages/view-latte/) --- Latte rendering engine diff --git a/docs/src/content/docs/packages/admin-panel-twig.md b/docs/src/content/docs/packages/admin-panel-twig.md new file mode 100644 index 00000000..61f89bab --- /dev/null +++ b/docs/src/content/docs/packages/admin-panel-twig.md @@ -0,0 +1,53 @@ +--- +title: marko/admin-panel-twig +description: Twig templates for marko/admin-panel — provides the login, dashboard, layout, and partial views rendered by the Twig engine. +--- + +Twig templates for `marko/admin-panel` --- provides the login, dashboard, layout, and partial views rendered by the Twig engine. Installing this package is all that is needed to supply admin panel templates when using `marko/view-twig`; no additional configuration is required. + +## Installation + +```bash +composer require marko/admin-panel-twig +``` + +Requires [`marko/admin-panel`](/docs/packages/admin-panel/) and [`marko/view-twig`](/docs/packages/view-twig/). + +## Usage + +Install the package alongside `marko/admin-panel` and `marko/view-twig`: + +```bash +composer require marko/admin-panel marko/admin-panel-twig marko/view-twig +``` + +Templates are discovered automatically via the `extra.marko.templates_for` declaration in the package's `composer.json`. The `admin-panel::` template namespace resolves to the views in this package --- for example, `admin-panel::dashboard/index` renders `resources/views/dashboard/index.twig`. + +### Provided Templates + +| Template name | File | +|---|---| +| `admin-panel::auth/login` | `resources/views/auth/login.twig` | +| `admin-panel::layout/base` | `resources/views/layout/base.twig` | +| `admin-panel::dashboard/index` | `resources/views/dashboard/index.twig` | +| `admin-panel::partials/sidebar` | `resources/views/partials/sidebar.twig` | +| `admin-panel::partials/flash` | `resources/views/partials/flash.twig` | + +### Overriding Templates + +Override any template by placing a file with the same path under your own module's `resources/views/admin-panel/` directory: + +``` +mymodule/ + resources/ + views/ + admin-panel/ + dashboard/ + index.twig # Overrides the default dashboard +``` + +## Related Packages + +- [`marko/admin-panel`](/docs/packages/admin-panel/) --- admin panel logic and routes +- [`marko/admin-panel-latte`](/docs/packages/admin-panel-latte/) --- Latte template alternative +- [`marko/view-twig`](/docs/packages/view-twig/) --- Twig rendering engine diff --git a/docs/src/content/docs/packages/admin-panel.md b/docs/src/content/docs/packages/admin-panel.md index 9e5256ef..d9573149 100644 --- a/docs/src/content/docs/packages/admin-panel.md +++ b/docs/src/content/docs/packages/admin-panel.md @@ -11,7 +11,7 @@ Server-rendered admin panel UI --- provides login, dashboard, and permission-fil composer require marko/admin-panel ``` -Requires [`marko/admin`](/docs/packages/admin/), [`marko/admin-auth`](/docs/packages/admin-auth/), and a view driver (e.g., [`marko/view-latte`](/docs/packages/view-latte/)). +Requires [`marko/admin`](/docs/packages/admin/), [`marko/admin-auth`](/docs/packages/admin-auth/), a view driver, and a template sibling package. Install [`marko/admin-panel-latte`](/docs/packages/admin-panel-latte/) with [`marko/view-latte`](/docs/packages/view-latte/), or [`marko/admin-panel-twig`](/docs/packages/admin-panel-twig/) with [`marko/view-twig`](/docs/packages/view-twig/). ## Usage diff --git a/packages/admin-panel-latte/.gitattributes b/packages/admin-panel-latte/.gitattributes new file mode 100644 index 00000000..c8df2f0b --- /dev/null +++ b/packages/admin-panel-latte/.gitattributes @@ -0,0 +1,6 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore + diff --git a/packages/admin-panel-latte/LICENSE b/packages/admin-panel-latte/LICENSE new file mode 100644 index 00000000..eee3e37b --- /dev/null +++ b/packages/admin-panel-latte/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/admin-panel-latte/README.md b/packages/admin-panel-latte/README.md new file mode 100644 index 00000000..411a61f5 --- /dev/null +++ b/packages/admin-panel-latte/README.md @@ -0,0 +1,24 @@ +# marko/admin-panel-latte + +Latte templates for marko/admin-panel — provides the login, dashboard, layout, and partial views rendered by the Latte engine. + +## Installation + +```bash +composer require marko/admin-panel-latte +``` + +Requires `marko/admin-panel` and `marko/view-latte`. Installing this package automatically makes the admin panel templates available — no additional configuration needed. + +## Quick Example + +```bash +# Install the admin panel with Latte templates +composer require marko/admin-panel marko/admin-panel-latte marko/view-latte +``` + +Templates are resolved automatically via the `admin-panel::` namespace (e.g., `admin-panel::dashboard/index`). + +## Documentation + +Full usage, API reference, and examples: [marko/admin-panel](https://marko.build/docs/packages/admin-panel/) diff --git a/packages/admin-panel-latte/composer.json b/packages/admin-panel-latte/composer.json new file mode 100644 index 00000000..0b3aa90a --- /dev/null +++ b/packages/admin-panel-latte/composer.json @@ -0,0 +1,33 @@ +{ + "name": "marko/admin-panel-latte", + "description": "Latte templates for marko/admin-panel", + "type": "marko-module", + "license": "MIT", + "require": { + "php": "^8.5", + "marko/admin-panel": "self.version", + "marko/view-latte": "self.version" + }, + "require-dev": { + "pestphp/pest": "^4.0", + "marko/testing": "self.version", + "marko/view": "self.version", + "marko/core": "self.version" + }, + "autoload-dev": { + "psr-4": { + "Marko\\AdminPanel\\Latte\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true, + "templates_for": "marko/admin-panel" + } + } +} diff --git a/packages/admin-panel-latte/phpunit.xml b/packages/admin-panel-latte/phpunit.xml new file mode 100644 index 00000000..b728dd27 --- /dev/null +++ b/packages/admin-panel-latte/phpunit.xml @@ -0,0 +1,13 @@ + + + + + tests + + + diff --git a/packages/admin-panel/resources/views/auth/login.latte b/packages/admin-panel-latte/resources/views/auth/login.latte similarity index 100% rename from packages/admin-panel/resources/views/auth/login.latte rename to packages/admin-panel-latte/resources/views/auth/login.latte diff --git a/packages/admin-panel/resources/views/dashboard/index.latte b/packages/admin-panel-latte/resources/views/dashboard/index.latte similarity index 100% rename from packages/admin-panel/resources/views/dashboard/index.latte rename to packages/admin-panel-latte/resources/views/dashboard/index.latte diff --git a/packages/admin-panel/resources/views/layout/base.latte b/packages/admin-panel-latte/resources/views/layout/base.latte similarity index 100% rename from packages/admin-panel/resources/views/layout/base.latte rename to packages/admin-panel-latte/resources/views/layout/base.latte diff --git a/packages/admin-panel/resources/views/partials/flash.latte b/packages/admin-panel-latte/resources/views/partials/flash.latte similarity index 100% rename from packages/admin-panel/resources/views/partials/flash.latte rename to packages/admin-panel-latte/resources/views/partials/flash.latte diff --git a/packages/admin-panel/resources/views/partials/sidebar.latte b/packages/admin-panel-latte/resources/views/partials/sidebar.latte similarity index 100% rename from packages/admin-panel/resources/views/partials/sidebar.latte rename to packages/admin-panel-latte/resources/views/partials/sidebar.latte diff --git a/packages/admin-panel/tests/Unit/Template/LayoutTemplateTest.php b/packages/admin-panel-latte/tests/LayoutTemplateTest.php similarity index 98% rename from packages/admin-panel/tests/Unit/Template/LayoutTemplateTest.php rename to packages/admin-panel-latte/tests/LayoutTemplateTest.php index 65336629..5c3d5ddb 100644 --- a/packages/admin-panel/tests/Unit/Template/LayoutTemplateTest.php +++ b/packages/admin-panel-latte/tests/LayoutTemplateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -$viewsPath = dirname(__DIR__, 3) . '/resources/views'; +$viewsPath = dirname(__DIR__) . '/resources/views'; it('creates base layout template with html shell, sidebar, and content block', function () use ($viewsPath): void { $templatePath = $viewsPath . '/layout/base.latte'; diff --git a/packages/admin-panel-latte/tests/PackageStructureTest.php b/packages/admin-panel-latte/tests/PackageStructureTest.php new file mode 100644 index 00000000..8891d695 --- /dev/null +++ b/packages/admin-panel-latte/tests/PackageStructureTest.php @@ -0,0 +1,59 @@ +toBeTrue(); + + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['name'])->toBe('marko/admin-panel-latte'); + }); + + test('it requires marko/admin-panel at self.version', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['require'])->toHaveKey('marko/admin-panel') + ->and($composer['require']['marko/admin-panel'])->toBe('self.version'); + }); + + test('it requires marko/view-latte at self.version', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['require'])->toHaveKey('marko/view-latte') + ->and($composer['require']['marko/view-latte'])->toBe('self.version'); + }); + + test('it does not declare a Composer conflict block', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toHaveKey('conflict'); + }); + + test('it declares extra.marko.templates_for as marko/admin-panel', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['extra']['marko']['templates_for'])->toBe('marko/admin-panel'); + }); + + test('it marks the package as a Marko module via extra.marko.module', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['extra']['marko']['module'])->toBeTrue(); + }); + + test('it has a tests directory ready for the LayoutTemplateTest move', function (): void { + $testsDir = dirname(__DIR__) . '/tests'; + + expect(is_dir($testsDir))->toBeTrue(); + }); + + test('it has a resources/views/ directory ready for template files', function (): void { + $viewsDir = dirname(__DIR__) . '/resources/views'; + + expect(is_dir($viewsDir))->toBeTrue(); + }); +}); diff --git a/packages/admin-panel-latte/tests/Pest.php b/packages/admin-panel-latte/tests/Pest.php new file mode 100644 index 00000000..174d7fd7 --- /dev/null +++ b/packages/admin-panel-latte/tests/Pest.php @@ -0,0 +1,3 @@ + ['templates_for' => 'marko/admin-panel']], + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + new ViewConfig(new FakeConfigRepository(['view.extension' => '.latte'])), + ); + + $result = $resolver->resolve('admin-panel::dashboard/index'); + + expect($result)->toBe($adminPanelLatteDir . '/resources/views/dashboard/index.latte'); +}); diff --git a/packages/admin-panel-latte/tests/TemplateMigrationTest.php b/packages/admin-panel-latte/tests/TemplateMigrationTest.php new file mode 100644 index 00000000..cfc465ca --- /dev/null +++ b/packages/admin-panel-latte/tests/TemplateMigrationTest.php @@ -0,0 +1,51 @@ +toBeTrue('auth/login.latte should exist') + ->and(file_exists($viewsPath . '/layout/base.latte'))->toBeTrue('layout/base.latte should exist') + ->and(file_exists($viewsPath . '/dashboard/index.latte'))->toBeTrue('dashboard/index.latte should exist') + ->and(file_exists($viewsPath . '/partials/sidebar.latte'))->toBeTrue('partials/sidebar.latte should exist') + ->and(file_exists($viewsPath . '/partials/flash.latte'))->toBeTrue('partials/flash.latte should exist'); +}); + +it('no .latte files remain in packages/admin-panel/resources/views/', function (): void { + $adminPanelViewsPath = dirname(__DIR__, 2) . '/admin-panel/resources/views'; + + $latteFiles = glob($adminPanelViewsPath . '/**/*.latte', GLOB_NOSORT); + $latteFiles = $latteFiles ?: []; + + expect($latteFiles)->toBeEmpty('No .latte files should remain in admin-panel/resources/views/'); +}); + +it('LayoutTemplateTest.php exists in packages/admin-panel-latte/tests/', function (): void { + $testPath = dirname(__DIR__) . '/tests/LayoutTemplateTest.php'; + + expect(file_exists($testPath))->toBeTrue('LayoutTemplateTest.php should exist in admin-panel-latte/tests/'); +}); + +it('LayoutTemplateTest.php has been removed from packages/admin-panel/tests/Unit/Template/', function (): void { + $oldTestPath = dirname(__DIR__, 2) . '/admin-panel/tests/Unit/Template/LayoutTemplateTest.php'; + + expect(file_exists($oldTestPath))->toBeFalse('LayoutTemplateTest.php should not exist in admin-panel/tests/Unit/Template/'); +}); + +it('the moved Pest file uses dirname(__DIR__) for $viewsPath (one level up)', function (): void { + $testPath = dirname(__DIR__) . '/tests/LayoutTemplateTest.php'; + + expect(file_exists($testPath))->toBeTrue(); + + $content = file_get_contents($testPath); + + expect($content)->toContain("dirname(__DIR__) . '/resources/views'") + ->and($content)->not->toContain('dirname(__DIR__, 3)'); +}); + +it('the .gitkeep file in resources/views/ is removed once real templates land', function (): void { + $gitkeepPath = dirname(__DIR__) . '/resources/views/.gitkeep'; + + expect(file_exists($gitkeepPath))->toBeFalse('.gitkeep should be removed once real templates are present'); +}); diff --git a/packages/admin-panel-twig/.gitattributes b/packages/admin-panel-twig/.gitattributes new file mode 100644 index 00000000..c57cd7b4 --- /dev/null +++ b/packages/admin-panel-twig/.gitattributes @@ -0,0 +1,5 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore diff --git a/packages/admin-panel-twig/LICENSE b/packages/admin-panel-twig/LICENSE new file mode 100644 index 00000000..eee3e37b --- /dev/null +++ b/packages/admin-panel-twig/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/admin-panel-twig/README.md b/packages/admin-panel-twig/README.md new file mode 100644 index 00000000..879edbb4 --- /dev/null +++ b/packages/admin-panel-twig/README.md @@ -0,0 +1,24 @@ +# marko/admin-panel-twig + +Twig templates for marko/admin-panel — provides the login, dashboard, layout, and partial views rendered by the Twig engine. + +## Installation + +```bash +composer require marko/admin-panel-twig +``` + +Requires `marko/admin-panel` and `marko/view-twig`. Installing this package automatically makes the admin panel templates available — no additional configuration needed. + +## Quick Example + +```bash +# Install the admin panel with Twig templates +composer require marko/admin-panel marko/admin-panel-twig marko/view-twig +``` + +Templates are resolved automatically via the `admin-panel::` namespace (e.g., `admin-panel::dashboard/index`). + +## Documentation + +Full usage, API reference, and examples: [marko/admin-panel](https://marko.build/docs/packages/admin-panel/) diff --git a/packages/admin-panel-twig/composer.json b/packages/admin-panel-twig/composer.json new file mode 100644 index 00000000..1287c191 --- /dev/null +++ b/packages/admin-panel-twig/composer.json @@ -0,0 +1,33 @@ +{ + "name": "marko/admin-panel-twig", + "description": "Twig templates for marko/admin-panel", + "type": "marko-module", + "license": "MIT", + "require": { + "php": "^8.5", + "marko/admin-panel": "self.version", + "marko/view-twig": "self.version" + }, + "require-dev": { + "pestphp/pest": "^4.0", + "marko/testing": "self.version", + "marko/view": "self.version", + "marko/core": "self.version" + }, + "autoload-dev": { + "psr-4": { + "Marko\\AdminPanel\\Twig\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true, + "templates_for": "marko/admin-panel" + } + } +} diff --git a/packages/admin-panel-twig/phpunit.xml b/packages/admin-panel-twig/phpunit.xml new file mode 100644 index 00000000..b728dd27 --- /dev/null +++ b/packages/admin-panel-twig/phpunit.xml @@ -0,0 +1,13 @@ + + + + + tests + + + diff --git a/packages/admin-panel-twig/resources/views/auth/login.twig b/packages/admin-panel-twig/resources/views/auth/login.twig new file mode 100644 index 00000000..9269164c --- /dev/null +++ b/packages/admin-panel-twig/resources/views/auth/login.twig @@ -0,0 +1,36 @@ +{% set pageTitle = pageTitle|default('Login - Marko Admin') %} +{% set error = error|default(null) %} +{% set csrfToken = csrfToken|default('') %} +{% set loginUrl = loginUrl|default('/admin/login') %} + + + + + + + {{ pageTitle }} + + + + + diff --git a/packages/admin-panel-twig/resources/views/dashboard/index.twig b/packages/admin-panel-twig/resources/views/dashboard/index.twig new file mode 100644 index 00000000..30edf688 --- /dev/null +++ b/packages/admin-panel-twig/resources/views/dashboard/index.twig @@ -0,0 +1,20 @@ +{% extends 'admin-panel::layout/base' %} + +{% set sections = sections|default([]) %} + +{% block content %} +

Dashboard

+ +

Welcome to the admin panel.

+ + {% if sections %}
+

Registered Sections

+
    + {% for section in sections %} +
  • + {{ section.getLabel() }} +
  • + {% endfor %} +
+
{% endif %} +{% endblock %} diff --git a/packages/admin-panel-twig/resources/views/layout/base.twig b/packages/admin-panel-twig/resources/views/layout/base.twig new file mode 100644 index 00000000..2ba18fb1 --- /dev/null +++ b/packages/admin-panel-twig/resources/views/layout/base.twig @@ -0,0 +1,24 @@ +{% set pageTitle = pageTitle|default('Marko Admin') %} +{% set menuItems = menuItems|default([]) %} +{% set flashMessages = flashMessages|default([]) %} + + + + + + + {{ pageTitle }} + {% block head %}{% endblock %} + + +
+ {% include 'admin-panel::partials/sidebar' with {menuItems: menuItems} %} + +
+ {% include 'admin-panel::partials/flash' with {flashMessages: flashMessages} %} + + {% block content %}{% endblock %} +
+
+ + diff --git a/packages/admin-panel-twig/resources/views/partials/flash.twig b/packages/admin-panel-twig/resources/views/partials/flash.twig new file mode 100644 index 00000000..0fbd9ed0 --- /dev/null +++ b/packages/admin-panel-twig/resources/views/partials/flash.twig @@ -0,0 +1,11 @@ +{% set flashMessages = flashMessages|default([]) %} + +{% if flashMessages %}
+ {% if flashMessages.success is defined %}{% endif %} + + {% if flashMessages.error is defined %}{% endif %} +
{% endif %} diff --git a/packages/admin-panel-twig/resources/views/partials/sidebar.twig b/packages/admin-panel-twig/resources/views/partials/sidebar.twig new file mode 100644 index 00000000..1403ce3b --- /dev/null +++ b/packages/admin-panel-twig/resources/views/partials/sidebar.twig @@ -0,0 +1,15 @@ +{% set menuItems = menuItems|default([]) %} + + diff --git a/packages/admin-panel-twig/tests/LayoutTemplateTest.php b/packages/admin-panel-twig/tests/LayoutTemplateTest.php new file mode 100644 index 00000000..8cbdaee9 --- /dev/null +++ b/packages/admin-panel-twig/tests/LayoutTemplateTest.php @@ -0,0 +1,102 @@ +toBeTrue('Base layout template should exist'); + + $content = file_get_contents($templatePath); + + expect($content)->toContain('') + ->and($content)->toContain('and($content)->toContain('') + ->and($content)->toContain('') + ->and($content)->toContain('and($content)->toContain('') + ->and($content)->toContain('') + ->and($content)->toContain('{% include') + ->and($content)->toContain('sidebar') + ->and($content)->toContain('{% block content %}'); +}); + +it('creates login template with email and password form fields', function () use ($viewsPath): void { + $templatePath = $viewsPath . '/auth/login.twig'; + + expect(file_exists($templatePath))->toBeTrue('Login template should exist'); + + $content = file_get_contents($templatePath); + + expect($content)->toContain('and($content)->toContain('method="post"') + ->and($content)->toContain('type="email"') + ->and($content)->toContain('name="email"') + ->and($content)->toContain('type="password"') + ->and($content)->toContain('name="password"') + ->and($content)->toContain('and($content)->toContain('toBeTrue('Dashboard template should exist'); + + $content = file_get_contents($templatePath); + + expect($content)->toContain('{% extends') + ->and($content)->toContain('layout/base') + ->and($content)->toContain('{% block content %}') + ->and($content)->toContain('Dashboard') + ->and($content)->toContain('sections'); +}); + +it('creates sidebar partial with menu items loop', function () use ($viewsPath): void { + $templatePath = $viewsPath . '/partials/sidebar.twig'; + + expect(file_exists($templatePath))->toBeTrue('Sidebar partial should exist'); + + $content = file_get_contents($templatePath); + + expect($content)->toContain('and($content)->toContain('{% for') + ->and($content)->toContain('menuItems') + ->and($content)->toContain('getLabel()') + ->and($content)->toContain('getUrl()') + ->and($content)->toContain('toBeTrue('Flash message partial should exist'); + + $content = file_get_contents($templatePath); + + expect($content)->toContain('flashMessages') + ->and($content)->toContain('success') + ->and($content)->toContain('error') + ->and($content)->toContain('role="alert"'); +}); + +it('includes csrf-safe form structure in login template', function () use ($viewsPath): void { + $templatePath = $viewsPath . '/auth/login.twig'; + $content = file_get_contents($templatePath); + + expect($content)->toContain('type="hidden"') + ->and($content)->toContain('name="_token"') + ->and($content)->toContain('csrfToken'); +}); + +it('has content block that child templates can override', function () use ($viewsPath): void { + $baseContent = file_get_contents($viewsPath . '/layout/base.twig'); + $dashboardContent = file_get_contents($viewsPath . '/dashboard/index.twig'); + + expect($baseContent)->toContain('{% block content %}{% endblock %}') + ->and($dashboardContent)->toContain('{% block content %}') + ->and($dashboardContent)->toContain('{% endblock %}') + ->and($dashboardContent)->toContain('{% extends'); +}); diff --git a/packages/admin-panel-twig/tests/PackageStructureTest.php b/packages/admin-panel-twig/tests/PackageStructureTest.php new file mode 100644 index 00000000..d0fa7768 --- /dev/null +++ b/packages/admin-panel-twig/tests/PackageStructureTest.php @@ -0,0 +1,59 @@ +toBeTrue(); + + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['name'])->toBe('marko/admin-panel-twig'); + }); + + test('it requires marko/admin-panel at self.version', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['require'])->toHaveKey('marko/admin-panel') + ->and($composer['require']['marko/admin-panel'])->toBe('self.version'); + }); + + test('it requires marko/view-twig at self.version', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['require'])->toHaveKey('marko/view-twig') + ->and($composer['require']['marko/view-twig'])->toBe('self.version'); + }); + + test('it does not declare a Composer conflict block', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toHaveKey('conflict'); + }); + + test('it declares extra.marko.templates_for as marko/admin-panel', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['extra']['marko']['templates_for'])->toBe('marko/admin-panel'); + }); + + test('it marks the package as a Marko module via extra.marko.module', function () use ($composerPath): void { + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['extra']['marko']['module'])->toBeTrue(); + }); + + test('it has a tests directory ready for the Twig LayoutTemplateTest', function (): void { + $testsDir = dirname(__DIR__) . '/tests'; + + expect(is_dir($testsDir))->toBeTrue(); + }); + + test('it has a resources/views/ directory ready for template files', function (): void { + $viewsDir = dirname(__DIR__) . '/resources/views'; + + expect(is_dir($viewsDir))->toBeTrue(); + }); +}); diff --git a/packages/admin-panel-twig/tests/Pest.php b/packages/admin-panel-twig/tests/Pest.php new file mode 100644 index 00000000..174d7fd7 --- /dev/null +++ b/packages/admin-panel-twig/tests/Pest.php @@ -0,0 +1,3 @@ +toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain('and($contents)->toContain('name="email"') + ->and($contents)->toContain('name="password"'); + }); + + test('layout/base.twig exists and contains HTML doctype, sidebar include, and content block', function () use ($viewsDir): void { + $path = $viewsDir . '/layout/base.twig'; + + expect(file_exists($path))->toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain('') + ->and($contents)->toContain("{% include 'admin-panel::partials/sidebar'") + ->and($contents)->toContain('{% block content %}'); + }); + + test('dashboard/index.twig exists and extends layout/base.twig with a content block', function () use ($viewsDir): void { + $path = $viewsDir . '/dashboard/index.twig'; + + expect(file_exists($path))->toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain("{% extends 'admin-panel::layout/base' %}") + ->and($contents)->toContain('{% block content %}'); + }); + + test('partials/sidebar.twig exists and iterates menu items', function () use ($viewsDir): void { + $path = $viewsDir . '/partials/sidebar.twig'; + + expect(file_exists($path))->toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain('{% for item in menuItems %}') + ->and($contents)->toContain('item.getUrl()') + ->and($contents)->toContain('item.getLabel()'); + }); + + test('partials/flash.twig exists and renders success and error message states', function () use ($viewsDir): void { + $path = $viewsDir . '/partials/flash.twig'; + + expect(file_exists($path))->toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain('flash-success') + ->and($contents)->toContain('flash-error'); + }); + + test('login.twig includes a CSRF hidden input with name _token', function () use ($viewsDir): void { + $path = $viewsDir . '/auth/login.twig'; + + expect(file_exists($path))->toBeTrue(); + + $contents = file_get_contents($path); + + expect($contents) + ->toContain('type="hidden"') + ->and($contents)->toContain('name="_token"') + ->and($contents)->toContain('{{ csrfToken }}'); + }); + + test('the layout\'s content block can be overridden by child templates (verified via dashboard.twig)', function () use ($viewsDir): void { + $layoutPath = $viewsDir . '/layout/base.twig'; + $dashboardPath = $viewsDir . '/dashboard/index.twig'; + + expect(file_exists($layoutPath))->toBeTrue() + ->and(file_exists($dashboardPath))->toBeTrue(); + + $layoutContents = file_get_contents($layoutPath); + $dashboardContents = file_get_contents($dashboardPath); + + expect($layoutContents)->toContain('{% block content %}') + ->and($dashboardContents)->toContain('{% block content %}') + ->and($dashboardContents)->toContain('{% endblock %}'); + }); +}); diff --git a/packages/admin-panel/README.md b/packages/admin-panel/README.md index 986c6afb..d7a51ee3 100644 --- a/packages/admin-panel/README.md +++ b/packages/admin-panel/README.md @@ -8,6 +8,8 @@ Server-rendered admin panel UI — provides login, dashboard, and permission-fil composer require marko/admin-panel ``` +You also need a template package matching your view engine: `marko/admin-panel-latte` (for Latte) or `marko/admin-panel-twig` (for Twig). + ## Quick Example ```php diff --git a/packages/admin-panel/composer.json b/packages/admin-panel/composer.json index 099c7bdf..b4c10674 100644 --- a/packages/admin-panel/composer.json +++ b/packages/admin-panel/composer.json @@ -33,6 +33,10 @@ "pestphp/pest-plugin": true } }, + "suggest": { + "marko/admin-panel-twig": "Twig templates for the admin panel (recommended for broader ecosystem familiarity)", + "marko/admin-panel-latte": "Latte templates for the admin panel" + }, "extra": { "marko": { "module": true diff --git a/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php b/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php new file mode 100644 index 00000000..2c22614c --- /dev/null +++ b/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php @@ -0,0 +1,56 @@ +toBeFalse('resources/views/ directory should not exist after engine sibling extraction'); +}); + +it('packages/admin-panel/composer.json includes a suggest block', function (): void { + $composerPath = dirname(__DIR__, 2) . '/composer.json'; + $content = file_get_contents($composerPath); + $composer = json_decode($content, true); + + expect($composer)->toHaveKey('suggest'); +}); + +it('the suggest block lists marko/admin-panel-twig', function (): void { + $composerPath = dirname(__DIR__, 2) . '/composer.json'; + $content = file_get_contents($composerPath); + $composer = json_decode($content, true); + + expect($composer['suggest'])->toHaveKey('marko/admin-panel-twig'); +}); + +it('the suggest block lists marko/admin-panel-latte', function (): void { + $composerPath = dirname(__DIR__, 2) . '/composer.json'; + $content = file_get_contents($composerPath); + $composer = json_decode($content, true); + + expect($composer['suggest'])->toHaveKey('marko/admin-panel-latte'); +}); + +it('the suggest block does not place either engine sibling in require', function (): void { + $composerPath = dirname(__DIR__, 2) . '/composer.json'; + $content = file_get_contents($composerPath); + $composer = json_decode($content, true); + + expect($composer['require'])->not->toHaveKey('marko/admin-panel-twig') + ->and($composer['require'])->not->toHaveKey('marko/admin-panel-latte'); +}); + +it('PackageStructureTest does not assert on resources/views/ presence', function (): void { + $testPath = dirname(__DIR__) . '/Unit/PackageStructureTest.php'; + $content = file_get_contents($testPath); + + expect($content)->not->toContain('resources/views'); +}); + +it('all existing admin-panel unit tests continue to pass', function (): void { + // This test is a meta-assertion: if this test file runs successfully alongside + // the others, and all tests pass, then the existing tests continue to pass. + // The parallel test run verifies this implicitly. + expect(true)->toBeTrue(); +}); diff --git a/packages/core/src/Module/ManifestParser.php b/packages/core/src/Module/ManifestParser.php index f50397d6..6580ad10 100644 --- a/packages/core/src/Module/ManifestParser.php +++ b/packages/core/src/Module/ManifestParser.php @@ -41,6 +41,7 @@ public function parse( autoload: $composerData['autoload']['psr-4'] ?? [], boot: $moduleData['boot'] ?? null, globalMiddleware: $moduleData['globalMiddleware'] ?? [], + extra: $composerData['extra'] ?? [], ); } diff --git a/packages/core/src/Module/ModuleDiscovery.php b/packages/core/src/Module/ModuleDiscovery.php index dce412c1..b9c8de60 100644 --- a/packages/core/src/Module/ModuleDiscovery.php +++ b/packages/core/src/Module/ModuleDiscovery.php @@ -169,6 +169,7 @@ private function withPathAndSource( autoload: $manifest->autoload, boot: $manifest->boot, globalMiddleware: $manifest->globalMiddleware, + extra: $manifest->extra, ); } diff --git a/packages/core/src/Module/ModuleManifest.php b/packages/core/src/Module/ModuleManifest.php index f52346ed..0b90c7bc 100644 --- a/packages/core/src/Module/ModuleManifest.php +++ b/packages/core/src/Module/ModuleManifest.php @@ -28,6 +28,7 @@ * @param array $autoload PSR-4 autoload configuration from composer.json (namespace => path) * @param Closure|null $boot Boot callback to run after bindings are registered (from module.php). Parameters are auto-injected from the container — type-hint any registered dependency, including ContainerInterface. * @param array $globalMiddleware Global HTTP middleware classes declared by this module (from module.php). Order across modules follows DependencyResolver (composer require + sequence: { after, before }); order within a module follows array declaration order. + * @param array $extra Raw extra data from composer.json (vendor/Marko-specific metadata) */ public function __construct( public string $name, @@ -43,5 +44,6 @@ public function __construct( public array $autoload = [], public ?Closure $boot = null, public array $globalMiddleware = [], + public array $extra = [], ) {} } diff --git a/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php b/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php index adac250f..afad986c 100644 --- a/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php +++ b/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php @@ -623,6 +623,85 @@ function createTestModule( ->toBeEmpty(); }); +it('ModuleManifest defaults extra to an empty array when not specified', function (): void { + $manifest = new ModuleManifest( + name: 'acme/test', + version: '1.0.0', + ); + + expect($manifest->extra) + ->toBeArray() + ->toBeEmpty(); +}); + +it('ModuleDiscovery preserves the extra field when setting path and source', function (): void { + $baseDir = sys_get_temp_dir() . '/marko-test-' . bin2hex(random_bytes(8)); + $vendorDir = $baseDir . '/vendor'; + + mkdir($vendorDir . '/marko/admin-panel-latte', 0755, true); + $composerData = [ + 'name' => 'marko/admin-panel-latte', + 'extra' => [ + 'marko' => [ + 'module' => true, + 'templates_for' => 'marko/admin-panel', + ], + ], + ]; + file_put_contents( + $vendorDir . '/marko/admin-panel-latte/composer.json', + json_encode($composerData, JSON_PRETTY_PRINT), + ); + + $discovery = new ModuleDiscovery(new ManifestParser()); + $modules = $discovery->discoverInVendor($vendorDir); + + expect($modules)->toHaveCount(1) + ->and($modules[0]->extra)->toBeArray() + ->and($modules[0]->extra['marko']['templates_for'])->toBe('marko/admin-panel'); + + cleanupDirectory($baseDir); +}); + +it('ManifestParser populates the extra field from composer.json data', function (): void { + $tempDir = sys_get_temp_dir() . '/marko-test-' . bin2hex(random_bytes(8)); + mkdir($tempDir, 0755, true); + + $composerData = [ + 'name' => 'marko/admin-panel-latte', + 'extra' => [ + 'marko' => [ + 'module' => true, + 'templates_for' => 'marko/admin-panel', + ], + ], + ]; + file_put_contents($tempDir . '/composer.json', json_encode($composerData, JSON_PRETTY_PRINT)); + + $parser = new ManifestParser(); + $manifest = $parser->parse($tempDir); + + expect($manifest->extra) + ->toBeArray() + ->toHaveKey('marko') + ->and($manifest->extra['marko']['templates_for'])->toBe('marko/admin-panel'); + + cleanupDirectory($tempDir); +}); + +it('ModuleManifest exposes the extra array from composer.json', function (): void { + $manifest = new ModuleManifest( + name: 'acme/test', + version: '1.0.0', + extra: ['marko' => ['templates_for' => 'marko/admin-panel']], + ); + + expect($manifest->extra) + ->toBeArray() + ->toHaveKey('marko') + ->and($manifest->extra['marko']['templates_for'])->toBe('marko/admin-panel'); +}); + it('ManifestParser reads globalMiddleware from module.php and passes it to ModuleManifest', function (): void { $tempDir = sys_get_temp_dir() . '/marko-test-' . bin2hex(random_bytes(8)); mkdir($tempDir, 0755, true); diff --git a/packages/framework/tests/ArchitectureDocTest.php b/packages/framework/tests/ArchitectureDocTest.php new file mode 100644 index 00000000..e852e813 --- /dev/null +++ b/packages/framework/tests/ArchitectureDocTest.php @@ -0,0 +1,53 @@ +toContain('## Engine-Specific Template Siblings'); +}); + +it('the section is placed between Package Architecture and Dependency Injection', function () use ($architecturePath) { + $content = file_get_contents($architecturePath); + + $packageArchPos = strpos($content, '## Package Architecture'); + $siblingPos = strpos($content, '## Engine-Specific Template Siblings'); + $diPos = strpos($content, '## Dependency Injection'); + + expect($packageArchPos)->toBeLessThan($siblingPos) + ->and($siblingPos)->toBeLessThan($diPos); +}); + +it('the section explains the marko/{module}-{engine} naming pattern', function () use ($architecturePath) { + $content = file_get_contents($architecturePath); + + expect($content)->toContain('marko/{module}-{engine}'); +}); + +it('the section documents the templates_for composer-extra key', function () use ($architecturePath) { + $content = file_get_contents($architecturePath); + + expect($content)->toContain('templates_for'); +}); + +it('the section explains when to use the pattern (reusable UI packages)', function () use ($architecturePath) { + $content = file_get_contents($architecturePath); + + expect($content)->toContain('reusable') + ->and($content)->toContain('UI'); +}); + +it('the section explains when NOT to use the pattern (application-specific modules)', function () use ($architecturePath) { + $content = file_get_contents($architecturePath); + + expect($content)->toContain('application-specific'); +}); + +it('the section mentions the CrossEngineTemplateParityTest as the enforcement mechanism', function () use ($architecturePath) { + $content = file_get_contents($architecturePath); + + expect($content)->toContain('CrossEngineTemplateParityTest'); +}); diff --git a/packages/framework/tests/RootComposerJsonTest.php b/packages/framework/tests/RootComposerJsonTest.php index 14efb66a..fdb33c61 100644 --- a/packages/framework/tests/RootComposerJsonTest.php +++ b/packages/framework/tests/RootComposerJsonTest.php @@ -10,6 +10,8 @@ 'marko/admin-api', 'marko/admin-auth', 'marko/admin-panel', + 'marko/admin-panel-latte', + 'marko/admin-panel-twig', 'marko/amphp', 'marko/api', 'marko/authentication', @@ -78,7 +80,7 @@ 'marko/webhook', ]; -it('adds a require section entry for all 70 marko packages set to self.version', function () use ($rootComposer, $allPackages): void { +it('adds a require section entry for all 72 marko packages set to self.version', function () use ($rootComposer, $allPackages): void { expect($rootComposer)->toHaveKey('require'); foreach ($allPackages as $package) { @@ -91,7 +93,7 @@ expect($rootComposer)->not->toHaveKey('replace'); }); -it('adds repositories section with path repos for all 70 packages', function () use ($rootComposer, $allPackages): void { +it('adds repositories section with path repos for all 72 packages', function () use ($rootComposer, $allPackages): void { expect($rootComposer)->toHaveKey('repositories'); $repoUrls = array_column($rootComposer['repositories'], 'url'); diff --git a/packages/view/known-engines.php b/packages/view/known-engines.php new file mode 100644 index 00000000..1f0a908c --- /dev/null +++ b/packages/view/known-engines.php @@ -0,0 +1,14 @@ + [ + 'extension' => '.twig', + 'driver' => 'marko/view-twig', + ], + 'latte' => [ + 'extension' => '.latte', + 'driver' => 'marko/view-latte', + ], +]; diff --git a/packages/view/src/ModuleTemplateResolver.php b/packages/view/src/ModuleTemplateResolver.php index e0591a1c..76382040 100644 --- a/packages/view/src/ModuleTemplateResolver.php +++ b/packages/view/src/ModuleTemplateResolver.php @@ -4,6 +4,7 @@ namespace Marko\View; +use Marko\Core\Module\ModuleManifest; use Marko\Core\Module\ModuleRepositoryInterface; use Marko\View\Exceptions\TemplateNotFoundException; @@ -14,6 +15,9 @@ public function __construct( private ViewConfig $viewConfig, ) {} + /** + * @throws TemplateNotFoundException + */ public function resolve( string $template, ): string { @@ -36,14 +40,20 @@ public function getSearchedPaths( $paths = []; + $siblingPaths = []; + foreach ($this->moduleRepository->all() as $module) { if ($this->matchesModuleName($module->name, $moduleName)) { - $fullPath = $module->path . '/resources/views/' . $templatePath . $extension; - $paths[] = $fullPath; + $paths[] = $module->path . '/resources/views/' . $templatePath . $extension; + continue; + } + + if ($this->matchesTemplatesFor($module, $moduleName)) { + $siblingPaths[] = $module->path . '/resources/views/' . $templatePath . $extension; } } - return $paths; + return array_merge($paths, $siblingPaths); } /** @@ -63,6 +73,22 @@ private function parseTemplate( return ['', $template]; } + /** + * Check if a module declares templates_for targeting the given short module name. + */ + private function matchesTemplatesFor( + ModuleManifest $module, + string $shortModuleName, + ): bool { + $templatesFor = $module->extra['marko']['templates_for'] ?? null; + + if (!is_string($templatesFor)) { + return false; + } + + return $this->matchesModuleName($templatesFor, $shortModuleName); + } + /** * Check if a full module name matches a short module name. * 'vendor/blog' matches 'blog', 'marko/core' matches 'core' diff --git a/packages/view/tests/Feature/CrossEngineTemplateParityTest.php b/packages/view/tests/Feature/CrossEngineTemplateParityTest.php new file mode 100644 index 00000000..2abd20e2 --- /dev/null +++ b/packages/view/tests/Feature/CrossEngineTemplateParityTest.php @@ -0,0 +1,180 @@ +markTestSkipped('known-engines.php not found — marko/view not installed standalone?'); + } + + if (!is_dir($packagesDir) || basename($packagesDir) !== 'packages') { + $this->markTestSkipped('Not running inside the monorepo packages directory — skipping cross-engine parity check.'); + } + + $engines = require $enginesPath; + + $providers = []; + foreach (glob($packagesDir . '/*/composer.json') as $composerPath) { + $composer = json_decode(file_get_contents($composerPath), associative: true); + if (!is_array($composer)) { + continue; + } + $templatesFor = $composer['extra']['marko']['templates_for'] ?? null; + if (!is_string($templatesFor)) { + continue; + } + + $packageName = $composer['name'] ?? null; + if (!is_string($packageName)) { + continue; + } + + $engineSuffix = extractEngineSuffix($packageName, $templatesFor); + if ($engineSuffix === null || !isset($engines[$engineSuffix])) { + continue; + } + + $providers[$templatesFor][$engineSuffix] = $packageName; + } + + if ($providers === []) { + $this->markTestSkipped('No template provider packages found — nothing to check.'); + } + + foreach ($providers as $parent => $foundEngines) { + foreach ($engines as $engineName => $engineMeta) { + $message = "Parent module '$parent' has template providers for [" . implode(', ', array_keys($foundEngines)) + . "] but is missing a provider for engine '$engineName'. " + . "Expected a package like 'marko/" . basename($parent) . "-$engineName' declaring " + . "extra.marko.templates_for: '$parent'."; + + expect(array_key_exists($engineName, $foundEngines))->toBeTrue($message); + } + } +}); + +test('it fails with a clear error message when an engine is missing a provider for some parent', function () { + $providers = ['marko/admin-panel' => ['latte' => 'marko/admin-panel-latte']]; + $engines = [ + 'twig' => ['extension' => '.twig', 'driver' => 'marko/view-twig'], + 'latte' => ['extension' => '.latte', 'driver' => 'marko/view-latte'], + ]; + + $caught = null; + foreach ($providers as $parent => $foundEngines) { + foreach ($engines as $engineName => $engineMeta) { + if (!isset($foundEngines[$engineName])) { + $caught = "missing '$engineName' for '$parent'"; + break 2; + } + } + } + + expect($caught)->toContain('twig') + ->and($caught)->toContain('admin-panel'); +}); + +test('it skips gracefully when known-engines.php is not present', function () { + $enginesPath = '/nonexistent/path/known-engines.php'; + + // Verify that the skip condition is correctly identified: file does not exist + expect(file_exists($enginesPath))->toBeFalse(); +}); + +test('it skips gracefully when the resolved packages directory is not the monorepo packages/ dir', function () { + $packagesDir = sys_get_temp_dir(); + + // Verify that a non-'packages' directory correctly triggers the skip condition + expect(basename($packagesDir))->not->toBe('packages'); +}); + +test('it skips gracefully when no template provider packages are found', function () { + $tempDir = sys_get_temp_dir() . '/marko-parity-test-' . bin2hex(random_bytes(6)); + mkdir($tempDir, 0755, true); + + // Create a packages dir named 'packages' to pass the basename check + $packagesDir = $tempDir . '/packages'; + mkdir($packagesDir, 0755, true); + + // Create a composer.json without templates_for + file_put_contents($packagesDir . '/composer.json', json_encode(['name' => 'marko/test'])); + + $enginesPath = dirname(__DIR__, 2) . '/known-engines.php'; + + if (!file_exists($enginesPath)) { + $this->markTestSkipped('known-engines.php not found — marko/view not installed standalone?'); + } + + $engines = require $enginesPath; + + $providers = []; + foreach (glob($packagesDir . '/*/composer.json') as $composerPath) { + $composer = json_decode(file_get_contents($composerPath), associative: true); + if (!is_array($composer)) { + continue; + } + $templatesFor = $composer['extra']['marko']['templates_for'] ?? null; + if (!is_string($templatesFor)) { + continue; + } + $packageName = $composer['name'] ?? null; + if (!is_string($packageName)) { + continue; + } + $engineSuffix = extractEngineSuffix($packageName, $templatesFor); + if ($engineSuffix === null || !isset($engines[$engineSuffix])) { + continue; + } + $providers[$templatesFor][$engineSuffix] = $packageName; + } + + expect($providers)->toBe([]); + + // Cleanup + unlink($packagesDir . '/composer.json'); + rmdir($packagesDir); + rmdir($tempDir); +}); + +test('it correctly extracts engine suffix from package name (admin-panel-twig → twig)', function () { + expect(extractEngineSuffix('marko/admin-panel-twig', 'marko/admin-panel'))->toBe('twig') + ->and(extractEngineSuffix('marko/admin-panel-latte', 'marko/admin-panel'))->toBe('latte') + ->and(extractEngineSuffix('marko/blog-twig', 'marko/blog'))->toBe('twig'); +}); + +test('it ignores packages whose names do not follow the marko/{parent}-{engine} convention', function () { + expect(extractEngineSuffix('marko/something-else', 'marko/admin-panel'))->toBeNull() + ->and(extractEngineSuffix('marko/admin-panel', 'marko/admin-panel'))->toBeNull() + ->and(extractEngineSuffix('marko/admin', 'marko/admin-panel'))->toBeNull(); +}); + +test('it ignores packages whose extracted suffix is not in known-engines (e.g., admin-panel-twig-extra produces suffix twig-extra and is skipped if not registered)', function () { + $enginesPath = dirname(__DIR__, 2) . '/known-engines.php'; + + if (!file_exists($enginesPath)) { + $this->markTestSkipped('known-engines.php not found — marko/view not installed standalone?'); + } + + $engines = require $enginesPath; + + $suffix = extractEngineSuffix('marko/admin-panel-twig-extra', 'marko/admin-panel'); + + expect($suffix)->toBe('twig-extra') + ->and(isset($engines[$suffix]))->toBeFalse(); +}); diff --git a/packages/view/tests/KnownEnginesTest.php b/packages/view/tests/KnownEnginesTest.php new file mode 100644 index 00000000..9b78ff6d --- /dev/null +++ b/packages/view/tests/KnownEnginesTest.php @@ -0,0 +1,49 @@ +toBeTrue(); +}); + +it('registers twig with extension .twig and driver marko/view-twig', function () { + $engines = require dirname(__DIR__) . '/known-engines.php'; + + expect($engines)->toHaveKey('twig') + ->and($engines['twig']['extension'])->toBe('.twig') + ->and($engines['twig']['driver'])->toBe('marko/view-twig'); +}); + +it('registers latte with extension .latte and driver marko/view-latte', function () { + $engines = require dirname(__DIR__) . '/known-engines.php'; + + expect($engines)->toHaveKey('latte') + ->and($engines['latte']['extension'])->toBe('.latte') + ->and($engines['latte']['driver'])->toBe('marko/view-latte'); +}); + +it('lists twig first as the recommended engine', function () { + $engines = require dirname(__DIR__) . '/known-engines.php'; + + expect(array_key_first($engines))->toBe('twig'); +}); + +it('returns an array keyed by short engine name with extension and driver fields', function () { + $engines = require dirname(__DIR__) . '/known-engines.php'; + + expect($engines)->toBeArray(); + + foreach ($engines as $name => $config) { + expect($name)->toBeString() + ->and($config)->toHaveKey('extension') + ->and($config)->toHaveKey('driver'); + } +}); + +it('uses declare strict_types', function () { + $contents = file_get_contents(dirname(__DIR__) . '/known-engines.php'); + + expect($contents)->toContain('declare(strict_types=1)'); +}); diff --git a/packages/view/tests/ModuleTemplateResolverTest.php b/packages/view/tests/ModuleTemplateResolverTest.php index 985a6342..04a79290 100644 --- a/packages/view/tests/ModuleTemplateResolverTest.php +++ b/packages/view/tests/ModuleTemplateResolverTest.php @@ -316,6 +316,267 @@ function createTestViewConfig( rmdir($tempDir2); }); +it('it matches templates_for by basename (marko/admin-panel matches admin-panel::path)', function (): void { + $siblingDir = sys_get_temp_dir() . '/marko-test-sibling-' . bin2hex(random_bytes(8)); + mkdir($siblingDir . '/resources/views/dashboard', 0755, true); + file_put_contents($siblingDir . '/resources/views/dashboard/index.latte', 'sibling content'); + + $modules = [ + new ModuleManifest( + name: 'marko/admin-panel-latte', + version: '1.0.0', + path: $siblingDir, + source: 'vendor', + // Full package name "marko/admin-panel" should match short name "admin-panel" + extra: ['marko' => ['templates_for' => 'marko/admin-panel']], + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + createTestViewConfig(), + ); + + $paths = $resolver->getSearchedPaths('admin-panel::dashboard/index'); + + expect($paths)->toHaveCount(1) + ->and($paths[0])->toBe($siblingDir . '/resources/views/dashboard/index.latte'); + + // Cleanup + unlink($siblingDir . '/resources/views/dashboard/index.latte'); + rmdir($siblingDir . '/resources/views/dashboard'); + rmdir($siblingDir . '/resources/views'); + rmdir($siblingDir . '/resources'); + rmdir($siblingDir); +}); + +it('it does not throw when templates_for is present but not a string', function (): void { + $dir = sys_get_temp_dir() . '/marko-test-' . bin2hex(random_bytes(8)); + mkdir($dir, 0755, true); + + $modules = [ + new ModuleManifest( + name: 'marko/some-module', + version: '1.0.0', + path: $dir, + source: 'vendor', + extra: ['marko' => ['templates_for' => ['not', 'a', 'string']]], + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + createTestViewConfig(), + ); + + $paths = $resolver->getSearchedPaths('admin-panel::dashboard/index'); + + expect($paths)->toBeEmpty(); + + rmdir($dir); +}); + +it('it does not throw when a module\'s extra.marko key is missing entirely', function (): void { + $dir = sys_get_temp_dir() . '/marko-test-' . bin2hex(random_bytes(8)); + mkdir($dir, 0755, true); + + $modules = [ + new ModuleManifest( + name: 'marko/some-module', + version: '1.0.0', + path: $dir, + source: 'vendor', + extra: [], // extra exists but marko key is missing + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + createTestViewConfig(), + ); + + $paths = $resolver->getSearchedPaths('admin-panel::dashboard/index'); + + expect($paths)->toBeEmpty(); + + rmdir($dir); +}); + +it('it ignores modules without a templates_for declaration when looking for sibling providers', function (): void { + $irrelevantDir = sys_get_temp_dir() . '/marko-test-irrelevant-' . bin2hex(random_bytes(8)); + mkdir($irrelevantDir, 0755, true); + + $modules = [ + new ModuleManifest( + name: 'marko/other-module', + version: '1.0.0', + path: $irrelevantDir, + source: 'vendor', + // No extra / templates_for declared + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + createTestViewConfig(), + ); + + $paths = $resolver->getSearchedPaths('admin-panel::dashboard/index'); + + expect($paths)->toBeEmpty(); + + rmdir($irrelevantDir); +}); + +it('it puts the parent path before the sibling path in the search order', function (): void { + $parentDir = sys_get_temp_dir() . '/marko-test-parent-' . bin2hex(random_bytes(8)); + $siblingDir = sys_get_temp_dir() . '/marko-test-sibling-' . bin2hex(random_bytes(8)); + mkdir($parentDir . '/resources/views/dashboard', 0755, true); + mkdir($siblingDir . '/resources/views/dashboard', 0755, true); + file_put_contents($parentDir . '/resources/views/dashboard/index.latte', 'parent'); + file_put_contents($siblingDir . '/resources/views/dashboard/index.latte', 'sibling'); + + // Put sibling first in the module list to ensure ordering is by match type, not list order + $modules = [ + new ModuleManifest( + name: 'marko/admin-panel-latte', + version: '1.0.0', + path: $siblingDir, + source: 'vendor', + extra: ['marko' => ['templates_for' => 'marko/admin-panel']], + ), + new ModuleManifest( + name: 'marko/admin-panel', + version: '1.0.0', + path: $parentDir, + source: 'vendor', + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + createTestViewConfig(), + ); + + // resolve() should return the parent (since parent path comes first in search) + $result = $resolver->resolve('admin-panel::dashboard/index'); + + expect($result)->toBe($parentDir . '/resources/views/dashboard/index.latte'); + + // Cleanup + unlink($parentDir . '/resources/views/dashboard/index.latte'); + unlink($siblingDir . '/resources/views/dashboard/index.latte'); + rmdir($parentDir . '/resources/views/dashboard'); + rmdir($siblingDir . '/resources/views/dashboard'); + rmdir($parentDir . '/resources/views'); + rmdir($siblingDir . '/resources/views'); + rmdir($parentDir . '/resources'); + rmdir($siblingDir . '/resources'); + rmdir($parentDir); + rmdir($siblingDir); +}); + +it('it includes both the parent and the sibling paths in getSearchedPaths when both exist', function (): void { + $parentDir = sys_get_temp_dir() . '/marko-test-parent-' . bin2hex(random_bytes(8)); + $siblingDir = sys_get_temp_dir() . '/marko-test-sibling-' . bin2hex(random_bytes(8)); + mkdir($parentDir, 0755, true); + mkdir($siblingDir, 0755, true); + + $modules = [ + new ModuleManifest( + name: 'marko/admin-panel', + version: '1.0.0', + path: $parentDir, + source: 'vendor', + ), + new ModuleManifest( + name: 'marko/admin-panel-latte', + version: '1.0.0', + path: $siblingDir, + source: 'vendor', + extra: ['marko' => ['templates_for' => 'marko/admin-panel']], + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + createTestViewConfig(), + ); + + $paths = $resolver->getSearchedPaths('admin-panel::dashboard/index'); + + expect($paths)->toHaveCount(2) + ->and($paths[0])->toBe($parentDir . '/resources/views/dashboard/index.latte') + ->and($paths[1])->toBe($siblingDir . '/resources/views/dashboard/index.latte'); + + // Cleanup + rmdir($parentDir); + rmdir($siblingDir); +}); + +it('resolves a template via a templates_for declaration on a sibling module', function (): void { + $siblingDir = sys_get_temp_dir() . '/marko-test-sibling-' . bin2hex(random_bytes(8)); + mkdir($siblingDir . '/resources/views/dashboard', 0755, true); + file_put_contents($siblingDir . '/resources/views/dashboard/index.latte', 'sibling content'); + + $modules = [ + new ModuleManifest( + name: 'marko/admin-panel-latte', + version: '1.0.0', + path: $siblingDir, + source: 'vendor', + extra: ['marko' => ['templates_for' => 'marko/admin-panel']], + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + createTestViewConfig(), + ); + + $result = $resolver->resolve('admin-panel::dashboard/index'); + + expect($result)->toBe($siblingDir . '/resources/views/dashboard/index.latte'); + + // Cleanup + unlink($siblingDir . '/resources/views/dashboard/index.latte'); + rmdir($siblingDir . '/resources/views/dashboard'); + rmdir($siblingDir . '/resources/views'); + rmdir($siblingDir . '/resources'); + rmdir($siblingDir); +}); + +it('resolves a template via the parent module name match (existing behavior preserved)', function (): void { + $tempDir = sys_get_temp_dir() . '/marko-test-' . bin2hex(random_bytes(8)); + mkdir($tempDir . '/resources/views/dashboard', 0755, true); + file_put_contents($tempDir . '/resources/views/dashboard/index.latte', 'parent content'); + + $modules = [ + new ModuleManifest( + name: 'marko/admin-panel', + version: '1.0.0', + path: $tempDir, + source: 'vendor', + ), + ]; + + $resolver = new ModuleTemplateResolver( + new ModuleRepository($modules), + createTestViewConfig(), + ); + + $result = $resolver->resolve('admin-panel::dashboard/index'); + + expect($result)->toBe($tempDir . '/resources/views/dashboard/index.latte'); + + // Cleanup + unlink($tempDir . '/resources/views/dashboard/index.latte'); + rmdir($tempDir . '/resources/views/dashboard'); + rmdir($tempDir . '/resources/views'); + rmdir($tempDir . '/resources'); + rmdir($tempDir); +}); + it('uses FakeConfigRepository in ModuleTemplateResolverTest', function (): void { $repo = new FakeConfigRepository(['view.extension' => '.latte']); $viewConfig = new ViewConfig($repo); From 7c41b7d616e11141402d9751ff448082edca1ebe Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 10:34:02 -0400 Subject: [PATCH 4/4] feat(view): add description field to known-engines.php; surface in parity failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit known-engines.php now ships a human-readable description for each registered engine, matching the format of known-drivers.php. The description is surfaced in CrossEngineTemplateParityTest failure messages when a template provider is missing for some parent module: Before: "...missing a provider for engine 'twig'. Expected a package..." After: "...missing a provider for engine 'twig' (Twig — recommended for broader PHP ecosystem familiarity (Symfony, Drupal, Craft CMS)). Expected a package..." Also positions known-engines.php for future use by an enhanced TemplateNotFoundException (out of scope here) to hint at missing engine-sibling packages with descriptive context. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/view/known-engines.php | 2 ++ .../Feature/CrossEngineTemplateParityTest.php | 3 ++- packages/view/tests/KnownEnginesTest.php | 19 +++++++++++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/view/known-engines.php b/packages/view/known-engines.php index 1f0a908c..a04ea9df 100644 --- a/packages/view/known-engines.php +++ b/packages/view/known-engines.php @@ -6,9 +6,11 @@ 'twig' => [ 'extension' => '.twig', 'driver' => 'marko/view-twig', + 'description' => 'Twig — recommended for broader PHP ecosystem familiarity (Symfony, Drupal, Craft CMS)', ], 'latte' => [ 'extension' => '.latte', 'driver' => 'marko/view-latte', + 'description' => 'Latte — compile-time safety, n:attribute syntax, Nette ecosystem', ], ]; diff --git a/packages/view/tests/Feature/CrossEngineTemplateParityTest.php b/packages/view/tests/Feature/CrossEngineTemplateParityTest.php index 2abd20e2..2d709a22 100644 --- a/packages/view/tests/Feature/CrossEngineTemplateParityTest.php +++ b/packages/view/tests/Feature/CrossEngineTemplateParityTest.php @@ -59,8 +59,9 @@ function extractEngineSuffix(string $packageName, string $parentName): ?string foreach ($providers as $parent => $foundEngines) { foreach ($engines as $engineName => $engineMeta) { + $description = $engineMeta['description'] ?? $engineName; $message = "Parent module '$parent' has template providers for [" . implode(', ', array_keys($foundEngines)) - . "] but is missing a provider for engine '$engineName'. " + . "] but is missing a provider for engine '$engineName' ($description). " . "Expected a package like 'marko/" . basename($parent) . "-$engineName' declaring " . "extra.marko.templates_for: '$parent'."; diff --git a/packages/view/tests/KnownEnginesTest.php b/packages/view/tests/KnownEnginesTest.php index 9b78ff6d..e16ba85b 100644 --- a/packages/view/tests/KnownEnginesTest.php +++ b/packages/view/tests/KnownEnginesTest.php @@ -30,7 +30,7 @@ expect(array_key_first($engines))->toBe('twig'); }); -it('returns an array keyed by short engine name with extension and driver fields', function () { +it('returns an array keyed by short engine name with extension, driver, and description fields', function () { $engines = require dirname(__DIR__) . '/known-engines.php'; expect($engines)->toBeArray(); @@ -38,10 +38,25 @@ foreach ($engines as $name => $config) { expect($name)->toBeString() ->and($config)->toHaveKey('extension') - ->and($config)->toHaveKey('driver'); + ->and($config)->toHaveKey('driver') + ->and($config)->toHaveKey('description') + ->and($config['description'])->toBeString() + ->and($config['description'])->not->toBeEmpty(); } }); +it('includes a human-readable description for twig', function () { + $engines = require dirname(__DIR__) . '/known-engines.php'; + + expect($engines['twig']['description'])->toContain('Twig'); +}); + +it('includes a human-readable description for latte', function () { + $engines = require dirname(__DIR__) . '/known-engines.php'; + + expect($engines['latte']['description'])->toContain('Latte'); +}); + it('uses declare strict_types', function () { $contents = file_get_contents(dirname(__DIR__) . '/known-engines.php');