Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
fix: resolve PHPStan level 9 errors and bump version to v3.1.3
This commit is contained in:
+2
-2
@@ -12,7 +12,7 @@
|
||||
"yaknet"
|
||||
],
|
||||
"homepage": "https://forum.yak.net.tr",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.3",
|
||||
"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": [
|
||||
|
||||
@@ -2,3 +2,6 @@ parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
bootstrapFiles:
|
||||
- vendor/autoload.php
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user