'object', 'properties' => $props]; if ($required) $schema['required'] = $required; return ['name' => $name, 'description' => $desc, 'scope' => 'read', 'handler' => 'AskTamMCP\\Extensions\\Batch3::' . $method, 'inputSchema' => $schema]; }; $registry->register($def('a11y_contrast_check', 'Compute the WCAG 2.1 contrast ratio between two hex colours and report AA/AAA pass for normal and large text.', ['foreground' => ['type' => 'string'], 'background' => ['type' => 'string']], ['foreground', 'background'], 'contrastCheck')); $registry->register($def('a11y_audit_page', 'Accessibility audit of one page: images missing alt text, heading-order skips, and empty links/buttons. Scans post content and Elementor data.', ['post_id' => ['type' => 'integer']], ['post_id'], 'auditPage')); $registry->register($def('a11y_heading_outline', 'Return the ordered heading outline (level + text) of a page and flag skipped heading levels.', ['post_id' => ['type' => 'integer']], ['post_id'], 'headingOutline')); $registry->register($def('a11y_images_missing_alt', 'List media-library image attachments that have no alt text.', ['per_page' => ['type' => 'integer', 'default' => 100]], [], 'imagesMissingAlt')); $registry->register($def('a11y_audit_site', 'Run the page accessibility audit across published pages/posts and return per-page issue counts.', ['post_types' => ['type' => 'array', 'items' => ['type' => 'string'], 'default' => ['page']], 'per_page' => ['type' => 'integer', 'default' => 25]], [], 'auditSite')); } // ---- CONTRAST ---- private static function luminance(string $hex): float { $hex = ltrim(trim($hex), '#'); if (strlen($hex) === 3) $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) throw new \RuntimeException("Invalid hex colour: {$hex}"); $lin = []; foreach ([0, 2, 4] as $i) { $v = hexdec(substr($hex, $i, 2)) / 255; $lin[] = $v <= 0.03928 ? $v / 12.92 : pow(($v + 0.055) / 1.055, 2.4); } return 0.2126 * $lin[0] + 0.7152 * $lin[1] + 0.0722 * $lin[2]; } public static function contrastCheck(array $args): array { $fg = (string) ($args['foreground'] ?? ''); $bg = (string) ($args['background'] ?? ''); $l1 = self::luminance($fg) + 0.05; $l2 = self::luminance($bg) + 0.05; $ratio = round(max($l1, $l2) / min($l1, $l2), 2); return [ 'foreground' => $fg, 'background' => $bg, 'ratio' => $ratio, 'aa_normal' => $ratio >= 4.5, 'aa_large' => $ratio >= 3.0, 'aaa_normal' => $ratio >= 7.0, 'aaa_large' => $ratio >= 4.5, ]; } // ---- PAGE AUDIT ---- public static function auditPage(array $args): array { $id = (int) ($args['post_id'] ?? 0); $post = $id ? get_post($id) : null; if (!$post) throw new \RuntimeException("Post not found: {$id}"); $imagesNoAlt = []; $emptyLinks = []; $headings = []; // Post content via DOM $html = (string) $post->post_content; if ($html !== '' && class_exists('DOMDocument')) { $dom = new \DOMDocument(); libxml_use_internal_errors(true); $dom->loadHTML('' . $html); libxml_clear_errors(); foreach ($dom->getElementsByTagName('img') as $img) { $alt = trim($img->getAttribute('alt')); if ($alt === '') $imagesNoAlt[] = ['source' => 'content', 'src' => $img->getAttribute('src')]; } foreach ($dom->getElementsByTagName('a') as $a) { $txt = trim($a->textContent); $aria = trim($a->getAttribute('aria-label')); if ($txt === '' && $aria === '') $emptyLinks[] = ['source' => 'content', 'href' => $a->getAttribute('href')]; } foreach (['h1','h2','h3','h4','h5','h6'] as $hx) { foreach ($dom->getElementsByTagName($hx) as $h) { $headings[] = ['level' => (int) substr($hx, 1), 'text' => trim($h->textContent)]; } } } // Elementor data $raw = get_post_meta($id, '_elementor_data', true); if (!empty($raw)) { $data = json_decode($raw, true); if (is_array($data)) self::walkA11y($data, $imagesNoAlt, $emptyLinks, $headings); } $headingIssues = self::headingSkips($headings); return [ 'post_id' => $id, 'title' => $post->post_title, 'images_missing_alt' => $imagesNoAlt, 'empty_links_buttons' => $emptyLinks, 'heading_issues' => $headingIssues, 'summary' => [ 'images_missing_alt' => count($imagesNoAlt), 'empty_links_buttons' => count($emptyLinks), 'heading_issues' => count($headingIssues), ], ]; } private static function walkA11y(array $elements, array &$img, array &$links, array &$headings): void { foreach ($elements as $el) { $wt = $el['widgetType'] ?? ''; $s = $el['settings'] ?? []; if (is_array($s)) { if ($wt === 'image' || $wt === 'image-box') { $url = $s['image']['url'] ?? ($s['selected_icon']['value'] ?? ''); $alt = trim((string) ($s['alt'] ?? '')); if ($url && $alt === '') $img[] = ['source' => 'elementor:' . $wt, 'element_id' => $el['id'] ?? '', 'src' => $url]; } if ($wt === 'button') { $txt = trim((string) ($s['text'] ?? '')); if ($txt === '') $links[] = ['source' => 'elementor:button', 'element_id' => $el['id'] ?? '']; } if ($wt === 'heading') { $lvl = (int) preg_replace('/\D/', '', (string) ($s['header_size'] ?? 'h2')) ?: 2; $headings[] = ['level' => $lvl, 'text' => trim(wp_strip_all_tags((string) ($s['title'] ?? ''))), 'element_id' => $el['id'] ?? '']; } } if (!empty($el['elements']) && is_array($el['elements'])) self::walkA11y($el['elements'], $img, $links, $headings); } } private static function headingSkips(array $headings): array { $issues = []; $prev = 0; foreach ($headings as $h) { $lvl = (int) $h['level']; if ($prev > 0 && $lvl > $prev + 1) { $issues[] = ['from_level' => $prev, 'to_level' => $lvl, 'text' => $h['text'], 'note' => "Heading level jumps from h{$prev} to h{$lvl}"]; } $prev = $lvl; } return $issues; } public static function headingOutline(array $args): array { $res = self::auditPage($args); // Rebuild outline from the same scan for a clean ordered view. $id = (int) ($args['post_id'] ?? 0); $post = get_post($id); $headings = []; $html = (string) $post->post_content; if ($html !== '' && class_exists('DOMDocument')) { $dom = new \DOMDocument(); libxml_use_internal_errors(true); $dom->loadHTML('' . $html); libxml_clear_errors(); foreach (['h1','h2','h3','h4','h5','h6'] as $hx) { foreach ($dom->getElementsByTagName($hx) as $h) { $headings[] = ['level' => (int) substr($hx, 1), 'text' => trim($h->textContent)]; } } } $raw = get_post_meta($id, '_elementor_data', true); if (!empty($raw)) { $data = json_decode($raw, true); $img = []; $links = []; if (is_array($data)) self::walkA11y($data, $img, $links, $headings); } return ['post_id' => $id, 'outline' => $headings, 'issues' => self::headingSkips($headings)]; } public static function imagesMissingAlt(array $args): array { $per = (int) ($args['per_page'] ?? 100); $atts = get_posts(['post_type' => 'attachment', 'post_mime_type' => 'image', 'post_status' => 'inherit', 'posts_per_page' => $per]); $missing = []; foreach ($atts as $a) { $alt = trim((string) get_post_meta($a->ID, '_wp_attachment_image_alt', true)); if ($alt === '') $missing[] = ['id' => $a->ID, 'title' => $a->post_title, 'url' => wp_get_attachment_url($a->ID)]; } return ['scanned' => count($atts), 'missing_alt' => $missing, 'missing_count' => count($missing)]; } public static function auditSite(array $args): array { $types = $args['post_types'] ?? ['page']; $per = (int) ($args['per_page'] ?? 25); $posts = get_posts(['post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $per, 'fields' => 'ids']); $pages = []; $totals = ['images_missing_alt' => 0, 'empty_links_buttons' => 0, 'heading_issues' => 0]; foreach ($posts as $pid) { $a = self::auditPage(['post_id' => (int) $pid]); $pages[] = ['post_id' => (int) $pid, 'title' => $a['title'], 'summary' => $a['summary']]; foreach ($totals as $k => $_) $totals[$k] += $a['summary'][$k]; } return ['audited' => count($posts), 'totals' => $totals, 'pages' => $pages]; } }