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(
'
',
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 .
* 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 = '';
// 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 '
' . esc_html__( 'The white-label monthly report is available on a paid plan.', 'abds-seo' ) . '
';
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;
}
}