1, 'divi/blurb' => 4, 'divi/cta' => 2]; private const IMAGE_SOURCES = [ 'divi/image' => ['image.innerContent', 'src', 'alt'], 'divi/blurb' => ['imageIcon.innerContent', 'src', 'alt'], 'divi/slide' => ['image.innerContent', 'src', 'alt'], ]; public function __construct(private DesignSystem $design, private ?PresetAssignment $presets = null) { } /** * @param list $touched Created or changed blocks (their descendants count too). * @return list */ public function collect(NodeIndex $index, array $touched): array { $blocks = []; foreach ($touched as $block) { foreach ($this->withDescendants($block) as $node) { $blocks[spl_object_id($node)] = $node; } } $warnings = []; foreach ($blocks as $block) { $id = $index->idOf($block) ?? '?'; array_push( $warnings, ...$this->code($block, $id), ...$this->image($block, $id), ...$this->empty($block, $id), ...$this->fixedValues($block, $id), ...$this->coveredPresets($block, $id) ); } return array_values(array_unique(array_merge($warnings, $this->headings($index, $blocks)))); } /** * Opaque blocks (global modules) are never descended into – their content is edited only in * the Divi Builder (Etappe 3), so no warning ever looks inside one. * * @return list */ private function withDescendants(Block $block): array { $all = [$block]; if (! $block->isOpaque()) { foreach ($block->children() as $child) { array_push($all, ...$this->withDescendants($child)); } } return $all; } /** @return list */ private function code(Block $block, string $id): array { return in_array($block->name(), ['divi/code', 'divi/fullwidth-code'], true) ? [sprintf('%s: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein ' . 'natives Modul passt.', $id)] : []; } /** @return list */ private function image(Block $block, string $id): array { $spec = self::IMAGE_SOURCES[$block->name()] ?? null; if ($spec === null) { return []; } $value = AttrMap::desktop($block->attrs(), $spec[0]); $src = is_array($value) ? ($value[$spec[1]] ?? '') : ''; $alt = is_array($value) ? ($value[$spec[2]] ?? '') : ''; return is_string($src) && $src !== '' && (! is_string($alt) || trim($alt) === '') ? [sprintf('%s: Bild ohne Alt-Text.', $id)] : []; } /** @return list */ private function empty(Block $block, string $id): array { $name = $block->name(); if (in_array($name, ['divi/section', 'divi/row', 'divi/column', 'divi/group'], true)) { return $block->children() === [] ? [sprintf('%s: leer (%s ohne Inhalt).', $id, $block->shortName())] : []; } $type = $block->shortName(); if (ShortformMap::childType($type) !== null && $block->children() === []) { return [sprintf('%s: %s ohne Einträge.', $id, $type)]; } $primary = ShortformMap::primary($type); if ($primary === null || ShortformMap::childType($type) !== null) { return []; } $values = ShortformMap::values($type, $block->attrs()); $value = $values[$primary] ?? ''; return NodeSummary::textOf($value) === '' ? [sprintf('%s: %s ist leer (%s fehlt).', $id, $type, $primary)] : []; } /** @return list */ private function fixedValues(Block $block, string $id): array { $warnings = []; $fonts = array_filter($this->design->fonts()); foreach ($this->leaves($block->attrs()) as [$key, $value]) { if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value)) === 1) { $match = $this->similarColor($value); if ($match !== null) { $warnings[] = sprintf( '%s: feste Farbe %s – globale Farbe "%s" (%s) passt.', $id, $value, $match['label'], DesignSystem::colorPlaceholder($match['id'], $match['label']) ); } } elseif ($key === 'family') { $font = array_search(mb_strtolower(trim($value)), array_map('mb_strtolower', $fonts), true); if ($font !== false) { $warnings[] = sprintf('%s: feste Schrift %s – {{font:%s}} verwenden.', $id, $value, $font); } } } return $warnings; } /** * Ruling R5: local values that cover an explicitly assigned preset of the block. Only Divi blocks that name a * preset cost work (opaque blocks – global instances – are never looked into); the preset book is read when the * first such block comes up, never for a page without presets. * * @return list */ private function coveredPresets(Block $block, string $id): array { if ($this->presets === null || ! $block->isDivi() || $block->isOpaque() || ! self::namesPresets($block)) { return []; } return $this->presets->overrides()->describe($id, $block->attrs(), $this->presets->names()); } private static function namesPresets(Block $block): bool { return NodeSummary::presetIds($block) !== [] || NodeSummary::groupPresets($block) !== []; } /** * Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block. * * @param array $touched * @return list */ private function headings(NodeIndex $index, array $touched): array { $warnings = []; $previous = null; foreach ($index->entries() as $entry) { $block = $entry['block']; foreach ($this->levels($block) as $level) { if ($previous !== null && $level > $previous + 1 && isset($touched[spl_object_id($block)])) { $warnings[] = sprintf( '%s: Sprung in der Überschriftenhierarchie (h%d nach h%d).', $entry['id'], $level, $previous ); } $previous = $level; } } return $warnings; } /** @return list Heading levels a block renders, in order. */ private function levels(Block $block): array { $default = self::HEADING_DEFAULTS[$block->name()] ?? null; if ($default !== null) { $font = AttrMap::desktop($block->attrs(), 'title.decoration.font.font'); $level = is_array($font) && is_string($font['headingLevel'] ?? null) ? $font['headingLevel'] : 'h' . $default; return [(int) substr($level, 1)]; } if ($block->name() === 'divi/text') { $html = AttrMap::desktop($block->attrs(), 'content.innerContent'); preg_match_all('/design->colors() as $color) { $other = self::rgb($color['value']); if ($rgb !== null && $other !== null && self::distance($rgb, $other) <= self::SIMILAR) { return $color; } } return null; } /** * @param array $values * @return list [last key, value] of every string leaf (Divi * references excluded – a fixed value that references a global one is not "fixed"). */ private function leaves(array $values): array { $leaves = []; foreach ($values as $name => $value) { if (is_array($value)) { array_push($leaves, ...$this->leaves($value)); } elseif (is_string($value) && ! VariableRef::contains($value)) { $leaves[] = [(string) $name, $value]; } } return $leaves; } /** @return array{int, int, int}|null */ private static function rgb(string $hex): ?array { $hex = ltrim(trim($hex), '#'); if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $hex) !== 1) { return null; } if (strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))]; } /** * @param array{int, int, int} $a * @param array{int, int, int} $b */ private static function distance(array $a, array $b): float { return sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2); } }