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