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
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ tested and readable on its own.
| `src/ContentFreshnessPlugin.php` | Composition root: services, injector bindings, shared instances, prefixes. |
| `src/Freshness/` | The domain: `FreshnessPolicy` and its option-backed implementation, the `MaxAge` and `PostAge` value objects, the `Clock`. |
| `src/Notice/`, `src/PostsList/`, `src/Settings/`, `src/Digest/` | The four features, one service each, with their WordPress adapters. |
| `src/Infrastructure/` | The scaffold's infrastructure, as of `mwpd/basic-scaffold@61a9b00`: service container, injector, views, autoloader, capability interfaces, multisite and uninstall lifecycle, the optional Query Monitor panel. |
| `src/Infrastructure/` | The scaffold's infrastructure, as of `mwpd/basic-scaffold@61a9b00` plus the asset capability merged in [mwpd/basic-scaffold#177](https://github.com/mwpd/basic-scaffold/pull/177) (`15cac4c`): service container, injector, views, autoloader, capability interfaces, multisite and uninstall lifecycle, scripts and styles, the optional Query Monitor panel. |
| `src/Exception/` | The plugin's exception types. |
| `views/` | Templates the services render, and `views/query-monitor-registration.php`, the template of the scaffold's optional Query Monitor panel. |
| `assets/` | The notice's stylesheet, shipped as it is, 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, stand-ins for WordPress classes, view fixtures. |
| `tests/php/Architecture/`, `tests/php/PHPStan/` | Architecture rules: the `architecture` suite, PHPStan rules, their tests and fixtures. |
Expand Down Expand Up @@ -68,10 +69,10 @@ pattern shows the plugin's way, with code that passes the checks.
**The core idea: complexity grows linearly.** Every concern is its own
service, and each service decides in isolation where and when it acts in the
WordPress lifecycle, by implementing small capability interfaces
(`Conditional`, `Delayed`, `HasDependencies`, `Registerable`, `Activateable`,
`Deactivateable`, and the site- and network-scoped lifecycle interfaces such as
`SiteActivateable` and `SiteUninstallable`). The infrastructure works out when
to trigger what. New logic is one more service, never one more condition threaded
(`Conditional`, `Delayed`, `HasDependencies`, `Registerable`, `HasAssets`,
`Activateable`, `Deactivateable`, and the site- and network-scoped lifecycle
interfaces such as `SiteActivateable` and `SiteUninstallable`). The
infrastructure works out when to trigger what. New logic is one more service, never one more condition threaded
through the others. A lifecycle point no interface covers gets a new
interface, not a conditional inside a service.

Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ All notable changes to this plugin are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the plugin uses
[Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- A default style for the outdated post notice, `assets/css/notice.css`: a
soft amber box, sized in `em` so it follows the theme's type, with a
high-contrast fallback. The plugin loads it in the head of a single post
that is outdated, and nowhere else. Themes override the
`mwpd-content-freshness-notice` class to adjust it, or dequeue the
`mwpd-content-freshness-notice` style handle to replace it.
- The scaffold's asset capability, ported from
[mwpd/basic-scaffold#37](https://github.com/mwpd/basic-scaffold/issues/37):
a service that implements `HasAssets` declares the scripts and styles it
needs from `get_assets()`, and the plugin hooks the enqueue actions once,
asks every registered service at each of them, and loads the declarations
through the `Assets` boundary. Handles are checked for the plugin's prefix
where they are declared.

## [1.0.1] - 2026-10-23

### Changed
Expand Down
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,33 @@ policy's `outdated_before()`, then lets `is_outdated()` decide about each
one, so a rule that leaves some old posts out applies to the email too. The plugin's tests do the same with a fixed policy
(`tests/php/Integration/Notice/OutdatedPostNoticeTest.php:78-100`).

### Changing the look of the notice

The notice comes with a default style, `assets/css/notice.css`: a soft amber
box with a dot in front of the text, sized in `em` so it follows the theme's
type. The plugin loads it in the head of a post that is outdated, and nowhere
else. To restyle the notice, override the class `mwpd-content-freshness-notice`
in your theme's stylesheet. To drop the default style altogether and start from
scratch, dequeue its handle:

```php
add_action(
'wp_enqueue_scripts',
static function (): void {
wp_dequeue_style( 'mwpd-content-freshness-notice' );
},
20 // After the plugin has enqueued it, at the default priority of 10.
);
```

### Changing the markup

The notice, the column cell, the setting field and the email body are views
in `views/`. A theme overrides one in a `content-freshness/` directory of its
own, at the same relative path: the notice, for example, with
`content-freshness/views/outdated-post-notice.php` in the child or parent
theme. The notice
has the class `mwpd-content-freshness-notice` for styling.
theme. Keep the class `mwpd-content-freshness-notice` on the notice's element,
or dequeue the default style as shown above.

## How it is built

Expand All @@ -113,7 +132,7 @@ protected function get_service_classes(): array {

| Service | Declares | Which means |
| --- | --- | --- |
| [`OutdatedPostNotice`](src/Notice/OutdatedPostNotice.php) | `Conditional` (`is_singular( 'post' )`), `Delayed` (`wp`), `Registerable` | Only exists on a single post, once the query has run, and filters `the_content` there. |
| [`OutdatedPostNotice`](src/Notice/OutdatedPostNotice.php) | `Conditional` (`is_singular( 'post' )`), `Delayed` (`wp`), `Registerable`, `HasAssets` | Only exists on a single post, once the query has run, filters `the_content` there, and declares its stylesheet for the head of an outdated post. |
| [`PostAgeColumn`](src/PostsList/PostAgeColumn.php) | `Conditional` (screen `edit-post`), `Delayed` (`current_screen`), `Registerable` | Only exists on the Posts list, and adds its column there. |
| [`FreshnessSetting`](src/Settings/FreshnessSetting.php) | `Delayed` (`admin_init`), `Registerable`, `SiteUninstallable` | Registers the setting in admin requests, and removes its option from every site when the plugin is deleted. |
| [`StaleContentDigest`](src/Digest/StaleContentDigest.php) | `Registerable`, `SiteActivateable`, `SiteDeactivateable`, `SiteInitializable`, `SiteUninstallable` | Hooks the cron callback in every request, schedules the weekly event in each site an activation reaches and in new sites of a network, and clears it in each site a deactivation reaches and on uninstall. |
Expand All @@ -127,7 +146,7 @@ Look at what each service does *not* do:

- The notice does not check `is_admin()`, and the column does not check
`is_singular()`. Each states its own condition once
(`src/Notice/OutdatedPostNotice.php:76-78`,
(`src/Notice/OutdatedPostNotice.php:99-101`,
`src/PostsList/PostAgeColumn.php:87-91`).
- The digest is deliberately **not** `Conditional`. WP-Cron runs in whatever
request comes along, so its callback has to be hooked in every request, and
Expand All @@ -148,7 +167,12 @@ Look at what each service does *not* do:
- No service fetches the current post when it is built. The notice asks
`MainLoop` inside its filter, so it only acts on the queried post in the
main loop, not on a related-posts widget further down
(`src/Notice/OutdatedPostNotice.php:134-140`).
(`src/Notice/OutdatedPostNotice.php:190-196`).
- The notice does not hook `wp_enqueue_scripts` for its stylesheet. It
declares the style in `get_assets()`, and only for the front end of an
outdated post; the infrastructure hooks the enqueue actions once and asks
(`src/Notice/OutdatedPostNotice.php:139-170`). A fresh post, an archive or
an admin screen loads nothing.

### Why adding a feature changed nothing else

Expand All @@ -175,7 +199,7 @@ Inside the plugin, every failure is an exception with a name, such as
before control goes back to WordPress: with `WP_DEBUG` on, the exception
surfaces; with it off, the notice is left out, the column cell stays empty,
or the digest waits for next week, and the rest of the page works
(`src/Notice/OutdatedPostNotice.php:156-161`).
(`src/Notice/OutdatedPostNotice.php:212-217`).

The site lifecycle events are the deliberate exception, as in the scaffold:
when the digest cannot be scheduled while the plugin is activated in a site,
Expand Down
51 changes: 51 additions & 0 deletions assets/css/notice.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Default style of the outdated post notice.
*
* OutdatedPostNotice declares this stylesheet in get_assets(), and the plugin
* loads it in the head of a single post that is outdated, and nowhere else.
* The markup it styles is the one in views/outdated-post-notice.php:
*
* <p class="mwpd-content-freshness-notice" role="note">…</p>
*
* It is a default, not a requirement. A theme replaces it by dequeuing the
* "mwpd-content-freshness-notice" handle, or adjusts it by overriding the
* class. Everything scales with the theme's font size, so the notice fits in
* with the post around it.
*/
.mwpd-content-freshness-notice {
display: flex;
gap: 0.75em;
align-items: baseline;
box-sizing: border-box;
margin: 0 0 1.5em;
padding: 0.875em 1em;
border-radius: 0.875em;
background: #fbefd6;
color: #3d2e12;
font-size: 0.9375em;
line-height: 1.5;
}

/* A dot marks the notice as a note, without an icon font or an image. */
.mwpd-content-freshness-notice::before {
content: "";
flex: none;
width: 0.625em;
height: 0.625em;
border-radius: 50%;
background: #c25e1f;
}

/*
* Windows High Contrast and other forced-colors modes drop backgrounds, so
* the notice keeps a border and the dot keeps the text color.
*/
@media (forced-colors: active) {
.mwpd-content-freshness-notice {
border: 1px solid CanvasText;
}

.mwpd-content-freshness-notice::before {
background: CanvasText;
}
}
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/Notice/`) | the infrastructure's interfaces (and traits that only fulfil them, and the values their methods take), the exceptions and the subsystems |
| a feature subsystem (any other top-level namespace, such as `src/Notice/`) | the infrastructure's interfaces (and traits that only fulfil them, and the values their methods take), 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 @@ -874,12 +874,17 @@ passes. The allowance holds for the whole class that implements the
interface; a class that implements no such interface is still reported for
naming the value. This allowance is Content Freshness's own, until the
scaffold settles it.
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: `ContentFreshnessPlugin`,
`ContentFreshnessPluginFactory` and the uninstall bridge
`ContentFreshnessUninstallHook`, an edge like the main file. `dependencyDirection.views` lists the
`ContentFreshnessUninstallHook`, 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 @@ -1172,6 +1177,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_content_freshness_` |
| `ajax` | `add_action` with a known `wp_ajax_` or `wp_ajax_nopriv_` action | the remainder after WordPress's action prefix | `mwpd_content_freshness_` |

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 @@ -1198,7 +1208,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 @@ -1211,15 +1223,18 @@ use the shorter `mwpd_cf_` so the prefix leaves room for a name within their
limits; the option prefix alone is already 23 characters. Hooks retain
`mwpd.content_freshness.` 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 @@ -1396,8 +1411,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 @@ -1406,18 +1422,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
Loading