release: v3.2.0 - Enterprise Edition with Live Web Dashboard, PDF/Excel Exporters, Framework Stubs & 110 WCAG Rules

This commit is contained in:
Enes Yakıştır
2026-07-31 00:13:31 +03:00
parent 58b1618d96
commit 73fbf485d2
36 changed files with 1420 additions and 11 deletions
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use Spatie\Color\Hex;
use YakNet\AccessibilityConsole\Color\Contrast\ContrastRatioCalculator;
use YakNet\AccessibilityConsole\Color\Simulators\DeuteranopiaSimulator;
use YakNet\AccessibilityConsole\Color\Simulators\ProtanopiaSimulator;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ColorBlindnessContrast extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_1_COLOR_BLINDNESS_CONTRAST'; }
public function getDescription(): string { return 'Text and background color contrast must remain accessible (minimum 4.5:1) under color vision deficiency simulations (Deuteranopia, Protanopia).'; }
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 (!$element->hasAttribute('style')) {
return null;
}
$style = $element->getAttribute('style');
$colorHex = $this->extractColorFromStyle($style, 'color');
$bgHex = $this->extractColorFromStyle($style, 'background-color') ?? $this->extractColorFromStyle($style, 'background');
if (!$colorHex || !$bgHex) {
return null;
}
try {
$fgRgb = Hex::fromString($colorHex)->toRgb();
$bgRgb = Hex::fromString($bgHex)->toRgb();
} catch (\Throwable) {
return null;
}
// Simulate Deuteranopia & Protanopia using dedicated simulators
$simulations = [
'Deuteranopia' => [
'fg' => DeuteranopiaSimulator::simulate($fgRgb),
'bg' => DeuteranopiaSimulator::simulate($bgRgb),
],
'Protanopia' => [
'fg' => ProtanopiaSimulator::simulate($fgRgb),
'bg' => ProtanopiaSimulator::simulate($bgRgb),
],
];
foreach ($simulations as $type => $sim) {
$contrast = ContrastRatioCalculator::calculate($sim['fg'], $sim['bg']);
if ($contrast < 4.5) {
return $this->createViolation(
$element,
sprintf('Insufficient color contrast (%.2f:1) under %s color blindness simulation (minimum 4.5:1 required).', $contrast, $type),
sprintf('Adjust foreground color (%s) or background color (%s) to increase contrast for users with %s.', $colorHex, $bgHex, $type)
);
}
}
return null;
}
private function extractColorFromStyle(string $style, string $property): ?string
{
if (preg_match('/' . preg_quote($property, '/') . '\s*:\s*(#[0-9a-fA-F]{3,6})/i', $style, $matches)) {
$hex = $matches[1];
if (strlen($hex) === 4) {
$hex = '#' . $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3];
}
return $hex;
}
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 ContentInfoParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_CONTENTINFO_PARENT'; }
public function getDescription(): string { return 'Footer contentinfo landmark must be at top-level body or within valid sectioning root.'; }
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) !== 'footer' && $element->getAttribute('role') !== 'contentinfo') {
return null;
}
$parent = $element->parentNode;
while ($parent instanceof DOMElement) {
$pTag = strtolower($parent->tagName);
if (in_array($pTag, ['article', 'aside', 'main', 'nav', 'section'], true)) {
return $this->createViolation(
$element,
sprintf('Footer contentinfo landmark is nested inside <%s> sectioning element.', $pTag),
'Place global footer contentinfo landmark at the top level of <body> rather than nested inside sectioning elements.'
);
}
$parent = $parent->parentNode;
}
return null;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageDimensionsAndScale extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_4_IMAGE_DIMENSIONS_SCALE'; }
public function getDescription(): string { return 'Images must specify explicit width and height attributes or CSS dimensions to prevent layout shifts and ensure accessible touch target sizes.'; }
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) !== 'img') {
return null;
}
$hasWidthAttr = $element->hasAttribute('width') && !empty(trim($element->getAttribute('width')));
$hasHeightAttr = $element->hasAttribute('height') && !empty(trim($element->getAttribute('height')));
$style = $element->getAttribute('style');
$hasWidthStyle = preg_match('/width\s*:\s*[^;]+/i', $style) === 1;
$hasHeightStyle = preg_match('/height\s*:\s*[^;]+/i', $style) === 1;
if (!$hasWidthAttr && !$hasWidthStyle) {
return $this->createViolation(
$element,
'Image element is missing explicit width attributes or CSS width rules.',
'Specify explicit width and height attributes (e.g. width="300" height="200") on the <img> tag to prevent layout shifts (CLS).'
);
}
if (!$hasHeightAttr && !$hasHeightStyle) {
return $this->createViolation(
$element,
'Image element is missing explicit height attributes or CSS height rules.',
'Specify explicit height attributes on the <img> tag to ensure proper rendering aspect ratio.'
);
}
// Check if image target is too small when nested in a link or button
if ($hasWidthAttr && $hasHeightAttr) {
$w = (int)$element->getAttribute('width');
$h = (int)$element->getAttribute('height');
$parentTag = strtolower($element->parentNode ? $element->parentNode->nodeName : '');
if (($parentTag === 'a' || $parentTag === 'button') && ($w < 24 || $h < 24)) {
return $this->createViolation(
$element,
sprintf('Interactive image target dimensions (%dx%dpx) are smaller than the recommended 24x24px minimum touch target size.', $w, $h),
'Increase interactive image target size or add padding to meet WCAG 2.5.5 touch target size requirements.'
);
}
}
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 InputTypePasswordVisibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_PASSWORD_VISIBILITY_TOGGLE'; }
public function getDescription(): string { return 'Password visibility toggle buttons must have accessible names and specify aria-pressed or aria-label attributes.'; }
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) !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$id = strtolower($element->getAttribute('id'));
$ariaLabel = $element->getAttribute('aria-label');
$isPasswordToggle = str_contains($class, 'password') || str_contains($class, 'toggle-pass') || str_contains($id, 'password') || str_contains($id, 'toggle-pass');
if ($isPasswordToggle && empty($ariaLabel) && !$element->hasAttribute('aria-labelledby') && empty(trim($element->textContent))) {
return $this->createViolation(
$element,
'Password visibility toggle button is missing an accessible name.',
'Add an aria-label="Şifreyi Göster/Gizle" or aria-pressed="false" attribute to the password toggle button.'
);
}
return null;
}
}
+33 -8
View File
@@ -2,11 +2,17 @@
namespace YakNet\AccessibilityConsole\Rules;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use YakNet\AccessibilityConsole\Core\AbstractRule;
class RuleLevels
{
/**
* Get rule instances for the specified level.
* Levels are incremental: Level 5 includes all rules from Levels 1, 2, 3, and 4.
* Recursively scans src/Rules and all subdirectories for rule classes.
*
* @param int $level
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
@@ -14,27 +20,46 @@ class RuleLevels
public static function getRulesForLevel(int $level): array
{
$rules = [];
$files = glob(__DIR__ . '/*.php');
if ($files === false) {
$dir = __DIR__;
if (!is_dir($dir)) {
return [];
}
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $className;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$realPath = $file->getRealPath();
$relativePath = str_replace([$dir . DIRECTORY_SEPARATOR, '.php'], ['', ''], $realPath);
$subNamespace = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $subNamespace;
if (!class_exists($fqcn)) {
// Fallback to flat namespace if class is in root Rules namespace
$flatClassName = $file->getBasename('.php');
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $flatClassName;
}
if (class_exists($fqcn)) {
$reflection = new \ReflectionClass($fqcn);
$reflection = new ReflectionClass($fqcn);
if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) {
continue;
}
// Check if it is a concrete rule
$isRule = $reflection->isSubclassOf(\YakNet\AccessibilityConsole\Core\AbstractRule::class) ||
$reflection->implementsInterface(\YakNet\AccessibilityConsole\Rules\RuleInterface::class);
$isRule = $reflection->isSubclassOf(AbstractRule::class) ||
$reflection->implementsInterface(RuleInterface::class);
if ($isRule) {
$className = $file->getBasename('.php');
// Exclude Turkish custom rules to match standard behavior
if (in_array($className, ['EmptyLinkRule', 'HeadingOrderRule', 'ImageAltRule'], true)) {
continue;
+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 TableNestedBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TABLE_NESTED_BANNED'; }
public function getDescription(): string { return 'Tables should not be nested inside another table as it confuses screen reader data table navigation.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'table') {
return null;
}
$parent = $element->parentNode;
while ($parent instanceof DOMElement) {
if (strtolower($parent->tagName) === 'table') {
return $this->createViolation(
$element,
'Table element is nested inside another table.',
'Refactor layout to use modern CSS Flexbox/Grid instead of nested tables for tabular data.'
);
}
$parent = $parent->parentNode;
}
return null;
}
}