diff --git a/composer.json b/composer.json index 642d354..bf0d0a1 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,9 @@ "symfony/yaml": "^6.4|^7.0", "monolog/monolog": "^3.0", "vlucas/phpdotenv": "^5.6", - "google-gemini-php/client": "^1.0" + "google-gemini-php/client": "^1.0", + "guzzlehttp/guzzle": "^7.8", + "masterminds/html5": "^2.8" }, "require-dev": { "phpunit/phpunit": "^10.5", diff --git a/demo/index.php b/demo/index.php index 51eace3..48e9daf 100644 --- a/demo/index.php +++ b/demo/index.php @@ -1,19 +1,11 @@ scan($html); if (!empty($violations)) { // Locate Sources - // We point it to the 'views' directory so it knows where to look for the error source $locator = new SourceLocator(__DIR__ . '/views'); foreach ($violations as $violation) { - $location = $locator->locate($violation->snippet); + $location = $locator->locate($violation->htmlSnippet); if ($location) { - $violation->setSourceLocation($location['file'], $location['line']); + $violation->location = $location; } } - // Render Report - $reporter = new HtmlReporter(); - $reportHtml = $reporter->render($violations); + // Render Report (Format as results mapped by page URL) + $reporter = new HtmlDashboardReporter(); + $reportHtml = $reporter->render(['http://localhost:8080/index.php' => $violations], 'demo/index.php'); // Inject into body $html = str_replace('', $reportHtml . '', $html); diff --git a/src/Console/Command/ScanCommand.php b/src/Console/Command/ScanCommand.php index d4f58c9..8008803 100644 --- a/src/Console/Command/ScanCommand.php +++ b/src/Console/Command/ScanCommand.php @@ -26,7 +26,10 @@ class ScanCommand extends Command ->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan') ->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions') ->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)') - ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping'); + ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping') + ->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively') + ->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth', '3') + ->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum number of pages to crawl', '20'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -46,73 +49,195 @@ class ScanCommand extends Command $io->title("Accessibility Console - Scanning: $target"); - // 1. Fetch HTML - $html = @file_get_contents($target); - if ($html === false || $html === '') { - $io->error("Could not read target: $target"); - return Command::FAILURE; - } - - // 2. Setup Scanner + // 1. Setup Scanner $scanner = new Scanner(); foreach (StandardRuleSet::all() as $rule) { $scanner->addRule($rule); } - // 3. Scan - $violations = $scanner->scan($html); + // 2. Fetch and Scan + $isUrl = str_starts_with($target, 'http://') || str_starts_with($target, 'https://'); + $crawl = (bool)$input->getOption('crawl') && $isUrl; - if (empty($violations)) { + /** @var array $results */ + $results = []; + + if ($crawl) { + $depthOption = $input->getOption('depth'); + $depthLimit = is_numeric($depthOption) ? (int)$depthOption : 3; + $maxPagesOption = $input->getOption('max-pages'); + $maxPages = is_numeric($maxPagesOption) ? (int)$maxPagesOption : 20; + + $io->comment("Starting recursive crawl from: $target (Max Depth: $depthLimit, Max Pages: $maxPages)"); + + $parsedBase = parse_url($target); + $baseHost = $parsedBase['host'] ?? ''; + + $queue = [ + ['url' => $target, 'depth' => 1] + ]; + $visited = []; + + $client = new \GuzzleHttp\Client([ + 'timeout' => 10, + 'allow_redirects' => true, + 'headers' => [ + 'User-Agent' => 'YakNet Accessibility Console Crawler/1.0', + ] + ]); + + while (!empty($queue) && count($visited) < $maxPages) { + $current = array_shift($queue); + if (!is_array($current)) { + continue; + } + + $url = $current['url']; + $depth = (int)$current['depth']; + + // Remove fragment + $cleanUrl = strtok($url, '#'); + if ($cleanUrl === false || isset($visited[$cleanUrl])) { + continue; + } + + $visited[$cleanUrl] = true; + $io->write(" Crawling [Depth $depth]: $cleanUrl... "); + + try { + $response = $client->request('GET', $cleanUrl); + $html = (string)$response->getBody(); + $io->writeln("OK"); + + // Scan the page HTML + $pageViolations = $scanner->scan($html); + $results[$cleanUrl] = $pageViolations; + + // Extract links if depth is less than limit + if ($depth < $depthLimit) { + $html5 = new \Masterminds\HTML5(['disable_html_ns' => true]); + $doc = $html5->loadHTML($html); + $links = $doc->getElementsByTagName('a'); + + foreach ($links as $link) { + $href = $link->getAttribute('href'); + if (empty($href) || str_starts_with($href, 'javascript:') || str_starts_with($href, 'mailto:') || str_starts_with($href, 'tel:')) { + continue; + } + + // Resolve relative URI + try { + $baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl); + $relativeUri = new \GuzzleHttp\Psr7\Uri($href); + $absoluteUri = \GuzzleHttp\Psr7\UriResolver::resolve($baseUri, $relativeUri); + $absoluteUrl = (string)$absoluteUri; + } catch (\Throwable) { + continue; + } + + // Check host compatibility + $parsedAbsolute = parse_url($absoluteUrl); + $absoluteHost = $parsedAbsolute['host'] ?? ''; + + if ($absoluteHost === $baseHost) { + // Add to queue if not already visited or in queue + $cleanAbsolute = strtok($absoluteUrl, '#'); + if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) { + $inQueue = false; + foreach ($queue as $q) { + if ($q['url'] === $cleanAbsolute) { + $inQueue = true; + break; + } + } + if (!$inQueue) { + $queue[] = ['url' => $cleanAbsolute, 'depth' => $depth + 1]; + } + } + } + } + } + } catch (\Throwable $e) { + $io->writeln("FAIL (" . $e->getMessage() . ")"); + } + } + } else { + // Standard single page/file scan + $html = @file_get_contents($target); + if ($html === false || $html === '') { + $io->error("Could not read target: $target"); + return Command::FAILURE; + } + $violations = $scanner->scan($html); + $results[$target] = $violations; + } + + $totalViolations = 0; + foreach ($results as $url => $violations) { + $totalViolations += count($violations); + } + + if ($totalViolations === 0) { $io->success("No violations found!"); return Command::SUCCESS; } - $io->section("Found " . count($violations) . " violations"); + $io->section("Found a total of $totalViolations violations across " . count($results) . " pages"); - // 4. Setup Helpers + // 3. Setup Helpers $locator = new SourceLocator($projectPath); $config = new Config(); $fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null; - foreach ($violations as $violation) { - $io->writeln(" {$violation->ruleId} \t{$violation->message}"); - - // Show Snippet - $io->writeln(" Snippet: " . htmlspecialchars($violation->htmlSnippet) . ""); - - // Source Mapping - $location = $locator->locate($violation->htmlSnippet); - if ($location) { - $io->writeln(" Source: {$location['file']}:{$location['line']}"); + foreach ($results as $url => $violations) { + if (empty($violations)) { + continue; } + $io->section("Page: $url (" . count($violations) . " violations)"); - // AI Fixer - if ($enableAi && $fixer) { - $io->write(" AI Fix Suggestion: "); - $suggestion = $fixer->suggestFix($violation); - if ($suggestion) { - $violation->fixSuggestion = $suggestion; - $io->writeln("" . htmlspecialchars($suggestion) . ""); - } else { - $io->writeln("Could not generate suggestion (check API key)"); + foreach ($violations as $violation) { + $io->writeln(" {$violation->ruleId} \t{$violation->message}"); + + // Show Snippet + $io->writeln(" Snippet: " . htmlspecialchars($violation->htmlSnippet) . ""); + + // Source Mapping + $location = $locator->locate($violation->htmlSnippet); + if ($location) { + $io->writeln(" Source: {$location['file']}:{$location['line']}"); } - } - $io->writeln(""); + // AI Fixer + if ($enableAi && $fixer) { + $io->write(" AI Fix Suggestion: "); + $suggestion = $fixer->suggestFix($violation); + if ($suggestion) { + $violation->fixSuggestion = $suggestion; + $io->writeln("" . htmlspecialchars($suggestion) . ""); + } else { + $io->writeln("Could not generate suggestion (check API key)"); + } + } + + $io->writeln(""); + } } - $io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions."); + $io->warning("Scanned " . $totalViolations . " errors. Use --ai to get fix suggestions."); - // Export Report + // 4. Export Report $reportPath = $input->getOption('report'); if (is_string($reportPath) && $reportPath !== '') { $extension = pathinfo($reportPath, PATHINFO_EXTENSION); if ($extension === 'json') { - $data = array_map(fn($v) => $v->toArray(), $violations); - file_put_contents($reportPath, json_encode($data, JSON_PRETTY_PRINT)); + $serialized = []; + foreach ($results as $url => $violations) { + $serialized[$url] = array_map(fn($v) => $v->toArray(), $violations); + } + file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT)); } else { $reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter(); - file_put_contents($reportPath, $reporter->render($violations, $target)); + file_put_contents($reportPath, $reporter->render($results, $target)); } $io->success("Report saved to: $reportPath"); } diff --git a/src/Core/Scanner.php b/src/Core/Scanner.php index 5ef4b17..ca298c2 100644 --- a/src/Core/Scanner.php +++ b/src/Core/Scanner.php @@ -28,9 +28,10 @@ class Scanner return []; } - $doc = new \DOMDocument(); - // Suppress warnings for malformed HTML - @$doc->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + $html5 = new \Masterminds\HTML5([ + 'disable_html_ns' => true, + ]); + $doc = $html5->loadHTML($html); return $this->engine->run($doc); } diff --git a/src/Reporting/HtmlDashboardReporter.php b/src/Reporting/HtmlDashboardReporter.php index 1806c1d..a94dc54 100644 --- a/src/Reporting/HtmlDashboardReporter.php +++ b/src/Reporting/HtmlDashboardReporter.php @@ -7,14 +7,14 @@ use YakNet\AccessibilityConsole\Core\Violation; class HtmlDashboardReporter { /** - * @param Violation[] $violations + * @param array $results */ - public function render(array $violations, string $target): string + public function render(array $results, string $target): string { - $count = count($violations); + $totalPages = count($results); $date = date('Y-m-d H:i:s'); - // Calculate severity and standard counts + // Calculate severity, standard counts and overall average score $criticalCount = 0; $errorCount = 0; $warningCount = 0; @@ -24,110 +24,145 @@ class HtmlDashboardReporter $standardAA = 0; $standardAAA = 0; - foreach ($violations as $v) { - switch ($v->severity) { - case \YakNet\AccessibilityConsole\Core\Severity::CRITICAL: - $criticalCount++; - break; - case \YakNet\AccessibilityConsole\Core\Severity::ERROR: - $errorCount++; - break; - case \YakNet\AccessibilityConsole\Core\Severity::WARNING: - $warningCount++; - break; - case \YakNet\AccessibilityConsole\Core\Severity::INFO: - $infoCount++; - break; + $pageScores = []; + $totalViolations = 0; + + foreach ($results as $url => $violations) { + $totalViolations += count($violations); + $pageCrit = 0; + $pageErr = 0; + $pageWarn = 0; + $pageInfo = 0; + + foreach ($violations as $v) { + switch ($v->severity) { + case \YakNet\AccessibilityConsole\Core\Severity::CRITICAL: + $criticalCount++; + $pageCrit++; + break; + case \YakNet\AccessibilityConsole\Core\Severity::ERROR: + $errorCount++; + $pageErr++; + break; + case \YakNet\AccessibilityConsole\Core\Severity::WARNING: + $warningCount++; + $pageWarn++; + break; + case \YakNet\AccessibilityConsole\Core\Severity::INFO: + $infoCount++; + $pageInfo++; + break; + } + + switch ($v->standard) { + case \YakNet\AccessibilityConsole\Core\WCAGStandard::A: + $standardA++; + break; + case \YakNet\AccessibilityConsole\Core\WCAGStandard::AA: + $standardAA++; + break; + case \YakNet\AccessibilityConsole\Core\WCAGStandard::AAA: + $standardAAA++; + break; + } } - switch ($v->standard) { - case \YakNet\AccessibilityConsole\Core\WCAGStandard::A: - $standardA++; - break; - case \YakNet\AccessibilityConsole\Core\WCAGStandard::AA: - $standardAA++; - break; - case \YakNet\AccessibilityConsole\Core\WCAGStandard::AAA: - $standardAAA++; - break; - } + $deductions = ($pageCrit * 15) + ($pageErr * 10) + ($pageWarn * 4) + ($pageInfo * 1); + $pageScores[$url] = max(0, min(100, 100 - $deductions)); } - // Calculate health score (0-100) - $deductions = ($criticalCount * 15) + ($errorCount * 10) + ($warningCount * 4) + ($infoCount * 1); - $score = max(0, min(100, 100 - $deductions)); + $averageScore = $totalPages > 0 ? (int)round(array_sum($pageScores) / $totalPages) : 100; + + // Generate Page Selector options + $pageOptions = ""; + foreach ($results as $url => $violations) { + $escapedUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8'); + $vCount = count($violations); + $pageOptions .= ""; + } $cards = ''; - foreach ($violations as $index => $v) { - $severityClass = strtolower($v->severity->value); - $standardVal = $v->standard->value; - $aiSuggestion = $v->fixSuggestion; - $explanation = ''; - $fixedCode = ''; + $cardIndex = 0; + foreach ($results as $url => $violations) { + $escapedUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8'); + foreach ($violations as $v) { + $severityClass = strtolower($v->severity->value); + $standardVal = $v->standard->value; + $aiSuggestion = $v->fixSuggestion; + $explanation = ''; + $fixedCode = ''; - if ($aiSuggestion && str_contains($aiSuggestion, 'FIX:')) { - preg_match('/EXPLANATION:(.*)FIX:(.*)/s', $aiSuggestion, $matches); - $explanation = trim($matches[1] ?? ''); - $fixedCode = trim($matches[2] ?? ''); - $fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode); - } else { - $fixedCode = $aiSuggestion; + if ($aiSuggestion && str_contains($aiSuggestion, 'FIX:')) { + preg_match('/EXPLANATION:(.*)FIX:(.*)/s', $aiSuggestion, $matches); + $explanation = trim($matches[1] ?? ''); + $fixedCode = trim($matches[2] ?? ''); + $fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode); + } else { + $fixedCode = $aiSuggestion; + } + + $escapedSnippet = htmlspecialchars($v->htmlSnippet, ENT_QUOTES, 'UTF-8'); + $escapedFixed = $fixedCode ? htmlspecialchars($fixedCode, ENT_QUOTES, 'UTF-8') : ''; + $escapedExplanation = $explanation ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : ''; + $locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . $v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A'; + $escapedRuleId = htmlspecialchars($v->ruleId, ENT_QUOTES, 'UTF-8'); + $escapedMessage = htmlspecialchars($v->message, ENT_QUOTES, 'UTF-8'); + + $hasAiFix = !empty($fixedCode); + $aiTabButton = $hasAiFix ? "" : ""; + $aiTabPanel = $hasAiFix ? " + " : ""; + + $cards .= " +
+
+
+ {$escapedRuleId} + WCAG {$standardVal} + + + " . ucfirst($severityClass) . " + +
+
+ + {$locationStr} +
+
+
+

{$escapedMessage}

+ +
+
+ + {$aiTabButton} +
+
+
{$escapedSnippet}
+
+ {$aiTabPanel} +
+ +
+ + Kaynak Sayfa: {$escapedUrl} +
+
+
"; + + $cardIndex++; } - - $escapedSnippet = htmlspecialchars($v->htmlSnippet, ENT_QUOTES, 'UTF-8'); - $escapedFixed = $fixedCode ? htmlspecialchars($fixedCode, ENT_QUOTES, 'UTF-8') : ''; - $escapedExplanation = $explanation ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : ''; - $locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . $v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A'; - $escapedRuleId = htmlspecialchars($v->ruleId, ENT_QUOTES, 'UTF-8'); - $escapedMessage = htmlspecialchars($v->message, ENT_QUOTES, 'UTF-8'); - - $hasAiFix = !empty($fixedCode); - $aiTabButton = $hasAiFix ? "" : ""; - $aiTabPanel = $hasAiFix ? " - " : ""; - - $cards .= " -
-
-
- {$escapedRuleId} - WCAG {$standardVal} - - - " . ucfirst($severityClass) . " - -
-
- - {$locationStr} -
-
-
-

{$escapedMessage}

- -
-
- - {$aiTabButton} -
-
-
{$escapedSnippet}
-
- {$aiTabPanel} -
-
-
"; } return <<
-
Hedef: {$target}
+
Hedef Site: {$target}
Tarih: {$date}
@@ -882,20 +946,16 @@ class HtmlDashboardReporter
- WCAG A - {$standardA} + Taranan Sayfa + {$totalPages}
- WCAG AA - {$standardAA} -
-
- WCAG AAA - {$standardAAA} + WCAG A / AA / AAA + {$standardA} / {$standardAA} / {$standardAAA}
Toplam İhlal - {$count} + {$totalViolations}
@@ -909,6 +969,12 @@ class HtmlDashboardReporter + +
+ +
@@ -946,7 +1012,7 @@ class HtmlDashboardReporter