2026-07-03 02:53:29 -05:00
|
|
|
<?php
|
|
|
|
|
namespace AuroraAdmin\Shell;
|
|
|
|
|
|
|
|
|
|
defined("ABSPATH") || exit();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Turns WordPress's global $menu / $submenu structures into a clean,
|
|
|
|
|
* JSON-friendly array the Vue sidebar can render.
|
|
|
|
|
*
|
|
|
|
|
* This is the deliberate, readable replacement for the original plugin's
|
|
|
|
|
* reverse-engineered menu reconstruction. It reads the same source of
|
|
|
|
|
* truth WordPress itself uses to paint the admin menu, so third-party
|
|
|
|
|
* plugin menu items appear automatically.
|
|
|
|
|
*/
|
|
|
|
|
class MenuSerializer
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* @return array List of top-level menu items, each:
|
|
|
|
|
* [ id, title, url, icon, count, separator(bool), submenu[] ]
|
|
|
|
|
*/
|
|
|
|
|
public static function serialize()
|
|
|
|
|
{
|
|
|
|
|
global $menu, $submenu;
|
|
|
|
|
|
|
|
|
|
if (!is_array($menu)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$items = [];
|
|
|
|
|
|
|
|
|
|
foreach ($menu as $item) {
|
|
|
|
|
// $item indexes (WordPress core):
|
|
|
|
|
// 0 => menu title (may contain HTML), 1 => capability,
|
|
|
|
|
// 2 => menu slug, 4 => classes, 6 => icon
|
|
|
|
|
$raw_title = isset($item[0]) ? $item[0] : "";
|
|
|
|
|
$capability = isset($item[1]) ? $item[1] : "";
|
|
|
|
|
$slug = isset($item[2]) ? $item[2] : "";
|
|
|
|
|
$classes = isset($item[4]) ? $item[4] : "";
|
|
|
|
|
$icon = isset($item[6]) ? $item[6] : "";
|
|
|
|
|
|
|
|
|
|
// Separators have a wp-menu-separator class and no real title.
|
|
|
|
|
if (strpos($classes, "wp-menu-separator") !== false) {
|
|
|
|
|
$items[] = ["separator" => true];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($capability) || !current_user_can($capability)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Aurora's own top-level page is represented in the sidebar's
|
|
|
|
|
// dedicated branded nav section (see Shell::print_mount()'s
|
|
|
|
|
// "auroraNav"), not in this WP-native-menu list — skip it here so it
|
|
|
|
|
// isn't shown twice.
|
|
|
|
|
if ($slug === "aurora-admin") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$title_data = self::clean_title($raw_title);
|
|
|
|
|
|
|
|
|
|
$entry = [
|
|
|
|
|
"id" => $slug,
|
|
|
|
|
"title" => $title_data["title"],
|
|
|
|
|
"url" => self::resolve_url($slug),
|
|
|
|
|
"icon" => self::resolve_icon($icon),
|
|
|
|
|
"count" => $title_data["count"],
|
|
|
|
|
"separator" => false,
|
|
|
|
|
"submenu" => [],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (isset($submenu[$slug]) && is_array($submenu[$slug])) {
|
|
|
|
|
foreach ($submenu[$slug] as $sub) {
|
|
|
|
|
$sub_cap = isset($sub[1]) ? $sub[1] : "";
|
|
|
|
|
if (empty($sub_cap) || !current_user_can($sub_cap)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$sub_title = self::clean_title(isset($sub[0]) ? $sub[0] : "");
|
|
|
|
|
$sub_slug = isset($sub[2]) ? $sub[2] : "";
|
|
|
|
|
|
|
|
|
|
$entry["submenu"][] = [
|
|
|
|
|
"id" => $sub_slug,
|
|
|
|
|
"title" => $sub_title["title"],
|
|
|
|
|
"url" => self::resolve_url($sub_slug, $slug),
|
|
|
|
|
"count" => $sub_title["count"],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$items[] = $entry;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $items;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Strips the markup WordPress packs into menu titles (update/count
|
|
|
|
|
* bubbles) and pulls the numeric count out separately.
|
|
|
|
|
*
|
|
|
|
|
* @return array{title:string,count:int|null}
|
|
|
|
|
*/
|
|
|
|
|
private static function clean_title($raw)
|
|
|
|
|
{
|
|
|
|
|
$count = null;
|
|
|
|
|
|
|
|
|
|
if (preg_match('/<span[^>]*>(\d+)/', $raw, $matches)) {
|
|
|
|
|
$count = (int) $matches[1];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WordPress menu titles are "Label <span ...>count bubble</span>" — the
|
|
|
|
|
// human label is always the plain text before the first tag, so take
|
|
|
|
|
// that rather than stripping tags (which would leave the count digits
|
|
|
|
|
// dangling in the title).
|
|
|
|
|
$label = $raw;
|
|
|
|
|
$first_tag = strpos($raw, "<");
|
|
|
|
|
if ($first_tag !== false) {
|
|
|
|
|
$label = substr($raw, 0, $first_tag);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$title = trim(wp_strip_all_tags($label));
|
|
|
|
|
|
|
|
|
|
return ["title" => $title, "count" => $count];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Builds the admin URL for a menu slug, mirroring how WordPress decides
|
|
|
|
|
* between a direct file (foo.php) and a registered page (admin.php?page=).
|
|
|
|
|
*/
|
|
|
|
|
private static function resolve_url($slug, $parent_slug = "")
|
|
|
|
|
{
|
|
|
|
|
if (empty($slug)) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 11:48:47 -05:00
|
|
|
if ($slug === "plugins.php") {
|
|
|
|
|
return admin_url("plugins.php?page=aurora-admin-plugins");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 02:53:29 -05:00
|
|
|
// Already an absolute URL.
|
|
|
|
|
if (preg_match('#^https?://#', $slug)) {
|
|
|
|
|
return $slug;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A slug pointing at a real admin file, possibly with a query string.
|
|
|
|
|
if (strpos($slug, ".php") !== false) {
|
|
|
|
|
return admin_url($slug);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Submenu of a file-based parent (e.g. edit.php) — the child slug is a
|
|
|
|
|
// file too, handled above; otherwise it's a page registered under that
|
|
|
|
|
// parent's screen, reached via the parent file with ?page=.
|
|
|
|
|
if (!empty($parent_slug) && strpos($parent_slug, ".php") !== false) {
|
|
|
|
|
return admin_url(
|
|
|
|
|
$parent_slug . (strpos($parent_slug, "?") !== false ? "&" : "?") . "page=" . $slug
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return admin_url("admin.php?page=" . $slug);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Normalizes the menu icon into something the Vue side can render:
|
|
|
|
|
* a dashicon class name, an image URL, or null for "use a default".
|
|
|
|
|
*/
|
|
|
|
|
private static function resolve_icon($icon)
|
|
|
|
|
{
|
|
|
|
|
if (empty($icon) || $icon === "none" || $icon === "div") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (strpos($icon, "dashicons-") === 0) {
|
|
|
|
|
return ["type" => "dashicon", "value" => $icon];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (strpos($icon, "data:image") === 0 || preg_match('#^https?://#', $icon)) {
|
|
|
|
|
return ["type" => "image", "value" => $icon];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|