diff --git a/php-transformer/docs/form-presentation-graph.md b/php-transformer/docs/form-presentation-graph.md new file mode 100644 index 000000000..3feefb1fe --- /dev/null +++ b/php-transformer/docs/form-presentation-graph.md @@ -0,0 +1,25 @@ +# 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. + +`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/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 74476bfbe..50106bde0 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -2666,6 +2666,11 @@ private function stylesheetAssetsForSource(string $html, string $sourcePath, arr if ( is_array($file) && ! isset($seenPaths[$file['path']]) ) { $assets[] = array( 'path' => $file['path'], 'source_path' => $file['source_path'] ?? $file['path'], 'content' => $file['content'], 'source_hash' => (string) ($file['provenance']['hash'] ?? hash('sha256', $file['content']) ), 'media' => (string) ($file['media'] ?? ''), 'type' => (string) ($file['type'] ?? '') ); $seenPaths[$file['path']] = true; + } elseif ( '' !== ($content = trim(html_entity_decode($tagRecord['content'], ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ) { + // Generated inline-style files can be omitted at the artifact + // file limit. Their source HTML was accepted independently, + // so retain the authored stylesheet for source analysis. + $assets[] = array( 'path' => 'inline-style-' . $inlineIndex . '.css', 'source_path' => 'inline-style', 'content' => $content, 'source_hash' => hash('sha256', $content), 'media' => $this->htmlAttribute($attributes, 'media'), 'type' => $this->htmlAttribute($attributes, 'type') ); } continue; } diff --git a/php-transformer/src/Css/CssAnalysisLimits.php b/php-transformer/src/Css/CssAnalysisLimits.php new file mode 100644 index 000000000..bdafe7c35 --- /dev/null +++ b/php-transformer/src/Css/CssAnalysisLimits.php @@ -0,0 +1,11 @@ +nextRuleBoundary($css, $offset); if ( null === $boundary ) { - if ( '' !== trim(substr($css, $offset)) ) { + if ( $this->hasNonTrivia($css, $offset) ) { $result['diagnostics'][] = 'malformed_stylesheet:' . $path; } return; @@ -249,6 +249,29 @@ private function nextRuleBoundary(string $css, int $offset): ?int return null; } + /** + * A stylesheet may legally end with whitespace and comments, including a + * source-map comment. Preserve malformed-input diagnostics for every other + * incomplete trailing token. + */ + private function hasNonTrivia(string $css, int $offset): bool + { + $state = CssSyntaxScanner::state(); + for ($length = strlen($css); $offset < $length; ) { + $insideComment = $state['comment']; + $startsComment = ! $insideComment && '' === $state['quote'] && '/*' === substr($css, $offset, 2); + $next = CssSyntaxScanner::consume($css, $offset, $state); + if ( null === $next ) { + return true; + } + if (! $insideComment && ! $startsComment && ! CssSyntaxScanner::isCssWhitespace($css[$offset])) { + return true; + } + $offset = $next; + } + return ! CssSyntaxScanner::isComplete($state); + } + /** * CSS comments disappear from selectors, but adjacent identifier-like tokens * need a separator to avoid changing a descendant selector into one token. 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/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 f82791ae4..6e38fc8dd 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FormFallbackFindingBuilder.php @@ -31,7 +31,9 @@ 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), + 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/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 f5c2a7bf5..d732b76a7 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -617,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, @@ -1928,7 +1929,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, '