]*?>.*?@si', '', $value)); } $value = trim((string) preg_replace('/[\r\n\t ]+/', ' ', $value)); $found = false; while (preg_match('/%[a-f0-9]{2}/i', $value, $match) === 1) { $value = str_replace($match[0], '', $value); $found = true; } return $found ? trim((string) preg_replace('/ +/', ' ', $value)) : $value; } /** * Like WpValueSanitizer (ruling S4): raw for users with unfiltered_html; otherwise like * wp_kses_post: NUL bytes, #is', '', str_replace(chr(0), '', $value)); return (string) preg_replace( '#]*>#i', '', $value ); } public function code(string $value): string { return $value; } /** * Like esc_url_raw: spaces become %20, characters outside its allow list (tags, quotes, * braces, NUL) go, %0d/%0a are removed repeatedly, javascript: gives ''. */ public function url(string $value): string { $value = str_replace(' ', '%20', trim($value)); $value = (string) preg_replace('|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i', '', $value); do { $before = $value; $value = str_replace(['%0d', '%0a', '%0D', '%0A'], '', $value); } while ($value !== $before); return str_starts_with(strtolower($value), 'javascript:') ? '' : $value; } public function allowsUnfilteredHtml(): bool { return $this->unfiltered; } /** Every post is readable but 666 (ruling S37, select_post settings). */ public function canReadPost(int $id): bool { return $id !== 666; } /** Ruling P10: 840 is a post, 666 no link target, 667 a readable attachment (none), every other ID a page. */ public function linkTargetType(int $id): ?string { return match ($id) { 666, 667 => null, 840 => 'post', default => 'page', }; } }; } public static function media(): MediaResolver { return new class implements MediaResolver { public function image(int $id): ?array { return match ($id) { 33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'], 12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''], // Alt texts carrying a reference, whole or NUL-split (ruling S33b: dropped). 44 => ['url' => 'https://kunde.test/wp-content/uploads/a.jpg', 'alt' => ComposeFixture::REFERENCE_ALT], // "<" + VT/FF: strip_tags keeps it as text – a later strip would build the tag (S32a). 46 => ['url' => 'https://kunde.test/wp-content/uploads/c.jpg', 'alt' => '<' . chr(11) . 'img src=x onerror=alert(1)>Team'], 45 => ['url' => 'https://kunde.test/wp-content/uploads/b.jpg', 'alt' => '$variable' . chr(0) . substr(ComposeFixture::REFERENCE_ALT, strlen('$variable'))], default => null, }; } }; } /** Link targets of the tests: 839 is a page, 840 a post; everything else is missing, trashed or unreadable. */ public static function links(): LinkTargets { return new class implements LinkTargets { public function postTypeOf(int $id): ?string { return match ($id) { 839 => 'page', 840 => 'post', default => null, }; } }; } public static function placeholders(): Placeholders { return new Placeholders(DiviFixtures::designSystem(), self::links()); } /** The strict $variable(…)$ checker for the sample design system and the default map. */ public static function references(): VariableRef { return new VariableRef(DiviFixtures::designSystem()); } /** The choke point for the sample catalog and design system (editor unless $unfiltered). */ public static function cleaner(bool $unfiltered = false): ValueCleaner { return new ValueCleaner(DiviFixtures::catalog(), self::references(), self::sanitizer($unfiltered)); } public static function attrWriter(bool $unfiltered = false): AttrWriter { return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::cleaner($unfiltered)); } /** * @param array>>|null $modules Short form per * type instead of ShortformMap (to reach paths the curated map never names). */ public static function shortform(bool $unfiltered = false, ?array $modules = null): ShortformWriter { return new ShortformWriter(self::placeholders(), self::media(), self::cleaner($unfiltered), $modules); } public static function rules(): StructureRules { return new StructureRules(DiviFixtures::catalog()); } /** * Builder version 5.2.1. $design replaces the sample design system (one Placeholders, one * VariableRef and one ValueCleaner shared by AttrWriter, ShortformWriter and Composer, like the * live wiring – ruling S31). The presets come from $book (default: the presets of $design, see * bookOf()); $groupsOf names the option groups per module (default: none). * * @param (Closure(string): array)|null $groupsOf */ public static function composer( bool $unfiltered = false, ?DesignSystem $design = null, ?PresetBook $book = null, ?Closure $groupsOf = null ): Composer { $design ??= DiviFixtures::designSystem(); $placeholders = new Placeholders($design); $cleaner = new ValueCleaner(DiviFixtures::catalog(), new VariableRef($design), self::sanitizer($unfiltered)); $attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, $cleaner); return new Composer( DiviFixtures::catalog(), self::rules(), new ShortformWriter($placeholders, self::media(), $cleaner), $attrWriter, $cleaner, self::presetAssignment($book, $groupsOf, $design, $cleaner), '5.2.1' ); } /** * The presets of $design in Divi's stored format (ids, names, defaults; no attrs) as a PresetBook – so the tests * that hand a DesignSystem to composer() keep their presets. */ public static function bookOf(DesignSystem $design): PresetBook { $raw = ['module' => [], 'group' => []]; foreach ($design->presets() as $preset) { $raw[$preset['kind']][$preset['module']] ??= ['default' => '', 'items' => []]; $raw[$preset['kind']][$preset['module']]['items'][$preset['id']] = [ 'id' => $preset['id'], 'name' => $preset['name'], $preset['kind'] === 'module' ? 'moduleName' : 'groupName' => $preset['module'], ]; if ($preset['default']) { $raw[$preset['kind']][$preset['module']]['default'] = $preset['id']; } } return PresetBook::fromDivi($raw); } /** * PresetAssignment as the live wiring builds it: the book is $book (default: bookOf($design), else of the sample * design system), the id check is the cleaner's keepsId(). * * @param (Closure(string): array)|null $groupsOf */ public static function presetAssignment( ?PresetBook $book = null, ?Closure $groupsOf = null, ?DesignSystem $design = null, ?ValueCleaner $cleaner = null ): PresetAssignment { $book ??= self::bookOf($design ?? DiviFixtures::designSystem()); $cleaner ??= self::cleaner(); return new PresetAssignment( static fn (): PresetBook => $book, $groupsOf ?? static fn (string $module): array => [], $cleaner->keepsId(...) ); } }