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
81 changes: 47 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ every plugin that bundles the package.
* [Rendering a banner](#rendering-a-banner)
* [Fields](#fields)
* [Versions](#versions)
* [Replacing the template](#replacing-the-template)
* [Replacing the template or stylesheet](#replacing-the-template-or-stylesheet)
* [When a banner renders](#when-a-banner-renders)
* [Dismissal](#dismissal)
* [Shared state](#shared-state)
Expand Down Expand Up @@ -59,14 +59,13 @@ add_action(
$fields->body = __( 'Unlock every block and priority support.', 'my-plugin' );
$fields->ctaLabel = __( 'Shop the sale', 'my-plugin' );
$fields->ctaUrl = 'https://example.com/black-friday?utm_source=my-plugin';
$fields->image = 'data:image/svg+xml;base64,…';
$fields->imageAlt = __( 'Nexcess', 'my-plugin' );

( new Registry() )->register(
new Banner(
'nexcess-black-friday-2026',
'1.0.0',
$fields,
plugins_url( 'vendor-prefixed/stellarwp/banners/', MY_PLUGIN_FILE ),
new DateTimeImmutable( '2026-11-27 00:00', new DateTimeZone( 'America/New_York' ) ),
new DateTimeImmutable( '2026-12-01 00:00', new DateTimeZone( 'America/New_York' ) )
)
Expand All @@ -78,6 +77,10 @@ add_action(
Register on `init`. Earlier, `__()` loads translations too soon. Later, another plugin may already
have rendered an older version of the banner.

The fourth argument is the URL your plugin serves its copy of the package from, with or without a
trailing slash. The banner loads its stylesheet from there, so build it with `plugins_url()` against
wherever Strauss put the package in your plugin.

## Rendering a banner

Fetch a banner by ID and render it wherever it should appear:
Expand All @@ -91,9 +94,9 @@ add_action(
);
```

`render()` prints immediately. Call it in your admin page template, or on a hook such as
`admin_notices`. The callable decides whether the banner shows here. Without it, the banner renders
unconditionally.
`render()` prints immediately: the link to the banner's stylesheet, then the banner. Call it in your
admin page template, or on a hook such as `admin_notices`. The callable decides whether the banner
shows here. Without it, the banner renders unconditionally.

Render the IDs your plugin registers: once `register()` has run, `get()` cannot throw for them. For
an ID only other plugins register, check `has()` first, as shown below.
Expand Down Expand Up @@ -132,49 +135,53 @@ It is PHP's own class, which Strauss does not prefix, so catch `\OutOfBoundsExce

| Property | Default | Notes |
| --- | --- | --- |
| `heading` | `''` | Bold first line. |
| `body` | `''` | |
| `heading` | `''` | The message, in white. |
| `body` | `''` | Follows the heading in blue, on a line of its own where the banner is narrow. |
| `ctaLabel` | `''` | The button renders only when both `ctaLabel` and `ctaUrl` are set. |
| `ctaUrl` | `''` | Add your own UTM parameters. Opens in the same tab. |
| `image` | `''` | A URL or a `data:` URI, shown at most 64px wide. |
| `imageAlt` | `''` | The image's alt text. Leave it empty when the image only decorates, such as a logo beside a heading that already names the product, and screen readers skip it. |
| `ctaUrl` | `''` | Add your own UTM parameters. Opens in a new tab. |
| `dismissLabel` | `'Dismiss this banner.'` | Screen-reader label of the dismiss control. |

The schedule is not a field. `Banner`'s fourth and fifth arguments, `?DateTimeImmutable $start` and
The schedule is not a field. `Banner`'s fifth and sixth arguments, `?DateTimeImmutable $start` and
`?DateTimeImmutable $end`, bound it. Either may be null, for no bound. The banner shows from `start`
and stops at `end`, compared as moments in time, so their time zones do not matter.

The markup lives in `src/templates/banner.php`, which escapes every value at output. Strauss copies it
with the classes.
The markup lives in `src/templates/banner.php`, which escapes every value at output, and its styles in
`src/templates/banner.css`. The banner sizes itself to the space it is given, not the screen: wide at
the top of a page, stacked in a narrow spot such as a settings sidebar.

## Replacing the template
## Replacing the template or stylesheet

Filter `nx_banners_template:{id}` to render a banner with your own template file:
Pass `render()` your own template file, stylesheet URL, or both:

```php
add_filter(
'nx_banners_template:my-plugin-pro-upsell',
static fn(): string => __DIR__ . '/templates/pro-upsell-banner.php'
$banner->render(
static fn(): bool => my_plugin_is_admin_page(),
__DIR__ . '/templates/pro-upsell-banner.php',
plugins_url( 'assets/pro-upsell-banner.css', MY_PLUGIN_FILE )
);
```

The template gets `$fields`, `$dismissUrl` and `$inline`. Start from the bundled one: it escapes
every value, adds the `inline` class when `$inline` is true, and links the dismiss control to
`$dismissUrl`. Read `$fields` only through its properties, never by type, since it may be another
copy's `BannerFields`.
Pass `null` to keep the bundled one. Either applies only to this call, so it never changes how
another plugin renders the same banner.

A template path that is not a file falls back to the bundled template. The template gets `$fields`,
`$dismiss_url` and `$inline`. Start from the bundled one: it escapes every value, adds the `inline`
class when `$inline` is true, and links the dismiss control to `$dismiss_url`. Read `$fields` only
through its properties, never by type, since it may be another copy's `BannerFields`.

The filter name is a string, so it applies whichever plugin renders the ID. Filter only IDs you own.
A value that is not the path of a file falls back to the bundled template.
A stylesheet you pass replaces the bundled one, so a template of your own still gets the bundled
styles unless you pass one too.

## Versions

Every banner has a version, `Banner`'s second argument. When several plugins register the same ID,
the registry keeps the newest by `version_compare()`, in whatever order they register. On a tie, the
first registered stays. Every plugin then renders that version, with the code of the plugin that
registered it.
first registered stays. Every plugin then renders that version, with the code, template and
stylesheet of the plugin that registered it. That is why the package URL is given to the banner and
not to `render()`.

So when product A registers version 1.0.0 of `nexcess-black-friday-2026` and product B registers 1.0.1,
both render 1.0.1. Bump the version whenever a banner's copy, schedule or image changes.
both render 1.0.1. Bump the version whenever a banner's copy or schedule changes.

Dismissal is keyed by ID, not version: a user who dismissed 1.0.0 does not see 1.0.1.

Expand All @@ -194,6 +201,10 @@ banner that would not show anyway.
When two plugins render the same banner ID on one request, the first to call `render()` prints it and
the other prints nothing. Different IDs each render.

Rendering prints the stylesheet's link just before the banner, not in the page head: `render()` runs
after wp-admin printed the head's styles, and a style enqueued that late prints in the footer, which
would leave the banner unstyled until the page finishes loading. WordPress prints each handle once.

The banner is a core `.notice`. Rendered on a notice hook, `admin_notices`, `all_admin_notices`,
`network_admin_notices` or `user_admin_notices`, it is left to wp-admin's `common.js`, which moves it
under the page heading like every other notice. Rendered anywhere else, it carries `inline`, which
Expand All @@ -212,9 +223,8 @@ earlier hook to handle it on. Nothing redirects, so the dismiss arguments stay i
until the next navigation. Repeating the request is harmless and keeps the first dismissal time. A
link with a bad nonce, or from a logged-out visitor, is ignored and the banner renders as usual.

No script ships. The notice carries `is-dismissible` for the padding the control needs, and core's
`makeNoticesDismissible()` skips notices that already contain a `.notice-dismiss`, so no second,
non-persisting button is appended.
No script ships. The notice does not carry `is-dismissible`, so core's `makeNoticesDismissible()`
adds no second, non-persisting button.

## Shared state

Expand All @@ -228,11 +238,11 @@ Everything shared between plugins is therefore reached through a string key:
| `nx-banners-dismiss` | Dismiss query argument carrying the banner ID. The nonce action is `nx-banners-dismiss:{id}`. |
| `nx-banners-nonce` | Nonce query argument, named so it cannot replace a `_wpnonce` already in the page URL. |
| `nx_banners_rendered:{id}` | Action fired when a banner renders; later copies check `did_action()` and stand down. |
| `nx_banners_template:{id}` | Filter: the path of the template file that banner renders with. |
| `nx-banners-{id}` | Handle of the banner's stylesheet in WordPress's style registry. |

A registered banner is called only through its methods, never checked by class: `version()` while
registering, and `fields()` and `render( ?callable $shouldDisplay )` by whoever fetches it, along with
the `BannerFields` property names.
registering, and `fields()` and `render( ?callable $shouldDisplay, ?string $template, ?string $stylesheet )`
by whoever fetches it, along with the `BannerFields` property names.

These keys and methods are a cross-version compatibility contract. Do not rename them or change their
shape: another plugin may still be running an older prefixed copy.
Expand All @@ -254,4 +264,7 @@ composer check
The suite uses [Brain\Monkey](https://github.com/Brain-WP/BrainMonkey) to stub the WordPress
functions the package calls, so it runs with nothing but Composer installed.

Bump `Package::VERSION` with every release. The bundled stylesheet's URL carries it, so browsers
fetch the new file instead of a cached one.

Requires PHP 7.4+ and WordPress 6.6+.
61 changes: 40 additions & 21 deletions src/Banner.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ final class Banner {
private const RENDERED_ACTION = 'nx_banners_rendered:';

/**
* Prefix of the filter that swaps a banner's template file. Shared across copies, so it applies
* whichever copy renders the ID.
* Prefix of the handle a banner's stylesheet registers under.
*/
private const TEMPLATE_FILTER = 'nx_banners_template:';
private const STYLE_HANDLE = 'nx-banners-';

/**
* The hooks core prints notices on. wp-admin's common.js moves a notice printed there under the
Expand All @@ -34,29 +33,35 @@ final class Banner {

private BannerFields $fields;

private string $packageUrl;

private ?DateTimeImmutable $start;

private ?DateTimeImmutable $end;

/**
* @param string $id Shared across plugins: the same ID is the same banner and the same dismissal.
* @param string $version Compared with version_compare(): the registry keeps the newest version of an ID.
* @param BannerFields $fields The banner fields.
* @param ?DateTimeImmutable $start The banner shows from this moment. Null shows it from now.
* @param ?DateTimeImmutable $end The banner stops showing at this moment. Null never ends it.
* @param BannerFields $fields The banner fields.
* @param string $packageUrl Where the registering plugin serves its copy of this package, such as
* plugins_url( 'vendor-prefixed/stellarwp/banners/', MY_PLUGIN_FILE ).
* @param ?DateTimeImmutable $start The banner shows from this moment. Null shows it from now.
* @param ?DateTimeImmutable $end The banner stops showing at this moment. Null never ends it.
*/
public function __construct(
string $id,
string $version,
BannerFields $fields,
string $packageUrl,
?DateTimeImmutable $start = null,
?DateTimeImmutable $end = null
) {
$this->id = $id;
$this->version = $version;
$this->fields = $fields;
$this->start = $start;
$this->end = $end;
$this->id = $id;
$this->version = $version;
$this->fields = $fields;
$this->packageUrl = $packageUrl;
$this->start = $start;
$this->end = $end;
}

public function id(): string {
Expand All @@ -79,8 +84,10 @@ public function fields(): BannerFields {
* of schedule or dismissed, or the callable declines.
*
* @param (callable(): bool)|null $shouldDisplay Omit to render unconditionally.
* @param ?string $template Path of a template file to render instead of the bundled one.
* @param ?string $stylesheet URL of a stylesheet to print instead of the bundled one.
*/
public function render( ?callable $shouldDisplay = null ): void {
public function render( ?callable $shouldDisplay = null, ?string $template = null, ?string $stylesheet = null ): void {
Dismissals::handleRequest( $this->id );

if (
Expand All @@ -99,20 +106,32 @@ public function render( ?callable $shouldDisplay = null ): void {
// anywhere else where the client called render().
$inline = array_filter( self::NOTICE_HOOKS, 'doing_action' ) === [];

$this->printTemplate( $this->fields, Dismissals::url( $this->id ), $inline );
$this->printStylesheet( $stylesheet );
$this->printTemplate( $template, $this->fields, Dismissals::url( $this->id ), $inline );
}

/**
* Print the bundled template, or the one a filter chose for this ID, with $fields, $dismissUrl and
* $inline in scope.
* Print the stylesheet's link right here. render() runs mid-page, after wp-admin printed the
* head's styles, so an enqueued one would print in the footer and leave the banner unstyled until then.
*/
private function printTemplate( BannerFields $fields, string $dismissUrl, bool $inline ): void {
$bundled = __DIR__ . '/templates/banner.php';
$template = apply_filters( self::TEMPLATE_FILTER . $this->id, $bundled );
private function printStylesheet( ?string $stylesheet ): void {
wp_register_style(
self::STYLE_HANDLE . $this->id,
$stylesheet ?? rtrim( $this->packageUrl, '/' ) . '/src/templates/banner.css',
[],
$stylesheet === null ? Package::VERSION : null
);
wp_print_styles( self::STYLE_HANDLE . $this->id );
}

// Every other copy has already stood down for this ID, so a broken filter must not hide it.
if ( ! is_string( $template ) || ! is_file( $template ) ) {
$template = $bundled;
/**
* Print the given template, or the bundled one, with $fields, $dismiss_url and $inline in scope.
* They are snake_case so that templates pass the WordPress coding standards most plugins lint with.
*/
private function printTemplate( ?string $template, BannerFields $fields, string $dismiss_url, bool $inline ): void {
// Every other copy has already stood down for this ID, so a broken path must not hide it.
if ( $template === null || ! is_file( $template ) ) {
$template = __DIR__ . '/templates/banner.php';
}

require $template;
Expand Down
17 changes: 5 additions & 12 deletions src/BannerFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,22 @@
final class BannerFields {

/**
* Bold first line.
* The message, in white.
*/
public string $heading = '';

/**
* Follows the heading in blue, on a line of its own where the banner is narrow.
*/
public string $body = '';

/**
* The button renders only when both ctaLabel and ctaUrl are set.
* The button renders only when both ctaLabel and ctaUrl are set. It opens in a new tab.
*/
public string $ctaLabel = '';

public string $ctaUrl = '';

/**
* A URL or a data: URI, shown at most 64px wide.
*/
public string $image = '';

/**
* Empty marks the image decorative, so screen readers skip it.
*/
public string $imageAlt = '';

/**
* Screen-reader label of the dismiss control.
*/
Expand Down
15 changes: 15 additions & 0 deletions src/Package.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types=1);

namespace Nexcess\Banners;

/**
* This copy of the package.
*/
final class Package {

/**
* Bump with every release: the bundled stylesheet's URL carries it, so browsers fetch the new file.
*/
public const VERSION = '1.0.0';

}
Loading
Loading