diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 9817f4ee..9562085a 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -1934,6 +1934,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d } } $blockMarkup = (string) ($compiledBlocks['serialized_blocks'] ?? ''); + $canonicalBlockMarkup = $blockMarkup; if ( $path === $entryPath ) { foreach ( $entryShellArtifacts as $shellArtifact ) { if ( ! is_array($shellArtifact) ) { @@ -1962,6 +1963,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d 'html' => $file['content'] ?? '', 'body_format' => $bodyFormat, 'block_markup' => $blockMarkup, + 'canonical_block_markup' => $canonicalBlockMarkup, 'shell_artifacts' => is_array($compiledBlocks['shell_artifacts'] ?? null) ? $compiledBlocks['shell_artifacts'] : array(), 'runtime_islands' => is_array($compiledBlocks['runtime_islands'] ?? null) ? $compiledBlocks['runtime_islands'] : array(), 'bytes' => $file['bytes'] ?? 0, @@ -2012,9 +2014,9 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d $shellArtifact['slug'] = 'entry-' . $slug; } $partSlugs[(string) $shellArtifact['slug']] = true; - if ( is_string($shellArtifact['inner_block_markup'] ?? null) ) { - $shellArtifact['block_markup'] = $shellArtifact['inner_block_markup']; - unset($shellArtifact['inner_block_markup']); + if ( is_string($shellArtifact['template_part_block_markup'] ?? null) ) { + $shellArtifact['block_markup'] = $shellArtifact['template_part_block_markup']; + unset($shellArtifact['inner_block_markup'], $shellArtifact['template_part_block_markup']); } $templateParts[] = $shellArtifact; } diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php b/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php index 1805cbc2..76c57f98 100644 --- a/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php +++ b/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php @@ -188,7 +188,7 @@ private function collectBlockNavigationLandmarks(array $blocks, array &$counts): continue; } - if ( 'core/navigation' === ($block['blockName'] ?? '') ) { + if ( 'core/navigation' === ($block['blockName'] ?? '') || $this->isStaticNavigationGroup($block) ) { ++$counts['nav']; } @@ -430,6 +430,17 @@ private function collectBlockNavigationMenus(array $blocks, string $path, array $menus[] = array( 'block_path' => $blockPath, 'represented_as_core_navigation' => true, + 'represented_as_native_navigation' => true, + 'item_count' => count($items), + 'items' => $items, + ); + } elseif ( $this->isStaticNavigationGroup($block) ) { + $items = array(); + $this->collectStaticNavigationItems(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(), $items); + $menus[] = array( + 'block_path' => $blockPath, + 'represented_as_core_navigation' => false, + 'represented_as_native_navigation' => true, 'item_count' => count($items), 'items' => $items, ); @@ -466,6 +477,66 @@ private function collectBlockNavigationItems(array $blocks, array &$items): void } } + /** @param array $block */ + private function isStaticNavigationGroup(array $block): bool + { + return 'core/group' === ($block['blockName'] ?? '') + && 'nav' === strtolower((string) ($block['attrs']['tagName'] ?? '')) + && in_array('blocks-engine-inline-navigation', preg_split('/\s+/', trim((string) ($block['attrs']['className'] ?? ''))) ?: array(), true) + && ! $this->containsCoreNavigation(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array()); + } + + /** @param array> $blocks */ + private function containsCoreNavigation(array $blocks): bool + { + foreach ( $blocks as $block ) { + if ( ! is_array($block) ) { + continue; + } + if ( 'core/navigation' === ($block['blockName'] ?? '') ) { + return true; + } + if ( $this->containsCoreNavigation(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array()) ) { + return true; + } + } + + return false; + } + + /** + * @param array> $blocks + * @param array> $items + */ + private function collectStaticNavigationItems(array $blocks, array &$items): void + { + foreach ( $blocks as $block ) { + if ( ! is_array($block) ) { + continue; + } + + $content = is_string($block['attrs']['content'] ?? null) + ? $block['attrs']['content'] + : (is_string($block['innerHTML'] ?? null) ? $block['innerHTML'] : ''); + if ( '' !== $content && preg_match_all('/]*)>(.*?)<\/a>/is', $content, $matches, PREG_SET_ORDER) ) { + foreach ( $matches as $match ) { + $href = ''; + if ( preg_match('/(?:^|\s)href\s*=\s*(["\'])(.*?)\1/is', (string) $match[1], $hrefMatch) ) { + $href = html_entity_decode((string) $hrefMatch[2], ENT_QUOTES | ENT_HTML5); + } + $items[] = array( + 'label' => $this->normalizedNavigationLabel((string) $match[2]), + 'url' => $href, + ); + } + } + + if ( ! empty($block['innerBlocks']) && is_array($block['innerBlocks']) ) { + $this->collectStaticNavigationItems($block['innerBlocks'], $items); + } + } + } + private function sourceNavigationAnchorLabel(DOMElement $anchor): string { $label = $this->normalizedNavigationLabel($anchor->textContent ?? ''); @@ -536,7 +607,7 @@ private function semanticParityFindings(array $sourceLandmarks, array $blockLand continue; } - if ( true !== ($blockMenu['represented_as_core_navigation'] ?? false) ) { + if ( true !== ($blockMenu['represented_as_native_navigation'] ?? $blockMenu['represented_as_core_navigation'] ?? false) ) { $findings[] = array( 'code' => 'navigation_core_block_missing', 'severity' => 'warning', diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index c2cbefdd..5a3571e3 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -355,6 +355,12 @@ final class HtmlTransformer /** @var array Source wrapper paths promoted into core/button. */ private array $sourceButtonPresentationMarkers = array(); + /** @var array */ + private array $sourceNavigationControlMarkers = array(); + + /** @var array */ + private array $sourceRichTextAnchorMarkers = array(); + /** @var array Source controls that need selector projection. */ private array $sourceControlPaths = array(); @@ -380,6 +386,9 @@ final class HtmlTransformer /** @var list */ private array $authorStylesheetAssets = array(); + /** @var array */ + private array $placeholderCaptionRules = array(); + /** A collision-checked custom element used solely to retain type specificity. */ private string $authorSpecificityShim = ''; @@ -451,6 +460,8 @@ public function transform(string $html, array $options = array()): TransformerRe $this->sourceTagMarkers = array(); $this->sourceControlMarkers = array(); $this->sourceButtonPresentationMarkers = array(); + $this->sourceNavigationControlMarkers = array(); + $this->sourceRichTextAnchorMarkers = array(); $this->sourceControlPaths = array(); $this->sourceSemanticMarkers = array(); $this->sourceRootChildMarkers = array(); @@ -461,6 +472,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->authorMarkerCounter = 0; $this->authorMarkerCollisionText = ''; $this->authorStylesheetAssets = array(); + $this->placeholderCaptionRules = array(); $this->authorSpecificityShim = ''; $this->authorClassSpecificityShim = ''; $this->authorIdSpecificityShim = ''; @@ -544,6 +556,8 @@ public function transform(string $html, array $options = array()): TransformerRe $interactionCandidates = $this->interactionCandidates($body); $this->collectSupersededNavToggleSelectors($body); $shellArtifacts = !array_key_exists('extract_global_shell', $options) || !empty($options['extract_global_shell']) ? $this->globalShellArtifacts($body, (string) ($options['source'] ?? 'html')) : array(); + // Shared shell identity must not vary by route-derived current state. + $this->markCurrentNavigationLinks($body, (string) ($options['source'] ?? '')); $blocks = $this->deduplicateNavigationBlocks($this->convertChildren($body, $fallbacks, true)); $this->recordRuntimeIslandsForPreservedHtmlBlocks($blocks); $this->appendInteractiveControlBehaviorLossFallbacks($body, $fallbacks); @@ -662,6 +676,9 @@ private function globalShellArtifacts(DOMElement $body, string $source, bool $re // landmark around an independently converted landmark block. $blocks = array($this->createBlock('core/group', $wrapperAttrs, $blocks, $child)); $markup = $this->runtime->serializeBlocks($blocks); + $templatePartBlocks = $blocks; + unset($templatePartBlocks[0]['attrs']['tagName']); + $templatePartMarkup = $this->runtime->serializeBlocks($templatePartBlocks); if ( '' === trim($markup) ) { continue; } @@ -673,6 +690,7 @@ private function globalShellArtifacts(DOMElement $body, string $source, bool $re 'body_format' => 'blocks', 'block_markup' => $markup, 'inner_block_markup' => $innerMarkup, + 'template_part_block_markup' => $templatePartMarkup, 'source_selector' => strtolower($child->tagName), 'source_classes' => $this->shellSourceClasses($child), 'source_hash' => hash('sha256', $this->outerHtml($child)), @@ -730,12 +748,20 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo $cssParts[] = $markerReset; } if ( str_contains($serializedBlocks, 'blocks-engine-list-navigation') ) { - $cssParts[] = '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item.wp-block-navigation-link{display:list-item;font:inherit}' + $cssParts[] = '.wp-block-navigation.blocks-engine-list-navigation{align-items:normal}' + . "\n" . '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item.wp-block-navigation-link{display:list-item;font:inherit}' . "\n" . '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item__content{display:inline}'; } if ( $includeAuthorStyles && '' !== $this->combinedAuthorCss ) { $cssParts[] = $this->rewriteAuthorStylesheet($this->combinedAuthorCss); } + if ( array() !== $this->placeholderCaptionRules ) { + $cssParts[] = implode("\n", $this->placeholderCaptionRules); + } + if ( str_contains($serializedBlocks, 'blocks-engine-propagated-link') || str_contains($serializedBlocks, 'blocks-engine-propagated-image-link') ) { + $cssParts[] = '.blocks-engine-propagated-link{background-color:transparent;color:inherit}.blocks-engine-propagated-link>a{color:inherit;border:0;text-decoration:inherit}' + . "\n" . '.blocks-engine-propagated-image-link>a{color:inherit;border:0;text-decoration:inherit}'; + } $css = trim(implode("\n\n", $cssParts)); if ( '' === $css ) { @@ -763,13 +789,80 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo ); } + private function markCurrentNavigationLinks(DOMElement $body, string $source): void + { + $sourcePath = $this->normalizedDocumentPath($source); + if ( '' === $sourcePath ) { + return; + } + + foreach ( $body->getElementsByTagName('a') as $anchor ) { + if ( ! $anchor instanceof DOMElement || ! $this->hasNavigationAncestor($anchor) ) { + continue; + } + + $href = trim($this->attr($anchor, 'href')); + if ( '' === $href || '#' === $href[0] || preg_match('#^[a-z][a-z0-9+.-]*:#i', $href) || str_starts_with($href, '//') ) { + continue; + } + + $hrefPath = parse_url($href, PHP_URL_PATH); + if ( ! is_string($hrefPath) || '' === $hrefPath ) { + continue; + } + + $targetPath = str_starts_with($hrefPath, '/') + ? $this->normalizedDocumentPath($hrefPath) + : $this->normalizedDocumentPath(dirname($sourcePath) . '/' . $hrefPath); + if ( $targetPath !== $sourcePath ) { + continue; + } + + $anchor->setAttribute('aria-current', 'page'); + $anchor->setAttribute('class', $this->mergeClassNames($this->attr($anchor, 'class'), 'active')); + } + } + + private function hasNavigationAncestor(DOMElement $element): bool + { + for ( $parent = $element->parentNode; $parent instanceof DOMElement; $parent = $parent->parentNode ) { + if ( 'nav' === strtolower($parent->tagName) || 'navigation' === strtolower(trim($this->attr($parent, 'role'))) ) { + return true; + } + } + + return false; + } + + private function normalizedDocumentPath(string $path): string + { + $urlPath = parse_url(str_replace('\\', '/', trim($path)), PHP_URL_PATH); + if ( ! is_string($urlPath) || '' === $urlPath ) { + return ''; + } + + $segments = array(); + foreach ( explode('/', ltrim($urlPath, '/')) as $segment ) { + if ( '' === $segment || '.' === $segment ) { + continue; + } + if ( '..' === $segment ) { + array_pop($segments); + continue; + } + $segments[] = $segment; + } + + return implode('/', $segments); + } + private function richTextMarkerResetCss(): string { if ( array() === $this->sourceRichTextSemanticMarkers ) { return ''; } - return ':where(mark)[style*="--blocks-engine-richtext-marker:"]{background-color:transparent;color:inherit}'; + return ':where(mark[class*="blocks-engine-richtext-"]){background-color:transparent;color:inherit}'; } /** @param array $options */ @@ -848,10 +941,15 @@ private function discoverAuthorInlineSemanticPaths(): void continue; } foreach ( $this->matchingAuthorSourceElements($parsed) as $element ) { - if ( 'span' !== strtolower($element->tagName) ) { + $tagName = strtolower($element->tagName); + $path = $this->sourceElementIdentity($element); + if ( 'a' === $tagName && $this->isRichTextInlineContext($element) ) { + $this->sourceRichTextAnchorMarkers[$path] ??= $this->allocateAuthorMarker('richtext-anchor'); + continue; + } + if ( 'span' !== $tagName ) { continue; } - $path = $this->sourceElementIdentity($element); if ( '' === $path ) { continue; } @@ -1139,12 +1237,18 @@ function (DOMElement $element) use ($shellTags): string { } $controls = array(); + $navigationControls = array(); $semanticLeaves = array(); $richTextLeaves = array(); + $richTextAnchors = array(); $hasNonProjected = false; foreach ( $matches as $element ) { $path = $element->getNodePath() ?? ''; - if ( isset($this->sourceControlMarkers[$path]) ) { + if ( isset($this->sourceNavigationControlMarkers[$path]) ) { + $navigationControls[] = $this->sourceNavigationControlMarkers[$path]; + } elseif ( isset($this->sourceRichTextAnchorMarkers[$path]) ) { + $richTextAnchors[] = $this->sourceRichTextAnchorMarkers[$path]; + } elseif ( isset($this->sourceControlMarkers[$path]) ) { $controls[] = $this->sourceControlMarkers[$path]; } elseif ( isset($this->sourceSemanticMarkers[$this->sourceElementIdentity($element)]) ) { $semanticLeaves[] = $this->sourceSemanticMarkers[$this->sourceElementIdentity($element)]; @@ -1155,26 +1259,37 @@ function (DOMElement $element) use ($shellTags): string { } } $controls = array_values(array_unique($controls)); + $navigationControls = array_values(array_unique($navigationControls)); $semanticLeaves = array_values(array_unique($semanticLeaves)); $richTextLeaves = array_values(array_unique($richTextLeaves)); - if ( array() === $controls && array() === $semanticLeaves && array() === $richTextLeaves ) { + $richTextAnchors = array_values(array_unique($richTextAnchors)); + if ( array() === $controls && array() === $navigationControls && array() === $semanticLeaves && array() === $richTextLeaves && array() === $richTextAnchors ) { $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed); continue; } - $projectedMarkers = array_merge($controls, $semanticLeaves, $richTextLeaves); - if ( $hasNonProjected ) { + $projectedMarkers = array_merge($controls, $semanticLeaves, $richTextLeaves, $richTextAnchors); + if ( $hasNonProjected && array() !== $projectedMarkers ) { $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed, ':not(:where(.' . implode(',.', $projectedMarkers) . '))'); } + if ( array() !== $navigationControls ) { + $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed); + } foreach ( $controls as $marker ) { $rewritten[] = $this->projectControlSelector($selector, $parsed, $marker, $controlWrapper); } + foreach ( $navigationControls as $marker ) { + $rewritten[] = $this->projectNavigationControlSelector($selector, $parsed, $marker); + } foreach ( $semanticLeaves as $marker ) { $rewritten[] = $this->projectSemanticLeafSelector($selector, $parsed, $marker); } foreach ( $richTextLeaves as $marker ) { $rewritten[] = $this->projectRichTextSemanticSelector($selector, $parsed, $marker); } + foreach ( $richTextAnchors as $marker ) { + $rewritten[] = $this->projectRichTextAnchorSelector($selector, $parsed, $marker); + } } return implode(',', $rewritten); } @@ -1270,6 +1385,20 @@ private function projectControlSelector(string $selector, array $parsed, string return ':where(.' . $marker . ')' . ($wrapper ? ':where(.wp-block-buttons)' : $this->selectorSpecificityShims($parsed) . '> :where(.wp-block-button__link)') . $suffix; } + /** @param array $parsed */ + private function projectNavigationControlSelector(string $selector, array $parsed, string $marker): string + { + $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']); + return '.wp-block-navigation-item.' . $marker . $this->selectorSpecificityShims($parsed) . '> .wp-block-navigation-item__content' . $suffix; + } + + /** @param array $parsed */ + private function projectRichTextAnchorSelector(string $selector, array $parsed, string $marker): string + { + $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']); + return 'mark.' . $marker . $this->selectorSpecificityShims($parsed) . '>a' . $suffix; + } + /** @param array $parsed */ private function projectSemanticLeafSelector(string $selector, array $parsed, string $marker): string { @@ -1281,7 +1410,7 @@ private function projectSemanticLeafSelector(string $selector, array $parsed, st private function projectRichTextSemanticSelector(string $selector, array $parsed, string $marker): string { $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']); - return 'mark[style*="--blocks-engine-richtext-marker:' . $marker . '"]' . $this->selectorSpecificityShims($parsed) . $suffix; + return 'mark.' . $marker . $this->selectorSpecificityShims($parsed) . $suffix; } /** @param array $parsed */ @@ -1652,6 +1781,17 @@ private function navigationUnderlineColor(DOMElement $item, DOMElement $anchor): ); } + /** @return array */ + private function svgPhrasingHostAttributes(DOMElement $element, string $imageMarkup): array + { + $className = $this->syntheticFlexItemClassName($element); + + return $this->paragraphAttributesForNonParagraphContent($element, array_filter(array( + 'content' => $imageMarkup, + 'className' => $className, + ), static fn (string $value): bool => '' !== $value), false); + } + /** * @param array> $fallbacks * @return array|null @@ -1673,7 +1813,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( 'svg' === $tagName && $this->svgNeedsPhrasingHost($element) ) { $imageMarkup = $this->inlineSvgRichTextImageMarkup($element); if ( null !== $imageMarkup ) { - return $this->createBlock('core/paragraph', array( 'content' => $imageMarkup ), array(), $element); + return $this->createBlock('core/paragraph', $this->svgPhrasingHostAttributes($element, $imageMarkup), array(), $element); } } @@ -1766,11 +1906,33 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return $inlineSvgTextGroup; } - if ( $this->hasAuthorSemanticMarker($element) ) { - $content = $this->innerHtml($element); + if ( $this->hasAuthorSemanticMarker($element) || $this->requiresIndependentSemanticWrapper($element) ) { + if ( $this->hasAuthorSemanticMarkedChild($element) ) { + $children = $this->convertChildren($element, $fallbacks, true); + if ( array() !== $children ) { + return $this->createBlock('core/group', $this->presentationAttributes($element), $children, $element); + } + } + $content = $this->richTextContentWithMaterializedInlineStyles($element); if ( '' !== trim($this->runtime->stripAllTags($content)) ) { + $paragraphAttrs = array( 'content' => $content ); + $parent = $element->parentNode instanceof DOMElement ? $element->parentNode : null; + $declarations = array_merge($this->structuralPresentationDeclarations($element), $this->presentationDeclarations($element)); + $hasSourceMargin = 0 < count(array_intersect(array_keys($declarations), array( 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left' ))); + $isPlainStructuralChild = $parent instanceof DOMElement + && $this->isStructuralLayoutElement($parent) + && ! $this->hasAuthorSemanticMarker($parent) + && ! $this->requiresIndependentSemanticWrapper($parent); + if ( ! $isPlainStructuralChild || ! $hasSourceMargin ) { + $paragraphAttrs['style']['spacing']['margin'] = array( + 'top' => '0', + 'right' => '0', + 'bottom' => '0', + 'left' => '0', + ); + } return $this->createBlock('core/group', $this->presentationAttributes($element), array( - $this->createBlock('core/paragraph', array( 'content' => $content )), + $this->createBlock('core/paragraph', $paragraphAttrs), ), $element); } } @@ -1783,9 +1945,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( 'transparent' === strtolower((string) ($declarations['-webkit-text-fill-color'] ?? '')) ) { $declarations['color'] = 'transparent'; } - $declarations['--blocks-engine-richtext-marker'] = $richTextMarker; + $style = $this->cssDeclarationString($declarations); return $this->createBlock('core/paragraph', array( - 'content' => '' . $content . '', + 'content' => '' . $content . '', ), array(), $element); } } @@ -1815,7 +1977,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return null; } - return $this->createBlock('core/paragraph', array( 'content' => $content )); + return $this->createBlock('core/paragraph', $this->paragraphAttributesForNonParagraphContent($element, array( 'content' => $content ), false)); } if ( 'ul' === $tagName || 'ol' === $tagName ) { @@ -2114,7 +2276,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( $this->svgNeedsPhrasingHost($element) ) { $imageMarkup = $this->inlineSvgRichTextImageMarkup($element); if ( null !== $imageMarkup ) { - return $this->createBlock('core/paragraph', array( 'content' => $imageMarkup ), array(), $element); + return $this->createBlock('core/paragraph', $this->svgPhrasingHostAttributes($element, $imageMarkup), array(), $element); } } $svgBlock = $this->inlineSvgBlockFromElement($element); @@ -2131,7 +2293,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( $this->svgNeedsPhrasingHost($element) ) { $imageMarkup = $this->inlineSvgRichTextImageMarkup($element); if ( null !== $imageMarkup ) { - return $this->createBlock('core/paragraph', array( 'content' => $imageMarkup ), array(), $element); + return $this->createBlock('core/paragraph', $this->svgPhrasingHostAttributes($element, $imageMarkup), array(), $element); } } @@ -2398,6 +2560,7 @@ private function convertMediaDispatchElement(DOMElement $element, string $tagNam $element, fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement), fn (string $value): string => $this->runtime->escapeHtml($value), + fn (DOMElement $sourceElement): array => $this->placeholderCaptionAttributes($sourceElement), fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) ); if ( null !== $placeholderMedia ) { @@ -2430,6 +2593,25 @@ private function convertMediaDispatchElement(DOMElement $element, string $tagNam return array( 'handled' => false, 'block' => null ); } + /** @return array */ + private function placeholderCaptionAttributes(DOMElement $element): array + { + $declarations = array(); + foreach ( $this->staticPseudoElementStyleRules as $rule ) { + if ( 'after' === ($rule['pseudo'] ?? '') && $this->matchesCssSelector($element, (string) ($rule['selector'] ?? '')) ) { + $declarations = array_merge($declarations, $rule['declarations'] ?? array()); + } + } + unset($declarations['content']); + $declarations['margin'] = '0'; + + $hash = substr(hash('sha256', json_encode($declarations, JSON_UNESCAPED_SLASHES) ?: ''), 0, 16); + $className = 'blocks-engine-placeholder-caption-' . $hash; + $this->placeholderCaptionRules[$className] = '.' . $className . '{' . $this->cssDeclarationString($declarations) . '}'; + + return array( 'className' => $className ); + } + /** * @return array|null */ @@ -2542,6 +2724,15 @@ private function createBlock(string $name, array $attrs = array(), array $innerB $this->sourceButtonPresentationMarkers[$presentationPath] = $this->sourceControlMarkers[$path]; } } + if ( 'core/navigation-link' === $name && 'a' === strtolower($logicalControl->tagName) && '' !== $this->combinedAuthorCss ) { + $path = $logicalControl->getNodePath() ?? ''; + if ( '' !== $path && ! isset($this->sourceNavigationControlMarkers[$path]) ) { + $this->sourceNavigationControlMarkers[$path] = $this->allocateAuthorMarker('navigation-control'); + } + if ( isset($this->sourceNavigationControlMarkers[$path]) ) { + $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), $this->sourceNavigationControlMarkers[$path]); + } + } $provenanceId = $this->nextSourceProvenanceId++; $this->recordPresentationProvenance($name, $attrs, $sourceElement); $this->recordStructureProvenance($name, $attrs, $sourceElement); @@ -2590,16 +2781,21 @@ private function authorSemanticMarkersForElement(DOMElement $element): array private function requiresIndependentSemanticWrapper(DOMElement $element): bool { - if ( 'span' !== strtolower($element->tagName) || $this->isRichTextInlineContext($element) ) { + if ( 'span' !== strtolower($element->tagName) || $this->isRichTextInlineContext($element) || 0 < $element->getElementsByTagName('svg')->length ) { return false; } $parent = $element->parentNode instanceof DOMElement ? $element->parentNode : null; - if ( ! $parent instanceof DOMElement || ! $this->isStructuralLayoutElement($parent) ) { + if ( ! $parent instanceof DOMElement ) { return false; } $declarations = array_merge($this->presentationDeclarations($element), $this->authorSemanticDeclarations($element)); + $structuralParent = $this->isStructuralLayoutElement($parent); + $authoredDeclarations = array_merge($this->authorSemanticDeclarations($element), $this->cssDeclarations($this->attr($element, 'style'))); + if ( ! $structuralParent && (! $this->isStandaloneInlineLeaf($element) || ! $this->requiresFlowVisualTokenWrapper($element, $authoredDeclarations)) ) { + return false; + } $display = strtolower(trim((string) ($declarations['display'] ?? 'inline'))); if ( ! in_array($display, array( '', 'inline', 'inherit', 'initial', 'unset' ), true) ) { return true; @@ -2614,6 +2810,51 @@ private function requiresIndependentSemanticWrapper(DOMElement $element): bool return false; } + /** @param array $declarations */ + private function requiresFlowVisualTokenWrapper(DOMElement $element, array $declarations): bool + { + $display = strtolower(trim((string) ($declarations['display'] ?? 'inline'))); + if ( in_array($display, array( 'inline-block', 'inline-flex', 'flex', 'grid', 'inline-grid' ), true) ) { + return true; + } + + foreach ( array( 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border', 'border-width', 'border-color', 'border-radius', 'background', 'background-color', 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height' ) as $property ) { + $value = strtolower(trim((string) ($declarations[$property] ?? ''))); + if ( '' !== $value + && ! in_array($value, array( 'none', 'auto', 'normal', 'transparent', 'initial', 'inherit', 'unset' ), true) + && 1 !== preg_match('/^0(?:[a-z%]*)?(?:\s+0(?:[a-z%]*)?){0,3}$/', $value) + && ! preg_match('/^rgba\([^)]*,\s*0(?:\.0+)?\)$/', $value) + ) { + return true; + } + } + + $spanSiblings = 0; + foreach ( $element->parentNode?->childNodes ?? array() as $sibling ) { + if ( $sibling instanceof DOMElement && 'span' === strtolower($sibling->tagName) ) { + ++$spanSiblings; + } + } + + return 1 < $spanSiblings && 'block' === $display; + } + + private function isStandaloneInlineLeaf(DOMElement $element): bool + { + $parent = $element->parentNode; + if ( ! $parent instanceof DOMElement ) { + return false; + } + + foreach ( $parent->childNodes as $sibling ) { + if ( XML_TEXT_NODE === $sibling->nodeType && '' !== trim($sibling->textContent ?? '') ) { + return false; + } + } + + return true; + } + private function isStructuralLayoutElement(DOMElement $element): bool { $declarations = array_merge($this->presentationDeclarations($element), $this->authorSemanticDeclarations($element)); @@ -2669,7 +2910,8 @@ private function richTextMarkerForElement(DOMElement $element): string return $marker; } - return $this->sourceRichTextSemanticMarkers[$this->sourceElementIdentity($element)] ?? ''; + $identity = $this->sourceElementIdentity($element); + return $this->sourceRichTextAnchorMarkers[$identity] ?? $this->sourceRichTextSemanticMarkers[$identity] ?? ''; } /** @@ -2944,10 +3186,13 @@ private function richTextRequiresHtmlFallback(string $content): bool return (bool) preg_match('/<(?:svg|canvas|img|picture|video|audio|iframe|object|embed|input|button|select|textarea|form)\b/i', $content); } - private function richTextContentWithMaterializedInlineStyles(DOMElement $element): string + /** @param list $excludedDescendantTags */ + private function richTextContentWithMaterializedInlineStyles(DOMElement $element, array $excludedDescendantTags = array()): string { - $content = $this->innerHtml($element); - if ( '' === $content || ! preg_match('/<(?:span|em|i|strong|b|mark|small|sub|sup)\b/i', $content) ) { + $content = array() === $excludedDescendantTags + ? $this->innerHtml($element) + : $this->innerHtmlWithoutTags($element, $excludedDescendantTags); + if ( '' === $content || ! preg_match('/<(?:a|span|em|i|strong|b|mark|small|sub|sup)\b/i', $content) ) { return $content; } @@ -2964,14 +3209,16 @@ private function richTextContentWithMaterializedInlineStyles(DOMElement $element $sourceInlines = array(); foreach ( $element->getElementsByTagName('*') as $sourceInline ) { - if ( $sourceInline instanceof DOMElement && in_array(strtolower($sourceInline->tagName), array( 'span', 'em', 'i', 'strong', 'b', 'mark', 'small', 'sub', 'sup' ), true) ) { + if ( $sourceInline instanceof DOMElement + && in_array(strtolower($sourceInline->tagName), array( 'a', 'span', 'em', 'i', 'strong', 'b', 'mark', 'small', 'sub', 'sup' ), true) + && ! $this->hasExcludedRichTextAncestor($sourceInline, $element, $excludedDescendantTags) ) { $sourceInlines[] = $sourceInline; } } $targetInlines = array(); foreach ( $body->getElementsByTagName('*') as $targetInline ) { - if ( $targetInline instanceof DOMElement && in_array(strtolower($targetInline->tagName), array( 'span', 'em', 'i', 'strong', 'b', 'mark', 'small', 'sub', 'sup' ), true) ) { + if ( $targetInline instanceof DOMElement && in_array(strtolower($targetInline->tagName), array( 'a', 'span', 'em', 'i', 'strong', 'b', 'mark', 'small', 'sub', 'sup' ), true) ) { $targetInlines[] = $targetInline; } } @@ -2985,7 +3232,17 @@ private function richTextContentWithMaterializedInlineStyles(DOMElement $element $inline = $this->richTextInlineVisualDeclarations($sourceInline); $marker = $this->richTextMarkerForElement($sourceInline); if ( '' !== $marker ) { - $inline['--blocks-engine-richtext-marker'] = $marker; + if ( 'a' === strtolower($targetInline->tagName) && isset($this->sourceRichTextAnchorMarkers[$this->sourceElementIdentity($sourceInline)]) ) { + $parent = $targetInline->parentNode; + if ( null !== $parent ) { + $mark = $document->createElement('mark'); + $mark->setAttribute('class', $marker); + $parent->replaceChild($mark, $targetInline); + $mark->appendChild($targetInline); + } + continue; + } + $targetInline->setAttribute('class', $this->mergeClassNames($this->attr($targetInline, 'class'), $marker)); } if ( array() === $inline ) { continue; @@ -2998,6 +3255,18 @@ private function richTextContentWithMaterializedInlineStyles(DOMElement $element return $this->innerHtml($body); } + /** @param list $excludedTags */ + private function hasExcludedRichTextAncestor(DOMElement $element, DOMElement $root, array $excludedTags): bool + { + for ( $parent = $element->parentNode; $parent instanceof DOMElement && $parent !== $root; $parent = $parent->parentNode ) { + if ( in_array(strtolower($parent->tagName), $excludedTags, true) ) { + return true; + } + } + + return false; + } + /** * @return array */ @@ -3021,6 +3290,7 @@ private function richTextInlineVisualDeclarations(DOMElement $element): array 'font-family', 'font-size', 'font-style', + 'font-variant-caps', 'font-weight', 'letter-spacing', 'line-height', @@ -3065,15 +3335,17 @@ private function replaceRichTextStylingHookWithMark(DOMElement $element): bool $declarations = $this->richTextInlineVisualDeclarations($element); $existingDeclarations = $this->cssDeclarations($this->attr($element, 'style')); - $marker = trim((string) ($existingDeclarations['--blocks-engine-richtext-marker'] ?? '')); + $marker = ''; + foreach ( preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array() as $className ) { + if ( preg_match('/^blocks-engine-richtext-[a-f0-9]+-\d+$/', $className) ) { + $marker = $className; + break; + } + } if ( '' === $marker && array() === $declarations ) { return false; } - if ( '' !== $marker ) { - $declarations['--blocks-engine-richtext-marker'] = $marker; - } - if ( '' === $marker && ! isset($declarations['background-color']) ) { $declarations['background-color'] = 'transparent'; } @@ -3087,6 +3359,9 @@ private function replaceRichTextStylingHookWithMark(DOMElement $element): bool } $mark = $document->createElement('mark'); + if ( '' !== $marker ) { + $mark->setAttribute('class', $marker); + } $mark->setAttribute('style', $this->cssDeclarationString($declarations)); while ( null !== $element->firstChild ) { $mark->appendChild($element->firstChild); @@ -3464,8 +3739,12 @@ private function textFlowBlockFromElement(DOMElement $element): ?array } $hasLineBreak = false; + $hasDirectText = false; foreach ( $element->childNodes as $child ) { if ( ! $child instanceof DOMElement ) { + if ( '' !== trim($child->textContent ?? '') ) { + $hasDirectText = true; + } continue; } @@ -3478,16 +3757,16 @@ private function textFlowBlockFromElement(DOMElement $element): ?array } } - if ( ! $hasLineBreak ) { + if ( ! $hasLineBreak && ! $hasDirectText ) { return null; } - $content = $this->richTextContentWithoutDecorativeSvg($element); + $content = $this->richTextContentWithMaterializedInlineStyles($element); if ( '' === trim($this->runtime->stripAllTags($content)) ) { return null; } - return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $content )), array(), $element); + return $this->createBlock('core/paragraph', $this->paragraphAttributesForNonParagraphContent($element, array_merge($this->presentationAttributes($element), array( 'content' => $content ))), array(), $element); } private function richTextContentWithoutDecorativeSvg(DOMElement $element): string @@ -3910,13 +4189,49 @@ private function paragraphBlockFromInlineContentWrapper(DOMElement $element): ?a return null; } - return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $content )), array(), $element); + $attrs = array_merge($this->presentationAttributes($element), array( 'content' => $content )); + $declarations = array_merge($this->structuralPresentationDeclarations($element), $this->presentationDeclarations($element)); + if ( array_filter(array_keys($declarations), static fn (string $property): bool => 'margin' === $property || str_starts_with($property, 'margin-')) ) { + $attrs = $this->paragraphAttributesForNonParagraphContent($element, $attrs); + } + + return $this->createBlock('core/paragraph', $attrs, array(), $element); + } + + /** + * A paragraph is the valid native host for non-paragraph source content. Its + * theme default margins are not part of that source box, so fill unspecified + * wrapper margins with zero while retaining margins projected onto the host. + * + * @param array $attrs + * @return array + */ + private function paragraphAttributesForNonParagraphContent(DOMElement $element, array $attrs, bool $sourcePresentationOnWrapper = true): array + { + $sourceMargin = $attrs['style']['spacing']['margin'] ?? array(); + $sourceMargin = is_array($sourceMargin) ? $sourceMargin : array(); + $declarations = $sourcePresentationOnWrapper + ? array_merge($this->structuralPresentationDeclarations($element), $this->presentationDeclarations($element)) + : array(); + foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) { + if ( ! array_key_exists($side, $sourceMargin) && (! $sourcePresentationOnWrapper || (! isset($declarations['margin']) && ! isset($declarations['margin-' . $side]))) ) { + $sourceMargin[$side] = '0'; + } + } + + return array_replace_recursive(array( + 'style' => array( + 'spacing' => array( + 'margin' => $sourceMargin, + ), + ), + ), $attrs); } private function hasAuthorSemanticMarkedChild(DOMElement $element): bool { foreach ( $element->childNodes as $child ) { - if ( $child instanceof DOMElement && $this->hasAuthorSemanticMarker($child) ) { + if ( $child instanceof DOMElement && ($this->hasAuthorSemanticMarker($child) || $this->requiresIndependentSemanticWrapper($child)) ) { return true; } } @@ -5517,7 +5832,7 @@ private function listItems(DOMElement $list, array &$fallbacks): array } } - $content = $this->innerHtmlWithoutTags($child, array( 'ul', 'ol' )); + $content = $this->richTextContentWithMaterializedInlineStyles($child, array( 'ul', 'ol' )); if ( '' === trim($this->runtime->stripAllTags($content)) && array() === $nested ) { continue; } @@ -5836,18 +6151,25 @@ private function runtimeCanvasSelectorsFromOptions(array $options): array private function isRuntimeDomTarget(DOMElement $element): bool { $id = trim($this->attr($element, 'id')); - if ( '' !== $id && isset($this->runtimeDomSelectors['#' . $id]) && ! $this->isPresentationalAnimationSelector('#' . $id) ) { + if ( '' !== $id + && isset($this->runtimeDomSelectors['#' . $id]) + && ! isset($this->supersededRuntimeSelectors['#' . $id]) + && ! $this->isPresentationalAnimationSelector('#' . $id) ) { return true; } foreach ( preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array() as $class ) { - if ( '' !== $class && isset($this->runtimeDomSelectors['.' . $class]) && ! $this->isPresentationalAnimationSelector('.' . $class) ) { + if ( '' !== $class + && isset($this->runtimeDomSelectors['.' . $class]) + && ! isset($this->supersededRuntimeSelectors['.' . $class]) + && ! $this->isPresentationalAnimationSelector('.' . $class) ) { return true; } } foreach ( array_keys($this->runtimeDomSelectors) as $selector ) { - if ( $this->isPresentationalAnimationSelector((string) $selector) ) { + if ( isset($this->supersededRuntimeSelectors[(string) $selector]) + || $this->isPresentationalAnimationSelector((string) $selector) ) { continue; } @@ -8080,6 +8402,8 @@ private function propagateLinkOntoImage(array &$block, array $linkAttrs): bool 'rel' => (string) ($linkAttrs['rel'] ?? ''), ), static fn (string $value): bool => '' !== trim($value)); + $imageLink['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), 'blocks-engine-propagated-image-link'); + $block = $this->rebuildBlock($block, array_merge($attrs, $imageLink)); return true; } @@ -8148,7 +8472,7 @@ private function wrapInlineLink(string $content, array $linkAttrs): string $attributes .= ' rel="' . htmlspecialchars($linkAttrs['rel'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"'; } - return '' . $content . ''; + return '' . $content . ''; } /** diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 38b30114..9653d549 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -44,6 +44,27 @@ public function match(DOMElement $element, PatternContext $context): ?array return null; } + if ( $this->isInlineUtilityNavigation($element) ) { + $content = $this->inlineUtilityNavigationContent($element); + if ( '' !== $content ) { + $paragraph = $createBlock('core/paragraph', array( + 'content' => $content, + 'style' => array( 'spacing' => array( 'margin' => array( + 'top' => '0', + 'right' => '0', + 'bottom' => '0', + 'left' => '0', + ) ) ), + )); + + $groupAttrs = $presentationAttributes($element); + $groupAttrs['tagName'] = 'nav'; + $groupAttrs['className'] = trim((string) ($groupAttrs['className'] ?? '') . ' blocks-engine-inline-navigation'); + + return $createBlock('core/group', $groupAttrs, array( $paragraph ), $element); + } + } + $links = $this->navigationBlocks($element, $presentationAttributes, $innerHtml, $createBlock, $isRuntimeDomTarget, false, $navigationUnderlineColor); if ( array() === $links ) { @@ -73,6 +94,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $navigationAttrs, $commonTextAttrs ); + $this->promoteDirectAnchorSpacing($element, $presentationAttributes, $navigationAttrs, $links); $navigation = $createBlock('core/navigation', $navigationAttrs, $links, $element); @@ -114,6 +136,56 @@ private function nestedLabeledNavigationAttributes(DOMElement $element, callable ); } + private function isInlineUtilityNavigation(DOMElement $element): bool + { + if ( 'nav' !== strtolower($element->tagName) || '' !== trim($this->attr($element, 'class')) || '' !== trim($this->attr($element, 'id')) || '' === trim($this->attr($element, 'aria-label')) ) { + return false; + } + + $anchorCount = 0; + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + if ( 'a' !== strtolower($child->tagName) ) { + return false; + } + $anchorCount++; + } + + return $anchorCount > 1; + } + + private function inlineUtilityNavigationContent(DOMElement $element): string + { + $anchors = array(); + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement || 'a' !== strtolower($child->tagName) ) { + continue; + } + + $href = $this->safeNavigationUrl($this->attr($child, 'href')); + if ( '' === $href ) { + continue; + } + + $attributes = ' href="' . htmlspecialchars($href, ENT_QUOTES | ENT_HTML5) . '"'; + foreach ( array( 'class', 'target', 'rel', 'aria-label' ) as $name ) { + $value = trim($this->attr($child, $name)); + if ( '' !== $value ) { + $attributes .= ' ' . $name . '="' . htmlspecialchars($value, ENT_QUOTES | ENT_HTML5) . '"'; + } + } + $label = trim((string) $child->textContent); + if ( '' === $label ) { + continue; + } + $anchors[] = '' . htmlspecialchars($label, ENT_QUOTES | ENT_HTML5) . ''; + } + + return implode(' ', $anchors); + } + /** * Core navigation links render text styles from their parent block context. * Promote only values shared by every link so mixed menus retain their own @@ -412,6 +484,11 @@ private function navigationItemAttributes(DOMElement $item, DOMElement $anchor, $itemAttrs = $item->isSameNode($anchor) ? array() : $this->withoutCoreNavigationClasses($presentationAttributes($item)); $anchorAttrs = $this->withoutCoreNavigationClasses($presentationAttributes($anchor)); $submenuAttrs = $submenuContainer instanceof DOMElement ? $this->withoutCoreNavigationClasses($presentationAttributes($submenuContainer)) : array(); + if ( $item->isSameNode($anchor) && isset($anchorAttrs['style']['spacing']['margin']) ) { + // Direct anchors become navigation items. Keep their item margins on + // the item rather than reinterpreting horizontal spacing as row gap. + $itemAttrs['style']['spacing']['margin'] = $anchorAttrs['style']['spacing']['margin']; + } if ( '' === (string) ($itemAttrs['className'] ?? '') && '' !== (string) ($anchorAttrs['className'] ?? '') ) { $itemAttrs['className'] = $anchorAttrs['className']; } @@ -515,25 +592,112 @@ private function navigationContainerAttributes(DOMElement $element, callable $pr if ( $this->isListNavigationSource($element) ) { $attrs['className'] = trim((string) ($attrs['className'] ?? '') . ' blocks-engine-list-navigation'); } - if ( '' !== (string) ($attrs['style']['spacing']['blockGap'] ?? '') ) { - return $attrs; - } + // core/navigation owns the item row. For a direct list, its declarations + // (rather than the nav wrapper's one-child layout) control that row. + $layoutOwner = $element; foreach ( $element->childNodes as $child ) { if ( ! $child instanceof DOMElement || ! in_array(strtolower($child->tagName), array( 'ul', 'ol' ), true) ) { continue; } - $listGap = (string) ($presentationAttributes($child)['style']['spacing']['blockGap'] ?? ''); - if ( '' !== $listGap ) { - $attrs['style']['spacing']['blockGap'] = $listGap; - break; - } + $layoutOwner = $child; + break; + } + + $ownerAttrs = $layoutOwner->isSameNode($element) ? $attrs : $this->withoutCoreNavigationClasses($presentationAttributes($layoutOwner)); + $ownerLayout = is_array($ownerAttrs['layout'] ?? null) ? $ownerAttrs['layout'] : array(); + $ownerGap = (string) ($ownerAttrs['style']['spacing']['blockGap'] ?? ''); + + if ( '' !== $ownerGap ) { + $attrs['style']['spacing']['blockGap'] = $ownerGap; + } else { + // Native navigation supplies a theme gap by default; source inline + // links and un-gapped list rows have no equivalent item spacing. + $attrs['style']['spacing']['blockGap'] = '0'; + } + + if ( 'flex' === ($ownerLayout['type'] ?? '') ) { + $attrs['layout'] = $ownerLayout; + } else { + $attrs['layout'] = array( 'type' => 'flex' ); + } + + if ( 'vertical' !== ($attrs['layout']['orientation'] ?? '') && ! isset($attrs['layout']['flexWrap']) ) { + // CSS flex rows default to nowrap, whereas core/navigation defaults to wrap. + $attrs['layout']['flexWrap'] = 'nowrap'; } return $attrs; } + /** + * Core navigation links do not reproduce sibling margins reliably. A uniform + * leading margin on every adjacent direct anchor is equivalent to row gap. + * + * @param array $containerAttrs + * @param array> $links + */ + private function promoteDirectAnchorSpacing(DOMElement $element, callable $presentationAttributes, array &$containerAttrs, array &$links): void + { + if ( 'vertical' === ($containerAttrs['layout']['orientation'] ?? '') ) { + return; + } + + $sourceAttrs = $this->withoutCoreNavigationClasses($presentationAttributes($element)); + if ( '' !== (string) ($sourceAttrs['style']['spacing']['blockGap'] ?? '') ) { + return; + } + + $anchors = array(); + foreach ( $element->childNodes as $child ) { + if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) ) { + $anchors[] = $child; + } + } + if ( count($anchors) < 2 || count($anchors) !== count($links) ) { + return; + } + + $leadingMargins = array_map(static function (array $link): string { + $margin = trim((string) ($link['attrs']['style']['spacing']['margin']['left'] ?? '')); + return '' === $margin ? '0' : $margin; + }, $links); + foreach ( $links as $link ) { + $trailingMargin = trim((string) ($link['attrs']['style']['spacing']['margin']['right'] ?? '')); + if ( '' !== $trailingMargin && ! in_array($trailingMargin, array( '0', '0px', '0rem', '0em' ), true) ) { + return; + } + } + $gap = $leadingMargins[1]; + if ( ! in_array($leadingMargins[0], array( '0', '0px', '0rem', '0em' ), true) || in_array($gap, array( '0', '0px', '0rem', '0em' ), true) ) { + return; + } + foreach ( array_slice($leadingMargins, 2) as $margin ) { + if ( $gap !== $margin ) { + return; + } + } + + $containerAttrs['style']['spacing']['blockGap'] = $gap; + foreach ( $links as $index => &$link ) { + if ( 0 === $index ) { + continue; + } + unset($link['attrs']['style']['spacing']['margin']['left']); + if ( empty($link['attrs']['style']['spacing']['margin']) ) { + unset($link['attrs']['style']['spacing']['margin']); + } + if ( empty($link['attrs']['style']['spacing']) ) { + unset($link['attrs']['style']['spacing']); + } + if ( empty($link['attrs']['style']) ) { + unset($link['attrs']['style']); + } + } + unset($link); + } + private function isListNavigationSource(DOMElement $element): bool { if ( in_array(strtolower($element->tagName), array( 'ul', 'ol' ), true) ) { @@ -896,7 +1060,23 @@ private function hasNavigationSignal(DOMElement $element): bool if ( in_array($token, array( 'nav', 'navbar', 'navigation', 'menu' ), true) ) { return true; } - if ( 'links' === $token && ! $this->isContactLinkCluster($element) ) { + if ( 'links' === $token && ! $this->isContactLinkCluster($element) && ! $this->hasCardLikeDirectAnchor($element) ) { + return true; + } + } + } + + return false; + } + + private function hasCardLikeDirectAnchor(DOMElement $element): bool + { + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement || 'a' !== strtolower($child->tagName) ) { + continue; + } + foreach ( $child->childNodes as $anchorChild ) { + if ( $anchorChild instanceof DOMElement && preg_match('/^(?:' . self::BLOCK_LEVEL_LABEL_TAGS . ')$/i', $anchorChild->tagName) ) { return true; } } diff --git a/php-transformer/src/HtmlToBlocks/Patterns/PlaceholderMediaPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/PlaceholderMediaPattern.php index dd19739b..3edf501e 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/PlaceholderMediaPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/PlaceholderMediaPattern.php @@ -12,10 +12,11 @@ final class PlaceholderMediaPattern /** * @param callable(DOMElement): array $presentationAttributes * @param callable(string): string $escapeHtml + * @param callable(DOMElement): array $captionAttributes * @param callable(string, array, array>, DOMElement|null): array $createBlock * @return array|null */ - public function match(DOMElement $element, callable $presentationAttributes, callable $escapeHtml, callable $createBlock): ?array + public function match(DOMElement $element, callable $presentationAttributes, callable $escapeHtml, callable $captionAttributes, callable $createBlock): ?array { if ( ! $this->isPlaceholderMediaElement($element) ) { return null; @@ -29,7 +30,7 @@ public function match(DOMElement $element, callable $presentationAttributes, cal unset($attrs['style']); $label = $this->placeholderLabel($element); - $children = '' !== $label ? array( $createBlock('core/paragraph', array( 'content' => $escapeHtml($label) ), array(), null) ) : array(); + $children = '' !== $label ? array( $createBlock('core/paragraph', array_merge($captionAttributes($element), array( 'content' => $escapeHtml($label) )), array(), null) ) : array(); return $createBlock('core/group', array_filter($attrs, static fn ($value): bool => is_array($value) ? array() !== $value : '' !== trim((string) $value)), $children, $element); } @@ -37,6 +38,12 @@ public function match(DOMElement $element, callable $presentationAttributes, cal private function isPlaceholderMediaElement(DOMElement $element): bool { $className = strtolower($this->attr($element, 'class')); + if ( 'img' === strtolower($this->attr($element, 'role')) + && '' !== trim($this->attr($element, 'aria-label')) + && '' !== trim($this->attr($element, 'data-caption')) + && 0 === $element->childElementCount ) { + return true; + } if ( ! preg_match('/(?:^|\s)(?:ph|placeholder|media-placeholder|image-placeholder|video-placeholder)(?:\s|$)/', $className) && ! preg_match('/(?:^|\s)ratio-[0-9]+(?:x|:|-)[0-9]+(?:\s|$)/', $className) ) { return false; } @@ -62,6 +69,10 @@ private function placeholderAspectRatio(DOMElement $element): string private function placeholderLabel(DOMElement $element): string { + $caption = trim($this->attr($element, 'data-caption')); + if ( '' !== $caption ) { + return $caption; + } foreach ( $element->getElementsByTagName('span') as $span ) { if ( ! $span instanceof DOMElement ) { continue; diff --git a/php-transformer/src/HtmlToBlocks/Patterns/QuotePattern.php b/php-transformer/src/HtmlToBlocks/Patterns/QuotePattern.php index 1aeb8063..fa742123 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/QuotePattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/QuotePattern.php @@ -53,7 +53,7 @@ public function matchBlockquote( $innerBlocks = $convertChildrenWithoutTags($element, $fallbacks, array( 'cite', 'footer' )); } if ( array() === $innerBlocks ) { - $innerBlocks[] = $createBlock('core/paragraph', array( 'content' => $value )); + $innerBlocks[] = $createBlock('core/paragraph', $this->syntheticParagraphAttributes($value)); } return $createBlock('core/quote', array_filter(array_merge($presentationAttributes($element), array( 'citation' => $citation )), static fn ($value): bool => '' !== $value), $innerBlocks, $element); @@ -118,7 +118,7 @@ public function matchFigureBlockquote( } $innerBlocks = array_merge($innerBlocks, $convertChildrenWithoutTags($blockquote, $fallbacks, array( 'cite', 'footer' ))); if ( array() === $innerBlocks ) { - $innerBlocks[] = $createBlock('core/paragraph', array( 'content' => $value )); + $innerBlocks[] = $createBlock('core/paragraph', $this->syntheticParagraphAttributes($value)); } return $createBlock('core/quote', $attrs, $innerBlocks, $figure); @@ -150,7 +150,27 @@ private function phrasingQuoteChildren(DOMElement $element, string $value, calla return array(); } - return array( $createBlock('core/paragraph', array( 'content' => $value )) ); + return array( $createBlock('core/paragraph', $this->syntheticParagraphAttributes($value)) ); + } + + /** + * @return array + */ + private function syntheticParagraphAttributes(string $content): array + { + return array( + 'content' => $content, + 'style' => array( + 'spacing' => array( + 'margin' => array( + 'top' => '0', + 'right' => '0', + 'bottom' => '0', + 'left' => '0', + ), + ), + ), + ); } } diff --git a/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php b/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php index 5c0b8b56..ed1f00c7 100644 --- a/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php +++ b/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php @@ -85,7 +85,7 @@ public static function matches(DOMElement $element, array $selector, bool $accou /** @return array{compound: array, suffix: array{start: int, end: int}|null, type_span: array{start: int, end: int, name: string}|null}|null */ private static function parseCompound(string $source, int $sourceStart, bool $isRightmost): ?array { - $compound = array( 'type' => null, 'universal' => false, 'classes' => array(), 'ids' => array(), 'attributes' => array(), 'nth_child' => null, 'first_child' => false, 'last_child' => false ); + $compound = array( 'type' => null, 'universal' => false, 'classes' => array(), 'ids' => array(), 'attributes' => array(), 'nth_child' => null, 'first_child' => false, 'last_child' => false, 'first_of_type' => false ); $offset = 0; $suffix = null; $typeSpan = null; @@ -115,6 +115,11 @@ private static function parseCompound(string $source, int $sourceStart, bool $is $hasSimple = true; continue; } + if ( 'first-of-type' === $lowerName && '(' !== ($source[ $offset ] ?? '') ) { + $compound['first_of_type'] = true; + $hasSimple = true; + continue; + } if ( 'nth-child' === $lowerName && '(' === ($source[ $offset ] ?? '') ) { $closing = strpos($source, ')', $offset + 1); if ( false === $closing ) { @@ -441,6 +446,13 @@ private static function matchesCompound(DOMElement $element, array $compound): b if ( $compound['last_child'] && self::hasNextElementSibling($element) ) { return false; } + if ( $compound['first_of_type'] ) { + for ( $previous = self::previousElementSibling($element); null !== $previous; $previous = self::previousElementSibling($previous) ) { + if ( 0 === strcasecmp($element->tagName, $previous->tagName) ) { + return false; + } + } + } return true; } diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index c245bccc..2e4e48a9 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -73,6 +73,9 @@ private function inlineGeometryProperties(): array 'flex-basis', 'object-fit', 'object-position', + 'gap', + 'row-gap', + 'column-gap', ); } @@ -233,11 +236,16 @@ private function hasConditionalStyleFamily(DOMElement $element, string $family): private function inlineGeometryClassName(DOMElement $element, array $excludedProperties = array()): string { $declarations = $this->cssDeclarations($this->attr($element, 'style')); + $isInlineFlex = preg_match('/^inline-flex$|^flex$/i', trim((string) ($declarations['display'] ?? ''))) === 1; + $carriesFlexGap = $isInlineFlex && 'a' === strtolower($element->tagName) && 0 < $element->getElementsByTagName('svg')->length; $geometry = array(); foreach ($this->inlineGeometryProperties() as $property) { if (in_array($property, $excludedProperties, true)) { continue; } + if (in_array($property, array('gap', 'row-gap', 'column-gap'), true) && ! $carriesFlexGap) { + continue; + } $rawValue = trim((string) ($declarations[$property] ?? '')); if (1 === preg_match('/\s*!important\s*$/i', $rawValue)) { continue; @@ -255,6 +263,10 @@ private function inlineGeometryClassName(DOMElement $element, array $excludedPro ksort($geometry); $declarations = array(); foreach ($geometry as $property => $value) { + if (in_array($property, array('gap', 'row-gap', 'column-gap'), true)) { + $declarations[] = $property . ':' . $value; + continue; + } // A converted inline declaration must continue to outrank authored // normal selectors, including ID selectors. Authored !important // rules retain their normal cascade priority through specificity. @@ -286,12 +298,17 @@ private function geometryStructuralPath(DOMElement $element): string private function inlineGeometryStyle(DOMElement $element, array $excludedProperties = array()): string { $declarations = $this->cssDeclarations($this->attr($element, 'style')); + $isInlineFlex = preg_match('/^inline-flex$|^flex$/i', trim((string) ($declarations['display'] ?? ''))) === 1; + $carriesFlexGap = $isInlineFlex && 'a' === strtolower($element->tagName) && 0 < $element->getElementsByTagName('svg')->length; $style = array(); $geometryValues = array(); foreach ($this->inlineGeometryProperties() as $property) { if (in_array($property, $excludedProperties, true)) { continue; } + if (in_array($property, array('gap', 'row-gap', 'column-gap'), true) && ! $carriesFlexGap) { + continue; + } $value = trim((string) ($declarations[$property] ?? '')); $geometryValues[] = $value; if (1 === preg_match('/\s*!important\s*$/i', $value)) { @@ -336,6 +353,71 @@ private function generatedGeometryCss(string $serializedBlocks): string return implode("\n", $rules); } + private function syntheticFlexItemClassName(DOMElement $element): string + { + $parent = $element->parentNode; + if ( ! $parent instanceof DOMElement || ! in_array(strtolower((string) ($this->structuralPresentationDeclarations($parent)['display'] ?? '')), array( 'flex', 'inline-flex' ), true) ) { + return ''; + } + + $flexShrink = trim((string) ($this->structuralPresentationDeclarations($element)['flex-shrink'] ?? '')); + if ( '' === $flexShrink || preg_match('/[{}<>;]/', $flexShrink) ) { + return ''; + } + + $rule = 'flex-shrink:' . $flexShrink; + $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\nsynthetic-flex-item\n" . $rule); + $this->generatedGeometryRules[$className] = '.' . $className . '{' . $rule . '}'; + + return $className; + } + + private function blockifiedLinkClassName(DOMElement $element): string + { + $parent = $element->parentNode; + $parentDisplay = $parent instanceof DOMElement + ? strtolower(trim((string) ($this->structuralPresentationDeclarations($parent)['display'] ?? ''))) + : ''; + if ( ! in_array($parentDisplay, array( 'flex', 'inline-flex', 'grid', 'inline-grid' ), true) ) { + return ''; + } + + $sourceDisplay = strtolower(trim((string) ($this->structuralPresentationDeclarations($element)['display'] ?? ''))); + $display = match ( $sourceDisplay ) { + 'flex', 'inline-flex' => 'flex', + 'grid', 'inline-grid' => 'grid', + default => 'block', + }; + $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\nblockified-link\n" . $display); + $this->generatedGeometryRules[$className] = '.' . $className . '>a{display:' . $display . '}'; + + return $className; + } + + private function buttonLinkLayoutClassName(DOMElement $element): string + { + $inline = $this->cssDeclarations($this->attr($element, 'style')); + $declarations = array_intersect_key($inline, array_flip(array( + 'display', + 'align-items', + 'justify-content', + 'flex-direction', + 'flex-wrap', + 'gap', + 'row-gap', + 'column-gap', + ))); + if ( array() === $declarations ) { + return ''; + } + + $rule = $this->cssDeclarationString($declarations); + $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\nbutton-link-layout\n" . $rule); + $this->generatedGeometryRules[$className] = '.' . $className . '> .wp-block-button__link{' . $rule . '}'; + + return $className; + } + /** * @return array */ @@ -794,10 +876,12 @@ private function safeVisualDeclarations(array $declarations): array 'column-gap', 'display', 'flex-direction', + 'flex-shrink', 'flex-wrap', 'font-family', 'font-size', 'font-style', + 'font-variant-caps', 'font-weight', 'letter-spacing', 'gap', @@ -821,9 +905,14 @@ private function safeVisualDeclarations(array $declarations): array 'padding-right', 'padding-top', 'position', + 'bottom', + 'left', + 'right', + 'top', 'row-gap', 'text-align', 'text-decoration', + 'text-shadow', 'text-transform', 'width', 'z-index', @@ -951,6 +1040,10 @@ private function layoutAttribute(DOMElement $element, string $mergedStyle = ''): $flexWrap = $this->layoutFlexWrap((string) ($inlineDeclarations['flex-wrap'] ?? $mergedDeclarations['flex-wrap'] ?? '')); if ( '' !== $flexWrap ) { $layout['flexWrap'] = $flexWrap; + } elseif ( 'a' === strtolower($element->tagName) && 0 < $element->getElementsByTagName('svg')->length ) { + // CSS flex rows default to nowrap, while core/group's Row layout + // defaults to wrapping after linked SVG content is decomposed. + $layout['flexWrap'] = 'nowrap'; } return $layout; diff --git a/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php b/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php index b1b2e78e..d3316cad 100644 --- a/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php @@ -27,6 +27,14 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null, ?DOMElement $logicalSourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement, $logicalSourceElement) ); if ( null !== $logo ) { + $button = $logo['innerBlocks'][0] ?? null; + $layoutClass = $this->buttonLinkLayoutClassName($element); + if ( is_array($button) && '' !== $layoutClass ) { + $buttonAttrs = is_array($button['attrs'] ?? null) ? $button['attrs'] : array(); + $buttonAttrs['className'] = $this->mergePresentationClassNames((string) ($buttonAttrs['className'] ?? ''), $layoutClass); + $logo['innerBlocks'][0] = $this->rebuildBlock($button, $buttonAttrs); + $logo = $this->rebuildBlock($logo, is_array($logo['attrs'] ?? null) ? $logo['attrs'] : array()); + } return $logo; } @@ -45,7 +53,7 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb } if ( '' === trim($element->textContent ?? '') && '' !== $this->safeLinkUrl($this->attr($element, 'href')) && '' !== trim($this->attr($element, 'aria-label')) ) { - return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) )), array(), $element); + return $this->createBlock('core/paragraph', $this->paragraphAttributesForNonParagraphContent($element, array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) ))), array(), $element); } if ( '' === trim($element->textContent ?? '') ) { @@ -62,7 +70,9 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb // A non-button anchor has no native width support. Promote its source // presentation to the paragraph wrapper so generated geometry remains // attached to the rendered block rather than being silently discarded. - return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) )), array(), $element); + $attrs = $this->presentationAttributes($element); + $attrs['className'] = $this->mergePresentationClassNames((string) ($attrs['className'] ?? ''), $this->blockifiedLinkClassName($element)); + return $this->createBlock('core/paragraph', $this->paragraphAttributesForNonParagraphContent($element, array_merge($attrs, array( 'content' => $this->outerHtml($element) ))), array(), $element); } /** diff --git a/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressionTrait.php b/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressionTrait.php index e4a7da16..92cb8b07 100644 --- a/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressionTrait.php @@ -56,10 +56,10 @@ private function collectSupersededNavToggleSelectors(DOMElement $root): void /** * Record the source selectors made redundant when a hamburger menu-toggle is * dropped in favor of the native navigation overlay: the toggle's own id and - * class selectors, plus the id/class selectors of the menu/overlay it - * controlled via `aria-controls`. A preserved site script may still reference - * these selectors (e.g. `.nav-toggle`, `#nav-mobile`); the runtime-dependency - * parity report uses this set to mark a resulting "missing DOM target" + * class selectors, plus the id/class selectors of a native-convertible + * menu/overlay it controlled via `aria-controls`. A preserved site script may + * still reference these selectors (e.g. `.nav-toggle`, `#nav-mobile`); the + * runtime-dependency parity report uses this set to mark a resulting "missing DOM target" * finding as a superseded, acceptable loss rather than a materialization bug. * Only selectors of menu-toggles the transformer actually removed are * recorded, so genuinely-broken targets stay flagged. @@ -74,10 +74,10 @@ private function recordSupersededNavToggleSelectors(DOMElement $toggle): void continue; } - $this->supersededRuntimeSelectors['#' . $controlledId] = true; - $target = $this->elementWithId($toggle, $controlledId); - if ( $target instanceof DOMElement && ! $target->isSameNode($toggle) ) { + if ( $target instanceof DOMElement + && ! $target->isSameNode($toggle) + && $this->convertsToCoreNavigation($target) ) { $this->recordSupersededSelectorsForElement($target); } } diff --git a/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php b/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php index 93bf470b..340acb97 100644 --- a/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php @@ -77,7 +77,7 @@ private function inlineSvgImageAttributesFromMarkup(DOMElement $element, string return null; } - $html = $this->ensureSvgImageNamespace($this->minifyInlineSvgForImage($html)); + $html = $this->ensureSvgImageNamespace($this->minifyInlineSvgForImage($this->stripProjectedSvgImageBoxStyle($html, $element))); $path = $this->materializedInlineSvgPath($element, $html); $this->generatedAssets[$path] = array( 'source' => 'inline-svg', @@ -339,6 +339,25 @@ private function minifyInlineSvgForImage(string $html): string return trim($html); } + private function stripProjectedSvgImageBoxStyle(string $html, DOMElement $element): string + { + if ( ! preg_match('/]*\sstyle\s*=\s*(["\'])(.*?)\1/i', $html, $match) ) { + return $html; + } + + $declarations = $this->cssDeclarations($match[2]); + $authoredDeclarations = $this->cssDeclarations($this->attr($element, 'style')); + foreach ( array( 'aspect-ratio', 'display', 'height', 'max-height', 'max-width', 'min-height', 'min-width', 'width' ) as $property ) { + if ( ! array_key_exists($property, $authoredDeclarations) ) { + unset($declarations[$property]); + } + } + $style = $this->cssDeclarationString($declarations); + $replacement = '' === $style ? '' : ' style="' . htmlspecialchars($style, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"'; + + return preg_replace('/\sstyle\s*=\s*(["\'])(.*?)\1/i', $replacement, $html, 1) ?? $html; + } + private function ensureSvgImageNamespace(string $html): string { if ( preg_match('/]*\sxmlns\s*=/i', $html) ) { diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 81b76787..336a4dc2 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -227,7 +227,10 @@ private function documents(mixed $documents, bool $part, array $tokens, AssetRef if ( ! is_array($document) || ! self::safePath($document['source_path'] ?? null) || ! is_string($document['block_markup'] ?? null) || '' === trim($document['block_markup']) ) { throw new InvalidArgumentException('Compiled site document lacks a safe identity or block markup.'); } - $markup = $references->content($document['block_markup'], $document['source_path']); + $sourceMarkup = is_string($document['canonical_block_markup'] ?? null) + ? $document['canonical_block_markup'] + : $document['block_markup']; + $markup = $references->content($sourceMarkup, $document['source_path']); $canonical = $this->routeLinks($markup, $document['source_path'], $routes); $target = $part ? 'parts/' . self::value($document, 'slug') . '.html' : self::value($document, 'source_path'); $row = array('source_path' => $document['source_path'], 'slug' => self::value($document, 'slug'), 'title' => self::value($document, 'title'), 'post_type' => self::value((array) ($document['metadata'] ?? array()), 'post_type', 'page'), 'parent_source_path' => self::value((array) ($document['metadata'] ?? array()), 'parent_source_path'), 'entrypoint' => ! empty($document['entrypoint']), 'area' => $part ? self::value($document, 'area', 'uncategorized') : null, 'placement' => $part && is_array($document['placement'] ?? null) ? $document['placement'] : ($part ? array('kind' => 'unbound') : null), 'canonical_block_markup' => $canonical, 'metadata' => is_array($document['metadata'] ?? null) ? $document['metadata'] : array(), 'document_metadata' => $this->documentMetadata($document, $references, $routes), 'provenance' => is_array($document['provenance'] ?? null) ? $document['provenance'] : array(), 'reconciliation_identity' => self::identity($part ? 'template-part' : 'page', $document['source_path'], $target), 'content_hash' => self::contentHash($canonical)); @@ -278,8 +281,8 @@ private function sharedShells(array $pages, array $reservedSlugs = array(), arra continue; } $first = $candidates[array_key_first($candidates)][0]; - $identity = hash('sha256', $area . "\0" . json_encode($first['classes']) . "\0" . $first['markup']); - foreach ($candidates as $rows) if ($identity !== hash('sha256', $area . "\0" . json_encode($rows[0]['classes']) . "\0" . $rows[0]['markup'])) { + $identity = hash('sha256', $area . "\0" . json_encode($first['classes']) . "\0" . $this->shellIdentityMarkup($first['markup'])); + foreach ($candidates as $rows) if ($identity !== hash('sha256', $area . "\0" . json_encode($rows[0]['classes']) . "\0" . $this->shellIdentityMarkup($rows[0]['markup']))) { $diagnostics[] = array('code' => 'wordpress_site_plan_shell_retained_ambiguous', 'severity' => 'info', 'message' => "{$area} shell candidates are not semantically equivalent across every page.", 'area' => $area); continue 2; } @@ -325,6 +328,12 @@ private function sharedShells(array $pages, array $reservedSlugs = array(), arra return array('pages' => $pages, 'parts' => $parts, 'diagnostics' => $diagnostics); } + private function shellIdentityMarkup(string $markup): string + { + $normalized = preg_replace('/\b(blocks-engine-[a-z0-9-]+)-[a-f0-9]{12}-\d+\b/', '$1-{identity}', $markup) ?? $markup; + return preg_replace('/(?:"className":"blocks-engine-[a-z0-9-]+-\{identity\}",|,"className":"blocks-engine-[a-z0-9-]+-\{identity\}"|"className":"blocks-engine-[a-z0-9-]+-\{identity\}")/', '', $normalized) ?? $normalized; + } + private function withoutTopLevelShell(string $markup, string $area): ?string { return $this->replaceTopLevelShell($markup, $area, ''); @@ -357,6 +366,7 @@ private function assets(mixed $assets): array { if ( ! is_array($assets) ) throw new InvalidArgumentException('Compiled site assets must be an array.'); $rows = array(); + $rowsBySource = array(); foreach ( $assets as $asset ) { if ( ! is_array($asset) || ! self::safePath($asset['path'] ?? null) ) throw new InvalidArgumentException('Compiled site asset lacks a safe source identity.'); // The compiler retains rejected source assets for diagnostics. They have no @@ -367,7 +377,14 @@ private function assets(mixed $assets): array $target = 'assets/' . str_replace('\\', '/', $compiledTarget); if ( ! self::safePath($target) ) throw new InvalidArgumentException('Compiled site asset lacks a safe target identity.'); $payload = is_string($asset['content_base64'] ?? null) ? $asset['content_base64'] : (string) ($asset['content'] ?? ''); - $rows[] = array('source_path' => $asset['path'], 'target_path' => $target, 'token' => 'asset-' . substr(hash('sha256', $target), 0, 16), 'source' => self::value($asset, 'source'), 'kind' => self::value($asset, 'kind'), 'role' => self::value($asset, 'role'), 'intent' => self::value($asset, 'intent'), 'mime_type' => self::value($asset, 'mime_type'), 'media' => self::value($asset, 'media'), 'bytes' => (int) ($asset['bytes'] ?? 0), 'hash' => self::value($asset, 'hash'), 'content' => $asset['content'] ?? null, 'content_base64' => $asset['content_base64'] ?? null, 'binary' => ! empty($asset['binary']), 'reconciliation_identity' => self::identity('asset', $asset['path'], $target), 'content_hash' => self::contentHash($payload)); + $row = array('source_path' => $asset['path'], 'target_path' => $target, 'token' => 'asset-' . substr(hash('sha256', $target), 0, 16), 'source' => self::value($asset, 'source'), 'kind' => self::value($asset, 'kind'), 'role' => self::value($asset, 'role'), 'intent' => self::value($asset, 'intent'), 'mime_type' => self::value($asset, 'mime_type'), 'media' => self::value($asset, 'media'), 'bytes' => (int) ($asset['bytes'] ?? 0), 'hash' => self::value($asset, 'hash'), 'content' => $asset['content'] ?? null, 'content_base64' => $asset['content_base64'] ?? null, 'binary' => ! empty($asset['binary']), 'reconciliation_identity' => self::identity('asset', $asset['path'], $target), 'content_hash' => self::contentHash($payload)); + $sourceIdentity = str_replace('\\', '/', strtolower($row['source_path'])); + if ( isset($rowsBySource[$sourceIdentity]) ) { + if ( $rowsBySource[$sourceIdentity] !== $row ) throw new InvalidArgumentException('Compiled site assets have colliding source identities with different payloads.'); + continue; + } + $rowsBySource[$sourceIdentity] = $row; + $rows[] = $row; } return $rows; } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index cf39a899..09e2bfee 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -573,7 +573,18 @@ public function match(DOMElement $element, PatternContext $context): ?array '
' )->toArray(); $classSizedInlineSvgCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $classSizedInlineSvgArtwork['assets'] ?? array())); +$classSizedInlineSvgAssets = array_values(array_filter($classSizedInlineSvgArtwork['assets'] ?? array(), static fn (array $asset): bool => 'svg' === ($asset['kind'] ?? ''))); $assert(str_contains($classSizedInlineSvgCss, '>img{display:inline;vertical-align:baseline;width:100%}'), 'inline SVG core/image applies class-owned responsive width to the image element'); +$assert(1 === count($classSizedInlineSvgAssets) && ! str_contains((string) ($classSizedInlineSvgAssets[0]['content'] ?? ''), 'style="width:100%"'), 'external SVG asset omits stylesheet-projected media-box declarations already carried by the native image'); + +$inlineStyledSvgArtwork = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$inlineStyledSvgAssets = array_values(array_filter($inlineStyledSvgArtwork['assets'] ?? array(), static fn (array $asset): bool => 'svg' === ($asset['kind'] ?? ''))); +$inlineStyledSvgContent = (string) ($inlineStyledSvgAssets[0]['content'] ?? ''); +$assert(1 === count($inlineStyledSvgAssets), 'authored inline SVG geometry materializes one generated SVG asset'); +$assert(str_contains($inlineStyledSvgContent, 'display:block') && str_contains($inlineStyledSvgContent, 'width:120px') && str_contains($inlineStyledSvgContent, 'height:80px') && str_contains($inlineStyledSvgContent, 'aspect-ratio:3/2'), 'external SVG assets preserve authored inline media-box declarations'); +$assert(str_contains($inlineStyledSvgContent, 'color:#123456'), 'external SVG assets preserve authored non-geometry declarations'); $emptyVisualCluster = ( new HtmlTransformer() )->transform( '
' @@ -869,6 +880,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(array() === ($rubyResult['fallbacks'] ?? array()), 'ruby phrasing content does not create unsupported fallbacks'); $assert('core/quote' === ($rubyQuote['blockName'] ?? ''), 'ruby phrasing content remains inside quote block'); $assert(str_contains((string) ($rubyResult['serialized_blocks'] ?? ''), '翻訳ほんやく'), 'ruby markup is preserved in quote content'); +$assert(str_contains((string) ($rubyResult['serialized_blocks'] ?? ''), 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'quote paragraphs synthesized around direct phrasing content add no source-absent margins'); $plaintextResult = ( new HtmlTransformer() )->transform( '

Before

Plain legacy text with &lt;b&gt;literal tags&lt;/b&gt;</PLAINTEXT><p>After</p>' @@ -1220,6 +1232,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $listGapNavigationBlock = $listGapNavigation['blocks'][0] ?? array(); $listGapNavigationSerialized = (string) ($listGapNavigation['serialized_blocks'] ?? ''); $assert('0' === ($listGapNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'direct navigation list gap projects onto core/navigation'); +$assert('nowrap' === ($listGapNavigationBlock['attrs']['layout']['flexWrap'] ?? ''), 'direct navigation list preserves its implicit non-wrapping row'); $assert('pass' === ($listGapNavigation['source_reports']['semantic_parity']['status'] ?? ''), 'direct navigation list gap preserves semantic parity'); $assert('pass' === ($listGapNavigation['source_reports']['wp_block_validity']['status'] ?? ''), 'direct navigation list gap serializes to a valid WordPress block'); $assert(str_contains($listGapNavigationSerialized, '<!-- wp:navigation ') && str_contains($listGapNavigationSerialized, '"blockGap":"0"'), 'direct navigation list gap uses canonical dynamic navigation serialization'); @@ -1227,7 +1240,110 @@ public function match(DOMElement $element, PatternContext $context): ?array $outerGapNavigation = ( new HtmlTransformer() )->transform( '<nav style="gap:1rem"><ul style="gap:0"><li><a href="/one">One</a></li><li><a href="/two">Two</a></li></ul></nav>' )->toArray(); -$assert('1rem' === ($outerGapNavigation['blocks'][0]['attrs']['style']['spacing']['blockGap'] ?? ''), 'outer navigation gap takes precedence over direct list gap'); +$assert('0' === ($outerGapNavigation['blocks'][0]['attrs']['style']['spacing']['blockGap'] ?? ''), 'direct list gap owns item spacing over its outer one-child navigation wrapper'); + +$directAnchorNavigation = ( new HtmlTransformer() )->transform( + '<nav><a href="/one">One</a><a href="/two">Two</a></nav>' +)->toArray(); +$directAnchorNavigationBlock = $directAnchorNavigation['blocks'][0] ?? array(); +$assert('0' === ($directAnchorNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'direct anchor navigation preserves its implicit zero item gap'); +$assert('nowrap' === ($directAnchorNavigationBlock['attrs']['layout']['flexWrap'] ?? ''), 'direct anchor navigation preserves its implicit non-wrapping row'); + +$inlineUtilityNavigation = ( new HtmlTransformer() )->transform( + '<nav aria-label="Utility"><a href="/library">Library</a><a href="/portal">Portal &amp; tools</a></nav>' +)->toArray(); +$inlineUtilityNavigationBlock = $inlineUtilityNavigation['blocks'][0] ?? array(); +$inlineUtilityNavigationParagraph = $inlineUtilityNavigationBlock['innerBlocks'][0] ?? array(); +$inlineUtilityNavigationParity = $inlineUtilityNavigation['source_reports']['semantic_parity'] ?? array(); +$assert('core/group' === ($inlineUtilityNavigationBlock['blockName'] ?? '') && 'nav' === ($inlineUtilityNavigationBlock['attrs']['tagName'] ?? '') && str_contains((string) ($inlineUtilityNavigationBlock['attrs']['className'] ?? ''), 'blocks-engine-inline-navigation'), 'anonymous labeled direct-link navigation retains inline source flow in a native semantic group'); +$assert('core/paragraph' === ($inlineUtilityNavigationParagraph['blockName'] ?? '') && str_contains((string) ($inlineUtilityNavigationParagraph['innerHTML'] ?? ''), 'Portal &amp; tools'), 'inline utility navigation keeps escaped editable links in one source-equivalent line box'); +$assert('pass' === ($inlineUtilityNavigationParity['status'] ?? '') && 1 === ($inlineUtilityNavigationParity['landmarks']['blocks']['nav'] ?? 0), 'native static navigation groups satisfy landmark semantic parity'); +$assert(2 === ($inlineUtilityNavigationParity['navigation_menus']['blocks'][0]['item_count'] ?? 0) && false === ($inlineUtilityNavigationParity['navigation_menus']['blocks'][0]['represented_as_core_navigation'] ?? true), 'native static navigation groups preserve menu labels and URLs in semantic parity'); + +$unsafeInlineUtilityNavigation = ( new HtmlTransformer() )->transform( + '<nav aria-label="Utility"><a href="/safe">Safe</a><a href="javascript:alert(1)">Unsafe</a></nav>' +)->toArray(); +$assert(! str_contains((string) ($unsafeInlineUtilityNavigation['serialized_blocks'] ?? ''), 'javascript:'), 'inline utility navigation drops unsafe link URLs'); + +$uniformMarginNavigation = ( new HtmlTransformer() )->transform( + '<nav><a href="/one">One</a><a href="/two" style="margin-left:18px">Two</a><a href="/three" style="margin-left:18px">Three</a></nav>' +)->toArray(); +$uniformMarginNavigationBlock = $uniformMarginNavigation['blocks'][0] ?? array(); +$uniformMarginNavigationLinks = $uniformMarginNavigationBlock['innerBlocks'] ?? array(); +$assert('18px' === ($uniformMarginNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'uniform adjacent direct-anchor margins become core navigation gap'); +$assert('nowrap' === ($uniformMarginNavigationBlock['attrs']['layout']['flexWrap'] ?? ''), 'uniform adjacent direct-anchor margins retain the non-wrapping row'); +$assert(! isset($uniformMarginNavigationLinks[1]['attrs']['style']['spacing']['margin']['left']) && ! isset($uniformMarginNavigationLinks[2]['attrs']['style']['spacing']['margin']['left']), 'promoted direct-anchor margins are removed from navigation links to avoid duplicate spacing'); + +$explicitGapNavigation = ( new HtmlTransformer() )->transform( + '<nav style="display:flex;gap:4px"><a href="/one">One</a><a href="/two" style="margin-left:18px">Two</a><a href="/three" style="margin-left:18px">Three</a></nav>' +)->toArray(); +$explicitGapNavigationBlock = $explicitGapNavigation['blocks'][0] ?? array(); +$assert('4px' === ($explicitGapNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'explicit direct-anchor navigation gap remains authoritative over item margins'); +$assert('18px' === ($explicitGapNavigationBlock['innerBlocks'][1]['attrs']['style']['spacing']['margin']['left'] ?? ''), 'explicit navigation gap leaves authored item margins intact'); + +$quickLinkCards = ( new HtmlTransformer() )->transform( + '<div class="quick-links"><a class="quick-link" href="/apply"><svg class="quick-link-icon" viewBox="0 0 24 24"><path d="M3 9l9-5 9 5-9 5z"/></svg><h3>Apply</h3><p>Choose a program.</p></a><a class="quick-link" href="/visit"><h3>Visit</h3><p>Plan your trip.</p></a></div>' +)->toArray(); +$quickLinkCardsBlock = $quickLinkCards['blocks'][0] ?? array(); +$quickLinkCardsCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $quickLinkCards['assets'] ?? array())); +$assert('core/group' === ($quickLinkCardsBlock['blockName'] ?? '') && 'quick-links' === ($quickLinkCardsBlock['attrs']['className'] ?? ''), 'card-like quick-links container remains a layout group rather than a navigation menu'); +$assert('core/group' === ($quickLinkCardsBlock['innerBlocks'][0]['blockName'] ?? '') && 'quick-link' === ($quickLinkCardsBlock['innerBlocks'][0]['attrs']['className'] ?? ''), 'block-content quick link remains a card group'); +$assert(array('top' => '0', 'right' => '0', 'bottom' => '0', 'left' => '0') === ($quickLinkCardsBlock['innerBlocks'][0]['innerBlocks'][0]['attrs']['style']['spacing']['margin'] ?? array()), 'synthetic SVG image-object host does not add paragraph margins to card layout'); +$assert(str_contains((string) ($quickLinkCards['serialized_blocks'] ?? ''), '<mark class="blocks-engine-propagated-link"><a href="/apply">') && str_contains($quickLinkCardsCss, '.blocks-engine-propagated-link{background-color:transparent;color:inherit}.blocks-engine-propagated-link>a{color:inherit;border:0;text-decoration:inherit}'), 'synthetic card link wrappers inherit source card paint instead of acquiring browser mark, standalone link color, or borders'); + +$captionedPlaceholder = ( new HtmlTransformer() )->transform( + '<style>.visual{position:relative;aspect-ratio:16/9}.visual::after{content:attr(data-caption);position:absolute;left:12px;bottom:10px;color:#fff;background:rgba(0,0,0,.7);padding:4px 8px;text-shadow:0 1px 2px rgba(0,0,0,.6)}</style><div class="visual" role="img" aria-label="Research vessel at sea" data-caption="Spring research cruise"></div>' +)->toArray(); +$captionedPlaceholderMarkup = (string) ($captionedPlaceholder['serialized_blocks'] ?? ''); +$captionedPlaceholderCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $captionedPlaceholder['assets'] ?? array())); +$assert(str_contains($captionedPlaceholderMarkup, 'blocks-engine-placeholder-media') && str_contains($captionedPlaceholderMarkup, 'Spring research cruise') && str_contains($captionedPlaceholderMarkup, 'blocks-engine-placeholder-caption-'), 'CSS visual placeholder materializes its generated caption as editable native text'); +$assert(str_contains($captionedPlaceholderCss, 'position:absolute') && str_contains($captionedPlaceholderCss, 'left:12px') && str_contains($captionedPlaceholderCss, 'bottom:10px') && str_contains($captionedPlaceholderCss, 'text-shadow:0 1px 2px rgba(0,0,0,.6)') && str_contains($captionedPlaceholderCss, 'margin:0'), 'materialized placeholder caption preserves pseudo-element paint and overlay geometry without affecting layout flow'); + +$listItemDescendantLinks = ( new HtmlTransformer() )->transform( + '<style>a{color:#900}.site-footer a{color:#fff}</style><footer class="site-footer"><ul><li><a href="/programs">Programs</a></li></ul></footer>' +)->toArray(); +$listItemDescendantLinkMarkup = (string) ($listItemDescendantLinks['serialized_blocks'] ?? ''); +$listItemDescendantLinkCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $listItemDescendantLinks['assets'] ?? array())); +$assert((bool) preg_match('/<mark class="blocks-engine-richtext-anchor-[^"]+"><a href="\\/programs">Programs<\\/a><\\/mark>/', $listItemDescendantLinkMarkup), 'list-item RichText materializes projected anchor identity around valid link content'); +$assert((bool) preg_match('/mark\.blocks-engine-richtext-anchor-[^{]+>a\{color:#fff\}/', $listItemDescendantLinkCss), 'descendant link paint remains attached after list-item RichText projection'); + +$richTextVariantCaps = ( new HtmlTransformer() )->transform( + '<style>.small-caps{font-variant-caps:all-small-caps;letter-spacing:.06em}</style><p>Founded <span class="small-caps">est. 1947</span></p>' +)->toArray(); +$assert(str_contains((string) ($richTextVariantCaps['serialized_blocks'] ?? ''), 'font-variant-caps:all-small-caps'), 'RichText materialization preserves authored font variant caps'); + +$inlineMetadataFlow = ( new HtmlTransformer() )->transform( + '<style>.event-meta{font-size:.82rem;margin-bottom:6px}.event-meta .pipe{margin:0 8px}.event-meta .room-code{font-family:monospace}</style><div class="event-meta">Committee chair: Prof. Mahalingam <span class="pipe">·</span> <span class="room-code">M-302</span> Mendel Hall</div>' +)->toArray(); +$inlineMetadataBlock = $inlineMetadataFlow['blocks'][0] ?? array(); +$assert('core/paragraph' === ($inlineMetadataBlock['blockName'] ?? '') && 'event-meta' === ($inlineMetadataBlock['attrs']['className'] ?? ''), 'phrasing-only div with direct text remains one metadata flow'); +$assert(str_contains((string) ($inlineMetadataBlock['attrs']['content'] ?? ''), 'Prof. Mahalingam') && str_contains((string) ($inlineMetadataBlock['attrs']['content'] ?? ''), 'M-302'), 'metadata flow preserves text and inline semantic tokens together'); +$assert(array('top' => '0', 'right' => '0', 'left' => '0') === ($inlineMetadataBlock['attrs']['style']['spacing']['margin'] ?? array()), 'non-paragraph metadata hosts leave authored stylesheet margins active and neutralize unspecified paragraph margins'); + +$standaloneTextControls = ( new HtmlTransformer() )->transform( + '<style>a{border-bottom:1px solid currentColor}.section-head{display:flex}.more-link{margin-bottom:6px}.event-date{border-right:1px solid}.month,.day,.time{display:block}.time{margin-top:4px}</style><div class="section-head"><a class="more-link" href="/news">All news</a></div><div class="event-date"><span class="month">Jun</span><span class="day">25</span><time class="time">3:00 PM</time></div>' +)->toArray(); +$standaloneLinkBlock = $standaloneTextControls['blocks'][0]['innerBlocks'][0] ?? array(); +$standaloneTimeBlock = $standaloneTextControls['blocks'][1]['innerBlocks'][2] ?? array(); +$standaloneTextControlsCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $standaloneTextControls['assets'] ?? array())); +$assert(array('top' => '0', 'right' => '0', 'left' => '0') === ($standaloneLinkBlock['attrs']['style']['spacing']['margin'] ?? array()), 'standalone source links leave authored stylesheet margins active without paragraph theme defaults'); +$assert(preg_match('/\.be-inline-geometry-[a-f0-9]+>a\{display:block\}/', $standaloneTextControlsCss) && str_contains((string) ($standaloneLinkBlock['attrs']['className'] ?? ''), 'be-inline-geometry-'), 'direct flex-item links retain their blockified border box after paragraph hosting'); +$assert(array('top' => '0', 'right' => '0', 'bottom' => '0', 'left' => '0') === ($standaloneTimeBlock['attrs']['style']['spacing']['margin'] ?? array()) && str_contains((string) ($standaloneTimeBlock['attrs']['content'] ?? ''), 'class="time"'), 'inline source elements hosted by paragraphs keep their source-owned inner margin and a neutral wrapper margin'); + +$wrappingNavigation = ( new HtmlTransformer() )->transform( + '<nav><ul style="display:flex;flex-wrap:wrap;gap:1rem"><li><a href="/one">One</a></li><li><a href="/two">Two</a></li></ul></nav>' +)->toArray(); +$wrappingNavigationBlock = $wrappingNavigation['blocks'][0] ?? array(); +$assert('1rem' === ($wrappingNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'direct list intentional gap projects onto core/navigation'); +$assert('wrap' === ($wrappingNavigationBlock['attrs']['layout']['flexWrap'] ?? ''), 'direct list intentional wrapping projects onto core/navigation'); + +$itemMarginNavigation = ( new HtmlTransformer() )->transform( + '<nav><a href="/one" style="margin-right:1rem">One</a><a href="/two" style="margin-left:2rem">Two</a></nav>' +)->toArray(); +$itemMarginNavigationBlock = $itemMarginNavigation['blocks'][0] ?? array(); +$itemMarginLinks = $itemMarginNavigationBlock['innerBlocks'] ?? array(); +$assert('0' === ($itemMarginNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'horizontal item margins do not become core navigation gap'); +$assert('1rem' === ($itemMarginLinks[0]['attrs']['style']['spacing']['margin']['right'] ?? '') && '2rem' === ($itemMarginLinks[1]['attrs']['style']['spacing']['margin']['left'] ?? ''), 'navigation item margins retain their authored horizontal directions'); $footerNavigationSections = ( new HtmlTransformer() )->transform( '<footer><div class="footer-grid"><nav aria-label="Product"><h3>Product</h3><ul><li><a class="footer-link" href="/features">Features</a></li><li><a class="footer-link" href="/pricing">Pricing</a></li></ul></nav><nav aria-label="Company"><p class="nav-title">Company</p><a class="footer-link" href="/about">About</a><a class="footer-link" href="/contact">Contact</a></nav><nav class="social-links" aria-label="Social"><a class="social-link" href="https://example.com/mastodon" aria-label="Mastodon"><svg aria-hidden="true"><path d="M0 0h1v1z"></path></svg></a><a class="social-link" href="https://example.com/github" title="GitHub"><span aria-hidden="true"></span></a></nav></div></footer>' @@ -1339,6 +1455,16 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(! str_contains($redundantToggleSerialized, '<!-- wp:button'), 'redundant JS hamburger menu-toggle is dropped instead of emitted as a dead core/button'); $assert(! str_contains($redundantToggleSerialized, 'nav-toggle'), 'redundant menu-toggle chrome class is not emitted into block output'); +$runtimeTargetRedundantToggle = ( new HtmlTransformer() )->transform( + '<header><button class="menu-trigger" aria-label="Open navigation" aria-controls="site-menu" aria-expanded="false"><span></span><span></span><span></span></button><nav id="site-menu" class="site-menu" aria-label="Primary"><a href="/">Home</a><a href="/about">About</a></nav></header>', + array('runtime_dom_selectors' => array('.menu-trigger', '#site-menu', '.site-menu')) +)->toArray(); +$runtimeTargetRedundantMarkup = (string) ($runtimeTargetRedundantToggle['serialized_blocks'] ?? ''); +$runtimeTargetRedundantIslands = $runtimeTargetRedundantToggle['source_reports']['runtime_islands'] ?? array(); +$assert(str_contains($runtimeTargetRedundantMarkup, '<!-- wp:navigation'), 'runtime-targeted controlled menu converts to core/navigation when native navigation supersedes its script behavior'); +$assert(! str_contains($runtimeTargetRedundantMarkup, '<!-- wp:html'), 'superseded controlled menu is not preserved as a raw runtime island'); +$assert(array() === array_values(array_filter($runtimeTargetRedundantIslands, static fn (array $island): bool => in_array($island['selector'] ?? '', array('.menu-trigger', '#site-menu', '.site-menu'), true))), 'superseded toggle and controlled menu are not reported as runtime islands'); + // Negative: a real labeled button, and a toggle-looking control with no associated // navigation, must still convert to core/button — only redundant chrome is dropped. $labeledButtons = ( new HtmlTransformer() )->transform( @@ -1400,6 +1526,15 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(! str_contains($nonConvertingSerialized, 'burger'), 'non-converting navbar hamburger toggle chrome class is not emitted into block output'); $assert(str_contains($nonConvertingSerialized, 'Studio'), 'non-converting navbar preserves the brand/logo content'); +$runtimeTargetNonConvertingNavbar = ( new HtmlTransformer() )->transform( + '<header><button class="burger" aria-label="Toggle menu" aria-controls="mixed-menu" aria-expanded="false"><span></span><span></span></button><nav id="mixed-menu" class="mixed-menu" aria-label="Primary"><ul><li>Announcement</li><li><a href="/music">Music</a></li></ul></nav></header>', + array('runtime_dom_selectors' => array('#mixed-menu', '.mixed-menu')) +)->toArray(); +$runtimeTargetNonConvertingMarkup = (string) ($runtimeTargetNonConvertingNavbar['serialized_blocks'] ?? ''); +$runtimeTargetNonConvertingIslands = $runtimeTargetNonConvertingNavbar['source_reports']['runtime_islands'] ?? array(); +$assert(! str_contains($runtimeTargetNonConvertingMarkup, '<!-- wp:navigation'), 'runtime-targeted non-converting navigation is not incorrectly replaced by core/navigation'); +$assert(1 === count(array_filter($runtimeTargetNonConvertingIslands, static fn (array $island): bool => '#mixed-menu' === ($island['selector'] ?? ''))), 'runtime-targeted non-converting navigation remains protected as a runtime island'); + // Negative (#232): a labelless toggle-shaped control with no associated navigation in // scope must NOT be over-suppressed by the broadened rule — it still converts to // core/button (only navigation-associated dead hamburgers are dropped). @@ -1908,16 +2043,33 @@ public function match(DOMElement $element, PatternContext $context): ?array 'entry' => 'index.html', 'files' => array( 'index.html' => '<!doctype html><html><head><link rel="stylesheet" href="styles.css"></head><body><header class="site-header"><nav class="subnav"><a href="#one">One</a></nav></header></body></html>', - 'styles.css' => '.site-header .subnav a{color:#31251c;text-decoration:none;border-color:#31251c}.site-header .subnav a:hover{color:#8f5031;border-color:#8f5031}', + 'styles.css' => '.site-header .subnav a{display:block;color:#31251c;text-decoration:none;border-color:#31251c}.site-header .subnav a:hover{color:#8f5031;border-color:#8f5031}', ), ) )->toArray(); $artifactNavAnchorStaticCss = (string) ($artifactNavAnchorCss['source_reports']['compiled_site']['theme']['static_css'] ?? ''); -$assert(str_contains($artifactNavAnchorStaticCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact static CSS replays nested nav anchor color through direct and descendant core/navigation wrappers'); +$artifactNavAnchorMarkup = (string) ($artifactNavAnchorCss['serialized_blocks'] ?? ''); +$artifactNavAnchorGeneratedCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $artifactNavAnchorCss['assets'] ?? array())); +$assert(str_contains($artifactNavAnchorStaticCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { display:block;color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact static CSS replays nested nav anchor color through direct and descendant core/navigation wrappers'); $assert(str_contains($artifactNavAnchorStaticCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content:hover, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content:hover { color:#8f5031;border-color:#8f5031 }'), 'artifact static CSS replays nested nav anchor hover color through core/navigation wrappers'); +$assert((bool) preg_match('/\.wp-block-navigation-item\.blocks-engine-navigation-control-[a-f0-9]+-\d+[^{}]*> \.wp-block-navigation-item__content\{display:block;/', $artifactNavAnchorGeneratedCss), 'artifact author CSS projects source anchor display through converted navigation item identity'); +$assert(str_contains($artifactNavAnchorMarkup, 'blocks-engine-navigation-control-'), 'converted navigation link carries the projected author-style identity'); + +$artifactAdjacentNavSpacing = $compiler->compile( + array( + 'entry' => 'index.html', + 'files' => array( + 'index.html' => '<!doctype html><html><head><link rel="stylesheet" href="styles.css"></head><body><div class="utility-bar"><nav><a href="/one">One</a><a href="/two">Two</a><a href="/three">Three</a></nav></div></body></html>', + 'styles.css' => '.utility-bar a{margin-left:18px}.utility-bar a:first-of-type{margin-left:0}', + ), + ) +)->toArray(); +$artifactAdjacentNavBlock = $artifactAdjacentNavSpacing['blocks'][0]['innerBlocks'][0] ?? array(); +$assert('18px' === ($artifactAdjacentNavBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'linked CSS first-of-type reset promotes uniform adjacent navigation spacing to native block gap'); +$assert(! isset($artifactAdjacentNavBlock['innerBlocks'][1]['attrs']['style']['spacing']['margin']['left']), 'linked CSS navigation spacing is not duplicated on promoted child margins'); $assert(! str_contains($artifactNavAnchorStaticCss, '.site-header.wp-block-navigation .subnav'), 'artifact static CSS does not attach core/navigation to the wrong ancestor selector'); $artifactNavAnchorRepairCss = (string) ($artifactNavAnchorCss['source_reports']['compiled_site']['visual_repair']['css'] ?? ''); -$assert(str_contains($artifactNavAnchorRepairCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact visual repair CSS carries nav anchor replay for downstream theme materializers'); +$assert(str_contains($artifactNavAnchorRepairCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { display:block;color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact visual repair CSS carries nav anchor replay for downstream theme materializers'); $artifactGeometry = $compiler->compile( array( @@ -2093,6 +2245,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $footerShellPart = $footerShellTemplateParts[0] ?? array(); $assert(! str_contains((string) ($footerShellIndexPage['block_markup'] ?? ''), 'Global footer copy'), 'compiled site removes global footer shell from page body when a footer template part exists'); $assert(str_contains((string) ($footerShellPart['block_markup'] ?? ''), 'Global footer copy'), 'compiled site preserves global footer copy in the footer template part'); +$assert(str_contains((string) ($footerShellPart['block_markup'] ?? ''), '"className":"site-footer"'), 'compiled footer template part preserves the source shell class boundary'); $assert(str_contains((string) ($footerShellAboutPage['block_markup'] ?? ''), 'Article byline footer'), 'compiled site preserves page-local article footer content while pruning global footer shell'); $assert(! str_contains((string) ($footerShellAboutPage['block_markup'] ?? ''), 'Global footer copy'), 'compiled site does not duplicate global footer shell on secondary page bodies'); diff --git a/php-transformer/tests/contract/shared-shell-plan.php b/php-transformer/tests/contract/shared-shell-plan.php index 9f452349..289c10b6 100644 --- a/php-transformer/tests/contract/shared-shell-plan.php +++ b/php-transformer/tests/contract/shared-shell-plan.php @@ -26,9 +26,10 @@ $assert(!array_filter($plan['template_parts'], static fn(array $part): bool => 'footer' === ($part['area'] ?? null)), 'Differing document footers remain page-local rather than becoming an ambiguous shared part.'); $assert(str_contains($header['canonical_block_markup'] ?? '', '"url":"/guides/about"') && str_contains($header['canonical_block_markup'] ?? '', '"url":"/"'), 'Route-relative navigation destinations are canonicalized before shell identity comparison.'); $assert(str_contains($header['canonical_block_markup'] ?? '', '"anchor":"site-chrome"') && str_contains($header['canonical_block_markup'] ?? '', 'site-header') && str_contains($header['canonical_block_markup'] ?? '', 'border'), 'Shared shell preserves its canonical landmark wrapper anchor, class, and style attributes.'); -foreach (array('index.html', 'guides/about.html', 'guides/team.html') as $source) { +$expectedFooters = array('index.html' => 'Home footer', 'guides/about.html' => 'About footer', 'guides/team.html' => 'Team footer'); +foreach ($expectedFooters as $source => $expectedFooter) { $markup = $documents[$source]['canonical_block_markup'] ?? ''; - $assert(1 === substr_count($markup, '"tagName":"header"') && str_contains($markup, 'Skip to content') && str_contains($markup, 'article header') && str_contains($markup, 'article footer') && 2 === substr_count($markup, '"tagName":"footer"'), "{$source} removes only the shared header and retains skip links, article landmarks, and its footer: {$markup}"); + $assert(1 === substr_count($markup, '"tagName":"header"') && str_contains($markup, 'Skip to content') && str_contains($markup, 'article header') && str_contains($markup, 'article footer') && str_contains($markup, $expectedFooter) && 2 === substr_count($markup, '"tagName":"footer"'), "{$source} removes only the shared header and retains skip links, article landmarks, and its complete footer: {$markup}"); } $assert(1 === substr_count($declaredWrites['templates/front-page.html']['payload']['data'], '"slug":"header"') && 1 === substr_count($declaredWrites['templates/page.html']['payload']['data'], '"slug":"header"') && 1 === substr_count($declaredWrites['templates/index.html']['payload']['data'], '"slug":"header"'), 'All base templates bind the shared header exactly once.'); $assert(array() !== array_filter($plan['assets'], static fn(array $asset): bool => 'css' === ($asset['kind'] ?? null) && str_contains((string) ($asset['content'] ?? ''), '.site-header')), 'Scoped shell styling is represented as a normal declared CSS asset write.'); @@ -50,7 +51,9 @@ $assert(str_contains($singleHeader['canonical_block_markup'] ?? '', '"tagName":"header"') && str_contains($singleHeader['canonical_block_markup'] ?? '', 'solo') && str_contains($singleHeader['canonical_block_markup'] ?? '', 'Solo') && !str_contains($pages($single)['index.html']['canonical_block_markup'] ?? '', 'Solo</p>'), 'Single-page entry chrome preserves its semantic landmark and source presentation while removing it from post content.'); $compiledHeader = array_values(array_filter($singleResult['source_reports']['compiled_site']['template_parts'] ?? array(), static fn(array $part): bool => 'header' === ($part['area'] ?? null)))[0] ?? array(); $compiledPage = $singleResult['source_reports']['compiled_site']['pages'][0] ?? array(); -$assert('entry_shell' === ($compiledHeader['placement']['kind'] ?? null) && !str_contains($compiledHeader['block_markup'] ?? '', '"tagName":"header"') && str_contains($compiledHeader['block_markup'] ?? '', 'Solo') && !str_contains($compiledPage['block_markup'] ?? '', 'Solo</p>'), 'The compiled-site compatibility report retains its established single-page entry shell projection without duplicate page chrome.'); +$assert('entry_shell' === ($compiledHeader['placement']['kind'] ?? null) && !str_contains($compiledHeader['block_markup'] ?? '', '"tagName":"header"') && str_contains($compiledHeader['block_markup'] ?? '', '"className":"solo"') && str_contains($compiledHeader['block_markup'] ?? '', 'Solo') && !str_contains($compiledPage['block_markup'] ?? '', 'Solo</p>'), 'The compiled-site compatibility report retains source shell classes on a non-landmark wrapper without duplicate page chrome.'); +$materializedHeader = array_values(array_filter($singleResult['source_reports']['materialization_plan']['template_part_writes'] ?? array(), static fn(array $write): bool => 'header' === ($write['area'] ?? null)))[0] ?? array(); +$assert(str_contains($materializedHeader['content'] ?? '', '"className":"solo"') && !str_contains($materializedHeader['content'] ?? '', '"tagName":"header"'), 'The v1 materialization projection carries source shell classes without nesting a header landmark inside the template-part wrapper.'); $incomplete = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '<header>Shared</header><main>Home</main>', 'about.html' => '<header>Shared</header><main>About</main>', 'contact.html' => '<main>Contact</main>')))->toArray()['source_reports']['wordpress_site_plan']; $multiple = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '<header>One</header><header>Two</header><main>Home</main>', 'about.html' => '<header>One</header><header>Two</header><main>About</main>')))->toArray()['source_reports']['wordpress_site_plan']; diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 05c5d241..49b4a2ce 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -139,6 +139,14 @@ $assert(isset($externalCommaUrl['source_reports']['wordpress_site_plan']), 'External browser URLs containing commas are validated as one URL rather than srcset candidates.'); $nestedDataUrl = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '<style>main{background:url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'n\'/%3E%3Crect filter=\'url(%23n)\'/%3E%3C/svg%3E")}</style><main>Texture</main>')))->toArray(); $assert(isset($nestedDataUrl['source_reports']['wordpress_site_plan']), 'Quoted CSS data URLs may contain encoded nested URL fragments without fabricating local references.'); + +$repeatedGeneratedAsset = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array( + 'index.html' => '<main><svg aria-label="Mark"><path d="M0 0h1v1z"/></svg></main>', + 'about.html' => '<main><svg aria-label="Mark"><path d="M0 0h1v1z"/></svg></main>', +)))->toArray(); +$repeatedGeneratedPlan = $repeatedGeneratedAsset['source_reports']['wordpress_site_plan'] ?? array(); +$repeatedGeneratedSvgAssets = array_values(array_filter($repeatedGeneratedPlan['assets'] ?? array(), static fn(array $asset): bool => 'image/svg+xml' === ($asset['mime_type'] ?? null))); +$assert(1 === count($repeatedGeneratedSvgAssets) && !isset($repeatedGeneratedAsset['source_reports']['wordpress_site_plan_diagnostics']), 'Byte-identical generated assets shared by multiple pages produce one canonical site-plan identity.'); $publicationSvg = '<svg xmlns="http://www.w3.org/2000/svg"><text>Example</text></svg>'; $publicationCss = '@font-face{font-family:Example;src:url(font.woff2)}'; $publicationToken = 'asset-' . substr(hash('sha256', 'assets/assets/font.woff2'), 0, 16); diff --git a/php-transformer/tests/fixtures/parity/html-anchor-inline-patterns.json b/php-transformer/tests/fixtures/parity/html-anchor-inline-patterns.json index d8870d26..474b085b 100644 --- a/php-transformer/tests/fixtures/parity/html-anchor-inline-patterns.json +++ b/php-transformer/tests/fixtures/parity/html-anchor-inline-patterns.json @@ -25,7 +25,7 @@ { "path": "blocks", "assert": "count", "count": 2 }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:image" }, { "path": "serialized_blocks", "assert": "contains", "value": "<a href=\"single.html\" aria-hidden=\"true\" tabindex=\"-1\"><img src=\"assets/photos/thumb-stop-motion.png\" alt=\"\"/></a>" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"bp-logo\"><a href=\"index.html\" aria-label=\"The Baseplate - home\"></a></p>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"bp-logo\" style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0\"><a href=\"index.html\" aria-label=\"The Baseplate - home\"></a></p>" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "\\u003ca class=\\u0022bp-logo" }, { "path": "fallbacks", "assert": "count", "count": 0 }, { "path": "coverage.0.fallback_count", "assert": "equals", "value": 0 } diff --git a/php-transformer/tests/fixtures/parity/html-brand-anchor-beside-active-nav-list.json b/php-transformer/tests/fixtures/parity/html-brand-anchor-beside-active-nav-list.json index f827a9ad..6642acbd 100644 --- a/php-transformer/tests/fixtures/parity/html-brand-anchor-beside-active-nav-list.json +++ b/php-transformer/tests/fixtures/parity/html-brand-anchor-beside-active-nav-list.json @@ -20,14 +20,14 @@ { "path": "blocks.0", "name": "core/group", "attrs": { "className": "site-nav", "tagName": "nav" } }, { "path": "blocks.0.innerBlocks.0", "name": "core/paragraph", "attrs": { "className": "nav-logo" } }, { "path": "blocks.0.innerBlocks.1", "name": "core/navigation", "attrs": { "className": "nav-links blocks-engine-list-navigation" } }, - { "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/navigation-link", "attrs": { "className": "active", "label": "Home", "url": "index.html", "kind": "custom", "anchorClassName": "active" } } + { "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/navigation-link", "attrs": { "className": "active blocks-engine-navigation-control-226ffd79f684-3", "label": "Home", "url": "index.html", "kind": "custom", "anchorClassName": "active" } } ], "expected_fallbacks": [], "expect": [ { "path": "status", "assert": "equals", "value": "success" }, { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"nav-logo\"><a href=\"index.html\">Mara <em><mark style=\"font-style:normal;color:#b8552a;background-color:transparent\">Vale</mark></em></a></p>" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"className\":\"nav-links blocks-engine-list-navigation\",\"overlayMenu\":\"mobile\"} -->" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation-link {\"className\":\"active\",\"label\":\"Home\",\"url\":\"index.html\",\"kind\":\"custom\",\"style\":{\"typography\":{\"textDecoration\":\"underline\"}},\"anchorClassName\":\"active\"} -->" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"className\":\"nav-links blocks-engine-list-navigation\",\"style\":{\"spacing\":{\"blockGap\":\"0\"}},\"layout\":{\"type\":\"flex\",\"flexWrap\":\"nowrap\"},\"overlayMenu\":\"mobile\"} -->" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation-link {\"className\":\"active blocks-engine-navigation-control-226ffd79f684-3\",\"label\":\"Home\",\"url\":\"index.html\",\"kind\":\"custom\",\"style\":{\"typography\":{\"textDecoration\":\"underline\"}},\"anchorClassName\":\"active\"} -->" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "\"label\":\"Mara \\u003cem" }, { "path": "fallbacks", "assert": "count", "count": 0 } ] diff --git a/php-transformer/tests/fixtures/parity/html-business-hours-label-value-rows.json b/php-transformer/tests/fixtures/parity/html-business-hours-label-value-rows.json index 3b5582b6..52f410e8 100644 --- a/php-transformer/tests/fixtures/parity/html-business-hours-label-value-rows.json +++ b/php-transformer/tests/fixtures/parity/html-business-hours-label-value-rows.json @@ -26,8 +26,8 @@ "expect": [ { "path": "status", "assert": "equals", "value": "success" }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:columns {\"className\":\"hours-row\"} -->" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"hours-day\">Monday</p>" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"hours-time closed\">Closed</p>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"hours-day\" style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0\">Monday</p>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"hours-time closed\" style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0\">Closed</p>" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "Monday\n" } ] } diff --git a/php-transformer/tests/fixtures/parity/html-code-pre-nav-shape.json b/php-transformer/tests/fixtures/parity/html-code-pre-nav-shape.json index 5da9d9de..ae2dc9f0 100644 --- a/php-transformer/tests/fixtures/parity/html-code-pre-nav-shape.json +++ b/php-transformer/tests/fixtures/parity/html-code-pre-nav-shape.json @@ -33,7 +33,7 @@ { "path": "blocks.0.innerBlocks.4.innerBlocks", "assert": "count", "count": 2 }, { "path": "fallbacks", "assert": "count", "count": 0 }, { "path": "blocks.0.innerBlocks.4.innerBlocks.1.attrs.url", "assert": "equals", "value": null }, - { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"className\":\"primary\",\"overlayMenu\":\"mobile\"} -->" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"className\":\"primary\",\"style\":{\"spacing\":{\"blockGap\":\"0\"}},\"layout\":{\"type\":\"flex\",\"flexWrap\":\"nowrap\"},\"overlayMenu\":\"mobile\"} -->" }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation-link {\"label\":\"Alpha\",\"url\":\"/alpha\",\"kind\":\"custom\"} -->" }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation-link {\"label\":\"Unsafe\",\"kind\":\"custom\"} -->" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "wp-block-navigation__container" }, diff --git a/php-transformer/tests/fixtures/parity/html-linked-card-anchor-grid.json b/php-transformer/tests/fixtures/parity/html-linked-card-anchor-grid.json index 87cd415d..67c94bef 100644 --- a/php-transformer/tests/fixtures/parity/html-linked-card-anchor-grid.json +++ b/php-transformer/tests/fixtures/parity/html-linked-card-anchor-grid.json @@ -21,7 +21,7 @@ { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "article-card featured" } }, { "path": "blocks.0.innerBlocks.0.innerBlocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "article-thumb" } }, { "path": "blocks.0.innerBlocks.0.innerBlocks.0.innerBlocks.1", "name": "core/group", "attrs": { "className": "article-card-body" } }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0.innerBlocks.1.innerBlocks.1", "name": "core/heading", "attrs": { "className": "article-headline", "content": "<a href=\"/story-one\">City Hall Rewrites the Map</a>", "level": 3 } }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0.innerBlocks.1.innerBlocks.1", "name": "core/heading", "attrs": { "className": "article-headline", "content": "<mark class=\"blocks-engine-propagated-link\"><a href=\"/story-one\">City Hall Rewrites the Map</a></mark>", "level": 3 } }, { "path": "blocks.0.innerBlocks.0.innerBlocks.1", "name": "core/group", "attrs": { "className": "article-card" } }, { "path": "blocks.0.innerBlocks.1", "name": "core/buttons", "attrs": { "className": "link-row" } }, { "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/button", "attrs": { "text": "Start", "url": "/start" } }, diff --git a/php-transformer/tests/fixtures/parity/html-list-navigation-classification.json b/php-transformer/tests/fixtures/parity/html-list-navigation-classification.json index dc8808bb..ed1da2cd 100644 --- a/php-transformer/tests/fixtures/parity/html-list-navigation-classification.json +++ b/php-transformer/tests/fixtures/parity/html-list-navigation-classification.json @@ -32,7 +32,7 @@ { "path": "blocks.0.innerBlocks.1.innerBlocks", "assert": "count", "count": 3 }, { "path": "blocks.1.innerBlocks", "assert": "count", "count": 2 }, { "path": "fallbacks", "assert": "count", "count": 0 }, - { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"className\":\"site-nav blocks-engine-list-navigation\",\"overlayMenu\":\"mobile\"}" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"className\":\"site-nav blocks-engine-list-navigation\",\"style\":{\"spacing\":{\"blockGap\":\"0\"}},\"layout\":{\"type\":\"flex\",\"flexWrap\":\"nowrap\"},\"overlayMenu\":\"mobile\"}" }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:list {\"className\":\"features\"}" }, { "path": "coverage.0.fallback_count", "assert": "equals", "value": 0 } ] diff --git a/php-transformer/tests/fixtures/parity/html-nav-overlay-menu-interactivity.json b/php-transformer/tests/fixtures/parity/html-nav-overlay-menu-interactivity.json index e7948981..8cf81477 100644 --- a/php-transformer/tests/fixtures/parity/html-nav-overlay-menu-interactivity.json +++ b/php-transformer/tests/fixtures/parity/html-nav-overlay-menu-interactivity.json @@ -29,7 +29,7 @@ { "path": "blocks.0.attrs.overlayMenu", "assert": "equals", "value": "mobile" }, { "path": "serialized_blocks", "assert": "contains", "value": "\"overlayMenu\":\"mobile\"" }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation-link {\"label\":\"Home\",\"url\":\"/\",\"kind\":\"custom\"} -->" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"className\":\"main-nav blocks-engine-list-navigation\",\"overlayMenu\":\"mobile\"} -->" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"className\":\"main-nav blocks-engine-list-navigation\",\"style\":{\"spacing\":{\"blockGap\":\"0\"}},\"layout\":{\"type\":\"flex\",\"flexWrap\":\"nowrap\"},\"overlayMenu\":\"mobile\"} -->" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "<!-- wp:html" }, { "path": "fallbacks", "assert": "count", "count": 0 } ] diff --git a/php-transformer/tests/fixtures/parity/html-quote-recognizer-parity.json b/php-transformer/tests/fixtures/parity/html-quote-recognizer-parity.json index d58ba5df..8cc100a7 100644 --- a/php-transformer/tests/fixtures/parity/html-quote-recognizer-parity.json +++ b/php-transformer/tests/fixtures/parity/html-quote-recognizer-parity.json @@ -21,7 +21,7 @@ { "path": "blocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "content": "Then ship it." } }, { "path": "blocks.1", "name": "core/pullquote", "attrs": { "className": "alignwide", "value": "Pull this hard.", "citation": "Editor" } }, { "path": "blocks.2", "name": "core/quote", "attrs": { "citation": "The distiller" } }, - { "path": "blocks.2.innerBlocks.0", "name": "core/paragraph", "attrs": { "content": "We <em>listen</em><br>to the garden." } }, + { "path": "blocks.2.innerBlocks.0", "name": "core/paragraph", "attrs": { "content": "We <em>listen</em><br>to the garden.", "style": { "spacing": { "margin": { "top": "0", "right": "0", "bottom": "0", "left": "0" } } } } }, { "path": "blocks.3", "name": "core/quote", "attrs": { "className": "testimonial-card", "citation": "<strong>Grace</strong>, Founder" } }, { "path": "blocks.3.innerBlocks.0", "name": "core/paragraph", "attrs": { "content": "Stay curious." } }, { "path": "blocks.4", "name": "core/pullquote", "attrs": { "className": "alignfull", "citation": "Critic", "value": "<p>Quote me.</p>" } } @@ -35,7 +35,7 @@ { "path": "blocks.3.innerBlocks", "assert": "count", "count": 1 }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:quote {\"className\":\"ethos-quote\",\"citation\":\"Ada\"} --><blockquote class=\"wp-block-quote ethos-quote\"><!-- wp:paragraph {\"content\":\"Build with care.\"} --><p>Build with care.</p><!-- /wp:paragraph --><!-- wp:paragraph {\"content\":\"Then ship it.\"} --><p>Then ship it.</p><!-- /wp:paragraph --><cite>Ada</cite></blockquote><!-- /wp:quote -->" }, { "path": "serialized_blocks", "assert": "contains", "value": "<figure class=\"wp-block-pullquote alignwide\"><blockquote>Pull this hard.<cite>Editor</cite></blockquote></figure>" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<blockquote class=\"wp-block-quote\"><!-- wp:paragraph {\"content\":\"We <em>listen</em><br>to the garden.\"} --><p>We <em>listen</em><br>to the garden.</p><!-- /wp:paragraph --><cite>The distiller</cite></blockquote>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<blockquote class=\"wp-block-quote\"><!-- wp:paragraph {\"content\":\"We <em>listen</em><br>to the garden.\",\"style\":{\"spacing\":{\"margin\":{\"top\":\"0\",\"right\":\"0\",\"bottom\":\"0\",\"left\":\"0\"}}}} --><p style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0\">We <em>listen</em><br>to the garden.</p><!-- /wp:paragraph --><cite>The distiller</cite></blockquote>" }, { "path": "serialized_blocks", "assert": "contains", "value": "<blockquote class=\"wp-block-quote testimonial-card\"><!-- wp:paragraph {\"content\":\"Stay curious.\"} --><p>Stay curious.</p><!-- /wp:paragraph --><cite><strong>Grace</strong>, Founder</cite></blockquote>" }, { "path": "serialized_blocks", "assert": "contains", "value": "<figure class=\"wp-block-pullquote alignfull\"><blockquote><p>Quote me.</p><cite>Critic</cite></blockquote></figure>" }, { "path": "fallbacks", "assert": "count", "count": 0 }, diff --git a/php-transformer/tests/fixtures/parity/html-richtext-partial-span-visual-mark.json b/php-transformer/tests/fixtures/parity/html-richtext-partial-span-visual-mark.json index c14e848a..3764a9f9 100644 --- a/php-transformer/tests/fixtures/parity/html-richtext-partial-span-visual-mark.json +++ b/php-transformer/tests/fixtures/parity/html-richtext-partial-span-visual-mark.json @@ -22,7 +22,7 @@ "expect": [ { "path": "status", "assert": "equals", "value": "success" }, { "path": "source_reports.wp_block_validity.status", "assert": "equals", "value": "pass" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<mark style=\"display:block;color:#b8893a;--blocks-engine-richtext-marker:blocks-engine-richtext-aa1434d6fce3-3\">Roasters</mark>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<mark class=\"blocks-engine-richtext-aa1434d6fce3-3\" style=\"display:block;color:#b8893a\">Roasters</mark>" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "class=\"accent\"" }, { "path": "fallbacks", "assert": "count", "count": 0 } ] diff --git a/php-transformer/tests/fixtures/parity/html-schedule-label-value-rows.json b/php-transformer/tests/fixtures/parity/html-schedule-label-value-rows.json index 16760a94..2468e845 100644 --- a/php-transformer/tests/fixtures/parity/html-schedule-label-value-rows.json +++ b/php-transformer/tests/fixtures/parity/html-schedule-label-value-rows.json @@ -26,10 +26,10 @@ "expect": [ { "path": "status", "assert": "equals", "value": "success" }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:columns {\"className\":\"session-row\"} -->" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<p><time class=\"session-time\">10:00 AM</time></p>" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"session-title\">Opening keynote</p>" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"detail-label\">Location</p>" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"detail-value\">Main Hall</p>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<p style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0\"><time class=\"session-time\">10:00 AM</time></p>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"session-title\" style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0\">Opening keynote</p>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"detail-label\" style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0\">Location</p>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<p class=\"detail-value\" style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0\">Main Hall</p>" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "10:00 AM\n" }, { "path": "fallbacks", "assert": "count", "count": 0 } ] diff --git a/php-transformer/tests/fixtures/parity/html-semantic-selector-preservation.json b/php-transformer/tests/fixtures/parity/html-semantic-selector-preservation.json index 6aeb4a38..ec4f6a95 100644 --- a/php-transformer/tests/fixtures/parity/html-semantic-selector-preservation.json +++ b/php-transformer/tests/fixtures/parity/html-semantic-selector-preservation.json @@ -27,7 +27,7 @@ { "path": "fallbacks", "assert": "count", "count": 0 }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:group {\"anchor\":\"site-header\",\"className\":\"site-header\",\"tagName\":\"header\"} -->" }, { "path": "serialized_blocks", "assert": "contains", "value": "<header id=\"site-header\" class=\"wp-block-group site-header\">" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"anchor\":\"mobile-nav\",\"className\":\"site-nav blocks-engine-list-navigation\",\"overlayMenu\":\"mobile\"} -->" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:navigation {\"anchor\":\"mobile-nav\",\"className\":\"site-nav blocks-engine-list-navigation\",\"style\":{\"spacing\":{\"blockGap\":\"0\"}},\"layout\":{\"type\":\"flex\",\"flexWrap\":\"nowrap\"},\"overlayMenu\":\"mobile\"} -->" }, { "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:group {\"anchor\":\"feature-section\",\"className\":\"feature-section reveal\",\"tagName\":\"section\"} -->" }, { "path": "serialized_blocks", "assert": "contains", "value": "<section id=\"feature-section\" class=\"wp-block-group feature-section reveal\">" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "js-site-header" }, diff --git a/php-transformer/tests/fixtures/parity/html-whole-element-link-card-propagated.json b/php-transformer/tests/fixtures/parity/html-whole-element-link-card-propagated.json index 2f486b7e..7d9c89bf 100644 --- a/php-transformer/tests/fixtures/parity/html-whole-element-link-card-propagated.json +++ b/php-transformer/tests/fixtures/parity/html-whole-element-link-card-propagated.json @@ -18,7 +18,7 @@ "expected_blocks": [ { "path": "blocks.0", "name": "core/group", "attrs": { "className": "feature-card" } }, { "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "url": "/img/feature.jpg", "alt": "Feature", "href": "https://example.com/feature/", "linkDestination": "custom" } }, - { "path": "blocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "<a href=\"https://example.com/feature/\">Feature Title</a>", "level": 3 } }, + { "path": "blocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "<mark class=\"blocks-engine-propagated-link\"><a href=\"https://example.com/feature/\">Feature Title</a></mark>", "level": 3 } }, { "path": "blocks.0.innerBlocks.2", "name": "core/paragraph" } ], "expected_fallbacks": [], @@ -31,9 +31,9 @@ { "path": "blocks.0.innerBlocks.0.blockName", "assert": "equals", "value": "core/image" }, { "path": "blocks.0.innerBlocks.0.attrs.href", "assert": "equals", "value": "https://example.com/feature/" }, { "path": "blocks.0.innerBlocks.0.attrs.linkDestination", "assert": "equals", "value": "custom" }, - { "path": "blocks.0.innerBlocks.1.attrs.content", "assert": "contains", "value": "<a href=\"https://example.com/feature/\">Feature Title</a>" }, + { "path": "blocks.0.innerBlocks.1.attrs.content", "assert": "contains", "value": "<mark class=\"blocks-engine-propagated-link\"><a href=\"https://example.com/feature/\">Feature Title</a></mark>" }, { "path": "serialized_blocks", "assert": "contains", "value": "<a href=\"https://example.com/feature/\"><img" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<a href=\"https://example.com/feature/\">Feature Title</a>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<mark class=\"blocks-engine-propagated-link\"><a href=\"https://example.com/feature/\">Feature Title</a></mark>" }, { "path": "serialized_blocks", "assert": "contains", "value": "<a href=\"https://example.com/feature/\">Feature description text.</a>" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "\"className\":\"feature-card\",\"href\"" }, { "path": "source_reports.html.dropped_link_wrappers", "assert": "count", "count": 0 }, diff --git a/php-transformer/tests/fixtures/parity/html-whole-element-link-group-no-href.json b/php-transformer/tests/fixtures/parity/html-whole-element-link-group-no-href.json index 81279e43..aa59bc90 100644 --- a/php-transformer/tests/fixtures/parity/html-whole-element-link-group-no-href.json +++ b/php-transformer/tests/fixtures/parity/html-whole-element-link-group-no-href.json @@ -17,7 +17,7 @@ }, "expected_blocks": [ { "path": "blocks.0", "name": "core/group", "attrs": { "className": "panel-link" } }, - { "path": "blocks.0.innerBlocks.0", "name": "core/heading", "attrs": { "content": "<a href=\"https://example.com/details/\" target=\"_blank\" rel=\"noopener\">Quarterly Review</a>", "level": 2 } }, + { "path": "blocks.0.innerBlocks.0", "name": "core/heading", "attrs": { "content": "<mark class=\"blocks-engine-propagated-link\"><a href=\"https://example.com/details/\" target=\"_blank\" rel=\"noopener\">Quarterly Review</a></mark>", "level": 2 } }, { "path": "blocks.0.innerBlocks.1", "name": "core/paragraph" } ], "expected_fallbacks": [], @@ -27,8 +27,8 @@ { "path": "blocks.0.blockName", "assert": "equals", "value": "core/group" }, { "path": "blocks.0.attrs.href", "assert": "equals", "value": null }, { "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 }, - { "path": "serialized_blocks", "assert": "contains", "value": "<a href=\"https://example.com/details/\" target=\"_blank\" rel=\"noopener\">Quarterly Review</a>" }, - { "path": "serialized_blocks", "assert": "contains", "value": "<a href=\"https://example.com/details/\" target=\"_blank\" rel=\"noopener\">A summary of the latest reporting period.</a>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<mark class=\"blocks-engine-propagated-link\"><a href=\"https://example.com/details/\" target=\"_blank\" rel=\"noopener\">Quarterly Review</a></mark>" }, + { "path": "serialized_blocks", "assert": "contains", "value": "<mark class=\"blocks-engine-propagated-link\"><a href=\"https://example.com/details/\" target=\"_blank\" rel=\"noopener\">A summary of the latest reporting period.</a></mark>" }, { "path": "serialized_blocks", "assert": "contains", "value": "Quarterly Review" }, { "path": "serialized_blocks", "assert": "contains", "value": "A summary of the latest reporting period." }, { "path": "serialized_blocks", "assert": "not_contains", "value": "\"href\":\"https://example.com/details/\"" }, diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index d527f253..ef72c330 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -48,7 +48,7 @@ ), ) )->toArray(); $richTextAssets = $richText['assets'] ?? array(); -$assert(str_starts_with((string) ($richTextAssets[0]['content'] ?? ''), ':where(mark)[style*="--blocks-engine-richtext-marker:"]{background-color:transparent;color:inherit}') && str_contains((string) ($richTextAssets[0]['content'] ?? ''), '{color:#e8a020}') && ! str_contains((string) ($richTextAssets[1]['content'] ?? ''), 'background-color:transparent;color:inherit'), 'artifact projection emits one marker reset before the first projected author stylesheet'); +$assert(str_starts_with((string) ($richTextAssets[0]['content'] ?? ''), ':where(mark[class*="blocks-engine-richtext-"]){background-color:transparent;color:inherit}') && str_contains((string) ($richTextAssets[0]['content'] ?? ''), '{color:#e8a020}') && ! str_contains((string) ($richTextAssets[1]['content'] ?? ''), 'background-color:transparent;color:inherit'), 'artifact projection emits one marker reset before the first projected author stylesheet'); $multiPage = ( new ArtifactCompiler() )->compile(array( 'files' => array( diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php index 0112e152..a379a67a 100644 --- a/php-transformer/tests/unit/author-selector-semantics.php +++ b/php-transformer/tests/unit/author-selector-semantics.php @@ -36,6 +36,14 @@ $navigationShellClass = (string) ($navigationShellBlock['attrs']['className'] ?? ''); $assert(str_contains($navigationShellClass, 'blocks-engine-source-nav-') && ! str_contains((string) ($navigationMenuBlock['attrs']['className'] ?? ''), 'blocks-engine-source-nav-') && str_contains($navigationShellCss, ':where(.' . $navigationShellClass . '):not(blocks-engine-specificity-') && ! preg_match('/(^|[},])nav\s*\{/', $navigationShellCss), 'nav type selectors stay scoped to the canonical source navigation shell instead of matching nested core navigation markup'); +$currentNavigation = ( new HtmlTransformer() )->transform('<style>.primary-nav a.active{color:gold;border-bottom-color:gold}</style><nav class="primary-nav"><a href="index.html">Home</a><a href="faculty.html">Faculty</a></nav>', array( 'source' => 'index.html' ))->toArray(); +$currentNavigationMarkup = (string) ($currentNavigation['serialized_blocks'] ?? ''); +$assert(str_contains($currentNavigationMarkup, 'anchorClassName":"active') && str_contains($currentNavigationMarkup, 'textDecoration":"underline') && 1 === substr_count($currentNavigationMarkup, 'anchorClassName":"active'), 'source-matching navigation links retain their current-page state before routes are rewritten'); +$assert((bool) preg_match('/\.wp-block-navigation-item\.blocks-engine-navigation-control-[^,{]+[^{}]*> \.wp-block-navigation-item__content\{color:gold;border-bottom-color:gold\}/', $css($currentNavigation)), 'current navigation anchor paint projects through the exact native navigation item identity'); + +$richTextAnchor = $transform('<style>p a{color:#8c3a2a;border-bottom:1px solid rgba(140,58,42,.25)}</style><p>Read <a href="/story">the full story</a>.</p>'); +$assert(str_contains((string) ($richTextAnchor['serialized_blocks'] ?? ''), '<mark class="blocks-engine-richtext-anchor-') && str_contains($css($richTextAnchor), 'mark.blocks-engine-richtext-anchor-') && str_contains($css($richTextAnchor), '>a{color:#8c3a2a;border-bottom:1px solid rgba(140,58,42,.25)}') && 'pass' === ($richTextAnchor['source_reports']['wp_block_validity']['status'] ?? ''), 'RichText links retain source border and paint through a valid marker wrapper'); + $controls = $transform('<style>a.cta:hover{padding:1rem}button.cta:focus{padding:2rem}</style><a class="cta" href="/go" style="padding:1px;background:#000">Go</a><button class="cta" style="padding:1px;background:#000">Send</button>'); $controlCss = $css($controls); $assert(2 === substr_count($controlCss, '> :where(.wp-block-button__link)') && str_contains($controlCss, ':hover') && str_contains($controlCss, ':focus'), 'promoted anchors and native buttons project dynamic selectors onto their links once'); @@ -121,7 +129,7 @@ $svgLogo = $transform('<style>.logo{display:inline-flex;align-items:center;gap:.6rem}.logo-mark{width:38px;height:38px;display:grid;place-items:center;flex:none}.logo-mark svg{width:22px;height:22px}</style><header><a class="logo" href="/" aria-label="Home"><span class="logo-mark"><svg viewBox="0 0 38 38" aria-hidden="true"><circle cx="19" cy="19" r="18"/></svg></span><span class="logo-text">Block Party</span></a></header>'); $svgLogoMarkup = (string) ($svgLogo['serialized_blocks'] ?? ''); -$assert(str_contains($svgLogoMarkup, '<span class="logo-mark" style="width:38px;height:38px;display:grid"') && str_contains($svgLogoMarkup, '<img src="assets/materialized-svg/') && str_contains($svgLogoMarkup, '<span class="logo-text">Block Party</span>') && 1 === count(array_filter($svgLogo['assets'] ?? array(), static fn (array $asset): bool => 'inline-svg' === ($asset['source'] ?? ''))) && 'pass' === ($svgLogo['source_reports']['wp_block_validity']['status'] ?? ''), 'structured text logos preserve passive inline SVG artwork and native RichText-safe container geometry'); +$assert(str_contains($svgLogoMarkup, '<span class="logo-mark ') && str_contains($svgLogoMarkup, 'style="width:38px;height:38px;display:grid"') && str_contains($svgLogoMarkup, '<img src="assets/materialized-svg/') && str_contains($svgLogoMarkup, '<span class="logo-text">Block Party</span>') && 1 === count(array_filter($svgLogo['assets'] ?? array(), static fn (array $asset): bool => 'inline-svg' === ($asset['source'] ?? ''))) && 'pass' === ($svgLogo['source_reports']['wp_block_validity']['status'] ?? ''), 'structured text logos preserve passive inline SVG artwork and native RichText-safe container geometry'); $plainSvgLogo = $transform('<style>.nav-logo{display:flex;align-items:center;gap:10px;margin-right:auto}</style><header><a class="nav-logo" href="/" aria-label="Home"><svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true"><circle cx="14" cy="14" r="13"/></svg>Relay Atlas</a></header>'); $plainSvgLogoMarkup = (string) ($plainSvgLogo['serialized_blocks'] ?? ''); @@ -144,21 +152,38 @@ $inlineCss = $css($inlineLeaves); $assert(3 === substr_count($inlineMarkup, '<div class="wp-block-group blocks-engine-semantic-') && 3 === substr_count($inlineCss, ':where(.blocks-engine-semantic-') && 'pass' === ($inlineLeaves['source_reports']['wp_block_validity']['status'] ?? ''), 'CSS-addressed sibling spans retain independent native wrapper identities and projected selector paths without HTML fallback'); +$flowTokens = $transform('<style>.event-date .month{display:block;font-size:11px;letter-spacing:.14em}.event-date .day{display:block;font-size:38px;line-height:1;margin:2px 0}.event-date .time{display:block;font:11px monospace;color:#456}.event-type{display:inline-block;font-size:10px;padding:2px 7px;border:1px solid #999;background:#eee;margin-bottom:8px}</style><div class="event-date"><span class="month">Jun</span><span class="day">25</span><span class="time">3:00 PM</span></div><div class="event-body"><span class="event-type">Defense</span><h3>Thesis title</h3></div>'); +$flowTokenMarkup = (string) ($flowTokens['serialized_blocks'] ?? ''); +$flowTokenCss = $css($flowTokens); +$assert(8 === substr_count($flowTokenMarkup, 'blocks-engine-semantic-') && 4 <= substr_count($flowTokenCss, ':where(.blocks-engine-semantic-'), 'standalone styled inline leaves in ordinary flow retain independent visual-token wrappers'); +$assert(4 === substr_count($flowTokenMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'synthetic visual-token paragraphs do not contribute source-absent spacing, including tokens inside structural layout parents'); +$assert(! str_contains($flowTokenMarkup, '<mark class="blocks-engine-richtext-') && 'pass' === ($flowTokens['source_reports']['wp_block_validity']['status'] ?? ''), 'standalone flow tokens avoid prose markers while retaining valid native blocks'); + +$linkedBrand = $transform('<style>.crest{flex-shrink:0}.brand-text{display:flex;flex-direction:column}.institution{font-size:.72rem;letter-spacing:.2em}.department{font-size:1.35rem;margin-top:2px}</style><header><a href="index.html" style="display:flex"><svg class="crest" viewBox="0 0 10 10"><path d="M0 0h10v10z"/></svg><span class="brand-text"><span class="institution">Brennan University</span><span class="department">Earth Sciences</span></span></a></header>'); +$linkedBrandMarkup = (string) ($linkedBrand['serialized_blocks'] ?? ''); +$linkedBrandCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), array_filter($linkedBrand['assets'] ?? array(), static fn (array $asset): bool => 'text/css' === ($asset['mime_type'] ?? '')))); +$assert(str_contains($linkedBrandMarkup, 'className":"institution blocks-engine-semantic-') && str_contains($linkedBrandMarkup, 'className":"department blocks-engine-semantic-') && 3 === substr_count($linkedBrandMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'linked brand SVG and text retain independent native flex children without synthetic paragraph margins when shell conversion changes DOM paths'); +$assert(str_contains($linkedBrandMarkup, 'be-inline-geometry-') && str_contains($linkedBrandCss, '{flex-shrink:0}'), 'synthetic SVG phrasing hosts inherit source flex-item shrink constraints'); + +$resetInlineMetadata = $transform('<style>*{margin:0;padding:0}.decision-item .who{font-size:9px;color:#777}</style><div class="decision-item"><p>Decision</p><span class="who">Byline</span></div>'); +$resetInlineMetadataMarkup = (string) ($resetInlineMetadata['serialized_blocks'] ?? ''); +$assert(! str_contains($resetInlineMetadataMarkup, 'blocks-engine-semantic-') && str_contains($resetInlineMetadataMarkup, '<mark class="blocks-engine-richtext-'), 'universal zero resets do not promote ordinary inline metadata into visual-token wrappers'); + $repeatedParents = $transform('<style>.row{display:flex}.row .pill{padding:2px 8px;border:1px solid #999}.other .pill{color:red}</style><div class="row"><span class="pill">First</span></div><div class="row"><span class="pill">Second</span></div><div class="other"><span class="pill">Third</span></div>'); $repeatedMarkup = (string) ($repeatedParents['serialized_blocks'] ?? ''); $repeatedCss = $css($repeatedParents); preg_match_all('/blocks-engine-semantic-[a-f0-9]+-\d+/', $repeatedMarkup . "\n" . $repeatedCss, $repeatedMarkers); -$assert(2 === count(array_unique($repeatedMarkers[0] ?? array())) && 2 === substr_count($repeatedCss, ':where(.blocks-engine-semantic-') && str_contains($repeatedMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($repeatedCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-'), 'repeated structural parents allocate unique source-path markers without leaking their box styles into an unrelated inline sibling'); +$assert(2 === count(array_unique($repeatedMarkers[0] ?? array())) && 2 === substr_count($repeatedCss, ':where(.blocks-engine-semantic-') && str_contains($repeatedMarkup, '<mark class="blocks-engine-richtext-') && str_contains($repeatedCss, 'mark.blocks-engine-richtext-'), 'repeated structural parents allocate unique source-path markers without leaking their box styles into an unrelated inline sibling'); $richTextPill = $transform('<style>p .pill{padding:2px 8px;border:1px solid #999}</style><p>Read <span class="pill">more</span>.</p>'); $richTextPillMarkup = (string) ($richTextPill['serialized_blocks'] ?? ''); $richTextPillCss = $css($richTextPill); -$assert(str_contains($richTextPillMarkup, '<mark style="') && str_contains($richTextPillMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($richTextPillCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && 'pass' === ($richTextPill['source_reports']['wp_block_validity']['status'] ?? ''), 'RichText-contained selector hooks survive through valid mark formatting and projected CSS'); +$assert(str_contains($richTextPillMarkup, '<mark class="blocks-engine-richtext-') && str_contains($richTextPillCss, 'mark.blocks-engine-richtext-') && 'pass' === ($richTextPill['source_reports']['wp_block_validity']['status'] ?? ''), 'RichText-contained selector hooks survive through valid mark formatting and projected CSS'); $richTextColor = $transform('<style>:root{--amber:#e8a020}.quote-mark{font-size:4rem;color:var(--amber)}</style><p><span class="quote-mark">&quot;</span>Testimonial</p>'); $richTextColorMarkup = (string) ($richTextColor['serialized_blocks'] ?? ''); $richTextColorCss = $css($richTextColor); -$assert(str_contains($richTextColorMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && ! str_contains($richTextColorMarkup, 'color:inherit') && ! str_contains($richTextColorMarkup, 'background-color:transparent') && str_contains($richTextColorCss, ':where(mark)[style*="--blocks-engine-richtext-marker:"]{background-color:transparent;color:inherit}') && str_contains($richTextColorCss, '{font-size:4rem;color:var(--amber)}') && strpos($richTextColorCss, 'color:inherit') < strpos($richTextColorCss, 'color:var(--amber)'), 'RichText marker reset stays below projected author paint instead of overriding it inline'); +$assert(str_contains($richTextColorMarkup, '<mark class="blocks-engine-richtext-') && ! str_contains($richTextColorMarkup, 'color:inherit') && ! str_contains($richTextColorMarkup, 'background-color:transparent') && str_contains($richTextColorCss, ':where(mark[class*="blocks-engine-richtext-"]){background-color:transparent;color:inherit}') && str_contains($richTextColorCss, '{font-size:4rem;color:var(--amber)}') && strpos($richTextColorCss, 'color:inherit') < strpos($richTextColorCss, 'color:var(--amber)'), 'RichText marker reset stays below projected author paint instead of overriding it inline'); $richTextPunctuation = $transform('<style>.quote-mark{font-size:4rem}</style><p><span class="quote-mark">"</span>The team\'s launch</p>'); $richTextPunctuationMarkup = (string) ($richTextPunctuation['serialized_blocks'] ?? ''); @@ -166,11 +191,11 @@ $standaloneBadge = $transform('<style>.card .badge{display:inline-block;margin-top:1rem;padding:.25rem .7rem;border:1px solid #999;border-radius:999px;background:#eee;color:#6040cc}</style><article class="card"><h2>Feature</h2><span class="badge">Stable</span></article>'); $standaloneBadgeMarkup = (string) ($standaloneBadge['serialized_blocks'] ?? ''); -$assert(str_contains($standaloneBadgeMarkup, '<mark style="') && str_contains($standaloneBadgeMarkup, 'display:inline-block') && str_contains($standaloneBadgeMarkup, 'padding:.25rem .7rem') && str_contains($standaloneBadgeMarkup, 'border-radius:999px') && str_contains($standaloneBadgeMarkup, 'background:#eee') && 'pass' === ($standaloneBadge['source_reports']['wp_block_validity']['status'] ?? ''), 'standalone RichText styling hooks carry static visual declarations without depending on runtime selector markers'); +$assert(str_contains($standaloneBadgeMarkup, 'className":"badge blocks-engine-semantic-') && str_contains($standaloneBadgeMarkup, 'padding-top:.25rem') && str_contains($standaloneBadgeMarkup, 'border-radius:999px') && str_contains($standaloneBadgeMarkup, 'background-color:#eee') && 'pass' === ($standaloneBadge['source_reports']['wp_block_validity']['status'] ?? ''), 'standalone styled badges retain independent native box geometry without HTML fallback'); $inlineStat = $transform('<style>.stat-num{font-size:4rem}.stat-num .suffix{font-size:2rem;color:#6040cc}</style><div class="stat-num"><span data-count="43">43</span><span class="suffix">%</span></div>'); $inlineStatMarkup = (string) ($inlineStat['serialized_blocks'] ?? ''); -$assert(1 === substr_count($inlineStatMarkup, '<!-- wp:paragraph') && str_contains($inlineStatMarkup, '>43</span><mark style=') && str_contains($inlineStatMarkup, 'font-size:2rem') && str_contains($inlineStatMarkup, '>%</mark>'), 'non-structural inline metrics and suffixes remain in one styled RichText line'); +$assert(1 === substr_count($inlineStatMarkup, '<!-- wp:paragraph') && str_contains($inlineStatMarkup, '>43</span><mark class="blocks-engine-richtext-') && str_contains($inlineStatMarkup, 'font-size:2rem') && str_contains($inlineStatMarkup, '>%</mark>'), 'non-structural inline metrics and suffixes remain in one styled RichText line'); $gradientText = $transform('<style>:root{--hero:linear-gradient(90deg,#26f,#f56)}h1 .grad{background:var(--hero);background-clip:text;-webkit-background-clip:text;-webkit-text-fill-color:transparent}</style><h1>Open <span class="grad">forever</span></h1>'); $gradientTextMarkup = (string) ($gradientText['serialized_blocks'] ?? ''); @@ -179,17 +204,17 @@ $richTextStates = $transform('<style>p .pill:hover{color:red}p .pill:focus{color:blue}p .pill:active{color:green}p .pill:visited{color:purple}</style><p>Read <span class="pill">more</span>.</p>'); $richTextStatesMarkup = (string) ($richTextStates['serialized_blocks'] ?? ''); $richTextStatesCss = $css($richTextStates); -$assert(str_contains($richTextStatesMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && 4 === substr_count($richTextStatesCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($richTextStatesCss, ':hover{color:red}') && str_contains($richTextStatesCss, ':focus{color:blue}') && str_contains($richTextStatesCss, ':active{color:green}') && str_contains($richTextStatesCss, ':visited{color:purple}') && ! str_contains($richTextStatesMarkup, ':hover') && ! str_contains($richTextStatesMarkup, ':focus') && ! str_contains($richTextStatesMarkup, ':active') && ! str_contains($richTextStatesMarkup, ':visited'), 'RichText marker projections retain dynamic pseudo-state suffixes without inlining a permanent state'); +$assert(str_contains($richTextStatesMarkup, '<mark class="blocks-engine-richtext-') && 4 === substr_count($richTextStatesCss, 'mark.blocks-engine-richtext-') && str_contains($richTextStatesCss, ':hover{color:red}') && str_contains($richTextStatesCss, ':focus{color:blue}') && str_contains($richTextStatesCss, ':active{color:green}') && str_contains($richTextStatesCss, ':visited{color:purple}') && ! str_contains($richTextStatesMarkup, ':hover') && ! str_contains($richTextStatesMarkup, ':focus') && ! str_contains($richTextStatesMarkup, ':active') && ! str_contains($richTextStatesMarkup, ':visited'), 'RichText marker projections retain dynamic pseudo-state suffixes without inlining a permanent state'); $nestedLeaf = $transform('<style>.meta{display:flex}.pill{padding:2px 8px;border:1px solid #999}.meta > .item .pill{color:red}</style><div class="meta"><div class="item"><span class="pill">Nested</span></div></div>'); $nestedLeafMarkup = (string) ($nestedLeaf['serialized_blocks'] ?? ''); $nestedLeafCss = $css($nestedLeaf); -$assert(! str_contains($nestedLeafMarkup, 'blocks-engine-semantic-') && str_contains($nestedLeafMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($nestedLeafCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && 'pass' === ($nestedLeaf['source_reports']['wp_block_validity']['status'] ?? ''), 'nested selector-addressable leaves retain a valid inline marker instead of becoming a structural group'); +$assert(str_contains($nestedLeafMarkup, 'blocks-engine-semantic-') && str_contains($nestedLeafCss, ':where(.blocks-engine-semantic-') && ! str_contains($nestedLeafMarkup, '<mark class="blocks-engine-richtext-') && 'pass' === ($nestedLeaf['source_reports']['wp_block_validity']['status'] ?? ''), 'a standalone padded leaf nested in flow retains an independent visual-token wrapper'); $proseBadge = $transform('<style>.card{display:flex}.badge{padding:2px 8px;border:1px solid #999}.card .badge{color:red}</style><div class="card"><div class="copy">Read <span class="badge">new</span> notes.</div></div>'); $proseBadgeMarkup = (string) ($proseBadge['serialized_blocks'] ?? ''); $proseBadgeCss = $css($proseBadge); -$assert(! str_contains($proseBadgeMarkup, 'blocks-engine-semantic-') && str_contains($proseBadgeMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($proseBadgeCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && 'pass' === ($proseBadge['source_reports']['wp_block_validity']['status'] ?? ''), 'a padded badge inside prose in a flex card remains an inline RichText marker'); +$assert(! str_contains($proseBadgeMarkup, 'blocks-engine-semantic-') && str_contains($proseBadgeMarkup, '<mark class="blocks-engine-richtext-') && str_contains($proseBadgeCss, 'mark.blocks-engine-richtext-') && 'pass' === ($proseBadge['source_reports']['wp_block_validity']['status'] ?? ''), 'a padded badge inside prose in a flex card remains an inline RichText marker'); $ordinaryInline = $transform('<style>span{color:red}</style><p>Read <span>this</span> now.</p>'); $ordinaryInlineMarkup = (string) ($ordinaryInline['serialized_blocks'] ?? ''); diff --git a/php-transformer/tests/unit/block-style-support-conversion.php b/php-transformer/tests/unit/block-style-support-conversion.php index 52f34846..1b2f4b2e 100644 --- a/php-transformer/tests/unit/block-style-support-conversion.php +++ b/php-transformer/tests/unit/block-style-support-conversion.php @@ -78,6 +78,17 @@ $assert('100svh' === ($groupAttrs['style']['dimensions']['minHeight'] ?? ''), '15: min-height maps to Gutenberg dimensions support', json_encode($groupAttrs['style']['dimensions'] ?? array())); $assert(str_contains($groupInnerHtml, 'min-height:100svh'), '16: rendered wrapper preserves section min-height geometry', $groupInnerHtml); +$linkedBrandResult = ( new HtmlTransformer() )->transform( + '<a class="brand" href="/" style="display:flex;align-items:center;gap:18px"><svg viewBox="0 0 10 10"><path d="M0 0h10v10z"></path></svg><span class="brand-text"><span>Acme</span><span>Labs</span></span></a>', + array() +)->toArray(); +$linkedBrand = $linkedBrandResult['blocks'][0] ?? array(); +$linkedBrandButton = $linkedBrand['innerBlocks'][0] ?? array(); +$linkedBrandAttrs = is_array($linkedBrandButton['attrs'] ?? null) ? $linkedBrandButton['attrs'] : array(); +$linkedBrandCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($linkedBrandResult['assets'] ?? null) ? $linkedBrandResult['assets'] : array())); +$assert('core/buttons' === ($linkedBrand['blockName'] ?? '') && 'core/button' === ($linkedBrandButton['blockName'] ?? ''), '16a: linked text logos retain current native button semantics', json_encode($linkedBrand)); +$assert(str_contains((string) ($linkedBrandAttrs['className'] ?? ''), 'be-inline-geometry-') && str_contains($linkedBrandCss, '> .wp-block-button__link{display:flex;align-items:center;gap:18px}'), '16b: linked text logos retain their inline flex alignment and exact gap on the rendered link', $linkedBrandCss); + $cardHtml = '<section class="pricing-shell" style="max-width:1120px;margin:0 auto;padding:5rem 2rem"><article class="pricing-card" style="max-width:360px;padding:2rem;background:#fff"><h2>Team</h2><p>Scale every launch.</p></article></section>'; $cardResult = ( new HtmlTransformer() )->transform($cardHtml, array())->toArray(); $cardShell = $cardResult['blocks'][0] ?? array(); @@ -251,25 +262,89 @@ $assert('masthead__bio' === ($textPresetAttrs['className'] ?? ''), '37: source paragraph class remains preserved without generated color class leakage', json_encode($textPresetAttrs)); $assert(! str_contains($textPresetInnerHtml, 'has-text-color has-text-color'), '38: rendered paragraph does not carry duplicate generated text color classes', $textPresetInnerHtml); +$inlineFlexText = ( new HtmlTransformer() )->transform('<div style="display:flex"><span>Utility copy</span><nav><a href="/">Home</a></nav></div>')->toArray(); +$inlineFlexParagraph = $inlineFlexText['blocks'][0]['innerBlocks'][0]['innerBlocks'][0] ?? array(); +$inlineFlexMarkup = (string) ($inlineFlexText['serialized_blocks'] ?? ''); +$inlineFlexMargins = $inlineFlexParagraph['attrs']['style']['spacing']['margin'] ?? array(); + +$assert( + array( 'top' => '0', 'right' => '0', 'bottom' => '0', 'left' => '0' ) === $inlineFlexMargins, + '39: standalone inline text retains its zero source margin when canonicalized as a flex-item paragraph', + json_encode($inlineFlexMargins) +); +$assert( + str_contains($inlineFlexMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), + '40: standalone inline text serializes the margin reset through native paragraph supports', + $inlineFlexMarkup +); + +$selectorMarginText = ( new HtmlTransformer() )->transform( + '<div class="utility" style="display:flex"><span>Label</span><span>Copy</span><nav><a href="/">Home</a></nav></div>', + array( 'static_css' => '.utility > span:nth-child(2){margin:3px 7px 11px 13px}' ) +)->toArray(); +$selectorMarginCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $selectorMarginText['assets'] ?? array())); +$selectorMarginFlexItem = $selectorMarginText['blocks'][0]['innerBlocks'][1] ?? array(); +$selectorMarginWrapper = $selectorMarginFlexItem['innerBlocks'][0] ?? array(); +$selectorMarginParagraph = $selectorMarginWrapper['innerBlocks'][0] ?? array(); +$selectorMarginClass = (string) ($selectorMarginFlexItem['attrs']['className'] ?? ''); + +$assert( + str_starts_with($selectorMarginClass, 'blocks-engine-semantic-') + && $selectorMarginClass === ($selectorMarginWrapper['attrs']['className'] ?? '') + && str_contains($selectorMarginCss, '.' . $selectorMarginClass) + && ! isset($selectorMarginParagraph['attrs']['style']['spacing']['margin']), + '41: structural-selector margin stays on the emitted flex-item wrapper without a competing paragraph margin', + json_encode(array( 'flex_item' => $selectorMarginFlexItem, 'paragraph' => $selectorMarginParagraph, 'css' => $selectorMarginCss )) +); + +$idMarginText = ( new HtmlTransformer() )->transform( + '<div style="display:flex"><span>Label</span><span id="utility-copy">Copy</span><nav><a href="/">Home</a></nav></div>', + array( 'static_css' => '#utility-copy{margin:3px 7px 11px 13px}' ) +)->toArray(); +$idMarginCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $idMarginText['assets'] ?? array())); +$idMarginFlexItem = $idMarginText['blocks'][0]['innerBlocks'][1] ?? array(); +$idMarginWrapper = $idMarginFlexItem['innerBlocks'][0] ?? array(); +$idMarginParagraph = $idMarginWrapper['innerBlocks'][0] ?? array(); +$idMarginClass = (string) ($idMarginFlexItem['attrs']['className'] ?? ''); + +$assert( + 'utility-copy' === ($idMarginFlexItem['attrs']['anchor'] ?? '') + && $idMarginClass === ($idMarginWrapper['attrs']['className'] ?? '') + && str_contains($idMarginCss, '.' . $idMarginClass) + && ! isset($idMarginParagraph['attrs']['style']['spacing']['margin']), + '42: ID-selector margin stays on the emitted flex-item wrapper without a competing paragraph margin', + json_encode(array( 'flex_item' => $idMarginFlexItem, 'paragraph' => $idMarginParagraph, 'css' => $idMarginCss )) +); + +$inlineMarginText = ( new HtmlTransformer() )->transform('<div style="display:flex"><span style="margin:3px 7px 11px 13px">Copy</span><nav><a href="/">Home</a></nav></div>')->toArray(); +$inlineMarginParagraph = $inlineMarginText['blocks'][0]['innerBlocks'][0]['innerBlocks'][0] ?? array(); +$inlineMargins = $inlineMarginParagraph['attrs']['style']['spacing']['margin'] ?? array(); + +$assert( + array( 'top' => '3px', 'right' => '7px', 'bottom' => '11px', 'left' => '13px' ) === $inlineMargins, + '43: resolved source inline margins become native paragraph margins after canonicalization', + json_encode($inlineMargins) +); + $paintCss = '.pricing-card{background:radial-gradient(circle at 20% 10%,rgba(255,255,255,.9),rgba(255,255,255,0) 38%),linear-gradient(180deg,#fff,#f5efe4);background-position:center top;background-size:120% 80%,100% 100%;background-repeat:no-repeat;box-shadow:0 28px 80px rgba(20,12,4,.18);padding:2rem;border-radius:24px}'; $paintHtml = '<main><section class="pricing-card"><h2>Roast Club</h2><p>Fresh coffee every week.</p></section></main>'; $paintResult = ( new HtmlTransformer() )->transform($paintHtml, array('static_css' => $paintCss))->toArray(); $paintBlock = $paintResult['blocks'][0] ?? array(); $paintAttrs = is_array($paintBlock['attrs'] ?? null) ? $paintBlock['attrs'] : array(); -$assert('pricing-card' === ($paintAttrs['className'] ?? ''), '39: high-value card wrapper keeps source class for class-owned paint CSS', json_encode($paintAttrs)); -$assert(! isset($paintAttrs['style']['box-shadow']), '40: class-owned box-shadow is not stored as an unsupported block style attr', json_encode($paintAttrs['style'] ?? array())); -$assert(! isset($paintAttrs['style']['background-position']) && ! isset($paintAttrs['style']['background-size']), '41: background layer controls stay out of block style attrs', json_encode($paintAttrs['style'] ?? array())); +$assert('pricing-card' === ($paintAttrs['className'] ?? ''), '44: high-value card wrapper keeps source class for class-owned paint CSS', json_encode($paintAttrs)); +$assert(! isset($paintAttrs['style']['box-shadow']), '45: class-owned box-shadow is not stored as an unsupported block style attr', json_encode($paintAttrs['style'] ?? array())); +$assert(! isset($paintAttrs['style']['background-position']) && ! isset($paintAttrs['style']['background-size']), '46: background layer controls stay out of block style attrs', json_encode($paintAttrs['style'] ?? array())); $rulesMethod = new ReflectionMethod(HtmlTransformer::class, 'staticStyleRules'); $paintRules = $rulesMethod->invoke(new HtmlTransformer(), '', $paintCss); $paintDeclarations = $paintRules[0]['declarations'] ?? array(); -$assert(($paintDeclarations['background'] ?? '') === 'radial-gradient(circle at 20% 10%,rgba(255,255,255,.9),rgba(255,255,255,0) 38%),linear-gradient(180deg,#fff,#f5efe4)', '42: radial and layered backgrounds survive safe CSS resolution', json_encode($paintDeclarations)); -$assert(($paintDeclarations['background-position'] ?? '') === 'center top', '43: background-position survives safe CSS resolution', json_encode($paintDeclarations)); -$assert(($paintDeclarations['background-size'] ?? '') === '120% 80%,100% 100%', '44: background-size survives safe CSS resolution', json_encode($paintDeclarations)); -$assert(($paintDeclarations['background-repeat'] ?? '') === 'no-repeat', '45: background-repeat survives safe CSS resolution', json_encode($paintDeclarations)); -$assert(($paintDeclarations['box-shadow'] ?? '') === '0 28px 80px rgba(20,12,4,.18)', '46: box-shadow survives safe CSS resolution', json_encode($paintDeclarations)); +$assert(($paintDeclarations['background'] ?? '') === 'radial-gradient(circle at 20% 10%,rgba(255,255,255,.9),rgba(255,255,255,0) 38%),linear-gradient(180deg,#fff,#f5efe4)', '47: radial and layered backgrounds survive safe CSS resolution', json_encode($paintDeclarations)); +$assert(($paintDeclarations['background-position'] ?? '') === 'center top', '48: background-position survives safe CSS resolution', json_encode($paintDeclarations)); +$assert(($paintDeclarations['background-size'] ?? '') === '120% 80%,100% 100%', '49: background-size survives safe CSS resolution', json_encode($paintDeclarations)); +$assert(($paintDeclarations['background-repeat'] ?? '') === 'no-repeat', '50: background-repeat survives safe CSS resolution', json_encode($paintDeclarations)); +$assert(($paintDeclarations['box-shadow'] ?? '') === '0 28px 80px rgba(20,12,4,.18)', '51: box-shadow survives safe CSS resolution', json_encode($paintDeclarations)); if ( $failures > 0 ) { fwrite(STDERR, "Block style support conversion tests: {$failures} failed, {$passes} passed\n"); diff --git a/php-transformer/tests/unit/css-selector-matcher.php b/php-transformer/tests/unit/css-selector-matcher.php index a5be98b8..20e82d70 100644 --- a/php-transformer/tests/unit/css-selector-matcher.php +++ b/php-transformer/tests/unit/css-selector-matcher.php @@ -66,6 +66,8 @@ $assert($result['supported'] && $result['matches'], "matches structural pseudo-class {$selector}"); } $assert(! $match('p:nth-child(1)', $byId('two'))['matches'], 'rejects a non-matching structural child index'); +$assert($match('input:first-of-type', $byId('control'))['matches'], 'matches first-of-type independently of child position'); +$assert(! $match('p:first-of-type', $byId('two'))['matches'], 'rejects a later sibling of the same type'); foreach ( array( ':disabled', ':not(.x)', ':is(.x)', ':where(.x)', ':has(.x)', ':nth-child(0)', ':nth-child(2n+1)', ':nth-child()', 'p::before', 'svg|a', '.a||.b', '.a, .b', '.a[', '.a >', '-', '.-', '#-', '.10', '\\', ".a\\\n", ".a\\\r", ".a\\\r\n", "[data-value=\"a\nb\"]", "[data-value=\"a\\\nb\"]", "[data-value=\"a\\\r\nb\"]", "[data-value=\"a\\\"]" ) as $selector ) { $assert(! CssSelectorMatcher::parse($selector)['supported'], "rejects unsupported or malformed {$selector}");