fix_enabled( 'skip_links' ) ) {
// Classic themes: wp_body_open fires right after
opens.
// Priority -1 beats wp_admin_bar_render which runs at priority 0.
add_action( 'wp_body_open', array( $this, 'render_skip_link' ), -1 );
// Block/FSE themes (e.g. Twenty Twenty-Five) never call wp_body_open.
// wp_footer is a universal fallback; the JS will move the element to
// the top of so it is still the first focusable item.
add_action( 'wp_footer', array( $this, 'render_skip_link_fallback' ), 0 );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_skip_link_assets' ) );
}
if ( $this->fix_enabled( 'focus_outlines' ) ) {
// Priority 100 ensures 'global-styles' has been registered by core
// (which hooks at the default priority 10) before we check it as
// a dependency below.
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_focus_outline_assets' ), 100 );
}
if ( $this->fix_enabled( 'html_lang' ) ) {
add_filter( 'language_attributes', array( $this, 'ensure_html_lang' ), 99 );
}
// New v2 fixers.
if ( $this->fix_enabled( 'search_form_label' ) ) {
add_filter( 'get_search_form', array( $this, 'fix_search_form_label' ), 99 );
}
if ( $this->fix_enabled( 'empty_search_results' ) ) {
add_action( 'pre_get_posts', array( $this, 'fix_empty_search_results' ) );
}
// DOM-level v2 fixers run from a single JS file, gated per-fix by
// flags localised into the script. Enqueued only when at least one
// is on so we do not ship dead bytes.
$dom_fixes_on = $this->fix_enabled( 'remove_positive_tabindex' )
|| $this->fix_enabled( 'remove_redundant_title' )
|| $this->fix_enabled( 'viewport_scalable' )
|| $this->fix_enabled( 'label_new_window' )
|| $this->fix_enabled( 'label_document_links' )
|| $this->fix_enabled( 'force_link_underline' )
|| $this->fix_enabled( 'label_comment_form' )
|| $this->fix_enabled( 'autoplay_video_controls' );
if ( $dom_fixes_on ) {
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_runtime_fixers' ), 20 );
}
// Footer accessibility statement link - independent of fix toggles.
if ( (bool) get_option( 'acceguru_statement_footer_link', false ) ) {
add_action( 'wp_footer', array( $this, 'render_statement_footer_link' ), 100 );
}
}
/**
* Outputs a small accessibility statement link in the site footer.
* Linked to the published statement page configured in the Statement settings.
*/
public function render_statement_footer_link(): void {
$page_id = (int) get_option( 'acceguru_statement_page_id', 0 );
if ( $page_id <= 0 || 'publish' !== get_post_status( $page_id ) ) {
return;
}
$url = (string) get_permalink( $page_id );
$text = get_option( 'acceguru_statement_footer_text', '' );
$text = is_scalar( $text ) ? (string) $text : '';
if ( '' === $text ) {
$text = __( 'Accessibility Statement', 'acceguru' );
}
printf(
'',
esc_url( $url ),
esc_html( $text )
);
self::fix_applied( 'statement_footer_link', 1, array( 'runtime' => 'php', 'page_id' => $page_id ) );
}
/**
* Returns true when a given fix is enabled. Reads the wp_options toggle
* and runs it through the per-fix filter.
*
* @param string $name Fix name without the 'acceguru_fix_' prefix.
* @return bool
*/
public static function fix_enabled( string $name ): bool {
$option = (bool) ( 1 === absint( get_option( 'acceguru_fix_' . $name, 0 ) ) );
return (bool) apply_filters( 'acceguru_fix_enabled_' . $name, $option );
}
/**
* Emits the standard "fix applied" event.
*
* Fired from the most demonstrative fixers (skip link, html lang,
* statement footer link, runtime fixers via JS).
*
* @param string $name Fixer slug, e.g. 'skip_links', 'html_lang', 'form_labels'.
* @param int $count How many DOM nodes / cases this fixer touched (1 if not countable).
* @param array $context Optional extras: post_id, runtime ('php' | 'js'), selector, etc.
*/
public static function fix_applied( string $name, int $count = 1, array $context = array() ): void {
/**
* Fires every time a fixer applies its change.
*
* @param string $name Fixer slug.
* @param int $count Number of nodes / cases touched.
* @param array $context Extras (post_id, runtime, selector...).
*/
do_action( 'acceguru_fix_applied', $name, $count, $context );
}
// -------------------------------------------------------------------------
// Skip links - WCAG 2.4.1
// -------------------------------------------------------------------------
/**
* Outputs the "Skip to main content" link via wp_body_open (classic themes).
*/
public function render_skip_link(): void {
$this->skip_link_rendered = true;
$this->output_skip_link_html();
}
/**
* Fallback for block/FSE themes that do not call wp_body_open.
* Outputs the skip link inside wp_footer; the JS moves it to the top of
* so it remains the first focusable element on the page.
*/
public function render_skip_link_fallback(): void {
if ( $this->skip_link_rendered ) {
return;
}
$this->output_skip_link_html();
}
/**
* Prints the skip link anchor. Shared by both render methods.
*/
private function output_skip_link_html(): void {
printf(
'%s',
esc_html__( 'Skip to main content', 'acceguru' )
);
self::fix_applied( 'skip_links', 1, array( 'runtime' => 'php' ) );
}
/**
* Enqueues the shared fixer stylesheet and the skip-link resolver script.
* The stylesheet handle is shared with the focus-outlines fix and is
* idempotent - wp_enqueue_style is a no-op when the handle already exists.
*/
public function enqueue_skip_link_assets(): void {
wp_enqueue_style(
'acceguru-fixer',
ACCEGURU_URL . 'assets/css/acceguru-fixer.css',
array(),
ACCEGURU_VERSION
);
wp_enqueue_script(
'acceguru-skip-link',
ACCEGURU_URL . 'assets/js/acceguru-skip-link.js',
array(),
ACCEGURU_VERSION,
true
);
// Custom skip-link targets from settings: comma- (or whitespace-)
// separated CSS selectors. Tried first, before the default list.
$raw = get_option( 'acceguru_skip_link_targets', '' );
$raw = is_scalar( $raw ) ? (string) $raw : '';
$tokens = preg_split( '/[\s,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY );
$targets = is_array( $tokens ) ? array_values( array_filter( array_map( 'trim', $tokens ) ) ) : array();
wp_localize_script(
'acceguru-skip-link',
'acceguruSkipLink',
array( 'targets' => $targets )
);
}
// -------------------------------------------------------------------------
// Focus outlines - WCAG 2.4.7 / 2.4.11
// -------------------------------------------------------------------------
/**
* Enqueues the focus outline stylesheet on the front end.
*
* Declares 'global-styles' as a dependency when it exists so that our
* stylesheet is output AFTER the block-theme global stylesheet in .
* This ensures our !important colour rules win the cascade when both
* declarations carry !important and specificity is equal - source order
* (last declaration wins) is the tie-breaker.
*/
public function enqueue_focus_outline_assets(): void {
$deps = array();
// Block themes (WP 5.8+) register 'global-styles'; declaring it as a
// dependency forces our to appear after theirs in .
if ( wp_style_is( 'global-styles', 'registered' ) ) {
$deps[] = 'global-styles';
}
wp_enqueue_style(
'acceguru-fixer',
ACCEGURU_URL . 'assets/css/acceguru-fixer.css',
$deps,
ACCEGURU_VERSION
);
}
// -------------------------------------------------------------------------
// HTML lang attribute - WCAG 3.1.1
// -------------------------------------------------------------------------
/**
* Filter callback for language_attributes. Ensures a non-empty lang=""
* attribute is present on . WordPress core already outputs this in
* almost every theme, but some templates and child themes drop it; this
* acts as a safety net.
*
* @param string $attributes The raw attribute string (e.g. lang="en-US").
* @return string
*/
public function ensure_html_lang( string $attributes ): string {
$changed = false;
// --- lang ---
// A real lang is present when lang= exists and is not an empty lang="".
$has_real_lang = ( false !== stripos( $attributes, 'lang=' ) )
&& ! preg_match( '/lang=(["\'])\s*\1/', $attributes );
if ( ! $has_real_lang ) {
$locale = str_replace( '_', '-', (string) get_bloginfo( 'language' ) );
if ( '' === $locale ) {
$locale = str_replace( '_', '-', (string) get_locale() );
}
if ( '' !== $locale ) {
// Strip any empty lang="" we found, then prepend a fresh one.
$attributes = (string) preg_replace( '/\s*lang=(["\'])\s*\1/', '', $attributes );
$prefix = sprintf( 'lang="%s"', esc_attr( $locale ) );
$attributes = '' === trim( $attributes ) ? $prefix : $prefix . ' ' . trim( $attributes );
$changed = true;
}
}
// --- dir ---
// Text direction (rtl/ltr) so RTL languages (Arabic, Hebrew) render the
// right way round. Only added when the theme has not set one already.
if ( false === stripos( $attributes, 'dir=' ) ) {
$attributes = trim( $attributes . ' ' . sprintf( 'dir="%s"', esc_attr( is_rtl() ? 'rtl' : 'ltr' ) ) );
$changed = true;
}
if ( $changed ) {
self::fix_applied( 'html_lang', 1, array( 'runtime' => 'php' ) );
}
return $attributes;
}
// -------------------------------------------------------------------------
// V2 fixers
// -------------------------------------------------------------------------
/**
* Gives the WordPress search field an accessible name by adding aria-label
* directly to the , so the field is named even when the theme hides
* or omits a visible label. Leaves the field alone only when it already has
* a NON-EMPTY name: a non-blank aria-label or title on the field, an
* aria-labelledby that points at an existing element with real text, a
*