release: v3.1.0 premium smart core engines and 14 new accessibility rules

This commit is contained in:
Enes Yakıştır
2026-07-13 10:54:25 +03:00
parent 7f3bf3cb7d
commit 5292c2c865
67 changed files with 2639 additions and 29 deletions
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaLabelledbyExists extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_LABELLEDBY'; }
public function getDescription(): string { return 'aria-labelledby and aria-describedby attributes must refer to existing element IDs.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$attrs = ['aria-labelledby', 'aria-describedby'];
foreach ($attrs as $attr) {
if ($element->hasAttribute($attr)) {
$ids = preg_split('/\s+/', trim($element->getAttribute($attr)));
if ($ids === false) {
continue;
}
$doc = $element->ownerDocument;
if (!$doc) {
continue;
}
foreach ($ids as $id) {
if ($id === '') {
continue;
}
$target = $doc->getElementById($id);
if (!$target) {
// XPath fallback in case DOMDocument getElementById fails (which is common without DTD/XML schemas)
$xpath = new \DOMXPath($doc);
$query = $xpath->query("//*[@id='" . addslashes($id) . "']");
if ($query === false || $query->length === 0) {
return $this->createViolation(
$element,
"The attribute $attr refers to a non-existent ID: \"$id\".",
"Ensure the element referenced by ID \"$id\" exists in the document, or correct the ID spelling."
);
}
}
}
}
}
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutocompleteValid extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE'; }
public function getDescription(): string { return 'Input fields that collect common user information must have a valid autocomplete attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
$type = strtolower($element->getAttribute('type') ?: 'text');
if (in_array($type, ['hidden', 'submit', 'button', 'reset', 'file', 'checkbox', 'radio', 'image'], true)) {
return null;
}
$name = strtolower($element->getAttribute('name'));
$id = strtolower($element->getAttribute('id'));
$personalFields = ['email', 'tel', 'phone', 'zip', 'postal', 'address', 'username', 'password', 'name', 'fname', 'lname', 'search'];
$isPersonal = false;
if ($type === 'email' || $type === 'tel' || $type === 'password') {
$isPersonal = true;
} else {
foreach ($personalFields as $field) {
if (str_contains($name, $field) || str_contains($id, $field)) {
$isPersonal = true;
break;
}
}
}
if ($isPersonal && !$element->hasAttribute('autocomplete')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a valid autocomplete attribute (e.g., autocomplete="email" or autocomplete="current-password").'
);
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutofocusBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_3_AUTOFOCUS'; }
public function getDescription(): string { return 'Form controls should not use the autofocus attribute as it disorients screen reader and keyboard users.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('autofocus')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the autofocus attribute and let the user navigate to the form field naturally, or use script-based focus with care.'
);
}
return null;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use Symfony\Component\DomCrawler\Crawler;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class BannedSelectors extends AbstractRule
{
/** @var array<string, string> */
private array $bannedSelectors = [
'center' => 'Obsolete <center> tag. Use CSS text-align instead.',
'blink' => 'Obsolete <blink> tag. Avoid blinking/flashing content.',
'marquee' => 'Obsolete <marquee> tag. Avoid moving content.',
'font' => 'Obsolete <font> tag. Use CSS font styling instead.',
'*[align]' => 'Deprecated align attribute. Use CSS text-align or float instead.',
'*[bgcolor]' => 'Deprecated bgcolor attribute. Use CSS background-color instead.',
];
public function check(\DOMDocument $doc): array
{
$violations = [];
$crawler = new Crawler($doc);
foreach ($this->bannedSelectors as $selector => $message) {
try {
$nodes = $crawler->filter($selector);
foreach ($nodes as $node) {
if ($node instanceof \DOMElement) {
$violations[] = $this->createViolation(
sprintf('[Banned Selector: "%s"] %s', $selector, $message),
$node
);
}
}
} catch (\Throwable $e) {
// Skip invalid selector configurations
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class BlinkTagBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_2_2_BLINK'; }
public function getDescription(): string { return 'The <blink> tag is obsolete and should not be used as it causes text to flash/blink.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) === 'blink') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <blink> tag and use CSS styling/animations if needed, or preferably avoid blinking content.'
);
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateH1 extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DUPLICATE_H1'; }
public function getDescription(): string { return 'Pages should not contain more than one h1 element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'h1') {
return null;
}
$doc = $element->ownerDocument;
if ($doc) {
$h1s = $doc->getElementsByTagName('h1');
if ($h1s->length > 1 && $h1s->item(0) !== $element) {
return $this->createViolation(
$element,
$this->getDescription(),
'Change this heading to an h2 or lower level heading, keeping only one h1 per page.'
);
}
}
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormSubmitButton extends AbstractRule
{
public function getId(): string { return 'WCAG_3_2_2_FORM_SUBMIT'; }
public function getDescription(): string { return 'Every form must contain a submit button to enable keyboard submission.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'form') {
return null;
}
$hasSubmit = false;
$buttons = $element->getElementsByTagName('button');
foreach ($buttons as $btn) {
$type = strtolower($btn->getAttribute('type') ?: 'submit');
if ($type === 'submit') {
$hasSubmit = true;
break;
}
}
if (!$hasSubmit) {
$inputs = $element->getElementsByTagName('input');
foreach ($inputs as $input) {
$type = strtolower($input->getAttribute('type'));
if ($type === 'submit' || $type === 'image') {
$hasSubmit = true;
break;
}
}
}
if (!$hasSubmit) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a submit control, such as a <button type="submit"> or <input type="submit"> inside the form.'
);
}
return null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltLong extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_LONG'; }
public function getDescription(): string { return 'Image alt text should be concise (less than 150 characters).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
if (mb_strlen($alt) > 150) {
return $this->createViolation(
$element,
$this->getDescription(),
'Shorten the alt text description to be under 150 characters, and move longer explanations to standard page content.'
);
}
return null;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltPlaceholder extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_PLACEHOLDER'; }
public function getDescription(): string { return 'Image alt text should not be a generic placeholder or filename.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
if ($alt === '') {
return null; // Empty alt is allowed for decorative images (WCAG 1.1.1)
}
$cleanAlt = strtolower($alt);
// Generic placeholder checks
$placeholders = [
'image', 'photo', 'picture', 'logo', 'icon', 'placeholder', 'untitled', 'temp', 'blank', 'pic', 'dot',
'resim', 'foto', 'fotoğraf', 'görsel', 'grafik', 'ikon', 'logo'
];
if (in_array($cleanAlt, $placeholders, true)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Provide a specific description of what the image shows, rather than using a generic word.'
);
}
// Filename checks
if (preg_match('/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt)) {
return $this->createViolation(
$element,
'Image alt text should not be a filename.',
'Provide a descriptive alternative text instead of the image filename.'
);
}
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LinkTextUrl extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_4_LINK_URL'; }
public function getDescription(): string { return 'Link text should not be a raw URL.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
$text = trim($element->textContent);
if (preg_match('/^(https?:\/\/|www\.)[^\s]+$/i', $text)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Replace the raw URL link text with a descriptive label explaining where the link goes.'
);
}
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ListStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LIST_STRUCTURE'; }
public function getDescription(): string { return 'Lists (ul, ol) must only contain list item elements (li) as direct children.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if ($tag !== 'ul' && $tag !== 'ol') {
return null;
}
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, ['li', 'script', 'template'], true)) {
return $this->createViolation(
$element,
sprintf('List element <%s> contains invalid direct child <%s>. Only <li> elements are allowed.', $tag, $childTag),
'Wrap the child element in an <li> tag, or move it outside the list structure.'
);
}
}
}
return null;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class PresentationTagsBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_PRESENTATION_TAGS'; }
public function getDescription(): string { return 'Obsolete presentation tags (<center>, <strike>, <basefont>, <tt>, <big>) should not be used.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
$bannedTags = ['center', 'strike', 'basefont', 'tt', 'big'];
$tag = strtolower($element->tagName);
if (in_array($tag, $bannedTags, true)) {
return $this->createViolation(
$element,
sprintf('The <%s> presentation tag is obsolete and should not be used.', $tag),
sprintf('Remove the <%s> tag and use modern CSS styling instead.', $tag)
);
}
return null;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ScrollableRegionFocusable extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_SCROLLABLE_FOCUS'; }
public function getDescription(): string { return 'Scrollable regions must be focusable via keyboard using tabindex="0".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$style = $element->getAttribute('style');
if ($style && preg_match('/overflow(-x|-y)?\s*:\s*(scroll|auto)/i', $style)) {
if (!$element->hasAttribute('tabindex') || (int)$element->getAttribute('tabindex') < 0) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add tabindex="0" to the scrollable element so keyboard users can navigate to and scroll it.'
);
}
}
return null;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SvgAltText extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_SVG'; }
public function getDescription(): string { return 'SVG elements must have alternative text or be hidden from screen readers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'svg') {
return null;
}
if ($element->getAttribute('aria-hidden') === 'true') {
return null;
}
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
return null;
}
$titles = $element->getElementsByTagName('title');
if ($titles->length > 0) {
$titleNode = $titles->item(0);
if ($titleNode !== null) {
$titleText = trim($titleNode->textContent);
if ($titleText !== '') {
return null;
}
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add an aria-label, aria-labelledby, a <title> element inside the SVG, or set aria-hidden="true" if it is decorative.'
);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableSummaryBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TABLE_SUMMARY'; }
public function getDescription(): string { return 'The summary attribute on table elements is obsolete and should not be used.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'table') {
return null;
}
if ($element->hasAttribute('summary')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the summary attribute. Use the <caption> element or aria-describedby instead.'
);
}
return null;
}
}