Files
nolimit-discounts/includes/shared-menu.php
T
kristianwindandClaude Opus 5 6a789349ec 1.5.3: shared-menu 1.2.2, and run the glyph style instead of reading it
678f1f03 is withdrawn. It hard-coded the icon box at 36x34, and WordPress
declares that parent three times with different sizes — 36x34 open, 35x30
folded, 34x30 under 960px — so a fixed number is correct in one state and 4px
too tall in the two a laptop is usually in. cache-manager measured where the
ink lands rather than where the box is and found it worse than that: the glyph
moved from 2.5px above centre to 2.5px below in the folded states, the same
error mirrored, in a state that had been nearly right. 1.2.2 removes the number
entirely with width/height at 100%.

1.5.2 is already published with 678f1f03, so this tree shipped the withdrawn
version. Checked before anything else, because a later candidate fatalled on
every admin page with "Unknown format specifier": that file was never taken
here, and the published 1.5.2 emits its stylesheet cleanly — verified by
running sprintf over its format string rather than reading it. So the shop is
cosmetically off in folded menus, not broken.

New assertion, and it is the one this round is really about: the style
function is INVOKED and its output captured, rather than the source being
read. The fatal that prompted it survived three sessions reading the file,
a clean php -l, a clean PHPCS, and two browsers agreeing about the CSS —
because every one of those was fed the stylesheet by hand rather than through
the printf that produces it. A literal per-cent must be doubled in a format
string, and nothing that looks at the file can see that.

Proven red with that exact fault rather than an arbitrary one: undoubling the
per-cent gives "ValueError: Unknown format specifier ';'".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:07:44 +02:00

402 lines
17 KiB
PHP

<?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.2' );
}
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}'
. '.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>';
}
}