diff --git a/php-transformer/CHANGELOG.md b/php-transformer/CHANGELOG.md index 952cfccb..fbb73fcc 100644 --- a/php-transformer/CHANGELOG.md +++ b/php-transformer/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. Entries are ## [0.4.10] - 2026-07-28 ### Changed +- Convert strict two-pane media/text layouts to core/media-text - Preserve label value row geometry - Cover synthetic paragraph parity - Reset synthetic paragraph margins diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 1dab12f9..c230e645 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -66,6 +66,8 @@ "php tests/unit/background-image-extractor.php", "php tests/unit/cover-block-factory.php", "php tests/unit/cover-pattern.php", + "php tests/unit/media-text-block-factory.php", + "php tests/unit/media-text-pattern.php", "php tests/unit/cover-style-resolver.php", "php tests/unit/css-stylesheet-transformer.php", "php tests/unit/css-selector-matcher.php", diff --git a/php-transformer/docs/html-transform-coverage.md b/php-transformer/docs/html-transform-coverage.md index cb712656..604465fc 100644 --- a/php-transformer/docs/html-transform-coverage.md +++ b/php-transformer/docs/html-transform-coverage.md @@ -15,6 +15,7 @@ Run the coverage fixtures with `composer parity` or as part of `composer test`. | Code | `html-core-text-structure.json` | `core/code`, `core/preformatted` | | Tables | `html-core-media-actions.json` | `core/table` with head/body/caption attrs | | Images | `html-core-media-actions.json`, `html-figure-quote-media.json` | `core/image` with URL, alt, dimensions, caption, identity, size, and class attrs | +| Media and text | `html-media-text.json` | `core/media-text` for strict two-pane image/video and text layouts with an authored horizontal mechanism (`display:flex`/`grid`, a usable grid template, or round-trip `wp-block-media-text` markup); matched `section`/`article` containers emit the block's canonical `div` wrapper; gates fail closed — mechanism-less containers, floated panes, unresolvable `var()` layout values, inherited RTL, and grid templates that cannot express a `mediaWidth` all decline into existing columns/group/author-layout handling | | Buttons | `html-core-media-actions.json` | `core/buttons` containing `core/button` children | | Shortcodes | `html-core-media-actions.json` | `core/shortcode` for standalone shortcode text | | Wrapper provenance and safety | `html-provenance-wrapper-safety.json` | Presentational semantic wrappers are preserved as `core/group`; unsupported fallback records include selector/source metadata and sanitized fallback HTML | @@ -28,7 +29,7 @@ Run the coverage fixtures with `composer parity` or as part of `composer test`. | Category | Status | Notes | | --- | --- | --- | -| Supported | Heading, paragraph, unordered/ordered list, quote, pullquote, code, preformatted, table, image, buttons/button, shortcode | Fixtures assert the block names and representative attrs currently emitted by `HtmlTransformer`. | +| Supported | Heading, paragraph, unordered/ordered list, quote, pullquote, code, preformatted, table, image, media-text, buttons/button, shortcode | Fixtures assert the block names and representative attrs currently emitted by `HtmlTransformer`. Media-text requires exactly two element children: one pure image/video side and one text-bearing side; ambiguous layouts retain existing columns/group behavior. | | Unsupported fallback | Unknown/custom elements, SVG markup, form controls, other unsupported top-level HTML | Fallbacks use `type: unsupported_element`, include the source tag, selector, caller source/scope when provided, sanitized HTML, and increment `coverage.0.fallback_count`. | | Context-required | Interactive/form behavior, embeds, advanced layout semantics, raw-handler hooks | These require WordPress/Gutenberg runtime context or richer product converter behavior and remain outside the PHP transformer's supported slice. | | Gutenberg editor validation | Gap | The repository has no browser harness that boots Gutenberg, registers generated companion blocks, and validates load/edit/save output. `wp_block_validity` is a PHP structural and canonical save-shape check; the WordPress integration test and Playwright visual-parity tooling do not exercise the editor. | diff --git a/php-transformer/src/CorpusDiagnostics/CorpusDetectors.php b/php-transformer/src/CorpusDiagnostics/CorpusDetectors.php index 39698804..c72666b1 100644 --- a/php-transformer/src/CorpusDiagnostics/CorpusDetectors.php +++ b/php-transformer/src/CorpusDiagnostics/CorpusDetectors.php @@ -4,8 +4,11 @@ namespace Automattic\BlocksEngine\PhpTransformer\CorpusDiagnostics; use Automattic\BlocksEngine\PhpTransformer\Contract\ConversionFindingContract; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\CoverPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\CoverStyleResolver; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssSelectorMatcher; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter; /** * Pure, read-only detectors that turn a transformer result envelope into a flat @@ -105,6 +108,7 @@ public static function collect(array $result, string $sourceHtml = '', ?callable $svgLost = self::svgContentLost($result, $flat); $layoutMisrecognition = self::layoutDirectionMisrecognition($sourceHtml, $columnsVerifier); $coverGateRejections = self::coverGateRejections($sourceHtml, $flat); + $mediaTextMetrics = self::mediaTextMetrics($sourceHtml, $flat); $findings = array(); foreach ( self::transformerFindings($result) as $finding ) { @@ -155,6 +159,9 @@ public static function collect(array $result, string $sourceHtml = '', ?callable 'svg_content_lost_count' => count($svgLost), 'layout_direction_misrecognition_count' => count($layoutMisrecognition), ); + foreach ( $mediaTextMetrics as $name => $value ) { + $metrics[ $name ] = $value; + } return array( 'metrics' => $metrics, @@ -251,6 +258,222 @@ public static function nativeRate(array $flat): array ); } + /** + * Count emitted core/media-text blocks and source-derived outcomes for + * strict two-pane candidates. A candidate has exactly two direct element + * children and exactly one img/video across those two sides. + * + * Outcome counters are exclusive. width_oob is intentionally not a decline: + * MediaTextPattern still emits the block and merely omits an out-of-bounds + * mediaWidth attribute. + * + * @param string $sourceHtml Original source HTML for the document. + * @param array> $flat Flattened emitted block list. + * @return array{ + * media_text_count: int, + * media_text_decline_media_impure_count: int, + * media_text_decline_no_text_side_count: int, + * media_text_decline_vertical_or_reversed_count: int, + * media_text_decline_unsafe_url_count: int, + * media_text_width_oob_count: int, + * media_text_decline_linked_video_count: int, + * media_text_decline_other_count: int, + * media_text_diagnostic_error_count: int + * } + */ + private static function mediaTextMetrics(string $sourceHtml, array $flat): array + { + $metrics = array( + 'media_text_count' => 0, + 'media_text_decline_media_impure_count' => 0, + 'media_text_decline_no_text_side_count' => 0, + 'media_text_decline_vertical_or_reversed_count' => 0, + 'media_text_decline_unsafe_url_count' => 0, + 'media_text_width_oob_count' => 0, + 'media_text_decline_linked_video_count' => 0, + 'media_text_decline_other_count' => 0, + 'media_text_diagnostic_error_count' => 0, + ); + + foreach ( $flat as $block ) { + if ( 'core/media-text' === ($block['blockName'] ?? null) ) { + ++$metrics['media_text_count']; + } + } + + if ( '' === trim($sourceHtml) ) { + return $metrics; + } + + $previous = libxml_use_internal_errors(true); + $doc = new \DOMDocument(); + $loaded = $doc->loadHTML('' . $sourceHtml); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if ( ! $loaded ) { + return $metrics; + } + + $xpath = new \DOMXPath($doc); + $nodes = $xpath->query('//body//*'); + if ( false === $nodes ) { + return $metrics; + } + $textTransformer = null; + $sourceStyleMarkup = ''; + $sourceCss = ''; + foreach ( $doc->getElementsByTagName('style') as $styleElement ) { + $styleCss = (string) $styleElement->textContent; + $sourceCss .= ( '' === $sourceCss ? '' : "\n" ) . $styleCss; + $styleMarkup = $doc->saveHTML($styleElement); + if ( is_string($styleMarkup) ) { + $sourceStyleMarkup .= $styleMarkup; + } + } + $styleRules = self::mediaTextStaticStyleRules($sourceCss); + if ( null === $styleRules ) { + // A PCRE failure erased the CSS cascade; gate outcomes computed + // without it would be fabrications, not approximations. + ++$metrics['media_text_diagnostic_error_count']; + return $metrics; + } + + $candidates = array(); + foreach ( $nodes as $node ) { + if ( ! $node instanceof \DOMElement ) { + continue; + } + + $children = self::directElementChildren($node); + if ( 2 !== count($children) ) { + continue; + } + + $mediaCounts = array( + self::mediaElementCount($children[0]), + self::mediaElementCount($children[1]), + ); + if ( 1 !== $mediaCounts[0] + $mediaCounts[1] ) { + continue; + } + + $candidates[] = array( + 'node' => $node, + 'children' => $children, + 'mediaIndex' => 1 === $mediaCounts[0] ? 0 : 1, + ); + } + + // A wrapper whose descendant is itself a candidate is not a two-pane + // candidate — evaluating it fabricates declines for markup that + // converts through the descendant. + $candidates = array_values(array_filter( + $candidates, + static function (array $candidate) use ($candidates): bool { + foreach ( $candidates as $other ) { + if ( $other['node'] === $candidate['node'] ) { + continue; + } + for ( $ancestor = $other['node']->parentNode; $ancestor instanceof \DOMElement; $ancestor = $ancestor->parentNode ) { + if ( $ancestor === $candidate['node'] ) { + return false; + } + } + } + + return true; + } + )); + + $sourcePasserCount = 0; + $widthOobCandidateCount = 0; + $directionCache = array(); + foreach ( $candidates as $candidate ) { + $node = $candidate['node']; + $children = $candidate['children']; + $mediaIndex = $candidate['mediaIndex']; + $textIndex = 0 === $mediaIndex ? 1 : 0; + + if ( self::hasNonIgnorableDirectNodes($node) ) { + ++$metrics['media_text_decline_other_count']; + continue; + } + + $resolution = self::diagnosticPureMediaResolution($children[ $mediaIndex ]); + if ( null === $resolution ) { + ++$metrics['media_text_decline_media_impure_count']; + continue; + } + + $media = $resolution['media']; + if ( 'video' === strtolower($media->tagName) && $resolution['anchor'] instanceof \DOMElement ) { + ++$metrics['media_text_decline_linked_video_count']; + continue; + } + + $containerStyle = self::mediaTextResolvedDeclarations( + $node, + $styleRules, + array( 'display', 'flex-direction', 'direction', 'grid-template-columns' ) + ); + $childStyles = array( + self::mediaTextResolvedDeclarations($children[0], $styleRules, array( 'order', 'flex-basis', 'width' )), + self::mediaTextResolvedDeclarations($children[1], $styleRules, array( 'order', 'flex-basis', 'width' )), + ); + if ( + self::mediaTextHasVerticalOrReversedLayout($containerStyle, $childStyles) + || self::diagnosticInheritedRtlBlocks($node, $styleRules, $directionCache) + ) { + ++$metrics['media_text_decline_vertical_or_reversed_count']; + continue; + } + + if ( '' === self::diagnosticSafeMediaUrl($media->getAttribute('src')) ) { + ++$metrics['media_text_decline_unsafe_url_count']; + continue; + } + + $textBearing = self::mediaTextSideHasTextBearingBlock( + $doc, + $children[ $textIndex ], + $sourceStyleMarkup, + $textTransformer + ); + if ( false === $textBearing ) { + ++$metrics['media_text_decline_no_text_side_count']; + continue; + } + if ( null === $textBearing ) { + // A crash inside the isolated text-side transform is a + // diagnostic failure, not a conversion decline. + ++$metrics['media_text_diagnostic_error_count']; + continue; + } + + ++$sourcePasserCount; + $mediaWidth = self::diagnosticMediaWidth($containerStyle, $childStyles[ $mediaIndex ], $mediaIndex); + if ( null !== $mediaWidth && ( 15 > $mediaWidth || 85 < $mediaWidth ) ) { + ++$widthOobCandidateCount; + } + } + + // Source-only gates cannot expose transform-time failures. Approximate + // `other` as otherwise-eligible candidates that did not emit, at document + // granularity; emitted blocks consume source passers, never known declines. + // The width diagnostic is capped by emitted adoption so it cannot also be + // counted as an `other` decline for the same document-level candidate. + $metrics['media_text_width_oob_count'] = min( + $widthOobCandidateCount, + $metrics['media_text_count'] + ); + $metrics['media_text_decline_other_count'] += max( + 0, + $sourcePasserCount - $metrics['media_text_count'] + ); + + return $metrics; + } + /** * var(--x) references in the emitted block markup. * @@ -773,6 +996,522 @@ public static function clusterKey(array $finding): string return $bucket . ' :: ' . $pattern; } + /** + * Keep only top-level author rules, matching HtmlTransformer's strict-gate + * cascade. Conditional and other at-rules remain available to the isolated + * text-side transform through the separately preserved source markup. + */ + private static function mediaTextTopLevelCss(string $css): string + { + $css = preg_replace('@/\*.*?\*/@s', '', $css) ?? $css; + $output = ''; + $length = strlen($css); + $depth = 0; + + for ( $offset = 0; $offset < $length; ++$offset ) { + $char = $css[ $offset ]; + if ( '"' === $char || "'" === $char ) { + $output .= $char; + for ( ++$offset; $offset < $length; ++$offset ) { + $output .= $css[ $offset ]; + if ( '\\' === $css[ $offset ] && $offset + 1 < $length ) { + $output .= $css[ ++$offset ]; + continue; + } + if ( $char === $css[ $offset ] ) { + break; + } + } + continue; + } + + if ( 0 !== $depth || '@' !== $char ) { + if ( '{' === $char ) { + ++$depth; + } elseif ( '}' === $char && 0 < $depth ) { + --$depth; + } + $output .= $char; + continue; + } + + // One forward scan for whichever terminator comes first. Two + // independent scans go quadratic when the other token is absent — + // each @ re-scans to end-of-css. + $terminator = self::mediaTextCssFirstToken($css, $offset); + if ( null === $terminator ) { + break; + } + if ( ';' === $terminator['token'] ) { + $offset = $terminator['position']; + continue; + } + $blockStart = $terminator['position']; + + $atRuleDepth = 1; + for ( $inner = $blockStart + 1; $inner < $length; ++$inner ) { + if ( '"' === $css[ $inner ] || "'" === $css[ $inner ] ) { + $quote = $css[ $inner ]; + for ( ++$inner; $inner < $length; ++$inner ) { + if ( '\\' === $css[ $inner ] ) { + ++$inner; + continue; + } + if ( $quote === $css[ $inner ] ) { + break; + } + } + continue; + } + if ( '{' === $css[ $inner ] ) { + ++$atRuleDepth; + } elseif ( '}' === $css[ $inner ] && 0 === --$atRuleDepth ) { + $offset = $inner; + continue 2; + } + } + break; + } + + return $output; + } + + /** + * Parse production-equivalent ordered static rules for media-text gate + * properties. Dynamic pseudo-state rules never affect resting strict gates. + * + * Returns null when PCRE itself fails — an empty ruleset means "no rules", + * which callers must not conflate with "could not read the rules". + * + * @return array, declarations: array}>|null + */ + private static function mediaTextStaticStyleRules(string $css): ?array + { + $css = self::mediaTextTopLevelCss($css); + $ruleCount = preg_match_all('/([^{}]+)\{([^{}]+)\}/', $css, $matches, PREG_SET_ORDER); + if ( false === $ruleCount ) { + return null; + } + if ( 0 === $ruleCount ) { + return array(); + } + + $requested = array_flip(array( + 'display', + 'flex-direction', + 'direction', + 'grid-template-columns', + 'order', + 'flex-basis', + 'width', + )); + $rules = array(); + foreach ( $matches as $match ) { + $declarations = array_intersect_key( + self::mediaTextCssDeclarations((string) $match[2]), + $requested + ); + if ( array() === $declarations ) { + continue; + } + foreach ( explode(',', (string) $match[1]) as $selectorSource ) { + $selectorSource = trim($selectorSource); + if ( + '' === $selectorSource + || preg_match('/:{1,2}(?:hover|focus-visible|focus-within|focus|active|visited|before|after)\b/i', $selectorSource) + ) { + continue; + } + $selector = CssSelectorMatcher::parse($selectorSource); + if ( $selector['supported'] ?? false ) { + $rules[] = array( + 'selector' => $selector, + 'declarations' => $declarations, + ); + } + } + } + + return $rules; + } + + /** + * Merge matching rules in source order, then inline declarations, exactly as + * StyleResolutionTrait::structuralPresentationDeclarations(). + * + * @param array, declarations: array}> $rules + * @param array $requested + * @return array + */ + private static function mediaTextResolvedDeclarations(\DOMElement $element, array $rules, array $requested): array + { + $declarations = array(); + foreach ( $rules as $rule ) { + $match = CssSelectorMatcher::matches($element, $rule['selector']); + if ( $match['supported'] && $match['matches'] ) { + $declarations = array_merge($declarations, $rule['declarations']); + } + } + $declarations = array_merge( + $declarations, + self::mediaTextCssDeclarations($element->getAttribute('style')) + ); + + return array_intersect_key($declarations, array_flip($requested)); + } + + /** @return array */ + private static function mediaTextCssDeclarations(string $style): array + { + $declarations = array(); + foreach ( CssValueSplitter::splitTopLevel($style, array( ';' )) as $declaration ) { + if ( ! str_contains($declaration, ':') ) { + continue; + } + [$name, $value] = array_map('trim', explode(':', $declaration, 2)); + $name = strtolower($name); + $value = preg_replace('/\s+/', ' ', $value) ?? $value; + $allowsImageUrl = in_array($name, array( 'background', 'background-image' ), true) + && ! preg_match('/(?:expression\s*\(|javascript\s*:)/i', $value); + if ( + '' !== $name + && '' !== $value + && ( $allowsImageUrl || ! preg_match('/(?:expression\s*\(|javascript\s*:|url\s*\()/i', $value) ) + ) { + $declarations[ $name ] = $value; + } + } + + return $declarations; + } + + /** + * First unquoted `{` or `;` at or after the offset, in one forward scan. + * + * @return array{token: string, position: int}|null + */ + private static function mediaTextCssFirstToken(string $css, int $offset): ?array + { + $length = strlen($css); + for ( ; $offset < $length; ++$offset ) { + if ( '"' === $css[ $offset ] || "'" === $css[ $offset ] ) { + $quote = $css[ $offset ]; + for ( ++$offset; $offset < $length; ++$offset ) { + if ( '\\' === $css[ $offset ] ) { + ++$offset; + continue; + } + if ( $quote === $css[ $offset ] ) { + break; + } + } + continue; + } + if ( '{' === $css[ $offset ] || ';' === $css[ $offset ] ) { + return array( + 'token' => $css[ $offset ], + 'position' => $offset, + ); + } + } + + return null; + } + + /** + * Convert the candidate text side through the production transformer, then + * apply the same recursive block-name test as PatternGateHelpersTrait. + * Embedded source styles are retained so selector-driven conversion stays + * as close as possible to the full-document path. + */ + private static function mediaTextSideHasTextBearingBlock( + \DOMDocument $doc, + \DOMElement $textSide, + string $sourceStyleMarkup, + ?HtmlTransformer &$transformer + ): ?bool { + $fragment = $doc->saveHTML($textSide); + if ( ! is_string($fragment) ) { + return null; + } + + $transformer ??= new HtmlTransformer(); + try { + $result = $transformer->transform( + '' . $sourceStyleMarkup . '' . $fragment . '', + array() + )->toArray(); + } catch ( \Throwable ) { + return null; + } + + $blocks = is_array($result['blocks'] ?? null) ? $result['blocks'] : array(); + $textBearingNames = array( 'core/heading', 'core/paragraph', 'core/list', 'core/buttons', 'core/quote' ); + foreach ( self::flatten($blocks) as $block ) { + if ( in_array($block['blockName'] ?? null, $textBearingNames, true) ) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private static function directElementChildren(\DOMElement $element): array + { + $children = array(); + foreach ( $element->childNodes as $child ) { + if ( $child instanceof \DOMElement ) { + $children[] = $child; + } + } + + return $children; + } + + private static function hasNonIgnorableDirectNodes(\DOMElement $element): bool + { + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType || $child instanceof \DOMElement ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) { + continue; + } + + return true; + } + + return false; + } + + private static function mediaElementCount(\DOMElement $element): int + { + return (in_array(strtolower($element->tagName), array( 'img', 'video' ), true) ? 1 : 0) + + $element->getElementsByTagName('img')->length + + $element->getElementsByTagName('video')->length; + } + + /** + * @return array{media: \DOMElement, anchor: \DOMElement|null}|null + */ + private static function diagnosticPureMediaResolution(\DOMElement $element, ?\DOMElement $anchor = null): ?array + { + $tagName = strtolower($element->tagName); + if ( in_array($tagName, array( 'img', 'video' ), true) ) { + if ( array() !== self::directElementChildren($element) || self::hasNonIgnorableDirectNodes($element) ) { + return null; + } + + return array( + 'media' => $element, + 'anchor' => $anchor, + ); + } + + if ( ! in_array($tagName, array( 'figure', 'div', 'a', 'picture' ), true) ) { + return null; + } + if ( 'a' === $tagName ) { + if ( $anchor instanceof \DOMElement ) { + return null; + } + $anchor = $element; + } + + if ( 'picture' === $tagName ) { + $image = null; + foreach ( $element->getElementsByTagName('*') as $descendant ) { + $descendantTag = strtolower($descendant->tagName); + if ( 'source' === $descendantTag ) { + continue; + } + if ( 'img' !== $descendantTag || $image instanceof \DOMElement ) { + return null; + } + $image = $descendant; + } + if ( ! $image instanceof \DOMElement || '' !== trim($element->textContent ?? '') ) { + return null; + } + + return array( 'media' => $image, 'anchor' => $anchor ); + } + + $children = self::directElementChildren($element); + if ( 1 !== count($children) || self::hasNonIgnorableDirectNodes($element) ) { + return null; + } + + return self::diagnosticPureMediaResolution($children[0], $anchor); + } + + /** + * Mirror the production inherited-direction gate: nearest ancestor (self + * included) with an explicit CSS `direction` or `dir` attribute wins; + * `dir="auto"` fails closed. + * + * Memoized by node path — candidates share ancestor chains, and each + * unmemoized level re-scans the whole ruleset. Node paths are stable keys + * here because the diagnostics DOM is never mutated. + * + * @param array, declarations: array}> $styleRules + * @param array $cache + */ + private static function diagnosticInheritedRtlBlocks(\DOMElement $element, array $styleRules, array &$cache): bool + { + $chain = array(); + $result = null; + for ( $node = $element; $node instanceof \DOMElement; $node = $node->parentNode ) { + $path = (string) $node->getNodePath(); + if ( array_key_exists($path, $cache) ) { + $result = $cache[ $path ]; + break; + } + $chain[] = $path; + + $declarations = self::mediaTextResolvedDeclarations($node, $styleRules, array( 'direction' )); + $direction = strtolower(self::mediaTextCssValue((string) ($declarations['direction'] ?? ''))); + if ( in_array($direction, array( 'ltr', 'rtl' ), true) ) { + $result = 'rtl' === $direction; + break; + } + + $dir = strtolower(trim($node->getAttribute('dir'))); + if ( 'auto' === $dir ) { + $result = true; + break; + } + if ( in_array($dir, array( 'ltr', 'rtl' ), true) ) { + $result = 'rtl' === $dir; + break; + } + } + + $result ??= false; + foreach ( $chain as $path ) { + $cache[ $path ] = $result; + } + + return $result; + } + + private static function diagnosticSafeMediaUrl(string $url): string + { + $url = trim($url); + if ( '' === $url || preg_match('/[\x00-\x1f\x7f]/', $url) ) { + return ''; + } + if ( str_starts_with($url, '//') || ! preg_match('/^([a-z][a-z0-9+.-]*)\s*:/i', $url, $matches) ) { + return $url; + } + + $scheme = strtolower($matches[1]); + if ( in_array($scheme, array( 'http', 'https' ), true) && preg_match('/^' . preg_quote($scheme, '/') . ':/i', $url) ) { + return $url; + } + if ( 'data' === $scheme && preg_match('/^data:image\/[a-z0-9.+-]+(?:[;,])/i', $url) ) { + return $url; + } + + return ''; + } + + /** + * @param array $containerStyle + * @param array> $childStyles + */ + private static function mediaTextHasVerticalOrReversedLayout(array $containerStyle, array $childStyles): bool + { + $display = strtolower(self::mediaTextCssValue((string) ($containerStyle['display'] ?? ''))); + $flexDirection = strtolower(self::mediaTextCssValue((string) ($containerStyle['flex-direction'] ?? ''))); + $direction = strtolower(self::mediaTextCssValue((string) ($containerStyle['direction'] ?? ''))); + if ( + ( 'flex' === $display && in_array($flexDirection, array( 'column', 'column-reverse', 'row-reverse' ), true) ) + || 'rtl' === $direction + ) { + return true; + } + + foreach ( $childStyles as $style ) { + if ( array_key_exists('order', $style) ) { + return true; + } + } + + return false; + } + + /** + * @param array $containerStyle + * @param array $mediaStyle + */ + private static function diagnosticMediaWidth(array $containerStyle, array $mediaStyle, int $mediaIndex): ?int + { + $display = strtolower(self::mediaTextCssValue((string) ($containerStyle['display'] ?? ''))); + $template = self::mediaTextCssValue((string) ($containerStyle['grid-template-columns'] ?? '')); + if ( 'grid' === $display && '' !== $template ) { + return self::diagnosticGridMediaWidth($template, $mediaIndex); + } + + foreach ( array( 'flex-basis', 'width' ) as $property ) { + $width = self::diagnosticPercentage((string) ($mediaStyle[ $property ] ?? '')); + if ( null !== $width ) { + return $width; + } + } + + return null; + } + + private static function diagnosticGridMediaWidth(string $template, int $mediaIndex): ?int + { + $tracks = CssValueSplitter::splitTopLevelWhitespace($template); + if ( 2 !== count($tracks) ) { + return null; + } + + $percentage = self::diagnosticPercentage($tracks[ $mediaIndex ]); + if ( null !== $percentage ) { + return $percentage; + } + + $firstFr = self::diagnosticFrValue($tracks[0]); + $secondFr = self::diagnosticFrValue($tracks[1]); + if ( null === $firstFr || null === $secondFr || 0.0 >= $firstFr + $secondFr || ! is_finite($firstFr + $secondFr) ) { + return null; + } + + return (int) round(100 * ( 0 === $mediaIndex ? $firstFr : $secondFr ) / ($firstFr + $secondFr)); + } + + private static function diagnosticPercentage(string $value): ?int + { + $value = self::mediaTextCssValue($value); + if ( ! preg_match('/^-?(?:\d+(?:\.\d*)?|\.\d+)%$/', $value) ) { + return null; + } + + return (int) round((float) rtrim($value, '%')); + } + + private static function diagnosticFrValue(string $value): ?float + { + $value = self::mediaTextCssValue($value); + if ( ! preg_match('/^(?:\d+(?:\.\d*)?|\.\d+)fr$/i', $value) ) { + return null; + } + + return (float) substr($value, 0, -2); + } + + private static function mediaTextCssValue(string $value): string + { + return trim(preg_replace('/\s*!\s*important\s*$/i', '', $value) ?? $value); + } + /** * Whether the raw content of a core/html block carries an SVG remnant/marker * — either a literal tag or an HTML comment that names svg (the diff --git a/php-transformer/src/CorpusDiagnostics/CorpusDiagnosticsRunner.php b/php-transformer/src/CorpusDiagnostics/CorpusDiagnosticsRunner.php index f20d914c..7e3411a2 100644 --- a/php-transformer/src/CorpusDiagnostics/CorpusDiagnosticsRunner.php +++ b/php-transformer/src/CorpusDiagnostics/CorpusDiagnosticsRunner.php @@ -50,6 +50,15 @@ public function run(string $corpusDir): array 'layout_direction_misrecognition_count' => 0, 'var_ref_count' => 0, 'var_custom_ref_count' => 0, + 'media_text_count' => 0, + 'media_text_decline_media_impure_count' => 0, + 'media_text_decline_no_text_side_count' => 0, + 'media_text_decline_vertical_or_reversed_count' => 0, + 'media_text_decline_unsafe_url_count' => 0, + 'media_text_width_oob_count' => 0, + 'media_text_decline_linked_video_count' => 0, + 'media_text_decline_other_count' => 0, + 'media_text_diagnostic_error_count' => 0, 'finding_count' => 0, ); @@ -81,6 +90,19 @@ public function run(string $corpusDir): array $totals['layout_direction_misrecognition_count'] += (int) $metrics['layout_direction_misrecognition_count']; $totals['var_ref_count'] += (int) $metrics['var_ref_count']; $totals['var_custom_ref_count'] += (int) $metrics['var_custom_ref_count']; + foreach ( array( + 'media_text_count', + 'media_text_decline_media_impure_count', + 'media_text_decline_no_text_side_count', + 'media_text_decline_vertical_or_reversed_count', + 'media_text_decline_unsafe_url_count', + 'media_text_width_oob_count', + 'media_text_decline_linked_video_count', + 'media_text_decline_other_count', + 'media_text_diagnostic_error_count', + ) as $metricName ) { + $totals[ $metricName ] += (int) $metrics[ $metricName ]; + } foreach ( $collected['findings'] as $finding ) { $key = CorpusDetectors::clusterKey($finding); @@ -150,6 +172,18 @@ public function renderSummary(array $report, int $limit = 25): string (int) ($totals['svg_content_lost_count'] ?? 0), (int) ($totals['layout_direction_misrecognition_count'] ?? 0) ); + $lines[] = sprintf( + 'MEDIA-TEXT: media_text_count=%d media_text_decline_media_impure_count=%d media_text_decline_no_text_side_count=%d media_text_decline_vertical_or_reversed_count=%d media_text_decline_unsafe_url_count=%d media_text_width_oob_count=%d media_text_decline_linked_video_count=%d media_text_decline_other_count=%d media_text_diagnostic_error_count=%d', + (int) ($totals['media_text_count'] ?? 0), + (int) ($totals['media_text_decline_media_impure_count'] ?? 0), + (int) ($totals['media_text_decline_no_text_side_count'] ?? 0), + (int) ($totals['media_text_decline_vertical_or_reversed_count'] ?? 0), + (int) ($totals['media_text_decline_unsafe_url_count'] ?? 0), + (int) ($totals['media_text_width_oob_count'] ?? 0), + (int) ($totals['media_text_decline_linked_video_count'] ?? 0), + (int) ($totals['media_text_decline_other_count'] ?? 0), + (int) ($totals['media_text_diagnostic_error_count'] ?? 0) + ); $lines[] = sprintf( 'INFORMATIONAL var density (materialized downstream by SSI — not a repair gap): var_refs=%d (custom=%d)', (int) $totals['var_ref_count'], @@ -331,4 +365,5 @@ private function columnsVerifier(): callable return is_array($first) && 'core/columns' === ($first['blockName'] ?? ''); }; } + } diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index 4e67c8c7..a3226c9d 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -66,7 +66,7 @@ private function normalizeAttrsForBlock(string $name, array $attrs): array // These core save functions do not reproduce dimensions.maxWidth. Inline // max-width is retained by the generated geometry carrier stylesheet. - if ( in_array($name, array( 'core/group', 'core/column', 'core/columns', 'core/image', 'core/list-item', 'core/paragraph', 'core/separator' ), true) ) { + if ( in_array($name, array( 'core/group', 'core/column', 'core/columns', 'core/image', 'core/list-item', 'core/media-text', 'core/paragraph', 'core/separator' ), true) ) { unset($attrs['style']['dimensions']['maxWidth']); if ( empty($attrs['style']['dimensions']) ) { unset($attrs['style']['dimensions']); @@ -76,6 +76,23 @@ private function normalizeAttrsForBlock(string $name, array $attrs): array } } + if ( 'core/media-text' === $name ) { + $supportedStyleGroups = array( 'border', 'color', 'elements', 'spacing', 'typography' ); + if ( is_array($attrs['style'] ?? null) ) { + foreach ( array_keys($attrs['style']) as $styleGroup ) { + if ( ! in_array($styleGroup, $supportedStyleGroups, true) ) { + unset($attrs['style'][ $styleGroup ]); + } + } + if ( empty($attrs['style']) ) { + unset($attrs['style']); + } + } else { + unset($attrs['style']); + } + unset($attrs['inlineGeometryStyle']); + } + if ( 'core/separator' === $name ) { unset($attrs['style']['spacing']['margin']['left'], $attrs['style']['spacing']['margin']['right']); if ( empty($attrs['style']['spacing']['margin']) ) { @@ -89,7 +106,7 @@ private function normalizeAttrsForBlock(string $name, array $attrs): array } } - if ( in_array($name, array( 'core/buttons', 'core/column', 'core/columns', 'core/group', 'core/heading', 'core/list', 'core/list-item', 'core/paragraph' ), true) ) { + if ( in_array($name, array( 'core/buttons', 'core/column', 'core/columns', 'core/group', 'core/heading', 'core/list', 'core/list-item', 'core/media-text', 'core/paragraph' ), true) ) { unset($attrs['style']['spacing']['blockGap']); if ( empty($attrs['style']['spacing']) ) { unset($attrs['style']['spacing']); @@ -142,6 +159,20 @@ private function commentAttrs(string $name, array $attrs): array if ( 'core/cover' === $name && 'px' === ($attrs['minHeightUnit'] ?? null) ) { unset($attrs['minHeightUnit']); } + if ( 'core/media-text' === $name ) { + if ( '' === ($attrs['mediaAlt'] ?? null) ) { + unset($attrs['mediaAlt']); + } + if ( 'left' === ($attrs['mediaPosition'] ?? null) ) { + unset($attrs['mediaPosition']); + } + if ( is_numeric($attrs['mediaWidth'] ?? null) && 50.0 === (float) $attrs['mediaWidth'] ) { + unset($attrs['mediaWidth']); + } + if ( true === ($attrs['isStackedOnMobile'] ?? null) ) { + unset($attrs['isStackedOnMobile']); + } + } return $attrs; } @@ -338,6 +369,10 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri return $this->coverHtml($attrs, $innerBlocks); } + if ( 'core/media-text' === $name ) { + return $this->mediaTextHtml($attrs, $innerBlocks); + } + if ( 'core/group' === $name ) { $tag = $this->groupTagName($attrs['tagName'] ?? null); return array( 'opening' => '<' . $tag . $this->blockSupportAttrs($attrs, 'wp-block-group') . '>', 'closing' => '' ); @@ -424,6 +459,99 @@ private function coverHtml(array $attrs, array $innerBlocks): array ); } + /** + * @param array $attrs + * @param array> $innerBlocks + * @return array{opening: string, closing: string} + */ + private function mediaTextHtml(array $attrs, array $innerBlocks): array + { + unset($innerBlocks); + + $mediaOnRight = 'right' === ($attrs['mediaPosition'] ?? 'left'); + $verticalAlignment = (string) ($attrs['verticalAlignment'] ?? ''); + if ( ! in_array($verticalAlignment, array( 'top', 'center', 'bottom' ), true) ) { + $verticalAlignment = ''; + } + + $wrapperClasses = array( 'wp-block-media-text' ); + if ( $mediaOnRight ) { + $wrapperClasses[] = 'has-media-on-the-right'; + } + if ( ! array_key_exists('isStackedOnMobile', $attrs) || false !== $attrs['isStackedOnMobile'] ) { + $wrapperClasses[] = 'is-stacked-on-mobile'; + } + if ( '' !== $verticalAlignment ) { + $wrapperClasses[] = 'is-vertically-aligned-' . $verticalAlignment; + } + if ( ! empty($attrs['style']['elements']['link']['color']) ) { + $wrapperClasses[] = 'has-link-color'; + } + + $wrapperAttrs = $attrs; + $wrapperStyle = ''; + if ( is_numeric($attrs['mediaWidth'] ?? null) ) { + $mediaWidth = (int) round((float) $attrs['mediaWidth']); + if ( 50 !== $mediaWidth ) { + $gridTemplateColumns = $mediaOnRight + ? 'auto ' . (string) $mediaWidth . '%' + : (string) $mediaWidth . '% auto'; + $wrapperStyle = 'grid-template-columns:' . $gridTemplateColumns; + } + } + + $wrapperOpening = 'blockSupportAttrs($wrapperAttrs, implode(' ', $wrapperClasses), $wrapperStyle) . '>'; + $contentOpening = '
'; + $figure = '
' . $this->mediaTextMediaHtml($attrs) . '
'; + + if ( $mediaOnRight ) { + return array( + 'opening' => $wrapperOpening . $contentOpening, + 'closing' => '
' . $figure . '', + ); + } + + return array( + 'opening' => $wrapperOpening . $figure . $contentOpening, + 'closing' => '', + ); + } + + /** + * @param array $attrs + */ + private function mediaTextMediaHtml(array $attrs): string + { + $mediaUrl = (string) ($attrs['mediaUrl'] ?? ''); + if ( 'video' === ($attrs['mediaType'] ?? '') ) { + return ''; + } + + if ( 'image' !== ($attrs['mediaType'] ?? '') ) { + return ''; + } + + $image = ''; + if ( '' !== $mediaUrl ) { + $image = 'htmlAttrs(array( + 'src' => $mediaUrl, + 'alt' => (string) ($attrs['mediaAlt'] ?? ''), + ), array( 'alt' )) . '/>'; + } + + $href = (string) ($attrs['href'] ?? ''); + if ( '' === $href ) { + return $image; + } + + return 'htmlAttrs(array( + 'class' => (string) ($attrs['linkClass'] ?? ''), + 'href' => $href, + 'target' => (string) ($attrs['linkTarget'] ?? ''), + 'rel' => (string) ($attrs['rel'] ?? ''), + )) . '>' . $image . ''; + } + /** * Resolve the wrapper tag for a `core/group`. Core's group `save()` renders * `` from the `tagName` attribute, defaulting to `div`. Only the @@ -803,14 +931,19 @@ private function mergeClassNames(string ...$classNames): string /** * @param array $attrs */ - private function blockSupportAttrs(array $attrs, string $baseClass = ''): string + private function blockSupportAttrs(array $attrs, string $baseClass = '', ?string $styleOverride = null): string { $support = $this->styleSupport($attrs['style'] ?? null); $presetClasses = $this->presetColorClasses($attrs); $layoutClasses = $this->layoutClasses($attrs['layout'] ?? null, $baseClass); $alignmentClasses = $this->textAlignmentClasses($attrs); $classes = $this->mergeClassNames($baseClass, $presetClasses, $support['classes'], $layoutClasses, $alignmentClasses, (string) ($attrs['className'] ?? '')); - $style = trim((string) $support['style'] . ';' . (string) ($attrs['inlineGeometryStyle'] ?? ''), ';'); + $style = trim( + (string) $support['style'] . ';' . (null === $styleOverride + ? (string) ($attrs['inlineGeometryStyle'] ?? '') + : $styleOverride), + ';' + ); return $this->htmlAttrs(array( 'id' => (string) ($attrs['anchor'] ?? ''), 'class' => $classes, diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 7180c0d3..5acd399e 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -20,6 +20,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\GalleryPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\LogoPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\MathPattern; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\MediaTextPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\NavigationPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\NavigationUnderlineColorResolver; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\ParameterTablePattern; @@ -166,6 +167,8 @@ final class HtmlTransformer private readonly CoverPattern $coverPattern; + private readonly MediaTextPattern $mediaTextPattern; + private readonly DetailsPattern $detailsPattern; private readonly GalleryPattern $galleryPattern; @@ -490,6 +493,7 @@ public function __construct(private readonly Runtime $runtime = new Runtime()) $this->codeWindowPattern = new CodeWindowPattern(); $this->columnsPattern = new ColumnsPattern(); $this->coverPattern = new CoverPattern(); + $this->mediaTextPattern = new MediaTextPattern(); $this->detailsPattern = new DetailsPattern(); $this->galleryPattern = new GalleryPattern(); $this->logoPattern = new LogoPattern(); @@ -2884,6 +2888,28 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return $this->authorLayoutBlockFromElement($element, $fallbacks); } + if ( in_array($tagName, array( 'div', 'section', 'article' ), true) ) { + // A strict two-pane media/text candidate is a more specific + // recognition than generic author-owned layout preservation: + // media-text candidates are by definition authored flex/grid + // containers, so they must be recognized before the layout is + // demoted to a css-owned core/group. + $mediaText = $this->mediaTextPattern->match( + $element, + $fallbacks, + fn (DOMElement $sourceElement, array &$sourceFallbacks, bool $captureUnsupported): array => $this->convertChildren($sourceElement, $sourceFallbacks, $captureUnsupported), + fn (DOMElement $sourceElement, array &$sourceFallbacks, bool $captureUnsupported): ?array => $this->convertElement($sourceElement, $sourceFallbacks, $captureUnsupported), + fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->mediaTextPresentationAttributes($sourceElement, $excludedGeometryProperties), + fn (DOMElement $sourceElement): string => $this->mediaTextPresentationStyle($sourceElement), + fn (DOMElement $sourceElement): array => $this->htmlAttributes($sourceElement), + fn (string $url): string => $this->resolvedAssetImageUrl($url), + fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) + ); + if ( null !== $mediaText ) { + return $mediaText; + } + } + if ( 'button' !== strtolower($this->attr($element, 'role')) && ! $this->hasClass($element, 'wp-block-columns') && $this->isAuthorOwnedLayout($element) @@ -2974,6 +3000,10 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( null !== $cover ) { return $cover; } + + // core/media-text is dispatched earlier in this method, before + // author-owned layout preservation — its candidates are by + // definition authored flex/grid containers. } $columns = $this->columnsPattern->match( diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php index 7c120311..fd09de41 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php @@ -28,6 +28,7 @@ final class ColumnsPattern { use PatternDomHelpersTrait; + use PatternGateHelpersTrait; /** * @param array> $fallbacks @@ -148,21 +149,6 @@ private function looksLikeColumnsContainer(DOMElement $element, string $resolved || preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex/', $inlineStyle); } - /** - * True when the resolved style declares a flex container whose main axis is - * vertical (flex-direction: column / column-reverse). flex-direction only has - * meaning on a flex container, so both display:flex and the column direction - * are required before redirecting away from horizontal columns. - */ - private function isVerticalFlexContainer(string $style): bool - { - if ( ! preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex\b/', $style) ) { - return false; - } - - return (bool) preg_match('/(?:^|;)\s*flex-direction\s*:\s*column(?:-reverse)?\b/', $style); - } - private function looksLikeSplitLayout(DOMElement $element): bool { $name = strtolower(trim($this->attr($element, 'class') . ' ' . $this->attr($element, 'id'))); diff --git a/php-transformer/src/HtmlToBlocks/Patterns/CoverPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/CoverPattern.php index 7179173a..70057d7e 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/CoverPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/CoverPattern.php @@ -29,6 +29,8 @@ */ final class CoverPattern { + use PatternGateHelpersTrait; + private CoverStyleResolver $styleResolver; private BackgroundImageExtractor $backgroundImageExtractor; @@ -245,31 +247,6 @@ private function directElementChildCount(DOMElement $element): int return $count; } - /** - * @param array> $blocks - */ - private function containsTextBearingBlock(array $blocks): bool - { - $textBearingNames = array( 'core/heading', 'core/paragraph', 'core/list', 'core/buttons', 'core/quote' ); - - foreach ( $blocks as $block ) { - if ( ! is_array($block) ) { - continue; - } - - if ( in_array($block['blockName'] ?? null, $textBearingNames, true) ) { - return true; - } - - $innerBlocks = $block['innerBlocks'] ?? array(); - if ( is_array($innerBlocks) && $this->containsTextBearingBlock($innerBlocks) ) { - return true; - } - } - - return false; - } - /** * @param array> $blocks */ diff --git a/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php new file mode 100644 index 00000000..679fea78 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php @@ -0,0 +1,862 @@ + null + * | + * +-- exactly one pure img/video side? -- no --> null + * | + * +-- strict media/layout gates pass? ---- no --> null + * | + * +-- convert text child once + * | + * +-- text-bearing block? -------------- no --> null + * | + * `-- core/media-text + */ +final class MediaTextPattern +{ + use PatternDomHelpersTrait; + use PatternGateHelpersTrait; + + /** + * @param array> $fallbacks + * @param callable(DOMElement, array>&, bool): array> $convertChildren + * @param callable(DOMElement, array>&, bool): (array|null) $convertElement + * @param callable(DOMElement, array): array $presentationAttributes + * @param callable(DOMElement): string $mergedPresentationStyle + * @param callable(DOMElement): array $htmlAttributes + * @param callable(string): string $resolveAssetUrl + * @param callable(string, array, array>, DOMElement|null): array $createBlock + * @return array|null + */ + public function match( + DOMElement $element, + array &$fallbacks, + callable $convertChildren, + callable $convertElement, + callable $presentationAttributes, + callable $mergedPresentationStyle, + callable $htmlAttributes, + callable $resolveAssetUrl, + callable $createBlock + ): ?array { + $elementChildren = $this->strictElementChildren($element); + if ( null === $elementChildren || 2 !== count($elementChildren) ) { + return null; + } + + $mediaCandidates = array(); + foreach ( $elementChildren as $index => $child ) { + $resolution = $this->pureMediaResolution($child); + if ( null !== $resolution ) { + $mediaCandidates[ $index ] = $resolution; + } + } + + if ( 1 !== count($mediaCandidates) ) { + return null; + } + + $mediaIndex = (int) array_key_first($mediaCandidates); + $textIndex = 0 === $mediaIndex ? 1 : 0; + $resolution = $mediaCandidates[ $mediaIndex ]; + if ( $this->containsMediaElement($elementChildren[ $textIndex ]) ) { + return null; + } + + $mediaType = strtolower($resolution['media']->tagName); + if ( 'video' === $mediaType && $resolution['anchor'] instanceof DOMElement ) { + return null; + } + + try { + $containerStyle = $mergedPresentationStyle($element); + } catch ( \Throwable ) { + return null; + } + + if ( $this->declaresUnresolvableGateValue($containerStyle, array( 'display', 'flex-direction', 'flex-flow', 'direction' )) ) { + return null; + } + + $displayType = $this->containerDisplayType($containerStyle); + $flexDirection = $this->flexDirectionFromStyle($containerStyle); + if ( 'flex' === $displayType && in_array($flexDirection, array( 'column', 'column-reverse', 'row-reverse' ), true) ) { + return null; + } + + // Conversion requires an authored horizontal mechanism: display + // flex/grid or a grid template. Without one the source renders + // stacked, and emitting the block would fabricate a side-by-side + // layout. Core's own wp-block-media-text markup is exempt — its grid + // comes from core stylesheets, not author CSS. + $hasGridTemplateColumns = $this->hasGridTemplateColumns($containerStyle); + if ( + null === $displayType + && ! $hasGridTemplateColumns + && ! in_array('wp-block-media-text', preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array(), true) + ) { + return null; + } + + if ( $this->inheritedDirectionBlocksConversion($element, $mergedPresentationStyle, $htmlAttributes) ) { + return null; + } + + $childStyles = array(); + try { + foreach ( $elementChildren as $index => $child ) { + $childStyles[ $index ] = $mergedPresentationStyle($child); + if ( $this->declaresUnresolvableGateValue($childStyles[ $index ], array( 'order', 'float' )) ) { + return null; + } + $childDeclarations = $this->styleDeclarations($childStyles[ $index ]); + $float = strtolower($this->normalizedCssValue((string) ($childDeclarations['float'] ?? ''))); + if ( in_array($float, array( 'left', 'right', 'inline-start', 'inline-end' ), true) ) { + return null; + } + $order = strtolower($this->normalizedCssValue((string) ($childDeclarations['order'] ?? ''))); + $isInitialOrder = in_array($order, array( 'initial', 'unset' ), true) + || ( is_numeric($order) && 0.0 === (float) $order ); + if ( '' !== $order && ! $isInitialOrder ) { + return null; + } + } + } catch ( \Throwable ) { + return null; + } + + try { + $mediaAttributes = $htmlAttributes($resolution['media']); + $sourceUrl = trim((string) ($mediaAttributes['src'] ?? '')); + if ( '' === $sourceUrl ) { + return null; + } + $mediaUrl = trim($resolveAssetUrl($sourceUrl)); + if ( '' === $this->safeMediaUrl($mediaUrl) ) { + return null; + } + } catch ( \Throwable ) { + return null; + } + + // Width gates are resolvable from styles alone, so they run BEFORE the + // text side is converted: convertElement is not side-effect free + // (block-binding occurrence counters), and a post-conversion decline + // re-converts the subtree through the fallback path. + $useGridTemplateColumns = 'flex' !== $displayType && $hasGridTemplateColumns; + $mediaWidth = $useGridTemplateColumns + ? $this->mediaWidthFromContainerStyle($containerStyle, $mediaIndex) + : null; + if ( null === $mediaWidth && $useGridTemplateColumns ) { + // A grid template that yields no percentage/fr-derived width (px, + // minmax(), var(), none, 3+ tracks) cannot be represented by + // mediaWidth; emitting the block would silently render 50/50. + return null; + } + if ( null === $mediaWidth ) { + $mediaWidth = $this->mediaWidthFromMediaStyle($childStyles[ $mediaIndex ]); + } + if ( null !== $mediaWidth ) { + $mediaWidth = max(15, min(85, $mediaWidth)); + } + + $localFallbacks = array(); + try { + $textBlock = $convertElement($elementChildren[ $textIndex ], $localFallbacks, true); + } catch ( \Throwable ) { + return null; + } + + $innerBlocks = null === $textBlock ? array() : array( $textBlock ); + if ( + 'core/group' === ($textBlock['blockName'] ?? null) + && $this->isHoistableTextSideGroupAttrs($textBlock['attrs'] ?? array(), $elementChildren[ $textIndex ]) + && is_array($textBlock['innerBlocks'] ?? null) + ) { + $innerBlocks = $textBlock['innerBlocks']; + } + + if ( array() === $innerBlocks || ! $this->containsTextBearingBlock($innerBlocks) ) { + return null; + } + + try { + $attrs = $presentationAttributes( + $element, + array( 'display', 'grid-template-columns', 'align-items', 'gap' ) + ); + } catch ( \Throwable ) { + return null; + } + unset($attrs['layout']); + + $attrs['mediaType'] = 'img' === $mediaType ? 'image' : 'video'; + $attrs['mediaUrl'] = $mediaUrl; + + if ( 'img' === $mediaType && '' !== (string) ($mediaAttributes['alt'] ?? '') ) { + $attrs['mediaAlt'] = (string) $mediaAttributes['alt']; + } + if ( 1 === $mediaIndex ) { + $attrs['mediaPosition'] = 'right'; + } + + if ( null !== $mediaWidth && 50 !== $mediaWidth ) { + $attrs['mediaWidth'] = $mediaWidth; + } + + $verticalAlignment = in_array($displayType, array( 'flex', 'grid' ), true) + ? $this->verticalAlignmentFromStyle($containerStyle) + : null; + if ( null !== $verticalAlignment ) { + $attrs['verticalAlignment'] = $verticalAlignment; + } + + if ( $resolution['anchor'] instanceof DOMElement ) { + try { + $anchorAttributes = $htmlAttributes($resolution['anchor']); + } catch ( \Throwable ) { + return null; + } + + $href = $this->safeLinkUrl((string) ($anchorAttributes['href'] ?? '')); + if ( '' !== $href ) { + $attrs['href'] = $href; + + // Link metadata is only meaningful alongside an emitted href: + // core sources these attributes from `figure a`, so without an + // anchor they can never round-trip — and a rejected href must + // not leave its rel/target/class text behind. + foreach ( array( + 'target' => 'linkTarget', + 'rel' => 'rel', + 'class' => 'linkClass', + ) as $sourceName => $attributeName ) { + $value = trim((string) ($anchorAttributes[ $sourceName ] ?? '')); + if ( '' !== $value ) { + $attrs[ $attributeName ] = $value; + } + } + } + } + + try { + $block = $createBlock('core/media-text', $attrs, $innerBlocks, $element); + } catch ( \Throwable ) { + return null; + } + + array_push($fallbacks, ...$localFallbacks); + + return $block; + } + + /** + * @return array|null + */ + private function strictElementChildren(DOMElement $element): ?array + { + $children = array(); + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType ) { + if ( '' !== trim($child->textContent ?? '') ) { + return null; + } + continue; + } + if ( ! $child instanceof DOMElement ) { + return null; + } + $children[] = $child; + } + + return $children; + } + + private function safeLinkUrl(string $url): string + { + return $this->safeUrlWithSchemes($url, array( 'http', 'https', 'mailto', 'tel' ), false); + } + + private function safeMediaUrl(string $url): string + { + return $this->safeUrlWithSchemes($url, array( 'http', 'https' ), true); + } + + /** + * @param array $allowedSchemes + */ + private function safeUrlWithSchemes(string $url, array $allowedSchemes, bool $allowImageData): string + { + $url = trim($url); + if ( '' === $url || preg_match('/[\x00-\x1f\x7f]/', $url) ) { + return ''; + } + + if ( str_starts_with($url, '//') ) { + return $url; + } + + if ( ! preg_match('/^([a-z][a-z0-9+.-]*)\s*:/i', $url, $matches) ) { + return $url; + } + + $scheme = strtolower($matches[1]); + if ( in_array($scheme, $allowedSchemes, true) && preg_match('/^' . preg_quote($scheme, '/') . ':/i', $url) ) { + return $url; + } + + if ( $allowImageData && 'data' === $scheme && preg_match('/^data:image\/([a-z0-9.+-]+)(?:[;,])/i', $url, $dataMatches) ) { + return in_array(strtolower($dataMatches[1]), array( 'svg', 'svg+xml' ), true) ? '' : $url; + } + + return ''; + } + + /** + * A converted text side is hoisted into the media-text content area when + * its wrapper group carries nothing the block would lose: no attrs at all, + * or only transformer-generated css-owned layout marker classes. Those + * markers compensate core group flow defaults inside a preserved author + * layout — inside core/media-text the block owns the pane layout, so the + * wrapper is pure noise. A marker-prefixed class that already appears in + * the SOURCE element's class attribute is author-authored, not generated, + * and may carry author stylesheet rules — never hoist it away. + * + * @param array $attrs + */ + private function isHoistableTextSideGroupAttrs(array $attrs, DOMElement $textSide): bool + { + if ( array() === $attrs ) { + return true; + } + if ( array( 'className' ) !== array_keys($attrs) || ! is_string($attrs['className']) ) { + return false; + } + + $sourceClasses = preg_split('/\s+/', trim($this->attr($textSide, 'class'))) ?: array(); + foreach ( preg_split('/\s+/', trim($attrs['className'])) ?: array() as $class ) { + if ( '' === $class ) { + continue; + } + if ( ! str_starts_with($class, 'blocks-engine-css-owned-') || in_array($class, $sourceClasses, true) ) { + return false; + } + } + + return true; + } + + /** + * True when any of the given gate properties carries a var() reference the + * static pipeline cannot resolve. Gates must fail closed on such values — + * treating them as absent silently converts with the default layout. + * + * Scans raw declarations (no validity filter) so unresolvable values are + * seen even though the value validators exclude them elsewhere. + * + * @param array $properties + */ + private function declaresUnresolvableGateValue(string $style, array $properties): bool + { + foreach ( \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevel($style, array( ';' )) as $declaration ) { + $separator = strpos($declaration, ':'); + if ( false === $separator ) { + continue; + } + $name = strtolower(trim(substr($declaration, 0, $separator))); + if ( ! in_array($name, $properties, true) ) { + continue; + } + if ( 1 === preg_match('/var\s*\(/i', substr($declaration, $separator + 1)) ) { + return true; + } + } + + return false; + } + + /** + * Resolve the effective text direction the way inheritance does: nearest + * ancestor (self included) with an explicit CSS `direction` or `dir` + * attribute wins. Declines on rtl, on `dir="auto"`, and on any direction + * value the static pipeline cannot resolve. + */ + private function inheritedDirectionBlocksConversion( + DOMElement $element, + callable $mergedPresentationStyle, + callable $htmlAttributes + ): bool { + for ( $node = $element; $node instanceof DOMElement; $node = $node->parentNode ) { + try { + $style = $mergedPresentationStyle($node); + $attributes = $htmlAttributes($node); + } catch ( \Throwable ) { + return true; + } + + if ( $this->declaresUnresolvableGateValue($style, array( 'direction' )) ) { + return true; + } + $direction = strtolower($this->normalizedCssValue((string) ($this->styleDeclarations($style)['direction'] ?? ''))); + if ( in_array($direction, array( 'ltr', 'rtl' ), true) ) { + return 'rtl' === $direction; + } + + $dir = strtolower(trim((string) ($attributes['dir'] ?? ''))); + if ( 'auto' === $dir ) { + return true; + } + if ( in_array($dir, array( 'ltr', 'rtl' ), true) ) { + return 'rtl' === $dir; + } + } + + return false; + } + + private function containsMediaElement(DOMElement $element): bool + { + if ( in_array(strtolower($element->tagName), array( 'img', 'video' ), true) ) { + return true; + } + + foreach ( $element->childNodes as $child ) { + if ( $child instanceof DOMElement && $this->containsMediaElement($child) ) { + return true; + } + } + + return false; + } + + /** + * @return array{media: DOMElement, anchor: DOMElement|null}|null + */ + private function pureMediaResolution(DOMElement $element, ?DOMElement $anchor = null): ?array + { + $tagName = strtolower($element->tagName); + if ( in_array($tagName, array( 'img', 'video' ), true) ) { + if ( ! $this->hasOnlyIgnorableChildren($element) ) { + return null; + } + + return array( + 'media' => $element, + 'anchor' => $anchor, + ); + } + + if ( ! in_array($tagName, array( 'figure', 'div', 'a', 'picture' ), true) ) { + return null; + } + if ( 'a' === $tagName ) { + if ( $anchor instanceof DOMElement ) { + return null; + } + $anchor = $element; + } + + if ( 'picture' === $tagName ) { + $image = $this->purePictureImage($element); + return $image instanceof DOMElement + ? array( 'media' => $image, 'anchor' => $anchor ) + : null; + } + + $child = $this->strictSingleMediaChild($element); + if ( ! $child instanceof DOMElement ) { + return null; + } + + return $this->pureMediaResolution($child, $anchor); + } + + private function purePictureImage(DOMElement $picture): ?DOMElement + { + $image = null; + if ( ! $this->collectPurePictureImage($picture, $image) ) { + return null; + } + + return $image; + } + + private function collectPurePictureImage(DOMElement $element, ?DOMElement &$image): bool + { + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType ) { + if ( '' !== trim($child->textContent ?? '') ) { + return false; + } + continue; + } + if ( ! $child instanceof DOMElement ) { + return false; + } + + $tagName = strtolower($child->tagName); + if ( 'source' === $tagName ) { + if ( ! $this->collectPurePictureImage($child, $image) ) { + return false; + } + continue; + } + if ( 'img' !== $tagName || $image instanceof DOMElement || ! $this->hasOnlyIgnorableChildren($child) ) { + return false; + } + $image = $child; + } + + return true; + } + + private function hasOnlyIgnorableChildren(DOMElement $element): bool + { + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) { + continue; + } + + return false; + } + + return true; + } + + private function strictSingleMediaChild(DOMElement $element): ?DOMElement + { + $candidate = null; + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType ) { + if ( '' !== trim($child->textContent ?? '') ) { + return null; + } + continue; + } + if ( ! $child instanceof DOMElement ) { + return null; + } + if ( $candidate instanceof DOMElement ) { + return null; + } + $candidate = $child; + } + + return $candidate; + } + + /** + * @return array + */ + private function styleDeclarations(string $style): array + { + $declarations = array(); + $important = array(); + foreach ( \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevel($style, array( ';' )) as $declaration ) { + $separator = strpos($declaration, ':'); + if ( false === $separator ) { + continue; + } + + $name = strtolower(trim(substr($declaration, 0, $separator))); + $value = trim(substr($declaration, $separator + 1)); + if ( '' === $name || '' === $value || ! $this->isValidMediaTextCssDeclaration($name, $value) ) { + continue; + } + + $valueIsImportant = $this->cssValueIsImportant($value); + if ( isset($declarations[ $name ]) && ($important[ $name ] ?? false) && ! $valueIsImportant ) { + continue; + } + + $declarations[ $name ] = $value; + $important[ $name ] = $valueIsImportant; + } + + return $declarations; + } + + private function hasGridTemplateColumns(string $style): bool + { + $declarations = $this->styleDeclarations($style); + return '' !== $this->normalizedCssValue((string) ($declarations['grid-template-columns'] ?? '')); + } + + private function mediaWidthFromContainerStyle(string $style, int $mediaIndex): ?int + { + $declarations = $this->styleDeclarations($style); + $template = $this->normalizedCssValue((string) ($declarations['grid-template-columns'] ?? '')); + if ( '' === $template ) { + return null; + } + + $tracks = \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevelWhitespace($template); + if ( 2 !== count($tracks) ) { + return null; + } + + $mediaPercentage = $this->percentageValue($tracks[ $mediaIndex ]); + if ( null !== $mediaPercentage ) { + return $mediaPercentage; + } + + // Core's own save shape pairs one percentage track with `auto` + // (`N% auto` / `auto N%`), so an auto media track beside a percentage + // text track expresses the complement. + if ( 'auto' === strtolower(trim($tracks[ $mediaIndex ])) ) { + $textPercentage = $this->percentageValue($tracks[ 0 === $mediaIndex ? 1 : 0 ]); + if ( null !== $textPercentage ) { + return 100 - $textPercentage; + } + } + + $firstFr = $this->frValue($tracks[0]); + $secondFr = $this->frValue($tracks[1]); + if ( null === $firstFr || null === $secondFr || 0.0 >= $firstFr + $secondFr || ! is_finite($firstFr + $secondFr) ) { + return null; + } + + return (int) round(100 * ( 0 === $mediaIndex ? $firstFr : $secondFr ) / ($firstFr + $secondFr)); + } + + private function mediaWidthFromMediaStyle(string $style): ?int + { + $declarations = $this->styleDeclarations($style); + foreach ( array( 'flex-basis', 'width' ) as $property ) { + $value = $this->percentageValue($this->normalizedCssValue((string) ($declarations[ $property ] ?? ''))); + if ( null !== $value ) { + return $value; + } + } + + return null; + } + + private function containerDisplayType(string $style): ?string + { + $display = strtolower($this->normalizedCssValue((string) ($this->styleDeclarations($style)['display'] ?? ''))); + return array( + 'flex' => 'flex', + 'inline-flex' => 'flex', + 'grid' => 'grid', + 'inline-grid' => 'grid', + )[ $display ] ?? null; + } + + private function flexDirectionFromStyle(string $style): string + { + $direction = ''; + $directionIsImportant = false; + $hasDirection = false; + foreach ( \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevel($style, array( ';' )) as $declaration ) { + $separator = strpos($declaration, ':'); + if ( false === $separator ) { + continue; + } + + $name = strtolower(trim(substr($declaration, 0, $separator))); + $rawValue = trim(substr($declaration, $separator + 1)); + $value = strtolower($this->normalizedCssValue($rawValue)); + $candidate = null; + if ( 'flex-direction' === $name ) { + if ( ! $this->isValidMediaTextCssDeclaration($name, $rawValue) ) { + continue; + } + $candidate = $value; + } elseif ( 'flex-flow' === $name ) { + if ( ! $this->isValidMediaTextCssDeclaration($name, $rawValue) ) { + continue; + } + $candidate = 'row'; + foreach ( \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevelWhitespace($value) as $component ) { + if ( in_array($component, array( 'row', 'row-reverse', 'column', 'column-reverse' ), true) ) { + $candidate = $component; + break; + } + } + } + + if ( null === $candidate ) { + continue; + } + + $candidateIsImportant = $this->cssValueIsImportant($rawValue); + if ( $hasDirection && $directionIsImportant && ! $candidateIsImportant ) { + continue; + } + + $direction = $candidate; + $directionIsImportant = $candidateIsImportant; + $hasDirection = true; + } + + return $direction; + } + + private function verticalAlignmentFromStyle(string $style): ?string + { + $declarations = $this->styleDeclarations($style); + $alignItems = strtolower($this->normalizedCssValue((string) ($declarations['align-items'] ?? ''))); + + return array( + 'flex-start' => 'top', + 'start' => 'top', + 'center' => 'center', + 'flex-end' => 'bottom', + 'end' => 'bottom', + )[ $alignItems ] ?? null; + } + + private function normalizedCssValue(string $value): string + { + return trim(preg_replace('/\s*!\s*important\s*$/i', '', $value) ?? $value); + } + + private function cssValueIsImportant(string $value): bool + { + return 1 === preg_match('/\s*!\s*important\s*$/i', $value); + } + + private function isValidMediaTextCssDeclaration(string $property, string $rawValue): bool + { + $value = strtolower($this->normalizedCssValue($rawValue)); + $cssWide = array( 'inherit', 'initial', 'revert', 'revert-layer', 'unset' ); + if ( in_array($value, $cssWide, true) ) { + return true; + } + + if ( 'display' === $property ) { + return in_array($value, array( + 'block', 'contents', 'flow-root', 'flex', 'grid', 'inline', 'inline-block', + 'inline-flex', 'inline-grid', 'inline-table', 'list-item', 'none', 'ruby', + 'ruby-base', 'ruby-base-container', 'ruby-text', 'ruby-text-container', + 'table', 'table-caption', 'table-cell', 'table-column', 'table-column-group', + 'table-footer-group', 'table-header-group', 'table-row', 'table-row-group', + ), true) || 1 === preg_match('/^(?:block|inline)\s+(?:flow|flow-root|flex|grid|ruby)(?:\s+list-item)?$/', $value); + } + + if ( 'flex-direction' === $property ) { + return in_array($value, array( 'column', 'column-reverse', 'row', 'row-reverse' ), true); + } + + if ( 'flex-flow' === $property ) { + $directions = array( 'column', 'column-reverse', 'row', 'row-reverse' ); + $wraps = array( 'nowrap', 'wrap', 'wrap-reverse' ); + $seenDirection = false; + $seenWrap = false; + $components = \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevelWhitespace($value); + if ( array() === $components || 2 < count($components) ) { + return false; + } + foreach ( $components as $component ) { + if ( in_array($component, $directions, true) && ! $seenDirection ) { + $seenDirection = true; + continue; + } + if ( in_array($component, $wraps, true) && ! $seenWrap ) { + $seenWrap = true; + continue; + } + return false; + } + return true; + } + + if ( 'order' === $property ) { + return is_numeric($value); + } + + if ( 'align-items' === $property ) { + return in_array($value, array( + 'anchor-center', 'baseline', 'center', 'dialog', 'end', 'first baseline', + 'flex-end', 'flex-start', 'last baseline', 'normal', 'self-end', 'self-start', + 'start', 'stretch', + ), true) || 1 === preg_match('/^(?:safe|unsafe)\s+(?:center|end|flex-end|flex-start|self-end|self-start|start)$/', $value); + } + + if ( 'direction' === $property ) { + return in_array($value, array( 'ltr', 'rtl' ), true); + } + + if ( in_array($property, array( 'flex-basis', 'width' ), true) ) { + return in_array($value, array( 'auto', 'contain', 'content', 'fit-content', 'max-content', 'min-content', 'stretch' ), true) + || 1 === preg_match('/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:%|[a-z]+)?$/i', $value) + || 1 === preg_match('/^(?:calc|clamp|fit-content|max|min|var)\(.+\)$/i', $value); + } + + if ( 'grid-template-columns' === $property ) { + return $this->isValidGridTemplateColumns($value); + } + + return true; + } + + private function isValidGridTemplateColumns(string $value): bool + { + if ( in_array($value, array( 'masonry', 'none', 'subgrid' ), true) ) { + return true; + } + + $tracks = \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevelWhitespace($value); + if ( array() === $tracks ) { + return false; + } + foreach ( $tracks as $track ) { + if ( in_array($track, array( 'auto', 'max-content', 'min-content' ), true) + || 1 === preg_match('/^\[[^\]]+\]$/', $track) + || 1 === preg_match('/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:%|fr|[a-z]+)$/i', $track) + || 1 === preg_match('/^(?:calc|clamp|fit-content|max|min|minmax|repeat|var)\(.+\)$/i', $track) + ) { + continue; + } + return false; + } + + return true; + } + + private function percentageValue(string $value): ?int + { + if ( ! preg_match('/^(?:\d+(?:\.\d*)?|\.\d+)%$/', trim($value), $matches) ) { + return null; + } + + $percentage = (float) rtrim($matches[0], '%'); + if ( 0 > $percentage || 100 < $percentage ) { + return null; + } + + return (int) round($percentage); + } + + private function frValue(string $value): ?float + { + if ( ! preg_match('/^(?:\d+(?:\.\d*)?|\.\d+)fr$/i', trim($value), $matches) ) { + return null; + } + + return (float) substr($matches[0], 0, -2); + } +} diff --git a/php-transformer/src/HtmlToBlocks/Patterns/PatternGateHelpersTrait.php b/php-transformer/src/HtmlToBlocks/Patterns/PatternGateHelpersTrait.php new file mode 100644 index 00000000..31ad34f1 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Patterns/PatternGateHelpersTrait.php @@ -0,0 +1,47 @@ +> $blocks + */ + private function containsTextBearingBlock(array $blocks): bool + { + $textBearingNames = array( 'core/heading', 'core/paragraph', 'core/list', 'core/buttons', 'core/quote' ); + + foreach ( $blocks as $block ) { + if ( ! is_array($block) ) { + continue; + } + + if ( in_array($block['blockName'] ?? null, $textBearingNames, true) ) { + return true; + } + + $innerBlocks = $block['innerBlocks'] ?? array(); + if ( is_array($innerBlocks) && $this->containsTextBearingBlock($innerBlocks) ) { + return true; + } + } + + return false; + } + + /** + * True when the resolved style declares a flex container whose main axis is + * vertical (flex-direction: column / column-reverse). flex-direction only has + * meaning on a flex container, so both display:flex and the column direction + * are required before redirecting away from horizontal columns. + */ + private function isVerticalFlexContainer(string $style): bool + { + if ( ! preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex\b/', $style) ) { + return false; + } + + return (bool) preg_match('/(?:^|;)\s*flex-direction\s*:\s*column(?:-reverse)?\b/', $style); + } +} diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index 8bddaa77..862b5d1f 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -47,6 +47,11 @@ trait StyleResolutionTrait */ private array $mergedPresentationStyleCache = array(); + /** + * @var array + */ + private array $mediaTextPresentationStyleCache = array(); + /** * Inline presentation declarations which core block supports cannot serialize * are carried by deterministic classes in a generated stylesheet. @@ -100,6 +105,7 @@ private function resetPresentationResolutionCache(): void $this->presentationAttributesCache = array(); $this->presentationDeclarationsCache = array(); $this->mergedPresentationStyleCache = array(); + $this->mediaTextPresentationStyleCache = array(); $this->generatedGeometryRules = array(); $this->geometryCarrierClassAllocator = null; } @@ -129,7 +135,34 @@ private function highValueStyleBoundaryPolicy(): HighValueStyleBoundaryPolicy */ private function presentationAttributes(DOMElement $element, array $excludedGeometryProperties = array(), array $forcedGeometryProperties = array()): array { - $cacheKey = $this->presentationCacheKey($element) . ':' . implode(',', $excludedGeometryProperties) . ':' . implode(',', $forcedGeometryProperties); + return $this->resolvedPresentationAttributes($element, $excludedGeometryProperties, $forcedGeometryProperties, false); + } + + /** + * Preserve inline-only geometry entirely in the generated carrier because + * core/media-text cannot serialize arbitrary wrapper geometry inline. + * + * @return array + */ + private function mediaTextPresentationAttributes(DOMElement $element, array $excludedGeometryProperties = array()): array + { + return $this->resolvedPresentationAttributes($element, $excludedGeometryProperties, array(), true); + } + + /** + * @return array + */ + private function resolvedPresentationAttributes( + DOMElement $element, + array $excludedGeometryProperties, + array $forcedGeometryProperties, + bool $carrierOwnsInlineGeometry + ): array + { + $cacheKey = $this->presentationCacheKey($element) + . ':' . implode(',', $excludedGeometryProperties) + . ':' . implode(',', $forcedGeometryProperties) + . ':' . ($carrierOwnsInlineGeometry ? 'carrier' : 'inline'); if ( isset($this->presentationAttributesCache[$cacheKey]) ) { return $this->presentationAttributesCache[$cacheKey]; } @@ -148,7 +181,13 @@ private function presentationAttributes(DOMElement $element, array $excludedGeom 'anchor' => $this->safeAnchor($this->attr($element, 'id')), 'className' => $this->mergePresentationClassNames( $this->promotedClassName($this->attr($element, 'class')), - $this->inlineGeometryClassName($element, $excludedGeometryProperties, $forcedGeometryProperties, $forcedGeometryDeclarations) + $this->inlineGeometryClassName( + $element, + $excludedGeometryProperties, + $forcedGeometryProperties, + $forcedGeometryDeclarations, + $carrierOwnsInlineGeometry + ) ), 'inlineGeometryStyle' => $this->inlineGeometryStyle($element, $excludedGeometryProperties, $forcedGeometryProperties), 'style' => $mapped['style'], @@ -285,9 +324,17 @@ private function hasConditionalStyleFamily(DOMElement $element, string $family): * inline geometry in a generated stylesheet; class-owned declarations are * already retained by author stylesheet materialization. */ - private function inlineGeometryClassName(DOMElement $element, array $excludedProperties = array(), array $forcedProperties = array(), array $forcedDeclarations = array()): string + private function inlineGeometryClassName( + DOMElement $element, + array $excludedProperties = array(), + array $forcedProperties = array(), + array $forcedDeclarations = array(), + bool $carrierOwnsInlineGeometry = false + ): string { - $declarations = $this->cssDeclarations($this->attr($element, 'style')); + $declarations = $carrierOwnsInlineGeometry + ? $this->mediaTextInlineCascadeDeclarations($this->attr($element, 'style')) + : $this->cssDeclarations($this->attr($element, 'style')); $geometry = array(); $properties = $this->inlineGeometryProperties(); $inlineBackground = (string) ($declarations['background'] ?? $declarations['background-image'] ?? ''); @@ -301,10 +348,12 @@ private function inlineGeometryClassName(DOMElement $element, array $excludedPro continue; } $rawValue = trim((string) ($declarations[$property] ?? ($forcedDeclarations[$property] ?? ''))); - if (1 === preg_match('/\s*!important\s*$/i', $rawValue)) { + if (! $carrierOwnsInlineGeometry && 1 === preg_match('/\s*!important\s*$/i', $rawValue)) { continue; } - $value = $rawValue; + $value = $carrierOwnsInlineGeometry + ? trim(preg_replace('/\s*!\s*important\s*$/i', '', $rawValue) ?? $rawValue) + : $rawValue; if ( in_array($property, array( 'background', 'background-image' ), true) ) { $value = CssUrlRewriter::rewrite($value, fn (string $url): string => $this->resolvedAssetImageUrl($url)); } @@ -513,6 +562,346 @@ private function structuralPresentationDeclarations(DOMElement $element): array return $this->mergeCssDeclarationMaps($declarations, $this->cssDeclarations($this->attr($element, 'style'))); } + /** + * Resolve media-text gate declarations without flattening CSS importance or + * shorthand/longhand order. Inline declarations outrank matched stylesheet + * declarations at equal importance. + * + * @return array + */ + private function mediaTextPresentationDeclarations(DOMElement $element): array + { + $cascade = array(); + $sequence = 0; + foreach ($this->staticStyleRules as $rule) { + if (! $this->matchesCssSelector($element, $rule['selector'])) { + continue; + } + foreach ($rule['mediaTextDeclarations'] ?? array() as $entry) { + $this->applyMediaTextCascadeDeclaration( + $cascade, + $entry['property'], + $entry['value'] . ($entry['important'] ? ' !important' : ''), + false, + $rule['mediaTextSpecificity'] ?? array( 0, 0, 0 ), + ++$sequence + ); + } + } + + foreach ($this->mediaTextInlineDeclarationEntries($this->attr($element, 'style')) as $entry) { + $this->applyMediaTextCascadeDeclaration( + $cascade, + $entry['property'], + $entry['value'] . ($entry['important'] ? ' !important' : ''), + true, + array( PHP_INT_MAX, PHP_INT_MAX, PHP_INT_MAX ), + ++$sequence + ); + } + + $declarations = array(); + foreach ($cascade as $property => $entry) { + $declarations[$property] = $entry['value'] . ($entry['important'] ? ' !important' : ''); + } + + return $declarations; + } + + /** + * @param array $cascade + * @param array{int, int, int} $specificity + */ + private function applyMediaTextCascadeDeclaration( + array &$cascade, + string $property, + string $rawValue, + bool $inline, + array $specificity, + int $sequence + ): void { + $property = str_starts_with($property, '--') ? $property : strtolower($property); + $important = 1 === preg_match('/\s*!\s*important\s*$/i', $rawValue); + $value = trim(preg_replace('/\s*!\s*important\s*$/i', '', $rawValue) ?? $rawValue); + if ('' === $property || '' === $value) { + return; + } + + if ('flex-flow' === $property) { + $property = 'flex-direction'; + // A var() flow is statically unresolvable — keep it verbatim so the + // strict gate declines on it instead of defaulting to row. + if (1 !== preg_match('/var\s*\(/i', $value)) { + $flowDirection = null; + foreach (CssValueSplitter::splitTopLevelWhitespace(strtolower($value)) as $component) { + if (in_array($component, array('row', 'row-reverse', 'column', 'column-reverse'), true)) { + $flowDirection = $component; + break; + } + } + $value = $flowDirection ?? (in_array(strtolower($value), array('inherit', 'unset', 'revert', 'revert-layer'), true) ? strtolower($value) : 'row'); + } + } + + $current = $cascade[$property] ?? null; + if (is_array($current)) { + if ($current['important'] && ! $important) { + return; + } + if ($current['important'] === $important) { + $specificityComparison = $this->compareMediaTextSpecificity($current['specificity'], $specificity); + if (0 < $specificityComparison) { + return; + } + if (0 === $specificityComparison && $current['sequence'] > $sequence) { + return; + } + if (0 === $specificityComparison && $current['sequence'] === $sequence && $current['inline'] && ! $inline) { + return; + } + } + } + + $cascade[$property] = array( + 'value' => $value, + 'important' => $important, + 'inline' => $inline, + 'specificity' => $specificity, + 'sequence' => $sequence, + ); + } + + /** + * @return list + */ + private function mediaTextInlineDeclarationEntries(string $style): array + { + $entries = array(); + foreach (CssValueSplitter::splitTopLevel($style, array(';')) as $declaration) { + $separator = strpos($declaration, ':'); + if (false === $separator) { + continue; + } + + $rawProperty = trim(substr($declaration, 0, $separator)); + $property = str_starts_with($rawProperty, '--') ? $rawProperty : strtolower($rawProperty); + $rawValue = trim(substr($declaration, $separator + 1)); + $important = 1 === preg_match('/\s*!\s*important\s*$/i', $rawValue); + $value = trim(preg_replace('/\s*!\s*important\s*$/i', '', $rawValue) ?? $rawValue); + $value = preg_replace('/\s+/', ' ', $value) ?? $value; + if ('' === $property + || '' === $value + || array() === $this->cssDeclarations($property . ':' . $value) + || ! $this->isValidMediaTextDeclarationValue($property, $value) + ) { + continue; + } + + $entries[] = array( + 'property' => $property, + 'value' => $value, + 'important' => $important, + ); + } + + return $entries; + } + + private function isValidMediaTextDeclarationValue(string $property, string $rawValue): bool + { + if (str_starts_with($property, '--')) { + return true; + } + + $value = strtolower(trim($rawValue)); + if (in_array($value, array('inherit', 'initial', 'revert', 'revert-layer', 'unset'), true)) { + return true; + } + + // var() values are valid CSS everywhere but statically unresolvable. + // They must SURVIVE into the cascade so the strict gates can fail + // closed on them — dropping them here makes the gate read "absent" + // and convert with the default layout. + if (1 === preg_match('/var\s*\(/i', $value)) { + return true; + } + + if ('display' === $property) { + return in_array($value, array( + 'block', 'contents', 'flow-root', 'flex', 'grid', 'inline', 'inline-block', + 'inline-flex', 'inline-grid', 'inline-table', 'list-item', 'none', 'ruby', + 'ruby-base', 'ruby-base-container', 'ruby-text', 'ruby-text-container', + 'table', 'table-caption', 'table-cell', 'table-column', 'table-column-group', + 'table-footer-group', 'table-header-group', 'table-row', 'table-row-group', + ), true) || 1 === preg_match('/^(?:block|inline)\s+(?:flow|flow-root|flex|grid|ruby)(?:\s+list-item)?$/', $value); + } + + if ('flex-direction' === $property) { + return in_array($value, array('column', 'column-reverse', 'row', 'row-reverse'), true); + } + + if ('flex-flow' === $property) { + $directions = array('column', 'column-reverse', 'row', 'row-reverse'); + $wraps = array('nowrap', 'wrap', 'wrap-reverse'); + $seenDirection = false; + $seenWrap = false; + $components = CssValueSplitter::splitTopLevelWhitespace($value); + if (array() === $components || 2 < count($components)) { + return false; + } + foreach ($components as $component) { + if (in_array($component, $directions, true) && ! $seenDirection) { + $seenDirection = true; + continue; + } + if (in_array($component, $wraps, true) && ! $seenWrap) { + $seenWrap = true; + continue; + } + return false; + } + return true; + } + + if ('order' === $property) { + return is_numeric($value); + } + + if ('align-items' === $property) { + return in_array($value, array( + 'anchor-center', 'baseline', 'center', 'dialog', 'end', 'first baseline', + 'flex-end', 'flex-start', 'last baseline', 'normal', 'self-end', 'self-start', + 'start', 'stretch', + ), true) || 1 === preg_match('/^(?:safe|unsafe)\s+(?:center|end|flex-end|flex-start|self-end|self-start|start)$/', $value); + } + + if ('direction' === $property) { + return in_array($value, array('ltr', 'rtl'), true); + } + + if (in_array($property, array('flex-basis', 'width'), true)) { + return in_array($value, array('auto', 'contain', 'content', 'fit-content', 'max-content', 'min-content', 'stretch'), true) + || 1 === preg_match('/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:%|[a-z]+)?$/i', $value) + || 1 === preg_match('/^(?:calc|clamp|fit-content|max|min|var)\(.+\)$/i', $value); + } + + if ('grid-template-columns' === $property) { + return $this->isValidMediaTextGridTemplateColumns($value); + } + + return true; + } + + private function isValidMediaTextGridTemplateColumns(string $value): bool + { + if (in_array($value, array('masonry', 'none', 'subgrid'), true)) { + return true; + } + + $tracks = CssValueSplitter::splitTopLevelWhitespace($value); + if (array() === $tracks) { + return false; + } + foreach ($tracks as $track) { + if (in_array($track, array('auto', 'max-content', 'min-content'), true) + || 1 === preg_match('/^\[[^\]]+\]$/', $track) + || 1 === preg_match('/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:%|fr|[a-z]+)$/i', $track) + || 1 === preg_match('/^(?:calc|clamp|fit-content|max|min|minmax|repeat|var)\(.+\)$/i', $track) + ) { + continue; + } + return false; + } + + return true; + } + + /** + * Preserve case-sensitive custom-property names while resolving duplicate + * inline declarations by CSS importance and source order. + * + * @return array + */ + private function mediaTextInlineCascadeDeclarations(string $style): array + { + $cascade = array(); + foreach ($this->mediaTextInlineDeclarationEntries($style) as $entry) { + $current = $cascade[$entry['property']] ?? null; + if (is_array($current) && $current['important'] && ! $entry['important']) { + continue; + } + $cascade[$entry['property']] = array( + 'value' => $entry['value'], + 'important' => $entry['important'], + ); + } + + $declarations = array(); + foreach ($cascade as $property => $entry) { + $declarations[$property] = $entry['value'] . ($entry['important'] ? ' !important' : ''); + } + + return $declarations; + } + + /** + * @return array{int, int, int} + */ + private function mediaTextSelectorSpecificity(string $selector): array + { + $parsed = $this->parsedCssSelector($selector); + if (! ($parsed['supported'] ?? false)) { + return array( 0, 0, 0 ); + } + + $ids = 0; + $classes = 0; + $elements = 0; + foreach ($parsed['compounds'] as $compound) { + $ids += count($compound['ids'] ?? array()); + $classes += count($compound['classes'] ?? array()) + count($compound['attributes'] ?? array()); + if (null !== ($compound['nth_child'] ?? null) || ($compound['first_child'] ?? false) || ($compound['last_child'] ?? false)) { + ++$classes; + } + if (null !== ($compound['type'] ?? null)) { + ++$elements; + } + } + + return array( $ids, $classes, $elements ); + } + + /** + * @param array{int, int, int} $left + * @param array{int, int, int} $right + */ + private function compareMediaTextSpecificity(array $left, array $right): int + { + foreach ( array( 0, 1, 2 ) as $index ) { + if ( $left[ $index ] !== $right[ $index ] ) { + return $left[ $index ] <=> $right[ $index ]; + } + } + + return 0; + } + + /** + * Resolve full authored layout style for media-text strict gates, including + * low-value direct children that general presentation resolution skips. + */ + private function mediaTextPresentationStyle(DOMElement $element): string + { + $cacheKey = $this->presentationCacheKey($element); + if ( isset($this->mediaTextPresentationStyleCache[$cacheKey]) ) { + return $this->mediaTextPresentationStyleCache[$cacheKey]; + } + + $this->mediaTextPresentationStyleCache[$cacheKey] = $this->cssDeclarationString($this->mediaTextPresentationDeclarations($element)); + + return $this->mediaTextPresentationStyleCache[$cacheKey]; + } + /** * Remove responsive/JS-revealed hidden base states (display:none / * visibility:hidden / opacity:0) from content-bearing or interactive @@ -650,7 +1039,7 @@ private function isHighValueStyledElement(DOMElement $element): bool } /** - * @return array, condition: string}> + * @return array, mediaTextDeclarations: list, mediaTextSpecificity: array{int, int, int}}> */ private function staticStyleRules(string $html, string $linkedCss): array { @@ -672,7 +1061,22 @@ private function staticStyleRules(string $html, string $linkedCss): array foreach ( $matches as $match ) { $declarations = $this->safeVisualDeclarations($this->cssDeclarations((string) $match[2])); - if ( array() === $declarations ) { + $mediaTextDeclarations = array_values(array_filter( + $this->mediaTextInlineDeclarationEntries((string) $match[2]), + static fn (array $entry): bool => in_array($entry['property'], array( + 'align-items', + 'direction', + 'display', + 'flex-basis', + 'flex-direction', + 'flex-flow', + 'float', + 'grid-template-columns', + 'order', + 'width', + ), true) + )); + if ( array() === $declarations && array() === $mediaTextDeclarations ) { continue; } foreach ( explode(',', (string) $match[1]) as $selector ) { @@ -681,6 +1085,8 @@ private function staticStyleRules(string $html, string $linkedCss): array $rules[] = array( 'selector' => $selector, 'declarations' => $declarations, + 'mediaTextDeclarations' => $mediaTextDeclarations, + 'mediaTextSpecificity' => $this->mediaTextSelectorSpecificity($selector), ); } } @@ -993,8 +1399,10 @@ private function safeVisualDeclarations(array $declarations): array 'color', 'align-items', 'column-gap', + 'direction', 'display', 'flex-direction', + 'flex-flow', 'flex', 'flex-basis', 'flex-grow', @@ -1020,6 +1428,7 @@ private function safeVisualDeclarations(array $declarations): array 'max-width', 'min-height', 'min-width', + 'order', 'padding', 'padding-bottom', 'padding-left', diff --git a/php-transformer/src/WordPress/CanonicalSaveShapeValidator.php b/php-transformer/src/WordPress/CanonicalSaveShapeValidator.php index f1bf2aed..37c0a4cb 100644 --- a/php-transformer/src/WordPress/CanonicalSaveShapeValidator.php +++ b/php-transformer/src/WordPress/CanonicalSaveShapeValidator.php @@ -59,6 +59,7 @@ final class CanonicalSaveShapeValidator private const TARGET_BLOCKS = array( 'core/group', 'core/cover', + 'core/media-text', 'core/columns', 'core/column', 'core/buttons', @@ -166,7 +167,7 @@ private function validateWrapper(array $block, string $blockName, string $path, // token core would reproduce from the className attribute. A leftover // source class core's save() never regenerates breaks the round-trip. foreach ( $wrapperClasses as $class ) { - if ( $this->isStructuralClass($class) || in_array($class, $classNameTokens, true) ) { + if ( $this->isStructuralClass($class, $blockName, $attrs) || in_array($class, $classNameTokens, true) ) { continue; } @@ -276,8 +277,25 @@ private function validateNoDuplicateClassTokens(array $block, ?string $blockName * input: the block base class, element classes, and attribute-derived * support classes (alignment, color/border has-*, and is-* state/style). */ - private function isStructuralClass(string $class): bool + /** + * @param array $attrs + */ + private function isStructuralClass(string $class, string $blockName, array $attrs): bool { + if ( 'core/media-text' === $blockName ) { + // State classes are structural only when the attribute core's + // save() derives them from justifies them — an unjustified state + // class is exactly the divergence this validator exists to catch. + if ( 'is-stacked-on-mobile' === $class ) { + return ! array_key_exists('isStackedOnMobile', $attrs) || false !== $attrs['isStackedOnMobile']; + } + foreach ( array( 'top', 'center', 'bottom' ) as $alignment ) { + if ( 'is-vertically-aligned-' . $alignment === $class ) { + return $alignment === ($attrs['verticalAlignment'] ?? null); + } + } + } + return str_starts_with($class, 'wp-block-') || str_starts_with($class, 'wp-element-') || str_starts_with($class, 'wp-container-') diff --git a/php-transformer/src/WordPress/GeneratedGutenbergClassPolicy.php b/php-transformer/src/WordPress/GeneratedGutenbergClassPolicy.php index 3b6e3a86..41632bea 100644 --- a/php-transformer/src/WordPress/GeneratedGutenbergClassPolicy.php +++ b/php-transformer/src/WordPress/GeneratedGutenbergClassPolicy.php @@ -21,6 +21,7 @@ final class GeneratedGutenbergClassPolicy 'core/details' => 'wp-block-details', 'core/heading' => 'wp-block-heading', 'core/list' => 'wp-block-list', + 'core/media-text' => 'wp-block-media-text', 'core/quote' => 'wp-block-quote', 'core/separator' => 'wp-block-separator', ); diff --git a/php-transformer/src/WordPress/Runtime.php b/php-transformer/src/WordPress/Runtime.php index f1a314b3..9cc985a1 100644 --- a/php-transformer/src/WordPress/Runtime.php +++ b/php-transformer/src/WordPress/Runtime.php @@ -30,6 +30,7 @@ final class Runtime 'core/list', 'core/list-item', 'core/math', + 'core/media-text', 'core/navigation', 'core/navigation-link', 'core/navigation-submenu', diff --git a/php-transformer/tests/fixtures/parity/html-media-text.json b/php-transformer/tests/fixtures/parity/html-media-text.json new file mode 100644 index 00000000..7c3203a0 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-media-text.json @@ -0,0 +1,37 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "html-media-text", + "description": "Converts strict two-pane image/text and video/text layouts to native core/media-text blocks while preserving key media geometry and link attributes.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/html-media-text.json", + "notes": "Covers a linked picture-backed image on the left and a video on the right; both layouts have exactly two element children, pure media subtrees, text-bearing content, and zero fallbacks." + }, + "legacy_comparison": { + "skip": true, + "reason": "This covers the PHP transformer's native core/media-text recognizer and canonical save shape." + }, + "operation": "html_transformer.transform", + "input": { + "content": "
\"Feature

Image feature

Linked media stays beside readable copy.

Video feature

Video renders on the right.

" + }, + "expected_blocks": [ + { "path": "blocks.0", "name": "core/media-text", "attrs": { "className": "feature-split", "mediaType": "image", "mediaUrl": "https://example.com/feature.jpg", "mediaAlt": "Feature view", "mediaWidth": 40, "verticalAlignment": "bottom", "href": "https://example.com/story", "linkTarget": "_blank", "rel": "noreferrer", "linkClass": "media-link" } }, + { "path": "blocks.0.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Image feature", "level": 2 } }, + { "path": "blocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "content": "Linked media stays beside readable copy." } }, + { "path": "blocks.1", "name": "core/media-text", "attrs": { "className": "video-split", "mediaPosition": "right", "mediaType": "video", "mediaUrl": "https://example.com/feature.mp4", "mediaWidth": 35, "verticalAlignment": "center" } }, + { "path": "blocks.1.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Video feature", "level": 3 } }, + { "path": "blocks.1.innerBlocks.1", "name": "core/paragraph", "attrs": { "content": "Video renders on the right." } } + ], + "expected_fallbacks": [], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "blocks", "assert": "count", "count": 2 }, + { "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 }, + { "path": "blocks.1.innerBlocks", "assert": "count", "count": 2 }, + { "path": "fallbacks", "assert": "count", "count": 0 }, + { "path": "source_reports.conversion_report.metrics.fallback_count", "assert": "equals", "value": 0 }, + { "path": "coverage.0.fallback_count", "assert": "equals", "value": 0 }, + { "path": "serialized_blocks", "assert": "contains", "value": "
Left

Build

' +); +$mediaLeft = $match( + $mediaLeftElement, + array( $heading ), + $fallbacks, + $record, + array( 'className' => 'feature', 'layout' => array( 'type' => 'flex' ) ) +); +$assertSame('core/media-text', $mediaLeft['blockName'] ?? null, 'Media-left strict pair matches core/media-text.'); +$assertSame('image', $mediaLeft['attrs']['mediaType'] ?? null, 'Image media emits mediaType image.'); +$assertSame('/resolved/left.jpg', $mediaLeft['attrs']['mediaUrl'] ?? null, 'Image src passes through asset resolver.'); +$assertSame('Left', $mediaLeft['attrs']['mediaAlt'] ?? null, 'Image alt passes through.'); +$assertTrue(! array_key_exists('mediaPosition', $mediaLeft['attrs'] ?? array()), 'Default left position is omitted.'); +$assertTrue(! array_key_exists('mediaWidth', $mediaLeft['attrs'] ?? array()), 'Default width is omitted.'); +$assertTrue(! array_key_exists('isStackedOnMobile', $mediaLeft['attrs'] ?? array()), 'Default mobile stacking is omitted.'); +$assertTrue(! array_key_exists('layout', $mediaLeft['attrs'] ?? array()), 'Consumed layout attr is removed.'); +$assertSame(array( $heading ), $mediaLeft['innerBlocks'] ?? null, 'Converted text side becomes innerBlocks.'); +$assertSame(1, $record['convertCalls'], 'Text side converts exactly once.'); +$assertSame(0, $record['convertChildrenCalls'], 'Text side never converts through convertChildren coupling.'); +$assertSame('div', $record['convertedTag'], 'Only text child is converted.'); +$assertSame(array( 'display', 'grid-template-columns', 'align-items', 'gap' ), $record['excluded'], 'Presentation extraction excludes consumed geometry.'); + +// Host conversion preserves text-child identity while plain groups may hoist. +$headingResult = $transformHtml('

Only head

'); +$headingMediaText = $headingResult['blocks'][0] ?? array(); +$assertSame('core/media-text', $headingMediaText['blockName'] ?? null, 'Heading text side matches media-text.'); +$assertSame('core/heading', $headingMediaText['innerBlocks'][0]['blockName'] ?? null, 'Heading text side keeps core/heading identity.'); +$assertTrue(! array_key_exists('mediaAlt', $headingMediaText['attrs'] ?? array()), 'Empty image alt is omitted from media-text attrs.'); + +$quoteResult = $transformHtml('

Quoted

'); +$assertSame('core/quote', $quoteResult['blocks'][0]['innerBlocks'][0]['blockName'] ?? null, 'Blockquote text side keeps core/quote identity.'); + +$styledTextResult = $transformHtml('

Styled copy

'); +$styledTextBlock = $styledTextResult['blocks'][0]['innerBlocks'][0] ?? array(); +$assertSame('core/group', $styledTextBlock['blockName'] ?? null, 'Styled text wrapper keeps core/group identity.'); +$assertSame('copy-panel blocks-engine-css-owned-layout', $styledTextBlock['attrs']['className'] ?? null, 'Styled text group keeps className plus layout-item marker.'); +$assertSame('1rem', $styledTextBlock['attrs']['style']['spacing']['padding']['top'] ?? null, 'Styled text group keeps style attrs.'); + +$plainTextResult = $transformHtml('

Head

Copy

'); +$plainTextBlocks = $plainTextResult['blocks'][0]['innerBlocks'] ?? array(); +$assertSame(2, count($plainTextBlocks), 'Attr-less text group hoists both children.'); +$assertSame('core/heading', $plainTextBlocks[0]['blockName'] ?? null, 'Hoisted first child keeps heading identity.'); +$assertSame('core/paragraph', $plainTextBlocks[1]['blockName'] ?? null, 'Hoisted second child keeps paragraph identity.'); + +$inlineParagraphResult = $transformHtml('

Read now.

'); +$inlineParagraphBlocks = $inlineParagraphResult['blocks'][0]['innerBlocks'] ?? array(); +$assertSame(1, count($inlineParagraphBlocks), 'Single paragraph remains one inner block.'); +$assertSame('core/paragraph', $inlineParagraphBlocks[0]['blockName'] ?? null, 'Single paragraph keeps core/paragraph identity.'); +$assertSame('Read now.', $inlineParagraphBlocks[0]['attrs']['content'] ?? null, 'Single paragraph keeps inline markup intact.'); + +// Media-right emits position and uses media track, not left track. +$fallbacks = array(); +$record = array(); +$mediaRightElement = $elementFromHtml( + '

Ship

' +); +$mediaRight = $match($mediaRightElement, array( $paragraph ), $fallbacks, $record); +$assertSame('right', $mediaRight['attrs']['mediaPosition'] ?? null, 'Second media child emits right position.'); +$assertSame(35, $mediaRight['attrs']['mediaWidth'] ?? null, 'Right media width derives from second grid track.'); +$assertSame('center', $mediaRight['attrs']['verticalAlignment'] ?? null, 'align-items center maps to center.'); + +// Video media consumes video without alt. +$fallbacks = array(); +$record = array(); +$videoElement = $elementFromHtml('

Watch

'); +$video = $match($videoElement, array( $paragraph ), $fallbacks, $record); +$assertSame('video', $video['attrs']['mediaType'] ?? null, 'Video media emits mediaType video.'); +$assertSame('/resolved/clip.mp4', $video['attrs']['mediaUrl'] ?? null, 'Video src passes through asset resolver.'); +$assertTrue(! array_key_exists('mediaAlt', $video['attrs'] ?? array()), 'Video media omits mediaAlt.'); + +// Link wrapper attributes survive. +$fallbacks = array(); +$record = array(); +$linkedElement = $elementFromHtml( + '
Fallback

Open

' +); +$linked = $match($linkedElement, array( $paragraph ), $fallbacks, $record); +$assertSame('/resolved/fallback.jpg', $linked['attrs']['mediaUrl'] ?? null, 'Picture uses img fallback src, not source srcset.'); +$assertSame('/full', $linked['attrs']['href'] ?? null, 'Link href passes through.'); +$assertSame('_blank', $linked['attrs']['linkTarget'] ?? null, 'Link target maps to linkTarget.'); +$assertSame('noopener', $linked['attrs']['rel'] ?? null, 'Link rel passes through.'); +$assertSame('zoom', $linked['attrs']['linkClass'] ?? null, 'Link class maps to linkClass.'); + +// Link destinations use an anchored scheme allowlist. +foreach ( array( + 'http://example.com/full', + 'https://example.com/full', + 'mailto:editor@example.com', + 'tel:+15551234567', + '//cdn.example.com/full', + '/relative/full', + '../relative/full', + 'relative/full', +) as $safeHref ) { + $fallbacks = array(); + $record = array(); + $safeLinkedElement = $elementFromHtml('

Safe copy

'); + $safeAnchor = $safeLinkedElement->getElementsByTagName('a')->item(0); + if ( ! $safeAnchor instanceof DOMElement ) { + throw new RuntimeException('Safe-link fixture did not produce anchor.'); + } + $safeAnchor->setAttribute('href', $safeHref); + $safeLinked = $match($safeLinkedElement, array( $paragraph ), $fallbacks, $record); + $assertSame($safeHref, $safeLinked['attrs']['href'] ?? null, 'Allowed link href survives: ' . $safeHref); +} + +// Unsafe link destinations never reach media-text attrs. +foreach ( array( 'javascript:alert(1)', 'javascript :alert(1)', 'data:text/html,unsafe', 'ftp://example.com/file', 'vbscript:unsafe', "/ok\x01bad" ) as $unsafeHref ) { + $fallbacks = array(); + $record = array(); + $unsafeLinkedElement = $elementFromHtml( + '
Safe

Safe copy

' + ); + $unsafeAnchor = $unsafeLinkedElement->getElementsByTagName('a')->item(0); + if ( ! $unsafeAnchor instanceof DOMElement ) { + throw new RuntimeException('Unsafe-link fixture did not produce anchor.'); + } + $unsafeAnchor->setAttribute('href', $unsafeHref); + $unsafeLinked = $match($unsafeLinkedElement, array( $paragraph ), $fallbacks, $record); + $assertTrue(! array_key_exists('href', $unsafeLinked['attrs'] ?? array()), 'Unsafe link href is omitted: ' . json_encode($unsafeHref)); + $assertSame(null, $unsafeLinked['attrs']['linkTarget'] ?? null, 'Rejected href drops anchor target metadata.'); + $assertSame(null, $unsafeLinked['attrs']['rel'] ?? null, 'Rejected href drops anchor rel metadata.'); + $assertSame(null, $unsafeLinked['attrs']['linkClass'] ?? null, 'Rejected href drops anchor class metadata.'); +} + +$unsafeLinkResult = $transformHtml( + '
Safe

Safe copy

' +); +$unsafeLinkBlock = $unsafeLinkResult['blocks'][0] ?? array(); +$assertSame('core/media-text', $unsafeLinkBlock['blockName'] ?? null, 'Unsafe linked media still converts with safe media URL.'); +$assertTrue(! array_key_exists('href', $unsafeLinkBlock['attrs'] ?? array()), 'Unsafe href is absent from emitted block attrs.'); +$assertTrue(! str_contains((string) ($unsafeLinkBlock['innerHTML'] ?? ''), 'javascript'), 'Unsafe href is absent from emitted markup.'); + +$substringLinkResult = $transformHtml( + '

Copy

' +); +$assertSame( + 'https://e.com/blog/what-is-javascript:-a-primer', + $substringLinkResult['blocks'][0]['attrs']['href'] ?? null, + 'javascript: substring outside leading scheme survives link allowlist.' +); + +// Resolved media URLs use image-safe schemes; unsafe media declines pre-conversion. +foreach ( array( + 'http://example.com/media.jpg', + 'https://example.com/media.jpg', + '//cdn.example.com/media.jpg', + '/images/media.jpg', + '../images/media.jpg', + 'images/media.jpg', + 'data:image/png;base64,AAAA', +) as $safeMediaUrl ) { + $fallbacks = array(); + $record = array(); + $safeMediaElement = $elementFromHtml('

Safe media

'); + $safeImage = $safeMediaElement->getElementsByTagName('img')->item(0); + if ( ! $safeImage instanceof DOMElement ) { + throw new RuntimeException('Safe-media fixture did not produce image.'); + } + $safeImage->setAttribute('src', $safeMediaUrl); + $safeMedia = $match( + $safeMediaElement, + array( $paragraph ), + $fallbacks, + $record, + array(), + false, + false, + static fn (string $url): string => $url + ); + $assertSame($safeMediaUrl, $safeMedia['attrs']['mediaUrl'] ?? null, 'Allowed media URL survives: ' . $safeMediaUrl); +} + +foreach ( array( 'javascript:alert(1)', 'data:text/html,unsafe', 'data:image/svg+xml;base64,AAAA', 'data:image/SVG;base64,AAAA', 'ftp://example.com/media.jpg', 'file:///tmp/media.jpg', "bad\x01media.jpg" ) as $unsafeMediaUrl ) { + $fallbacks = array(); + $record = array(); + $unsafeMediaElement = $elementFromHtml('

Unsafe media

'); + $unsafeImage = $unsafeMediaElement->getElementsByTagName('img')->item(0); + if ( ! $unsafeImage instanceof DOMElement ) { + throw new RuntimeException('Unsafe-media fixture did not produce image.'); + } + $unsafeImage->setAttribute('src', $unsafeMediaUrl); + $unsafeMedia = $match( + $unsafeMediaElement, + array( $paragraph ), + $fallbacks, + $record, + array(), + false, + false, + static fn (string $url): string => $url + ); + $assertNull($unsafeMedia, 'Unsafe media URL declines: ' . json_encode($unsafeMediaUrl)); + $assertSame(0, $record['convertCalls'], 'Unsafe media URL declines before text conversion.'); +} + +$fallbacks = array(); +$record = array(); +$unsafeResolvedMedia = $match( + $mediaLeftElement, + array( $heading ), + $fallbacks, + $record, + array(), + false, + false, + static fn (string $url): string => 'javascript:resolved' +); +$assertNull($unsafeResolvedMedia, 'Unsafe resolved media URL declines match.'); +$assertSame(0, $record['convertCalls'], 'Unsafe resolved media URL declines before text conversion.'); + +// Width derives from media-child flex-basis, then width, and from two fr tracks. +$fallbacks = array(); +$record = array(); +$flexBasisElement = $elementFromHtml('

Basis

'); +$flexBasis = $match($flexBasisElement, array( $paragraph ), $fallbacks, $record); +$assertSame(42, $flexBasis['attrs']['mediaWidth'] ?? null, 'Media flex-basis percentage derives width.'); + +$fallbacks = array(); +$record = array(); +$widthElement = $elementFromHtml('

Width

'); +$width = $match($widthElement, array( $paragraph ), $fallbacks, $record); +$assertSame(38, $width['attrs']['mediaWidth'] ?? null, 'Media width percentage rounds to nearest integer.'); + +// Media-child style resolution failures decline and discard local fallbacks. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$styleFailure = $match($mediaLeftElement, array( $heading ), $fallbacks, $record, array(), true, true); +$assertNull($styleFailure, 'Media-child style failure declines match.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Media-child style failure leaves host fallbacks unchanged.'); + +$fallbacks = array(); +$record = array(); +$frElement = $elementFromHtml('

Fr

'); +$fr = $match($frElement, array( $paragraph ), $fallbacks, $record); +$assertSame(40, $fr['attrs']['mediaWidth'] ?? null, 'Two fr tracks derive media share.'); + +foreach ( array( '30% 24rem' => 30, '35% minmax(10rem,1fr)' => 35 ) as $gridTemplate => $expectedWidth ) { + $fallbacks = array(); + $record = array(); + $mixedTrackElement = $elementFromHtml('

Mixed

'); + $mixedTrack = $match($mixedTrackElement, array( $paragraph ), $fallbacks, $record); + $assertSame($expectedWidth, $mixedTrack['attrs']['mediaWidth'] ?? null, 'Bare percentage media track ignores other track unit: ' . $gridTemplate); +} + +foreach ( array( + 'grid-template-columns:30% auto' => 30, + 'display:block;grid-template-columns:30% auto' => 30, + 'display:flex;grid-template-columns:30% auto' => null, + 'display:inline-grid;grid-template-columns:30% auto' => 30, +) as $gridIntentStyle => $expectedWidth ) { + $fallbacks = array(); + $record = array(); + $gridIntentElement = $elementFromHtml('

Grid intent

'); + $gridIntent = $match($gridIntentElement, array( $paragraph ), $fallbacks, $record); + $assertSame($expectedWidth, $gridIntent['attrs']['mediaWidth'] ?? null, 'Grid tracks imply grid intent unless display resolves flex: ' . $gridIntentStyle); +} + +foreach ( array( 10 => 15, 14 => 15, 15 => 15, 85 => 85, 86 => 85, 90 => 85 ) as $sourceWidth => $expectedWidth ) { + $fallbacks = array(); + $record = array(); + $boundedWidthElement = $elementFromHtml('

Bounded

'); + $boundedWidth = $match($boundedWidthElement, array( $paragraph ), $fallbacks, $record); + $actualWidth = $boundedWidth['attrs']['mediaWidth'] ?? null; + $assertSame($expectedWidth, $actualWidth, 'mediaWidth clamps to inclusive 15..85 range: ' . $sourceWidth); +} + +$fallbacks = array(); +$record = array(); +$flexContradictionElement = $elementFromHtml('

Flex width

'); +$flexContradiction = $match($flexContradictionElement, array( $paragraph ), $fallbacks, $record); +$assertSame(41, $flexContradiction['attrs']['mediaWidth'] ?? null, 'Resolved flex ignores grid tracks and keeps media flex-basis/width fallback order.'); + +// Vertical alignment mapping covers all core values. +foreach ( array( 'flex-start' => 'top', 'start' => 'top', 'center' => 'center', 'flex-end' => 'bottom', 'end' => 'bottom' ) as $alignItems => $expectedAlignment ) { + $fallbacks = array(); + $record = array(); + $alignmentElement = $elementFromHtml('

Align

'); + $alignment = $match($alignmentElement, array( $paragraph ), $fallbacks, $record); + $assertSame($expectedAlignment, $alignment['attrs']['verticalAlignment'] ?? null, 'align-items ' . $alignItems . ' maps to core value.'); +} + +foreach ( array( + 'align-items:center' => null, + 'display:block;align-items:center' => null, + 'display:inline-flex;align-items:center' => 'center', + 'display:inline-grid;align-items:center' => 'center', +) as $alignmentStyle => $expectedAlignment ) { + $fallbacks = array(); + $record = array(); + $alignmentElement = $elementFromHtml('

Align

'); + $alignment = $match($alignmentElement, array( $paragraph ), $fallbacks, $record); + $assertSame($expectedAlignment, $alignment['attrs']['verticalAlignment'] ?? null, 'Inline display forms share flex/grid alignment semantics: ' . $alignmentStyle); +} + +$fallbacks = array(); +$record = array(); +$gridEndElement = $elementFromHtml('

Grid end

'); +$gridEnd = $match($gridEndElement, array( $paragraph ), $fallbacks, $record); +$assertSame('bottom', $gridEnd['attrs']['verticalAlignment'] ?? null, 'Grid align-items end maps to bottom.'); + +// Media-side impurity declines before text conversion. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$captionElement = $elementFromHtml('
Caption

Copy

'); +$assertNull($match($captionElement, array( $paragraph ), $fallbacks, $record, array(), true), 'Figcaption makes media side impure.'); +$assertSame(0, $record['convertCalls'], 'Impure media declines before text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Impure decline leaves host fallbacks unchanged.'); + +// A second media-bearing pane is gallery-shaped, not text-bearing. +$fallbacks = array(); +$record = array(); +$ambiguousGalleryElement = $elementFromHtml( + '
C
D
D caption
', + 'div' +); +$assertNull($match($ambiguousGalleryElement, array( $paragraph ), $fallbacks, $record), 'Second pane with media descendant declines as ambiguous gallery.'); +$assertSame(0, $record['convertCalls'], 'Second media-bearing pane declines before text conversion.'); + +// Exactly three element children decline before conversion. +$fallbacks = array(); +$record = array(); +$threeChildrenElement = $elementFromHtml('

Copy

'); +$assertNull($match($threeChildrenElement, array( $paragraph ), $fallbacks, $record), 'Three element children decline.'); +$assertSame(0, $record['convertCalls'], 'Three-child decline avoids conversion.'); + +// Strict layout-direction gates decline before text conversion. +foreach ( array( + 'row reverse' => '

Reverse

', + 'inline flex column' => '

Vertical

', + 'flex flow reverse' => '

Reverse

', + 'media order' => '

Ordered

', + 'text order' => '

Ordered

', + 'rtl' => '

RTL

', + 'dir rtl' => '

RTL

', +) as $gateName => $gateHtml ) { + $fallbacks = array( array( 'reason' => 'existing' ) ); + $record = array(); + $gatedElement = $elementFromHtml($gateHtml); + $assertNull($match($gatedElement, array( $paragraph ), $fallbacks, $record, array(), true), 'Strict layout gate declines: ' . $gateName); + $assertSame(0, $record['convertCalls'], 'Strict layout gate runs before text conversion: ' . $gateName); + $assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Strict layout decline leaves host fallbacks unchanged: ' . $gateName); +} + +$fallbacks = array(); +$record = array(); +$rowElement = $elementFromHtml('

Row

'); +$row = $match($rowElement, array( $paragraph ), $fallbacks, $record); +$assertSame('core/media-text', $row['blockName'] ?? null, 'Normal flex row remains eligible.'); + +foreach ( array( '0', '+0', '-0', '0.0', 'initial', 'unset' ) as $initialOrder ) { + $fallbacks = array(); + $record = array(); + $initialOrderElement = $elementFromHtml('

Initial order

'); + $initialOrderBlock = $match($initialOrderElement, array( $paragraph ), $fallbacks, $record); + $assertSame('core/media-text', $initialOrderBlock['blockName'] ?? null, 'Initial-equivalent child order remains eligible: ' . $initialOrder); +} + +foreach ( array( + 'order:1 !important;order:0' => null, + 'order:0 !important;order:1' => 'core/media-text', +) as $orderCascade => $expectedBlockName ) { + $fallbacks = array(); + $record = array(); + $orderCascadeElement = $elementFromHtml('

Order cascade

'); + $orderCascadeBlock = $match($orderCascadeElement, array( $paragraph ), $fallbacks, $record); + $assertSame($expectedBlockName, $orderCascadeBlock['blockName'] ?? null, 'Order gate honors declaration importance: ' . $orderCascade); +} + +foreach ( array( + 'flex-direction:row;flex-flow:row-reverse wrap' => null, + 'flex-flow:row-reverse wrap;flex-direction:row' => 'core/media-text', + 'flex-flow:row;flex-direction:row;flex-flow:row-reverse wrap' => null, + 'flex-direction:row !important;flex-flow:column' => 'core/media-text', +) as $directionOrder => $expectedBlockName ) { + $fallbacks = array(); + $record = array(); + $directionOrderElement = $elementFromHtml('

Flow order

'); + $directionOrderBlock = $match($directionOrderElement, array( $paragraph ), $fallbacks, $record); + $assertSame($expectedBlockName, $directionOrderBlock['blockName'] ?? null, 'Last flex-flow/flex-direction declaration controls direction: ' . $directionOrder); +} + +foreach ( array( + 'display:flex;flex-flow:column;flex-direction:banana', + 'display:flex;flex-direction:column;flex-flow:nope', + 'display:flex;display:banana;flex-direction:column', +) as $invalidDirectionStyle ) { + $fallbacks = array(); + $record = array(); + $invalidDirectionElement = $elementFromHtml('

Invalid direction

'); + $invalidDirectionBlock = $match($invalidDirectionElement, array( $paragraph ), $fallbacks, $record); + $assertNull($invalidDirectionBlock, 'Invalid CSS value does not override earlier valid layout declaration: ' . $invalidDirectionStyle); +} + +$fallbacks = array(); +$record = array(); +$invalidInitialOrderElement = $elementFromHtml('

Invalid order

'); +$invalidInitialOrderBlock = $match($invalidInitialOrderElement, array( $paragraph ), $fallbacks, $record); +$assertSame('core/media-text', $invalidInitialOrderBlock['blockName'] ?? null, 'Invalid order does not override earlier zero order.'); + +$fallbacks = array(); +$record = array(); +$invalidAlignmentElement = $elementFromHtml('

Invalid align

'); +$invalidAlignmentBlock = $match($invalidAlignmentElement, array( $paragraph ), $fallbacks, $record); +$assertSame('center', $invalidAlignmentBlock['attrs']['verticalAlignment'] ?? null, 'Invalid alignment does not override earlier center alignment.'); + +$fallbacks = array(); +$record = array(); +$invalidGridElement = $elementFromHtml('

Invalid grid

'); +$invalidGridBlock = $match($invalidGridElement, array( $paragraph ), $fallbacks, $record); +$assertSame(30, $invalidGridBlock['attrs']['mediaWidth'] ?? null, 'Invalid grid template does not override earlier valid tracks.'); + +$fallbacks = array(); +$record = array(); +$invalidWidthElement = $elementFromHtml('

Invalid width

'); +$invalidWidthBlock = $match($invalidWidthElement, array( $paragraph ), $fallbacks, $record); +$assertSame(40, $invalidWidthBlock['attrs']['mediaWidth'] ?? null, 'Invalid media width does not override earlier percentage width.'); + +// Authored direction/order rules reach strict gates for low-value direct children. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$authoredOrderElement = $elementFromHtml('

Ordered

'); +$authoredOrder = $match( + $authoredOrderElement, + array( $paragraph ), + $fallbacks, + $record, + array(), + true, + false, + null, + false, + static fn (DOMElement $sourceElement): string => str_contains(' ' . $sourceElement->getAttribute('class') . ' ', ' copy ') + ? 'order:2' + : $sourceElement->getAttribute('style') +); +$assertNull($authoredOrder, 'Authored child order declines match.'); +$assertSame(0, $record['convertCalls'], 'Authored child order declines before text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Authored child order leaves host fallbacks unchanged.'); + +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$authoredRtlElement = $elementFromHtml('

RTL

'); +$authoredRtl = $match( + $authoredRtlElement, + array( $paragraph ), + $fallbacks, + $record, + array(), + true, + false, + null, + false, + static fn (DOMElement $sourceElement): string => str_contains(' ' . $sourceElement->getAttribute('class') . ' ', ' shell ') + ? 'display:grid;direction:rtl' + : $sourceElement->getAttribute('style') +); +$assertNull($authoredRtl, 'Authored container rtl declines match.'); +$assertSame(0, $record['convertCalls'], 'Authored container rtl declines before text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Authored container rtl leaves host fallbacks unchanged.'); + +// Link-wrapped video is not representable by core/media-text save markup. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$linkedVideoElement = $elementFromHtml('

Copy

'); +$assertNull($match($linkedVideoElement, array( $paragraph ), $fallbacks, $record, array(), true), 'Link-wrapped video declines.'); +$assertSame(0, $record['convertCalls'], 'Link-wrapped video declines before text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Link-wrapped video decline leaves host fallbacks unchanged.'); + +// Vertical flex declines before conversion and leaves local fallbacks untouched. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$verticalElement = $elementFromHtml('

Stacked

'); +$assertNull($match($verticalElement, array( $paragraph ), $fallbacks, $record, array(), true), 'Vertical flex container declines.'); +$assertSame(0, $record['convertCalls'], 'Vertical gate runs before text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Vertical decline discards text-side local fallbacks.'); + +// Converted text side must contain a recursive text-bearing block. +$fallbacks = array(); +$record = array(); +$imageOnly = array( + 'blockName' => 'core/group', + 'attrs' => array(), + 'innerBlocks' => array( array( 'blockName' => 'core/image', 'attrs' => array(), 'innerBlocks' => array() ) ), +); +$assertNull($match($mediaLeftElement, array( $imageOnly ), $fallbacks, $record), 'Non-text text side declines.'); +$assertSame(1, $record['convertCalls'], 'Non-text side converts once for gate and output reuse.'); + +// Underivable grid geometries omit mediaWidth. +foreach ( array( 'minmax(12rem,1fr) auto', '1fr 40%' ) as $gridTemplate ) { + $fallbacks = array(); + $record = array(); + $underivableElement = $elementFromHtml('

Unknown

'); + $underivable = $match($underivableElement, array( $paragraph ), $fallbacks, $record); + $assertTrue(! array_key_exists('mediaWidth', $underivable['attrs'] ?? array()), 'Underivable grid width is omitted: ' . $gridTemplate); +} + +// Text-side fallbacks push only after successful block creation. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$matchedWithFallback = $match($mediaLeftElement, array( $heading ), $fallbacks, $record, array(), true); +$assertSame('core/media-text', $matchedWithFallback['blockName'] ?? null, 'Text fallback does not suppress valid match.'); +$assertSame( + array( array( 'reason' => 'existing' ), array( 'type' => 'html', 'reason' => 'unsupported_element' ) ), + $fallbacks, + 'Matched text-side fallback pushes to host accumulator.' +); + +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$createFailure = $match( + $mediaLeftElement, + array( $heading ), + $fallbacks, + $record, + array(), + true, + false, + null, + true +); +$assertNull($createFailure, 'Block creation failure declines match.'); +$assertSame(1, $record['convertCalls'], 'Block creation failure occurs after one text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Block creation failure discards text-side local fallbacks.'); + +// Ladder fallthrough remains unchanged for strict declines. +$geometryResult = $transformHtml( + '

Copy

' +); +$geometryBlock = $geometryResult['blocks'][0] ?? array(); +$geometryOpening = (string) ($geometryBlock['innerContent'][0] ?? ''); +$geometryAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $geometryResult['assets'] ?? array())); +$assertSame('core/media-text', $geometryBlock['blockName'] ?? null, 'Grid geometry case emits media-text.'); +$assertSame( + array( 'top' => '1rem', 'right' => '1rem', 'bottom' => '1rem', 'left' => '1rem' ), + $geometryBlock['attrs']['style']['spacing']['padding'] ?? null, + 'Media-text attrs preserve supported padding.' +); +$assertTrue(! isset($geometryBlock['attrs']['style']['dimensions']), 'Media-text attrs omit unsupported dimensions.'); +$assertSame('accent', $geometryBlock['attrs']['backgroundColor'] ?? null, 'Media-text preserves top-level preset attr.'); +$assertContains('has-accent-background-color has-background', $geometryOpening, 'Media-text preserves top-level preset classes.'); +$assertContains('be-inline-geometry-', (string) ($geometryBlock['attrs']['className'] ?? ''), 'Media-text preserves generated geometry carrier class.'); +$assertContains('padding-top:1rem;padding-right:1rem;padding-bottom:1rem;padding-left:1rem;grid-template-columns:30% auto', $geometryOpening, 'Media-text wrapper merges support styles with grid tracks.'); +foreach ( array( 'max-width', 'min-height', 'aspect-ratio', '--media-gap' ) as $leakedProperty ) { + $assertTrue(! str_contains($geometryOpening, $leakedProperty), 'Media-text wrapper style omits source property: ' . $leakedProperty); +} +$assertContains('max-width:900px !important', $geometryAssets, 'Carrier stylesheet preserves source max-width.'); +$assertContains('min-height:30rem !important', $geometryAssets, 'Carrier stylesheet preserves source min-height.'); +$assertContains('aspect-ratio:16/9 !important', $geometryAssets, 'Carrier stylesheet preserves source aspect-ratio.'); + +$variableGeometryResult = $transformHtml( + '

Copy

' +); +$variableGeometryBlock = $variableGeometryResult['blocks'][0] ?? array(); +$variableGeometryOpening = (string) ($variableGeometryBlock['innerContent'][0] ?? ''); +$variableGeometryAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $variableGeometryResult['assets'] ?? array())); +$assertSame('core/media-text', $variableGeometryBlock['blockName'] ?? null, 'Variable geometry case emits media-text.'); +$assertContains('--base:420px !important', $variableGeometryAssets, 'Carrier stylesheet preserves transitive custom-property definition.'); +$assertContains('--pane:var(--base) !important', $variableGeometryAssets, 'Carrier stylesheet preserves directly referenced custom-property definition.'); +$assertContains('min-height:var(--pane) !important', $variableGeometryAssets, 'Carrier stylesheet preserves variable geometry declaration.'); +$assertTrue(! str_contains($variableGeometryOpening, '--pane'), 'Media-text wrapper keeps custom properties out of inline style.'); +$assertTrue(! str_contains($variableGeometryOpening, 'min-height'), 'Media-text wrapper keeps variable geometry out of inline style.'); + +$importantGeometryResult = $transformHtml( + '

Copy

' +); +$importantGeometryBlock = $importantGeometryResult['blocks'][0] ?? array(); +$importantGeometryOpening = (string) ($importantGeometryBlock['innerContent'][0] ?? ''); +$importantGeometryAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $importantGeometryResult['assets'] ?? array())); +$assertSame('core/media-text', $importantGeometryBlock['blockName'] ?? null, 'Important geometry case emits media-text.'); +$assertContains('min-height:420px !important', $importantGeometryAssets, 'Carrier stylesheet preserves important-only geometry.'); +$assertTrue(! str_contains($importantGeometryOpening, 'min-height'), 'Media-text wrapper keeps important geometry out of inline style.'); + +$importantCascadeResult = $transformHtml( + '

Copy

' +); +$importantCascadeAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $importantCascadeResult['assets'] ?? array())); +$assertContains('min-height:420px !important', $importantCascadeAssets, 'Carrier stylesheet honors important geometry over later normal declaration.'); +$assertTrue(! str_contains($importantCascadeAssets, 'min-height:200px'), 'Carrier stylesheet drops losing normal geometry declaration.'); + +$caseSensitiveVariableResult = $transformHtml( + '

Copy

' +); +$caseSensitiveVariableAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $caseSensitiveVariableResult['assets'] ?? array())); +$assertContains('--x:400px !important', $caseSensitiveVariableAssets, 'Carrier stylesheet preserves case-sensitive custom-property identity.'); +$assertTrue(! str_contains($caseSensitiveVariableAssets, '--x:500px'), 'Carrier stylesheet does not merge differently cased custom properties.'); + +$clampedWidthResult = $transformHtml('

Copy

'); +$clampedWidthBlock = $clampedWidthResult['blocks'][0] ?? array(); +$assertSame(15, $clampedWidthBlock['attrs']['mediaWidth'] ?? null, 'Out-of-range source width clamps in emitted attrs.'); +$assertContains('grid-template-columns:15% auto', (string) ($clampedWidthBlock['innerContent'][0] ?? ''), 'Clamped width controls emitted wrapper track.'); + +$impliedGridResult = $transformHtml('

Copy

'); +$assertSame(30, $impliedGridResult['blocks'][0]['attrs']['mediaWidth'] ?? null, 'Grid tracks derive emitted width without explicit display grid.'); + +$inlineFlexAlignmentResult = $transformHtml('

Copy

'); +$assertSame('center', $inlineFlexAlignmentResult['blocks'][0]['attrs']['verticalAlignment'] ?? null, 'Inline flex preserves emitted vertical alignment.'); + +$rowReverseResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($rowReverseResult['blocks'][0]['blockName'] ?? null), 'Flex row-reverse falls through without media-text.'); + +$inlineFlexColumnResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($inlineFlexColumnResult['blocks'][0]['blockName'] ?? null), 'Inline-flex column falls through without media-text.'); + +$flexFlowReverseResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($flexFlowReverseResult['blocks'][0]['blockName'] ?? null), 'Flex-flow row-reverse falls through without media-text.'); + +$authoredFlexFlowResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($authoredFlexFlowResult['blocks'][0]['blockName'] ?? null), 'Stylesheet-authored flex-flow row-reverse reaches media-text gate.'); + +$importantAuthoredFlowResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($importantAuthoredFlowResult['blocks'][0]['blockName'] ?? null), 'Stylesheet important flex-flow beats inline normal flex-flow.'); + +$repeatedAuthoredFlowResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($repeatedAuthoredFlowResult['blocks'][0]['blockName'] ?? null), 'Stylesheet declaration order resolves repeated flex-flow against flex-direction.'); + +$inlineImportantFlowResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $inlineImportantFlowResult['blocks'][0]['blockName'] ?? null, 'Inline important flex-direction beats stylesheet important flex-flow.'); + +$specificAuthoredFlowResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($specificAuthoredFlowResult['blocks'][0]['blockName'] ?? null), 'Higher-specificity stylesheet flex-flow beats later lower-specificity rule.'); + +$tupleSpecificityFlowResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($tupleSpecificityFlowResult['blocks'][0]['blockName'] ?? null), 'ID specificity beats any number of class selectors without scalar carry.'); + +$attributeValueSpecificityResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($attributeValueSpecificityResult['blocks'][0]['blockName'] ?? null), 'ID-like text inside attribute value does not add ID specificity.'); + +foreach ( array( + 'display:flex;flex-flow:column;flex-direction:banana', + 'display:flex;flex-direction:column;flex-flow:nope', + 'display:flex;display:banana;flex-direction:column', +) as $invalidIntegrationStyle ) { + $invalidIntegrationResult = $transformHtml('

Copy

'); + $assertTrue('core/media-text' !== ($invalidIntegrationResult['blocks'][0]['blockName'] ?? null), 'Transform ignores invalid layout declaration: ' . $invalidIntegrationStyle); +} + +$invalidAuthoredLayoutResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($invalidAuthoredLayoutResult['blocks'][0]['blockName'] ?? null), 'Stylesheet invalid value does not override earlier valid layout declaration.'); + +$initialOrderResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $initialOrderResult['blocks'][0]['blockName'] ?? null, 'Authored order zero remains eligible for media-text.'); + +$authoredOrderResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($authoredOrderResult['blocks'][0]['blockName'] ?? null), 'Authored .copy{order:2} declines media-text.'); + +$importantAuthoredOrderResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($importantAuthoredOrderResult['blocks'][0]['blockName'] ?? null), 'Stylesheet important order beats inline normal order.'); + +$repeatedAuthoredOrderResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($repeatedAuthoredOrderResult['blocks'][0]['blockName'] ?? null), 'Stylesheet declaration importance resolves repeated order declarations.'); + +$authoredRtlResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($authoredRtlResult['blocks'][0]['blockName'] ?? null), 'Authored .shell{display:grid;direction:rtl} declines media-text.'); + +$dirRtlResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($dirRtlResult['blocks'][0]['blockName'] ?? null), 'Container dir=rtl declines media-text.'); + +$svgDataResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($svgDataResult['blocks'][0]['blockName'] ?? null), 'SVG data media URL declines media-text.'); + +$lastDisplayWinsResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $lastDisplayWinsResult['blocks'][0]['blockName'] ?? null, 'Last duplicate display declaration controls row-reverse gate.'); + +$lastFlexDirectionWinsResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $lastFlexDirectionWinsResult['blocks'][0]['blockName'] ?? null, 'Last duplicate flex-direction declaration controls row-reverse gate.'); + +$reviewerLastDisplayResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($reviewerLastDisplayResult['blocks'][0]['blockName'] ?? null), 'Last display:block declaration declines via the mechanism gate, not the stale flex column.'); + +$reviewerLastDirectionResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $reviewerLastDirectionResult['blocks'][0]['blockName'] ?? null, 'Last flex-direction:row declaration supersedes stale column.'); + +$linkedVideoResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($linkedVideoResult['blocks'][0]['blockName'] ?? null), 'Linked video falls through without media-text.'); + +$captionResult = $transformHtml('
Caption

Copy

'); +$assertSame('core/columns', $captionResult['blocks'][0]['blockName'] ?? null, 'Figcaption decline falls through to existing columns path.'); + +$ambiguousGalleryResult = $transformHtml( + '
C
D
D caption
' +); +$assertSame('core/gallery', $ambiguousGalleryResult['blocks'][0]['blockName'] ?? null, 'Two media-bearing figure panes remain core/gallery.'); + +$threeChildrenResult = $transformHtml('

Copy

'); +$assertSame('core/group', $threeChildrenResult['blocks'][0]['blockName'] ?? null, 'Three-child decline falls through to author-owned layout preservation.'); + +$verticalResult = $transformHtml('

Stacked

'); +$assertTrue('core/media-text' !== ($verticalResult['blocks'][0]['blockName'] ?? null), 'Vertical flex decline never emits media-text.'); +$assertTrue('core/columns' !== ($verticalResult['blocks'][0]['blockName'] ?? null), 'Vertical flex decline keeps existing columns rejection.'); + +// Unresolvable var() on gate properties fails closed instead of converting +// with the default layout. +foreach ( array( + 'display:flex;flex-direction:var(--stack-direction)', + 'display:flex;flex-flow:var(--stack-flow)', + 'display:var(--layout-mode)', + 'display:flex;direction:var(--text-direction)', +) as $unresolvableContainerStyle ) { + $unresolvableContainerResult = $transformHtml('

Copy

'); + $assertTrue('core/media-text' !== ($unresolvableContainerResult['blocks'][0]['blockName'] ?? null), 'Unresolvable container gate value declines: ' . $unresolvableContainerStyle); +} + +$authoredVarDirectionResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($authoredVarDirectionResult['blocks'][0]['blockName'] ?? null), 'Stylesheet var() flex-direction declines media-text.'); + +$varOrderResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($varOrderResult['blocks'][0]['blockName'] ?? null), 'Unresolvable child order declines media-text.'); + +// Inherited RTL declines even when declared on an ancestor. +$ancestorDirResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($ancestorDirResult['blocks'][0]['blockName'] ?? null) && ! str_contains(json_encode($ancestorDirResult['blocks']), 'core\/media-text'), 'Ancestor dir=rtl declines media-text.'); + +$bodyDirectionResult = $transformHtml('

Copy

'); +$assertTrue(! str_contains(json_encode($bodyDirectionResult['blocks']), 'core\/media-text'), 'Inherited body{direction:rtl} declines media-text.'); + +$nearestLtrResult = $transformHtml('

Copy

'); +$assertTrue(str_contains(json_encode($nearestLtrResult['blocks']), 'core\/media-text'), 'Nearest dir=ltr overrides ancestor rtl and converts.'); + +$dirAutoResult = $transformHtml('

Copy

'); +$assertTrue(! str_contains(json_encode($dirAutoResult['blocks']), 'core\/media-text'), 'dir=auto fails closed and declines media-text.'); + +// Floated panes decline instead of converting with DOM-order position. +$floatRightResult = $transformHtml('

Copy

'); +$assertTrue(! str_contains(json_encode($floatRightResult['blocks']), 'core\/media-text'), 'Floated media pane declines media-text.'); + +$authoredFloatResult = $transformHtml('

Copy

'); +$assertTrue(! str_contains(json_encode($authoredFloatResult['blocks']), 'core\/media-text'), 'Stylesheet-floated text pane declines media-text.'); + +// Grid templates that cannot express a mediaWidth decline instead of +// silently rendering 50/50. +foreach ( array( + 'display:grid;grid-template-columns:300px auto', + 'display:grid;grid-template-columns:minmax(200px,1fr) 2fr', + 'display:grid;grid-template-columns:none', + 'display:grid;grid-template-columns:var(--cols)', +) as $inexpressibleGridStyle ) { + $inexpressibleGridResult = $transformHtml('

Copy

'); + $assertTrue(! str_contains(json_encode($inexpressibleGridResult['blocks']), 'core\/media-text'), 'Inexpressible grid template declines: ' . $inexpressibleGridStyle); +} + +$expressibleGridResult = $transformHtml('

Copy

'); +$assertTrue(str_contains(json_encode($expressibleGridResult['blocks']), 'core\/media-text'), 'Percentage grid template still converts.'); + +// A split-implying class with no authored horizontal CSS renders stacked and +// defers to the columns demotion policy instead of fabricating a side pair. +$classOnlySplitResult = $transformHtml('

Fresh Bread Daily

Baked every morning.

Bread
'); +$assertTrue(! str_contains(json_encode($classOnlySplitResult['blocks']), 'core\/media-text'), 'Class-implied split without authored CSS declines media-text.'); + +$classSplitWithCssResult = $transformHtml('

Fresh Bread Daily

Baked every morning.

Bread
'); +$assertTrue(str_contains(json_encode($classSplitWithCssResult['blocks']), 'core\/media-text'), 'Class-implied split with authored grid converts.'); + +// Existing wp-block-media-text markup round-trips through same strict gate. +$roundTripResult = $transformHtml( + '
' + . '

Round trip

' + . '
Round
' + . '
' +); +$roundTrip = $roundTripResult['blocks'][0] ?? array(); +$assertSame('core/media-text', $roundTrip['blockName'] ?? null, 'wp-block-media-text markup passes strict round-trip gate.'); +$assertSame('right', $roundTrip['attrs']['mediaPosition'] ?? null, 'Round-trip DOM order restores right position.'); +$assertContains('has-media-on-the-right', (string) ($roundTrip['innerHTML'] ?? ''), 'Round-trip save shape restores right class.'); + +// Media-text style resolution memoizes by the shared presentation cache key. +$memoizedTransformer = new HtmlTransformer(); +$memoizedElement = $elementFromHtml('

Memo

'); +$mediaStyleMethod = new ReflectionMethod(HtmlTransformer::class, 'mediaTextPresentationStyle'); +$presentationKeyMethod = new ReflectionMethod(HtmlTransformer::class, 'presentationCacheKey'); +$mediaStyleCacheProperty = new ReflectionProperty(HtmlTransformer::class, 'mediaTextPresentationStyleCache'); +$firstMediaStyle = $mediaStyleMethod->invoke($memoizedTransformer, $memoizedElement); +$memoizedElement->setAttribute('style', 'display:grid'); +$secondMediaStyle = $mediaStyleMethod->invoke($memoizedTransformer, $memoizedElement); +$mediaStyleCache = $mediaStyleCacheProperty->getValue($memoizedTransformer); +$presentationKey = $presentationKeyMethod->invoke($memoizedTransformer, $memoizedElement); +$assertSame('display:flex', $firstMediaStyle, 'Media-text presentation style resolves initial authored style.'); +$assertSame($firstMediaStyle, $secondMediaStyle, 'Media-text presentation style reuses cached value for same DOM node.'); +$assertSame($firstMediaStyle, $mediaStyleCache[$presentationKey] ?? null, 'Media-text style cache uses shared presentation cache key.'); + +// Emitted media-text markup passes Runtime serialization validation. +$runtime = new Runtime(); +$serializedRoundTrip = $runtime->serializeBlocks(array( $roundTrip )); +$validity = $runtime->validateBlockSerialization($serializedRoundTrip); +$assertSame('pass', $validity['status'] ?? null, 'Emitted media-text markup passes serialization validity.'); + +if ( 0 === $failures ) { + echo "media text pattern ok\n"; +} + +exit(0 === $failures ? 0 : 1); diff --git a/php-transformer/tools/visual-parity/tests/fixtures/media-text-source.html b/php-transformer/tools/visual-parity/tests/fixtures/media-text-source.html new file mode 100644 index 00000000..1b20f65d --- /dev/null +++ b/php-transformer/tools/visual-parity/tests/fixtures/media-text-source.html @@ -0,0 +1,85 @@ + + + + + + Media Text Visual Parity Source + + + +
+
+ Abstract green and gold gradient +
+
+

Stories built side by side

+

A strict two-pane composition keeps expressive media and editable text together without losing the original visual rhythm.

+
+
+ + diff --git a/php-transformer/tools/visual-parity/tests/transform-media-text.php b/php-transformer/tools/visual-parity/tests/transform-media-text.php new file mode 100644 index 00000000..150355a4 --- /dev/null +++ b/php-transformer/tools/visual-parity/tests/transform-media-text.php @@ -0,0 +1,74 @@ +transform($source)->toArray(); +if ( 'failed' === ($result['status'] ?? null) ) { + throw new RuntimeException('Media-text source fixture transform failed.'); +} + +$mediaTextCount = 0; +$countMediaText = static function (array $blocks) use (&$countMediaText, &$mediaTextCount): void { + foreach ( $blocks as $block ) { + if ( 'core/media-text' === ($block['blockName'] ?? null) ) { + ++$mediaTextCount; + } + if ( is_array($block['innerBlocks'] ?? null) ) { + $countMediaText($block['innerBlocks']); + } + } +}; +$countMediaText($result['blocks'] ?? array()); +if ( 1 !== $mediaTextCount ) { + throw new RuntimeException(sprintf('Expected one core/media-text block; found %d.', $mediaTextCount)); +} + +$stylesheet = ''; +foreach ( $result['assets'] ?? array() as $asset ) { + if ( 'stylesheet' !== ($asset['role'] ?? null) || 'text/css' !== ($asset['mime_type'] ?? null) ) { + continue; + } + $stylesheet .= (string) ($asset['content'] ?? '') . "\n"; +} + +$coreMediaTextCss = <<<'CSS' +@media (max-width: 600px) { + .wp-block-media-text.is-stacked-on-mobile { + grid-template-columns: 100% !important; + } +} +CSS; + +$rendered = ( new Runtime() )->renderBlocks($result['blocks'] ?? array()); +$target = '' . "\n" + . '' . "\n" + . '' . "\n" + . ' ' . "\n" + . ' ' . "\n" + . ' Media Text Visual Parity Target' . "\n" + . ' ' . "\n" + . '' . "\n" + . '' . "\n" . $rendered . "\n" . '' . "\n" + . '' . "\n"; + +$targetDirectory = dirname($targetPath); +if ( ! is_dir($targetDirectory) && ! mkdir($targetDirectory, 0777, true) && ! is_dir($targetDirectory) ) { + throw new RuntimeException('Unable to create media-text target directory.'); +} +if ( false === file_put_contents($targetPath, $target) ) { + throw new RuntimeException('Unable to write media-text target fixture.'); +} + +fwrite(STDOUT, sprintf("media-text target written: %s\n", $targetPath));