diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php
index 3f160525..5553e608 100644
--- a/php-transformer/src/HtmlToBlocks/BlockFactory.php
+++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php
@@ -62,6 +62,8 @@ public function create(string $name, array $attrs = array(), array $innerBlocks
*/
private function normalizeAttrsForBlock(string $name, array $attrs): array
{
+ $attrs = $this->normalizeClassNameAttr($attrs);
+
if ( in_array($name, array( 'core/group', 'core/columns' ), true) ) {
unset($attrs['style']['dimensions']['maxWidth']);
if ( empty($attrs['style']['dimensions']) ) {
@@ -85,6 +87,33 @@ private function normalizeAttrsForBlock(string $name, array $attrs): array
return $attrs;
}
+ /**
+ * @param array $attrs
+ * @return array
+ */
+ private function normalizeClassNameAttr(array $attrs): array
+ {
+ if ( ! is_string($attrs['className'] ?? null) ) {
+ return $attrs;
+ }
+
+ $classes = array();
+ foreach ( preg_split('/\s+/', trim($attrs['className'])) ?: array() as $class ) {
+ if ( '' === $class || GeneratedGutenbergClassPolicy::isGeneratedClassName($class) || in_array($class, $classes, true) ) {
+ continue;
+ }
+ $classes[] = $class;
+ }
+
+ if ( array() === $classes ) {
+ unset($attrs['className']);
+ return $attrs;
+ }
+
+ $attrs['className'] = implode(' ', $classes);
+ return $attrs;
+ }
+
/**
* @param array $attrs
* @return array
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index 356faebb..c8d3a4b8 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -804,6 +804,24 @@ public function match(DOMElement $element, PatternContext $context): ?array
$assert(str_contains($canonicalWrapperAttrsSerialized, 'Menu
'), 'heading wrappers include canonical and support classes with supported color style');
$assert(str_contains($canonicalWrapperAttrsSerialized, 'Fresh daily.
'), 'paragraph wrappers preserve runtime-addressable classes and supported margin style');
+$paragraphGeneratedClassLeakResult = ( new HtmlTransformer() )->transform(
+ 'Slow roasted daily.
'
+)->toArray();
+$paragraphGeneratedClassLeakBlock = $paragraphGeneratedClassLeakResult['blocks'][0] ?? array();
+$paragraphGeneratedClassLeakSerialized = (string) ($paragraphGeneratedClassLeakResult['serialized_blocks'] ?? '');
+$assert('hero-tagline' === ($paragraphGeneratedClassLeakBlock['attrs']['className'] ?? ''), 'paragraph className strips duplicate generated color classes while preserving custom classes');
+$assert(str_contains($paragraphGeneratedClassLeakSerialized, 'Slow roasted daily.
'), 'paragraph serialization emits generated has-text-color once before custom classes');
+$assert(1 === substr_count($paragraphGeneratedClassLeakSerialized, 'has-text-color'), 'paragraph serialization emits has-text-color exactly once');
+$assert('pass' === ($paragraphGeneratedClassLeakResult['source_reports']['wp_block_validity']['status'] ?? ''), 'paragraph with stripped generated className passes generated block validity checks');
+
+$paragraphFactoryGeneratedClassLeakBlock = ( new BlockFactory() )->create('core/paragraph', array(
+ 'content' => 'Slow roasted daily.',
+ 'className' => 'has-text-color has-text-color hero-tagline',
+ 'style' => array('color' => array('text' => 'red')),
+));
+$assert('hero-tagline' === ($paragraphFactoryGeneratedClassLeakBlock['attrs']['className'] ?? ''), 'BlockFactory strips generated classes from direct paragraph className attrs');
+$assert(str_contains((string) ($paragraphFactoryGeneratedClassLeakBlock['innerHTML'] ?? ''), 'Slow roasted daily.
'), 'BlockFactory paragraph innerHTML emits a single generated color class');
+
$paragraphSvgResult = ( new HtmlTransformer() )->transform(
'
'
)->toArray();