From 4993401c1be213e64438221c80246b5dd9f0f68c Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sat, 24 Oct 2026 01:55:10 +0200 Subject: [PATCH] Let templates call presenter methods passed with the render A service can now pass a Presenter with View::render() and render_partial(). The template scope forwards a method call on $this to the presenter when an interface extending Presenter declares it. String and stringable results are escaped like context properties, SafeHtml passes through as markup, and other values come back unchanged. A call without a presenter, an undeclared method or a presenter method shadowed by the scope fails the view through the existing debug and production policy. The loop sample now reads its header through a PostHeader presenter. Closes #173 --- docs/design-principles.md | 22 +- docs/patterns.md | 106 ++++++-- phpstan.neon.dist | 2 + src/BasicScaffoldPlugin.php | 9 + src/Exception/InvalidPresenterMethod.php | 76 ++++++ src/Infrastructure/Presenter.php | 42 +++ src/Infrastructure/TemplateScope.php | 17 +- src/Infrastructure/View.php | 20 +- src/Infrastructure/View/EmptyView.php | 13 +- src/Infrastructure/View/SafeHtml.php | 49 ++++ .../View/SimpleTemplateScope.php | 96 ++++++- src/Infrastructure/View/SimpleView.php | 81 +++++- src/SampleSubsystem/PostHeader.php | 48 ++++ src/SampleSubsystem/QueriedPostHeader.php | 62 +++++ src/SampleSubsystem/SampleLoopService.php | 18 +- tests/php/Architecture/infrastructure-api.txt | 27 +- tests/php/Fixture/View/FixedGreeting.php | 68 +++++ tests/php/Fixture/View/FixedRawGreeting.php | 27 ++ tests/php/Fixture/View/Greeting.php | 42 +++ tests/php/Fixture/View/Label.php | 30 +++ tests/php/Fixture/View/RawGreeting.php | 27 ++ .../views/plugin/presenter-call-view.php | 14 + .../views/plugin/presenter-partial.php | 11 + .../Fixture/views/plugin/presenter-view.php | 14 + .../plugin/presenter-with-partial-view.php | 12 + .../php/Integration/SampleLoopServiceTest.php | 16 +- tests/php/Unit/QueriedPostHeaderTest.php | 74 ++++++ tests/php/Unit/SimpleTemplateScopeTest.php | 72 +++++ tests/php/Unit/SimpleViewPresenterTest.php | 249 ++++++++++++++++++ tests/php/Unit/SimpleViewTest.php | 4 +- views/test-loop-service.php | 14 +- 31 files changed, 1265 insertions(+), 97 deletions(-) create mode 100644 src/Exception/InvalidPresenterMethod.php create mode 100644 src/Infrastructure/Presenter.php create mode 100644 src/Infrastructure/View/SafeHtml.php create mode 100644 src/SampleSubsystem/PostHeader.php create mode 100644 src/SampleSubsystem/QueriedPostHeader.php create mode 100644 tests/php/Fixture/View/FixedGreeting.php create mode 100644 tests/php/Fixture/View/FixedRawGreeting.php create mode 100644 tests/php/Fixture/View/Greeting.php create mode 100644 tests/php/Fixture/View/Label.php create mode 100644 tests/php/Fixture/View/RawGreeting.php create mode 100644 tests/php/Fixture/views/plugin/presenter-call-view.php create mode 100644 tests/php/Fixture/views/plugin/presenter-partial.php create mode 100644 tests/php/Fixture/views/plugin/presenter-view.php create mode 100644 tests/php/Fixture/views/plugin/presenter-with-partial-view.php create mode 100644 tests/php/Unit/QueriedPostHeaderTest.php create mode 100644 tests/php/Unit/SimpleTemplateScopeTest.php create mode 100644 tests/php/Unit/SimpleViewPresenterTest.php diff --git a/docs/design-principles.md b/docs/design-principles.md index 8ca2acd..bdd6198 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -79,7 +79,7 @@ and activation stores the uninstall callback plugin is deleted. Everything else is decided per service. `SampleLoopService` shows the result: it declares that it runs at `wp` and only on singular views -(`src/SampleSubsystem/SampleLoopService.php:58-75`), and no other code has to +(`src/SampleSubsystem/SampleLoopService.php:63-80`), and no other code has to know. The questions are static methods on purpose: the plugin can **decide before it @@ -133,7 +133,7 @@ In the code: (`src/Infrastructure/ServiceBasedPlugin.php:270-302`). Swapping in an adapter for a full-featured container changes one constructor call, not the plugin. - `ViewFactory` is bound to `TemplatedViewFactory` in the composition root - (`src/BasicScaffoldPlugin.php:128`) and can be rebound the same way. + (`src/BasicScaffoldPlugin.php:130`) and can be rebound the same way. When improving infrastructure, keep the simple implementation simple. Add capability only when the scaffold's own use needs it, and keep the interface @@ -159,7 +159,7 @@ the built-in implementation as the fallback: a custom injector also needs its own factory, because the default factory cannot know how to create it. - `SimpleView` accepts any `DebugMode` and falls back to `WordPressDebugMode` - (`src/Infrastructure/View/SimpleView.php:98`), and so do the view factories, + (`src/Infrastructure/View/SimpleView.php:105`), and so do the view factories, which hand theirs on to the views they create. The fallback is the only place the default is named. Callers never have to @@ -169,7 +169,7 @@ A collaborator only has a default when there is an obvious one. Where the plugin root is has none the infrastructure could know: it is computed once in the composition root, next to the theme override directory, and handed to the view factories as a `ViewConfiguration` -(`src/BasicScaffoldPlugin.php:247-250`). A `SimpleView` or a view factory +(`src/BasicScaffoldPlugin.php:256-259`). A `SimpleView` or a view factory built by hand, outside the composition root, is passed one explicitly; without it, resolving a plugin-relative view or the default locations fails with `InvalidViewConfiguration` rather than guessing. A `TemplatedView` built @@ -308,7 +308,7 @@ injected collaborators, not through inheritance. Only classes deliberately designed as a base are open: `ServiceBasedPlugin` is abstract so the composition root can extend it (`src/Infrastructure/ServiceBasedPlugin.php:39`), and `SimpleView` is the base that `TemplatedView` builds on -(`src/Infrastructure/View/SimpleView.php:37`). Every other class in `src/` is +(`src/Infrastructure/View/SimpleView.php:39`). Every other class in `src/` is `final`, and new classes follow suit unless they are designed for extension from the start. @@ -410,13 +410,17 @@ delegation returns an instance of the class it was registered for **Output is escaped for the context it lands in.** The reverse direction gets the same care. Values a view reads as properties are escaped by default -(`src/Infrastructure/View/SimpleView.php:366-397`), and a template runs in a +(`src/Infrastructure/View/SimpleView.php:425-456`), and a template runs in a scope of its own, so neither a context key named like one of the view's properties nor an assignment to `$this` can get around that -(`src/Infrastructure/View/SimpleView.php:179-199`). A value fetched with +(`src/Infrastructure/View/SimpleView.php:237-257`). A value fetched with `raw()` is the explicit exception, and the template must then escape it for where it is printed: HTML text, an attribute, a URL or JavaScript each need their own escaping, using WordPress's matching `esc_*` function. +What a presenter method returns to a template follows the same rule: strings +are escaped, and markup the presenter builds on purpose comes back as +`SafeHtml`, which the presenter answers for +(`src/Infrastructure/View/SimpleTemplateScope.php:128-150`). ## 10. Errors are exceptions @@ -478,7 +482,7 @@ debug mode, the view factories rethrow the original exception, and `SimpleView::render()` throws `FailedToLoadView` with the template's exception as its cause. In production, a factory returns an `EmptyView` and a failing template renders an empty string, after the view has discarded the -output buffers the template opened (`src/Infrastructure/View/SimpleView.php:132-152`, +output buffers the template opened (`src/Infrastructure/View/SimpleView.php:146-168`, `src/Infrastructure/View/TemplatedViewFactory.php:90-111`). The samples need no `try`/`catch` around rendering for that reason. @@ -564,7 +568,7 @@ collaboration or synchronization between plugins. **Everything public carries the plugin's prefix**, so that several plugins built from this scaffold can run side by side without clashing. That covers -hook names and service identifiers (`src/BasicScaffoldPlugin.php:313-315`, `:339-343`), as +hook names and service identifiers (`src/BasicScaffoldPlugin.php:322-324`, `:348-352`), as well as option, meta and transient names, script and style handles, and the paths a theme uses to override the plugin's views. The prefixes prevent collisions; they are not a coordination protocol. diff --git a/docs/patterns.md b/docs/patterns.md index a57ff36..8c60246 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -40,6 +40,7 @@ the need may be new: ask on an issue rather than inventing a workaround. - [I need a default collaborator that can be replaced](#i-need-a-default-collaborator-that-can-be-replaced) - [I need to start simple and add implementations later](#i-need-to-start-simple-and-add-implementations-later) - [I need to print output](#i-need-to-print-output) +- [I need a template to ask for more than its context](#i-need-a-template-to-ask-for-more-than-its-context) - [I need to report a failure](#i-need-to-report-a-failure) - [I need to run code when the plugin file loads](#i-need-to-run-code-when-the-plugin-file-loads) - [I need a name in a space WordPress shares](#i-need-a-name-in-a-space-wordpress-shares) @@ -73,7 +74,7 @@ protected function get_service_classes(): array { } ``` -**Proven by:** `src/BasicScaffoldPlugin.php:80-98` declares the two sample +**Proven by:** `src/BasicScaffoldPlugin.php:82-100` declares the two sample services; neither knows about the other. **Rules:** `architecture.compositionRootContracts` builds every declared @@ -210,7 +211,7 @@ as soon as they are, at the end of the action they arrive on. The plugin does not reject that combination, and does not run an action again to make up for a priority that has passed. -**Proven by:** `src/SampleSubsystem/SampleLoopService.php:28-75`, and the +**Proven by:** `src/SampleSubsystem/SampleLoopService.php:28-80`, and the priority and dependency cases in `tests/php/Unit/ServiceBasedPluginTest.php:533-660`. @@ -537,7 +538,7 @@ $plugin = new BasicScaffoldPlugin( true, $custom_injector ); **Proven by:** `src/SampleSubsystem/SampleBackendService.php:60-66` and `:82-99`, `src/Infrastructure/View/TemplatedViewFactory.php:39` and `:90-111`, -bound at `src/BasicScaffoldPlugin.php:128`. The injector is made injectable at +bound at `src/BasicScaffoldPlugin.php:130`. The injector is made injectable at `src/Infrastructure/ServiceBasedPlugin.php:1168-1171`, proven by `tests/php/Unit/ServiceBasedPluginTest.php:1013-1061`. @@ -646,7 +647,7 @@ protected function get_shared_instances(): array { The one exception is the plugin itself, which WordPress needs to reach from outside the object graph: see the next section. -**Proven by:** `src/BasicScaffoldPlugin.php:203-213` is where shared instances +**Proven by:** `src/BasicScaffoldPlugin.php:212-222` is where shared instances are declared; `Injector::share()` (`src/Infrastructure/Injector.php:78`) is what the plugin calls for each one. @@ -780,15 +781,15 @@ static facade, no template-tag functions file, no generic service lookup and no hook that hands an API object over. `BasicScaffoldPluginFactory::create()` and `Plugin::get_container()` stay public because the main file, the listed bridges and the tests need them; that makes them callable, not supported for -other code. One investigation is open and not approved, and one addition is planned: +other code. #144, which is open and not approved, looks at an API for +extensions that need more than hooks, including the order in which several +extensions register. It waits for a concrete extension that hooks cannot +serve. -- #144 looks at an API for extensions that need more than hooks, including - the order in which several extensions register. It waits for a concrete - extension that hooks cannot serve. -- #173 adds presenters: objects a service passes with a render, whose - declared methods a template calls through `$this`. They only reach the - plugin's own views, including a theme's overrides of them, never a theme's - other files or another plugin. +Presenters are not an API for other code either. A service passes one with a +render, and only the plugin's own views reach it, including a theme's +overrides of them, never a theme's other files or another plugin. See +[I need a template to ask for more than its context](#i-need-a-template-to-ask-for-more-than-its-context). **Proven by:** the configuration filters at `src/Infrastructure/ServiceBasedPlugin.php:1230-1233` and `:1691-1772`, and @@ -884,10 +885,10 @@ The interface is for state a service depends on. **Proven by:** `src/SampleSubsystem/MainLoop.php:19-49` and `src/SampleSubsystem/WordPressMainLoop.php:27-67`, bound at -`src/BasicScaffoldPlugin.php:153`, and asked at call time in -`src/SampleSubsystem/SampleLoopService.php:123-141`. +`src/BasicScaffoldPlugin.php:155`, and asked at call time in +`src/SampleSubsystem/SampleLoopService.php:137-155`. `tests/php/Unit/WordPressMainLoopTest.php` covers each state of the loop, and -`tests/php/Integration/SampleLoopServiceTest.php:52-113` shows the header on +`tests/php/Integration/SampleLoopServiceTest.php:53-115` shows the header on the queried post only. For request input, a boundary adapter reads the superglobals, sanitizes and @@ -923,7 +924,7 @@ public function __construct( } ``` -**Proven by:** `src/Infrastructure/View/SimpleView.php:88-99`, and the +**Proven by:** `src/Infrastructure/View/SimpleView.php:95-106`, and the injector and container defaults of `ServiceBasedPlugin` (`src/Infrastructure/ServiceBasedPlugin.php:270-302`). @@ -986,20 +987,81 @@ production, so a broken template leaves its own place on the page empty while the rest of the page renders. See [I need to report a failure](#i-need-to-report-a-failure). ```php -// views/test-loop-service.php -$current_post = $this->raw( 'post' ); +// views/test-backend-service.php +$raw_plugin = $this->raw( 'plugin' ); ?> -Post title: post_title ) ?> +

Hello World! from the plugin ?> plugin!

+

Raw value:

``` **Proven by:** `src/SampleSubsystem/SampleBackendService.php:82-99` and -`views/test-loop-service.php:7-17`. +`views/test-backend-service.php:5-15`. **Rules:** `architecture.outputOnlyThroughViews` requires direct method calls on typed `Renderable` or `View` objects when printing from services. `architecture.rawIsEscaped` checks raw values in templates, and PHPCS (WordPress Coding Standards) checks escaping. +## I need a template to ask for more than its context + +**What people reach for:** WordPress or plugin functions called straight from +the template, a global helper or `Plugin::instance()` lookup, or one "helpers" +object that every template can reach. + +**Why not here:** [principle 5](design-principles.md#5-one-composition-root-builds-the-object-graph) +and [principle 7](design-principles.md#7-show-the-alternative-to-wordpress-anti-patterns) +rule out globals and lookups for collaborators. A plugin-wide helpers object +would also grow with every subsystem and let every template call everything, +which breaks the linear growth the scaffold is built on. + +**Instead:** give the template a presenter. Declare what it asks as an +interface that extends `Presenter`, implement it, bind it in the composition +root, inject it into the service that renders the template, and pass it with +the render. Name both types in the template's annotation, so PHPStan and +Psalm check each call: + +```php +// The service passes its presenter with the render. +$this->view_factory->create( 'views/test-loop-service' )->render( [], $this->post_header ); + +// views/test-loop-service.php +/** @var TemplateScope&PostHeader $this */ +?> +Post title: title() ?> +``` + +Use the context for values the service already has, and a presenter for +queries: ones that take arguments, need collaborators, or should be answered +when the template asks. + +- Only methods declared on an interface that extends `Presenter` are callable. + Any other public method of the implementation fails the view. +- A string or stringable result is escaped for HTML text, like a context + property, and anything else, such as a boolean for an `if`, comes back as it + is. A presenter that builds markup on purpose returns `SafeHtml`, and is then + responsible for escaping every part of it. A value printed into a URL, an + attribute or JavaScript still needs that context's `esc_*` function. +- A partial inherits the presenter, unless `render_partial()` is given its own + as the third argument. The presenter holds for that render only. +- Calling a method with no presenter, an undeclared method or a presenter that + throws fails the view: an exception in debug mode, an empty view in + production. A presenter method named like one of the scope's own methods + (`raw`, `render_partial`) fails the same way, as templates could never call + it. +- Arguments are passed by position. Parameters by reference do not work + through `$this`. +- Theme overrides call presenter methods by name, so renaming one breaks them. + +**Proven by:** `src/SampleSubsystem/PostHeader.php:29-48` and +`src/SampleSubsystem/QueriedPostHeader.php:25-62`, bound at +`src/BasicScaffoldPlugin.php:162`, passed at +`src/SampleSubsystem/SampleLoopService.php:151-152` and called in +`views/test-loop-service.php:6-18`. `tests/php/Unit/SimpleViewPresenterTest.php` +covers results, failures and partials. + +**Rules:** PHPStan and Psalm check presenter calls in templates against the +`@var` annotation. + ## I need to report a failure **What people reach for:** returning `false`, `null` or a `WP_Error`, calling @@ -1050,7 +1112,7 @@ try { **Proven by:** `src/Exception/FailedToFindPost.php:27`, thrown at `src/SampleSubsystem/WordPressMainLoop.php:62`, -`src/Infrastructure/View/SimpleView.php:121-152` and +`src/Infrastructure/View/SimpleView.php:135-168` and `src/Exception/FailedToLoadView.php:39`. **Rules:** `architecture.noWordPressErrorMechanisms`, `architecture.noFalseOnFailure`, @@ -1113,7 +1175,7 @@ a plugin, usually to its slug. Themes that already override the plugin's views move their files into that directory: a file directly in the theme's `views/` folder is no longer picked up. -**Proven by:** `src/BasicScaffoldPlugin.php:317-328`, and +**Proven by:** `src/BasicScaffoldPlugin.php:326-337`, and `tests/php/Unit/TemplatedViewFactoryTest.php`, which shows that a bare theme `views/` file is not selected and that two override directories do not see each other's files. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4789b86..060a540 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -161,6 +161,8 @@ parameters: # runs in, which only hands calls on to that view. Replacing it # means replacing the view, through ViewFactory. - MWPD\BasicScaffold\Infrastructure\View\SimpleTemplateScope + # Markup a presenter vouches for, created where it is built. + - MWPD\BasicScaffold\Infrastructure\View\SafeHtml - MWPD\BasicScaffold\Infrastructure\Lifecycle\SiteId - MWPD\BasicScaffold\Infrastructure\Lifecycle\NetworkId - MWPD\BasicScaffold\Infrastructure\Lifecycle\LifecycleScope diff --git a/src/BasicScaffoldPlugin.php b/src/BasicScaffoldPlugin.php index f0fdd4f..b4277e9 100644 --- a/src/BasicScaffoldPlugin.php +++ b/src/BasicScaffoldPlugin.php @@ -14,6 +14,8 @@ namespace MWPD\BasicScaffold; use MWPD\BasicScaffold\SampleSubsystem\MainLoop; +use MWPD\BasicScaffold\SampleSubsystem\PostHeader; +use MWPD\BasicScaffold\SampleSubsystem\QueriedPostHeader; use MWPD\BasicScaffold\SampleSubsystem\SampleBackendService; use MWPD\BasicScaffold\SampleSubsystem\SampleLoopService; use MWPD\BasicScaffold\SampleSubsystem\WordPressMainLoop; @@ -152,6 +154,13 @@ protected function get_bindings(): array { */ MainLoop::class => WordPressMainLoop::class, + /* + * The presenter SampleLoopService passes to its header template. + * It asks the main loop for the queried post as well, each time + * the template calls it. + */ + PostHeader::class => QueriedPostHeader::class, + // Add your bindings here. ]; } diff --git a/src/Exception/InvalidPresenterMethod.php b/src/Exception/InvalidPresenterMethod.php new file mode 100644 index 0000000..e1eaef2 --- /dev/null +++ b/src/Exception/InvalidPresenterMethod.php @@ -0,0 +1,76 @@ + + * @license MIT + * @link https://www.mwpd.io/ + * @copyright 2019 Alain Schlesser + */ + +declare( strict_types=1 ); + +namespace MWPD\BasicScaffold\Exception; + +use BadMethodCallException; + +/** + * Exception for a method a template calls on `$this` that no presenter + * offers. + */ +final class InvalidPresenterMethod extends BadMethodCallException implements BasicScaffoldException { + + /** + * Create a new instance of the exception for a template that calls a + * method while no presenter was passed to the view. + * + * @param string $method Name of the method the template called. + */ + public static function for_missing_presenter( string $method ): self { + $message = \sprintf( + 'The template called the method "%s()", but the view was rendered without a presenter.' + . ' Pass one to render() or render_partial().', + $method + ); + + return new self( $message ); + } + + /** + * Create a new instance of the exception for a template that calls a + * method its presenter does not declare. + * + * @param string $method Name of the method the template called. + * @param string $presenter Class of the presenter the view was rendered with. + */ + public static function from_method( string $method, string $presenter ): self { + $message = \sprintf( + 'The template called the method "%1$s()", which no interface extending Presenter declares' + . ' for the presenter "%2$s". Declare it on the presenter\'s interface.', + $method, + $presenter + ); + + return new self( $message ); + } + + /** + * Create a new instance of the exception for a presenter that declares a + * method templates cannot reach, because the template scope has a method + * of the same name. + * + * @param string $method Name of the method the presenter declares. + * @param string $presenter Class of the presenter the view was rendered with. + */ + public static function for_reserved_name( string $method, string $presenter ): self { + $message = \sprintf( + 'The presenter "%1$s" declares the method "%2$s()", which templates cannot call:' + . ' the template scope has a method of that name. Rename the presenter method.', + $presenter, + $method + ); + + return new self( $message ); + } +} diff --git a/src/Infrastructure/Presenter.php b/src/Infrastructure/Presenter.php new file mode 100644 index 0000000..0055631 --- /dev/null +++ b/src/Infrastructure/Presenter.php @@ -0,0 +1,42 @@ + + * @license MIT + * @link https://www.mwpd.io/ + * @copyright 2019 Alain Schlesser + */ + +declare( strict_types=1 ); + +namespace MWPD\BasicScaffold\Infrastructure; + +/** + * An object that answers the presentation queries of a template. + * + * A template can call methods on `$this` when the service that renders it + * passes a presenter along: + * + * $view->render( [ 'post' => $post ], $this->post_header ); + * + * Only methods declared on an interface that extends Presenter are callable, + * so a template sees exactly the contract its presenter publishes, and other + * public methods of the implementation stay out of reach: + * + * interface PostHeader extends Presenter { + * public function title(): string; + * } + * + * A string or stringable result is escaped like a context property. Other + * values, such as booleans for a branch, are returned as they are. A presenter + * that produces markup on purpose returns it as SafeHtml. + * + * A presenter is a collaborator like any other: the service that renders the + * template receives it through its constructor. Partials inherit the + * presenter of the template that renders them unless they are given their + * own. + */ +interface Presenter { +} diff --git a/src/Infrastructure/TemplateScope.php b/src/Infrastructure/TemplateScope.php index d558046..260f482 100644 --- a/src/Infrastructure/TemplateScope.php +++ b/src/Infrastructure/TemplateScope.php @@ -36,6 +36,12 @@ * * Assigning to `$this` inside a template fails the view, like any other * template error. + * + * When the view is rendered with a Presenter, the template can also call the + * methods the presenter's interface declares. Name both in the annotation, so + * static analysis checks those calls as well: + * + * /** @var TemplateScope&PostHeader $this *\/ */ interface TemplateScope { @@ -71,16 +77,17 @@ public function raw( $property ); /** * Render a partial view. * - * The passed-in context is optional, and will fall back to the context of - * the template that renders the partial if omitted. + * The passed-in context and presenter are optional, and each falls back to + * the one of the template that renders the partial if omitted. * - * @param string $path Path of the partial to render. - * @param array|null $context Context in which to render the partial. + * @param string $path Path of the partial to render. + * @param array|null $context Context in which to render the partial. + * @param ?Presenter $presenter Presenter for the partial. * * @return string Rendered HTML, or an empty string if rendering failed * outside of debug mode. * @throws InvalidPath If the provided path was not valid (in debug mode). * @throws FailedToLoadView If the view could not be loaded (in debug mode). */ - public function render_partial( string $path, ?array $context = null ): string; + public function render_partial( string $path, ?array $context = null, ?Presenter $presenter = null ): string; } diff --git a/src/Infrastructure/View.php b/src/Infrastructure/View.php index 609ab2c..e59ab5b 100644 --- a/src/Infrastructure/View.php +++ b/src/Infrastructure/View.php @@ -40,13 +40,18 @@ interface View extends Renderable { * view renders nothing instead, so that a broken view does not take the * rest of the page down with it. * - * @param array $context Context in which to render. + * The context holds the values the template prints. A presenter answers + * the queries the template makes through method calls on `$this`. + * + * @param array $context Context in which to render. + * @param ?Presenter $presenter Presenter whose declared methods the + * template can call. Optional. * * @return string Rendered HTML, or an empty string if rendering failed * outside of debug mode. * @throws FailedToLoadView If the view could not be loaded (in debug mode). */ - public function render( array $context = [] ): string; + public function render( array $context = [], ?Presenter $presenter = null ): string; /** * Render a partial view. @@ -54,18 +59,19 @@ public function render( array $context = [] ): string; * This can be used from within a currently rendered view, to include * nested partials. * - * The passed-in context is optional, and will fall back to the parent's - * context if omitted. + * The passed-in context and presenter are optional, and each falls back to + * the parent's if omitted. * - * @param string $path Path of the partial to render. - * @param array|null $context Context in which to render the partial. + * @param string $path Path of the partial to render. + * @param array|null $context Context in which to render the partial. + * @param ?Presenter $presenter Presenter for the partial. * * @return string Rendered HTML, or an empty string if rendering failed * outside of debug mode. * @throws InvalidPath If the provided path was not valid (in debug mode). * @throws FailedToLoadView If the view could not be loaded (in debug mode). */ - public function render_partial( string $path, ?array $context = null ): string; + public function render_partial( string $path, ?array $context = null, ?Presenter $presenter = null ): string; /** * Return the raw value of a context property. diff --git a/src/Infrastructure/View/EmptyView.php b/src/Infrastructure/View/EmptyView.php index 1710b3a..2a8384b 100644 --- a/src/Infrastructure/View/EmptyView.php +++ b/src/Infrastructure/View/EmptyView.php @@ -13,6 +13,7 @@ namespace MWPD\BasicScaffold\Infrastructure\View; +use MWPD\BasicScaffold\Infrastructure\Presenter; use MWPD\BasicScaffold\Infrastructure\View; use stdClass; @@ -32,23 +33,25 @@ final class EmptyView extends stdClass implements View { /** * Render the view, which produces no output. * - * @param array $context Context in which to render. Ignored. + * @param array $context Context in which to render. Ignored. + * @param ?Presenter $presenter Presenter for the template. Ignored. * * @return string An empty string. */ - public function render( array $context = [] ): string { + public function render( array $context = [], ?Presenter $presenter = null ): string { return ''; } /** * Render a partial view, which produces no output either. * - * @param string $path Path of the partial to render. Ignored. - * @param array|null $context Context in which to render the partial. Ignored. + * @param string $path Path of the partial to render. Ignored. + * @param array|null $context Context in which to render the partial. Ignored. + * @param ?Presenter $presenter Presenter for the partial. Ignored. * * @return string An empty string. */ - public function render_partial( string $path, ?array $context = null ): string { + public function render_partial( string $path, ?array $context = null, ?Presenter $presenter = null ): string { return ''; } diff --git a/src/Infrastructure/View/SafeHtml.php b/src/Infrastructure/View/SafeHtml.php new file mode 100644 index 0000000..708e9d5 --- /dev/null +++ b/src/Infrastructure/View/SafeHtml.php @@ -0,0 +1,49 @@ + + * @license MIT + * @link https://www.mwpd.io/ + * @copyright 2019 Alain Schlesser + */ + +declare( strict_types=1 ); + +namespace MWPD\BasicScaffold\Infrastructure\View; + +/** + * Markup that a presenter vouches for. + * + * A template escapes what a presenter returns, unless the presenter returns + * it as SafeHtml. Whoever creates it is responsible for every part of the + * markup already being escaped for HTML. It is for HTML only: a value printed + * into a URL, an attribute or JavaScript still needs the escaping of that + * context. + */ +final class SafeHtml { + + /** + * Markup, escaped already. + */ + private string $html; + + /** + * Instantiate a SafeHtml object. + * + * @param string $html Markup, every part of it escaped already. + */ + public function __construct( string $html ) { + $this->html = $html; + } + + /** + * Return the markup. + * + * @return string Markup, escaped already. + */ + public function __toString(): string { + return $this->html; + } +} diff --git a/src/Infrastructure/View/SimpleTemplateScope.php b/src/Infrastructure/View/SimpleTemplateScope.php index 7b90714..c776274 100644 --- a/src/Infrastructure/View/SimpleTemplateScope.php +++ b/src/Infrastructure/View/SimpleTemplateScope.php @@ -13,19 +13,24 @@ namespace MWPD\BasicScaffold\Infrastructure\View; +use Closure; use MWPD\BasicScaffold\Exception\InvalidContextProperty; +use MWPD\BasicScaffold\Exception\InvalidPresenterMethod; +use MWPD\BasicScaffold\Infrastructure\Presenter; use MWPD\BasicScaffold\Infrastructure\TemplateScope; use MWPD\BasicScaffold\Infrastructure\View; /** * The scope a SimpleView renders its template in. * - * Every call is handed on to the view that renders the template, so escaping - * and the debug-mode policy stay in one place, and a view that overrides them - * keeps working. The scope itself holds nothing a template could change. + * Every property read is handed on to the view that renders the template, so + * escaping and the debug-mode policy stay in one place, and a view that + * overrides them keeps working. A method call is handed on to the presenter, + * if its interface declares the method. The scope itself holds nothing a + * template could change. * - * The view's own property is private and the template runs without a class - * scope, so a context key named `view` reaches __get() like any other. + * The scope's own properties are private and the template runs without a + * class scope, so a context key named `view` reaches __get() like any other. */ final class SimpleTemplateScope implements TemplateScope { @@ -34,13 +39,42 @@ final class SimpleTemplateScope implements TemplateScope { */ private View $view; + /** + * Escaping function of the view, for the results of presenter methods. + * + * @var Closure(mixed): string + */ + private Closure $escape; + + /** + * Presenter the view was rendered with, if any. + */ + private ?Presenter $presenter; + + /** + * Methods the presenter's interfaces declare, keyed by their lowercase + * name, as PHP method names are case-insensitive. + * + * @var array + */ + private array $methods; + /** * Instantiate a SimpleTemplateScope object. * - * @param View $view View that renders the template. + * @param View $view View that renders the template. + * @param Closure $escape Escaping function of the view. + * @param ?Presenter $presenter Presenter the view was rendered with, if any. + * @param array $methods Methods the presenter's interfaces declare, + * keyed by their lowercase name. + * + * @phpstan-param Closure(mixed): string $escape */ - public function __construct( View $view ) { - $this->view = $view; + public function __construct( View $view, Closure $escape, ?Presenter $presenter = null, array $methods = [] ) { + $this->view = $view; + $this->escape = $escape; + $this->presenter = $presenter; + $this->methods = $methods; } /** @@ -78,6 +112,43 @@ public function __set( string $property, $value ): void { throw InvalidContextProperty::for_assignment( $property ); } + /** + * Call a method the presenter declares. + * + * The result is escaped the way a context property is, unless it is + * SafeHtml, which the presenter vouches for. A result that is neither a + * string nor stringable, such as a boolean, is returned as it is. + * + * @param string $method Method the template called. + * @param array $arguments Arguments the template passed, by position. + * @return mixed Result of the presenter method, escaped if it is printable. + * @throws InvalidPresenterMethod If no presenter was passed, or its interfaces + * do not declare the method. + */ + public function __call( string $method, array $arguments ) { + if ( null === $this->presenter ) { + throw InvalidPresenterMethod::for_missing_presenter( $method ); + } + + $declared = $this->methods[ \strtolower( $method ) ] ?? null; + + if ( null === $declared ) { + throw InvalidPresenterMethod::from_method( $method, \get_class( $this->presenter ) ); + } + + $result = $this->presenter->{$declared}( ...$arguments ); + + if ( $result instanceof SafeHtml ) { + return $result; + } + + if ( \is_string( $result ) || ( \is_object( $result ) && \method_exists( $result, '__toString' ) ) ) { + return ( $this->escape )( $result ); + } + + return $result; + } + /** * Return the raw value of a context property. * @@ -91,12 +162,13 @@ public function raw( $property ) { /** * Render a partial view. * - * @param string $path Path of the partial to render. - * @param array|null $context Context in which to render the partial. + * @param string $path Path of the partial to render. + * @param array|null $context Context in which to render the partial. + * @param ?Presenter $presenter Presenter for the partial. * @return string Rendered HTML, or an empty string if rendering failed * outside of debug mode. */ - public function render_partial( string $path, ?array $context = null ): string { - return $this->view->render_partial( $path, $context ); + public function render_partial( string $path, ?array $context = null, ?Presenter $presenter = null ): string { + return $this->view->render_partial( $path, $context, $presenter ); } } diff --git a/src/Infrastructure/View/SimpleView.php b/src/Infrastructure/View/SimpleView.php index 421ecde..a0c5b3f 100644 --- a/src/Infrastructure/View/SimpleView.php +++ b/src/Infrastructure/View/SimpleView.php @@ -17,8 +17,10 @@ use MWPD\BasicScaffold\Exception\FailedToLoadView; use MWPD\BasicScaffold\Exception\InvalidContextProperty; use MWPD\BasicScaffold\Exception\InvalidPath; +use MWPD\BasicScaffold\Exception\InvalidPresenterMethod; use MWPD\BasicScaffold\Exception\InvalidViewConfiguration; use Closure; +use MWPD\BasicScaffold\Infrastructure\Presenter; use MWPD\BasicScaffold\Infrastructure\TemplateScope; use MWPD\BasicScaffold\Infrastructure\View; use MWPD\BasicScaffold\Infrastructure\ViewFactory; @@ -70,6 +72,11 @@ class SimpleView extends stdClass implements View { */ private ?ViewConfiguration $configuration; + /** + * Presenter of the current render, if any, to pass onto partial views. + */ + private ?Presenter $presenter = null; + /** * Instantiate a SimpleView object. * @@ -101,16 +108,23 @@ public function __construct( /** * Render the current view with a given context. * - * @param array $context Context in which to render. + * @param array $context Context in which to render. + * @param ?Presenter $presenter Presenter whose declared methods the + * template can call. Optional. * * @return string Rendered HTML, or an empty string if the template failed * outside of debug mode. * @throws FailedToLoadView If the template failed (in debug mode). */ - public function render( array $context = [] ): string { + public function render( array $context = [], ?Presenter $presenter = null ): string { // Add entire context as array to the current instance to pass onto - // partial views. - $this->_context_ = $context; + // partial views, and the presenter with it. The presenter only holds + // for this render: a presenter method can render this same view + // again, and the partials of this render must not inherit that + // inner render's presenter. + $previous_presenter = $this->presenter; + $this->_context_ = $context; + $this->presenter = $presenter; // Save the current buffering level so the view can return to it // afterwards. The template might open or close buffering levels of @@ -119,7 +133,7 @@ public function render( array $context = [] ): string { \ob_start(); try { - $this->include_template( new SimpleTemplateScope( $this ) ); + $this->include_template( $this->create_scope( $presenter ) ); // Buffers the template opened and left open hold part of its // output, so they are flushed into the view's own buffer rather @@ -149,6 +163,8 @@ public function render( array $context = [] ): string { } return ''; + } finally { + $this->presenter = $previous_presenter; } // If the template closed the view's own buffer, there is nothing left @@ -163,6 +179,48 @@ public function render( array $context = [] ): string { return false === $buffer ? '' : $buffer; } + /** + * Create the scope to render the template in. + * + * A presenter method that shares its name with one of the scope's own + * methods could never be called, so it fails the view, inside render()'s + * failure handling, rather than being silently unreachable. + * + * @param ?Presenter $presenter Presenter the view is rendered with, if any. + * @return TemplateScope Scope to render the template in. + * @throws InvalidPresenterMethod If the presenter declares a reserved method name. + */ + private function create_scope( ?Presenter $presenter ): TemplateScope { + $escape = Closure::fromCallable( [ $this, 'escape' ] ); + + if ( null === $presenter ) { + return new SimpleTemplateScope( $this, $escape ); + } + + $reserved = \array_change_key_case( \array_flip( \get_class_methods( SimpleTemplateScope::class ) ) ); + $methods = []; + + // For an object, class_implements() always returns a list, if an empty + // one; its false is for a class name that cannot be loaded. + $interfaces = \class_implements( $presenter ); + + foreach ( \is_array( $interfaces ) ? $interfaces : [] as $interface ) { + if ( ! \is_subclass_of( $interface, Presenter::class ) ) { + continue; + } + + foreach ( \get_class_methods( $interface ) as $method ) { + if ( isset( $reserved[ \strtolower( $method ) ] ) ) { + throw InvalidPresenterMethod::for_reserved_name( $method, \get_class( $presenter ) ); + } + + $methods[ \strtolower( $method ) ] = $method; + } + } + + return new SimpleTemplateScope( $this, $escape, $presenter, $methods ); + } + /** * Include the template in its own scope. * @@ -246,11 +304,12 @@ private function remove_buffer(): bool { * This can be used from within a currently rendered view, to include * nested partials. * - * The passed-in context is optional, and will fall back to the parent's - * context if omitted. + * The passed-in context and presenter are optional, and each falls back to + * the parent's if omitted. * - * @param string $path Path of the partial to render. - * @param array|null $context Context in which to render the partial. + * @param string $path Path of the partial to render. + * @param array|null $context Context in which to render the partial. + * @param ?Presenter $presenter Presenter for the partial. * * @return string Rendered HTML, or an empty string if the partial failed * outside of debug mode. @@ -258,9 +317,9 @@ private function remove_buffer(): bool { * with the built-in view factories). * @throws FailedToLoadView If the partial failed (in debug mode). */ - public function render_partial( string $path, ?array $context = null ): string { + public function render_partial( string $path, ?array $context = null, ?Presenter $presenter = null ): string { return $this->view_factory->create( $path ) - ->render( $context ?? $this->_context_ ); + ->render( $context ?? $this->_context_, $presenter ?? $this->presenter ); } /** diff --git a/src/SampleSubsystem/PostHeader.php b/src/SampleSubsystem/PostHeader.php new file mode 100644 index 0000000..d8cae43 --- /dev/null +++ b/src/SampleSubsystem/PostHeader.php @@ -0,0 +1,48 @@ + + * @license MIT + * @link https://www.mwpd.io/ + * @copyright 2019 Alain Schlesser + */ + +declare( strict_types=1 ); + +namespace MWPD\BasicScaffold\SampleSubsystem; + +use MWPD\BasicScaffold\Exception\FailedToFindPost; +use MWPD\BasicScaffold\Infrastructure\Presenter; + +/** + * What the post header template asks about the post it introduces. + * + * The template calls these methods on `$this`, and only these: the interface + * extends Presenter, so its methods are what the template scope forwards. The + * strings they return are escaped for HTML before the template prints them. + * + * Templates and theme overrides of them call these methods by name, so + * renaming one breaks every override that uses it. + */ +interface PostHeader extends Presenter { + + /** + * Get the title of the post. + * + * @throws FailedToFindPost If there is no post to introduce. + * + * @return string Title of the post, as plain text. + */ + public function title(): string; + + /** + * Get the date the post was published on. + * + * @throws FailedToFindPost If there is no post to introduce. + * + * @return string Publication date of the post. + */ + public function date(): string; +} diff --git a/src/SampleSubsystem/QueriedPostHeader.php b/src/SampleSubsystem/QueriedPostHeader.php new file mode 100644 index 0000000..b544ae2 --- /dev/null +++ b/src/SampleSubsystem/QueriedPostHeader.php @@ -0,0 +1,62 @@ + + * @license MIT + * @link https://www.mwpd.io/ + * @copyright 2019 Alain Schlesser + */ + +declare( strict_types=1 ); + +namespace MWPD\BasicScaffold\SampleSubsystem; + +use MWPD\BasicScaffold\Exception\FailedToFindPost; + +/** + * The header of the post the main query asked for. + * + * It asks the main loop for the post each time the template calls it, like + * SampleLoopService does, so it answers for the post being rendered at that + * moment. + */ +final class QueriedPostHeader implements PostHeader { + + /** + * Main loop, asked for the queried post. + */ + private MainLoop $main_loop; + + /** + * Instantiate a QueriedPostHeader object. + * + * @param MainLoop $main_loop Main loop to ask for the queried post. + */ + public function __construct( MainLoop $main_loop ) { + $this->main_loop = $main_loop; + } + + /** + * Get the title of the queried post. + * + * @throws FailedToFindPost If the main query did not ask for a post. + * + * @return string Title of the post, as plain text. + */ + public function title(): string { + return $this->main_loop->queried_post()->post_title; + } + + /** + * Get the date the queried post was published on. + * + * @throws FailedToFindPost If the main query did not ask for a post. + * + * @return string Publication date of the post. + */ + public function date(): string { + return $this->main_loop->queried_post()->post_date; + } +} diff --git a/src/SampleSubsystem/SampleLoopService.php b/src/SampleSubsystem/SampleLoopService.php index c992b09..1c19703 100644 --- a/src/SampleSubsystem/SampleLoopService.php +++ b/src/SampleSubsystem/SampleLoopService.php @@ -50,6 +50,11 @@ final class SampleLoopService implements Service, Registerable, Conditional, Del */ private MainLoop $main_loop; + /** + * Presenter the header template asks about the post. + */ + private PostHeader $post_header; + /** * Check whether the conditional service is currently needed. * @@ -81,8 +86,10 @@ public static function get_registration_action(): string { * the views. * @param MainLoop $main_loop Main loop to ask which post is being * rendered. + * @param PostHeader $post_header Presenter the header template asks + * about the post. */ - public function __construct( ViewFactory $view_factory, MainLoop $main_loop ) { + public function __construct( ViewFactory $view_factory, MainLoop $main_loop, PostHeader $post_header ) { /* * We request a view factory from the injector so that we can create a * new view to be rendered when we want to show our sample notice. @@ -96,6 +103,13 @@ public function __construct( ViewFactory $view_factory, MainLoop $main_loop ) { * instead, and asks it each time the filter runs. */ $this->main_loop = $main_loop; + + /* + * The header template asks the presenter for what it prints. The + * service hands it to the view with each render, so the template gets + * this presenter and no other. + */ + $this->post_header = $post_header; } /** @@ -135,7 +149,7 @@ public function prepend_post_header( string $content ): string { * SampleBackendService, this covers failures of the view only. */ $post_header = $this->view_factory->create( 'views/test-loop-service' ) - ->render( [ 'post' => $this->main_loop->queried_post() ] ); + ->render( [], $this->post_header ); return $post_header . $content; } diff --git a/tests/php/Architecture/infrastructure-api.txt b/tests/php/Architecture/infrastructure-api.txt index 64d689d..8dd8412 100644 --- a/tests/php/Architecture/infrastructure-api.txt +++ b/tests/php/Architecture/infrastructure-api.txt @@ -104,6 +104,7 @@ Infrastructure\PluginLifecycle interface Infrastructure\PluginLifecycle::public abstract function activate_in( Infrastructure\Lifecycle\LifecycleScope $scope ): void Infrastructure\PluginLifecycle::public abstract function deactivate_in( Infrastructure\Lifecycle\LifecycleScope $scope ): void Infrastructure\PluginLifecycle::public abstract function uninstall(): void +Infrastructure\Presenter interface Infrastructure\QueryMonitor\CollectorFactory interface Infrastructure\QueryMonitor\CollectorFactory::public abstract function create(): \QM_Collector Infrastructure\QueryMonitor\OutputFactory interface @@ -243,13 +244,13 @@ Infrastructure\TemplateScope interface Infrastructure\TemplateScope::public abstract function __get( string $property ) Infrastructure\TemplateScope::public abstract function __isset( string $property ): bool Infrastructure\TemplateScope::public abstract function raw( $property ) -Infrastructure\TemplateScope::public abstract function render_partial( string $path, ?array $context = null ): string +Infrastructure\TemplateScope::public abstract function render_partial( string $path, ?array $context = null, ?Infrastructure\Presenter $presenter = null ): string Infrastructure\View extends Infrastructure\Renderable Infrastructure\View interface Infrastructure\View::public abstract function __get( string $property ) Infrastructure\View::public abstract function raw( $property ) -Infrastructure\View::public abstract function render( array $context = [] ): string -Infrastructure\View::public abstract function render_partial( string $path, ?array $context = null ): string +Infrastructure\View::public abstract function render( array $context = [], ?Infrastructure\Presenter $presenter = null ): string +Infrastructure\View::public abstract function render_partial( string $path, ?array $context = null, ?Infrastructure\Presenter $presenter = null ): string Infrastructure\ViewFactory interface Infrastructure\ViewFactory::public abstract function create( string $relative_path ): Infrastructure\View Infrastructure\View\EmptyView final class @@ -257,16 +258,20 @@ Infrastructure\View\EmptyView implements Infrastructure\Renderable Infrastructure\View\EmptyView implements Infrastructure\View Infrastructure\View\EmptyView::public function __get( string $property ) Infrastructure\View\EmptyView::public function raw( $property ) -Infrastructure\View\EmptyView::public function render( array $context = [] ): string -Infrastructure\View\EmptyView::public function render_partial( string $path, ?array $context = null ): string +Infrastructure\View\EmptyView::public function render( array $context = [], ?Infrastructure\Presenter $presenter = null ): string +Infrastructure\View\EmptyView::public function render_partial( string $path, ?array $context = null, ?Infrastructure\Presenter $presenter = null ): string +Infrastructure\View\SafeHtml final class +Infrastructure\View\SafeHtml::public function __construct( string $html ) +Infrastructure\View\SafeHtml::public function __toString(): string Infrastructure\View\SimpleTemplateScope final class Infrastructure\View\SimpleTemplateScope implements Infrastructure\TemplateScope -Infrastructure\View\SimpleTemplateScope::public function __construct( Infrastructure\View $view ) +Infrastructure\View\SimpleTemplateScope::public function __call( string $method, array $arguments ) +Infrastructure\View\SimpleTemplateScope::public function __construct( Infrastructure\View $view, \Closure $escape, ?Infrastructure\Presenter $presenter = null, array $methods = [] ) Infrastructure\View\SimpleTemplateScope::public function __get( string $property ) Infrastructure\View\SimpleTemplateScope::public function __isset( string $property ): bool Infrastructure\View\SimpleTemplateScope::public function __set( string $property, $value ): void Infrastructure\View\SimpleTemplateScope::public function raw( $property ) -Infrastructure\View\SimpleTemplateScope::public function render_partial( string $path, ?array $context = null ): string +Infrastructure\View\SimpleTemplateScope::public function render_partial( string $path, ?array $context = null, ?Infrastructure\Presenter $presenter = null ): string Infrastructure\View\SimpleView class Infrastructure\View\SimpleView implements Infrastructure\Renderable Infrastructure\View\SimpleView implements Infrastructure\View @@ -284,8 +289,8 @@ Infrastructure\View\SimpleView::public function __construct( string $path, Infra Infrastructure\View\SimpleView::public function __get( string $property ) Infrastructure\View\SimpleView::public function __isset( string $property ): bool Infrastructure\View\SimpleView::public function raw( $property ) -Infrastructure\View\SimpleView::public function render( array $context = [] ): string -Infrastructure\View\SimpleView::public function render_partial( string $path, ?array $context = null ): string +Infrastructure\View\SimpleView::public function render( array $context = [], ?Infrastructure\Presenter $presenter = null ): string +Infrastructure\View\SimpleView::public function render_partial( string $path, ?array $context = null, ?Infrastructure\Presenter $presenter = null ): string Infrastructure\View\SimpleViewFactory final class Infrastructure\View\SimpleViewFactory implements Infrastructure\Service Infrastructure\View\SimpleViewFactory implements Infrastructure\ViewFactory @@ -299,8 +304,8 @@ Infrastructure\View\TemplatedView::public function __get( string $property ) Infrastructure\View\TemplatedView::public function __isset( string $property ): bool Infrastructure\View\TemplatedView::public function add_location( string $location ): self Infrastructure\View\TemplatedView::public function raw( $property ) -Infrastructure\View\TemplatedView::public function render( array $context = [] ): string -Infrastructure\View\TemplatedView::public function render_partial( string $path, ?array $context = null ): string +Infrastructure\View\TemplatedView::public function render( array $context = [], ?Infrastructure\Presenter $presenter = null ): string +Infrastructure\View\TemplatedView::public function render_partial( string $path, ?array $context = null, ?Infrastructure\Presenter $presenter = null ): string Infrastructure\View\TemplatedViewFactory final class Infrastructure\View\TemplatedViewFactory implements Infrastructure\Service Infrastructure\View\TemplatedViewFactory implements Infrastructure\ViewFactory diff --git a/tests/php/Fixture/View/FixedGreeting.php b/tests/php/Fixture/View/FixedGreeting.php new file mode 100644 index 0000000..60b44ac --- /dev/null +++ b/tests/php/Fixture/View/FixedGreeting.php @@ -0,0 +1,68 @@ + + * @license MIT + * @link https://www.mwpd.io/ + * @copyright 2019 Alain Schlesser + */ + +declare( strict_types=1 ); + +namespace MWPD\BasicScaffold\Tests\Fixture\View; + +use LogicException; +use MWPD\BasicScaffold\Infrastructure\View\SafeHtml; + +/** + * A greeting with fixed answers, and a public method no interface declares. + */ +final class FixedGreeting implements Greeting { + + private string $salutation; + + public function __construct( string $salutation = 'Hello' ) { + $this->salutation = $salutation; + } + + public function greeting( string $name, string $punctuation = '!' ): string { + return $this->salutation . ', ' . $name . $punctuation; + } + + public function is_formal(): bool { + return true; + } + + public function emphasis(): SafeHtml { + return new SafeHtml( 'welcome' ); + } + + public function label(): Label { + return new Label( '