Compare commits

...
2 Commits
76 changed files with 3097 additions and 34 deletions
+2 -2
View File
@@ -12,7 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.1.2",
"version": "3.1.4",
"type": "library",
"license": "MIT",
"authors": [
@@ -43,7 +43,7 @@
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10"
"phpstan/phpstan": "^2.2"
},
"autoload": {
"files": [
+3
View File
@@ -2,3 +2,6 @@ parameters:
level: 9
paths:
- src
bootstrapFiles:
- vendor/autoload.php
+5 -1
View File
@@ -51,7 +51,11 @@ class ClaudeProvider implements AiProviderInterface
]);
$data = json_decode($response->getBody()->getContents(), true);
if (is_array($data) && isset($data['content'][0]['text']) && is_string($data['content'][0]['text'])) {
if (is_array($data)
&& isset($data['content']) && is_array($data['content'])
&& isset($data['content'][0]) && is_array($data['content'][0])
&& isset($data['content'][0]['text']) && is_string($data['content'][0]['text'])
) {
return $data['content'][0]['text'];
}
return null;
+6 -1
View File
@@ -50,7 +50,12 @@ class OpenAiProvider implements AiProviderInterface
]);
$data = json_decode($response->getBody()->getContents(), true);
if (is_array($data) && isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])) {
if (is_array($data)
&& isset($data['choices']) && is_array($data['choices'])
&& isset($data['choices'][0]) && is_array($data['choices'][0])
&& isset($data['choices'][0]['message']) && is_array($data['choices'][0]['message'])
&& isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])
) {
return $data['choices'][0]['message']['content'];
}
return null;
-3
View File
@@ -302,9 +302,6 @@ class ScanCommand extends Command
while (!empty($queue) && count($visited) < $maxPages) {
$current = array_shift($queue);
if (!is_array($current)) {
continue;
}
$url = $current['url'];
$depth = (int)$current['depth'];
+6 -1
View File
@@ -24,7 +24,12 @@ class BaselineManager
}
foreach ($data['violations'] as $item) {
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) {
if (!is_array($item)) {
continue;
}
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])
&& is_string($item['file']) && is_string($item['ruleId']) && is_string($item['htmlSnippet'])
) {
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
$this->ignoredKeys[$key] = true;
}
+7 -5
View File
@@ -35,6 +35,7 @@ class Config
try {
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
if (is_array($content)) {
/** @var array<string, mixed> $content */
return new self($content);
}
} catch (\Throwable $e) {
@@ -56,7 +57,7 @@ class Config
{
$paths = $this->get('paths');
if (is_array($paths)) {
return $paths;
return array_values(array_filter($paths, 'is_string'));
}
if (is_string($paths)) {
return [$paths];
@@ -71,7 +72,7 @@ class Config
{
$exclude = $this->get('exclude_paths');
if (is_array($exclude)) {
return $exclude;
return array_values(array_filter($exclude, 'is_string'));
}
if (is_string($exclude)) {
return [$exclude];
@@ -108,10 +109,10 @@ class Config
if (is_array($rules)) {
if (isset($rules['include']) && is_array($rules['include'])) {
$include = $rules['include'];
$include = array_values(array_filter($rules['include'], 'is_string'));
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = $rules['exclude'];
$exclude = array_values(array_filter($rules['exclude'], 'is_string'));
}
}
@@ -123,6 +124,7 @@ class Config
public function getGeminiApiKey(): ?string
{
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
$key = $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
return is_string($key) ? $key : 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 AnchorMissingHref extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ANCHOR_HREF'; }
public function getDescription(): string { return 'Anchor elements (<a>) acting as links must have a valid href attribute.'; }
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) !== 'a') {
return null;
}
if ($element->hasAttribute('name') || $element->hasAttribute('role')) {
return null;
}
if (!$element->hasAttribute('href') || trim($element->getAttribute('href')) === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a valid href attribute to the <a> element, or use a <button> element if it triggers an action.'
);
}
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 AppletBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_APPLET_BANNED'; }
public function getDescription(): string { return 'The <applet> element is obsolete and non-accessible. Use modern HTML5 elements instead.'; }
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) === 'applet') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <applet> element and replace it with modern HTML5 or accessible media embeddings.'
);
}
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AreaAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_AreaAlt'; }
public function getDescription(): string { return 'Checks that <area> elements in image maps have non-empty alt attributes.'; }
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) !== 'area') {
return null;
}
$alt = $element->getAttribute('alt');
$ariaLabel = $element->getAttribute('aria-label');
if (empty(trim($alt)) && empty(trim($ariaLabel))) {
return $this->createViolation(
$element,
'<area> öğesinde alt veya aria-label eksik.',
'Provide a non-empty alt or aria-label attribute for the <area> element.'
);
}
return null;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaExpandedState extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_EXPANDED'; }
public function getDescription(): string { return 'Collapsible toggle buttons (accordion, dropdown, collapse) must have an aria-expanded attribute.'; }
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
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $tagName !== 'a' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$dataBsToggle = strtolower($element->getAttribute('data-bs-toggle'));
$dataToggle = strtolower($element->getAttribute('data-toggle'));
$isCollapsible = str_contains($class, 'dropdown-toggle') ||
str_contains($class, 'accordion-button') ||
str_contains($class, 'collapse-toggle') ||
in_array($dataBsToggle, ['collapse', 'dropdown', 'offcanvas'], true) ||
in_array($dataToggle, ['collapse', 'dropdown'], true);
if (!$isCollapsible) {
return null;
}
if ($element->hasAttribute('aria-expanded')) {
$val = strtolower($element->getAttribute('aria-expanded'));
if (in_array($val, ['true', 'false'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-expanded="false" (or "true") to the toggle element so screen readers announce expanded/collapsed status.'
);
}
}
+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 AriaHiddenBody extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_BODY'; }
public function getDescription(): string { return 'The <body> or <html> element must not have aria-hidden="true".'; }
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
{
$tagName = strtolower($element->tagName);
if ($tagName !== 'body' && $tagName !== 'html') {
return null;
}
if (strtolower($element->getAttribute('aria-hidden')) === 'true') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove aria-hidden="true" from the <body>/<html> tag to prevent hiding the entire document from screen readers.'
);
}
return null;
}
}
+12
View File
@@ -57,6 +57,18 @@ class AriaHiddenFocusable extends AbstractRule
return $type !== 'hidden' && !$element->hasAttribute('disabled');
}
if ($element->hasAttribute('contenteditable') && strtolower($element->getAttribute('contenteditable')) === 'true') {
return true;
}
if (in_array($tagName, ['details', 'summary'], true)) {
return true;
}
if (in_array($tagName, ['audio', 'video'], true) && $element->hasAttribute('controls')) {
return true;
}
if ($tagName === 'iframe') {
return true;
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaLiveStatus extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_3_STATUS_MESSAGES'; }
public function getDescription(): string { return 'Dynamic status containers and toast messages must use aria-live or implicit live roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
$class = strtolower($element->getAttribute('class'));
$role = strtolower($element->getAttribute('role'));
$statusClasses = ['toast', 'status-message', 'loading-message', 'alert-message', 'live-region'];
$isStatusContainer = false;
foreach ($statusClasses as $statusClass) {
if (str_contains($class, $statusClass)) {
$isStatusContainer = true;
break;
}
}
if (!$isStatusContainer) {
return null;
}
// Implicit live roles in ARIA include status, alert, log
if (in_array($role, ['status', 'alert', 'log'], true)) {
return null;
}
if ($element->hasAttribute('aria-live')) {
$liveVal = strtolower($element->getAttribute('aria-live'));
if (in_array($liveVal, ['polite', 'assertive'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-live="polite" (or role="status"/role="alert") so screen readers dynamically announce updates to users.'
);
}
}
+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 AriaPressedToggle extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_TOGGLE_BUTTON_STATE'; }
public function getDescription(): string { return 'Toggle buttons must use aria-pressed="true" or "false" to communicate state changes.'; }
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
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$dataToggle = strtolower($element->getAttribute('data-toggle'));
$isToggleButton = str_contains($class, 'btn-toggle') || str_contains($class, 'toggle-btn') || $dataToggle === 'button';
if (!$isToggleButton) {
return null;
}
if ($element->hasAttribute('aria-pressed')) {
$val = strtolower($element->getAttribute('aria-pressed'));
if (in_array($val, ['true', 'false', 'mixed'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-pressed="true" or aria-pressed="false" to indicate the toggle state to assistive technology.'
);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_REQUIRED_ATTR'; }
public function getDescription(): string { return 'Elements with ARIA roles must have all required ARIA attributes for that role.'; }
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 (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredMap = [
'slider' => ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'],
'checkbox' => ['aria-checked'],
'combobox' => ['aria-expanded'],
'scrollbar' => ['aria-controls', 'aria-valuenow', 'aria-valuemax', 'aria-valuemin', 'aria-orientation'],
'spinbutton' => ['aria-valuenow', 'aria-valuemax', 'aria-valuemin'],
'switch' => ['aria-checked'],
'meter' => ['aria-valuenow'],
'separator' => ['aria-valuenow'] // strictly when focusable, but we check if the role is present
];
foreach ($roles as $role) {
if (isset($requiredMap[$role])) {
$missingAttrs = [];
foreach ($requiredMap[$role] as $reqAttr) {
if (!$element->hasAttribute($reqAttr)) {
$missingAttrs[] = $reqAttr;
}
}
if (!empty($missingAttrs)) {
// Specific case: separator without tabindex is not focusable and doesn't require aria-valuenow in some contexts
if ($role === 'separator' && !$element->hasAttribute('tabindex')) {
continue;
}
$missingStr = implode(', ', $missingAttrs);
return $this->createViolation(
$element,
"ARIA role '{$role}' eksik zorunlu niteliklere sahip: {$missingStr}.",
"Belirtilen ARIA rolü için zorunlu olan {$missingStr} niteliklerini ekleyin."
);
}
}
}
return null;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredChildren extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_CHILDREN'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must contain children with specific roles.'; }
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 (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredChildrenMap = [
'list' => ['listitem'],
'menu' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'menubar' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'tablist' => ['tab'],
'tree' => ['treeitem'],
'grid' => ['row', 'rowgroup'],
'table' => ['row', 'rowgroup'],
'radiogroup' => ['radio'],
'listbox' => ['option'],
'rowgroup' => ['row'],
'row' => ['cell', 'gridcell', 'columnheader', 'rowheader']
];
foreach ($roles as $role) {
if (isset($requiredChildrenMap[$role])) {
$hasRequiredChild = $this->hasChildWithRole($element, $requiredChildrenMap[$role]);
if (!$hasRequiredChild) {
// Check if children are dynamically generated with templates
$doc = $element->ownerDocument;
if ($doc !== null) {
$html = $doc->saveHTML($element);
if ($html !== false && preg_match('/(\{\{|\{%|<\?)/', $html)) {
continue;
}
}
$expectedStr = implode(' veya ', $requiredChildrenMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli alt rollere sahip değil.",
"Bu elemanın içine rolü {$expectedStr} olan çocuk elemanlar ekleyin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasChildWithRole(DOMElement $element, array $requiredRoles): bool
{
$doc = $element->ownerDocument;
if ($doc === null) {
return false;
}
$xpath = new \DOMXPath($doc);
foreach ($requiredRoles as $reqRole) {
$query = ".//*[@role='{$reqRole}' or contains(@role, ' {$reqRole}') or contains(@role, '{$reqRole} ')]";
$nodes = $xpath->query($query, $element);
if ($nodes !== false && $nodes->length > 0) {
return true;
}
}
return false;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_PARENT'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must be contained by parents with specific roles.'; }
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 (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredParentMap = [
'listitem' => ['list', 'group'],
'menuitem' => ['menu', 'menubar', 'group'],
'menuitemcheckbox' => ['menu', 'menubar'],
'menuitemradio' => ['menu', 'menubar'],
'tab' => ['tablist'],
'treeitem' => ['tree', 'group'],
'cell' => ['row'],
'gridcell' => ['row'],
'columnheader' => ['row'],
'rowheader' => ['row'],
'option' => ['listbox', 'group'],
'row' => ['grid', 'table', 'treegrid', 'rowgroup']
];
foreach ($roles as $role) {
if (isset($requiredParentMap[$role])) {
$hasRequiredParent = $this->hasParentWithRole($element, $requiredParentMap[$role]);
if (!$hasRequiredParent) {
$expectedStr = implode(' veya ', $requiredParentMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli üst eleman rollerinden hiçbirine sahip değil.",
"Bu elemanı rolü {$expectedStr} olan bir üst elemanın içine yerleştirin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasParentWithRole(DOMElement $element, array $requiredRoles): bool
{
$parent = $element->parentNode;
while ($parent !== null && $parent instanceof DOMElement) {
if ($parent->hasAttribute('role')) {
$parentRoles = explode(' ', strtolower(trim($parent->getAttribute('role'))));
foreach ($parentRoles as $pRole) {
if (in_array($pRole, $requiredRoles)) {
return true;
}
}
}
// For native HTML semantics fallback (e.g. <ul> is a list)
$tagName = strtolower($parent->tagName);
$nativeRole = $this->getNativeRole($tagName);
if ($nativeRole && in_array($nativeRole, $requiredRoles)) {
return true;
}
$parent = $parent->parentNode;
}
return false;
}
private function getNativeRole(string $tagName): ?string
{
$map = [
'ul' => 'list',
'ol' => 'list',
'dl' => 'list',
'table' => 'table',
'tr' => 'row',
'tbody' => 'rowgroup',
'thead' => 'rowgroup',
'tfoot' => 'rowgroup',
'select' => 'listbox',
'optgroup' => 'group'
];
return $map[$tagName] ?? 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 AriaValidAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR'; }
public function getDescription(): string { return 'ARIA attributes must be valid names.'; }
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
{
$validAttributes = [
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls',
'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded',
'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation',
'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly',
'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount',
'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected',
'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow',
'aria-valuetext'
];
$invalidAttrs = [];
foreach ($element->attributes as $attr) {
/** @var \DOMAttr $attr */
$name = strtolower($attr->nodeName);
if (strpos($name, 'aria-') === 0) {
if (!in_array($name, $validAttributes)) {
$invalidAttrs[] = $name;
}
}
}
if (!empty($invalidAttrs)) {
$invalidStr = implode(', ', $invalidAttrs);
return $this->createViolation(
$element,
"Geçersiz ARIA nitelikleri bulundu: {$invalidStr}.",
"Sadece geçerli ARIA niteliklerini kullanın. Geçersiz olanları kaldırın veya düzeltin."
);
}
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttrValue extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR_VALUE'; }
public function getDescription(): string { return 'ARIA attributes must contain valid values.'; }
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
{
$validationMap = [
'aria-hidden' => ['true', 'false'],
'aria-live' => ['off', 'polite', 'assertive'],
'aria-expanded' => ['true', 'false', 'undefined'],
'aria-pressed' => ['true', 'false', 'mixed', 'undefined'],
'aria-checked' => ['true', 'false', 'mixed', 'undefined'],
'aria-current' => ['page', 'step', 'location', 'date', 'time', 'true', 'false'],
'aria-sort' => ['ascending', 'descending', 'none', 'other'],
'aria-orientation' => ['horizontal', 'vertical', 'undefined'],
'aria-haspopup' => ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
'aria-invalid' => ['grammar', 'false', 'spelling', 'true'],
'aria-autocomplete' => ['inline', 'list', 'both', 'none'],
'aria-dropeffect' => ['copy', 'execute', 'link', 'move', 'none', 'popup']
];
for ($i = 0; $i < $element->attributes->length; $i++) {
$attr = $element->attributes->item($i);
if (!$attr instanceof \DOMAttr) {
continue;
}
$name = strtolower($attr->nodeName);
if (isset($validationMap[$name])) {
$value = strtolower(trim($attr->nodeValue ?? ''));
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $value) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $value)) {
continue;
}
if (!in_array($value, $validationMap[$name])) {
$validStr = implode(', ', $validationMap[$name]);
return $this->createViolation(
$element,
"{$name} niteliği geçersiz bir değere sahip: '{$value}'.",
"{$name} niteliğinin değerini geçerli olanlardan biriyle değiştirin: {$validStr}."
);
}
} elseif ($name === 'aria-relevant') {
$values = explode(' ', strtolower(trim($attr->nodeValue ?? '')));
$validRelevant = ['additions', 'removals', 'text', 'all'];
$rawValue = $attr->nodeValue ?? '';
$isTemplate = preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', trim($rawValue)) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', trim($rawValue));
if (!$isTemplate) {
foreach ($values as $val) {
if (!empty($val) && !in_array($val, $validRelevant)) {
return $this->createViolation(
$element,
"aria-relevant niteliği geçersiz bir değer içeriyor: '{$val}'.",
"aria-relevant değerlerini sadece additions, removals, text, all kelimelerinden oluşacak şekilde ayarlayın."
);
}
}
}
}
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AudioTrack extends AbstractRule
{
public function getId(): string { return 'WCAG_1_2_1_AudioTrack'; }
public function getDescription(): string { return 'Checks that <audio> elements have a <track> child element for captions/descriptions.'; }
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) !== 'audio') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$tracks = $xpath->query('./track', $element);
if ($tracks === false || $tracks->length === 0) {
return $this->createViolation(
$element,
'<audio> öğesi <track> alt öğesi içermiyor.',
'Add a <track> element for captions or provide an accessible transcript.'
);
}
return null;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutocompleteValidTokens extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE_VALID_TOKENS'; }
public function getDescription(): string { return 'Autocomplete attribute must use valid token values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if (!$element->hasAttribute('autocomplete')) {
return null;
}
$autocompleteValue = trim($element->getAttribute('autocomplete'));
if ($autocompleteValue === '') {
return null;
}
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $autocompleteValue) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $autocompleteValue)) {
return null;
}
$tokens = explode(' ', strtolower($autocompleteValue));
$validBaseTokens = [
'off', 'on', 'name', 'honorific-prefix', 'given-name', 'additional-name',
'family-name', 'honorific-suffix', 'nickname', 'email', 'username',
'new-password', 'current-password', 'one-time-code', 'organization-title',
'organization', 'street-address', 'address-line1', 'address-line2',
'address-line3', 'address-level4', 'address-level3', 'address-level2',
'address-level1', 'country', 'country-name', 'postal-code', 'cc-name',
'cc-given-name', 'cc-additional-name', 'cc-family-name', 'cc-number',
'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type',
'transaction-currency', 'transaction-amount', 'language', 'bday',
'bday-day', 'bday-month', 'bday-year', 'sex', 'tel', 'tel-country-code',
'tel-national', 'tel-area-code', 'tel-local', 'tel-extension', 'impp',
'url', 'photo'
];
foreach ($tokens as $token) {
if (empty($token)) continue;
// Allow section-* prefixes
if (strpos($token, 'section-') === 0) {
continue;
}
// Allow shipping/billing prefixes
if ($token === 'shipping' || $token === 'billing') {
continue;
}
// Check for valid base tokens
// Additional check: some tokens like 'tel' can be prefixed, we handle it simply by checking if it's in the list
$isValid = false;
foreach ($validBaseTokens as $baseToken) {
if ($token === $baseToken || str_ends_with($token, "-{$baseToken}")) {
$isValid = true;
break;
}
}
if (!$isValid) {
return $this->createViolation(
$element,
"Geçersiz autocomplete değeri veya belirteci: '{$token}'.",
"Geçerli bir HTML5 autocomplete belirteci kullanın (örneğin: name, email, tel)."
);
}
}
return null;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ButtonLoadingState extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_BUTTON_LOADING_STATE'; }
public function getDescription(): string { return 'Buttons displaying loading spinners must inform screen readers via aria-busy or aria-label.'; }
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
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$hasLoadingClass = str_contains($class, 'loading') || str_contains($class, 'spinner') || str_contains($class, 'is-busy');
$hasSpinnerChild = false;
foreach ($element->getElementsByTagName('*') as $child) {
$childClass = strtolower($child->getAttribute('class'));
if (str_contains($childClass, 'spinner') || str_contains($childClass, 'loading') || str_contains($childClass, 'loader')) {
$hasSpinnerChild = true;
break;
}
}
if (!$hasLoadingClass && !$hasSpinnerChild && strtolower($element->getAttribute('aria-busy')) !== 'true') {
return null;
}
// Must have aria-busy="true" or an explicit aria-label/aria-live describing the loading process
$hasAriaBusy = strtolower($element->getAttribute('aria-busy')) === 'true';
$hasAriaLive = $element->hasAttribute('aria-live');
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
if ($hasAriaBusy || ($hasAriaLabel && $hasAriaLive)) {
return null;
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-busy="true" and an aria-label (e.g. aria-label="Loading...") to the button when an async action is in progress.'
);
}
}
+56 -3
View File
@@ -27,10 +27,63 @@ class ButtonName extends AbstractRule
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
if ($text !== '') {
return null;
}
return null;
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
if ($element->hasAttribute('aria-labelledby')) {
$id = $element->getAttribute('aria-labelledby');
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//*[@id='$id']");
if ($labels !== false && $labels->length > 0) {
$labelNode = $labels->item(0);
if ($labelNode instanceof \DOMNode && trim($labelNode->textContent) !== '') {
return null;
}
}
}
}
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
}
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class CanvasAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_CanvasAlt'; }
public function getDescription(): string { return 'Checks that <canvas> elements have fallback content or aria labels.'; }
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) !== 'canvas') {
return null;
}
$hasAriaLabel = !empty($element->getAttribute('aria-label'));
$hasAriaLabelledby = !empty($element->getAttribute('aria-labelledby'));
$hasRoleImgLabel = ($element->getAttribute('role') === 'img') && $hasAriaLabel;
if ($hasAriaLabel || $hasAriaLabelledby || $hasRoleImgLabel) {
return null;
}
$content = trim($element->textContent);
$hasChildElements = false;
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$hasChildElements = true;
break;
}
}
if ($content === '' && !$hasChildElements) {
return $this->createViolation(
$element,
'<canvas> öğesi alternatif içerik veya etiket içermiyor.',
'Provide fallback content inside the <canvas> tag or use aria-label/aria-labelledby.'
);
}
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ClickHandlerKeyboard extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_CLICK_KEYBOARD'; }
public function getDescription(): string { return 'Non-interactive elements with click handlers should also have keyboard handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('onclick')) {
return null;
}
$tagName = strtolower($element->tagName);
$interactiveElements = ['a', 'button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $interactiveElements)) {
return null;
}
$role = strtolower($element->getAttribute('role'));
if (($role === 'button' || $role === 'link') && $element->hasAttribute('tabindex')) {
return null;
}
if ($element->hasAttribute('onkeydown') || $element->hasAttribute('onkeyup') || $element->hasAttribute('onkeypress')) {
return null;
}
return $this->createViolation(
$element,
'onclick olay işleyicisine sahip etkileşimli olmayan öğenin klavye işleyicisi (onkeydown vb.) eksik. / Non-interactive element with onclick is missing a keyboard handler.',
'Klavye desteği için onkeydown/onkeyup ekleyin ve tabindex="0" tanımlayın. / Add onkeydown/onkeyup handlers and tabindex="0" for keyboard support.'
);
}
}
+50 -3
View File
@@ -249,9 +249,56 @@ class ColorContrast extends AbstractRule
}
$colors = [
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff',
'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc',
'bisque' => '#ffe4c4', 'black' => '#000000', 'blanchedalmond' => '#ffebcd',
'blue' => '#0000ff', 'blueviolet' => '#8a2be2', 'brown' => '#a52a2a',
'burlywood' => '#deb887', 'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00',
'chocolate' => '#d2691e', 'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed',
'cornsilk' => '#fff8dc', 'crimson' => '#dc143c', 'cyan' => '#00ffff',
'darkblue' => '#00008b', 'darkcyan' => '#008b8b', 'darkgoldenrod' => '#b8860b',
'darkgray' => '#a9a9a9', 'darkgreen' => '#006400', 'darkgrey' => '#a9a9a9',
'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b', 'darkolivegreen' => '#556b2f',
'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc', 'darkred' => '#8b0000',
'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f', 'darkslateblue' => '#483d8b',
'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f', 'darkturquoise' => '#00ced1',
'darkviolet' => '#9400d3', 'deeppink' => '#ff1493', 'deepskyblue' => '#00bfff',
'dimgray' => '#696969', 'dimgrey' => '#696969', 'dodgerblue' => '#1e90ff',
'firebrick' => '#b22222', 'floralwhite' => '#fffaf0', 'forestgreen' => '#228b22',
'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc', 'ghostwhite' => '#f8f8ff',
'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080',
'green' => '#008000', 'greenyellow' => '#adff2f', 'grey' => '#808080',
'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred' => '#cd5c5c',
'indigo' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c',
'lavender' => '#e6e6fa', 'lavenderblush' => '#fff0f5', 'lawngreen' => '#7cfc00',
'lemonchiffon' => '#fffacd', 'lightblue' => '#add8e6', 'lightcoral' => '#f08080',
'lightcyan' => '#e0ffff', 'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3',
'lightgreen' => '#90ee90', 'lightgrey' => '#d3d3d3', 'lightpink' => '#ffb6c1',
'lightsalmon' => '#ffa07a', 'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa',
'lightslategray' => '#778899', 'lightslategrey' => '#778899', 'lightsteelblue' => '#b0c4de',
'lightyellow' => '#ffffe0', 'lime' => '#00ff00', 'limegreen' => '#32cd32',
'linen' => '#faf0e6', 'magenta' => '#ff00ff', 'maroon' => '#800000',
'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd', 'mediumorchid' => '#ba55d3',
'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371', 'mediumslateblue' => '#7b68ee',
'mediumspringgreen' => '#00fa9a', 'mediumturquoise' => '#48d1cc', 'mediumvioletred' => '#c71585',
'midnightblue' => '#191970', 'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1',
'moccasin' => '#ffe4b5', 'navajowhite' => '#ffdead', 'navy' => '#000080',
'oldlace' => '#fdf5e6', 'olive' => '#808000', 'olivedrab' => '#6b8e23',
'orange' => '#ffa500', 'orangered' => '#ff4500', 'orchid' => '#da70d6',
'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98', 'paleturquoise' => '#afeeee',
'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5', 'peachpuff' => '#ffdab9',
'peru' => '#cd853f', 'pink' => '#ffc0cb', 'plum' => '#dda0dd',
'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399',
'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1',
'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', 'sandybrown' => '#f4a460',
'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d',
'silver' => '#c0c0c0', 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd',
'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa',
'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c',
'teal' => '#008080', 'thistle' => '#d8bfd8', 'tomato' => '#ff6347',
'turquoise' => '#40e0d0', 'violet' => '#ee82ee', 'wheat' => '#f5deb3',
'white' => '#ffffff', 'whitesmoke' => '#f5f5f5', 'yellow' => '#ffff00',
'yellowgreen' => '#9acd32',
];
if (isset($colors[strtolower($val)])) {
return $colors[strtolower($val)];
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DefinitionListStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DefinitionListStructure'; }
public function getDescription(): string { return 'Checks that <dl> elements only contain <dt>, <dd>, and <div> as direct children.'; }
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) !== 'dl') {
return null;
}
$invalidChildren = [];
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, ['dt', 'dd', 'div'])) {
$invalidChildren[] = $childTag;
}
}
}
if (count($invalidChildren) > 0) {
return $this->createViolation(
$element,
'<dl> öğesi doğrudan alt öğe olarak sadece <dt>, <dd> veya <div> içerebilir.',
'Remove or wrap invalid children (' . implode(', ', array_unique($invalidChildren)) . ') inside <dd> or <dt>.'
);
}
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DialogAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_DIALOG_ACCESSIBILITY'; }
public function getDescription(): string { return 'Dialog elements must have an accessible name.'; }
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
{
$role = strtolower($element->getAttribute('role'));
$tagName = strtolower($element->tagName);
if ($role !== 'dialog' && $role !== 'alertdialog' && $tagName !== 'dialog') {
return null;
}
$ariaLabel = trim($element->getAttribute('aria-label'));
$ariaLabelledby = trim($element->getAttribute('aria-labelledby'));
$title = trim($element->getAttribute('title'));
if (!empty($ariaLabel) || !empty($ariaLabelledby) || !empty($title)) {
return null;
}
return $this->createViolation(
$element,
'Dialog bileşeninin erişilebilir bir adı (accessible name) eksik. / Dialog component is missing an accessible name.',
'Dialog elementine aria-label veya aria-labelledby niteliği ekleyin. / Add aria-label or aria-labelledby to the dialog element.'
);
}
}
+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 EmbedAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_EmbedAccessibility'; }
public function getDescription(): string { return 'Checks that <embed> elements have aria-label, aria-labelledby, or title attribute for accessible name.'; }
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) !== 'embed') {
return null;
}
$ariaLabel = $element->getAttribute('aria-label');
$ariaLabelledby = $element->getAttribute('aria-labelledby');
$title = $element->getAttribute('title');
if (empty(trim($ariaLabel)) && empty(trim($ariaLabelledby)) && empty(trim($title))) {
return $this->createViolation(
$element,
'<embed> öğesinde erişilebilir isim eksik.',
'Provide an aria-label, aria-labelledby, or title attribute for the <embed> element.'
);
}
return null;
}
}
+54 -8
View File
@@ -27,18 +27,64 @@ class EmptyLink extends AbstractRule
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
if ($text !== '') {
return null;
}
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
return null;
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
$spans = $xpath->query('.//span', $element);
if ($spans !== false) {
foreach ($spans as $span) {
if ($span instanceof DOMElement) {
if (trim($span->textContent) !== '') {
return null;
}
if ($span->hasAttribute('aria-label') && trim($span->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
}
+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 FigureCaptionStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_FIGCAPTION_PARENT'; }
public function getDescription(): string { return '<figcaption> elements must be direct children of a <figure> element.'; }
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) !== 'figcaption') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement || strtolower($parent->tagName) !== 'figure') {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the <figcaption> element inside a parent <figure> element or change it to a standard paragraph/caption element.'
);
}
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 FocusTrapping extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_2_FOCUS_TRAP'; }
public function getDescription(): string { return 'Detects potential keyboard traps via inline event handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$onkeydown = $element->getAttribute('onkeydown');
$onkeypress = $element->getAttribute('onkeypress');
$handlers = $onkeydown . ' ' . $onkeypress;
if (empty(trim($handlers))) {
return null;
}
if (strpos($handlers, 'preventDefault') !== false || strpos($handlers, 'return false') !== false) {
return $this->createViolation(
$element,
'Klavye odağını hapseden (keyboard trap) potansiyel olay işleyicisi tespit edildi. / Potential keyboard trap detected in inline event handlers.',
'Kullanıcıların Esc veya Tab tuşları ile bu alandan çıkabilmesini sağlayın. / Ensure users can exit this component using Esc or Tab keys.'
);
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FocusableWithoutRole extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_FOCUSABLE_ROLE'; }
public function getDescription(): string { return 'Non-interactive focusable elements (with tabindex >= 0) must have an explicit ARIA role.'; }
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('tabindex')) {
return null;
}
$tabindex = (int)$element->getAttribute('tabindex');
if ($tabindex < 0) {
return null;
}
$tagName = strtolower($element->tagName);
$nonInteractiveTags = ['div', 'span', 'p', 'li', 'section', 'article', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
if (in_array($tagName, $nonInteractiveTags, true) && !$element->hasAttribute('role')) {
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Add an appropriate ARIA role (e.g., role="button", role="link") to the focusable <%s> element.', $tagName)
);
}
return null;
}
}
+2 -2
View File
@@ -40,8 +40,8 @@ class FormLabel extends AbstractRule
}
}
// Check for aria-label or aria-labelledby
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
// Check for aria-label, aria-labelledby, or title
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
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 FormRequiredIndicator extends AbstractRule
{
public function getId(): string { return 'WCAG_3_3_2_FORM_REQUIRED_INDICATOR'; }
public function getDescription(): string { return 'Required form fields should have aria-required="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if ($element->hasAttribute('required')) {
$ariaRequired = strtolower(trim($element->getAttribute('aria-required')));
if ($ariaRequired !== 'true') {
// If it has 'required' but not aria-required="true", warn
return $this->createViolation(
$element,
"Zorunlu form alanı aria-required=\"true\" niteliğini içermiyor.",
"Ekran okuyucuların form alanının zorunlu olduğunu doğru şekilde anons edebilmesi için aria-required=\"true\" niteliğini eklemeyi düşünün."
);
}
}
return null;
}
}
+4 -2
View File
@@ -35,7 +35,9 @@ class ImageAltPlaceholder extends AbstractRule
// Generic placeholder checks
$placeholders = [
'image', 'photo', 'picture', 'logo', 'icon', 'placeholder', 'untitled', 'temp', 'blank', 'pic', 'dot',
'resim', 'foto', 'fotoğraf', 'görsel', 'grafik', 'ikon', 'logo'
'resim', 'foto', 'fotoğraf', 'görsel', 'grafik', 'ikon', 'logo',
'arkaplan', 'kapak', 'simge', 'ürün', 'dosya', 'adsız', 'varsayılan',
'default', 'hero', 'no-image', 'noimage', 'null'
];
if (in_array($cleanAlt, $placeholders, true)) {
return $this->createViolation(
@@ -46,7 +48,7 @@ class ImageAltPlaceholder extends AbstractRule
}
// Filename checks
if (preg_match('/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt)) {
if (preg_match('/^[^\s]+\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt) || preg_match('/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt)) {
return $this->createViolation(
$element,
'Image alt text should not be a filename.',
+3 -1
View File
@@ -17,7 +17,9 @@ class ImageAltRedundant extends AbstractRule
/** @var array<int, string> */
private array $redundantWords = [
'resmi', 'görüntüsü', 'fotoğrafı', 'grafiği', 'logosu', 'ikonu',
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of'
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of',
'profile photo', 'banner image', 'background image', 'thumbnail of', 'screenshot of', 'avatar of',
'görseli', 'arkaplan resmi', 'profil fotoğrafı', 'küçük resmi', 'ekran görüntüsü'
];
public function check(DOMElement $element): ?Violation
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputImageAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_INPUT_IMAGE_ALT'; }
public function getDescription(): string { return '<input type="image"> elements must have an alternative text.'; }
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) !== 'input') {
return null;
}
if (strtolower($element->getAttribute('type')) !== 'image') {
return null;
}
$hasAlt = $element->hasAttribute('alt') && trim($element->getAttribute('alt')) !== '';
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
$hasAriaLabelledBy = $element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '';
$hasTitle = $element->hasAttribute('title') && trim($element->getAttribute('title')) !== '';
if (!$hasAlt && !$hasAriaLabel && !$hasAriaLabelledBy && !$hasTitle) {
return $this->createViolation(
$element,
"<input type=\"image\"> elemanının alternatif metni (alt) bulunmuyor.",
"Görsel butonlar (<input type=\"image\">) için alt, aria-label veya title niteliklerinden birini kullanarak amacını açıklayın."
);
}
return null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputMissingAutocomplete extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_INPUT_AUTOCOMPLETE'; }
public function getDescription(): string { return 'Input fields collecting personal data should have an autocomplete attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
$type = strtolower($element->getAttribute('type'));
if (in_array($type, ['hidden', 'submit', 'button', 'reset', 'checkbox', 'radio', 'image', 'file'], true)) {
return null;
}
$name = strtolower($element->getAttribute('name'));
$id = strtolower($element->getAttribute('id'));
$targetKeywords = ['email', 'phone', 'tel', 'username', 'user', 'password', 'pass', 'address', 'zip', 'city', 'country', 'name', 'fname', 'lname'];
$isPersonalField = false;
if (in_array($type, ['email', 'tel'], true)) {
$isPersonalField = true;
} else {
foreach ($targetKeywords as $keyword) {
if (str_contains($name, $keyword) || str_contains($id, $keyword)) {
$isPersonalField = true;
break;
}
}
}
if ($isPersonalField && (!$element->hasAttribute('autocomplete') || trim($element->getAttribute('autocomplete')) === '')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add an autocomplete attribute (e.g., autocomplete="email", autocomplete="username") to improve accessibility and user experience.'
);
}
return null;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InteractiveRoleTabindex extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_ROLE_TABINDEX'; }
public function getDescription(): string { return 'Elements with interactive ARIA roles must be focusable.'; }
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
{
$role = strtolower($element->getAttribute('role'));
$interactiveRoles = ['button', 'link', 'checkbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'combobox', 'gridcell'];
if (empty($role) || !in_array($role, $interactiveRoles)) {
return null;
}
if ($element->hasAttribute('tabindex')) {
return null;
}
$tagName = strtolower($element->tagName);
$focusableNative = ['button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $focusableNative)) {
return null;
}
if ($tagName === 'a' && $element->hasAttribute('href')) {
return null;
}
if (($tagName === 'audio' || $tagName === 'video') && $element->hasAttribute('controls')) {
return null;
}
return $this->createViolation(
$element,
'Etkileşimli bir role ('.$role.') sahip olan öğe klavye ile odaklanılabilir (focusable) değil. / Element with interactive role ('.$role.') is not focusable.',
'Öğeye tabindex="0" (veya uygun tabindex değeri) ekleyerek odaklanılabilir hale getirin. / Add tabindex="0" to make the element focusable.'
);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkBanner extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkBanner'; }
public function getDescription(): string { return 'Checks that there is at most one page-level <header> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'header') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$ancestors = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $element);
if ($ancestors !== false && $ancestors->length > 0) {
return null; // It's not a page-level banner
}
$headers = $xpath->query('//header');
if ($headers === false) {
return null;
}
$index = 0;
foreach ($headers as $header) {
if (!$header instanceof \DOMNode) {
continue;
}
$anc = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $header);
if ($anc !== false && $anc->length === 0) {
$index++;
if ($header->isSameNode($element) && $index > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla ana (top-level) <header> bulundu.',
'Ensure there is at most one top-level <header> element (banner landmark) on the page.'
);
}
}
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkContentInfo extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkContentInfo'; }
public function getDescription(): string { return 'Checks that there is at most one page-level <footer> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'footer') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$ancestors = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $element);
if ($ancestors !== false && $ancestors->length > 0) {
return null; // It's not a page-level footer
}
$footers = $xpath->query('//footer');
if ($footers === false) {
return null;
}
$index = 0;
foreach ($footers as $footer) {
if (!$footer instanceof \DOMNode) {
continue;
}
$anc = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $footer);
if ($anc !== false && $anc->length === 0) {
$index++;
if ($footer->isSameNode($element) && $index > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla ana (top-level) <footer> bulundu.',
'Ensure there is at most one top-level <footer> element (contentinfo landmark) on the page.'
);
}
}
}
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkMain extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkMain'; }
public function getDescription(): string { return 'Checks if the page has exactly one <main> element or element with role="main".'; }
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) !== 'html') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$mains = $xpath->query('//main | //*[@role="main"]');
if ($mains === false || $mains->length === 0) {
return $this->createViolation(
$element,
'Sayfada <main> alanı bulunamadı.',
'Add exactly one <main> element or element with role="main" to the page.'
);
} elseif ($mains->length > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla <main> alanı bulundu.',
'Ensure there is only one <main> element or element with role="main" per page.'
);
}
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkNoDuplicate extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkNoDuplicate'; }
public function getDescription(): string { return 'Checks that if there are multiple landmarks of the same type, each has a unique label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['nav', 'aside', 'section', 'form'])) {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$elementsOfSameType = $xpath->query('//' . $tag);
if ($elementsOfSameType === false || $elementsOfSameType->length <= 1) {
return null;
}
$label = $element->getAttribute('aria-label');
$labelledby = $element->getAttribute('aria-labelledby');
if (empty($label) && empty($labelledby)) {
return $this->createViolation(
$element,
"Birden fazla <{$tag}> öğesi var ancak bu öğenin benzersiz bir etiketi (aria-label/aria-labelledby) yok.",
"Provide a unique aria-label or aria-labelledby for each <{$tag}> when multiple exist."
);
}
// Check for duplicates
$duplicateLabel = false;
foreach ($elementsOfSameType as $el) {
if (!$el instanceof DOMElement) {
continue;
}
if (!$el->isSameNode($element)) {
$otherLabel = $el->getAttribute('aria-label');
$otherLabelledby = $el->getAttribute('aria-labelledby');
if (!empty($label) && $otherLabel === $label) {
$duplicateLabel = true;
break;
}
if (!empty($labelledby) && $otherLabelledby === $labelledby) {
$duplicateLabel = true;
break;
}
}
}
if ($duplicateLabel) {
return $this->createViolation(
$element,
"Birden fazla <{$tag}> öğesi aynı etiketi kullanıyor.",
"Ensure aria-label or aria-labelledby is unique for each <{$tag}>."
);
}
return null;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
/**
* WCAG 3.1.2 — Checks that when both `lang` and `xml:lang` are present on <html>,
* their primary language subtags match.
*
* A mismatch between these attributes can cause assistive technologies to
* announce content in the wrong language.
*/
class LangMismatch extends AbstractRule
{
public function getId(): string { return 'WCAG_3_1_2_LANG_MISMATCH'; }
public function getDescription(): string { return 'The lang and xml:lang attributes on <html> must have matching primary language subtags.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$lang = $element->getAttribute('lang');
$xmlLang = $element->getAttribute('xml:lang');
// Only check when both are present
if (empty($lang) || empty($xmlLang)) {
return null;
}
$primaryLang = $this->getPrimarySubtag($lang);
$primaryXmlLang = $this->getPrimarySubtag($xmlLang);
if (strtolower($primaryLang) !== strtolower($primaryXmlLang)) {
return $this->createViolation(
$element,
"lang=\"{$lang}\" ile xml:lang=\"{$xmlLang}\" arasında birincil dil uyumsuzluğu var.",
'lang ve xml:lang niteliklerinin birincil dil alt etiketlerinin eşleştiğinden emin olun (örn. her ikisi de "en" veya "tr").'
);
}
return null;
}
/**
* Extracts the primary language subtag from a BCP 47 language tag.
* e.g. "en-US" → "en", "tr" → "tr"
*/
private function getPrimarySubtag(string $langTag): string
{
$parts = explode('-', trim($langTag));
return $parts[0];
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ class LanguageSubtag extends AbstractRule
// Simple IETF language tag format check (e.g. "en", "tr", "en-US", "zh-Hant")
$isValid = preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/', $lang);
if (!$isValid || $lang === '') {
if (!$isValid) {
return $this->createViolation(
$element,
$this->getDescription(),
+2 -1
View File
@@ -17,7 +17,8 @@ class LinkTextGeneric extends AbstractRule
/** @var array<int, string> */
private array $genericWords = [
'click here', 'click', 'read more', 'more', 'here', 'download', 'go', 'details', 'link', 'website', 'page', 'info',
'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi'
'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi',
'daha fazla bilgi', 'oku', 'bak', 'ayrıntılar', 'incele', 'see more', 'view', 'open'
];
public function check(DOMElement $element): ?Violation
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ListItemParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LIST_ITEM_PARENT'; }
public function getDescription(): string { return 'List items (<li>) must be contained inside a parent <ul>, <ol>, or <menu> element.'; }
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) !== 'li') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement) {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the orphan <li> element in a parent <ul> or <ol> tag.'
);
}
$parentTag = strtolower($parent->tagName);
$validParents = ['ul', 'ol', 'menu'];
if (!in_array($parentTag, $validParents, true) && strtolower($parent->getAttribute('role')) !== 'list') {
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Move the <li> element inside a <ul> or <ol> container instead of <%s>.', $parentTag)
);
}
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class NestedInteractive extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_NestedInteractive'; }
public function getDescription(): string { return 'Checks for nested interactive elements.'; }
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
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['a', 'button'])) {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$interactives = $xpath->query('.//a | .//button | .//input | .//select | .//textarea', $element);
if ($interactives !== false && $interactives->length > 0) {
return $this->createViolation(
$element,
"İç içe geçmiş etkileşimli öğeler bulundu (<{$tag}> içerisinde).",
'Ensure interactive controls are not nested.'
);
}
return null;
}
}
+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 OptgroupLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_OPTGROUP_LABEL'; }
public function getDescription(): string { return '<optgroup> elements must have a non-empty label attribute.'; }
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) !== 'optgroup') {
return null;
}
if (!$element->hasAttribute('label') || trim($element->getAttribute('label')) === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a descriptive label attribute to the <optgroup> element.'
);
}
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ProgressbarAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_PROGRESSBAR'; }
public function getDescription(): string { return 'Progressbar elements must have an accessible name and required aria attributes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$tagName = strtolower($element->tagName);
if ($role !== 'progressbar' && $tagName !== 'progress') {
return null;
}
$hasName = $element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title');
if (!$hasName) {
return $this->createViolation(
$element,
'İlerleme çubuğunun (progressbar) erişilebilir bir adı eksik. / Progressbar is missing an accessible name.',
'aria-label, aria-labelledby veya title niteliği ekleyin. / Add aria-label, aria-labelledby, or title attribute.'
);
}
if ($role === 'progressbar' && $tagName !== 'progress') {
$hasValueNow = $element->hasAttribute('aria-valuenow');
$hasValueMinMax = $element->hasAttribute('aria-valuemin') && $element->hasAttribute('aria-valuemax');
$hasValueText = $element->hasAttribute('aria-valuetext');
if (!$hasValueNow || (!$hasValueMinMax && !$hasValueText)) {
return $this->createViolation(
$element,
'Özel ilerleme çubuğunun (role="progressbar") durum bildiren ARIA nitelikleri eksik. / Custom progressbar is missing state ARIA attributes.',
'aria-valuenow ile birlikte aria-valuemin/aria-valuemax veya aria-valuetext niteliklerini ekleyin. / Add aria-valuenow along with aria-valuemin/aria-valuemax or aria-valuetext.'
);
}
}
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 RadioGroupFieldset extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_RADIO_GROUP_FIELDSET'; }
public function getDescription(): string { return 'Radio button groups sharing the same name should be grouped inside a <fieldset> with a <legend> or role="radiogroup".'; }
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) !== 'input') {
return null;
}
if (strtolower($element->getAttribute('type')) !== 'radio') {
return null;
}
$name = $element->getAttribute('name');
if ($name === '') {
return null;
}
// Check if ancestor is fieldset or role="radiogroup"
$current = $element->parentNode;
while ($current instanceof DOMElement) {
$parentTag = strtolower($current->tagName);
$parentRole = strtolower($current->getAttribute('role'));
if ($parentTag === 'fieldset') {
$legends = $current->getElementsByTagName('legend');
if ($legends->length > 0) {
return null;
}
}
if ($parentRole === 'radiogroup' && ($current->hasAttribute('aria-label') || $current->hasAttribute('aria-labelledby'))) {
return null;
}
$current = $current->parentNode;
}
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Wrap radio inputs named "%s" inside a <fieldset> with a <legend> to group related radio choices.', $name)
);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SelectHasAccessibleName extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_SELECT_ACCESSIBLE_NAME'; }
public function getDescription(): string { return 'Select elements must have an accessible name.'; }
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) !== 'select') {
return null;
}
// Check aria-label
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
// Check aria-labelledby
if ($element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '') {
return null;
}
// Check title
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Check if there is an associated <label for="id">
$id = $element->getAttribute('id');
if (!empty($id)) {
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new \DOMXPath($doc);
// escape quotes in id
$safeId = addslashes($id);
$labels = $xpath->query("//label[@for='{$safeId}']");
if ($labels !== false && $labels->length > 0) {
return null;
}
}
// Check if wrapped in a <label>
$parent = $element->parentNode;
while ($parent !== null && $parent instanceof DOMElement) {
if (strtolower($parent->tagName) === 'label') {
return null;
}
$parent = $parent->parentNode;
}
return $this->createViolation(
$element,
"<select> elemanının erişilebilir bir adı (label) yok.",
"<select> elemanına erişilebilir bir ad sağlamak için bir <label> ilişkilendirin veya aria-label niteliği ekleyin."
);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SkipNavigation extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_1_SkipNavigation'; }
public function getDescription(): string { return 'Checks if the page has a skip navigation mechanism.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$hasSkipLink = false;
$links = $xpath->query('//a[starts-with(@href, "#")]');
if ($links !== false && $links->length > 0) {
// Simplified check: if there is an anchor linking to an id, we assume it could be a skip link
$hasSkipLink = true;
}
$mains = $xpath->query('//main | //*[@role="main"]');
$hasMain = ($mains !== false && $mains->length > 0);
$navs = $xpath->query('//nav[@role="navigation"] | //nav');
$hasNav = ($navs !== false && $navs->length > 0);
if (!$hasSkipLink && (!$hasMain || !$hasNav)) {
// Re-evaluating the rule condition:
// OR look for a <main> element or role="main" anywhere
// OR look for a <nav> element with role="navigation"
if (!$hasMain && !$hasNav) {
return $this->createViolation(
$element,
'Sayfada atlama mekanizması (skip navigation) bulunamadı.',
'Provide a skip link (href="#..."), a <main> element, or a <nav role="navigation"> to allow users to bypass repetitive content.'
);
}
}
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 SummaryParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_SUMMARY_PARENT'; }
public function getDescription(): string { return '<summary> elements must be direct children of a <details> element.'; }
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) !== 'summary') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement || strtolower($parent->tagName) !== 'details') {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the <summary> element inside a <details> element or change it to a standard heading/button.'
);
}
return null;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableFakeHeading extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_FAKE_HEADING'; }
public function getDescription(): string { return 'Detects <td> elements that appear to be headers visually.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'td') {
return null;
}
$text = trim($element->textContent);
if (empty($text) || strlen($text) > 50) {
return null;
}
$style = $element->getAttribute('style');
$hasBoldStyle = preg_match('/font-weight:\s*(bold|[789]00)/i', $style);
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$strongOrB = $xpath->query('.//b | .//strong', $element);
$hasBoldChild = false;
if ($strongOrB !== false) {
foreach ($strongOrB as $node) {
if ($node instanceof \DOMNode && trim($node->textContent) === $text) {
$hasBoldChild = true;
break;
}
}
}
if ($hasBoldStyle || $hasBoldChild) {
return $this->createViolation(
$element,
'Başlık gibi biçimlendirilmiş <td> öğesi bulundu, bunun yerine <th> kullanılmalıdır. / <td> styled as a heading found, use <th> instead.',
'Kalın yazılmış hücre metnini <th> etiketi ile değiştirin. / Change the bolded <td> cell to a <th> element.'
);
}
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableMultiCaption extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TABLE_MULTI_CAPTION'; }
public function getDescription(): string { return 'A <table> element must not contain more than one <caption> element.'; }
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) !== 'table') {
return null;
}
$captionCount = 0;
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement && strtolower($child->tagName) === 'caption') {
$captionCount++;
}
}
if ($captionCount > 1) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove duplicate <caption> elements so that the table has at most one caption.'
);
}
return null;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ThHasScope extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TH_SCOPE'; }
public function getDescription(): string { return '<th> elements in complex tables should have a scope attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'th') {
return null;
}
if ($element->hasAttribute('scope')) {
return null;
}
// Find parent table
$table = $element->parentNode;
while ($table !== null && !($table instanceof DOMElement && strtolower($table->tagName) === 'table')) {
$table = $table->parentNode;
}
if (!$table instanceof DOMElement) {
return null;
}
// Determine if table is complex
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$thRows = $xpath->query('.//tr[th]', $table);
$hasColspan = $xpath->query('.//*[@colspan or @rowspan]', $table);
if ($thRows !== false && $hasColspan !== false && ($thRows->length > 1 || $hasColspan->length > 0)) {
return $this->createViolation(
$element,
'Karmaşık tablolardaki <th> etiketleri scope niteliği (col, row vb.) içermelidir. / <th> elements in complex tables must have a scope attribute.',
'<th> etiketine uygun "scope" niteliğini (col, row, colgroup, rowgroup) ekleyin. / Add appropriate "scope" attribute to <th>.'
);
}
return null;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
/**
* WCAG 2.4.2 — Ensures the <title> element is not empty or whitespace-only.
*
* A present but empty <title> is worse than no title at all in some screen
* readers because the browser may fall back to the filename/URL.
*/
class TitleEmpty extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_2_TITLE_EMPTY'; }
public function getDescription(): string { return 'The <title> element must not be empty.'; }
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) !== 'title') {
return null;
}
// Only check <title> inside <head> (not SVG <title>)
$parent = $element->parentNode;
if ($parent instanceof DOMElement && strtolower($parent->tagName) !== 'head') {
return null;
}
$text = trim($element->textContent);
// Skip template expressions
if (preg_match('/(\{\{|\{%|<\?)/', $text)) {
return null;
}
if ($text === '') {
return $this->createViolation(
$element,
'<title> öğesi boş veya yalnızca boşluk karakterlerinden oluşuyor.',
'Sayfa içeriğini tanımlayan anlamlı bir <title> metni ekleyin.'
);
}
// Check for generic/placeholder titles
$genericTitles = [
'untitled', 'document', 'page', 'no title', 'başlıksız',
'sayfa', 'doküman', 'test', 'index', 'home page',
];
if (in_array(strtolower($text), $genericTitles, true)) {
return $this->createViolation(
$element,
"Sayfa başlığı çok genel: '{$text}'.",
'Sayfa içeriğini açıkça tanımlayan benzersiz ve açıklayıcı bir başlık kullanın.'
);
}
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
/**
* WCAG 2.4.7 — Ensures that visually hidden elements are not independently focusable.
*
* Elements hidden with CSS techniques like `clip`, `clip-path`, or tiny dimensions
* while remaining in the tab order can confuse keyboard users since focus moves
* to an invisible element.
*/
class VisuallyHiddenFocusable extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_7_VISUALLY_HIDDEN_FOCUSABLE'; }
public function getDescription(): string { return 'Visually hidden elements should not be independently focusable unless they become visible on focus.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$style = $element->getAttribute('style');
if ($style === '') {
return null;
}
$isVisuallyHidden = false;
// Detect common visually-hidden CSS patterns
if (preg_match('/\bclip\s*:\s*rect\s*\(\s*0/i', $style)) {
$isVisuallyHidden = true;
} elseif (preg_match('/\bclip-path\s*:\s*inset\s*\(\s*50%\s*\)/i', $style)) {
$isVisuallyHidden = true;
} elseif (preg_match('/\bwidth\s*:\s*1px/i', $style) && preg_match('/\bheight\s*:\s*1px/i', $style) && preg_match('/\boverflow\s*:\s*hidden/i', $style)) {
$isVisuallyHidden = true;
} elseif (preg_match('/\bposition\s*:\s*absolute/i', $style) && preg_match('/\bleft\s*:\s*-\d{4,}px/i', $style)) {
$isVisuallyHidden = true;
}
if (!$isVisuallyHidden) {
return null;
}
// Check if the element is focusable
$tag = strtolower($element->tagName);
$focusableTags = ['a', 'button', 'input', 'select', 'textarea'];
$isFocusable = in_array($tag, $focusableTags) || $element->hasAttribute('tabindex');
if ($tag === 'a' && !$element->hasAttribute('href')) {
$isFocusable = false;
}
$tabindex = $element->getAttribute('tabindex');
if ($tabindex === '-1') {
$isFocusable = false;
}
if (!$isFocusable) {
return null;
}
// Skip if element has :focus styles hinting it becomes visible (we can't detect CSS pseudos in static analysis,
// but we can check for a common class pattern)
$class = $element->getAttribute('class');
if (preg_match('/\b(sr-only-focusable|skip-link|skip-nav|skip-to)\b/i', $class)) {
return null;
}
return $this->createViolation(
$element,
'Görsel olarak gizlenmiş bir öğe klavye ile odaklanabilir durumda, bu durum klavye kullanıcılarını yanıltabilir.',
'Öğeyi tabindex="-1" ile tab sırasından çıkarın veya odaklandığında görünür hale getirin (skip-link deseni).'
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AnchorMissingHref;
class AnchorMissingHrefTest extends TestCase
{
public function testFlagsAnchorWithoutHref(): void
{
$scanner = new Scanner();
$scanner->addRule(new AnchorMissingHref());
$violations = $scanner->scan('<div><a>Empty Link</a></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ANCHOR_HREF', $violations[0]->ruleId);
}
public function testPassesAnchorWithValidHref(): void
{
$scanner = new Scanner();
$scanner->addRule(new AnchorMissingHref());
$violations = $scanner->scan('<div><a href="https://example.com">Valid Link</a></div>');
$this->assertCount(0, $violations);
}
public function testPassesAnchorWithRole(): void
{
$scanner = new Scanner();
$scanner->addRule(new AnchorMissingHref());
$violations = $scanner->scan('<div><a role="button">Button Link</a></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AppletBanned;
class AppletBannedTest extends TestCase
{
public function testFlagsAppletTag(): void
{
$scanner = new Scanner();
$scanner->addRule(new AppletBanned());
$violations = $scanner->scan('<div><applet code="MyApplet.class"></applet></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_APPLET_BANNED', $violations[0]->ruleId);
}
public function testPassesObjectTag(): void
{
$scanner = new Scanner();
$scanner->addRule(new AppletBanned());
$violations = $scanner->scan('<div><object data="movie.mp4">Fallback</object></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaExpandedState;
class AriaExpandedStateTest extends TestCase
{
public function testFlagsDropdownToggleWithoutAriaExpanded(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaExpandedState());
$violations = $scanner->scan('<button class="dropdown-toggle" data-bs-toggle="dropdown">Menu</button>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_EXPANDED', $violations[0]->ruleId);
}
public function testPassesDropdownToggleWithAriaExpanded(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaExpandedState());
$violations = $scanner->scan('<button class="dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Menu</button>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaHiddenBody;
class AriaHiddenBodyTest extends TestCase
{
public function testFlagsAriaHiddenBody(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaHiddenBody());
$violations = $scanner->scan('<body aria-hidden="true"><div>Content</div></body>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_HIDDEN_BODY', $violations[0]->ruleId);
}
public function testPassesNormalBody(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaHiddenBody());
$violations = $scanner->scan('<body><div>Content</div></body>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaLiveStatus;
class AriaLiveStatusTest extends TestCase
{
public function testFlagsToastContainerWithoutAriaLive(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaLiveStatus());
$violations = $scanner->scan('<div class="toast">Saving changes...</div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_3_STATUS_MESSAGES', $violations[0]->ruleId);
}
public function testPassesToastWithAriaLive(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaLiveStatus());
$violations = $scanner->scan('<div class="toast" aria-live="polite">Saving changes...</div>');
$this->assertCount(0, $violations);
}
public function testPassesToastWithRoleStatus(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaLiveStatus());
$violations = $scanner->scan('<div class="toast" role="status">Saving changes...</div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaPressedToggle;
class AriaPressedToggleTest extends TestCase
{
public function testFlagsToggleButtonWithoutAriaPressed(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaPressedToggle());
$violations = $scanner->scan('<button class="btn-toggle">Mute</button>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_TOGGLE_BUTTON_STATE', $violations[0]->ruleId);
}
public function testPassesToggleButtonWithAriaPressed(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaPressedToggle());
$violations = $scanner->scan('<button class="btn-toggle" aria-pressed="false">Mute</button>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\ButtonLoadingState;
class ButtonLoadingStateTest extends TestCase
{
public function testFlagsLoadingButtonWithoutAriaBusy(): void
{
$scanner = new Scanner();
$scanner->addRule(new ButtonLoadingState());
$violations = $scanner->scan('<button class="btn is-loading"><span class="spinner"></span> Submit</button>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_BUTTON_LOADING_STATE', $violations[0]->ruleId);
}
public function testPassesLoadingButtonWithAriaBusy(): void
{
$scanner = new Scanner();
$scanner->addRule(new ButtonLoadingState());
$violations = $scanner->scan('<button class="btn is-loading" aria-busy="true"><span class="spinner"></span> Submit</button>');
$this->assertCount(0, $violations);
}
}
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\FigureCaptionStructure;
class FigureCaptionStructureTest extends TestCase
{
public function testFlagsOrphanFigcaption(): void
{
$scanner = new Scanner();
$scanner->addRule(new FigureCaptionStructure());
$violations = $scanner->scan('<div><figcaption>Caption text</figcaption></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_FIGCAPTION_PARENT', $violations[0]->ruleId);
}
public function testPassesValidFigcaptionInFigure(): void
{
$scanner = new Scanner();
$scanner->addRule(new FigureCaptionStructure());
$violations = $scanner->scan('<figure><img src="img.jpg" alt="test"><figcaption>Caption text</figcaption></figure>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\FocusableWithoutRole;
class FocusableWithoutRoleTest extends TestCase
{
public function testFlagsFocusableDivWithoutRole(): void
{
$scanner = new Scanner();
$scanner->addRule(new FocusableWithoutRole());
$violations = $scanner->scan('<div tabindex="0">Click me</div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_FOCUSABLE_ROLE', $violations[0]->ruleId);
}
public function testPassesFocusableDivWithRole(): void
{
$scanner = new Scanner();
$scanner->addRule(new FocusableWithoutRole());
$violations = $scanner->scan('<div tabindex="0" role="button">Click me</div>');
$this->assertCount(0, $violations);
}
public function testPassesNegativeTabindexDiv(): void
{
$scanner = new Scanner();
$scanner->addRule(new FocusableWithoutRole());
$violations = $scanner->scan('<div tabindex="-1">Programmatic focus only</div>');
$this->assertCount(0, $violations);
}
}
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\InputMissingAutocomplete;
class InputMissingAutocompleteTest extends TestCase
{
public function testFlagsPersonalDataInputWithoutAutocomplete(): void
{
$scanner = new Scanner();
$scanner->addRule(new InputMissingAutocomplete());
$violations = $scanner->scan('<form><input type="email" name="user_email"></form>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_5_INPUT_AUTOCOMPLETE', $violations[0]->ruleId);
}
public function testPassesPersonalDataInputWithAutocomplete(): void
{
$scanner = new Scanner();
$scanner->addRule(new InputMissingAutocomplete());
$violations = $scanner->scan('<form><input type="email" name="user_email" autocomplete="email"></form>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\ListItemParent;
class ListItemParentTest extends TestCase
{
public function testFlagsOrphanLiElement(): void
{
$scanner = new Scanner();
$scanner->addRule(new ListItemParent());
$violations = $scanner->scan('<div><li>Orphan Item</li></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_LIST_ITEM_PARENT', $violations[0]->ruleId);
}
public function testPassesLiInsideUl(): void
{
$scanner = new Scanner();
$scanner->addRule(new ListItemParent());
$violations = $scanner->scan('<ul><li>Valid Item</li></ul>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\OptgroupLabel;
class OptgroupLabelTest extends TestCase
{
public function testFlagsOptgroupWithoutLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new OptgroupLabel());
$violations = $scanner->scan('<select><optgroup><option>Option 1</option></optgroup></select>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_OPTGROUP_LABEL', $violations[0]->ruleId);
}
public function testPassesOptgroupWithLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new OptgroupLabel());
$violations = $scanner->scan('<select><optgroup label="Fruits"><option>Apple</option></optgroup></select>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\RadioGroupFieldset;
class RadioGroupFieldsetTest extends TestCase
{
public function testFlagsRadioGroupWithoutFieldsetLegend(): void
{
$scanner = new Scanner();
$scanner->addRule(new RadioGroupFieldset());
$violations = $scanner->scan('<div><input type="radio" name="gender" value="m"><input type="radio" name="gender" value="f"></div>');
$this->assertGreaterThanOrEqual(1, count($violations));
$this->assertSame('WCAG_1_3_1_RADIO_GROUP_FIELDSET', $violations[0]->ruleId);
}
public function testPassesRadioGroupInFieldsetWithLegend(): void
{
$scanner = new Scanner();
$scanner->addRule(new RadioGroupFieldset());
$violations = $scanner->scan('<fieldset><legend>Gender</legend><input type="radio" name="gender" value="m"><input type="radio" name="gender" value="f"></fieldset>');
$this->assertCount(0, $violations);
}
public function testPassesRadioGroupWithRoleRadiogroup(): void
{
$scanner = new Scanner();
$scanner->addRule(new RadioGroupFieldset());
$violations = $scanner->scan('<div role="radiogroup" aria-label="Gender"><input type="radio" name="gender" value="m"><input type="radio" name="gender" value="f"></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\SummaryParent;
class SummaryParentTest extends TestCase
{
public function testFlagsOrphanSummary(): void
{
$scanner = new Scanner();
$scanner->addRule(new SummaryParent());
$violations = $scanner->scan('<div><summary>Summary text</summary></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_SUMMARY_PARENT', $violations[0]->ruleId);
}
public function testPassesSummaryInDetails(): void
{
$scanner = new Scanner();
$scanner->addRule(new SummaryParent());
$violations = $scanner->scan('<details><summary>Summary text</summary><p>More details</p></details>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\TableMultiCaption;
class TableMultiCaptionTest extends TestCase
{
public function testFlagsMultipleCaptionsInTable(): void
{
$scanner = new Scanner();
$scanner->addRule(new TableMultiCaption());
$violations = $scanner->scan('<table><caption>Caption 1</caption><caption>Caption 2</caption><tr><td>Data</td></tr></table>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_TABLE_MULTI_CAPTION', $violations[0]->ruleId);
}
public function testPassesSingleCaptionTable(): void
{
$scanner = new Scanner();
$scanner->addRule(new TableMultiCaption());
$violations = $scanner->scan('<table><caption>Single Caption</caption><tr><td>Data</td></tr></table>');
$this->assertCount(0, $violations);
}
}