diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php index 37ca2bf..a6bfd32 100644 --- a/src/Integrations/Divi/Compose/Composer.php +++ b/src/Integrations/Divi/Compose/Composer.php @@ -503,7 +503,7 @@ final class Composer } try { - $clean = $this->cleaner->clean($name, self::LABEL_PATH, $label, null, 'text'); + $clean = $this->cleaner->clean($name, self::LABEL_PATH, $label, null, ValueKind::Text); } catch (InvalidArgumentException $e) { $this->errors[$path] = $e->getMessage(); @@ -553,7 +553,7 @@ final class Composer // Design-system data, but still through the choke point: an id text() would change is refused. try { - $clean = $this->cleaner->clean($name, 'modulePreset', $id, null, 'text'); + $clean = $this->cleaner->clean($name, 'modulePreset', $id, null, ValueKind::Text); } catch (InvalidArgumentException $e) { $clean = null; } diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index 1b4aa78..06408e2 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -26,9 +26,6 @@ final class ShortformWriter private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'; - /** Curated kind => sanitizer kind of ValueCleaner::clean(). */ - private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text']; - /** * @param array>>|null $modules Short form per * type (tests only); null = ShortformMap. @@ -107,7 +104,7 @@ final class ShortformWriter $value = trim($this->placeholders->resolve($value)); if (VariableRef::contains($value)) { - $reference = $this->cleaner->clean($module, $path, $value, $subKey, 'text'); + $reference = $this->cleaner->clean($module, $path, $value, $subKey, ValueKind::Text); if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') { throw new InvalidArgumentException(self::COLOR_ERROR); } @@ -119,7 +116,7 @@ final class ShortformWriter throw new InvalidArgumentException(self::COLOR_ERROR); } - $clean = $this->cleaner->clean($module, $path, $value, $subKey, 'text'); + $clean = $this->cleaner->clean($module, $path, $value, $subKey, ValueKind::Text); if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) { throw new InvalidArgumentException(self::COLOR_ERROR); } @@ -175,7 +172,7 @@ final class ShortformWriter [$urlKey, $idKey, $altKey] = $keys; $base = $path . '.desktop.value.'; - $url = $this->cleaner->clean($module, $path, $image['url'], $urlKey, 'url'); + $url = $this->cleaner->clean($module, $path, $image['url'], $urlKey, ValueKind::Url); if ($url === '') { throw new InvalidArgumentException($notFound); } @@ -206,7 +203,7 @@ final class ShortformWriter } try { - $clean = $this->cleaner->clean($module, $path, $alt, $altKey, 'text'); + $clean = $this->cleaner->clean($module, $path, $alt, $altKey, ValueKind::Text); } catch (InvalidArgumentException) { return ''; } @@ -253,18 +250,19 @@ final class ShortformWriter return $this->color($module, $path, $value, $sub); } - $kind = self::CLEAN_KIND[$spec['kind']] ?? 'text'; + // The curated kind as sanitizer kind; any other kind is text. + $kind = ValueKind::tryFrom((string) $spec['kind']) ?? ValueKind::Text; if (! is_string($value) && ! is_int($value) && ! is_float($value)) { throw new InvalidArgumentException('Text erwartet'); } $value = (string) $value; - if ($kind !== 'code') { + if ($kind !== ValueKind::Code) { $value = $this->placeholders->resolve($value); } $clean = $this->cleaner->clean($module, $path, $value, $sub, $kind); - if ($kind === 'url' && $clean === '' && trim($value) !== '') { + if ($kind === ValueKind::Url && $clean === '' && trim($value) !== '') { throw new InvalidArgumentException('ungültige URL'); } diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index 8d3d8c4..9e4b9fa 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -20,7 +20,8 @@ use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; * 3. UTF-8 check of the raw string. * 4. S32/S32a: ControlChars::strip() on the RAW string. * 5. S3/S3a/S23/S24: kind routing by catalog kind, sub-field, key name, path segment - * (kind()); S33a: a kind override may only tighten the routed kind (tightens()). + * (kind(), a ValueKind); S33a: a kind override may only tighten the routed kind + * (ValueKind::tightens()). * 6. S1/S2: a string with the $variable( marker → VariableRef::canonical() (whole value only; * URL fields only content references) – its result is returned as it is. * 7. the sanitizer of the kind: html / code / url / text (ValueSanitizer). @@ -48,9 +49,6 @@ final class ValueCleaner /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */ private const URL_SEGMENT = '/url$/i'; - /** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */ - private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2]; - /** * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, @@ -68,12 +66,11 @@ final class ValueCleaner /** * Cleans one value written to $path (steps 1–8 above). $subKey names the sub-field of a * composite path ("linkUrl" of button.innerContent) when $value is its string alone. $kind - * ('html'|'code'|'url'|'text') replaces the catalog routing for every string of $value: the - * short form passes its curated field kind (ShortformMap), so icon-list-item text is always - * plain text. It may only tighten (ruling S33a): code < html < text; url only where the - * routing says url – a weaker or other-family kind is refused. + * replaces the catalog routing for every string of $value: the short form passes its + * curated field kind (ShortformMap), so icon-list-item text is always plain text. It may only + * tighten (ruling S33a): code < html < text; url only where the routing says url – a weaker + * or other-family kind is refused. * - * @param 'html'|'code'|'url'|'text'|null $kind * @throws InvalidArgumentException */ public function clean( @@ -81,7 +78,7 @@ final class ValueCleaner string $path, mixed $value, ?string $subKey = null, - ?string $kind = null + ?ValueKind $kind = null ): mixed { $this->guardPath($path); @@ -91,10 +88,8 @@ final class ValueCleaner /** * The kind clean() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text * uses it to leave URL and code fields alone (D10). - * - * @return 'html'|'code'|'url'|'text' */ - public function kindOf(string $module, string $path, ?string $subKey = null): string + public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind { return $this->kind($module, $path, $subKey, $subKey); } @@ -111,7 +106,7 @@ final class ValueCleaner return function (string $path, ?string $key, string $value) use ($module): ?string { $kind = $this->kindOf($module, $path, $key); - return $kind === 'text' || $kind === 'html' + return $kind === ValueKind::Text || $kind === ValueKind::Html ? (string) $this->clean($module, $path, $value, $key) : null; }; @@ -139,7 +134,7 @@ final class ValueCleaner * * @param string|null $subName First key below the state value: selects the sub-kind of a composite path. * @param string|null $key Nearest key above $value: the URL fallback when the catalog names no kind. - * @param 'html'|'code'|'url'|'text'|null $kind Fixed kind instead of the routing (short form). + * @param ValueKind|null $kind Fixed kind instead of the routing (short form). * @throws InvalidArgumentException */ private function cleanNode( @@ -148,7 +143,7 @@ final class ValueCleaner mixed $value, ?string $subName, ?string $key, - ?string $kind = null + ?ValueKind $kind = null ): mixed { if (is_array($value)) { $clean = []; @@ -185,27 +180,27 @@ final class ValueCleaner // Step 5 (S3/S3a/S23/S24, S33a). $routed = $this->kind($module, $path, $subName, $key); - if ($kind !== null && ! self::tightens($kind, $routed)) { + if ($kind !== null && ! $kind->tightens($routed)) { throw new InvalidArgumentException(sprintf( 'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").', - $kind, + $kind->value, $path, - $routed + $routed->value )); } $kind ??= $routed; // Step 6 (S1/S2): the only way a reference is stored – whole, straight from canonical(). if (VariableRef::contains($value)) { - return $this->references->canonical($value, $this->sanitizer, $kind === 'url'); + return $this->references->canonical($value, $this->sanitizer, $kind === ValueKind::Url); } // Step 7. $clean = match ($kind) { - 'html' => $this->sanitizer->html($value), - 'code' => $this->sanitizer->code($value), - 'url' => $this->sanitizer->url($value), - default => $this->sanitizer->text($value), + ValueKind::Html => $this->sanitizer->html($value), + ValueKind::Code => $this->sanitizer->code($value), + ValueKind::Url => $this->sanitizer->url($value), + ValueKind::Text => $this->sanitizer->text($value), }; // Step 8 (S28): sanitizers strip characters ("", "%41") and can thereby build a marker @@ -218,31 +213,14 @@ final class ValueCleaner return $clean; } - /** - * Whether the kind override $kind is at least as strict as the routed kind (ruling S33a). - * Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text - * (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is - * tightened by the others, so only url → url passes. - */ - private static function tightens(string $kind, string $routed): bool - { - if ($kind === 'url' || $routed === 'url') { - return $kind === $routed; - } - - return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed]; - } - /** * Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the * sub-field for a composite path – decides. A string reached through a key below a path * with one scalar kind counts as unknown (S24). Unknown falls back to the key name (URL keys * at any depth), then to the last path segment (…Url), then content.innerContent → rich * text (S23: fields Divi defines only in JS), else text – never laxer than text. - * - * @return 'html'|'code'|'url'|'text' */ - private function kind(string $module, string $path, ?string $subName, ?string $key): string + private function kind(string $module, string $path, ?string $subName, ?string $key): ValueKind { // "unknown" also for a composite path whose sub-kinds do not name $subName. $kind = $this->catalog->pathKind($module, $path, $subName); @@ -251,26 +229,26 @@ final class ValueCleaner } return match ($kind) { - 'richtext' => 'html', - 'code' => 'code', - 'url', 'image' => 'url', + 'richtext' => ValueKind::Html, + 'code' => ValueKind::Code, + 'url', 'image' => ValueKind::Url, 'unknown' => $this->unknownKind($path, $key), - default => 'text', + default => ValueKind::Text, }; } - /** @return 'html'|'url'|'text' */ - private function unknownKind(string $path, ?string $key): string + /** Html, Url or Text – never Code. */ + private function unknownKind(string $path, ?string $key): ValueKind { if ($key !== null) { - return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text'; + return preg_match(self::URL_KEY, $key) === 1 ? ValueKind::Url : ValueKind::Text; } $segments = explode('.', $path); if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) { - return 'url'; + return ValueKind::Url; } - return $path === 'content.innerContent' ? 'html' : 'text'; + return $path === 'content.innerContent' ? ValueKind::Html : ValueKind::Text; } } diff --git a/src/Integrations/Divi/Compose/ValueKind.php b/src/Integrations/Divi/Compose/ValueKind.php new file mode 100644 index 0000000..bdca4a6 --- /dev/null +++ b/src/Integrations/Divi/Compose/ValueKind.php @@ -0,0 +1,48 @@ + 0, + self::Html => 1, + self::Text => 2, + self::Url => null, + }; + } + + /** + * Whether this kind, as an override, is at least as strict as the routed kind (ruling S33a). + * Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text + * (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is + * tightened by the others, so only url → url passes. + */ + public function tightens(self $routed): bool + { + if ($this === self::Url || $routed === self::Url) { + return $this === $routed; + } + + return $this->strictness() >= $routed->strictness(); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php index 71cb9ae..706e1fd 100644 --- a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\Compose\ValueKind; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -100,13 +101,13 @@ final class ValueCleanerTest extends TestCase { $cleaner = ComposeFixture::cleaner(); - self::assertSame('html', $cleaner->kindOf('divi/text', 'content.innerContent')); - self::assertSame('text', $cleaner->kindOf('divi/heading', 'title.innerContent')); - self::assertSame('code', $cleaner->kindOf('divi/code', 'content.innerContent')); - self::assertSame('text', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text')); - self::assertSame('url', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl')); - self::assertSame('text', $cleaner->kindOf('divi/image', 'image.innerContent', 'alt')); - self::assertSame('url', $cleaner->kindOf('divi/image', 'image.innerContent', 'src')); + self::assertSame(ValueKind::Html, $cleaner->kindOf('divi/text', 'content.innerContent')); + self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/heading', 'title.innerContent')); + self::assertSame(ValueKind::Code, $cleaner->kindOf('divi/code', 'content.innerContent')); + self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text')); + self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl')); + self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/image', 'image.innerContent', 'alt')); + self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/image', 'image.innerContent', 'src')); } /** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */ @@ -163,19 +164,19 @@ final class ValueCleanerTest extends TestCase { $cleaner = ComposeFixture::cleaner(); - self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '

x

', null, 'text')); - self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', 'x', null, 'text')); - self::assertSame('

x

', $cleaner->clean('divi/code', 'content.innerContent', '

x

', null, 'html')); - self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', 'Los', null, 'text')); - self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url')); + self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '

x

', null, ValueKind::Text)); + self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', 'x', null, ValueKind::Text)); + self::assertSame('

x

', $cleaner->clean('divi/code', 'content.innerContent', '

x

', null, ValueKind::Html)); + self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', 'Los', null, ValueKind::Text)); + self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', ValueKind::Url)); $weaker = [ - 'html over text' => ['divi/heading', 'title.innerContent', null, 'html'], - 'code over text' => ['divi/heading', 'title.innerContent', null, 'code'], - 'code over html' => ['divi/text', 'content.innerContent', null, 'code'], - 'url over text' => ['divi/heading', 'title.innerContent', null, 'url'], - 'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'], - 'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'], + 'html over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Html], + 'code over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Code], + 'code over html' => ['divi/text', 'content.innerContent', null, ValueKind::Code], + 'url over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Url], + 'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Text], + 'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Html], ]; foreach ($weaker as $name => [$module, $path, $sub, $kind]) { try { diff --git a/tests/Unit/Integrations/Divi/Compose/ValueKindTest.php b/tests/Unit/Integrations/Divi/Compose/ValueKindTest.php new file mode 100644 index 0000000..54f4e8d --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ValueKindTest.php @@ -0,0 +1,37 @@ + ['code'], + 'html' => ['code', 'html'], + 'text' => ['code', 'html', 'text'], + 'url' => ['url'], + ]; + foreach (ValueKind::cases() as $override) { + foreach (ValueKind::cases() as $routed) { + self::assertSame( + in_array($routed->value, $allowed[$override->value], true), + $override->tightens($routed), + $override->value . ' over ' . $routed->value + ); + } + } + } + + public function testValuesAreTheCuratedKindNames(): void + { + self::assertSame(['html', 'code', 'url', 'text'], array_map(static fn (ValueKind $kind): string => $kind->value, ValueKind::cases())); + self::assertNull(ValueKind::tryFrom('richtext')); + } +}