Rule id => documentation URL.
*/
function acceguru_custom_rule_urls(): array {
return array(
'image-low-quality-alt' => 'https://www.w3.org/WAI/tutorials/images/decision-tree/',
'image-long-alt' => 'https://www.w3.org/WAI/tutorials/images/complex/',
'image-title-no-alt' => 'https://www.w3.org/WAI/tutorials/images/decorative/',
'image-empty-alt-review' => 'https://www.w3.org/WAI/tutorials/images/decision-tree/',
'underlined-non-link' => 'https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html',
'improper-link' => 'https://www.w3.org/WAI/ARIA/apg/patterns/button/',
'broken-anchor-link' => 'https://www.w3.org/WAI/WCAG21/Understanding/parsing.html',
'ambiguous-link-text' => 'https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html',
'link-opens-new-window-no-warning' => 'https://www.w3.org/WAI/WCAG21/Techniques/general/G201',
'missing-table-header' => 'https://www.w3.org/WAI/tutorials/tables/',
'text-too-small' => 'https://www.w3.org/WAI/WCAG21/Understanding/resize-text.html',
'text-justified' => 'https://www.w3.org/WAI/WCAG21/Understanding/visual-presentation.html',
'possible-heading' => 'https://www.w3.org/WAI/tutorials/page-structure/headings/',
);
}
/**
* Documentation URL for any rule id, whichever check it came from.
*
* Use this wherever a rule name is turned into a link: it answers with the
* W3C page for an AcceGuru check and falls back to the Deque page for an
* axe-core rule.
*
* @param string $rule_id Rule identifier.
* @return string Unescaped URL.
*/
function acceguru_rule_help_url( string $rule_id ): string {
$custom = acceguru_custom_rule_urls();
return $custom[ $rule_id ] ?? acceguru_axe_rule_url( $rule_id );
}
require_once ACCEGURU_PATH . 'includes/class-acceguru-scanner.php';
require_once ACCEGURU_PATH . 'includes/class-acceguru-fixer.php';
require_once ACCEGURU_PATH . 'includes/class-acceguru-alt-text.php';
require_once ACCEGURU_PATH . 'includes/class-acceguru-admin.php';
require_once ACCEGURU_PATH . 'includes/class-acceguru-admin-columns.php';
// Compatibility modules (includes/compat/): each is a self-contained
// integration with a third-party plugin, hooked onto this plugin's own
// extension points.
require_once ACCEGURU_PATH . 'includes/compat/class-acceguru-compat-wpml.php';
/**
* Whether the pro add-on is active. Always false in the free plugin -
* the pro add-on hooks this filter and returns true once a valid licence
* key is verified. Use this gate (not a direct option check) when adding
* pro-only behaviour.
*
* @return bool
*/
function acceguru_is_pro(): bool {
return (bool) apply_filters( 'acceguru_is_pro', false );
}
/**
* URL of the upgrade / pricing page. Filterable so the pro add-on or a
* white-label build can repoint it.
*
* @return string
*/
function acceguru_pricing_url(): string {
return (string) apply_filters( 'acceguru_pricing_url', 'https://acce.guru/#pricing' );
}
/**
* Returns the small plugin icon used as a prefix glyph next to section titles
* (Dashboard cards, Settings tabs, statement / bulk-alt screens). Pre-escaped
* so view files can echo it directly.
*
* @return string Escaped HTML, safe to echo.
*/
function acceguru_section_icon(): string {
return '
';
}
/**
* Allowed-HTML map for wp_kses() when echoing the plugin's own pre-built admin
* markup (section icons, help tooltips, the FAQ rail,
* the by-impact table cells, the post-type quick links). Every tag and
* attribute these helpers emit is listed here, so wrapping their output in
* wp_kses() keeps the markup intact while making the escaping explicit at the
* point of output - no phpcs:ignore needed.
*
* @return array>
*/
function acceguru_admin_allowed_html(): array {
$common = array(
'class' => true,
'id' => true,
'style' => true,
'title' => true,
'role' => true,
'aria-hidden' => true,
'aria-label' => true,
'aria-describedby' => true,
'aria-expanded' => true,
);
return array(
'a' => $common + array( 'href' => true, 'target' => true, 'rel' => true ),
'img' => $common + array( 'src' => true, 'alt' => true, 'width' => true, 'height' => true ),
'span' => $common,
'div' => $common + array( 'data-acceguru-feature' => true ),
'p' => $common,
'h2' => $common,
'h3' => $common,
'ul' => $common,
'li' => $common,
'button' => $common + array( 'type' => true ),
'details' => $common + array( 'open' => true ),
'summary' => $common,
'aside' => $common,
);
}
/**
* Returns the FAQ box HTML for the right-side rail. Plain-language Q&A
* about the most common scan / fixer questions. Native /
* accordion - no JS, no dependencies.
*
* @return string Escaped HTML, safe to echo.
*/
function acceguru_faq_box(): string {
$faq = array(
array(
'q' => __( 'Why do some pages fail during a scan?', 'acceguru' ),
'a' => __( 'If your shared hosting is weak or overloaded, scanning a single page can take longer than 30 seconds. When that happens, the scan moves on to the next page. Click "Retry failed" or run the scan again.', 'acceguru' ),
),
array(
'q' => __( 'Do I need to keep the tab open during a full-site scan?', 'acceguru' ),
'a' => __( 'Yes. The scan runs in your browser tab. Closing the tab, switching away, or clicking to another page stops it. Keep the tab open and in front until it finishes. Switching to a different browser tab also slows it down.', 'acceguru' ),
),
array(
'q' => __( 'Why can\'t every problem be fixed automatically?', 'acceguru' ),
'a' => __( 'Some problems need a human to decide. A plugin can fix structural markup, but it cannot judge whether your alt text truly describes the picture, or whether a heading makes sense in context. Structural fixes are automated; judgement calls are up to you.', 'acceguru' ),
),
array(
'q' => __( 'Why does another tool show a different number of issues?', 'acceguru' ),
'a' => __( 'Different tools check for different things and count differently. AcceGuru looks at your page the way a visitor sees it - after our automatic fixers have done their work - and counts one problem per rule. Other tools may check the raw page, or count every element separately. The numbers will not match, and that is normal.', 'acceguru' ),
),
array(
'q' => __( 'Why does "Issues Found" not drop after I turn on fixers?', 'acceguru' ),
'a' => __( 'The fixers do not rewrite your saved content - they fix the page on the fly each time it loads. The scanner sees pages the way visitors do, so it already sees the fixed version. The number you see is what is LEFT after the fixers ran.', 'acceguru' ),
),
array(
'q' => __( 'Does scanning send my data anywhere?', 'acceguru' ),
'a' => __( 'No. Accessibility scanning runs entirely in your browser, and scan results are stored only in your site database. Optional AI features may contact the selected AI provider or a remote attachment/CDN URL, as described in the AI providers and Privacy sections of the plugin readme.', 'acceguru' ),
),
array(
'q' => __( 'Why do I see problems from my theme or other plugins?', 'acceguru' ),
'a' => __( 'The scan checks the whole page as it is displayed - including parts your theme and other plugins add, not just your own post content. Some of those our fixers handle automatically. For the rest you can hide the rule with "Ignore this rule", or fix it in the theme itself.', 'acceguru' ),
),
);
$items = '';
foreach ( $faq as $row ) {
$items .= sprintf(
'%1$s
%2$s
',
esc_html( $row['q'] ),
esc_html( $row['a'] )
);
}
return sprintf(
'',
esc_html__( 'FAQ', 'acceguru' ),
$items,
esc_url( 'https://docs.acce.guru' ),
esc_html__( 'View All Documentation', 'acceguru' )
);
}
/**
* Renders the "Upgrade to Pro" card for the right-side rail. Returns an empty
* string when the pro add-on is active - there is nothing left to offer.
*
* Custom post type scanning is deliberately absent from the list: it has been
* part of the free plugin since 2026-07-06 and must not be advertised as paid.
*
* @return string Escaped HTML, safe to echo.
*/
function acceguru_pro_upsell_box(): string {
if ( acceguru_is_pro() ) {
return '';
}
$features = array(
__( '5 advanced fixers: form labels, heading order, ARIA landmarks, duplicate IDs, empty links', 'acceguru' ),
__( 'Scan archive & taxonomy pages', 'acceguru' ),
__( 'Scan WooCommerce product category & tag pages', 'acceguru' ),
__( 'Automatic check after saving in the editor', 'acceguru' ),
__( 'Weekly / Monthly email report', 'acceguru' ),
__( 'Parallel fast site scan (Fast / Faster)', 'acceguru' ),
__( 'Issues-over-time trends + CSV / JSON export', 'acceguru' ),
__( 'Dismiss issues with a full audit log', 'acceguru' ),
__( 'Role-based permissions', 'acceguru' ),
__( 'Priority email support', 'acceguru' ),
);
$items = '';
foreach ( $features as $feature ) {
$items .= '' . esc_html( $feature ) . '';
}
return sprintf(
''
. '
'
. '
'
. '%2$s'
. '%3$s PRO'
. '
'
. '
%4$s
'
. '
'
. '
%7$s'
. '
',
esc_url( ACCEGURU_URL . 'assets/images/logo.svg' ),
esc_html__( 'Upgrade to', 'acceguru' ),
esc_html__( 'AcceGuru', 'acceguru' ),
esc_html__( 'Unlock the full accessibility toolkit on this site.', 'acceguru' ),
$items, // Each is individually escaped above.
esc_url( acceguru_pricing_url() ),
esc_html__( 'Upgrade to Pro', 'acceguru' )
);
}
/**
* Renders the right-side rail (Dashboard + Settings): the upgrade card (when
* the caller asks for it and the add-on is absent) plus the FAQ box, wrapped
* in a sticky container so both stay visible during scroll on wide screens.
*
* The upgrade card belongs on the Settings screen only, so the dashboard
* passes false and gets the FAQ alone.
*
* @param bool $include_upsell Whether to include the upgrade card.
* @return string Escaped HTML, safe to echo.
*/
function acceguru_rail( bool $include_upsell = true ): string {
$inner = ( $include_upsell ? acceguru_pro_upsell_box() : '' ) . acceguru_faq_box();
return sprintf(
'',
esc_attr__( 'AcceGuru sidebar', 'acceguru' ),
$inner
);
}
/**
* Bootstraps the plugin on plugins_loaded.
*/
function acceguru_init(): void {
// Translations: WP 4.6+ auto-loads .mo files for plugins hosted on
// wordpress.org as soon as the textdomain matches the slug, so we no
// longer call load_plugin_textdomain() ourselves.
// On version change, clear any cached dashboard aggregate so the new
// build's stale-scan filter takes effect immediately instead of waiting
// for the transient to expire.
$stored_version = get_option( 'acceguru_installed_version', '' );
$stored_version = is_scalar( $stored_version ) ? (string) $stored_version : '';
if ( $stored_version !== ACCEGURU_VERSION ) {
delete_transient( 'acceguru_dashboard_data' );
update_option( 'acceguru_installed_version', ACCEGURU_VERSION );
}
new Acceguru_Scanner();
new Acceguru_Alt_Text();
// Accessibility statement shortcode - needed in both admin (preview) and front-end.
add_shortcode( 'acceguru_statement', array( 'Acceguru_Admin', 'render_statement_shortcode' ) );
if ( is_admin() ) {
new Acceguru_Admin();
new Acceguru_Admin_Columns();
} else {
// Front-end only: apply automated fixes conditionally per settings.
new Acceguru_Fixer();
}
}
add_action( 'plugins_loaded', 'acceguru_init' );
/**
* Adds Documentation and Rate plugin links to the plugins-list row meta.
*
* @param array $links Existing meta links.
* @param string $plugin_file Plugin file path relative to plugins dir.
* @return array
*/
function acceguru_plugin_row_meta( array $links, string $plugin_file ): array {
if ( plugin_basename( __FILE__ ) !== $plugin_file ) {
return $links;
}
$extra = array(
'' . esc_html__( 'Documentation', 'acceguru' ) . '',
'' . esc_html__( 'Support', 'acceguru' ) . '',
'' . esc_html__( 'Rate plugin', 'acceguru' ) . '',
);
return array_merge( $links, $extra );
}
add_filter( 'plugin_row_meta', 'acceguru_plugin_row_meta', 10, 2 );
/**
* Adds a "Settings" link before "Deactivate" on the Plugins screen row.
*
* @param array $links Existing action links.
* @return array
*/
function acceguru_plugin_action_links( array $links ): array {
$settings_url = admin_url( 'admin.php?page=acceguru-settings' );
$settings_link = '' . esc_html__( 'Settings', 'acceguru' ) . '';
array_unshift( $links, $settings_link );
return $links;
}
add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'acceguru_plugin_action_links' );
/**
* Runs on plugin activation: sets default options in wp_options.
*/
function acceguru_activate(): void {
$defaults = array(
'wcag_level' => 'AA',
'include_best_practice' => 1,
'fix_skip_links' => 1,
'fix_focus_outlines' => 1,
'fix_html_lang' => 1,
// Low-impact fixes - safe to enable out of the box. They add
// screen-reader labels, strip harmful attributes, or make small
// non-design changes (an empty-search-results message, dropping
// redundant title tooltips); they do not restyle the normal page.
'fix_search_form_label' => 1,
'fix_label_comment_form' => 1,
'fix_empty_search_results' => 1,
'fix_remove_positive_tabindex' => 1,
'fix_remove_redundant_title' => 1,
'fix_viewport_scalable' => 1,
// Visible fixes - deliberately OFF by default because they change
// the site's appearance (new-window links get a visible "(opens in
// new tab)" marker with an arrow, underlined links, player controls
// over background videos, visible "(PDF)" labels). Owners opt in.
'fix_label_new_window' => 0,
'fix_force_link_underline' => 0,
'fix_autoplay_video_controls' => 0,
'fix_label_document_links' => 0,
'ai_provider' => 'openai',
'ai_model_openai' => 'gpt-4o-mini',
'ai_max_alt_length' => 125,
'ai_auto_alt' => 0,
);
foreach ( $defaults as $key => $value ) {
if ( false === get_option( 'acceguru_' . $key ) ) {
add_option( 'acceguru_' . $key, $value );
}
}
}
register_activation_hook( __FILE__, 'acceguru_activate' );
/**
* Runs on plugin deactivation: clears all plugin transients.
*/
function acceguru_deactivate(): void {
global $wpdb;
// One-shot bulk transient cleanup on deactivation; nothing to cache here.
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- One-shot bulk transient cleanup on deactivation; core has no delete-by-prefix API and caching is moot here.
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
$wpdb->esc_like( '_transient_acceguru_' ) . '%',
$wpdb->esc_like( '_transient_timeout_acceguru_' ) . '%'
)
);
}
register_deactivation_hook( __FILE__, 'acceguru_deactivate' );