403 ) ); } $cleared = \AiRankSuite\Support\Cache::purge(); $back = wp_get_referer() ?: admin_url(); wp_safe_redirect( add_query_arg( 'airanksuite-purged', rawurlencode( implode( ',', $cleared ) ), $back ) ); exit; } ); add_action( 'rest_api_init', array( $this, 'routes' ) ); add_action( 'rest_api_init', function () { register_rest_route( 'airanksuite/v1', '/cache/purge', array( 'methods' => 'POST', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'callback' => static function () { $cleared = \AiRankSuite\Support\Cache::purge(); return new \WP_REST_Response( array( 'cleared' => $cleared, 'detected' => \AiRankSuite\Support\Cache::detect(), ) ); }, ) ); register_rest_route( 'airanksuite/v1', '/cache/status', array( 'methods' => 'GET', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'callback' => static fn() => new \WP_REST_Response( array( 'detected' => \AiRankSuite\Support\Cache::detect() ) ), ) ); // Webhooks: the events catalogue, the subscriptions, a test ping // and the delivery log. Managing settings is the right bar — a // webhook receives the same data the reports show. // A webhook sends site data to an address somebody typed in, so // the plugin's own, delegable capability is not enough here: // WordPress has to agree that this is an administrator. $state = static fn() => new \WP_REST_Response( array( 'events' => \AiRankSuite\Support\Events::catalogue(), 'hooks' => \AiRankSuite\Support\Events::public_hooks(), 'deliveries' => \AiRankSuite\Support\Events::deliveries(), ) ); register_rest_route( 'airanksuite/v1', '/webhooks', array( array( 'methods' => 'GET', 'permission_callback' => static fn() => current_user_can( 'manage_options' ), 'callback' => $state, ), array( 'methods' => 'POST', 'permission_callback' => static fn() => current_user_can( 'manage_options' ), 'callback' => static function ( \WP_REST_Request $r ) use ( $state ) { $saved = \AiRankSuite\Support\Events::save( (array) $r->get_json_params() ); if ( $saved instanceof \WP_Error ) { return $saved; } $out = $state()->get_data(); $out['saved'] = $saved; return new \WP_REST_Response( $out ); }, ), ) ); register_rest_route( 'airanksuite/v1', '/webhooks/(?P[a-f0-9\-]{36})', array( array( 'methods' => 'DELETE', 'permission_callback' => static fn() => current_user_can( 'manage_options' ), 'callback' => static function ( \WP_REST_Request $r ) use ( $state ) { \AiRankSuite\Support\Events::delete( (string) $r['id'] ); return $state(); }, ), ) ); register_rest_route( 'airanksuite/v1', '/webhooks/(?P[a-f0-9\-]{36})/test', array( 'methods' => 'POST', 'permission_callback' => static fn() => current_user_can( 'manage_options' ), 'callback' => static function ( \WP_REST_Request $r ) use ( $state ) { $hook = \AiRankSuite\Support\Events::find( (string) $r['id'] ); if ( ! $hook ) { return new \WP_Error( 'airanksuite_webhook_missing', __( 'No such webhook.', 'ai-rank-suite' ), array( 'status' => 404 ) ); } $result = \AiRankSuite\Support\Events::deliver( $hook, array( 'event' => 'webhook.test', 'site' => home_url( '/' ), 'time' => time(), 'version' => AIRANKSUITE_VERSION, 'data' => array( 'hello' => 'from AI Rank Suite' ), ) ); $out = $state()->get_data(); $out['result'] = $result; return new \WP_REST_Response( $out ); }, ) ); } ); // The status light. Priority 100 puts it to the right of the site name // and the "New" menu rather than in front of them — it is a status, not // a navigation item. add_action( 'admin_bar_menu', array( Services\Toolbar::class, 'render' ), 100 ); add_action( 'admin_enqueue_scripts', array( Services\Toolbar::class, 'assets' ) ); // The front end too: an editor looking at a live page is exactly when // "what does this page score" is the question. add_action( 'wp_enqueue_scripts', array( Services\Toolbar::class, 'assets' ) ); // Anything that changes the answer invalidates the cached one. foreach ( array( 'save_post', 'deleted_post', 'airanksuite/autopilot/tick', 'airanksuite/audit/done' ) as $hook ) { add_action( $hook, array( Services\Pulse::class, 'flush' ) ); } } /** The name on the menu and the header — the agency's, when set. */ public static function brand(): string { $core = Kernel::service( \AiRankSuite\Support\Options::class )->get( 'core', \AiRankSuite\Modules\Settings\SettingsModule::defaults() ); $name = trim( (string) ( $core['brand_name'] ?? '' ) ); return '' !== $name ? $name : __( 'AI Rank Suite', 'ai-rank-suite' ); } /** * No notice but ours renders on this plugin's own pages. * * This was removed once, on the argument that notices above the heading * are how core screens behave — and put back as an explicit product * decision by the owner: the plugin's screens are the product, and a * stack of other plugins' banners on top of them is not. The scope is * the whole trade-off, so it is enforced first: on any page that is not * one of this plugin's six, this method does nothing at all, and update * and security notices appear everywhere WordPress puts them. * * One exception survives on our pages: the module-quarantine notice. * That one says part of this plugin crashed, and hiding it would make a * genuine failure look like a working product. */ public function silence_foreign_notices(): void { if ( ! function_exists( 'get_current_screen' ) ) { return; } $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! str_starts_with( $page, 'ai-rank-suite' ) ) { $screen = get_current_screen(); if ( ! $screen || ! str_contains( (string) $screen->id, 'ai-rank-suite' ) ) { return; } } global $wp_filter; foreach ( array( 'admin_notices', 'all_admin_notices', 'user_admin_notices', 'network_admin_notices' ) as $hook ) { if ( empty( $wp_filter[ $hook ] ) ) { continue; } foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) { foreach ( array_keys( $callbacks ) as $id ) { if ( str_contains( (string) $id, 'quarantine' ) ) { continue; } unset( $wp_filter[ $hook ]->callbacks[ $priority ][ $id ] ); } } } remove_action( 'admin_notices', 'update_nag', 3 ); remove_action( 'network_admin_notices', 'update_nag', 3 ); remove_action( 'admin_notices', 'maintenance_nag', 10 ); } /** * The areas that get a real WordPress submenu. * * A plugin that replaces the admin menu with its own sidebar is a web app * wearing WordPress as a frame; every SEO plugin people already trust — * Rank Math, Yoast, SEOPress, Jetpack — puts its areas in the menu where * WordPress users look for them, with their own URL, their own browser * history and their own bookmark. Each entry opens the same application at * a different route. * * @return array */ /** * The admin menu, and the map the app navigates by. * * One entry per WordPress submenu. Each one owns a *group* of screens — * the submenu opens the group's first screen and the group's other screens * appear as a nav-tab row on the page, which is how every SEO plugin a * WordPress user already owns is built. Six menu items reach all of them; * twenty-six menu items would reach the same screens and bury the rest of * the admin menu doing it. * * `group` matches the group key in the app's screen table (App.jsx), and * the whole map is handed to the browser so a link that crosses a group * lands on the right admin page instead of leaving the menu highlighting * the page the visitor came from. */ public static function screens(): array { $screens = array( 'ai-rank-suite' => array( 'label' => __( 'Dashboard', 'ai-rank-suite' ), 'route' => 'dashboard', 'group' => 'Overview', 'cap' => 'manage_options' ), 'ai-rank-suite-seo' => array( 'label' => __( 'SEO', 'ai-rank-suite' ), 'route' => 'seo', 'group' => 'Search', 'cap' => 'manage_options' ), 'ai-rank-suite-optimize' => array( 'label' => __( 'Optimize', 'ai-rank-suite' ), 'route' => 'content', 'group' => 'Optimize', 'cap' => 'manage_options' ), 'ai-rank-suite-ai' => array( 'label' => __( 'AI search', 'ai-rank-suite' ), 'route' => 'ai-visibility', 'group' => 'AI search', 'cap' => 'manage_options' ), 'ai-rank-suite-measure' => array( 'label' => __( 'Measure', 'ai-rank-suite' ), 'route' => 'measure', 'group' => 'Measure', 'cap' => 'manage_options' ), 'ai-rank-suite-settings' => array( 'label' => __( 'Settings', 'ai-rank-suite' ), 'route' => 'settings', 'group' => 'System', 'cap' => 'manage_options' ), ); // A shop gets its own menu entry. Everything under it existed as tabs // of other screens; a merchant looked for "shop" and found "feeds". if ( class_exists( 'WooCommerce' ) ) { $shop = array( 'ai-rank-suite-shop' => array( 'label' => __( 'Shop', 'ai-rank-suite' ), 'route' => 'shop', 'group' => 'Shop', 'cap' => 'manage_options' ) ); $screens = array_slice( $screens, 0, 3, true ) + $shop + array_slice( $screens, 3, null, true ); } return $screens; } public function add_menu(): void { add_menu_page( self::brand(), self::brand(), 'manage_options', 'ai-rank-suite', array( $this, 'render_app' ), 'data:image/svg+xml;base64,' . base64_encode( '' ), 58 ); // The first submenu shares the parent slug on purpose: WordPress would // otherwise repeat the menu title there, giving "AI Rank Suite → AI // Rank Suite" instead of "→ Dashboard". foreach ( self::screens() as $slug => $screen ) { add_submenu_page( 'ai-rank-suite', $screen['label'], $screen['label'], $screen['cap'], $slug, array( $this, 'render_app' ) ); } /* * The upgrade entry, free edition only. Amber, last in the menu, the * way every freemium SEO plugin a buyer already owns does it — the * convention is the point, because a convention is recognised without * being read. The Pro edition never shows it: advertising an upgrade * to somebody who paid is how review sections fill up with one star. * * It opens a page of this plugin's own. Every redirect this entry ever * tried landed on the WordPress dashboard instead: Freemius' pricing * and account slugs because the SDK registers neither until a customer * has opted in and registered, and the product's own pricing page * because wp_safe_redirect() refuses a host that is not this site and * substitutes admin_url(). See UpgradeScreen for the whole story. */ if ( ! ( defined( 'AIRANKSUITE_IS_PRO' ) && AIRANKSUITE_IS_PRO ) ) { add_submenu_page( 'ai-rank-suite', __( 'Upgrade to Pro', 'ai-rank-suite' ), '' . esc_html__( 'Upgrade to Pro', 'ai-rank-suite' ) . ' →', 'manage_options', UpgradeScreen::SLUG, array( UpgradeScreen::class, 'render' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'upgrade_menu_css' ) ); add_action( 'admin_enqueue_scripts', array( UpgradeScreen::class, 'enqueue' ) ); } } /** * A page of our own for Freemius to take over. * * The SDK replaces whichever menu slug it is given with its licence-key * form for as long as the licence is inactive * (Freemius::override_plugin_menu_with_activation). Pointed at the main * menu that means a customer who has just paid cannot see the product they * bought until they find the email with the key in it — and it contradicts * this plugin's own rule that an inactive licence pauses new work rather * than hiding what is already there. * * So Freemius gets this submenu instead. It renders nothing on its own; * the SDK overrides it, and if Freemius is not configured at all the page * simply sends you to the in-app licence screen. */ public function add_license_page(): void { add_submenu_page( 'ai-rank-suite', __( 'License', 'ai-rank-suite' ), __( 'License', 'ai-rank-suite' ), 'manage_options', 'ai-rank-suite-license', static function (): void { echo '

' . esc_html__( 'License', 'ai-rank-suite' ) . '

' . wp_kses_post( sprintf( /* translators: %s: link to the in-app licence screen. */ __( 'Licensing is managed inside the plugin — open the License tab.', 'ai-rank-suite' ), esc_url( admin_url( 'admin.php?page=ai-rank-suite-settings#/settings/license' ) ) ) ) . '

'; } ); } /** * Put our admin menu back if the Freemius SDK removed it. * * Freemius::_prepare_admin_menu() calls remove_menu_item() when * `false === has_api_connectivity() && ! is_enable_anonymous()`. A * premium-only product has anonymous mode force-disabled by the SDK * (Freemius::parse_settings()), so that condition reduces to "the site * cannot reach api.freemius.com" — true on any host with outbound HTTP * blocked, a strict firewall, or WP_HTTP_BLOCK_EXTERNAL set. * * The observable result without this guard is not a degraded plugin, it is * a hard failure: admin.php?page=ai-rank-suite returns HTTP 500 and * "Cannot load ai-rank-suite." from wp_die(), with the menu entry gone. * Verified by forcing connectivity_test.is_connected = false. * * Mechanically, FS_Admin_Menu_Manager::remove_menu_item() calls * remove_all_actions() on the page's hook rather than deleting the menu * entry, so WordPress' get_plugin_page_hook() finds no callback and * admin.php bails out. Restoring the render callback is therefore the fix; * the top-level entry is only re-added in the network-activation branch * where Freemius does unset it too. * * This only fires when the page is genuinely unreachable. During normal * activation mode Freemius keeps a callback on the hook to draw its * licence-activation screen, so has_action() is true and this is a no-op — * that intended premium-only flow is left alone. * * Hooked at PHP_INT_MAX, which outranks WP_FS__LOWEST_PRIORITY (999999999). */ public function restore_menu_if_removed(): void { global $admin_page_hooks; if ( ! isset( $admin_page_hooks['ai-rank-suite'] ) ) { $this->add_menu(); return; } $hook = get_plugin_page_hookname( 'ai-rank-suite', '' ); if ( $hook && ! has_action( $hook ) ) { add_action( $hook, array( $this, 'render_app' ) ); } } /** Is this admin page one of ours? */ private function is_our_screen( string $hook ): bool { if ( 'toplevel_page_ai-rank-suite' === $hook ) { return true; } foreach ( array_keys( self::screens() ) as $slug ) { if ( str_ends_with( $hook, '_page_' . $slug ) ) { return true; } } return false; } /** Which route the current menu page opens on. */ private function current_route(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : 'ai-rank-suite'; $screens = self::screens(); return (string) ( $screens[ $page ]['route'] ?? 'dashboard' ); } /** The heading WordPress users expect at the top of an admin page. */ private function current_title(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : 'ai-rank-suite'; $screens = self::screens(); return (string) ( $screens[ $page ]['label'] ?? self::brand() ); } /** * A version string that changes whenever the file does. * * Falls back to the release number when the file cannot be stat'd, which * is the old behaviour and never worse than it. */ private static function asset_version( string $file ): string { $stamp = is_readable( $file ) ? (int) filemtime( $file ) : 0; return 0 === $stamp ? AIRANKSUITE_VERSION : AIRANKSUITE_VERSION . '.' . $stamp; } /** * Amber and bold, so the entry reads as an action rather than a screen. * * Attached to a registered handle rather than printed into admin_head: a * printed tag is invisible to the style loader and to anything that * combines or defers stylesheets, which is the reason the directory asks * for enqueues even for a one-line rule. */ public function upgrade_menu_css(): void { \AiRankSuite\Support\Inline::style( 'airanksuite-menu-upgrade', '.airanksuite-menu-upgrade{color:#f0a325;font-weight:600}#adminmenu a:hover .airanksuite-menu-upgrade{color:#ffb84d}' ); } public function render_app(): void { // The shell paints BEFORE the 289 KB bundle arrives: a sidebar and // three card ghosts in pure inline CSS, replaced the moment React // mounts. Without it the first visit to this screen is a white void // for as long as the network takes — which reads as "broken", not // "loading". Self-contained on purpose: no classes from the bundle's // stylesheet, so it cannot flash unstyled when that file is slow too. // A WordPress admin page starts with .wrap and an

. Printing them // from PHP means the page has its title and its frame before a single // byte of JavaScript arrives — the same thing every core screen does, // and the reason a plugin feels native rather than embedded. echo '
'; echo '

' . esc_html( $this->current_title() ) . '

'; echo '
'; echo '
' . '' . '
'; } public function enqueue( string $hook ): void { // Every screen of ours, not only the top-level page: each submenu is a // real admin page with its own hook suffix. if ( ! $this->is_our_screen( $hook ) ) { return; } $build = AIRANKSUITE_DIR . 'assets/admin/build/'; $url = AIRANKSUITE_URL . 'assets/admin/build/'; // Local SEO picks a logo and a photo, and the only picker a WordPress // user already knows how to drive is the media library's own. wp_enqueue_media(); /* * The cache buster is the file, not the release number. * * Two builds shipped as 0.0.5 during one day of fixes, so every * browser that had loaded the first one kept serving it from disk: * same URL, same `?ver=`, no reason to re-fetch. The PHP side updated * and the JavaScript did not, which is the worst possible mixture — * on that install `window.airanksuiteData.modules` correctly said the AI * module is absent while the stale bundle still drew the buttons that * call it. Hashing the file means a rebuilt asset is always a new URL. */ /* * The boot skeleton's rules, enqueued in the head so they are in * force before the markup they style is printed. They used to sit in * a