Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f4799dc38 | ||
|
|
c79ddfcc50 | ||
|
|
5292c2c865 |
@@ -4,3 +4,5 @@
|
||||
/rapor.html
|
||||
/.phpunit.result.cache
|
||||
/phpstan-baseline.neon
|
||||
/.phpunit.cache/
|
||||
|
||||
@@ -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->addCommand(new ScanCommand());
|
||||
$application->addCommand(new FixCommand());
|
||||
$application->addCommand(new InitCommand());
|
||||
$application->addCommand(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();
|
||||
+9
-3
@@ -12,7 +12,7 @@
|
||||
"yaknet"
|
||||
],
|
||||
"homepage": "https://forum.yak.net.tr",
|
||||
"version": "3.0.5",
|
||||
"version": "3.1.2",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
@@ -35,13 +35,20 @@
|
||||
"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"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/accessibility-console.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"YakNet\\AccessibilityConsole\\": "src/"
|
||||
}
|
||||
@@ -57,4 +64,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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'][0]['text']) && is_string($data['content'][0]['text'])) {
|
||||
return $data['content'][0]['text'];
|
||||
}
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return "Claude Error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?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'][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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// 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
|
||||
$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.</>");
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Parser;
|
||||
|
||||
class HtmlParser implements ParserInterface
|
||||
{
|
||||
public function parse(string $content): string
|
||||
{
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 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'
|
||||
];
|
||||
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('/\.(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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 SvgAltText extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_1_1_1_SVG'; }
|
||||
public function getDescription(): string { return 'SVG elements must have alternative text or be hidden from screen readers.'; }
|
||||
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) !== 'svg') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($element->getAttribute('aria-hidden') === 'true') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$titles = $element->getElementsByTagName('title');
|
||||
if ($titles->length > 0) {
|
||||
$titleNode = $titles->item(0);
|
||||
if ($titleNode !== null) {
|
||||
$titleText = trim($titleNode->textContent);
|
||||
if ($titleText !== '') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add an aria-label, aria-labelledby, a <title> element inside the SVG, or set aria-hidden="true" if it is decorative.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 TableSummaryBanned extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_1_3_1_TABLE_SUMMARY'; }
|
||||
public function getDescription(): string { return 'The summary attribute on table elements is obsolete and should not be used.'; }
|
||||
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) !== 'table') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($element->hasAttribute('summary')) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Remove the summary attribute. Use the <caption> element or aria-describedby instead.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
use YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer;
|
||||
use YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator;
|
||||
|
||||
if (!function_exists('a11y_scan')) {
|
||||
/**
|
||||
* Scan HTML content for accessibility violations.
|
||||
*
|
||||
* @param string $html
|
||||
* @param int $level
|
||||
* @return array<int, \YakNet\AccessibilityConsole\Core\Violation>
|
||||
*/
|
||||
function a11y_scan(string $html, int $level = 4): array
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
foreach ($rules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
return $scanner->scan($html);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('a11y_score')) {
|
||||
/**
|
||||
* Calculate accessibility score (0-100) for HTML content.
|
||||
*
|
||||
* @param string $html
|
||||
* @param int $level
|
||||
* @return int
|
||||
*/
|
||||
function a11y_score(string $html, int $level = 4): int
|
||||
{
|
||||
$violations = a11y_scan($html, $level);
|
||||
$metrics = AccessibilityScoreCalculator::calculate($violations);
|
||||
return $metrics['score'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('a11y_quick_fix')) {
|
||||
/**
|
||||
* Perform local self-healing fixes on the HTML content.
|
||||
*
|
||||
* @param string $html
|
||||
* @param int $level
|
||||
* @return string
|
||||
*/
|
||||
function a11y_quick_fix(string $html, int $level = 4): string
|
||||
{
|
||||
$violations = a11y_scan($html, $level);
|
||||
$healer = new LocalHealer();
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$healed = $healer->heal($v);
|
||||
if ($healed !== null && str_contains($healed, 'FIX:')) {
|
||||
preg_match('/FIX:(.*)/s', $healed, $matches);
|
||||
$suggestion = trim($matches[1] ?? '');
|
||||
$replacedSuggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||
$suggestion = is_string($replacedSuggestion) ? $replacedSuggestion : '';
|
||||
|
||||
if ($suggestion !== '') {
|
||||
$htmlSnippet = $v->htmlSnippet;
|
||||
if (str_contains($html, $htmlSnippet)) {
|
||||
$html = str_replace($htmlSnippet, $suggestion, $html);
|
||||
} else {
|
||||
$html = str_replace(trim($htmlSnippet), trim($suggestion), $html);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Core;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AccessibilityScoreCalculatorTest extends TestCase
|
||||
{
|
||||
public function testScoreCalculationWithNoViolations(): void
|
||||
{
|
||||
$metrics = AccessibilityScoreCalculator::calculate([]);
|
||||
$this->assertSame(100, $metrics['score']);
|
||||
}
|
||||
|
||||
public function testScoreCalculationDeductions(): void
|
||||
{
|
||||
$violations = [
|
||||
new Violation('WCAG_1_1_1_ALT', 'Img alt missing', Severity::ERROR, WCAGStandard::A, '<img>'), // -8
|
||||
new Violation('WCAG_2_4_3_AUTOFOCUS', 'Autofocus usage', Severity::WARNING, WCAGStandard::A, '<input autofocus>') // -3
|
||||
];
|
||||
|
||||
$metrics = AccessibilityScoreCalculator::calculate($violations);
|
||||
$this->assertSame(89, $metrics['score']); // 100 - 8 - 3 = 89
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Core;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Baseline\FuzzyBaselineMatcher;
|
||||
|
||||
class FuzzyBaselineMatcherTest extends TestCase
|
||||
{
|
||||
public function testFuzzyMatchIgnoresAttributeOrderAndSpaces(): void
|
||||
{
|
||||
$snippetA = '<img src="logo.png" alt="logo" class="banner">';
|
||||
$snippetB = ' <img class="banner" src="logo.png" alt="logo"> ';
|
||||
|
||||
$this->assertTrue(FuzzyBaselineMatcher::match($snippetA, $snippetB));
|
||||
}
|
||||
|
||||
public function testNormalizeLowercaseTags(): void
|
||||
{
|
||||
$html = '<IMG SRC="logo.png" ALT="logo">';
|
||||
$norm = FuzzyBaselineMatcher::normalize($html);
|
||||
|
||||
$this->assertSame('<img alt="logo" src="logo.png">', $norm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Core;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class LocalHealerTest extends TestCase
|
||||
{
|
||||
public function testHealsBlinkTag(): void
|
||||
{
|
||||
$healer = new LocalHealer();
|
||||
$violation = new Violation(
|
||||
ruleId: 'WCAG_2_2_2_BLINK',
|
||||
message: 'Banned blink tag.',
|
||||
severity: Severity::ERROR,
|
||||
standard: WCAGStandard::A,
|
||||
htmlSnippet: '<blink>Hello</blink>'
|
||||
);
|
||||
|
||||
$result = $healer->heal($violation);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertStringContainsString('FIX: <span style="text-decoration: blink;">Hello</span>', $result);
|
||||
}
|
||||
|
||||
public function testHealsAutofocusAttribute(): void
|
||||
{
|
||||
$healer = new LocalHealer();
|
||||
$violation = new Violation(
|
||||
ruleId: 'WCAG_2_4_3_AUTOFOCUS',
|
||||
message: 'Banned autofocus attribute.',
|
||||
severity: Severity::WARNING,
|
||||
standard: WCAGStandard::A,
|
||||
htmlSnippet: '<input type="text" autofocus />'
|
||||
);
|
||||
|
||||
$result = $healer->heal($violation);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertStringContainsString('FIX: <input type="text" />', $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Core;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator;
|
||||
|
||||
class VisualDiffGeneratorTest extends TestCase
|
||||
{
|
||||
public function testGeneratesVisualDiff(): void
|
||||
{
|
||||
$generator = new VisualDiffGenerator();
|
||||
$original = "hello world\nfoo\n";
|
||||
$fixed = "hello world\nbar\n";
|
||||
|
||||
$diff = $generator->generate($original, $fixed);
|
||||
|
||||
$this->assertStringContainsString('--- Original', $diff);
|
||||
$this->assertStringContainsString('+++ Healed', $diff);
|
||||
$this->assertStringContainsString('<fg=red>-foo', $diff);
|
||||
$this->assertStringContainsString('<fg=green>+bar', $diff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\AriaLabelledbyExists;
|
||||
|
||||
class AriaLabelledbyExistsTest extends TestCase
|
||||
{
|
||||
public function testFlagsMissingAriaLabelTarget(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new AriaLabelledbyExists());
|
||||
|
||||
$violations = $scanner->scan('<div><button aria-labelledby="non_existent_id">Submit</button></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_4_1_2_ARIA_LABELLEDBY', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesExistingAriaLabelTarget(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new AriaLabelledbyExists());
|
||||
|
||||
$violations = $scanner->scan('<div><div id="real_id">Label</div><button aria-labelledby="real_id">Submit</button></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\AutocompleteValid;
|
||||
|
||||
class AutocompleteValidTest extends TestCase
|
||||
{
|
||||
public function testFlagsMissingAutocomplete(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new AutocompleteValid());
|
||||
|
||||
$violations = $scanner->scan('<div><input type="email" name="user_email" /></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_1_3_5_AUTOCOMPLETE', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesWithAutocomplete(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new AutocompleteValid());
|
||||
|
||||
$violations = $scanner->scan('<div><input type="email" name="user_email" autocomplete="email" /></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
|
||||
public function testSkipsNonPersonalInputs(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new AutocompleteValid());
|
||||
|
||||
$violations = $scanner->scan('<div><input type="checkbox" name="agree" /><input type="submit" /></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\AutofocusBanned;
|
||||
|
||||
class AutofocusBannedTest extends TestCase
|
||||
{
|
||||
public function testFlagsAutofocusAttribute(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new AutofocusBanned());
|
||||
|
||||
$violations = $scanner->scan('<div><input type="text" name="search" autofocus /></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_2_4_3_AUTOFOCUS', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesNormalInput(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new AutofocusBanned());
|
||||
|
||||
$violations = $scanner->scan('<div><input type="text" name="search" /></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\BannedSelectors;
|
||||
|
||||
class BannedSelectorsTest extends TestCase
|
||||
{
|
||||
public function testFlagsBannedCssSelectorTags(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new BannedSelectors());
|
||||
|
||||
$violations = $scanner->scan('<div><center>Obsolete tag</center><div align="center">Deprecated align</div></div>');
|
||||
$this->assertCount(2, $violations);
|
||||
|
||||
$ruleIds = array_map(fn($v) => $v->ruleId, $violations);
|
||||
$this->assertContains('BannedSelectors', $ruleIds);
|
||||
}
|
||||
|
||||
public function testPassesSemanticHtml(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new BannedSelectors());
|
||||
|
||||
$violations = $scanner->scan('<div><span style="text-align: center;">Semantic structure</span></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\BlinkTagBanned;
|
||||
|
||||
class BlinkTagBannedTest extends TestCase
|
||||
{
|
||||
public function testFlagsBlinkTag(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new BlinkTagBanned());
|
||||
|
||||
$violations = $scanner->scan('<div><blink>Blinking text!</blink></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_2_2_2_BLINK', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesNormalSpan(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new BlinkTagBanned());
|
||||
|
||||
$violations = $scanner->scan('<div><span>Normal text</span></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\DuplicateH1;
|
||||
|
||||
class DuplicateH1Test extends TestCase
|
||||
{
|
||||
public function testFlagsDuplicateH1(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new DuplicateH1());
|
||||
|
||||
$violations = $scanner->scan('<div><h1>Heading 1</h1><h1>Heading 2</h1></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_1_3_1_DUPLICATE_H1', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesSingleH1(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new DuplicateH1());
|
||||
|
||||
$violations = $scanner->scan('<div><h1>Heading 1</h1><h2>Heading 2</h2></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\FormSubmitButton;
|
||||
|
||||
class FormSubmitButtonTest extends TestCase
|
||||
{
|
||||
public function testFlagsFormWithoutSubmitButton(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new FormSubmitButton());
|
||||
|
||||
$violations = $scanner->scan('<form><input type="text" name="name" /></form>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_3_2_2_FORM_SUBMIT', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesFormWithButtonSubmit(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new FormSubmitButton());
|
||||
|
||||
$violations = $scanner->scan('<form><input type="text" name="name" /><button>Submit</button></form>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
|
||||
public function testPassesFormWithInputSubmit(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new FormSubmitButton());
|
||||
|
||||
$violations = $scanner->scan('<form><input type="text" name="name" /><input type="submit" value="Go" /></form>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\ImageAltLong;
|
||||
|
||||
class ImageAltLongTest extends TestCase
|
||||
{
|
||||
public function testFlagsTooLongAltText(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ImageAltLong());
|
||||
|
||||
$longAlt = str_repeat("a", 151);
|
||||
$violations = $scanner->scan('<div><img src="pic.png" alt="' . $longAlt . '" /></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_1_1_1_ALT_LONG', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesNormalAltText(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ImageAltLong());
|
||||
|
||||
$violations = $scanner->scan('<div><img src="pic.png" alt="Short description" /></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\ImageAltPlaceholder;
|
||||
|
||||
class ImageAltPlaceholderTest extends TestCase
|
||||
{
|
||||
public function testFlagsPlaceholderAltText(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ImageAltPlaceholder());
|
||||
|
||||
$violations = $scanner->scan('<div><img src="logo.png" alt="logo" /></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_1_1_1_ALT_PLACEHOLDER', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testFlagsFilenameAltText(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ImageAltPlaceholder());
|
||||
|
||||
$violations = $scanner->scan('<div><img src="banner.jpg" alt="header_banner.jpg" /></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_1_1_1_ALT_PLACEHOLDER', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesDescriptiveAltText(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ImageAltPlaceholder());
|
||||
|
||||
$violations = $scanner->scan('<div><img src="banner.jpg" alt="YakNet Static Analysis Console Banner Image" /></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
|
||||
public function testPassesEmptyAlt(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ImageAltPlaceholder());
|
||||
|
||||
$violations = $scanner->scan('<div><img src="decoration.jpg" alt="" /></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\LinkTextUrl;
|
||||
|
||||
class LinkTextUrlTest extends TestCase
|
||||
{
|
||||
public function testFlagsLinkWithRawUrlText(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new LinkTextUrl());
|
||||
|
||||
$violations = $scanner->scan('<div><a href="https://google.com">https://google.com</a></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_2_4_4_LINK_URL', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesDescriptiveLinkText(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new LinkTextUrl());
|
||||
|
||||
$violations = $scanner->scan('<div><a href="https://google.com">Google Search Engine</a></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\ListStructure;
|
||||
|
||||
class ListStructureTest extends TestCase
|
||||
{
|
||||
public function testFlagsInvalidDirectChildrenInList(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ListStructure());
|
||||
|
||||
$violations = $scanner->scan('<ul><div>Invalid</div><li>Valid</li></ul>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_1_3_1_LIST_STRUCTURE', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesValidListStructure(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ListStructure());
|
||||
|
||||
$violations = $scanner->scan('<ul><li>Valid 1</li><li>Valid 2</li><script>alert(1);</script></ul>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\PresentationTagsBanned;
|
||||
|
||||
class PresentationTagsBannedTest extends TestCase
|
||||
{
|
||||
public function testFlagsBannedPresentationTags(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new PresentationTagsBanned());
|
||||
|
||||
$violations = $scanner->scan('<div><center>Centered text</center><strike>Strikethrough</strike></div>');
|
||||
$this->assertCount(2, $violations);
|
||||
$this->assertSame('WCAG_1_3_1_PRESENTATION_TAGS', $violations[0]->ruleId);
|
||||
$this->assertSame('WCAG_1_3_1_PRESENTATION_TAGS', $violations[1]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesCssStyling(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new PresentationTagsBanned());
|
||||
|
||||
$violations = $scanner->scan('<div><span style="text-align: center; text-decoration: line-through;">Styled Text</span></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\ScrollableRegionFocusable;
|
||||
|
||||
class ScrollableRegionFocusableTest extends TestCase
|
||||
{
|
||||
public function testFlagsScrollableRegionWithoutTabindex(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ScrollableRegionFocusable());
|
||||
|
||||
$violations = $scanner->scan('<div style="overflow: scroll; height: 100px;">Scrollable content</div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_2_1_1_SCROLLABLE_FOCUS', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesScrollableRegionWithTabindex(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ScrollableRegionFocusable());
|
||||
|
||||
$violations = $scanner->scan('<div style="overflow-y: auto;" tabindex="0">Scrollable content</div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
|
||||
public function testPassesNonScrollableElement(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new ScrollableRegionFocusable());
|
||||
|
||||
$violations = $scanner->scan('<div style="color: blue;">Non-scrollable content</div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\SvgAltText;
|
||||
|
||||
class SvgAltTextTest extends TestCase
|
||||
{
|
||||
public function testFlagsSvgWithoutAlt(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new SvgAltText());
|
||||
|
||||
$violations = $scanner->scan('<div><svg width="10"><circle /></svg></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_1_1_1_SVG', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesSvgWithAriaLabel(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new SvgAltText());
|
||||
|
||||
$violations = $scanner->scan('<div><svg width="10" aria-label="Company logo"><circle /></svg></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
|
||||
public function testPassesSvgWithTitle(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new SvgAltText());
|
||||
|
||||
$violations = $scanner->scan('<div><svg width="10"><title>Real Title</title><circle /></svg></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
|
||||
public function testPassesSvgHidden(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new SvgAltText());
|
||||
|
||||
$violations = $scanner->scan('<div><svg width="10" aria-hidden="true"><circle /></svg></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Tests\Rules;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\TableSummaryBanned;
|
||||
|
||||
class TableSummaryBannedTest extends TestCase
|
||||
{
|
||||
public function testFlagsTableWithSummaryAttribute(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new TableSummaryBanned());
|
||||
|
||||
$violations = $scanner->scan('<div><table summary="This table lists details."><tr><td>Data</td></tr></table></div>');
|
||||
$this->assertCount(1, $violations);
|
||||
$this->assertSame('WCAG_1_3_1_TABLE_SUMMARY', $violations[0]->ruleId);
|
||||
}
|
||||
|
||||
public function testPassesTableWithoutSummaryAttribute(): void
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$scanner->addRule(new TableSummaryBanned());
|
||||
|
||||
$violations = $scanner->scan('<div><table><caption>Details</caption><tr><td>Data</td></tr></table></div>');
|
||||
$this->assertCount(0, $violations);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user