Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions php-transformer/src/HtmlToBlocks/Elements/CustomWrapperConventions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements;

use Automattic\BlocksEngine\PhpTransformer\Css\CssValueSplitter;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleResolver;
use DOMElement;

/**
* Recognizes custom element tags used as a wrapping convention rather than as
* component hosts, and the author attribute selectors that still address an
* instance.
*/
final class CustomWrapperConventions
{
/**
* 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;

public function __construct(
private readonly StyleResolver $styleResolver
) {
}

/**
* Data-attribute compounds written in author selectors, e.g. the
* `[data-key="a"]` of `[data-key="a"] .photo`.
*
* @return array<string, true>
*/
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<string, true> $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<int, DOMElement> $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 = '/(?<![\w-])' . preg_quote($tag, '/') . '(?![\w-])/i';
if ( 1 !== preg_match($pattern, $css) ) {
return true;
}

foreach ( CssValueSplitter::ruleBlocks($css) as $rule ) {
if ( 1 !== preg_match($pattern, $rule['selector']) ) {
continue;
}
foreach ( $this->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);
}
}
148 changes: 5 additions & 143 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -316,6 +306,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;
Expand Down Expand Up @@ -609,6 +600,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(
Expand Down Expand Up @@ -2263,13 +2255,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);
Expand All @@ -2279,86 +2271,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<string, true>
*/
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<string, true> $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<int, DOMElement> $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.
Expand All @@ -2368,56 +2280,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 = '/(?<![\w-])' . preg_quote($tag, '/') . '(?![\w-])/i';
if ( 1 !== preg_match($pattern, $css) ) {
return true;
}

foreach ( CssValueSplitter::ruleBlocks($css) as $rule ) {
if ( 1 !== preg_match($pattern, $rule['selector']) ) {
continue;
}
foreach ( $this->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
Expand Down
Loading