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
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b805741ef2 | ||
|
|
84f713e434 | ||
|
|
c6df5e2bdc | ||
|
|
75171c6f96 |
+20
-1
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Broken Page</title>
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="main-content">Welcome to a broken page</h1>
|
||||
@@ -30,6 +30,25 @@
|
||||
<!-- Missing Iframe Title -->
|
||||
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>
|
||||
|
||||
<!-- New WCAG Rule Violations for testing -->
|
||||
|
||||
<!-- LinkTextGeneric: Generic text without context -->
|
||||
<a href="/read-more">Click here</a>
|
||||
<a href="/devam-oku">devamını oku</a>
|
||||
|
||||
<!-- PlaceholderAsLabel: Input relying only on placeholder -->
|
||||
<input type="text" placeholder="Search website...">
|
||||
|
||||
<!-- ImageAltRedundant: Redundant words in alt text -->
|
||||
<img src="logo2.png" alt="logo of our company">
|
||||
<img src="profile.jpg" alt="profile photo of john doe">
|
||||
|
||||
<!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden="true" -->
|
||||
<div aria-hidden="true">
|
||||
<button type="button">Hidden click</button>
|
||||
</div>
|
||||
<a href="#" aria-hidden="true">Hidden link</a>
|
||||
|
||||
<p>This page has multiple accessibility issues.</p>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
parameters:
|
||||
level: 6
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
@@ -30,8 +30,16 @@ class FixCommand extends Command
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$target = $input->getArgument('target');
|
||||
$projectPath = $input->getOption('project-path') ?? getcwd();
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
if (!is_string($target)) {
|
||||
$io->error("Target must be a string URL or file path.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$projectPathOption = $input->getOption('project-path');
|
||||
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
||||
if ($projectPath === false) {
|
||||
$projectPath = '.';
|
||||
}
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
$config = new Config();
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
@@ -45,7 +53,7 @@ class FixCommand extends Command
|
||||
|
||||
// 1. Fetch HTML
|
||||
$html = @file_get_contents($target);
|
||||
if (!$html) {
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
@@ -33,14 +33,22 @@ class ScanCommand extends Command
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$target = $input->getArgument('target');
|
||||
$enableAi = $input->getOption('ai');
|
||||
$projectPath = $input->getOption('project-path') ?? getcwd();
|
||||
if (!is_string($target)) {
|
||||
$io->error("Target must be a string URL or file path.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$enableAi = (bool)$input->getOption('ai');
|
||||
$projectPathOption = $input->getOption('project-path');
|
||||
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
||||
if ($projectPath === false) {
|
||||
$projectPath = '.';
|
||||
}
|
||||
|
||||
$io->title("Accessibility Console - Scanning: $target");
|
||||
|
||||
// 1. Fetch HTML
|
||||
$html = @file_get_contents($target);
|
||||
if (!$html) {
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
@@ -97,7 +105,7 @@ class ScanCommand extends Command
|
||||
|
||||
// Export Report
|
||||
$reportPath = $input->getOption('report');
|
||||
if ($reportPath) {
|
||||
if (is_string($reportPath) && $reportPath !== '') {
|
||||
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
||||
if ($extension === 'json') {
|
||||
$data = array_map(fn($v) => $v->toArray(), $violations);
|
||||
|
||||
@@ -21,12 +21,21 @@ abstract class AbstractRule
|
||||
|
||||
protected function createViolation(string $message, \DOMElement $element): Violation
|
||||
{
|
||||
$doc = $element->ownerDocument;
|
||||
$html = '';
|
||||
if ($doc !== null) {
|
||||
$htmlVal = $doc->saveHTML($element);
|
||||
if (is_string($htmlVal)) {
|
||||
$html = $htmlVal;
|
||||
}
|
||||
}
|
||||
|
||||
return new Violation(
|
||||
ruleId: (new \ReflectionClass($this))->getShortName(),
|
||||
message: $message,
|
||||
severity: $this->getSeverity(),
|
||||
standard: $this->getStandard(),
|
||||
htmlSnippet: $element->ownerDocument->saveHTML($element)
|
||||
htmlSnippet: $html
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-18
@@ -19,25 +19,21 @@ class RuleEngine
|
||||
{
|
||||
$allViolations = [];
|
||||
foreach ($this->rules as $rule) {
|
||||
if (method_exists($rule, 'check')) {
|
||||
// Determine if it's a document-level rule or element-level rule
|
||||
$reflection = new \ReflectionMethod($rule, 'check');
|
||||
$params = $reflection->getParameters();
|
||||
|
||||
$type = $params[0]->getType();
|
||||
if (isset($params[0]) && $type instanceof \ReflectionNamedType && $type->getName() === 'DOMDocument') {
|
||||
// Document-level rule (Core\AbstractRule style)
|
||||
$violations = $rule->check($doc);
|
||||
$allViolations = array_merge($allViolations, $violations);
|
||||
} else {
|
||||
// Element-level rule (Rules\RuleInterface style)
|
||||
// We need to iterate over all elements
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$elements = $xpath->query('//*');
|
||||
if ($rule instanceof AbstractRule) {
|
||||
// Document-level rule (Core\AbstractRule style)
|
||||
$violations = $rule->check($doc);
|
||||
$allViolations = array_merge($allViolations, $violations);
|
||||
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
|
||||
// Element-level rule (Rules\RuleInterface style)
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$elements = $xpath->query('//*');
|
||||
if ($elements !== false) {
|
||||
foreach ($elements as $element) {
|
||||
$violation = $rule->check($element);
|
||||
if ($violation) {
|
||||
$allViolations[] = $violation;
|
||||
if ($element instanceof \DOMElement) {
|
||||
$violation = $rule->check($element);
|
||||
if ($violation) {
|
||||
$allViolations[] = $violation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
class Scanner
|
||||
{
|
||||
public function __construct(private ?RuleEngine $engine = null)
|
||||
private RuleEngine $engine;
|
||||
|
||||
public function __construct(?RuleEngine $engine = null)
|
||||
{
|
||||
if ($this->engine === null) {
|
||||
$this->engine = new RuleEngine();
|
||||
}
|
||||
$this->engine = $engine ?? new RuleEngine();
|
||||
}
|
||||
|
||||
public function addRule(mixed $rule): void
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,12 +16,21 @@ abstract class AbstractRule implements RuleInterface
|
||||
|
||||
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
|
||||
{
|
||||
$doc = $element->ownerDocument;
|
||||
$html = '';
|
||||
if ($doc !== null) {
|
||||
$htmlVal = $doc->saveHTML($element);
|
||||
if (is_string($htmlVal)) {
|
||||
$html = $htmlVal;
|
||||
}
|
||||
}
|
||||
|
||||
return new Violation(
|
||||
ruleId: $this->getId(),
|
||||
message: $message,
|
||||
severity: $this->getSeverity(),
|
||||
standard: $this->getStandard(),
|
||||
htmlSnippet: $element->ownerDocument->saveHTML($element),
|
||||
htmlSnippet: $html,
|
||||
fixSuggestion: $suggestion
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaHiddenFocusable extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_FOCUS'; }
|
||||
public function getDescription(): string { return 'Focusable elements must not be hidden with aria-hidden="true".'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if ($element->getAttribute('aria-hidden') !== 'true') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isFocusable($element) || $this->hasFocusableDescendant($element)) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Remove aria-hidden="true" from the container or make its focusable elements non-focusable (e.g. by setting tabindex="-1" or disabled).'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isFocusable(DOMElement $element): bool
|
||||
{
|
||||
$tagName = strtolower($element->tagName);
|
||||
|
||||
if ($element->hasAttribute('tabindex')) {
|
||||
$tabindex = (int)$element->getAttribute('tabindex');
|
||||
if ($tabindex < 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($tagName === 'a' && $element->hasAttribute('href')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($tagName, ['button', 'select', 'textarea'], true)) {
|
||||
return !$element->hasAttribute('disabled');
|
||||
}
|
||||
|
||||
if ($tagName === 'input') {
|
||||
$type = strtolower($element->getAttribute('type'));
|
||||
return $type !== 'hidden' && !$element->hasAttribute('disabled');
|
||||
}
|
||||
|
||||
if ($tagName === 'iframe') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasFocusableDescendant(DOMElement $element): bool
|
||||
{
|
||||
$doc = $element->ownerDocument;
|
||||
if ($doc === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$descendants = $xpath->query('.//*', $element);
|
||||
if ($descendants === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($descendants as $descendant) {
|
||||
if ($descendant instanceof DOMElement && $this->isFocusable($descendant)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+35
-31
@@ -28,43 +28,47 @@ class AriaRole extends AbstractRule
|
||||
|
||||
// 1. Check WAI-ARIA Role Validity
|
||||
$roleElements = $xpath->query('//*[@role]');
|
||||
foreach ($roleElements as $el) {
|
||||
if (!$el instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
$role = trim($el->getAttribute('role'));
|
||||
if ($role === '') {
|
||||
continue;
|
||||
}
|
||||
$roles = array_filter(explode(' ', $role));
|
||||
foreach ($roles as $r) {
|
||||
if (!in_array(strtolower($r), self::$validRoles, true)) {
|
||||
$violations[] = $this->createViolation(
|
||||
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
|
||||
$el
|
||||
);
|
||||
if ($roleElements !== false) {
|
||||
foreach ($roleElements as $el) {
|
||||
if (!$el instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
$role = trim($el->getAttribute('role'));
|
||||
if ($role === '') {
|
||||
continue;
|
||||
}
|
||||
$roles = array_filter(explode(' ', $role));
|
||||
foreach ($roles as $r) {
|
||||
if (!in_array(strtolower($r), self::$validRoles, true)) {
|
||||
$violations[] = $this->createViolation(
|
||||
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
|
||||
$el
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check aria-controls Targets Existence
|
||||
$controlsElements = $xpath->query('//*[@aria-controls]');
|
||||
foreach ($controlsElements as $el) {
|
||||
if (!$el instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
$targetsStr = trim($el->getAttribute('aria-controls'));
|
||||
if ($targetsStr === '') {
|
||||
continue;
|
||||
}
|
||||
$targets = array_filter(explode(' ', $targetsStr));
|
||||
foreach ($targets as $targetId) {
|
||||
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
|
||||
if ($targetQuery->length === 0) {
|
||||
$violations[] = $this->createViolation(
|
||||
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
|
||||
$el
|
||||
);
|
||||
if ($controlsElements !== false) {
|
||||
foreach ($controlsElements as $el) {
|
||||
if (!$el instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
$targetsStr = trim($el->getAttribute('aria-controls'));
|
||||
if ($targetsStr === '') {
|
||||
continue;
|
||||
}
|
||||
$targets = array_filter(explode(' ', $targetsStr));
|
||||
foreach ($targets as $targetId) {
|
||||
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
|
||||
if ($targetQuery !== false && $targetQuery->length === 0) {
|
||||
$violations[] = $this->createViolation(
|
||||
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
|
||||
$el
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,14 +99,14 @@ class ColorContrast extends AbstractRule
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$r = hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
|
||||
$g = hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
|
||||
$b = hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
|
||||
$r = (int)hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
|
||||
$g = (int)hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
|
||||
$b = (int)hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
|
||||
return [$r, $g, $b];
|
||||
} elseif (strlen($hex) === 6) {
|
||||
$r = hexdec(substr($hex, 0, 2));
|
||||
$g = hexdec(substr($hex, 2, 2));
|
||||
$b = hexdec(substr($hex, 4, 2));
|
||||
$r = (int)hexdec(substr($hex, 0, 2));
|
||||
$g = (int)hexdec(substr($hex, 2, 2));
|
||||
$b = (int)hexdec(substr($hex, 4, 2));
|
||||
return [$r, $g, $b];
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -13,6 +13,9 @@ class DuplicateId extends AbstractRule
|
||||
$violations = [];
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$elements = $xpath->query('//*[@id]');
|
||||
if ($elements === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($elements as $el) {
|
||||
|
||||
@@ -29,10 +29,13 @@ class FormLabel extends AbstractRule
|
||||
|
||||
if ($element->hasAttribute('id')) {
|
||||
$id = $element->getAttribute('id');
|
||||
$xpath = new \DOMXPath($element->ownerDocument);
|
||||
$labels = $xpath->query("//label[@for='$id']");
|
||||
if ($labels->length > 0) {
|
||||
return null;
|
||||
$doc = $element->ownerDocument;
|
||||
if ($doc !== null) {
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$labels = $xpath->query("//label[@for='$id']");
|
||||
if ($labels !== false && $labels->length > 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ class HeadingOrderRule extends AbstractRule
|
||||
$violations = [];
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
|
||||
if ($headings === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lastLevel = 0;
|
||||
foreach ($headings as $h) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class ImageAltRedundant extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_1_1_1_ALT_REDUNDANT'; }
|
||||
public function getDescription(): string { return 'Image alt text should not contain redundant words like "image" or "photo".'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
|
||||
/** @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'
|
||||
];
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (strtolower($element->tagName) !== 'img') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$element->hasAttribute('alt')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$alt = trim($element->getAttribute('alt'));
|
||||
$cleanAlt = strtolower(preg_replace('/\s+/', ' ', $alt) ?? '');
|
||||
|
||||
if ($cleanAlt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->redundantWords as $word) {
|
||||
if (str_starts_with($cleanAlt, $word) || str_contains($cleanAlt, ' ' . $word)) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Remove redundant phrases like "' . $word . '" from the alt text. Screen readers already announce elements as images.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class LinkTextGeneric extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_2_4_4_LINK_GENERIC'; }
|
||||
public function getDescription(): string { return 'Links must have descriptive text. Generic text like "click here" or "read more" is not accessible.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
|
||||
/** @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'
|
||||
];
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (strtolower($element->tagName) !== 'a') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim($element->textContent);
|
||||
$cleanText = strtolower(preg_replace('/\s+/', ' ', $text) ?? '');
|
||||
|
||||
if (in_array($cleanText, $this->genericWords, true)) {
|
||||
// Check for descriptive attributes
|
||||
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Change the link text to describe its target, or add an aria-label attribute to provide context.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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 PageTitle extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_2_4_2_PAGE_TITLE'; }
|
||||
public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (strtolower($element->tagName) !== 'head') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$titles = $element->getElementsByTagName('title');
|
||||
|
||||
if ($titles->length === 0) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add a <title> element inside the <head>.'
|
||||
);
|
||||
}
|
||||
|
||||
$titleText = '';
|
||||
$firstTitle = $titles->item(0);
|
||||
if ($firstTitle !== null) {
|
||||
$titleText = trim($firstTitle->textContent);
|
||||
}
|
||||
|
||||
if ($titleText === '') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Provide descriptive text inside the <title> element.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class PlaceholderAsLabel extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_3_3_2_PLACEHOLDER_LABEL'; }
|
||||
public function getDescription(): string { return 'Inputs should not use placeholder text as their only label.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
$tags = ['input', 'select', 'textarea'];
|
||||
if (!in_array(strtolower($element->tagName), $tags, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip hidden inputs, buttons, etc.
|
||||
$type = strtolower($element->getAttribute('type'));
|
||||
if ($type === 'hidden' || $type === 'submit' || $type === 'button' || $type === 'reset') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If it doesn't have a placeholder, this rule doesn't apply (FormLabel handles general missing labels)
|
||||
if (!$element->hasAttribute('placeholder')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there is an aria-label or aria-labelledby
|
||||
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for associated label element
|
||||
if ($element->hasAttribute('id')) {
|
||||
$id = $element->getAttribute('id');
|
||||
$doc = $element->ownerDocument;
|
||||
if ($doc !== null) {
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$labels = $xpath->query("//label[@for='$id']");
|
||||
if ($labels !== false && $labels->length > 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if wrapped in label
|
||||
$parent = $element->parentNode;
|
||||
while ($parent) {
|
||||
if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'label') {
|
||||
return null;
|
||||
}
|
||||
$parent = $parent->parentNode;
|
||||
}
|
||||
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add a <label for="..."> or an aria-label attribute. Placeholder text is not a reliable label replacement.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ class StandardRuleSet
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
/** @var RuleInterface[] $rules */
|
||||
$rules = [
|
||||
new HtmlHasLang(),
|
||||
new ImgAltText(),
|
||||
new EmptyLink(),
|
||||
@@ -23,6 +24,12 @@ class StandardRuleSet
|
||||
new DuplicateId(),
|
||||
new AriaRole(),
|
||||
new IframeTitle(),
|
||||
new LinkTextGeneric(),
|
||||
new PlaceholderAsLabel(),
|
||||
new ImageAltRedundant(),
|
||||
new AriaHiddenFocusable(),
|
||||
new PageTitle(),
|
||||
];
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ class SourceLocator
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet));
|
||||
$replaced = preg_replace('/\s+/', ' ', $snippet);
|
||||
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
|
||||
if ($cleanSnippet === '') {
|
||||
return null;
|
||||
}
|
||||
@@ -33,7 +34,17 @@ class SourceLocator
|
||||
|
||||
try {
|
||||
$directory = new RecursiveDirectoryIterator($this->searchPath, \FilesystemIterator::SKIP_DOTS);
|
||||
$iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
|
||||
$filter = new \RecursiveCallbackFilterIterator($directory, function ($current) {
|
||||
/** @var SplFileInfo $current */
|
||||
if ($current->isDir()) {
|
||||
$filename = $current->getFilename();
|
||||
if ($filename === 'vendor' || $filename === '.git' || $filename === 'node_modules') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
|
||||
} catch (\UnexpectedValueException) {
|
||||
return null;
|
||||
}
|
||||
@@ -81,7 +92,7 @@ class SourceLocator
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type: string, score?: float, location?: array{file: string, line: int}}
|
||||
* @return array{type: 'exact', location: array{file: string, line: int}}|array{type: 'fuzzy', score: float, location: array{file: string, line: int}}|array{type: 'none'}
|
||||
*/
|
||||
private function searchInFile(string $filepath, string $searchSnippet, string $fullSnippet): array
|
||||
{
|
||||
@@ -98,7 +109,8 @@ class SourceLocator
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
$lineNumber = $index + 1;
|
||||
$cleanLine = trim(preg_replace('/\s+/', ' ', $line));
|
||||
$replacedLine = preg_replace('/\s+/', ' ', $line);
|
||||
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
|
||||
|
||||
if (str_contains($cleanLine, $searchSnippet)) {
|
||||
return [
|
||||
|
||||
+1866
-75
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user