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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions docs/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
106 changes: 84 additions & 22 deletions docs/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`).

Expand Down Expand Up @@ -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: <?= esc_html( $current_post->post_title ) ?>
<p>Hello World! from the <b><?= $this->plugin ?></b> plugin!</p>
<p><em>Raw value: <b><?= esc_html( $raw_plugin ) ?></b></em></p>
```

**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: <?= $this->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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/BasicScaffoldPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
];
}
Expand Down
76 changes: 76 additions & 0 deletions src/Exception/InvalidPresenterMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php
/**
* MWPD Basic Plugin Scaffold.
*
* @package MWPD\BasicScaffold
* @author Alain Schlesser <alain.schlesser@gmail.com>
* @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 );
}
}
Loading