From a701a02b44f046e22ec2abe9783fe65d91e73dff Mon Sep 17 00:00:00 2001 From: Dimitrios Pantazis Date: Tue, 13 Oct 2026 15:32:30 +0300 Subject: [PATCH 1/5] Name the template's dismiss URL in snake_case Templates now get `$dismiss_url` instead of `$dismissUrl`, so a plugin that replaces the template passes the WordPress coding standards without a phpcs:ignore. Templates still reading `$dismissUrl` break; none have shipped. --- README.md | 4 ++-- src/Banner.php | 7 ++++--- src/templates/banner.php | 4 ++-- tests/BannerTest.php | 5 +++-- tests/Fixtures/custom-banner.php | 2 ++ 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0b4a9e0..e8815f8 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,9 @@ add_filter( ); ``` -The template gets `$fields`, `$dismissUrl` and `$inline`. Start from the bundled one: it escapes +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 -`$dismissUrl`. Read `$fields` only through its properties, never by type, since it may be another +`$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. diff --git a/src/Banner.php b/src/Banner.php index 5c1dff2..eea458a 100644 --- a/src/Banner.php +++ b/src/Banner.php @@ -103,10 +103,11 @@ public function render( ?callable $shouldDisplay = null ): void { } /** - * Print the bundled template, or the one a filter chose for this ID, with $fields, $dismissUrl and - * $inline in scope. + * Print the bundled template, or the one a filter chose for this ID, 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( BannerFields $fields, string $dismissUrl, bool $inline ): void { + private function printTemplate( BannerFields $fields, string $dismiss_url, bool $inline ): void { $bundled = __DIR__ . '/templates/banner.php'; $template = apply_filters( self::TEMPLATE_FILTER . $this->id, $bundled ); diff --git a/src/templates/banner.php b/src/templates/banner.php index acc0b54..8016126 100644 --- a/src/templates/banner.php +++ b/src/templates/banner.php @@ -9,7 +9,7 @@ * makeNoticesDismissible() skips notices that already carry a .notice-dismiss. * * @var \Nexcess\Banners\BannerFields $fields - * @var string $dismissUrl + * @var string $dismiss_url * @var bool $inline */ @@ -27,5 +27,5 @@

ctaLabel ); ?>

- dismissLabel ); ?> + dismissLabel ); ?> diff --git a/tests/BannerTest.php b/tests/BannerTest.php index bdba9f4..d7a8237 100644 --- a/tests/BannerTest.php +++ b/tests/BannerTest.php @@ -162,14 +162,15 @@ public function testRendersNothingOutsideTheAdmin(): void { $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ) ) ); } - public function testAFilteredTemplateReplacesTheMarkup(): void { + public function testAFilteredTemplateReplacesTheMarkupAndGetsTheDismissUrl(): void { $this->stubDismissed( [] ); $this->stubRenderable(); Filters\expectApplied( 'nx_banners_template:promo' )->once()->andReturn( __DIR__ . '/Fixtures/custom-banner.php' ); $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ) ); - $this->assertSame( '

Go Pro

', trim( $output ) ); + $this->assertStringContainsString( '

Go Pro

', $output ); + $this->assertMatchesRegularExpression( '//', $output ); } /** diff --git a/tests/Fixtures/custom-banner.php b/tests/Fixtures/custom-banner.php index cb2e2ff..b7b3592 100644 --- a/tests/Fixtures/custom-banner.php +++ b/tests/Fixtures/custom-banner.php @@ -4,6 +4,8 @@ * A client's own template, chosen through the nx_banners_template:{id} filter. * * @var \Nexcess\Banners\BannerFields $fields + * @var string $dismiss_url */ ?>

heading ); ?>

+
x From 37eb17ced5f0110efe3bbcb5beeaad51dda6e6a6 Mon Sep 17 00:00:00 2001 From: Dimitrios Pantazis Date: Tue, 13 Oct 2026 15:33:38 +0300 Subject: [PATCH 2/5] Ship the Nexcess design as the bundled banner Plugins showing a Nexcess banner no longer write their own markup and CSS. The bundled template is now the black Nexcess banner, and it sizes itself to the space it is given: wide at the top of a page, stacked in a narrow spot such as a settings sidebar. Its styles live in `src/templates/banner.css`. `Banner` takes the URL the registering plugin serves the package from, so the stylesheet always comes from the same copy as the template that renders. `render()` prints the stylesheet's link just before the banner, since a style enqueued mid-page only prints in the footer and would leave the banner unstyled until then. Breaking: the package URL is `Banner`'s new fourth argument, moving the schedule to fifth and sixth, and the `image` and `imageAlt` fields are gone because the design has no image. Nothing has shipped yet. --- README.md | 48 ++++++++++------- src/Banner.php | 43 ++++++++++++--- src/BannerFields.php | 17 ++---- src/templates/banner.css | 108 ++++++++++++++++++++++++++++++++++++++ src/templates/banner.php | 33 +++++++----- tests/BannerTest.php | 110 ++++++++++++++++++++------------------- tests/DismissalTest.php | 16 +++--- tests/RegistryTest.php | 2 +- tests/TestCase.php | 39 ++++++++++---- 9 files changed, 291 insertions(+), 125 deletions(-) create mode 100644 src/templates/banner.css diff --git a/README.md b/README.md index e8815f8..9041b59 100644 --- a/README.md +++ b/README.md @@ -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,20 +135,19 @@ 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 @@ -164,17 +166,19 @@ every value, adds the `inline` class when `$inline` is true, and links the dismi 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 value that is not the path of a file falls back to the bundled template. The bundled stylesheet +still prints. ## 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 +198,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 +220,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 @@ -229,6 +236,7 @@ Everything shared between plugins is therefore reached through a string key: | `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 diff --git a/src/Banner.php b/src/Banner.php index eea458a..05ef55f 100644 --- a/src/Banner.php +++ b/src/Banner.php @@ -22,6 +22,11 @@ final class Banner { */ private const TEMPLATE_FILTER = 'nx_banners_template:'; + /** + * Prefix of the handle a banner's stylesheet registers under. + */ + private const STYLE_HANDLE = 'nx-banners-'; + /** * The hooks core prints notices on. wp-admin's common.js moves a notice printed there under the * page heading, unless it carries "inline". @@ -34,6 +39,8 @@ final class Banner { private BannerFields $fields; + private string $packageUrl; + private ?DateTimeImmutable $start; private ?DateTimeImmutable $end; @@ -41,22 +48,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 { @@ -99,9 +110,25 @@ public function render( ?callable $shouldDisplay = null ): void { // anywhere else where the client called render(). $inline = array_filter( self::NOTICE_HOOKS, 'doing_action' ) === []; + $this->printStylesheet(); $this->printTemplate( $this->fields, Dismissals::url( $this->id ), $inline ); } + /** + * 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 printStylesheet(): void { + wp_register_style( + self::STYLE_HANDLE . $this->id, + rtrim( $this->packageUrl, '/' ) . '/src/templates/banner.css', + [], + // A new release replaces the bundled file at the same URL, so its modification time busts caches. + (string) filemtime( __DIR__ . '/templates/banner.css' ) + ); + wp_print_styles( self::STYLE_HANDLE . $this->id ); + } + /** * Print the bundled template, or the one a filter chose for this ID, with $fields, $dismiss_url and * $inline in scope. They are snake_case so that templates pass the WordPress coding standards most 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/templates/banner.css b/src/templates/banner.css new file mode 100644 index 0000000..9f712a7 --- /dev/null +++ b/src/templates/banner.css @@ -0,0 +1,108 @@ +/* Printed by Banner::render() just before templates/banner.php. */ + +.notice.nx-banner { + position: relative; + /* A size container has no width of its own, so it would collapse in a flex column such as a sidebar. */ + align-self: stretch; + container-type: inline-size; + /* Vertical only: each admin page sets a notice's side margins. */ + margin-top: 16px; + margin-bottom: 16px; + padding: 0; + border: 0; + box-shadow: none; + background: #000; +} + +/* Rendered where the client called render(), not as a notice: sit flush with what is above. */ +.notice.nx-banner.inline { + margin-top: 0; +} + +.nx-banner__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 24px 72px 24px 32px; +} + +.notice.nx-banner .nx-banner__text { + max-width: 34em; + margin: 0; + padding: 0; + color: #fff; + font-size: clamp(16px, 2.2cqi, 26px); + line-height: 1.25; + text-wrap: balance; +} + +.nx-banner__accent { + color: #2b69e3; +} + +/* Outranks wp-admin's link colours and underline. */ +.notice.nx-banner .nx-banner__cta, +.notice.nx-banner .nx-banner__cta:visited { + flex: none; + padding: 10px 32px; + border-radius: 999px; + background: #2b69e3; + color: #fff; + font-size: 15px; + text-decoration: none; +} + +.notice.nx-banner .nx-banner__cta:hover, +.notice.nx-banner .nx-banner__cta:focus { + background: #1d55c4; + color: #fff; +} + +.nx-banner__dismiss { + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; +} + +.nx-banner__dismiss::before, +.nx-banner__dismiss::after { + content: ""; + position: absolute; + top: 11px; + left: 2px; + width: 20px; + height: 1.5px; + background: #fff; + transform: rotate(45deg); +} + +.nx-banner__dismiss::after { + transform: rotate(-45deg); +} + +.nx-banner__dismiss:focus { + box-shadow: 0 0 0 2px #2b69e3; + outline: 0; +} + +@container (max-width: 520px) { + .nx-banner__inner { + flex-direction: column; + align-items: flex-start; + gap: 32px; + padding: 40px 28px 32px; + } + + .notice.nx-banner .nx-banner__text { + font-size: 26px; + } + + .nx-banner__accent { + display: block; + margin-top: 16px; + font-size: 20px; + } +} diff --git a/src/templates/banner.php b/src/templates/banner.php index 8016126..f6cdfd8 100644 --- a/src/templates/banner.php +++ b/src/templates/banner.php @@ -1,12 +1,16 @@ -
- 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 d7a8237..9f9895d 100644 --- a/tests/BannerTest.php +++ b/tests/BannerTest.php @@ -17,7 +17,6 @@ final class BannerTest extends TestCase { 'body' => 'Unlock every block.', 'ctaLabel' => 'Upgrade', 'ctaUrl' => 'https://example.test/pro', - 'image' => 'data:image/svg+xml;base64,PHN2Zy8+', ]; /** @@ -33,6 +32,16 @@ public static function brokenTemplates(): array { ]; } + /** + * @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 +54,38 @@ 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*
assertMatchesRegularExpression( '/
render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ) ) + $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ) ); } @@ -92,7 +116,7 @@ public function testInsideAnotherHookItStaysWhereItIsRendered(): void { public function testOnANoticeHookCoreMovesItUnderTheHeading( string $hook ): void { $output = $this->renderDuring( $hook ); - $this->assertStringContainsString( 'Go Pro', $output ); + $this->assertStringContainsString( 'Go Pro', $output ); $this->assertDoesNotMatchRegularExpression( '/
stubDismissed( [] ); $this->stubRenderable(); - $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( [ 'heading' => 'Go Pro', 'ctaLabel' => 'Upgrade' ] ) ) ); - - $this->assertStringNotContainsString( 'button-primary', $output ); - } - - public function testOmitsTheImageWhenNoneIsGiven(): void { - $this->stubDismissed( [] ); - $this->stubRenderable(); - - $this->assertStringNotContainsString( 'render( new Banner( 'promo', '1.0.0', self::fields( [ 'heading' => 'Go Pro' ] ) ) ) ); - } - - public function testDescribesTheImageWithItsAltText(): void { - $this->stubDismissed( [] ); - $this->stubRenderable(); - - $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS + [ 'imageAlt' => 'Pro logo' ] ) ) ); + $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( [ 'heading' => 'Go Pro', 'ctaLabel' => 'Upgrade' ] ), self::PACKAGE_URL ) ); - $this->assertStringContainsString( 'Pro logoassertStringNotContainsString( 'nx-banner__cta', $output ); } public function testEscapesEveryField(): void { @@ -126,7 +134,6 @@ public function testEscapesEveryField(): void { 'heading' => '', 'body' => '', 'ctaLabel' => 'cta', - 'imageAlt' => '" onerror="alert(1)', 'dismissLabel' => '', ]; @@ -137,12 +144,8 @@ public function testEscapesEveryField(): void { new Banner( 'promo', '1.0.0', - self::fields( - $copy + [ - 'ctaUrl' => "https://example.test/?a='b'", - 'image' => "https://example.test/logo.png?c='d'", - ] - ) + self::fields( $copy + [ 'ctaUrl' => "https://example.test/?a='b'" ] ), + self::PACKAGE_URL ) ); @@ -151,7 +154,6 @@ public function testEscapesEveryField(): void { $this->assertStringContainsString( htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' ), $output ); } $this->assertStringContainsString( 'href="https://example.test/?a='b'"', $output ); - $this->assertStringContainsString( 'src="https://example.test/logo.png?c='d'"', $output ); } public function testRendersNothingOutsideTheAdmin(): void { @@ -159,7 +161,7 @@ public function testRendersNothingOutsideTheAdmin(): void { $this->stubDismissed( [] ); $this->stubRenderable(); - $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ) ) ); + $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ) ); } public function testAFilteredTemplateReplacesTheMarkupAndGetsTheDismissUrl(): void { @@ -167,10 +169,11 @@ public function testAFilteredTemplateReplacesTheMarkupAndGetsTheDismissUrl(): vo $this->stubRenderable(); Filters\expectApplied( 'nx_banners_template:promo' )->once()->andReturn( __DIR__ . '/Fixtures/custom-banner.php' ); - $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->assertMatchesRegularExpression( '/
/', $output ); + $this->assertStringNotContainsString( 'nx-banner__inner', $output ); } /** @@ -186,41 +189,41 @@ public function testABrokenFilteredTemplateFallsBackToTheBundledOne( $template ) $this->stubRenderable(); Filters\expectApplied( 'nx_banners_template:promo' )->andReturn( $template ); - $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( 'nx-banner__inner', $output ); } public function testRendersWhenTheCallableReturnsTrue(): void { $this->stubDismissed( [] ); $this->stubRenderable(); - $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ), static fn(): bool => true ) ); + $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ), static fn(): bool => true ) ); } public function testRendersNothingWhenTheCallableReturnsFalse(): void { $this->stubDismissed( [] ); - $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ), static fn(): bool => false ) ); + $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ), static fn(): bool => false ) ); } public function testRendersNothingBeforeTheStart(): void { $this->stubDismissed( [] ); - $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), new DateTimeImmutable( '+1 hour' ) ) ) ); + $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL, new DateTimeImmutable( '+1 hour' ) ) ) ); } public function testRendersNothingAfterTheEnd(): void { $this->stubDismissed( [] ); - $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), null, new DateTimeImmutable( '-1 hour' ) ) ) ); + $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL, null, new DateTimeImmutable( '-1 hour' ) ) ) ); } public function testRendersInsideTheSchedule(): void { $this->stubDismissed( [] ); $this->stubRenderable(); - $banner = new Banner( 'promo', '1.0.0', new BannerFields(), new DateTimeImmutable( '-1 hour' ), new DateTimeImmutable( '+1 hour' ) ); + $banner = new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL, new DateTimeImmutable( '-1 hour' ), new DateTimeImmutable( '+1 hour' ) ); $this->assertNotSame( '', $this->render( $banner ) ); } @@ -234,6 +237,7 @@ public function testTheScheduleComparesMomentsNotWallClocksAcrossTimeZones(): vo 'promo', '1.0.0', new BannerFields(), + self::PACKAGE_URL, new DateTimeImmutable( '-1 hour', new DateTimeZone( 'Pacific/Kiritimati' ) ), new DateTimeImmutable( '+1 hour', new DateTimeZone( 'Pacific/Pago_Pago' ) ) ); @@ -249,9 +253,9 @@ public function testTheFirstCopyToRenderABannerWinsTheRequest(): void { $this->stubDismissed( [] ); $this->stubRenderable(); - $first = $this->render( new Banner( 'campaign', '1.0.0', self::fields( [ 'heading' => 'First' ] ) ) ); + $first = $this->render( new Banner( 'campaign', '1.0.0', self::fields( [ 'heading' => 'First' ] ), self::PACKAGE_URL ) ); $second = $this->render( - new Banner( 'campaign', '1.0.0', self::fields( [ 'heading' => 'Second' ] ) ), + new Banner( 'campaign', '1.0.0', self::fields( [ 'heading' => 'Second' ] ), self::PACKAGE_URL ), function (): bool { $this->fail( 'A banner that already rendered asked its callable again.' ); } @@ -267,21 +271,21 @@ public function testDifferentBannersEachRender(): void { $this->stubDismissed( [] ); $this->stubRenderable(); - $this->assertNotSame( '', $this->render( new Banner( 'upsell', '1.0.0', self::fields( self::FIELDS ) ) ) ); - $this->assertNotSame( '', $this->render( new Banner( 'campaign', '1.0.0', self::fields( self::FIELDS ) ) ) ); + $this->assertNotSame( '', $this->render( new Banner( 'upsell', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ) ); + $this->assertNotSame( '', $this->render( new Banner( 'campaign', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ) ); } public function testRendersNothingOnceTheUserDismissedIt(): void { $this->stubDismissed( [ 'promo' => true ] ); - $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ) ) ); + $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ) ); } public function testDismissingAnotherBannerDoesNotHideThisOne(): void { $this->stubDismissed( [ 'other' => true ] ); $this->stubRenderable(); - $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ) ) ) ); + $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ) ); } /** @@ -293,7 +297,7 @@ private function renderDuring( string $hook ): string { $output = ''; Actions\expectDone( $hook )->once()->whenHappen( function () use ( &$output ): void { - $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 ) ); } ); diff --git a/tests/DismissalTest.php b/tests/DismissalTest.php index b6b65b5..146eecf 100644 --- a/tests/DismissalTest.php +++ b/tests/DismissalTest.php @@ -26,7 +26,7 @@ public function testTheDismissLinkCarriesTheBannerAndANonceBoundToIt(): void { $this->assertStringContainsString( 'href="https://example.test/wp-admin/?nx-banners-dismiss=promo&nx-banners-nonce=signed"', - $this->render( new Banner( 'promo', '1.0.0', new BannerFields() ) ) + $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL ) ) ); } @@ -54,7 +54,7 @@ static function ( int $userId, string $key, array $value ) use ( &$stored ): boo $before = time(); - $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields() ) ) ); + $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL ) ) ); $this->assertSame( 1700000000, $stored['other'] ); $this->assertIsInt( $stored['promo'] ); $this->assertGreaterThanOrEqual( $before, $stored['promo'] ); @@ -69,7 +69,7 @@ public function testARepeatedDismissalKeepsTheFirstTimestamp(): void { Functions\when( 'wp_verify_nonce' )->justReturn( 1 ); Functions\expect( 'update_user_meta' )->never(); - $this->render( new Banner( 'promo', '1.0.0', new BannerFields() ) ); + $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL ) ); } public function testALoggedOutDismissalIsIgnored(): void { @@ -82,7 +82,7 @@ public function testALoggedOutDismissalIsIgnored(): void { Functions\expect( 'wp_verify_nonce' )->never(); Functions\expect( 'update_user_meta' )->never(); - $this->render( new Banner( 'promo', '1.0.0', new BannerFields() ) ); + $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL ) ); } public function testAnInvalidNonceIsIgnoredAndTheBannerStillRenders(): void { @@ -94,7 +94,7 @@ public function testAnInvalidNonceIsIgnoredAndTheBannerStillRenders(): void { Functions\expect( 'wp_verify_nonce' )->once()->andReturn( false ); Functions\expect( 'update_user_meta' )->never(); - $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields() ) ) ); + $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL ) ) ); } public function testADismissalForAnotherBannerIsIgnored(): void { @@ -106,7 +106,7 @@ public function testADismissalForAnotherBannerIsIgnored(): void { Functions\expect( 'wp_verify_nonce' )->never(); Functions\expect( 'update_user_meta' )->never(); - $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields() ) ) ); + $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL ) ) ); } public function testANonStringDismissalIsIgnored(): void { @@ -118,7 +118,7 @@ public function testANonStringDismissalIsIgnored(): void { Functions\expect( 'wp_verify_nonce' )->never(); Functions\expect( 'update_user_meta' )->never(); - $this->render( new Banner( 'promo', '1.0.0', new BannerFields() ) ); + $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL ) ); } public function testANonStringNonceIsIgnored(): void { @@ -130,7 +130,7 @@ public function testANonStringNonceIsIgnored(): void { Functions\expect( 'wp_verify_nonce' )->never(); Functions\expect( 'update_user_meta' )->never(); - $this->render( new Banner( 'promo', '1.0.0', new BannerFields() ) ); + $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL ) ); } } diff --git a/tests/RegistryTest.php b/tests/RegistryTest.php index 631e3db..d52bd13 100644 --- a/tests/RegistryTest.php +++ b/tests/RegistryTest.php @@ -13,7 +13,7 @@ final class RegistryTest extends TestCase { private static function banner( string $version, string $heading ): Banner { - return new Banner( 'promo', $version, self::fields( [ 'heading' => $heading ] ) ); + return new Banner( 'promo', $version, self::fields( [ 'heading' => $heading ] ), self::PACKAGE_URL ); } public function testANewerVersionReplacesTheRegisteredOne(): void { diff --git a/tests/TestCase.php b/tests/TestCase.php index e603ccb..af64854 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,7 +3,6 @@ namespace Nexcess\Banners\Tests; use Brain\Monkey; -use Brain\Monkey\Expectation\EscapeHelper; use Brain\Monkey\Functions; use Mockery; use Nexcess\Banners\Banner; @@ -14,6 +13,11 @@ abstract class TestCase extends PHPUnitTestCase { protected const USER_ID = 7; + /** + * Where a plugin serves its copy of the package. + */ + protected const PACKAGE_URL = 'https://example.test/wp-content/plugins/my-plugin/vendor/stellarwp/banners/'; + /** * @param array $values Keyed by BannerFields property name. */ @@ -35,16 +39,8 @@ protected function setUp(): void { Monkey\setUp(); Functions\stubEscapeFunctions(); - Functions\when( 'esc_url' )->alias( - static function ( string $url, ?array $protocols = null ): string { - // WordPress drops a data: URI unless the caller allows that protocol explicitly. - if ( strpos( $url, 'data:' ) === 0 && ! in_array( 'data', $protocols ?? [], true ) ) { - return ''; - } - - return EscapeHelper::escUrl( $url ); - } - ); + Functions\stubTranslationFunctions(); + $this->stubStyles(); Functions\when( 'get_current_user_id' )->justReturn( self::USER_ID ); // A logged-in user in wp-admin; the tests of the other cases override these. Functions\when( 'is_admin' )->justReturn( true ); @@ -80,6 +76,27 @@ static function ( int $userId, string $key ) use ( $value ) { ); } + /** + * WordPress's style registry, reduced to what render() relies on: printing a registered handle + * prints a link to its file. + */ + private function stubStyles(): void { + $styles = []; + + Functions\when( 'wp_register_style' )->alias( + static function ( string $handle, string $src ) use ( &$styles ): bool { + $styles[ $handle ] = $src; + + return true; + } + ); + Functions\when( 'wp_print_styles' )->alias( + static function ( string $handle ) use ( &$styles ): void { + echo ''; + } + ); + } + /** * Stub everything the render path needs beyond the dismissal state. */ From 296b83311f579081909ad7eb8a86d6f14ef0ea3a Mon Sep 17 00:00:00 2001 From: Dimitrios Pantazis Date: Tue, 13 Oct 2026 15:33:53 +0300 Subject: [PATCH 3/5] Let render() take its own template and stylesheet A plugin can now swap the template, the stylesheet or both for a single call: `render( $shouldDisplay, $template, $stylesheet )`. The choice applies only to the plugin making the call, so a plugin no longer adds and removes a filter around its own render to keep from changing how other plugins render the same banner. A template path that is not a file still falls back to the bundled template, and no stylesheet means the bundled one. Breaking: the `nx_banners_template:{id}` filter is gone. Nothing has shipped yet. --- README.md | 34 ++++++++++++++------------- src/Banner.php | 36 +++++++++++----------------- tests/BannerTest.php | 40 +++++++++++++++++++++----------- tests/Fixtures/custom-banner.php | 2 +- tests/TestCase.php | 9 +++++-- 5 files changed, 66 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 9041b59..17963dc 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) @@ -149,25 +149,28 @@ The markup lives in `src/templates/banner.php`, which escapes every value at out `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`, `$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`. +Pass `null` to keep the bundled one. Either applies only to this call, so it never changes how +another plugin renders the same banner. -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. The bundled stylesheet -still prints. +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`. + +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 @@ -235,12 +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. diff --git a/src/Banner.php b/src/Banner.php index 05ef55f..0c7db22 100644 --- a/src/Banner.php +++ b/src/Banner.php @@ -16,12 +16,6 @@ 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. - */ - private const TEMPLATE_FILTER = 'nx_banners_template:'; - /** * Prefix of the handle a banner's stylesheet registers under. */ @@ -90,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 ( @@ -110,37 +106,33 @@ public function render( ?callable $shouldDisplay = null ): void { // anywhere else where the client called render(). $inline = array_filter( self::NOTICE_HOOKS, 'doing_action' ) === []; - $this->printStylesheet(); - $this->printTemplate( $this->fields, Dismissals::url( $this->id ), $inline ); + $this->printStylesheet( $stylesheet ); + $this->printTemplate( $template, $this->fields, Dismissals::url( $this->id ), $inline ); } /** * 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 printStylesheet(): void { + private function printStylesheet( ?string $stylesheet ): void { wp_register_style( self::STYLE_HANDLE . $this->id, - rtrim( $this->packageUrl, '/' ) . '/src/templates/banner.css', + $stylesheet ?? rtrim( $this->packageUrl, '/' ) . '/src/templates/banner.css', [], // A new release replaces the bundled file at the same URL, so its modification time busts caches. - (string) filemtime( __DIR__ . '/templates/banner.css' ) + $stylesheet === null ? (string) filemtime( __DIR__ . '/templates/banner.css' ) : null ); wp_print_styles( self::STYLE_HANDLE . $this->id ); } /** - * Print the bundled template, or the one a filter chose for this ID, with $fields, $dismiss_url and - * $inline in scope. They are snake_case so that templates pass the WordPress coding standards most - * plugins lint with. + * 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( BannerFields $fields, string $dismiss_url, bool $inline ): void { - $bundled = __DIR__ . '/templates/banner.php'; - $template = apply_filters( self::TEMPLATE_FILTER . $this->id, $bundled ); - - // 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; + 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/tests/BannerTest.php b/tests/BannerTest.php index 9f9895d..92fe4af 100644 --- a/tests/BannerTest.php +++ b/tests/BannerTest.php @@ -3,7 +3,6 @@ namespace Nexcess\Banners\Tests; use Brain\Monkey\Actions; -use Brain\Monkey\Filters; use Brain\Monkey\Functions; use DateTimeImmutable; use DateTimeZone; @@ -20,13 +19,11 @@ final class BannerTest extends TestCase { ]; /** - * @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__ ], ]; @@ -86,6 +83,21 @@ public function testPrintsTheBundledStylesheetFromThePackageUrlBeforeTheBanner( ); } + public function testPrintsTheStylesheetItIsGiven(): void { + $this->stubDismissed( [] ); + $this->stubRenderable(); + + $output = $this->render( + new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ), + null, + null, + 'https://example.test/wp-content/plugins/my-plugin/banner.css' + ); + + $this->assertStringContainsString( '', $output ); + $this->assertStringNotContainsString( 'src/templates/banner.css', $output ); + } + /** * Without "inline", wp-admin's common.js moves every .notice under the page heading, which would * undo rendering the banner where the client called it. @@ -164,12 +176,15 @@ public function testRendersNothingOutsideTheAdmin(): void { $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ) ); } - public function testAFilteredTemplateReplacesTheMarkupAndGetsTheDismissUrl(): void { + public function testRendersWithTheTemplateItIsGivenAndPassesItTheDismissUrl(): void { $this->stubDismissed( [] ); $this->stubRenderable(); - Filters\expectApplied( 'nx_banners_template:promo' )->once()->andReturn( __DIR__ . '/Fixtures/custom-banner.php' ); - $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ); + $output = $this->render( + new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ), + null, + __DIR__ . '/Fixtures/custom-banner.php' + ); $this->assertStringContainsString( '

Go Pro

', $output ); $this->assertMatchesRegularExpression( '/
/', $output ); @@ -177,19 +192,16 @@ public function testAFilteredTemplateReplacesTheMarkupAndGetsTheDismissUrl(): vo } /** - * A broken filter must neither fatal the page nor hide the banner: by the time the template + * A broken path must neither fatal the page nor hide the banner: by the time the template * loads, every other copy has already stood down for this ID. * * @dataProvider brokenTemplates - * - * @param mixed $template What the filter returned. */ - public function testABrokenFilteredTemplateFallsBackToTheBundledOne( $template ): void { + public function testABrokenTemplatePathFallsBackToTheBundledOne( string $template ): void { $this->stubDismissed( [] ); $this->stubRenderable(); - Filters\expectApplied( 'nx_banners_template:promo' )->andReturn( $template ); - $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ) ); + $output = $this->render( new Banner( 'promo', '1.0.0', self::fields( self::FIELDS ), self::PACKAGE_URL ), null, $template ); $this->assertStringContainsString( 'nx-banner__inner', $output ); } diff --git a/tests/Fixtures/custom-banner.php b/tests/Fixtures/custom-banner.php index b7b3592..a2145e4 100644 --- a/tests/Fixtures/custom-banner.php +++ b/tests/Fixtures/custom-banner.php @@ -1,7 +1,7 @@ returnArg(); } - protected function render( Banner $banner, ?callable $shouldDisplay = null ): string { + protected function render( + Banner $banner, + ?callable $shouldDisplay = null, + ?string $template = null, + ?string $stylesheet = null + ): string { ob_start(); - $banner->render( $shouldDisplay ); + $banner->render( $shouldDisplay, $template, $stylesheet ); return (string) ob_get_clean(); } From ba0c45b4267fd2f492d1cb1cb01f39f895f9c866 Mon Sep 17 00:00:00 2001 From: Dimitrios Pantazis Date: Tue, 13 Oct 2026 15:42:11 +0300 Subject: [PATCH 4/5] Version the bundled stylesheet by package release The bundled stylesheet's URL now carries `Package::VERSION` instead of the file's modification time, so it changes when a release does. Bump the constant with every release, or browsers keep the cached stylesheet. A stylesheet passed to `render()` still gets no version. --- README.md | 3 +++ src/Banner.php | 3 +-- src/Package.php | 15 +++++++++++++++ tests/BannerTest.php | 3 ++- tests/TestCase.php | 6 +++--- 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 src/Package.php diff --git a/README.md b/README.md index 17963dc..2dc299c 100644 --- a/README.md +++ b/README.md @@ -264,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 0c7db22..ba96d90 100644 --- a/src/Banner.php +++ b/src/Banner.php @@ -119,8 +119,7 @@ private function printStylesheet( ?string $stylesheet ): void { self::STYLE_HANDLE . $this->id, $stylesheet ?? rtrim( $this->packageUrl, '/' ) . '/src/templates/banner.css', [], - // A new release replaces the bundled file at the same URL, so its modification time busts caches. - $stylesheet === null ? (string) filemtime( __DIR__ . '/templates/banner.css' ) : null + $stylesheet === null ? Package::VERSION : null ); wp_print_styles( self::STYLE_HANDLE . $this->id ); } 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 @@ +stubRenderable(); $this->assertMatchesRegularExpression( - '/^\s*