Files
nolimit-discounts/includes/shared-menu.php
T

413 lines
17 KiB
PHP
Raw Normal View History

<?php
/**
* The one admin menu the manager plugins share.
*
* ┌──────────────────────────────────────────────────────────────────────────┐
* │ THIS FILE IS BYTE-IDENTICAL IN EVERY MANAGER PLUGIN. │
* │ Do not edit it to add something for one plugin. Extend it through the │
* │ `wpmgr_dashboard_cards` filter, which is what it is for. │
* │ Contract version 1.1.0. See "Why it is frozen" below — and note that │
* │ the two changes it has had were both paid for in load-order confusion. │
* └──────────────────────────────────────────────────────────────────────────┘
*
* The problem it solves: a family of plugins each adding a top-level menu is
* a handful of entries in a sidebar that already has fifteen. So they share
* one parent and each adds a submenu under it. Six of them as of this
* writing, which is why the count is not stated anywhere it would have to be
* maintained.
*
* Three things about how it does that are load-bearing, and none is obvious.
*
* **1. Every declaration is guarded.** Each plugin `require_once`s its own
* copy of this file, in one request. `require_once` only deduplicates by
* resolved path, and these are different paths — so without the
* `function_exists()` guard, activating the second plugin is
* `Fatal error: Cannot redeclare wpmgr_ensure_menu()`. A white screen on a
* shop, caused by nothing except owning two of these plugins. It is the same
* shape as the update-hook collision: a fault that exists only when there is
* more than one, which means no repository's own tests can find it.
*
* **2. Whichever copy loads first wins, and that order is arbitrary** — it is
* plugin activation order, effectively alphabetical by directory. PHP cannot
* un-declare a function, so there is no honest way for a newer copy to replace
* an older one that has already been loaded. The only stable answer is that
* the file never changes. Hence: **frozen**. The parent slug, the capability,
* the position and the filter name are a contract between every repository in
* the family, and anything new arrives through the filter.
*
* 🔴 It has moved twice, and both times the cost was the same: a half-adopted
* change means the menu is whatever the alphabetically-first plugin's copy
* says, and every repository's own hash check goes green on its own side
* while the family disagrees. Nolimit SEO measured exactly that today — two
* plugins on 1.1.0 and three on 1.0.0, so a shop got the infinity glyph or
* the old cog depending on activation order. If this file has to change,
* every plugin takes it in the same hour or the change is not made.
*
* **3. The dashboard is built from a filter, not owned by a plugin.** If the
* plugin that happened to register the menu is deactivated, the menu is
* re-registered by the next one on the following request and the page still
* renders — because the page's content comes from whoever is installed, not
* from whoever created it. Registering a callback that lives in one plugin
* would mean deactivating that plugin takes the other four's screens with it.
*
* Each plugin calls `wpmgr_ensure_menu()` at the top of its own `admin_menu`
* callback, before its own `add_submenu_page( 'wpmgr', … )`. Not on some other
* hook: `add_submenu_page` against a parent that does not exist yet silently
* fails, and whose `admin_menu` runs first is, again, arbitrary.
*
* The package below is the family, not any one plugin: this file is
* byte-identical in five repositories, and a tag naming whichever of them it
* was first written in reads as a mistake in the other four — which invites
* somebody to "fix" it, in one repository, which is the exact edit the freeze
* exists to prevent.
*
* @package WordPress_Manager_Family
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
define( 'WPMGR_MENU_VERSION', '1.2.3' );
}
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
define( 'WPMGR_MENU_SLUG', 'wpmgr' );
}
if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
/**
* Register the shared parent menu, unless somebody already has.
*
* Call this first thing in your own `admin_menu` callback.
*/
function wpmgr_ensure_menu() {
global $admin_page_hooks;
if ( isset( $admin_page_hooks[ WPMGR_MENU_SLUG ] ) ) {
return;
}
/**
* The label on the shared parent menu.
*
* Left untranslated here on purpose: this file is byte-identical in
* five plugins, so it cannot carry any one of their text domains, and
* a string with the wrong domain is a string that is silently never
* translated. Any plugin may translate it in its own domain through
* this filter.
*
* @param string $title The menu title.
*/
$title = apply_filters( 'wpmgr_menu_title', 'Manager' );
/**
* The glyph on the shared parent menu.
*
* An infinity sign, because the family is called Nolimit. A cog said
* nothing, and there is no infinity dashicon.
*
* Drawn as TEXT rather than as an SVG passed to add_menu_page(), and
* the reason is colour. WordPress renders a data-URI SVG as a CSS
* background-image, and a background-image cannot inherit a colour —
* so it stays one fixed grey while every other icon lightens on hover
* and turns white on the current item. It looks subtly broken under
* every admin colour scheme, including the default. A `::before`
* glyph is text: it takes the colour it is given and follows all of
* those states for free, with no image request.
*
* Filterable so that changing it later needs no edit to this file.
* This file is byte-identical across the family and hash-frozen in
* each plugin's CI, so an edit here is a coordinated release for all
* of them; a filter is one line in one plugin.
*
* @param string $glyph A single character, or '' for the old cog.
*/
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
add_menu_page(
$title,
$title,
'manage_options',
WPMGR_MENU_SLUG,
'wpmgr_render_dashboard',
// 'none' makes WordPress emit the icon wrapper with no dashicon
// class on it, which is the hook the style below needs. A dashicon
// name here would win instead, because its class carries its own
// ::before content.
'' === $glyph ? 'dashicons-admin-generic' : 'none',
// Just below WooCommerce (55.x) and above Appearance (60). The
// fractional position is not decoration: an integer collides with
// whatever core or another plugin put there, and the loser is
// simply dropped.
58.9
);
if ( '' !== $glyph ) {
add_action( 'admin_head', 'wpmgr_menu_glyph_style' );
}
}
/**
* The one style rule the glyph needs.
*
* Printed in the head on every admin page, because the menu is on every
* admin page — a stylesheet enqueued only on this family's own screens
* would leave a blank square everywhere else.
*
* `font-family` has to be overridden: WordPress sets the icon wrapper to
* the Dashicons font, and Dashicons has no infinity glyph, so inheriting
* it renders nothing at all.
*
* 🔴 The box is WordPress's own, measured rather than guessed, and the
* glyph is centred in it geometrically rather than nudged.
*
* wp-admin/css/admin-menu.css sets `div.wp-menu-image:before` to
* `padding: 7px 0` — exactly 7 + 20 + 7 for a 20px dashicon on
* `line-height: 1` in a 34px box. The first version of this rule set
* `line-height: 1.4` and inherited that padding, making the line box 28px
* and the whole pseudo-element 42px inside a 34px parent. Kristian
* reported the result: the glyph sitting above centre.
*
* So: drop the padding that only existed to position a 20px line, and
* centre with flex. That matters more for an arbitrary glyph than for a
* dashicon — U+221E sits on the maths axis rather than filling its em box,
* so any rule that positions by line-height and padding is positioning the
* box and hoping about the glyph. `align-items: center` centres what is
* actually drawn, which is also what keeps working if anyone returns
* something else from `wpmgr_menu_glyph`.
*
* 🔴 `100%%`, not `100%`. This is a printf format string, so a literal
* per-cent sign has to be doubled — and the first version of the
* percentage fix did not, which is a **fatal on every admin page**:
* "Uncaught ValueError: Unknown format specifier". Three sessions agreed
* on that file after reading it, linting it and running PHPCS over it, and
* all three are blind to this: the syntax is valid and the standard has
* nothing to say. Only running the function finds it, and it was the site
* test that did. The fixed-pixel version could not have had this fault,
* so it arrived with the improvement.
*
* 🔴 The size is `100%`, not the 36x34 the desktop menu happens to be.
* That stylesheet declares the parent THREE times and they disagree:
* `#adminmenu div.wp-menu-image` is 36x34, `.folded #adminmenu
* div.wp-menu-image` is 35x30, and under `@media (max-width: 960px)`
* `.auto-fold #adminmenu div.wp-menu-image` is 34x30. A pseudo-element
* fixed at 36x34 is therefore correct on a wide screen with the menu open
* and 4px too tall in both of the other states — which are the states a
* laptop is in. Percentages resolve against whichever parent is in force,
* so there is no number here to be right about. Measured by reading that
* stylesheet, not by eye.
*/
function wpmgr_menu_glyph_style() {
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
if ( '' === $glyph ) {
return;
}
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the slug is reduced to [a-z0-9_-] and the glyph to CSS numeric escapes; both are escaped for the context they are printed in, which is CSS rather than HTML.
printf(
'<style>#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;display:flex;align-items:center;justify-content:center;width:100%%;height:100%%;padding:0;line-height:1}</style>',
preg_replace( '/[^a-z0-9_-]/', '', strtolower( WPMGR_MENU_SLUG ) ),
wpmgr_css_escape( $glyph )
);
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* A string that is safe inside a CSS string literal, whatever it was.
*
* 🔴 esc_attr() is the wrong tool here and it protected by accident.
* Nolimit Discounts measured it: a glyph of `x"} body{display:none}` comes
* out as `x&quot;} …`, the CSS string never closes, and nothing breaks
* out — but only because the entity happens to contain no quote. The CSS
* parser does not decode HTML entities, so it was never escaping in this
* context; it was a substitution that coincidentally removed the one
* character that mattered. Two things follow from that: a glyph containing
* a quote renders as the literal text `&quot;` in the menu, and a single
* backslash — which esc_attr() leaves alone, and which IS an escape to the
* CSS parser — swallows the closing quote and the rest of the rule with
* it. Measured, not reasoned: the string then runs on to the quote in
* "Segoe UI" and the declaration block is corrupt.
*
* So do not filter the input; change its alphabet. Every character becomes
* a CSS numeric escape, so the emitted value contains nothing but
* backslashes, hex digits and spaces and cannot leave the string for any
* input at all. No whitelist to keep honest, no character class to get
* wrong, and an emoji still works.
*
* The decoder is written out rather than using mb_ord() because WordPress
* supports installs without mbstring, and ord() on a multi-byte character
* silently yields the first byte — which would render our own ∞ as
* mojibake on exactly those installs and on no machine any of us test on.
*
* @param string $text Text from a filter, i.e. arbitrary.
* @return string CSS escapes, safe between double quotes.
*/
function wpmgr_css_escape( $text ) {
$out = '';
$len = strlen( $text );
for ( $i = 0; $i < $len; $i++ ) {
$byte = ord( $text[ $i ] );
if ( $byte < 0x80 ) {
$code = $byte;
$tail = 0;
} elseif ( $byte >= 0xF0 ) {
$code = $byte & 0x07;
$tail = 3;
} elseif ( $byte >= 0xE0 ) {
$code = $byte & 0x0F;
$tail = 2;
} elseif ( $byte >= 0xC0 ) {
$code = $byte & 0x1F;
$tail = 1;
} else {
continue; // A stray continuation byte is not a character.
}
while ( $tail > 0 && $i + 1 < $len && 0x80 === ( ord( $text[ $i + 1 ] ) & 0xC0 ) ) {
++$i;
$code = ( $code << 6 ) | ( ord( $text[ $i ] ) & 0x3F );
--$tail;
}
if ( $tail > 0 ) {
continue; // Truncated sequence.
}
// The trailing space terminates the escape, so a following hex
// digit cannot be read as part of it.
$out .= sprintf( '\\%X ', $code );
}
return $out;
}
/**
* The shared landing page.
*
* Nothing here knows which plugins exist. Every card comes from the
* filter, so the page is correct whichever of them registered the menu and
* whichever of them have since been deactivated.
*/
function wpmgr_render_dashboard() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html( 'You do not have permission to view this page.' ) );
}
/**
* Cards on the shared dashboard, one per installed manager plugin.
*
* Each entry:
* title string Plugin name, already translated by its owner.
* status string 'ok' | 'warn' | 'fail' — what the check said.
* headline string One short line: the number that matters.
* lines array label => value, a handful at most.
* url string Where "Open" goes.
* colour string The plugin's own colour, as #rrggbb.
*
* Implementations must not measure anything here. This filter runs on
* an admin page load; a card that walks the disk or makes an HTTP
* request turns somebody else's dashboard into a slow page, and they
* will not know why.
*
* @param array $cards The cards so far.
*/
$cards = apply_filters( 'wpmgr_dashboard_cards', array() );
echo '<div class="wrap wpmgr-dashboard">';
echo '<h1>' . esc_html( apply_filters( 'wpmgr_menu_title', 'Manager' ) ) . '</h1>';
if ( empty( $cards ) ) {
echo '<p>Nothing to report.</p></div>';
return;
}
echo '<style>'
. '.wpmgr-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-top:20px}'
. '.wpmgr-card{background:#fff;border:1px solid #dcdcde;border-radius:8px;padding:0;overflow:hidden}'
. '.wpmgr-card header{padding:14px 18px;color:#fff;display:flex;justify-content:space-between;align-items:center}'
. '.wpmgr-card header h2{margin:0;font-size:14px;color:inherit}'
. '.wpmgr-card .wpmgr-body{padding:16px 18px}'
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
// 🔴 A DARK overlay, not a light one. White 11px text on
// rgba(255,255,255,.22) over a card colour is 3.45:1 on this
// plugin's green and 3.42:1 on a blue — under the 4.5:1 that small
// text needs, on every family colour dark enough to need white
// text in the first place. rgba(0,0,0,.18) takes the same four
// colours to 6.94–9.37:1, because darkening a background that
// already carries white text can only help.
//
// Measured rather than reasoned: the composite was read back out
// of a canvas in headless Chromium, not computed — #50977f for the
// old rule, #19644b for this one.
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
. 'background:rgba(0,0,0,.18);padding:2px 8px;border-radius:10px}'
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
. '</style>';
echo '<div class="wpmgr-cards">';
$states = array(
'ok' => 'OK',
'warn' => 'Attention',
'fail' => 'Action needed',
);
foreach ( $cards as $card ) {
if ( empty( $card['title'] ) ) {
continue;
}
$colour = isset( $card['colour'] ) && preg_match( '/^#[0-9a-f]{6}$/i', $card['colour'] )
? $card['colour']
: '#50575e';
$status = isset( $card['status'] ) && isset( $states[ $card['status'] ] ) ? $card['status'] : 'ok';
echo '<section class="wpmgr-card">';
printf(
'<header style="background:%s"><h2>%s</h2><span class="wpmgr-state">%s</span></header>',
esc_attr( $colour ),
esc_html( $card['title'] ),
esc_html( $states[ $status ] )
);
echo '<div class="wpmgr-body">';
if ( ! empty( $card['headline'] ) ) {
echo '<p class="wpmgr-headline">' . esc_html( $card['headline'] ) . '</p>';
}
if ( ! empty( $card['lines'] ) && is_array( $card['lines'] ) ) {
echo '<dl>';
foreach ( $card['lines'] as $label => $value ) {
echo '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';
}
echo '</dl>';
}
echo '</div>';
if ( ! empty( $card['url'] ) ) {
printf(
'<footer><a class="button" href="%s">%s</a></footer>',
esc_url( $card['url'] ),
esc_html( isset( $card['action'] ) ? $card['action'] : 'Open' )
);
}
echo '</section>';
}
echo '</div></div>';
}
}