Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 238
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Akismet_Admin_Chrome
0.00% covered (danger)
0.00%
0 / 236
0.00% covered (danger)
0.00%
0 / 6
132
0.00% covered (danger)
0.00%
0 / 1
 jetpack_logo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 akismet_logo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init_hooks
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 print_styles
0.00% covered (danger)
0.00%
0 / 211
0.00% covered (danger)
0.00%
0 / 1
6
 render_header
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 render_footer
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Renders the unified Jetpack admin header and footer chrome on Akismet's admin pages.
4 *
5 * Akismet (5.7+) exposes two action hooks in its admin views:
6 *  - `akismet_header`: when an action is registered, it REPLACES Akismet's default masthead.
7 *  - `akismet_footer`: rendered at the bottom of every admin view; empty by default.
8 *
9 * This integration consumes those hooks from the Jetpack plugin so that Akismet's pages
10 * share the same branded header bar, standardized footer AND contained layout as the rest
11 * of the unified Jetpack admin (the `@wordpress/admin-ui` page header + `JetpackFooter` look
12 * used by My Jetpack, Protect, Social, etc.): a fixed header, an internally-scrolling middle
13 * and a footer pinned to the bottom of the viewport.
14 *
15 * The markup mirrors the admin-ui page header and `.jetpack-footer` component, and the
16 * styling is a small self-contained stylesheet (no external CSS, no JS, no build step) that
17 * reproduces the measured computed styles of those components plus the
18 * `jetpack-admin-page-layout` mixin, adapted to Akismet's markup
19 * (`#wpbody-content > #akismet-plugin-container > header / .akismet-lower / footer`). This
20 * keeps the integration resilient to the CSS-Module class hashing used by the real React
21 * components.
22 *
23 * NOTE: This class does not modify the Akismet plugin in any way. It only registers
24 * callbacks on Akismet's own action hooks.
25 *
26 * @package automattic/jetpack
27 */
28
29use Automattic\Jetpack\Redirect;
30use Automattic\Jetpack\Status;
31use Automattic\Jetpack\Status\Host;
32
33if ( ! defined( 'ABSPATH' ) ) {
34    exit( 0 );
35}
36
37/**
38 * Wires the unified Jetpack header, footer and contained layout onto Akismet's admin pages.
39 */
40class Akismet_Admin_Chrome {
41
42    /**
43     * The green Jetpack logo mark, sized via the `height` attribute by callers.
44     *
45     * @param int $height Pixel height of the logo.
46     * @return string SVG markup.
47     */
48    private function jetpack_logo( $height ) {
49        return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" height="' . (int) $height . '" class="jp-akismet-logo" aria-hidden="true"><path fill="#069e08" d="M16,0C7.2,0,0,7.2,0,16s7.2,16,16,16s16-7.2,16-16S24.8,0,16,0z M15,19H7l8-16V19z M17,29V13h8L17,29z"></path></svg>';
50    }
51
52    /**
53     * The Akismet logo mark — the green rounded square with the white "A", taken from
54     * Akismet's own `akismet-refresh-logo.svg`. Sized via the `height` attribute by callers.
55     *
56     * @param int $height Pixel height of the logo.
57     * @return string SVG markup.
58     */
59    private function akismet_logo( $height ) {
60        return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" height="' . (int) $height . '" class="jp-akismet-mark" aria-hidden="true"><rect width="44" height="44" fill="#357B49" rx="6"/><path fill="#fff" fill-rule="evenodd" d="m29.746 28.31-6.392-16.797c-.152-.397-.305-.672-.789-.675-.673 0-1.408.611-1.746 1.316l-7.378 16.154c-.072.16-.143.311-.214.454-.5.995-1.045 1.546-2.357 1.626a.399.399 0 0 0-.16.033l-.01.004a.399.399 0 0 0-.23.392v.01c0 .054.01.106.03.155l.004.01a.416.416 0 0 0 .394.252h6.212a.417.417 0 0 0 .307-.12.416.416 0 0 0 .124-.305.398.398 0 0 0-.105-.302.399.399 0 0 0-.294-.127c-.757 0-2.197-.062-2.197-1.164.02-.318.103-.63.245-.916l1.399-3.152c.52-1.163 1.654-1.163 2.572-1.163h5.843c.023 0 .044 0 .062.003.13.014.16.081.214.242l1.534 4.07a2.857 2.857 0 0 1 .216 1.04c0 .054-.003.104-.01.153-.09.726-.831.887-1.49.887a.4.4 0 0 0-.294.127l-.007.008-.007.008a.401.401 0 0 0-.092.286v.01c0 .054.01.106.03.155l.005.01a.42.42 0 0 0 .395.252h7.011a.413.413 0 0 0 .279-.13.412.412 0 0 0 .11-.297.387.387 0 0 0-.09-.294.388.388 0 0 0-.277-.135c-1.448-.122-2.295-.643-2.847-2.08Zm-11.985-5.844 2.847-6.304c.361-.728.659-1.486.889-2.265 0-.06.03-.092.06-.092s.061.032.061.091c.02.122.045.247.073.374.197.888.584 1.878.914 2.723l.176.453 1.684 4.529a.927.927 0 0 1 .092.4.473.473 0 0 1-.009.094c-.041.202-.228.272-.602.272h-6.063c-.122 0-.184-.03-.184-.092a.36.36 0 0 1 .062-.183Zm17.107-.721c0 .786-.446 1.231-1.25 1.231-.806 0-1.125-.409-1.125-1.034 0-.786.465-1.231 1.25-1.231.785 0 1.125.427 1.125 1.034ZM9.629 23.002c.803 0 1.25-.447 1.25-1.231 0-.607-.343-1.036-1.128-1.036-.785 0-1.25.447-1.25 1.231 0 .625.325 1.036 1.128 1.036Z" clip-rule="evenodd"/></svg>';
61    }
62
63    /**
64     * Register the hooks that drive the chrome.
65     *
66     * Safe to call unconditionally: the header/footer callbacks are only ever fired by
67     * Akismet's own admin views, and the inline stylesheet is printed alongside the header.
68     *
69     * Idempotent: this can be wired from more than one place depending on the platform —
70     * `Jetpack_Admin` on Atomic/self-hosted, and `Akismet_Admin_WPCOM` on WordPress.com
71     * Simple sites (the two run under different load orders). The static guard ensures the
72     * `akismet_header` / `akismet_footer` callbacks are only ever registered once, so the
73     * chrome can never render twice regardless of how many call sites fire.
74     */
75    public function init_hooks() {
76        static $registered = false;
77        if ( $registered ) {
78            return;
79        }
80        $registered = true;
81
82        add_action( 'akismet_header', array( $this, 'render_header' ) );
83        add_action( 'akismet_footer', array( $this, 'render_footer' ) );
84    }
85
86    /**
87     * The self-contained stylesheet reproducing the admin-ui page header + `.jetpack-footer`
88     * computed styles. Printed once, alongside the header.
89     */
90    private function print_styles() {
91        static $printed = false;
92        if ( $printed ) {
93            return;
94        }
95        $printed = true;
96        ?>
97        <style id="jp-akismet-chrome-css">
98            /* ── Header (admin-ui page header look) ── */
99            .jp-akismet-header {
100                display: flex;
101                align-items: center;
102                box-sizing: border-box;
103                min-height: 72px;
104                padding: 16px 24px;
105                background: #fff;
106                /* `box-shadow` (not `border-bottom`) so the hairline never adds to the
107                    box and nudges the logo/title off the admin-ui header's vertical center. */
108                box-shadow: inset 0 -1px 0 #e4e4e4;
109            }
110            .jp-akismet-header__title {
111                display: flex;
112                align-items: center;
113                gap: 8px;
114            }
115            /* 24×24 visual slot centering the 20px logo, matching My Jetpack/Boost. */
116            .jp-akismet-header__visual {
117                display: flex;
118                align-items: center;
119                justify-content: center;
120                flex-shrink: 0;
121                width: 24px;
122                height: 24px;
123            }
124            .jp-akismet-header__title h1 {
125                margin: 0;
126                padding: 0;
127                font-size: 15px;
128                font-weight: 500;
129                line-height: 20px;
130                color: #1e1e1e;
131            }
132            /* ── Footer (.jetpack-footer look) ── */
133            .jp-akismet-footer {
134                display: flex;
135                align-items: center;
136                flex-wrap: wrap;
137                gap: 24px;
138                box-sizing: border-box;
139                padding: 20px 24px;
140                border-top: 1px solid #e4e4e4;
141                background: #fff;
142                color: #1e1e1e;
143                font-size: 13px;
144            }
145            .jp-akismet-footer__logo {
146                display: flex;
147                align-items: center;
148                gap: 8px;
149                font-weight: 500;
150            }
151            .jp-akismet-footer__menu {
152                display: flex;
153                gap: 16px;
154            }
155            /* `#akismet-plugin-container a` is green in Akismet's own CSS; this scoped
156                selector outranks it so the footer links read as neutral grey (#707070,
157                matching My Jetpack's footer links). */
158            #akismet-plugin-container .jp-akismet-footer__menu a {
159                color: #707070;
160                text-decoration: none;
161            }
162            #akismet-plugin-container .jp-akismet-footer__menu a:hover {
163                color: #1e1e1e;
164                text-decoration: underline;
165            }
166            .jp-akismet-footer__a8c {
167                margin-inline-start: auto;
168                display: inline-flex;
169                align-items: center;
170            }
171            /* Mobile: the byline drops its right-push and wraps to its own full-width
172                row, left-aligned — matching how JetpackFooter stacks on other pages. */
173            @media (max-width: 782px) {
174                .jp-akismet-footer__a8c {
175                    margin-inline-start: 0;
176                    flex-basis: 100%;
177                }
178            }
179            .jp-akismet-footer__a8c svg path {
180                fill: #707070;
181            }
182
183            /* ── Hello Dolly ──
184                The `.jetpack-admin-page #dolly` treatment (right-aligned, italic,
185                WPDS colors) ships in the jetpack-components / My Jetpack admin CSS,
186                which doesn't load on Akismet's page — so without this, Dolly's lyric
187                falls back to left-aligned here. Re-declare it so Dolly lands top-right
188                exactly like every other unified Jetpack admin page. */
189            .jetpack-admin-page #dolly {
190                float: none;
191                text-align: end;
192                background: var(--wpds-color-bg-surface-neutral-strong, #fff);
193                font-style: italic;
194                color: var(--wpds-color-fg-content-neutral-weak, #87a6bc);
195                border-bottom: none;
196            }
197            @media (max-width: 659px) {
198                .jetpack-admin-page #dolly {
199                    display: none;
200                }
201            }
202
203            /* ── Contained layout: fixed header, scrolling middle, pinned footer ──
204                Mirrors the jetpack-admin-page-layout mixin, adapted to Akismet's
205                markup (#wpbody-content > #akismet-plugin-container > header/.akismet-lower/footer).
206                Scoped to both menu locations: jetpack_page_… and settings_page_…
207
208                The mixin uses physical `left`/`right` because its SCSS is compiled
209                through rtlcss, which emits a flipped stylesheet. This inline `<style>`
210                has no such build step, so it uses CSS logical properties
211                (`inset-inline-*`, `padding-inline-*`, `margin-inline`) to flip with the
212                admin menu under RTL locales. */
213            body[class*="_page_akismet-key-config"] #wpcontent {
214                padding-inline-start: 0;
215            }
216            body[class*="_page_akismet-key-config"] #wpfooter {
217                display: none;
218            }
219            /* `#screen-meta-links` (the Screen Options / Help tabs container) is always
220                emitted by core's admin header even when empty, and core gives it
221                `margin: 0 10px 20px 0`. On wp.com Simple sites its contents are hidden
222                but the element — and its 20px bottom margin — remain, reserving a blank
223                slot at the very top of the page above the Jetpack header. The
224                `jetpack-admin-page-layout` mixin hides it for the same reason; do it here
225                too. Left UNSCOPED (not under `_page_akismet-key-config`) on purpose: the
226                inline stylesheet is only ever printed on Akismet admin views, and Simple
227                renders its stats UI under a different slug (`dashboard_page_akismet-stats`),
228                so an unscoped rule covers every page this chrome appears on. */
229            #screen-meta-links {
230                display: none;
231            }
232            body[class*="_page_akismet-key-config"] #wpbody-content {
233                box-sizing: border-box;
234                position: fixed;
235                top: var(--wp-admin-bar-height, 32px);
236                inset-inline-start: 160px;
237                inset-inline-end: 0;
238                bottom: 0;
239                width: auto;
240                padding-bottom: 0;
241                overflow: hidden;
242                display: flex;
243                flex-direction: column;
244            }
245            body[class*="_page_akismet-key-config"].folded #wpbody-content {
246                inset-inline-start: 36px;
247            }
248            @media (max-width: 960px) {
249                body[class*="_page_akismet-key-config"].auto-fold #wpbody-content {
250                    inset-inline-start: 36px;
251                }
252            }
253            @media (min-width: 961px) {
254                body[class*="_page_akismet-key-config"].is-nav-unification:not(.folded) #wpbody-content {
255                    inset-inline-start: 272px;
256                }
257            }
258            body[class*="_page_akismet-key-config"] #akismet-plugin-container {
259                flex: 1 1 auto;
260                min-height: 0;
261                min-width: 0;
262                display: flex;
263                flex-direction: column;
264                /* Drop Akismet's 1px container border: it insets the whole column by 1px,
265                    pushing the header logo/title off the admin-ui alignment grid. */
266                border: 0;
267            }
268            body[class*="_page_akismet-key-config"] .jp-akismet-header {
269                flex-shrink: 0;
270            }
271            /* The scrollable middle. Target the generic child between header and
272                footer (not `.akismet-lower` specifically) so the config, start AND
273                stats (bare iframe) views all get a full-width scroll surface. The
274                `#wpbody-content` id keeps these rules ahead of Akismet's own
275                `.akismet-lower { width: 720px }`. */
276            body[class*="_page_akismet-key-config"] #wpbody-content > #akismet-plugin-container > :not(.jp-akismet-header):not(.jp-akismet-footer) {
277                flex: 1 1 auto;
278                min-height: 0;
279                min-width: 0;
280                width: auto;
281                max-width: none;
282                margin: 0;
283                overflow: auto;
284            }
285            /* Move the readable-width restriction onto the content blocks so the
286                scroll surface itself spans the full width (scrolling works wherever
287                the pointer is), while the cards stay a comfortable column, left-
288                aligned with the header. 45rem ≈ Akismet's original 720px column. */
289            body[class*="_page_akismet-key-config"] .akismet-lower > * {
290                box-sizing: border-box;
291                max-width: 45rem;
292                margin-inline: auto;
293            }
294            body[class*="_page_akismet-key-config"] .jp-akismet-footer {
295                flex-shrink: 0;
296            }
297            @media (max-width: 782px) {
298                body[class*="_page_akismet-key-config"] #wpbody-content,
299                body[class*="_page_akismet-key-config"].folded #wpbody-content,
300                body[class*="_page_akismet-key-config"].auto-fold #wpbody-content {
301                    top: var(--wp-admin-bar-height, 46px);
302                    inset-inline-start: 0;
303                }
304            }
305        </style>
306        <?php
307    }
308
309    /**
310     * Render the admin-ui-style header (Akismet logo + title) that replaces Akismet's default masthead.
311     */
312    public function render_header() {
313        $this->print_styles();
314        ?>
315        <header class="jp-akismet-header">
316            <div class="jp-akismet-header__title">
317                <span class="jp-akismet-header__visual" aria-hidden="true"><?php echo $this->akismet_logo( 20 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?></span>
318                <h1><?php esc_html_e( 'Akismet Anti-spam', 'jetpack' ); ?></h1>
319            </div>
320        </header>
321        <?php
322    }
323
324    /**
325     * Render the unified Jetpack footer (`.jetpack-footer` look) at the bottom of the page.
326     */
327    public function render_footer() {
328        // Match wrap_ui(): link the byline to the local About page when Jetpack isn't connectable,
329        // otherwise to the external jetpack.com redirect.
330        $connectable = ! Jetpack::is_connection_ready() && ! ( new Status() )->is_offline_mode();
331        $a8c_url     = ! $connectable
332            ? admin_url( 'admin.php?page=jetpack_about' )
333            : Redirect::get_url( 'jetpack' );
334        ?>
335        <footer class="jp-akismet-footer jetpack-footer" aria-label="<?php esc_attr_e( 'Jetpack', 'jetpack' ); ?>" role="contentinfo">
336            <div class="jp-akismet-footer__logo">
337                <?php echo $this->jetpack_logo( 16 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. ?>
338                <span><?php esc_html_e( 'Jetpack', 'jetpack' ); ?></span>
339            </div>
340            <?php if ( ! ( new Host() )->is_wpcom_platform() ) : ?>
341            <nav class="jp-akismet-footer__menu">
342                <a href="<?php echo esc_url( admin_url( 'admin.php?page=my-jetpack#/products' ) ); ?>"><?php echo esc_html_x( 'Products', 'Navigation item', 'jetpack' ); ?></a>
343                <a href="<?php echo esc_url( admin_url( 'admin.php?page=my-jetpack#/help' ) ); ?>"><?php echo esc_html_x( 'Help', 'Navigation item', 'jetpack' ); ?></a>
344            </nav>
345            <?php endif; ?>
346            <a class="jp-akismet-footer__a8c" href="<?php echo esc_url( $a8c_url ); ?>" aria-label="<?php esc_attr_e( 'An Automattic Airline', 'jetpack' ); ?>">
347                <svg role="img" x="0" y="0" viewBox="0 0 935 38.2" height="7" aria-hidden="true"><path d="M317.1 38.2c-12.6 0-20.7-9.1-20.7-18.5v-1.2c0-9.6 8.2-18.5 20.7-18.5 12.6 0 20.8 8.9 20.8 18.5v1.2C337.9 29.1 329.7 38.2 317.1 38.2zM331.2 18.6c0-6.9-5-13-14.1-13s-14 6.1-14 13v0.9c0 6.9 5 13.1 14 13.1s14.1-6.2 14.1-13.1V18.6zM175 36.8l-4.7-8.8h-20.9l-4.5 8.8h-7L157 1.3h5.5L182 36.8H175zM159.7 8.2L152 23.1h15.7L159.7 8.2zM212.4 38.2c-12.7 0-18.7-6.9-18.7-16.2V1.3h6.6v20.9c0 6.6 4.3 10.5 12.5 10.5 8.4 0 11.9-3.9 11.9-10.5V1.3h6.7V22C231.4 30.8 225.8 38.2 212.4 38.2zM268.6 6.8v30h-6.7v-30h-15.5V1.3h37.7v5.5H268.6zM397.3 36.8V8.7l-1.8 3.1 -14.9 25h-3.3l-14.7-25 -1.8-3.1v28.1h-6.5V1.3h9.2l14 24.4 1.7 3 1.7-3 13.9-24.4h9.1v35.5H397.3zM454.4 36.8l-4.7-8.8h-20.9l-4.5 8.8h-7l19.2-35.5h5.5l19.5 35.5H454.4zM439.1 8.2l-7.7 14.9h15.7L439.1 8.2zM488.4 6.8v30h-6.7v-30h-15.5V1.3h37.7v5.5H488.4zM537.3 6.8v30h-6.7v-30h-15.5V1.3h37.7v5.5H537.3zM569.3 36.8V4.6c2.7 0 3.7-1.4 3.7-3.4h2.8v35.5L569.3 36.8 569.3 36.8zM628 11.3c-3.2-2.9-7.9-5.7-14.2-5.7 -9.5 0-14.8 6.5-14.8 13.3v0.7c0 6.7 5.4 13 15.3 13 5.9 0 10.8-2.8 13.9-5.7l4 4.2c-3.9 3.8-10.5 7.1-18.3 7.1 -13.4 0-21.6-8.7-21.6-18.3v-1.2c0-9.6 8.9-18.7 21.9-18.7 7.5 0 14.3 3.1 18 7.1L628 11.3zM321.5 12.4c1.2 0.8 1.5 2.4 0.8 3.6l-6.1 9.4c-0.8 1.2-2.4 1.6-3.6 0.8l0 0c-1.2-0.8-1.5-2.4-0.8-3.6l6.1-9.4C318.7 11.9 320.3 11.6 321.5 12.4L321.5 12.4z"></path><path d="M37.5 36.7l-4.7-8.9H11.7l-4.6 8.9H0L19.4 0.8H25l19.7 35.9H37.5zM22 7.8l-7.8 15.1h15.9L22 7.8zM82.8 36.7l-23.3-24 -2.3-2.5v26.6h-6.7v-36H57l22.6 24 2.3 2.6V0.8h6.7v35.9H82.8z"></path><path d="M719.9 37l-4.8-8.9H694l-4.6 8.9h-7.1l19.5-36h5.6l19.8 36H719.9zM704.4 8l-7.8 15.1h15.9L704.4 8zM733 37V1h6.8v36H733zM781 37c-1.8 0-2.6-2.5-2.9-5.8l-0.2-3.7c-0.2-3.6-1.7-5.1-8.4-5.1h-12.8V37H750V1h19.6c10.8 0 15.7 4.3 15.7 9.9 0 3.9-2 7.7-9 9 7 0.5 8.5 3.7 8.6 7.9l0.1 3c0.1 2.5 0.5 4.3 2.2 6.1V37H781zM778.5 11.8c0-2.6-2.1-5.1-7.9-5.1h-13.8v10.8h14.4c5 0 7.3-2.4 7.3-5.2V11.8zM794.8 37V1h6.8v30.4h28.2V37H794.8zM836.7 37V1h6.8v36H836.7zM886.2 37l-23.4-24.1 -2.3-2.5V37h-6.8V1h6.5l22.7 24.1 2.3 2.6V1h6.8v36H886.2zM902.3 37V1H935v5.6h-26v9.2h20v5.5h-20v10.1h26V37H902.3z"></path></svg>
348            </a>
349        </footer>
350        <?php
351    }
352}