diff --git a/php-transformer/src/HtmlToBlocks/Elements/CustomWrapperConventions.php b/php-transformer/src/HtmlToBlocks/Elements/CustomWrapperConventions.php new file mode 100644 index 000000000..6db5d658b --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Elements/CustomWrapperConventions.php @@ -0,0 +1,163 @@ + + */ + public function authorDataAttributeSelectors(string $css): array + { + $selectors = array(); + foreach ( CssValueSplitter::ruleBlocks($css) as $rule ) { + if ( ! str_contains($rule['selector'], '[data-') ) { + continue; + } + if ( preg_match_all('/\[\s*data-[A-Za-z][A-Za-z0-9_-]*\s*(?:[*^$|~]?=\s*(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\]\s"\']+)\s*(?:[is]\s*)?)?\]/', $rule['selector'], $matches) ) { + foreach ( $matches[0] as $selector ) { + $selectors[$selector] = true; + } + } + } + + return $selectors; + } + + /** + * Whether an author selector keys on this instance through its own + * attributes. Such an instance is a selector hook, not a wrapping + * convention: removing it silently detaches every rule that needs it as + * a subject or ancestor. Equality compounds must match the instance's + * value; any other operator fails closed on the attribute name. + * + * @param array $selectors + */ + public function authorAttributeSelectorAddresses(DOMElement $element, array $selectors): bool + { + foreach ( array_keys($selectors) as $selector ) { + $parsed = \Automattic\BlocksEngine\PhpTransformer\Support\RuntimeSelectorVocabulary::parseAttributeSelector($selector); + if ( null !== $parsed ) { + if ( \Automattic\BlocksEngine\PhpTransformer\Support\RuntimeSelectorVocabulary::matchesElement($element, $selector, array()) ) { + return true; + } + continue; + } + if ( 1 === preg_match('/^\[\s*(data-[A-Za-z][A-Za-z0-9_-]*)/', $selector, $name) && $element->hasAttribute(strtolower($name[1])) ) { + return true; + } + } + + return false; + } + + /** + * Whether a custom element tag is used as a wrapping convention rather than + * as a component host. + * + * @param array $elements Every instance of the tag. + */ + public function isWrapperConventionTag(string $tag, array $elements, string $staticCss): bool + { + if ( self::WRAPPER_CONVENTION_MIN_INSTANCES > count($elements) ) { + return false; + } + + // A tag that carries presentation of its own is not render-neutral. + // `display:contents` is the exception that proves the rule: it states + // the element generates no box and renders as its children. + if ( ! $this->customElementCssIsRenderNeutral($tag, $staticCss) ) { + return false; + } + + $shapes = array(); + foreach ( $elements as $element ) { + $shapes[$this->wrappedContentShape($element)] = true; + if ( 1 < count($shapes) ) { + return true; + } + } + + return false; + } + + /** + * Whether every author rule addressing a custom element tag leaves it + * render-neutral, so replacing instances with their children preserves the + * authored presentation. + */ + private function customElementCssIsRenderNeutral(string $tag, string $css): bool + { + if ( '' === trim($css) ) { + return true; + } + + $pattern = '/(?styleResolver->cssDeclarations($rule['declarations']) as $property => $value ) { + if ( 'display' !== strtolower(trim($property)) || 'contents' !== strtolower(trim($value)) ) { + return false; + } + } + } + + return true; + } + + /** + * Shallow tag skeleton of the content a wrapper carries. Instances of a real + * component repeat this shape; instances of a wrapper do not. + */ + private function wrappedContentShape(DOMElement $element, int $depth = 0): string + { + $parts = array(); + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + $tag = strtolower($child->tagName); + $parts[] = self::WRAPPER_CONTENT_SHAPE_DEPTH > $depth + ? $tag . '(' . $this->wrappedContentShape($child, $depth + 1) . ')' + : $tag; + } + + return implode(',', $parts); + } +} diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 25f009824..58fd85f78 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -209,16 +209,6 @@ final class HtmlCompilation extends SourceBlockCreator implements RichTextInline { private const GENERATED_COMPONENT_MIN_SOURCE_DEPTH = 14; - /** - * Instances a custom element tag must reach before recurring usage can be - * read as a wrapping convention rather than a component. - */ - private const WRAPPER_CONVENTION_MIN_INSTANCES = 3; - - /** - * Depth of the shallow tag skeleton compared across wrapper instances. - */ - private const WRAPPER_CONTENT_SHAPE_DEPTH = 2; use DomHelpersTrait; private readonly AuthorLayoutOwnership $authorLayoutOwnership; @@ -317,6 +307,7 @@ final class HtmlCompilation extends SourceBlockCreator implements RichTextInline private readonly Elements\RenderNeutralElements $renderNeutral; private readonly Elements\InertElements $inertElements; private readonly Elements\EmptyInlineGeometryLowering $emptyInlineGeometry; + private readonly Elements\CustomWrapperConventions $wrapperConventions; private readonly SourceProvenance $sourceProvenance; private readonly RuntimeResourceElementConverter $runtimeResourceConverter; @@ -611,6 +602,7 @@ function (DOMElement $element): array { $this->renderNeutral = new Elements\RenderNeutralElements($this->session, $this->styleResolver, $this->sourceElementClassifier, $this->sourceElementDescription, $this->runtimeIslands, $this->emptyElementFootprint); $this->inertElements = new Elements\InertElements($this->styleResolver, $this->sourceElementClassifier, $this->sourceVisibility, $this->runtimeIslands); $this->emptyInlineGeometry = new Elements\EmptyInlineGeometryLowering($this, $this->session, $this->sourceElementClassifier, $this->emptyElementFootprint, $this->authoredStateButtons, $this->layoutShells, $this->runtimeIslands); + $this->wrapperConventions = new Elements\CustomWrapperConventions($this->styleResolver); $this->customImageHosts = new Elements\CustomImageHostLowering($this, $this->blockFactory, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->layoutShells, $this->runtimeIslands, $this->authorLayoutOwnership); $this->anchors = new Elements\AnchorLowering($this, $this, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->imageUrls, $this->layoutShells, $this->linkWrapperPropagator, $this->runtimeIslands, $this->customImageHosts); $this->layoutTables = new Elements\LayoutTableLowering( @@ -2265,13 +2257,13 @@ private function unwrapRenderNeutralCustomElements(DOMElement $body, string $sta $attributeSelectors = null; foreach ( $instances as $tag => $elements ) { - if ( ! $this->isWrapperConventionTag($tag, $elements, $staticCss) ) { + if ( ! $this->wrapperConventions->isWrapperConventionTag($tag, $elements, $staticCss) ) { continue; } - $attributeSelectors ??= $this->authorDataAttributeSelectors($staticCss); + $attributeSelectors ??= $this->wrapperConventions->authorDataAttributeSelectors($staticCss); foreach ( $elements as $element ) { if ( $this->renderNeutral->isRenderNeutralWrapper($element, false) ) { - if ( $this->authorAttributeSelectorAddresses($element, $attributeSelectors) ) { + if ( $this->wrapperConventions->authorAttributeSelectorAddresses($element, $attributeSelectors) ) { $this->retainedCustomWrapperHooks[] = $element; } else { $this->richTextStylingHooks()->unwrapElement($element); @@ -2281,86 +2273,6 @@ private function unwrapRenderNeutralCustomElements(DOMElement $body, string $sta } } - /** - * Data-attribute compounds written in author selectors, e.g. the - * `[data-key="a"]` of `[data-key="a"] .photo`. - * - * @return array - */ - private function authorDataAttributeSelectors(string $css): array - { - $selectors = array(); - foreach ( CssValueSplitter::ruleBlocks($css) as $rule ) { - if ( ! str_contains($rule['selector'], '[data-') ) { - continue; - } - if ( preg_match_all('/\[\s*data-[A-Za-z][A-Za-z0-9_-]*\s*(?:[*^$|~]?=\s*(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\]\s"\']+)\s*(?:[is]\s*)?)?\]/', $rule['selector'], $matches) ) { - foreach ( $matches[0] as $selector ) { - $selectors[$selector] = true; - } - } - } - - return $selectors; - } - - /** - * Whether an author selector keys on this instance through its own - * attributes. Such an instance is a selector hook, not a wrapping - * convention: removing it silently detaches every rule that needs it as - * a subject or ancestor. Equality compounds must match the instance's - * value; any other operator fails closed on the attribute name. - * - * @param array $selectors - */ - private function authorAttributeSelectorAddresses(DOMElement $element, array $selectors): bool - { - foreach ( array_keys($selectors) as $selector ) { - $parsed = \Automattic\BlocksEngine\PhpTransformer\Support\RuntimeSelectorVocabulary::parseAttributeSelector($selector); - if ( null !== $parsed ) { - if ( \Automattic\BlocksEngine\PhpTransformer\Support\RuntimeSelectorVocabulary::matchesElement($element, $selector, array()) ) { - return true; - } - continue; - } - if ( 1 === preg_match('/^\[\s*(data-[A-Za-z][A-Za-z0-9_-]*)/', $selector, $name) && $element->hasAttribute(strtolower($name[1])) ) { - return true; - } - } - - return false; - } - - /** - * Whether a custom element tag is used as a wrapping convention rather than - * as a component host. - * - * @param array $elements Every instance of the tag. - */ - private function isWrapperConventionTag(string $tag, array $elements, string $staticCss): bool - { - if ( self::WRAPPER_CONVENTION_MIN_INSTANCES > count($elements) ) { - return false; - } - - // A tag that carries presentation of its own is not render-neutral. - // `display:contents` is the exception that proves the rule: it states - // the element generates no box and renders as its children. - if ( ! $this->customElementCssIsRenderNeutral($tag, $staticCss) ) { - return false; - } - - $shapes = array(); - foreach ( $elements as $element ) { - $shapes[$this->wrappedContentShape($element)] = true; - if ( 1 < count($shapes) ) { - return true; - } - } - - return false; - } - /** * Author CSS visible to the document: declared stylesheets plus the styles * the source embeds inline. @@ -2370,56 +2282,6 @@ private function authoredCssText(string $html, string $staticCss): string return $this->stylesheetAnalysisComposer->combinedAuthorStylesheet($html, $staticCss); } - /** - * Whether every author rule addressing a custom element tag leaves it - * render-neutral, so replacing instances with their children preserves the - * authored presentation. - */ - private function customElementCssIsRenderNeutral(string $tag, string $css): bool - { - if ( '' === trim($css) ) { - return true; - } - - $pattern = '/(?styleResolver->cssDeclarations($rule['declarations']) as $property => $value ) { - if ( 'display' !== strtolower(trim($property)) || 'contents' !== strtolower(trim($value)) ) { - return false; - } - } - } - - return true; - } - - /** - * Shallow tag skeleton of the content a wrapper carries. Instances of a real - * component repeat this shape; instances of a wrapper do not. - */ - private function wrappedContentShape(DOMElement $element, int $depth = 0): string - { - $parts = array(); - foreach ( $element->childNodes as $child ) { - if ( ! $child instanceof DOMElement ) { - continue; - } - $tag = strtolower($child->tagName); - $parts[] = self::WRAPPER_CONTENT_SHAPE_DEPTH > $depth - ? $tag . '(' . $this->wrappedContentShape($child, $depth + 1) . ')' - : $tag; - } - - return implode(',', $parts); - } - private function collectGeneratedComponentCandidates(DOMElement $element, int $depth = 0): void { if (self::GENERATED_COMPONENT_MIN_SOURCE_DEPTH <= $depth