222 lines
10 KiB
PHP
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];
|
|
}
|
|
}
|