diff --git a/README.md b/README.md index 0b4a9e0..2dc299c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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' ) ) ) @@ -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: @@ -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. @@ -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. @@ -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 @@ -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 @@ -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. @@ -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+. diff --git a/src/Banner.php b/src/Banner.php index 5c1dff2..ba96d90 100644 --- a/src/Banner.php +++ b/src/Banner.php @@ -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 @@ -34,6 +33,8 @@ final class Banner { private BannerFields $fields; + private string $packageUrl; + private ?DateTimeImmutable $start; private ?DateTimeImmutable $end; @@ -41,22 +42,26 @@ final class Banner { /** * @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 { @@ -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 ( @@ -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; diff --git a/src/BannerFields.php b/src/BannerFields.php index 4405877..1cb1616 100644 --- a/src/BannerFields.php +++ b/src/BannerFields.php @@ -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. */ diff --git a/src/Package.php b/src/Package.php new file mode 100644 index 0000000..cbca87e --- /dev/null +++ b/src/Package.php @@ -0,0 +1,15 @@ + -
- image !== '' ) : ?> - <?php echo esc_attr( $fields->imageAlt ); ?> - -
-

heading ); ?>

-

body ); ?>

+
+
+

+ heading ); ?> + body ); ?> +

ctaLabel !== '' && $fields->ctaUrl !== '' ) : ?> -

ctaLabel ); ?>

+ + ctaLabel ); ?> + + +
- dismissLabel ); ?> + + dismissLabel ); ?> +
diff --git a/tests/BannerTest.php b/tests/BannerTest.php index bdba9f4..86cf356 100644 --- a/tests/BannerTest.php +++ b/tests/BannerTest.php @@ -3,12 +3,12 @@ namespace Nexcess\Banners\Tests; use Brain\Monkey\Actions; -use Brain\Monkey\Filters; use Brain\Monkey\Functions; use DateTimeImmutable; use DateTimeZone; use Nexcess\Banners\Banner; use Nexcess\Banners\BannerFields; +use Nexcess\Banners\Package; final class BannerTest extends TestCase { @@ -17,22 +17,29 @@ final class BannerTest extends TestCase { 'body' => 'Unlock every block.', 'ctaLabel' => 'Upgrade', 'ctaUrl' => 'https://example.test/pro', - 'image' => 'data:image/svg+xml;base64,PHN2Zy8+', ]; /** - * @return array + * @return array */ public static function brokenTemplates(): array { return [ - 'false' => [ false ], - 'null' => [ null ], - 'an array' => [ [] ], + 'an empty path' => [ '' ], 'a missing file' => [ __DIR__ . '/Fixtures/no-such-template.php' ], 'a directory' => [ __DIR__ ], ]; } + /** + * @return array + */ + public static function packageUrls(): array { + return [ + 'with a trailing slash' => [ self::PACKAGE_URL ], + 'without a trailing slash' => [ rtrim( self::PACKAGE_URL, '/' ) ], + ]; + } + /** * @return array */ @@ -45,23 +52,53 @@ public static function noticeHooks(): array { ]; } - public function testRendersTheCopyCtaImageAndDismissLink(): void { + public function testRendersTheCopyCtaAndDismissLink(): void { $this->stubDismissed( [] ); $this->stubRenderable(); - $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ) ); + $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ); - $this->assertStringContainsString( 'Go Pro', $output ); - $this->assertStringContainsString( 'Unlock every block.', $output ); - $this->assertStringContainsString( - 'Upgrade', + $this->assertMatchesRegularExpression( '/>\s*Go Pro\s*Unlock every block\.<\/span>/', $output ); + $this->assertMatchesRegularExpression( + '/\s*Upgrade/', $output ); - $this->assertStringContainsString( 'assertStringContainsString( 'class="notice-dismiss"', $output ); + $this->assertStringContainsString( '(opens in a new tab)', $output ); + $this->assertMatchesRegularExpression( '//', $output ); $this->assertStringContainsString( 'Dismiss this banner.', $output ); } + /** + * render() runs mid-page, after wp-admin printed the head's styles: an enqueued style would print + * in the footer and leave the banner unstyled until then, so its link prints right before it. + * + * @dataProvider packageUrls + */ + public function testPrintsTheBundledStylesheetFromThePackageUrlBeforeTheBanner( string $packageUrl ): void { + $this->stubDismissed( [] ); + $this->stubRenderable(); + + $this->assertMatchesRegularExpression( + '/^\s*