add_action( 'admin_menu', $this, 'add_menu_pages' ); $loader->add_action( 'admin_enqueue_scripts', $this, 'enqueue_assets' ); $loader->add_action( 'enqueue_block_editor_assets', $this, 'enqueue_block_editor_assets' ); $loader->add_action( 'admin_post_abds_seo_save_settings', $this, 'handle_save_settings' ); $loader->add_action( 'admin_post_abds_seo_run_migration', $this, 'handle_run_migration' ); $loader->add_action( 'admin_post_abds_seo_deactivate_source', $this, 'handle_deactivate_source' ); $loader->add_action( 'admin_post_abds_seo_export_csv', $this, 'handle_export_csv' ); $loader->add_action( 'admin_post_abds_seo_export_monthly_csv', $this, 'handle_export_monthly_csv' ); $loader->add_action( 'admin_post_abds_seo_run_content_gaps', $this, 'handle_run_content_gaps' ); $loader->add_action( 'admin_post_abds_seo_gsc_connect', $this, 'handle_gsc_connect' ); $loader->add_action( 'admin_post_abds_seo_gsc_callback', $this, 'handle_gsc_callback' ); $loader->add_action( 'admin_post_abds_seo_run_first_scan', $this, 'handle_run_first_scan' ); $loader->add_action( 'admin_post_abds_seo_run_audit', $this, 'handle_run_audit' ); $loader->add_action( 'admin_init', $this, 'maybe_activation_redirect' ); $loader->add_action( 'admin_notices', $this, 'render_activation_notice' ); $loader->add_action( 'wp_ajax_abds_seo_dismiss_activation_notice', $this, 'handle_dismiss_activation_notice' ); } /** * One-time redirect to the Dashboard right after activation, so a fresh * install actually lands somewhere instead of the plugin going silent — * the Dashboard is also where the existing onboarding wizard auto-opens * (see ABDS_SEO_Onboarding / OnboardingLauncher in src/admin.js), so this * redirect is what makes that wizard reachable at all for a first-time * activation, not a new UI of its own. * * Standard WP pattern: a short-lived transient set on activation, cleared * on first use so it never fires again, and skipped on bulk/network * activation (redirecting N freshly bulk-activated plugins to N different * screens is the classic anti-pattern this guards against). */ public function maybe_activation_redirect(): void { if ( ! get_transient( ABDS_SEO_Activator::ACTIVATION_REDIRECT_TRANSIENT ) ) { return; } delete_transient( ABDS_SEO_Activator::ACTIVATION_REDIRECT_TRANSIENT ); if ( wp_doing_ajax() || isset( $_GET['activate-multi'] ) || is_network_admin() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only presence check (bulk-activation guard), never used as a value and changes no state. return; } if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG ) ); exit; } /** * A single dismissible "just activated" notice pointing at the Dashboard. * Gated on two independent conditions, either of which is enough reason * to stop showing it — no scan having run yet is itself the "still * relevant" check, so a manual dismiss is a second, separate opt-out for * someone who saw it and isn't ready to run a scan yet. */ public function render_activation_notice(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { return; } if ( get_option( 'abds_seo_activation_notice_dismissed' ) ) { return; } if ( $this->has_ever_scanned() ) { return; } printf( '

%1$s %3$s

', esc_html__( 'ABDS SEO is active.', 'abds-seo' ), esc_url( admin_url( 'admin.php?page=' . self::MENU_SLUG ) ), esc_html__( 'Run your first site audit', 'abds-seo' ) ); // Self-contained inline script: this notice can render on ANY // wp-admin screen (admin_notices is global), not just the plugin's // own pages where the abds-seo-admin bundle is enqueued, so it can't // rely on that bundle being present. Persists the dismissal server- // side — core's own "is-dismissible" fade only hides it for the // current pageview, it would otherwise come back on the next load. printf( '', esc_js( wp_create_nonce( 'abds_seo_dismiss_activation_notice' ) ), esc_url( admin_url( 'admin-ajax.php' ) ) ); } /** * Persists the activation notice's dismissal so core's own client-side * "is-dismissible" fade (which only hides it for the current pageview) * doesn't bring it back on the next page load. */ public function handle_dismiss_activation_notice(): void { check_ajax_referer( 'abds_seo_dismiss_activation_notice', 'nonce' ); if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_send_json_error( null, 403 ); } update_option( 'abds_seo_activation_notice_dismissed', 1 ); wp_send_json_success(); } /** * Whether any site scan has ever run — the same detection * dashboard-page.php already uses ($last_scan = latest row or null), * reused here so the activation notice and the Dashboard's own empty * state agree on what "no scan yet" means. * * @return bool */ protected function has_ever_scanned(): bool { global $wpdb; $scans_table = $wpdb->prefix . 'abds_seo_scans'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $scans_table is $wpdb->prefix plus a hardcoded literal, never user input. Runs at most once per admin_notices pass, only until dismissed or a scan exists. return null !== $wpdb->get_var( "SELECT id FROM {$scans_table} LIMIT 1" ); } /** * Handles the Dashboard's "Run your first site audit" button: queues the * exact same scan the recurring schedule and WP-CLI already trigger * (abds_seo_run_site_scan) — no new scanning logic, just a manual trigger * for the existing one. Returns immediately: the action only enqueues * throttled Action Scheduler batches (see ABDS_SEO_Scan_Scheduler:: * run_site_scan()), it doesn't scan inline, so this never risks a * request timeout regardless of site size. */ public function handle_run_first_scan(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_run_first_scan' ); do_action( 'abds_seo_run_site_scan' ); wp_safe_redirect( add_query_arg( 'abds-seo-scan-started', '1', admin_url( 'admin.php?page=' . self::MENU_SLUG ) ) ); exit; } /** * R3 (Audit 2.0): non-JS fallback for starting a background audit run — * the Audit screen's own "Запустити аудит" button normally calls * POST /audit/runs directly and starts polling its live progress (D4), * this admin-post route is what a plain form submit (JS disabled, or the * very first paint before the bundle finishes loading) falls back to. * Gated on AUDIT_CAPABILITY, matching the new "Аудит сайту" menu item * (D3) rather than the general CAPABILITY other admin-post handlers on * this page use. */ public function handle_run_audit(): void { if ( ! current_user_can( ABDS_SEO_Activator::AUDIT_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_run_audit' ); ( new ABDS_SEO_Audit_Scheduler() )->start_audit_run( 'manual', get_current_user_id() ); wp_safe_redirect( add_query_arg( 'abds-seo-audit-started', '1', admin_url( 'admin.php?page=abds-seo-audit' ) ) ); exit; } /** * Starts the GSC OAuth flow: stashes a CSRF state, then redirects the * admin to Google's consent screen. */ public function handle_gsc_connect(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_gsc_connect' ); // GSC integration ships in the ABDS SEO Pro add-on; without it the class // is absent, so bail to settings rather than fatally instantiating it. if ( ! class_exists( 'ABDS_SEO_GSC_Settings' ) ) { wp_safe_redirect( $this->settings_url() ); exit; } $settings = new ABDS_SEO_GSC_Settings(); if ( ! $settings->has_client_credentials() ) { wp_safe_redirect( add_query_arg( 'abds-seo-gsc-error', 'no_credentials', $this->settings_url() ) ); exit; } $state = wp_generate_password( 24, false ); set_transient( 'abds_seo_gsc_state_' . get_current_user_id(), $state, 15 * MINUTE_IN_SECONDS ); wp_redirect( // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- external redirect to Google's OAuth consent endpoint is the whole point; wp_safe_redirect would strip the off-site host. ABDS_SEO_GSC_OAuth::build_auth_url( $settings->get()['client_id'], ABDS_SEO_GSC_Settings::redirect_uri(), $state ) ); exit; } /** * Handles Google's OAuth callback: verifies state, exchanges the code * for tokens, stores them, and returns to the settings page. */ public function handle_gsc_callback(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } // The OAuth callback is a redirect FROM Google, not a form submit, so // it cannot carry a WP nonce. The CSRF guard is the OAuth `state`: // generated in handle_gsc_connect(), stored in a per-user transient, // and compared here with hash_equals — the standard mechanism. // phpcs:disable WordPress.Security.NonceVerification.Recommended $expected = get_transient( 'abds_seo_gsc_state_' . get_current_user_id() ); $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended // GSC integration is a Pro feature; if the add-on is absent there is no // handler class, so bail to settings instead of fatally instantiating it. if ( ! class_exists( 'ABDS_SEO_GSC_Settings' ) ) { wp_safe_redirect( $this->settings_url() ); exit; } if ( ! $expected || ! hash_equals( (string) $expected, $state ) ) { wp_safe_redirect( add_query_arg( 'abds-seo-gsc-error', 'state', $this->settings_url() ) ); exit; } delete_transient( 'abds_seo_gsc_state_' . get_current_user_id() ); if ( '' === $code ) { wp_safe_redirect( add_query_arg( 'abds-seo-gsc-error', 'denied', $this->settings_url() ) ); exit; } $settings = new ABDS_SEO_GSC_Settings(); $tokens = ( new ABDS_SEO_GSC_OAuth() )->exchange_code( $code, $settings->get()['client_id'], $settings->get_client_secret(), ABDS_SEO_GSC_Settings::redirect_uri() ); if ( is_wp_error( $tokens ) ) { wp_safe_redirect( add_query_arg( 'abds-seo-gsc-error', 'exchange', $this->settings_url() ) ); exit; } $settings->store_tokens( $tokens['access_token'], $tokens['refresh_token'], $tokens['expires_in'] ); wp_safe_redirect( add_query_arg( 'abds-seo-gsc-connected', '1', $this->settings_url() ) ); exit; } /** * URL of the plugin settings page. * * @return string */ protected function settings_url(): string { return admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ); } /** * Builds a URL to the migration wizard page, optionally with extra query args. * * @param array $args Query args to append. * @return string */ protected function migration_wizard_url( array $args = array() ): string { return add_query_arg( $args, admin_url( 'admin.php?page=' . self::MENU_SLUG . '-migration' ) ); } /** * Handles the "Перенести дані" form submission on the migration wizard. */ public function handle_run_migration(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_run_migration' ); $source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : ''; $manager = new ABDS_SEO_Migration_Manager(); $adapter = null; foreach ( $manager->get_adapters() as $candidate ) { if ( $candidate->get_slug() === $source ) { $adapter = $candidate; break; } } if ( $adapter ) { $result = $manager->migrate( $adapter ); wp_safe_redirect( $this->migration_wizard_url( array( 'abds-seo-migrated' => '1', 'source' => $source, 'total' => $result['pages_total'], 'migrated' => $result['pages_migrated'], ) ) ); exit; } wp_safe_redirect( $this->migration_wizard_url( array( 'abds-seo-migration-error' => '1' ) ) ); exit; } /** * Handles the "Деактивувати цей плагін" form submission on the migration wizard. */ public function handle_deactivate_source(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_deactivate_source' ); $source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : ''; $manager = new ABDS_SEO_Migration_Manager(); $deactivated = false; $found = false; foreach ( $manager->get_adapters() as $candidate ) { if ( $candidate->get_slug() === $source ) { $found = true; $deactivated = $candidate->deactivate_self(); break; } } if ( $deactivated ) { $redirect_args = array( 'abds-seo-deactivated' => '1' ); } elseif ( $found ) { $redirect_args = array( 'abds-seo-deactivation-error' => '1' ); } else { $redirect_args = array( 'abds-seo-migration-error' => '1' ); } wp_safe_redirect( $this->migration_wizard_url( $redirect_args ) ); exit; } /** * Saves the plain-PHP-form settings (no-JS fallback). Richer settings * (AI provider/BYOK key, added in a later phase) will go through the * REST API from the React settings screen instead. */ public function handle_save_settings(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_save_settings' ); $settings = get_option( ABDS_SEO_Migration_Manager::SETTINGS_OPTION, array() ); $settings['keep_data_on_uninstall'] = ! empty( $_POST['keep_data_on_uninstall'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above via check_admin_referer(). update_option( ABDS_SEO_Migration_Manager::SETTINGS_OPTION, $settings ); $referer = wp_get_referer(); $redirect_url = $referer ? $referer : admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ); wp_safe_redirect( add_query_arg( 'abds-seo-saved', '1', $redirect_url ) ); exit; } /** * Registers the top-level "ABDS SEO" menu and its submenu structure. * R1 (F1.2): Dashboard / SEO Audit (renamed from Reports, per the master * plan's "Reports merges into SEO Audit") / Monthly report / Tools * (renamed from Migration) / Plans (new — the upgrade/pricing panels, * relocated out of Settings by render_plans_page()) / Settings. * R2 (F2.1) adds Search Appearance. Indexing/Schema/Redirects/Analytics * still get no entry — §104: a nav item only exists once its screen is * real, never as a placeholder. "Content SEO" was considered for R1 and * explicitly dropped by the coordinator (no real hub screen yet). * * Capabilities (F1.5) are per-screen, not one shared constant: the * top-level entry itself uses AUDIT_CAPABILITY (the least-restrictive * of the submenu caps below) purely so WordPress shows the parent menu * at all to Editor/Author — each submenu's own capability is what * actually gates that page; WordPress hides a submenu item (and, if * none remain visible, the parent) from a user who doesn't hold it. */ public function add_menu_pages(): void { $this->own_page_hooks[] = add_menu_page( __( 'ABDS SEO', 'abds-seo' ), __( 'ABDS SEO', 'abds-seo' ), ABDS_SEO_Activator::AUDIT_CAPABILITY, self::MENU_SLUG, array( $this, 'render_dashboard_page' ), $this->menu_icon(), 80 ); $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Dashboard', 'abds-seo' ), __( 'Dashboard', 'abds-seo' ), ABDS_SEO_Activator::AUDIT_CAPABILITY, self::MENU_SLUG, array( $this, 'render_dashboard_page' ) ); // R3 (Audit 2.0): a live, on-demand full-site audit — placed // immediately after Dashboard (D3, coordinator decision 28Aug26). // The pre-existing "SEO Audit" page below is a filtered/exportable // content-score REPORT (ABDS_SEO_Reports::query(), CSV export), a // different mental model than "run this and watch it happen" — so // it's relabeled "Reports" (its slug/callback are unchanged) rather // than merged with this new screen, avoiding two menu items that // both say "audit". $this->audit_page_hook = add_submenu_page( self::MENU_SLUG, __( 'Site Audit', 'abds-seo' ), __( 'Site Audit', 'abds-seo' ), ABDS_SEO_Activator::AUDIT_CAPABILITY, self::MENU_SLUG . '-audit', array( $this, 'render_audit_page' ) ); $this->own_page_hooks[] = $this->audit_page_hook; $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Reports', 'abds-seo' ), __( 'Reports', 'abds-seo' ), ABDS_SEO_Activator::AUDIT_CAPABILITY, self::MENU_SLUG . '-reports', array( $this, 'render_reports_page' ) ); $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Monthly report', 'abds-seo' ), __( 'Monthly report', 'abds-seo' ), ABDS_SEO_Activator::AUDIT_CAPABILITY, self::MENU_SLUG . '-monthly', array( $this, 'render_monthly_report_page' ) ); $this->search_appearance_page_hook = add_submenu_page( self::MENU_SLUG, __( 'Search Appearance', 'abds-seo' ), __( 'Search Appearance', 'abds-seo' ), ABDS_SEO_Activator::SETTINGS_CAPABILITY, self::MENU_SLUG . '-search-appearance', array( $this, 'render_search_appearance_page' ) ); $this->own_page_hooks[] = $this->search_appearance_page_hook; $this->indexing_page_hook = add_submenu_page( self::MENU_SLUG, __( 'Indexing', 'abds-seo' ), __( 'Indexing', 'abds-seo' ), ABDS_SEO_Activator::SETTINGS_CAPABILITY, self::MENU_SLUG . '-indexing', array( $this, 'render_indexing_page' ) ); $this->own_page_hooks[] = $this->indexing_page_hook; $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Tools', 'abds-seo' ), __( 'Tools', 'abds-seo' ), ABDS_SEO_Activator::MANAGE_CAPABILITY, self::MENU_SLUG . '-migration', array( $this, 'render_migration_wizard_page' ) ); $this->plans_page_hook = add_submenu_page( self::MENU_SLUG, __( 'Plans', 'abds-seo' ), __( 'Plans', 'abds-seo' ), ABDS_SEO_Activator::MANAGE_CAPABILITY, self::MENU_SLUG . '-plans', array( $this, 'render_plans_page' ) ); $this->own_page_hooks[] = $this->plans_page_hook; $this->settings_page_hook = add_submenu_page( self::MENU_SLUG, __( 'Settings', 'abds-seo' ), __( 'Settings', 'abds-seo' ), ABDS_SEO_Activator::SETTINGS_CAPABILITY, self::MENU_SLUG . '-settings', array( $this, 'render_settings_page' ) ); $this->own_page_hooks[] = $this->settings_page_hook; // F2.5: the noindex-activation review list, reached only from the // activation notice's own link — a null parent slug registers a real // admin page with no nav entry (WordPress's documented technique). // Not a §104 violation: §104 is about nav items never pointing at a // half-built screen, and this page deliberately isn't a nav item. $noindex_review_hook = add_submenu_page( null, __( 'Pages Affected by Noindex/Nofollow Activation', 'abds-seo' ), __( 'Pages Affected by Noindex/Nofollow Activation', 'abds-seo' ), ABDS_SEO_Activator::CAPABILITY, self::MENU_SLUG . '-noindex-activation-review', array( $this, 'render_noindex_activation_review_page' ) ); $this->own_page_hooks[] = $noindex_review_hook; // A null-parent page gets no $submenu entry, so WordPress core's own // get_admin_page_title() can never resolve one for it — admin-header.php // runs (and strip_tags()'s the still-null $title) BEFORE this page's // own render callback ever executes, so setting $GLOBALS['title'] // from inside render_noindex_activation_review_page() would be too // late (caught live: the deprecation notice persisted after trying // exactly that). load-{hook} fires early enough, before admin-header.php. add_action( "load-{$noindex_review_hook}", static function () { $GLOBALS['title'] = __( 'Pages Affected by Noindex/Nofollow Activation', 'abds-seo' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- the documented WP technique for a null-parent add_submenu_page() page to supply its own admin ; core has no other way to derive one for a page with no $submenu entry. } ); } /** * The admin-menu icon as a base64 SVG data URI — a monochrome silhouette * of the block-grid mark (assets/icon.svg), single flat grey fill, the * fourth block's checkmark cut out as negative space via an SVG <mask>. * WordPress does not actually recolor a data-URI menu icon's hue per * admin colour scheme (a data-URI used as a background-image is an * opaque, isolated rendering context — CSS `currentColor`/custom * properties from the page cannot reach inside it); what it does is dim * the icon via `opacity` (muted at rest, full at hover/current), which * is why a single flat grey — not a per-scheme colour swap — is the * correct source colour here. The full-colour blue-yellow logo lives in * assets/icon.svg and is used everywhere WP renders the icon as a real * inline image (Dashboard header) rather than a CSS background-image. * * @return string A data:image/svg+xml;base64 URI. */ private function menu_icon(): string { $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">' . '<mask id="abds-seo-menu-check"><rect x="10.8" y="10.8" width="5.3" height="5.3" rx="1.1" fill="#fff"/>' . '<path d="M12.1 13.5 L13.2 14.6 L15.2 12.1" fill="none" stroke="#000" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></mask>' . '<rect x="3.9" y="3.9" width="5.3" height="5.3" rx="1.1" fill="#a7aaad"/>' . '<rect x="10.8" y="3.9" width="5.3" height="5.3" rx="1.1" fill="#a7aaad"/>' . '<rect x="3.9" y="10.8" width="5.3" height="5.3" rx="1.1" fill="#a7aaad"/>' . '<rect x="10.8" y="10.8" width="5.3" height="5.3" rx="1.1" fill="#a7aaad" mask="url(#abds-seo-menu-check)"/>' . '</svg>'; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- encoding an inline SVG for a menu-icon data URI, not code obfuscation. return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } /** * Cache-busting version for a plugin-relative asset: the file's mtime, so a * CSS edit reaches browsers even though ABDS_SEO_VERSION stays fixed between * releases. Falls back to the plugin version if the file can't be stat'd. * * @param string $relative Path relative to the plugin dir. * @return string */ private function asset_version( string $relative ): string { $mtime = @filemtime( ABDS_SEO_PLUGIN_DIR . $relative ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a missing/unreadable asset just falls back to the plugin version below; no need to surface a warning on every admin page load. return false !== $mtime ? (string) $mtime : ABDS_SEO_VERSION; } /** * Enqueues the plugin's admin CSS/JS, scoped to its own pages only. * * @param string $hook_suffix Current admin page hook suffix. */ public function enqueue_assets( string $hook_suffix ): void { if ( ! in_array( $hook_suffix, $this->own_page_hooks, true ) ) { return; } // Design tokens first, so every other stylesheet (and the React UI) can // reference the CSS custom properties they define. wp_enqueue_style( 'abds-seo-tokens', ABDS_SEO_PLUGIN_URL . 'admin/css/tokens.css', array(), $this->asset_version( 'admin/css/tokens.css' ) ); wp_enqueue_style( 'abds-seo-admin', ABDS_SEO_PLUGIN_URL . 'admin/css/admin.css', array( 'abds-seo-tokens' ), $this->asset_version( 'admin/css/admin.css' ) ); $asset_file = ABDS_SEO_PLUGIN_DIR . 'build/admin.asset.php'; if ( ! file_exists( $asset_file ) ) { return; // Build not run yet; pages still render server-side via partials. } $asset = require $asset_file; wp_enqueue_script( 'abds-seo-admin', ABDS_SEO_PLUGIN_URL . 'build/admin.js', $asset['dependencies'], $asset['version'], true ); wp_set_script_translations( 'abds-seo-admin', 'abds-seo', ABDS_SEO_PLUGIN_DIR . 'languages' ); wp_localize_script( 'abds-seo-admin', 'abdsSeoAdmin', array( 'restUrl' => esc_url_raw( rest_url( ABDS_SEO_Rest_Controller::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'canManage' => current_user_can( ABDS_SEO_Activator::CAPABILITY ), ) ); // The site's effective tier + the presentation copy for every gated // feature, so the UI can show a current-tier badge and turn locked // features into teasers (name/benefit/required tier) rather than hiding // them. Read-only display data; the gate itself is unchanged. $gate = ABDS_SEO_License_Accessor::gate(); $tier = $gate->effective_tier(); wp_localize_script( 'abds-seo-admin', 'abdsSeoLicense', array( 'tier' => $tier, 'tierLabel' => ABDS_SEO_Feature_Presentation::tier_label( $tier ), 'features' => ABDS_SEO_Feature_Presentation::describe( $tier ), 'freeFeatures' => ABDS_SEO_Feature_Presentation::describe_free(), 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ) ), // The license server's own buy page — every upgrade CTA in the // plugin (Upgrade panel, locked-feature teasers) builds a direct // link here with ?tier=/&period=/¤cy= already set, so a // click lands with the tier pre-selected instead of a second // choice screen. Always localized (not plans-screen-only like // abdsSeoPricing below) because locked-feature teasers render on // the Dashboard and SEO Audit screens too. 'buyUrlBase' => 'https://license.abds-seo.com/buy/', ) ); // The optional onboarding wizard's per-user state, so it can decide to // auto-open (only when still pending) without a fetch flash. wp_localize_script( 'abds-seo-admin', 'abdsSeoOnboarding', ( new ABDS_SEO_Onboarding() )->get_state( get_current_user_id() ) ); // Upgrade pricing — ONLY on the Plans screen (F1.2: relocated from // Settings, where the UpgradePanel needing it used to live), never // on every admin page load, and never gated on a license key being // present (this is the one deliberate exception documented in // readme.txt's FAQ: an anonymous, keyless GET to show accurate // prices, cached, falling back to static numbers on any failure — // see ABDS_SEO_Pricing). if ( $hook_suffix === $this->plans_page_hook ) { wp_localize_script( 'abds-seo-admin', 'abdsSeoPricing', array_merge( ( new ABDS_SEO_Pricing() )->get_prices(), array( // The marketing site's general pricing section — kept // only as a secondary "compare plans in detail" link // now; the primary Buy CTA on each tier card goes // straight to the license server's buy page instead // (abdsSeoLicense.buyUrlBase) so a click doesn't land // on a second choice screen. 'compareUrl' => 'https://abds-seo.com/#pricing', ) ) ); } // Search Appearance (R2, F2.1) — its own dedicated bundle, loaded // only on its own page, not folded into build/admin.js (which is // already a large shared bundle covering unrelated concerns — // license, onboarding, premium teasers — loaded on every plugin // admin page). if ( $hook_suffix === $this->search_appearance_page_hook ) { $sa_asset_file = ABDS_SEO_PLUGIN_DIR . 'build/search-appearance.asset.php'; if ( file_exists( $sa_asset_file ) ) { $sa_asset = require $sa_asset_file; wp_enqueue_script( 'abds-seo-search-appearance', ABDS_SEO_PLUGIN_URL . 'build/search-appearance.js', $sa_asset['dependencies'], $sa_asset['version'], true ); wp_set_script_translations( 'abds-seo-search-appearance', 'abds-seo', ABDS_SEO_PLUGIN_DIR . 'languages' ); // The Global tab's organization logo is picked from the media library. wp_enqueue_media(); wp_localize_script( 'abds-seo-search-appearance', 'abdsSeoSearchAppearance', array( 'restUrl' => esc_url_raw( rest_url( ABDS_SEO_REST_Base::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), // Tabs the server considers "real" this release — the // tab bar only ever shows entries in this list (§104: // no dead tab can appear mid-development). Grows by // one per R2 build step. 'tabs' => array( 'global', 'content_types', 'taxonomies', 'archives', 'system' ), ) ); } } // Indexing (R2, F2.6) — same dedicated-bundle reasoning as Search // Appearance above. if ( $hook_suffix === $this->indexing_page_hook ) { $idx_asset_file = ABDS_SEO_PLUGIN_DIR . 'build/indexing.asset.php'; if ( file_exists( $idx_asset_file ) ) { $idx_asset = require $idx_asset_file; wp_enqueue_script( 'abds-seo-indexing', ABDS_SEO_PLUGIN_URL . 'build/indexing.js', $idx_asset['dependencies'], $idx_asset['version'], true ); wp_set_script_translations( 'abds-seo-indexing', 'abds-seo', ABDS_SEO_PLUGIN_DIR . 'languages' ); wp_localize_script( 'abds-seo-indexing', 'abdsSeoIndexing', array( 'restUrl' => esc_url_raw( rest_url( ABDS_SEO_REST_Base::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), // Same §104-enforcing growing list as // abdsSeoSearchAppearance.tabs. 'tabs' => array( 'sitemap', 'robots_txt', 'indexnow', 'redirects' ), ) ); } } // R3 (Audit 2.0) — same dedicated-bundle reasoning as Search // Appearance/Indexing above. if ( $hook_suffix === $this->audit_page_hook ) { $audit_asset_file = ABDS_SEO_PLUGIN_DIR . 'build/audit.asset.php'; if ( file_exists( $audit_asset_file ) ) { $audit_asset = require $audit_asset_file; wp_enqueue_script( 'abds-seo-audit', ABDS_SEO_PLUGIN_URL . 'build/audit.js', $audit_asset['dependencies'], $audit_asset['version'], true ); wp_set_script_translations( 'abds-seo-audit', 'abds-seo', ABDS_SEO_PLUGIN_DIR . 'languages' ); wp_localize_script( 'abds-seo-audit', 'abdsSeoAudit', array( 'restUrl' => esc_url_raw( rest_url( ABDS_SEO_REST_Base::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'settingsUrl' => admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ), ) ); } } } /** * Loads the Gutenberg sidebar bundle only in the block editor, never on * the frontend and never outside the editor screen — and only for users * who hold the content capability, so a role without it (e.g. a plain * Contributor) never loads a panel whose REST calls would just 403. */ public function enqueue_block_editor_assets(): void { if ( ! current_user_can( ABDS_SEO_Activator::CONTENT_CAPABILITY ) ) { return; } $asset_file = ABDS_SEO_PLUGIN_DIR . 'build/index.asset.php'; if ( ! file_exists( $asset_file ) ) { return; } $asset = require $asset_file; wp_enqueue_script( 'abds-seo-editor', ABDS_SEO_PLUGIN_URL . 'build/index.js', $asset['dependencies'], $asset['version'], true ); wp_set_script_translations( 'abds-seo-editor', 'abds-seo', ABDS_SEO_PLUGIN_DIR . 'languages' ); wp_localize_script( 'abds-seo-editor', 'abdsSeoEditor', array( 'restUrl' => esc_url_raw( rest_url( ABDS_SEO_Rest_Controller::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ) ), // See abdsSeoLicense.buyUrlBase's comment in enqueue_assets(): // same direct-to-buy-page link, localized separately here // because the editor bundle is a distinct script handle. 'buyUrlBase' => 'https://license.abds-seo.com/buy/', // 3.3 (multi-keyword): the REAL cap is server-enforced in // ABDS_SEO_Secondary_Keywords::write_cap() regardless of this // value — a stale/tampered client value can't unlock more // than the site is actually licensed for, it can only cause // the UI to attempt (and then have silently capped) a save // it shouldn't have offered. This exists purely so Pro's // FocusKeywordChips can show the right slot count/teaser // instead of letting the user fill in keywords that vanish // on save with no explanation. 'maxSecondaryKeywords' => ABDS_SEO_License_Accessor::gate()->allows( ABDS_SEO_Feature_Map::FEATURE_MULTI_KEYWORD ) ? ABDS_SEO_Content_Analyzer::MAX_SECONDARY_KEYWORDS : 0, ) ); // Same tier/feature data as the admin bundle's own 'abdsSeoLicense' // localize call above -- added 2026-09-23 after a real gap: the Pro // editor bundle used to publish its sidebar sections // (window.abdsSeoProSidebar) unconditionally the moment it loaded, // with no way to tell whether the site's tier actually allows each // feature, so an unlicensed/free-tier site got the real, working // panel instead of the free bundle's own teaser fallback. Pro's // editor.js now reads this the same way admin.js already does via // findFeature(). $editor_gate = ABDS_SEO_License_Accessor::gate(); $editor_tier = $editor_gate->effective_tier(); wp_localize_script( 'abds-seo-editor', 'abdsSeoLicense', array( 'tier' => $editor_tier, 'features' => ABDS_SEO_Feature_Presentation::describe( $editor_tier ), ) ); // Design tokens + the sidebar's own styles inside the block editor, so // the panel picks up the same palette as the standalone admin pages. The // tokens must load first (the sidebar CSS references their variables). wp_enqueue_style( 'abds-seo-tokens', ABDS_SEO_PLUGIN_URL . 'admin/css/tokens.css', array(), $this->asset_version( 'admin/css/tokens.css' ) ); $style_file = ABDS_SEO_PLUGIN_DIR . 'build/style-index.css'; if ( file_exists( $style_file ) ) { wp_enqueue_style( 'abds-seo-editor', ABDS_SEO_PLUGIN_URL . 'build/style-index.css', array( 'abds-seo-tokens' ), $asset['version'] ); } } /** * Renders the dashboard page. Gated on AUDIT_CAPABILITY, matching the * capability add_menu_pages() registered this page's submenu under * (F1.5) — this explicit check is defense-in-depth behind WordPress's * own submenu-capability gate, so the two must always agree or a role * that can see the menu link would hit a dead-end permission wall. */ public function render_dashboard_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::AUDIT_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/dashboard-page.php'; } /** * Renders the settings page. Gated on SETTINGS_CAPABILITY (F1.5), * matching add_menu_pages()'s registration for this submenu. */ public function render_settings_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::SETTINGS_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/settings-page.php'; } /** * Renders the Search Appearance page (R2, F2.1). Gated on * SETTINGS_CAPABILITY, matching add_menu_pages()'s registration — * per D1 (R2 coordinator sign-off), the page itself is menu-gated on * this granular capability even though the REST routes it talks to * still use the legacy CAPABILITY for now. */ public function render_search_appearance_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::SETTINGS_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/search-appearance-page.php'; } /** * Renders the Indexing page (R2, F2.6). Gated on SETTINGS_CAPABILITY, * matching add_menu_pages()'s registration and Search Appearance's own * gate. */ public function render_indexing_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::SETTINGS_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/indexing-page.php'; } /** * Renders the R3 "Site Audit" page. Gated on AUDIT_CAPABILITY, matching * add_menu_pages()'s registration (D3). */ public function render_audit_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::AUDIT_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/audit-page.php'; } /** * Renders the F2.5 noindex-activation review list. Gated on the general * CAPABILITY, matching the notice that links here and * ABDS_SEO_Noindex_Activation_Notice::handle_acknowledge()'s own gate. */ public function render_noindex_activation_review_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/noindex-activation-review-page.php'; } /** * Renders the Plans page (F1.2): the upgrade/pricing panels relocated * out of Settings into their own submenu. Gated on MANAGE_CAPABILITY * (F1.5), matching add_menu_pages()'s registration — a purchase * decision is a site-management action, not a settings tweak, per the * coordinator's R1 sign-off. */ public function render_plans_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::MANAGE_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/plans-page.php'; } /** * Renders the Tools page (migration wizard). Gated on MANAGE_CAPABILITY * (F1.5), matching add_menu_pages()'s registration for this submenu. */ public function render_migration_wizard_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::MANAGE_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/migration-wizard-page.php'; } /** * Renders the SEO Audit page (formerly "Reports"). Gated on * AUDIT_CAPABILITY (F1.5), matching add_menu_pages()'s registration. */ public function render_reports_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::AUDIT_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/reports-page.php'; } /** * Queues the background content-gaps computation from the reports page * form. The page itself only ever reads stored status/results — the * heavy SERP+AI work happens on Action Scheduler's queue, never on an * admin request. */ public function handle_run_content_gaps(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_run_content_gaps' ); // Content-gap analysis is a Pro feature; without the add-on the class is // absent, so bail to reports rather than fatally instantiating it. if ( ! class_exists( 'ABDS_SEO_Content_Gaps' ) ) { wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '-reports' ) ); exit; } $referer = wp_get_referer(); $redirect_url = $referer ? $referer : admin_url( 'admin.php?page=' . self::MENU_SLUG . '-reports' ); // The reports-page form only renders when $gaps_available is true (the // real license gate, since 2026-09-23), but schedule() itself is the // actual enforcement point -- reachable directly via admin-post.php // regardless of what the form rendered, the same class of bypass this // whole audit exists to close. A licensing WP_Error here must not be // treated as success (it used to be -- schedule()'s return value was // simply discarded). $scheduled = ( new ABDS_SEO_Content_Gaps() )->schedule(); if ( is_wp_error( $scheduled ) ) { wp_safe_redirect( add_query_arg( 'abds-seo-gaps-error', rawurlencode( $scheduled->get_error_message() ), $redirect_url ) ); exit; } wp_safe_redirect( add_query_arg( 'abds-seo-gaps-queued', '1', $redirect_url ) ); exit; } /** * Renders the monthly consolidated report, styled for printing so the * browser's "Save as PDF" produces the PDF deliverable without this * plugin shipping a PDF engine (and the font problems that come with * one for non-Latin text). Gated on AUDIT_CAPABILITY (F1.5), matching * add_menu_pages()'s registration for this submenu. */ public function render_monthly_report_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::AUDIT_CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } // The monthly report is a Pro feature (ABDS SEO Pro add-on). Without it // the builder classes are absent, so show a teaser instead of loading the // partial that would instantiate them. if ( ! class_exists( 'ABDS_SEO_Monthly_Report' ) || ! ABDS_SEO_License_Accessor::gate()->allows( ABDS_SEO_Feature_Map::FEATURE_MONTHLY_REPORT ) ) { echo '<div class="wrap abds-seo-wrap"><h1>' . esc_html__( 'ABDS SEO — Monthly report', 'abds-seo' ) . '</h1>'; echo '<p>' . esc_html__( 'The white-label monthly report is available on a paid plan.', 'abds-seo' ) . '</p></div>'; return; } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/monthly-report-page.php'; } /** * Streams the monthly report as CSV — the same structure the print view * renders, from the same builder, so the file and the page can never * disagree. */ public function handle_export_monthly_csv(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_export_monthly_csv' ); // Monthly report is a Pro feature; without the add-on the builder classes // are absent, so bail rather than fatally instantiating them. if ( ! class_exists( 'ABDS_SEO_Monthly_Report' ) || ! ABDS_SEO_License_Accessor::gate()->allows( ABDS_SEO_Feature_Map::FEATURE_MONTHLY_REPORT ) ) { wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '-reports' ) ); exit; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above via check_admin_referer(); the value is re-validated by ABDS_SEO_Monthly_Report_Builder::resolve_period(), which falls back to the last complete month for anything malformed. $period = isset( $_GET['period'] ) ? sanitize_text_field( wp_unslash( $_GET['period'] ) ) : ''; $report = ( new ABDS_SEO_Monthly_Report() )->generate( $period ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="abds-seo-monthly-' . $report['period']['period'] . '.csv"' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://output is a stream handle, not a real file; WP_Filesystem has no equivalent for streaming to the response body. $output = fopen( 'php://output', 'w' ); // A BOM so Excel opens the Cyrillic column headers as UTF-8 rather // than mojibake — the same file is otherwise fine everywhere else. fwrite( $output, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- writing to php://output, see above. foreach ( ABDS_SEO_Monthly_Report_Builder::to_csv_rows( $report ) as $row ) { fputcsv( $output, $row ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the fopen( 'php://output' ) above. fclose( $output ); exit; } /** * Streams the content-score report as a CSV download. Uses the exact * same ABDS_SEO_Reports query the on-screen reports table and the * /reports REST endpoint use, so the CSV always matches what's * currently visible for the same filters — a JSON REST endpoint isn't * used here on purpose: a browser file download from a REST response * needs a Blob/JS round trip for no benefit over the classic * admin-post.php GET-with-nonce pattern WordPress already provides. */ public function handle_export_csv(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_export_csv' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above via check_admin_referer(); every value is re-validated/clamped by ABDS_SEO_Reports::normalize_args() regardless. $args = array( 'date_from' => isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : '', 'date_to' => isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : '', // GET key is 'abds_type', not 'post_type' -- see reports-page.php's // own comment on this same rename for why. 'post_type' => isset( $_GET['abds_type'] ) ? sanitize_key( wp_unslash( $_GET['abds_type'] ) ) : '', 'min_score' => isset( $_GET['min_score'] ) ? sanitize_text_field( wp_unslash( $_GET['min_score'] ) ) : '', 'max_score' => isset( $_GET['max_score'] ) ? sanitize_text_field( wp_unslash( $_GET['max_score'] ) ) : '', 'orderby' => isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : 'content_score', 'order' => isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'asc', ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="abds-seo-report-' . gmdate( 'Y-m-d' ) . '.csv"' ); ( new ABDS_SEO_Reports() )->stream_csv( $args ); exit; } }