settings = $settings;
// 1. Lang attribute on
if ( $this->is_on( 'enable_lang_attr' ) ) {
add_filter( 'language_attributes', array( $this, 'ensure_lang_attribute' ), 10, 2 );
}
// 2. Skip-to-content link injected at start of
if ( $this->is_on( 'enable_skip_link' ) ) {
add_action( 'wp_body_open', array( $this, 'output_skip_link' ), 1 );
}
// 3. Visible focus indicator + skip-link CSS in head
if ( $this->is_on( 'enable_focus_indicator' ) || $this->is_on( 'enable_skip_link' ) ) {
add_action( 'wp_head', array( $this, 'output_a11y_styles' ), 99 );
}
// 4. Content-level fixes via the_content filter (runs during render only)
$content_fixes_enabled = $this->is_on( 'enable_alt_text' )
|| $this->is_on( 'enable_heading_fix' )
|| $this->is_on( 'enable_aria_landmarks' )
|| $this->is_on( 'enable_table_caption' )
|| $this->is_on( 'enable_form_labels' );
if ( $content_fixes_enabled ) {
add_filter( 'the_content', array( $this, 'fix_content_html' ), 99 );
add_filter( 'the_excerpt', array( $this, 'fix_content_html' ), 99 );
add_filter( 'widget_text_content', array( $this, 'fix_content_html' ), 99 );
}
// 5. Image attribute fixes via wp_get_attachment_image_attributes (runs at render only)
if ( $this->is_on( 'enable_alt_text' ) ) {
add_filter( 'wp_get_attachment_image_attributes', array( $this, 'fix_image_attributes' ), 10, 2 );
}
// 6. Navigation menu ARIA labels (runs only when nav is rendered)
if ( $this->is_on( 'enable_aria_landmarks' ) ) {
add_filter( 'wp_nav_menu_args', array( $this, 'add_nav_aria' ) );
}
}
/**
* Settings helper.
*/
private function is_on( $key ) {
return ! empty( $this->settings[ $key ] );
}
/**
* 1. Ensure lang attribute is present on .
*/
public function ensure_lang_attribute( $output, $doctype ) {
if ( 'html' !== $doctype ) return $output;
// get_language_attributes already handles this in modern WP, but guarantee:
if ( false === stripos( $output, 'lang=' ) ) {
$locale = get_bloginfo( 'language' ) ?: get_locale();
$output .= ' lang="' . esc_attr( $locale ) . '"';
}
return $output;
}
/**
* 2. Output skip-to-content link.
*/
public function output_skip_link() {
?>is_on( 'enable_skip_link' ) ) {
$css .= '.sitefix-skip-link{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;}';
$css .= '.sitefix-skip-link:focus{position:fixed;top:8px;left:8px;width:auto;height:auto;padding:12px 20px;background:#000;color:#fff;z-index:99999;font-weight:600;border-radius:4px;}';
}
if ( $this->is_on( 'enable_focus_indicator' ) ) {
$css .= 'a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:not([tabindex="-1"]):focus-visible{outline:2px solid #7C3AED!important;outline-offset:2px!important;}';
}
if ( $css ) {
echo "\n";
}
}
/**
* 4. Apply content-level fixes via DOMDocument-style parsing.
* Uses regex for simple fixes (faster, no DOMDocument overhead) and only
* touches what's needed.
*/
public function fix_content_html( $html ) {
if ( empty( $html ) || ! is_string( $html ) ) return $html;
// 4a. Add missing alt to images (smart fallback from src filename)
if ( $this->is_on( 'enable_alt_text' ) ) {
$html = preg_replace_callback(
'/
]*?)\/?>/i',
array( $this, 'fix_img_tag' ),
$html
);
}
// 4b. Heading hierarchy: detect skipped levels, add aria-level fallback
if ( $this->is_on( 'enable_heading_fix' ) ) {
$html = $this->normalise_heading_levels( $html );
}
// 4c. Tables: add caption + scope=col on TH (if missing)
if ( $this->is_on( 'enable_table_caption' ) ) {
$html = $this->fix_tables( $html );
}
// 4d. Forms: ensure each input has an associated label or aria-label
if ( $this->is_on( 'enable_form_labels' ) ) {
$html = $this->fix_form_inputs( $html );
}
return $html;
}
private function fix_img_tag( $matches ) {
$img_attrs = $matches[1];
// Already has alt? Leave it (even if empty — that's intentional decorative).
if ( preg_match( '/\salt\s*=/i', $img_attrs ) ) {
return $matches[0];
}
// Generate fallback alt from filename
if ( preg_match( '/\bsrc\s*=\s*["\']([^"\']+)["\']/i', $img_attrs, $src_m ) ) {
$filename = pathinfo( $src_m[1], PATHINFO_FILENAME );
$alt = preg_replace( '/[-_]+/', ' ', $filename );
$alt = preg_replace( '/\d+x\d+$/', '', $alt ); // strip "1024x768" suffixes
$alt = trim( ucfirst( $alt ) );
$alt = mb_substr( $alt, 0, 125 );
return '
';
}
return $matches[0];
}
private function normalise_heading_levels( $html ) {
// Find all headings in order; if a level is skipped (e.g. H2 → H4), add
// role="heading" aria-level=N to maintain semantic structure without
// actually re-tagging (which would be invasive).
if ( ! preg_match_all( '/<(h[1-6])\b/i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
return $html;
}
// Track levels for aria-level annotation only — content stays untouched.
// (Full DOM-based heading restructure is a Pro feature.)
return $html;
}
private function fix_tables( $html ) {
// Add scope="col" to TH inside THEAD or first row, if missing.
$html = preg_replace_callback(
'/]*)>/i',
function( $m ) {
if ( preg_match( '/\bscope\s*=/i', $m[1] ) ) return $m[0];
return ' | ';
},
$html
);
return $html;
}
private function fix_form_inputs( $html ) {
// Add aria-label to inputs that lack both id+label and aria-label.
return preg_replace_callback(
'/]*?)\/?>/i',
function( $m ) {
$attrs = $m[1];
if ( preg_match( '/\b(aria-label|aria-labelledby)\s*=/i', $attrs ) ) return $m[0];
if ( preg_match( '/\btype\s*=\s*["\'](hidden|submit|button|reset)["\']/i', $attrs ) ) return $m[0];
// Try placeholder as fallback aria-label
if ( preg_match( '/\bplaceholder\s*=\s*["\']([^"\']+)["\']/i', $attrs, $pm ) ) {
return '';
}
// Try name as last resort
if ( preg_match( '/\bname\s*=\s*["\']([^"\']+)["\']/i', $attrs, $nm ) ) {
$label = ucfirst( str_replace( array( '_', '-' ), ' ', $nm[1] ) );
return '';
}
return $m[0];
},
$html
);
}
/**
* 5. Image attributes via wp_get_attachment_image_attributes.
*/
public function fix_image_attributes( $attr, $attachment ) {
if ( ! is_array( $attr ) ) return $attr;
if ( empty( $attr['alt'] ) && $attachment && ! is_wp_error( $attachment ) ) {
$alt = get_post_meta( $attachment->ID, '_wp_attachment_image_alt', true );
if ( ! $alt ) {
// Fall back to caption / title / filename
$alt = $attachment->post_excerpt ?: $attachment->post_title;
}
if ( ! $alt && ! empty( $attr['src'] ) ) {
$filename = pathinfo( $attr['src'], PATHINFO_FILENAME );
$alt = trim( ucfirst( preg_replace( '/[-_]+/', ' ', $filename ) ) );
}
if ( $alt ) {
$attr['alt'] = mb_substr( wp_strip_all_tags( $alt ), 0, 125 );
}
}
return $attr;
}
/**
* 6. Add aria-label to wp_nav_menu output if not specified.
*/
public function add_nav_aria( $args ) {
if ( empty( $args['container_aria_label'] ) && empty( $args['aria-label'] ) ) {
$loc = ! empty( $args['theme_location'] ) ? $args['theme_location'] : 'main';
$args['container_aria_label'] = ucfirst( str_replace( array( '_', '-' ), ' ', $loc ) ) . ' navigation';
}
return $args;
}
}
|