2026-09-17 22:05:19 +02:00
<? 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. │
2026-09-20 09:31:37 +02:00
* │ 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. │
2026-09-17 22:05:19 +02:00
* └──────────────────────────────────────────────────────────────────────────┘
*
2026-09-20 09:31:37 +02:00
* 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.
2026-09-17 22:05:19 +02:00
*
* Three things about how it does that are load-bearing, and none is obvious.
*
2026-09-20 09:31:37 +02:00
* **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
2026-09-17 22:05:19 +02:00
* `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,
2026-09-20 09:31:37 +02:00
* 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.
2026-09-17 22:05:19 +02:00
*
* **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' ) ) {
2026-09-20 09:52:40 +02:00
define ( 'WPMGR_MENU_VERSION' , '1.2.0' );
2026-09-17 22:05:19 +02:00
}
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' );
2026-09-20 09:31:37 +02:00
/**
* 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' , '∞' );
2026-09-17 22:05:19 +02:00
add_menu_page (
$title ,
$title ,
'manage_options' ,
WPMGR_MENU_SLUG ,
'wpmgr_render_dashboard' ,
2026-09-20 09:31:37 +02:00
// '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' ,
2026-09-17 22:05:19 +02:00
// 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
);
2026-09-20 09:31:37 +02:00
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.
*/
function wpmgr_menu_glyph_style () {
$glyph = apply_filters ( 'wpmgr_menu_glyph' , '∞' );
if ( '' === $glyph ) {
return ;
}
2026-09-20 09:52:40 +02:00
// 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.
2026-09-20 09:31:37 +02:00
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;line-height:1.4}</style>' ,
2026-09-20 09:52:40 +02:00
preg_replace ( '/[^a-z0-9_-]/' , '' , strtolower ( WPMGR_MENU_SLUG ) ),
wpmgr_css_escape ( $glyph )
2026-09-20 09:31:37 +02:00
);
2026-09-20 09:52:40 +02:00
// 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"} …`, 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 `"` 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 ;
2026-09-17 22:05:19 +02:00
}
/**
* 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}'
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
. 'background:rgba(255,255,255,.22);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>' ;
}
}