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
338 lines
14 KiB
PHP
338 lines
14 KiB
PHP
<?php
|
|
|
|
namespace YakNet\AccessibilityConsole\Syntax;
|
|
|
|
class HtmlSyntaxLinter
|
|
{
|
|
/** @var array<int, string> */
|
|
private static array $voidElements = [
|
|
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
'link', 'meta', 'param', 'source', 'track', 'wbr'
|
|
];
|
|
|
|
/**
|
|
* Lint raw HTML/template content for markup syntax errors and structural defects.
|
|
*
|
|
* @param string $html
|
|
* @return SyntaxIssue[]
|
|
*/
|
|
public function lint(string $html): array
|
|
{
|
|
if (trim($html) === '') {
|
|
return [];
|
|
}
|
|
|
|
$issues = [];
|
|
|
|
// 1. Check for unclosed comments
|
|
$this->checkUnclosedComments($html, $issues);
|
|
|
|
// 2. Check for duplicate attributes on same tag & malformed attribute syntax
|
|
$this->checkAttributes($html, $issues);
|
|
|
|
// 3. Check for invalid self-closing on non-void elements
|
|
$this->checkInvalidSelfClosing($html, $issues);
|
|
|
|
// 4. Check for unclosed / mismatched tags (structural validation)
|
|
$this->checkTagStructure($html, $issues);
|
|
|
|
// 5. Check multiple DOCTYPE / HEAD / BODY declarations
|
|
$this->checkStructureTagMultiplicity($html, $issues);
|
|
|
|
return $issues;
|
|
}
|
|
|
|
/**
|
|
* @param SyntaxIssue[] $issues
|
|
*/
|
|
private function checkUnclosedComments(string $html, array &$issues): void
|
|
{
|
|
$offset = 0;
|
|
while (($pos = strpos($html, '<!--', $offset)) !== false) {
|
|
$endPos = strpos($html, '-->', $pos + 4);
|
|
if ($endPos === false) {
|
|
$line = $this->getLineFromOffset($html, $pos);
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_UNCLOSED_COMMENT',
|
|
message: 'HTML comment is opened with <!-- but never closed with -->.',
|
|
line: $line,
|
|
snippet: mb_substr($html, $pos, 80),
|
|
fixSuggestion: 'Close the comment with -->.',
|
|
severity: 'error'
|
|
);
|
|
break;
|
|
}
|
|
$offset = $endPos + 3;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param SyntaxIssue[] $issues
|
|
*/
|
|
private function checkAttributes(string $html, array &$issues): void
|
|
{
|
|
// Match start tags <tagname attributes...>
|
|
$pattern = '/<([a-zA-Z0-9:-]+)(\s+[^>]*)?>/s';
|
|
if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) {
|
|
foreach ($matches[0] as $index => $match) {
|
|
$fullTag = $match[0];
|
|
$offset = $match[1];
|
|
$tagName = strtolower($matches[1][$index][0]);
|
|
|
|
if (str_starts_with($fullTag, '<?') || str_starts_with($fullTag, '<!')) {
|
|
continue;
|
|
}
|
|
|
|
$attrString = $matches[2][$index][0] ?? '';
|
|
if (trim($attrString) === '') {
|
|
continue;
|
|
}
|
|
|
|
$line = $this->getLineFromOffset($html, $offset);
|
|
|
|
// A. Check for missing space between attributes (e.g. href="x"class="y")
|
|
if (preg_match('/["\']([a-zA-Z0-9_:-]+)=["\']/', $attrString, $missingSpaceMatch, PREG_OFFSET_CAPTURE)) {
|
|
$mOffset = $offset + (int)$missingSpaceMatch[0][1];
|
|
$mLine = $this->getLineFromOffset($html, $mOffset);
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_MISSING_ATTR_SPACE',
|
|
message: "Missing whitespace separator between attributes on <{$tagName}>.",
|
|
line: $mLine,
|
|
snippet: $fullTag,
|
|
fixSuggestion: 'Insert a space between adjacent attributes.',
|
|
severity: 'warning'
|
|
);
|
|
}
|
|
|
|
// B. Parse individual attributes to check for duplicates
|
|
$attrNames = [];
|
|
$attrPattern = '/([a-zA-Z0-9_:-]+)(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?/s';
|
|
if (preg_match_all($attrPattern, $attrString, $attrMatches)) {
|
|
foreach ($attrMatches[1] as $aName) {
|
|
$normalizedName = strtolower(trim($aName));
|
|
if ($normalizedName === '' || $normalizedName === '/') {
|
|
continue;
|
|
}
|
|
|
|
if (isset($attrNames[$normalizedName])) {
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_DUPLICATE_ATTR',
|
|
message: "Duplicate attribute '{$normalizedName}' declared multiple times on <{$tagName}> element (HTML5 3.2.4 violation).",
|
|
line: $line,
|
|
snippet: $fullTag,
|
|
fixSuggestion: "Remove the duplicate '{$normalizedName}' attribute.",
|
|
severity: 'error'
|
|
);
|
|
} else {
|
|
$attrNames[$normalizedName] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param SyntaxIssue[] $issues
|
|
*/
|
|
private function checkInvalidSelfClosing(string $html, array &$issues): void
|
|
{
|
|
// Match <tagname ... /> where tagname is NOT in void elements list and not SVG/MathML
|
|
$pattern = '/<([a-zA-Z0-9-]+)(\s+[^>]*)?\/>/s';
|
|
if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) {
|
|
foreach ($matches[0] as $index => $match) {
|
|
$fullTag = $match[0];
|
|
$offset = $match[1];
|
|
$tagName = strtolower($matches[1][$index][0]);
|
|
|
|
if (in_array($tagName, self::$voidElements, true)) {
|
|
continue; // Allowed to self-close in HTML5/XHTML
|
|
}
|
|
|
|
// Skip SVG and MathML subtrees/elements
|
|
if (in_array($tagName, ['path', 'circle', 'rect', 'line', 'polygon', 'polyline', 'ellipse', 'use', 'stop'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$line = $this->getLineFromOffset($html, $offset);
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_INVALID_SELF_CLOSING',
|
|
message: "Self-closing syntax (<{$tagName} />) is invalid on non-void HTML element <{$tagName}>. It causes DOM tree distortion.",
|
|
line: $line,
|
|
snippet: $fullTag,
|
|
fixSuggestion: "Replace with explicit opening and closing tags: <{$tagName}></{$tagName}>.",
|
|
severity: 'error'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param SyntaxIssue[] $issues
|
|
*/
|
|
private function checkTagStructure(string $html, array &$issues): void
|
|
{
|
|
// Strip comments and script/style contents for structure check
|
|
$clean = preg_replace('/<!--.*?-->/s', '', $html) ?? $html;
|
|
$clean = preg_replace('/<script\b[^>]*>.*?<\/script>/si', '', $clean) ?? $clean;
|
|
$clean = preg_replace('/<style\b[^>]*>.*?<\/style>/si', '', $clean) ?? $clean;
|
|
$clean = preg_replace('/<\?php.*?\?>/si', '', $clean) ?? $clean;
|
|
|
|
// Find all tags in order: <tag> or </tag>
|
|
$tagPattern = '/<(\/)?([a-zA-Z0-9:-]+)(?:\s+[^>]*)?>/s';
|
|
if (preg_match_all($tagPattern, $clean, $matches, PREG_OFFSET_CAPTURE)) {
|
|
$stack = [];
|
|
$trackedTags = [
|
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
'details', 'summary', 'dialog', 'main', 'nav', 'header', 'footer',
|
|
'section', 'article', 'aside', 'form', 'fieldset', 'div', 'span', 'p', 'a', 'button'
|
|
];
|
|
|
|
foreach ($matches[0] as $index => $match) {
|
|
$fullMatch = $match[0];
|
|
$offset = $match[1];
|
|
$isClosing = ($matches[1][$index][0] === '/');
|
|
$tagName = strtolower($matches[2][$index][0]);
|
|
|
|
if (in_array($tagName, self::$voidElements, true)) {
|
|
continue;
|
|
}
|
|
|
|
if (str_starts_with($fullMatch, '<?') || str_starts_with($fullMatch, '<!')) {
|
|
continue;
|
|
}
|
|
|
|
// Check self-closing syntax
|
|
if (str_ends_with(trim($fullMatch), '/>')) {
|
|
continue;
|
|
}
|
|
|
|
if (!in_array($tagName, $trackedTags, true)) {
|
|
continue;
|
|
}
|
|
|
|
if (!$isClosing) {
|
|
$stack[] = ['tag' => $tagName, 'offset' => $offset, 'line' => $this->getLineFromOffset($clean, $offset)];
|
|
} else {
|
|
if (empty($stack)) {
|
|
$line = $this->getLineFromOffset($clean, $offset);
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_UNOPENED_TAG',
|
|
message: "Closing tag </{$tagName}> has no matching opening tag.",
|
|
line: $line,
|
|
snippet: $fullMatch,
|
|
fixSuggestion: "Remove the stray </{$tagName}> closing tag.",
|
|
severity: 'error'
|
|
);
|
|
} else {
|
|
$last = end($stack);
|
|
if ($last['tag'] === $tagName) {
|
|
array_pop($stack);
|
|
} else {
|
|
// Find if $tagName exists deeper in the stack
|
|
$foundIndex = null;
|
|
for ($i = count($stack) - 1; $i >= 0; $i--) {
|
|
if ($stack[$i]['tag'] === $tagName) {
|
|
$foundIndex = $i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($foundIndex !== null) {
|
|
$unclosed = array_slice($stack, $foundIndex + 1);
|
|
$stack = array_slice($stack, 0, $foundIndex);
|
|
$line = $this->getLineFromOffset($clean, $offset);
|
|
foreach ($unclosed as $u) {
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_MISMATCHED_TAG',
|
|
message: "Mismatched closing tag </{$tagName}>; <{$u['tag']}> opened at line {$u['line']} was not closed properly.",
|
|
line: $line,
|
|
snippet: $fullMatch,
|
|
fixSuggestion: "Close <{$u['tag']}> before </{$tagName}>.",
|
|
severity: 'error'
|
|
);
|
|
}
|
|
} else {
|
|
$line = $this->getLineFromOffset($clean, $offset);
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_UNOPENED_TAG',
|
|
message: "Closing tag </{$tagName}> has no matching opening tag.",
|
|
line: $line,
|
|
snippet: $fullMatch,
|
|
fixSuggestion: "Remove the stray </{$tagName}> closing tag.",
|
|
severity: 'error'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check any unclosed structural tags left on stack
|
|
foreach ($stack as $unclosed) {
|
|
// Ignore optional closing tags like p or li if unclosed at document end
|
|
if (in_array($unclosed['tag'], ['p', 'li', 'dt', 'dd', 'span'], true)) {
|
|
continue;
|
|
}
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_UNCLOSED_TAG',
|
|
message: "Unclosed tag <{$unclosed['tag']}> opened at line {$unclosed['line']} was never closed.",
|
|
line: $unclosed['line'],
|
|
snippet: "<{$unclosed['tag']}>",
|
|
fixSuggestion: "Add matching closing tag </{$unclosed['tag']}>.",
|
|
severity: 'error'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param SyntaxIssue[] $issues
|
|
*/
|
|
private function checkStructureTagMultiplicity(string $html, array &$issues): void
|
|
{
|
|
// 1. Multiple <body> tags
|
|
if (preg_match_all('/<body\b[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) {
|
|
if (count($matches[0]) > 1) {
|
|
foreach (array_slice($matches[0], 1) as $match) {
|
|
$line = $this->getLineFromOffset($html, $match[1]);
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_MULTIPLE_BODY',
|
|
message: 'Document contains multiple <body> elements. Only one <body> is permitted per HTML document.',
|
|
line: $line,
|
|
snippet: $match[0],
|
|
fixSuggestion: 'Merge contents into a single <body> element.',
|
|
severity: 'error'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Multiple <head> tags
|
|
if (preg_match_all('/<head\b[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) {
|
|
if (count($matches[0]) > 1) {
|
|
foreach (array_slice($matches[0], 1) as $match) {
|
|
$line = $this->getLineFromOffset($html, $match[1]);
|
|
$issues[] = new SyntaxIssue(
|
|
code: 'SYNTAX_MULTIPLE_HEAD',
|
|
message: 'Document contains multiple <head> elements. Only one <head> is permitted per HTML document.',
|
|
line: $line,
|
|
snippet: $match[0],
|
|
fixSuggestion: 'Merge head tags into a single <head> element.',
|
|
severity: 'error'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function getLineFromOffset(string $content, int $offset): int
|
|
{
|
|
if ($offset <= 0) {
|
|
return 1;
|
|
}
|
|
$sub = substr($content, 0, $offset);
|
|
return substr_count($sub, "\n") + 1;
|
|
}
|
|
}
|