diff --git a/AGENTS.md b/AGENTS.md index 1ff6492..485b404 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. | diff --git a/assets/css/sample-loop-service.css b/assets/css/sample-loop-service.css new file mode 100644 index 0000000..2d9782a --- /dev/null +++ b/assets/css/sample-loop-service.css @@ -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; +} diff --git a/assets/js/sample-backend-service.js b/assets/js/sample-backend-service.js new file mode 100644 index 0000000..6c5f238 --- /dev/null +++ b/assets/js/sample-backend-service.js @@ -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(); + } +}() ); diff --git a/docs/architecture-rules.md b/docs/architecture-rules.md index c08a7c0..e615979 100644 --- a/docs/architecture-rules.md +++ b/docs/architecture-rules.md @@ -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 | @@ -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`. @@ -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 @@ -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.` sets the kind's `prefix`, its prefix `constants`, the `allowed` names it may read and the `managed` names it @@ -1202,7 +1214,10 @@ 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.` +lists the static methods, as `Class::method`, whose 1st argument creates a name +of the kind, and `prefixedDeclarations.forwarders.` 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, @@ -1210,7 +1225,7 @@ 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` @@ -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 @@ -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` diff --git a/docs/design-principles.md b/docs/design-principles.md index bdd6198..314f9c0 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -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 @@ -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 @@ -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) | @@ -476,7 +478,7 @@ 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 @@ -484,9 +486,13 @@ 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 diff --git a/docs/patterns.md b/docs/patterns.md index 8c60246..369aee6 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -28,6 +28,7 @@ the need may be new: ask on an issue rather than inventing a workaround. - [I need to add hooks](#i-need-to-add-hooks) - [I need a service only in some requests](#i-need-a-service-only-in-some-requests) - [I need to hook in later](#i-need-to-hook-in-later) +- [I need to load a script or style](#i-need-to-load-a-script-or-style) - [I need to set something up when the plugin is activated](#i-need-to-set-something-up-when-the-plugin-is-activated) - [I need to clean up when the plugin is uninstalled](#i-need-to-clean-up-when-the-plugin-is-uninstalled) - [I need to create objects on demand](#i-need-to-create-objects-on-demand) @@ -118,7 +119,7 @@ final class SampleBackendService implements Service, Registerable, Conditional { } ``` -**Proven by:** `src/SampleSubsystem/SampleBackendService.php:60-77`. +**Proven by:** `src/SampleSubsystem/SampleBackendService.php:66-104`. **Rules:** `architecture.noHooksInConstructor`, `architecture.hooksInLifecycleMethods`, `architecture.constructorsOnlyAssign`. @@ -145,7 +146,7 @@ public static function is_needed(): bool { } ``` -**Proven by:** `src/SampleSubsystem/SampleBackendService.php:45-52`. +**Proven by:** `src/SampleSubsystem/SampleBackendService.php:51-58`. When the answer depends on the query, such as `is_singular()`, also implement `Delayed` and wait for `wp` or a later action as shown in @@ -211,7 +212,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-80`, and the +**Proven by:** `src/SampleSubsystem/SampleLoopService.php:31-83`, and the priority and dependency cases in `tests/php/Unit/ServiceBasedPluginTest.php:533-660`. @@ -228,6 +229,84 @@ interface method. Add `use DefaultRegistrationPriority;` (from the `Infrastructure` namespace) to each of them to keep the priority of 10 they were registered at before, or implement the method to choose another one. +## I need to load a script or style + +**What people reach for:** `add_action( 'wp_enqueue_scripts', … )` in the +service, with `wp_enqueue_style()` and a `plugins_url( 'css/x.css', __FILE__ )` +or a `MY_PLUGIN_URL` constant inside it, and an `is_singular()` or +`get_current_screen()` check to keep the file off pages that do not need it. + +**Why not here:** [the core idea](design-principles.md#how-services-map-to-it): +when and where a service acts is the infrastructure's job, so every service +wiring its own enqueue hooks spreads lifecycle logic again. +[Principle 5](design-principles.md#5-one-composition-root-builds-the-object-graph) +rules out the constant, and [principle 1](design-principles.md#1-runs-on-php-and-wordpress-alone) +any build step to produce the files. + +**Instead:** implement `HasAssets` and return what the service needs from +`get_assets()`. The plugin hooks the enqueue actions once, asks every +registered service at each of them, and loads what they return. `Style` and +`Script` are immutable values that a service creates itself, through their +named constructors and chained options: + +```php +public function get_assets( AssetContext $context ): iterable { + if ( ! $context->is_front_end() ) { + return []; + } + + return [ Style::file( 'mwpd-basic-scaffold-post-header', 'assets/css/sample-loop-service.css' ) ]; +} +``` + +Most of "where" is already decided: a service that `Conditional` rules out, +or that `Delayed` has not registered yet, is never asked. `get_assets()` only +picks among the enqueue points of a request it exists in: + +| Context | WordPress action | +| --- | --- | +| `AssetContext::front_end()` | `wp_enqueue_scripts` | +| `AssetContext::admin( $hook_suffix )` | `admin_enqueue_scripts`; `is_admin( 'settings_page_x' )` checks the screen | +| `AssetContext::block_editor()` | `enqueue_block_editor_assets`, for the editor's own interface | +| `AssetContext::login()` | `login_enqueue_scripts` | + +Several of them can run in one request: the block editor fires its own action +and then the admin one. Because the plugin asks at the action, the answer can +also depend on the state of the request, such as whether the post being shown +is outdated. + +- **Where the files are served from** comes from the main file: its directory + URL, and its `Version` header as the version of every asset that sets none + with `with_version()`. A missing header is an `InvalidAsset`, never a silent + fallback. To serve the assets from elsewhere, such as a CDN, return your own + `AssetLocation` from `get_asset_location()` in the composition root. +- **A few lines of CSS** can be printed inline with `Style::inline()`, which + saves a request and can still be dequeued by its handle. +- **Scripts** can be `in_footer()`, `deferred()` or `async()`. WordPress before + 6.3 ignores the strategy and loads the script the classic way. +- **A service registered late**, after an enqueue point has run, is asked for + it at once. WordPress then prints a style in the footer, or not at all once + the footer has been printed, so register a service with assets no later than + it needs them. +- **Two services may declare the same asset.** Declaring a handle again with + another file or other options is a conflict: debug mode throws. Production + skips the conflicting asset and the service's assets after it for that + enqueue point; the ones before it are already enqueued, and the other + services still load theirs. A failure in `get_assets()` itself loads none of + the service's assets, as they are all collected first. + +**Proven by:** `src/SampleSubsystem/SampleLoopService.php:130-145` (a style on +singular views) and `src/SampleSubsystem/SampleBackendService.php:75-93` (a +deferred script on admin screens), the orchestration in +`src/Infrastructure/ServiceBasedPlugin.php:1925-2131` with its defaults at +`:2496-2547`, and +`tests/php/Unit/AssetLoadingTest.php` with `tests/php/Integration/SampleAssetsTest.php`. + +**Rules:** `architecture.prefixedNames` checks the handle where a service +declares it, in `Style::file()`, `Style::inline()` and `Script::file()`. +`architecture.capabilitiesOnServices` accepts a `Delayed` service whose only +work is its assets. + ## I need to set something up when the plugin is activated **What people reach for:** `register_activation_hook()` next to the code that @@ -536,8 +615,8 @@ plugin constructor instead: $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`, +**Proven by:** `src/SampleSubsystem/SampleBackendService.php:66-72` and +`:109-126`, `src/Infrastructure/View/TemplatedViewFactory.php:39` and `:90-111`, 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`. @@ -886,7 +965,7 @@ 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:155`, and asked at call time in -`src/SampleSubsystem/SampleLoopService.php:137-155`. +`src/SampleSubsystem/SampleLoopService.php:159-177`. `tests/php/Unit/WordPressMainLoopTest.php` covers each state of the loop, and `tests/php/Integration/SampleLoopServiceTest.php:53-115` shows the header on the queried post only. @@ -994,8 +1073,8 @@ $raw_plugin = $this->raw( 'plugin' );

Raw value:

``` -**Proven by:** `src/SampleSubsystem/SampleBackendService.php:82-99` and -`views/test-backend-service.php:5-15`. +**Proven by:** `src/SampleSubsystem/SampleBackendService.php:109-126` and +`views/test-backend-service.php:5-20`. **Rules:** `architecture.outputOnlyThroughViews` requires direct method calls on typed `Renderable` or `View` objects when printing from services. @@ -1055,7 +1134,7 @@ when the template asks. **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 +`src/SampleSubsystem/SampleLoopService.php:173-174` and called in `views/test-loop-service.php:6-18`. `tests/php/Unit/SimpleViewPresenterTest.php` covers results, failures and partials. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 060a540..51b80b5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -60,8 +60,10 @@ parameters: - MWPD\BasicScaffold\Infrastructure\SiteUninstallable - MWPD\BasicScaffold\Infrastructure\NetworkUninstallable - MWPD\BasicScaffold\Infrastructure\Registration\ObservesRegistration + - MWPD\BasicScaffold\Infrastructure\HasAssets delayed: MWPD\BasicScaffold\Infrastructure\Delayed registerable: MWPD\BasicScaffold\Infrastructure\Registerable + hasAssets: MWPD\BasicScaffold\Infrastructure\HasAssets finalByDefault: # Classes designed to be extended (design principle 6). baseClasses: @@ -171,6 +173,12 @@ parameters: - MWPD\BasicScaffold\Infrastructure\Lifecycle\SiteSelection - MWPD\BasicScaffold\Infrastructure\Lifecycle\NetworkSelection - MWPD\BasicScaffold\Infrastructure\Registration\RegistrationRecord + - MWPD\BasicScaffold\Infrastructure\Asset\AssetContext + - MWPD\BasicScaffold\Infrastructure\Asset\AssetHandle + - MWPD\BasicScaffold\Infrastructure\Asset\AssetLocation + - MWPD\BasicScaffold\Infrastructure\Asset\AssetPath + - MWPD\BasicScaffold\Infrastructure\Asset\Script + - MWPD\BasicScaffold\Infrastructure\Asset\Style # A read-only wrapper around the registration log, like the # lazy service wrapper above. - MWPD\BasicScaffold\Infrastructure\Registration\ReadOnlyRegistrationReport @@ -338,3 +346,18 @@ parameters: constants: [] allowed: [] managed: [] + # Static methods that create a name of a kind, checked like the + # WordPress functions above, and the classes that only pass such names + # on to WordPress (design principle 13). + prefixedDeclarations: + methods: + # The asset declarations create the handle, so it is checked + # where a service declares an asset. + assets: + - MWPD\BasicScaffold\Infrastructure\Asset\Style::file + - MWPD\BasicScaffold\Infrastructure\Asset\Style::inline + - MWPD\BasicScaffold\Infrastructure\Asset\Script::file + forwarders: + # Registers and enqueues the handles declared above. + assets: + - MWPD\BasicScaffold\Infrastructure\Asset\WordPressAssets diff --git a/src/Exception/InvalidAsset.php b/src/Exception/InvalidAsset.php new file mode 100644 index 0000000..56235db --- /dev/null +++ b/src/Exception/InvalidAsset.php @@ -0,0 +1,172 @@ + + * @license MIT + * @link https://www.mwpd.io/ + * @copyright 2019 Alain Schlesser + */ + +declare( strict_types=1 ); + +namespace MWPD\BasicScaffold\Exception; + +use InvalidArgumentException; + +/** + * Exception thrown when a script or style is declared in a way WordPress + * cannot load as intended. + */ +final class InvalidAsset extends InvalidArgumentException implements BasicScaffoldException { + + /** + * Create a new instance of the exception for an empty asset handle. + */ + public static function for_empty_handle(): self { + return new self( 'An asset handle cannot be empty. Start it with the plugin\'s asset prefix.' ); + } + + /** + * Create a new instance of the exception for a handle WordPress could not + * enqueue as it was registered. + * + * @param string $handle Handle that was passed. + */ + public static function for_handle( string $handle ): self { + $message = \sprintf( + 'The asset handle "%s" contains a "?", whitespace or a control character. ' + . 'WordPress would register it, but not find it when it is enqueued.', + $handle + ); + + return new self( $message ); + } + + /** + * Create a new instance of the exception for an asset path that is not a + * plain path inside the plugin. + * + * @param string $handle Handle of the asset. + * @param string $path Path that was passed. + */ + public static function for_path( string $handle, string $path ): self { + $message = \sprintf( + 'The path "%s" of the asset "%s" must be relative to the plugin root, such as "assets/css/notice.css": ' + . 'no leading slash, URL, backslash, query, "%%" escape, ".." segment or control character.', + $path, + $handle + ); + + return new self( $message ); + } + + /** + * Create a new instance of the exception for an inline style without CSS. + * + * @param string $handle Handle of the style. + */ + public static function for_empty_css( string $handle ): self { + return new self( \sprintf( 'The inline style "%s" has no CSS.', $handle ) ); + } + + /** + * Create a new instance of the exception for inline CSS that contains a + * style tag of its own. + * + * @param string $handle Handle of the style. + */ + public static function for_style_tag( string $handle ): self { + $message = \sprintf( + 'The inline style "%s" contains a ' ), + 'contains a