From cd9d5460b6c8360c2c7add8f4c7704f8e8a14a17 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 16:45:09 -0400 Subject: [PATCH 1/8] feat(forms): retain bounded auxiliary SVG presentation --- .../ArtifactCompiler/RuntimeDeclarations.php | 2 + .../Elements/FormControlMetadataBuilder.php | 74 ++++++++++++++++++- .../src/HtmlToBlocks/HtmlCompilation.php | 3 +- .../src/HtmlToBlocks/Support/SourceDom.php | 54 ++++++++++++++ .../tests/contract/wordpress-site-plan.php | 10 +++ php-transformer/tests/unit/source-dom.php | 12 +++ 6 files changed, 153 insertions(+), 2 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php b/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php index 5486e422e..6a122a5cb 100644 --- a/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php +++ b/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php @@ -6,6 +6,7 @@ use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FormControlMetadataBuilder; use InvalidArgumentException; use JsonException; @@ -69,6 +70,7 @@ public static function normalizeList(mixed $raw): array $normalized['payload'] = $payload; if ('entity_collection' === $kind && 'forms' === $name && 'generic/forms/v1' === ($payload['schema'] ?? null)) foreach ($payload['entities'] ?? array() as $entity) if (is_array($entity) && isset($entity['layout_graph'])) { if (!is_array($entity['layout_graph'])) throw new InvalidArgumentException("Runtime declaration {$index} form layout graph must be an object."); FormLayoutGraphBuilder::assertValid($entity['layout_graph']); } if ('entity_collection' === $kind && 'forms' === $name && 'generic/forms/v1' === ($payload['schema'] ?? null)) foreach ($payload['entities'] ?? array() as $entity) if (is_array($entity) && isset($entity['presentation_graph'])) { if (!is_array($entity['presentation_graph'])) throw new InvalidArgumentException("Runtime declaration {$index} form presentation graph must be an object."); FormPresentationGraphBuilder::assertValid($entity['presentation_graph']); } + if ('entity_collection' === $kind && 'forms' === $name && 'generic/forms/v1' === ($payload['schema'] ?? null)) foreach ($payload['entities'] ?? array() as $entity) if (is_array($entity)) foreach ($entity['controls'] ?? array() as $control) if (is_array($control) && isset($control['auxiliary_visuals'])) { if (!is_array($control['auxiliary_visuals'])) throw new InvalidArgumentException("Runtime declaration {$index} form auxiliary visuals must be a list."); FormControlMetadataBuilder::assertAuxiliaryVisuals($control['auxiliary_visuals']); } } if ('entity_collection' === $kind && (!isset($normalized['type'], $normalized['payload']['entities']) || !array_is_list($normalized['payload']['entities']))) throw new InvalidArgumentException("Runtime declaration {$index} entity collections require a typed entities payload."); if (isset($declaration['required_for'])) { diff --git a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php index 7970eeb5f..3d5dac885 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php @@ -9,14 +9,21 @@ use DOMDocument; use DOMElement; use DOMNode; +use InvalidArgumentException; /** Builds provider-neutral form and control metadata from source DOM. */ final class FormControlMetadataBuilder { + private const MAX_AUXILIARY_VISUALS = 4; + private const MAX_AUXILIARY_VISUAL_BYTES = 12288; + private const MAX_AUXILIARY_VISUAL_DIMENSION = 4096; + /** @param Closure(DOMElement): string $elementSelector */ public function __construct( private readonly Closure $elementSelector, - private readonly ?Closure $presentationAttributes = null + private readonly ?Closure $presentationAttributes = null, + /** @var (Closure(DOMElement): string)|null $sanitizeInlineSvgMarkup */ + private readonly ?Closure $sanitizeInlineSvgMarkup = null ) { } @@ -105,6 +112,10 @@ public function control(DOMElement $control): array $metadata['presentation'] = array( 'style' => $presentation['style'] ); } } + $auxiliaryVisuals = $this->auxiliaryVisuals($control); + if ( array() !== $auxiliaryVisuals ) { + $metadata['auxiliary_visuals'] = $auxiliaryVisuals; + } } if ( $control->hasAttribute('required') || 'true' === strtolower(trim(SourceDom::attr($control, 'aria-required'))) ) { @@ -140,6 +151,22 @@ public function control(DOMElement $control): array return $metadata; } + /** @param array> $visuals */ + public static function assertAuxiliaryVisuals(array $visuals): void + { + if ( ! array_is_list($visuals) || count($visuals) > self::MAX_AUXILIARY_VISUALS ) { + throw new InvalidArgumentException('Form control auxiliary visuals are invalid.'); + } + foreach ( $visuals as $visual ) { + if ( ! is_array($visual) || array_diff(array_keys($visual), array( 'kind', 'markup', 'intrinsic_size' )) || 'inline_svg' !== ($visual['kind'] ?? null) || ! is_string($visual['markup'] ?? null) || strlen($visual['markup']) > self::MAX_AUXILIARY_VISUAL_BYTES || ! SourceDom::isSafeInlineSvgMarkup($visual['markup']) ) { + throw new InvalidArgumentException('Form control auxiliary visual is unsafe.'); + } + if ( isset($visual['intrinsic_size']) && (! is_array($visual['intrinsic_size']) || array_diff(array_keys($visual['intrinsic_size']), array( 'width', 'height' )) || ! self::validDimension($visual['intrinsic_size']['width'] ?? null) || ! self::validDimension($visual['intrinsic_size']['height'] ?? null)) ) { + throw new InvalidArgumentException('Form control auxiliary visual dimensions are invalid.'); + } + } + } + public function label(DOMElement $control): string { $ariaLabel = trim(SourceDom::attr($control, 'aria-label')); @@ -300,6 +327,51 @@ private function buttonText(DOMElement $control): string return '' !== $text ? $text : trim(SourceDom::attr($control, 'value')); } + /** @return array> */ + private function auxiliaryVisuals(DOMElement $control): array + { + if ( null === $this->sanitizeInlineSvgMarkup ) { + return array(); + } + + $visuals = array(); + foreach ( $control->getElementsByTagName('svg') as $svg ) { + if ( ! $svg instanceof DOMElement || count($visuals) >= self::MAX_AUXILIARY_VISUALS || ! SourceDom::svgHasDrawableContent($svg) ) { + continue; + } + $markup = trim(($this->sanitizeInlineSvgMarkup)($svg)); + if ( strlen($markup) > self::MAX_AUXILIARY_VISUAL_BYTES || ! SourceDom::isSafeInlineSvgMarkup($markup) ) { + continue; + } + $visual = array( 'kind' => 'inline_svg', 'markup' => $markup ); + $size = $this->intrinsicSize($svg); + if ( array() !== $size ) { + $visual['intrinsic_size'] = $size; + } + $visuals[] = $visual; + } + self::assertAuxiliaryVisuals($visuals); + return $visuals; + } + + /** @return array */ + private function intrinsicSize(DOMElement $svg): array + { + $width = $this->dimension(SourceDom::attr($svg, 'width')); + $height = $this->dimension(SourceDom::attr($svg, 'height')); + return null !== $width && null !== $height ? array( 'width' => $width, 'height' => $height ) : array(); + } + + private function dimension(string $value): ?int + { + return 1 === preg_match('/^[1-9][0-9]{0,3}$/D', trim($value)) && (int) $value <= self::MAX_AUXILIARY_VISUAL_DIMENSION ? (int) $value : null; + } + + private static function validDimension(mixed $value): bool + { + return is_int($value) && $value > 0 && $value <= self::MAX_AUXILIARY_VISUAL_DIMENSION; + } + private function collapseRepeatedLabel(string $label): string { if ( preg_match('/^\s*(.+?)[.!?]\s+\1\s*$/iu', $label, $match) ) { diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 1508fd79f..bc9ae3f7c 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -535,7 +535,8 @@ function (DOMElement $element, array &$fallbacks): void { ), $this->styleResolver, $this->runtime); $this->formControlMetadataBuilder = new FormControlMetadataBuilder( fn (DOMElement $element): string => $this->elementSelector($element), - fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element) + fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element), + fn (DOMElement $element): string => $this->svgMaterializer->restoreSvgCasing($this->sanitizeInlineSvgMarkup($element)) ); $this->authoredFormControlBlockConverter = new AuthoredFormControlBlockConverter( $this->formControlMetadataBuilder, diff --git a/php-transformer/src/HtmlToBlocks/Support/SourceDom.php b/php-transformer/src/HtmlToBlocks/Support/SourceDom.php index d512b23b9..0f07e2f3e 100644 --- a/php-transformer/src/HtmlToBlocks/Support/SourceDom.php +++ b/php-transformer/src/HtmlToBlocks/Support/SourceDom.php @@ -516,6 +516,60 @@ public static function isSafeSvgContent(string $content): bool return '' !== trim($content) && preg_match('/)/i', $content) && ! preg_match('/<\s*script\b|\son[a-z]+\s*=|javascript\s*:/i', $content); } + /** + * Validate markup intended for a trusted inline SVG rendering surface. + * + * This deliberately accepts a small, passive SVG subset instead of trying + * to clean caller-controlled markup. XML parsing makes entity decoding and + * the single-root requirement part of the boundary rather than regexes. + */ + public static function isSafeInlineSvgMarkup(string $markup): bool + { + if ( '' === trim($markup) || str_contains($markup, 'loadXML($markup, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_COMPACT); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if ( ! $loaded || ! $document->documentElement instanceof DOMElement || 'svg' !== strtolower($document->documentElement->tagName) ) { + return false; + } + + $allowedTags = array_flip(array( + 'svg', 'g', 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', + 'text', 'tspan', 'title', 'desc', 'defs', 'lineargradient', 'radialgradient', + 'stop', 'clippath', 'mask', 'pattern', 'marker', 'filter', 'feblend', + 'fecolormatrix', 'fecomposite', 'fegaussianblur', 'femerge', 'femergenode', + 'feoffset', 'feflood', 'feturbulence', + )); + $blockedAttributes = array_flip(array( 'href', 'xlink:href', 'src', 'style' )); + $nodes = array( $document->documentElement ); + while ( array() !== $nodes ) { + /** @var DOMElement $element */ + $element = array_pop($nodes); + if ( ! isset($allowedTags[strtolower($element->tagName)]) ) { + return false; + } + foreach ( $element->attributes as $attribute ) { + $name = strtolower($attribute->name); + $value = trim($attribute->value); + if ( str_starts_with($name, 'on') || isset($blockedAttributes[$name]) || (str_contains($name, ':') && ! in_array($name, array( 'xmlns', 'xml:lang', 'xml:space' ), true)) || preg_match('/(?:^|[^a-z])url\s*\(/i', $value) ) { + return false; + } + } + foreach ( $element->childNodes as $child ) { + if ( $child instanceof DOMElement ) { + $nodes[] = $child; + } + } + } + + return true; + } + /** * Whether an inline SVG carries any drawable artwork worth preserving. * diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 500c4a073..5b4772cd8 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -332,6 +332,16 @@ $projectedTopology = $topologyDeclaration['payload']['entities'][0]['control_topology'] ?? null; $assert(is_array($projectedTopology) && RuntimeDeclarations::hash($projectedTopology) === RuntimeDeclarations::hash($topologyFallback['control_topology'] ?? null), 'Artifact compiler and WordPress site plan project the generic form control topology unchanged.'); $assert('wrapper-0' === ($projectedTopology['nodes'][1]['parent'] ?? null) && 'wrapper-0' === ($projectedTopology['nodes'][3]['parent'] ?? null) && array(0, 1) === array($projectedTopology['nodes'][1]['order'] ?? null, $projectedTopology['nodes'][3]['order'] ?? null) && array(0, 1, 2) === array($projectedTopology['nodes'][2]['control'] ?? null, $projectedTopology['nodes'][4]['control'] ?? null, $projectedTopology['nodes'][6]['control'] ?? null), 'The generic/forms/v1 declaration retains shared-row identity, source order, and flat control references.'); +$auxiliaryVisualResult = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
')))->toArray(); +$auxiliaryVisualDeclaration = current(array_filter($auxiliaryVisualResult['source_reports']['wordpress_site_plan']['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); +$auxiliaryVisualControl = $auxiliaryVisualDeclaration['payload']['entities'][0]['controls'][0] ?? array(); +$auxiliaryVisuals = $auxiliaryVisualControl['auxiliary_visuals'] ?? array(); +$assert('button' === ($auxiliaryVisualControl['tag'] ?? null) && 2 === count($auxiliaryVisuals) && 'inline_svg' === ($auxiliaryVisuals[0]['kind'] ?? null) && 24 === ($auxiliaryVisuals[0]['intrinsic_size']['width'] ?? null) && 24 === ($auxiliaryVisuals[0]['intrinsic_size']['height'] ?? null) && str_contains((string) ($auxiliaryVisuals[0]['markup'] ?? ''), ' array('entrypoint' => 'index.html', 'runtime_declarations' => array(array('kind' => 'entity_collection', 'type' => 'forms', 'source_path' => 'index.html', 'payload' => array('schema' => 'generic/forms/v1', 'entities' => array(array('controls' => array(array('auxiliary_visuals' => array(array('kind' => 'inline_svg', 'markup' => $markup))))))))), 'files' => array('index.html' => '
Caller
')); +$safeAuxiliaryVisual = $auxiliaryVisualPayload(''); +$safeAuxiliaryVisual['runtime_declarations'][0]['payload']['entities'][0]['controls'][0]['auxiliary_visuals'][] = array('kind' => 'inline_svg', 'markup' => '', 'intrinsic_size' => array('width' => 20, 'height' => 20)); +$assert(2 === count((new ArtifactCompiler())->compile($safeAuxiliaryVisual)->toArray()['source_reports']['wordpress_site_plan']['runtime_declarations'][0]['payload']['entities'][0]['controls'][0]['auxiliary_visuals'] ?? array()), 'Runtime declaration intake accepts passive globe and 20px intrinsic chevron SVG payloads.'); +foreach (array('Click', '
extra root
', '', '') as $unsafeAuxiliaryMarkup) $throws(static fn() => (new ArtifactCompiler())->compile($auxiliaryVisualPayload($unsafeAuxiliaryMarkup)), 'Runtime declaration intake rejects strict-boundary unsafe auxiliary SVG payloads.'); $layoutArtifact = array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'css/style.css' => '.form{display:grid;grid-template-columns:1fr;gap:1rem}.form .row-2{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.field{display:flex;flex-direction:column;gap:.3rem}@media (max-width:640px){.form .row-2{grid-template-columns:1fr}}')); $layoutResult = (new ArtifactCompiler())->compile($layoutArtifact)->toArray(); $layoutFallback = current(array_filter($layoutResult['fallbacks'] ?? array(), static fn(array $fallback): bool => 'html_form_fallback' === ($fallback['diagnostic_code'] ?? null))); diff --git a/php-transformer/tests/unit/source-dom.php b/php-transformer/tests/unit/source-dom.php index ea971193e..34f6a61a3 100644 --- a/php-transformer/tests/unit/source-dom.php +++ b/php-transformer/tests/unit/source-dom.php @@ -134,6 +134,18 @@ 'a non-image data URL is rejected even on src' ); +// --- strict inline SVG payloads --------------------------------------------- + +$assert(SourceDom::isSafeInlineSvgMarkup(''), 'a passive globe SVG is accepted as inline markup'); +$assert(SourceDom::isSafeInlineSvgMarkup(''), 'a 20px intrinsic chevron SVG is accepted without conflating CSS presentation size'); +$assert(! SourceDom::isSafeInlineSvgMarkup('Click'), 'an entity-encoded javascript SVG link is rejected'); +$assert(! SourceDom::isSafeInlineSvgMarkup('
extra root
'), 'SVG markup with an extra HTML root is rejected'); +$assert(! SourceDom::isSafeInlineSvgMarkup(''), 'scriptable SVG animation is rejected'); +$assert(! SourceDom::isSafeInlineSvgMarkup(''), 'SVG mutation elements are rejected'); +$assert(! SourceDom::isSafeInlineSvgMarkup(''), 'external SVG resources are rejected'); +$assert(! SourceDom::isSafeInlineSvgMarkup(''), 'decoded external paint URLs are rejected'); +$assert(! SourceDom::isSafeInlineSvgMarkup(']>'), 'DOCTYPE and entity declarations are rejected'); + // --- statelessness ---------------------------------------------------------- $probe = $element('
x
'); From ebb19cc0110377781bd76b0eae4c19451b9b993e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 20:45:33 -0400 Subject: [PATCH 2/8] feat(forms): retain identified visual parts and responsive style facts --- .../docs/form-presentation-graph.md | 23 +++++ .../ArtifactCompiler/RuntimeDeclarations.php | 2 - .../Elements/FormControlMetadataBuilder.php | 74 +-------------- .../Elements/FormFallbackFindingBuilder.php | 3 +- .../Elements/FormFallbackFindingContext.php | 9 +- .../src/HtmlToBlocks/HtmlCompilation.php | 6 +- .../Style/FormPresentationGraphBuilder.php | 94 +++++++++++++++++-- php-transformer/tests/contract/run.php | 14 ++- .../tests/contract/wordpress-site-plan.php | 16 ++-- 9 files changed, 145 insertions(+), 96 deletions(-) create mode 100644 php-transformer/docs/form-presentation-graph.md diff --git a/php-transformer/docs/form-presentation-graph.md b/php-transformer/docs/form-presentation-graph.md new file mode 100644 index 000000000..fa6bc8995 --- /dev/null +++ b/php-transformer/docs/form-presentation-graph.md @@ -0,0 +1,23 @@ +# Form Presentation Graph + +`generic/computed-form-presentation/v2` is an optional `generic/forms/v1` payload member. It carries bounded source-CSS facts for controls, associated labels, and identified inline SVG descendants. It is not a provider recipe and never assigns visual semantics such as "globe" or "chevron". + +```php +array{ + schema: 'generic/computed-form-presentation/v2', + basis: 'source_css_cascade', + controls: list, + visual_parts: list, provenance: list}|array{state: 'unknown'} + }>, + variants: list, precedence: array, provenance: list}>, + truncated: bool, limits: array{controls: 128, rules_per_role: 32}, diagnostics: list +} +``` + +`visual_parts` contains only drawable SVG descendants that pass `SourceDom::isSafeInlineSvgMarkup()`. `source_selector` identifies the source descendant and `index` links it to the source control order. `source_css.state: 'unknown'` means no unconditional source-CSS facts were matched. Responsive facts can still be present in `variants`; consumers evaluate those conditions rather than treating an unknown base as missing presentation. Consumers must not infer a role or layout from SVG shape or order. + +Existing v1 graphs remain valid. Graphs without visual parts retain the v1 envelope; graphs with visual parts emit v2. This contract describes source appearance, not interaction-state semantics or provider destinations. diff --git a/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php b/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php index 6a122a5cb..5486e422e 100644 --- a/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php +++ b/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php @@ -6,7 +6,6 @@ use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder; -use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FormControlMetadataBuilder; use InvalidArgumentException; use JsonException; @@ -70,7 +69,6 @@ public static function normalizeList(mixed $raw): array $normalized['payload'] = $payload; if ('entity_collection' === $kind && 'forms' === $name && 'generic/forms/v1' === ($payload['schema'] ?? null)) foreach ($payload['entities'] ?? array() as $entity) if (is_array($entity) && isset($entity['layout_graph'])) { if (!is_array($entity['layout_graph'])) throw new InvalidArgumentException("Runtime declaration {$index} form layout graph must be an object."); FormLayoutGraphBuilder::assertValid($entity['layout_graph']); } if ('entity_collection' === $kind && 'forms' === $name && 'generic/forms/v1' === ($payload['schema'] ?? null)) foreach ($payload['entities'] ?? array() as $entity) if (is_array($entity) && isset($entity['presentation_graph'])) { if (!is_array($entity['presentation_graph'])) throw new InvalidArgumentException("Runtime declaration {$index} form presentation graph must be an object."); FormPresentationGraphBuilder::assertValid($entity['presentation_graph']); } - if ('entity_collection' === $kind && 'forms' === $name && 'generic/forms/v1' === ($payload['schema'] ?? null)) foreach ($payload['entities'] ?? array() as $entity) if (is_array($entity)) foreach ($entity['controls'] ?? array() as $control) if (is_array($control) && isset($control['auxiliary_visuals'])) { if (!is_array($control['auxiliary_visuals'])) throw new InvalidArgumentException("Runtime declaration {$index} form auxiliary visuals must be a list."); FormControlMetadataBuilder::assertAuxiliaryVisuals($control['auxiliary_visuals']); } } if ('entity_collection' === $kind && (!isset($normalized['type'], $normalized['payload']['entities']) || !array_is_list($normalized['payload']['entities']))) throw new InvalidArgumentException("Runtime declaration {$index} entity collections require a typed entities payload."); if (isset($declaration['required_for'])) { diff --git a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php index 3d5dac885..7970eeb5f 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php @@ -9,21 +9,14 @@ use DOMDocument; use DOMElement; use DOMNode; -use InvalidArgumentException; /** Builds provider-neutral form and control metadata from source DOM. */ final class FormControlMetadataBuilder { - private const MAX_AUXILIARY_VISUALS = 4; - private const MAX_AUXILIARY_VISUAL_BYTES = 12288; - private const MAX_AUXILIARY_VISUAL_DIMENSION = 4096; - /** @param Closure(DOMElement): string $elementSelector */ public function __construct( private readonly Closure $elementSelector, - private readonly ?Closure $presentationAttributes = null, - /** @var (Closure(DOMElement): string)|null $sanitizeInlineSvgMarkup */ - private readonly ?Closure $sanitizeInlineSvgMarkup = null + private readonly ?Closure $presentationAttributes = null ) { } @@ -112,10 +105,6 @@ public function control(DOMElement $control): array $metadata['presentation'] = array( 'style' => $presentation['style'] ); } } - $auxiliaryVisuals = $this->auxiliaryVisuals($control); - if ( array() !== $auxiliaryVisuals ) { - $metadata['auxiliary_visuals'] = $auxiliaryVisuals; - } } if ( $control->hasAttribute('required') || 'true' === strtolower(trim(SourceDom::attr($control, 'aria-required'))) ) { @@ -151,22 +140,6 @@ public function control(DOMElement $control): array return $metadata; } - /** @param array> $visuals */ - public static function assertAuxiliaryVisuals(array $visuals): void - { - if ( ! array_is_list($visuals) || count($visuals) > self::MAX_AUXILIARY_VISUALS ) { - throw new InvalidArgumentException('Form control auxiliary visuals are invalid.'); - } - foreach ( $visuals as $visual ) { - if ( ! is_array($visual) || array_diff(array_keys($visual), array( 'kind', 'markup', 'intrinsic_size' )) || 'inline_svg' !== ($visual['kind'] ?? null) || ! is_string($visual['markup'] ?? null) || strlen($visual['markup']) > self::MAX_AUXILIARY_VISUAL_BYTES || ! SourceDom::isSafeInlineSvgMarkup($visual['markup']) ) { - throw new InvalidArgumentException('Form control auxiliary visual is unsafe.'); - } - if ( isset($visual['intrinsic_size']) && (! is_array($visual['intrinsic_size']) || array_diff(array_keys($visual['intrinsic_size']), array( 'width', 'height' )) || ! self::validDimension($visual['intrinsic_size']['width'] ?? null) || ! self::validDimension($visual['intrinsic_size']['height'] ?? null)) ) { - throw new InvalidArgumentException('Form control auxiliary visual dimensions are invalid.'); - } - } - } - public function label(DOMElement $control): string { $ariaLabel = trim(SourceDom::attr($control, 'aria-label')); @@ -327,51 +300,6 @@ private function buttonText(DOMElement $control): string return '' !== $text ? $text : trim(SourceDom::attr($control, 'value')); } - /** @return array> */ - private function auxiliaryVisuals(DOMElement $control): array - { - if ( null === $this->sanitizeInlineSvgMarkup ) { - return array(); - } - - $visuals = array(); - foreach ( $control->getElementsByTagName('svg') as $svg ) { - if ( ! $svg instanceof DOMElement || count($visuals) >= self::MAX_AUXILIARY_VISUALS || ! SourceDom::svgHasDrawableContent($svg) ) { - continue; - } - $markup = trim(($this->sanitizeInlineSvgMarkup)($svg)); - if ( strlen($markup) > self::MAX_AUXILIARY_VISUAL_BYTES || ! SourceDom::isSafeInlineSvgMarkup($markup) ) { - continue; - } - $visual = array( 'kind' => 'inline_svg', 'markup' => $markup ); - $size = $this->intrinsicSize($svg); - if ( array() !== $size ) { - $visual['intrinsic_size'] = $size; - } - $visuals[] = $visual; - } - self::assertAuxiliaryVisuals($visuals); - return $visuals; - } - - /** @return array */ - private function intrinsicSize(DOMElement $svg): array - { - $width = $this->dimension(SourceDom::attr($svg, 'width')); - $height = $this->dimension(SourceDom::attr($svg, 'height')); - return null !== $width && null !== $height ? array( 'width' => $width, 'height' => $height ) : array(); - } - - private function dimension(string $value): ?int - { - return 1 === preg_match('/^[1-9][0-9]{0,3}$/D', trim($value)) && (int) $value <= self::MAX_AUXILIARY_VISUAL_DIMENSION ? (int) $value : null; - } - - private static function validDimension(mixed $value): bool - { - return is_int($value) && $value > 0 && $value <= self::MAX_AUXILIARY_VISUAL_DIMENSION; - } - private function collapseRepeatedLabel(string $label): string { if ( preg_match('/^\s*(.+?)[.!?]\s+\1\s*$/iu', $label, $match) ) { diff --git a/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php b/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php index f82791ae4..349a1b8ff 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php @@ -31,7 +31,8 @@ public function build(DOMElement $element, ?array $readableFormBlock, ?array $bi $controlTopology = (new FormControlTopologyBuilder())->build($element); $layoutGraph = (new FormLayoutGraphBuilder())->build($element, $this->context->stylesheetAssets(), $this->context->formLayoutCss()); $presentationGraph = (new FormPresentationGraphBuilder( - fn (DOMElement $control, string $value): string => $this->context->resolvePresentationValue($control, $value) + fn (DOMElement $control, string $value): string => $this->context->resolvePresentationValue($control, $value), + fn (DOMElement $element): string => $this->context->sanitizeInlineSvgMarkup($element) ))->build($element, $this->context->stylesheetAssets(), $this->context->formLayoutCss()); $boundedHtml = $this->context->boundedFallbackHtml($element); $replacesRuntimeIsland = null !== $bindingBlock; diff --git a/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingContext.php b/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingContext.php index 4c630cac2..35cc5ddc5 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingContext.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingContext.php @@ -18,6 +18,7 @@ final class FormFallbackFindingContext * @param Closure(DOMElement): array $classifyFallbackSubtree * @param Closure(array, string, array): array $blockBinding * @param (Closure(DOMElement, string): string)|null $resolvePresentationValue + * @param (Closure(DOMElement): string)|null $sanitizeInlineSvgMarkup */ public function __construct( private readonly HtmlTransformerSession $session, @@ -26,7 +27,8 @@ public function __construct( private readonly Closure $sourceContext, private readonly Closure $classifyFallbackSubtree, private readonly Closure $blockBinding, - private readonly ?Closure $resolvePresentationValue = null + private readonly ?Closure $resolvePresentationValue = null, + private readonly ?Closure $sanitizeInlineSvgMarkup = null ) { } @@ -80,6 +82,11 @@ public function resolvePresentationValue(DOMElement $element, string $value): st return null !== $this->resolvePresentationValue ? ($this->resolvePresentationValue)($element, $value) : $value; } + public function sanitizeInlineSvgMarkup(DOMElement $element): string + { + return null !== $this->sanitizeInlineSvgMarkup ? ($this->sanitizeInlineSvgMarkup)($element) : ''; + } + /** @param array $finding @return array */ public function buildFallbackDiagnostic(array $finding): array { diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index cc4596c75..04a0e3a06 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -535,8 +535,7 @@ function (DOMElement $element, array &$fallbacks): void { ), $this->styleResolver, $this->runtime); $this->formControlMetadataBuilder = new FormControlMetadataBuilder( fn (DOMElement $element): string => $this->elementSelector($element), - fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element), - fn (DOMElement $element): string => $this->svgMaterializer->restoreSvgCasing($this->sanitizeInlineSvgMarkup($element)) + fn (DOMElement $element): array => $this->styleResolver->presentationAttributes($element) ); $this->authoredFormControlBlockConverter = new AuthoredFormControlBlockConverter( $this->formControlMetadataBuilder, @@ -618,7 +617,8 @@ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): arr fn (DOMElement $element): array => $this->sourceContext($element), fn (DOMElement $element): array => $this->fallbackEmitter()->classifyFallbackSubtree($element), fn (array $block, string $role, array $supersededRuntimeSelectors): array => $this->blockBinding($block, $role, $supersededRuntimeSelectors), - fn (DOMElement $element, string $value): string => $this->styleResolver->resolveCssVariablesInValue($value, $element) + fn (DOMElement $element, string $value): string => $this->styleResolver->resolveCssVariablesInValue($value, $element), + fn (DOMElement $element): string => $this->svgMaterializer->restoreSvgCasing($this->sanitizeInlineSvgMarkup($element)) ), $this->formControlMetadataBuilder, $this->formSuccessPanelMetadataBuilder, diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index ef7c66531..5003ee81b 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -5,6 +5,7 @@ use Automattic\BlocksEngine\PhpTransformer\Css\CssRuleAnalyzer; use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatcher; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom; use Closure; use DOMDocument; use DOMElement; @@ -20,6 +21,9 @@ final class FormPresentationGraphBuilder private const MAX_SELECTORS = 16384; private const MAX_CONDITION_DEPTH = 8; private const MAX_VARIANTS = 256; + private const MAX_VISUAL_PARTS = 32; + private const MAX_VISUAL_BYTES = 12288; + private const MAX_VISUAL_DIMENSION = 4096; private const MAX_PROVENANCE = 16; private const MAX_DIAGNOSTICS = 32; private const PROPERTIES = array( @@ -32,14 +36,16 @@ final class FormPresentationGraphBuilder 'height', 'letter-spacing', 'line-height', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'min-height', 'min-width', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'padding-block-start', 'padding-block-end', 'padding-inline-start', 'padding-inline-end', - 'text-align', 'text-decoration', 'text-indent', 'text-transform', 'vertical-align', 'width' + 'text-align', 'text-decoration', 'text-indent', 'text-transform', 'vertical-align', 'width', + 'align-self', 'bottom', 'flex', 'flex-basis', 'flex-grow', 'flex-shrink', 'inset', 'justify-self', + 'left', 'margin-block', 'margin-inline', 'order', 'position', 'right', 'top', 'transform', 'z-index' ); private array $diagnostics = array(); private bool $truncated = false; - /** @param (Closure(DOMElement, string): string)|null $resolveValue */ - public function __construct(private readonly ?Closure $resolveValue = null) + /** @param (Closure(DOMElement, string): string)|null $resolveValue @param (Closure(DOMElement): string)|null $sanitizeInlineSvgMarkup */ + public function __construct(private readonly ?Closure $resolveValue = null, private readonly ?Closure $sanitizeInlineSvgMarkup = null) { } @@ -71,6 +77,7 @@ function (array $selector) use ($controlsForCustomProperties): bool { $this->truncated = $analysis['truncated']; $controls = array(); $variants = array(); + $visualParts = array(); foreach ( $this->controls($form) as $index => $control ) { if ( $index >= self::MAX_CONTROLS ) { @@ -108,13 +115,29 @@ function (array $selector) use ($controlsForCustomProperties): bool { } } } + foreach ( $this->visualParts($control, $index, $analysis['rules'], $customPropertyAnalysis['rules']) as $part ) { + if ( count($visualParts) >= self::MAX_VISUAL_PARTS ) { + $this->truncated = true; + $this->diagnostics[] = 'visual_part_limit'; + break; + } + $visualParts[] = $part['part']; + foreach ( $part['variants'] as $variant ) { + if ( count($variants) >= self::MAX_VARIANTS ) { + $this->truncated = true; + $this->diagnostics[] = 'variant_limit'; + break 2; + } + $variants[] = $variant; + } + } if ( count($row) > 1 ) { $controls[] = $row; } } $graph = array( - 'schema' => 'generic/computed-form-presentation/v1', + 'schema' => array() === $visualParts ? 'generic/computed-form-presentation/v1' : 'generic/computed-form-presentation/v2', 'basis' => 'source_css_cascade', 'truncated' => $this->truncated, 'limits' => array( 'controls' => self::MAX_CONTROLS, 'rules_per_role' => self::MAX_RULES_PER_ROLE ), @@ -122,6 +145,9 @@ function (array $selector) use ($controlsForCustomProperties): bool { 'variants' => $variants, 'diagnostics' => array_slice(array_values(array_unique($this->diagnostics)), 0, self::MAX_DIAGNOSTICS), ); + if ( array() !== $visualParts ) { + $graph['visual_parts'] = $visualParts; + } self::assertValid($graph); return $graph; } @@ -129,7 +155,11 @@ function (array $selector) use ($controlsForCustomProperties): bool { /** @param array $graph */ public static function assertValid(array $graph): void { - if ( 'generic/computed-form-presentation/v1' !== ($graph['schema'] ?? null) || 'source_css_cascade' !== ($graph['basis'] ?? null) || ! is_bool($graph['truncated'] ?? null) || ! is_array($graph['limits'] ?? null) || array_diff(array_keys($graph['limits']), array( 'controls', 'rules_per_role' )) || self::MAX_CONTROLS !== ($graph['limits']['controls'] ?? null) || self::MAX_RULES_PER_ROLE !== ($graph['limits']['rules_per_role'] ?? null) || ! is_array($graph['controls'] ?? null) || ! array_is_list($graph['controls']) || count($graph['controls']) > self::MAX_CONTROLS || ! is_array($graph['variants'] ?? null) || ! array_is_list($graph['variants']) || count($graph['variants']) > self::MAX_VARIANTS || ! is_array($graph['diagnostics'] ?? null) || ! array_is_list($graph['diagnostics']) || count($graph['diagnostics']) > self::MAX_DIAGNOSTICS || array_filter($graph['diagnostics'], static fn (mixed $diagnostic): bool => ! is_string($diagnostic) || '' === trim($diagnostic) || strlen($diagnostic) > 1100) || array_diff(array_keys($graph), array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'variants', 'diagnostics' )) ) { + $version = $graph['schema'] ?? null; + $v1 = 'generic/computed-form-presentation/v1' === $version; + $v2 = 'generic/computed-form-presentation/v2' === $version; + $expectedKeys = $v2 ? array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'visual_parts', 'variants', 'diagnostics' ) : array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'variants', 'diagnostics' ); + if ( (! $v1 && ! $v2) || 'source_css_cascade' !== ($graph['basis'] ?? null) || ! is_bool($graph['truncated'] ?? null) || ! is_array($graph['limits'] ?? null) || array_diff(array_keys($graph['limits']), array( 'controls', 'rules_per_role' )) || self::MAX_CONTROLS !== ($graph['limits']['controls'] ?? null) || self::MAX_RULES_PER_ROLE !== ($graph['limits']['rules_per_role'] ?? null) || ! is_array($graph['controls'] ?? null) || ! array_is_list($graph['controls']) || count($graph['controls']) > self::MAX_CONTROLS || ($v2 && (! is_array($graph['visual_parts'] ?? null) || ! array_is_list($graph['visual_parts']) || count($graph['visual_parts']) > self::MAX_VISUAL_PARTS)) || ! is_array($graph['variants'] ?? null) || ! array_is_list($graph['variants']) || count($graph['variants']) > self::MAX_VARIANTS || ! is_array($graph['diagnostics'] ?? null) || ! array_is_list($graph['diagnostics']) || count($graph['diagnostics']) > self::MAX_DIAGNOSTICS || array_filter($graph['diagnostics'], static fn (mixed $diagnostic): bool => ! is_string($diagnostic) || '' === trim($diagnostic) || strlen($diagnostic) > 1100) || array_diff(array_keys($graph), $expectedKeys) ) { throw new InvalidArgumentException('Form presentation graph envelope is invalid.'); } $seen = array(); @@ -142,8 +172,14 @@ public static function assertValid(array $graph): void if ( isset($row[$role]) ) self::assertRole($row[$role], null); } } + $partIds = array(); + foreach ( $v2 ? $graph['visual_parts'] : array() as $part ) { + self::assertVisualPart($part); + if ( isset($partIds[$part['id']]) ) throw new InvalidArgumentException('Form presentation visual part identity is duplicated.'); + $partIds[$part['id']] = $part['index']; + } foreach ( $graph['variants'] as $variant ) { - if ( ! is_array($variant) || array_diff(array_keys($variant), array( 'index', 'role', 'condition', 'style_patch', 'precedence', 'provenance' )) || ! is_int($variant['index'] ?? null) || $variant['index'] < 0 || $variant['index'] >= self::MAX_CONTROLS || ! in_array($variant['role'] ?? null, array( 'control', 'label' ), true) || ! is_array($variant['condition'] ?? null) || ! self::validCondition($variant['condition']) || ! is_array($variant['style_patch'] ?? null) || array() === $variant['style_patch'] || ! is_array($variant['precedence'] ?? null) || ! is_array($variant['provenance'] ?? null) ) { + if ( ! is_array($variant) || array_diff(array_keys($variant), array( 'index', 'role', 'part_id', 'condition', 'style_patch', 'precedence', 'provenance' )) || ! is_int($variant['index'] ?? null) || $variant['index'] < 0 || $variant['index'] >= self::MAX_CONTROLS || ! in_array($variant['role'] ?? null, $v2 ? array( 'control', 'label', 'visual_part' ) : array( 'control', 'label' ), true) || ('visual_part' === ($variant['role'] ?? null) ? (! is_string($variant['part_id'] ?? null) || ! isset($partIds[$variant['part_id']]) || $variant['index'] !== $partIds[$variant['part_id']]) : isset($variant['part_id'])) || ! is_array($variant['condition'] ?? null) || ! self::validCondition($variant['condition']) || ! is_array($variant['style_patch'] ?? null) || array() === $variant['style_patch'] || ! is_array($variant['precedence'] ?? null) || ! is_array($variant['provenance'] ?? null) ) { throw new InvalidArgumentException('Form presentation variant is invalid.'); } self::assertStyles($variant['style_patch']); @@ -161,6 +197,18 @@ private static function assertRole(mixed $role, ?array $condition): void self::assertProvenance($role['provenance'], $role['styles'], $condition); } + /** A visual part describes source identity and facts, never an inferred semantic role. */ + private static function assertVisualPart(mixed $part): void + { + if ( ! is_array($part) || array_diff(array_keys($part), array( 'id', 'index', 'kind', 'source_selector', 'markup', 'intrinsic_size', 'source_css' )) || ! is_string($part['id'] ?? null) || ! preg_match('/^control-[0-9]+-svg-[0-9]+$/', $part['id']) || ! is_int($part['index'] ?? null) || $part['index'] < 0 || $part['index'] >= self::MAX_CONTROLS || ! is_string($part['source_selector'] ?? null) || '' === trim($part['source_selector']) || strlen($part['source_selector']) > 2048 || 'inline_svg' !== ($part['kind'] ?? null) || ! is_string($part['markup'] ?? null) || strlen($part['markup']) > self::MAX_VISUAL_BYTES || ! SourceDom::isSafeInlineSvgMarkup($part['markup']) || ! is_array($part['source_css'] ?? null) || ! in_array($part['source_css']['state'] ?? null, array( 'known', 'unknown' ), true) ) throw new InvalidArgumentException('Form presentation visual part is invalid.'); + if ( isset($part['intrinsic_size']) && (! is_array($part['intrinsic_size']) || array_diff(array_keys($part['intrinsic_size']), array( 'width', 'height' )) || ! self::validDimension($part['intrinsic_size']['width'] ?? null) || ! self::validDimension($part['intrinsic_size']['height'] ?? null)) ) throw new InvalidArgumentException('Form presentation visual part dimensions are invalid.'); + $sourceCss = $part['source_css']; + if ( 'known' === $sourceCss['state'] ) { + if ( array_diff(array_keys($sourceCss), array( 'state', 'styles', 'provenance' )) || ! is_array($sourceCss['styles'] ?? null) || array() === $sourceCss['styles'] || ! is_array($sourceCss['provenance'] ?? null) ) throw new InvalidArgumentException('Form presentation visual part source CSS is invalid.'); + self::assertStyles($sourceCss['styles']); self::assertProvenance($sourceCss['provenance'], $sourceCss['styles'], null); + } elseif ( array_diff(array_keys($sourceCss), array( 'state' )) ) throw new InvalidArgumentException('Form presentation visual part unknown CSS state is invalid.'); + } + private static function assertStyles(array $styles): void { foreach ( $styles as $key => $value ) if ( ! is_string($key) || ! in_array($key, array_map(self::key(...), self::PROPERTIES), true) || ! is_string($value) || '' === trim($value) || strlen($value) > 160 ) throw new InvalidArgumentException('Form presentation style is invalid.'); @@ -190,6 +238,36 @@ private function label(DOMElement $control): ?DOMElement return null; } + /** @return list, variants: list>}> */ + private function visualParts(DOMElement $control, int $index, array $rules, array $customPropertyRules): array + { + if ( null === $this->sanitizeInlineSvgMarkup ) return array(); + $parts = array(); + $ordinal = 0; + foreach ( $control->getElementsByTagName('svg') as $svg ) { + if ( ! $svg instanceof DOMElement || ! SourceDom::svgHasDrawableContent($svg) ) continue; + if ( count($parts) >= self::MAX_VISUAL_PARTS ) { $this->truncated = true; $this->diagnostics[] = 'visual_part_limit'; break; } + $selector = SourceDom::elementSelector($svg); + if ( strlen($selector) > 2048 ) { $this->diagnostics[] = 'visual_selector_limit'; continue; } + $markup = trim(($this->sanitizeInlineSvgMarkup)($svg)); + if ( strlen($markup) > self::MAX_VISUAL_BYTES || ! SourceDom::isSafeInlineSvgMarkup($markup) ) { $this->diagnostics[] = 'unsafe_visual_part'; continue; } + $matched = $this->matched($svg, $rules); + $styles = $this->styles($matched['base'], $svg, null, $customPropertyRules); + $part = array( 'id' => 'control-' . $index . '-svg-' . $ordinal++, 'index' => $index, 'kind' => 'inline_svg', 'source_selector' => $selector, 'markup' => $markup, 'source_css' => array( 'state' => 'unknown' ) ); + $size = $this->intrinsicSize($svg); + if ( array() !== $size ) $part['intrinsic_size'] = $size; + if ( array() !== $styles ) $part['source_css'] = array( 'state' => 'known', 'styles' => $styles, 'provenance' => $this->provenance($matched['base'], null) ); + $variants = array(); + foreach ( $this->effectiveConditional($matched['conditional'], $matched['base']) as $encoded => $facts ) { + $condition = json_decode($encoded, true); + $patch = $this->styles($facts, $svg, $condition, $customPropertyRules); + if ( array() !== $patch ) $variants[] = array( 'index' => $index, 'role' => 'visual_part', 'part_id' => $part['id'], 'condition' => $condition, 'style_patch' => $patch, 'precedence' => $this->precedence($facts), 'provenance' => $this->provenance($facts, $condition) ); + } + $parts[] = array( 'part' => $part, 'variants' => $variants ); + } + return $parts; + } + /** @param list> $rules */ private function matched(DOMElement $element, array $rules): array { @@ -277,6 +355,10 @@ private function expandCustomProperties(string $value, array $customProperties): return trim($value); } private static function key(string $property): string { return str_replace('-', '_', $property); } + /** @return array */ + private function intrinsicSize(DOMElement $svg): array { $width = $this->dimension(SourceDom::attr($svg, 'width')); $height = $this->dimension(SourceDom::attr($svg, 'height')); return null !== $width && null !== $height ? array( 'width' => $width, 'height' => $height ) : array(); } + private function dimension(string $value): ?int { return 1 === preg_match('/^[1-9][0-9]{0,3}$/D', trim($value)) && (int) $value <= self::MAX_VISUAL_DIMENSION ? (int) $value : null; } + private static function validDimension(mixed $value): bool { return is_int($value) && $value > 0 && $value <= self::MAX_VISUAL_DIMENSION; } private function precedence(array $facts): array { $result = array(); foreach ( $facts as $property => $fact ) $result[$property] = array( 'source_order' => $fact['order'], 'specificity' => $fact['specificity'], 'important' => $fact['important'] ); ksort($result); return $result; } private function provenance(array $facts, ?array $condition): array { $grouped = array(); foreach ( $facts as $property => $fact ) { $key = $fact['path'] . "\n" . $fact['selector']; $grouped[$key] ??= array( 'source_path' => $fact['path'], 'source_sha256' => $fact['hash'], 'selector' => $fact['selector'], 'condition' => $condition, 'properties' => array() ); $grouped[$key]['properties'][] = $property; } foreach ( $grouped as &$item ) sort($item['properties'], SORT_STRING); unset($item); if ( count($grouped) > self::MAX_PROVENANCE ) { $this->truncated = true; $this->diagnostics[] = 'provenance_limit'; } return array_slice(array_values($grouped), 0, self::MAX_PROVENANCE); } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 24f2708d8..e43a099d3 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1336,7 +1336,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $presentationGraph = $presentationFallback['presentation_graph'] ?? array(); $presentationRows = array_column($presentationGraph['controls'] ?? array(), null, 'index'); $presentationVariant = $presentationGraph['variants'][0] ?? array(); -$assert('generic/computed-form-presentation/v1' === ($presentationGraph['schema'] ?? null) && '40px' === ($presentationRows[0]['control']['styles']['height'] ?? null) && 'transparent' === ($presentationRows[0]['control']['styles']['background'] ?? null) && '14px' === ($presentationRows[0]['label']['styles']['font_size'] ?? null) && '8px' === ($presentationRows[0]['label']['styles']['margin_bottom'] ?? null) && 'input' === ($presentationRows[0]['control']['provenance'][0]['selector'] ?? null), 'form presentation graph captures source control and associated-label declarations with stylesheet provenance'); +$assert('generic/computed-form-presentation/v1' === ($presentationGraph['schema'] ?? null) && !isset($presentationGraph['visual_parts']) && '40px' === ($presentationRows[0]['control']['styles']['height'] ?? null) && 'transparent' === ($presentationRows[0]['control']['styles']['background'] ?? null) && '14px' === ($presentationRows[0]['label']['styles']['font_size'] ?? null) && '8px' === ($presentationRows[0]['label']['styles']['margin_bottom'] ?? null) && 'input' === ($presentationRows[0]['control']['provenance'][0]['selector'] ?? null), 'form presentation graphs without visual parts retain the persisted v1 envelope and source control facts'); $assert('10px' === ($presentationRows[0]['control']['styles']['border_radius'] ?? null) && '3px 3px 3px 10px' === ($presentationRows[0]['control']['styles']['padding'] ?? null) && !str_contains(json_encode($presentationGraph), 'var('), 'form presentation graph resolves source-scoped custom properties before provider projection'); $scopedPresentationCss = '#field{--control-radius:12px;--control-padding:4px 5px 6px 7px}input{border-radius:var(--control-radius,0);padding:var(--control-padding)}'; $scopedPresentationGraph = (new HtmlTransformer())->transform('
', array('static_css' => $scopedPresentationCss))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); @@ -1349,8 +1349,20 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $assert('85px' === ($responsiveTokenVariants['(max-width:50rem)']['style_patch']['height'] ?? null) && '8px' === ($responsiveTokenVariants['(max-width:50rem)']['style_patch']['padding_inline_start'] ?? null) && '86px' === ($responsiveTokenVariants['(min-width:51rem)']['style_patch']['height'] ?? null) && '10px' === ($responsiveTokenVariants['(min-width:51rem)']['style_patch']['padding_inline_start'] ?? null) && !str_contains(json_encode($responsiveTokenGraph), 'var('), 'form presentation resolves ancestor custom properties within each responsive condition before provider projection'); $caseSensitiveTokenGraph = (new HtmlTransformer())->transform('
', array('static_css' => '#field{--inputHeight:86px}textarea{height:var(--inputHeight)}'))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); $assert('86px' === ($caseSensitiveTokenGraph['controls'][0]['control']['styles']['height'] ?? null), 'form presentation preserves case-sensitive custom property names while resolving source controls'); +$visualPresentationHtml = '
'; +$visualPresentationCss = 'button span{display:flex;position:relative}.globe{width:24px;height:24px;flex:none}.chevron{width:16px;height:16px;position:absolute;right:8px}@media (max-width:50rem){.chevron{width:12px;right:4px}}'; +$visualPresentationGraph = (new HtmlTransformer())->transform($visualPresentationHtml, array('static_css' => $visualPresentationCss))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); +$visualParts = $visualPresentationGraph['visual_parts'] ?? array(); +$visualVariants = array(); foreach ($visualPresentationGraph['variants'] ?? array() as $variant) if ('visual_part' === ($variant['role'] ?? null)) $visualVariants[$variant['part_id'] ?? ''] = $variant; +$assert(2 === count($visualParts) && 'control-0-svg-0' === ($visualParts[0]['id'] ?? null) && 'inline_svg' === ($visualParts[0]['kind'] ?? null) && 24 === ($visualParts[0]['intrinsic_size']['width'] ?? null) && '24px' === ($visualParts[0]['source_css']['styles']['width'] ?? null) && '24px' === ($visualParts[0]['source_css']['styles']['height'] ?? null) && '.globe' === ($visualParts[0]['source_css']['provenance'][0]['selector'] ?? null) && !str_contains((string) ($visualParts[0]['markup'] ?? ''), 'onclick') && 'control-0-svg-1' === ($visualParts[1]['id'] ?? null) && 20 === ($visualParts[1]['intrinsic_size']['width'] ?? null) && '16px' === ($visualParts[1]['source_css']['styles']['width'] ?? null) && 'absolute' === ($visualParts[1]['source_css']['styles']['position'] ?? null) && '8px' === ($visualParts[1]['source_css']['styles']['right'] ?? null) && '12px' === ($visualVariants['control-0-svg-1']['style_patch']['width'] ?? null) && 'media' === ($visualVariants['control-0-svg-1']['condition']['kind'] ?? null), 'form presentation identifies visual SVG descendants by control order and source selector, retaining intrinsic geometry, actual CSS layout facts, conditional variants, provenance, and sanitized markup without semantic role guesses'); +$conditionalVisualGraph = (new HtmlTransformer())->transform('
')->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); +$assert('unknown' === ($conditionalVisualGraph['visual_parts'][0]['source_css']['state'] ?? null) && 'visual_part' === ($conditionalVisualGraph['variants'][0]['role'] ?? null) && 'max(16px,16px)' === ($conditionalVisualGraph['variants'][0]['style_patch']['width'] ?? null), 'conditional-only visual sizing remains in its responsive variant even when base CSS is unknown'); +$unknownVisualGraph = (new HtmlTransformer())->transform('
')->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); +$assert('unknown' === ($unknownVisualGraph['visual_parts'][0]['source_css']['state'] ?? null), 'form presentation explicitly represents unknown source CSS instead of inventing visual semantics'); $invalidPresentation = $presentationGraph; $invalidPresentation['controls'][0]['control']['styles']['untrusted'] = 'value'; try { \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder::assertValid($invalidPresentation); $assert(false, 'presentation graph validation rejects unknown style properties'); } catch (\InvalidArgumentException) { $assert(true, 'presentation graph validation rejects unknown style properties'); } $unsafePresentation = $presentationGraph; $unsafePresentation['controls'][0]['control']['provenance'][0]['source_path'] = '../untrusted.css'; try { \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder::assertValid($unsafePresentation); $assert(false, 'presentation graph validation rejects unsafe provenance paths'); } catch (\InvalidArgumentException) { $assert(true, 'presentation graph validation rejects unsafe provenance paths'); } +$unsafeVisualPresentation = $visualPresentationGraph; $unsafeVisualPresentation['visual_parts'][0]['markup'] = ''; try { \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder::assertValid($unsafeVisualPresentation); $assert(false, 'presentation graph validation rejects unsafe visual payloads'); } catch (\InvalidArgumentException) { $assert(true, 'presentation graph validation rejects unsafe visual payloads'); } +$misownedVisualVariant = $visualPresentationGraph; $misownedVisualVariant['variants'][0]['index'] = 1; try { \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder::assertValid($misownedVisualVariant); $assert(false, 'presentation graph validation rejects visual variants owned by another control'); } catch (\InvalidArgumentException) { $assert(true, 'presentation graph validation rejects visual variants owned by another control'); } $provenanceLimitedProperties = array('appearance', 'background', 'border', 'border-radius', 'box-sizing', 'color', 'display', 'font-family', 'font-size', 'font-style', 'font-variant', 'font-weight', 'height', 'letter-spacing', 'line-height', 'margin', 'max-width'); $provenanceLimitedCss = implode('', array_map(static fn (string $property, int $index): string => 'input.rule-' . $index . '{' . $property . ':initial}', $provenanceLimitedProperties, array_keys($provenanceLimitedProperties))); $provenanceLimitedHtml = '
'; diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 5b4772cd8..65b45f078 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -332,16 +332,14 @@ $projectedTopology = $topologyDeclaration['payload']['entities'][0]['control_topology'] ?? null; $assert(is_array($projectedTopology) && RuntimeDeclarations::hash($projectedTopology) === RuntimeDeclarations::hash($topologyFallback['control_topology'] ?? null), 'Artifact compiler and WordPress site plan project the generic form control topology unchanged.'); $assert('wrapper-0' === ($projectedTopology['nodes'][1]['parent'] ?? null) && 'wrapper-0' === ($projectedTopology['nodes'][3]['parent'] ?? null) && array(0, 1) === array($projectedTopology['nodes'][1]['order'] ?? null, $projectedTopology['nodes'][3]['order'] ?? null) && array(0, 1, 2) === array($projectedTopology['nodes'][2]['control'] ?? null, $projectedTopology['nodes'][4]['control'] ?? null, $projectedTopology['nodes'][6]['control'] ?? null), 'The generic/forms/v1 declaration retains shared-row identity, source order, and flat control references.'); -$auxiliaryVisualResult = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
')))->toArray(); +$auxiliaryVisualResult = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'style.css' => '.globe{width:24px;height:24px}.chevron{width:16px;height:16px}@media (max-width:50rem){.chevron{width:12px}}')))->toArray(); $auxiliaryVisualDeclaration = current(array_filter($auxiliaryVisualResult['source_reports']['wordpress_site_plan']['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); -$auxiliaryVisualControl = $auxiliaryVisualDeclaration['payload']['entities'][0]['controls'][0] ?? array(); -$auxiliaryVisuals = $auxiliaryVisualControl['auxiliary_visuals'] ?? array(); -$assert('button' === ($auxiliaryVisualControl['tag'] ?? null) && 2 === count($auxiliaryVisuals) && 'inline_svg' === ($auxiliaryVisuals[0]['kind'] ?? null) && 24 === ($auxiliaryVisuals[0]['intrinsic_size']['width'] ?? null) && 24 === ($auxiliaryVisuals[0]['intrinsic_size']['height'] ?? null) && str_contains((string) ($auxiliaryVisuals[0]['markup'] ?? ''), ' array('entrypoint' => 'index.html', 'runtime_declarations' => array(array('kind' => 'entity_collection', 'type' => 'forms', 'source_path' => 'index.html', 'payload' => array('schema' => 'generic/forms/v1', 'entities' => array(array('controls' => array(array('auxiliary_visuals' => array(array('kind' => 'inline_svg', 'markup' => $markup))))))))), 'files' => array('index.html' => '
Caller
')); -$safeAuxiliaryVisual = $auxiliaryVisualPayload(''); -$safeAuxiliaryVisual['runtime_declarations'][0]['payload']['entities'][0]['controls'][0]['auxiliary_visuals'][] = array('kind' => 'inline_svg', 'markup' => '', 'intrinsic_size' => array('width' => 20, 'height' => 20)); -$assert(2 === count((new ArtifactCompiler())->compile($safeAuxiliaryVisual)->toArray()['source_reports']['wordpress_site_plan']['runtime_declarations'][0]['payload']['entities'][0]['controls'][0]['auxiliary_visuals'] ?? array()), 'Runtime declaration intake accepts passive globe and 20px intrinsic chevron SVG payloads.'); -foreach (array('Click', '
extra root
', '', '') as $unsafeAuxiliaryMarkup) $throws(static fn() => (new ArtifactCompiler())->compile($auxiliaryVisualPayload($unsafeAuxiliaryMarkup)), 'Runtime declaration intake rejects strict-boundary unsafe auxiliary SVG payloads.'); +$auxiliaryVisualGraph = $auxiliaryVisualDeclaration['payload']['entities'][0]['presentation_graph'] ?? array(); +$auxiliaryVisuals = $auxiliaryVisualGraph['visual_parts'] ?? array(); +$assert('generic/computed-form-presentation/v2' === ($auxiliaryVisualGraph['schema'] ?? null) && 2 === count($auxiliaryVisuals) && 'control-0-svg-0' === ($auxiliaryVisuals[0]['id'] ?? null) && 'inline_svg' === ($auxiliaryVisuals[0]['kind'] ?? null) && 24 === ($auxiliaryVisuals[0]['intrinsic_size']['width'] ?? null) && '24px' === ($auxiliaryVisuals[0]['source_css']['styles']['width'] ?? null) && !str_contains((string) ($auxiliaryVisuals[0]['markup'] ?? ''), 'onclick') && 20 === ($auxiliaryVisuals[1]['intrinsic_size']['width'] ?? null) && '16px' === ($auxiliaryVisuals[1]['source_css']['styles']['width'] ?? null) && !isset($auxiliaryVisualDeclaration['payload']['entities'][0]['controls'][1]['auxiliary_visuals']), 'WordPress site plans retain identified, sanitized SVG visual parts with intrinsic geometry and actual source CSS facts, rather than flat candidate roles.'); +$unsafeVisualPayload = array('entrypoint' => 'index.html', 'runtime_declarations' => array(array('kind' => 'entity_collection', 'type' => 'forms', 'source_path' => 'index.html', 'payload' => array('schema' => 'generic/forms/v1', 'entities' => array(array('presentation_graph' => $auxiliaryVisualGraph))))), 'files' => array('index.html' => '
Caller
')); +$unsafeVisualPayload['runtime_declarations'][0]['payload']['entities'][0]['presentation_graph']['visual_parts'][0]['markup'] = ''; +$throws(static fn() => (new ArtifactCompiler())->compile($unsafeVisualPayload), 'Runtime declaration intake rejects unsafe identified visual-part payloads.'); $layoutArtifact = array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'css/style.css' => '.form{display:grid;grid-template-columns:1fr;gap:1rem}.form .row-2{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.field{display:flex;flex-direction:column;gap:.3rem}@media (max-width:640px){.form .row-2{grid-template-columns:1fr}}')); $layoutResult = (new ArtifactCompiler())->compile($layoutArtifact)->toArray(); $layoutFallback = current(array_filter($layoutResult['fallbacks'] ?? array(), static fn(array $fallback): bool => 'html_form_fallback' === ($fallback['diagnostic_code'] ?? null))); From e9af684ae7a19ecd8c4aae57de5744e4906bf58a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 09:05:53 -0400 Subject: [PATCH 3/8] feat(forms): preserve source visual group layout --- .../docs/form-presentation-graph.md | 2 + .../Style/FormPresentationGraphBuilder.php | 73 +++++++++++++++++-- php-transformer/tests/contract/run.php | 7 +- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/php-transformer/docs/form-presentation-graph.md b/php-transformer/docs/form-presentation-graph.md index fa6bc8995..d52f97742 100644 --- a/php-transformer/docs/form-presentation-graph.md +++ b/php-transformer/docs/form-presentation-graph.md @@ -21,3 +21,5 @@ array{ `visual_parts` contains only drawable SVG descendants that pass `SourceDom::isSafeInlineSvgMarkup()`. `source_selector` identifies the source descendant and `index` links it to the source control order. `source_css.state: 'unknown'` means no unconditional source-CSS facts were matched. Responsive facts can still be present in `variants`; consumers evaluate those conditions rather than treating an unknown base as missing presentation. Consumers must not infer a role or layout from SVG shape or order. Existing v1 graphs remain valid. Graphs without visual parts retain the v1 envelope; graphs with visual parts emit v2. This contract describes source appearance, not interaction-state semantics or provider destinations. + +The unshipped v2 envelope also includes `visual_groups`. Each group records the source selector of the SVG parts' lowest common ancestor, a stable `id`, ordered `part_ids`, and `source_css` using the same base-fact shape as visual parts. Group responsive variants use `role: visual_group` and `group_id` rather than a control index. Consumers preserve those authored container facts independently of each SVG's intrinsic dimensions and keep provider visibility state outside that layout container. diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index be75f181b..b95579480 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -39,7 +39,8 @@ final class FormPresentationGraphBuilder 'padding-block-start', 'padding-block-end', 'padding-inline-start', 'padding-inline-end', 'text-align', 'text-decoration', 'text-indent', 'text-transform', 'vertical-align', 'width', 'align-self', 'bottom', 'flex', 'flex-basis', 'flex-grow', 'flex-shrink', 'inset', 'justify-self', - 'left', 'margin-block', 'margin-inline', 'order', 'position', 'right', 'top', 'transform', 'z-index' + 'left', 'margin-block', 'margin-inline', 'order', 'position', 'right', 'top', 'transform', 'z-index', + 'flex-direction', 'align-items', 'justify-content', 'gap' ); private array $diagnostics = array(); @@ -79,6 +80,7 @@ function (array $selector) use ($controlsForCustomProperties): bool { $controls = array(); $variants = array(); $visualParts = array(); + $visualGroups = array(); foreach ( $this->controls($form) as $index => $control ) { if ( $index >= self::MAX_CONTROLS ) { @@ -116,7 +118,8 @@ function (array $selector) use ($controlsForCustomProperties): bool { } } } - foreach ( $this->visualParts($control, $index, $analysis['rules'], $customPropertyAnalysis['rules']) as $part ) { + $capturedParts = $this->visualParts($control, $index, $analysis['rules'], $customPropertyAnalysis['rules']); + foreach ( $capturedParts as $part ) { if ( count($visualParts) >= self::MAX_VISUAL_PARTS ) { $this->truncated = true; $this->diagnostics[] = 'visual_part_limit'; @@ -132,6 +135,18 @@ function (array $selector) use ($controlsForCustomProperties): bool { $variants[] = $variant; } } + $visualGroup = $capturedParts === array() ? null : $capturedParts[array_key_last($capturedParts)]; + if ( isset($visualGroup['group']) && array() === array_diff($visualGroup['group']['part_ids'], array_column($visualParts, 'id')) ) { + $visualGroups[] = $visualGroup['group']; + foreach ( $visualGroup['group_variants'] as $variant ) { + if ( count($variants) >= self::MAX_VARIANTS ) { + $this->truncated = true; + $this->diagnostics[] = 'variant_limit'; + break 2; + } + $variants[] = $variant; + } + } if ( count($row) > 1 ) { $controls[] = $row; } @@ -148,6 +163,7 @@ function (array $selector) use ($controlsForCustomProperties): bool { ); if ( array() !== $visualParts ) { $graph['visual_parts'] = $visualParts; + $graph['visual_groups'] = $visualGroups; } self::assertValid($graph); return $graph; @@ -159,8 +175,8 @@ public static function assertValid(array $graph): void $version = $graph['schema'] ?? null; $v1 = 'generic/computed-form-presentation/v1' === $version; $v2 = 'generic/computed-form-presentation/v2' === $version; - $expectedKeys = $v2 ? array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'visual_parts', 'variants', 'diagnostics' ) : array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'variants', 'diagnostics' ); - if ( (! $v1 && ! $v2) || 'source_css_cascade' !== ($graph['basis'] ?? null) || ! is_bool($graph['truncated'] ?? null) || ! is_array($graph['limits'] ?? null) || array_diff(array_keys($graph['limits']), array( 'controls', 'rules_per_role' )) || self::MAX_CONTROLS !== ($graph['limits']['controls'] ?? null) || self::MAX_RULES_PER_ROLE !== ($graph['limits']['rules_per_role'] ?? null) || ! is_array($graph['controls'] ?? null) || ! array_is_list($graph['controls']) || count($graph['controls']) > self::MAX_CONTROLS || ($v2 && (! is_array($graph['visual_parts'] ?? null) || ! array_is_list($graph['visual_parts']) || count($graph['visual_parts']) > self::MAX_VISUAL_PARTS)) || ! is_array($graph['variants'] ?? null) || ! array_is_list($graph['variants']) || count($graph['variants']) > self::MAX_VARIANTS || ! is_array($graph['diagnostics'] ?? null) || ! array_is_list($graph['diagnostics']) || count($graph['diagnostics']) > self::MAX_DIAGNOSTICS || array_filter($graph['diagnostics'], static fn (mixed $diagnostic): bool => ! is_string($diagnostic) || '' === trim($diagnostic) || strlen($diagnostic) > 1100) || array_diff(array_keys($graph), $expectedKeys) ) { + $expectedKeys = $v2 ? array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'visual_parts', 'visual_groups', 'variants', 'diagnostics' ) : array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'variants', 'diagnostics' ); + if ( (! $v1 && ! $v2) || 'source_css_cascade' !== ($graph['basis'] ?? null) || ! is_bool($graph['truncated'] ?? null) || ! is_array($graph['limits'] ?? null) || array_diff(array_keys($graph['limits']), array( 'controls', 'rules_per_role' )) || self::MAX_CONTROLS !== ($graph['limits']['controls'] ?? null) || self::MAX_RULES_PER_ROLE !== ($graph['limits']['rules_per_role'] ?? null) || ! is_array($graph['controls'] ?? null) || ! array_is_list($graph['controls']) || count($graph['controls']) > self::MAX_CONTROLS || ($v2 && (! is_array($graph['visual_parts'] ?? null) || ! array_is_list($graph['visual_parts']) || count($graph['visual_parts']) > self::MAX_VISUAL_PARTS || ! is_array($graph['visual_groups'] ?? null) || ! array_is_list($graph['visual_groups']) || count($graph['visual_groups']) > self::MAX_VISUAL_PARTS)) || ! is_array($graph['variants'] ?? null) || ! array_is_list($graph['variants']) || count($graph['variants']) > self::MAX_VARIANTS || ! is_array($graph['diagnostics'] ?? null) || ! array_is_list($graph['diagnostics']) || count($graph['diagnostics']) > self::MAX_DIAGNOSTICS || array_filter($graph['diagnostics'], static fn (mixed $diagnostic): bool => ! is_string($diagnostic) || '' === trim($diagnostic) || strlen($diagnostic) > 1100) || array_diff(array_keys($graph), $expectedKeys) ) { throw new InvalidArgumentException('Form presentation graph envelope is invalid.'); } $seen = array(); @@ -179,8 +195,16 @@ public static function assertValid(array $graph): void if ( isset($partIds[$part['id']]) ) throw new InvalidArgumentException('Form presentation visual part identity is duplicated.'); $partIds[$part['id']] = $part['index']; } + $groupIds = array(); + foreach ( $v2 ? $graph['visual_groups'] : array() as $group ) { + self::assertVisualGroup($group, $partIds); + if ( isset($groupIds[$group['id']]) ) throw new InvalidArgumentException('Form presentation visual group identity is duplicated.'); + $groupIds[$group['id']] = true; + } foreach ( $graph['variants'] as $variant ) { - if ( ! is_array($variant) || array_diff(array_keys($variant), array( 'index', 'role', 'part_id', 'condition', 'style_patch', 'precedence', 'provenance' )) || ! is_int($variant['index'] ?? null) || $variant['index'] < 0 || $variant['index'] >= self::MAX_CONTROLS || ! in_array($variant['role'] ?? null, $v2 ? array( 'control', 'label', 'visual_part' ) : array( 'control', 'label' ), true) || ('visual_part' === ($variant['role'] ?? null) ? (! is_string($variant['part_id'] ?? null) || ! isset($partIds[$variant['part_id']]) || $variant['index'] !== $partIds[$variant['part_id']]) : isset($variant['part_id'])) || ! is_array($variant['condition'] ?? null) || ! self::validCondition($variant['condition']) || ! is_array($variant['style_patch'] ?? null) || array() === $variant['style_patch'] || ! is_array($variant['precedence'] ?? null) || ! is_array($variant['provenance'] ?? null) ) { + $isVisualPart = 'visual_part' === ($variant['role'] ?? null); $isVisualGroup = 'visual_group' === ($variant['role'] ?? null); + $keys = $isVisualPart ? array( 'index', 'role', 'part_id', 'condition', 'style_patch', 'precedence', 'provenance' ) : ($isVisualGroup ? array( 'role', 'group_id', 'condition', 'style_patch', 'precedence', 'provenance' ) : array( 'index', 'role', 'condition', 'style_patch', 'precedence', 'provenance' )); + if ( ! is_array($variant) || array_diff(array_keys($variant), $keys) || (! $isVisualGroup && (! is_int($variant['index'] ?? null) || $variant['index'] < 0 || $variant['index'] >= self::MAX_CONTROLS)) || ! in_array($variant['role'] ?? null, $v2 ? array( 'control', 'label', 'visual_part', 'visual_group' ) : array( 'control', 'label' ), true) || ($isVisualPart ? (! is_string($variant['part_id'] ?? null) || ! isset($partIds[$variant['part_id']]) || $variant['index'] !== $partIds[$variant['part_id']]) : ($isVisualGroup ? (! is_string($variant['group_id'] ?? null) || ! isset($groupIds[$variant['group_id']])) : (isset($variant['part_id']) || isset($variant['group_id'])))) || ! is_array($variant['condition'] ?? null) || ! self::validCondition($variant['condition']) || ! is_array($variant['style_patch'] ?? null) || array() === $variant['style_patch'] || ! is_array($variant['precedence'] ?? null) || ! is_array($variant['provenance'] ?? null) ) { throw new InvalidArgumentException('Form presentation variant is invalid.'); } self::assertStyles($variant['style_patch']); @@ -210,6 +234,16 @@ private static function assertVisualPart(mixed $part): void } elseif ( array_diff(array_keys($sourceCss), array( 'state' )) ) throw new InvalidArgumentException('Form presentation visual part unknown CSS state is invalid.'); } + /** A visual group retains the authored common container without assigning semantic meaning. */ + private static function assertVisualGroup(mixed $group, array $partIds): void + { + if ( ! is_array($group) || array_diff(array_keys($group), array( 'id', 'source_selector', 'part_ids', 'source_css' )) || ! is_string($group['id'] ?? null) || ! preg_match('/^visual-group-[a-f0-9]{16}$/', $group['id']) || ! is_string($group['source_selector'] ?? null) || '' === trim($group['source_selector']) || strlen($group['source_selector']) > 2048 || ! is_array($group['part_ids'] ?? null) || ! array_is_list($group['part_ids']) || count($group['part_ids']) < 2 || count($group['part_ids']) > self::MAX_VISUAL_PARTS || count(array_unique($group['part_ids'])) !== count($group['part_ids']) || array_filter($group['part_ids'], static fn (mixed $id): bool => ! is_string($id) || ! isset($partIds[$id])) || ! is_array($group['source_css'] ?? null) || ! in_array($group['source_css']['state'] ?? null, array( 'known', 'unknown' ), true) ) throw new InvalidArgumentException('Form presentation visual group is invalid.'); + if ( 'known' === $group['source_css']['state'] ) { + if ( array_diff(array_keys($group['source_css']), array( 'state', 'styles', 'provenance' )) || ! is_array($group['source_css']['styles'] ?? null) || array() === $group['source_css']['styles'] || ! is_array($group['source_css']['provenance'] ?? null) ) throw new InvalidArgumentException('Form presentation visual group source CSS is invalid.'); + self::assertStyles($group['source_css']['styles']); self::assertProvenance($group['source_css']['provenance'], $group['source_css']['styles'], null); + } elseif ( array_diff(array_keys($group['source_css']), array( 'state' )) ) throw new InvalidArgumentException('Form presentation visual group unknown CSS state is invalid.'); + } + private static function assertStyles(array $styles): void { foreach ( $styles as $key => $value ) if ( ! is_string($key) || ! in_array($key, array_map(self::key(...), self::PROPERTIES), true) || ! is_string($value) || '' === trim($value) || strlen($value) > 160 ) throw new InvalidArgumentException('Form presentation style is invalid.'); @@ -239,7 +273,7 @@ private function label(DOMElement $control): ?DOMElement return null; } - /** @return list, variants: list>}> */ + /** @return list, variants: list>, group?: array, group_variants?: list>}> */ private function visualParts(DOMElement $control, int $index, array $rules, array $customPropertyRules): array { if ( null === $this->sanitizeInlineSvgMarkup ) return array(); @@ -264,11 +298,36 @@ private function visualParts(DOMElement $control, int $index, array $rules, arra $patch = $this->styles($facts, $svg, $condition, $customPropertyRules); if ( array() !== $patch ) $variants[] = array( 'index' => $index, 'role' => 'visual_part', 'part_id' => $part['id'], 'condition' => $condition, 'style_patch' => $patch, 'precedence' => $this->precedence($facts), 'provenance' => $this->provenance($facts, $condition) ); } - $parts[] = array( 'part' => $part, 'variants' => $variants ); + $parts[] = array( 'part' => $part, 'variants' => $variants, 'element' => $svg ); } + $svgs = array_column($parts, 'element'); + if ( count($svgs) >= 2 && ($groupElement = $this->lowestCommonAncestor($svgs)) instanceof DOMElement ) { + $selector = SourceDom::elementSelector($groupElement); + if ( strlen($selector) <= 2048 ) { + $matched = $this->matched($groupElement, $rules); + $styles = $this->styles($matched['base'], $groupElement, null, $customPropertyRules); + $group = array( 'id' => 'visual-group-' . substr(hash('sha256', $selector), 0, 16), 'source_selector' => $selector, 'part_ids' => array_column(array_column($parts, 'part'), 'id'), 'source_css' => array( 'state' => 'unknown' ) ); + if ( array() !== $styles ) $group['source_css'] = array( 'state' => 'known', 'styles' => $styles, 'provenance' => $this->provenance($matched['base'], null) ); + $groupVariants = array(); + foreach ( $this->effectiveConditional($matched['conditional'], $matched['base']) as $encoded => $facts ) { $condition = json_decode($encoded, true); $patch = $this->styles($facts, $groupElement, $condition, $customPropertyRules); if ( array() !== $patch ) $groupVariants[] = array( 'role' => 'visual_group', 'group_id' => $group['id'], 'condition' => $condition, 'style_patch' => $patch, 'precedence' => $this->precedence($facts), 'provenance' => $this->provenance($facts, $condition) ); } + $parts[array_key_last($parts)]['group'] = $group; + $parts[array_key_last($parts)]['group_variants'] = $groupVariants; + } + } + foreach ( $parts as &$part ) unset($part['element']); unset($part); return $parts; } + /** @param list $elements */ + private function lowestCommonAncestor(array $elements): ?DOMElement + { + for ( $candidate = $elements[0]->parentNode; $candidate instanceof DOMElement; $candidate = $candidate->parentNode ) { + foreach ( $elements as $element ) { for ( $current = $element; $current instanceof DOMElement && ! $current->isSameNode($candidate); $current = $current->parentNode instanceof DOMElement ? $current->parentNode : null ) {} if (! $current instanceof DOMElement) continue 2; } + return $candidate; + } + return null; + } + /** @param list> $rules */ private function matched(DOMElement $element, array $rules): array { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index e7df757b7..c976a6c4b 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1358,12 +1358,15 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $assert('85px' === ($responsiveTokenVariants['(max-width:50rem)']['style_patch']['height'] ?? null) && '8px' === ($responsiveTokenVariants['(max-width:50rem)']['style_patch']['padding_inline_start'] ?? null) && '86px' === ($responsiveTokenVariants['(min-width:51rem)']['style_patch']['height'] ?? null) && '10px' === ($responsiveTokenVariants['(min-width:51rem)']['style_patch']['padding_inline_start'] ?? null) && !str_contains(json_encode($responsiveTokenGraph), 'var('), 'form presentation resolves ancestor custom properties within each responsive condition before provider projection'); $caseSensitiveTokenGraph = (new HtmlTransformer())->transform('
', array('static_css' => '#field{--inputHeight:86px}textarea{height:var(--inputHeight)}'))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); $assert('86px' === ($caseSensitiveTokenGraph['controls'][0]['control']['styles']['height'] ?? null), 'form presentation preserves case-sensitive custom property names while resolving source controls'); -$visualPresentationHtml = '
'; -$visualPresentationCss = 'button span{display:flex;position:relative}.globe{width:24px;height:24px;flex:none}.chevron{width:16px;height:16px;position:absolute;right:8px}@media (max-width:50rem){.chevron{width:12px;right:4px}}'; +$visualPresentationHtml = '
'; +$visualPresentationCss = '.visual-group{display:flex;flex-direction:row;align-items:center;justify-content:space-between;gap:8px;position:relative}.globe{width:24px;height:24px;flex:none}.chevron{width:16px;height:16px;position:absolute;right:8px}@media (max-width:50rem){.visual-group{gap:4px}.chevron{width:12px;right:4px}}'; $visualPresentationGraph = (new HtmlTransformer())->transform($visualPresentationHtml, array('static_css' => $visualPresentationCss))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); $visualParts = $visualPresentationGraph['visual_parts'] ?? array(); +$visualGroups = $visualPresentationGraph['visual_groups'] ?? array(); $visualVariants = array(); foreach ($visualPresentationGraph['variants'] ?? array() as $variant) if ('visual_part' === ($variant['role'] ?? null)) $visualVariants[$variant['part_id'] ?? ''] = $variant; +$visualGroupVariant = current(array_filter($visualPresentationGraph['variants'] ?? array(), static fn(array $variant): bool => 'visual_group' === ($variant['role'] ?? null))); $assert(2 === count($visualParts) && 'control-0-svg-0' === ($visualParts[0]['id'] ?? null) && 'inline_svg' === ($visualParts[0]['kind'] ?? null) && 24 === ($visualParts[0]['intrinsic_size']['width'] ?? null) && '24px' === ($visualParts[0]['source_css']['styles']['width'] ?? null) && '24px' === ($visualParts[0]['source_css']['styles']['height'] ?? null) && '.globe' === ($visualParts[0]['source_css']['provenance'][0]['selector'] ?? null) && !str_contains((string) ($visualParts[0]['markup'] ?? ''), 'onclick') && 'control-0-svg-1' === ($visualParts[1]['id'] ?? null) && 20 === ($visualParts[1]['intrinsic_size']['width'] ?? null) && '16px' === ($visualParts[1]['source_css']['styles']['width'] ?? null) && 'absolute' === ($visualParts[1]['source_css']['styles']['position'] ?? null) && '8px' === ($visualParts[1]['source_css']['styles']['right'] ?? null) && '12px' === ($visualVariants['control-0-svg-1']['style_patch']['width'] ?? null) && 'media' === ($visualVariants['control-0-svg-1']['condition']['kind'] ?? null), 'form presentation identifies visual SVG descendants by control order and source selector, retaining intrinsic geometry, actual CSS layout facts, conditional variants, provenance, and sanitized markup without semantic role guesses'); +$assert(1 === count($visualGroups) && str_ends_with((string) ($visualGroups[0]['source_selector'] ?? ''), 'span:nth-of-type(1)') && array('control-0-svg-0', 'control-0-svg-1') === ($visualGroups[0]['part_ids'] ?? null) && 'flex' === ($visualGroups[0]['source_css']['styles']['display'] ?? null) && 'row' === ($visualGroups[0]['source_css']['styles']['flex_direction'] ?? null) && 'center' === ($visualGroups[0]['source_css']['styles']['align_items'] ?? null) && 'space-between' === ($visualGroups[0]['source_css']['styles']['justify_content'] ?? null) && '8px' === ($visualGroups[0]['source_css']['styles']['gap'] ?? null) && '4px' === ($visualGroupVariant['style_patch']['gap'] ?? null), 'form presentation retains the SVG LCA source identity and only authored visual-group flex facts plus its conditional gap without control-index ownership'); $conditionalVisualGraph = (new HtmlTransformer())->transform('
')->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); $assert('unknown' === ($conditionalVisualGraph['visual_parts'][0]['source_css']['state'] ?? null) && 'visual_part' === ($conditionalVisualGraph['variants'][0]['role'] ?? null) && 'max(16px,16px)' === ($conditionalVisualGraph['variants'][0]['style_patch']['width'] ?? null), 'conditional-only visual sizing remains in its responsive variant even when base CSS is unknown'); $unknownVisualGraph = (new HtmlTransformer())->transform('
')->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); From 4fde8cc44cc708d827477aa3e7d76165b5f3e83f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 11:51:05 -0400 Subject: [PATCH 4/8] feat(forms): preserve explicit required-marker presentation --- .../docs/form-presentation-graph.md | 6 +-- .../Elements/FormControlMetadataBuilder.php | 29 ++++++++---- .../Elements/FormFallbackFindingBuilder.php | 3 +- .../Style/FormPresentationGraphBuilder.php | 44 +++++++++++++------ php-transformer/tests/contract/run.php | 7 +++ 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/php-transformer/docs/form-presentation-graph.md b/php-transformer/docs/form-presentation-graph.md index d52f97742..3feefb1fe 100644 --- a/php-transformer/docs/form-presentation-graph.md +++ b/php-transformer/docs/form-presentation-graph.md @@ -6,20 +6,20 @@ array{ schema: 'generic/computed-form-presentation/v2', basis: 'source_css_cascade', - controls: list, + controls: list, visual_parts: list, provenance: list}|array{state: 'unknown'} }>, - variants: list, precedence: array, provenance: list}>, + variants: list, precedence: array, provenance: list}>, truncated: bool, limits: array{controls: 128, rules_per_role: 32}, diagnostics: list } ``` `visual_parts` contains only drawable SVG descendants that pass `SourceDom::isSafeInlineSvgMarkup()`. `source_selector` identifies the source descendant and `index` links it to the source control order. `source_css.state: 'unknown'` means no unconditional source-CSS facts were matched. Responsive facts can still be present in `variants`; consumers evaluate those conditions rather than treating an unknown base as missing presentation. Consumers must not infer a role or layout from SVG shape or order. -Existing v1 graphs remain valid. Graphs without visual parts retain the v1 envelope; graphs with visual parts emit v2. This contract describes source appearance, not interaction-state semantics or provider destinations. +`required_marker` is emitted only for the existing explicit required marker identity: an `aria-hidden="true"` span containing one to four asterisks on a required control's associated label. It records the marker's own matched CSS and variants; when it inherits the label unchanged, it safely has empty `styles` and `provenance` rather than invented source facts. Existing v1 graphs remain valid. Graphs without visual parts or an explicit required marker retain the v1 envelope; graphs with either emit v2. This contract describes source appearance, not interaction-state semantics or provider destinations. The unshipped v2 envelope also includes `visual_groups`. Each group records the source selector of the SVG parts' lowest common ancestor, a stable `id`, ordered `part_ids`, and `source_css` using the same base-fact shape as visual parts. Group responsive variants use `role: visual_group` and `group_id` rather than a control index. Consumers preserve those authored container facts independently of each SVG's intrinsic dimensions and keep provider visibility state outside that layout container. diff --git a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php index 7970eeb5f..ec34bd90f 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormControlMetadataBuilder.php @@ -109,14 +109,8 @@ public function control(DOMElement $control): array if ( $control->hasAttribute('required') || 'true' === strtolower(trim(SourceDom::attr($control, 'aria-required'))) ) { $metadata['required'] = true; - if ( $labelElement instanceof DOMElement ) { - foreach ( $labelElement->getElementsByTagName('span') as $marker ) { - $text = trim($marker->textContent ?? ''); - if ( 'true' === strtolower(SourceDom::attr($marker, 'aria-hidden')) && preg_match('/^\*{1,4}$/D', $text) ) { - $metadata['required_text'] = $text; - break; - } - } + if ( ($marker = $this->requiredMarker($control)) instanceof DOMElement ) { + $metadata['required_text'] = trim($marker->textContent ?? ''); } } foreach ( array( 'disabled', 'readonly', 'checked', 'multiple' ) as $attribute ) { @@ -192,6 +186,25 @@ public function associatedLabel(DOMElement $control): ?DOMElement return null; } + /** The explicit decorative required marker also supplies provider presentation identity. */ + public function requiredMarker(DOMElement $control): ?DOMElement + { + if ( ! $control->hasAttribute('required') && 'true' !== strtolower(trim(SourceDom::attr($control, 'aria-required'))) ) { + return null; + } + $label = $this->labelElement($control); + if ( ! $label instanceof DOMElement ) { + return null; + } + foreach ( $label->getElementsByTagName('span') as $marker ) { + $text = trim($marker->textContent ?? ''); + if ( 'true' === strtolower(SourceDom::attr($marker, 'aria-hidden')) && preg_match('/^\*{1,4}$/D', $text) ) { + return $marker; + } + } + return null; + } + private function labelElement(DOMElement $control): ?DOMElement { $label = $this->associatedLabel($control); diff --git a/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php b/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php index 349a1b8ff..6e38fc8dd 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php @@ -32,7 +32,8 @@ public function build(DOMElement $element, ?array $readableFormBlock, ?array $bi $layoutGraph = (new FormLayoutGraphBuilder())->build($element, $this->context->stylesheetAssets(), $this->context->formLayoutCss()); $presentationGraph = (new FormPresentationGraphBuilder( fn (DOMElement $control, string $value): string => $this->context->resolvePresentationValue($control, $value), - fn (DOMElement $element): string => $this->context->sanitizeInlineSvgMarkup($element) + fn (DOMElement $element): string => $this->context->sanitizeInlineSvgMarkup($element), + fn (DOMElement $control): ?DOMElement => $this->metadataBuilder->requiredMarker($control) ))->build($element, $this->context->stylesheetAssets(), $this->context->formLayoutCss()); $boundedHtml = $this->context->boundedFallbackHtml($element); $replacesRuntimeIsland = null !== $bindingBlock; diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index b95579480..debd1884d 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -46,8 +46,8 @@ final class FormPresentationGraphBuilder private array $diagnostics = array(); private bool $truncated = false; - /** @param (Closure(DOMElement, string): string)|null $resolveValue @param (Closure(DOMElement): string)|null $sanitizeInlineSvgMarkup */ - public function __construct(private readonly ?Closure $resolveValue = null, private readonly ?Closure $sanitizeInlineSvgMarkup = null) + /** @param (Closure(DOMElement, string): string)|null $resolveValue @param (Closure(DOMElement): string)|null $sanitizeInlineSvgMarkup @param (Closure(DOMElement): ?DOMElement)|null $requiredMarker */ + public function __construct(private readonly ?Closure $resolveValue = null, private readonly ?Closure $sanitizeInlineSvgMarkup = null, private readonly ?Closure $requiredMarker = null) { } @@ -57,7 +57,7 @@ public function build(DOMElement $form, array $stylesheets, string $inlineCss = $this->diagnostics = array(); $this->truncated = false; $analysis = (new CssRuleAnalyzer())->analyze($stylesheets, $inlineCss, self::PROPERTIES, self::MAX_CSS_BYTES, self::MAX_RULES, self::MAX_SELECTORS, self::MAX_CONDITION_DEPTH); - $controlsForCustomProperties = $this->controls($form); + $controlsForCustomProperties = $this->presentationElements($form); $customPropertyAnalysis = (new CssRuleAnalyzer())->analyze( $stylesheets, $inlineCss, @@ -81,6 +81,7 @@ function (array $selector) use ($controlsForCustomProperties): bool { $variants = array(); $visualParts = array(); $visualGroups = array(); + $hasRequiredMarker = false; foreach ( $this->controls($form) as $index => $control ) { if ( $index >= self::MAX_CONTROLS ) { @@ -89,13 +90,18 @@ function (array $selector) use ($controlsForCustomProperties): bool { break; } $row = array( 'index' => $index ); - foreach ( array( 'control' => $control, 'label' => $this->label($control) ) as $role => $element ) { + $roles = array( 'control' => $control, 'label' => $this->label($control) ); + if ( null !== $this->requiredMarker && ($marker = ($this->requiredMarker)($control)) instanceof DOMElement ) { + $roles['required_marker'] = $marker; + $hasRequiredMarker = true; + } + foreach ( $roles as $role => $element ) { if ( ! $element instanceof DOMElement ) { continue; } $matched = $this->matched($element, $analysis['rules']); $styles = $this->styles($matched['base'], $element, null, $customPropertyAnalysis['rules']); - if ( array() !== $styles ) { + if ( array() !== $styles || 'required_marker' === $role ) { $row[$role] = array( 'styles' => $styles, 'provenance' => $this->provenance($matched['base'], null) ); } foreach ( $this->effectiveConditional($matched['conditional'], $matched['base']) as $encoded => $facts ) { @@ -153,7 +159,7 @@ function (array $selector) use ($controlsForCustomProperties): bool { } $graph = array( - 'schema' => array() === $visualParts ? 'generic/computed-form-presentation/v1' : 'generic/computed-form-presentation/v2', + 'schema' => array() === $visualParts && ! $hasRequiredMarker ? 'generic/computed-form-presentation/v1' : 'generic/computed-form-presentation/v2', 'basis' => 'source_css_cascade', 'truncated' => $this->truncated, 'limits' => array( 'controls' => self::MAX_CONTROLS, 'rules_per_role' => self::MAX_RULES_PER_ROLE ), @@ -161,7 +167,7 @@ function (array $selector) use ($controlsForCustomProperties): bool { 'variants' => $variants, 'diagnostics' => array_slice(array_values(array_unique($this->diagnostics)), 0, self::MAX_DIAGNOSTICS), ); - if ( array() !== $visualParts ) { + if ( array() !== $visualParts || $hasRequiredMarker ) { $graph['visual_parts'] = $visualParts; $graph['visual_groups'] = $visualGroups; } @@ -181,12 +187,12 @@ public static function assertValid(array $graph): void } $seen = array(); foreach ( $graph['controls'] as $row ) { - if ( ! is_array($row) || array_diff(array_keys($row), array( 'index', 'control', 'label' )) || ! is_int($row['index'] ?? null) || $row['index'] < 0 || $row['index'] >= self::MAX_CONTROLS || isset($seen[$row['index']]) || (! isset($row['control']) && ! isset($row['label'])) ) { + if ( ! is_array($row) || array_diff(array_keys($row), $v2 ? array( 'index', 'control', 'label', 'required_marker' ) : array( 'index', 'control', 'label' )) || ! is_int($row['index'] ?? null) || $row['index'] < 0 || $row['index'] >= self::MAX_CONTROLS || isset($seen[$row['index']]) || (! isset($row['control']) && ! isset($row['label']) && ! isset($row['required_marker'])) ) { throw new InvalidArgumentException('Form presentation control is invalid.'); } $seen[$row['index']] = true; - foreach ( array( 'control', 'label' ) as $role ) { - if ( isset($row[$role]) ) self::assertRole($row[$role], null); + foreach ( $v2 ? array( 'control', 'label', 'required_marker' ) : array( 'control', 'label' ) as $role ) { + if ( isset($row[$role]) ) self::assertRole($row[$role], null, 'required_marker' === $role); } } $partIds = array(); @@ -204,7 +210,7 @@ public static function assertValid(array $graph): void foreach ( $graph['variants'] as $variant ) { $isVisualPart = 'visual_part' === ($variant['role'] ?? null); $isVisualGroup = 'visual_group' === ($variant['role'] ?? null); $keys = $isVisualPart ? array( 'index', 'role', 'part_id', 'condition', 'style_patch', 'precedence', 'provenance' ) : ($isVisualGroup ? array( 'role', 'group_id', 'condition', 'style_patch', 'precedence', 'provenance' ) : array( 'index', 'role', 'condition', 'style_patch', 'precedence', 'provenance' )); - if ( ! is_array($variant) || array_diff(array_keys($variant), $keys) || (! $isVisualGroup && (! is_int($variant['index'] ?? null) || $variant['index'] < 0 || $variant['index'] >= self::MAX_CONTROLS)) || ! in_array($variant['role'] ?? null, $v2 ? array( 'control', 'label', 'visual_part', 'visual_group' ) : array( 'control', 'label' ), true) || ($isVisualPart ? (! is_string($variant['part_id'] ?? null) || ! isset($partIds[$variant['part_id']]) || $variant['index'] !== $partIds[$variant['part_id']]) : ($isVisualGroup ? (! is_string($variant['group_id'] ?? null) || ! isset($groupIds[$variant['group_id']])) : (isset($variant['part_id']) || isset($variant['group_id'])))) || ! is_array($variant['condition'] ?? null) || ! self::validCondition($variant['condition']) || ! is_array($variant['style_patch'] ?? null) || array() === $variant['style_patch'] || ! is_array($variant['precedence'] ?? null) || ! is_array($variant['provenance'] ?? null) ) { + if ( ! is_array($variant) || array_diff(array_keys($variant), $keys) || (! $isVisualGroup && (! is_int($variant['index'] ?? null) || $variant['index'] < 0 || $variant['index'] >= self::MAX_CONTROLS)) || ! in_array($variant['role'] ?? null, $v2 ? array( 'control', 'label', 'required_marker', 'visual_part', 'visual_group' ) : array( 'control', 'label' ), true) || ($isVisualPart ? (! is_string($variant['part_id'] ?? null) || ! isset($partIds[$variant['part_id']]) || $variant['index'] !== $partIds[$variant['part_id']]) : ($isVisualGroup ? (! is_string($variant['group_id'] ?? null) || ! isset($groupIds[$variant['group_id']])) : (isset($variant['part_id']) || isset($variant['group_id'])))) || ! is_array($variant['condition'] ?? null) || ! self::validCondition($variant['condition']) || ! is_array($variant['style_patch'] ?? null) || array() === $variant['style_patch'] || ! is_array($variant['precedence'] ?? null) || ! is_array($variant['provenance'] ?? null) ) { throw new InvalidArgumentException('Form presentation variant is invalid.'); } self::assertStyles($variant['style_patch']); @@ -215,9 +221,9 @@ public static function assertValid(array $graph): void } } - private static function assertRole(mixed $role, ?array $condition): void + private static function assertRole(mixed $role, ?array $condition, bool $allowEmpty = false): void { - if ( ! is_array($role) || count($role) !== 2 || array_diff(array_keys($role), array( 'styles', 'provenance' )) || ! is_array($role['styles'] ?? null) || array() === $role['styles'] || ! is_array($role['provenance'] ?? null) ) throw new InvalidArgumentException('Form presentation role is invalid.'); + if ( ! is_array($role) || count($role) !== 2 || array_diff(array_keys($role), array( 'styles', 'provenance' )) || ! is_array($role['styles'] ?? null) || (! $allowEmpty && array() === $role['styles']) || ! is_array($role['provenance'] ?? null) ) throw new InvalidArgumentException('Form presentation role is invalid.'); self::assertStyles($role['styles']); self::assertProvenance($role['provenance'], $role['styles'], $condition); } @@ -265,6 +271,18 @@ private function controls(DOMElement $form): array return $result; } + /** @return list */ + private function presentationElements(DOMElement $form): array + { + $elements = $this->controls($form); + foreach ( $this->controls($form) as $control ) { + $label = $this->label($control); + if ( $label instanceof DOMElement ) $elements[] = $label; + if ( null !== $this->requiredMarker && ($marker = ($this->requiredMarker)($control)) instanceof DOMElement ) $elements[] = $marker; + } + return $elements; + } + private function label(DOMElement $control): ?DOMElement { $id = $control->getAttribute('id'); diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index c976a6c4b..09c17b0d8 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1358,6 +1358,13 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $assert('85px' === ($responsiveTokenVariants['(max-width:50rem)']['style_patch']['height'] ?? null) && '8px' === ($responsiveTokenVariants['(max-width:50rem)']['style_patch']['padding_inline_start'] ?? null) && '86px' === ($responsiveTokenVariants['(min-width:51rem)']['style_patch']['height'] ?? null) && '10px' === ($responsiveTokenVariants['(min-width:51rem)']['style_patch']['padding_inline_start'] ?? null) && !str_contains(json_encode($responsiveTokenGraph), 'var('), 'form presentation resolves ancestor custom properties within each responsive condition before provider projection'); $caseSensitiveTokenGraph = (new HtmlTransformer())->transform('
', array('static_css' => '#field{--inputHeight:86px}textarea{height:var(--inputHeight)}'))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); $assert('86px' === ($caseSensitiveTokenGraph['controls'][0]['control']['styles']['height'] ?? null), 'form presentation preserves case-sensitive custom property names while resolving source controls'); +$requiredMarkerCss = '.contact-label{--marker-size:14px;font-size:14px;line-height:19.6px}.contact-label .marker{font-size:var(--marker-size);line-height:19.6px}@media (max-width:50rem){.contact-label .marker{font-size:12px;margin-left:2px}}'; +$requiredMarkerGraph = (new HtmlTransformer())->transform('
', array('static_css' => $requiredMarkerCss))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); +$requiredMarkerRow = $requiredMarkerGraph['controls'][0]['required_marker'] ?? array(); +$requiredMarkerVariant = current(array_filter($requiredMarkerGraph['variants'] ?? array(), static fn(array $variant): bool => 'required_marker' === ($variant['role'] ?? null))); +$assert('generic/computed-form-presentation/v2' === ($requiredMarkerGraph['schema'] ?? null) && '14px' === ($requiredMarkerRow['styles']['font_size'] ?? null) && '19.6px' === ($requiredMarkerRow['styles']['line_height'] ?? null) && '.contact-label .marker' === ($requiredMarkerRow['provenance'][0]['selector'] ?? null) && '12px' === ($requiredMarkerVariant['style_patch']['font_size'] ?? null) && '2px' === ($requiredMarkerVariant['style_patch']['margin_left'] ?? null), 'form presentation reuses the explicit required-marker identity, its ancestor custom property scope, and conditional source CSS facts'); +$inheritedRequiredMarkerGraph = (new HtmlTransformer())->transform('
', array('static_css' => '.contact-label{font-size:14px;line-height:19.6px}'))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); +$assert('generic/computed-form-presentation/v2' === ($inheritedRequiredMarkerGraph['schema'] ?? null) && array() === ($inheritedRequiredMarkerGraph['controls'][0]['required_marker']['styles'] ?? null) && array() === ($inheritedRequiredMarkerGraph['controls'][0]['required_marker']['provenance'] ?? null), 'form presentation safely retains an explicit inherited required marker without inventing CSS provenance'); $visualPresentationHtml = '
'; $visualPresentationCss = '.visual-group{display:flex;flex-direction:row;align-items:center;justify-content:space-between;gap:8px;position:relative}.globe{width:24px;height:24px;flex:none}.chevron{width:16px;height:16px;position:absolute;right:8px}@media (max-width:50rem){.visual-group{gap:4px}.chevron{width:12px;right:4px}}'; $visualPresentationGraph = (new HtmlTransformer())->transform($visualPresentationHtml, array('static_css' => $visualPresentationCss))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); From b574fd9061609ae946c9d5bbae9a62ef39e184a7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 13:22:23 -0400 Subject: [PATCH 5/8] feat(forms): retain exclusive source field container presentation --- .../SourceElementClassifier.php | 2 +- .../Style/FormControlTopologyBuilder.php | 32 ++++++++ .../Style/FormPresentationGraphBuilder.php | 73 +++++++++++++++++-- php-transformer/tests/contract/run.php | 9 +++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php index 39e2a8b43..be2bb8639 100644 --- a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php +++ b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php @@ -278,7 +278,7 @@ public function isPositiveCssLength(string $value): bool public function isVisibleEmptyVisualPaint(string $value): bool { $value = strtolower(trim($value)); - if ( '' === $value || 'none' === $value || 'transparent' === $value || preg_match('/^rgba?\([^)]*,\s*0(?:\.0+)?\s*\)$/', $value) ) { + if ( '' === $value || 'none' === $value || 'transparent' === $value || preg_match('/^0(?:px|%)?(?:\s+0(?:px|%)?)?$/', $value) || preg_match('/^rgba?\([^)]*,\s*0(?:\.0+)?\s*\)$/', $value) ) { return false; } diff --git a/php-transformer/src/HtmlToBlocks/Style/FormControlTopologyBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormControlTopologyBuilder.php index eeec4a41f..9cd67e4a4 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormControlTopologyBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormControlTopologyBuilder.php @@ -52,6 +52,38 @@ public function build(DOMElement $form): array ); } + /** + * Return each control's nearest-to-farthest wrapper ancestors that contain no + * other form controls. Labels and other non-controls intentionally do not + * make a wrapper shared. + * + * @return array> + */ + public function exclusiveWrapperAncestors(DOMElement $form): array + { + $result = array(); + $controls = $this->controls($form); + $owners = array(); + foreach ( $controls as $control ) { + $depth = 0; + for ( $ancestor = $control->parentNode; $ancestor instanceof DOMElement && ! $ancestor->isSameNode($form) && $depth < self::MAX_DEPTH; $ancestor = $ancestor->parentNode, ++$depth ) { + $path = $ancestor->getNodePath(); + $owners[$path] = ($owners[$path] ?? 0) + 1; + } + } + foreach ( $controls as $index => $control ) { + $ancestors = array(); + $depth = 0; + for ( $ancestor = $control->parentNode; $ancestor instanceof DOMElement && ! $ancestor->isSameNode($form) && $depth < self::MAX_DEPTH; $ancestor = $ancestor->parentNode, ++$depth ) { + if ( 1 === ($owners[$ancestor->getNodePath()] ?? 0) ) { + $ancestors[] = $ancestor; + } + } + $result[$index] = $ancestors; + } + return $result; + } + /** * @param array $controlIndexes * @param array $relevantElements diff --git a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php index debd1884d..f5a8359e3 100644 --- a/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Style/FormPresentationGraphBuilder.php @@ -5,6 +5,7 @@ use Automattic\BlocksEngine\PhpTransformer\Css\CssRuleAnalyzer; use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatcher; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Classification\SourceElementClassifier; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom; use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath; use Closure; @@ -27,6 +28,13 @@ final class FormPresentationGraphBuilder private const MAX_VISUAL_DIMENSION = 4096; private const MAX_PROVENANCE = 16; private const MAX_DIAGNOSTICS = 32; + private const CONTROL_CONTAINER_PROPERTIES = array( + 'background', 'background-color', 'border', 'border-color', 'border-style', 'border-width', + 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', + 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', + 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius', + ); private const PROPERTIES = array( 'appearance', 'background', 'background-color', 'border', 'border-color', 'border-style', 'border-width', 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', @@ -82,6 +90,8 @@ function (array $selector) use ($controlsForCustomProperties): bool { $visualParts = array(); $visualGroups = array(); $hasRequiredMarker = false; + $controlContainers = array(); + $exclusiveAncestors = (new FormControlTopologyBuilder())->exclusiveWrapperAncestors($form); foreach ( $this->controls($form) as $index => $control ) { if ( $index >= self::MAX_CONTROLS ) { @@ -124,6 +134,14 @@ function (array $selector) use ($controlsForCustomProperties): bool { } } } + $container = $this->controlContainer($index, $exclusiveAncestors[$index] ?? array(), $analysis['rules'], $customPropertyAnalysis['rules']); + if ( null !== $container ) { + $controlContainers[] = $container['container']; + foreach ( $container['variants'] as $variant ) { + if ( count($variants) >= self::MAX_VARIANTS ) { $this->truncated = true; $this->diagnostics[] = 'variant_limit'; break 2; } + $variants[] = $variant; + } + } $capturedParts = $this->visualParts($control, $index, $analysis['rules'], $customPropertyAnalysis['rules']); foreach ( $capturedParts as $part ) { if ( count($visualParts) >= self::MAX_VISUAL_PARTS ) { @@ -159,7 +177,7 @@ function (array $selector) use ($controlsForCustomProperties): bool { } $graph = array( - 'schema' => array() === $visualParts && ! $hasRequiredMarker ? 'generic/computed-form-presentation/v1' : 'generic/computed-form-presentation/v2', + 'schema' => array() === $visualParts && ! $hasRequiredMarker && array() === $controlContainers ? 'generic/computed-form-presentation/v1' : 'generic/computed-form-presentation/v2', 'basis' => 'source_css_cascade', 'truncated' => $this->truncated, 'limits' => array( 'controls' => self::MAX_CONTROLS, 'rules_per_role' => self::MAX_RULES_PER_ROLE ), @@ -167,9 +185,10 @@ function (array $selector) use ($controlsForCustomProperties): bool { 'variants' => $variants, 'diagnostics' => array_slice(array_values(array_unique($this->diagnostics)), 0, self::MAX_DIAGNOSTICS), ); - if ( array() !== $visualParts || $hasRequiredMarker ) { + if ( 'generic/computed-form-presentation/v2' === $graph['schema'] ) { $graph['visual_parts'] = $visualParts; $graph['visual_groups'] = $visualGroups; + $graph['control_containers'] = $controlContainers; } self::assertValid($graph); return $graph; @@ -181,10 +200,16 @@ public static function assertValid(array $graph): void $version = $graph['schema'] ?? null; $v1 = 'generic/computed-form-presentation/v1' === $version; $v2 = 'generic/computed-form-presentation/v2' === $version; - $expectedKeys = $v2 ? array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'visual_parts', 'visual_groups', 'variants', 'diagnostics' ) : array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'variants', 'diagnostics' ); - if ( (! $v1 && ! $v2) || 'source_css_cascade' !== ($graph['basis'] ?? null) || ! is_bool($graph['truncated'] ?? null) || ! is_array($graph['limits'] ?? null) || array_diff(array_keys($graph['limits']), array( 'controls', 'rules_per_role' )) || self::MAX_CONTROLS !== ($graph['limits']['controls'] ?? null) || self::MAX_RULES_PER_ROLE !== ($graph['limits']['rules_per_role'] ?? null) || ! is_array($graph['controls'] ?? null) || ! array_is_list($graph['controls']) || count($graph['controls']) > self::MAX_CONTROLS || ($v2 && (! is_array($graph['visual_parts'] ?? null) || ! array_is_list($graph['visual_parts']) || count($graph['visual_parts']) > self::MAX_VISUAL_PARTS || ! is_array($graph['visual_groups'] ?? null) || ! array_is_list($graph['visual_groups']) || count($graph['visual_groups']) > self::MAX_VISUAL_PARTS)) || ! is_array($graph['variants'] ?? null) || ! array_is_list($graph['variants']) || count($graph['variants']) > self::MAX_VARIANTS || ! is_array($graph['diagnostics'] ?? null) || ! array_is_list($graph['diagnostics']) || count($graph['diagnostics']) > self::MAX_DIAGNOSTICS || array_filter($graph['diagnostics'], static fn (mixed $diagnostic): bool => ! is_string($diagnostic) || '' === trim($diagnostic) || strlen($diagnostic) > 1100) || array_diff(array_keys($graph), $expectedKeys) ) { + $expectedKeys = $v2 ? array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'visual_parts', 'visual_groups', 'control_containers', 'variants', 'diagnostics' ) : array( 'schema', 'basis', 'truncated', 'limits', 'controls', 'variants', 'diagnostics' ); + if ( (! $v1 && ! $v2) || 'source_css_cascade' !== ($graph['basis'] ?? null) || ! is_bool($graph['truncated'] ?? null) || ! is_array($graph['limits'] ?? null) || array_diff(array_keys($graph['limits']), array( 'controls', 'rules_per_role' )) || self::MAX_CONTROLS !== ($graph['limits']['controls'] ?? null) || self::MAX_RULES_PER_ROLE !== ($graph['limits']['rules_per_role'] ?? null) || ! is_array($graph['controls'] ?? null) || ! array_is_list($graph['controls']) || count($graph['controls']) > self::MAX_CONTROLS || ($v2 && (! is_array($graph['visual_parts'] ?? null) || ! array_is_list($graph['visual_parts']) || count($graph['visual_parts']) > self::MAX_VISUAL_PARTS || ! is_array($graph['visual_groups'] ?? null) || ! array_is_list($graph['visual_groups']) || count($graph['visual_groups']) > self::MAX_VISUAL_PARTS || ! is_array($graph['control_containers'] ?? null) || ! array_is_list($graph['control_containers']) || count($graph['control_containers']) > self::MAX_CONTROLS)) || ! is_array($graph['variants'] ?? null) || ! array_is_list($graph['variants']) || count($graph['variants']) > self::MAX_VARIANTS || ! is_array($graph['diagnostics'] ?? null) || ! array_is_list($graph['diagnostics']) || count($graph['diagnostics']) > self::MAX_DIAGNOSTICS || array_filter($graph['diagnostics'], static fn (mixed $diagnostic): bool => ! is_string($diagnostic) || '' === trim($diagnostic) || strlen($diagnostic) > 1100) || array_diff(array_keys($graph), $expectedKeys) ) { throw new InvalidArgumentException('Form presentation graph envelope is invalid.'); } + $containerIndexes = array(); + foreach ( $v2 ? $graph['control_containers'] : array() as $container ) { + if ( ! is_array($container) || array_diff(array_keys($container), array( 'index', 'source_selector', 'styles', 'provenance' )) || ! is_int($container['index'] ?? null) || $container['index'] < 0 || $container['index'] >= self::MAX_CONTROLS || isset($containerIndexes[$container['index']]) || ! is_string($container['source_selector'] ?? null) || '' === trim($container['source_selector']) || strlen($container['source_selector']) > 2048 || ! is_array($container['styles'] ?? null) || array_diff(array_keys($container['styles']), array_map(self::key(...), self::CONTROL_CONTAINER_PROPERTIES)) || ! is_array($container['provenance'] ?? null) ) throw new InvalidArgumentException('Form presentation control container is invalid.'); + self::assertStyles($container['styles']); self::assertProvenance($container['provenance'], $container['styles'], null); + $containerIndexes[$container['index']] = array() !== $container['styles']; + } $seen = array(); foreach ( $graph['controls'] as $row ) { if ( ! is_array($row) || array_diff(array_keys($row), $v2 ? array( 'index', 'control', 'label', 'required_marker' ) : array( 'index', 'control', 'label' )) || ! is_int($row['index'] ?? null) || $row['index'] < 0 || $row['index'] >= self::MAX_CONTROLS || isset($seen[$row['index']]) || (! isset($row['control']) && ! isset($row['label']) && ! isset($row['required_marker'])) ) { @@ -207,18 +232,21 @@ public static function assertValid(array $graph): void if ( isset($groupIds[$group['id']]) ) throw new InvalidArgumentException('Form presentation visual group identity is duplicated.'); $groupIds[$group['id']] = true; } + $containerVariants = array(); foreach ( $graph['variants'] as $variant ) { $isVisualPart = 'visual_part' === ($variant['role'] ?? null); $isVisualGroup = 'visual_group' === ($variant['role'] ?? null); $keys = $isVisualPart ? array( 'index', 'role', 'part_id', 'condition', 'style_patch', 'precedence', 'provenance' ) : ($isVisualGroup ? array( 'role', 'group_id', 'condition', 'style_patch', 'precedence', 'provenance' ) : array( 'index', 'role', 'condition', 'style_patch', 'precedence', 'provenance' )); - if ( ! is_array($variant) || array_diff(array_keys($variant), $keys) || (! $isVisualGroup && (! is_int($variant['index'] ?? null) || $variant['index'] < 0 || $variant['index'] >= self::MAX_CONTROLS)) || ! in_array($variant['role'] ?? null, $v2 ? array( 'control', 'label', 'required_marker', 'visual_part', 'visual_group' ) : array( 'control', 'label' ), true) || ($isVisualPart ? (! is_string($variant['part_id'] ?? null) || ! isset($partIds[$variant['part_id']]) || $variant['index'] !== $partIds[$variant['part_id']]) : ($isVisualGroup ? (! is_string($variant['group_id'] ?? null) || ! isset($groupIds[$variant['group_id']])) : (isset($variant['part_id']) || isset($variant['group_id'])))) || ! is_array($variant['condition'] ?? null) || ! self::validCondition($variant['condition']) || ! is_array($variant['style_patch'] ?? null) || array() === $variant['style_patch'] || ! is_array($variant['precedence'] ?? null) || ! is_array($variant['provenance'] ?? null) ) { + if ( ! is_array($variant) || array_diff(array_keys($variant), $keys) || (! $isVisualGroup && (! is_int($variant['index'] ?? null) || $variant['index'] < 0 || $variant['index'] >= self::MAX_CONTROLS)) || ! in_array($variant['role'] ?? null, $v2 ? array( 'control', 'label', 'required_marker', 'control_container', 'visual_part', 'visual_group' ) : array( 'control', 'label' ), true) || ($isVisualPart ? (! is_string($variant['part_id'] ?? null) || ! isset($partIds[$variant['part_id']]) || $variant['index'] !== $partIds[$variant['part_id']]) : ($isVisualGroup ? (! is_string($variant['group_id'] ?? null) || ! isset($groupIds[$variant['group_id']])) : (isset($variant['part_id']) || isset($variant['group_id'])))) || ! is_array($variant['condition'] ?? null) || ! self::validCondition($variant['condition']) || ! is_array($variant['style_patch'] ?? null) || array() === $variant['style_patch'] || ! is_array($variant['precedence'] ?? null) || ! is_array($variant['provenance'] ?? null) ) { throw new InvalidArgumentException('Form presentation variant is invalid.'); } self::assertStyles($variant['style_patch']); + if ( 'control_container' === $variant['role'] ) $containerVariants[$variant['index']] = true; foreach ( $variant['precedence'] as $property => $precedence ) { if ( ! in_array($property, self::PROPERTIES, true) || ! isset($variant['style_patch'][self::key($property)]) || ! is_array($precedence) || ! is_int($precedence['source_order'] ?? null) || ! is_int($precedence['specificity'] ?? null) || ! is_bool($precedence['important'] ?? null) ) throw new InvalidArgumentException('Form presentation precedence is invalid.'); } self::assertProvenance($variant['provenance'], $variant['style_patch'], $variant['condition']); } + foreach ( $containerIndexes as $index => $hasBaseStyles ) if (! $hasBaseStyles && ! isset($containerVariants[$index])) throw new InvalidArgumentException('Form presentation conditional control container has no variants.'); } private static function assertRole(mixed $role, ?array $condition, bool $allowEmpty = false): void @@ -228,6 +256,41 @@ private static function assertRole(mixed $role, ?array $condition, bool $allowEm self::assertProvenance($role['provenance'], $role['styles'], $condition); } + /** @param list $ancestors @return array{container:array,variants:list}|null */ + private function controlContainer(int $index, array $ancestors, array $rules, array $customPropertyRules): ?array + { + $painted = array(); + $properties = array_flip(self::CONTROL_CONTAINER_PROPERTIES); + foreach ( $ancestors as $ancestor ) { + $matched = $this->matched($ancestor, $rules); + $facts = array_intersect_key($matched['base'], $properties); + $styles = $this->styles($facts, $ancestor, null, $customPropertyRules); + $variants = array(); + foreach ( $this->effectiveConditional($matched['conditional'], $matched['base']) as $encoded => $conditionalFacts ) { + $condition = json_decode($encoded, true); $conditionalFacts = array_intersect_key($conditionalFacts, $properties); + $patch = $this->styles($conditionalFacts, $ancestor, $condition, $customPropertyRules); + if ($this->hasContainerPaint($patch)) $variants[] = array( 'index' => $index, 'role' => 'control_container', 'condition' => $condition, 'style_patch' => $patch, 'precedence' => $this->precedence($conditionalFacts), 'provenance' => $this->provenance($conditionalFacts, $condition) ); + } + if ($this->hasContainerPaint($styles) || array() !== $variants) $painted[] = array( 'element' => $ancestor, 'facts' => $this->hasContainerPaint($styles) ? $facts : array(), 'matched' => $matched, 'styles' => $this->hasContainerPaint($styles) ? $styles : array(), 'variants' => $variants ); + } + if ( count($painted) > 1 ) { $this->diagnostics[] = 'control_container_multiple_painted_wrappers'; return null; } + if ( array() === $painted ) return null; + $paint = $painted[0]; + $container = array( 'index' => $index, 'source_selector' => SourceDom::elementSelector($paint['element']), 'styles' => $paint['styles'], 'provenance' => $this->provenance($paint['facts'], null) ); + return array( 'container' => $container, 'variants' => $paint['variants'] ); + } + + /** A container paint requires a visible fill or border, not radius or neutral resets alone. */ + private function hasContainerPaint(array $styles): bool + { + $classifier = new SourceElementClassifier(); + foreach ( array('background', 'background_color') as $property ) if (isset($styles[$property]) && $classifier->isVisibleEmptyVisualPaint($styles[$property])) return true; + if (isset($styles['border']) && $classifier->isVisibleEmptyVisualBorder($styles['border'])) return true; + foreach ( array('border_width', 'border_top_width', 'border_right_width', 'border_bottom_width', 'border_left_width') as $property ) if (isset($styles[$property]) && ! $classifier->isPositiveCssLength($styles[$property])) return false; + foreach ( array('border_color', 'border_top_color', 'border_right_color', 'border_bottom_color', 'border_left_color') as $property ) if (isset($styles[$property]) && $classifier->isVisibleEmptyVisualPaint($styles[$property])) return true; + return false; + } + /** A visual part describes source identity and facts, never an inferred semantic role. */ private static function assertVisualPart(mixed $part): void { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 09c17b0d8..b6de53025 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1365,6 +1365,15 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $assert('generic/computed-form-presentation/v2' === ($requiredMarkerGraph['schema'] ?? null) && '14px' === ($requiredMarkerRow['styles']['font_size'] ?? null) && '19.6px' === ($requiredMarkerRow['styles']['line_height'] ?? null) && '.contact-label .marker' === ($requiredMarkerRow['provenance'][0]['selector'] ?? null) && '12px' === ($requiredMarkerVariant['style_patch']['font_size'] ?? null) && '2px' === ($requiredMarkerVariant['style_patch']['margin_left'] ?? null), 'form presentation reuses the explicit required-marker identity, its ancestor custom property scope, and conditional source CSS facts'); $inheritedRequiredMarkerGraph = (new HtmlTransformer())->transform('
', array('static_css' => '.contact-label{font-size:14px;line-height:19.6px}'))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); $assert('generic/computed-form-presentation/v2' === ($inheritedRequiredMarkerGraph['schema'] ?? null) && array() === ($inheritedRequiredMarkerGraph['controls'][0]['required_marker']['styles'] ?? null) && array() === ($inheritedRequiredMarkerGraph['controls'][0]['required_marker']['provenance'] ?? null), 'form presentation safely retains an explicit inherited required marker without inventing CSS provenance'); +$containerPresentationGraph = (new HtmlTransformer())->transform('
', array('static_css' => 'input{border:0}.field{border:1px solid rgba(30,75,110,.6);background:rgb(247,249,251);border-radius:0}.shared{border:2px solid #111}.plain{display:block}'))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); +$containerRows = array_column($containerPresentationGraph['control_containers'] ?? array(), null, 'index'); +$assert('generic/computed-form-presentation/v2' === ($containerPresentationGraph['schema'] ?? null) && '1px solid rgba(30,75,110,.6)' === ($containerRows[0]['styles']['border'] ?? null) && 'rgb(247,249,251)' === ($containerRows[0]['styles']['background'] ?? null) && '0' === ($containerRows[0]['styles']['border_radius'] ?? null) && '.field' === ($containerRows[0]['provenance'][0]['selector'] ?? null) && !isset($containerRows[1]) && !isset($containerRows[2]) && !isset($containerRows[3]), 'form presentation retains only an exclusively owned painted control container, preserving its source CSS provenance without assigning shared or borderless wrappers'); +$nestedContainerGraph = (new HtmlTransformer())->transform('
', array('static_css' => '.outer{border:1px solid #111}.inner{background:#fff}'))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); +$assert(array() === ($nestedContainerGraph['control_containers'] ?? array()) && in_array('control_container_multiple_painted_wrappers', $nestedContainerGraph['diagnostics'] ?? array(), true), 'form presentation reports nested painted control containers rather than collapsing multiple source chrome layers onto one provider target'); +$conditionalContainerGraph = (new HtmlTransformer())->transform('
', array('static_css' => 'div{border:0;background:0 0}@media (max-width:48rem){.field{border:1px solid #123;background:#fff}}'))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); +$conditionalContainer = $conditionalContainerGraph['control_containers'][0] ?? array(); $conditionalContainerVariant = current(array_filter($conditionalContainerGraph['variants'] ?? array(), static fn(array $variant): bool => 'control_container' === ($variant['role'] ?? null))); +$assert(array() === ($conditionalContainer['styles'] ?? null) && str_ends_with((string) ($conditionalContainer['source_selector'] ?? ''), 'div:nth-of-type(1)') && '1px solid #123' === ($conditionalContainerVariant['style_patch']['border'] ?? null) && '#fff' === ($conditionalContainerVariant['style_patch']['background'] ?? null) && 'media' === ($conditionalContainerVariant['condition']['kind'] ?? null), 'form presentation preserves an exclusively owned condition-only painted container without treating neutral base resets as paint'); +$invalidConditionalContainerGraph = $conditionalContainerGraph; $invalidConditionalContainerGraph['variants'] = array(); try { \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormPresentationGraphBuilder::assertValid($invalidConditionalContainerGraph); $assert(false, 'form presentation validation rejects an empty-base control container without same-index variants'); } catch (\InvalidArgumentException) { $assert(true, 'form presentation validation rejects an empty-base control container without same-index variants'); } $visualPresentationHtml = '
'; $visualPresentationCss = '.visual-group{display:flex;flex-direction:row;align-items:center;justify-content:space-between;gap:8px;position:relative}.globe{width:24px;height:24px;flex:none}.chevron{width:16px;height:16px;position:absolute;right:8px}@media (max-width:50rem){.visual-group{gap:4px}.chevron{width:12px;right:4px}}'; $visualPresentationGraph = (new HtmlTransformer())->transform($visualPresentationHtml, array('static_css' => $visualPresentationCss))->toArray()['fallbacks'][0]['presentation_graph'] ?? array(); From 574a2497d6154e54b30559521d8ee200e2624999 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 10 Sep 2026 14:46:08 -0400 Subject: [PATCH 6/8] fix(editor): preserve empty group grid participation --- .../src/HtmlToBlocks/HtmlCompilation.php | 4 +- ...tifact-inline-style-extraction-base64.json | 2 +- .../artifact-inline-style-extraction.json | 2 +- .../tools/visual-parity/package.json | 2 +- .../tests/empty-group-editor-height.mjs | 38 +++++++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 php-transformer/tools/visual-parity/tests/empty-group-editor-height.mjs diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 04a0e3a06..b544874a2 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -1919,7 +1919,9 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo // core Group and its children. Keep authored grid/flex children as // direct layout items, matching the saved frontend markup. $beforeAuthorCssParts[] = ':root :where(.' . self::CSS_OWNED_LAYOUT_CLASS . ')>.block-editor-inner-blocks,' - . ':root :where(.' . self::CSS_OWNED_LAYOUT_CLASS . ')>.block-editor-inner-blocks>.block-editor-block-list__layout{display:contents}'; + . ':root :where(.' . self::CSS_OWNED_LAYOUT_CLASS . ')>.block-editor-inner-blocks>.block-editor-block-list__layout{display:contents}' + // Empty Group placeholders have an additional unadorned editor wrapper. + . ':root .editor-styles-wrapper :where(.' . self::CSS_OWNED_LAYOUT_CLASS . ')>div:not([class]):not([id]):not([style]):has(>[data-block].wp-block-group__placeholder){display:contents}'; } $layoutShellBlockName = $this->generatedBlocks()->blockName('layout-shell'); if ( str_contains($serializedBlocks, '