@@ -236,16 +256,11 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
);
if ( 'button' === ($attrs['tagName'] ?? '') ) {
- $buttonAttrs = array_intersect_key($attrs, array_flip(array( 'type', 'role', 'aria-label', 'aria-controls', 'aria-expanded', 'aria-haspopup' )));
- foreach ( $attrs as $attrName => $attrValue ) {
- if ( is_string($attrName) && str_starts_with(strtolower($attrName), 'data-') ) {
- $buttonAttrs[$attrName] = (string) $attrValue;
- }
- }
- $buttonAttrs = array_merge(array(
+ $buttonAttrs = array(
+ 'type' => (string) ($attrs['type'] ?? 'button'),
'class' => $this->mergeClassNames('wp-block-button__link', $support['classes'], 'wp-element-button'),
'style' => $support['style'],
- ), $buttonAttrs);
+ );
return 'htmlAttrs($wrapperAttrs) . '>
';
}
@@ -474,11 +489,9 @@ private function buttonStyleSupport(array $attrs): array
$text = (string) ($style['color']['text'] ?? '');
if ( '' !== $text ) {
$classes[] = 'has-text-color';
- $declarations[] = 'color:' . $text;
}
if ( '' !== $background ) {
$classes[] = 'has-background';
- $declarations[] = 'background-color:' . $background;
}
$border = is_array($style['border'] ?? null) ? $style['border'] : array();
@@ -486,16 +499,23 @@ private function buttonStyleSupport(array $attrs): array
$classes[] = 'has-border-color';
$declarations[] = 'border-color:' . (string) $border['color'];
}
- if ( isset($border['width']) && '' !== (string) $border['width'] ) {
- $declarations[] = 'border-width:' . (string) $border['width'];
- }
if ( isset($border['style']) && '' !== (string) $border['style'] ) {
$declarations[] = 'border-style:' . (string) $border['style'];
}
+ if ( isset($border['width']) && '' !== (string) $border['width'] ) {
+ $declarations[] = 'border-width:' . (string) $border['width'];
+ }
if ( isset($border['radius']) && '' !== (string) $border['radius'] ) {
$declarations[] = 'border-radius:' . (string) $border['radius'];
}
+ if ( '' !== $text ) {
+ $declarations[] = 'color:' . $text;
+ }
+ if ( '' !== $background ) {
+ $declarations[] = 'background-color:' . $background;
+ }
+
$padding = is_array($style['spacing']['padding'] ?? null) ? $style['spacing']['padding'] : array();
foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
if ( isset($padding[$side]) && '' !== (string) $padding[$side] ) {
@@ -545,7 +565,9 @@ private function mergeClassNames(string ...$classNames): string
private function blockSupportAttrs(array $attrs, string $baseClass = ''): string
{
$support = $this->styleSupport($attrs['style'] ?? null);
- $classes = $this->mergeClassNames($baseClass, $support['classes'], (string) ($attrs['className'] ?? ''));
+ $presetClasses = $this->presetColorClasses($attrs);
+ $layoutClasses = $this->layoutClasses($attrs['layout'] ?? null, $baseClass);
+ $classes = $this->mergeClassNames($baseClass, $presetClasses, $support['classes'], $layoutClasses, (string) ($attrs['className'] ?? ''));
return $this->htmlAttrs(array(
'id' => (string) ($attrs['anchor'] ?? ''),
'class' => $classes,
@@ -573,6 +595,50 @@ private function styleSupport(mixed $style): array
);
}
+ /**
+ * @param array $attrs
+ */
+ private function presetColorClasses(array $attrs): string
+ {
+ $classes = array();
+ $textColor = $this->safeSlug((string) ($attrs['textColor'] ?? ''));
+ if ( '' !== $textColor ) {
+ $classes[] = 'has-' . $textColor . '-color';
+ $classes[] = 'has-text-color';
+ }
+
+ $backgroundColor = $this->safeSlug((string) ($attrs['backgroundColor'] ?? ''));
+ if ( '' !== $backgroundColor ) {
+ $classes[] = 'has-' . $backgroundColor . '-background-color';
+ $classes[] = 'has-background';
+ }
+
+ return implode(' ', $classes);
+ }
+
+ private function layoutClasses(mixed $layout, string $baseClass): string
+ {
+ if ( ! is_array($layout) ) {
+ return '';
+ }
+
+ $type = $this->safeSlug((string) ($layout['type'] ?? ''));
+ if ( ! in_array($type, array( 'constrained', 'flex', 'flow', 'grid' ), true) ) {
+ return '';
+ }
+
+ return $this->mergeClassNames(
+ 'is-layout-' . $type,
+ '' !== $baseClass ? $baseClass . '-is-layout-' . $type : ''
+ );
+ }
+
+ private function safeSlug(string $value): string
+ {
+ $value = strtolower(trim($value));
+ return preg_match('/^[a-z0-9_-]+$/', $value) ? $value : '';
+ }
+
/**
* @param array $attrs
* @param array $includeEmpty
diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php b/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php
index 5d825d0d..6ca3870a 100644
--- a/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php
+++ b/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php
@@ -102,6 +102,9 @@ public function collect(
'diagnostic_class' => 'runtime_island_preserved',
'suggested_repair_class' => 'preserve_runtime_island',
'preservation_strategy' => $island['preservation_strategy'] ?? 'bounded_raw_html_runtime_island',
+ 'disposition' => $island['disposition'] ?? null,
+ 'preservation_status' => $island['preservation_status'] ?? null,
+ 'js_handling' => $island['js_handling'] ?? null,
'runtime_requirement' => $island['runtime_requirement'] ?? null,
'kind' => $island['kind'] ?? null,
'reason' => $island['preservation_reason'] ?? null,
diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php b/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php
index 6aa70398..56f75995 100644
--- a/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php
+++ b/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php
@@ -506,6 +506,9 @@ public function recordRuntimeIsland(DOMElement $element, string $kind, string $r
'diagnostic_code' => 'preserved_runtime_island',
'preservation_reason' => $reason,
'runtime_requirement' => $runtimeRequirement,
+ 'disposition' => 'preserve',
+ 'preservation_status' => 'accepted_runtime_preservation',
+ 'js_handling' => 'client_script_execution' === $runtimeRequirement ? 'preserve_verbatim' : '',
'source_snippet' => $boundedHtml['html'],
'source_bytes' => $boundedHtml['bytes'],
'source_truncated' => $boundedHtml['truncated'],
diff --git a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php
index 293190d0..1cf7b333 100644
--- a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php
+++ b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php
@@ -84,6 +84,9 @@ private static function defaults(array $fields): array
'conversion_classification' => 'runtime_island_preserved',
'loss_class' => 'runtime_island_preserved',
'diagnostic_class' => 'runtime_island_preserved',
+ 'disposition' => 'preserve',
+ 'preservation_status' => 'accepted_runtime_preservation',
+ 'js_handling' => 'preserve_verbatim',
'preservation_strategy' => 'scoped_runtime_metadata',
'runtime_requirement' => 'client_script_execution',
'recoverability' => 'recoverable_with_script_enqueue_or_component_runtime',
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index 51944e1d..c9b07cc6 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -1099,6 +1099,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
if ( preg_match('/^h([1-6])$/', $tagName, $matches) ) {
$content = $this->innerHtml($element);
+ if ( $this->richTextRequiresHtmlFallback($content) ) {
+ return $this->createBlock('core/html', array( 'content' => $this->restoreSvgCasing($this->outerHtml($element)) ), array(), $element);
+ }
if ( '' === trim($this->runtime->stripAllTags($content)) ) {
return null;
}
@@ -1111,6 +1114,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
if ( 'p' === $tagName ) {
$content = $this->innerHtml($element);
+ if ( $this->richTextRequiresHtmlFallback($content) ) {
+ return $this->createBlock('core/html', array( 'content' => $this->restoreSvgCasing($this->outerHtml($element)) ), array(), $element);
+ }
if ( '' === trim($this->runtime->stripAllTags($content)) ) {
if ( $this->isRuntimeDomTarget($element) ) {
return $this->createBlock('core/group', $this->presentationAttributes($element), array(), $element);
@@ -1418,14 +1424,6 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
return $linkedImage;
}
- if ( '' === trim($element->textContent ?? '') && '' !== $this->safeLinkUrl($this->attr($element, 'href')) && '' !== trim($this->attr($element, 'aria-label')) ) {
- return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) )), array(), $element);
- }
-
- if ( '' === trim($element->textContent ?? '') ) {
- return null;
- }
-
$logo = $this->logoPattern->match(
$element,
fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement),
@@ -1450,6 +1448,14 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
return $button;
}
+ if ( '' === trim($element->textContent ?? '') && '' !== $this->safeLinkUrl($this->attr($element, 'href')) && '' !== trim($this->attr($element, 'aria-label')) ) {
+ return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) )), array(), $element);
+ }
+
+ if ( '' === trim($element->textContent ?? '') ) {
+ return null;
+ }
+
if ( $this->hasBlockContentChildren($element) ) {
$children = $this->convertChildren($element, $fallbacks, true);
if ( array() !== $children ) {
@@ -1829,6 +1835,10 @@ private function createBlock(string $name, array $attrs = array(), array $innerB
{
$attrs = $this->hoistContentWrappingSpans($name, $attrs);
+ if ( $sourceElement instanceof DOMElement && in_array($name, array( 'core/paragraph', 'core/heading' ), true) && $this->richTextRequiresHtmlFallback((string) ($attrs['content'] ?? '')) ) {
+ return $this->blockFactory->create('core/html', array( 'content' => $this->restoreSvgCasing($this->outerHtml($sourceElement)) ));
+ }
+
if ( $sourceElement instanceof DOMElement ) {
$provenanceId = $this->nextSourceProvenanceId++;
$this->recordPresentationProvenance($name, $attrs, $sourceElement);
@@ -1880,7 +1890,10 @@ private function createBlock(string $name, array $attrs = array(), array $innerB
* - Remaining sibling/partial styling-hook spans are UNWRAPPED to their
* inner content. Their per-span class styling cannot ride valid RichText
* here, so this is best-effort; the emitted block is always valid.
- * Genuine inline formats (strong/em/a/br/…) are never touched.
+ * Genuine inline formats (strong/em/a/br/…) are kept, but arbitrary
+ * class/style hooks on links are moved to the block wrapper when the link is
+ * the sole content wrapper, or dropped when they are partial-content hooks.
+ * RichText's link format round-trips href/target/rel, not source CSS hooks.
*
* A list item whose content carries block-level children (an image/heading/
* paragraph "card", e.g. a commerce product grid) is left untouched here:
@@ -1897,7 +1910,7 @@ private function hoistContentWrappingSpans(string $name, array $attrs): array
}
$content = (string) ($attrs['content'] ?? '');
- if ( '' === $content || ! preg_match('/unwrapElement($wrapper);
}
+ $soleAnchor = $this->soleRichTextAnchor($body);
+ if ( $soleAnchor instanceof DOMElement ) {
+ $hoistedClasses = trim($hoistedClasses . ' ' . $this->attr($soleAnchor, 'class'));
+ $anchorStyle = trim($this->attr($soleAnchor, 'style'));
+ if ( '' !== $anchorStyle ) {
+ $hoistedDeclarations = array_merge($hoistedDeclarations, $this->cssDeclarations($anchorStyle));
+ }
+ }
+
+ foreach ( $this->richTextAnchors($body) as $anchor ) {
+ $anchor->removeAttribute('class');
+ $anchor->removeAttribute('style');
+ }
+
// Unwrap any remaining styling-hook spans (sibling / partial content):
// best-effort, the class styling is not representable as valid RichText.
foreach ( $this->stylingHookSpans($body) as $span ) {
@@ -1982,6 +2009,22 @@ private function soleStylingHookSpan(DOMElement $container): ?DOMElement
return $only instanceof DOMElement && $this->isStylingHookSpan($only) ? $only : null;
}
+ private function soleRichTextAnchor(DOMElement $container): ?DOMElement
+ {
+ $only = null;
+ foreach ( $container->childNodes as $child ) {
+ if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) {
+ continue;
+ }
+ if ( null !== $only ) {
+ return null;
+ }
+ $only = $child;
+ }
+
+ return $only instanceof DOMElement && 'a' === strtolower($only->tagName) ? $only : null;
+ }
+
/**
* A `` whose only attributes are class and/or style (at least one
* non-empty). These are presentational styling hooks RichText cannot store,
@@ -2022,6 +2065,26 @@ private function stylingHookSpans(DOMElement $container): array
return $spans;
}
+ /**
+ * @return array
+ */
+ private function richTextAnchors(DOMElement $container): array
+ {
+ $anchors = array();
+ foreach ( $container->getElementsByTagName('a') as $anchor ) {
+ if ( $anchor instanceof DOMElement && ( $anchor->hasAttribute('class') || $anchor->hasAttribute('style') ) ) {
+ $anchors[] = $anchor;
+ }
+ }
+
+ return $anchors;
+ }
+
+ private function richTextRequiresHtmlFallback(string $content): bool
+ {
+ return (bool) preg_match('/<(?:svg|canvas|img|picture|video|audio|iframe|object|embed|input|button|select|textarea|form)\b/i', $content);
+ }
+
/**
* Replace an element with its children in place, dropping only the wrapper.
*/
@@ -4372,10 +4435,13 @@ private function isPassiveSvgElement(DOMElement $element, array $allowedTags, ar
private function formControls(DOMElement $form): array
{
$controls = array();
+ $order = 0;
foreach ( $this->formControlElements($form) as $control ) {
$metadata = $this->formControlMetadata($control);
if ( array() !== $metadata ) {
+ $metadata['order'] = $order;
$controls[] = $metadata;
+ ++$order;
}
}
@@ -4383,18 +4449,32 @@ private function formControls(DOMElement $form): array
}
/**
- * @return array
+ * @return array
*/
private function formMetadata(DOMElement $form): array
{
- return array_filter(
+ $metadata = array_filter(
array(
- 'action' => $this->attr($form, 'action'),
- 'method' => strtolower($this->attr($form, 'method')),
- 'enctype' => $this->attr($form, 'enctype'),
+ 'id' => $this->attr($form, 'id'),
+ 'name' => $this->attr($form, 'name'),
+ 'class' => $this->attr($form, 'class'),
+ 'aria_label' => $this->attr($form, 'aria-label'),
+ 'action' => $this->attr($form, 'action'),
+ 'method' => strtolower($this->attr($form, 'method')),
+ 'enctype' => $this->attr($form, 'enctype'),
+ 'target' => $this->attr($form, 'target'),
+ 'autocomplete' => $this->attr($form, 'autocomplete'),
),
static fn (string $value): bool => '' !== $value
);
+
+ foreach ( array( 'novalidate' ) as $attribute ) {
+ if ( $form->hasAttribute($attribute) ) {
+ $metadata[$attribute] = true;
+ }
+ }
+
+ return $metadata;
}
/**
@@ -4752,6 +4832,7 @@ private function formFallbackFinding(DOMElement $element, ?array $readableFormBl
'selector' => $this->elementSelector($element),
'attributes' => $this->htmlAttributes($element),
'form' => $this->formMetadata($element),
+ 'success_panel' => $this->formSuccessPanelMetadata($element),
'context' => $this->sourceContext($element),
'classification' => $this->fallbackEmitter->classifyFallbackSubtree($element),
'events' => $this->eventMetadata($element),
@@ -4766,6 +4847,58 @@ private function formFallbackFinding(DOMElement $element, ?array $readableFormBl
), $this->fallbackProvenance);
}
+ /**
+ * @return array
+ */
+ private function formSuccessPanelMetadata(DOMElement $form): array
+ {
+ for ( $sibling = $form->nextSibling; $sibling instanceof DOMNode; $sibling = $sibling->nextSibling ) {
+ if ( XML_TEXT_NODE === $sibling->nodeType && '' === trim($sibling->textContent ?? '') ) {
+ continue;
+ }
+
+ if ( ! $sibling instanceof DOMElement ) {
+ return array();
+ }
+
+ if ( ! $this->hasSuccessPanelSignal($sibling) ) {
+ return array();
+ }
+
+ $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($sibling));
+ return array_filter(array(
+ 'selector' => $this->elementSelector($sibling),
+ 'id' => $this->attr($sibling, 'id'),
+ 'class' => $this->attr($sibling, 'class'),
+ 'role' => $this->attr($sibling, 'role'),
+ 'aria_live' => $this->attr($sibling, 'aria-live'),
+ 'text' => $this->normalizedSuccessPanelText($sibling),
+ 'html' => $boundedHtml['html'],
+ 'html_bytes' => $boundedHtml['bytes'],
+ 'html_truncated' => $boundedHtml['truncated'],
+ ), static fn (mixed $value): bool => is_bool($value) || is_int($value) || '' !== trim((string) $value));
+ }
+
+ return array();
+ }
+
+ private function normalizedSuccessPanelText(DOMElement $element): string
+ {
+ $html = preg_replace('/<\/?[a-z][a-z0-9]*\b[^>]*>/i', ' ', $this->innerHtml($element)) ?? $element->textContent ?? '';
+ return trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '');
+ }
+
+ private function hasSuccessPanelSignal(DOMElement $element): bool
+ {
+ $role = strtolower($this->attr($element, 'role'));
+ if ( in_array($role, array( 'status', 'alert' ), true) ) {
+ return true;
+ }
+
+ $tokens = strtolower(trim($this->attr($element, 'id') . ' ' . $this->attr($element, 'class') . ' ' . $this->attr($element, 'aria-live')));
+ return (bool) preg_match('/(?:^|[^a-z0-9])(?:success|sent|submitted|thank|thanks|confirmation|confirmed)(?:[^a-z0-9]|$)/', $tokens);
+ }
+
/**
* Whether a non-