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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ material.
| `src/Infrastructure/` | What generated plugins build on: service container, injector, views, autoloader. |
| `src/Exception/` | The scaffold's exception types. |
| `src/SampleSubsystem/`, `views/` | Example services and templates that a generated plugin replaces, apart from `views/query-monitor-registration.php`, the template of the optional Query Monitor panel in `src/Infrastructure/QueryMonitor/`. |
| `assets/` | The sample services' stylesheet and script, shipped as they are, without a build step. |
| `tests/php/Unit`, `tests/php/Integration` | PHPUnit suites. Both use Brain Monkey; neither needs a WordPress install. |
| `tests/php/Fixture/` | Test doubles and view fixtures. |
| `tests/php/Architecture/`, `tests/php/PHPStan/` | Architecture rules: the `architecture` suite, PHPStan rules, their tests and fixtures. |
Expand Down
12 changes: 12 additions & 0 deletions assets/css/sample-loop-service.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Post header of SampleLoopService.
*
* The service declares this stylesheet in get_assets(), and the plugin only
* loads it on the front end of singular views, where the header shows.
*/
.mwpd-basic-scaffold-post-header {
margin: 0 0 1.5em;
padding: 0.75em 1em;
border-inline-start: 4px solid currentColor;
font-size: 0.875em;
}
54 changes: 54 additions & 0 deletions assets/js/sample-backend-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copy button of SampleBackendService's notice.
*
* The service declares this script for admin screens, deferred, so it
* usually runs once the notice has been parsed. WordPress before 6.3 loads it
* the classic way, in the head and before the notice, so it waits for the
* document whenever that is still loading. It copies the text the button
* carries and reports the result in the status next to it. The messages come
* from the notice's markup, so this file holds no text of its own.
*/
( function () {
'use strict';

function setUp( button ) {
var status = button.parentNode.querySelector( '.mwpd-basic-scaffold-copy-status' );

function report( message ) {
if ( status ) {
status.textContent = message;
}
}

// The Clipboard API needs a secure context, so the button stays
// hidden where it could not work.
if ( ! navigator.clipboard ) {
return;
}

button.hidden = false;

button.addEventListener( 'click', function () {
navigator.clipboard.writeText( button.dataset.mwpdBasicScaffoldCopy ).then(
function () {
report( button.dataset.mwpdBasicScaffoldCopied );
},
function () {
report( button.dataset.mwpdBasicScaffoldFailed );
}
);
} );
}

function setUpAll() {
var buttons = document.querySelectorAll( '[data-mwpd-basic-scaffold-copy]' );

Array.prototype.forEach.call( buttons, setUp );
}

if ( 'loading' === document.readyState ) {
document.addEventListener( 'DOMContentLoaded', setUpAll );
} else {
setUpAll();
}
}() );
39 changes: 28 additions & 11 deletions docs/architecture-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,7 @@ native types and in any documentation tag, such as `@var`, `@throws` or
| --- | --- |
| the exceptions (`src/Exception/`) | other exceptions |
| the infrastructure (`src/Infrastructure/`) | the infrastructure and the exceptions |
| a feature subsystem (any other top-level namespace, such as `src/SampleSubsystem/`) | the infrastructure's interfaces (and traits that only fulfil them), the exceptions and the subsystems |
| a feature subsystem (any other top-level namespace, such as `src/SampleSubsystem/`) | the infrastructure's interfaces (and traits that only fulfil them), its value objects, the exceptions and the subsystems |
| the composition root | everything |
| any other class directly in the root namespace | the infrastructure's interfaces and the exceptions |
| a view template (`views/`) | everything but the tests |
Expand All @@ -866,12 +866,17 @@ whose methods all belong to infrastructure interfaces the class implements,
such as `DefaultRegistrationPriority` for `Delayed`: the trait only fulfils a
contract the class already depends on, and is not an object the composition
root could bind instead. A trait that brings any other method is reported.
A subsystem may also name the infrastructure's value objects, the classes
listed in `newOnlyWhereAllowed.valueObjects`, such as the `Style` a service
declares in `get_assets()`: a value is not a collaborator the composition root
could bind instead, which is what the interfaces are for.

**Configuration:** `dependencyDirection.infrastructure` names the
infrastructure's namespace, and `dependencyDirection.compositionRoot` lists the
classes of the composition root: `BasicScaffoldPlugin`,
`BasicScaffoldPluginFactory` and the uninstall bridge
`BasicScaffoldUninstallHook`, an edge like the main file. `dependencyDirection.views` lists the
`BasicScaffoldUninstallHook`, an edge like the main file. The value objects come from
`newOnlyWhereAllowed.valueObjects`. `dependencyDirection.views` lists the
directories of the view templates that run in production. The exceptions' namespace comes from
`exceptions.namespace`, the tests' from `excludedNamespaces`.

Expand Down Expand Up @@ -1163,6 +1168,11 @@ with the prefix for their kind:
| `settings` | `register_setting` | option group, the 1st argument, is a read and may name a core settings page; option name, the 2nd argument, always needs the prefix | `mwpd_basic_scaffold_` |
| `ajax` | `add_action` with a known `wp_ajax_` or `wp_ajax_nopriv_` action | the remainder after WordPress's action prefix | `mwpd_basic_scaffold_` |

The same check applies where a name is created by a static method rather than
a WordPress function: the handle, the 1st argument, of the asset declarations
`Style::file()`, `Style::inline()` and `Script::file()`. A service declares its
asset there, and the infrastructure passes the handle on to WordPress later.

A name passes when PHPStan knows it starts with the prefix, or when it is built
from one of the kind's prefix constants of the class itself, as in
`static::HOOK_PREFIX . static::SERVICES_FILTER`. A name the rule cannot work
Expand All @@ -1189,7 +1199,9 @@ must carry the AJAX prefix. A known AJAX wrapper with an unresolved remainder
fails with the usual "cannot check" message. A name WordPress owns that the
plugin also has to write passes, reads and writes alike, when the kind lists it
as `managed`: a reviewed exception for state WordPress keeps on the plugin's
behalf, such as its entry in the `uninstall_plugins` option.
behalf, such as its entry in the `uninstall_plugins` option. A class listed as
a forwarder of a kind, `WordPressAssets` for asset handles, is not checked: it
only passes on handles that were checked where they were declared.

**Configuration:** `prefixedNames.<kind>` sets the kind's `prefix`, its
prefix `constants`, the `allowed` names it may read and the `managed` names it
Expand All @@ -1202,15 +1214,18 @@ use the shorter `mwpd_bs_` so the prefix leaves room for a name within their
limits; the option prefix alone is already 20 characters. Hooks retain
`mwpd.basic_scaffold.` and the `HOOK_PREFIX` constant. A plugin generated from
the scaffold renames these settings together with `HOOK_PREFIX` and
`SERVICE_PREFIX` in its composition root.
`SERVICE_PREFIX` in its composition root. `prefixedDeclarations.methods.<kind>`
lists the static methods, as `Class::method`, whose 1st argument creates a name
of the kind, and `prefixedDeclarations.forwarders.<kind>` the classes that only
pass such names on; both are renamed with the plugin's namespace.

**Fix:** start the name with the prefix, or build it from the prefix
constant. Shorten overlong post type and taxonomy names, including the prefix,
to the WordPress limit. Writing a name WordPress owns needs a reviewed
`managed` entry; add one only for state WordPress keeps for the plugin itself,
and say why beside it.

**Patterns:** [I need a name in a space WordPress shares](patterns.md#i-need-a-name-in-a-space-wordpress-shares), [I need a public API for themes or other plugins](patterns.md#i-need-a-public-api-for-themes-or-other-plugins).
**Patterns:** [I need a name in a space WordPress shares](patterns.md#i-need-a-name-in-a-space-wordpress-shares), [I need a public API for themes or other plugins](patterns.md#i-need-a-public-api-for-themes-or-other-plugins), [I need to load a script or style](patterns.md#i-need-to-load-a-script-or-style).

### `architecture.suppressionsAreNamed`

Expand Down Expand Up @@ -1387,8 +1402,9 @@ one of the eight scoped lifecycle interfaces (`SiteActivateable`,
`NetworkActivateable`, `SiteDeactivateable`, `NetworkDeactivateable`,
`SiteInitializable`, `SiteUninitializable`, `SiteUninstallable`,
`NetworkUninstallable`) also implements `Service`.
A `Delayed` class also implements `Registerable`: waiting for an action only
makes sense when the service has hooks to register. Inherited interfaces count,
A `Delayed` class also implements `Registerable` or `HasAssets`: waiting for an
action only makes sense when the service has hooks to register or assets to
load. Inherited interfaces count,
including on abstract classes.

**Allows:** interfaces and traits, ordinary classes with no lifecycle
Expand All @@ -1397,18 +1413,19 @@ infrastructure and the configured composition-root classes orchestrate services
and are outside this check.

**Configuration:** `capabilitiesOnServices.service` names the service marker,
`capabilities` lists the lifecycle interfaces, and `delayed` and `registerable`
name the two interfaces whose combination is checked. All are fully qualified
`capabilities` lists the lifecycle interfaces, and `delayed`, `registerable`
and `hasAssets` name the interfaces whose combination is checked. All are fully qualified
names, renamed together with the plugin's namespace. The orchestration
exclusions come from `dependencyDirection.infrastructure` and
`dependencyDirection.compositionRoot`.

**Fix:** implement `Service` so the plugin can orchestrate the class; for a
`Delayed` class, implement `Registerable` and add its hooks in `register()`.
`Delayed` class, implement `Registerable` and add its hooks in `register()`, or
`HasAssets` if all it does is load scripts or styles.
Declare the service in the composition root as shown in
[I need to add a feature](patterns.md#i-need-to-add-a-feature).

**Patterns:** [I need to add a feature](patterns.md#i-need-to-add-a-feature), [I need to see why a service did not run](patterns.md#i-need-to-see-why-a-service-did-not-run).
**Patterns:** [I need to add a feature](patterns.md#i-need-to-add-a-feature), [I need to see why a service did not run](patterns.md#i-need-to-see-why-a-service-did-not-run), [I need to load a script or style](patterns.md#i-need-to-load-a-script-or-style).

### `architecture.servicesDeclared`

Expand Down
20 changes: 13 additions & 7 deletions docs/design-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,20 @@ Each lifecycle question a service has to answer is a small interface in
| What happens in each site, or once for the network, on activation or deactivation? | `SiteActivateable`, `NetworkActivateable`, `SiteDeactivateable`, `NetworkDeactivateable` | Called with a `SiteContext` in every site the event reaches, switched to, or with a `NetworkContext` once for the network. Any of them replaces the parameterless call of that event. |
| What happens when a site is created or deleted? | `SiteInitializable`, `SiteUninitializable` | Called in a new site of a network where the plugin is network-active, and in a site about to be deleted, in requests where the plugin is loaded. |
| What happens when the plugin is uninstalled? | `SiteUninstallable`, `NetworkUninstallable` | Called through the uninstall bridge in every site, then once for each network on multisite, or in the one site of a single-site installation. |
| Which scripts and styles does it need? | `HasAssets::get_assets()` | Asked at each enqueue action (front end, admin, block editor, login) once the service is registered, and asked at once for the actions that have already run. The plugin loads what it returns through `Assets`, served from the main file's directory with its `Version` header. [Pattern](patterns.md#i-need-to-load-a-script-or-style) |
| Does it show how the services were registered? | `ObservesRegistration::observes_registration()` | Asked by class before any service is scheduled. If one says yes, the plugin records every registration decision of the request and hands the history to constructors asking for a `RegistrationReport`; otherwise it records nothing. The optional Query Monitor panel is the built-in observer. [Pattern](patterns.md#i-need-to-see-why-a-service-did-not-run) |

The plugin itself only hooks into `plugins_loaded`
(`src/Infrastructure/ServiceBasedPlugin.php:90`) and the activation and
deactivation hooks (`basic-scaffold.php:129-151`). On multisite, registration
also hooks into the creation and deletion of sites when a service needs it,
and into the four enqueue actions when a service has assets,
and activation stores the uninstall callback
(`src/BasicScaffoldUninstallHook.php:36-38`), which WordPress calls when the
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:63-80`), and no other code has to
(`src/SampleSubsystem/SampleLoopService.php:66-83`), and no other code has to
know.

The questions are static methods on purpose: the plugin can **decide before it
Expand Down Expand Up @@ -207,7 +209,7 @@ another part of the plugin depends on:
- **Dependency inversion:** high-level code depends on interfaces, and the
injector supplies the implementations. `SampleBackendService` receives a
`ViewFactory` through its constructor
(`src/SampleSubsystem/SampleBackendService.php:60`) and never creates one.
(`src/SampleSubsystem/SampleBackendService.php:66`) and never creates one.

**Implementation details may take shortcuts.** Inside a class, behind its
interface, it is fine to call WordPress functions, use the plugin API and read
Expand Down Expand Up @@ -320,12 +322,12 @@ book, shows the better alternative so that plugins built on it learn it too.

| Common anti-pattern | What the scaffold does instead |
| --- | --- |
| Registering hooks in a constructor, so creating an object changes global state | Constructors only store dependencies. Hooks are added in `register()` (`Registerable`), called by the plugin at the right time (`src/SampleSubsystem/SampleBackendService.php:71-77`). [Pattern](patterns.md#i-need-to-add-hooks) |
| Registering hooks in a constructor, so creating an object changes global state | Constructors only store dependencies. Hooks are added in `register()` (`Registerable`), called by the plugin at the right time (`src/SampleSubsystem/SampleBackendService.php:98-104`). [Pattern](patterns.md#i-need-to-add-hooks) |
| Singletons with `::instance()` and static state | Shared instances come from the injector (`share()`). The plugin factory keeps one shared plugin instance without the Singleton pattern (`src/BasicScaffoldPluginFactory.php:45-63`). [Pattern](patterns.md#i-need-a-singleton) |
| A service locator, such as a static `Services::get( 'id' )` | Each need behind it has its own answer: constructor injection, a factory, a typed registry, `share()`, or a bridge at the edge of the object graph (principle 5). [Pattern](patterns.md#i-need-a-service-locator) |
| A global variable or singleton so that WordPress callbacks can reach the plugin | A bridge gets the plugin through the plugin factory and delegates at once (`basic-scaffold.php:129-151`). [Pattern](patterns.md#i-need-to-reach-the-plugin-from-a-static-wordpress-callback) |
| Creating collaborators with `new` inside a class, or pulling them from globals | Dependencies are injected through the constructor and configured as bindings in the composition root. [Pattern: creating objects](patterns.md#i-need-to-create-objects-on-demand), [WordPress state](patterns.md#i-need-wordpress-state-such-as-the-current-post) |
| `is_admin()` checks wrapped around hook calls throughout the code | A service states when it is needed once, in `Conditional::is_needed()` (`src/SampleSubsystem/SampleBackendService.php:45-52`). [Pattern](patterns.md#i-need-a-service-only-in-some-requests) |
| `is_admin()` checks wrapped around hook calls throughout the code | A service states when it is needed once, in `Conditional::is_needed()` (`src/SampleSubsystem/SampleBackendService.php:51-58`). [Pattern](patterns.md#i-need-a-service-only-in-some-requests) |
| Hooking to an early action just to wait for a later one | A service names its registration action through `Delayed`, and the plugin defers it. [Pattern](patterns.md#i-need-to-hook-in-later) |
| Asking query-dependent conditions before the query has run | A `Conditional` service also implements `Delayed` and returns `wp` or a later action from `get_registration_action()`, so the plugin waits before asking `is_needed()`. [Pattern](patterns.md#i-need-to-hook-in-later) |
| Doing work when a file is included | Only `basic-scaffold.php` has side effects; everything else is autoloadable declarations. [Pattern](patterns.md#i-need-to-run-code-when-the-plugin-file-loads) |
Expand Down Expand Up @@ -476,17 +478,21 @@ the code falls back to how WordPress works:
the site keeps working. A view that fails renders nothing. That field or
notice is still broken, but the page around it is not taken down with it.

**Views already work this way; other callbacks do not yet.** A view that
**Views and assets already work this way; other callbacks do not yet.** A view that
cannot be created or rendered is handled inside the view system itself. In
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:146-168`,
`src/Infrastructure/View/TemplatedViewFactory.php:90-111`). The samples need no
`try`/`catch` around rendering for that reason.
`try`/`catch` around rendering for that reason. Loading assets has the same
boundary: a failure in a service's `get_assets()` or while enqueueing is
rethrown unchanged in debug mode, and in production skips that service's
remaining assets for the enqueue point while the other services still load
theirs (`ServiceBasedPlugin::enqueue_assets_of()`).

Outside the view system, nothing stops an exception before it reaches
Outside the view system and the asset loading, nothing stops an exception before it reaches
WordPress yet. An exception thrown by a
service's own code in a hook callback, or during registration, activation or
deactivation, still reaches WordPress with debug mode on or off. How to catch
Expand Down
Loading