diff --git a/includes/Extensions/Batch3.php b/includes/Extensions/Batch3.php new file mode 100644 index 0000000..5d5d285 --- /dev/null +++ b/includes/Extensions/Batch3.php @@ -0,0 +1,221 @@ + '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]; + } +}