Files
advanced-mcp-plugin/includes/Extensions/Batch3.php
T

222 lines
10 KiB
PHP

<?php
/**
* Batch 3 — Accessibility auditor (Pro-equivalent, net-new, read-only).
*
* Reimplements the a11y / contrast capabilities of the elementor-mcp Pro tier as
* independent code: WCAG 2.1 contrast math, missing-alt detection, heading-order
* checks, empty-link/button detection — across post content and Elementor data.
* No proprietary Pro source used. GPL-2.0-or-later. See NOTICE.md.
*
* All tools are read-only. Registered via the asktam_mcp_register_tools hook.
*/
namespace AskTamMCP\Extensions;
defined('ABSPATH') || exit;
class Batch3 {
public static function register($registry): void {
$def = function (string $name, string $desc, array $props, array $required, string $method) {
$schema = ['type' => '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('<?xml encoding="utf-8" ?>' . $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('<?xml encoding="utf-8" ?>' . $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];
}
}