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
feat: Release v3.0.0 - PHPStan-style analysis, configuration, baseline, and advanced WCAG rules
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
|
||||
class Analyser
|
||||
{
|
||||
private Config $config;
|
||||
private Scanner $scanner;
|
||||
private BaselineManager $baselineManager;
|
||||
private string $projectRoot;
|
||||
|
||||
public function __construct(?Config $config = null, ?Scanner $scanner = null, ?BaselineManager $baselineManager = null)
|
||||
{
|
||||
$this->config = $config ?? new Config();
|
||||
$this->scanner = $scanner ?? $this->setupScanner();
|
||||
$this->baselineManager = $baselineManager ?? new BaselineManager();
|
||||
|
||||
$cwd = getcwd();
|
||||
$this->projectRoot = $cwd ? str_replace('\\', '/', realpath($cwd) ?: $cwd) : '.';
|
||||
|
||||
$baselinePath = $this->config->getBaselinePath();
|
||||
if ($baselinePath) {
|
||||
$this->baselineManager->load($this->projectRoot . '/' . $baselinePath);
|
||||
}
|
||||
}
|
||||
|
||||
private function setupScanner(): Scanner
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$rules = RuleLevels::getRulesForLevel($this->config->getLevel());
|
||||
$rulesConfig = $this->config->getRulesConfig();
|
||||
|
||||
$activeRules = [];
|
||||
foreach ($rules as $rule) {
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: (new \ReflectionClass($rule))->getShortName();
|
||||
|
||||
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
|
||||
continue;
|
||||
}
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
|
||||
// Include custom rules
|
||||
foreach ($rulesConfig['include'] as $customRuleClass) {
|
||||
if (class_exists($customRuleClass)) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($customRuleClass);
|
||||
if ($reflection->isInstantiable()) {
|
||||
$rule = $reflection->newInstance();
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: $reflection->getShortName();
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore instantiation failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activeRules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
return $scanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse files and return a list of violations.
|
||||
*
|
||||
* @param array<int, string>|null $pathsOverride
|
||||
* @param callable|null $onProgress Function called for progress tracking: fn(string $file, int $index, int $total)
|
||||
* @return array{violations: Violation[], baselinedCount: int}
|
||||
*/
|
||||
public function analyse(?array $pathsOverride = null, ?callable $onProgress = null): array
|
||||
{
|
||||
$paths = $pathsOverride ?? $this->config->getPaths();
|
||||
$excludePaths = $this->config->getExcludePaths();
|
||||
|
||||
// Resolve all files to scan
|
||||
$filesToScan = [];
|
||||
foreach ($paths as $path) {
|
||||
if (is_file($path)) {
|
||||
$filesToScan[] = str_replace('\\', '/', realpath($path) ?: $path);
|
||||
} elseif (is_dir($path)) {
|
||||
$files = $this->findFiles($path, $excludePaths);
|
||||
$filesToScan = array_merge($filesToScan, $files);
|
||||
}
|
||||
}
|
||||
|
||||
$filesToScan = array_unique($filesToScan);
|
||||
$totalFiles = count($filesToScan);
|
||||
|
||||
$violations = [];
|
||||
$baselinedCount = 0;
|
||||
|
||||
foreach ($filesToScan as $index => $file) {
|
||||
if ($onProgress) {
|
||||
$onProgress($file, $index + 1, $totalFiles);
|
||||
}
|
||||
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false || trim($content) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan file content
|
||||
try {
|
||||
$fileViolations = $this->scanner->scan($content);
|
||||
} catch (\Throwable $e) {
|
||||
// Skip if parsing failed dramatically
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativeFile = $this->makePathRelative($file);
|
||||
|
||||
foreach ($fileViolations as $violation) {
|
||||
// Find line number using line-by-line helper
|
||||
$line = $this->locateLineInContent($content, $violation->htmlSnippet);
|
||||
|
||||
// Store location in violation
|
||||
$violation->location = [
|
||||
'file' => $relativeFile,
|
||||
'line' => $line
|
||||
];
|
||||
|
||||
// Check if ignored in baseline
|
||||
if ($this->baselineManager->isIgnored($relativeFile, $violation->ruleId, $violation->htmlSnippet)) {
|
||||
$baselinedCount++;
|
||||
} else {
|
||||
$violations[] = $violation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'violations' => $violations,
|
||||
'baselinedCount' => $baselinedCount
|
||||
];
|
||||
}
|
||||
|
||||
public function getBaselineManager(): BaselineManager
|
||||
{
|
||||
return $this->baselineManager;
|
||||
}
|
||||
|
||||
public function makePathRelative(string $absolutePath): string
|
||||
{
|
||||
$absolutePath = str_replace('\\', '/', realpath($absolutePath) ?: $absolutePath);
|
||||
if ($this->projectRoot !== '.' && str_starts_with($absolutePath, $this->projectRoot)) {
|
||||
$relative = ltrim(substr($absolutePath, strlen($this->projectRoot)), '/');
|
||||
return $relative === '' ? '.' : $relative;
|
||||
}
|
||||
return $absolutePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $excludePaths
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function findFiles(string $dir, array $excludePaths): array
|
||||
{
|
||||
$realDir = realpath($dir);
|
||||
if ($realDir === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
try {
|
||||
$directory = new RecursiveDirectoryIterator($realDir, \FilesystemIterator::SKIP_DOTS);
|
||||
$filter = new \RecursiveCallbackFilterIterator($directory, function ($current) use ($excludePaths) {
|
||||
/** @var SplFileInfo $current */
|
||||
$filename = $current->getFilename();
|
||||
$pathname = str_replace('\\', '/', $current->getPathname());
|
||||
|
||||
// Exclude common directories/files
|
||||
foreach ($excludePaths as $exclude) {
|
||||
$normalizedExclude = str_replace('\\', '/', $exclude);
|
||||
if ($filename === $normalizedExclude || str_contains($pathname, '/' . $normalizedExclude . '/')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
|
||||
|
||||
$extensions = ['php', 'html', 'twig', 'blade.php'];
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
/** @var SplFileInfo $file */
|
||||
if ($file->isDir()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filename = $file->getFilename();
|
||||
$isValidExt = false;
|
||||
foreach ($extensions as $ext) {
|
||||
if (str_ends_with($filename, '.' . $ext) || ($ext === 'blade.php' && str_ends_with($filename, '.blade.php'))) {
|
||||
$isValidExt = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isValidExt) {
|
||||
$files[] = str_replace('\\', '/', $file->getPathname());
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore directory iteration errors
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function locateLineInContent(string $content, string $snippet): int
|
||||
{
|
||||
$replaced = preg_replace('/\s+/', ' ', $snippet);
|
||||
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
|
||||
if ($cleanSnippet === '') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$searchSnippet = mb_strlen($cleanSnippet) > 150 ? mb_substr($cleanSnippet, 0, 150) : $cleanSnippet;
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
// 1. Exact match pass
|
||||
foreach ($lines as $index => $line) {
|
||||
$replacedLine = preg_replace('/\s+/', ' ', $line);
|
||||
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
|
||||
if (str_contains($cleanLine, $searchSnippet)) {
|
||||
return $index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse snippet to find tag and attributes for fuzzy match pass
|
||||
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
|
||||
$tagName = $matches[1] ?? null;
|
||||
if (!$tagName) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
preg_match_all('/([a-zA-Z0-9-]+)=["\']([^"\']*)["\']/', $snippet, $attrMatches, PREG_SET_ORDER);
|
||||
$attributes = [];
|
||||
foreach ($attrMatches as $match) {
|
||||
$attributes[$match[1]] = $match[2];
|
||||
}
|
||||
|
||||
$bestLine = 1;
|
||||
$maxScore = 0;
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
$replacedLine = preg_replace('/\s+/', ' ', $line);
|
||||
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
|
||||
|
||||
if (!str_contains(strtolower($cleanLine), '<' . strtolower($tagName))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = 1.0;
|
||||
if (in_array(strtolower($tagName), ['html', 'head', 'body', 'title'], true)) {
|
||||
$score += 1.0;
|
||||
}
|
||||
|
||||
foreach ($attributes as $name => $value) {
|
||||
if (str_contains($cleanLine, "$name=\"$value\"") || str_contains($cleanLine, "$name='$value'")) {
|
||||
$score += 2.0;
|
||||
} elseif (str_contains($cleanLine, $name . '=')) {
|
||||
$score += 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
if ($score > $maxScore) {
|
||||
$maxScore = $score;
|
||||
$bestLine = $index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $bestLine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
class BaselineManager
|
||||
{
|
||||
/** @var array<string, bool> */
|
||||
private array $ignoredKeys = [];
|
||||
|
||||
public function load(string $baselinePath): bool
|
||||
{
|
||||
if (!file_exists($baselinePath) || !is_readable($baselinePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = file_get_contents($baselinePath);
|
||||
if ($content === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($data['violations'] as $item) {
|
||||
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) {
|
||||
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
|
||||
$this->ignoredKeys[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Violation[] $violations
|
||||
*/
|
||||
public function generate(array $violations, string $baselinePath): bool
|
||||
{
|
||||
$serialized = [
|
||||
'generator' => 'YakNet Accessibility Console',
|
||||
'violations' => []
|
||||
];
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$file = $v->location['file'] ?? '';
|
||||
$serialized['violations'][] = [
|
||||
'file' => $file,
|
||||
'ruleId' => $v->ruleId,
|
||||
'htmlSnippet' => $v->htmlSnippet
|
||||
];
|
||||
}
|
||||
|
||||
// Sort baseline violations by file and ruleId for cleaner git diffs
|
||||
usort($serialized['violations'], function ($a, $b) {
|
||||
$fileCmp = strcmp($a['file'], $b['file']);
|
||||
if ($fileCmp !== 0) {
|
||||
return $fileCmp;
|
||||
}
|
||||
return strcmp($a['ruleId'], $b['ruleId']);
|
||||
});
|
||||
|
||||
$json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dir = dirname($baselinePath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
return file_put_contents($baselinePath, $json) !== false;
|
||||
}
|
||||
|
||||
public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool
|
||||
{
|
||||
$key = $this->calculateKey($file, $ruleId, $htmlSnippet);
|
||||
return isset($this->ignoredKeys[$key]);
|
||||
}
|
||||
|
||||
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) {
|
||||
$normalizedSnippet = trim($htmlSnippet);
|
||||
}
|
||||
|
||||
// Normalize file separator to forward slashes for cross-platform compatibility
|
||||
$normalizedFile = str_replace('\\', '/', $file);
|
||||
|
||||
return md5($normalizedFile . '::' . $ruleId . '::' . $normalizedSnippet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
use HeadlessChromium\BrowserFactory;
|
||||
use HeadlessChromium\Navigation;
|
||||
|
||||
class ChromeRenderer
|
||||
{
|
||||
/**
|
||||
* Navigate to URL using headless Chrome and return fully rendered HTML.
|
||||
*/
|
||||
public function render(string $url): string
|
||||
{
|
||||
$browserFactory = new BrowserFactory();
|
||||
|
||||
$options = [
|
||||
'windowSize' => [1280, 1024],
|
||||
'noSandbox' => true,
|
||||
];
|
||||
|
||||
// Windows Chrome binary path auto-detection override
|
||||
$chromeBinary = $_ENV['CHROME_BINARY'] ?? getenv('CHROME_BINARY') ?: null;
|
||||
if (!$chromeBinary && PHP_OS_FAMILY === 'Windows') {
|
||||
$standardPaths = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe'
|
||||
];
|
||||
foreach ($standardPaths as $path) {
|
||||
if (file_exists($path)) {
|
||||
$chromeBinary = $path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($chromeBinary) {
|
||||
$options['chromeBinary'] = $chromeBinary;
|
||||
}
|
||||
|
||||
$browser = $browserFactory->createBrowser($options);
|
||||
|
||||
try {
|
||||
$page = $browser->createPage();
|
||||
|
||||
// Navigate and wait for onload event
|
||||
$navigation = $page->navigate($url);
|
||||
$navigation->waitForNavigation(\HeadlessChromium\Page::LOAD, 10000); // 10 seconds timeout
|
||||
|
||||
// Extra wait for async JS rendering to complete
|
||||
usleep(1500000); // 1.5s
|
||||
|
||||
$html = $page->getHtml();
|
||||
} finally {
|
||||
$browser->close();
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
+96
-1
@@ -13,11 +13,34 @@ class Config
|
||||
public function __construct(array $settings = [])
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->loadDotenv();
|
||||
}
|
||||
|
||||
private function loadDotenv(): void
|
||||
{
|
||||
$cwd = getcwd();
|
||||
if ($cwd && file_exists($cwd . '/.env')) {
|
||||
try {
|
||||
$dotenv = \Dotenv\Dotenv::createImmutable($cwd);
|
||||
$dotenv->safeLoad();
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore Dotenv errors silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromYaml(string $path): self
|
||||
{
|
||||
// Placeholder for YAML loading logic
|
||||
if (file_exists($path)) {
|
||||
try {
|
||||
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
|
||||
if (is_array($content)) {
|
||||
return new self($content);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
@@ -26,6 +49,78 @@ class Config
|
||||
return $this->settings[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getPaths(): array
|
||||
{
|
||||
$paths = $this->get('paths');
|
||||
if (is_array($paths)) {
|
||||
return $paths;
|
||||
}
|
||||
if (is_string($paths)) {
|
||||
return [$paths];
|
||||
}
|
||||
return ['.']; // default to current directory
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getExcludePaths(): array
|
||||
{
|
||||
$exclude = $this->get('exclude_paths');
|
||||
if (is_array($exclude)) {
|
||||
return $exclude;
|
||||
}
|
||||
if (is_string($exclude)) {
|
||||
return [$exclude];
|
||||
}
|
||||
return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults
|
||||
}
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
$level = $this->get('level', 4);
|
||||
return is_numeric($level) ? (int)$level : 4;
|
||||
}
|
||||
|
||||
public function getBaselinePath(): ?string
|
||||
{
|
||||
$baseline = $this->get('baseline');
|
||||
return is_string($baseline) ? $baseline : null;
|
||||
}
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
$format = $this->get('format', 'console');
|
||||
return is_string($format) ? $format : 'console';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{include: array<int, string>, exclude: array<int, string>}
|
||||
*/
|
||||
public function getRulesConfig(): array
|
||||
{
|
||||
$rules = $this->get('rules', []);
|
||||
$include = [];
|
||||
$exclude = [];
|
||||
|
||||
if (is_array($rules)) {
|
||||
if (isset($rules['include']) && is_array($rules['include'])) {
|
||||
$include = $rules['include'];
|
||||
}
|
||||
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
|
||||
$exclude = $rules['exclude'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'include' => $include,
|
||||
'exclude' => $exclude
|
||||
];
|
||||
}
|
||||
|
||||
public function getGeminiApiKey(): ?string
|
||||
{
|
||||
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
|
||||
|
||||
Reference in New Issue
Block a user