%3$s ',
esc_attr( $id ),
esc_attr__( 'More info', 'acceguru' ),
esc_html( $text )
);
}
/** @var string Hook suffix for the dashboard page. */
private string $dashboard_hook = '';
/** @var string Hook suffix for the settings page. */
private string $settings_hook = '';
/** @var string Hook suffix for the bulk alt-text page. */
private string $bulk_alt_hook = '';
/** @var string Hook suffix for the accessibility statement page. */
private string $statement_hook = '';
/**
* Constructor - registers all hooks.
*/
public function __construct() {
add_action( 'admin_menu', array( $this, 'register_menus' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_init', array( $this, 'maybe_create_statement_page' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_media_alt_assets' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_chrome' ) );
add_action( 'admin_init', array( $this, 'maybe_set_install_time' ) );
add_action( 'admin_init', array( $this, 'handle_review_notice_action' ) );
add_action( 'wp_dashboard_setup', array( $this, 'register_dashboard_widget' ) );
}
/**
* Register a dashboard widget on the WP Home screen with a brief
* accessibility scan summary.
*
* @return void
*/
public function register_dashboard_widget(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
wp_add_dashboard_widget(
'acceguru_dashboard_widget',
esc_html( sprintf(
/* translators: %s: product name, "AcceGuru" or "AcceGuru PRO" */
__( '%s - Accessibility Status', 'acceguru' ),
acceguru_brand_name()
) ),
array( $this, 'render_dashboard_widget' )
);
}
/**
* Render the dashboard widget content.
*
* @return void
*/
public function render_dashboard_widget(): void {
$summary = get_option( 'acceguru_site_scan_summary', array() );
$pages_scanned = is_array( $summary ) ? (int) ( $summary['pages_scanned'] ?? 0 ) : 0;
$dash_url = admin_url( 'admin.php?page=acceguru' );
if ( ! empty( $summary ) && is_array( $summary ) && ! Acceguru_Scanner::summary_is_current( $summary ) ) {
echo '' . esc_html__( 'Saved scan results use older rules or settings. Run a new full-site scan to refresh them.', 'acceguru' ) . '
';
echo '' . esc_html( sprintf( /* translators: %s: product name, "AcceGuru" or "AcceGuru PRO" */ __( 'Open %s Dashboard', 'acceguru' ), acceguru_brand_name() ) ) . '
';
return;
}
if ( $pages_scanned <= 0 ) {
echo '' . esc_html__( 'No site scan yet. Run your first full-site scan to see accessibility issues here.', 'acceguru' ) . '
';
echo '' . esc_html( sprintf( /* translators: %s: product name, "AcceGuru" or "AcceGuru PRO" */ __( 'Open %s Dashboard', 'acceguru' ), acceguru_brand_name() ) ) . '
';
return;
}
$data = $this->build_dashboard_data();
$totals = $data['totals'] ?? array();
$bpt = isset( $totals['by_post_type'] ) && is_array( $totals['by_post_type'] ) ? $totals['by_post_type'] : array();
$total_issues = (int) ( $totals['total'] ?? 0 );
$by_impact = $totals['by_impact'] ?? array();
// Same date, and the same full/cancelled rule, as the plugin dashboard
// page. Every scan save writes 'scan_date', so the line always appears;
// the old separate key was only ever written by a summary recompute.
$last_scan_raw = isset( $summary['scan_date'] ) ? (string) $summary['scan_date'] : '';
$last_scan_ts = '' !== $last_scan_raw ? strtotime( $last_scan_raw ) : false;
$last_scan = $last_scan_ts ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $last_scan_ts ) : $last_scan_raw;
$scan_partial = ! empty( $summary['partial'] );
?>
·
$row ) :
$pt_obj = get_post_type_object( $pt );
$label = $pt_obj && ! empty( $pt_obj->labels->name ) ? $pt_obj->labels->name : $pt;
?>
()
time() ) {
return false;
}
}
$installed = (int) get_option( 'acceguru_install_time', 0 );
if ( ! $installed || ( time() - $installed ) < ( 5 * DAY_IN_SECONDS ) ) {
return false;
}
// Require at least one site scan so we don't pester users on day 5
// who never tried the main feature.
$summary = get_option( 'acceguru_site_scan_summary', array() );
if ( empty( $summary['pages_scanned'] ) ) {
return false;
}
return true;
}
/**
* Render the review-prompt card. Caller decides where to place it.
*
* @return void
*/
public static function render_review_notice(): void {
if ( ! self::should_show_review_notice() ) {
return;
}
$base_url = admin_url( 'admin.php?page=acceguru' );
$snooze_url = wp_nonce_url( add_query_arg( 'acceguru_review_action', 'snooze', $base_url ), 'acceguru_review_notice' );
$never_url = wp_nonce_url( add_query_arg( 'acceguru_review_action', 'dismiss', $base_url ), 'acceguru_review_notice' );
$review_url = 'https://wordpress.org/support/plugin/acceguru/reviews/';
?>
.wp-has-submenu,
#adminmenu #toplevel_page_acceguru > a.menu-top {
background-color: #2FBE8B !important;
color: #134B87 !important;
font-weight: 700;
transition: background-color .15s ease, color .15s ease;
}
#adminmenu #toplevel_page_acceguru > a.menu-top .wp-menu-name {
color: #134B87 !important;
}
#adminmenu #toplevel_page_acceguru:hover > a.menu-top,
#adminmenu #toplevel_page_acceguru.opensub > a.menu-top,
#adminmenu #toplevel_page_acceguru.wp-has-current-submenu > a.menu-top,
#adminmenu #toplevel_page_acceguru.current > a.menu-top {
background-color: #134B87 !important;
color: #2FBE8B !important;
}
#adminmenu #toplevel_page_acceguru:hover > a.menu-top .wp-menu-name,
#adminmenu #toplevel_page_acceguru.opensub > a.menu-top .wp-menu-name,
#adminmenu #toplevel_page_acceguru.wp-has-current-submenu > a.menu-top .wp-menu-name,
#adminmenu #toplevel_page_acceguru.current > a.menu-top .wp-menu-name {
color: #2FBE8B !important;
}
';
}
// -------------------------------------------------------------------------
// Menus
// -------------------------------------------------------------------------
/**
* Registers top-level menu and submenus.
*/
public function register_menus(): void {
// The side menu names the edition too, so the whole admin reads the
// same way: "AcceGuru" on its own, "AcceGuru PRO" with a licence.
$this->dashboard_hook = (string) add_menu_page(
esc_html( acceguru_brand_name() ),
esc_html( acceguru_brand_name() ),
'manage_options',
'acceguru',
array( $this, 'render_dashboard_page' ),
ACCEGURU_URL . 'assets/images/icon.png',
80
);
add_submenu_page(
'acceguru',
esc_html__( 'Dashboard', 'acceguru' ),
esc_html__( 'Dashboard', 'acceguru' ),
'manage_options',
'acceguru',
array( $this, 'render_dashboard_page' )
);
$this->settings_hook = (string) add_submenu_page(
'acceguru',
esc_html__( 'Settings', 'acceguru' ),
esc_html__( 'Settings', 'acceguru' ),
'manage_options',
'acceguru-settings',
array( $this, 'render_settings_page' )
);
// Bulk Alt Text and Statement now live as tabs of the Settings
// screen. The render_*_page methods below remain so the
// settings-page.php tab includes can call them.
// "Upgrade to Pro" submenu, shown only while the pro add-on is absent.
// The slug is a normal internal page name: WordPress treats a menu slug
// as a file path when it looks like one, and an external address there
// makes hosts with a path restriction print a warning. The entry
// forwards to the pricing page as soon as it loads.
if ( ! acceguru_is_pro() ) {
$upgrade_hook = (string) add_submenu_page(
'acceguru',
esc_html__( 'Upgrade to Pro', 'acceguru' ),
'' . esc_html__( 'Upgrade to Pro', 'acceguru' ) . ' ',
'manage_options',
'acceguru-upgrade',
array( $this, 'render_upgrade_page' )
);
if ( '' !== $upgrade_hook ) {
add_action( 'load-' . $upgrade_hook, array( $this, 'redirect_to_pricing' ) );
}
}
}
/**
* Sends the "Upgrade to Pro" menu entry to the pricing page before the
* admin screen renders anything.
*/
public function redirect_to_pricing(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
wp_redirect( esc_url_raw( acceguru_pricing_url() ) ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Deliberate exit to the plugin's own pricing page.
exit;
}
/**
* Fallback body for the "Upgrade to Pro" screen. The redirect above runs
* first, so this is only reached if headers were already sent.
*/
public function render_upgrade_page(): void {
printf(
'',
esc_url( acceguru_pricing_url() ),
esc_html__( 'Upgrade to Pro', 'acceguru' )
);
}
// -------------------------------------------------------------------------
// Settings API
// -------------------------------------------------------------------------
/**
* Registers all settings sections and fields.
*/
public function register_settings(): void {
// Per-tab page slug + option group. Separate groups are critical:
// options.php zeroes any registered checkbox absent from $_POST, so a
// shared group across tabs would wipe other tabs' checkboxes when the
// user saves a single tab.
$page = 'acceguru-settings-scanner';
$group = 'acceguru_settings_scanner';
// ----- Scanner Settings -----
add_settings_section( 'acceguru_scanner', esc_html__( 'Scanner Settings', 'acceguru' ), '__return_false', $page );
register_setting( $group, 'acceguru_wcag_level', array( 'sanitize_callback' => array( $this, 'sanitize_wcag_level' ) ) );
add_settings_field(
'acceguru_wcag_level',
esc_html__( 'WCAG level to check', 'acceguru' ) . ' ' . self::help_tip(
__( 'A, AA and AAA are WCAG conformance levels. AA is a common project target. AAA includes additional criteria and may not be achievable for all content. Choose the level appropriate for your site.', 'acceguru' )
),
array( $this, 'render_select' ),
$page,
'acceguru_scanner',
array(
'option' => 'acceguru_wcag_level',
'choices' => array( 'A' => 'A', 'AA' => 'AA', 'AAA' => 'AAA' ),
)
);
register_setting( $group, 'acceguru_include_section_508', array( 'sanitize_callback' => 'absint', 'default' => 0 ) );
add_settings_field(
'acceguru_include_section_508',
esc_html__( 'Include Section 508 rules', 'acceguru' ),
array( $this, 'render_checkbox' ),
$page,
'acceguru_scanner',
array(
'option' => 'acceguru_include_section_508',
'description' => __( 'Adds axe-core rules tagged for Section 508. Enable this to include Section 508-tagged findings in scan results.', 'acceguru' ),
)
);
register_setting( $group, 'acceguru_include_en_301_549', array( 'sanitize_callback' => 'absint', 'default' => 0 ) );
add_settings_field(
'acceguru_include_en_301_549',
esc_html__( 'Include EN 301 549 rules', 'acceguru' ),
array( $this, 'render_checkbox' ),
$page,
'acceguru_scanner',
array(
'option' => 'acceguru_include_en_301_549',
'description' => __( 'Adds axe-core rules tagged for EN 301 549. Enable this to include EN 301 549-tagged findings in scan results.', 'acceguru' ),
)
);
register_setting( $group, 'acceguru_include_best_practice', array( 'sanitize_callback' => 'absint', 'default' => 1 ) );
register_setting( $group, 'acceguru_skip_noindex', array( 'sanitize_callback' => 'absint', 'default' => 0 ) );
register_setting( $group, 'acceguru_compat_wpml', array( 'sanitize_callback' => 'absint', 'default' => 1 ) );
add_settings_field(
'acceguru_include_best_practice',
esc_html__( 'Include recommended checks', 'acceguru' ) . ' ' . self::help_tip(
__( 'Adds recommended automated checks from axe-core and AcceGuru that are not formal WCAG failures. Recommended on.', 'acceguru' )
),
array( $this, 'render_checkbox' ),
$page,
'acceguru_scanner',
array(
'option' => 'acceguru_include_best_practice',
'default' => 1,
)
);
// Post types eligible for full-site scan. Multi-checkbox of every
// public post type the site has registered.
register_setting( $group, 'acceguru_scan_post_types', array(
'sanitize_callback' => array( $this, 'sanitize_post_types' ),
'default' => array( 'post', 'page' ),
) );
add_settings_field(
'acceguru_scan_post_types',
esc_html__( 'Post types to include in full-site scan', 'acceguru' ) . ' ' . self::help_tip(
__( 'Choose which content types the site-scan walks. Defaults to Posts + Pages. On large sites with thousands of WooCommerce products you may want to leave Products off.', 'acceguru' )
),
array( $this, 'render_post_types' ),
$page,
'acceguru_scanner',
array( 'option' => 'acceguru_scan_post_types' )
);
register_setting( $group, 'acceguru_show_editor_sidebar', array( 'sanitize_callback' => 'absint', 'default' => 1 ) );
add_settings_field(
'acceguru_show_editor_sidebar',
esc_html( sprintf(
/* translators: %s: product name, "AcceGuru" or "AcceGuru PRO" */
__( 'Show %s in Block Editor sidebar', 'acceguru' ),
acceguru_brand_name()
) ),
array( $this, 'render_checkbox' ),
$page,
'acceguru_scanner',
array(
'option' => 'acceguru_show_editor_sidebar',
'default' => 1,
'description' => __( 'Disable to hide the editor sidebar panel. Useful when the editor is too crowded with other plugins.', 'acceguru' ),
)
);
register_setting( $group, 'acceguru_overlay_position', array( 'sanitize_callback' => array( $this, 'sanitize_overlay_position' ), 'default' => 'right' ) );
add_settings_field(
'acceguru_overlay_position',
esc_html__( 'Front-end overlay position', 'acceguru' ) . ' ' . self::help_tip(
__( 'Which side the AcceGuru panel docks to when you click the admin-bar button on the front end. Both options push the page content over to make room.', 'acceguru' )
),
array( $this, 'render_select' ),
$page,
'acceguru_scanner',
array(
'option' => 'acceguru_overlay_position',
'choices' => array(
'right' => __( 'Right (default)', 'acceguru' ),
'left' => __( 'Left', 'acceguru' ),
),
)
);
register_setting( $group, 'acceguru_fab_position', array( 'sanitize_callback' => array( $this, 'sanitize_fab_position' ), 'default' => 'bottom-right' ) );
register_setting( $group, 'acceguru_skip_link_targets', array( 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ) );
register_setting( $group, 'acceguru_scan_exclude_selectors', array( 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '' ) );
add_settings_field(
'acceguru_skip_link_targets',
esc_html__( 'Skip-link target selectors', 'acceguru' ) . ' ' . self::help_tip(
__( 'Comma-separated CSS selectors the skip link will try first, before falling back to defaults (main, [role=main], #main, #content, #primary). Use when your theme\'s main content area has a non-standard ID/class.', 'acceguru' )
),
array( $this, 'render_text' ),
$page,
'acceguru_scanner',
array(
'option' => 'acceguru_skip_link_targets',
'placeholder' => '#site-main, .page-wrapper',
)
);
// ----- Fixer Settings (own tab, four sub-sections) -----
$page = 'acceguru-settings-fixers';
$group = 'acceguru_settings_fixers';
add_settings_section( 'acceguru_fixer_structure', esc_html__( 'Page structure', 'acceguru' ), '__return_false', $page );
add_settings_section( 'acceguru_fixer_forms', esc_html__( 'Forms & search', 'acceguru' ), '__return_false', $page );
add_settings_section( 'acceguru_fixer_links', esc_html__( 'Links & buttons', 'acceguru' ), '__return_false', $page );
add_settings_section( 'acceguru_fixer_cleanup', esc_html__( 'Cleanup', 'acceguru' ), '__return_false', $page );
$fixer_fields = array(
// Page structure.
'acceguru_fix_skip_links' => array(
'section' => 'acceguru_fixer_structure',
'label' => __( 'Enable skip links', 'acceguru' ),
'tip' => __( 'Adds a "Skip to main content" link as the first focusable element on every page. Helps keyboard users bypass the navigation. WCAG 2.4.1.', 'acceguru' ),
),
'acceguru_fix_focus_outlines' => array(
'section' => 'acceguru_fixer_structure',
'label' => __( 'Enable focus outlines', 'acceguru' ),
'tip' => __( 'Adds a visible focus indicator to every interactive element. Required so keyboard users can see what they have selected. WCAG 2.4.7.', 'acceguru' ),
),
'acceguru_fix_html_lang' => array(
'section' => 'acceguru_fixer_structure',
'label' => __( 'Add lang and dir attributes to ', 'acceguru' ),
'tip' => __( 'Adds based on the WordPress site language and text direction, so screen readers use the right pronunciation and right-to-left languages (Arabic, Hebrew) render correctly. WCAG 3.1.1.', 'acceguru' ),
),
// Forms & search.
'acceguru_fix_search_form_label' => array(
'section' => 'acceguru_fixer_forms',
'label' => __( 'Add an accessible name to the search field', 'acceguru' ),
'tip' => __( 'Adds an aria-label to the WP search field when it has no accessible name of its own, so screen readers can announce it. Skipped when the field already has a name. WCAG 1.3.1 / 4.1.2.', 'acceguru' ),
),
'acceguru_fix_label_comment_form' => array(
'section' => 'acceguru_fixer_forms',
'label' => __( 'Add labels to the comment form', 'acceguru' ),
'tip' => __( 'Adds an accessible name to the WordPress comment fields (Name, Email, Website, Comment) when the theme leaves them without a label, so screen-reader users know what each field is for. WCAG 1.3.1 / 4.1.2.', 'acceguru' ),
),
'acceguru_fix_empty_search_results' => array(
'section' => 'acceguru_fixer_forms',
'label' => __( 'Show search results page on empty search', 'acceguru' ),
'tip' => __( 'When a search is submitted with an empty query, force WordPress to render the search results template so users see a meaningful "no results" response instead of being silently sent to the home page.', 'acceguru' ),
),
// Links & buttons.
'acceguru_fix_label_new_window' => array(
'section' => 'acceguru_fixer_links',
'label' => __( 'Label links opening a new window', 'acceguru' ),
'tip' => __( 'Adds an "(opens in new tab)" screen-reader label and a small ↗ icon to every that does not already announce the new window. WCAG 3.2.5.', 'acceguru' ),
),
'acceguru_fix_label_document_links' => array(
'section' => 'acceguru_fixer_links',
'label' => __( 'Flag links to documents (PDF, Word...)', 'acceguru' ),
'tip' => __( 'Appends a short "(PDF)", "(Word)", "(Excel)" marker to links that point at non-HTML files - plus the file size for files on this site - so users know a download or different format follows. Skips links that already say so. WCAG 3.2.4 best practice.', 'acceguru' ),
),
'acceguru_fix_force_link_underline' => array(
'section' => 'acceguru_fixer_links',
'label' => __( 'Underline non-navigation links', 'acceguru' ),
'tip' => __( 'Underlines links inside content (skipping menus and button-styled links) so colour-blind and low-vision users can tell links from plain text. WCAG 1.4.1.', 'acceguru' ),
),
'acceguru_fix_autoplay_video_controls' => array(
'section' => 'acceguru_fixer_links',
'label' => __( 'Add controls to autoplay videos', 'acceguru' ),
'tip' => __( 'Forces controls="" onto every that ships without them so users can pause, mute, and stop the video. WCAG 2.2.2 / 1.4.2.', 'acceguru' ),
),
// Cleanup.
'acceguru_fix_remove_positive_tabindex' => array(
'section' => 'acceguru_fixer_cleanup',
'label' => __( 'Remove positive tabindex values', 'acceguru' ),
'tip' => __( 'Forces every tabindex greater than 0 to tabindex="0". Positive tabindex breaks the natural keyboard order and is almost always a bug. WCAG 2.4.3.', 'acceguru' ),
),
'acceguru_fix_remove_redundant_title' => array(
'section' => 'acceguru_fixer_cleanup',
'label' => __( 'Remove redundant title attributes', 'acceguru' ),
'tip' => __( 'Strips title="..." when it duplicates the element\'s visible text or aria-label. Stops screen readers announcing the same name twice. WCAG 4.1.2.', 'acceguru' ),
),
'acceguru_fix_viewport_scalable' => array(
'section' => 'acceguru_fixer_cleanup',
'label' => __( 'Make viewport scalable', 'acceguru' ),
'tip' => __( 'Rewrites the so users can pinch-zoom (overrides themes that ship user-scalable=no or maximum-scale=1). WCAG 1.4.4 / 1.4.10.', 'acceguru' ),
),
);
foreach ( $fixer_fields as $option => $field ) {
$label = $field['label'] . ' ' . self::help_tip( $field['tip'] );
register_setting( $group, $option, array( 'sanitize_callback' => 'absint' ) );
add_settings_field(
$option,
$label,
array( $this, 'render_checkbox' ),
$page,
$field['section'],
array( 'option' => $option )
);
}
// ----- AI Alt Text (own tab) -----
$page = 'acceguru-settings-ai';
$group = 'acceguru_settings_ai';
add_settings_section( 'acceguru_ai', esc_html__( 'AI Alt Text', 'acceguru' ), '__return_false', $page );
// Provider selector: gemini (default), mistral, openai or anthropic.
// Validated in sanitize_ai_provider against
// Acceguru_Alt_Text::PROVIDERS so the dispatch table and settings
// stay in sync.
register_setting( $group, 'acceguru_ai_provider', array( 'sanitize_callback' => array( $this, 'sanitize_ai_provider' ) ) );
// Per-provider API keys. Each is stored in its own wp_options slot
// so switching providers does not destroy the other key. Gemini
// keeps the legacy `acceguru_ai_api_key` slot for backwards
// compatibility; the rest are namespaced per provider.
register_setting( $group, 'acceguru_ai_api_key', array( 'sanitize_callback' => array( $this, 'sanitize_api_key' ) ) );
register_setting( $group, 'acceguru_ai_api_key_mistral', array( 'sanitize_callback' => array( $this, 'sanitize_api_key_mistral' ) ) );
register_setting( $group, 'acceguru_ai_api_key_openai', array( 'sanitize_callback' => array( $this, 'sanitize_api_key_openai' ) ) );
register_setting( $group, 'acceguru_ai_api_key_anthropic', array( 'sanitize_callback' => array( $this, 'sanitize_api_key_anthropic' ) ) );
// Per-provider model. Same legacy-name rule: Gemini stays on
// `acceguru_ai_model`, the rest are namespaced per provider.
register_setting( $group, 'acceguru_ai_model', array( 'sanitize_callback' => array( $this, 'sanitize_ai_model' ) ) );
register_setting( $group, 'acceguru_ai_model_mistral', array( 'sanitize_callback' => array( $this, 'sanitize_ai_model_mistral' ) ) );
register_setting( $group, 'acceguru_ai_model_openai', array( 'sanitize_callback' => array( $this, 'sanitize_ai_model_openai' ) ) );
register_setting( $group, 'acceguru_ai_model_anthropic', array( 'sanitize_callback' => array( $this, 'sanitize_ai_model_anthropic' ) ) );
register_setting( $group, 'acceguru_ai_auto_alt', array( 'sanitize_callback' => 'absint' ) );
add_settings_field(
'acceguru_ai_auto_alt',
esc_html__( 'Auto-generate alt text on upload', 'acceguru' ),
array( $this, 'render_checkbox' ),
$page,
'acceguru_ai',
array( 'option' => 'acceguru_ai_auto_alt' )
);
register_setting( $group, 'acceguru_ai_max_alt_length', array( 'sanitize_callback' => array( $this, 'sanitize_max_alt_length' ) ) );
add_settings_field(
'acceguru_ai_max_alt_length',
esc_html__( 'Max alt text length (characters)', 'acceguru' ),
array( $this, 'render_number' ),
$page,
'acceguru_ai',
array( 'option' => 'acceguru_ai_max_alt_length', 'min' => 50, 'max' => 500 )
);
// ----- Accessibility Statement (separate menu page; group only) -----
$stmt_group = 'acceguru_statement';
register_setting( $stmt_group, 'acceguru_statement_org', array( 'sanitize_callback' => 'sanitize_text_field' ) );
register_setting( $stmt_group, 'acceguru_statement_email', array( 'sanitize_callback' => 'sanitize_email' ) );
register_setting( $stmt_group, 'acceguru_statement_contact_url', array( 'sanitize_callback' => 'esc_url_raw' ) );
register_setting( $stmt_group, 'acceguru_statement_conformance', array( 'sanitize_callback' => array( $this, 'sanitize_wcag_level' ) ) );
register_setting( $stmt_group, 'acceguru_statement_extra', array( 'sanitize_callback' => 'wp_kses_post' ) );
// acceguru_statement_page_id is deliberately NOT registered here. The
// statement form has no field for it, and options.php writes every
// option of the group from the submitted form - a missing field is
// saved as an empty value, which absint() turns into 0. That silently
// erased the published page ID on every "Save settings", leaving the
// footer link with nothing to point at. Only the create/update handler
// below writes this option.
register_setting( $stmt_group, 'acceguru_statement_footer_link', array( 'sanitize_callback' => 'absint' ) );
register_setting( $stmt_group, 'acceguru_statement_footer_text', array( 'sanitize_callback' => 'sanitize_text_field' ) );
// ----- Advanced (own tab) -----
$page = 'acceguru-settings-advanced';
$group = 'acceguru_settings_advanced';
add_settings_section( 'acceguru_advanced', esc_html__( 'Advanced', 'acceguru' ), '__return_false', $page );
register_setting( $group, 'acceguru_delete_on_uninstall', array( 'sanitize_callback' => 'absint', 'default' => 0 ) );
add_settings_field(
'acceguru_delete_on_uninstall',
esc_html__( 'Delete all data on uninstall', 'acceguru' ),
array( $this, 'render_checkbox' ),
$page,
'acceguru_advanced',
array(
'option' => 'acceguru_delete_on_uninstall',
'description' => __( 'When enabled, removing the plugin via Plugins → Delete will also wipe all plugin settings, scan results, AcceGuru add-on data, and the AI API key. Leave off to preserve data across reinstalls.', 'acceguru' ),
)
);
}
// -------------------------------------------------------------------------
// Sanitize callbacks
// -------------------------------------------------------------------------
/**
* Returns a scalar setting value as a string without converting arrays or objects.
*
* @param mixed $value Raw setting value.
* @param string $default Fallback for non-scalar input.
*/
private static function scalar_setting_string( mixed $value, string $default = '' ): string {
return is_scalar( $value ) ? (string) $value : $default;
}
/**
* Sanitizes the WCAG level option.
*
* @param mixed $value Raw input.
*/
public function sanitize_wcag_level( mixed $value ): string {
$value = sanitize_text_field( self::scalar_setting_string( $value, 'AA' ) );
return in_array( $value, array( 'A', 'AA', 'AAA' ), true ) ? $value : 'AA';
}
/**
* Sanitizes the AI provider option. Allowed values come from
* Acceguru_Alt_Text::PROVIDERS so this stays in sync with the
* dispatch table on the generator side. Anything unrecognised is
* clamped back to gemini.
*
* @param mixed $value Raw input.
*/
public function sanitize_ai_provider( mixed $value ): string {
$value = sanitize_text_field( self::scalar_setting_string( $value, 'gemini' ) );
return in_array( $value, Acceguru_Alt_Text::PROVIDERS, true ) ? $value : 'gemini';
}
/**
* Sanitizes the Gemini model option.
*
* @param mixed $value Raw input.
*/
public function sanitize_ai_model( mixed $value ): string {
$value = sanitize_text_field( self::scalar_setting_string( $value, Acceguru_Alt_Text::DEFAULT_MODELS['gemini'] ) );
$allowed = Acceguru_Alt_Text::ALLOWED_MODELS['gemini'];
return in_array( $value, $allowed, true ) ? $value : Acceguru_Alt_Text::DEFAULT_MODELS['gemini'];
}
/**
* Sanitizes the Mistral model option.
*
* @param mixed $value Raw input.
*/
public function sanitize_ai_model_mistral( mixed $value ): string {
$value = sanitize_text_field( self::scalar_setting_string( $value, Acceguru_Alt_Text::DEFAULT_MODELS['mistral'] ) );
$allowed = Acceguru_Alt_Text::ALLOWED_MODELS['mistral'];
return in_array( $value, $allowed, true ) ? $value : Acceguru_Alt_Text::DEFAULT_MODELS['mistral'];
}
/**
* Sanitizes the OpenAI model option.
*
* @param mixed $value Raw input.
*/
public function sanitize_ai_model_openai( mixed $value ): string {
$value = sanitize_text_field( self::scalar_setting_string( $value, Acceguru_Alt_Text::DEFAULT_MODELS['openai'] ) );
$allowed = Acceguru_Alt_Text::ALLOWED_MODELS['openai'];
return in_array( $value, $allowed, true ) ? $value : Acceguru_Alt_Text::DEFAULT_MODELS['openai'];
}
/**
* Sanitizes the Anthropic (Claude) model option.
*
* @param mixed $value Raw input.
*/
public function sanitize_ai_model_anthropic( mixed $value ): string {
$value = sanitize_text_field( self::scalar_setting_string( $value, Acceguru_Alt_Text::DEFAULT_MODELS['anthropic'] ) );
$allowed = Acceguru_Alt_Text::ALLOWED_MODELS['anthropic'];
return in_array( $value, $allowed, true ) ? $value : Acceguru_Alt_Text::DEFAULT_MODELS['anthropic'];
}
/**
* Human-readable provider name for notices and labels.
*
* @param string $provider Provider slug.
*/
private function provider_label( string $provider ): string {
return Acceguru_Alt_Text::provider_label( $provider );
}
/**
* Human-readable, translatable label for an axe impact level. The raw
* values (critical/serious/moderate/minor) are internal keys and must
* never be shown to the user as-is - ucfirst() on them produces English
* that no translator can reach.
*
* @param string $level Impact level key.
*/
public static function impact_label( string $level ): string {
$labels = array(
'critical' => __( 'Critical', 'acceguru' ),
'serious' => __( 'Serious', 'acceguru' ),
'moderate' => __( 'Moderate', 'acceguru' ),
'minor' => __( 'Minor', 'acceguru' ),
);
return $labels[ $level ] ?? ucfirst( $level );
}
/**
* One very short phrase saying what a level means, for the badge tooltips
* in the post lists. A few words only: the bubble sits over a table row
* and a sentence there is in the way rather than helpful. The dashboard
* keeps its own fuller wording, with examples, where there is room.
*
* "manual" is not an axe impact. It stands with the others here because
* the column shows it beside them and it needs the same one-line answer.
*
* @param string $level Impact key, or "manual" for the checks a person judges.
*/
public static function impact_hint( string $level ): string {
$hints = array(
'critical' => __( 'blocks access', 'acceguru' ),
'serious' => __( 'major barrier but workable', 'acceguru' ),
'moderate' => __( 'hinders but does not block', 'acceguru' ),
'minor' => __( 'best-practice nits', 'acceguru' ),
'manual' => __( 'the scanner could not judge these on its own', 'acceguru' ),
);
return $hints[ $level ] ?? '';
}
/**
* The example that goes with the phrase above, on its own line, in the
* same words the dashboard uses.
*
* @param string $level Impact key, or "manual" for the checks a person judges.
*/
public static function impact_example( string $level ): string {
$examples = array(
'critical' => __( '(e.g. missing alt)', 'acceguru' ),
'serious' => __( '(e.g. low contrast)', 'acceguru' ),
'moderate' => __( '(e.g. content outside landmark)', 'acceguru' ),
'minor' => __( '(e.g. empty heading)', 'acceguru' ),
'manual' => __( '(e.g. contrast over a gradient)', 'acceguru' ),
);
return $examples[ $level ] ?? '';
}
/**
* Short "time since" wording for narrow table cells.
*
* WordPress spells its units out in full ("7 minutes"), which is too wide
* for the Last Scan column. Seconds, minutes and hours get an abbreviated
* form of their own; a day and longer keep the wording WordPress supplies,
* because those are short already.
*
* @param int $timestamp Unix time of the event.
* @return string Ready-to-print text, already including "ago".
*/
public static function short_time_ago( int $timestamp ): string {
$diff = time() - $timestamp;
if ( $diff < 0 ) {
$diff = 0;
}
if ( $diff < MINUTE_IN_SECONDS ) {
/* translators: %d: whole seconds. Keep it short, it sits in a narrow table column. */
return sprintf( __( '%d sec. ago', 'acceguru' ), max( 1, $diff ) );
}
if ( $diff < HOUR_IN_SECONDS ) {
/* translators: %d: whole minutes. Keep it short, it sits in a narrow table column. */
return sprintf( __( '%d min. ago', 'acceguru' ), max( 1, (int) round( $diff / MINUTE_IN_SECONDS ) ) );
}
if ( $diff < DAY_IN_SECONDS ) {
/* translators: %d: whole hours. Keep it short, it sits in a narrow table column. */
return sprintf( __( '%d hr. ago', 'acceguru' ), max( 1, (int) round( $diff / HOUR_IN_SECONDS ) ) );
}
/* translators: %s: time span such as "2 days", supplied by WordPress. */
return sprintf( __( '%s ago', 'acceguru' ), human_time_diff( $timestamp ) );
}
/**
* Sanitizes and encrypts the Gemini API key.
*
* @param mixed $value Raw input.
*/
public function sanitize_api_key( mixed $value ): string {
return $this->sanitize_api_key_for( 'gemini', $value );
}
/**
* Sanitizes and encrypts the Mistral API key.
*
* @param mixed $value Raw input.
*/
public function sanitize_api_key_mistral( mixed $value ): string {
return $this->sanitize_api_key_for( 'mistral', $value );
}
/**
* Sanitizes and encrypts the OpenAI API key.
*
* @param mixed $value Raw input.
*/
public function sanitize_api_key_openai( mixed $value ): string {
return $this->sanitize_api_key_for( 'openai', $value );
}
/**
* Sanitizes and encrypts the Anthropic API key.
*
* @param mixed $value Raw input.
*/
public function sanitize_api_key_anthropic( mixed $value ): string {
return $this->sanitize_api_key_for( 'anthropic', $value );
}
/**
* Shared sanitiser body for any provider's API key. Preserves the
* existing stored key when the field is submitted blank (the password
* input is intentionally never pre-filled, so a blank submit is the
* "don't change it" path) and queues a single "missing key" notice
* only when the *currently selected* provider's key ends up empty.
* Mistral being blank while Gemini is selected (or vice versa) is fine
* and does not generate noise.
*
* @param string $provider Provider slug.
* @param mixed $value Raw input.
*/
private function sanitize_api_key_for( string $provider, mixed $value ): string {
$option = Acceguru_Alt_Text::api_key_option_name( $provider );
$stored = self::scalar_setting_string( get_option( $option, '' ) );
$value = sanitize_text_field( self::scalar_setting_string( $value ) );
if ( '' === $value ) {
$final = $stored;
} elseif ( '' !== Acceguru_Alt_Text::decrypt_api_key( $value ) ) {
// A newly created option is sanitized once by update_option() and
// again by add_option(). Keep the valid ciphertext from the first
// pass instead of encrypting it a second time.
$final = $value;
} else {
try {
$final = Acceguru_Alt_Text::encrypt_api_key( $value );
} catch ( \RuntimeException $e ) {
// The key is never written unencrypted. If AES-256-GCM is
// unavailable or encryption fails, keep whatever was stored
// before and tell the administrator; do not save the new key.
static $encrypt_notice_added = array();
if ( empty( $encrypt_notice_added[ $provider ] ) ) {
add_settings_error(
$option,
'acceguru_api_key_encrypt_failed_' . $provider,
sprintf(
/* translators: %s: provider name */
__( 'Could not securely store the %s API key: AES-256-GCM encryption is unavailable or failed. The key was not saved.', 'acceguru' ),
$this->provider_label( $provider )
),
'error'
);
$encrypt_notice_added[ $provider ] = true;
}
return $stored;
}
}
// Only the active provider's missing key is worth warning about.
// Prefer the POSTed provider value over the stored one because the
// user may be switching providers in the same submit. Nonce is
// already verified by the Settings API (options.php) before this
// sanitize callback runs, so the superglobal read here is safe.
$selected = self::scalar_setting_string( get_option( 'acceguru_ai_provider', 'gemini' ), 'gemini' );
if ( isset( $_POST['acceguru_ai_provider'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Runs inside a Settings API sanitize callback; options.php already verified the nonce before this fires.
$posted_provider = sanitize_text_field( wp_unslash( $_POST['acceguru_ai_provider'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Runs inside a Settings API sanitize callback; options.php already verified the nonce before this fires.
$selected = $this->sanitize_ai_provider( $posted_provider );
}
if ( '' === $final && $selected === $provider ) {
// WordPress calls this sanitize callback twice in one request
// when the option does not yet exist in the DB (once from
// update_option, once from the fall-through add_option). Guard
// per-provider so the warning notice is queued only once.
static $notice_added = array();
if ( empty( $notice_added[ $provider ] ) ) {
add_settings_error(
$option,
'acceguru_empty_api_key_' . $provider,
sprintf(
/* translators: %s: provider name */
__( '%s API key is required - AI alt text will not work until you save one.', 'acceguru' ),
$this->provider_label( $provider )
),
'warning'
);
$notice_added[ $provider ] = true;
}
}
return $final;
}
/**
* Sanitizes the max alt text length option.
*
* @param mixed $value Raw input.
*/
public function sanitize_max_alt_length( mixed $value ): int {
if ( ! is_scalar( $value ) ) {
return 125;
}
$value = absint( $value );
return ( $value >= 50 && $value <= 500 ) ? $value : 125;
}
// -------------------------------------------------------------------------
// Field renderers
// -------------------------------------------------------------------------
/**
* Renders a checkbox input.
*
* @param array $args Field arguments including 'option' key.
*/
public function render_checkbox( array $args ): void {
$option = $args['option'];
$default = isset( $args['default'] ) ? absint( $args['default'] ) : 0;
printf(
' ',
esc_attr( $option ),
checked( 1, absint( get_option( $option, $default ) ), false )
);
if ( ! empty( $args['description'] ) ) {
printf( ' %s
', esc_html( (string) $args['description'] ) );
}
}
/**
* Renders a select input.
*
* @param array $args Field arguments including 'option' and 'choices' keys.
*/
public function render_select( array $args ): void {
$option = $args['option'];
$choices = $args['choices'];
$current = (string) get_option( $option, array_key_first( $choices ) );
printf( '', esc_attr( $option ) );
foreach ( $choices as $val => $label ) {
printf(
'%s ',
esc_attr( (string) $val ),
selected( $current, (string) $val, false ),
esc_html( $label )
);
}
echo ' ';
}
/**
* Renders a number input.
*
* @param array $args Field arguments including 'option', 'min', 'max' keys.
*/
public function render_number( array $args ): void {
$option = $args['option'];
printf(
' ',
esc_attr( $option ),
absint( get_option( $option, 125 ) ),
absint( $args['min'] ?? 1 ),
absint( $args['max'] ?? 9999 )
);
}
/**
* Renders a free-text input.
*
* @param array $args Field arguments including 'option', 'placeholder' keys.
*/
public function render_text( array $args ): void {
$option = $args['option'];
printf(
' ',
esc_attr( $option ),
esc_attr( (string) get_option( $option, '' ) ),
esc_attr( (string) ( $args['placeholder'] ?? '' ) )
);
}
/**
* Renders a multi-checkbox group of every public post type. Used for
* the "Post types to include in full-site scan" setting.
*
* @param array $args Field arguments including 'option' key.
*/
public function render_post_types( array $args ): void {
$option = $args['option'];
$selected = (array) get_option( $option, array( 'post', 'page' ) );
$types = get_post_types( array( 'public' => true ), 'objects' );
echo '';
foreach ( $types as $type ) {
$slug = (string) $type->name;
$label = (string) $type->label;
$checked = in_array( $slug, $selected, true ) ? ' checked' : '';
printf(
' %4$s ',
esc_attr( $option ),
esc_attr( $slug ),
esc_attr( $checked ),
esc_html( $label )
);
}
echo ' ';
}
/**
* Sanitises a list of post-type slugs against the public type registry.
* Attachment is dropped: the site-scan never walks it (attachment pages
* use the 'inherit' status the target query excludes), so storing it
* would keep a selection the scanner silently discards.
*
* @param mixed $raw Raw input from the form.
* @return array
*/
public function sanitize_post_types( mixed $raw ): array {
if ( ! is_array( $raw ) ) {
return array( 'post', 'page' );
}
$eligible = Acceguru_Scanner::eligible_post_types();
$out = array();
foreach ( $raw as $slug ) {
if ( ! is_scalar( $slug ) ) {
continue;
}
$slug = sanitize_key( (string) $slug );
if ( '' !== $slug && isset( $eligible[ $slug ] ) ) {
$out[] = $slug;
}
}
return empty( $out ) ? array( 'post', 'page' ) : array_values( array_unique( $out ) );
}
/**
* Sanitises the front-end overlay-position select to one of three values.
*
* @param mixed $raw Raw input.
*/
public function sanitize_overlay_position( mixed $raw ): string {
$v = sanitize_text_field( self::scalar_setting_string( $raw, 'right' ) );
return in_array( $v, array( 'right', 'left' ), true ) ? $v : 'right';
}
/**
* Sanitises the front-end launcher-button position select. "off" hides
* the floating button entirely; the rest place it in a screen corner.
*
* @param mixed $raw Raw input.
*/
public function sanitize_fab_position( mixed $raw ): string {
$v = sanitize_text_field( self::scalar_setting_string( $raw, 'bottom-right' ) );
$allowed = array( 'off', 'bottom-right', 'bottom-left', 'top-right', 'top-left' );
return in_array( $v, $allowed, true ) ? $v : 'bottom-right';
}
/**
* Renders the API key password field.
* Never outputs the stored value; shows a placeholder when a key exists.
*/
public function render_api_key_field(): void {
$stored = (string) get_option( 'acceguru_ai_api_key', '' );
$has_key = '' !== $stored;
printf(
' %s',
$has_key
? esc_attr__( '(key saved - enter new value to replace)', 'acceguru' )
: esc_attr__( 'Paste your API key here', 'acceguru' ),
$has_key
? '' . esc_html__( '🔑 Key saved', 'acceguru' ) . ' '
: '' . esc_html__( '⚠ No key saved', 'acceguru' ) . ' '
);
printf(
'%2$s
',
$has_key ? '' : ' disabled="disabled"',
esc_html__( 'Test API key', 'acceguru' )
);
if ( ! $has_key ) {
printf(
'%s
',
esc_html__( 'Save your API key first, then click "Test API key" to verify the provider responds.', 'acceguru' )
);
} else {
printf(
'%s
',
esc_html__( 'The field above always renders empty for security. The saved key is used for tests and Bulk Alt Text. Type a new value and save to replace it.', 'acceguru' )
);
}
}
// -------------------------------------------------------------------------
// Page renderers
// -------------------------------------------------------------------------
/**
* Renders the Dashboard admin page.
*/
public function render_dashboard_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$dashboard_data = $this->build_dashboard_data();
include ACCEGURU_PATH . 'admin/views/dashboard-page.php';
}
/**
* Walks all posts that have a AcceGuru summary stored and returns
* top-offending pages and top-violated rules.
*
* @return array{top_pages:array>,top_rules:array}
*/
public static function build_dashboard_data(): array {
$current_profile_hash = Acceguru_Scanner::scan_profile_hash();
$cached = get_transient( 'acceguru_dashboard_data' );
if ( is_array( $cached )
&& isset( $cached['scan_profile_hash'] )
&& Acceguru_Scanner::SCAN_SCHEMA_VERSION === (int) ( $cached['scan_schema_version'] ?? 0 )
&& Acceguru_Scanner::RULESET_VERSION === (string) ( $cached['ruleset_version'] ?? '' )
&& hash_equals( $current_profile_hash, (string) $cached['scan_profile_hash'] ) ) {
return $cached;
}
$site_summary = get_option( 'acceguru_site_scan_summary', null );
$has_site_summary = is_array( $site_summary );
$site_summary_current = Acceguru_Scanner::summary_is_current( $site_summary );
$top_pages = array();
$rule_counts = array();
$axe_passes = 0;
$axe_violations = 0;
// Checks a person still has to judge. Summed from the same per-page
// summaries as everything else, so the card always matches the last
// full scan. Deliberately kept apart from the issue totals.
//
// The total is only honest when EVERY counted page carries the field.
// After an update from a version that did not record it, some pages
// have it and some do not; summing just the new ones would present a
// partial figure as the site total. One page without it is enough to
// leave the total null, and the card then shows a dash.
$manual_checks = 0;
$manual_missing = false;
$stale_pages = 0;
// Dataset filter: only count pages scanned at or after the start of the
// most recent fresh site-scan. This keeps a partial / cancelled scan
// from mixing with stale per-post meta left by an earlier full scan.
// When no dataset start is recorded (e.g. legacy data) the filter is a
// no-op and every scanned page is included, as before.
$since_raw = (string) get_option( 'acceguru_dataset_since', '' );
$since_ts = '' !== $since_raw ? strtotime( $since_raw ) : 0;
// Process saved scan records in bounded batches. The aggregate itself is
// unchanged, but a large site no longer loads every matching post ID in
// one database query.
foreach ( Acceguru_Scanner::post_ids_in_batches(
array(
'post_type' => Acceguru_Scanner::scan_post_types(),
'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ),
'meta_key' => '_acceguru_scan_summary', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Aggregate runs once per dashboard load and the result is cached in a transient.
'orderby' => 'ID',
'order' => 'ASC',
)
) as $pid ) {
$summary = get_post_meta( (int) $pid, '_acceguru_scan_summary', true );
if ( ! is_array( $summary ) ) {
continue;
}
// Skip pages whose last scan predates the current dataset. Once a
// dataset marker exists, a missing date cannot prove membership either.
if ( $since_ts > 0 ) {
$page_ts = isset( $summary['scan_date'] ) ? strtotime( (string) $summary['scan_date'] ) : 0;
if ( $page_ts <= 0 || $page_ts < $since_ts ) {
continue;
}
}
if ( ! Acceguru_Scanner::summary_is_current( $summary ) ) {
++$stale_pages;
continue;
}
$issues = get_post_meta( (int) $pid, '_acceguru_scan_results', true );
if ( ! is_array( $issues ) ) {
$issues = array();
}
// Same filtering the per-post view applies, through the same call -
// ignored rules, then whatever add-ons drop. Counting it here a
// second time is how the cards drifted away from the page view.
$issues = Acceguru_Scanner::filter_issues( $issues, (int) $pid );
$axe_passes += (int) ( $summary['axe_passes_count'] ?? $summary['passes_count'] ?? 0 );
// Keep the raw axe denominator paired with raw passes. The filtered
// issue list below still owns visible totals and breakdowns.
$axe_violations += (int) ( $summary['axe_violations_count'] ?? Acceguru_Scanner::count_issues_by_source( $issues, 'axe' ) );
// One shared count for the card and for the table row below, so a
// check marked as reviewed moves both together. Null means the
// record carries nothing on the subject at all.
$page_manual = Acceguru_Scanner::manual_check_count( (int) $pid, $summary );
if ( null === $page_manual ) {
$manual_missing = true;
} else {
$manual_checks += $page_manual;
}
$by_impact = array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0 );
$total = 0;
foreach ( $issues as $issue ) {
$rule = isset( $issue['rule_id'] ) ? (string) $issue['rule_id'] : '';
if ( '' === $rule ) {
continue;
}
++$total;
$imp = (string) ( $issue['impact'] ?? 'minor' );
if ( isset( $by_impact[ $imp ] ) ) {
++$by_impact[ $imp ];
}
$rule_counts[ $rule ] = ( $rule_counts[ $rule ] ?? 0 ) + 1;
}
$top_pages[] = array(
'id' => (int) $pid,
'title' => get_the_title( (int) $pid ),
'edit_url' => (string) get_edit_post_link( (int) $pid, 'raw' ),
'view_url' => (string) get_permalink( (int) $pid ),
'total_issues' => $total,
'manual_checks' => $page_manual,
'scan_date' => isset( $summary['scan_date'] ) ? (string) $summary['scan_date'] : '',
'by_impact' => $by_impact,
'post_type' => (string) get_post_type( (int) $pid ),
);
}
usort(
$top_pages,
static function ( $a, $b ): int {
return $b['total_issues'] <=> $a['total_issues'];
}
);
arsort( $rule_counts );
// Aggregate FULL totals across every scanned page BEFORE slicing
// $top_pages to the top-10 display set. Without this the dashboard
// cards would silently undercount whenever the site has more than
// 10 scanned pages - issues from the 11th-onward pages would be
// dropped from the headline totals while the site-scan progress
// still sums them, producing the "84 in scan log, 78 on cards"
// discrepancy users were seeing.
$totals = array(
'pages' => count( $top_pages ),
'total' => 0,
'axe_passes' => $axe_passes,
'axe_violations' => $axe_violations,
'manual_checks' => $manual_missing ? null : $manual_checks,
'current' => $has_site_summary && $site_summary_current && 0 === $stale_pages,
'stale' => $has_site_summary && ( ! $site_summary_current || $stale_pages > 0 ),
'stale_pages' => $stale_pages,
'by_impact' => array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0 ),
);
$by_post_type = array();
foreach ( $top_pages as $p ) {
$totals['total'] += (int) $p['total_issues'];
$totals['by_impact']['critical'] += (int) ( $p['by_impact']['critical'] ?? 0 );
$totals['by_impact']['serious'] += (int) ( $p['by_impact']['serious'] ?? 0 );
$totals['by_impact']['moderate'] += (int) ( $p['by_impact']['moderate'] ?? 0 );
$totals['by_impact']['minor'] += (int) ( $p['by_impact']['minor'] ?? 0 );
$pt = (string) ( $p['post_type'] ?? '' );
if ( '' === $pt ) {
continue;
}
if ( ! isset( $by_post_type[ $pt ] ) ) {
$by_post_type[ $pt ] = array(
'pages' => 0,
'critical' => 0,
'serious' => 0,
'moderate' => 0,
'minor' => 0,
'total' => 0,
);
}
$by_post_type[ $pt ]['pages'] += 1;
$by_post_type[ $pt ]['critical']+= (int) ( $p['by_impact']['critical'] ?? 0 );
$by_post_type[ $pt ]['serious'] += (int) ( $p['by_impact']['serious'] ?? 0 );
$by_post_type[ $pt ]['moderate']+= (int) ( $p['by_impact']['moderate'] ?? 0 );
$by_post_type[ $pt ]['minor'] += (int) ( $p['by_impact']['minor'] ?? 0 );
$by_post_type[ $pt ]['total'] += (int) $p['total_issues'];
}
$totals['by_post_type'] = $by_post_type;
$data = array(
'top_pages' => array_slice( $top_pages, 0, 15 ),
'top_rules' => $rule_counts,
'totals' => $totals,
'scan_schema_version' => Acceguru_Scanner::SCAN_SCHEMA_VERSION,
'ruleset_version' => Acceguru_Scanner::RULESET_VERSION,
'scan_profile_hash' => $current_profile_hash,
);
set_transient( 'acceguru_dashboard_data', $data, HOUR_IN_SECONDS );
return $data;
}
/**
* Renders the Settings admin page.
*/
public function render_settings_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
include ACCEGURU_PATH . 'admin/views/settings-page.php';
}
/**
* Renders the Bulk Alt Text admin page.
*/
public function render_bulk_alt_page(): void {
if ( ! current_user_can( 'upload_files' ) ) {
return;
}
include ACCEGURU_PATH . 'admin/views/bulk-alt-text-page.php';
}
/**
* Renders the Accessibility Statement admin page.
*/
public function render_statement_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$statement_html = self::build_statement_html();
include ACCEGURU_PATH . 'admin/views/statement-page.php';
}
// -------------------------------------------------------------------------
// Accessibility Statement
// -------------------------------------------------------------------------
/**
* Builds the rendered statement HTML using stored settings and the latest scan summary.
*/
public static function build_statement_html(): string {
$option_string = static function ( string $name, string $default = '' ): string {
$value = get_option( $name, $default );
return is_scalar( $value ) ? (string) $value : $default;
};
$org = $option_string( 'acceguru_statement_org', (string) get_bloginfo( 'name' ) );
$email = $option_string( 'acceguru_statement_email', (string) get_bloginfo( 'admin_email' ) );
$contact_url = $option_string( 'acceguru_statement_contact_url' );
$level = $option_string( 'acceguru_statement_conformance', $option_string( 'acceguru_wcag_level', 'AA' ) );
$extra = $option_string( 'acceguru_statement_extra' );
$summary = get_option( 'acceguru_site_scan_summary', null );
$site_url = (string) home_url( '/' );
$today = date_i18n( (string) get_option( 'date_format' ), time() );
// How many pages the run covered, and when, come from the stored summary:
// that is the record of the run. The outstanding issue count does not -
// it must reflect rules ignored since the scan, or the statement would
// publish a number the dashboard no longer shows.
$totals = self::build_dashboard_data()['totals'] ?? array();
$pages_scanned = is_array( $summary ) && isset( $summary['pages_scanned'] ) ? (int) $summary['pages_scanned'] : 0;
$total_issues = (int) ( $totals['total'] ?? 0 );
$scan_date_raw = is_array( $summary ) && ! empty( $summary['scan_date'] ) ? (string) $summary['scan_date'] : '';
$scan_date_ts = '' !== $scan_date_raw ? strtotime( $scan_date_raw ) : false;
$scan_date = $scan_date_ts ? date_i18n( (string) get_option( 'date_format' ), $scan_date_ts ) : $scan_date_raw;
ob_start();
?>
0 ) : ?>
$atts Shortcode attributes (unused).
*/
public static function render_statement_shortcode( $atts = array() ): string {
unset( $atts );
return self::build_statement_html();
}
/**
* Finds a published page that already renders the statement shortcode.
*
* Used only when the stored page ID is missing, so a site that lost the ID
* adopts its existing page instead of gaining a duplicate one.
*
* @return int Page ID, or 0 when there is none.
*/
private static function find_statement_page(): int {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off lookup behind an admin button press; post content is not searchable through WP_Query.
$page_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type = 'page'
AND post_status = 'publish'
AND post_content LIKE %s
ORDER BY ID ASC
LIMIT 1",
'%' . $wpdb->esc_like( '[acceguru_statement]' ) . '%'
)
);
return null === $page_id ? 0 : (int) $page_id;
}
/**
* Handles the "Create / update statement page" admin POST action.
*/
public function maybe_create_statement_page(): void {
if ( ! isset( $_POST['acceguru_create_statement_page'] ) ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
check_admin_referer( 'acceguru_statement_page' );
$existing_id = (int) get_option( 'acceguru_statement_page_id', 0 );
if ( $existing_id <= 0 || ! get_post_status( $existing_id ) ) {
// No usable ID stored - adopt the page that already renders the
// statement instead of publishing a second copy of it.
$existing_id = self::find_statement_page();
}
$post_data = array(
'post_title' => __( 'Accessibility Statement', 'acceguru' ),
'post_content' => '[acceguru_statement]',
'post_status' => 'publish',
'post_type' => 'page',
);
if ( $existing_id > 0 && get_post_status( $existing_id ) ) {
$post_data['ID'] = $existing_id;
wp_update_post( $post_data );
$page_id = $existing_id;
} else {
$page_id = (int) wp_insert_post( $post_data );
}
if ( $page_id > 0 ) {
update_option( 'acceguru_statement_page_id', $page_id );
}
wp_safe_redirect(
add_query_arg(
array(
'page' => 'acceguru-settings',
'tab' => 'statement',
'acceguru_saved' => '1',
),
admin_url( 'admin.php' )
)
);
exit;
}
// -------------------------------------------------------------------------
// Asset enqueueing
// -------------------------------------------------------------------------
/**
* Enqueues CSS and JS only on plugin admin pages.
*
* @param string $hook_suffix Current page hook suffix.
*/
public function enqueue_assets( string $hook_suffix ): void {
$plugin_hooks = array( $this->dashboard_hook, $this->settings_hook );
if ( ! in_array( $hook_suffix, $plugin_hooks, true ) ) {
return;
}
wp_enqueue_style(
'acceguru-admin',
ACCEGURU_URL . 'admin/css/acceguru-admin.css',
array(),
ACCEGURU_VERSION
);
// New design system (Settings + Dashboard reference design).
wp_enqueue_style(
'acceguru-design',
ACCEGURU_URL . 'admin/css/acceguru-design.css',
array( 'acceguru-admin' ),
ACCEGURU_VERSION
);
// DM Sans + JetBrains Mono for the new design surface, bundled with the
// plugin (no external request - see assets/fonts/acceguru-fonts.css).
wp_enqueue_style(
'acceguru-dmsans',
ACCEGURU_URL . 'assets/fonts/acceguru-fonts.css',
array(),
ACCEGURU_VERSION
);
wp_enqueue_script(
'acceguru-admin',
ACCEGURU_URL . 'admin/js/acceguru-admin.js',
array( 'wp-api-fetch', 'wp-i18n' ),
ACCEGURU_VERSION,
true
);
wp_set_script_translations( 'acceguru-admin', 'acceguru', ACCEGURU_PATH . 'languages' );
wp_localize_script(
'acceguru-admin',
'acceguruAdmin',
array(
// Per-provider "is a key saved?" booleans, so the JS provider
// switcher can enable/disable the Test button immediately
// without a REST round-trip every time the dropdown changes.
'savedKeys' => array(
'gemini' => '' !== (string) get_option( 'acceguru_ai_api_key', '' ),
'mistral' => '' !== (string) get_option( 'acceguru_ai_api_key_mistral', '' ),
'openai' => '' !== (string) get_option( 'acceguru_ai_api_key_openai', '' ),
'anthropic' => '' !== (string) get_option( 'acceguru_ai_api_key_anthropic', '' ),
),
// Names for the delete confirmation, so it says which key is
// about to go rather than just "the key".
'providerLabels' => array(
'gemini' => Acceguru_Alt_Text::provider_label( 'gemini' ),
'mistral' => Acceguru_Alt_Text::provider_label( 'mistral' ),
'openai' => Acceguru_Alt_Text::provider_label( 'openai' ),
'anthropic' => Acceguru_Alt_Text::provider_label( 'anthropic' ),
),
)
);
// Tab-specific assets on the settings screen.
if ( $hook_suffix === $this->settings_hook ) {
$tab = isset( $_GET['tab'] ) ? sanitize_key( (string) $_GET['tab'] ) : 'scanner'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only tab check that only decides which assets to enqueue; nothing is written.
if ( 'ai' === $tab && current_user_can( 'upload_files' ) ) {
wp_enqueue_script(
'acceguru-bulk-alt',
ACCEGURU_URL . 'admin/js/acceguru-bulk-alt.js',
array( 'wp-api-fetch', 'wp-i18n' ),
ACCEGURU_VERSION,
true
);
wp_set_script_translations( 'acceguru-bulk-alt', 'acceguru', ACCEGURU_PATH . 'languages' );
// Tell the JS whether the currently selected provider has a
// key saved, so the Generate button stays disabled when there
// is no chance the requests could succeed. Re-rendered on
// every page load - the user just saves a key and the next
// render flips the flag.
$active_provider = (string) get_option( 'acceguru_ai_provider', 'gemini' );
if ( ! in_array( $active_provider, Acceguru_Alt_Text::PROVIDERS, true ) ) {
$active_provider = 'gemini';
}
$key_option = Acceguru_Alt_Text::api_key_option_name( $active_provider );
wp_localize_script(
'acceguru-bulk-alt',
'acceguruBulkAlt',
array(
'hasApiKey' => '' !== (string) get_option( $key_option, '' ),
'provider' => $active_provider,
)
);
}
}
if ( $hook_suffix === $this->dashboard_hook ) {
wp_enqueue_script(
'acceguru-site-scan',
ACCEGURU_URL . 'admin/js/acceguru-site-scan.js',
array( 'wp-api-fetch', 'wp-i18n' ),
ACCEGURU_VERSION,
true
);
wp_set_script_translations( 'acceguru-site-scan', 'acceguru', ACCEGURU_PATH . 'languages' );
// Full saved log for the "Download .txt" link. The on-screen log
// collapses skipped pages into one line, but the export
// must stay complete and grouped, so the download reads this full
// copy rather than the collapsed DOM.
$scan_log = Acceguru_Scanner::normalize_scan_log_entries(
get_option( 'acceguru_last_scan_log', array() )
);
wp_localize_script(
'acceguru-site-scan',
'acceguruSiteScanLog',
array_values( $scan_log )
);
// Last-scan counters so the on-screen "Scanned X - Y with issues -
// Z failed - W clean" row and the "Totals:" line in the .txt
// download survive the post-scan auto-reload. Always an array (may
// be empty - JS treats missing/zero scanned as "do not render").
$scan_log_counters = get_option( 'acceguru_last_scan_log_counters', array() );
wp_localize_script(
'acceguru-site-scan',
'acceguruSiteScanLogCounters',
is_array( $scan_log_counters ) ? $scan_log_counters : array()
);
// WordPress timezone (offset in minutes east of UTC) so the
// downloadable .txt log header shows the site's local time, not the
// browser's. Plus the last scan's duration so the header can report
// how long the scan took (the live JS timer resets on reload).
$tz_offset = (int) round( wp_timezone()->getOffset( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) / 60 );
$scan_summary = get_option( 'acceguru_site_scan_summary', array() );
$summary_current = Acceguru_Scanner::summary_is_current( $scan_summary );
// The site scan runs sequentially (one page at a time), so no
// concurrency value is localized here.
wp_localize_script(
'acceguru-site-scan',
'acceguruSiteScan',
array_merge( Acceguru_Scanner::client_scan_context(), array(
'tzOffsetMin' => $tz_offset,
'durationMs' => is_array( $scan_summary ) && isset( $scan_summary['duration_ms'] ) ? (int) $scan_summary['duration_ms'] : 0,
'summaryCurrent' => $summary_current,
'summaryStale' => is_array( $scan_summary ) && ! $summary_current,
'savedScanProfileHash' => is_array( $scan_summary ) ? (string) ( $scan_summary['scan_profile_hash'] ?? '' ) : '',
// Per-page deadline (ms) the site-scan controller waits before
// giving up on a page.
'timeoutMs' => 30000,
) )
);
}
}
/**
* Enqueues the Media Library "Generate with AI" button script on any
* screen that can open the attachment details modal or edit an attachment.
*
* @param string $hook_suffix Current page hook suffix.
*/
public function enqueue_media_alt_assets( string $hook_suffix ): void {
$screens = array( 'upload.php', 'post.php', 'post-new.php', 'site-editor.php', 'widgets.php' );
if ( ! in_array( $hook_suffix, $screens, true ) ) {
return;
}
if ( ! current_user_can( 'upload_files' ) ) {
return;
}
wp_enqueue_script(
'acceguru-media-alt',
ACCEGURU_URL . 'admin/js/acceguru-media-alt.js',
array( 'wp-api-fetch', 'wp-i18n' ),
ACCEGURU_VERSION,
true
);
wp_set_script_translations( 'acceguru-media-alt', 'acceguru', ACCEGURU_PATH . 'languages' );
}
}