Files
aurora-wordpress-admin/admin/src/Shell/MenuSerializer.php
T
nixpanelandClaude Sonnet 5 56b6674527 Add a real replacement Posts management page (v2.5.0)
Posts was previously just dark-themed skinning over the native
edit.php list table, unlike Comments/Plugins which are genuine
replacements. Built the same way: new PostsData.php REST controller
(list with status/search filters matching native tabs, trash, restore,
permanent delete) and a Vue page matching the Comments layout.

- SubApps.php registers it as aurora-admin-posts, parented under
  edit.php (same pattern as Plugins under plugins.php).
- redirect_native_pages() redirects direct edit.php visits (typed URL,
  not just the sidebar link) to the Aurora page — gated to the default
  post type only, so Pages and other custom post types are untouched.
- MenuSerializer rewrites the sidebar's Posts/All Posts links the same
  way Plugins/Comments already were.
- Gated by Settings > Posts' "Modern posts table" toggle, re-added now
  that it has a real implementation (it was removed a few commits ago
  for describing a feature that didn't exist).
- Fixed modern_plugins/modern_comments/modern_posts all defaulting to
  visually "on" in the Settings UI to match their actual PHP default.

Verified live: trash/restore/status filters/search all work correctly,
edit.php redirects to the Aurora page, edit.php?post_type=page (Pages)
correctly does not.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-05 05:59:11 -05:00

208 lines
6.5 KiB
PHP

<?php
namespace AuroraAdmin\Shell;
use AuroraAdmin\Options\Settings;
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] : "";
// Aurora's own sub-apps (Menu Creator, Comments, etc.) are
// presented in the sidebar's dedicated Aurora nav section
// instead — skip them here so they aren't duplicated. Most are
// parented under "aurora-admin" itself, already skipped above
// wholesale, but "aurora-admin-plugins" is parented under the
// real "plugins.php" menu and needs this explicit check.
if (strpos($sub_slug, "aurora-admin-") === 0) {
continue;
}
$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 "";
}
// Both checks default to true so existing installs keep their current
// behavior (previously always on regardless of these settings) until
// explicitly turned off in Settings > Plugins / Settings > Comments.
if ($slug === "plugins.php" && Settings::get("modern_plugins", true)) {
return admin_url("plugins.php?page=aurora-admin-plugins");
}
if ($slug === "edit-comments.php" && Settings::get("modern_comments", true)) {
return admin_url("admin.php?page=aurora-admin-comments");
}
// "edit.php" (no query string) is specifically the default post type's
// (Posts) list — other post types register as "edit.php?post_type=xyz",
// a different string, so they're untouched.
if ($slug === "edit.php" && Settings::get("modern_posts", true)) {
return admin_url("admin.php?page=aurora-admin-posts");
}
// 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;
}
}