German problems; [] when $root may move to the target post. */ public function problems(Block $root): array { if ($this->sanitizer->allowsUnfilteredHtml()) { return []; } $problems = []; $this->walk($root, $problems); return array_values(array_unique($problems)); } /** @param list $problems */ private function walk(Block $block, array &$problems): void { if (! $block->isDivi()) { $problems[] = sprintf('%s: kein Divi-Block – %s.', $block->name(), self::BUILDER_ONLY); return; } $attrs = $this->attributesOf($block); if ($attrs === null) { $problems[] = sprintf( '%s: Attribute nicht lesbar (oder globaler Verweis ohne Moduldaten) – %s.', $block->name(), self::BUILDER_ONLY ); return; } $module = GlobalInstance::effectiveName($block); if (in_array($module, self::CODE_MODULES, true)) { $problems[] = sprintf('%s: Code-Modul – %s.', $module, self::BUILDER_ONLY); } foreach (AttrMap::flatten($attrs) as $path => $value) { $this->value($module, (string) $path, $value, $problems); } foreach ($block->parts() as $part) { if ($part instanceof Block) { $this->walk($part, $problems); } elseif (trim($part) !== '') { $problems[] = sprintf('%s: Freitext zwischen den Blöcken – %s.', $module, self::BUILDER_ONLY); } } } /** * The attributes to check: a plain block's own, an instance's localAttrs (Divi renders them over * the library entry, F7); null for an opaque block that cannot be read that way. * * @return array|null */ private function attributesOf(Block $block): ?array { if (! $block->isOpaque()) { return $block->attrs(); } $attrs = $block->attrs(); $name = GlobalInstance::effectiveName($block); if ( $block->name() !== GlobalInstance::BLOCK || ! str_starts_with($name, 'divi/') || $name === GlobalInstance::BLOCK || ! is_string($attrs['globalModule'] ?? null) ) { return null; } $local = $attrs['localAttrs'] ?? []; if ($local instanceof stdClass && get_object_vars($local) === []) { return []; } return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null; } /** * One flattened path: the builder-only gate, then every string below it – a breakpoint map's * state values the way AttrWriter hands them to ValueCleaner::clean(). * * @param list $problems */ private function value(string $module, string $path, mixed $value, array &$problems): void { if (! $this->allowedPath($path, $problems)) { return; } if (! AttrMap::isBreakpointMap($value)) { $this->node($module, $path, $path, $value, null, null, $problems); return; } foreach ($value as $states) { if ($states instanceof stdClass) { $states = get_object_vars($states); } if (! is_array($states) || array_is_list($states)) { $this->node($module, $path, $path, $states, null, null, $problems); continue; } foreach ($states as $state => $item) { $field = in_array($state, self::STATE_KEYS, true) ? null : (string) $state; $where = $field === null ? $path : $path . '.' . $field; $this->node($module, $path, $where, $item, $field, $field, $problems); } } } /** * @param string $where $path plus the keys below the state value: builder-only keys at any depth. * @param string|null $subName First key below the state value (ValueCleaner's sub-kind). * @param string|null $key Nearest key above the value. * @param list $problems */ private function node( string $module, string $path, string $where, mixed $value, ?string $subName, ?string $key, array &$problems ): void { if ($where !== $path && ! $this->allowedPath($where, $problems)) { return; } if ($value instanceof stdClass) { $value = get_object_vars($value); } if (is_array($value)) { foreach ($value as $childKey => $item) { if (is_string($childKey)) { $below = $where . '.' . $childKey; $this->node($module, $path, $below, $item, $subName ?? $childKey, $childKey, $problems); } else { $this->node($module, $path, $where, $item, $subName, $key, $problems); } } return; } if (is_string($value)) { $problem = $this->stringProblem($module, $path, $value, $subName, $key); if ($problem !== null) { $problems[] = sprintf('%s: %s', $where, $problem); } } } /** @param list $problems */ private function allowedPath(string $path, array &$problems): bool { try { $this->cleaner->guardPath($path); } catch (InvalidArgumentException $e) { $problems[] = $e->getMessage(); return false; } return true; } private function stringProblem(string $module, string $path, string $value, ?string $subName, ?string $key): ?string { if (ControlChars::strip($value) !== $value) { return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.'; } $kind = $this->cleaner->kindAt($module, $path, $subName, $key); if (VariableRef::contains($value)) { return $this->referenceProblem($value, $kind === ValueKind::Url); } $stable = match ($kind) { ValueKind::Html => $this->sanitizer->html($value) === $value, ValueKind::Code => $this->sanitizer->code($value) === $value, ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value, ValueKind::Url => trim($value) === '' || $this->sanitizer->url($value) !== '', }; return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – ' . self::BUILDER_ONLY . '.'; } /** Same check as a client reference (VariableRef::canonical()); refused when cleaning would change it. */ private function referenceProblem(string $value, bool $url): ?string { try { $canonical = $this->references->canonical($value, $this->sanitizer, $url); } catch (InvalidArgumentException $e) { return $e->getMessage(); } $cleaned = self::normalized(VariableRef::settingsOf($canonical)); return $cleaned === self::normalized(VariableRef::settingsOf($value)) ? null : 'Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – ' . self::BUILDER_ONLY . '.'; } /** * @param array $settings * @return array Keys sorted, scalars as strings (post_id 839 = "839"). */ private static function normalized(array $settings): array { ksort($settings); return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings); } }