HEX
Server: LiteSpeed
System: Linux cde4.duelhost.dk 4.18.0-553.34.1.lve.el8.x86_64 #1 SMP Thu Jan 9 16:30:32 UTC 2025 x86_64
User: dmhkjfau (1027)
PHP: 7.4.33
Disabled: exec,system,passthru,shell_exec,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname
Upload Files
File: /home/dmhkjfau/public_html/wp-content/plugins/wp-seopress/src/Actions/Admin/ModuleDashboard.php
<?php // phpcs:ignore

namespace SEOPress\Actions\Admin;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use SEOPress\Core\Hooks\ExecuteHooks;

/**
 * Enqueue the React-powered SEOPress dashboard on the seopress-option page.
 *
 * The dashboard is progressively migrated to React block by block; each
 * server-rendered block is replaced by a section in the React app. Until a
 * given block is migrated the PHP version keeps rendering, so the page is
 * always functional in either state.
 *
 * @since 9.9.0
 */
class ModuleDashboard implements ExecuteHooks {

	/**
	 * Page slug for the main SEOPress dashboard.
	 *
	 * @var string
	 */
	const PAGE_SLUG = 'seopress-option';

	/**
	 * Register hooks.
	 *
	 * Since 9.9.1 the Dashboard is rendered through the unified Settings
	 * React shell (ModuleSettings handles enqueue + localize). This module
	 * keeps the data-building logic so ModuleSettings can call
	 * getDashboardPayload(), plus the outdated-Pro notice surfaced inside
	 * that payload when relevant.
	 *
	 * @return void
	 */
	public function hooks() {
		// Intentionally no admin_enqueue_scripts hook: the unified Settings
		// shell owns the Dashboard mount and enqueue (ModuleSettings).
	}

	/**
	 * Build the localized payload consumed by the Dashboard React component.
	 *
	 * Public so ModuleSettings co-localizes the same payload onto the
	 * unified Settings React bundle on every SEOPress admin page (the
	 * Dashboard section is rendered through SPA navigation).
	 *
	 * @since 9.9.1
	 *
	 * @return array
	 */
	public function getDashboardPayload() {
		return array(
			'REST_URL'        => rest_url(),
			'NONCE'           => wp_create_nonce( 'wp_rest' ),
			'ADMIN_URL'       => admin_url(),
			'ASSETS_URL'      => SEOPRESS_URL_PUBLIC,
			'PLUGIN_URL'      => SEOPRESS_PLUGIN_DIR_URL,
			'FEATURES'        => $this->getFeatures(),
			'GROUPS'          => $this->getFeatureGroups(),
			'INTRO'           => $this->getIntroPayload(),
			'GET_STARTED'     => $this->getGetStartedPayload(),
			'TASKS'           => $this->getTasksPayload(),
			'INTEGRATIONS'    => $this->getIntegrationsPayload(),
			'PROMOTIONS'      => $this->getPromotionsPayload(),
			'OVERVIEW'        => $this->getOverviewPayload(),
			'IMPROVEMENTS'    => $this->getImprovementsPayload(),
			'HIDDEN_BLOCKS'   => $this->getHiddenBlocks(),
			'OUTDATED_PRO'    => $this->getOutdatedProNotice(),
		);
	}

	/**
	 * Build an outdated-PRO notice payload when SEOPress PRO is active but
	 * below the version that knows how to register Site overview tabs into
	 * the unified Settings shell (9.9.1). Lets the Dashboard React render
	 * a "please update PRO" banner so users understand why their Site
	 * overview section is missing.
	 *
	 * @since 9.9.1
	 *
	 * @return array Empty array when no notice is needed.
	 */
	private function getOutdatedProNotice() {
		if ( ! defined( 'SEOPRESS_PRO_VERSION' ) ) {
			return array();
		}

		$pro_version = SEOPRESS_PRO_VERSION;
		// Dev mode placeholder — never considered outdated.
		if ( '{VERSION}' === $pro_version ) {
			return array();
		}

		if ( version_compare( $pro_version, '9.9.1', '>=' ) ) {
			return array();
		}

		return array(
			'title'   => __( 'SEOPress PRO update available', 'wp-seopress' ),
			'message' => sprintf(
				/* translators: %s: current SEOPress PRO version. */
				__( 'You are running SEOPress PRO %s. Please update to 9.9.1 or higher so the Site overview tabs (Google Analytics, Matomo, PageSpeed, Search Console) render correctly in this Dashboard.', 'wp-seopress' ),
				esc_html( $pro_version )
			),
			'ctaUrl'   => admin_url( 'plugins.php' ),
			'ctaLabel' => __( 'Go to Plugins', 'wp-seopress' ),
		);
	}

	/**
	 * Intro section payload (header with plugin name + tagline).
	 *
	 * Mirrors inc/admin/blocks/intro.php including the white-label rules
	 * (constant SEOPRESS_WL_ADMIN_HEADER and the PRO ToggleWhiteLabel +
	 * WhiteLabelListTitle overrides).
	 *
	 * @return array
	 */
	private function getIntroPayload() {
		$visible = ! ( defined( 'SEOPRESS_WL_ADMIN_HEADER' ) && false === SEOPRESS_WL_ADMIN_HEADER );

		$plugin_name = 'SEOPress';
		if ( is_plugin_active( 'wp-seopress-pro/seopress-pro.php' ) ) {
			$toggle_service = seopress_get_service( 'ToggleOption' );
			if ( method_exists( $toggle_service, 'getToggleWhiteLabel' ) && '1' === $toggle_service->getToggleWhiteLabel() ) {
				if ( function_exists( 'seopress_pro_get_service' )
					&& method_exists( seopress_pro_get_service( 'OptionPro' ), 'getWhiteLabelListTitle' )
					&& seopress_pro_get_service( 'OptionPro' )->getWhiteLabelListTitle() ) {
					$plugin_name = (string) seopress_pro_get_service( 'OptionPro' )->getWhiteLabelListTitle();
				}
			}
		}

		// SEOPRESS_VERSION is the literal "{VERSION}" placeholder until the
		// release build script substitutes it. Suppress it in that case so the
		// header doesn't ship a placeholder string to the user.
		$version = SEOPRESS_VERSION;
		if ( '{VERSION}' === $version ) {
			$version = '';
		}

		return array(
			'visible'    => $visible,
			'pluginName' => $plugin_name,
			'version'    => $version,
			'logoUrl'    => SEOPRESS_ASSETS_DIR . '/img/logo-seopress.svg',
			'tagline'    => __( 'Your control center for SEO.', 'wp-seopress' ),
		);
	}

	/**
	 * Get Started section payload.
	 *
	 * Mirrors the two display modes of inc/admin/blocks/get-started.php:
	 *  - "wizard"    : pitch the Setup Wizard to brand-new installs.
	 *  - "checklist" : show recurring SEO reminders once the wizard has
	 *                  been completed.
	 * Hidden entirely when SEOPRESS_WL_ADMIN_HEADER is false.
	 *
	 * @return array
	 */
	private function getGetStartedPayload() {
		$visible = ! ( defined( 'SEOPRESS_WL_ADMIN_HEADER' ) && false === SEOPRESS_WL_ADMIN_HEADER );

		$notice_wizard = (bool) seopress_get_service( 'NoticeOption' )->getNoticeWizard();
		$mode          = $notice_wizard ? 'checklist' : 'wizard';

		$wizard_url = admin_url( 'admin.php?page=seopress-setup' );

		if ( 'checklist' === $mode ) {
			$content = array(
				'badge'    => __( 'SEO Tips', 'wp-seopress' ),
				'title'    => __( 'SEO Checklist', 'wp-seopress' ),
				'subtitle' => __( 'Quick reminders to boost your rankings.', 'wp-seopress' ),
				'items'    => array(
					__( 'Add meta descriptions to your posts', 'wp-seopress' ),
					__( 'Optimize images with alt text', 'wp-seopress' ),
					__( 'Submit your sitemap to Google', 'wp-seopress' ),
					__( 'Check for broken links', 'wp-seopress' ),
				),
				'ctaLabel' => __( 'Run Setup Wizard', 'wp-seopress' ),
				'ctaUrl'   => $wizard_url,
			);
		} else {
			$content = array(
				'badge'    => __( 'Quick Setup', 'wp-seopress' ),
				'title'    => __( 'Rank Higher on Google', 'wp-seopress' ),
				'subtitle' => __( 'Configure your SEO in minutes with our guided wizard.', 'wp-seopress' ),
				'items'    => array(
					__( 'Optimize titles & meta', 'wp-seopress' ),
					__( 'Generate XML sitemaps', 'wp-seopress' ),
					__( 'Connect to Google', 'wp-seopress' ),
				),
				'ctaLabel' => __( 'Start Setup Wizard', 'wp-seopress' ),
				'ctaUrl'   => $wizard_url,
				'helper'   => __( 'Takes only 2 minutes', 'wp-seopress' ),
			);
		}

		return array(
			'visible'   => $visible,
			'mode'      => $mode,
			'content'   => $content,
			'hideNonce' => wp_create_nonce( 'seopress_hide_notices_nonce' ),
		);
	}

	/**
	 * Tasks (SEOPress Suite) section payload.
	 *
	 * Mirrors inc/admin/blocks/tasks.php — status badge + CTA per product,
	 * promo strip when PRO isn't active, white-label guards.
	 *
	 * @return array
	 */
	private function getTasksPayload() {
		$wl_admin = ! ( defined( 'SEOPRESS_WL_ADMIN_HEADER' ) && false === SEOPRESS_WL_ADMIN_HEADER );

		$wl_pro = false;
		if ( is_plugin_active( 'wp-seopress-pro/seopress-pro.php' ) ) {
			$toggle_service = seopress_get_service( 'ToggleOption' );
			if ( method_exists( $toggle_service, 'getToggleWhiteLabel' ) && '1' === $toggle_service->getToggleWhiteLabel() ) {
				$wl_pro = true;
			}
		}

		if ( ! $wl_admin || $wl_pro ) {
			return array( 'visible' => false );
		}

		$docs = function_exists( 'seopress_get_docs_links' ) ? seopress_get_docs_links() : array();

		$status_pro      = 'valid' === get_option( 'seopress_pro_license_status' );
		$status_insights = 'valid' === get_option( 'seopress_insights_license_status' );

		$license_url = admin_url( 'admin.php?page=seopress-license' );

		$definitions = array(
			array(
				'key'        => 'wp-seopress/seopress.php',
				'title'      => 'SEOPress Free',
				'logo'       => SEOPRESS_URL_ASSETS . '/img/logo-seopress-free.svg',
				'url'        => isset( $docs['pricing'] ) ? $docs['pricing'] : '',
				'ctaText'    => __( 'Learn more', 'wp-seopress' ),
				'hasLicense' => false,
				'licenseOk'  => true,
			),
			array(
				'key'        => 'wp-seopress-pro/seopress-pro.php',
				'title'      => 'SEOPress PRO',
				'logo'       => SEOPRESS_URL_ASSETS . '/img/logo-seopress-pro.svg',
				'url'        => isset( $docs['addons']['pro'] ) ? $docs['addons']['pro'] : '',
				'ctaText'    => __( 'Get PRO', 'wp-seopress' ),
				'hasLicense' => true,
				'licenseOk'  => $status_pro,
			),
			array(
				'key'        => 'wp-seopress-insights/seopress-insights.php',
				'title'      => 'SEOPress Insights',
				'logo'       => SEOPRESS_URL_ASSETS . '/img/logo-seopress-insights.svg',
				'url'        => isset( $docs['addons']['insights'] ) ? $docs['addons']['insights'] : '',
				'ctaText'    => __( 'Get Insights', 'wp-seopress' ),
				'hasLicense' => true,
				'licenseOk'  => $status_insights,
			),
		);

		$products = array();
		foreach ( $definitions as $def ) {
			$is_active = is_plugin_active( $def['key'] );
			$status    = $is_active ? 'active' : 'inactive';
			$label     = $is_active ? __( 'Active', 'wp-seopress' ) : __( 'Inactive', 'wp-seopress' );

			if ( $is_active && $def['hasLicense'] ) {
				if ( $def['licenseOk'] ) {
					$label = __( 'License valid', 'wp-seopress' );
				} else {
					$status = 'expired';
					$label  = __( 'License invalid', 'wp-seopress' );
				}
			}

			$products[] = array(
				'key'          => $def['key'],
				'title'        => $def['title'],
				'logo'         => $def['logo'],
				'url'          => $def['url'],
				'ctaText'      => $def['ctaText'],
				'status'       => $status,
				'label'        => $label,
				'showUpsell'   => ! $is_active,
				'showActivate' => $is_active && $def['hasLicense'] && ! $def['licenseOk'],
				'activateUrl'  => $license_url,
			);
		}

		return array(
			'visible'   => true,
			'title'     => __( 'SEOPress Suite', 'wp-seopress' ),
			'subtitle'  => __( 'From on-site to off-site SEO, our SEO plugins cover all your needs to rank higher in search engines.', 'wp-seopress' ),
			'products'  => $products,
			'showPromo' => ! is_plugin_active( 'wp-seopress-pro/seopress-pro.php' ),
			'promoText' => __( 'Unlock AI, redirections, schemas, and more.', 'wp-seopress' ),
			'promoCta'  => __( 'Get PRO', 'wp-seopress' ),
			'promoUrl'  => isset( $docs['addons']['pro'] ) ? $docs['addons']['pro'] : '',
		);
	}

	/**
	 * Integrations section payload.
	 *
	 * Detects every supported third-party plugin / theme that's currently
	 * active on the site, returns the matching titles. Mirrors
	 * inc/admin/blocks/integrations.php.
	 *
	 * @return array
	 */
	private function getIntegrationsPayload() {
		$visible = ! ( defined( 'SEOPRESS_WL_ADMIN_HEADER' ) && false === SEOPRESS_WL_ADMIN_HEADER );
		if ( ! $visible ) {
			return array( 'visible' => false );
		}

		$docs = function_exists( 'seopress_get_docs_links' ) ? seopress_get_docs_links() : array();

		$catalogue = array(
			array( 'astra', 'Astra' ),
			array( 'codepress-admin-columns/codepress-admin-columns.php', 'Admin Columns' ),
			array( 'admin-columns-pro/admin-columns-pro.php', 'Admin Columns PRO' ),
			array( 'advanced-custom-fields/acf.php', 'Advanced Custom Fields' ),
			array( 'advanced-custom-fields-pro/acf.php', 'Advanced Custom Fields PRO' ),
			array( 'amp/amp.php', 'AMP' ),
			array( 'bbpress/bbpress.php', 'bbPress' ),
			array( 'beaver-builder-lite-version/fl-builder.php', 'Beaver Builder Lite' ),
			array( 'bb-plugin/fl-builder.php', 'Beaver Builder Agency' ),
			array( 'bricks', 'Bricks' ),
			array( 'Divi', 'Divi' ),
			array( 'breakdance/plugin.php', 'Breakdance' ),
			array( 'buddypress/bp-loader.php', 'BuddyPress' ),
			array( 'easy-digital-downloads/easy-digital-downloads.php', 'Easy Digital Downloads' ),
			array( 'easy-digital-downloads-pro/easy-digital-downloads.php', 'Easy Digital Downloads PRO' ),
			array( 'elementor/elementor.php', 'Elementor' ),
			array( 'enfold', 'Enfold' ),
			array( 'the-events-calendar/the-events-calendar.php', 'The Events Calendar' ),
			array( 'events-calendar-pro/events-calendar-pro.php', 'The Events Calendar PRO' ),
			array( 'jetpack/jetpack.php', 'Jetpack' ),
			array( 'js_composer/js_composer.php', 'WPBakery Page Builder' ),
			array( 'multilingual-press/multilingual-press.php', 'MultilingualPress' ),
			array( 'oxygen/functions.php', 'Oxygen Builder' ),
			array( 'permalink-manager/permalink-manager.php', 'Permalink Manager' ),
			array( 'permalink-manager-pro/permalink-manager.php', 'Permalink Manager PRO' ),
			array( 'polylang/polylang.php', 'Polylang' ),
			array( 'polylang-pro/polylang.php', 'Polylang PRO' ),
			array( 'sitepress-multilingual-cms/sitepress.php', 'WPML' ),
			array( 'weglot/weglot.php', 'Weglot' ),
			array( 'wp-rocket/wp-rocket.php', 'WP Rocket' ),
			array( 'woocommerce/woocommerce.php', 'WooCommerce' ),
		);

		$theme  = wp_get_theme();
		$active = array();

		foreach ( $catalogue as $entry ) {
			list( $key, $title ) = $entry;

			$is_active = is_plugin_active( $key )
				|| $key === $theme->template
				|| $key === $theme->parent_theme;

			if ( $is_active ) {
				$active[] = array(
					'key'   => (string) $key,
					'title' => (string) $title,
				);
			}
		}

		return array(
			'visible'              => true,
			'title'                => __( 'Integrations', 'wp-seopress' ),
			'subtitle'             => __( 'You\'re using these plugins / themes on your site. We provide advanced integrations with them to improve your SEO.', 'wp-seopress' ),
			'items'                => $active,
			'allIntegrationsUrl'   => isset( $docs['integrations']['all'] ) ? $docs['integrations']['all'] : '',
			'allIntegrationsLabel' => __( 'See all', 'wp-seopress' ),
			'emptyText'            => __( 'Currently, no specific integration found for your site. Contact us if you have any doubts about the compatibility between your plugins/themes and our products.', 'wp-seopress' ),
			'contactUrl'           => isset( $docs['contact'] ) ? $docs['contact'] : '',
			'contactCta'           => __( 'Request an integration', 'wp-seopress' ),
		);
	}

	/**
	 * Promotions section payload (Recommended Partners — affiliates).
	 *
	 * Mirrors inc/admin/blocks/promotions.php. Affiliate brand colors come
	 * straight from the API and are passed through to React unchanged — the
	 * rest of the chrome stays on the WP admin theme color.
	 *
	 * @return array
	 */
	private function getPromotionsPayload() {
		$wl_admin = ! ( defined( 'SEOPRESS_WL_ADMIN_HEADER' ) && false === SEOPRESS_WL_ADMIN_HEADER );

		$wl_pro = false;
		if ( is_plugin_active( 'wp-seopress-pro/seopress-pro.php' ) ) {
			$toggle_service = seopress_get_service( 'ToggleOption' );
			if ( method_exists( $toggle_service, 'getToggleWhiteLabel' ) && '1' === $toggle_service->getToggleWhiteLabel() ) {
				$wl_pro = true;
			}
		}

		if ( ! $wl_admin || $wl_pro ) {
			return array( 'visible' => false );
		}

		$affiliates = array();
		$service    = seopress_get_service( 'PromotionService' );
		if ( method_exists( $service, 'getAffiliatePartners' ) ) {
			$affiliates = $service->getAffiliatePartners();
		}

		if ( empty( $affiliates ) ) {
			return array( 'visible' => false );
		}

		$items = array();
		foreach ( $affiliates as $affiliate ) {
			$items[] = array(
				'id'              => isset( $affiliate['id'] ) ? (string) $affiliate['id'] : '',
				'name'            => isset( $affiliate['name'] ) ? (string) $affiliate['name'] : '',
				'description'     => isset( $affiliate['description'] ) ? (string) $affiliate['description'] : '',
				'url'             => isset( $affiliate['url'] ) ? (string) $affiliate['url'] : '',
				'icon'            => isset( $affiliate['icon'] ) ? (string) $affiliate['icon'] : '',
				'logoUrl'         => isset( $affiliate['logo_url'] ) ? (string) $affiliate['logo_url'] : '',
				'backgroundColor' => isset( $affiliate['styling']['background_color'] ) ? (string) $affiliate['styling']['background_color'] : '',
				'textColor'       => isset( $affiliate['styling']['text_color'] ) ? (string) $affiliate['styling']['text_color'] : '',
			);
		}

		return array(
			'visible'  => true,
			'title'    => __( 'Recommended Partners', 'wp-seopress' ),
			'subtitle' => __( 'Trusted tools to enhance your website performance.', 'wp-seopress' ),
			'ctaLabel' => __( 'Learn more', 'wp-seopress' ),
			'items'    => $items,
		);
	}

	/**
	 * Site overview payload.
	 *
	 * The free plugin only renders the chrome (title, period selector, tab
	 * strip, body). PRO injects tab renderers through the React extension
	 * registry (`registerOverviewTab`). The free section stays hidden
	 * until at least one tab is registered.
	 *
	 * @return array
	 */
	private function getOverviewPayload() {
		return array(
			'title' => __( 'Site overview', 'wp-seopress' ),
		);
	}

	/**
	 * Whether a dashboard block was hidden through the header "Display"
	 * panel.
	 *
	 * Reads the exact pre-existing SEOPress options DashboardDisplayPreference
	 * persists to — no new option is introduced: the notice toggles live in
	 * `seopress_notices`, the two appearance toggles in
	 * `seopress_advanced_option_name`. A stored '1' means "hide".
	 *
	 * @param string $store 'notice' for seopress_notices, anything else for
	 *                      seopress_advanced_option_name.
	 * @param string $key   The option array key.
	 * @return bool
	 */
	private function isBlockHidden( $store, $key ) {
		$option = 'notice' === $store ? 'seopress_notices' : 'seopress_advanced_option_name';
		$values = get_option( $option, array() );

		return is_array( $values ) && isset( $values[ $key ] ) && '1' === (string) $values[ $key ];
	}

	/**
	 * Per-section "hidden by the Display panel" state.
	 *
	 * Keyed by the React section id consumed by the useBlockHidden() hook.
	 * Values are read from the same pre-existing SEOPress options the
	 * DashboardDisplayPreference REST route writes to, so toggling a
	 * preference flips the matching block live without a page reload.
	 *
	 * @return array<string,bool>
	 */
	private function getHiddenBlocks() {
		return array(
			'get-started'   => $this->isBlockHidden( 'notice', 'notice-get-started' ),
			'tasks'         => $this->isBlockHidden( 'notice', 'notice-tasks' ),
			'integrations'  => $this->isBlockHidden( 'notice', 'notice-integrations' ),
			// Promotions (Recommended Partners) are always visible — users can no
			// longer hide them. The old `notice-promotions` option is ignored.
			'promotions'    => false,
			'site-overview' => $this->isBlockHidden( 'advanced', 'seopress_advanced_appearance_seo_tools' ),
			'notifications' => $this->isBlockHidden( 'advanced', 'seopress_advanced_appearance_notifications' ),
		);
	}

	/**
	 * SEO Improvements payload.
	 *
	 * Maps the existing Notifications service output to a flat list the
	 * React section can render as an accordion. We keep the impact key so
	 * the UI can color the badge dot, and pass through the action link so
	 * each tip ships a "Fix now" CTA when relevant.
	 *
	 * @return array
	 */
	private function getImprovementsPayload() {
		$visible = ! ( defined( 'SEOPRESS_WL_ADMIN_HEADER' ) && false === SEOPRESS_WL_ADMIN_HEADER );
		if ( ! $visible ) {
			return array( 'visible' => false );
		}

		$service = seopress_get_service( 'Notifications' );
		if ( ! method_exists( $service, 'generateAllNotifications' ) ) {
			return array( 'visible' => false );
		}

		$generated = $service->generateAllNotifications();
		if ( ! is_array( $generated ) ) {
			return array( 'visible' => false );
		}

		$raw = array();
		foreach ( $generated as $key => $entry ) {
			// `generateAllNotifications()` appends an `$args['impact']`
			// summary at the end of the array (counts per severity). It
			// is not a notification — skip it explicitly so it doesn't
			// leak into the React list as an empty card.
			if ( 'impact' === $key ) {
				continue;
			}
			if ( ! is_array( $entry ) ) {
				continue;
			}
			if ( isset( $entry['status'] ) && false === $entry['status'] ) {
				continue;
			}
			if ( empty( $entry['title'] ) ) {
				continue;
			}
			$raw[] = $entry;
		}

		if ( method_exists( $service, 'orderByImpact' ) ) {
			$raw = $service->orderByImpact( $raw );
		}

		$items = array();
		foreach ( $raw as $entry ) {
			$impact_key = '';
			if ( ! empty( $entry['impact'] ) && is_array( $entry['impact'] ) ) {
				$impact_key = (string) array_key_first( $entry['impact'] );
			}

			$link_url      = '';
			$link_label    = '';
			$link_external = false;
			if ( ! empty( $entry['link'] ) && is_array( $entry['link'] ) ) {
				$link_url      = isset( $entry['link']['en'] ) ? (string) $entry['link']['en'] : '';
				$link_label    = isset( $entry['link']['title'] ) ? (string) $entry['link']['title'] : '';
				$link_external = ! empty( $entry['link']['external'] );
			}

			$items[] = array(
				'id'           => isset( $entry['id'] ) ? sanitize_key( $entry['id'] ) : '',
				// React renders title/desc via dangerouslySetInnerHTML so they can
				// keep inline formatting (links, <strong>). wp_kses_post() at the
				// boundary closes the XSS path without changing the rendered look.
				'title'        => isset( $entry['title'] ) ? wp_kses_post( (string) $entry['title'] ) : '',
				'desc'         => isset( $entry['desc'] ) ? wp_kses_post( (string) $entry['desc'] ) : '',
				'impact'       => $impact_key,
				'linkUrl'      => esc_url_raw( $link_url ),
				'linkLabel'    => sanitize_text_field( $link_label ),
				'linkExternal' => (bool) $link_external,
				'deleteable'   => ! empty( $entry['deleteable'] ),
			);
		}

		return array(
			'visible'   => true,
			'title'     => __( 'SEO Improvements needed', 'wp-seopress' ),
			'subtitle'  => __( 'Quick reminders to boost your rankings.', 'wp-seopress' ),
			'items'     => $items,
			'ajaxUrl'   => admin_url( 'admin-ajax.php' ),
			'hideNonce' => wp_create_nonce( 'seopress_hide_notices_nonce' ),
		);
	}

	/**
	 * Default group definitions for the dashboard features list.
	 *
	 * Extensions can add or rename groups via the `seopress_features_list_groups`
	 * filter. Each entry must declare an `id` and `label`.
	 *
	 * @return array
	 */
	private function getFeatureGroups() {
		$groups = array(
			array(
				'id'    => 'content',
				'label' => __( 'Content', 'wp-seopress' ),
			),
			array(
				'id'    => 'technical',
				'label' => __( 'Technical', 'wp-seopress' ),
			),
			array(
				'id'    => 'data-tracking',
				'label' => __( 'Data & Tracking', 'wp-seopress' ),
			),
		);

		return apply_filters( 'seopress_features_list_groups', $groups );
	}

	/**
	 * Default group per built-in feature.
	 *
	 * @return array
	 */
	private function getDefaultGroupMap() {
		return array(
			'titles'           => 'content',
			'social'           => 'content',
			'advanced'         => 'content',
			'xml-sitemap'      => 'technical',
			'instant-indexing' => 'technical',
			'tools'            => 'technical',
			'google-analytics' => 'data-tracking',
		);
	}

	/**
	 * Whether the current admin screen is the SEOPress dashboard.
	 *
	 * @return bool
	 */
	private function isDashboardPage() {
		if ( ! is_admin() ) {
			return false;
		}

		if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return false;
		}

		return self::PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
	}

	/**
	 * Build the feature list payload consumed by the React FeaturesList section.
	 *
	 * Mirrors the data assembled by inc/admin/blocks/features-list.php so that
	 * extensions hooking into seopress_features_list_before_tools and
	 * seopress_features_list_after_tools (notably SEOPress PRO) keep working.
	 *
	 * @return array
	 */
	private function getFeatures() {
		$features = array(
			'titles'           => array(
				'svg'         => SEOPRESS_URL_ASSETS . '/img/ico-titles-metas.svg',
				'title'       => __( 'Titles & Metas', 'wp-seopress' ),
				'desc'        => __( 'Manage all your titles & metas for post types, taxonomies, archives...', 'wp-seopress' ),
				'btn_primary' => admin_url( 'admin.php?page=seopress-titles' ),
				'filter'      => 'seopress_remove_feature_titles',
			),
			'xml-sitemap'      => array(
				'svg'         => SEOPRESS_URL_ASSETS . '/img/ico-sitemaps.svg',
				'title'       => __( 'XML & HTML Sitemaps', 'wp-seopress' ),
				'desc'        => __( 'Manage your XML - Image - Video - HTML Sitemap.', 'wp-seopress' ),
				'btn_primary' => admin_url( 'admin.php?page=seopress-xml-sitemap' ),
				'filter'      => 'seopress_remove_feature_xml_sitemap',
			),
			'social'           => array(
				'svg'         => SEOPRESS_URL_ASSETS . '/img/ico-social-networks.svg',
				'title'       => __( 'Social Networks', 'wp-seopress' ),
				'desc'        => __( 'Open Graph, X Cards, Google Knowledge Graph and more...', 'wp-seopress' ),
				'btn_primary' => admin_url( 'admin.php?page=seopress-social' ),
				'filter'      => 'seopress_remove_feature_social',
			),
			'google-analytics' => array(
				'svg'         => SEOPRESS_URL_ASSETS . '/img/ico-analytics.svg',
				'title'       => __( 'Analytics', 'wp-seopress' ),
				'desc'        => __( 'Track everything about your visitors with Google Analytics / Matomo / Microsoft Clarity.', 'wp-seopress' ),
				'btn_primary' => admin_url( 'admin.php?page=seopress-google-analytics' ),
				'filter'      => 'seopress_remove_feature_google_analytics',
			),
			'instant-indexing' => array(
				'svg'         => SEOPRESS_URL_ASSETS . '/img/ico-instant-indexing.svg',
				'title'       => __( 'Instant Indexing', 'wp-seopress' ),
				'desc'        => __( 'Ping Google & Bing to quickly index your content.', 'wp-seopress' ),
				'btn_primary' => admin_url( 'admin.php?page=seopress-instant-indexing' ),
				'filter'      => 'seopress_remove_feature_instant_indexing',
			),
			'advanced'         => array(
				'svg'         => SEOPRESS_URL_ASSETS . '/img/ico-advanced.svg',
				'title'       => __( 'Image SEO & Advanced settings', 'wp-seopress' ),
				'desc'        => __( 'Optimize your images for SEO. Configure advanced settings.', 'wp-seopress' ),
				'btn_primary' => admin_url( 'admin.php?page=seopress-advanced' ),
				'filter'      => 'seopress_remove_feature_advanced',
			),
		);

		$features = apply_filters( 'seopress_features_list_before_tools', $features );

		$features['tools'] = array(
			'svg'         => SEOPRESS_URL_ASSETS . '/img/ico-tools.svg',
			'title'       => __( 'Tools', 'wp-seopress' ),
			'desc'        => __( 'Import/Export plugin settings from site to site.', 'wp-seopress' ),
			'btn_primary' => admin_url( 'admin.php?page=seopress-import-export' ),
			'filter'      => 'seopress_remove_feature_tools',
			'toggle'      => false,
		);

		$features = apply_filters( 'seopress_features_list_after_tools', $features );

		$group_map = $this->getDefaultGroupMap();
		$payload   = array();

		foreach ( $features as $key => $value ) {
			if ( isset( $value['filter'] ) ) {
				$keep = apply_filters( $value['filter'], true );
				if ( true !== $keep ) {
					continue;
				}
			}

			$toggle  = isset( $value['toggle'] ) ? (bool) $value['toggle'] : true;
			$enabled = $toggle ? '1' === (string) seopress_get_toggle_option( $key ) : true;

			// Extensions can override the group per feature by setting a `group`
			// key on the feature array; fall back to the built-in map, and to
			// "content" for anything still unassigned.
			$group = isset( $value['group'] ) ? (string) $value['group'] : '';
			if ( '' === $group && isset( $group_map[ $key ] ) ) {
				$group = $group_map[ $key ];
			}
			if ( '' === $group ) {
				$group = 'content';
			}

			$payload[] = array(
				'key'         => (string) $key,
				'title'       => isset( $value['title'] ) ? (string) $value['title'] : '',
				'description' => isset( $value['desc'] ) ? (string) $value['desc'] : '',
				'iconUrl'     => isset( $value['svg'] ) ? (string) $value['svg'] : '',
				'btnPrimary'  => isset( $value['btn_primary'] ) ? (string) $value['btn_primary'] : '',
				'hasToggle'   => $toggle,
				'enabled'     => $enabled,
				'group'       => $group,
			);
		}

		return $this->orderPayload( $payload );
	}

	/**
	 * Explicit display order of features inside each category.
	 *
	 * The free plugin only owns the order of its own keys; PRO replaces the
	 * whole sequence (free + PRO keys interleaved) through the
	 * `seopress_features_list_order` filter. Keys absent from a group's list
	 * fall to the end of that group, in their original insertion order.
	 *
	 * @return array<string,string[]> Ordered feature keys keyed by group id.
	 */
	private function getFeatureOrder() {
		$order = array(
			'content'       => array( 'titles', 'advanced', 'social' ),
			'technical'     => array( 'instant-indexing', 'xml-sitemap', 'tools' ),
			'data-tracking' => array( 'google-analytics' ),
		);

		return apply_filters( 'seopress_features_list_order', $order );
	}

	/**
	 * Sort the payload by category, then by the explicit per-group order,
	 * with a stable fallback on the original insertion index so unlisted
	 * keys keep a deterministic position at the end of their group.
	 *
	 * @param array $payload The unsorted feature payload.
	 * @return array The sorted feature payload.
	 */
	private function orderPayload( $payload ) {
		$order      = $this->getFeatureOrder();
		$group_rank = array();
		foreach ( array_values( $this->getFeatureGroups() ) as $gi => $g ) {
			if ( isset( $g['id'] ) ) {
				$group_rank[ $g['id'] ] = $gi;
			}
		}

		foreach ( $payload as $i => $unused ) {
			$payload[ $i ]['_idx'] = $i;
		}

		usort(
			$payload,
			function ( $a, $b ) use ( $order, $group_rank ) {
				$ga = isset( $group_rank[ $a['group'] ] ) ? $group_rank[ $a['group'] ] : PHP_INT_MAX;
				$gb = isset( $group_rank[ $b['group'] ] ) ? $group_rank[ $b['group'] ] : PHP_INT_MAX;
				if ( $ga !== $gb ) {
					return $ga <=> $gb;
				}

				$la = isset( $order[ $a['group'] ] ) ? $order[ $a['group'] ] : array();
				$lb = isset( $order[ $b['group'] ] ) ? $order[ $b['group'] ] : array();
				$pa = array_search( $a['key'], $la, true );
				$pb = array_search( $b['key'], $lb, true );
				$pa = ( false === $pa ) ? PHP_INT_MAX : $pa;
				$pb = ( false === $pb ) ? PHP_INT_MAX : $pb;
				if ( $pa !== $pb ) {
					return $pa <=> $pb;
				}

				return $a['_idx'] <=> $b['_idx'];
			}
		);

		foreach ( $payload as $i => $unused ) {
			unset( $payload[ $i ]['_idx'] );
		}

		return $payload;
	}
}