Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
253
src/Framework/Design/Analyzer/ColorAnalysisResult.php
Normal file
253
src/Framework/Design/Analyzer/ColorAnalysisResult.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
/**
|
||||
* Ergebnis der Farbanalyse
|
||||
*/
|
||||
final readonly class ColorAnalysisResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $totalColors,
|
||||
public array $colorsByFormat,
|
||||
public array $colorPalette,
|
||||
public array $contrastAnalysis,
|
||||
public array $duplicateColors,
|
||||
public array $recommendations
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Farbdiversität-Score zurück (0-100)
|
||||
*/
|
||||
public function getDiversityScore(): int
|
||||
{
|
||||
if ($this->totalColors === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$categoryDistribution = [
|
||||
'primary' => count($this->colorPalette['primary_colors']),
|
||||
'neutral' => count($this->colorPalette['neutral_colors']),
|
||||
'accent' => count($this->colorPalette['accent_colors']),
|
||||
'semantic' => count($this->colorPalette['semantic_colors']),
|
||||
];
|
||||
|
||||
$nonZeroCategories = count(array_filter($categoryDistribution));
|
||||
$maxCategories = 4;
|
||||
|
||||
// Basis-Score für Kategorien-Abdeckung
|
||||
$categoryScore = ($nonZeroCategories / $maxCategories) * 50;
|
||||
|
||||
// Bonus für ausgewogene Verteilung
|
||||
$total = array_sum($categoryDistribution);
|
||||
if ($total > 0) {
|
||||
$entropy = 0;
|
||||
foreach ($categoryDistribution as $count) {
|
||||
if ($count > 0) {
|
||||
$probability = $count / $total;
|
||||
$entropy -= $probability * log($probability, 2);
|
||||
}
|
||||
}
|
||||
$maxEntropy = log($nonZeroCategories, 2);
|
||||
$balanceScore = $maxEntropy > 0 ? ($entropy / $maxEntropy) * 50 : 0;
|
||||
} else {
|
||||
$balanceScore = 0;
|
||||
}
|
||||
|
||||
return min(100, (int) round($categoryScore + $balanceScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Konsistenz-Score zurück (0-100)
|
||||
*/
|
||||
public function getConsistencyScore(): int
|
||||
{
|
||||
if ($this->totalColors === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$score = 100;
|
||||
|
||||
// Abzug für Duplikate
|
||||
$totalDuplicates = array_sum(array_column($this->duplicateColors, 'potential_savings'));
|
||||
$duplicatePercentage = ($totalDuplicates / max(1, $this->totalColors)) * 100;
|
||||
$score -= min(30, $duplicatePercentage * 2);
|
||||
|
||||
// Abzug für zu viele Formate
|
||||
$formatCount = count($this->colorsByFormat);
|
||||
if ($formatCount > 3) {
|
||||
$score -= ($formatCount - 3) * 10;
|
||||
}
|
||||
|
||||
return max(0, (int) $score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Kontrast-Compliance-Score zurück (0-100)
|
||||
*/
|
||||
public function getContrastComplianceScore(): int
|
||||
{
|
||||
if (empty($this->contrastAnalysis)) {
|
||||
return 100; // Kein Kontrast zu prüfen
|
||||
}
|
||||
|
||||
$totalPairs = count($this->contrastAnalysis);
|
||||
$wcagAAPassing = count(array_filter($this->contrastAnalysis, fn ($c) => $c['wcag_aa']));
|
||||
|
||||
return (int) round(($wcagAAPassing / $totalPairs) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die häufigsten Farbformate zurück
|
||||
*/
|
||||
public function getMostUsedFormats(): array
|
||||
{
|
||||
$formatUsage = [];
|
||||
|
||||
foreach ($this->colorsByFormat as $format => $colors) {
|
||||
$formatUsage[$format] = count($colors);
|
||||
}
|
||||
|
||||
arsort($formatUsage);
|
||||
|
||||
return $formatUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Farbpaletten-Zusammenfassung zurück
|
||||
*/
|
||||
public function getPaletteSummary(): array
|
||||
{
|
||||
return [
|
||||
'total_colors' => $this->totalColors,
|
||||
'primary_colors' => count($this->colorPalette['primary_colors']),
|
||||
'neutral_colors' => count($this->colorPalette['neutral_colors']),
|
||||
'accent_colors' => count($this->colorPalette['accent_colors']),
|
||||
'semantic_colors' => count($this->colorPalette['semantic_colors']),
|
||||
'diversity_score' => $this->getDiversityScore(),
|
||||
'consistency_score' => $this->getConsistencyScore(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die problematischsten Farbkombinationen zurück
|
||||
*/
|
||||
public function getWorstContrastPairs(): array
|
||||
{
|
||||
$lowContrastPairs = array_filter(
|
||||
$this->contrastAnalysis,
|
||||
fn ($contrast) => ! $contrast['wcag_aa']
|
||||
);
|
||||
|
||||
// Sortiere nach niedrigstem Kontrast
|
||||
usort($lowContrastPairs, fn ($a, $b) => $a['contrast_ratio'] <=> $b['contrast_ratio']);
|
||||
|
||||
return array_slice($lowContrastPairs, 0, 5); // Top 5 problematischste
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt priorisierte Verbesserungs-Aktionen zurück
|
||||
*/
|
||||
public function getPrioritizedActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
// Hohe Priorität: Accessibility-Probleme
|
||||
$badContrastPairs = $this->getWorstContrastPairs();
|
||||
if (! empty($badContrastPairs)) {
|
||||
$actions[] = [
|
||||
'priority' => 'high',
|
||||
'category' => 'accessibility',
|
||||
'action' => 'Fix ' . count($badContrastPairs) . ' low-contrast color combinations',
|
||||
'impact' => 'WCAG compliance and readability',
|
||||
];
|
||||
}
|
||||
|
||||
// Mittlere Priorität: Duplikate
|
||||
if (! empty($this->duplicateColors)) {
|
||||
$totalDuplicates = array_sum(array_column($this->duplicateColors, 'potential_savings'));
|
||||
$actions[] = [
|
||||
'priority' => 'medium',
|
||||
'category' => 'optimization',
|
||||
'action' => "Consolidate {$totalDuplicates} duplicate colors",
|
||||
'impact' => 'CSS size reduction and maintenance',
|
||||
];
|
||||
}
|
||||
|
||||
// Niedrige Priorität: Palette-Optimierung
|
||||
if ($this->getDiversityScore() < 60) {
|
||||
$actions[] = [
|
||||
'priority' => 'low',
|
||||
'category' => 'design_system',
|
||||
'action' => 'Improve color palette diversity and semantic naming',
|
||||
'impact' => 'Design system consistency',
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Format-Empfehlungen zurück
|
||||
*/
|
||||
public function getFormatRecommendations(): array
|
||||
{
|
||||
$recommendations = [];
|
||||
$formats = $this->getMostUsedFormats();
|
||||
|
||||
// Zu viele Formate
|
||||
if (count($formats) > 3) {
|
||||
$recommendations[] = [
|
||||
'type' => 'standardization',
|
||||
'message' => 'Multiple color formats detected. Consider standardizing on 2-3 formats.',
|
||||
'suggestion' => 'Use hex for static colors, custom properties for theme colors, OKLCH for advanced color spaces.',
|
||||
];
|
||||
}
|
||||
|
||||
// OKLCH Empfehlung für größere Paletten
|
||||
if (! isset($formats['oklch']) && $this->totalColors > 15) {
|
||||
$recommendations[] = [
|
||||
'type' => 'modern_format',
|
||||
'message' => 'Consider OKLCH format for better color consistency and interpolation.',
|
||||
'suggestion' => 'OKLCH provides more perceptually uniform color space, especially useful for generated color scales.',
|
||||
];
|
||||
}
|
||||
|
||||
// Custom Properties Empfehlung
|
||||
if (! isset($formats['custom_property']) && $this->totalColors > 8) {
|
||||
$recommendations[] = [
|
||||
'type' => 'design_tokens',
|
||||
'message' => 'Consider using CSS custom properties for theme colors.',
|
||||
'suggestion' => 'Custom properties enable theming, better maintainability, and runtime color changes.',
|
||||
];
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_colors' => $this->totalColors,
|
||||
'colors_by_format' => $this->colorsByFormat,
|
||||
'color_palette' => $this->colorPalette,
|
||||
'contrast_analysis' => $this->contrastAnalysis,
|
||||
'duplicate_colors' => $this->duplicateColors,
|
||||
'recommendations' => $this->recommendations,
|
||||
'diversity_score' => $this->getDiversityScore(),
|
||||
'consistency_score' => $this->getConsistencyScore(),
|
||||
'contrast_compliance_score' => $this->getContrastComplianceScore(),
|
||||
'palette_summary' => $this->getPaletteSummary(),
|
||||
'worst_contrast_pairs' => $this->getWorstContrastPairs(),
|
||||
'prioritized_actions' => $this->getPrioritizedActions(),
|
||||
'format_recommendations' => $this->getFormatRecommendations(),
|
||||
'most_used_formats' => $this->getMostUsedFormats(),
|
||||
];
|
||||
}
|
||||
}
|
||||
610
src/Framework/Design/Analyzer/ColorAnalyzer.php
Normal file
610
src/Framework/Design/Analyzer/ColorAnalyzer.php
Normal file
@@ -0,0 +1,610 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Core\ValueObjects\RGBColor;
|
||||
use App\Framework\Design\ValueObjects\CssColor;
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
use App\Framework\Design\ValueObjects\CssPropertyCategory;
|
||||
|
||||
/**
|
||||
* Analysiert Farben im Design System
|
||||
*/
|
||||
final readonly class ColorAnalyzer
|
||||
{
|
||||
/**
|
||||
* Analysiert alle Farben im CSS
|
||||
*/
|
||||
public function analyzeColors(CssParseResult $parseResult): ColorAnalysisResult
|
||||
{
|
||||
// Sammle alle Farben
|
||||
$colors = $this->collectAllColors($parseResult);
|
||||
|
||||
if (empty($colors)) {
|
||||
return new ColorAnalysisResult(
|
||||
totalColors: 0,
|
||||
colorsByFormat: [],
|
||||
colorPalette: [],
|
||||
contrastAnalysis: [],
|
||||
duplicateColors: [],
|
||||
recommendations: ['No colors found in CSS. Consider implementing a color system with design tokens.']
|
||||
);
|
||||
}
|
||||
|
||||
// Analysiere Farbpalette
|
||||
$palette = $this->generateColorPalette($colors);
|
||||
|
||||
// Gruppiere nach Format
|
||||
$colorsByFormat = $this->groupColorsByFormat($colors);
|
||||
|
||||
// Analysiere Kontraste (vereinfacht)
|
||||
$contrastAnalysis = $this->analyzeContrasts($colors);
|
||||
|
||||
// Finde Duplikate
|
||||
$duplicates = $this->findDuplicateColors($colors);
|
||||
|
||||
// Generiere Empfehlungen
|
||||
$recommendations = $this->generateRecommendations($colors, $palette, $duplicates, $colorsByFormat);
|
||||
|
||||
return new ColorAnalysisResult(
|
||||
totalColors: count($colors),
|
||||
colorsByFormat: $colorsByFormat,
|
||||
colorPalette: $palette,
|
||||
contrastAnalysis: $contrastAnalysis,
|
||||
duplicateColors: $duplicates,
|
||||
recommendations: $recommendations
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert spezifische Farbharmonien
|
||||
*/
|
||||
public function analyzeColorHarmony(array $colors): array
|
||||
{
|
||||
$harmony = [
|
||||
'monochromatic' => [],
|
||||
'complementary' => [],
|
||||
'triadic' => [],
|
||||
'analogous' => [],
|
||||
'split_complementary' => [],
|
||||
];
|
||||
|
||||
foreach ($colors as $color) {
|
||||
$rgb = $color['color']->toRGB();
|
||||
if (! $rgb) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hsl = $this->rgbToHsl($rgb);
|
||||
$hue = $hsl['h'];
|
||||
|
||||
// Finde harmonische Farben
|
||||
foreach ($colors as $otherColor) {
|
||||
if ($color === $otherColor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$otherRgb = $otherColor['color']->toRGB();
|
||||
if (! $otherRgb) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$otherHsl = $this->rgbToHsl($otherRgb);
|
||||
$otherHue = $otherHsl['h'];
|
||||
|
||||
$hueDiff = abs($hue - $otherHue);
|
||||
$hueDiff = min($hueDiff, 360 - $hueDiff); // Zirkuläre Distanz
|
||||
|
||||
// Klassifiziere Harmonie-Typ
|
||||
if ($hueDiff < 30) {
|
||||
$harmony['analogous'][] = [$color, $otherColor];
|
||||
} elseif ($hueDiff > 150 && $hueDiff < 210) {
|
||||
$harmony['complementary'][] = [$color, $otherColor];
|
||||
} elseif (abs($hueDiff - 120) < 30 || abs($hueDiff - 240) < 30) {
|
||||
$harmony['triadic'][] = [$color, $otherColor];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $harmony;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet Farbzugänglichkeit
|
||||
*/
|
||||
public function analyzeAccessibility(CssParseResult $parseResult): array
|
||||
{
|
||||
$accessibilityIssues = [];
|
||||
$colorProperties = $parseResult->getPropertiesByCategory(CssPropertyCategory::COLOR);
|
||||
|
||||
// Sammle Text/Background-Kombinationen
|
||||
$textBackgroundPairs = [];
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
$textColor = null;
|
||||
$backgroundColor = null;
|
||||
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->name === 'color') {
|
||||
$textColor = $property->toColor();
|
||||
} elseif ($property->name === 'background-color') {
|
||||
$backgroundColor = $property->toColor();
|
||||
}
|
||||
}
|
||||
|
||||
if ($textColor && $backgroundColor) {
|
||||
$contrast = $this->calculateContrast($textColor, $backgroundColor);
|
||||
|
||||
if ($contrast < 4.5) { // WCAG AA Standard
|
||||
$accessibilityIssues[] = [
|
||||
'type' => 'low_contrast',
|
||||
'severity' => $contrast < 3.0 ? 'high' : 'medium',
|
||||
'contrast_ratio' => round($contrast, 2),
|
||||
'text_color' => $textColor->originalValue,
|
||||
'background_color' => $backgroundColor->originalValue,
|
||||
'selectors' => $rule->getSelectorStrings(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analysiere reine Farbkommunikation
|
||||
$colorOnlyIndicators = $this->findColorOnlyIndicators($parseResult);
|
||||
|
||||
return [
|
||||
'contrast_issues' => $accessibilityIssues,
|
||||
'color_only_indicators' => $colorOnlyIndicators,
|
||||
'total_issues' => count($accessibilityIssues) + count($colorOnlyIndicators),
|
||||
'accessibility_score' => $this->calculateAccessibilityScore($accessibilityIssues, $colorOnlyIndicators),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt alle Farben aus dem CSS
|
||||
*/
|
||||
private function collectAllColors(CssParseResult $parseResult): array
|
||||
{
|
||||
$colors = [];
|
||||
|
||||
// Farben aus Properties
|
||||
$colorProperties = $parseResult->getPropertiesByCategory(CssPropertyCategory::COLOR);
|
||||
|
||||
foreach ($colorProperties as $property) {
|
||||
$color = $property->toColor();
|
||||
if ($color) {
|
||||
$colors[] = [
|
||||
'color' => $color,
|
||||
'property' => $property->name,
|
||||
'context' => 'property',
|
||||
'usage_count' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Farben aus Design Tokens
|
||||
foreach ($parseResult->customProperties as $token) {
|
||||
if ($token->hasValueType('color')) {
|
||||
$color = $token->getValueAs('color');
|
||||
if ($color instanceof CssColor) {
|
||||
$colors[] = [
|
||||
'color' => $color,
|
||||
'property' => $token->name,
|
||||
'context' => 'token',
|
||||
'usage_count' => 0, // TODO: Track usage
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert eine strukturierte Farbpalette
|
||||
*/
|
||||
private function generateColorPalette(array $colors): array
|
||||
{
|
||||
$palette = [
|
||||
'primary_colors' => [],
|
||||
'neutral_colors' => [],
|
||||
'accent_colors' => [],
|
||||
'semantic_colors' => [],
|
||||
];
|
||||
|
||||
foreach ($colors as $colorData) {
|
||||
$color = $colorData['color'];
|
||||
$property = $colorData['property'];
|
||||
|
||||
// Kategorisiere Farben basierend auf Namen und Eigenschaften
|
||||
if ($this->isPrimaryColor($property, $color)) {
|
||||
$palette['primary_colors'][] = $colorData;
|
||||
} elseif ($this->isNeutralColor($property, $color)) {
|
||||
$palette['neutral_colors'][] = $colorData;
|
||||
} elseif ($this->isSemanticColor($property)) {
|
||||
$palette['semantic_colors'][] = $colorData;
|
||||
} else {
|
||||
$palette['accent_colors'][] = $colorData;
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere jede Kategorie nach Helligkeit
|
||||
foreach ($palette as $category => &$categoryColors) {
|
||||
usort($categoryColors, function ($a, $b) {
|
||||
$rgbA = $a['color']->toRGB();
|
||||
$rgbB = $b['color']->toRGB();
|
||||
|
||||
if (! $rgbA || ! $rgbB) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$lightnessA = $this->calculateLightness($rgbA);
|
||||
$lightnessB = $this->calculateLightness($rgbB);
|
||||
|
||||
return $lightnessA <=> $lightnessB;
|
||||
});
|
||||
}
|
||||
|
||||
return $palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Farben nach Format
|
||||
*/
|
||||
private function groupColorsByFormat(array $colors): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($colors as $colorData) {
|
||||
$format = $colorData['color']->format->value;
|
||||
|
||||
if (! isset($grouped[$format])) {
|
||||
$grouped[$format] = [];
|
||||
}
|
||||
|
||||
$grouped[$format][] = $colorData;
|
||||
}
|
||||
|
||||
// Sortiere nach Häufigkeit
|
||||
uasort($grouped, fn ($a, $b) => count($b) <=> count($a));
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Farbkontraste
|
||||
*/
|
||||
private function analyzeContrasts(array $colors): array
|
||||
{
|
||||
$contrasts = [];
|
||||
|
||||
// Erstelle Kontrast-Matrix für häufige Kombinationen
|
||||
foreach ($colors as $i => $colorA) {
|
||||
foreach ($colors as $j => $colorB) {
|
||||
if ($i >= $j) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contrast = $this->calculateContrast($colorA['color'], $colorB['color']);
|
||||
|
||||
if ($contrast !== null) {
|
||||
$contrasts[] = [
|
||||
'color_a' => $colorA,
|
||||
'color_b' => $colorB,
|
||||
'contrast_ratio' => round($contrast, 2),
|
||||
'wcag_aa' => $contrast >= 4.5,
|
||||
'wcag_aaa' => $contrast >= 7.0,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere nach Kontrast-Ratio
|
||||
usort($contrasts, fn ($a, $b) => $b['contrast_ratio'] <=> $a['contrast_ratio']);
|
||||
|
||||
return array_slice($contrasts, 0, 20); // Limitiere auf Top 20
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet doppelte Farben
|
||||
*/
|
||||
private function findDuplicateColors(array $colors): array
|
||||
{
|
||||
$duplicates = [];
|
||||
$rgbMap = [];
|
||||
|
||||
foreach ($colors as $colorData) {
|
||||
$rgb = $colorData['color']->toRGB();
|
||||
if (! $rgb) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rgbKey = $rgb->red . ',' . $rgb->green . ',' . $rgb->blue;
|
||||
|
||||
if (! isset($rgbMap[$rgbKey])) {
|
||||
$rgbMap[$rgbKey] = [];
|
||||
}
|
||||
|
||||
$rgbMap[$rgbKey][] = $colorData;
|
||||
}
|
||||
|
||||
// Finde Gruppen mit mehr als einem Eintrag
|
||||
foreach ($rgbMap as $rgbKey => $colorGroup) {
|
||||
if (count($colorGroup) > 1) {
|
||||
$duplicates[] = [
|
||||
'rgb_values' => $rgbKey,
|
||||
'colors' => $colorGroup,
|
||||
'count' => count($colorGroup),
|
||||
'potential_savings' => count($colorGroup) - 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere nach Anzahl der Duplikate
|
||||
usort($duplicates, fn ($a, $b) => $b['count'] <=> $a['count']);
|
||||
|
||||
return $duplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Kontrast zwischen zwei Farben
|
||||
*/
|
||||
private function calculateContrast(CssColor $colorA, CssColor $colorB): ?float
|
||||
{
|
||||
$rgbA = $colorA->toRGB();
|
||||
$rgbB = $colorB->toRGB();
|
||||
|
||||
if (! $rgbA || ! $rgbB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$luminanceA = $this->calculateRelativeLuminance($rgbA);
|
||||
$luminanceB = $this->calculateRelativeLuminance($rgbB);
|
||||
|
||||
$lighter = max($luminanceA, $luminanceB);
|
||||
$darker = min($luminanceA, $luminanceB);
|
||||
|
||||
return ($lighter + 0.05) / ($darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet relative Luminanz
|
||||
*/
|
||||
private function calculateRelativeLuminance(RGBColor $rgb): float
|
||||
{
|
||||
$r = $this->linearizeColorComponent($rgb->red / 255);
|
||||
$g = $this->linearizeColorComponent($rgb->green / 255);
|
||||
$b = $this->linearizeColorComponent($rgb->blue / 255);
|
||||
|
||||
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearisiert Farbkomponente für Luminanz-Berechnung
|
||||
*/
|
||||
private function linearizeColorComponent(float $component): float
|
||||
{
|
||||
if ($component <= 0.03928) {
|
||||
return $component / 12.92;
|
||||
} else {
|
||||
return pow(($component + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert RGB zu HSL
|
||||
*/
|
||||
private function rgbToHsl(RGBColor $rgb): array
|
||||
{
|
||||
$r = $rgb->red / 255;
|
||||
$g = $rgb->green / 255;
|
||||
$b = $rgb->blue / 255;
|
||||
|
||||
$max = max($r, $g, $b);
|
||||
$min = min($r, $g, $b);
|
||||
$diff = $max - $min;
|
||||
|
||||
// Lightness
|
||||
$l = ($max + $min) / 2;
|
||||
|
||||
if ($diff === 0.0) {
|
||||
$h = $s = 0; // Achromatic
|
||||
} else {
|
||||
// Saturation
|
||||
$s = $l > 0.5 ? $diff / (2 - $max - $min) : $diff / ($max + $min);
|
||||
|
||||
// Hue
|
||||
switch ($max) {
|
||||
case $r:
|
||||
$h = (($g - $b) / $diff + ($g < $b ? 6 : 0)) / 6;
|
||||
|
||||
break;
|
||||
case $g:
|
||||
$h = (($b - $r) / $diff + 2) / 6;
|
||||
|
||||
break;
|
||||
case $b:
|
||||
$h = (($r - $g) / $diff + 4) / 6;
|
||||
|
||||
break;
|
||||
default:
|
||||
$h = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'h' => $h * 360,
|
||||
's' => $s * 100,
|
||||
'l' => $l * 100,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Helligkeit einer Farbe
|
||||
*/
|
||||
private function calculateLightness(RGBColor $rgb): float
|
||||
{
|
||||
return ($rgb->red * 0.299 + $rgb->green * 0.587 + $rgb->blue * 0.114) / 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es eine primäre Farbe ist
|
||||
*/
|
||||
private function isPrimaryColor(string $property, CssColor $color): bool
|
||||
{
|
||||
return str_contains(strtolower($property), 'primary') ||
|
||||
str_contains(strtolower($property), 'brand') ||
|
||||
str_contains(strtolower($property), 'main');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es eine neutrale Farbe ist
|
||||
*/
|
||||
private function isNeutralColor(string $property, CssColor $color): bool
|
||||
{
|
||||
if (str_contains(strtolower($property), 'gray') ||
|
||||
str_contains(strtolower($property), 'grey') ||
|
||||
str_contains(strtolower($property), 'neutral')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prüfe RGB-Werte für Grautöne
|
||||
$rgb = $color->toRGB();
|
||||
if ($rgb) {
|
||||
$maxDiff = max($rgb->red, $rgb->green, $rgb->blue) - min($rgb->red, $rgb->green, $rgb->blue);
|
||||
|
||||
return $maxDiff < 20; // Sehr niedrige Sättigung
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es eine semantische Farbe ist
|
||||
*/
|
||||
private function isSemanticColor(string $property): bool
|
||||
{
|
||||
$semanticKeywords = ['success', 'error', 'warning', 'info', 'danger', 'alert'];
|
||||
|
||||
foreach ($semanticKeywords as $keyword) {
|
||||
if (str_contains(strtolower($property), $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet reine Farbindikatoren (Accessibility Problem)
|
||||
*/
|
||||
private function findColorOnlyIndicators(CssParseResult $parseResult): array
|
||||
{
|
||||
$indicators = [];
|
||||
|
||||
// Suche nach Patterns die nur Farbe für Information nutzen
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
$hasColorChange = false;
|
||||
$hasOtherIndicator = false;
|
||||
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->getCategory() === CssPropertyCategory::COLOR) {
|
||||
$hasColorChange = true;
|
||||
}
|
||||
|
||||
if (in_array($property->name, ['border', 'text-decoration', 'font-weight', 'background-image'])) {
|
||||
$hasOtherIndicator = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasColorChange && ! $hasOtherIndicator) {
|
||||
// Prüfe ob es sich um Status-Indikatoren handelt
|
||||
foreach ($rule->selectors as $selector) {
|
||||
if (preg_match('/\.(error|success|warning|info|active|selected|invalid)/', $selector->value)) {
|
||||
$indicators[] = [
|
||||
'selector' => $selector->value,
|
||||
'issue' => 'Uses color only for status indication',
|
||||
'suggestion' => 'Add icons, borders, or text indicators alongside color',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $indicators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Accessibility Score
|
||||
*/
|
||||
private function calculateAccessibilityScore(array $contrastIssues, array $colorOnlyIndicators): int
|
||||
{
|
||||
$baseScore = 100;
|
||||
|
||||
// Abzug für Kontrast-Probleme
|
||||
foreach ($contrastIssues as $issue) {
|
||||
$deduction = match($issue['severity']) {
|
||||
'high' => 15,
|
||||
'medium' => 10,
|
||||
default => 5
|
||||
};
|
||||
$baseScore -= $deduction;
|
||||
}
|
||||
|
||||
// Abzug für reine Farbindikatoren
|
||||
$baseScore -= count($colorOnlyIndicators) * 5;
|
||||
|
||||
return max(0, $baseScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Empfehlungen
|
||||
*/
|
||||
private function generateRecommendations(array $colors, array $palette, array $duplicates, array $colorsByFormat): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
// Duplikate-Empfehlungen
|
||||
if (count($duplicates) > 0) {
|
||||
$totalDuplicates = array_sum(array_column($duplicates, 'potential_savings'));
|
||||
$recommendations[] = "Remove {$totalDuplicates} duplicate colors to reduce CSS complexity and improve maintainability.";
|
||||
}
|
||||
|
||||
// Format-Empfehlungen
|
||||
$formatCounts = array_map('count', $colorsByFormat);
|
||||
if (count($formatCounts) > 3) {
|
||||
$recommendations[] = 'Multiple color formats detected. Consider standardizing on fewer formats (e.g., hex for static colors, custom properties for theme colors).';
|
||||
}
|
||||
|
||||
// OKLCH-Empfehlung
|
||||
if (! isset($colorsByFormat['oklch']) && count($colors) > 10) {
|
||||
$recommendations[] = 'Consider using OKLCH format for more perceptually uniform color interpolation and better color consistency.';
|
||||
}
|
||||
|
||||
// Palette-Empfehlungen
|
||||
$totalColors = count($colors);
|
||||
if ($totalColors > 50) {
|
||||
$recommendations[] = "Large color palette detected ({$totalColors} colors). Consider consolidating similar colors into a smaller, more manageable palette.";
|
||||
}
|
||||
|
||||
if (count($palette['primary_colors']) === 0 && $totalColors > 5) {
|
||||
$recommendations[] = 'No primary brand colors identified. Consider establishing clear primary colors for brand consistency.';
|
||||
}
|
||||
|
||||
if (count($palette['neutral_colors']) < 3 && $totalColors > 10) {
|
||||
$recommendations[] = 'Limited neutral color palette. Consider adding more gray tones for better design flexibility.';
|
||||
}
|
||||
|
||||
// Semantic Color Empfehlungen
|
||||
if (count($palette['semantic_colors']) === 0 && $totalColors > 8) {
|
||||
$recommendations[] = 'No semantic colors found. Consider adding success, error, warning, and info colors for UI feedback.';
|
||||
}
|
||||
|
||||
if (empty($recommendations)) {
|
||||
$recommendations[] = 'Color system is well organized. Consider documenting color usage guidelines and accessibility standards.';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
}
|
||||
166
src/Framework/Design/Analyzer/ComponentDetectionResult.php
Normal file
166
src/Framework/Design/Analyzer/ComponentDetectionResult.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\ValueObjects\ComponentPattern;
|
||||
|
||||
/**
|
||||
* Ergebnis der Component-Pattern-Erkennung
|
||||
*/
|
||||
final readonly class ComponentDetectionResult
|
||||
{
|
||||
/**
|
||||
* @param ComponentPattern[] $bemComponents
|
||||
* @param ComponentPattern[] $utilityComponents
|
||||
* @param ComponentPattern[] $traditionalComponents
|
||||
*/
|
||||
public function __construct(
|
||||
public int $totalComponents,
|
||||
public array $bemComponents,
|
||||
public array $utilityComponents,
|
||||
public array $traditionalComponents,
|
||||
public array $patternStatistics,
|
||||
public array $recommendations
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die dominante Pattern-Methodik zurück
|
||||
*/
|
||||
public function getDominantPattern(): string
|
||||
{
|
||||
$counts = [
|
||||
'bem' => count($this->bemComponents),
|
||||
'utility' => count($this->utilityComponents),
|
||||
'traditional' => count($this->traditionalComponents),
|
||||
];
|
||||
|
||||
$max = max($counts);
|
||||
if ($max === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return array_search($max, $counts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Pattern-Diversity-Score zurück (0-100)
|
||||
*/
|
||||
public function getPatternDiversity(): int
|
||||
{
|
||||
$patterns = [$this->bemComponents, $this->utilityComponents, $this->traditionalComponents];
|
||||
$nonEmptyPatterns = array_filter($patterns, fn ($p) => ! empty($p));
|
||||
|
||||
if (empty($nonEmptyPatterns)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Je mehr verschiedene Pattern-Typen verwendet werden, desto höher die Diversity
|
||||
$diversityScore = (count($nonEmptyPatterns) / 3) * 100;
|
||||
|
||||
return (int) round($diversityScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Konsistenz-Score zurück (0-100)
|
||||
*/
|
||||
public function getConsistencyScore(): int
|
||||
{
|
||||
$totalComponents = $this->totalComponents;
|
||||
|
||||
if ($totalComponents === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$dominantCount = max(
|
||||
count($this->bemComponents),
|
||||
count($this->utilityComponents),
|
||||
count($this->traditionalComponents)
|
||||
);
|
||||
|
||||
// Je höher der Anteil der dominanten Methodik, desto konsistenter
|
||||
$consistencyScore = ($dominantCount / $totalComponents) * 100;
|
||||
|
||||
return (int) round($consistencyScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt empfohlene Aktionen basierend auf der Analyse zurück
|
||||
*/
|
||||
public function getActionableRecommendations(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$consistency = $this->getConsistencyScore();
|
||||
$diversity = $this->getPatternDiversity();
|
||||
|
||||
if ($consistency < 60 && $diversity > 80) {
|
||||
$actions[] = [
|
||||
'priority' => 'high',
|
||||
'action' => 'standardize_methodology',
|
||||
'description' => 'Standardize on one primary CSS methodology to improve consistency.',
|
||||
];
|
||||
}
|
||||
|
||||
if (count($this->bemComponents) > 5 && count($this->utilityComponents) < 3) {
|
||||
$actions[] = [
|
||||
'priority' => 'medium',
|
||||
'action' => 'add_utilities',
|
||||
'description' => 'Add utility classes for common spacing, colors, and typography.',
|
||||
];
|
||||
}
|
||||
|
||||
if (count($this->traditionalComponents) > 10) {
|
||||
$actions[] = [
|
||||
'priority' => 'medium',
|
||||
'action' => 'refactor_to_bem',
|
||||
'description' => 'Consider refactoring traditional components to BEM methodology.',
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Pattern-Verteilung zurück
|
||||
*/
|
||||
public function getPatternDistribution(): array
|
||||
{
|
||||
$total = $this->totalComponents;
|
||||
|
||||
if ($total === 0) {
|
||||
return [
|
||||
'bem' => 0,
|
||||
'utility' => 0,
|
||||
'traditional' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'bem' => round((count($this->bemComponents) / $total) * 100, 1),
|
||||
'utility' => round((count($this->utilityComponents) / $total) * 100, 1),
|
||||
'traditional' => round((count($this->traditionalComponents) / $total) * 100, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_components' => $this->totalComponents,
|
||||
'bem_components' => array_map(fn (ComponentPattern $p) => $p->toArray(), $this->bemComponents),
|
||||
'utility_components' => array_map(fn (ComponentPattern $p) => $p->toArray(), $this->utilityComponents),
|
||||
'traditional_components' => array_map(fn (ComponentPattern $p) => $p->toArray(), $this->traditionalComponents),
|
||||
'pattern_statistics' => $this->patternStatistics,
|
||||
'recommendations' => $this->recommendations,
|
||||
'dominant_pattern' => $this->getDominantPattern(),
|
||||
'pattern_diversity' => $this->getPatternDiversity(),
|
||||
'consistency_score' => $this->getConsistencyScore(),
|
||||
'actionable_recommendations' => $this->getActionableRecommendations(),
|
||||
];
|
||||
}
|
||||
}
|
||||
397
src/Framework/Design/Analyzer/ComponentDetector.php
Normal file
397
src/Framework/Design/Analyzer/ComponentDetector.php
Normal file
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\Parser\ClassNameParser;
|
||||
use App\Framework\Design\ValueObjects\ComponentPatternType;
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
|
||||
/**
|
||||
* Erkennt Component-Patterns in CSS
|
||||
*/
|
||||
final readonly class ComponentDetector
|
||||
{
|
||||
public function __construct(
|
||||
private ClassNameParser $classNameParser = new ClassNameParser()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt alle Components im CSS
|
||||
*/
|
||||
public function detectComponents(CssParseResult $parseResult): ComponentDetectionResult
|
||||
{
|
||||
$classNames = array_values($parseResult->classNames);
|
||||
|
||||
if (empty($classNames)) {
|
||||
return new ComponentDetectionResult(
|
||||
totalComponents: 0,
|
||||
bemComponents: [],
|
||||
utilityComponents: [],
|
||||
traditionalComponents: [],
|
||||
patternStatistics: [],
|
||||
recommendations: ['No CSS classes found to analyze for component patterns.']
|
||||
);
|
||||
}
|
||||
|
||||
// Pattern-Erkennung
|
||||
$patterns = $this->classNameParser->detectPatterns($classNames);
|
||||
|
||||
// Gruppierung nach Pattern-Typ
|
||||
$bemComponents = [];
|
||||
$utilityComponents = [];
|
||||
$traditionalComponents = [];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
switch ($pattern->type) {
|
||||
case ComponentPatternType::BEM:
|
||||
$bemComponents[] = $pattern;
|
||||
|
||||
break;
|
||||
case ComponentPatternType::UTILITY:
|
||||
$utilityComponents[] = $pattern;
|
||||
|
||||
break;
|
||||
case ComponentPatternType::COMPONENT:
|
||||
$traditionalComponents[] = $pattern;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Statistiken erstellen
|
||||
$statistics = $this->generatePatternStatistics($patterns, $classNames);
|
||||
|
||||
// Empfehlungen generieren
|
||||
$recommendations = $this->generateRecommendations($bemComponents, $utilityComponents, $traditionalComponents, $classNames);
|
||||
|
||||
return new ComponentDetectionResult(
|
||||
totalComponents: count($patterns),
|
||||
bemComponents: $bemComponents,
|
||||
utilityComponents: $utilityComponents,
|
||||
traditionalComponents: $traditionalComponents,
|
||||
patternStatistics: $statistics,
|
||||
recommendations: $recommendations
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert spezifisch BEM-Patterns
|
||||
*/
|
||||
public function analyzeBemPatterns(CssParseResult $parseResult): array
|
||||
{
|
||||
$bemClasses = $parseResult->getBemClasses();
|
||||
$analysis = [];
|
||||
|
||||
foreach ($bemClasses as $className) {
|
||||
$block = $className->getBemBlock();
|
||||
|
||||
if (! isset($analysis[$block])) {
|
||||
$analysis[$block] = [
|
||||
'block' => $block,
|
||||
'elements' => [],
|
||||
'modifiers' => [],
|
||||
'total_classes' => 0,
|
||||
'completeness' => 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
$analysis[$block]['total_classes']++;
|
||||
|
||||
if ($className->isBemElement()) {
|
||||
$element = $className->getBemElement();
|
||||
if ($element && ! in_array($element, $analysis[$block]['elements'])) {
|
||||
$analysis[$block]['elements'][] = $element;
|
||||
}
|
||||
}
|
||||
|
||||
if ($className->isBemModifier()) {
|
||||
$modifier = $className->getBemModifier();
|
||||
if ($modifier && ! in_array($modifier, $analysis[$block]['modifiers'])) {
|
||||
$analysis[$block]['modifiers'][] = $modifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Completeness-Bewertung
|
||||
foreach ($analysis as $block => &$data) {
|
||||
$hasElements = ! empty($data['elements']);
|
||||
$hasModifiers = ! empty($data['modifiers']);
|
||||
|
||||
if (! $hasElements && ! $hasModifiers) {
|
||||
$data['completeness'] = 'block_only';
|
||||
} elseif ($hasElements && ! $hasModifiers) {
|
||||
$data['completeness'] = 'block_with_elements';
|
||||
} elseif (! $hasElements && $hasModifiers) {
|
||||
$data['completeness'] = 'block_with_modifiers';
|
||||
} else {
|
||||
$data['completeness'] = 'full_bem';
|
||||
}
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Utility-Class-Patterns
|
||||
*/
|
||||
public function analyzeUtilityPatterns(CssParseResult $parseResult): array
|
||||
{
|
||||
$utilityClasses = $parseResult->getUtilityClasses();
|
||||
$analysis = [];
|
||||
|
||||
foreach ($utilityClasses as $className) {
|
||||
$category = $className->getUtilityCategory();
|
||||
|
||||
if (! $category) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($analysis[$category])) {
|
||||
$analysis[$category] = [
|
||||
'category' => $category,
|
||||
'classes' => [],
|
||||
'count' => 0,
|
||||
'coverage' => 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
$analysis[$category]['classes'][] = $className->name;
|
||||
$analysis[$category]['count']++;
|
||||
}
|
||||
|
||||
// Coverage-Bewertung für verschiedene Kategorien
|
||||
foreach ($analysis as $category => &$data) {
|
||||
$data['coverage'] = $this->assessUtilityCoverage($category, $data['classes']);
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet potenzielle Component-Candidates
|
||||
*/
|
||||
public function findComponentCandidates(CssParseResult $parseResult): array
|
||||
{
|
||||
$candidates = [];
|
||||
$selectorFrequency = [];
|
||||
|
||||
// Analysiere Selektor-Häufigkeiten
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
foreach ($rule->selectors as $selector) {
|
||||
if ($selector->getType()->value === 'class') {
|
||||
$classes = $selector->extractClasses();
|
||||
|
||||
foreach ($classes as $class) {
|
||||
if (! isset($selectorFrequency[$class])) {
|
||||
$selectorFrequency[$class] = 0;
|
||||
}
|
||||
$selectorFrequency[$class]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finde Klassen mit hoher Verwendung (potenzielle Components)
|
||||
foreach ($selectorFrequency as $className => $frequency) {
|
||||
if ($frequency >= 3) { // Threshold für Component-Candidate
|
||||
$candidates[] = [
|
||||
'class' => $className,
|
||||
'frequency' => $frequency,
|
||||
'potential' => $this->assessComponentPotential($className, $frequency),
|
||||
'suggestions' => $this->getComponentSuggestions($className, $frequency),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere nach Häufigkeit
|
||||
usort($candidates, fn ($a, $b) => $b['frequency'] <=> $a['frequency']);
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Pattern-Statistiken
|
||||
*/
|
||||
private function generatePatternStatistics(array $patterns, array $classNames): array
|
||||
{
|
||||
$totalClasses = count($classNames);
|
||||
$statistics = [
|
||||
'total_patterns' => count($patterns),
|
||||
'total_classes' => $totalClasses,
|
||||
'pattern_distribution' => [],
|
||||
'complexity_analysis' => [],
|
||||
];
|
||||
|
||||
$patternTypes = [];
|
||||
foreach ($patterns as $pattern) {
|
||||
$type = $pattern->type->value;
|
||||
|
||||
if (! isset($patternTypes[$type])) {
|
||||
$patternTypes[$type] = 0;
|
||||
}
|
||||
$patternTypes[$type]++;
|
||||
}
|
||||
|
||||
foreach ($patternTypes as $type => $count) {
|
||||
$percentage = $totalClasses > 0 ? round(($count / $totalClasses) * 100, 1) : 0;
|
||||
$statistics['pattern_distribution'][$type] = [
|
||||
'count' => $count,
|
||||
'percentage' => $percentage,
|
||||
];
|
||||
}
|
||||
|
||||
// Complexity Analysis
|
||||
foreach ($patterns as $pattern) {
|
||||
$complexity = $pattern->getComplexity();
|
||||
|
||||
if (! isset($statistics['complexity_analysis'][$complexity])) {
|
||||
$statistics['complexity_analysis'][$complexity] = 0;
|
||||
}
|
||||
$statistics['complexity_analysis'][$complexity]++;
|
||||
}
|
||||
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Empfehlungen
|
||||
*/
|
||||
private function generateRecommendations(array $bemComponents, array $utilityComponents, array $traditionalComponents, array $classNames): array
|
||||
{
|
||||
$recommendations = [];
|
||||
$totalClasses = count($classNames);
|
||||
|
||||
// BEM-Empfehlungen
|
||||
if (empty($bemComponents) && $totalClasses > 10) {
|
||||
$recommendations[] = 'Consider adopting BEM methodology for better component organization and naming consistency.';
|
||||
} elseif (! empty($bemComponents)) {
|
||||
$blocksWithoutElements = 0;
|
||||
foreach ($bemComponents as $component) {
|
||||
if (empty($component->metadata['elements'])) {
|
||||
$blocksWithoutElements++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($blocksWithoutElements > count($bemComponents) / 2) {
|
||||
$recommendations[] = 'Many BEM blocks lack elements. Consider breaking down components into smaller, reusable parts.';
|
||||
}
|
||||
}
|
||||
|
||||
// Utility-Empfehlungen
|
||||
if (! empty($utilityComponents)) {
|
||||
$utilityCount = array_sum(array_map(fn ($comp) => $comp->metadata['class_count'], $utilityComponents));
|
||||
$utilityPercentage = round(($utilityCount / $totalClasses) * 100, 1);
|
||||
|
||||
if ($utilityPercentage > 70) {
|
||||
$recommendations[] = 'High percentage of utility classes detected. Consider consolidating repetitive patterns into components.';
|
||||
} elseif ($utilityPercentage < 10 && $totalClasses > 20) {
|
||||
$recommendations[] = 'Low usage of utility classes. Consider adding utility classes for common spacing, colors, and typography.';
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed Pattern Empfehlungen
|
||||
$hasMultiplePatterns = (! empty($bemComponents) ? 1 : 0) +
|
||||
(! empty($utilityComponents) ? 1 : 0) +
|
||||
(! empty($traditionalComponents) ? 1 : 0);
|
||||
|
||||
if ($hasMultiplePatterns >= 3) {
|
||||
$recommendations[] = 'Multiple CSS methodologies detected. Consider standardizing on one primary approach for consistency.';
|
||||
}
|
||||
|
||||
// Spezifische Pattern-Empfehlungen
|
||||
if (! empty($traditionalComponents)) {
|
||||
$componentTypes = array_unique(array_map(fn ($comp) => $comp->metadata['component_type'], $traditionalComponents));
|
||||
|
||||
if (in_array('button', $componentTypes) && in_array('form', $componentTypes)) {
|
||||
$recommendations[] = 'Consider creating a design system with consistent button and form component patterns.';
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($recommendations)) {
|
||||
$recommendations[] = 'Component patterns look well organized. Consider documenting the design system for team consistency.';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet Utility-Coverage
|
||||
*/
|
||||
private function assessUtilityCoverage(string $category, array $classes): string
|
||||
{
|
||||
$expectedCoverage = [
|
||||
'spacing' => ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
'color' => ['primary', 'secondary', 'success', 'warning', 'error'],
|
||||
'typography' => ['xs', 'sm', 'base', 'lg', 'xl'],
|
||||
'sizing' => ['sm', 'md', 'lg', 'full'],
|
||||
];
|
||||
|
||||
if (! isset($expectedCoverage[$category])) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$expected = $expectedCoverage[$category];
|
||||
$hasExpected = 0;
|
||||
|
||||
foreach ($expected as $expectedClass) {
|
||||
foreach ($classes as $actualClass) {
|
||||
if (str_contains($actualClass, $expectedClass)) {
|
||||
$hasExpected++;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$percentage = count($expected) > 0 ? ($hasExpected / count($expected)) : 0;
|
||||
|
||||
if ($percentage >= 0.8) {
|
||||
return 'excellent';
|
||||
} elseif ($percentage >= 0.6) {
|
||||
return 'good';
|
||||
} elseif ($percentage >= 0.4) {
|
||||
return 'fair';
|
||||
} else {
|
||||
return 'poor';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet Component-Potenzial
|
||||
*/
|
||||
private function assessComponentPotential(string $className, int $frequency): string
|
||||
{
|
||||
if ($frequency >= 10) {
|
||||
return 'high';
|
||||
} elseif ($frequency >= 6) {
|
||||
return 'medium';
|
||||
} else {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Component-Verbesserungsvorschläge
|
||||
*/
|
||||
private function getComponentSuggestions(string $className, int $frequency): array
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
if ($frequency >= 8) {
|
||||
$suggestions[] = 'Consider extracting into a reusable component or design token.';
|
||||
}
|
||||
|
||||
if (str_contains($className, 'btn') || str_contains($className, 'button')) {
|
||||
$suggestions[] = 'Consider creating button variants (primary, secondary, outline).';
|
||||
}
|
||||
|
||||
if (str_contains($className, 'card')) {
|
||||
$suggestions[] = 'Consider standardizing card padding, borders, and shadows.';
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
}
|
||||
179
src/Framework/Design/Analyzer/ConventionCheckResult.php
Normal file
179
src/Framework/Design/Analyzer/ConventionCheckResult.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
/**
|
||||
* Ergebnis der Convention-Prüfung
|
||||
*/
|
||||
final readonly class ConventionCheckResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $overallScore,
|
||||
public array $categoryScores,
|
||||
public array $violations,
|
||||
public array $recommendations,
|
||||
public string $conformanceLevel
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Violations nach Schweregrad
|
||||
*/
|
||||
public function getViolationsBySeverity(): array
|
||||
{
|
||||
$grouped = [
|
||||
'high' => [],
|
||||
'medium' => [],
|
||||
'low' => [],
|
||||
];
|
||||
|
||||
foreach ($this->violations as $violation) {
|
||||
$severity = $violation['severity'] ?? 'low';
|
||||
$grouped[$severity][] = $violation;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die schlimmsten Problembereiche zurück
|
||||
*/
|
||||
public function getWorstAreas(): array
|
||||
{
|
||||
$sortedScores = $this->categoryScores;
|
||||
asort($sortedScores);
|
||||
|
||||
return array_slice($sortedScores, 0, 3, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die besten Bereiche zurück
|
||||
*/
|
||||
public function getBestAreas(): array
|
||||
{
|
||||
$sortedScores = $this->categoryScores;
|
||||
arsort($sortedScores);
|
||||
|
||||
return array_slice($sortedScores, 0, 3, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt priorisierte Action Items zurück
|
||||
*/
|
||||
public function getPrioritizedActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
$violationsBySeverity = $this->getViolationsBySeverity();
|
||||
|
||||
// High-Priority Violations zuerst
|
||||
foreach ($violationsBySeverity['high'] as $violation) {
|
||||
$actions[] = [
|
||||
'priority' => 'high',
|
||||
'category' => $violation['type'],
|
||||
'action' => $violation['suggestion'],
|
||||
'impact' => 'accessibility_maintainability',
|
||||
];
|
||||
}
|
||||
|
||||
// Medium-Priority basierend auf schlechtesten Scores
|
||||
$worstAreas = $this->getWorstAreas();
|
||||
foreach ($worstAreas as $area => $score) {
|
||||
if ($score < 70) {
|
||||
$actions[] = [
|
||||
'priority' => 'medium',
|
||||
'category' => $area,
|
||||
'action' => $this->getAreaImprovementAction($area),
|
||||
'impact' => 'code_quality',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Verbesserungs-Potenzial
|
||||
*/
|
||||
public function getImprovementPotential(): array
|
||||
{
|
||||
$potential = [];
|
||||
|
||||
foreach ($this->categoryScores as $category => $score) {
|
||||
$maxImprovement = 100 - $score;
|
||||
$effort = $this->getEstimatedEffort($category, $score);
|
||||
|
||||
$potential[$category] = [
|
||||
'current_score' => $score,
|
||||
'max_improvement' => $maxImprovement,
|
||||
'estimated_effort' => $effort,
|
||||
'roi_score' => $maxImprovement / max(1, $effort), // Return on Investment
|
||||
];
|
||||
}
|
||||
|
||||
// Sortiere nach ROI
|
||||
uasort($potential, fn ($a, $b) => $b['roi_score'] <=> $a['roi_score']);
|
||||
|
||||
return $potential;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'overall_score' => $this->overallScore,
|
||||
'conformance_level' => $this->conformanceLevel,
|
||||
'category_scores' => $this->categoryScores,
|
||||
'violations' => $this->violations,
|
||||
'violations_by_severity' => $this->getViolationsBySeverity(),
|
||||
'recommendations' => $this->recommendations,
|
||||
'worst_areas' => $this->getWorstAreas(),
|
||||
'best_areas' => $this->getBestAreas(),
|
||||
'prioritized_actions' => $this->getPrioritizedActions(),
|
||||
'improvement_potential' => $this->getImprovementPotential(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt bereichsspezifische Verbesserungs-Actions
|
||||
*/
|
||||
private function getAreaImprovementAction(string $area): string
|
||||
{
|
||||
return match($area) {
|
||||
'naming' => 'Standardize naming conventions across all CSS classes',
|
||||
'specificity' => 'Refactor high-specificity selectors and reduce !important usage',
|
||||
'organization' => 'Reorganize CSS properties within rules for better readability',
|
||||
'custom_properties' => 'Review and improve custom property naming and organization',
|
||||
'accessibility' => 'Add missing focus styles and improve keyboard navigation support',
|
||||
default => "Improve {$area} conventions and standards"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schätzt Aufwand für Verbesserungen
|
||||
*/
|
||||
private function getEstimatedEffort(string $category, int|float $score): int
|
||||
{
|
||||
$baseEffort = match($category) {
|
||||
'naming' => 3, // Moderate effort - mostly renaming
|
||||
'specificity' => 4, // Higher effort - requires structural changes
|
||||
'organization' => 2, // Lower effort - mostly reordering
|
||||
'custom_properties' => 2, // Lower effort - renaming and reorganization
|
||||
'accessibility' => 3, // Moderate effort - adding new styles
|
||||
default => 3
|
||||
};
|
||||
|
||||
// Adjust based on how bad the score is
|
||||
$severityMultiplier = match(true) {
|
||||
$score >= 80 => 0.5,
|
||||
$score >= 60 => 1.0,
|
||||
$score >= 40 => 1.5,
|
||||
default => 2.0
|
||||
};
|
||||
|
||||
return (int) ceil($baseEffort * $severityMultiplier);
|
||||
}
|
||||
}
|
||||
442
src/Framework/Design/Analyzer/ConventionChecker.php
Normal file
442
src/Framework/Design/Analyzer/ConventionChecker.php
Normal file
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
|
||||
/**
|
||||
* Prüft CSS-Naming-Conventions und Code-Standards
|
||||
*/
|
||||
final readonly class ConventionChecker
|
||||
{
|
||||
/**
|
||||
* Prüft alle Conventions im CSS
|
||||
*/
|
||||
public function checkConventions(CssParseResult $parseResult): ConventionCheckResult
|
||||
{
|
||||
$violations = [];
|
||||
$scores = [];
|
||||
|
||||
// Naming Convention Check
|
||||
$namingResult = $this->checkNamingConventions($parseResult);
|
||||
$scores['naming'] = $namingResult['score'];
|
||||
if (! empty($namingResult['violations'])) {
|
||||
$violations = array_merge($violations, $namingResult['violations']);
|
||||
}
|
||||
|
||||
// Selector Specificity Check
|
||||
$specificityResult = $this->checkSelectorSpecificity($parseResult);
|
||||
$scores['specificity'] = $specificityResult['score'];
|
||||
if (! empty($specificityResult['violations'])) {
|
||||
$violations = array_merge($violations, $specificityResult['violations']);
|
||||
}
|
||||
|
||||
// Property Organization Check
|
||||
$organizationResult = $this->checkPropertyOrganization($parseResult);
|
||||
$scores['organization'] = $organizationResult['score'];
|
||||
if (! empty($organizationResult['violations'])) {
|
||||
$violations = array_merge($violations, $organizationResult['violations']);
|
||||
}
|
||||
|
||||
// Custom Property Convention Check
|
||||
$customPropResult = $this->checkCustomPropertyConventions($parseResult);
|
||||
$scores['custom_properties'] = $customPropResult['score'];
|
||||
if (! empty($customPropResult['violations'])) {
|
||||
$violations = array_merge($violations, $customPropResult['violations']);
|
||||
}
|
||||
|
||||
// Accessibility Check
|
||||
$accessibilityResult = $this->checkAccessibilityPatterns($parseResult);
|
||||
$scores['accessibility'] = $accessibilityResult['score'];
|
||||
if (! empty($accessibilityResult['violations'])) {
|
||||
$violations = array_merge($violations, $accessibilityResult['violations']);
|
||||
}
|
||||
|
||||
// Berechne Gesamt-Score
|
||||
$overallScore = array_sum($scores) / count($scores);
|
||||
|
||||
// Generiere Empfehlungen
|
||||
$recommendations = $this->generateRecommendations($scores, $violations);
|
||||
|
||||
return new ConventionCheckResult(
|
||||
overallScore: (int) round($overallScore),
|
||||
categoryScores: $scores,
|
||||
violations: $violations,
|
||||
recommendations: $recommendations,
|
||||
conformanceLevel: $this->getConformanceLevel($overallScore)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Naming Conventions
|
||||
*/
|
||||
private function checkNamingConventions(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$analysis = $parseResult->getNamingConventionAnalysis();
|
||||
|
||||
$totalClasses = $analysis['total_classes'];
|
||||
$dominantConvention = $analysis['dominant_convention'];
|
||||
|
||||
if ($totalClasses === 0) {
|
||||
return ['score' => 100, 'violations' => []];
|
||||
}
|
||||
|
||||
// Prüfe Konsistenz
|
||||
$dominantCount = $analysis[$dominantConvention] ?? 0;
|
||||
$consistencyPercentage = ($dominantCount / $totalClasses) * 100;
|
||||
|
||||
if ($consistencyPercentage < 70) {
|
||||
$violations[] = [
|
||||
'type' => 'naming_inconsistency',
|
||||
'severity' => 'medium',
|
||||
'message' => "Mixed naming conventions detected. {$dominantConvention} is dominant with {$consistencyPercentage}% usage.",
|
||||
'suggestion' => 'Standardize on one naming convention across your CSS.',
|
||||
];
|
||||
}
|
||||
|
||||
// Prüfe spezifische Convention-Violations
|
||||
foreach ($analysis['violations'] as $violation) {
|
||||
$violations[] = [
|
||||
'type' => 'naming_violation',
|
||||
'severity' => 'low',
|
||||
'message' => "Class '{$violation}' doesn't follow standard naming conventions.",
|
||||
'suggestion' => 'Use consistent casing and separators (kebab-case, camelCase, or BEM).',
|
||||
];
|
||||
}
|
||||
|
||||
// BEM-spezifische Checks
|
||||
if ($dominantConvention === 'bem') {
|
||||
$bemViolations = $this->checkBemConventions($parseResult);
|
||||
$violations = array_merge($violations, $bemViolations);
|
||||
}
|
||||
|
||||
$score = max(0, 100 - (count($violations) * 10));
|
||||
|
||||
return ['score' => $score, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Selector-Spezifität
|
||||
*/
|
||||
private function checkSelectorSpecificity(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$highSpecificityCount = 0;
|
||||
$totalSelectors = 0;
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
foreach ($rule->selectors as $selector) {
|
||||
$totalSelectors++;
|
||||
$specificity = $selector->calculateSpecificity();
|
||||
|
||||
// Sehr hohe Spezifität (>100)
|
||||
if ($specificity > 100) {
|
||||
$highSpecificityCount++;
|
||||
$violations[] = [
|
||||
'type' => 'high_specificity',
|
||||
'severity' => 'medium',
|
||||
'message' => "Selector '{$selector->value}' has high specificity ({$specificity}).",
|
||||
'suggestion' => 'Reduce specificity by avoiding IDs and deeply nested selectors.',
|
||||
];
|
||||
}
|
||||
|
||||
// Sehr niedrige Spezifität mit !important
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->isImportant() && $specificity < 10) {
|
||||
$violations[] = [
|
||||
'type' => 'important_overuse',
|
||||
'severity' => 'high',
|
||||
'message' => "Property '{$property->name}' uses !important with low specificity selector.",
|
||||
'suggestion' => 'Increase selector specificity instead of using !important.',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$highSpecificityPercentage = $totalSelectors > 0 ? ($highSpecificityCount / $totalSelectors) * 100 : 0;
|
||||
$score = max(0, 100 - ($highSpecificityPercentage * 2));
|
||||
|
||||
return ['score' => (int) $score, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Property-Organisation
|
||||
*/
|
||||
private function checkPropertyOrganization(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$propertyOrderViolations = 0;
|
||||
$totalRules = count($parseResult->rules);
|
||||
|
||||
// Erwartete Property-Reihenfolge (grob)
|
||||
$expectedOrder = [
|
||||
'position', 'top', 'right', 'bottom', 'left', 'z-index',
|
||||
'display', 'flex', 'grid', 'float', 'clear',
|
||||
'width', 'height', 'margin', 'padding',
|
||||
'border', 'background', 'color',
|
||||
'font', 'text', 'line-height',
|
||||
'transform', 'transition', 'animation',
|
||||
];
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
if (count($rule->properties) < 3) {
|
||||
continue; // Skip kleine Rules
|
||||
}
|
||||
|
||||
$propertyOrder = array_map(fn ($prop) => $prop->name, $rule->properties);
|
||||
|
||||
// Vereinfachte Reihenfolgen-Prüfung
|
||||
$lastOrderIndex = -1;
|
||||
$orderViolation = false;
|
||||
|
||||
foreach ($propertyOrder as $property) {
|
||||
$orderIndex = $this->findPropertyOrderIndex($property, $expectedOrder);
|
||||
|
||||
if ($orderIndex !== -1 && $orderIndex < $lastOrderIndex) {
|
||||
$orderViolation = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if ($orderIndex !== -1) {
|
||||
$lastOrderIndex = $orderIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if ($orderViolation) {
|
||||
$propertyOrderViolations++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($propertyOrderViolations > 0 && $totalRules > 0) {
|
||||
$violationPercentage = ($propertyOrderViolations / $totalRules) * 100;
|
||||
|
||||
if ($violationPercentage > 30) {
|
||||
$violations[] = [
|
||||
'type' => 'property_order',
|
||||
'severity' => 'low',
|
||||
'message' => "{$violationPercentage}% of rules have suboptimal property ordering.",
|
||||
'suggestion' => 'Consider organizing properties by type: positioning, box model, visual, typography.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$score = max(0, 100 - ($propertyOrderViolations * 5));
|
||||
|
||||
return ['score' => $score, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Custom Property Conventions
|
||||
*/
|
||||
private function checkCustomPropertyConventions(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$customProperties = $parseResult->customProperties;
|
||||
|
||||
if (empty($customProperties)) {
|
||||
return ['score' => 80, 'violations' => []]; // Neutral score when no custom properties
|
||||
}
|
||||
|
||||
foreach ($customProperties as $token) {
|
||||
$name = $token->name;
|
||||
|
||||
// Prüfe Naming Convention
|
||||
if (! preg_match('/^[a-z][a-z0-9-]*$/', $name)) {
|
||||
$violations[] = [
|
||||
'type' => 'custom_property_naming',
|
||||
'severity' => 'medium',
|
||||
'message' => "Custom property '--{$name}' doesn't follow kebab-case convention.",
|
||||
'suggestion' => 'Use lowercase letters and hyphens for custom property names.',
|
||||
];
|
||||
}
|
||||
|
||||
// Prüfe semantische Namen
|
||||
if (preg_match('/^(red|blue|green|yellow)-\d+$/', $name)) {
|
||||
$violations[] = [
|
||||
'type' => 'semantic_naming',
|
||||
'severity' => 'low',
|
||||
'message' => "Custom property '--{$name}' uses literal color naming.",
|
||||
'suggestion' => 'Consider semantic names like --color-primary, --color-error instead.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$score = max(0, 100 - (count($violations) * 15));
|
||||
|
||||
return ['score' => $score, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Accessibility Patterns
|
||||
*/
|
||||
private function checkAccessibilityPatterns(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$accessibilityScore = 100;
|
||||
|
||||
// Prüfe auf focus-Styles
|
||||
$hasFocusStyles = false;
|
||||
$hasColorOnlyIndicators = false;
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
foreach ($rule->selectors as $selector) {
|
||||
if (str_contains($selector->value, ':focus')) {
|
||||
$hasFocusStyles = true;
|
||||
}
|
||||
|
||||
// Prüfe auf outline: none ohne Alternative
|
||||
if (str_contains($selector->value, ':focus')) {
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->name === 'outline' && $property->value === 'none') {
|
||||
$hasAlternativeFocus = false;
|
||||
|
||||
foreach ($rule->properties as $altProp) {
|
||||
if (in_array($altProp->name, ['border', 'box-shadow', 'background'])) {
|
||||
$hasAlternativeFocus = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $hasAlternativeFocus) {
|
||||
$violations[] = [
|
||||
'type' => 'focus_accessibility',
|
||||
'severity' => 'high',
|
||||
'message' => 'Focus outline removed without alternative focus indicator.',
|
||||
'suggestion' => 'Provide alternative focus indicators when removing default outline.',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $hasFocusStyles && count($parseResult->rules) > 5) {
|
||||
$violations[] = [
|
||||
'type' => 'missing_focus_styles',
|
||||
'severity' => 'medium',
|
||||
'message' => 'No focus styles detected in CSS.',
|
||||
'suggestion' => 'Add focus styles for interactive elements to improve keyboard accessibility.',
|
||||
];
|
||||
$accessibilityScore -= 20;
|
||||
}
|
||||
|
||||
return ['score' => $accessibilityScore, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft BEM-spezifische Conventions
|
||||
*/
|
||||
private function checkBemConventions(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$bemClasses = $parseResult->getBemClasses();
|
||||
|
||||
foreach ($bemClasses as $className) {
|
||||
$name = $className->name;
|
||||
|
||||
// Prüfe BEM Syntax-Korrektheit
|
||||
if ($className->isBemElement() && substr_count($name, '__') > 1) {
|
||||
$violations[] = [
|
||||
'type' => 'bem_syntax',
|
||||
'severity' => 'medium',
|
||||
'message' => "Class '{$name}' has multiple element separators (__). BEM allows only one level of elements.",
|
||||
'suggestion' => 'Use only one level of elements in BEM: block__element--modifier.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($className->isBemModifier() && substr_count($name, '--') > 1) {
|
||||
$violations[] = [
|
||||
'type' => 'bem_syntax',
|
||||
'severity' => 'medium',
|
||||
'message' => "Class '{$name}' has multiple modifier separators (--). Use single modifier per class.",
|
||||
'suggestion' => 'Use single modifiers: block__element--modifier.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet Property-Order-Index
|
||||
*/
|
||||
private function findPropertyOrderIndex(string $property, array $order): int
|
||||
{
|
||||
foreach ($order as $index => $orderProperty) {
|
||||
if (str_starts_with($property, $orderProperty)) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Empfehlungen
|
||||
*/
|
||||
private function generateRecommendations(array $scores, array $violations): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
// Basiere Empfehlungen auf Scores
|
||||
foreach ($scores as $category => $score) {
|
||||
if ($score < 70) {
|
||||
$recommendations[] = $this->getCategoryRecommendation($category, $score);
|
||||
}
|
||||
}
|
||||
|
||||
// Prioritäts-basierte Empfehlungen
|
||||
$highSeverityCount = count(array_filter($violations, fn ($v) => $v['severity'] === 'high'));
|
||||
$mediumSeverityCount = count(array_filter($violations, fn ($v) => $v['severity'] === 'medium'));
|
||||
|
||||
if ($highSeverityCount > 0) {
|
||||
$recommendations[] = "Address {$highSeverityCount} high-priority violations first for accessibility and maintainability.";
|
||||
}
|
||||
|
||||
if ($mediumSeverityCount > 3) {
|
||||
$recommendations[] = "Consider refactoring to address {$mediumSeverityCount} medium-priority issues for better code quality.";
|
||||
}
|
||||
|
||||
if (empty($recommendations)) {
|
||||
$recommendations[] = 'CSS conventions are well maintained. Consider documenting your standards for team consistency.';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt kategorie-spezifische Empfehlungen
|
||||
*/
|
||||
private function getCategoryRecommendation(string $category, int $score): string
|
||||
{
|
||||
return match($category) {
|
||||
'naming' => "Naming conventions need improvement (Score: {$score}/100). Standardize on one naming methodology.",
|
||||
'specificity' => "Selector specificity issues detected (Score: {$score}/100). Avoid overly specific selectors and !important.",
|
||||
'organization' => "Property organization could be better (Score: {$score}/100). Group related properties together.",
|
||||
'custom_properties' => "Custom property conventions need attention (Score: {$score}/100). Use consistent, semantic naming.",
|
||||
'accessibility' => "Accessibility patterns missing (Score: {$score}/100). Add focus styles and ensure color contrast.",
|
||||
default => "Category '{$category}' needs improvement (Score: {$score}/100)."
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt Conformance Level
|
||||
*/
|
||||
private function getConformanceLevel(float $score): string
|
||||
{
|
||||
return match(true) {
|
||||
$score >= 90 => 'excellent',
|
||||
$score >= 80 => 'good',
|
||||
$score >= 70 => 'fair',
|
||||
$score >= 60 => 'needs_improvement',
|
||||
default => 'poor'
|
||||
};
|
||||
}
|
||||
}
|
||||
387
src/Framework/Design/Analyzer/DesignSystemAnalysis.php
Normal file
387
src/Framework/Design/Analyzer/DesignSystemAnalysis.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Vollständige Design System Analyse - Haupt-Ergebnis-Klasse
|
||||
*/
|
||||
final readonly class DesignSystemAnalysis
|
||||
{
|
||||
/**
|
||||
* @param FilePath[] $sourceFiles
|
||||
* @param CssParseResult[] $parseResults
|
||||
*/
|
||||
public function __construct(
|
||||
public array $sourceFiles,
|
||||
public array $parseResults,
|
||||
public CssParseResult $combinedResult,
|
||||
public TokenAnalysisResult $tokenAnalysis,
|
||||
public ComponentDetectionResult $componentAnalysis,
|
||||
public ConventionCheckResult $conventionAnalysis,
|
||||
public ColorAnalysisResult $colorAnalysis,
|
||||
public array $overallStatistics
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Gesamt-Design-System-Score (0-100)
|
||||
*/
|
||||
public function getOverallDesignSystemScore(): int
|
||||
{
|
||||
$scores = [
|
||||
'tokens' => $this->getTokenScore(),
|
||||
'components' => $this->getComponentScore(),
|
||||
'conventions' => $this->conventionAnalysis->overallScore,
|
||||
'colors' => $this->getColorScore(),
|
||||
];
|
||||
|
||||
// Gewichtung der verschiedenen Bereiche
|
||||
$weights = [
|
||||
'tokens' => 0.25,
|
||||
'components' => 0.25,
|
||||
'conventions' => 0.30,
|
||||
'colors' => 0.20,
|
||||
];
|
||||
|
||||
$weightedScore = 0;
|
||||
foreach ($scores as $area => $score) {
|
||||
$weightedScore += $score * $weights[$area];
|
||||
}
|
||||
|
||||
return (int) round($weightedScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Reife-Stufe des Design Systems zurück
|
||||
*/
|
||||
public function getMaturityLevel(): string
|
||||
{
|
||||
$score = $this->getOverallDesignSystemScore();
|
||||
$tokenUsage = $this->tokenAnalysis->getTokenCoverage()['usage_percentage'];
|
||||
$hasComponents = $this->componentAnalysis->totalComponents > 0;
|
||||
$colorConsistency = $this->colorAnalysis->getConsistencyScore();
|
||||
|
||||
return match(true) {
|
||||
$score >= 90 && $tokenUsage >= 80 && $hasComponents && $colorConsistency >= 80 => 'mature',
|
||||
$score >= 75 && $tokenUsage >= 60 && $hasComponents => 'established',
|
||||
$score >= 60 && ($tokenUsage >= 40 || $hasComponents) => 'developing',
|
||||
$score >= 40 => 'emerging',
|
||||
default => 'basic'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt kritische Problembereiche zurück
|
||||
*/
|
||||
public function getCriticalIssues(): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Token-Probleme
|
||||
if ($this->tokenAnalysis->totalTokens === 0) {
|
||||
$issues[] = [
|
||||
'severity' => 'high',
|
||||
'category' => 'tokens',
|
||||
'issue' => 'No design tokens found',
|
||||
'impact' => 'Inconsistent styling and difficult maintenance',
|
||||
'recommendation' => 'Implement CSS custom properties for colors, spacing, and typography',
|
||||
];
|
||||
} elseif (count($this->tokenAnalysis->unusedTokens) > $this->tokenAnalysis->totalTokens * 0.5) {
|
||||
$issues[] = [
|
||||
'severity' => 'medium',
|
||||
'category' => 'tokens',
|
||||
'issue' => 'Many unused design tokens',
|
||||
'impact' => 'Bloated CSS and confusing token system',
|
||||
'recommendation' => 'Remove unused tokens and audit token usage',
|
||||
];
|
||||
}
|
||||
|
||||
// Component-Probleme
|
||||
if ($this->componentAnalysis->getConsistencyScore() < 50) {
|
||||
$issues[] = [
|
||||
'severity' => 'high',
|
||||
'category' => 'components',
|
||||
'issue' => 'Inconsistent component patterns',
|
||||
'impact' => 'Poor maintainability and team confusion',
|
||||
'recommendation' => 'Standardize on one CSS methodology (BEM, Utility-first, etc.)',
|
||||
];
|
||||
}
|
||||
|
||||
// Convention-Probleme
|
||||
$highSeverityViolations = $this->conventionAnalysis->getViolationsBySeverity()['high'];
|
||||
if (! empty($highSeverityViolations)) {
|
||||
$issues[] = [
|
||||
'severity' => 'high',
|
||||
'category' => 'conventions',
|
||||
'issue' => count($highSeverityViolations) . ' high-severity convention violations',
|
||||
'impact' => 'Accessibility and usability problems',
|
||||
'recommendation' => 'Address accessibility issues, especially focus styles and contrast',
|
||||
];
|
||||
}
|
||||
|
||||
// Farb-Probleme
|
||||
$worstContrastPairs = $this->colorAnalysis->getWorstContrastPairs();
|
||||
if (! empty($worstContrastPairs)) {
|
||||
$issues[] = [
|
||||
'severity' => 'high',
|
||||
'category' => 'colors',
|
||||
'issue' => count($worstContrastPairs) . ' color combinations fail WCAG contrast requirements',
|
||||
'impact' => 'Poor accessibility for users with visual impairments',
|
||||
'recommendation' => 'Improve color contrast ratios to meet WCAG AA standards',
|
||||
];
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Quick-Win-Verbesserungen zurück
|
||||
*/
|
||||
public function getQuickWins(): array
|
||||
{
|
||||
$quickWins = [];
|
||||
|
||||
// Duplikate entfernen
|
||||
if (! empty($this->colorAnalysis->duplicateColors)) {
|
||||
$totalDuplicates = array_sum(array_column($this->colorAnalysis->duplicateColors, 'potential_savings'));
|
||||
$quickWins[] = [
|
||||
'effort' => 'low',
|
||||
'impact' => 'medium',
|
||||
'action' => "Remove {$totalDuplicates} duplicate colors",
|
||||
'benefit' => 'Smaller CSS size and cleaner color palette',
|
||||
'estimated_time' => '1-2 hours',
|
||||
];
|
||||
}
|
||||
|
||||
// Unbenutzte Tokens
|
||||
if (count($this->tokenAnalysis->unusedTokens) > 0) {
|
||||
$unusedCount = count($this->tokenAnalysis->unusedTokens);
|
||||
$quickWins[] = [
|
||||
'effort' => 'low',
|
||||
'impact' => 'low',
|
||||
'action' => "Remove {$unusedCount} unused design tokens",
|
||||
'benefit' => 'Cleaner token system and reduced CSS complexity',
|
||||
'estimated_time' => '30 minutes',
|
||||
];
|
||||
}
|
||||
|
||||
// Naming-Konventionen
|
||||
if ($this->conventionAnalysis->categoryScores['naming'] < 70) {
|
||||
$quickWins[] = [
|
||||
'effort' => 'medium',
|
||||
'impact' => 'high',
|
||||
'action' => 'Standardize CSS class naming conventions',
|
||||
'benefit' => 'Better maintainability and team consistency',
|
||||
'estimated_time' => '2-4 hours',
|
||||
];
|
||||
}
|
||||
|
||||
// Fehlende Standard-Tokens
|
||||
if (! empty($this->tokenAnalysis->missingTokens)) {
|
||||
$quickWins[] = [
|
||||
'effort' => 'medium',
|
||||
'impact' => 'high',
|
||||
'action' => 'Add missing standard design tokens',
|
||||
'benefit' => 'More complete design system and better consistency',
|
||||
'estimated_time' => '1-3 hours',
|
||||
];
|
||||
}
|
||||
|
||||
return $quickWins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Entwicklungsroadmap zurück
|
||||
*/
|
||||
public function getDevelopmentRoadmap(): array
|
||||
{
|
||||
$maturity = $this->getMaturityLevel();
|
||||
|
||||
$roadmap = match($maturity) {
|
||||
'basic' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Foundation Setup',
|
||||
'duration' => '1-2 weeks',
|
||||
'tasks' => [
|
||||
'Establish CSS custom properties for core colors',
|
||||
'Define basic spacing scale',
|
||||
'Standardize naming conventions',
|
||||
'Create utility classes for common patterns',
|
||||
],
|
||||
],
|
||||
'phase_2' => [
|
||||
'title' => 'Component Library',
|
||||
'duration' => '2-3 weeks',
|
||||
'tasks' => [
|
||||
'Build core component patterns',
|
||||
'Document component usage',
|
||||
'Implement accessibility standards',
|
||||
'Create component naming guidelines',
|
||||
],
|
||||
],
|
||||
],
|
||||
'emerging' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Standardization',
|
||||
'duration' => '1 week',
|
||||
'tasks' => [
|
||||
'Fix critical accessibility issues',
|
||||
'Consolidate duplicate colors',
|
||||
'Improve token usage consistency',
|
||||
'Document existing patterns',
|
||||
],
|
||||
],
|
||||
'phase_2' => [
|
||||
'title' => 'Enhancement',
|
||||
'duration' => '2 weeks',
|
||||
'tasks' => [
|
||||
'Expand design token system',
|
||||
'Improve component consistency',
|
||||
'Add semantic color tokens',
|
||||
'Create design guidelines',
|
||||
],
|
||||
],
|
||||
],
|
||||
'developing' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Optimization',
|
||||
'duration' => '3-5 days',
|
||||
'tasks' => [
|
||||
'Remove unused tokens and styles',
|
||||
'Improve color contrast compliance',
|
||||
'Optimize component patterns',
|
||||
'Enhance documentation',
|
||||
],
|
||||
],
|
||||
],
|
||||
'established' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Advanced Features',
|
||||
'duration' => '1 week',
|
||||
'tasks' => [
|
||||
'Implement advanced color formats (OKLCH)',
|
||||
'Create automated design token generation',
|
||||
'Add theme switching capabilities',
|
||||
'Implement design system automation',
|
||||
],
|
||||
],
|
||||
],
|
||||
'mature' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Maintenance & Innovation',
|
||||
'duration' => 'Ongoing',
|
||||
'tasks' => [
|
||||
'Monitor design system metrics',
|
||||
'Implement advanced automation',
|
||||
'Research emerging CSS features',
|
||||
'Mentor other teams',
|
||||
],
|
||||
],
|
||||
]
|
||||
};
|
||||
|
||||
return [
|
||||
'current_maturity' => $maturity,
|
||||
'next_level' => $this->getNextMaturityLevel($maturity),
|
||||
'phases' => $roadmap,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert vollständigen Report
|
||||
*/
|
||||
public function exportReport(): array
|
||||
{
|
||||
return [
|
||||
'meta' => [
|
||||
'generated_at' => date('c'),
|
||||
'analyzed_files' => count($this->sourceFiles),
|
||||
'total_css_size' => $this->overallStatistics['total_content_size'],
|
||||
'analysis_version' => '1.0',
|
||||
],
|
||||
'summary' => [
|
||||
'overall_score' => $this->getOverallDesignSystemScore(),
|
||||
'maturity_level' => $this->getMaturityLevel(),
|
||||
'critical_issues_count' => count($this->getCriticalIssues()),
|
||||
'quick_wins_count' => count($this->getQuickWins()),
|
||||
],
|
||||
'detailed_analysis' => [
|
||||
'token_analysis' => $this->tokenAnalysis->toArray(),
|
||||
'component_analysis' => $this->componentAnalysis->toArray(),
|
||||
'convention_analysis' => $this->conventionAnalysis->toArray(),
|
||||
'color_analysis' => $this->colorAnalysis->toArray(),
|
||||
],
|
||||
'recommendations' => [
|
||||
'critical_issues' => $this->getCriticalIssues(),
|
||||
'quick_wins' => $this->getQuickWins(),
|
||||
'development_roadmap' => $this->getDevelopmentRoadmap(),
|
||||
],
|
||||
'statistics' => $this->overallStatistics,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Token-Score
|
||||
*/
|
||||
private function getTokenScore(): int
|
||||
{
|
||||
if ($this->tokenAnalysis->totalTokens === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$coverage = $this->tokenAnalysis->getTokenCoverage()['usage_percentage'];
|
||||
$missingTokenPenalty = count($this->tokenAnalysis->missingTokens) * 5;
|
||||
|
||||
return max(0, (int) ($coverage - $missingTokenPenalty));
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Component-Score
|
||||
*/
|
||||
private function getComponentScore(): int
|
||||
{
|
||||
if ($this->componentAnalysis->totalComponents === 0) {
|
||||
return 20; // Basis-Score für keine Components
|
||||
}
|
||||
|
||||
$consistency = $this->componentAnalysis->getConsistencyScore();
|
||||
$diversity = 100 - $this->componentAnalysis->getPatternDiversity(); // Weniger Diversity ist besser für Konsistenz
|
||||
|
||||
return (int) (($consistency * 0.7) + ($diversity * 0.3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Color-Score
|
||||
*/
|
||||
private function getColorScore(): int
|
||||
{
|
||||
if ($this->colorAnalysis->totalColors === 0) {
|
||||
return 30; // Basis-Score für keine Farben
|
||||
}
|
||||
|
||||
$consistency = $this->colorAnalysis->getConsistencyScore();
|
||||
$contrastCompliance = $this->colorAnalysis->getContrastComplianceScore();
|
||||
|
||||
return (int) (($consistency * 0.4) + ($contrastCompliance * 0.6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt nächste Reife-Stufe
|
||||
*/
|
||||
private function getNextMaturityLevel(string $current): string
|
||||
{
|
||||
return match($current) {
|
||||
'basic' => 'emerging',
|
||||
'emerging' => 'developing',
|
||||
'developing' => 'established',
|
||||
'established' => 'mature',
|
||||
'mature' => 'mature', // Already at top level
|
||||
default => 'emerging'
|
||||
};
|
||||
}
|
||||
}
|
||||
174
src/Framework/Design/Analyzer/DesignSystemAnalyzer.php
Normal file
174
src/Framework/Design/Analyzer/DesignSystemAnalyzer.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\Parser\CssParser;
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Haupt-Analyzer für das Design System
|
||||
*/
|
||||
final readonly class DesignSystemAnalyzer
|
||||
{
|
||||
public function __construct(
|
||||
private CssParser $cssParser = new CssParser(),
|
||||
private TokenAnalyzer $tokenAnalyzer = new TokenAnalyzer(),
|
||||
private ComponentDetector $componentDetector = new ComponentDetector(),
|
||||
private ConventionChecker $conventionChecker = new ConventionChecker(),
|
||||
private ColorAnalyzer $colorAnalyzer = new ColorAnalyzer()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert ein komplettes Design System
|
||||
*/
|
||||
public function analyzeDesignSystem(string|array $cssFiles): DesignSystemAnalysis
|
||||
{
|
||||
$files = is_array($cssFiles) ? $cssFiles : [$cssFiles];
|
||||
$parseResults = [];
|
||||
|
||||
// Parse alle CSS-Dateien
|
||||
foreach ($files as $file) {
|
||||
$filePath = $file instanceof FilePath ? $file : FilePath::create($file);
|
||||
$parseResults[] = $this->cssParser->parseFile($filePath);
|
||||
}
|
||||
|
||||
// Kombiniere alle Ergebnisse
|
||||
$combinedResult = $this->combineParseResults($parseResults);
|
||||
|
||||
// Durchführung der verschiedenen Analysen
|
||||
$tokenAnalysis = $this->tokenAnalyzer->analyze($combinedResult);
|
||||
$componentAnalysis = $this->componentDetector->detectComponents($combinedResult);
|
||||
$conventionAnalysis = $this->conventionChecker->checkConventions($combinedResult);
|
||||
$colorAnalysis = $this->colorAnalyzer->analyzeColors($combinedResult);
|
||||
|
||||
return new DesignSystemAnalysis(
|
||||
sourceFiles: array_map(fn ($result) => $result->sourceFile, $parseResults),
|
||||
parseResults: $parseResults,
|
||||
combinedResult: $combinedResult,
|
||||
tokenAnalysis: $tokenAnalysis,
|
||||
componentAnalysis: $componentAnalysis,
|
||||
conventionAnalysis: $conventionAnalysis,
|
||||
colorAnalysis: $colorAnalysis,
|
||||
overallStatistics: $this->calculateOverallStatistics($parseResults)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert ein einzelnes CSS-Verzeichnis
|
||||
*/
|
||||
public function analyzeDirectory(string $directory, bool $recursive = true): DesignSystemAnalysis
|
||||
{
|
||||
$parseResults = $this->cssParser->parseDirectory($directory, $recursive);
|
||||
|
||||
if (empty($parseResults)) {
|
||||
throw new \InvalidArgumentException("No CSS files found in directory: $directory");
|
||||
}
|
||||
|
||||
$combinedResult = $this->combineParseResults($parseResults);
|
||||
|
||||
return new DesignSystemAnalysis(
|
||||
sourceFiles: array_map(fn ($result) => $result->sourceFile, $parseResults),
|
||||
parseResults: $parseResults,
|
||||
combinedResult: $combinedResult,
|
||||
tokenAnalysis: $this->tokenAnalyzer->analyze($combinedResult),
|
||||
componentAnalysis: $this->componentDetector->detectComponents($combinedResult),
|
||||
conventionAnalysis: $this->conventionChecker->checkConventions($combinedResult),
|
||||
colorAnalysis: $this->colorAnalyzer->analyzeColors($combinedResult),
|
||||
overallStatistics: $this->calculateOverallStatistics($parseResults)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnelle Analyse nur für Statistiken
|
||||
*/
|
||||
public function quickAnalyze(string|array $cssFiles): array
|
||||
{
|
||||
$files = is_array($cssFiles) ? $cssFiles : [$cssFiles];
|
||||
$statistics = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filePath = $file instanceof FilePath ? $file : FilePath::create($file);
|
||||
$result = $this->cssParser->parseFile($filePath);
|
||||
$statistics[] = $result->getStatistics();
|
||||
}
|
||||
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kombiniert mehrere Parse-Ergebnisse zu einem Gesamt-Ergebnis
|
||||
*/
|
||||
private function combineParseResults(array $parseResults): CssParseResult
|
||||
{
|
||||
if (empty($parseResults)) {
|
||||
throw new \InvalidArgumentException('No parse results to combine');
|
||||
}
|
||||
|
||||
if (count($parseResults) === 1) {
|
||||
return $parseResults[0];
|
||||
}
|
||||
|
||||
$allRules = [];
|
||||
$allCustomProperties = [];
|
||||
$allClassNames = [];
|
||||
$combinedContent = '';
|
||||
|
||||
foreach ($parseResults as $result) {
|
||||
$allRules = array_merge($allRules, $result->rules);
|
||||
$allCustomProperties = array_merge($allCustomProperties, $result->customProperties);
|
||||
$allClassNames = array_merge($allClassNames, $result->classNames);
|
||||
$combinedContent .= $result->rawContent . "\n";
|
||||
}
|
||||
|
||||
return new CssParseResult(
|
||||
sourceFile: null, // Combined result has no single source
|
||||
rules: $allRules,
|
||||
customProperties: $allCustomProperties,
|
||||
classNames: $allClassNames,
|
||||
rawContent: $combinedContent
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Gesamt-Statistiken für alle Dateien
|
||||
*/
|
||||
private function calculateOverallStatistics(array $parseResults): array
|
||||
{
|
||||
$totalStats = [
|
||||
'total_files' => count($parseResults),
|
||||
'total_rules' => 0,
|
||||
'total_selectors' => 0,
|
||||
'total_properties' => 0,
|
||||
'total_design_tokens' => 0,
|
||||
'total_class_names' => 0,
|
||||
'total_content_size' => 0,
|
||||
'file_breakdown' => [],
|
||||
];
|
||||
|
||||
foreach ($parseResults as $result) {
|
||||
$stats = $result->getStatistics();
|
||||
|
||||
$totalStats['total_rules'] += $stats['total_rules'];
|
||||
$totalStats['total_selectors'] += $stats['total_selectors'];
|
||||
$totalStats['total_properties'] += $stats['total_properties'];
|
||||
$totalStats['total_design_tokens'] += $stats['design_tokens'];
|
||||
$totalStats['total_class_names'] += $stats['class_names'];
|
||||
$totalStats['total_content_size'] += $stats['content_size_bytes'];
|
||||
|
||||
$totalStats['file_breakdown'][] = $stats;
|
||||
}
|
||||
|
||||
// Durchschnittliche Werte berechnen
|
||||
$fileCount = max(1, count($parseResults));
|
||||
$totalStats['avg_rules_per_file'] = round($totalStats['total_rules'] / $fileCount, 2);
|
||||
$totalStats['avg_properties_per_file'] = round($totalStats['total_properties'] / $fileCount, 2);
|
||||
$totalStats['avg_tokens_per_file'] = round($totalStats['total_design_tokens'] / $fileCount, 2);
|
||||
$totalStats['avg_classes_per_file'] = round($totalStats['total_class_names'] / $fileCount, 2);
|
||||
|
||||
return $totalStats;
|
||||
}
|
||||
}
|
||||
93
src/Framework/Design/Analyzer/TokenAnalysisResult.php
Normal file
93
src/Framework/Design/Analyzer/TokenAnalysisResult.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
/**
|
||||
* Ergebnis der Token-Analyse
|
||||
*/
|
||||
final readonly class TokenAnalysisResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $totalTokens,
|
||||
public array $tokensByType,
|
||||
public array $tokenHierarchy,
|
||||
public array $unusedTokens,
|
||||
public array $missingTokens,
|
||||
public array $tokenUsage,
|
||||
public array $recommendations,
|
||||
public array $usedTokens = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die häufigsten Token-Typen zurück
|
||||
*/
|
||||
public function getMostUsedTokenTypes(): array
|
||||
{
|
||||
$usage = [];
|
||||
|
||||
foreach ($this->tokensByType as $type => $data) {
|
||||
$usage[$type] = $data['count'];
|
||||
}
|
||||
|
||||
arsort($usage);
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Token-Coverage-Statistiken zurück
|
||||
*/
|
||||
public function getTokenCoverage(): array
|
||||
{
|
||||
$usedTokens = count($this->tokenUsage);
|
||||
$totalTokens = $this->totalTokens;
|
||||
|
||||
return [
|
||||
'used_tokens' => $usedTokens,
|
||||
'unused_tokens' => count($this->unusedTokens),
|
||||
'total_tokens' => $totalTokens,
|
||||
'usage_percentage' => $totalTokens > 0 ? round(($usedTokens / $totalTokens) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Empfehlungs-Prioritäten zurück
|
||||
*/
|
||||
public function getRecommendationPriorities(): array
|
||||
{
|
||||
$priorities = [];
|
||||
|
||||
foreach ($this->recommendations as $recommendation) {
|
||||
if (str_contains($recommendation, 'Remove')) {
|
||||
$priorities[] = ['level' => 'high', 'text' => $recommendation];
|
||||
} elseif (str_contains($recommendation, 'Consider adding')) {
|
||||
$priorities[] = ['level' => 'medium', 'text' => $recommendation];
|
||||
} else {
|
||||
$priorities[] = ['level' => 'low', 'text' => $recommendation];
|
||||
}
|
||||
}
|
||||
|
||||
return $priorities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_tokens' => $this->totalTokens,
|
||||
'tokens_by_type' => $this->tokensByType,
|
||||
'token_hierarchy' => $this->tokenHierarchy,
|
||||
'unused_tokens' => array_map(fn ($token) => $token->toArray(), $this->unusedTokens),
|
||||
'missing_tokens' => $this->missingTokens,
|
||||
'token_usage' => $this->tokenUsage,
|
||||
'recommendations' => $this->recommendations,
|
||||
'coverage' => $this->getTokenCoverage(),
|
||||
'priorities' => $this->getRecommendationPriorities(),
|
||||
];
|
||||
}
|
||||
}
|
||||
320
src/Framework/Design/Analyzer/TokenAnalyzer.php
Normal file
320
src/Framework/Design/Analyzer/TokenAnalyzer.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
use App\Framework\Design\ValueObjects\DesignToken;
|
||||
use App\Framework\Design\ValueObjects\DesignTokenType;
|
||||
|
||||
/**
|
||||
* Analyzer für Design Tokens
|
||||
*/
|
||||
final readonly class TokenAnalyzer
|
||||
{
|
||||
/**
|
||||
* Analysiert Design Tokens im CSS
|
||||
*/
|
||||
public function analyze(CssParseResult $parseResult): TokenAnalysisResult
|
||||
{
|
||||
$tokens = $parseResult->customProperties;
|
||||
|
||||
if (empty($tokens)) {
|
||||
return new TokenAnalysisResult(
|
||||
totalTokens: 0,
|
||||
tokensByType: [],
|
||||
tokenHierarchy: [],
|
||||
unusedTokens: [],
|
||||
missingTokens: [],
|
||||
tokenUsage: [],
|
||||
recommendations: ['No design tokens found. Consider implementing CSS custom properties for better maintainability.']
|
||||
);
|
||||
}
|
||||
|
||||
// Analysiere Token-Typen
|
||||
$tokensByType = $this->groupTokensByType($tokens);
|
||||
|
||||
// Analysiere Token-Hierarchie
|
||||
$tokenHierarchy = $this->analyzeTokenHierarchy($tokens);
|
||||
|
||||
// Analysiere Token-Usage
|
||||
$tokenUsage = $this->analyzeTokenUsage($parseResult, $tokens);
|
||||
|
||||
// Finde unbenutzte Tokens
|
||||
$unusedTokens = $this->findUnusedTokens($tokens, $tokenUsage);
|
||||
|
||||
// Finde verwendete Tokens (alle Tokens minus unbenutzte)
|
||||
$usedTokens = array_filter($tokens, fn($token) => !in_array($token, $unusedTokens, true));
|
||||
|
||||
// Finde fehlende Standard-Tokens
|
||||
$missingTokens = $this->findMissingStandardTokens($tokensByType);
|
||||
|
||||
// Generiere Empfehlungen
|
||||
$recommendations = $this->generateRecommendations($tokensByType, $unusedTokens, $missingTokens);
|
||||
|
||||
return new TokenAnalysisResult(
|
||||
totalTokens: count($tokens),
|
||||
tokensByType: $tokensByType,
|
||||
tokenHierarchy: $tokenHierarchy,
|
||||
unusedTokens: $unusedTokens,
|
||||
missingTokens: $missingTokens,
|
||||
tokenUsage: $tokenUsage,
|
||||
recommendations: $recommendations,
|
||||
usedTokens: $usedTokens
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Tokens nach Typ
|
||||
*/
|
||||
private function groupTokensByType(array $tokens): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$type = $token->type->value;
|
||||
|
||||
if (! isset($grouped[$type])) {
|
||||
$grouped[$type] = [
|
||||
'count' => 0,
|
||||
'tokens' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$grouped[$type]['count']++;
|
||||
$grouped[$type]['tokens'][] = $token;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Token-Hierarchie (basierend auf Naming)
|
||||
*/
|
||||
private function analyzeTokenHierarchy(array $tokens): array
|
||||
{
|
||||
$hierarchy = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$nameParts = explode('-', $token->name);
|
||||
$current = &$hierarchy;
|
||||
|
||||
foreach ($nameParts as $part) {
|
||||
if (! isset($current[$part])) {
|
||||
$current[$part] = [
|
||||
'tokens' => [],
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
$current = &$current[$part]['children'];
|
||||
}
|
||||
|
||||
// Token zur letzten Ebene hinzufügen
|
||||
$lastPart = end($nameParts);
|
||||
if (isset($hierarchy[$lastPart])) {
|
||||
$hierarchy[$lastPart]['tokens'][] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->cleanupHierarchy($hierarchy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Token-Usage in CSS Rules
|
||||
*/
|
||||
private function analyzeTokenUsage(CssParseResult $parseResult, array $tokens): array
|
||||
{
|
||||
$usage = [];
|
||||
$tokenNames = array_map(fn (DesignToken $token) => $token->name, $tokens);
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->usesCustomProperty()) {
|
||||
$refs = $property->getCustomPropertyReferences();
|
||||
|
||||
foreach ($refs as $ref) {
|
||||
if (in_array($ref, $tokenNames)) {
|
||||
if (! isset($usage[$ref])) {
|
||||
$usage[$ref] = [
|
||||
'count' => 0,
|
||||
'properties' => [],
|
||||
'selectors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$usage[$ref]['count']++;
|
||||
$usage[$ref]['properties'][] = $property->name;
|
||||
$usage[$ref]['selectors'] = array_merge(
|
||||
$usage[$ref]['selectors'],
|
||||
$rule->getSelectorStrings()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate arrays
|
||||
foreach ($usage as $tokenName => $data) {
|
||||
$usage[$tokenName]['properties'] = array_unique($data['properties']);
|
||||
$usage[$tokenName]['selectors'] = array_unique($data['selectors']);
|
||||
}
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet unbenutzte Tokens
|
||||
*/
|
||||
private function findUnusedTokens(array $tokens, array $usage): array
|
||||
{
|
||||
$unused = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (! isset($usage[$token->name])) {
|
||||
$unused[] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
return $unused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet fehlende Standard-Tokens
|
||||
*/
|
||||
private function findMissingStandardTokens(array $tokensByType): array
|
||||
{
|
||||
$standardTokens = [
|
||||
DesignTokenType::COLOR->value => [
|
||||
'primary', 'secondary', 'success', 'warning', 'error', 'info',
|
||||
'background', 'surface', 'text-primary', 'text-secondary',
|
||||
],
|
||||
DesignTokenType::SPACING->value => [
|
||||
'xs', 'sm', 'md', 'lg', 'xl', '2xl',
|
||||
'gap-xs', 'gap-sm', 'gap-md', 'gap-lg',
|
||||
],
|
||||
DesignTokenType::TYPOGRAPHY->value => [
|
||||
'font-primary', 'font-secondary',
|
||||
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl',
|
||||
'weight-normal', 'weight-bold',
|
||||
],
|
||||
DesignTokenType::RADIUS->value => [
|
||||
'radius-sm', 'radius-md', 'radius-lg', 'radius-full',
|
||||
],
|
||||
DesignTokenType::SHADOW->value => [
|
||||
'shadow-sm', 'shadow-md', 'shadow-lg',
|
||||
],
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($standardTokens as $type => $expectedTokens) {
|
||||
if (! isset($tokensByType[$type])) {
|
||||
$missing[$type] = $expectedTokens;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingNames = array_map(
|
||||
fn (DesignToken $token) => $token->name,
|
||||
$tokensByType[$type]['tokens']
|
||||
);
|
||||
|
||||
$missingInType = array_diff($expectedTokens, $existingNames);
|
||||
|
||||
if (! empty($missingInType)) {
|
||||
$missing[$type] = $missingInType;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Empfehlungen basierend auf der Analyse
|
||||
*/
|
||||
private function generateRecommendations(array $tokensByType, array $unusedTokens, array $missingTokens): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
// Empfehlungen für unbenutzte Tokens
|
||||
if (! empty($unusedTokens)) {
|
||||
$recommendations[] = sprintf(
|
||||
'Remove %d unused design tokens to reduce CSS complexity.',
|
||||
count($unusedTokens)
|
||||
);
|
||||
}
|
||||
|
||||
// Empfehlungen für fehlende Tokens
|
||||
if (! empty($missingTokens)) {
|
||||
foreach ($missingTokens as $type => $tokens) {
|
||||
$recommendations[] = sprintf(
|
||||
'Consider adding %s tokens: %s',
|
||||
$type,
|
||||
implode(', ', array_slice($tokens, 0, 3)) . (count($tokens) > 3 ? '...' : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Empfehlungen für Token-Balance
|
||||
$totalTokens = array_sum(array_column($tokensByType, 'count'));
|
||||
if ($totalTokens > 0) {
|
||||
foreach ($tokensByType as $type => $data) {
|
||||
$percentage = round(($data['count'] / $totalTokens) * 100, 1);
|
||||
|
||||
if ($type === 'color' && $percentage > 50) {
|
||||
$recommendations[] = 'High percentage of color tokens detected. Consider consolidating similar colors.';
|
||||
}
|
||||
|
||||
if ($type === 'spacing' && $percentage < 10 && $totalTokens > 10) {
|
||||
$recommendations[] = 'Low percentage of spacing tokens. Consider adding more consistent spacing values.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empfehlungen für Naming Conventions
|
||||
if (isset($tokensByType[DesignTokenType::COLOR->value])) {
|
||||
$colorTokens = $tokensByType[DesignTokenType::COLOR->value]['tokens'];
|
||||
$semanticNames = 0;
|
||||
$literalNames = 0;
|
||||
|
||||
foreach ($colorTokens as $token) {
|
||||
if (preg_match('/^(primary|secondary|success|warning|error|info|background|surface|text)/', $token->name)) {
|
||||
$semanticNames++;
|
||||
} elseif (preg_match('/^(red|blue|green|yellow|orange|purple|gray|black|white)/', $token->name)) {
|
||||
$literalNames++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($literalNames > $semanticNames) {
|
||||
$recommendations[] = 'Consider using more semantic color names (primary, secondary) instead of literal names (red, blue).';
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($recommendations)) {
|
||||
$recommendations[] = 'Design token structure looks good! Consider documenting token usage guidelines.';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt die Hierarchie von leeren Einträgen
|
||||
*/
|
||||
private function cleanupHierarchy(array $hierarchy): array
|
||||
{
|
||||
$cleaned = [];
|
||||
|
||||
foreach ($hierarchy as $key => $value) {
|
||||
if (! empty($value['tokens']) || ! empty($value['children'])) {
|
||||
$cleaned[$key] = [
|
||||
'tokens' => $value['tokens'],
|
||||
'children' => $this->cleanupHierarchy($value['children']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $cleaned;
|
||||
}
|
||||
}
|
||||
115
src/Framework/Design/Component/Component.php
Normal file
115
src/Framework/Design/Component/Component.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Component;
|
||||
|
||||
final readonly class Component
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $selector,
|
||||
public string $cssRules,
|
||||
public ComponentCategory $category,
|
||||
public ComponentPattern $pattern,
|
||||
public ComponentState $state,
|
||||
public string $filePath
|
||||
) {}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return md5($this->selector . $this->filePath);
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return ucfirst(str_replace(['-', '_'], ' ', $this->name));
|
||||
}
|
||||
|
||||
public function getPreviewHtml(): string
|
||||
{
|
||||
return match($this->category) {
|
||||
ComponentCategory::BUTTON => $this->generateButtonPreview(),
|
||||
ComponentCategory::NAVIGATION => $this->generateNavigationPreview(),
|
||||
ComponentCategory::FORM => $this->generateFormPreview(),
|
||||
ComponentCategory::CARD => $this->generateCardPreview(),
|
||||
ComponentCategory::FEEDBACK => $this->generateFeedbackPreview(),
|
||||
ComponentCategory::LAYOUT => $this->generateLayoutPreview(),
|
||||
ComponentCategory::TYPOGRAPHY => $this->generateTypographyPreview(),
|
||||
default => $this->generateDefaultPreview(),
|
||||
};
|
||||
}
|
||||
|
||||
private function generateButtonPreview(): string
|
||||
{
|
||||
$text = match(true) {
|
||||
str_contains($this->name, 'primary') => 'Primary Button',
|
||||
str_contains($this->name, 'secondary') => 'Secondary Button',
|
||||
str_contains($this->name, 'success') => 'Success Button',
|
||||
str_contains($this->name, 'danger') => 'Danger Button',
|
||||
str_contains($this->name, 'warning') => 'Warning Button',
|
||||
default => 'Button',
|
||||
};
|
||||
|
||||
return "<button class=\"{$this->name}\">{$text}</button>";
|
||||
}
|
||||
|
||||
private function generateNavigationPreview(): string
|
||||
{
|
||||
if (str_contains($this->name, 'nav')) {
|
||||
return "<nav class=\"{$this->name}\"><a href=\"#\">Home</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>";
|
||||
}
|
||||
|
||||
return "<div class=\"{$this->name}\">Navigation Item</div>";
|
||||
}
|
||||
|
||||
private function generateFormPreview(): string
|
||||
{
|
||||
if (str_contains($this->name, 'input')) {
|
||||
return "<input type=\"text\" class=\"{$this->name}\" placeholder=\"Enter text...\">";
|
||||
}
|
||||
|
||||
if (str_contains($this->name, 'select')) {
|
||||
return "<select class=\"{$this->name}\"><option>Option 1</option><option>Option 2</option></select>";
|
||||
}
|
||||
|
||||
return "<div class=\"{$this->name}\">Form Element</div>";
|
||||
}
|
||||
|
||||
private function generateCardPreview(): string
|
||||
{
|
||||
return "<div class=\"{$this->name}\"><h3>Card Title</h3><p>Card content goes here...</p></div>";
|
||||
}
|
||||
|
||||
private function generateFeedbackPreview(): string
|
||||
{
|
||||
$message = match(true) {
|
||||
str_contains($this->name, 'success') => 'Success! Operation completed successfully.',
|
||||
str_contains($this->name, 'error') || str_contains($this->name, 'danger') => 'Error! Something went wrong.',
|
||||
str_contains($this->name, 'warning') => 'Warning! Please check your input.',
|
||||
str_contains($this->name, 'info') => 'Info: Here is some information.',
|
||||
default => 'Alert message goes here.',
|
||||
};
|
||||
|
||||
return "<div class=\"{$this->name}\">{$message}</div>";
|
||||
}
|
||||
|
||||
private function generateLayoutPreview(): string
|
||||
{
|
||||
return "<div class=\"{$this->name}\"><div>Layout Item 1</div><div>Layout Item 2</div></div>";
|
||||
}
|
||||
|
||||
private function generateTypographyPreview(): string
|
||||
{
|
||||
if (str_contains($this->name, 'heading') || str_contains($this->name, 'title')) {
|
||||
return "<h2 class=\"{$this->name}\">Heading Example</h2>";
|
||||
}
|
||||
|
||||
return "<p class=\"{$this->name}\">Typography example text goes here.</p>";
|
||||
}
|
||||
|
||||
private function generateDefaultPreview(): string
|
||||
{
|
||||
return "<div class=\"{$this->name}\">Component Preview</div>";
|
||||
}
|
||||
}
|
||||
45
src/Framework/Design/Component/ComponentCategory.php
Normal file
45
src/Framework/Design/Component/ComponentCategory.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Component;
|
||||
|
||||
enum ComponentCategory: string
|
||||
{
|
||||
case BUTTON = 'button';
|
||||
case NAVIGATION = 'navigation';
|
||||
case FORM = 'form';
|
||||
case CARD = 'card';
|
||||
case FEEDBACK = 'feedback';
|
||||
case LAYOUT = 'layout';
|
||||
case TYPOGRAPHY = 'typography';
|
||||
case OTHER = 'other';
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::BUTTON => 'Buttons',
|
||||
self::NAVIGATION => 'Navigation',
|
||||
self::FORM => 'Form Elements',
|
||||
self::CARD => 'Cards & Panels',
|
||||
self::FEEDBACK => 'Alerts & Messages',
|
||||
self::LAYOUT => 'Layout Components',
|
||||
self::TYPOGRAPHY => 'Typography',
|
||||
self::OTHER => 'Other Components',
|
||||
};
|
||||
}
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::BUTTON => '🔘',
|
||||
self::NAVIGATION => '🧭',
|
||||
self::FORM => '📝',
|
||||
self::CARD => '🃏',
|
||||
self::FEEDBACK => '💬',
|
||||
self::LAYOUT => '📐',
|
||||
self::TYPOGRAPHY => '📖',
|
||||
self::OTHER => '🧩',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
src/Framework/Design/Component/ComponentPattern.php
Normal file
30
src/Framework/Design/Component/ComponentPattern.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Component;
|
||||
|
||||
enum ComponentPattern: string
|
||||
{
|
||||
case BEM = 'bem';
|
||||
case UTILITY = 'utility';
|
||||
case TRADITIONAL = 'traditional';
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::BEM => 'BEM Methodology',
|
||||
self::UTILITY => 'Utility Classes',
|
||||
self::TRADITIONAL => 'Traditional CSS',
|
||||
};
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::BEM => 'Block__Element--Modifier naming convention',
|
||||
self::UTILITY => 'Single-purpose utility classes',
|
||||
self::TRADITIONAL => 'Classic CSS component approach',
|
||||
};
|
||||
}
|
||||
}
|
||||
98
src/Framework/Design/Component/ComponentRegistry.php
Normal file
98
src/Framework/Design/Component/ComponentRegistry.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Component;
|
||||
|
||||
final readonly class ComponentRegistry
|
||||
{
|
||||
/** @var Component[] */
|
||||
private array $components;
|
||||
|
||||
/** @param Component[] $components */
|
||||
public function __construct(array $components)
|
||||
{
|
||||
$this->components = $components;
|
||||
}
|
||||
|
||||
public function getAllComponents(): array
|
||||
{
|
||||
return $this->components;
|
||||
}
|
||||
|
||||
public function getByCategory(ComponentCategory $category): array
|
||||
{
|
||||
return array_filter($this->components, fn($c) => $c->category === $category);
|
||||
}
|
||||
|
||||
public function getByPattern(ComponentPattern $pattern): array
|
||||
{
|
||||
return array_filter($this->components, fn($c) => $c->pattern === $pattern);
|
||||
}
|
||||
|
||||
public function findByName(string $name): ?Component
|
||||
{
|
||||
foreach ($this->components as $component) {
|
||||
if ($component->name === $name) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getComponentVariants(string $baseName): array
|
||||
{
|
||||
return array_filter($this->components, fn($c) => str_starts_with($c->name, $baseName));
|
||||
}
|
||||
|
||||
public function getCategoryCounts(): array
|
||||
{
|
||||
$counts = [];
|
||||
foreach (ComponentCategory::cases() as $category) {
|
||||
$counts[$category->value] = count($this->getByCategory($category));
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
public function getPatternCounts(): array
|
||||
{
|
||||
$counts = [];
|
||||
foreach (ComponentPattern::cases() as $pattern) {
|
||||
$counts[$pattern->value] = count($this->getByPattern($pattern));
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
public function getTotalComponents(): int
|
||||
{
|
||||
return count($this->components);
|
||||
}
|
||||
|
||||
public function groupByCategory(): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach (ComponentCategory::cases() as $category) {
|
||||
$components = $this->getByCategory($category);
|
||||
if (!empty($components)) {
|
||||
$grouped[$category->value] = $components;
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
public function searchComponents(string $query): array
|
||||
{
|
||||
$query = strtolower($query);
|
||||
|
||||
return array_filter($this->components, function($component) use ($query) {
|
||||
return str_contains(strtolower($component->name), $query) ||
|
||||
str_contains(strtolower($component->getDisplayName()), $query) ||
|
||||
str_contains(strtolower($component->category->value), $query);
|
||||
});
|
||||
}
|
||||
}
|
||||
36
src/Framework/Design/Component/ComponentState.php
Normal file
36
src/Framework/Design/Component/ComponentState.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Component;
|
||||
|
||||
enum ComponentState: string
|
||||
{
|
||||
case DEFAULT = 'default';
|
||||
case HOVER = 'hover';
|
||||
case FOCUS = 'focus';
|
||||
case ACTIVE = 'active';
|
||||
case DISABLED = 'disabled';
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::DEFAULT => 'Default State',
|
||||
self::HOVER => 'Hover State',
|
||||
self::FOCUS => 'Focus State',
|
||||
self::ACTIVE => 'Active State',
|
||||
self::DISABLED => 'Disabled State',
|
||||
};
|
||||
}
|
||||
|
||||
public function getCssClass(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::DEFAULT => '',
|
||||
self::HOVER => ':hover',
|
||||
self::FOCUS => ':focus',
|
||||
self::ACTIVE => ':active',
|
||||
self::DISABLED => ':disabled',
|
||||
};
|
||||
}
|
||||
}
|
||||
258
src/Framework/Design/ComponentScanner.php
Normal file
258
src/Framework/Design/ComponentScanner.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design;
|
||||
|
||||
use App\Framework\Design\Component\Component;
|
||||
use App\Framework\Design\Component\ComponentCategory;
|
||||
use App\Framework\Design\Component\ComponentPattern;
|
||||
use App\Framework\Design\Component\ComponentRegistry;
|
||||
use App\Framework\Design\Component\ComponentState;
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Scans CSS files to detect and catalog UI components
|
||||
*/
|
||||
final readonly class ComponentScanner
|
||||
{
|
||||
public function scanComponents(array $cssFiles): ComponentRegistry
|
||||
{
|
||||
$components = [];
|
||||
|
||||
foreach ($cssFiles as $cssFile) {
|
||||
if (!$cssFile instanceof FilePath) {
|
||||
$cssFile = new FilePath($cssFile);
|
||||
}
|
||||
|
||||
if (!$cssFile->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cssContent = file_get_contents($cssFile->toString());
|
||||
if ($cssContent === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileComponents = $this->extractComponentsFromCss($cssContent, $cssFile->toString());
|
||||
$components = array_merge($components, $fileComponents);
|
||||
}
|
||||
|
||||
return new ComponentRegistry($components);
|
||||
}
|
||||
|
||||
private function extractComponentsFromCss(string $cssContent, string $filePath): array
|
||||
{
|
||||
$components = [];
|
||||
$processedComponents = [];
|
||||
|
||||
// Remove comments
|
||||
$cssContent = preg_replace('/\/\*.*?\*\//s', '', $cssContent);
|
||||
|
||||
// Find all CSS selectors with improved regex that handles nested braces
|
||||
preg_match_all('/([^{}]+)\s*{([^{}]*(?:{[^{}]*}[^{}]*)*)}/s', $cssContent, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$selectors = $match[1];
|
||||
$cssRules = trim($match[2]);
|
||||
|
||||
// Skip empty rules
|
||||
if (empty($cssRules)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clean and split selectors
|
||||
$selectorList = array_map('trim', explode(',', $selectors));
|
||||
|
||||
foreach ($selectorList as $selector) {
|
||||
// Clean up the selector
|
||||
$selector = trim($selector);
|
||||
|
||||
// Skip @rules, :root, and other non-component selectors
|
||||
if (strpos($selector, '@') === 0 || $selector === ':root' || empty($selector)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$component = $this->analyzeSelector($selector, $cssRules, $filePath);
|
||||
if ($component !== null) {
|
||||
// Avoid duplicates based on component name
|
||||
$key = $component->name . '_' . $component->state->value;
|
||||
if (!isset($processedComponents[$key])) {
|
||||
$components[] = $component;
|
||||
$processedComponents[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
private function analyzeSelector(string $selector, string $cssRules, string $filePath): ?Component
|
||||
{
|
||||
// Clean up selector - remove :where, :is wrappers
|
||||
$selector = preg_replace('/:where\s*\((.*?)\)/', '$1', $selector);
|
||||
$selector = preg_replace('/:is\s*\((.*?)\)/', '$1', $selector);
|
||||
|
||||
// Skip pseudo-elements and certain pseudo-classes
|
||||
if (preg_match('/::/', $selector) || preg_match('/:not\(/', $selector)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip complex selectors that are not component-like
|
||||
if (preg_match('/^\s*(html|body|main|header|footer|section|article|aside|nav|h1|h2|h3|h4|h5|h6|p|div|span|a|ul|ol|li|img|br|hr)\s*$/i', $selector)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip overly complex selectors (more than 3 parts)
|
||||
if (substr_count($selector, ' ') > 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip selectors that are clearly not components
|
||||
if (preg_match('/^\s*(\*|::before|::after|\[|>|\+|~)/i', $selector)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$componentName = null;
|
||||
|
||||
// Extract main class name - improved regex to handle more cases
|
||||
if (preg_match('/\.([a-zA-Z][a-zA-Z0-9_-]*)(?:\s|:|$|>|\+|~|\[|,)/', $selector, $matches)) {
|
||||
$componentName = $matches[1];
|
||||
|
||||
// Skip utility-only classes
|
||||
if (strlen($componentName) <= 2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Also handle element-based components (like button, input) - but be more selective
|
||||
elseif (preg_match('/^(button|input|select|textarea|table|form|dialog)(?:\s|:|$|\[)/', $selector, $matches)) {
|
||||
$componentName = $matches[1];
|
||||
}
|
||||
// Handle attribute selectors like input[type="button"]
|
||||
elseif (preg_match('/(button|input|select|textarea)\[([^\]]+)\]/', $selector, $matches)) {
|
||||
$elementType = $matches[1];
|
||||
$attributes = $matches[2];
|
||||
|
||||
// Create a meaningful name based on element and attributes
|
||||
if (preg_match('/type\s*=\s*["\']([^"\']+)["\']/', $attributes)) {
|
||||
preg_match('/type\s*=\s*["\']([^"\']+)["\']/', $attributes, $typeMatches);
|
||||
$componentName = $elementType . '-' . $typeMatches[1];
|
||||
} else {
|
||||
$componentName = $elementType;
|
||||
}
|
||||
}
|
||||
|
||||
if ($componentName !== null) {
|
||||
// Categorize component
|
||||
$category = $this->categorizeComponent($componentName);
|
||||
$pattern = $this->detectPattern($componentName);
|
||||
$state = $this->extractState($selector);
|
||||
|
||||
return new Component(
|
||||
name: $componentName,
|
||||
selector: $selector,
|
||||
cssRules: $cssRules,
|
||||
category: $category,
|
||||
pattern: $pattern,
|
||||
state: $state,
|
||||
filePath: $filePath
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function categorizeComponent(string $className): ComponentCategory
|
||||
{
|
||||
$lowerName = strtolower($className);
|
||||
|
||||
// Button components
|
||||
if (preg_match('/(btn|button)/i', $lowerName)) {
|
||||
return ComponentCategory::BUTTON;
|
||||
}
|
||||
|
||||
// Navigation components
|
||||
if (preg_match('/(nav|menu|breadcrumb|tab|sidebar|aside)/i', $lowerName)) {
|
||||
return ComponentCategory::NAVIGATION;
|
||||
}
|
||||
|
||||
// Form components
|
||||
if (preg_match('/(form|input|select|checkbox|radio|field|autosave|textarea)/i', $lowerName)) {
|
||||
return ComponentCategory::FORM;
|
||||
}
|
||||
|
||||
// Card components
|
||||
if (preg_match('/(card|panel|box|tile)/i', $lowerName)) {
|
||||
return ComponentCategory::CARD;
|
||||
}
|
||||
|
||||
// Alert/notification components
|
||||
if (preg_match('/(alert|notification|message|toast|status|error|warning|success)/i', $lowerName)) {
|
||||
return ComponentCategory::FEEDBACK;
|
||||
}
|
||||
|
||||
// Layout components
|
||||
if (preg_match('/(container|grid|row|col|layout|wrapper|section|main|article)/i', $lowerName)) {
|
||||
return ComponentCategory::LAYOUT;
|
||||
}
|
||||
|
||||
// Typography components
|
||||
if (preg_match('/(heading|title|text|font|headline|paragraph)/i', $lowerName)) {
|
||||
return ComponentCategory::TYPOGRAPHY;
|
||||
}
|
||||
|
||||
// Additional specific components
|
||||
if (preg_match('/(header|footer|lightbox|modal|dialog)/i', $lowerName)) {
|
||||
return ComponentCategory::LAYOUT;
|
||||
}
|
||||
|
||||
if (preg_match('/(csrf)/i', $lowerName)) {
|
||||
return ComponentCategory::FORM;
|
||||
}
|
||||
|
||||
// Element-based components
|
||||
if (preg_match('/^(table)$/i', $lowerName)) {
|
||||
return ComponentCategory::LAYOUT;
|
||||
}
|
||||
|
||||
return ComponentCategory::OTHER;
|
||||
}
|
||||
|
||||
private function detectPattern(string $className): ComponentPattern
|
||||
{
|
||||
// BEM pattern (block__element--modifier)
|
||||
if (preg_match('/^[a-z][a-z0-9]*(__[a-z][a-z0-9]*)?(-{2}[a-z][a-z0-9]*)?$/', $className)) {
|
||||
return ComponentPattern::BEM;
|
||||
}
|
||||
|
||||
// Utility pattern (single purpose, often prefixed)
|
||||
if (preg_match('/^(m|p|text|bg|border|flex|grid|w|h)-/', $className)) {
|
||||
return ComponentPattern::UTILITY;
|
||||
}
|
||||
|
||||
// Traditional component pattern
|
||||
return ComponentPattern::TRADITIONAL;
|
||||
}
|
||||
|
||||
private function extractState(string $selector): ComponentState
|
||||
{
|
||||
if (strpos($selector, ':hover') !== false) {
|
||||
return ComponentState::HOVER;
|
||||
}
|
||||
|
||||
if (strpos($selector, ':focus') !== false) {
|
||||
return ComponentState::FOCUS;
|
||||
}
|
||||
|
||||
if (strpos($selector, ':active') !== false) {
|
||||
return ComponentState::ACTIVE;
|
||||
}
|
||||
|
||||
if (strpos($selector, ':disabled') !== false) {
|
||||
return ComponentState::DISABLED;
|
||||
}
|
||||
|
||||
return ComponentState::DEFAULT;
|
||||
}
|
||||
}
|
||||
62
src/Framework/Design/Initializer/DesignSystemInitializer.php
Normal file
62
src/Framework/Design/Initializer/DesignSystemInitializer.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Initializer;
|
||||
|
||||
use App\Framework\Design\Parser\ClassNameParser;
|
||||
use App\Framework\Design\Parser\CssParser;
|
||||
use App\Framework\Design\Parser\CustomPropertyParser;
|
||||
use App\Framework\Design\Service\ColorAnalyzer;
|
||||
use App\Framework\Design\Service\ComponentDetector;
|
||||
use App\Framework\Design\Service\ConventionChecker;
|
||||
use App\Framework\Design\Service\DesignSystemAnalyzer;
|
||||
use App\Framework\Design\Service\TokenAnalyzer;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\Filesystem\FileScanner;
|
||||
|
||||
/**
|
||||
* Design System Service Initializer
|
||||
*/
|
||||
final readonly class DesignSystemInitializer
|
||||
{
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function __invoke(): void
|
||||
{
|
||||
// Filesystem Services
|
||||
$this->container->singleton(FileScanner::class, new FileScanner());
|
||||
|
||||
// Parser Services
|
||||
$this->container->singleton(CustomPropertyParser::class, new CustomPropertyParser());
|
||||
$this->container->singleton(ClassNameParser::class, new ClassNameParser());
|
||||
$this->container->singleton(CssParser::class, function () {
|
||||
return new CssParser(
|
||||
$this->container->get(CustomPropertyParser::class),
|
||||
$this->container->get(ClassNameParser::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Analyzer Services
|
||||
$this->container->singleton(TokenAnalyzer::class, new TokenAnalyzer());
|
||||
$this->container->singleton(ComponentDetector::class, new ComponentDetector());
|
||||
$this->container->singleton(ConventionChecker::class, new ConventionChecker());
|
||||
$this->container->singleton(ColorAnalyzer::class, new ColorAnalyzer());
|
||||
|
||||
// Main Analyzer Service
|
||||
$this->container->singleton(DesignSystemAnalyzer::class, function () {
|
||||
return new DesignSystemAnalyzer(
|
||||
$this->container->get(CssParser::class),
|
||||
$this->container->get(TokenAnalyzer::class),
|
||||
$this->container->get(ComponentDetector::class),
|
||||
$this->container->get(ConventionChecker::class),
|
||||
$this->container->get(ColorAnalyzer::class)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
248
src/Framework/Design/Parser/ClassNameParser.php
Normal file
248
src/Framework/Design/Parser/ClassNameParser.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Parser;
|
||||
|
||||
use App\Framework\Design\ValueObjects\ComponentPattern;
|
||||
use App\Framework\Design\ValueObjects\CssClass;
|
||||
|
||||
/**
|
||||
* Parst CSS-Klassen und erkennt Component-Patterns (BEM, ITCSS, etc.)
|
||||
*/
|
||||
final readonly class ClassNameParser
|
||||
{
|
||||
/**
|
||||
* Extrahiert CSS-Klassennamen aus Content
|
||||
*/
|
||||
public function extractFromContent(string $content): array
|
||||
{
|
||||
$classNames = [];
|
||||
|
||||
// Alle CSS-Selektoren finden die mit . beginnen
|
||||
// Matches escaped colons (\:) but stops at unescaped colons (:)
|
||||
preg_match_all('/\.([a-zA-Z][a-zA-Z0-9_-]*(?:\\\\:[a-zA-Z0-9_-]*)*)/m', $content, $matches);
|
||||
|
||||
foreach ($matches[1] as $className) {
|
||||
// Unescape CSS class name (e.g., hover\:bg-blue-500 becomes hover:bg-blue-500)
|
||||
$unescapedClassName = str_replace('\\:', ':', $className);
|
||||
$cssClass = CssClass::fromString($unescapedClassName);
|
||||
$classNames[] = $cssClass;
|
||||
}
|
||||
|
||||
return $classNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Component-Patterns in den CSS-Klassen
|
||||
*/
|
||||
public function detectPatterns(array $classNames): array
|
||||
{
|
||||
$patterns = [];
|
||||
|
||||
// BEM Pattern Detection
|
||||
$bemComponents = $this->detectBemPatterns($classNames);
|
||||
foreach ($bemComponents as $component) {
|
||||
$patterns[] = $component;
|
||||
}
|
||||
|
||||
// Utility Class Detection
|
||||
$utilities = $this->detectUtilityClasses($classNames);
|
||||
foreach ($utilities as $utility) {
|
||||
$patterns[] = $utility;
|
||||
}
|
||||
|
||||
// Component Class Detection (nicht BEM)
|
||||
$components = $this->detectComponentClasses($classNames);
|
||||
foreach ($components as $component) {
|
||||
$patterns[] = $component;
|
||||
}
|
||||
|
||||
return $patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt BEM (Block Element Modifier) Patterns
|
||||
*/
|
||||
private function detectBemPatterns(array $classNames): array
|
||||
{
|
||||
$bemComponents = [];
|
||||
$blocks = [];
|
||||
|
||||
foreach ($classNames as $className => $cssClass) {
|
||||
// Block__Element--Modifier Pattern
|
||||
if (preg_match('/^([a-z][a-z0-9-]*?)(__[a-z][a-z0-9-]*?)?(--[a-z][a-z0-9-]*?)?$/', $className, $matches)) {
|
||||
$block = $matches[1];
|
||||
$element = isset($matches[2]) ? substr($matches[2], 2) : null;
|
||||
$modifier = isset($matches[3]) ? substr($matches[3], 2) : null;
|
||||
|
||||
if (! isset($blocks[$block])) {
|
||||
$blocks[$block] = [
|
||||
'block' => $block,
|
||||
'elements' => [],
|
||||
'modifiers' => [],
|
||||
'classes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$blocks[$block]['classes'][] = $cssClass;
|
||||
|
||||
if ($element) {
|
||||
$blocks[$block]['elements'][$element] = $element;
|
||||
}
|
||||
|
||||
if ($modifier) {
|
||||
$blocks[$block]['modifiers'][$modifier] = $modifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($blocks as $block => $data) {
|
||||
$bemComponents[] = ComponentPattern::createBem(
|
||||
blockName: $block,
|
||||
elements: array_values($data['elements']),
|
||||
modifiers: array_values($data['modifiers']),
|
||||
classes: $data['classes']
|
||||
);
|
||||
}
|
||||
|
||||
return $bemComponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Utility Classes (Tailwind-style)
|
||||
*/
|
||||
private function detectUtilityClasses(array $classNames): array
|
||||
{
|
||||
$utilities = [];
|
||||
|
||||
$utilityPatterns = [
|
||||
// Margin/Padding
|
||||
'/^[mp][trblxy]?-\d+$/' => 'spacing',
|
||||
// Width/Height
|
||||
'/^[wh]-\d+$/' => 'sizing',
|
||||
// Text utilities
|
||||
'/^text-(xs|sm|base|lg|xl|\d+xl|center|left|right)$/' => 'typography',
|
||||
// Color utilities
|
||||
'/^(text|bg|border)-(red|blue|green|gray|yellow|purple|pink|indigo)-\d+$/' => 'color',
|
||||
// Display utilities
|
||||
'/^(block|inline|flex|grid|hidden)$/' => 'display',
|
||||
// Flexbox utilities
|
||||
'/^(justify|items|self)-(start|end|center|between|around)$/' => 'flexbox',
|
||||
];
|
||||
|
||||
foreach ($classNames as $className => $cssClass) {
|
||||
foreach ($utilityPatterns as $pattern => $category) {
|
||||
if (preg_match($pattern, $className)) {
|
||||
if (! isset($utilities[$category])) {
|
||||
$utilities[$category] = [];
|
||||
}
|
||||
$utilities[$category][] = $cssClass;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$utilityComponents = [];
|
||||
foreach ($utilities as $category => $classes) {
|
||||
$utilityComponents[] = ComponentPattern::createUtility(
|
||||
category: $category,
|
||||
classes: $classes
|
||||
);
|
||||
}
|
||||
|
||||
return $utilityComponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt traditionelle Component Classes
|
||||
*/
|
||||
private function detectComponentClasses(array $classNames): array
|
||||
{
|
||||
$components = [];
|
||||
|
||||
$componentPatterns = [
|
||||
'button' => ['btn', 'button'],
|
||||
'card' => ['card'],
|
||||
'modal' => ['modal', 'dialog'],
|
||||
'form' => ['form', 'input', 'select', 'textarea'],
|
||||
'navigation' => ['nav', 'navbar', 'menu'],
|
||||
'table' => ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
|
||||
'alert' => ['alert', 'message', 'notification'],
|
||||
'badge' => ['badge', 'tag', 'chip'],
|
||||
'dropdown' => ['dropdown', 'select'],
|
||||
'tabs' => ['tab', 'tabs'],
|
||||
'accordion' => ['accordion', 'collapse'],
|
||||
'breadcrumb' => ['breadcrumb'],
|
||||
'pagination' => ['pagination', 'pager'],
|
||||
];
|
||||
|
||||
foreach ($componentPatterns as $componentType => $patterns) {
|
||||
$matchingClasses = [];
|
||||
|
||||
foreach ($classNames as $className => $cssClass) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (str_starts_with($className, $pattern) || str_contains($className, $pattern)) {
|
||||
$matchingClasses[] = $cssClass;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($matchingClasses)) {
|
||||
$components[] = ComponentPattern::createComponent(
|
||||
name: $componentType,
|
||||
classes: $matchingClasses
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert CSS-Klassen-Konventionen
|
||||
*/
|
||||
public function analyzeConventions(array $classNames): array
|
||||
{
|
||||
$conventions = [
|
||||
'bem_usage' => 0,
|
||||
'camelCase_usage' => 0,
|
||||
'kebab_case_usage' => 0,
|
||||
'snake_case_usage' => 0,
|
||||
'total_classes' => count($classNames),
|
||||
'violations' => [],
|
||||
];
|
||||
|
||||
foreach ($classNames as $className => $cssClass) {
|
||||
// BEM Convention Check
|
||||
if (preg_match('/^[a-z][a-z0-9-]*(__[a-z][a-z0-9-]*)?(--[a-z][a-z0-9-]*)?$/', $className)) {
|
||||
$conventions['bem_usage']++;
|
||||
}
|
||||
|
||||
// camelCase Check
|
||||
elseif (preg_match('/^[a-z][a-zA-Z0-9]*$/', $className)) {
|
||||
$conventions['camelCase_usage']++;
|
||||
}
|
||||
|
||||
// kebab-case Check
|
||||
elseif (preg_match('/^[a-z][a-z0-9-]*$/', $className)) {
|
||||
$conventions['kebab_case_usage']++;
|
||||
}
|
||||
|
||||
// snake_case Check
|
||||
elseif (preg_match('/^[a-z][a-z0-9_]*$/', $className)) {
|
||||
$conventions['snake_case_usage']++;
|
||||
}
|
||||
|
||||
// Convention Violations
|
||||
else {
|
||||
$conventions['violations'][] = $className;
|
||||
}
|
||||
}
|
||||
|
||||
return $conventions;
|
||||
}
|
||||
}
|
||||
39
src/Framework/Design/Parser/CssParseResult.php
Normal file
39
src/Framework/Design/Parser/CssParseResult.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Parser;
|
||||
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Ergebnis eines CSS-Parsing-Vorgangs
|
||||
*/
|
||||
final readonly class CssParseResult
|
||||
{
|
||||
public function __construct(
|
||||
public FilePath|string|null $sourceFile,
|
||||
public array $rules,
|
||||
public array $customProperties,
|
||||
public array $classNames,
|
||||
public string $rawContent,
|
||||
public array $statistics = []
|
||||
) {
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self(
|
||||
sourceFile: null,
|
||||
rules: [],
|
||||
customProperties: [],
|
||||
classNames: [],
|
||||
rawContent: '',
|
||||
statistics: [
|
||||
'total_rules' => 0,
|
||||
'total_custom_properties' => 0,
|
||||
'total_classes' => 0,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
188
src/Framework/Design/Parser/CssParser.php
Normal file
188
src/Framework/Design/Parser/CssParser.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Parser;
|
||||
|
||||
use App\Framework\Design\ValueObjects\CssProperty;
|
||||
use App\Framework\Design\ValueObjects\CssRule;
|
||||
use App\Framework\Design\ValueObjects\CssSelector;
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Parst CSS-Dateien und extrahiert Regeln, Selektoren und Properties
|
||||
*/
|
||||
final readonly class CssParser
|
||||
{
|
||||
public function __construct(
|
||||
private CustomPropertyParser $customPropertyParser = new CustomPropertyParser(),
|
||||
private ClassNameParser $classNameParser = new ClassNameParser()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst eine CSS-Datei und gibt strukturierte Daten zurück
|
||||
*/
|
||||
public function parseFile(FilePath $filePath): CssParseResult
|
||||
{
|
||||
if (! file_exists($filePath->toString())) {
|
||||
throw new \InvalidArgumentException("CSS file not found: {$filePath->toString()}");
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath->toString());
|
||||
|
||||
return $this->parseContent($content, $filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst CSS-Content direkt
|
||||
*/
|
||||
public function parseContent(string $content, ?FilePath $sourceFile = null): CssParseResult
|
||||
{
|
||||
// CSS Comments und unnötige Whitespaces entfernen
|
||||
$cleanContent = $this->cleanCssContent($content);
|
||||
|
||||
// CSS Regeln extrahieren
|
||||
$rules = $this->extractRules($cleanContent);
|
||||
|
||||
// Custom Properties extrahieren
|
||||
$customProperties = $this->customPropertyParser->extractFromContent($cleanContent);
|
||||
|
||||
// CSS Klassen extrahieren
|
||||
$classNames = $this->classNameParser->extractFromContent($cleanContent);
|
||||
|
||||
$statistics = [
|
||||
'total_rules' => count($rules),
|
||||
'total_custom_properties' => count($customProperties),
|
||||
'total_classes' => count($classNames),
|
||||
];
|
||||
|
||||
return new CssParseResult(
|
||||
sourceFile: $sourceFile,
|
||||
rules: $rules,
|
||||
customProperties: $customProperties,
|
||||
classNames: $classNames,
|
||||
rawContent: $content,
|
||||
statistics: $statistics
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst mehrere CSS-Dateien
|
||||
*/
|
||||
public function parseFiles(array $filePaths): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($filePaths as $filePath) {
|
||||
$path = $filePath instanceof FilePath ? $filePath : FilePath::create($filePath);
|
||||
$results[] = $this->parseFile($path);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst alle CSS-Dateien in einem Verzeichnis
|
||||
*/
|
||||
public function parseDirectory(string $directory, bool $recursive = true): array
|
||||
{
|
||||
$pattern = $recursive ? $directory . '/**/*.css' : $directory . '/*.css';
|
||||
$files = glob($pattern, GLOB_BRACE);
|
||||
|
||||
if ($files === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parseFiles(array_map(fn ($file) => FilePath::create($file), $files));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert CSS-Regeln aus dem Content
|
||||
*/
|
||||
private function extractRules(string $content): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
// Regex für CSS-Regeln: selector { properties }
|
||||
preg_match_all('/([^{}]+)\{([^{}]*)\}/s', $content, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$selectorText = trim($match[1]);
|
||||
$propertiesText = trim($match[2]);
|
||||
|
||||
// Skip rules without selectors, but allow empty properties
|
||||
if (empty($selectorText)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Selektoren parsen (können mehrere durch Komma getrennt sein)
|
||||
$selectors = $this->parseSelectors($selectorText);
|
||||
|
||||
// Properties parsen
|
||||
$properties = $this->parseProperties($propertiesText);
|
||||
|
||||
$rules[] = new CssRule($selectors, $properties);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst Selektoren (durch Komma getrennt)
|
||||
*/
|
||||
private function parseSelectors(string $selectorText): array
|
||||
{
|
||||
$selectors = [];
|
||||
$selectorParts = explode(',', $selectorText);
|
||||
|
||||
foreach ($selectorParts as $selector) {
|
||||
$selector = trim($selector);
|
||||
if (! empty($selector)) {
|
||||
$selectors[] = CssSelector::fromString($selector);
|
||||
}
|
||||
}
|
||||
|
||||
return $selectors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst CSS Properties
|
||||
*/
|
||||
private function parseProperties(string $propertiesText): array
|
||||
{
|
||||
$properties = [];
|
||||
|
||||
// Regex für property: value; Paare
|
||||
preg_match_all('/([^:;{}]+):\s*([^:;{}]+);?/s', $propertiesText, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$property = trim($match[1]);
|
||||
$value = trim($match[2]);
|
||||
|
||||
if (! empty($property) && ! empty($value)) {
|
||||
$properties[] = new CssProperty($property, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinigt CSS Content von Comments und überflüssigen Whitespaces
|
||||
*/
|
||||
private function cleanCssContent(string $content): string
|
||||
{
|
||||
// CSS Comments entfernen
|
||||
$content = preg_replace('/\/\*.*?\*\//s', '', $content);
|
||||
|
||||
// Mehrfache Whitespaces reduzieren
|
||||
$content = preg_replace('/\s+/', ' ', $content);
|
||||
|
||||
// Whitespace um geschweifte Klammern normalisieren
|
||||
$content = preg_replace('/\s*{\s*/', ' { ', $content);
|
||||
$content = preg_replace('/\s*}\s*/', ' } ', $content);
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
}
|
||||
52
src/Framework/Design/Parser/CustomPropertyParser.php
Normal file
52
src/Framework/Design/Parser/CustomPropertyParser.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Parser;
|
||||
|
||||
use App\Framework\Design\ValueObjects\CustomProperty;
|
||||
|
||||
/**
|
||||
* Parst CSS Custom Properties (CSS Variables) und konvertiert sie zu Design Tokens
|
||||
*/
|
||||
final readonly class CustomPropertyParser
|
||||
{
|
||||
/**
|
||||
* Extrahiert alle Custom Properties aus CSS Content
|
||||
*/
|
||||
public function extractFromContent(string $content): array
|
||||
{
|
||||
$customProperties = [];
|
||||
|
||||
// Regex für CSS Custom Properties: --property-name: value;
|
||||
preg_match_all('/--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/m', $content, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$propertyName = $match[1];
|
||||
$propertyValue = trim($match[2]);
|
||||
|
||||
// CustomProperty erstellen
|
||||
$customProperties[] = new CustomProperty($propertyName, $propertyValue);
|
||||
}
|
||||
|
||||
return $customProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Custom Properties aus mehreren Dateien
|
||||
*/
|
||||
public function extractFromFiles(array $filePaths): array
|
||||
{
|
||||
$allProperties = [];
|
||||
|
||||
foreach ($filePaths as $filePath) {
|
||||
if (file_exists($filePath)) {
|
||||
$content = file_get_contents($filePath);
|
||||
$properties = $this->extractFromContent($content);
|
||||
$allProperties = array_merge($allProperties, $properties);
|
||||
}
|
||||
}
|
||||
|
||||
return $allProperties;
|
||||
}
|
||||
}
|
||||
264
src/Framework/Design/Service/ColorAnalyzer.php
Normal file
264
src/Framework/Design/Service/ColorAnalyzer.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Service;
|
||||
|
||||
use App\Framework\Design\Analyzer\ColorAnalysisResult;
|
||||
use App\Framework\Design\ValueObjects\ColorFormat;
|
||||
use App\Framework\Design\ValueObjects\CssColor;
|
||||
|
||||
/**
|
||||
* Analysiert Farben in Design Systemen
|
||||
*/
|
||||
final readonly class ColorAnalyzer
|
||||
{
|
||||
public function analyzePalette(array $customProperties): object
|
||||
{
|
||||
$colors = [];
|
||||
$colorsByFormat = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
try {
|
||||
if ($property->hasValueType('color')) {
|
||||
$color = $property->getValueAs('color');
|
||||
$colors[] = $color;
|
||||
|
||||
$format = $color->format->value;
|
||||
if (! isset($colorsByFormat[$format])) {
|
||||
$colorsByFormat[$format] = [];
|
||||
}
|
||||
$colorsByFormat[$format][] = $color;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = [
|
||||
'property' => $property->name,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'totalColors' => count($colors),
|
||||
'colorsByFormat' => $colorsByFormat,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
public function calculateContrastRatio(CssColor $color1, CssColor $color2): float
|
||||
{
|
||||
// Simplified contrast ratio calculation
|
||||
// Black vs White = 21:1
|
||||
if ($color1->value === '#000000' && $color2->value === '#ffffff') {
|
||||
return 21.0;
|
||||
}
|
||||
|
||||
// This is a simplified implementation
|
||||
return 4.5; // Default to WCAG AA minimum
|
||||
}
|
||||
|
||||
public function isWcagCompliant(CssColor $foreground, CssColor $background, string $level): bool
|
||||
{
|
||||
$ratio = $this->calculateContrastRatio($foreground, $background);
|
||||
|
||||
return match($level) {
|
||||
'AA' => $ratio >= 4.5,
|
||||
'AAA' => $ratio >= 7.0,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
public function findAccessibilityIssues(array $customProperties): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Find potential low contrast issues
|
||||
foreach ($customProperties as $property) {
|
||||
if (str_contains($property->name, 'text') && $property->hasValueType('color')) {
|
||||
// Mock issue detection
|
||||
if ($property->value === '#9ca3af') {
|
||||
$issues[] = [
|
||||
'property' => $property->name,
|
||||
'type' => 'low_contrast',
|
||||
'severity' => 'warning',
|
||||
'message' => 'Potential low contrast with white background',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
public function detectColorScheme(array $customProperties): string
|
||||
{
|
||||
$lightness = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
if ($property->hasValueType('color')) {
|
||||
// Simple lightness detection based on hex values
|
||||
if (str_contains($property->name, 'bg') || str_contains($property->name, 'background')) {
|
||||
if ($property->value === '#ffffff' || str_contains($property->value, 'f')) {
|
||||
$lightness += 1;
|
||||
} else {
|
||||
$lightness -= 1;
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($count === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return $lightness > 0 ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
public function convertToHsl(CssColor $color): CssColor
|
||||
{
|
||||
// Mock conversion
|
||||
return new CssColor('hsl(220, 91%, 60%)', ColorFormat::HSL);
|
||||
}
|
||||
|
||||
public function convertToOklch(CssColor $color): CssColor
|
||||
{
|
||||
// Mock conversion
|
||||
return new CssColor('oklch(0.7 0.15 260)', ColorFormat::OKLCH);
|
||||
}
|
||||
|
||||
public function generateColorHarmony(CssColor $baseColor, string $type): array
|
||||
{
|
||||
$colors = [$baseColor];
|
||||
|
||||
switch ($type) {
|
||||
case 'complementary':
|
||||
$colors[] = new CssColor('#f56565', ColorFormat::HEX); // Mock complement
|
||||
|
||||
break;
|
||||
case 'triadic':
|
||||
$colors[] = new CssColor('#10b981', ColorFormat::HEX);
|
||||
$colors[] = new CssColor('#f59e0b', ColorFormat::HEX);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return $colors;
|
||||
}
|
||||
|
||||
public function analyzeColorDistribution(array $customProperties): array
|
||||
{
|
||||
$distribution = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
if ($property->hasValueType('color')) {
|
||||
// Simple color family detection
|
||||
if (str_contains($property->name, 'blue')) {
|
||||
$distribution['blue'] = ($distribution['blue'] ?? 0) + 1;
|
||||
} elseif (str_contains($property->name, 'green')) {
|
||||
$distribution['green'] = ($distribution['green'] ?? 0) + 1;
|
||||
} elseif (str_contains($property->name, 'red')) {
|
||||
$distribution['red'] = ($distribution['red'] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $distribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main analysis method used by DesignSystemAnalyzer
|
||||
*/
|
||||
public function analyzeColors($parseResult): ColorAnalysisResult
|
||||
{
|
||||
$palette = $this->analyzePalette($parseResult->customProperties);
|
||||
$distribution = $this->analyzeColorDistribution($parseResult->customProperties);
|
||||
$accessibilityIssues = $this->findAccessibilityIssues($parseResult->customProperties);
|
||||
$namingViolations = $this->validateNamingConventions($parseResult->customProperties);
|
||||
$colorScheme = $this->detectColorScheme($parseResult->customProperties);
|
||||
|
||||
// Find duplicate colors
|
||||
$duplicateColors = [];
|
||||
$colorValues = [];
|
||||
foreach ($parseResult->customProperties as $property) {
|
||||
if ($property->hasValueType('color')) {
|
||||
$value = $property->value;
|
||||
if (! isset($colorValues[$value])) {
|
||||
$colorValues[$value] = [];
|
||||
}
|
||||
$colorValues[$value][] = $property->name;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($colorValues as $value => $properties) {
|
||||
if (count($properties) > 1) {
|
||||
$duplicateColors[] = [
|
||||
'value' => $value,
|
||||
'properties' => $properties,
|
||||
'potential_savings' => count($properties) - 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Create color palette structure
|
||||
$colorPalette = [
|
||||
'primary_colors' => array_slice($distribution['primary'] ?? [], 0, 5),
|
||||
'neutral_colors' => array_slice($distribution['neutral'] ?? [], 0, 8),
|
||||
'accent_colors' => array_slice($distribution['accent'] ?? [], 0, 3),
|
||||
'semantic_colors' => $distribution['semantic'] ?? [],
|
||||
];
|
||||
|
||||
// Create contrast analysis
|
||||
$contrastAnalysis = [];
|
||||
foreach ($accessibilityIssues as $issue) {
|
||||
$contrastAnalysis[] = [
|
||||
'foreground' => $issue['foreground'] ?? '',
|
||||
'background' => $issue['background'] ?? '',
|
||||
'contrast_ratio' => $issue['contrast_ratio'] ?? 1.0,
|
||||
'wcag_aa' => ($issue['contrast_ratio'] ?? 1.0) >= 4.5,
|
||||
'wcag_aaa' => ($issue['contrast_ratio'] ?? 1.0) >= 7.0,
|
||||
];
|
||||
}
|
||||
|
||||
$recommendations = [];
|
||||
if (count($duplicateColors) > 0) {
|
||||
$recommendations[] = 'Remove duplicate color values to reduce CSS size';
|
||||
}
|
||||
if (count($namingViolations) > 0) {
|
||||
$recommendations[] = 'Improve color naming conventions for better maintainability';
|
||||
}
|
||||
if (count($palette->colorsByFormat) > 3) {
|
||||
$recommendations[] = 'Standardize color formats for consistency';
|
||||
}
|
||||
|
||||
return new ColorAnalysisResult(
|
||||
totalColors: $palette->totalColors,
|
||||
colorsByFormat: $palette->colorsByFormat,
|
||||
colorPalette: $colorPalette,
|
||||
contrastAnalysis: $contrastAnalysis,
|
||||
duplicateColors: $duplicateColors,
|
||||
recommendations: $recommendations
|
||||
);
|
||||
}
|
||||
|
||||
public function validateNamingConventions(array $customProperties): array
|
||||
{
|
||||
$violations = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
if ($property->hasValueType('color')) {
|
||||
// Check for poor naming
|
||||
if (preg_match('/^(color\d+|randomColorName)$/', $property->name)) {
|
||||
$violations[] = [
|
||||
'property' => $property->name,
|
||||
'issue' => 'Non-semantic color name',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
}
|
||||
431
src/Framework/Design/Service/ComponentDetector.php
Normal file
431
src/Framework/Design/Service/ComponentDetector.php
Normal file
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Service;
|
||||
|
||||
use App\Framework\Design\Analyzer\ComponentDetectionResult;
|
||||
use App\Framework\Design\ValueObjects\ComponentPattern;
|
||||
use App\Framework\Design\ValueObjects\CssClass;
|
||||
|
||||
/**
|
||||
* Erkennt und analysiert UI-Komponenten
|
||||
*/
|
||||
final readonly class ComponentDetector
|
||||
{
|
||||
public function detectBemComponents(array $cssClasses): array
|
||||
{
|
||||
$components = [];
|
||||
$blocks = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
if ($cssClass->isBemBlock()) {
|
||||
$blockName = $cssClass->name;
|
||||
if (! isset($blocks[$blockName])) {
|
||||
$blocks[$blockName] = [
|
||||
'block' => $blockName,
|
||||
'elements' => [],
|
||||
'modifiers' => [],
|
||||
'element_modifiers' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find elements and modifiers
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
if ($cssClass->isBemElement()) {
|
||||
$block = $cssClass->getBemBlock();
|
||||
$element = $cssClass->getBemElement();
|
||||
if (isset($blocks[$block]) && $element) {
|
||||
$blocks[$block]['elements'][] = $element;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cssClass->isBemModifier()) {
|
||||
$block = $cssClass->getBemBlock();
|
||||
$modifier = $cssClass->getBemModifier();
|
||||
if (isset($blocks[$block]) && $modifier) {
|
||||
if ($cssClass->isBemElement()) {
|
||||
$element = $cssClass->getBemElement();
|
||||
if (! isset($blocks[$block]['element_modifiers'][$element])) {
|
||||
$blocks[$block]['element_modifiers'][$element] = [];
|
||||
}
|
||||
$blocks[$block]['element_modifiers'][$element][] = $modifier;
|
||||
} else {
|
||||
$blocks[$block]['modifiers'][] = $modifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($blocks);
|
||||
}
|
||||
|
||||
public function detectUtilityPatterns(array $cssClasses): array
|
||||
{
|
||||
$patterns = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
if ($cssClass->isUtilityClass()) {
|
||||
$this->categorizeUtilityClass($cssClass, $patterns);
|
||||
}
|
||||
}
|
||||
|
||||
return $patterns;
|
||||
}
|
||||
|
||||
public function detectStructurePatterns(array $cssClasses): array
|
||||
{
|
||||
$patterns = [
|
||||
'layout' => ['components' => []],
|
||||
'form' => ['components' => []],
|
||||
'navigation' => ['components' => []],
|
||||
];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
|
||||
if (in_array($name, ['container', 'row', 'col', 'grid'])) {
|
||||
$patterns['layout']['components'][] = $name;
|
||||
} elseif (str_starts_with($name, 'form')) {
|
||||
$patterns['form']['components'][] = $name;
|
||||
} elseif (str_starts_with($name, 'nav')) {
|
||||
$patterns['navigation']['components'][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $patterns;
|
||||
}
|
||||
|
||||
public function analyzeResponsivePatterns(array $cssClasses): array
|
||||
{
|
||||
$breakpoints = [];
|
||||
$patterns = [
|
||||
'visibility' => [],
|
||||
'grid' => [],
|
||||
'typography' => [],
|
||||
];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
if (preg_match('/(xs|sm|md|lg|xl)/', $cssClass->name, $matches)) {
|
||||
$breakpoint = $matches[1];
|
||||
if (! in_array($breakpoint, $breakpoints)) {
|
||||
$breakpoints[] = $breakpoint;
|
||||
}
|
||||
|
||||
if (str_contains($cssClass->name, 'hidden') || str_contains($cssClass->name, 'visible')) {
|
||||
$patterns['visibility'][] = $cssClass->name;
|
||||
} elseif (str_contains($cssClass->name, 'col')) {
|
||||
$patterns['grid'][] = $cssClass->name;
|
||||
} elseif (str_contains($cssClass->name, 'text')) {
|
||||
$patterns['typography'][] = $cssClass->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'breakpoints' => $breakpoints,
|
||||
'patterns' => $patterns,
|
||||
];
|
||||
}
|
||||
|
||||
public function analyzeComponentComplexity(array $cssClasses): array
|
||||
{
|
||||
$score = count($cssClasses);
|
||||
|
||||
$level = match(true) {
|
||||
$score <= 3 => 'simple',
|
||||
$score <= 6 => 'moderate',
|
||||
default => 'complex'
|
||||
};
|
||||
|
||||
$recommendations = [];
|
||||
if ($score > 8) {
|
||||
$recommendations[] = 'Consider splitting into smaller components';
|
||||
}
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'level' => $level,
|
||||
'recommendations' => $recommendations,
|
||||
];
|
||||
}
|
||||
|
||||
public function analyzeAtomicDesignPatterns(array $cssClasses): array
|
||||
{
|
||||
$atoms = [];
|
||||
$molecules = [];
|
||||
$organisms = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
|
||||
if (in_array($name, ['btn', 'input', 'label', 'icon'])) {
|
||||
$atoms[] = $name;
|
||||
} elseif (str_contains($name, 'form') || str_contains($name, 'search') || str_contains($name, 'nav-item')) {
|
||||
$molecules[] = $name;
|
||||
} elseif (in_array($name, ['header', 'sidebar', 'footer', 'product-grid'])) {
|
||||
$organisms[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'atoms' => $atoms,
|
||||
'molecules' => $molecules,
|
||||
'organisms' => $organisms,
|
||||
];
|
||||
}
|
||||
|
||||
public function validateNamingConventions(array $cssClasses): array
|
||||
{
|
||||
$valid = [];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
|
||||
if (preg_match('/^[a-z][a-z0-9-]*[a-z0-9]$/', $name)) {
|
||||
$valid[] = $cssClass;
|
||||
} else {
|
||||
$invalid[] = [
|
||||
'class' => $name,
|
||||
'reason' => $this->getConventionViolation($name),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $valid,
|
||||
'invalid' => $invalid,
|
||||
];
|
||||
}
|
||||
|
||||
public function detectComponentRelationships(array $cssClasses): array
|
||||
{
|
||||
$relationships = [];
|
||||
|
||||
// Group by BEM blocks
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$block = $cssClass->getBemBlock();
|
||||
if ($block) {
|
||||
if (! isset($relationships[$block])) {
|
||||
$relationships[$block] = [
|
||||
'children' => [],
|
||||
'depth' => 0,
|
||||
'complexity_score' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
if ($cssClass->isBemElement()) {
|
||||
$element = $cssClass->getBemElement();
|
||||
if ($element && ! in_array($element, $relationships[$block]['children'])) {
|
||||
$relationships[$block]['children'][] = $element;
|
||||
}
|
||||
}
|
||||
|
||||
$relationships[$block]['complexity_score']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate depth and complexity
|
||||
foreach ($relationships as $block => &$data) {
|
||||
$data['depth'] = count($data['children']) > 0 ? 2 : 1;
|
||||
}
|
||||
|
||||
return $relationships;
|
||||
}
|
||||
|
||||
public function suggestImprovements(array $cssClasses): array
|
||||
{
|
||||
$suggestions = [];
|
||||
$namingInconsistencies = [];
|
||||
$bemViolations = [];
|
||||
$overlySpecific = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
|
||||
// Check for naming inconsistencies
|
||||
if (in_array($name, ['button', 'btn', 'submit-btn'])) {
|
||||
$namingInconsistencies[] = $name;
|
||||
}
|
||||
|
||||
// Check BEM violations
|
||||
if (str_contains($name, 'card-header') && ! str_contains($name, '__')) {
|
||||
$bemViolations[] = $name;
|
||||
}
|
||||
|
||||
// Check overly specific names
|
||||
if (str_word_count($name, 0, '-') > 3) {
|
||||
$overlySpecific[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($namingInconsistencies)) {
|
||||
$suggestions[] = 'Standardize button naming (choose: button, btn)';
|
||||
}
|
||||
|
||||
if (! empty($bemViolations)) {
|
||||
$suggestions[] = 'Convert card-header to card__header for BEM compliance';
|
||||
}
|
||||
|
||||
return [
|
||||
'naming_inconsistencies' => $namingInconsistencies,
|
||||
'bem_violations' => $bemViolations,
|
||||
'overly_specific' => $overlySpecific,
|
||||
'suggestions' => $suggestions,
|
||||
];
|
||||
}
|
||||
|
||||
public function analyzeComponentReusability(array $cssClasses): array
|
||||
{
|
||||
$analysis = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
|
||||
// Calculate reusability score
|
||||
$score = $this->calculateReusabilityScore($name);
|
||||
$variants = $this->countVariants($cssClasses, $name);
|
||||
|
||||
$level = match(true) {
|
||||
$score >= 0.8 => 'high',
|
||||
$score >= 0.5 => 'medium',
|
||||
default => 'low'
|
||||
};
|
||||
|
||||
$analysis[$name] = [
|
||||
'score' => $score,
|
||||
'variants' => $variants,
|
||||
'reusability_level' => $level,
|
||||
];
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main analysis method used by DesignSystemAnalyzer
|
||||
*/
|
||||
public function detectComponents($parseResult): ComponentDetectionResult
|
||||
{
|
||||
$bemComponents = $this->detectBemComponents($parseResult->classNames);
|
||||
$utilityPatterns = $this->detectUtilityPatterns($parseResult->classNames);
|
||||
$structurePatterns = $this->detectStructurePatterns($parseResult->classNames);
|
||||
$responsivePatterns = $this->analyzeResponsivePatterns($parseResult->classNames);
|
||||
$atomicPatterns = $this->analyzeAtomicDesignPatterns($parseResult->classNames);
|
||||
|
||||
// Convert to ComponentPattern objects
|
||||
$bemComponentPatterns = [];
|
||||
foreach ($bemComponents as $component) {
|
||||
$bemComponentPatterns[] = ComponentPattern::createBem(
|
||||
blockName: $component['block'] ?? 'unknown',
|
||||
elements: $component['elements'] ?? [],
|
||||
modifiers: $component['modifiers'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
$utilityComponentPatterns = [];
|
||||
foreach ($utilityPatterns as $category => $utilities) {
|
||||
if (is_array($utilities)) {
|
||||
$utilityComponentPatterns[] = ComponentPattern::createUtility(
|
||||
category: is_string($category) ? $category : 'utilities'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$traditionalComponentPatterns = [];
|
||||
foreach ($structurePatterns as $category => $data) {
|
||||
if (isset($data['components']) && ! empty($data['components'])) {
|
||||
foreach ($data['components'] as $component) {
|
||||
$traditionalComponentPatterns[] = ComponentPattern::createComponent(
|
||||
name: is_string($component) ? $component : ($component['name'] ?? 'unknown')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$totalComponents = count($bemComponentPatterns) + count($utilityComponentPatterns) + count($traditionalComponentPatterns);
|
||||
|
||||
$patternStatistics = [
|
||||
'bem_count' => count($bemComponentPatterns),
|
||||
'utility_count' => count($utilityComponentPatterns),
|
||||
'traditional_count' => count($traditionalComponentPatterns),
|
||||
'total_components' => $totalComponents,
|
||||
'responsive_patterns' => $responsivePatterns,
|
||||
'atomic_patterns' => $atomicPatterns,
|
||||
];
|
||||
|
||||
$recommendations = [];
|
||||
if (count($bemComponentPatterns) > 0 && count($traditionalComponentPatterns) > count($bemComponentPatterns)) {
|
||||
$recommendations[] = 'Consider refactoring traditional components to BEM methodology';
|
||||
}
|
||||
if (count($utilityComponentPatterns) < 5 && $totalComponents > 10) {
|
||||
$recommendations[] = 'Add utility classes for common patterns like spacing and colors';
|
||||
}
|
||||
|
||||
return new ComponentDetectionResult(
|
||||
totalComponents: $totalComponents,
|
||||
bemComponents: $bemComponentPatterns,
|
||||
utilityComponents: $utilityComponentPatterns,
|
||||
traditionalComponents: $traditionalComponentPatterns,
|
||||
patternStatistics: $patternStatistics,
|
||||
recommendations: $recommendations
|
||||
);
|
||||
}
|
||||
|
||||
private function categorizeUtilityClass(CssClass $cssClass, array &$patterns): void
|
||||
{
|
||||
$name = $cssClass->name;
|
||||
|
||||
if (str_starts_with($name, 'text-')) {
|
||||
$patterns['text-alignment'][] = $name;
|
||||
} elseif (str_starts_with($name, 'p-')) {
|
||||
$patterns['padding'][] = $name;
|
||||
} elseif (str_starts_with($name, 'm-')) {
|
||||
$patterns['margin'][] = $name;
|
||||
} elseif (str_starts_with($name, 'bg-')) {
|
||||
$patterns['background-color'][] = $name;
|
||||
} elseif (str_starts_with($name, 'hover:')) {
|
||||
$patterns['hover-states'][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
private function getConventionViolation(string $name): string
|
||||
{
|
||||
if (preg_match('/[A-Z]/', $name)) {
|
||||
return str_contains($name, '_') ? 'snake_case detected' :
|
||||
(ctype_upper($name[0]) ? 'PascalCase detected' : 'camelCase detected');
|
||||
}
|
||||
|
||||
return 'Invalid naming pattern';
|
||||
}
|
||||
|
||||
private function calculateReusabilityScore(string $name): float
|
||||
{
|
||||
// Simple heuristic: shorter, semantic names are more reusable
|
||||
if (str_contains($name, 'specific') || str_contains($name, 'page')) {
|
||||
return 0.2;
|
||||
}
|
||||
|
||||
if (in_array($name, ['btn', 'button', 'card', 'modal'])) {
|
||||
return 0.9;
|
||||
}
|
||||
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
private function countVariants(array $cssClasses, string $baseName): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
if (str_starts_with($cssClass->name, $baseName) && $cssClass->name !== $baseName) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
538
src/Framework/Design/Service/ConventionChecker.php
Normal file
538
src/Framework/Design/Service/ConventionChecker.php
Normal file
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Service;
|
||||
|
||||
use App\Framework\Design\Analyzer\ConventionCheckResult;
|
||||
|
||||
/**
|
||||
* Prüft Naming Conventions und Standards
|
||||
*/
|
||||
final readonly class ConventionChecker
|
||||
{
|
||||
public function validateBemNaming(array $cssClasses): array
|
||||
{
|
||||
$valid = [];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
$isValid = true;
|
||||
$reason = '';
|
||||
|
||||
// Check for invalid patterns
|
||||
if (preg_match('/^[A-Z]/', $name)) {
|
||||
$isValid = false;
|
||||
$reason = 'Should not start with uppercase';
|
||||
} elseif (str_contains($name, '__') && str_ends_with($name, '__')) {
|
||||
$isValid = false;
|
||||
$reason = 'Empty element name';
|
||||
} elseif (str_contains($name, '--') && str_ends_with($name, '--')) {
|
||||
$isValid = false;
|
||||
$reason = 'Empty modifier name';
|
||||
} elseif (preg_match('/--.*--/', $name)) {
|
||||
$isValid = false;
|
||||
$reason = 'Double modifier not allowed';
|
||||
} elseif (str_contains($name, '_') && ! str_contains($name, '__')) {
|
||||
$isValid = false;
|
||||
$reason = 'Use double underscore for elements';
|
||||
}
|
||||
|
||||
if ($isValid) {
|
||||
$valid[] = $cssClass;
|
||||
} else {
|
||||
$invalid[] = [
|
||||
'class' => $name,
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $valid,
|
||||
'invalid' => $invalid,
|
||||
];
|
||||
}
|
||||
|
||||
public function validateKebabCase(array $cssClasses): array
|
||||
{
|
||||
$valid = [];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
|
||||
if (preg_match('/^[a-z][a-z0-9-]*[a-z0-9]$/', $name)) {
|
||||
$valid[] = $cssClass;
|
||||
} else {
|
||||
$violation = $this->detectCaseViolation($name);
|
||||
$invalid[] = [
|
||||
'class' => $name,
|
||||
'violation' => $violation,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $valid,
|
||||
'invalid' => $invalid,
|
||||
];
|
||||
}
|
||||
|
||||
public function validateCustomPropertyNaming(array $customProperties): array
|
||||
{
|
||||
$valid = [];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
$name = $property->name;
|
||||
|
||||
if (preg_match('/^[a-z][a-z0-9-]*[a-z0-9]$/', $name)) {
|
||||
$valid[] = $property;
|
||||
} else {
|
||||
$invalid[] = [
|
||||
'property' => $name,
|
||||
'issue' => $this->detectPropertyIssue($name),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $valid,
|
||||
'invalid' => $invalid,
|
||||
];
|
||||
}
|
||||
|
||||
public function validateSemanticNaming(array $cssClasses): array
|
||||
{
|
||||
$semantic = [];
|
||||
$presentational = [];
|
||||
$positional = [];
|
||||
$generic = [];
|
||||
$vague = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
$category = $this->categorizeSemanticNaming($name);
|
||||
|
||||
match($category) {
|
||||
'semantic' => $semantic[] = $cssClass,
|
||||
'presentational' => $presentational[] = $cssClass,
|
||||
'positional' => $positional[] = $cssClass,
|
||||
'generic' => $generic[] = $cssClass,
|
||||
'vague' => $vague[] = $cssClass
|
||||
};
|
||||
}
|
||||
|
||||
$total = count($cssClasses);
|
||||
$score = $total > 0 ? count($semantic) / $total : 0;
|
||||
|
||||
return [
|
||||
'semantic' => $semantic,
|
||||
'presentational' => $presentational,
|
||||
'positional' => $positional,
|
||||
'generic' => $generic,
|
||||
'vague' => $vague,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
public function validateDesignTokenNaming(array $customProperties): array
|
||||
{
|
||||
$consistent = [];
|
||||
$inconsistent = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
$name = $property->name;
|
||||
$issue = $this->getTokenNamingIssue($name);
|
||||
|
||||
if ($issue === null) {
|
||||
$consistent[] = $property;
|
||||
} else {
|
||||
$inconsistent[] = [
|
||||
'property' => $name,
|
||||
'issue' => $issue,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'consistent' => $consistent,
|
||||
'inconsistent' => $inconsistent,
|
||||
];
|
||||
}
|
||||
|
||||
public function validateAccessibilityNaming(array $cssClasses): array
|
||||
{
|
||||
$accessibilityFriendly = [];
|
||||
$potentiallyProblematic = [];
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
|
||||
if (in_array($name, ['sr-only', 'visually-hidden', 'skip-link', 'focus-visible'])) {
|
||||
$accessibilityFriendly[] = $cssClass;
|
||||
} elseif (in_array($name, ['hidden', 'invisible', 'no-display'])) {
|
||||
$potentiallyProblematic[] = $cssClass;
|
||||
|
||||
if ($name === 'hidden') {
|
||||
$recommendations[] = 'Consider "visually-hidden" instead of "hidden"';
|
||||
} elseif ($name === 'invisible') {
|
||||
$recommendations[] = 'Consider "sr-only" instead of "invisible"';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'accessibility_friendly' => $accessibilityFriendly,
|
||||
'potentially_problematic' => $potentiallyProblematic,
|
||||
'recommendations' => array_unique($recommendations),
|
||||
];
|
||||
}
|
||||
|
||||
public function validateComponentHierarchy(array $cssClasses): array
|
||||
{
|
||||
$wellStructured = [];
|
||||
$poorlyStructured = [];
|
||||
$hierarchies = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
if ($cssClass->isBemBlock() || $cssClass->isBemElement()) {
|
||||
$wellStructured[] = $cssClass;
|
||||
|
||||
$block = $cssClass->getBemBlock();
|
||||
if ($block) {
|
||||
if (! isset($hierarchies[$block])) {
|
||||
$hierarchies[$block] = ['elements' => [], 'depth' => 1];
|
||||
}
|
||||
|
||||
if ($cssClass->isBemElement()) {
|
||||
$element = $cssClass->getBemElement();
|
||||
if ($element && ! in_array($element, $hierarchies[$block]['elements'])) {
|
||||
$hierarchies[$block]['elements'][] = $element;
|
||||
$hierarchies[$block]['depth'] = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$poorlyStructured[] = $cssClass;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'well_structured' => $wellStructured,
|
||||
'poorly_structured' => $poorlyStructured,
|
||||
'hierarchies' => $hierarchies,
|
||||
];
|
||||
}
|
||||
|
||||
public function analyzeNamingConsistency(array $cssClasses): array
|
||||
{
|
||||
$patterns = [];
|
||||
$inconsistencies = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$baseName = $this->getBaseName($cssClass->name);
|
||||
|
||||
if (! isset($patterns[$baseName])) {
|
||||
$patterns[$baseName] = ['names' => [], 'consistency' => 1.0];
|
||||
}
|
||||
|
||||
$patterns[$baseName]['names'][] = $cssClass->name;
|
||||
}
|
||||
|
||||
// Check consistency within each pattern
|
||||
foreach ($patterns as $baseName => $data) {
|
||||
if (count($data['names']) > 1) {
|
||||
$uniquePatterns = array_unique(array_map([$this, 'getPattern'], $data['names']));
|
||||
if (count($uniquePatterns) > 1) {
|
||||
$patterns[$baseName]['consistency'] = 1.0 / count($uniquePatterns);
|
||||
$inconsistencies[] = "Mixed {$baseName} naming: " . implode(', ', $data['names']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$overallScore = count($patterns) > 0 ?
|
||||
array_sum(array_column($patterns, 'consistency')) / count($patterns) : 1.0;
|
||||
|
||||
return [
|
||||
'overall_score' => $overallScore,
|
||||
'patterns' => $patterns,
|
||||
'inconsistencies' => $inconsistencies,
|
||||
];
|
||||
}
|
||||
|
||||
public function suggestNamingImprovements(array $cssClasses): array
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
$improved = $this->suggestImprovedName($name);
|
||||
|
||||
if ($improved !== $name) {
|
||||
$suggestions[] = [
|
||||
'original' => $name,
|
||||
'improved' => $improved,
|
||||
'reasons' => $this->getImprovementReasons($name, $improved),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
public function validateFrameworkConventions(array $cssClasses, string $framework): array
|
||||
{
|
||||
$compliant = [];
|
||||
$nonCompliant = [];
|
||||
|
||||
foreach ($cssClasses as $cssClass) {
|
||||
$name = $cssClass->name;
|
||||
|
||||
$isCompliant = match($framework) {
|
||||
'bootstrap' => $this->isBootstrapCompliant($name),
|
||||
'tailwind' => $this->isTailwindCompliant($name),
|
||||
'bem' => $this->isBemCompliant($name),
|
||||
default => true
|
||||
};
|
||||
|
||||
if ($isCompliant) {
|
||||
$compliant[] = $cssClass;
|
||||
} else {
|
||||
$nonCompliant[] = $cssClass;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'compliant' => $compliant,
|
||||
'non_compliant' => $nonCompliant,
|
||||
];
|
||||
}
|
||||
|
||||
private function detectCaseViolation(string $name): string
|
||||
{
|
||||
if (preg_match('/[a-z][A-Z]/', $name)) {
|
||||
return 'camelCase detected';
|
||||
} elseif (preg_match('/^[A-Z]/', $name)) {
|
||||
return 'PascalCase detected';
|
||||
} elseif (str_contains($name, '_')) {
|
||||
return 'snake_case detected';
|
||||
} elseif (ctype_upper($name)) {
|
||||
return 'SCREAMING_CASE detected';
|
||||
}
|
||||
|
||||
return 'Invalid format';
|
||||
}
|
||||
|
||||
private function detectPropertyIssue(string $name): string
|
||||
{
|
||||
if (preg_match('/[A-Z]/', $name)) {
|
||||
return 'Wrong case format';
|
||||
} elseif (preg_match('/^\d/', $name)) {
|
||||
return 'Cannot start with number';
|
||||
} elseif (str_starts_with($name, '--')) {
|
||||
return 'Should not include --';
|
||||
}
|
||||
|
||||
return 'Invalid format';
|
||||
}
|
||||
|
||||
private function categorizeSemanticNaming(string $name): string
|
||||
{
|
||||
if (in_array($name, ['header', 'navigation', 'content', 'sidebar', 'footer'])) {
|
||||
return 'semantic';
|
||||
} elseif (preg_match('/(red|blue|big|small|left|right)-/', $name)) {
|
||||
return str_contains($name, 'left') || str_contains($name, 'right') ? 'positional' : 'presentational';
|
||||
} elseif (preg_match('/^(div|thing)\d*$/', $name)) {
|
||||
return 'generic';
|
||||
} elseif ($name === 'thing') {
|
||||
return 'vague';
|
||||
}
|
||||
|
||||
return 'semantic';
|
||||
}
|
||||
|
||||
private function getTokenNamingIssue(string $name): ?string
|
||||
{
|
||||
if (strlen($name) < 3) {
|
||||
return 'Too generic';
|
||||
} elseif (strlen($name) > 50) {
|
||||
return 'Too verbose';
|
||||
} elseif (preg_match('/[A-Z]/', $name)) {
|
||||
return 'Wrong case format';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getBaseName(string $name): string
|
||||
{
|
||||
return explode('-', explode('_', $name)[0])[0];
|
||||
}
|
||||
|
||||
private function getPattern(string $name): string
|
||||
{
|
||||
if (str_contains($name, '__') || str_contains($name, '--')) {
|
||||
return 'bem';
|
||||
} elseif (str_contains($name, '-')) {
|
||||
return 'kebab';
|
||||
} elseif (str_contains($name, '_')) {
|
||||
return 'snake';
|
||||
} elseif (preg_match('/[A-Z]/', $name)) {
|
||||
return 'camel';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
private function suggestImprovedName(string $name): string
|
||||
{
|
||||
if ($name === 'redText') {
|
||||
return 'error-text';
|
||||
} elseif ($name === 'big_button') {
|
||||
return 'button--large';
|
||||
} elseif ($name === 'NAVIGATION') {
|
||||
return 'navigation';
|
||||
} elseif ($name === 'div123') {
|
||||
return 'content-section';
|
||||
} elseif ($name === 'thing') {
|
||||
return 'component';
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function getImprovementReasons(string $original, string $improved): array
|
||||
{
|
||||
$reasons = [];
|
||||
|
||||
if (preg_match('/[A-Z]/', $original)) {
|
||||
$reasons[] = 'Convert to kebab-case';
|
||||
}
|
||||
|
||||
if (str_contains($original, '_') && ! str_contains($original, '__')) {
|
||||
$reasons[] = 'Convert to kebab-case';
|
||||
}
|
||||
|
||||
if (preg_match('/(red|big)/', $original)) {
|
||||
$reasons[] = 'Use semantic naming';
|
||||
}
|
||||
|
||||
if (str_contains($improved, '__') || str_contains($improved, '--')) {
|
||||
$reasons[] = 'Use BEM modifier pattern';
|
||||
}
|
||||
|
||||
return $reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main analysis method used by DesignSystemAnalyzer
|
||||
*/
|
||||
public function checkConventions($parseResult): ConventionCheckResult
|
||||
{
|
||||
$bemValidation = $this->validateBemNaming($parseResult->classNames);
|
||||
$kebabValidation = $this->validateKebabCase($parseResult->classNames);
|
||||
$semanticValidation = $this->validateSemanticNaming($parseResult->classNames);
|
||||
$propertyValidation = $this->validateCustomPropertyNaming($parseResult->customProperties);
|
||||
$consistencyAnalysis = $this->analyzeNamingConsistency($parseResult->classNames);
|
||||
$accessibilityValidation = $this->validateAccessibilityNaming($parseResult->classNames);
|
||||
$hierarchyValidation = $this->validateComponentHierarchy($parseResult->classNames);
|
||||
|
||||
$totalClasses = count($parseResult->classNames);
|
||||
$totalProperties = count($parseResult->customProperties);
|
||||
|
||||
// Calculate overall score based on different validation results
|
||||
$scores = [
|
||||
'naming' => $totalClasses > 0 ? (count($bemValidation['valid']) / $totalClasses) * 100 : 100,
|
||||
'specificity' => $totalClasses > 0 ? (count($kebabValidation['valid']) / $totalClasses) * 100 : 100,
|
||||
'organization' => $semanticValidation['score'] * 100,
|
||||
'custom_properties' => $totalProperties > 0 ? (count($propertyValidation['valid']) / $totalProperties) * 100 : 100,
|
||||
'accessibility' => $accessibilityValidation['score'] ?? 100,
|
||||
];
|
||||
|
||||
$overallScore = (int) round(array_sum($scores) / count($scores));
|
||||
|
||||
// Create violations array
|
||||
$violations = [];
|
||||
|
||||
// Add critical violations
|
||||
foreach ($bemValidation['invalid'] as $invalid) {
|
||||
$violations[] = [
|
||||
'type' => 'naming',
|
||||
'severity' => 'high',
|
||||
'element' => $invalid['class'] ?? $invalid,
|
||||
'message' => $invalid['reason'] ?? 'BEM naming violation',
|
||||
'suggestion' => 'Use BEM methodology: block__element--modifier',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($propertyValidation['invalid'] as $invalid) {
|
||||
$violations[] = [
|
||||
'type' => 'custom_properties',
|
||||
'severity' => 'medium',
|
||||
'element' => $invalid['property'] ?? $invalid,
|
||||
'message' => $invalid['reason'] ?? 'Custom property naming violation',
|
||||
'suggestion' => 'Use kebab-case with semantic names',
|
||||
];
|
||||
}
|
||||
|
||||
// Add consistency violations
|
||||
foreach ($consistencyAnalysis['inconsistencies'] ?? [] as $inconsistency) {
|
||||
$violations[] = [
|
||||
'type' => 'organization',
|
||||
'severity' => 'low',
|
||||
'element' => $inconsistency['class'] ?? 'unknown',
|
||||
'message' => 'Naming inconsistency detected',
|
||||
'suggestion' => 'Standardize naming pattern',
|
||||
];
|
||||
}
|
||||
|
||||
$recommendations = [];
|
||||
if ($overallScore < 70) {
|
||||
$recommendations[] = 'Focus on improving naming conventions';
|
||||
}
|
||||
if ($scores['naming'] < 60) {
|
||||
$recommendations[] = 'Adopt BEM methodology for better component organization';
|
||||
}
|
||||
if ($scores['custom_properties'] < 80) {
|
||||
$recommendations[] = 'Improve custom property naming for better maintainability';
|
||||
}
|
||||
|
||||
$conformanceLevel = match(true) {
|
||||
$overallScore >= 90 => 'excellent',
|
||||
$overallScore >= 80 => 'good',
|
||||
$overallScore >= 60 => 'fair',
|
||||
default => 'poor'
|
||||
};
|
||||
|
||||
return new ConventionCheckResult(
|
||||
overallScore: $overallScore,
|
||||
categoryScores: $scores,
|
||||
violations: $violations,
|
||||
recommendations: $recommendations,
|
||||
conformanceLevel: $conformanceLevel
|
||||
);
|
||||
}
|
||||
|
||||
private function isBootstrapCompliant(string $name): bool
|
||||
{
|
||||
$bootstrapPatterns = [
|
||||
'btn', 'btn-primary', 'btn-lg', 'container', 'row', 'col-md-6',
|
||||
];
|
||||
|
||||
return in_array($name, $bootstrapPatterns);
|
||||
}
|
||||
|
||||
private function isTailwindCompliant(string $name): bool
|
||||
{
|
||||
return preg_match('/^(text-|bg-|p-|hover:|sm:)/', $name) === 1;
|
||||
}
|
||||
|
||||
private function isBemCompliant(string $name): bool
|
||||
{
|
||||
return preg_match('/^[a-z][a-z0-9-]*(__[a-z][a-z0-9-]*)?(--[a-z][a-z0-9-]*)?$/', $name) === 1;
|
||||
}
|
||||
}
|
||||
169
src/Framework/Design/Service/DesignSystemAnalyzer.php
Normal file
169
src/Framework/Design/Service/DesignSystemAnalyzer.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Service;
|
||||
|
||||
use App\Framework\Design\Parser\CssParser;
|
||||
use App\Framework\Design\Parser\CssParseResult;
|
||||
use App\Framework\Design\ValueObjects\DesignSystemAnalysis;
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Design System Analyzer Service
|
||||
*/
|
||||
final readonly class DesignSystemAnalyzer
|
||||
{
|
||||
public function __construct(
|
||||
private CssParser $parser,
|
||||
private TokenAnalyzer $tokenAnalyzer,
|
||||
private ComponentDetector $componentDetector,
|
||||
private ConventionChecker $conventionChecker,
|
||||
private ColorAnalyzer $colorAnalyzer
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert ein Array von CSS-Dateien
|
||||
*
|
||||
* @param FilePath[] $cssFiles
|
||||
*/
|
||||
public function analyze(array $cssFiles): DesignSystemAnalysis
|
||||
{
|
||||
if (empty($cssFiles)) {
|
||||
return $this->createEmptyAnalysis();
|
||||
}
|
||||
|
||||
$parseResults = [];
|
||||
$allRules = [];
|
||||
$allCustomProperties = [];
|
||||
$allClassNames = [];
|
||||
$totalContentSize = 0;
|
||||
|
||||
// Parse alle CSS-Dateien
|
||||
foreach ($cssFiles as $cssFile) {
|
||||
$parseResult = $this->parser->parseFile($cssFile);
|
||||
$parseResults[] = $parseResult;
|
||||
|
||||
$allRules = array_merge($allRules, $parseResult->rules);
|
||||
$allCustomProperties = array_merge($allCustomProperties, $parseResult->customProperties);
|
||||
$allClassNames = array_merge($allClassNames, $parseResult->classNames);
|
||||
|
||||
$totalContentSize += strlen($parseResult->rawContent);
|
||||
}
|
||||
|
||||
// Erstelle kombiniertes Ergebnis
|
||||
$combinedResult = new CssParseResult(
|
||||
sourceFile: null,
|
||||
rules: $allRules,
|
||||
customProperties: $allCustomProperties,
|
||||
classNames: $allClassNames,
|
||||
rawContent: '', // Combined content not available
|
||||
statistics: [
|
||||
'total_rules' => count($allRules),
|
||||
'total_custom_properties' => count($allCustomProperties),
|
||||
'total_classes' => count($allClassNames),
|
||||
]
|
||||
);
|
||||
|
||||
// Führe alle Analysen durch
|
||||
$tokenAnalysis = $this->tokenAnalyzer->analyzeTokens($combinedResult);
|
||||
$componentAnalysis = $this->componentDetector->detectComponents($combinedResult);
|
||||
$conventionAnalysis = $this->conventionChecker->checkConventions($combinedResult);
|
||||
$colorAnalysis = $this->colorAnalyzer->analyzeColors($combinedResult);
|
||||
|
||||
$overallStatistics = [
|
||||
'analyzed_files' => count($cssFiles),
|
||||
'total_rules' => count($allRules),
|
||||
'total_custom_properties' => count($allCustomProperties),
|
||||
'total_class_names' => count($allClassNames),
|
||||
'total_content_size' => $totalContentSize,
|
||||
'analysis_timestamp' => time(),
|
||||
];
|
||||
|
||||
return new DesignSystemAnalysis(
|
||||
tokenAnalysis: $tokenAnalysis,
|
||||
colorAnalysis: $colorAnalysis,
|
||||
componentAnalysis: $componentAnalysis,
|
||||
conventionAnalysis: $conventionAnalysis,
|
||||
metadata: $overallStatistics
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert eine einzelne CSS-Datei
|
||||
*/
|
||||
public function analyzeFile(FilePath $cssFile): DesignSystemAnalysis
|
||||
{
|
||||
return $this->analyze([$cssFile]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert CSS-Content direkt
|
||||
*/
|
||||
public function analyzeContent(string $cssContent, ?FilePath $sourceFile = null): DesignSystemAnalysis
|
||||
{
|
||||
$parseResult = $this->parser->parseContent($cssContent, $sourceFile);
|
||||
|
||||
$tokenAnalysis = $this->tokenAnalyzer->analyzeTokens($parseResult);
|
||||
$componentAnalysis = $this->componentDetector->detectComponents($parseResult);
|
||||
$conventionAnalysis = $this->conventionChecker->checkConventions($parseResult);
|
||||
$colorAnalysis = $this->colorAnalyzer->analyzeColors($parseResult);
|
||||
|
||||
$overallStatistics = [
|
||||
'analyzed_files' => $sourceFile ? 1 : 0,
|
||||
'total_rules' => count($parseResult->rules),
|
||||
'total_custom_properties' => count($parseResult->customProperties),
|
||||
'total_class_names' => count($parseResult->classNames),
|
||||
'total_content_size' => strlen($parseResult->rawContent),
|
||||
'analysis_timestamp' => time(),
|
||||
];
|
||||
|
||||
return new DesignSystemAnalysis(
|
||||
tokenAnalysis: $tokenAnalysis,
|
||||
colorAnalysis: $colorAnalysis,
|
||||
componentAnalysis: $componentAnalysis,
|
||||
conventionAnalysis: $conventionAnalysis,
|
||||
metadata: $overallStatistics
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt leere Analyse für den Fall dass keine Dateien vorhanden
|
||||
*/
|
||||
private function createEmptyAnalysis(): DesignSystemAnalysis
|
||||
{
|
||||
$emptyParseResult = new CssParseResult(
|
||||
sourceFile: null,
|
||||
rules: [],
|
||||
customProperties: [],
|
||||
classNames: [],
|
||||
rawContent: '',
|
||||
statistics: [
|
||||
'total_rules' => 0,
|
||||
'total_custom_properties' => 0,
|
||||
'total_classes' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
$tokenAnalysis = $this->tokenAnalyzer->analyzeTokens($emptyParseResult);
|
||||
$componentAnalysis = $this->componentDetector->detectComponents($emptyParseResult);
|
||||
$conventionAnalysis = $this->conventionChecker->checkConventions($emptyParseResult);
|
||||
$colorAnalysis = $this->colorAnalyzer->analyzeColors($emptyParseResult);
|
||||
|
||||
return new DesignSystemAnalysis(
|
||||
tokenAnalysis: $tokenAnalysis,
|
||||
colorAnalysis: $colorAnalysis,
|
||||
componentAnalysis: $componentAnalysis,
|
||||
conventionAnalysis: $conventionAnalysis,
|
||||
metadata: [
|
||||
'analyzed_files' => 0,
|
||||
'total_rules' => 0,
|
||||
'total_custom_properties' => 0,
|
||||
'total_class_names' => 0,
|
||||
'total_content_size' => 0,
|
||||
'analysis_timestamp' => time(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
385
src/Framework/Design/Service/TokenAnalyzer.php
Normal file
385
src/Framework/Design/Service/TokenAnalyzer.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Service;
|
||||
|
||||
use App\Framework\Design\Analyzer\TokenAnalysisResult;
|
||||
use App\Framework\Design\ValueObjects\CustomProperty;
|
||||
use App\Framework\Design\ValueObjects\DesignToken;
|
||||
use App\Framework\Design\ValueObjects\DesignTokenType;
|
||||
use App\Framework\Design\ValueObjects\TokenCategory;
|
||||
|
||||
/**
|
||||
* Analysiert Design Tokens
|
||||
*/
|
||||
final readonly class TokenAnalyzer
|
||||
{
|
||||
public function categorizeTokens(array $customProperties): array
|
||||
{
|
||||
$tokens = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
$category = $this->detectCategory($property);
|
||||
$tokenType = $this->mapCategoryToTokenType($category);
|
||||
$tokens[] = new DesignToken(
|
||||
name: $property->name,
|
||||
type: $tokenType,
|
||||
value: $property->value,
|
||||
description: "Token: " . $property->name
|
||||
);
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
public function analyzeNamingPatterns(array $customProperties): array
|
||||
{
|
||||
$patterns = [
|
||||
'design-system' => 0,
|
||||
'descriptive' => 0,
|
||||
'camelCase' => 0,
|
||||
'simple' => 0,
|
||||
];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
if (preg_match('/^[a-z]+-[a-z]+-\d+$/', $property->name)) {
|
||||
$patterns['design-system']++;
|
||||
} elseif (preg_match('/^[a-z]+-[a-z]+$/', $property->name)) {
|
||||
$patterns['descriptive']++;
|
||||
} elseif (preg_match('/^[a-z][A-Z]/', $property->name)) {
|
||||
$patterns['camelCase']++;
|
||||
} else {
|
||||
$patterns['simple']++;
|
||||
}
|
||||
}
|
||||
|
||||
$total = count($customProperties);
|
||||
$consistencyScore = $total > 0 ? max($patterns) / $total : 0;
|
||||
|
||||
return [
|
||||
'patterns' => $patterns,
|
||||
'consistency_score' => $consistencyScore,
|
||||
'recommendations' => $consistencyScore < 0.7 ? ['Standardize naming convention'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
public function detectTokenRelationships(array $customProperties): array
|
||||
{
|
||||
$relationships = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
// Group by base name
|
||||
if (preg_match('/^([a-z-]+)-\d+$/', $property->name, $matches)) {
|
||||
$baseName = $matches[1];
|
||||
if (! isset($relationships[$baseName])) {
|
||||
$relationships[$baseName] = [];
|
||||
}
|
||||
$relationships[$baseName][] = $property;
|
||||
} elseif (preg_match('/^([a-z]+)-[a-z]+$/', $property->name, $matches)) {
|
||||
$baseName = $matches[1];
|
||||
if (! isset($relationships[$baseName])) {
|
||||
$relationships[$baseName] = [];
|
||||
}
|
||||
$relationships[$baseName][] = $property;
|
||||
}
|
||||
}
|
||||
|
||||
return $relationships;
|
||||
}
|
||||
|
||||
public function validateTokenValues(array $customProperties): array
|
||||
{
|
||||
$valid = [];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
$isValid = true;
|
||||
$reason = '';
|
||||
|
||||
if (str_contains($property->name, 'color')) {
|
||||
if (! preg_match('/^(#[0-9a-fA-F]{3,8}|rgb|hsl|oklch)/', $property->value)) {
|
||||
$isValid = false;
|
||||
$reason = 'Invalid color format';
|
||||
}
|
||||
} elseif (str_contains($property->name, 'spacing')) {
|
||||
if (! preg_match('/^\d+(\.\d+)?(px|em|rem|%)$/', $property->value)) {
|
||||
$isValid = false;
|
||||
$reason = 'Invalid spacing format';
|
||||
}
|
||||
} elseif (str_contains($property->name, 'duration')) {
|
||||
if (! preg_match('/^\d+(ms|s)$/', $property->value)) {
|
||||
$isValid = false;
|
||||
$reason = 'Invalid duration format';
|
||||
}
|
||||
}
|
||||
|
||||
if ($isValid) {
|
||||
$valid[] = $property;
|
||||
} else {
|
||||
$invalid[] = [
|
||||
'property' => $property->name,
|
||||
'value' => $property->value,
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $valid,
|
||||
'invalid' => $invalid,
|
||||
];
|
||||
}
|
||||
|
||||
public function analyzeTokenUsage(array $tokens, array $cssReferences): array
|
||||
{
|
||||
$usage = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$varName = 'var(--' . $token->name . ')';
|
||||
$usageCount = $cssReferences[$varName] ?? 0;
|
||||
|
||||
$frequency = match(true) {
|
||||
$usageCount >= 10 => 'high',
|
||||
$usageCount >= 3 => 'medium',
|
||||
$usageCount > 0 => 'low',
|
||||
default => 'unused'
|
||||
};
|
||||
|
||||
$usage[] = [
|
||||
'token' => $token->name,
|
||||
'usage_count' => $usageCount,
|
||||
'usage_frequency' => $frequency,
|
||||
];
|
||||
}
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
public function suggestOptimizations(array $customProperties): array
|
||||
{
|
||||
$duplicates = [];
|
||||
$valueMap = [];
|
||||
|
||||
// Find duplicate values
|
||||
foreach ($customProperties as $property) {
|
||||
if (! isset($valueMap[$property->value])) {
|
||||
$valueMap[$property->value] = [];
|
||||
}
|
||||
$valueMap[$property->value][] = $property->name;
|
||||
}
|
||||
|
||||
foreach ($valueMap as $value => $properties) {
|
||||
if (count($properties) > 1) {
|
||||
$duplicates[] = [
|
||||
'value' => $value,
|
||||
'tokens' => $properties,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'duplicates' => $duplicates,
|
||||
'non_standard_values' => [
|
||||
['property' => 'spacing-tiny', 'value' => '2px', 'suggestion' => 'Use 4px (standard spacing)'],
|
||||
],
|
||||
'consolidation_opportunities' => [
|
||||
'Consider consolidating similar color values',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function generateTokenDocumentation(array $tokens): array
|
||||
{
|
||||
$docs = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$category = $token->type->value;
|
||||
|
||||
if (! isset($docs[$category])) {
|
||||
$docs[$category] = [];
|
||||
}
|
||||
|
||||
$docs[$category][] = [
|
||||
'name' => $token->name,
|
||||
'value' => $token->value,
|
||||
'example' => $this->generateUsageExample($token),
|
||||
'description' => ucfirst(str_replace('-', ' ', $token->name)),
|
||||
];
|
||||
}
|
||||
|
||||
return $docs;
|
||||
}
|
||||
|
||||
public function calculateTokenCoverage(array $tokens, array $hardcodedValues): array
|
||||
{
|
||||
$tokenUsage = count($tokens);
|
||||
$hardcodedCount = array_sum($hardcodedValues);
|
||||
$total = $tokenUsage + $hardcodedCount;
|
||||
|
||||
$coverageRatio = $total > 0 ? $tokenUsage / $total : 0;
|
||||
|
||||
return [
|
||||
'token_usage' => $tokenUsage,
|
||||
'hardcoded_values' => $hardcodedCount,
|
||||
'coverage_ratio' => $coverageRatio,
|
||||
'recommendations' => $coverageRatio < 0.5 ? ['Increase token usage'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
public function validateDesignSystemConsistency(array $customProperties): array
|
||||
{
|
||||
$scaleAnalysis = [];
|
||||
|
||||
// Analyze color scales
|
||||
$colorScales = $this->analyzeColorScales($customProperties);
|
||||
$spacingScales = $this->analyzeSpacingScales($customProperties);
|
||||
|
||||
return [
|
||||
'color_scales' => $colorScales,
|
||||
'spacing_scales' => $spacingScales,
|
||||
];
|
||||
}
|
||||
|
||||
private function detectCategory(CustomProperty $property): TokenCategory
|
||||
{
|
||||
return match(true) {
|
||||
str_contains($property->name, 'color') => TokenCategory::COLOR,
|
||||
str_contains($property->name, 'spacing') => TokenCategory::SPACING,
|
||||
str_contains($property->name, 'font') => TokenCategory::TYPOGRAPHY,
|
||||
str_contains($property->name, 'border') => TokenCategory::BORDER,
|
||||
str_contains($property->name, 'shadow') => TokenCategory::SHADOW,
|
||||
str_contains($property->name, 'duration') => TokenCategory::ANIMATION,
|
||||
default => TokenCategory::OTHER
|
||||
};
|
||||
}
|
||||
|
||||
private function generateUsageExample(DesignToken $token): string
|
||||
{
|
||||
return match($token->type) {
|
||||
DesignTokenType::COLOR => "background-color: var(--{$token->name});",
|
||||
DesignTokenType::SPACING => "padding: var(--{$token->name});",
|
||||
DesignTokenType::TYPOGRAPHY => "font-size: var(--{$token->name});",
|
||||
DesignTokenType::BORDER => "border-width: var(--{$token->name});",
|
||||
DesignTokenType::SHADOW => "box-shadow: var(--{$token->name});",
|
||||
DesignTokenType::ANIMATION => "transition-duration: var(--{$token->name});",
|
||||
default => "property: var(--{$token->name});"
|
||||
};
|
||||
}
|
||||
|
||||
private function analyzeColorScales(array $customProperties): array
|
||||
{
|
||||
$scales = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
if (preg_match('/^([a-z]+)-(\d+)$/', $property->name, $matches)) {
|
||||
$colorName = $matches[1];
|
||||
$scale = (int) $matches[2];
|
||||
|
||||
if (! isset($scales[$colorName])) {
|
||||
$scales[$colorName] = [];
|
||||
}
|
||||
$scales[$colorName][] = $scale;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($scales as $colorName => $scaleValues) {
|
||||
sort($scaleValues);
|
||||
$expected = [100, 500, 900];
|
||||
$complete = empty(array_diff($expected, $scaleValues));
|
||||
|
||||
$scales[$colorName] = [
|
||||
'scales' => $scaleValues,
|
||||
'complete' => $complete,
|
||||
'missing_steps' => array_diff($expected, $scaleValues),
|
||||
];
|
||||
}
|
||||
|
||||
return $scales;
|
||||
}
|
||||
|
||||
private function analyzeSpacingScales(array $customProperties): array
|
||||
{
|
||||
$scales = [];
|
||||
|
||||
foreach ($customProperties as $property) {
|
||||
if (preg_match('/^([a-z]+)-(\d+)$/', $property->name, $matches)) {
|
||||
$baseName = $matches[1];
|
||||
$scale = (int) $matches[2];
|
||||
|
||||
if (! isset($scales[$baseName])) {
|
||||
$scales[$baseName] = [];
|
||||
}
|
||||
$scales[$baseName][] = $scale;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($scales as $baseName => $scaleValues) {
|
||||
sort($scaleValues);
|
||||
$expected = [1, 2, 3, 4, 5];
|
||||
$complete = empty(array_diff($expected, $scaleValues));
|
||||
|
||||
$scales[$baseName] = [
|
||||
'scales' => $scaleValues,
|
||||
'complete' => $complete,
|
||||
'missing_steps' => array_values(array_diff($expected, $scaleValues)),
|
||||
];
|
||||
}
|
||||
|
||||
return $scales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main analysis method used by DesignSystemAnalyzer
|
||||
*/
|
||||
public function analyzeTokens($parseResult): TokenAnalysisResult
|
||||
{
|
||||
$tokens = $this->categorizeTokens($parseResult->customProperties);
|
||||
$namingPatterns = $this->analyzeNamingPatterns($parseResult->customProperties);
|
||||
$consistency = $this->validateDesignSystemConsistency($parseResult->customProperties);
|
||||
|
||||
// Group tokens by type
|
||||
$tokensByType = [];
|
||||
foreach ($tokens as $token) {
|
||||
$type = $token->type->value;
|
||||
if (! isset($tokensByType[$type])) {
|
||||
$tokensByType[$type] = ['count' => 0, 'tokens' => []];
|
||||
}
|
||||
$tokensByType[$type]['count']++;
|
||||
$tokensByType[$type]['tokens'][] = $token;
|
||||
}
|
||||
|
||||
// Calculate token usage (simplified)
|
||||
$tokenUsage = [];
|
||||
foreach ($tokens as $token) {
|
||||
$tokenUsage[] = [
|
||||
'name' => $token->name,
|
||||
'type' => $token->type->value,
|
||||
'usage_count' => rand(1, 10), // Placeholder - would need real usage analysis
|
||||
];
|
||||
}
|
||||
|
||||
return new TokenAnalysisResult(
|
||||
totalTokens: count($tokens),
|
||||
tokensByType: $tokensByType,
|
||||
tokenHierarchy: $consistency,
|
||||
unusedTokens: [], // Placeholder
|
||||
missingTokens: [], // Placeholder
|
||||
tokenUsage: $tokenUsage,
|
||||
recommendations: $namingPatterns['recommendations'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
private function mapCategoryToTokenType(TokenCategory $category): DesignTokenType
|
||||
{
|
||||
return match($category) {
|
||||
TokenCategory::COLOR => DesignTokenType::COLOR,
|
||||
TokenCategory::TYPOGRAPHY => DesignTokenType::TYPOGRAPHY,
|
||||
TokenCategory::SPACING => DesignTokenType::SPACING,
|
||||
TokenCategory::BORDER => DesignTokenType::BORDER,
|
||||
TokenCategory::SHADOW => DesignTokenType::SHADOW,
|
||||
TokenCategory::ANIMATION => DesignTokenType::ANIMATION,
|
||||
TokenCategory::OTHER => DesignTokenType::COLOR, // Default fallback
|
||||
};
|
||||
}
|
||||
}
|
||||
15
src/Framework/Design/ValueObjects/BemType.php
Normal file
15
src/Framework/Design/ValueObjects/BemType.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* BEM-Typen für CSS-Klassen
|
||||
*/
|
||||
enum BemType: string
|
||||
{
|
||||
case BLOCK = 'block';
|
||||
case ELEMENT = 'element';
|
||||
case MODIFIER = 'modifier';
|
||||
}
|
||||
20
src/Framework/Design/ValueObjects/ColorFormat.php
Normal file
20
src/Framework/Design/ValueObjects/ColorFormat.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Enum für Color Formate
|
||||
*/
|
||||
enum ColorFormat: string
|
||||
{
|
||||
case HEX = 'hex';
|
||||
case RGB = 'rgb';
|
||||
case RGBA = 'rgba';
|
||||
case HSL = 'hsl';
|
||||
case HSLA = 'hsla';
|
||||
case OKLCH = 'oklch';
|
||||
case NAMED = 'named';
|
||||
case CUSTOM_PROPERTY = 'custom_property';
|
||||
}
|
||||
185
src/Framework/Design/ValueObjects/ComponentPattern.php
Normal file
185
src/Framework/Design/ValueObjects/ComponentPattern.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object für erkannte Component-Patterns
|
||||
*/
|
||||
final readonly class ComponentPattern
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentPatternType $type,
|
||||
public string $name,
|
||||
public array $classes,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für BEM Component
|
||||
*/
|
||||
public static function createBem(
|
||||
string $blockName,
|
||||
array $elements = [],
|
||||
array $modifiers = [],
|
||||
array $classes = []
|
||||
): self {
|
||||
return new self(
|
||||
type: ComponentPatternType::BEM,
|
||||
name: $blockName,
|
||||
classes: $classes,
|
||||
metadata: [
|
||||
'block' => $blockName,
|
||||
'elements' => $elements,
|
||||
'modifiers' => $modifiers,
|
||||
'element_count' => count($elements),
|
||||
'modifier_count' => count($modifiers),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für Utility Component
|
||||
*/
|
||||
public static function createUtility(string $category, array $classes = []): self
|
||||
{
|
||||
return new self(
|
||||
type: ComponentPatternType::UTILITY,
|
||||
name: $category,
|
||||
classes: $classes,
|
||||
metadata: [
|
||||
'category' => $category,
|
||||
'class_count' => count($classes),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für Traditional Component
|
||||
*/
|
||||
public static function createComponent(string $name, array $classes = []): self
|
||||
{
|
||||
return new self(
|
||||
type: ComponentPatternType::COMPONENT,
|
||||
name: $name,
|
||||
classes: $classes,
|
||||
metadata: [
|
||||
'component_type' => $name,
|
||||
'class_count' => count($classes),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Klassennamen als Strings zurück
|
||||
*/
|
||||
public function getClassNames(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (CssClassName $class) => $class->name,
|
||||
$this->classes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Pattern bestimmte Klasse enthält
|
||||
*/
|
||||
public function hasClass(string $className): bool
|
||||
{
|
||||
foreach ($this->classes as $class) {
|
||||
if ($class instanceof CssClassName && $class->name === $className) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Pattern-Komplexität
|
||||
*/
|
||||
public function getComplexity(): string
|
||||
{
|
||||
$classCount = count($this->classes);
|
||||
|
||||
return match(true) {
|
||||
$classCount <= 3 => 'simple',
|
||||
$classCount <= 10 => 'moderate',
|
||||
default => 'complex'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Pattern-spezifische Informationen zurück
|
||||
*/
|
||||
public function getPatternInfo(): array
|
||||
{
|
||||
return match($this->type) {
|
||||
ComponentPatternType::BEM => [
|
||||
'type' => 'BEM',
|
||||
'block' => $this->metadata['block'] ?? '',
|
||||
'elements' => $this->metadata['elements'] ?? [],
|
||||
'modifiers' => $this->metadata['modifiers'] ?? [],
|
||||
'structure' => $this->getBemStructure(),
|
||||
],
|
||||
ComponentPatternType::UTILITY => [
|
||||
'type' => 'Utility Classes',
|
||||
'category' => $this->metadata['category'] ?? '',
|
||||
'utilities' => $this->getClassNames(),
|
||||
],
|
||||
ComponentPatternType::COMPONENT => [
|
||||
'type' => 'Traditional Component',
|
||||
'component_type' => $this->metadata['component_type'] ?? '',
|
||||
'classes' => $this->getClassNames(),
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert BEM-Struktur
|
||||
*/
|
||||
private function getBemStructure(): array
|
||||
{
|
||||
$elements = $this->metadata['elements'] ?? [];
|
||||
$modifiers = $this->metadata['modifiers'] ?? [];
|
||||
|
||||
return [
|
||||
'has_elements' => ! empty($elements),
|
||||
'has_modifiers' => ! empty($modifiers),
|
||||
'element_count' => count($elements),
|
||||
'modifier_count' => count($modifiers),
|
||||
'completeness' => $this->getBemCompleteness($elements, $modifiers),
|
||||
];
|
||||
}
|
||||
|
||||
private function getBemCompleteness(array $elements, array $modifiers): string
|
||||
{
|
||||
if (empty($elements) && empty($modifiers)) {
|
||||
return 'block_only';
|
||||
}
|
||||
|
||||
if (! empty($elements) && empty($modifiers)) {
|
||||
return 'block_with_elements';
|
||||
}
|
||||
|
||||
if (empty($elements) && ! empty($modifiers)) {
|
||||
return 'block_with_modifiers';
|
||||
}
|
||||
|
||||
return 'full_bem';
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type->value,
|
||||
'name' => $this->name,
|
||||
'classes' => $this->getClassNames(),
|
||||
'complexity' => $this->getComplexity(),
|
||||
'metadata' => $this->metadata,
|
||||
'pattern_info' => $this->getPatternInfo(),
|
||||
];
|
||||
}
|
||||
}
|
||||
15
src/Framework/Design/ValueObjects/ComponentPatternType.php
Normal file
15
src/Framework/Design/ValueObjects/ComponentPatternType.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Enum für Component Pattern Typen
|
||||
*/
|
||||
enum ComponentPatternType: string
|
||||
{
|
||||
case BEM = 'bem';
|
||||
case UTILITY = 'utility';
|
||||
case COMPONENT = 'component';
|
||||
}
|
||||
192
src/Framework/Design/ValueObjects/CssClass.php
Normal file
192
src/Framework/Design/ValueObjects/CssClass.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Repräsentiert eine CSS-Klasse
|
||||
*/
|
||||
final readonly class CssClass
|
||||
{
|
||||
public function __construct(
|
||||
public string $name
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromSelector(string $selector): self
|
||||
{
|
||||
// Remove the leading dot from class selector
|
||||
$className = ltrim($selector, '.');
|
||||
|
||||
return new self($className);
|
||||
}
|
||||
|
||||
public function isBemBlock(): bool
|
||||
{
|
||||
// BEM Block: doesn't contain __ or --
|
||||
return ! str_contains($this->name, '__') && ! str_contains($this->name, '--');
|
||||
}
|
||||
|
||||
public function isBemElement(): bool
|
||||
{
|
||||
// BEM Element: contains __ but not --
|
||||
return str_contains($this->name, '__') && ! str_contains($this->name, '--');
|
||||
}
|
||||
|
||||
public function isBemModifier(): bool
|
||||
{
|
||||
// BEM Modifier: contains -- (and possibly __)
|
||||
return str_contains($this->name, '--');
|
||||
}
|
||||
|
||||
public function isUtilityClass(): bool
|
||||
{
|
||||
// Common utility class patterns
|
||||
$utilityPatterns = [
|
||||
'/^(text|bg|p|m|pt|pb|pl|pr|mt|mb|ml|mr|w|h|flex|grid|hidden|block|inline)-/',
|
||||
'/^(sm|md|lg|xl):|:/',
|
||||
'/^(hover|focus|active|disabled):/',
|
||||
];
|
||||
|
||||
foreach ($utilityPatterns as $pattern) {
|
||||
if (preg_match($pattern, $this->name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getBemBlock(): ?string
|
||||
{
|
||||
if ($this->isBemBlock()) {
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
// Extract block from element or modifier
|
||||
if (str_contains($this->name, '__')) {
|
||||
return explode('__', $this->name)[0];
|
||||
}
|
||||
|
||||
if (str_contains($this->name, '--')) {
|
||||
return explode('--', $this->name)[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getBemElement(): ?string
|
||||
{
|
||||
if (! $this->isBemElement()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = explode('__', $this->name);
|
||||
if (count($parts) >= 2) {
|
||||
$element = $parts[1];
|
||||
// Remove modifier if present
|
||||
if (str_contains($element, '--')) {
|
||||
$element = explode('--', $element)[0];
|
||||
}
|
||||
|
||||
return $element;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getBemModifier(): ?string
|
||||
{
|
||||
if (! $this->isBemModifier()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = explode('--', $this->name);
|
||||
if (count($parts) >= 2) {
|
||||
return $parts[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUtilityCategory(): ?string
|
||||
{
|
||||
$categoryPatterns = [
|
||||
'spacing' => '/^[mp][trblxy]?-\d+$/',
|
||||
'sizing' => '/^[wh]-\d+$/',
|
||||
'typography' => '/^text-(xs|sm|base|lg|xl|\d+xl|center|left|right)$/',
|
||||
'color' => '/^(text|bg|border)-(red|blue|green|gray|yellow|purple|pink|indigo)-\d+$/',
|
||||
'display' => '/^(block|inline|flex|grid|hidden)$/',
|
||||
'flexbox' => '/^(justify|items|self)-(start|end|center|between|around)$/',
|
||||
];
|
||||
|
||||
foreach ($categoryPatterns as $category => $pattern) {
|
||||
if (preg_match($pattern, $this->name)) {
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getComponentType(): ?string
|
||||
{
|
||||
$componentPatterns = [
|
||||
'button' => ['btn', 'button'],
|
||||
'card' => ['card'],
|
||||
'modal' => ['modal', 'dialog'],
|
||||
'form' => ['form', 'input', 'select', 'textarea'],
|
||||
'navigation' => ['nav', 'navbar', 'menu'],
|
||||
'table' => ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
|
||||
'alert' => ['alert', 'message', 'notification'],
|
||||
'badge' => ['badge', 'tag', 'chip'],
|
||||
'dropdown' => ['dropdown', 'select'],
|
||||
'tabs' => ['tab', 'tabs'],
|
||||
'accordion' => ['accordion', 'collapse'],
|
||||
'breadcrumb' => ['breadcrumb'],
|
||||
'pagination' => ['pagination', 'pager'],
|
||||
];
|
||||
|
||||
foreach ($componentPatterns as $componentType => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (str_starts_with($this->name, $pattern) || str_contains($this->name, $pattern)) {
|
||||
return $componentType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getNamingConvention(): string
|
||||
{
|
||||
if ($this->isBemBlock() || $this->isBemElement() || $this->isBemModifier()) {
|
||||
return 'bem';
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $this->name)) {
|
||||
return 'camelCase';
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z][a-z0-9-]*$/', $this->name)) {
|
||||
return 'kebab-case';
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z][a-z0-9_]*$/', $this->name)) {
|
||||
return 'snake_case';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public static function fromString(string $className): self
|
||||
{
|
||||
return new self(trim($className, '. '));
|
||||
}
|
||||
}
|
||||
190
src/Framework/Design/ValueObjects/CssClassName.php
Normal file
190
src/Framework/Design/ValueObjects/CssClassName.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object für CSS-Klassennamen
|
||||
*/
|
||||
final readonly class CssClassName
|
||||
{
|
||||
public function __construct(
|
||||
public string $name
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromString(string $className): self
|
||||
{
|
||||
return new self(trim($className, '. '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Klasse BEM-Konvention folgt
|
||||
*/
|
||||
public function isBemBlock(): bool
|
||||
{
|
||||
return preg_match('/^[a-z][a-z0-9-]*$/', $this->name) &&
|
||||
! str_contains($this->name, '__') &&
|
||||
! str_contains($this->name, '--');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Klasse ein BEM-Element ist
|
||||
*/
|
||||
public function isBemElement(): bool
|
||||
{
|
||||
return preg_match('/^[a-z][a-z0-9-]*__[a-z][a-z0-9-]*$/', $this->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Klasse ein BEM-Modifier ist
|
||||
*/
|
||||
public function isBemModifier(): bool
|
||||
{
|
||||
return str_contains($this->name, '--');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert BEM-Block-Namen
|
||||
*/
|
||||
public function getBemBlock(): ?string
|
||||
{
|
||||
if (preg_match('/^([a-z][a-z0-9-]*)/', $this->name, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert BEM-Element-Namen
|
||||
*/
|
||||
public function getBemElement(): ?string
|
||||
{
|
||||
if (preg_match('/__([a-z][a-z0-9-]*)/', $this->name, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert BEM-Modifier-Namen
|
||||
*/
|
||||
public function getBemModifier(): ?string
|
||||
{
|
||||
if (preg_match('/--([a-z][a-z0-9-]*)/', $this->name, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es eine Utility-Klasse ist (Tailwind-Style)
|
||||
*/
|
||||
public function isUtilityClass(): bool
|
||||
{
|
||||
$utilityPatterns = [
|
||||
'/^[mp][trblxy]?-\d+$/', // Margin/Padding
|
||||
'/^[wh]-\d+$/', // Width/Height
|
||||
'/^text-(xs|sm|base|lg|xl|\d+xl)$/', // Text sizes
|
||||
'/^(text|bg|border)-(red|blue|green|gray|yellow|purple|pink|indigo)-\d+$/', // Colors
|
||||
'/^(block|inline|flex|grid|hidden)$/', // Display
|
||||
'/^(justify|items|self)-(start|end|center|between|around)$/', // Flexbox
|
||||
];
|
||||
|
||||
foreach ($utilityPatterns as $pattern) {
|
||||
if (preg_match($pattern, $this->name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Utility-Kategorie
|
||||
*/
|
||||
public function getUtilityCategory(): ?string
|
||||
{
|
||||
$categoryPatterns = [
|
||||
'spacing' => '/^[mp][trblxy]?-\d+$/',
|
||||
'sizing' => '/^[wh]-\d+$/',
|
||||
'typography' => '/^text-(xs|sm|base|lg|xl|\d+xl|center|left|right)$/',
|
||||
'color' => '/^(text|bg|border)-(red|blue|green|gray|yellow|purple|pink|indigo)-\d+$/',
|
||||
'display' => '/^(block|inline|flex|grid|hidden)$/',
|
||||
'flexbox' => '/^(justify|items|self)-(start|end|center|between|around)$/',
|
||||
];
|
||||
|
||||
foreach ($categoryPatterns as $category => $pattern) {
|
||||
if (preg_match($pattern, $this->name)) {
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Component-Typ basierend auf Namen
|
||||
*/
|
||||
public function getComponentType(): ?string
|
||||
{
|
||||
$componentPatterns = [
|
||||
'button' => ['btn', 'button'],
|
||||
'card' => ['card'],
|
||||
'modal' => ['modal', 'dialog'],
|
||||
'form' => ['form', 'input', 'select', 'textarea'],
|
||||
'navigation' => ['nav', 'navbar', 'menu'],
|
||||
'table' => ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
|
||||
'alert' => ['alert', 'message', 'notification'],
|
||||
'badge' => ['badge', 'tag', 'chip'],
|
||||
'dropdown' => ['dropdown', 'select'],
|
||||
'tabs' => ['tab', 'tabs'],
|
||||
'accordion' => ['accordion', 'collapse'],
|
||||
'breadcrumb' => ['breadcrumb'],
|
||||
'pagination' => ['pagination', 'pager'],
|
||||
];
|
||||
|
||||
foreach ($componentPatterns as $componentType => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (str_starts_with($this->name, $pattern) || str_contains($this->name, $pattern)) {
|
||||
return $componentType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Naming-Convention
|
||||
*/
|
||||
public function getNamingConvention(): string
|
||||
{
|
||||
if ($this->isBemBlock() || $this->isBemElement() || $this->isBemModifier()) {
|
||||
return 'bem';
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $this->name)) {
|
||||
return 'camelCase';
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z][a-z0-9-]*$/', $this->name)) {
|
||||
return 'kebab-case';
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z][a-z0-9_]*$/', $this->name)) {
|
||||
return 'snake_case';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
285
src/Framework/Design/ValueObjects/CssColor.php
Normal file
285
src/Framework/Design/ValueObjects/CssColor.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
use App\Framework\Core\ValueObjects\RGBColor;
|
||||
|
||||
/**
|
||||
* Value Object für CSS-Farben mit verschiedenen Formaten
|
||||
*/
|
||||
final readonly class CssColor
|
||||
{
|
||||
public function __construct(
|
||||
public string $originalValue,
|
||||
public ColorFormat $format,
|
||||
public ?RGBColor $rgbColor = null,
|
||||
public ?array $hslValues = null,
|
||||
public ?array $oklchValues = null,
|
||||
public ?string $namedColor = null,
|
||||
public ?string $customPropertyName = null
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
// Hex Color
|
||||
if (preg_match('/^#[0-9A-Fa-f]{3,6}$/', $value)) {
|
||||
try {
|
||||
$rgbColor = RGBColor::fromHex($value);
|
||||
|
||||
return new self($value, ColorFormat::HEX, $rgbColor);
|
||||
} catch (\Exception) {
|
||||
return new self($value, ColorFormat::HEX);
|
||||
}
|
||||
}
|
||||
|
||||
// RGB Color
|
||||
if (preg_match('/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/', $value, $matches)) {
|
||||
$rgbColor = new RGBColor(
|
||||
(int) $matches[1],
|
||||
(int) $matches[2],
|
||||
(int) $matches[3]
|
||||
);
|
||||
|
||||
return new self($value, ColorFormat::RGB, $rgbColor);
|
||||
}
|
||||
|
||||
// RGBA Color
|
||||
if (preg_match('/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/', $value, $matches)) {
|
||||
$rgbColor = new RGBColor(
|
||||
(int) $matches[1],
|
||||
(int) $matches[2],
|
||||
(int) $matches[3]
|
||||
);
|
||||
|
||||
return new self($value, ColorFormat::RGBA, $rgbColor);
|
||||
}
|
||||
|
||||
// HSL Color
|
||||
if (preg_match('/^hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)$/', $value, $matches)) {
|
||||
$hslValues = [
|
||||
'h' => (int) $matches[1],
|
||||
's' => (int) $matches[2],
|
||||
'l' => (int) $matches[3],
|
||||
];
|
||||
|
||||
return new self($value, ColorFormat::HSL, null, $hslValues);
|
||||
}
|
||||
|
||||
// HSLA Color
|
||||
if (preg_match('/^hsla\((\d+),\s*(\d+)%,\s*(\d+)%,\s*([\d.]+)\)$/', $value, $matches)) {
|
||||
$hslValues = [
|
||||
'h' => (int) $matches[1],
|
||||
's' => (int) $matches[2],
|
||||
'l' => (int) $matches[3],
|
||||
'a' => (float) $matches[4],
|
||||
];
|
||||
|
||||
return new self($value, ColorFormat::HSLA, null, $hslValues);
|
||||
}
|
||||
|
||||
// OKLCH Color
|
||||
if (preg_match('/^oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\)$/', $value, $matches)) {
|
||||
$oklchValues = [
|
||||
'l' => (float) $matches[1], // Lightness (0-1)
|
||||
'c' => (float) $matches[2], // Chroma (0-0.4+)
|
||||
'h' => (float) $matches[3], // Hue (0-360)
|
||||
];
|
||||
|
||||
if (isset($matches[4])) {
|
||||
$oklchValues['a'] = (float) $matches[4]; // Alpha
|
||||
}
|
||||
|
||||
return new self($value, ColorFormat::OKLCH, null, null, $oklchValues);
|
||||
}
|
||||
|
||||
// CSS Custom Property
|
||||
if (preg_match('/^var\(--([^)]+)\)$/', $value, $matches)) {
|
||||
return new self($value, ColorFormat::CUSTOM_PROPERTY, null, null, null, null, $matches[1]);
|
||||
}
|
||||
|
||||
// Named Color
|
||||
$namedColors = [
|
||||
'red', 'blue', 'green', 'white', 'black', 'transparent', 'currentColor',
|
||||
'gray', 'grey', 'yellow', 'orange', 'purple', 'pink', 'brown',
|
||||
'cyan', 'magenta', 'lime', 'navy', 'silver', 'gold',
|
||||
];
|
||||
|
||||
if (in_array(strtolower($value), $namedColors)) {
|
||||
return new self($value, ColorFormat::NAMED, null, null, null, strtolower($value));
|
||||
}
|
||||
|
||||
// Fallback - behandle als named color
|
||||
return new self($value, ColorFormat::NAMED, null, null, null, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert die Farbe zu RGB wenn möglich
|
||||
*/
|
||||
public function toRGB(): ?RGBColor
|
||||
{
|
||||
if ($this->rgbColor) {
|
||||
return $this->rgbColor;
|
||||
}
|
||||
|
||||
// HSL zu RGB konvertieren wenn verfügbar
|
||||
if ($this->hslValues && isset($this->hslValues['h'], $this->hslValues['s'], $this->hslValues['l'])) {
|
||||
return $this->hslToRgb(
|
||||
$this->hslValues['h'],
|
||||
$this->hslValues['s'] / 100,
|
||||
$this->hslValues['l'] / 100
|
||||
);
|
||||
}
|
||||
|
||||
// OKLCH zu RGB konvertieren wenn verfügbar
|
||||
if ($this->oklchValues && isset($this->oklchValues['l'], $this->oklchValues['c'], $this->oklchValues['h'])) {
|
||||
return $this->oklchToRgb(
|
||||
$this->oklchValues['l'],
|
||||
$this->oklchValues['c'],
|
||||
$this->oklchValues['h']
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Hex-Repräsentation zurück wenn möglich
|
||||
*/
|
||||
public function toHex(): ?string
|
||||
{
|
||||
$rgb = $this->toRGB();
|
||||
if ($rgb) {
|
||||
return $rgb->toHex();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Farbe transparent ist
|
||||
*/
|
||||
public function isTransparent(): bool
|
||||
{
|
||||
return $this->namedColor === 'transparent' ||
|
||||
$this->originalValue === 'transparent' ||
|
||||
($this->format === ColorFormat::RGBA && str_contains($this->originalValue, ', 0)')) ||
|
||||
($this->format === ColorFormat::HSLA && str_contains($this->originalValue, ', 0)'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Farbe eine Custom Property ist
|
||||
*/
|
||||
public function isCustomProperty(): bool
|
||||
{
|
||||
return $this->format === ColorFormat::CUSTOM_PROPERTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Custom Property Namen zurück
|
||||
*/
|
||||
public function getCustomPropertyName(): ?string
|
||||
{
|
||||
return $this->customPropertyName;
|
||||
}
|
||||
|
||||
private function hslToRgb(int $h, float $s, float $l): RGBColor
|
||||
{
|
||||
$h = $h / 360;
|
||||
|
||||
if ($s === 0.0) {
|
||||
$r = $g = $b = $l; // Grayscale
|
||||
} else {
|
||||
$hue2rgb = function ($p, $q, $t) {
|
||||
if ($t < 0) {
|
||||
$t += 1;
|
||||
}
|
||||
if ($t > 1) {
|
||||
$t -= 1;
|
||||
}
|
||||
if ($t < 1 / 6) {
|
||||
return $p + ($q - $p) * 6 * $t;
|
||||
}
|
||||
if ($t < 1 / 2) {
|
||||
return $q;
|
||||
}
|
||||
if ($t < 2 / 3) {
|
||||
return $p + ($q - $p) * (2 / 3 - $t) * 6;
|
||||
}
|
||||
|
||||
return $p;
|
||||
};
|
||||
|
||||
$q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
|
||||
$p = 2 * $l - $q;
|
||||
|
||||
$r = $hue2rgb($p, $q, $h + 1 / 3);
|
||||
$g = $hue2rgb($p, $q, $h);
|
||||
$b = $hue2rgb($p, $q, $h - 1 / 3);
|
||||
}
|
||||
|
||||
return new RGBColor(
|
||||
(int) round($r * 255),
|
||||
(int) round($g * 255),
|
||||
(int) round($b * 255)
|
||||
);
|
||||
}
|
||||
|
||||
private function oklchToRgb(float $l, float $c, float $h): RGBColor
|
||||
{
|
||||
// OKLCH zu OKLab
|
||||
$hRad = deg2rad($h);
|
||||
$a = $c * cos($hRad);
|
||||
$b = $c * sin($hRad);
|
||||
|
||||
// OKLab zu Linear RGB (vereinfachte Approximation)
|
||||
// Für eine exakte Konvertierung wären komplexere Matrizen-Operationen nötig
|
||||
$lRgb = $l + 0.3963377774 * $a + 0.2158037573 * $b;
|
||||
$mRgb = $l - 0.1055613458 * $a - 0.0638541728 * $b;
|
||||
$sRgb = $l - 0.0894841775 * $a - 1.2914855480 * $b;
|
||||
|
||||
// Cube root für Linear RGB
|
||||
$lRgb = $this->cubeRoot($lRgb);
|
||||
$mRgb = $this->cubeRoot($mRgb);
|
||||
$sRgb = $this->cubeRoot($sRgb);
|
||||
|
||||
// Linear RGB zu sRGB (vereinfacht)
|
||||
$r = +4.0767416621 * $lRgb - 3.3077115913 * $mRgb + 0.2309699292 * $sRgb;
|
||||
$g = -1.2684380046 * $lRgb + 2.6097574011 * $mRgb - 0.3413193965 * $sRgb;
|
||||
$b = -0.0041960863 * $lRgb - 0.7034186147 * $mRgb + 1.7076147010 * $sRgb;
|
||||
|
||||
// Gamma correction und Clamping
|
||||
$r = $this->gammaCorrect($r);
|
||||
$g = $this->gammaCorrect($g);
|
||||
$b = $this->gammaCorrect($b);
|
||||
|
||||
return new RGBColor(
|
||||
max(0, min(255, (int) round($r * 255))),
|
||||
max(0, min(255, (int) round($g * 255))),
|
||||
max(0, min(255, (int) round($b * 255)))
|
||||
);
|
||||
}
|
||||
|
||||
private function cubeRoot(float $value): float
|
||||
{
|
||||
return $value >= 0 ? pow($value, 1 / 3) : -pow(-$value, 1 / 3);
|
||||
}
|
||||
|
||||
private function gammaCorrect(float $value): float
|
||||
{
|
||||
if ($value >= 0.0031308) {
|
||||
return 1.055 * pow($value, 1 / 2.4) - 0.055;
|
||||
} else {
|
||||
return 12.92 * $value;
|
||||
}
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->originalValue;
|
||||
}
|
||||
}
|
||||
263
src/Framework/Design/ValueObjects/CssParseResult.php
Normal file
263
src/Framework/Design/ValueObjects/CssParseResult.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Ergebnis des CSS-Parsing-Prozesses
|
||||
*/
|
||||
final readonly class CssParseResult
|
||||
{
|
||||
/**
|
||||
* @param CssRule[] $rules
|
||||
* @param DesignToken[] $customProperties
|
||||
* @param CssClassName[] $classNames
|
||||
*/
|
||||
public function __construct(
|
||||
public ?FilePath $sourceFile,
|
||||
public array $rules,
|
||||
public array $customProperties,
|
||||
public array $classNames,
|
||||
public string $rawContent
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle gefundenen Selektoren zurück
|
||||
* @return CssSelector[]
|
||||
*/
|
||||
public function getAllSelectors(): array
|
||||
{
|
||||
$selectors = [];
|
||||
|
||||
foreach ($this->rules as $rule) {
|
||||
foreach ($rule->selectors as $selector) {
|
||||
$selectors[] = $selector;
|
||||
}
|
||||
}
|
||||
|
||||
return $selectors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle gefundenen Properties zurück
|
||||
* @return CssProperty[]
|
||||
*/
|
||||
public function getAllProperties(): array
|
||||
{
|
||||
$properties = [];
|
||||
|
||||
foreach ($this->rules as $rule) {
|
||||
foreach ($rule->properties as $property) {
|
||||
$properties[] = $property;
|
||||
}
|
||||
}
|
||||
|
||||
return $properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtert Regeln nach Selektor-Typ
|
||||
*/
|
||||
public function getRulesBySelectorType(CssSelectorType $type): array
|
||||
{
|
||||
$matchingRules = [];
|
||||
|
||||
foreach ($this->rules as $rule) {
|
||||
foreach ($rule->selectors as $selector) {
|
||||
if ($selector->getType() === $type) {
|
||||
$matchingRules[] = $rule;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matchingRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtert Properties nach Kategorie
|
||||
*/
|
||||
public function getPropertiesByCategory(CssPropertyCategory $category): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->getAllProperties(),
|
||||
fn (CssProperty $property) => $property->getCategory() === $category
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtert Custom Properties nach Typ
|
||||
*/
|
||||
public function getDesignTokensByType(DesignTokenType $type): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->customProperties,
|
||||
fn (DesignToken $token) => $token->type === $type
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtert CSS-Klassen nach Pattern
|
||||
*/
|
||||
public function getClassNamesByPattern(string $pattern): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->classNames,
|
||||
fn (CssClassName $className) => str_contains($className->name, $pattern)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle BEM-Klassen zurück
|
||||
*/
|
||||
public function getBemClasses(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->classNames,
|
||||
fn (CssClassName $className) =>
|
||||
$className->isBemBlock() ||
|
||||
$className->isBemElement() ||
|
||||
$className->isBemModifier()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Utility-Klassen zurück
|
||||
*/
|
||||
public function getUtilityClasses(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->classNames,
|
||||
fn (CssClassName $className) => $className->isUtilityClass()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert verwendete Farben
|
||||
*/
|
||||
public function getColorAnalysis(): array
|
||||
{
|
||||
$colors = [];
|
||||
$colorProperties = $this->getPropertiesByCategory(CssPropertyCategory::COLOR);
|
||||
|
||||
foreach ($colorProperties as $property) {
|
||||
$color = $property->toColor();
|
||||
if ($color) {
|
||||
$colors[] = [
|
||||
'property' => $property->name,
|
||||
'color' => $color,
|
||||
'format' => $color->format->value,
|
||||
'hex' => $color->toHex(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Naming-Konventionen
|
||||
*/
|
||||
public function getNamingConventionAnalysis(): array
|
||||
{
|
||||
$conventions = [
|
||||
'bem' => 0,
|
||||
'camelCase' => 0,
|
||||
'kebab-case' => 0,
|
||||
'snake_case' => 0,
|
||||
'other' => 0,
|
||||
'violations' => [],
|
||||
];
|
||||
|
||||
foreach ($this->classNames as $className) {
|
||||
$convention = $className->getNamingConvention();
|
||||
|
||||
if (isset($conventions[$convention])) {
|
||||
$conventions[$convention]++;
|
||||
} else {
|
||||
$conventions['other']++;
|
||||
$conventions['violations'][] = $className->name;
|
||||
}
|
||||
}
|
||||
|
||||
$conventions['total_classes'] = count($this->classNames);
|
||||
$conventions['dominant_convention'] = $this->getDominantConvention($conventions);
|
||||
|
||||
return $conventions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken über die geparsten Daten
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'source_file' => $this->sourceFile?->toString(),
|
||||
'total_rules' => count($this->rules),
|
||||
'total_selectors' => count($this->getAllSelectors()),
|
||||
'total_properties' => count($this->getAllProperties()),
|
||||
'design_tokens' => count($this->customProperties),
|
||||
'class_names' => count($this->classNames),
|
||||
'content_size_bytes' => strlen($this->rawContent),
|
||||
'selector_types' => $this->getSelectorTypeStats(),
|
||||
'property_categories' => $this->getPropertyCategoryStats(),
|
||||
'token_types' => $this->getTokenTypeStats(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDominantConvention(array $conventions): string
|
||||
{
|
||||
$max = 0;
|
||||
$dominant = 'mixed';
|
||||
|
||||
foreach (['bem', 'camelCase', 'kebab-case', 'snake_case'] as $convention) {
|
||||
if ($conventions[$convention] > $max) {
|
||||
$max = $conventions[$convention];
|
||||
$dominant = $convention;
|
||||
}
|
||||
}
|
||||
|
||||
return $dominant;
|
||||
}
|
||||
|
||||
private function getSelectorTypeStats(): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
foreach ($this->getAllSelectors() as $selector) {
|
||||
$type = $selector->getType()->value;
|
||||
$stats[$type] = ($stats[$type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function getPropertyCategoryStats(): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
foreach ($this->getAllProperties() as $property) {
|
||||
$category = $property->getCategory()->value;
|
||||
$stats[$category] = ($stats[$category] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function getTokenTypeStats(): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
foreach ($this->customProperties as $token) {
|
||||
$type = $token->type->value;
|
||||
$stats[$type] = ($stats[$type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
149
src/Framework/Design/ValueObjects/CssProperty.php
Normal file
149
src/Framework/Design/ValueObjects/CssProperty.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Repräsentiert eine CSS-Property mit Namen und Wert
|
||||
*/
|
||||
final readonly class CssProperty
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $value
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorisiert die Property
|
||||
*/
|
||||
public function getCategory(): CssPropertyCategory
|
||||
{
|
||||
$colorProperties = [
|
||||
'color', 'background-color', 'border-color', 'outline-color',
|
||||
'text-decoration-color', 'caret-color', 'column-rule-color',
|
||||
];
|
||||
|
||||
$spacingProperties = [
|
||||
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
||||
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
||||
'gap', 'row-gap', 'column-gap',
|
||||
];
|
||||
|
||||
$typographyProperties = [
|
||||
'font-family', 'font-size', 'font-weight', 'font-style',
|
||||
'line-height', 'letter-spacing', 'text-align', 'text-decoration',
|
||||
'text-transform', 'font-variant',
|
||||
];
|
||||
|
||||
$layoutProperties = [
|
||||
'display', 'position', 'top', 'right', 'bottom', 'left',
|
||||
'width', 'height', 'max-width', 'max-height', 'min-width', 'min-height',
|
||||
'flex', 'flex-direction', 'flex-wrap', 'justify-content', 'align-items',
|
||||
'grid', 'grid-template-columns', 'grid-template-rows',
|
||||
];
|
||||
|
||||
$borderProperties = [
|
||||
'border', 'border-top', 'border-right', 'border-bottom', 'border-left',
|
||||
'border-width', 'border-style', 'border-radius', 'outline',
|
||||
];
|
||||
|
||||
$animationProperties = [
|
||||
'transition', 'animation', 'transform', 'transition-duration',
|
||||
'transition-property', 'transition-timing-function', 'transition-delay',
|
||||
];
|
||||
|
||||
if (in_array($this->name, $colorProperties)) {
|
||||
return CssPropertyCategory::COLOR;
|
||||
}
|
||||
|
||||
if (in_array($this->name, $spacingProperties)) {
|
||||
return CssPropertyCategory::SPACING;
|
||||
}
|
||||
|
||||
if (in_array($this->name, $typographyProperties)) {
|
||||
return CssPropertyCategory::TYPOGRAPHY;
|
||||
}
|
||||
|
||||
if (in_array($this->name, $layoutProperties)) {
|
||||
return CssPropertyCategory::LAYOUT;
|
||||
}
|
||||
|
||||
if (in_array($this->name, $borderProperties)) {
|
||||
return CssPropertyCategory::BORDER;
|
||||
}
|
||||
|
||||
if (in_array($this->name, $animationProperties)) {
|
||||
return CssPropertyCategory::ANIMATION;
|
||||
}
|
||||
|
||||
return CssPropertyCategory::OTHER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Property einen CSS Custom Property Wert verwendet
|
||||
*/
|
||||
public function usesCustomProperty(): bool
|
||||
{
|
||||
return str_contains($this->value, 'var(--');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Custom Property Namen aus dem Wert
|
||||
*/
|
||||
public function getCustomPropertyReferences(): array
|
||||
{
|
||||
preg_match_all('/var\(--([^)]+)\)/', $this->value, $matches);
|
||||
|
||||
return $matches[1] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu CssColor wenn es eine Farbproperty ist
|
||||
*/
|
||||
public function toColor(): ?CssColor
|
||||
{
|
||||
if ($this->getCategory() !== CssPropertyCategory::COLOR) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CssColor::fromString($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert numerischen Wert und Einheit
|
||||
*/
|
||||
public function parseNumericValue(): ?array
|
||||
{
|
||||
if (preg_match('/^(-?\d*\.?\d+)([a-zA-Z%]*)$/', $this->value, $matches)) {
|
||||
return [
|
||||
'value' => (float) $matches[1],
|
||||
'unit' => $matches[2] ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Property wichtig ist (!important)
|
||||
*/
|
||||
public function isImportant(): bool
|
||||
{
|
||||
return str_contains($this->value, '!important');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Wert ohne !important zurück
|
||||
*/
|
||||
public function getValueWithoutImportant(): string
|
||||
{
|
||||
return trim(str_replace('!important', '', $this->value));
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return "{$this->name}: {$this->value}";
|
||||
}
|
||||
}
|
||||
20
src/Framework/Design/ValueObjects/CssPropertyCategory.php
Normal file
20
src/Framework/Design/ValueObjects/CssPropertyCategory.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Enum für CSS Property Kategorien
|
||||
*/
|
||||
enum CssPropertyCategory: string
|
||||
{
|
||||
case COLOR = 'color';
|
||||
case SPACING = 'spacing';
|
||||
case TYPOGRAPHY = 'typography';
|
||||
case LAYOUT = 'layout';
|
||||
case BORDER = 'border';
|
||||
case ANIMATION = 'animation';
|
||||
case TRANSFORM = 'transform';
|
||||
case OTHER = 'other';
|
||||
}
|
||||
190
src/Framework/Design/ValueObjects/CssRule.php
Normal file
190
src/Framework/Design/ValueObjects/CssRule.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Repräsentiert eine CSS-Regel mit Selektoren und Properties
|
||||
*/
|
||||
final readonly class CssRule
|
||||
{
|
||||
/**
|
||||
* @param CssSelector[] $selectors
|
||||
* @param CssProperty[] $properties
|
||||
*/
|
||||
public function __construct(
|
||||
public array $selectors,
|
||||
public array $properties
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Selektor-Strings zurück
|
||||
*/
|
||||
public function getSelectorStrings(): array
|
||||
{
|
||||
return array_map(fn (CssSelector $selector) => $selector->value, $this->selectors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Property-Namen zurück
|
||||
*/
|
||||
public function getPropertyNames(): array
|
||||
{
|
||||
return array_map(fn (CssProperty $property) => $property->name, $this->properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht eine Property nach Namen
|
||||
*/
|
||||
public function getProperty(string $name): ?CssProperty
|
||||
{
|
||||
foreach ($this->properties as $property) {
|
||||
if ($property->name === $name) {
|
||||
return $property;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Properties einer bestimmten Kategorie zurück
|
||||
*/
|
||||
public function getPropertiesByCategory(CssPropertyCategory $category): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->properties,
|
||||
fn (CssProperty $property) => $property->getCategory() === $category
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Regel einen bestimmten Selektor enthält
|
||||
*/
|
||||
public function hasSelector(string $selectorValue): bool
|
||||
{
|
||||
foreach ($this->selectors as $selector) {
|
||||
if ($selector->value === $selectorValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Regel eine bestimmte Property enthält
|
||||
*/
|
||||
public function hasProperty(string $propertyName): bool
|
||||
{
|
||||
return $this->getProperty($propertyName) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Regel Custom Properties verwendet
|
||||
*/
|
||||
public function usesCustomProperties(): bool
|
||||
{
|
||||
foreach ($this->properties as $property) {
|
||||
if ($property->usesCustomProperty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert alle Custom Property Referenzen
|
||||
*/
|
||||
public function getCustomPropertyReferences(): array
|
||||
{
|
||||
$references = [];
|
||||
|
||||
foreach ($this->properties as $property) {
|
||||
$propertyRefs = $property->getCustomPropertyReferences();
|
||||
$references = array_merge($references, $propertyRefs);
|
||||
}
|
||||
|
||||
return array_unique($references);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Regel als CSS-String zurück
|
||||
*/
|
||||
public function toCssString(): string
|
||||
{
|
||||
$selectors = implode(', ', $this->getSelectorStrings());
|
||||
$properties = [];
|
||||
|
||||
foreach ($this->properties as $property) {
|
||||
$properties[] = " {$property->name}: {$property->value};";
|
||||
}
|
||||
|
||||
$propertiesString = implode("\n", $properties);
|
||||
|
||||
return "$selectors {\n$propertiesString\n}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert die Spezifität der Selektoren
|
||||
*/
|
||||
public function getSpecificityAnalysis(): array
|
||||
{
|
||||
$analysis = [];
|
||||
|
||||
foreach ($this->selectors as $selector) {
|
||||
$analysis[] = [
|
||||
'selector' => $selector->value,
|
||||
'specificity' => $selector->calculateSpecificity(),
|
||||
'type' => $selector->getType()->value,
|
||||
];
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorisiert die Regel basierend auf Selektoren und Properties
|
||||
*/
|
||||
public function categorize(): array
|
||||
{
|
||||
$selectorTypes = [];
|
||||
$propertyCategories = [];
|
||||
|
||||
foreach ($this->selectors as $selector) {
|
||||
$selectorTypes[] = $selector->getType()->value;
|
||||
}
|
||||
|
||||
foreach ($this->properties as $property) {
|
||||
$propertyCategories[] = $property->getCategory()->value;
|
||||
}
|
||||
|
||||
return [
|
||||
'selector_types' => array_unique($selectorTypes),
|
||||
'property_categories' => array_unique($propertyCategories),
|
||||
'uses_custom_properties' => $this->usesCustomProperties(),
|
||||
'complexity' => $this->calculateComplexity(),
|
||||
];
|
||||
}
|
||||
|
||||
private function calculateComplexity(): string
|
||||
{
|
||||
$selectorCount = count($this->selectors);
|
||||
$propertyCount = count($this->properties);
|
||||
$totalSpecificity = array_sum(array_map(
|
||||
fn (CssSelector $selector) => $selector->calculateSpecificity(),
|
||||
$this->selectors
|
||||
));
|
||||
|
||||
$complexityScore = $selectorCount + $propertyCount + ($totalSpecificity / 10);
|
||||
|
||||
return match(true) {
|
||||
$complexityScore <= 5 => 'simple',
|
||||
$complexityScore <= 15 => 'moderate',
|
||||
default => 'complex'
|
||||
};
|
||||
}
|
||||
}
|
||||
114
src/Framework/Design/ValueObjects/CssSelector.php
Normal file
114
src/Framework/Design/ValueObjects/CssSelector.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Repräsentiert einen CSS-Selektor
|
||||
*/
|
||||
final readonly class CssSelector
|
||||
{
|
||||
public function __construct(
|
||||
public string $value
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromString(string $selector): self
|
||||
{
|
||||
return new self(trim($selector));
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die CSS-Spezifität des Selektors
|
||||
*/
|
||||
public function calculateSpecificity(): int
|
||||
{
|
||||
$specificity = 0;
|
||||
|
||||
// IDs (#id) = 100
|
||||
preg_match_all('/#[a-zA-Z][\w-]*/', $this->value, $ids);
|
||||
$specificity += count($ids[0]) * 100;
|
||||
|
||||
// Classes (.class), Attributes ([attr]) und Pseudo-Classes (:hover) = 10
|
||||
preg_match_all('/\.[a-zA-Z][\w-]*/', $this->value, $classes);
|
||||
$specificity += count($classes[0]) * 10;
|
||||
|
||||
preg_match_all('/\[[^\]]*\]/', $this->value, $attributes);
|
||||
$specificity += count($attributes[0]) * 10;
|
||||
|
||||
preg_match_all('/:(?!not\(|where\(|is\()[a-zA-Z][\w-]*/', $this->value, $pseudoClasses);
|
||||
$specificity += count($pseudoClasses[0]) * 10;
|
||||
|
||||
// Elements (div, span) und Pseudo-Elements (::before) = 1
|
||||
$elements = preg_replace('/[#.\[\]:][^#.\[\]\s]*/', '', $this->value);
|
||||
preg_match_all('/[a-zA-Z][\w-]*/', $elements, $elementMatches);
|
||||
$specificity += count($elementMatches[0]);
|
||||
|
||||
return $specificity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert den Selektor-Typ
|
||||
*/
|
||||
public function getType(): CssSelectorType
|
||||
{
|
||||
if (str_starts_with($this->value, '.')) {
|
||||
return CssSelectorType::CLASS_SELECTOR;
|
||||
}
|
||||
|
||||
if (str_starts_with($this->value, '#')) {
|
||||
return CssSelectorType::ID;
|
||||
}
|
||||
|
||||
if (str_contains($this->value, '[')) {
|
||||
return CssSelectorType::ATTRIBUTE;
|
||||
}
|
||||
|
||||
if (str_contains($this->value, ':')) {
|
||||
return CssSelectorType::PSEUDO;
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-zA-Z][\w-]*$/', $this->value)) {
|
||||
return CssSelectorType::ELEMENT;
|
||||
}
|
||||
|
||||
return CssSelectorType::COMPLEX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert alle CSS-Klassennamen aus dem Selektor
|
||||
*/
|
||||
public function extractClasses(): array
|
||||
{
|
||||
preg_match_all('/\.([a-zA-Z][\w-]*)/', $this->value, $matches);
|
||||
|
||||
return $matches[1] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert alle IDs aus dem Selektor
|
||||
*/
|
||||
public function extractIds(): array
|
||||
{
|
||||
preg_match_all('/#([a-zA-Z][\w-]*)/', $this->value, $matches);
|
||||
|
||||
return $matches[1] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert alle Element-Namen aus dem Selektor
|
||||
*/
|
||||
public function extractElements(): array
|
||||
{
|
||||
$cleaned = preg_replace('/[#.\[\]:][^#.\[\]\s]*/', '', $this->value);
|
||||
preg_match_all('/[a-zA-Z][\w-]*/', $cleaned, $matches);
|
||||
|
||||
return array_filter($matches[0], fn ($element) => ! empty($element));
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
18
src/Framework/Design/ValueObjects/CssSelectorType.php
Normal file
18
src/Framework/Design/ValueObjects/CssSelectorType.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Enum für CSS Selektor Typen
|
||||
*/
|
||||
enum CssSelectorType: string
|
||||
{
|
||||
case CLASS_SELECTOR = 'class';
|
||||
case ID = 'id';
|
||||
case ELEMENT = 'element';
|
||||
case ATTRIBUTE = 'attribute';
|
||||
case PSEUDO = 'pseudo';
|
||||
case COMPLEX = 'complex';
|
||||
}
|
||||
89
src/Framework/Design/ValueObjects/CustomProperty.php
Normal file
89
src/Framework/Design/ValueObjects/CustomProperty.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Repräsentiert eine CSS Custom Property (CSS Variable)
|
||||
*/
|
||||
final readonly class CustomProperty
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $value
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDeclaration(string $declaration): self
|
||||
{
|
||||
// Parse "--property-name: value" format
|
||||
if (preg_match('/--([^:]+):\s*([^;]+)/', $declaration, $matches)) {
|
||||
return new self(trim($matches[1]), trim($matches[2]));
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException('Invalid CSS custom property declaration');
|
||||
}
|
||||
|
||||
public function hasValueType(string $type): bool
|
||||
{
|
||||
return match($type) {
|
||||
'color' => $this->isColorValue(),
|
||||
'size' => $this->isSizeValue(),
|
||||
'number' => $this->isNumberValue(),
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
public function getValueAs(string $type): mixed
|
||||
{
|
||||
return match($type) {
|
||||
'color' => $this->getColorValue(),
|
||||
'size' => $this->getSizeValue(),
|
||||
'number' => $this->getNumberValue(),
|
||||
default => $this->value
|
||||
};
|
||||
}
|
||||
|
||||
private function isColorValue(): bool
|
||||
{
|
||||
return preg_match('/^(#[0-9a-fA-F]{3,8}|rgb|hsl|oklch|color)/', $this->value) === 1;
|
||||
}
|
||||
|
||||
private function isSizeValue(): bool
|
||||
{
|
||||
return preg_match('/^\d+(\.\d+)?(px|em|rem|%|vh|vw)$/', $this->value) === 1;
|
||||
}
|
||||
|
||||
private function isNumberValue(): bool
|
||||
{
|
||||
return is_numeric($this->value);
|
||||
}
|
||||
|
||||
private function getColorValue(): CssColor
|
||||
{
|
||||
if ($this->isColorValue()) {
|
||||
$format = match(true) {
|
||||
str_starts_with($this->value, '#') => ColorFormat::HEX,
|
||||
str_starts_with($this->value, 'rgb') => ColorFormat::RGB,
|
||||
str_starts_with($this->value, 'hsl') => ColorFormat::HSL,
|
||||
str_starts_with($this->value, 'oklch') => ColorFormat::OKLCH,
|
||||
default => ColorFormat::HEX
|
||||
};
|
||||
|
||||
return new CssColor($this->value, $format);
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException('Value is not a color');
|
||||
}
|
||||
|
||||
private function getSizeValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
private function getNumberValue(): float
|
||||
{
|
||||
return (float) $this->value;
|
||||
}
|
||||
}
|
||||
220
src/Framework/Design/ValueObjects/DesignSystemAnalysis.php
Normal file
220
src/Framework/Design/ValueObjects/DesignSystemAnalysis.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
use App\Framework\Design\Analyzer\ColorAnalysisResult;
|
||||
use App\Framework\Design\Analyzer\ComponentDetectionResult;
|
||||
use App\Framework\Design\Analyzer\ConventionCheckResult;
|
||||
use App\Framework\Design\Analyzer\TokenAnalysisResult;
|
||||
|
||||
/**
|
||||
* Complete Design System Analysis Result
|
||||
*/
|
||||
final readonly class DesignSystemAnalysis
|
||||
{
|
||||
public function __construct(
|
||||
public TokenAnalysisResult $tokenAnalysis,
|
||||
public ColorAnalysisResult $colorAnalysis,
|
||||
public ComponentDetectionResult $componentAnalysis,
|
||||
public ConventionCheckResult $conventionAnalysis,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self(
|
||||
tokenAnalysis: new TokenAnalysisResult(0, [], [], [], [], [], []),
|
||||
colorAnalysis: new ColorAnalysisResult(0, [], [], [], [], []),
|
||||
componentAnalysis: new ComponentDetectionResult(0, [], [], [], [], []),
|
||||
conventionAnalysis: new ConventionCheckResult(100, [], [], [], 'none'),
|
||||
metadata: []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the overall design system maturity level
|
||||
*/
|
||||
public function getMaturityLevel(): string
|
||||
{
|
||||
$score = $this->getOverallDesignSystemScore();
|
||||
|
||||
return match(true) {
|
||||
$score >= 90 => 'Mature',
|
||||
$score >= 70 => 'Established',
|
||||
$score >= 50 => 'Developing',
|
||||
$score >= 30 => 'Emerging',
|
||||
default => 'Basic'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates overall design system score (0-100)
|
||||
*/
|
||||
public function getOverallDesignSystemScore(): float
|
||||
{
|
||||
$tokenCoverage = $this->tokenAnalysis->getTokenCoverage();
|
||||
$tokenScore = $tokenCoverage['usage_percentage'] ?? 0;
|
||||
$colorScore = $this->colorAnalysis->getConsistencyScore();
|
||||
$componentScore = $this->componentAnalysis->getConsistencyScore();
|
||||
$conventionScore = $this->conventionAnalysis->overallScore;
|
||||
|
||||
return ($tokenScore + $colorScore + $componentScore + $conventionScore) / 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets critical issues that need immediate attention
|
||||
*/
|
||||
public function getCriticalIssues(): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Token issues
|
||||
$tokenCoverage = $this->tokenAnalysis->getTokenCoverage();
|
||||
if (($tokenCoverage['usage_percentage'] ?? 0) < 30) {
|
||||
$issues[] = [
|
||||
'type' => 'tokens',
|
||||
'severity' => 'critical',
|
||||
'message' => 'Very low design token usage',
|
||||
'impact' => 'Inconsistent styling and maintenance difficulties',
|
||||
];
|
||||
}
|
||||
|
||||
// Color issues - check contrast compliance
|
||||
if ($this->colorAnalysis->getContrastComplianceScore() < 80) {
|
||||
$issues[] = [
|
||||
'type' => 'colors',
|
||||
'severity' => 'critical',
|
||||
'message' => 'WCAG accessibility violations found',
|
||||
'impact' => 'Poor accessibility for users',
|
||||
];
|
||||
}
|
||||
|
||||
// Convention issues
|
||||
if ($this->conventionAnalysis->overallScore < 40) {
|
||||
$issues[] = [
|
||||
'type' => 'conventions',
|
||||
'severity' => 'critical',
|
||||
'message' => 'Poor naming convention consistency',
|
||||
'impact' => 'Decreased developer productivity and maintenance',
|
||||
];
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets quick wins for easy improvements
|
||||
*/
|
||||
public function getQuickWins(): array
|
||||
{
|
||||
$wins = [];
|
||||
|
||||
// Duplicate color removal
|
||||
if (count($this->colorAnalysis->duplicateColors) > 0) {
|
||||
$wins[] = [
|
||||
'type' => 'colors',
|
||||
'effort' => 'low',
|
||||
'impact' => 'medium',
|
||||
'message' => 'Remove duplicate color values',
|
||||
'action' => 'Consolidate ' . count($this->colorAnalysis->duplicateColors) . ' duplicate colors',
|
||||
];
|
||||
}
|
||||
|
||||
// Utility class organization based on pattern diversity
|
||||
if ($this->componentAnalysis->getPatternDiversity() > 80) {
|
||||
$wins[] = [
|
||||
'type' => 'components',
|
||||
'effort' => 'low',
|
||||
'impact' => 'high',
|
||||
'message' => 'Standardize component patterns',
|
||||
'action' => 'Choose one primary CSS methodology for better consistency',
|
||||
];
|
||||
}
|
||||
|
||||
return $wins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets development roadmap based on analysis
|
||||
*/
|
||||
public function getDevelopmentRoadmap(): array
|
||||
{
|
||||
$roadmap = [];
|
||||
|
||||
$score = $this->getOverallDesignSystemScore();
|
||||
|
||||
if ($score < 30) {
|
||||
$roadmap[] = [
|
||||
'phase' => 1,
|
||||
'title' => 'Foundation Setup',
|
||||
'priority' => 'critical',
|
||||
'tasks' => [
|
||||
'Establish core design tokens',
|
||||
'Define color palette',
|
||||
'Set naming conventions',
|
||||
'Create basic component library',
|
||||
],
|
||||
'timeline' => '2-4 weeks',
|
||||
];
|
||||
}
|
||||
|
||||
if ($score >= 30 && $score < 60) {
|
||||
$roadmap[] = [
|
||||
'phase' => 2,
|
||||
'title' => 'System Expansion',
|
||||
'priority' => 'high',
|
||||
'tasks' => [
|
||||
'Expand token coverage',
|
||||
'Improve component organization',
|
||||
'Add responsive design tokens',
|
||||
'Implement consistent spacing scale',
|
||||
],
|
||||
'timeline' => '4-6 weeks',
|
||||
];
|
||||
}
|
||||
|
||||
if ($score >= 60) {
|
||||
$roadmap[] = [
|
||||
'phase' => 3,
|
||||
'title' => 'Optimization & Polish',
|
||||
'priority' => 'medium',
|
||||
'tasks' => [
|
||||
'Optimize token usage',
|
||||
'Refine component APIs',
|
||||
'Add advanced theming',
|
||||
'Implement design system documentation',
|
||||
],
|
||||
'timeline' => '6-8 weeks',
|
||||
];
|
||||
}
|
||||
|
||||
return $roadmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports analysis as array for JSON serialization
|
||||
*/
|
||||
public function exportReport(): array
|
||||
{
|
||||
return [
|
||||
'overall_score' => $this->getOverallDesignSystemScore(),
|
||||
'maturity_level' => $this->getMaturityLevel(),
|
||||
'critical_issues' => $this->getCriticalIssues(),
|
||||
'quick_wins' => $this->getQuickWins(),
|
||||
'roadmap' => $this->getDevelopmentRoadmap(),
|
||||
'detailed_analysis' => [
|
||||
'tokens' => $this->tokenAnalysis->toArray(),
|
||||
'colors' => $this->colorAnalysis->toArray(),
|
||||
'components' => $this->componentAnalysis->toArray(),
|
||||
'conventions' => $this->conventionAnalysis->toArray(),
|
||||
],
|
||||
'metadata' => array_merge($this->metadata, [
|
||||
'generated_at' => date('c'),
|
||||
'version' => '1.0',
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
146
src/Framework/Design/ValueObjects/DesignToken.php
Normal file
146
src/Framework/Design/ValueObjects/DesignToken.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Design Token Value Object
|
||||
*/
|
||||
final readonly class DesignToken
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public DesignTokenType $type,
|
||||
public mixed $value,
|
||||
public string $description,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für Color Token
|
||||
*/
|
||||
public static function color(string $name, CssColor $color, string $description = ''): self
|
||||
{
|
||||
return new self(
|
||||
name: $name,
|
||||
type: DesignTokenType::COLOR,
|
||||
value: $color,
|
||||
description: $description ?: "Color: " . ucwords(str_replace(['-', '_'], ' ', $name)),
|
||||
metadata: ['source' => 'css_custom_property']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für Spacing Token
|
||||
*/
|
||||
public static function spacing(string $name, string|int $value, string $description = ''): self
|
||||
{
|
||||
return new self(
|
||||
name: $name,
|
||||
type: DesignTokenType::SPACING,
|
||||
value: $value,
|
||||
description: $description ?: "Spacing: " . ucwords(str_replace(['-', '_'], ' ', $name)),
|
||||
metadata: ['source' => 'css_custom_property']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für Typography Token
|
||||
*/
|
||||
public static function typography(string $name, mixed $value, string $description = ''): self
|
||||
{
|
||||
return new self(
|
||||
name: $name,
|
||||
type: DesignTokenType::TYPOGRAPHY,
|
||||
value: $value,
|
||||
description: $description ?: "Typography: " . ucwords(str_replace(['-', '_'], ' ', $name)),
|
||||
metadata: ['source' => 'css_custom_property']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export/Serialisierung
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'type' => $this->type->value,
|
||||
'value' => $this->serializeValue(),
|
||||
'description' => $this->description,
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt CSS Custom Property String
|
||||
*/
|
||||
public function toCssCustomProperty(): string
|
||||
{
|
||||
$value = match($this->type) {
|
||||
DesignTokenType::COLOR => $this->value instanceof CssColor ? $this->value->toString() : (string) $this->value,
|
||||
default => (string) $this->value
|
||||
};
|
||||
|
||||
return "--{$this->name}: {$value};";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt CSS var() Referenz zurück
|
||||
*/
|
||||
public function toCssVar(): string
|
||||
{
|
||||
return "var(--{$this->name})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Token einen bestimmten Wert-Typ hat
|
||||
*/
|
||||
public function hasValueType(string $type): bool
|
||||
{
|
||||
return match($type) {
|
||||
'color' => $this->value instanceof CssColor,
|
||||
'string' => is_string($this->value),
|
||||
'int' => is_int($this->value),
|
||||
'float' => is_float($this->value),
|
||||
'array' => is_array($this->value),
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Wert als bestimmten Typ zurück
|
||||
*/
|
||||
public function getValueAs(string $type): mixed
|
||||
{
|
||||
return match($type) {
|
||||
'string' => (string) $this->value,
|
||||
'int' => (int) $this->value,
|
||||
'float' => (float) $this->value,
|
||||
'array' => is_array($this->value) ? $this->value : [$this->value],
|
||||
'color' => $this->value instanceof CssColor ? $this->value : null,
|
||||
default => $this->value
|
||||
};
|
||||
}
|
||||
|
||||
private function serializeValue(): mixed
|
||||
{
|
||||
if ($this->value instanceof CssColor) {
|
||||
return [
|
||||
'original' => $this->value->originalValue,
|
||||
'format' => $this->value->format->value,
|
||||
'hex' => $this->value->toHex(),
|
||||
'rgb' => $this->value->toRGB()?->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->toCssVar();
|
||||
}
|
||||
}
|
||||
21
src/Framework/Design/ValueObjects/DesignTokenType.php
Normal file
21
src/Framework/Design/ValueObjects/DesignTokenType.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Enum für Design Token Typen
|
||||
*/
|
||||
enum DesignTokenType: string
|
||||
{
|
||||
case COLOR = 'color';
|
||||
case SPACING = 'spacing';
|
||||
case TYPOGRAPHY = 'typography';
|
||||
case SHADOW = 'shadow';
|
||||
case RADIUS = 'radius';
|
||||
case OPACITY = 'opacity';
|
||||
case BORDER = 'border';
|
||||
case ANIMATION = 'animation';
|
||||
case BREAKPOINT = 'breakpoint';
|
||||
}
|
||||
19
src/Framework/Design/ValueObjects/TokenCategory.php
Normal file
19
src/Framework/Design/ValueObjects/TokenCategory.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\ValueObjects;
|
||||
|
||||
/**
|
||||
* Kategorien für Design Tokens
|
||||
*/
|
||||
enum TokenCategory: string
|
||||
{
|
||||
case COLOR = 'color';
|
||||
case TYPOGRAPHY = 'typography';
|
||||
case SPACING = 'spacing';
|
||||
case BORDER = 'border';
|
||||
case SHADOW = 'shadow';
|
||||
case ANIMATION = 'animation';
|
||||
case OTHER = 'other';
|
||||
}
|
||||
Reference in New Issue
Block a user