Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
273 lines
11 KiB
PHP
273 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Support;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
||
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
||
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
||
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||
|
||
/**
|
||
* Compose classes wired with the real Divi 5.2.1 sample catalog, the sample design system and
|
||
* predictable fakes for sanitizing and media (no WordPress). $unfiltered switches the fake
|
||
* sanitizer between a user with and without unfiltered_html (ruling S7).
|
||
*/
|
||
final class ComposeFixture
|
||
{
|
||
/** A valid content reference as an attachment's alt text (media ids 44 and 45). */
|
||
public const REFERENCE_ALT = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
|
||
|
||
public static function sanitizer(bool $unfiltered = false): ValueSanitizer
|
||
{
|
||
return new class ($unfiltered) implements ValueSanitizer {
|
||
public function __construct(private bool $unfiltered)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* Like sanitize_text_field (_sanitize_text_fields): tags only go when there is a "<"
|
||
* (wp_strip_all_tags; PHP's strip_tags also drops NUL then), whitespace runs become one
|
||
* space, trim, %XX octets go repeatedly. Without a "<", NUL and other control
|
||
* characters stay – as in WordPress (ruling S32: AttrWriter strips them).
|
||
*/
|
||
public function text(string $value): string
|
||
{
|
||
if (str_contains($value, '<')) {
|
||
$value = strip_tags((string) preg_replace('@<(script|style)[^>]*?>.*?</\\1>@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, <script> blocks and tags outside a small allow list go.
|
||
*/
|
||
public function html(string $value): string
|
||
{
|
||
if ($this->unfiltered) {
|
||
return $value;
|
||
}
|
||
|
||
$value = (string) preg_replace('#<script\b[^>]*>.*?</script>#is', '', str_replace(chr(0), '', $value));
|
||
|
||
return (string) preg_replace(
|
||
'#</?(?!(?:p|b|i|em|strong|a|br|span|h[1-6]|ul|ol|li)\b)[a-z][^>]*>#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<string, array<string, array<string, mixed>>>|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<string, string>)|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(). $load replaces $book with a loader the test can
|
||
* count.
|
||
*
|
||
* @param (Closure(string): array<string, string>)|null $groupsOf
|
||
* @param (Closure(): PresetBook)|null $load
|
||
*/
|
||
public static function presetAssignment(
|
||
?PresetBook $book = null,
|
||
?Closure $groupsOf = null,
|
||
?DesignSystem $design = null,
|
||
?ValueCleaner $cleaner = null,
|
||
?Closure $load = null
|
||
): PresetAssignment {
|
||
$book ??= self::bookOf($design ?? DiviFixtures::designSystem());
|
||
$cleaner ??= self::cleaner();
|
||
|
||
return new PresetAssignment(
|
||
$load ?? static fn (): PresetBook => $book,
|
||
$groupsOf ?? static fn (string $module): array => [],
|
||
$cleaner->keepsId(...)
|
||
);
|
||
}
|
||
}
|