refactor: ValueKind enum instead of kind string literals (L1)

enum ValueKind: string { Html, Code, Url, Text } with strictness() and
tightens(self $routed) (the S33a rule formerly AttrWriter/ValueCleaner::tightens
over STRICTNESS). ValueCleaner::clean() takes ?ValueKind, kindOf()/kind()/
unknownKind() return it, the sanitizer match is exhaustive over the four cases;
the "Feldart" message prints ->value, so its text is unchanged. ShortformWriter
drops the identity map CLEAN_KIND for ValueKind::tryFrom($spec['kind']) ??
ValueKind::Text; Composer and ShortformWriter pass ValueKind::Text/Url.
The installed PHPCompatibility sniffs do not know enums: two sniffs are disabled
in ValueKind.php only, with the reason.
Tests: ValueCleanerTest passes/asserts ValueKind cases instead of strings;
+ValueKindTest (full tightens matrix, case values).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 18:13:23 +02:00
co-authored by Claude Opus 5.5
parent 62ce99e558
commit 570c1e6142
6 changed files with 143 additions and 81 deletions
+2 -2
View File
@@ -503,7 +503,7 @@ final class Composer
} }
try { 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) { } catch (InvalidArgumentException $e) {
$this->errors[$path] = $e->getMessage(); $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. // Design-system data, but still through the choke point: an id text() would change is refused.
try { try {
$clean = $this->cleaner->clean($name, 'modulePreset', $id, null, 'text'); $clean = $this->cleaner->clean($name, 'modulePreset', $id, null, ValueKind::Text);
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$clean = null; $clean = null;
} }
@@ -26,9 +26,6 @@ final class ShortformWriter
private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'; 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<string, array<string, array<string, mixed>>>|null $modules Short form per * @param array<string, array<string, array<string, mixed>>>|null $modules Short form per
* type (tests only); null = ShortformMap. * type (tests only); null = ShortformMap.
@@ -107,7 +104,7 @@ final class ShortformWriter
$value = trim($this->placeholders->resolve($value)); $value = trim($this->placeholders->resolve($value));
if (VariableRef::contains($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') { if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') {
throw new InvalidArgumentException(self::COLOR_ERROR); throw new InvalidArgumentException(self::COLOR_ERROR);
} }
@@ -119,7 +116,7 @@ final class ShortformWriter
throw new InvalidArgumentException(self::COLOR_ERROR); 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) { if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) {
throw new InvalidArgumentException(self::COLOR_ERROR); throw new InvalidArgumentException(self::COLOR_ERROR);
} }
@@ -175,7 +172,7 @@ final class ShortformWriter
[$urlKey, $idKey, $altKey] = $keys; [$urlKey, $idKey, $altKey] = $keys;
$base = $path . '.desktop.value.'; $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 === '') { if ($url === '') {
throw new InvalidArgumentException($notFound); throw new InvalidArgumentException($notFound);
} }
@@ -206,7 +203,7 @@ final class ShortformWriter
} }
try { try {
$clean = $this->cleaner->clean($module, $path, $alt, $altKey, 'text'); $clean = $this->cleaner->clean($module, $path, $alt, $altKey, ValueKind::Text);
} catch (InvalidArgumentException) { } catch (InvalidArgumentException) {
return ''; return '';
} }
@@ -253,18 +250,19 @@ final class ShortformWriter
return $this->color($module, $path, $value, $sub); 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)) { if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
throw new InvalidArgumentException('Text erwartet'); throw new InvalidArgumentException('Text erwartet');
} }
$value = (string) $value; $value = (string) $value;
if ($kind !== 'code') { if ($kind !== ValueKind::Code) {
$value = $this->placeholders->resolve($value); $value = $this->placeholders->resolve($value);
} }
$clean = $this->cleaner->clean($module, $path, $value, $sub, $kind); $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'); throw new InvalidArgumentException('ungültige URL');
} }
+29 -51
View File
@@ -20,7 +20,8 @@ use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
* 3. UTF-8 check of the raw string. * 3. UTF-8 check of the raw string.
* 4. S32/S32a: ControlChars::strip() on 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 * 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; * 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. * URL fields only content references) – its result is returned as it is.
* 7. the sanitizer of the kind: html / code / url / text (ValueSanitizer). * 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. */ /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
private const URL_SEGMENT = '/url$/i'; 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 * 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, * 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 * 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 * 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 * replaces the catalog routing for every string of $value: the short form passes its
* short form passes its curated field kind (ShortformMap), so icon-list-item text is always * curated field kind (ShortformMap), so icon-list-item text is always plain text. It may only
* plain text. It may only tighten (ruling S33a): code < html < text; url only where the * tighten (ruling S33a): code < html < text; url only where the routing says url – a weaker
* routing says url – a weaker or other-family kind is refused. * or other-family kind is refused.
* *
* @param 'html'|'code'|'url'|'text'|null $kind
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function clean( public function clean(
@@ -81,7 +78,7 @@ final class ValueCleaner
string $path, string $path,
mixed $value, mixed $value,
?string $subKey = null, ?string $subKey = null,
?string $kind = null ?ValueKind $kind = null
): mixed { ): mixed {
$this->guardPath($path); $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 * 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). * 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); 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 { return function (string $path, ?string $key, string $value) use ($module): ?string {
$kind = $this->kindOf($module, $path, $key); $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) ? (string) $this->clean($module, $path, $value, $key)
: null; : 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 $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 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 * @throws InvalidArgumentException
*/ */
private function cleanNode( private function cleanNode(
@@ -148,7 +143,7 @@ final class ValueCleaner
mixed $value, mixed $value,
?string $subName, ?string $subName,
?string $key, ?string $key,
?string $kind = null ?ValueKind $kind = null
): mixed { ): mixed {
if (is_array($value)) { if (is_array($value)) {
$clean = []; $clean = [];
@@ -185,27 +180,27 @@ final class ValueCleaner
// Step 5 (S3/S3a/S23/S24, S33a). // Step 5 (S3/S3a/S23/S24, S33a).
$routed = $this->kind($module, $path, $subName, $key); $routed = $this->kind($module, $path, $subName, $key);
if ($kind !== null && ! self::tightens($kind, $routed)) { if ($kind !== null && ! $kind->tightens($routed)) {
throw new InvalidArgumentException(sprintf( throw new InvalidArgumentException(sprintf(
'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").', 'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").',
$kind, $kind->value,
$path, $path,
$routed $routed->value
)); ));
} }
$kind ??= $routed; $kind ??= $routed;
// Step 6 (S1/S2): the only way a reference is stored – whole, straight from canonical(). // Step 6 (S1/S2): the only way a reference is stored – whole, straight from canonical().
if (VariableRef::contains($value)) { 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. // Step 7.
$clean = match ($kind) { $clean = match ($kind) {
'html' => $this->sanitizer->html($value), ValueKind::Html => $this->sanitizer->html($value),
'code' => $this->sanitizer->code($value), ValueKind::Code => $this->sanitizer->code($value),
'url' => $this->sanitizer->url($value), ValueKind::Url => $this->sanitizer->url($value),
default => $this->sanitizer->text($value), ValueKind::Text => $this->sanitizer->text($value),
}; };
// Step 8 (S28): sanitizers strip characters ("<x>", "%41") and can thereby build a marker // Step 8 (S28): sanitizers strip characters ("<x>", "%41") and can thereby build a marker
@@ -218,31 +213,14 @@ final class ValueCleaner
return $clean; 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 * 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 * 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 * 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 * 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. * 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. // "unknown" also for a composite path whose sub-kinds do not name $subName.
$kind = $this->catalog->pathKind($module, $path, $subName); $kind = $this->catalog->pathKind($module, $path, $subName);
@@ -251,26 +229,26 @@ final class ValueCleaner
} }
return match ($kind) { return match ($kind) {
'richtext' => 'html', 'richtext' => ValueKind::Html,
'code' => 'code', 'code' => ValueKind::Code,
'url', 'image' => 'url', 'url', 'image' => ValueKind::Url,
'unknown' => $this->unknownKind($path, $key), 'unknown' => $this->unknownKind($path, $key),
default => 'text', default => ValueKind::Text,
}; };
} }
/** @return 'html'|'url'|'text' */ /** Html, Url or Text – never Code. */
private function unknownKind(string $path, ?string $key): string private function unknownKind(string $path, ?string $key): ValueKind
{ {
if ($key !== null) { 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); $segments = explode('.', $path);
if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) { 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;
} }
} }
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
// phpcs:disable PHPCompatibility.FunctionDeclarations.NewParamTypeDeclarations.SelfOutsideClassScopeFound
/**
* The sanitizer kind ValueCleaner routes a string to (step 5 of its pipeline): html (kses for
* users without unfiltered_html), code (raw only with unfiltered_html), url (esc_url_raw) or
* text (no markup). Pure PHP.
*/
enum ValueKind: string
{
case Html = 'html';
case Code = 'code';
case Url = 'url';
case Text = 'text';
/** Position in the text family from laxest to strictest (ruling S33a); null for url, which stands apart. */
public function strictness(): ?int
{
return match ($this) {
self::Code => 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();
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException; use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\ValueKind;
use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -100,13 +101,13 @@ final class ValueCleanerTest extends TestCase
{ {
$cleaner = ComposeFixture::cleaner(); $cleaner = ComposeFixture::cleaner();
self::assertSame('html', $cleaner->kindOf('divi/text', 'content.innerContent')); self::assertSame(ValueKind::Html, $cleaner->kindOf('divi/text', 'content.innerContent'));
self::assertSame('text', $cleaner->kindOf('divi/heading', 'title.innerContent')); self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/heading', 'title.innerContent'));
self::assertSame('code', $cleaner->kindOf('divi/code', 'content.innerContent')); self::assertSame(ValueKind::Code, $cleaner->kindOf('divi/code', 'content.innerContent'));
self::assertSame('text', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text')); self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
self::assertSame('url', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl')); self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
self::assertSame('text', $cleaner->kindOf('divi/image', 'image.innerContent', 'alt')); self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/image', 'image.innerContent', 'alt'));
self::assertSame('url', $cleaner->kindOf('divi/image', 'image.innerContent', 'src')); 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. */ /** 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(); $cleaner = ComposeFixture::cleaner();
self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p>', null, 'text')); self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p>', null, ValueKind::Text));
self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', '<b>x</b>', null, 'text')); self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', '<b>x</b>', null, ValueKind::Text));
self::assertSame('<p>x</p>', $cleaner->clean('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html')); self::assertSame('<p>x</p>', $cleaner->clean('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, ValueKind::Html));
self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text')); self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Los</b>', null, ValueKind::Text));
self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url')); self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', ValueKind::Url));
$weaker = [ $weaker = [
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'], 'html over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Html],
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'], 'code over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Code],
'code over html' => ['divi/text', 'content.innerContent', null, 'code'], 'code over html' => ['divi/text', 'content.innerContent', null, ValueKind::Code],
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'], 'url over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Url],
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'], 'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Text],
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'], 'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Html],
]; ];
foreach ($weaker as $name => [$module, $path, $sub, $kind]) { foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
try { try {
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\Compose\ValueKind;
use PHPUnit\Framework\TestCase;
/** Ruling S33a: an override may only tighten – code < html < text; url only to url. */
final class ValueKindTest extends TestCase
{
public function testTightensMatrix(): void
{
$allowed = [
'code' => ['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'));
}
}