Compare commits

...
9 Commits
144 changed files with 7050 additions and 62 deletions
+2
View File
@@ -4,3 +4,5 @@
/rapor.html
/.phpunit.result.cache
/phpstan-baseline.neon
/.phpunit.cache/
+8 -6
View File
@@ -11,17 +11,19 @@ if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
}
use Symfony\Component\Console\Application;
use YakNet\AccessibilityConsole\Core\Container\ContainerFactory;
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
$application = new Application('YakNet Accessibility Console', '3.0.1');
$container = ContainerFactory::create();
$application = new Application('YakNet Accessibility Console', '3.1.2');
// Add commands
$application->add(new ScanCommand());
$application->add(new FixCommand());
$application->add(new InitCommand());
$application->add(new RulesCommand());
// Add commands resolved via DI Container
$application->addCommand($container->get(ScanCommand::class));
$application->addCommand($container->get(FixCommand::class));
$application->addCommand($container->get(InitCommand::class));
$application->addCommand($container->get(RulesCommand::class));
$application->run();
+13 -6
View File
@@ -12,7 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.0.2",
"version": "3.1.4",
"type": "library",
"license": "MIT",
"authors": [
@@ -27,21 +27,28 @@
"ext-dom": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"symfony/console": "^6.4|^7.0",
"symfony/yaml": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0",
"monolog/monolog": "^3.0",
"vlucas/phpdotenv": "^5.6",
"google-gemini-php/client": "^1.0",
"google-gemini-php/client": "^1.0|^2.0",
"guzzlehttp/guzzle": "^7.8",
"masterminds/html5": "^2.8",
"sabberworm/php-css-parser": "^9.4",
"chrome-php/chrome": "^1.15"
"chrome-php/chrome": "^1.15",
"sebastian/diff": "^5.1 || ^6.0 || ^7.0 || ^8.0 || ^9.0",
"php-di/php-di": "^7.1",
"symfony/css-selector": "^8.1",
"symfony/dom-crawler": "^8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10"
"phpstan/phpstan": "^2.2"
},
"autoload": {
"files": [
"src/accessibility-console.php"
],
"psr-4": {
"YakNet\\AccessibilityConsole\\": "src/"
}
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Local Fix</title>
</head>
<body>
<span style="text-decoration: blink;">Hello blink</span>
<input type="text" name="username" autofocus />
</body>
</html>
+18 -2
View File
@@ -1,9 +1,25 @@
<html>
<head>
<title>Test Page</title>
<meta http-equiv="refresh" content="30">
</head>
<body>
<img src="avatar.png">
<button></button>
<img src="avatar.png" alt="Avatar">
<button>Submit</button>
<a href="#">Click here for nothing</a>
<a href="javascript:void(0)">Do something</a>
<label for="nonexistent-id">Broken Label</label>
<table>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
<tr>
<td scope="row">Data 1</td>
<td>Data 2</td>
</tr>
</table>
</body>
</html>
+3
View File
@@ -2,3 +2,6 @@ parameters:
level: 9
paths:
- src
bootstrapFiles:
- vendor/autoload.php
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="YakNet Accessibility Console Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\AI\Providers\GeminiProvider;
use YakNet\AccessibilityConsole\AI\Providers\OpenAiProvider;
use YakNet\AccessibilityConsole\AI\Providers\ClaudeProvider;
use YakNet\AccessibilityConsole\AI\Providers\MockAiProvider;
use YakNet\AccessibilityConsole\Core\Violation;
class AiFixerManager
{
/** @var array<string, AiProviderInterface> */
private array $providers = [];
private ?string $activeProviderName = null;
public function __construct(?string $geminiApiKey = null, ?string $openAiApiKey = null, ?string $claudeApiKey = null)
{
// Register default providers
if ($geminiApiKey) {
$this->registerProvider('gemini', new GeminiProvider($geminiApiKey));
$this->activeProviderName = 'gemini';
}
if ($openAiApiKey) {
$this->registerProvider('openai', new OpenAiProvider($openAiApiKey));
if (!$this->activeProviderName) {
$this->activeProviderName = 'openai';
}
}
if ($claudeApiKey) {
$this->registerProvider('claude', new ClaudeProvider($claudeApiKey));
if (!$this->activeProviderName) {
$this->activeProviderName = 'claude';
}
}
// Always register mock provider as fallback/testing option
$this->registerProvider('mock', new MockAiProvider());
if (!$this->activeProviderName) {
$this->activeProviderName = 'mock';
}
}
public function registerProvider(string $name, AiProviderInterface $provider): void
{
$this->providers[strtolower($name)] = $provider;
}
public function setActiveProvider(string $name): void
{
$key = strtolower($name);
if (isset($this->providers[$key])) {
$this->activeProviderName = $key;
}
}
public function getActiveProvider(): ?AiProviderInterface
{
if ($this->activeProviderName && isset($this->providers[$this->activeProviderName])) {
return $this->providers[$this->activeProviderName];
}
return null;
}
public function suggestFix(Violation $violation): ?string
{
$provider = $this->getActiveProvider();
if ($provider) {
return $provider->suggestFix($violation);
}
return "No active AI provider configured.";
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\Core\Violation;
interface AiProviderInterface
{
/**
* Get the name of this AI provider.
*
* @return string
*/
public function getName(): string;
/**
* Suggest a fix for the given violation.
*
* @param Violation $violation
* @return string|null
*/
public function suggestFix(Violation $violation): ?string;
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\Core\Violation;
class PromptBuilder
{
public static function build(Violation $violation): string
{
return <<<PROMPT
You are a WCAG 2.1 Accessibility expert.
A static analysis tool found an accessibility issue in this HTML snippet.
Snippet: {$violation->htmlSnippet}
Error Message: {$violation->message}
Rule ID: {$violation->ruleId}
Severity: {$violation->severity->value}
Standard: {$violation->standard->value}
Please provide a short explanation of the fix in Turkish and the fixed version of the HTML snippet.
Use the following EXACT format:
EXPLANATION: [A short explanation of what you fixed and why, in Turkish]
FIX: [Only the corrected HTML snippet, no surrounding explanation]
PROMPT;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use GuzzleHttp\Client as GuzzleClient;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class ClaudeProvider implements AiProviderInterface
{
private ?GuzzleClient $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
$this->client = new GuzzleClient([
'base_uri' => 'https://api.anthropic.com/v1/',
'headers' => [
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',
'Content-Type' => 'application/json',
],
'timeout' => 10.0,
]);
}
}
public function getName(): string
{
return 'Anthropic Claude';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$response = $this->client->post('messages', [
'json' => [
'model' => 'claude-3-5-haiku-20241022',
'max_tokens' => 1000,
'messages' => [
['role' => 'user', 'content' => $prompt]
],
],
]);
$data = json_decode($response->getBody()->getContents(), true);
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;
} catch (\Throwable $e) {
return "Claude Error: " . $e->getMessage();
}
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use Gemini;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class GeminiProvider implements AiProviderInterface
{
private ?\Gemini\Client $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
try {
$this->client = \Gemini::client($this->apiKey);
} catch (\Throwable) {
$this->client = null;
}
}
}
public function getName(): string
{
return 'Google Gemini';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->apiKey || !$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
return trim($result->text());
} catch (\Throwable $e) {
return "Gemini Error: " . $e->getMessage();
}
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\Core\Violation;
class MockAiProvider implements AiProviderInterface
{
public function getName(): string
{
return 'Mock AI';
}
public function suggestFix(Violation $violation): ?string
{
$snippet = $violation->htmlSnippet;
$fixedSnippet = $snippet;
// Simple mock fixes based on rule ID
if ($violation->ruleId === 'WCAG_2_2_2_BLINK') {
$fixedSnippet = str_replace(['<blink>', '</blink>'], ['<span>', '</span>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_1_DUPLICATE_H1') {
$fixedSnippet = str_replace(['<h1>', '</h1>'], ['<h2>', '</h2>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_5_AUTOCOMPLETE') {
$fixedSnippet = str_replace('<input', '<input autocomplete="email"', $snippet);
} elseif ($violation->ruleId === 'WCAG_2_1_1_SCROLLABLE_FOCUS') {
$fixedSnippet = str_replace('<div', '<div tabindex="0"', $snippet);
} elseif ($violation->ruleId === 'WCAG_4_1_2_ARIA_LABELLEDBY') {
$fixedSnippet = str_replace('aria-labelledby="non_existent_label"', 'aria-labelledby="real_label"', $snippet);
} elseif ($violation->ruleId === 'WCAG_1_1_1_ALT_PLACEHOLDER') {
if (str_contains($snippet, 'alt="logo"')) {
$fixedSnippet = str_replace('alt="logo"', 'alt="YakNet Accessibility Console Company Logo"', $snippet);
} else {
$fixedSnippet = str_replace('alt="header_banner.jpg"', 'alt="YakNet Accessibility Console Marketing Banner"', $snippet);
}
} elseif ($violation->ruleId === 'WCAG_1_1_1_SVG') {
$fixedSnippet = str_replace('<svg', '<svg aria-hidden="true"', $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_1_TABLE_SUMMARY') {
$fixedSnippet = preg_replace('/summary=".*?"/i', '', $snippet) ?: $snippet;
} elseif ($violation->ruleId === 'WCAG_1_3_1_PRESENTATION_TAGS') {
$fixedSnippet = str_replace(['<center>', '</center>', '<strike>', '</strike>'], ['<span style="text-align: center; display: block;">', '</span>', '<span style="text-decoration: line-through;">', '</span>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_2_4_3_AUTOFOCUS') {
$fixedSnippet = str_replace(' autofocus', '', $snippet);
}
return "EXPLANATION: Mocked correction for accessibility validation.\nFIX: " . $fixedSnippet;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use GuzzleHttp\Client as GuzzleClient;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class OpenAiProvider implements AiProviderInterface
{
private ?GuzzleClient $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
$this->client = new GuzzleClient([
'base_uri' => 'https://api.openai.com/v1/',
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
],
'timeout' => 10.0,
]);
}
}
public function getName(): string
{
return 'OpenAI GPT';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$response = $this->client->post('chat/completions', [
'json' => [
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'user', 'content' => $prompt]
],
'temperature' => 0.2,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
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;
} catch (\Throwable $e) {
return "OpenAI Error: " . $e->getMessage();
}
}
}
+60 -17
View File
@@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\AI\AiFixerManager;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
@@ -89,7 +89,8 @@ class FixCommand extends Command
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
$fixer = new GeminiFixer($apiKey);
$fixer = new AiFixerManager($apiKey);
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$fixedCount = 0;
// Group violations by file path
@@ -116,27 +117,49 @@ class FixCommand extends Command
continue;
}
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
foreach ($fileViolations as $violation) {
$line = $violation->location['line'] ?? 0;
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
// Get AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
// Try Local Healer first
$rawSuggestion = $localHealer->heal($violation);
$isLocalFix = false;
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
// Fallback to AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
}
$suggestion = $rawSuggestion;
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
if (!is_string($suggestion)) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
$io->writeln("<fg=green>Fix Found!</>");
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
if ($isLocalFix) {
$io->writeln("<fg=green>Local Fix Found!</>");
} else {
$io->writeln("<fg=green>AI Fix Found!</>");
}
$io->writeln(" <fg=gray>Diff:</>");
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
$io->writeln($indentedDiff);
if ($dryRun) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
@@ -210,7 +233,8 @@ class FixCommand extends Command
$io->note("Found " . count($violations) . " violations. Starting AI analysis...");
$fixer = new GeminiFixer($apiKey);
$fixer = new AiFixerManager($apiKey);
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$locator = new SourceLocator($projectPath);
$fixedCount = 0;
@@ -224,23 +248,42 @@ class FixCommand extends Command
continue;
}
// Get AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
$rawSuggestion = $localHealer->heal($violation);
$isLocalFix = false;
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
$rawSuggestion = $fixer->suggestFix($violation);
}
$suggestion = $rawSuggestion;
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
if (!is_string($suggestion)) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
$io->writeln("<fg=green>Fix Found!</>");
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
if ($isLocalFix) {
$io->writeln("<fg=green>Local Fix Found!</>");
} else {
$io->writeln("<fg=green>AI Fix Found!</>");
}
$io->writeln(" <fg=gray>Diff:</>");
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
$io->writeln($indentedDiff);
if ($dryRun) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
+23 -3
View File
@@ -18,6 +18,10 @@ use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
use YakNet\AccessibilityConsole\Reporting\JsonFormatter;
use YakNet\AccessibilityConsole\Reporting\XmlFormatter;
use YakNet\AccessibilityConsole\Reporting\MarkdownFormatter;
use YakNet\AccessibilityConsole\Reporting\CsvFormatter;
use YakNet\AccessibilityConsole\Reporting\GitLabCodeQualityFormatter;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command
@@ -202,10 +206,29 @@ class ScanCommand extends Command
$formatter = new JsonFormatter();
} elseif ($format === 'github') {
$formatter = new GithubFormatter();
} elseif ($format === 'xml') {
$formatter = new XmlFormatter();
} elseif ($format === 'markdown') {
$formatter = new MarkdownFormatter();
} elseif ($format === 'csv') {
$formatter = new CsvFormatter();
} elseif ($format === 'gitlab') {
$formatter = new GitLabCodeQualityFormatter();
}
$exitCode = $formatter->format($violations, $baselinedCount, $io);
if ($format === 'console') {
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$io->section("Accessibility Ratings");
$color = $metrics['score'] >= 90 ? 'green' : ($metrics['score'] >= 70 ? 'yellow' : 'red');
$io->writeln(sprintf("Health Score: <fg=%s>%d/100</>", $color, $metrics['score']));
$io->writeln(sprintf(" - Screen Reader / Visual Impact: <fg=cyan>%d%%</>", $metrics['visualImpact']));
$io->writeln(sprintf(" - Keyboard / Motor Impact: <fg=cyan>%d%%</>", $metrics['motorImpact']));
$io->writeln(sprintf(" - Cognitive / Distraction Impact: <fg=cyan>%d%%</>", $metrics['cognitiveImpact']));
$io->newLine();
}
// Export report if --report is specified
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
@@ -279,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'];
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Baseline;
class FuzzyBaselineMatcher
{
/**
* Determine if two HTML snippets are fuzzy matched.
*
* @param string $snippetA
* @param string $snippetB
* @return bool
*/
public static function match(string $snippetA, string $snippetB): bool
{
$normA = self::normalize($snippetA);
$normB = self::normalize($snippetB);
if ($normA === '' || $normB === '') {
return false;
}
return $normA === $normB;
}
/**
* Normalize HTML snippet by stripping formatting, converting to lowercase,
* and sorting attributes.
*
* @param string $html
* @return string
*/
public static function normalize(string $html): string
{
$html = trim($html);
if ($html === '') {
return '';
}
// Parse using simple regex to sort attributes and lowercase tag
if (preg_match('/^<([a-zA-Z0-9:-]+)(.*?)>$/s', $html, $matches)) {
$tag = strtolower($matches[1]);
$attrsContent = $matches[2];
// Parse attributes
preg_match_all('/([a-zA-Z0-9:-]+)=["\'](.*?)["\']/s', $attrsContent, $attrMatches, PREG_SET_ORDER);
$attrs = [];
foreach ($attrMatches as $match) {
$attrs[strtolower($match[1])] = trim($match[2]);
}
ksort($attrs);
$attrStr = '';
foreach ($attrs as $name => $value) {
$attrStr .= sprintf(' %s="%s"', $name, $value);
}
return sprintf('<%s%s>', $tag, $attrStr);
}
// Fallback: Strip all whitespace and lowercase
$cleaned = preg_replace('/\s+/', '', $html) ?: '';
return strtolower($cleaned);
}
}
+9 -4
View File
@@ -24,7 +24,12 @@ class BaselineManager
}
foreach ($data['violations'] as $item) {
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) {
if (!is_array($item)) {
continue;
}
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])
&& is_string($item['file']) && is_string($item['ruleId']) && is_string($item['htmlSnippet'])
) {
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
$this->ignoredKeys[$key] = true;
}
@@ -82,9 +87,9 @@ class BaselineManager
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
{
// Normalize whitespaces and trim to prevent minor formatting updates from breaking baseline
$normalizedSnippet = preg_replace('/\s+/', ' ', trim($htmlSnippet));
if ($normalizedSnippet === null) {
// Normalize snippet using fuzzy baseline matcher
$normalizedSnippet = \YakNet\AccessibilityConsole\Core\Baseline\FuzzyBaselineMatcher::normalize($htmlSnippet);
if ($normalizedSnippet === '') {
$normalizedSnippet = trim($htmlSnippet);
}
+7 -5
View File
@@ -35,6 +35,7 @@ class Config
try {
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
if (is_array($content)) {
/** @var array<string, mixed> $content */
return new self($content);
}
} catch (\Throwable $e) {
@@ -56,7 +57,7 @@ class Config
{
$paths = $this->get('paths');
if (is_array($paths)) {
return $paths;
return array_values(array_filter($paths, 'is_string'));
}
if (is_string($paths)) {
return [$paths];
@@ -71,7 +72,7 @@ class Config
{
$exclude = $this->get('exclude_paths');
if (is_array($exclude)) {
return $exclude;
return array_values(array_filter($exclude, 'is_string'));
}
if (is_string($exclude)) {
return [$exclude];
@@ -108,10 +109,10 @@ class Config
if (is_array($rules)) {
if (isset($rules['include']) && is_array($rules['include'])) {
$include = $rules['include'];
$include = array_values(array_filter($rules['include'], 'is_string'));
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = $rules['exclude'];
$exclude = array_values(array_filter($rules['exclude'], 'is_string'));
}
}
@@ -123,6 +124,7 @@ class Config
public function getGeminiApiKey(): ?string
{
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
$key = $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
return is_string($key) ? $key : null;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Config;
class SmartPresetBuilder
{
/**
* Analyze HTML content features to suggest rules presets.
*
* @param string $html
* @return array{suggestedLevel: int, tagsCheckedCount: array<string, int>, recommendedExclusions: string[]}
*/
public function analyze(string $html): array
{
$tagsChecked = ['img' => 0, 'form' => 0, 'table' => 0, 'svg' => 0, 'video' => 0];
foreach (array_keys($tagsChecked) as $tag) {
$tagsChecked[$tag] = substr_count(strtolower($html), '<' . $tag);
}
// Calculate recommendations
$suggestedLevel = 3; // Standard baseline
if ($tagsChecked['form'] > 5 || $tagsChecked['table'] > 3) {
$suggestedLevel = 4; // Higher complexity requires more strict tests
}
if ($tagsChecked['video'] > 0) {
$suggestedLevel = 5; // Video elements need caption checks, standard level 5
}
$exclusions = [];
if ($tagsChecked['svg'] === 0) {
$exclusions[] = 'WCAG_1_1_1_SVG';
}
if ($tagsChecked['video'] === 0) {
$exclusions[] = 'WCAG_1_2_2_VIDEO_TRACK';
}
return [
'suggestedLevel' => $suggestedLevel,
'tagsCheckedCount' => $tagsChecked,
'recommendedExclusions' => $exclusions,
];
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Container;
use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
class ContainerFactory
{
/**
* Create and configure the PHP-DI container.
*
* @return ContainerInterface
*/
public static function create(): ContainerInterface
{
$builder = new ContainerBuilder();
$builder->addDefinitions([
Config::class => function () {
$configPath = getcwd() . '/a11y.yaml';
return Config::fromYaml($configPath);
},
Scanner::class => function () {
return new Scanner();
},
Analyser::class => function (ContainerInterface $c) {
$config = $c->get(Config::class);
$scanner = $c->get(Scanner::class);
return new Analyser(
$config instanceof Config ? $config : null,
$scanner instanceof Scanner ? $scanner : null
);
},
ScanCommand::class => function () {
return new ScanCommand();
},
FixCommand::class => function () {
return new FixCommand();
},
InitCommand::class => function () {
return new InitCommand();
},
RulesCommand::class => function () {
return new RulesCommand();
},
]);
return $builder->build();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Diff;
use SebastianBergmann\Diff\Differ;
use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
class VisualDiffGenerator
{
private Differ $differ;
public function __construct()
{
$this->differ = new Differ(new UnifiedDiffOutputBuilder("--- Original\n+++ Healed\n"));
}
/**
* Generate a colorized git-style unified diff string.
*
* @param string $original
* @param string $fixed
* @return string
*/
public function generate(string $original, string $fixed): string
{
$diff = $this->differ->diff($original, $fixed);
$lines = explode("\n", $diff);
$colorized = [];
foreach ($lines as $line) {
if (str_starts_with($line, '+') && !str_starts_with($line, '+++')) {
$colorized[] = "<fg=green>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '-') && !str_starts_with($line, '---')) {
$colorized[] = "<fg=red>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '@@')) {
$colorized[] = "<fg=cyan>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
$colorized[] = "<fg=gray>" . htmlspecialchars($line) . "</>";
} else {
$colorized[] = htmlspecialchars($line);
}
}
return implode("\n", $colorized);
}
}
@@ -0,0 +1,95 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Metrics;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\Severity;
class AccessibilityScoreCalculator
{
/**
* Calculate accessibility score (0-100) and user impact summaries.
*
* @param Violation[] $violations
* @return array{score: int, visualImpact: int, motorImpact: int, cognitiveImpact: int}
*/
public static function calculate(array $violations): array
{
$score = 100;
$visualCount = 0;
$motorCount = 0;
$cognitiveCount = 0;
foreach ($violations as $v) {
// Deduct points based on severity
$deduction = 3; // Warning default
if ($v->severity === Severity::CRITICAL) {
$deduction = 15;
} elseif ($v->severity === Severity::ERROR) {
$deduction = 8;
}
$score -= $deduction;
// Map rule ID to disabled user groups impact
$id = $v->ruleId;
if (self::isVisual($id)) {
$visualCount++;
}
if (self::isMotor($id)) {
$motorCount++;
}
if (self::isCognitive($id)) {
$cognitiveCount++;
}
}
$score = max(0, $score);
// Normalize counts to percentage indicators of severity
$total = count($violations);
$visualImpact = $total > 0 ? (int)(($visualCount / $total) * 100) : 0;
$motorImpact = $total > 0 ? (int)(($motorCount / $total) * 100) : 0;
$cognitiveImpact = $total > 0 ? (int)(($cognitiveCount / $total) * 100) : 0;
return [
'score' => $score,
'visualImpact' => $visualImpact,
'motorImpact' => $motorImpact,
'cognitiveImpact' => $cognitiveImpact,
];
}
private static function isVisual(string $ruleId): bool
{
$visualKeywords = ['ALT', 'LABEL', 'TITLE', 'ARIA', 'LANG', 'CONTRAST', 'COLOR', 'TEXT', 'HEADING'];
foreach ($visualKeywords as $key) {
if (str_contains($ruleId, $key)) {
return true;
}
}
return false;
}
private static function isMotor(string $ruleId): bool
{
$motorKeywords = ['ACCESSKEY', 'FOCUS', 'TABINDEX', 'KEYBOARD', 'LINK_JAVASCRIPT', 'AUTOFOCUS', 'SCROLLABLE'];
foreach ($motorKeywords as $key) {
if (str_contains($ruleId, $key)) {
return true;
}
}
return false;
}
private static function isCognitive(string $ruleId): bool
{
$cognitiveKeywords = ['MARQUEE', 'BLINK', 'REFRESH', 'AUTOPLAY', 'AUTOCOMPLETE'];
foreach ($cognitiveKeywords as $key) {
if (str_contains($ruleId, $key)) {
return true;
}
}
return false;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace YakNet\AccessibilityConsole\Core\SelfHealing;
use YakNet\AccessibilityConsole\Core\Violation;
class LocalHealer
{
/**
* Attempt to deterministically fix simple violations locally.
*
* @param Violation $violation
* @return string|null
*/
public function heal(Violation $violation): ?string
{
$snippet = $violation->htmlSnippet;
$ruleId = $violation->ruleId;
switch ($ruleId) {
case 'WCAG_2_2_2_BLINK':
$fixed = str_replace(
['<blink>', '</blink>', '<BLINK>', '</BLINK>'],
['<span style="text-decoration: blink;">', '</span>', '<span style="text-decoration: blink;">', '</span>'],
$snippet
);
return "EXPLANATION: Banned <blink> tag replaced with a span and text-decoration style.\nFIX: " . $fixed;
case 'WCAG_2_4_3_AUTOFOCUS':
$fixed = preg_replace('/\s+autofocus(=["\'](.*?)["\'])?/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Autofocus attribute removed to prevent screen reader interruption on load.\nFIX: " . $fixed;
case 'WCAG_1_3_1_TABLE_SUMMARY':
$fixed = preg_replace('/\s+summary=["\'](.*?)["\']/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Deprecated table summary attribute removed. Use caption elements instead.\nFIX: " . $fixed;
case 'WCAG_2_1_1_SCROLLABLE_FOCUS':
if (!str_contains($snippet, 'tabindex')) {
$fixed = preg_replace('/(style=["\'](.*?)["\'])/i', '$1 tabindex="0"', $snippet) ?: $snippet;
return "EXPLANATION: Added tabindex=\"0\" to scrollable region to allow keyboard scrolling.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_3_1_PRESENTATION_TAGS':
if (preg_match('/<center>(.*?)<\/center>/is', $snippet, $matches)) {
$fixed = '<div style="text-align: center;">' . $matches[1] . '</div>';
return "EXPLANATION: Center element replaced with div and text-align CSS.\nFIX: " . $fixed;
}
if (preg_match('/<strike>(.*?)<\/strike>/is', $snippet, $matches)) {
$fixed = '<span style="text-decoration: line-through;">' . $matches[1] . '</span>';
return "EXPLANATION: Strike element replaced with span and line-through decoration CSS.\nFIX: " . $fixed;
}
break;
case 'WCAG_3_1_1_LANG':
if (preg_match('/<html(\s+[^>]*>|>)/i', $snippet)) {
$fixed = preg_replace('/<html/i', '<html lang="en"', $snippet) ?: $snippet;
return "EXPLANATION: Injected default lang=\"en\" to HTML element.\nFIX: " . $fixed;
}
break;
}
return null;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class BladeParser implements ParserInterface
{
public function parse(string $content): string
{
// Replace {!! ... !!} with static text
$content = preg_replace('/\{\!\!(.*?)\!\!\}/s', 'StaticRawBladeContent', $content) ?? $content;
// Replace {{ ... }} with static text
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticBladeContent', $content) ?? $content;
// Strip Blade directives like @if, @else, @endif, @foreach, @endforeach, etc.
$content = preg_replace('/@[a-zA-Z0-9_]+\s*(\(.*?\))?/i', '', $content) ?? $content;
return $content;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class HtmlParser implements ParserInterface
{
public function parse(string $content): string
{
return $content;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
interface ParserInterface
{
/**
* Parse template file contents and strip out dynamic content placeholders
* to return clean, standard HTML for scanning.
*
* @param string $content
* @return string
*/
public function parse(string $content): string;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class TwigParser implements ParserInterface
{
public function parse(string $content): string
{
// Strip Twig comments {# ... #}
$content = preg_replace('/\{\#.*?\#\}/s', '', $content) ?? $content;
// Replace Twig output {{ ... }} with static text
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticTwigOutput', $content) ?? $content;
// Strip Twig statements {% ... %}
$content = preg_replace('/\{\%.*?\%\}/s', '', $content) ?? $content;
return $content;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagCriterion
{
public function __construct(
public string $id,
public string $title,
public string $level,
public string $url,
public string $description,
public string $badExample,
public string $goodExample
) {}
/**
* @return array{id: string, title: string, level: string, url: string, description: string, badExample: string, goodExample: string}
*/
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'level' => $this->level,
'url' => $this->url,
'description' => $this->description,
'badExample' => $this->badExample,
'goodExample' => $this->goodExample,
];
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagDatabase
{
/** @var array<string, WcagCriterion> */
private static array $criteria = [];
public static function load(): void
{
if (!empty(self::$criteria)) {
return;
}
self::add(new WcagCriterion(
id: 'WCAG_2_2_2_BLINK',
title: '2.2.2 Pause, Stop, Hide',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html',
description: 'The <blink> tag causes flashing text, presenting significant cognitive and visual accessibility barriers.',
badExample: '<blink>Blinking Text</blink>',
goodExample: '<span class="attention-style">Styled Text</span>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_DUPLICATE_H1',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'Pages should contain exactly one h1 element to present a clear hierarchy and entry point for screen readers.',
badExample: '<h1>Title</h1><h1>Another Title</h1>',
goodExample: '<h1>Title</h1><h2>Subtitle</h2>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_5_AUTOCOMPLETE',
title: '1.3.5 Identify Input Purpose',
level: 'AA',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html',
description: 'Inputs collecting user personal info must have autocomplete attributes to assist users with cognitive impairments.',
badExample: '<input type="email" name="email">',
goodExample: '<input type="email" name="email" autocomplete="email">'
));
self::add(new WcagCriterion(
id: 'WCAG_2_1_1_SCROLLABLE_FOCUS',
title: '2.1.1 Keyboard',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html',
description: 'Scrollable regions must be focusable using keyboard navigation (tabindex="0") so keyboard-only users can scroll them.',
badExample: '<div style="overflow: scroll; height: 100px;">Scrollable content</div>',
goodExample: '<div style="overflow: scroll; height: 100px;" tabindex="0">Scrollable content</div>'
));
self::add(new WcagCriterion(
id: 'WCAG_4_1_2_ARIA_LABELLEDBY',
title: '4.1.2 Name, Role, Value',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
description: 'aria-labelledby and aria-describedby attributes must refer to existing element IDs to prevent screen readers from reading empty contexts.',
badExample: '<button aria-labelledby="missing_id">Submit</button>',
goodExample: '<div id="btn_lbl">Submit Form</div><button aria-labelledby="btn_lbl">Submit</button>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_1_1_ALT_PLACEHOLDER',
title: '1.1.1 Non-text Content',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
description: 'Alt attributes must not contain placeholder words or file names. They should provide a meaningful description of the image content.',
badExample: '<img src="logo.png" alt="logo">',
goodExample: '<img src="logo.png" alt="YakNet Company Logo">'
));
self::add(new WcagCriterion(
id: 'WCAG_1_1_1_SVG',
title: '1.1.1 Non-text Content',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
description: 'Inline SVGs must either contain alternative descriptions or be hidden from assistive technologies using aria-hidden="true".',
badExample: '<svg><circle cx="5" cy="5" r="5"></svg>',
goodExample: '<svg aria-hidden="true"><circle cx="5" cy="5" r="5"></svg>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_TABLE_SUMMARY',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'The summary attribute on <table> is deprecated in HTML5. Use <caption> elements for tables description instead.',
badExample: '<table summary="Table Summary">',
goodExample: '<table><caption>Table Description</caption>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_PRESENTATION_TAGS',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'Obsolete presentation tags like <center> and <strike> are banned. Use standard CSS classes or styling.',
badExample: '<center>Centered Text</center>',
goodExample: '<span style="text-align: center; display: block;">Centered Text</span>'
));
self::add(new WcagCriterion(
id: 'WCAG_2_4_3_AUTOFOCUS',
title: '2.4.3 Focus Order',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html',
description: 'autofocus attribute can cause keyboard traps and disorient blind/low-vision users on page load. Focus should be managed contextually.',
badExample: '<input name="search" autofocus>',
goodExample: '<input name="search">'
));
}
public static function add(WcagCriterion $criterion): void
{
self::$criteria[$criterion->id] = $criterion;
}
public static function get(string $ruleId): ?WcagCriterion
{
self::load();
return self::$criteria[$ruleId] ?? null;
}
/**
* @return array<string, WcagCriterion>
*/
public static function all(): array
{
self::load();
return self::$criteria;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagReference
{
/**
* Look up the WCAG Success Criterion for a given rule ID.
*
* @param string $ruleId
* @return WcagCriterion|null
*/
public static function lookup(string $ruleId): ?WcagCriterion
{
return WcagDatabase::get($ruleId);
}
/**
* Get a list of all WCAG criteria mapped in the console.
*
* @return array<string, WcagCriterion>
*/
public static function getCatalog(): array
{
return WcagDatabase::all();
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class CsvFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$out = fopen('php://temp', 'w+');
if ($out === false) {
return Command::FAILURE;
}
// Write header
fputcsv($out, ['Rule ID', 'Severity', 'Standard', 'File', 'Line', 'Message', 'HTML Snippet', 'Fix Suggestion'], ',', '"', '\\');
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
fputcsv($out, [
$v->ruleId,
$v->severity->value,
$v->standard->value,
$file,
$line,
$v->message,
$v->htmlSnippet,
$v->fixSuggestion
], ',', '"', '\\');
}
rewind($out);
$csv = stream_get_contents($out);
fclose($out);
$io->write($csv ?: '');
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\Severity;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class GitLabCodeQualityFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$quality = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 1;
// Map our severity to GitLab code quality severities:
// info, minor, major, critical, blocker
$gitLabSeverity = 'major';
if ($v->severity === Severity::CRITICAL) {
$gitLabSeverity = 'critical';
} elseif ($v->severity === Severity::WARNING) {
$gitLabSeverity = 'minor';
}
$quality[] = [
'description' => sprintf('[%s] %s', $v->ruleId, $v->message),
'fingerprint' => md5($v->ruleId . ':' . $file . ':' . $line . ':' . $v->htmlSnippet),
'severity' => $gitLabSeverity,
'location' => [
'path' => $file,
'lines' => [
'begin' => $line
]
]
];
}
$io->write((string)json_encode($quality, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class MarkdownFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$md = [];
$md[] = "# Accessibility Scan Report";
$md[] = "";
$md[] = sprintf("- **Total Violations:** %d", count($violations));
$md[] = sprintf("- **Baselined Violations:** %d", $baselinedCount);
$md[] = "";
$md[] = "## Detailed Violations";
$md[] = "";
if (empty($violations)) {
$md[] = "*No violations found!* 🎉";
} else {
foreach ($violations as $index => $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
$md[] = sprintf("### %d. %s [%s - %s]", $index + 1, $v->ruleId, strtoupper($v->severity->value), $v->standard->value);
$md[] = sprintf("- **File:** `%s` on line %d", $file, $line);
$md[] = sprintf("- **Message:** %s", $v->message);
if ($v->fixSuggestion) {
$md[] = sprintf("- **Fix Suggestion:** %s", $v->fixSuggestion);
}
$md[] = "- **Snippet:**";
$md[] = " ```html";
$md[] = " " . trim($v->htmlSnippet);
$md[] = " ```";
$md[] = "";
}
}
$io->write(implode("\n", $md));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class XmlFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
$root = $dom->createElement('report');
$root->setAttribute('totalViolations', (string)count($violations));
$root->setAttribute('baselinedViolations', (string)$baselinedCount);
$dom->appendChild($root);
$violationsNode = $dom->createElement('violations');
$root->appendChild($violationsNode);
foreach ($violations as $v) {
$node = $dom->createElement('violation');
$node->setAttribute('ruleId', $v->ruleId);
$node->setAttribute('severity', $v->severity->value);
$node->setAttribute('standard', $v->standard->value);
$msg = $dom->createElement('message', htmlspecialchars($v->message));
$node->appendChild($msg);
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
$loc = $dom->createElement('location');
$loc->setAttribute('file', $file);
$loc->setAttribute('line', (string)$line);
$node->appendChild($loc);
$snippet = $dom->createElement('htmlSnippet');
$snippet->appendChild($dom->createCDATASection($v->htmlSnippet));
$node->appendChild($snippet);
if ($v->fixSuggestion) {
$sug = $dom->createElement('suggestion', htmlspecialchars($v->fixSuggestion));
$node->appendChild($sug);
}
$violationsNode->appendChild($node);
}
$io->write($dom->saveXML() ?: '');
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AnchorMissingHref extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ANCHOR_HREF'; }
public function getDescription(): string { return 'Anchor elements (<a>) acting as links must have a valid href attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
if ($element->hasAttribute('name') || $element->hasAttribute('role')) {
return null;
}
if (!$element->hasAttribute('href') || trim($element->getAttribute('href')) === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a valid href attribute to the <a> element, or use a <button> element if it triggers an action.'
);
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AppletBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_APPLET_BANNED'; }
public function getDescription(): string { return 'The <applet> element is obsolete and non-accessible. Use modern HTML5 elements instead.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) === 'applet') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <applet> element and replace it with modern HTML5 or accessible media embeddings.'
);
}
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AreaAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_AreaAlt'; }
public function getDescription(): string { return 'Checks that <area> elements in image maps have non-empty alt attributes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'area') {
return null;
}
$alt = $element->getAttribute('alt');
$ariaLabel = $element->getAttribute('aria-label');
if (empty(trim($alt)) && empty(trim($ariaLabel))) {
return $this->createViolation(
$element,
'<area> öğesinde alt veya aria-label eksik.',
'Provide a non-empty alt or aria-label attribute for the <area> element.'
);
}
return null;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaExpandedState extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_EXPANDED'; }
public function getDescription(): string { return 'Collapsible toggle buttons (accordion, dropdown, collapse) must have an aria-expanded attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $tagName !== 'a' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$dataBsToggle = strtolower($element->getAttribute('data-bs-toggle'));
$dataToggle = strtolower($element->getAttribute('data-toggle'));
$isCollapsible = str_contains($class, 'dropdown-toggle') ||
str_contains($class, 'accordion-button') ||
str_contains($class, 'collapse-toggle') ||
in_array($dataBsToggle, ['collapse', 'dropdown', 'offcanvas'], true) ||
in_array($dataToggle, ['collapse', 'dropdown'], true);
if (!$isCollapsible) {
return null;
}
if ($element->hasAttribute('aria-expanded')) {
$val = strtolower($element->getAttribute('aria-expanded'));
if (in_array($val, ['true', 'false'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-expanded="false" (or "true") to the toggle element so screen readers announce expanded/collapsed status.'
);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaHiddenBody extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_BODY'; }
public function getDescription(): string { return 'The <body> or <html> element must not have aria-hidden="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation
{
$tagName = strtolower($element->tagName);
if ($tagName !== 'body' && $tagName !== 'html') {
return null;
}
if (strtolower($element->getAttribute('aria-hidden')) === 'true') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove aria-hidden="true" from the <body>/<html> tag to prevent hiding the entire document from screen readers.'
);
}
return null;
}
}
+12
View File
@@ -57,6 +57,18 @@ class AriaHiddenFocusable extends AbstractRule
return $type !== 'hidden' && !$element->hasAttribute('disabled');
}
if ($element->hasAttribute('contenteditable') && strtolower($element->getAttribute('contenteditable')) === 'true') {
return true;
}
if (in_array($tagName, ['details', 'summary'], true)) {
return true;
}
if (in_array($tagName, ['audio', 'video'], true) && $element->hasAttribute('controls')) {
return true;
}
if ($tagName === 'iframe') {
return true;
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaLabelledbyExists extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_LABELLEDBY'; }
public function getDescription(): string { return 'aria-labelledby and aria-describedby attributes must refer to existing element IDs.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$attrs = ['aria-labelledby', 'aria-describedby'];
foreach ($attrs as $attr) {
if ($element->hasAttribute($attr)) {
$ids = preg_split('/\s+/', trim($element->getAttribute($attr)));
if ($ids === false) {
continue;
}
$doc = $element->ownerDocument;
if (!$doc) {
continue;
}
foreach ($ids as $id) {
if ($id === '') {
continue;
}
$target = $doc->getElementById($id);
if (!$target) {
// XPath fallback in case DOMDocument getElementById fails (which is common without DTD/XML schemas)
$xpath = new \DOMXPath($doc);
$query = $xpath->query("//*[@id='" . addslashes($id) . "']");
if ($query === false || $query->length === 0) {
return $this->createViolation(
$element,
"The attribute $attr refers to a non-existent ID: \"$id\".",
"Ensure the element referenced by ID \"$id\" exists in the document, or correct the ID spelling."
);
}
}
}
}
}
return null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaLiveStatus extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_3_STATUS_MESSAGES'; }
public function getDescription(): string { return 'Dynamic status containers and toast messages must use aria-live or implicit live roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
$class = strtolower($element->getAttribute('class'));
$role = strtolower($element->getAttribute('role'));
$statusClasses = ['toast', 'status-message', 'loading-message', 'alert-message', 'live-region'];
$isStatusContainer = false;
foreach ($statusClasses as $statusClass) {
if (str_contains($class, $statusClass)) {
$isStatusContainer = true;
break;
}
}
if (!$isStatusContainer) {
return null;
}
// Implicit live roles in ARIA include status, alert, log
if (in_array($role, ['status', 'alert', 'log'], true)) {
return null;
}
if ($element->hasAttribute('aria-live')) {
$liveVal = strtolower($element->getAttribute('aria-live'));
if (in_array($liveVal, ['polite', 'assertive'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-live="polite" (or role="status"/role="alert") so screen readers dynamically announce updates to users.'
);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaPressedToggle extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_TOGGLE_BUTTON_STATE'; }
public function getDescription(): string { return 'Toggle buttons must use aria-pressed="true" or "false" to communicate state changes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$dataToggle = strtolower($element->getAttribute('data-toggle'));
$isToggleButton = str_contains($class, 'btn-toggle') || str_contains($class, 'toggle-btn') || $dataToggle === 'button';
if (!$isToggleButton) {
return null;
}
if ($element->hasAttribute('aria-pressed')) {
$val = strtolower($element->getAttribute('aria-pressed'));
if (in_array($val, ['true', 'false', 'mixed'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-pressed="true" or aria-pressed="false" to indicate the toggle state to assistive technology.'
);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_REQUIRED_ATTR'; }
public function getDescription(): string { return 'Elements with ARIA roles must have all required ARIA attributes for that role.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredMap = [
'slider' => ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'],
'checkbox' => ['aria-checked'],
'combobox' => ['aria-expanded'],
'scrollbar' => ['aria-controls', 'aria-valuenow', 'aria-valuemax', 'aria-valuemin', 'aria-orientation'],
'spinbutton' => ['aria-valuenow', 'aria-valuemax', 'aria-valuemin'],
'switch' => ['aria-checked'],
'meter' => ['aria-valuenow'],
'separator' => ['aria-valuenow'] // strictly when focusable, but we check if the role is present
];
foreach ($roles as $role) {
if (isset($requiredMap[$role])) {
$missingAttrs = [];
foreach ($requiredMap[$role] as $reqAttr) {
if (!$element->hasAttribute($reqAttr)) {
$missingAttrs[] = $reqAttr;
}
}
if (!empty($missingAttrs)) {
// Specific case: separator without tabindex is not focusable and doesn't require aria-valuenow in some contexts
if ($role === 'separator' && !$element->hasAttribute('tabindex')) {
continue;
}
$missingStr = implode(', ', $missingAttrs);
return $this->createViolation(
$element,
"ARIA role '{$role}' eksik zorunlu niteliklere sahip: {$missingStr}.",
"Belirtilen ARIA rolü için zorunlu olan {$missingStr} niteliklerini ekleyin."
);
}
}
}
return null;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredChildren extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_CHILDREN'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must contain children with specific roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredChildrenMap = [
'list' => ['listitem'],
'menu' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'menubar' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'tablist' => ['tab'],
'tree' => ['treeitem'],
'grid' => ['row', 'rowgroup'],
'table' => ['row', 'rowgroup'],
'radiogroup' => ['radio'],
'listbox' => ['option'],
'rowgroup' => ['row'],
'row' => ['cell', 'gridcell', 'columnheader', 'rowheader']
];
foreach ($roles as $role) {
if (isset($requiredChildrenMap[$role])) {
$hasRequiredChild = $this->hasChildWithRole($element, $requiredChildrenMap[$role]);
if (!$hasRequiredChild) {
// Check if children are dynamically generated with templates
$doc = $element->ownerDocument;
if ($doc !== null) {
$html = $doc->saveHTML($element);
if ($html !== false && preg_match('/(\{\{|\{%|<\?)/', $html)) {
continue;
}
}
$expectedStr = implode(' veya ', $requiredChildrenMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli alt rollere sahip değil.",
"Bu elemanın içine rolü {$expectedStr} olan çocuk elemanlar ekleyin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasChildWithRole(DOMElement $element, array $requiredRoles): bool
{
$doc = $element->ownerDocument;
if ($doc === null) {
return false;
}
$xpath = new \DOMXPath($doc);
foreach ($requiredRoles as $reqRole) {
$query = ".//*[@role='{$reqRole}' or contains(@role, ' {$reqRole}') or contains(@role, '{$reqRole} ')]";
$nodes = $xpath->query($query, $element);
if ($nodes !== false && $nodes->length > 0) {
return true;
}
}
return false;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_PARENT'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must be contained by parents with specific roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredParentMap = [
'listitem' => ['list', 'group'],
'menuitem' => ['menu', 'menubar', 'group'],
'menuitemcheckbox' => ['menu', 'menubar'],
'menuitemradio' => ['menu', 'menubar'],
'tab' => ['tablist'],
'treeitem' => ['tree', 'group'],
'cell' => ['row'],
'gridcell' => ['row'],
'columnheader' => ['row'],
'rowheader' => ['row'],
'option' => ['listbox', 'group'],
'row' => ['grid', 'table', 'treegrid', 'rowgroup']
];
foreach ($roles as $role) {
if (isset($requiredParentMap[$role])) {
$hasRequiredParent = $this->hasParentWithRole($element, $requiredParentMap[$role]);
if (!$hasRequiredParent) {
$expectedStr = implode(' veya ', $requiredParentMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli üst eleman rollerinden hiçbirine sahip değil.",
"Bu elemanı rolü {$expectedStr} olan bir üst elemanın içine yerleştirin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasParentWithRole(DOMElement $element, array $requiredRoles): bool
{
$parent = $element->parentNode;
while ($parent !== null && $parent instanceof DOMElement) {
if ($parent->hasAttribute('role')) {
$parentRoles = explode(' ', strtolower(trim($parent->getAttribute('role'))));
foreach ($parentRoles as $pRole) {
if (in_array($pRole, $requiredRoles)) {
return true;
}
}
}
// For native HTML semantics fallback (e.g. <ul> is a list)
$tagName = strtolower($parent->tagName);
$nativeRole = $this->getNativeRole($tagName);
if ($nativeRole && in_array($nativeRole, $requiredRoles)) {
return true;
}
$parent = $parent->parentNode;
}
return false;
}
private function getNativeRole(string $tagName): ?string
{
$map = [
'ul' => 'list',
'ol' => 'list',
'dl' => 'list',
'table' => 'table',
'tr' => 'row',
'tbody' => 'rowgroup',
'thead' => 'rowgroup',
'tfoot' => 'rowgroup',
'select' => 'listbox',
'optgroup' => 'group'
];
return $map[$tagName] ?? null;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR'; }
public function getDescription(): string { return 'ARIA attributes must be valid names.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$validAttributes = [
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls',
'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded',
'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation',
'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly',
'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount',
'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected',
'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow',
'aria-valuetext'
];
$invalidAttrs = [];
foreach ($element->attributes as $attr) {
/** @var \DOMAttr $attr */
$name = strtolower($attr->nodeName);
if (strpos($name, 'aria-') === 0) {
if (!in_array($name, $validAttributes)) {
$invalidAttrs[] = $name;
}
}
}
if (!empty($invalidAttrs)) {
$invalidStr = implode(', ', $invalidAttrs);
return $this->createViolation(
$element,
"Geçersiz ARIA nitelikleri bulundu: {$invalidStr}.",
"Sadece geçerli ARIA niteliklerini kullanın. Geçersiz olanları kaldırın veya düzeltin."
);
}
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttrValue extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR_VALUE'; }
public function getDescription(): string { return 'ARIA attributes must contain valid values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$validationMap = [
'aria-hidden' => ['true', 'false'],
'aria-live' => ['off', 'polite', 'assertive'],
'aria-expanded' => ['true', 'false', 'undefined'],
'aria-pressed' => ['true', 'false', 'mixed', 'undefined'],
'aria-checked' => ['true', 'false', 'mixed', 'undefined'],
'aria-current' => ['page', 'step', 'location', 'date', 'time', 'true', 'false'],
'aria-sort' => ['ascending', 'descending', 'none', 'other'],
'aria-orientation' => ['horizontal', 'vertical', 'undefined'],
'aria-haspopup' => ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
'aria-invalid' => ['grammar', 'false', 'spelling', 'true'],
'aria-autocomplete' => ['inline', 'list', 'both', 'none'],
'aria-dropeffect' => ['copy', 'execute', 'link', 'move', 'none', 'popup']
];
for ($i = 0; $i < $element->attributes->length; $i++) {
$attr = $element->attributes->item($i);
if (!$attr instanceof \DOMAttr) {
continue;
}
$name = strtolower($attr->nodeName);
if (isset($validationMap[$name])) {
$value = strtolower(trim($attr->nodeValue ?? ''));
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $value) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $value)) {
continue;
}
if (!in_array($value, $validationMap[$name])) {
$validStr = implode(', ', $validationMap[$name]);
return $this->createViolation(
$element,
"{$name} niteliği geçersiz bir değere sahip: '{$value}'.",
"{$name} niteliğinin değerini geçerli olanlardan biriyle değiştirin: {$validStr}."
);
}
} elseif ($name === 'aria-relevant') {
$values = explode(' ', strtolower(trim($attr->nodeValue ?? '')));
$validRelevant = ['additions', 'removals', 'text', 'all'];
$rawValue = $attr->nodeValue ?? '';
$isTemplate = preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', trim($rawValue)) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', trim($rawValue));
if (!$isTemplate) {
foreach ($values as $val) {
if (!empty($val) && !in_array($val, $validRelevant)) {
return $this->createViolation(
$element,
"aria-relevant niteliği geçersiz bir değer içeriyor: '{$val}'.",
"aria-relevant değerlerini sadece additions, removals, text, all kelimelerinden oluşacak şekilde ayarlayın."
);
}
}
}
}
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AudioTrack extends AbstractRule
{
public function getId(): string { return 'WCAG_1_2_1_AudioTrack'; }
public function getDescription(): string { return 'Checks that <audio> elements have a <track> child element for captions/descriptions.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'audio') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$tracks = $xpath->query('./track', $element);
if ($tracks === false || $tracks->length === 0) {
return $this->createViolation(
$element,
'<audio> öğesi <track> alt öğesi içermiyor.',
'Add a <track> element for captions or provide an accessible transcript.'
);
}
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutocompleteValid extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE'; }
public function getDescription(): string { return 'Input fields that collect common user information must have a valid autocomplete attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
$type = strtolower($element->getAttribute('type') ?: 'text');
if (in_array($type, ['hidden', 'submit', 'button', 'reset', 'file', 'checkbox', 'radio', 'image'], true)) {
return null;
}
$name = strtolower($element->getAttribute('name'));
$id = strtolower($element->getAttribute('id'));
$personalFields = ['email', 'tel', 'phone', 'zip', 'postal', 'address', 'username', 'password', 'name', 'fname', 'lname', 'search'];
$isPersonal = false;
if ($type === 'email' || $type === 'tel' || $type === 'password') {
$isPersonal = true;
} else {
foreach ($personalFields as $field) {
if (str_contains($name, $field) || str_contains($id, $field)) {
$isPersonal = true;
break;
}
}
}
if ($isPersonal && !$element->hasAttribute('autocomplete')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a valid autocomplete attribute (e.g., autocomplete="email" or autocomplete="current-password").'
);
}
return null;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutocompleteValidTokens extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE_VALID_TOKENS'; }
public function getDescription(): string { return 'Autocomplete attribute must use valid token values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if (!$element->hasAttribute('autocomplete')) {
return null;
}
$autocompleteValue = trim($element->getAttribute('autocomplete'));
if ($autocompleteValue === '') {
return null;
}
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $autocompleteValue) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $autocompleteValue)) {
return null;
}
$tokens = explode(' ', strtolower($autocompleteValue));
$validBaseTokens = [
'off', 'on', 'name', 'honorific-prefix', 'given-name', 'additional-name',
'family-name', 'honorific-suffix', 'nickname', 'email', 'username',
'new-password', 'current-password', 'one-time-code', 'organization-title',
'organization', 'street-address', 'address-line1', 'address-line2',
'address-line3', 'address-level4', 'address-level3', 'address-level2',
'address-level1', 'country', 'country-name', 'postal-code', 'cc-name',
'cc-given-name', 'cc-additional-name', 'cc-family-name', 'cc-number',
'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type',
'transaction-currency', 'transaction-amount', 'language', 'bday',
'bday-day', 'bday-month', 'bday-year', 'sex', 'tel', 'tel-country-code',
'tel-national', 'tel-area-code', 'tel-local', 'tel-extension', 'impp',
'url', 'photo'
];
foreach ($tokens as $token) {
if (empty($token)) continue;
// Allow section-* prefixes
if (strpos($token, 'section-') === 0) {
continue;
}
// Allow shipping/billing prefixes
if ($token === 'shipping' || $token === 'billing') {
continue;
}
// Check for valid base tokens
// Additional check: some tokens like 'tel' can be prefixed, we handle it simply by checking if it's in the list
$isValid = false;
foreach ($validBaseTokens as $baseToken) {
if ($token === $baseToken || str_ends_with($token, "-{$baseToken}")) {
$isValid = true;
break;
}
}
if (!$isValid) {
return $this->createViolation(
$element,
"Geçersiz autocomplete değeri veya belirteci: '{$token}'.",
"Geçerli bir HTML5 autocomplete belirteci kullanın (örneğin: name, email, tel)."
);
}
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutofocusBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_3_AUTOFOCUS'; }
public function getDescription(): string { return 'Form controls should not use the autofocus attribute as it disorients screen reader and keyboard users.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('autofocus')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the autofocus attribute and let the user navigate to the form field naturally, or use script-based focus with care.'
);
}
return null;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use Symfony\Component\DomCrawler\Crawler;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class BannedSelectors extends AbstractRule
{
/** @var array<string, string> */
private array $bannedSelectors = [
'center' => 'Obsolete <center> tag. Use CSS text-align instead.',
'blink' => 'Obsolete <blink> tag. Avoid blinking/flashing content.',
'marquee' => 'Obsolete <marquee> tag. Avoid moving content.',
'font' => 'Obsolete <font> tag. Use CSS font styling instead.',
'*[align]' => 'Deprecated align attribute. Use CSS text-align or float instead.',
'*[bgcolor]' => 'Deprecated bgcolor attribute. Use CSS background-color instead.',
];
public function check(\DOMDocument $doc): array
{
$violations = [];
$crawler = new Crawler($doc);
foreach ($this->bannedSelectors as $selector => $message) {
try {
$nodes = $crawler->filter($selector);
foreach ($nodes as $node) {
if ($node instanceof \DOMElement) {
$violations[] = $this->createViolation(
sprintf('[Banned Selector: "%s"] %s', $selector, $message),
$node
);
}
}
} catch (\Throwable $e) {
// Skip invalid selector configurations
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class BlinkTagBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_2_2_BLINK'; }
public function getDescription(): string { return 'The <blink> tag is obsolete and should not be used as it causes text to flash/blink.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) === 'blink') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <blink> tag and use CSS styling/animations if needed, or preferably avoid blinking content.'
);
}
return null;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ButtonLoadingState extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_BUTTON_LOADING_STATE'; }
public function getDescription(): string { return 'Buttons displaying loading spinners must inform screen readers via aria-busy or aria-label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$hasLoadingClass = str_contains($class, 'loading') || str_contains($class, 'spinner') || str_contains($class, 'is-busy');
$hasSpinnerChild = false;
foreach ($element->getElementsByTagName('*') as $child) {
$childClass = strtolower($child->getAttribute('class'));
if (str_contains($childClass, 'spinner') || str_contains($childClass, 'loading') || str_contains($childClass, 'loader')) {
$hasSpinnerChild = true;
break;
}
}
if (!$hasLoadingClass && !$hasSpinnerChild && strtolower($element->getAttribute('aria-busy')) !== 'true') {
return null;
}
// Must have aria-busy="true" or an explicit aria-label/aria-live describing the loading process
$hasAriaBusy = strtolower($element->getAttribute('aria-busy')) === 'true';
$hasAriaLive = $element->hasAttribute('aria-live');
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
if ($hasAriaBusy || ($hasAriaLabel && $hasAriaLive)) {
return null;
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-busy="true" and an aria-label (e.g. aria-label="Loading...") to the button when an async action is in progress.'
);
}
}
+56 -3
View File
@@ -27,10 +27,63 @@ class ButtonName extends AbstractRule
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
if ($text !== '') {
return null;
}
return null;
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
if ($element->hasAttribute('aria-labelledby')) {
$id = $element->getAttribute('aria-labelledby');
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//*[@id='$id']");
if ($labels !== false && $labels->length > 0) {
$labelNode = $labels->item(0);
if ($labelNode instanceof \DOMNode && trim($labelNode->textContent) !== '') {
return null;
}
}
}
}
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
}
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class CanvasAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_CanvasAlt'; }
public function getDescription(): string { return 'Checks that <canvas> elements have fallback content or aria labels.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'canvas') {
return null;
}
$hasAriaLabel = !empty($element->getAttribute('aria-label'));
$hasAriaLabelledby = !empty($element->getAttribute('aria-labelledby'));
$hasRoleImgLabel = ($element->getAttribute('role') === 'img') && $hasAriaLabel;
if ($hasAriaLabel || $hasAriaLabelledby || $hasRoleImgLabel) {
return null;
}
$content = trim($element->textContent);
$hasChildElements = false;
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$hasChildElements = true;
break;
}
}
if ($content === '' && !$hasChildElements) {
return $this->createViolation(
$element,
'<canvas> öğesi alternatif içerik veya etiket içermiyor.',
'Provide fallback content inside the <canvas> tag or use aria-label/aria-labelledby.'
);
}
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ClickHandlerKeyboard extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_CLICK_KEYBOARD'; }
public function getDescription(): string { return 'Non-interactive elements with click handlers should also have keyboard handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('onclick')) {
return null;
}
$tagName = strtolower($element->tagName);
$interactiveElements = ['a', 'button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $interactiveElements)) {
return null;
}
$role = strtolower($element->getAttribute('role'));
if (($role === 'button' || $role === 'link') && $element->hasAttribute('tabindex')) {
return null;
}
if ($element->hasAttribute('onkeydown') || $element->hasAttribute('onkeyup') || $element->hasAttribute('onkeypress')) {
return null;
}
return $this->createViolation(
$element,
'onclick olay işleyicisine sahip etkileşimli olmayan öğenin klavye işleyicisi (onkeydown vb.) eksik. / Non-interactive element with onclick is missing a keyboard handler.',
'Klavye desteği için onkeydown/onkeyup ekleyin ve tabindex="0" tanımlayın. / Add onkeydown/onkeyup handlers and tabindex="0" for keyboard support.'
);
}
}
+50 -3
View File
@@ -249,9 +249,56 @@ class ColorContrast extends AbstractRule
}
$colors = [
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff',
'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc',
'bisque' => '#ffe4c4', 'black' => '#000000', 'blanchedalmond' => '#ffebcd',
'blue' => '#0000ff', 'blueviolet' => '#8a2be2', 'brown' => '#a52a2a',
'burlywood' => '#deb887', 'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00',
'chocolate' => '#d2691e', 'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed',
'cornsilk' => '#fff8dc', 'crimson' => '#dc143c', 'cyan' => '#00ffff',
'darkblue' => '#00008b', 'darkcyan' => '#008b8b', 'darkgoldenrod' => '#b8860b',
'darkgray' => '#a9a9a9', 'darkgreen' => '#006400', 'darkgrey' => '#a9a9a9',
'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b', 'darkolivegreen' => '#556b2f',
'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc', 'darkred' => '#8b0000',
'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f', 'darkslateblue' => '#483d8b',
'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f', 'darkturquoise' => '#00ced1',
'darkviolet' => '#9400d3', 'deeppink' => '#ff1493', 'deepskyblue' => '#00bfff',
'dimgray' => '#696969', 'dimgrey' => '#696969', 'dodgerblue' => '#1e90ff',
'firebrick' => '#b22222', 'floralwhite' => '#fffaf0', 'forestgreen' => '#228b22',
'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc', 'ghostwhite' => '#f8f8ff',
'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080',
'green' => '#008000', 'greenyellow' => '#adff2f', 'grey' => '#808080',
'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred' => '#cd5c5c',
'indigo' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c',
'lavender' => '#e6e6fa', 'lavenderblush' => '#fff0f5', 'lawngreen' => '#7cfc00',
'lemonchiffon' => '#fffacd', 'lightblue' => '#add8e6', 'lightcoral' => '#f08080',
'lightcyan' => '#e0ffff', 'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3',
'lightgreen' => '#90ee90', 'lightgrey' => '#d3d3d3', 'lightpink' => '#ffb6c1',
'lightsalmon' => '#ffa07a', 'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa',
'lightslategray' => '#778899', 'lightslategrey' => '#778899', 'lightsteelblue' => '#b0c4de',
'lightyellow' => '#ffffe0', 'lime' => '#00ff00', 'limegreen' => '#32cd32',
'linen' => '#faf0e6', 'magenta' => '#ff00ff', 'maroon' => '#800000',
'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd', 'mediumorchid' => '#ba55d3',
'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371', 'mediumslateblue' => '#7b68ee',
'mediumspringgreen' => '#00fa9a', 'mediumturquoise' => '#48d1cc', 'mediumvioletred' => '#c71585',
'midnightblue' => '#191970', 'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1',
'moccasin' => '#ffe4b5', 'navajowhite' => '#ffdead', 'navy' => '#000080',
'oldlace' => '#fdf5e6', 'olive' => '#808000', 'olivedrab' => '#6b8e23',
'orange' => '#ffa500', 'orangered' => '#ff4500', 'orchid' => '#da70d6',
'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98', 'paleturquoise' => '#afeeee',
'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5', 'peachpuff' => '#ffdab9',
'peru' => '#cd853f', 'pink' => '#ffc0cb', 'plum' => '#dda0dd',
'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399',
'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1',
'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', 'sandybrown' => '#f4a460',
'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d',
'silver' => '#c0c0c0', 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd',
'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa',
'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c',
'teal' => '#008080', 'thistle' => '#d8bfd8', 'tomato' => '#ff6347',
'turquoise' => '#40e0d0', 'violet' => '#ee82ee', 'wheat' => '#f5deb3',
'white' => '#ffffff', 'whitesmoke' => '#f5f5f5', 'yellow' => '#ffff00',
'yellowgreen' => '#9acd32',
];
if (isset($colors[strtolower($val)])) {
return $colors[strtolower($val)];
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DefinitionListStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DefinitionListStructure'; }
public function getDescription(): string { return 'Checks that <dl> elements only contain <dt>, <dd>, and <div> as direct children.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'dl') {
return null;
}
$invalidChildren = [];
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, ['dt', 'dd', 'div'])) {
$invalidChildren[] = $childTag;
}
}
}
if (count($invalidChildren) > 0) {
return $this->createViolation(
$element,
'<dl> öğesi doğrudan alt öğe olarak sadece <dt>, <dd> veya <div> içerebilir.',
'Remove or wrap invalid children (' . implode(', ', array_unique($invalidChildren)) . ') inside <dd> or <dt>.'
);
}
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DialogAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_DIALOG_ACCESSIBILITY'; }
public function getDescription(): string { return 'Dialog elements must have an accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$tagName = strtolower($element->tagName);
if ($role !== 'dialog' && $role !== 'alertdialog' && $tagName !== 'dialog') {
return null;
}
$ariaLabel = trim($element->getAttribute('aria-label'));
$ariaLabelledby = trim($element->getAttribute('aria-labelledby'));
$title = trim($element->getAttribute('title'));
if (!empty($ariaLabel) || !empty($ariaLabelledby) || !empty($title)) {
return null;
}
return $this->createViolation(
$element,
'Dialog bileşeninin erişilebilir bir adı (accessible name) eksik. / Dialog component is missing an accessible name.',
'Dialog elementine aria-label veya aria-labelledby niteliği ekleyin. / Add aria-label or aria-labelledby to the dialog element.'
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateH1 extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DUPLICATE_H1'; }
public function getDescription(): string { return 'Pages should not contain more than one h1 element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'h1') {
return null;
}
$doc = $element->ownerDocument;
if ($doc) {
$h1s = $doc->getElementsByTagName('h1');
if ($h1s->length > 1 && $h1s->item(0) !== $element) {
return $this->createViolation(
$element,
$this->getDescription(),
'Change this heading to an h2 or lower level heading, keeping only one h1 per page.'
);
}
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmbedAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_EmbedAccessibility'; }
public function getDescription(): string { return 'Checks that <embed> elements have aria-label, aria-labelledby, or title attribute for accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'embed') {
return null;
}
$ariaLabel = $element->getAttribute('aria-label');
$ariaLabelledby = $element->getAttribute('aria-labelledby');
$title = $element->getAttribute('title');
if (empty(trim($ariaLabel)) && empty(trim($ariaLabelledby)) && empty(trim($title))) {
return $this->createViolation(
$element,
'<embed> öğesinde erişilebilir isim eksik.',
'Provide an aria-label, aria-labelledby, or title attribute for the <embed> element.'
);
}
return null;
}
}
+54 -8
View File
@@ -27,18 +27,64 @@ class EmptyLink extends AbstractRule
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
if ($text !== '') {
return null;
}
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
return null;
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
$spans = $xpath->query('.//span', $element);
if ($spans !== false) {
foreach ($spans as $span) {
if ($span instanceof DOMElement) {
if (trim($span->textContent) !== '') {
return null;
}
if ($span->hasAttribute('aria-label') && trim($span->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FigureCaptionStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_FIGCAPTION_PARENT'; }
public function getDescription(): string { return '<figcaption> elements must be direct children of a <figure> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'figcaption') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement || strtolower($parent->tagName) !== 'figure') {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the <figcaption> element inside a parent <figure> element or change it to a standard paragraph/caption element.'
);
}
return null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FocusTrapping extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_2_FOCUS_TRAP'; }
public function getDescription(): string { return 'Detects potential keyboard traps via inline event handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$onkeydown = $element->getAttribute('onkeydown');
$onkeypress = $element->getAttribute('onkeypress');
$handlers = $onkeydown . ' ' . $onkeypress;
if (empty(trim($handlers))) {
return null;
}
if (strpos($handlers, 'preventDefault') !== false || strpos($handlers, 'return false') !== false) {
return $this->createViolation(
$element,
'Klavye odağını hapseden (keyboard trap) potansiyel olay işleyicisi tespit edildi. / Potential keyboard trap detected in inline event handlers.',
'Kullanıcıların Esc veya Tab tuşları ile bu alandan çıkabilmesini sağlayın. / Ensure users can exit this component using Esc or Tab keys.'
);
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FocusableWithoutRole extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_FOCUSABLE_ROLE'; }
public function getDescription(): string { return 'Non-interactive focusable elements (with tabindex >= 0) must have an explicit ARIA role.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('tabindex')) {
return null;
}
$tabindex = (int)$element->getAttribute('tabindex');
if ($tabindex < 0) {
return null;
}
$tagName = strtolower($element->tagName);
$nonInteractiveTags = ['div', 'span', 'p', 'li', 'section', 'article', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
if (in_array($tagName, $nonInteractiveTags, true) && !$element->hasAttribute('role')) {
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Add an appropriate ARIA role (e.g., role="button", role="link") to the focusable <%s> element.', $tagName)
);
}
return null;
}
}
+2 -2
View File
@@ -40,8 +40,8 @@ class FormLabel extends AbstractRule
}
}
// Check for aria-label or aria-labelledby
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
// Check for aria-label, aria-labelledby, or title
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
return null;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormRequiredIndicator extends AbstractRule
{
public function getId(): string { return 'WCAG_3_3_2_FORM_REQUIRED_INDICATOR'; }
public function getDescription(): string { return 'Required form fields should have aria-required="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if ($element->hasAttribute('required')) {
$ariaRequired = strtolower(trim($element->getAttribute('aria-required')));
if ($ariaRequired !== 'true') {
// If it has 'required' but not aria-required="true", warn
return $this->createViolation(
$element,
"Zorunlu form alanı aria-required=\"true\" niteliğini içermiyor.",
"Ekran okuyucuların form alanının zorunlu olduğunu doğru şekilde anons edebilmesi için aria-required=\"true\" niteliğini eklemeyi düşünün."
);
}
}
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormSubmitButton extends AbstractRule
{
public function getId(): string { return 'WCAG_3_2_2_FORM_SUBMIT'; }
public function getDescription(): string { return 'Every form must contain a submit button to enable keyboard submission.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'form') {
return null;
}
$hasSubmit = false;
$buttons = $element->getElementsByTagName('button');
foreach ($buttons as $btn) {
$type = strtolower($btn->getAttribute('type') ?: 'submit');
if ($type === 'submit') {
$hasSubmit = true;
break;
}
}
if (!$hasSubmit) {
$inputs = $element->getElementsByTagName('input');
foreach ($inputs as $input) {
$type = strtolower($input->getAttribute('type'));
if ($type === 'submit' || $type === 'image') {
$hasSubmit = true;
break;
}
}
}
if (!$hasSubmit) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a submit control, such as a <button type="submit"> or <input type="submit"> inside the form.'
);
}
return null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltLong extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_LONG'; }
public function getDescription(): string { return 'Image alt text should be concise (less than 150 characters).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
if (mb_strlen($alt) > 150) {
return $this->createViolation(
$element,
$this->getDescription(),
'Shorten the alt text description to be under 150 characters, and move longer explanations to standard page content.'
);
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltPlaceholder extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_PLACEHOLDER'; }
public function getDescription(): string { return 'Image alt text should not be a generic placeholder or filename.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
if ($alt === '') {
return null; // Empty alt is allowed for decorative images (WCAG 1.1.1)
}
$cleanAlt = strtolower($alt);
// Generic placeholder checks
$placeholders = [
'image', 'photo', 'picture', 'logo', 'icon', 'placeholder', 'untitled', 'temp', 'blank', 'pic', 'dot',
'resim', 'foto', 'fotoğraf', 'görsel', 'grafik', 'ikon', 'logo',
'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(
$element,
$this->getDescription(),
'Provide a specific description of what the image shows, rather than using a generic word.'
);
}
// Filename checks
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.',
'Provide a descriptive alternative text instead of the image filename.'
);
}
return null;
}
}
+3 -1
View File
@@ -17,7 +17,9 @@ class ImageAltRedundant extends AbstractRule
/** @var array<int, string> */
private array $redundantWords = [
'resmi', 'görüntüsü', 'fotoğrafı', 'grafiği', 'logosu', 'ikonu',
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of'
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of',
'profile photo', 'banner image', 'background image', 'thumbnail of', 'screenshot of', 'avatar of',
'görseli', 'arkaplan resmi', 'profil fotoğrafı', 'küçük resmi', 'ekran görüntüsü'
];
public function check(DOMElement $element): ?Violation
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputImageAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_INPUT_IMAGE_ALT'; }
public function getDescription(): string { return '<input type="image"> elements must have an alternative text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
if (strtolower($element->getAttribute('type')) !== 'image') {
return null;
}
$hasAlt = $element->hasAttribute('alt') && trim($element->getAttribute('alt')) !== '';
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
$hasAriaLabelledBy = $element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '';
$hasTitle = $element->hasAttribute('title') && trim($element->getAttribute('title')) !== '';
if (!$hasAlt && !$hasAriaLabel && !$hasAriaLabelledBy && !$hasTitle) {
return $this->createViolation(
$element,
"<input type=\"image\"> elemanının alternatif metni (alt) bulunmuyor.",
"Görsel butonlar (<input type=\"image\">) için alt, aria-label veya title niteliklerinden birini kullanarak amacını açıklayın."
);
}
return null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputMissingAutocomplete extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_INPUT_AUTOCOMPLETE'; }
public function getDescription(): string { return 'Input fields collecting personal data should have an autocomplete attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
$type = strtolower($element->getAttribute('type'));
if (in_array($type, ['hidden', 'submit', 'button', 'reset', 'checkbox', 'radio', 'image', 'file'], true)) {
return null;
}
$name = strtolower($element->getAttribute('name'));
$id = strtolower($element->getAttribute('id'));
$targetKeywords = ['email', 'phone', 'tel', 'username', 'user', 'password', 'pass', 'address', 'zip', 'city', 'country', 'name', 'fname', 'lname'];
$isPersonalField = false;
if (in_array($type, ['email', 'tel'], true)) {
$isPersonalField = true;
} else {
foreach ($targetKeywords as $keyword) {
if (str_contains($name, $keyword) || str_contains($id, $keyword)) {
$isPersonalField = true;
break;
}
}
}
if ($isPersonalField && (!$element->hasAttribute('autocomplete') || trim($element->getAttribute('autocomplete')) === '')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add an autocomplete attribute (e.g., autocomplete="email", autocomplete="username") to improve accessibility and user experience.'
);
}
return null;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InteractiveRoleTabindex extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_ROLE_TABINDEX'; }
public function getDescription(): string { return 'Elements with interactive ARIA roles must be focusable.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$interactiveRoles = ['button', 'link', 'checkbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'combobox', 'gridcell'];
if (empty($role) || !in_array($role, $interactiveRoles)) {
return null;
}
if ($element->hasAttribute('tabindex')) {
return null;
}
$tagName = strtolower($element->tagName);
$focusableNative = ['button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $focusableNative)) {
return null;
}
if ($tagName === 'a' && $element->hasAttribute('href')) {
return null;
}
if (($tagName === 'audio' || $tagName === 'video') && $element->hasAttribute('controls')) {
return null;
}
return $this->createViolation(
$element,
'Etkileşimli bir role ('.$role.') sahip olan öğe klavye ile odaklanılabilir (focusable) değil. / Element with interactive role ('.$role.') is not focusable.',
'Öğeye tabindex="0" (veya uygun tabindex değeri) ekleyerek odaklanılabilir hale getirin. / Add tabindex="0" to make the element focusable.'
);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LabelTargetExists extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LABEL_TARGET'; }
public function getDescription(): string { return 'Label elements with a \'for\' attribute must target an existing element ID.'; }
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) !== 'label') {
return null;
}
if ($element->hasAttribute('for')) {
$forId = trim($element->getAttribute('for'));
if ($forId === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Provide a valid element ID in the for attribute.'
);
}
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*[@id="' . $forId . '"]');
if ($elements === false || $elements->length === 0) {
return $this->createViolation(
$element,
$this->getDescription(),
"Ensure the 'for' attribute value (\"$forId\") matches the ID of a form input element."
);
}
}
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkBanner extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkBanner'; }
public function getDescription(): string { return 'Checks that there is at most one page-level <header> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'header') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$ancestors = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $element);
if ($ancestors !== false && $ancestors->length > 0) {
return null; // It's not a page-level banner
}
$headers = $xpath->query('//header');
if ($headers === false) {
return null;
}
$index = 0;
foreach ($headers as $header) {
if (!$header instanceof \DOMNode) {
continue;
}
$anc = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $header);
if ($anc !== false && $anc->length === 0) {
$index++;
if ($header->isSameNode($element) && $index > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla ana (top-level) <header> bulundu.',
'Ensure there is at most one top-level <header> element (banner landmark) on the page.'
);
}
}
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkContentInfo extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkContentInfo'; }
public function getDescription(): string { return 'Checks that there is at most one page-level <footer> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'footer') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$ancestors = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $element);
if ($ancestors !== false && $ancestors->length > 0) {
return null; // It's not a page-level footer
}
$footers = $xpath->query('//footer');
if ($footers === false) {
return null;
}
$index = 0;
foreach ($footers as $footer) {
if (!$footer instanceof \DOMNode) {
continue;
}
$anc = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $footer);
if ($anc !== false && $anc->length === 0) {
$index++;
if ($footer->isSameNode($element) && $index > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla ana (top-level) <footer> bulundu.',
'Ensure there is at most one top-level <footer> element (contentinfo landmark) on the page.'
);
}
}
}
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkMain extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkMain'; }
public function getDescription(): string { return 'Checks if the page has exactly one <main> element or element with role="main".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$mains = $xpath->query('//main | //*[@role="main"]');
if ($mains === false || $mains->length === 0) {
return $this->createViolation(
$element,
'Sayfada <main> alanı bulunamadı.',
'Add exactly one <main> element or element with role="main" to the page.'
);
} elseif ($mains->length > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla <main> alanı bulundu.',
'Ensure there is only one <main> element or element with role="main" per page.'
);
}
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkNoDuplicate extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkNoDuplicate'; }
public function getDescription(): string { return 'Checks that if there are multiple landmarks of the same type, each has a unique label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['nav', 'aside', 'section', 'form'])) {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$elementsOfSameType = $xpath->query('//' . $tag);
if ($elementsOfSameType === false || $elementsOfSameType->length <= 1) {
return null;
}
$label = $element->getAttribute('aria-label');
$labelledby = $element->getAttribute('aria-labelledby');
if (empty($label) && empty($labelledby)) {
return $this->createViolation(
$element,
"Birden fazla <{$tag}> öğesi var ancak bu öğenin benzersiz bir etiketi (aria-label/aria-labelledby) yok.",
"Provide a unique aria-label or aria-labelledby for each <{$tag}> when multiple exist."
);
}
// Check for duplicates
$duplicateLabel = false;
foreach ($elementsOfSameType as $el) {
if (!$el instanceof DOMElement) {
continue;
}
if (!$el->isSameNode($element)) {
$otherLabel = $el->getAttribute('aria-label');
$otherLabelledby = $el->getAttribute('aria-labelledby');
if (!empty($label) && $otherLabel === $label) {
$duplicateLabel = true;
break;
}
if (!empty($labelledby) && $otherLabelledby === $labelledby) {
$duplicateLabel = true;
break;
}
}
}
if ($duplicateLabel) {
return $this->createViolation(
$element,
"Birden fazla <{$tag}> öğesi aynı etiketi kullanıyor.",
"Ensure aria-label or aria-labelledby is unique for each <{$tag}>."
);
}
return null;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
/**
* WCAG 3.1.2 — Checks that when both `lang` and `xml:lang` are present on <html>,
* their primary language subtags match.
*
* A mismatch between these attributes can cause assistive technologies to
* announce content in the wrong language.
*/
class LangMismatch extends AbstractRule
{
public function getId(): string { return 'WCAG_3_1_2_LANG_MISMATCH'; }
public function getDescription(): string { return 'The lang and xml:lang attributes on <html> must have matching primary language subtags.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$lang = $element->getAttribute('lang');
$xmlLang = $element->getAttribute('xml:lang');
// Only check when both are present
if (empty($lang) || empty($xmlLang)) {
return null;
}
$primaryLang = $this->getPrimarySubtag($lang);
$primaryXmlLang = $this->getPrimarySubtag($xmlLang);
if (strtolower($primaryLang) !== strtolower($primaryXmlLang)) {
return $this->createViolation(
$element,
"lang=\"{$lang}\" ile xml:lang=\"{$xmlLang}\" arasında birincil dil uyumsuzluğu var.",
'lang ve xml:lang niteliklerinin birincil dil alt etiketlerinin eşleştiğinden emin olun (örn. her ikisi de "en" veya "tr").'
);
}
return null;
}
/**
* Extracts the primary language subtag from a BCP 47 language tag.
* e.g. "en-US" → "en", "tr" → "tr"
*/
private function getPrimarySubtag(string $langTag): string
{
$parts = explode('-', trim($langTag));
return $parts[0];
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ class LanguageSubtag extends AbstractRule
// Simple IETF language tag format check (e.g. "en", "tr", "en-US", "zh-Hant")
$isValid = preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/', $lang);
if (!$isValid || $lang === '') {
if (!$isValid) {
return $this->createViolation(
$element,
$this->getDescription(),
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LinkHrefJavascript extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_LINK_JAVASCRIPT'; }
public function getDescription(): string { return 'Links must not use javascript: void(0) or # as their href. Use buttons for scripting actions.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
if ($element->hasAttribute('href')) {
$href = trim($element->getAttribute('href'));
// Check for javascript: links or '#' (which represents a placeholder/scripting target)
if (str_starts_with(strtolower($href), 'javascript:') || $href === '#') {
return $this->createViolation(
$element,
$this->getDescription(),
'Convert the link to a <button> element or provide a valid destination URL.'
);
}
}
return null;
}
}
+2 -1
View File
@@ -17,7 +17,8 @@ class LinkTextGeneric extends AbstractRule
/** @var array<int, string> */
private array $genericWords = [
'click here', 'click', 'read more', 'more', 'here', 'download', 'go', 'details', 'link', 'website', 'page', 'info',
'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi'
'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi',
'daha fazla bilgi', 'oku', 'bak', 'ayrıntılar', 'incele', 'see more', 'view', 'open'
];
public function check(DOMElement $element): ?Violation
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LinkTextUrl extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_4_LINK_URL'; }
public function getDescription(): string { return 'Link text should not be a raw URL.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
$text = trim($element->textContent);
if (preg_match('/^(https?:\/\/|www\.)[^\s]+$/i', $text)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Replace the raw URL link text with a descriptive label explaining where the link goes.'
);
}
return null;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ListItemParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LIST_ITEM_PARENT'; }
public function getDescription(): string { return 'List items (<li>) must be contained inside a parent <ul>, <ol>, or <menu> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'li') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement) {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the orphan <li> element in a parent <ul> or <ol> tag.'
);
}
$parentTag = strtolower($parent->tagName);
$validParents = ['ul', 'ol', 'menu'];
if (!in_array($parentTag, $validParents, true) && strtolower($parent->getAttribute('role')) !== 'list') {
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Move the <li> element inside a <ul> or <ol> container instead of <%s>.', $parentTag)
);
}
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ListStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LIST_STRUCTURE'; }
public function getDescription(): string { return 'Lists (ul, ol) must only contain list item elements (li) as direct children.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if ($tag !== 'ul' && $tag !== 'ol') {
return null;
}
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, ['li', 'script', 'template'], true)) {
return $this->createViolation(
$element,
sprintf('List element <%s> contains invalid direct child <%s>. Only <li> elements are allowed.', $tag, $childTag),
'Wrap the child element in an <li> tag, or move it outside the list structure.'
);
}
}
}
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class MetaRefreshBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_2_1_META_REFRESH'; }
public function getDescription(): string { return 'Do not use meta http-equiv="refresh" for automatic page redirects or updates as it disorients users.'; }
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) !== 'meta') {
return null;
}
if ($element->hasAttribute('http-equiv')) {
$httpEquiv = trim($element->getAttribute('http-equiv'));
if (strtolower($httpEquiv) === 'refresh') {
return $this->createViolation(
$element,
$this->getDescription(),
'Use server-side redirects (e.g. HTTP 301/302) or standard JavaScript routing instead of meta refresh.'
);
}
}
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class NestedInteractive extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_NestedInteractive'; }
public function getDescription(): string { return 'Checks for nested interactive elements.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['a', 'button'])) {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$interactives = $xpath->query('.//a | .//button | .//input | .//select | .//textarea', $element);
if ($interactives !== false && $interactives->length > 0) {
return $this->createViolation(
$element,
"İç içe geçmiş etkileşimli öğeler bulundu (<{$tag}> içerisinde).",
'Ensure interactive controls are not nested.'
);
}
return null;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class OptgroupLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_OPTGROUP_LABEL'; }
public function getDescription(): string { return '<optgroup> elements must have a non-empty label attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'optgroup') {
return null;
}
if (!$element->hasAttribute('label') || trim($element->getAttribute('label')) === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a descriptive label attribute to the <optgroup> element.'
);
}
return null;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class PresentationTagsBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_PRESENTATION_TAGS'; }
public function getDescription(): string { return 'Obsolete presentation tags (<center>, <strike>, <basefont>, <tt>, <big>) should not be used.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
$bannedTags = ['center', 'strike', 'basefont', 'tt', 'big'];
$tag = strtolower($element->tagName);
if (in_array($tag, $bannedTags, true)) {
return $this->createViolation(
$element,
sprintf('The <%s> presentation tag is obsolete and should not be used.', $tag),
sprintf('Remove the <%s> tag and use modern CSS styling instead.', $tag)
);
}
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ProgressbarAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_PROGRESSBAR'; }
public function getDescription(): string { return 'Progressbar elements must have an accessible name and required aria attributes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$tagName = strtolower($element->tagName);
if ($role !== 'progressbar' && $tagName !== 'progress') {
return null;
}
$hasName = $element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title');
if (!$hasName) {
return $this->createViolation(
$element,
'İlerleme çubuğunun (progressbar) erişilebilir bir adı eksik. / Progressbar is missing an accessible name.',
'aria-label, aria-labelledby veya title niteliği ekleyin. / Add aria-label, aria-labelledby, or title attribute.'
);
}
if ($role === 'progressbar' && $tagName !== 'progress') {
$hasValueNow = $element->hasAttribute('aria-valuenow');
$hasValueMinMax = $element->hasAttribute('aria-valuemin') && $element->hasAttribute('aria-valuemax');
$hasValueText = $element->hasAttribute('aria-valuetext');
if (!$hasValueNow || (!$hasValueMinMax && !$hasValueText)) {
return $this->createViolation(
$element,
'Özel ilerleme çubuğunun (role="progressbar") durum bildiren ARIA nitelikleri eksik. / Custom progressbar is missing state ARIA attributes.',
'aria-valuenow ile birlikte aria-valuemin/aria-valuemax veya aria-valuetext niteliklerini ekleyin. / Add aria-valuenow along with aria-valuemin/aria-valuemax or aria-valuetext.'
);
}
}
return null;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class RadioGroupFieldset extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_RADIO_GROUP_FIELDSET'; }
public function getDescription(): string { return 'Radio button groups sharing the same name should be grouped inside a <fieldset> with a <legend> or role="radiogroup".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
if (strtolower($element->getAttribute('type')) !== 'radio') {
return null;
}
$name = $element->getAttribute('name');
if ($name === '') {
return null;
}
// Check if ancestor is fieldset or role="radiogroup"
$current = $element->parentNode;
while ($current instanceof DOMElement) {
$parentTag = strtolower($current->tagName);
$parentRole = strtolower($current->getAttribute('role'));
if ($parentTag === 'fieldset') {
$legends = $current->getElementsByTagName('legend');
if ($legends->length > 0) {
return null;
}
}
if ($parentRole === 'radiogroup' && ($current->hasAttribute('aria-label') || $current->hasAttribute('aria-labelledby'))) {
return null;
}
$current = $current->parentNode;
}
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Wrap radio inputs named "%s" inside a <fieldset> with a <legend> to group related radio choices.', $name)
);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ScopeOnlyOnHeaders extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TABLE_SCOPE'; }
public function getDescription(): string { return 'The scope attribute is only valid on th elements, not td elements.'; }
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) !== 'td') {
return null;
}
if ($element->hasAttribute('scope')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the scope attribute from the td element or change the element to th if it is a header.'
);
}
return null;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ScrollableRegionFocusable extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_SCROLLABLE_FOCUS'; }
public function getDescription(): string { return 'Scrollable regions must be focusable via keyboard using tabindex="0".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$style = $element->getAttribute('style');
if ($style && preg_match('/overflow(-x|-y)?\s*:\s*(scroll|auto)/i', $style)) {
if (!$element->hasAttribute('tabindex') || (int)$element->getAttribute('tabindex') < 0) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add tabindex="0" to the scrollable element so keyboard users can navigate to and scroll it.'
);
}
}
return null;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SelectHasAccessibleName extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_SELECT_ACCESSIBLE_NAME'; }
public function getDescription(): string { return 'Select elements must have an accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'select') {
return null;
}
// Check aria-label
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
// Check aria-labelledby
if ($element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '') {
return null;
}
// Check title
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Check if there is an associated <label for="id">
$id = $element->getAttribute('id');
if (!empty($id)) {
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new \DOMXPath($doc);
// escape quotes in id
$safeId = addslashes($id);
$labels = $xpath->query("//label[@for='{$safeId}']");
if ($labels !== false && $labels->length > 0) {
return null;
}
}
// Check if wrapped in a <label>
$parent = $element->parentNode;
while ($parent !== null && $parent instanceof DOMElement) {
if (strtolower($parent->tagName) === 'label') {
return null;
}
$parent = $parent->parentNode;
}
return $this->createViolation(
$element,
"<select> elemanının erişilebilir bir adı (label) yok.",
"<select> elemanına erişilebilir bir ad sağlamak için bir <label> ilişkilendirin veya aria-label niteliği ekleyin."
);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SkipNavigation extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_1_SkipNavigation'; }
public function getDescription(): string { return 'Checks if the page has a skip navigation mechanism.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$hasSkipLink = false;
$links = $xpath->query('//a[starts-with(@href, "#")]');
if ($links !== false && $links->length > 0) {
// Simplified check: if there is an anchor linking to an id, we assume it could be a skip link
$hasSkipLink = true;
}
$mains = $xpath->query('//main | //*[@role="main"]');
$hasMain = ($mains !== false && $mains->length > 0);
$navs = $xpath->query('//nav[@role="navigation"] | //nav');
$hasNav = ($navs !== false && $navs->length > 0);
if (!$hasSkipLink && (!$hasMain || !$hasNav)) {
// Re-evaluating the rule condition:
// OR look for a <main> element or role="main" anywhere
// OR look for a <nav> element with role="navigation"
if (!$hasMain && !$hasNav) {
return $this->createViolation(
$element,
'Sayfada atlama mekanizması (skip navigation) bulunamadı.',
'Provide a skip link (href="#..."), a <main> element, or a <nav role="navigation"> to allow users to bypass repetitive content.'
);
}
}
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SummaryParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_SUMMARY_PARENT'; }
public function getDescription(): string { return '<summary> elements must be direct children of a <details> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'summary') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement || strtolower($parent->tagName) !== 'details') {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the <summary> element inside a <details> element or change it to a standard heading/button.'
);
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More