Files
WP-LIT-MCP/tests/Support/ComposeFixture.php
T
janniclas.loosenandClaude Opus 5.5 06f13799de fix: full-form internal links follow the {{link:}} target rules (ruling P10)
One PostIdParser for {{link:}} and select_post; post_link_url_<type> needs a
living, readable, non-attachment target of exactly that type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 14:54:51 +02:00

216 lines
8.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Support;
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;
/**
* 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).
*/
public static function composer(bool $unfiltered = false, ?DesignSystem $design = 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,
$design,
'5.2.1'
);
}
}