diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index c010322d..5ded01ad 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -8,6 +8,8 @@ use Automattic\BlocksEngine\PhpTransformer\Contract\TransformerResult; use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatBridge; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssStylesheetTransformer; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\ShellLandmarkPolicy; use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath; use Automattic\BlocksEngine\PhpTransformer\StaticSite\MaterializationPlanBuilder; @@ -282,6 +284,8 @@ private function runtimeDeclarationsFromFallbacks(array $declarations, array $fa } elseif ( 'html_form_fallback' === $code && is_array($fallback['controls'] ?? null) ) { $selector = is_string($fallback['selector'] ?? null) ? $fallback['selector'] : ''; $form = array('selector' => $selector, 'source_path' => $sourcePath, 'form' => is_array($fallback['form'] ?? null) ? $fallback['form'] : array(), 'controls' => array_values(array_filter($fallback['controls'], 'is_array'))); + if ( is_array($fallback['control_topology'] ?? null) ) $form['control_topology'] = $fallback['control_topology']; + if ( is_array($fallback['layout_graph'] ?? null) ) { FormLayoutGraphBuilder::assertValid($fallback['layout_graph']); $form['layout_graph'] = $fallback['layout_graph']; } if ( is_array($fallback['binding'] ?? null) && 'generic/block-binding/v1' === ($fallback['binding']['schema'] ?? null) && is_string($fallback['binding']['search_block_markup'] ?? null) && '' !== trim($fallback['binding']['search_block_markup']) ) { $form['bindings'] = array(array_merge($fallback['binding'], array('source_path' => $sourcePath))); } @@ -875,7 +879,7 @@ private function stylesheetAssetsForSource(string $html, string $sourcePath, arr ++$inlineIndex; $file = $inline[$inlineIndex] ?? null; if ( is_array($file) && ! isset($seenPaths[$file['path']]) ) { - $assets[] = array( 'path' => $file['path'], 'content' => $file['content'], 'source_hash' => (string) ($file['provenance']['hash'] ?? hash('sha256', $file['content']) ), 'media' => (string) ($file['media'] ?? ''), 'type' => (string) ($file['type'] ?? '') ); + $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; } continue; @@ -888,7 +892,7 @@ private function stylesheetAssetsForSource(string $html, string $sourcePath, arr $path = $occurrencePaths[$sourcePathForLink][$linkOccurrences[$sourcePathForLink]] ?? ''; $file = $byPath[$path] ?? null; if ( is_array($file) && ! isset($seenPaths[$path]) ) { - $assets[] = array( 'path' => $path, 'content' => $file['content'], 'source_hash' => (string) ($file['provenance']['hash'] ?? hash('sha256', $file['content']) ), 'media' => $this->htmlAttribute((string) $tag, 'media'), 'type' => $this->htmlAttribute((string) $tag, 'type') ); + $assets[] = array( 'path' => $path, 'source_path' => $file['stylesheet_source_path'] ?? $sourcePathForLink, 'content' => $file['content'], 'source_hash' => (string) ($file['provenance']['hash'] ?? hash('sha256', $file['content']) ), 'media' => $this->htmlAttribute((string) $tag, 'media'), 'type' => $this->htmlAttribute((string) $tag, 'type') ); $seenPaths[$path] = true; } } @@ -1011,7 +1015,18 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio if ( array() === $authoritativeContent ) { $authoritativeContent[] = (string) ($file['content'] ?? ''); } - $content = implode("\n", array_merge(array_keys($pathProjections), $authoritativeContent)); + $preambles = array(); + $stylesheets = array(); + foreach ( $authoritativeContent as $stylesheet ) { + $split = ( new CssStylesheetTransformer() )->splitLeadingAtRulePreamble($stylesheet); + if ( '' !== trim($split['preamble']) ) { + $preambles[] = $split['preamble']; + } + if ( '' !== trim($split['stylesheet']) ) { + $stylesheets[] = $split['stylesheet']; + } + } + $content = implode("\n", array_merge($preambles, array_keys($pathProjections), $stylesheets)); $file['content'] = $content; // Projection rewrites the CSS text, so any base64 twin from the // source payload is stale. Drop it and let the rewritten text be the diff --git a/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php b/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php index 59f0ab77..b44566ee 100644 --- a/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php +++ b/php-transformer/src/ArtifactCompiler/RuntimeDeclarations.php @@ -4,6 +4,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler; use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder; use InvalidArgumentException; use JsonException; @@ -65,6 +66,7 @@ public static function normalizeList(mixed $raw): array try { $encoded = self::canonicalJson($payload); } catch (InvalidArgumentException) { throw new InvalidArgumentException("Runtime declaration {$index} payload is not serializable."); } if (strlen($encoded) > self::MAX_PAYLOAD_BYTES) throw new InvalidArgumentException("Runtime declaration {$index} payload exceeds the byte limit."); $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 && (!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/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 6ac5da41..9181f05c 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -31,6 +31,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssSelectorMatcher; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssStylesheetTransformer; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\BackgroundImageExtractor; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\ButtonLinkDispatchTrait; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\DomHelpersTrait; @@ -53,6 +54,19 @@ final class HtmlTransformer private const MAX_INTERACTION_CANDIDATES = 100; + private const MAX_FORM_TOPOLOGY_DEPTH = 8; + + private const MAX_FORM_TOPOLOGY_NODES = 128; + + private const MAX_FORM_TOPOLOGY_CLASSES = 8; + + /** @var array */ + private const FORM_TOPOLOGY_WRAPPER_TAGS = array( + 'article', 'aside', 'dd', 'div', 'dl', 'dt', 'fieldset', 'footer', 'header', + 'label', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'table', 'tbody', + 'td', 'tfoot', 'th', 'thead', 'tr', 'ul', + ); + /** * Tag-only script selectors that must keep their native DOM shape when a * first-party runtime binds directly to them. @@ -382,6 +396,8 @@ final class HtmlTransformer /** @var list */ private array $authorStylesheetAssets = array(); + private string $formLayoutCss = ''; + /** A collision-checked custom element used solely to retain type specificity. */ private string $authorSpecificityShim = ''; @@ -464,6 +480,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->authorMarkerCounter = 0; $this->authorMarkerCollisionText = ''; $this->authorStylesheetAssets = array(); + $this->formLayoutCss = ''; $this->authorSpecificityShim = ''; $this->authorClassSpecificityShim = ''; $this->authorIdSpecificityShim = ''; @@ -749,6 +766,15 @@ private function metrics(string $input, array $blocks, string $output, array $fa private function materializeAuthorStylesheet(string $html, string $staticCss, bool $includeAuthorStyles = true, string $serializedBlocks = ''): void { $cssParts = array(); + $authorCss = ''; + if ( $includeAuthorStyles && '' !== $this->combinedAuthorCss ) { + $authorCss = $this->rewriteAuthorStylesheet($this->combinedAuthorCss); + $split = ( new CssStylesheetTransformer() )->splitLeadingAtRulePreamble($authorCss); + if ( '' !== trim($split['preamble']) ) { + $cssParts[] = $split['preamble']; + } + $authorCss = $split['stylesheet']; + } $geometryCss = $this->generatedGeometryCss($serializedBlocks); if ( '' !== $geometryCss ) { // Important carrier rules precede author CSS: they retain inline @@ -764,8 +790,8 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo $cssParts[] = '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item.wp-block-navigation-link{display:list-item;font:inherit}' . "\n" . '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item__content{display:inline}'; } - if ( $includeAuthorStyles && '' !== $this->combinedAuthorCss ) { - $cssParts[] = $this->rewriteAuthorStylesheet($this->combinedAuthorCss); + if ( '' !== trim($authorCss) ) { + $cssParts[] = $authorCss; } $css = trim(implode("\n\n", $cssParts)); @@ -810,6 +836,7 @@ private function prepareAuthorSelectorSemantics(string $html, string $staticCss, $this->combinedAuthorCss = array() === $this->authorStylesheetAssets ? $this->combinedAuthorStylesheet($html, $staticCss) : implode("\n\n", array_column($this->authorStylesheetAssets, 'content')); + $this->formLayoutCss = $this->combinedAuthorCss; // Ignore already-generated-looking markers when seeding so collision // avoidance remains deterministic even when source CSS contains one. $seedInput = preg_replace('/blocks-engine-(?:source-p|control|specificity(?:-(?:class|id))?)-[a-f0-9]+-\d+/', '', $html . "\0" . $this->combinedAuthorCss) ?? ''; @@ -954,7 +981,7 @@ private function combinedAuthorStylesheet(string $html, string $staticCss): stri return trim(implode("\n\n", $cssParts)); } - /** @param array $options @return list */ + /** @param array $options @return list */ private function authorStylesheetAssetsFromOptions(array $options): array { if ( ! is_array($options['author_stylesheet_assets'] ?? null) ) { @@ -965,7 +992,7 @@ private function authorStylesheetAssetsFromOptions(array $options): array if ( ! is_array($asset) || ! is_string($asset['path'] ?? null) || '' === $asset['path'] || ! is_string($asset['content'] ?? null) ) { continue; } - $assets[] = array( 'path' => $asset['path'], 'content' => $asset['content'], 'source_hash' => is_string($asset['source_hash'] ?? null) ? $asset['source_hash'] : hash('sha256', $asset['content']) ); + $assets[] = array( 'path' => $asset['path'], 'source_path' => is_string($asset['source_path'] ?? null) ? $asset['source_path'] : $asset['path'], 'content' => $asset['content'], 'source_hash' => is_string($asset['source_hash'] ?? null) ? $asset['source_hash'] : hash('sha256', $asset['content']), 'media' => is_string($asset['media'] ?? null) ? $asset['media'] : '' ); } return $assets; } @@ -978,7 +1005,8 @@ private function authorStylesheetProjections(): array foreach ( $this->authorStylesheetAssets as $asset ) { $content = $this->rewriteAuthorStylesheet($asset['content']); if ( '' !== $markerReset ) { - $content = $markerReset . "\n" . $content; + $split = ( new CssStylesheetTransformer() )->splitLeadingAtRulePreamble($content); + $content = $split['preamble'] . $markerReset . "\n" . $split['stylesheet']; $markerReset = ''; } $hash = hash('sha256', $content); @@ -2222,6 +2250,11 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( null !== $navigation ) { return $this->rememberAccordionDisclosureRoot($navigation, $element); } + + $inlineNavigation = $this->inlineNavigationGroupBlockFromElement($element); + if ( null !== $inlineNavigation ) { + return $inlineNavigation; + } } if ( ShellLandmarkPolicy::isFlowContainerTag($tagName) ) { @@ -2302,7 +2335,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca fn (DOMElement $sourceElement, array &$sourceFallbacks, bool $captureUnsupported): array => $this->convertChildren($sourceElement, $sourceFallbacks, $captureUnsupported), fn (DOMElement $sourceElement, array &$sourceFallbacks, bool $captureUnsupported): ?array => $this->convertElement($sourceElement, $sourceFallbacks, $captureUnsupported), fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement), - fn (DOMElement $sourceElement): string => $this->mergedPresentationStyle($sourceElement), + fn (DOMElement $sourceElement): string => $this->cssDeclarationString($this->structuralPresentationDeclarations($sourceElement)), fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) ); if ( null !== $columns ) { @@ -3102,7 +3135,22 @@ private function richTextInlineVisualDeclarations(DOMElement $element): array $declarations['color'] = 'transparent'; } - return array_intersect_key($declarations, $allowed); + $declarations = array_intersect_key($declarations, $allowed); + if ( in_array(strtolower($element->tagName), array( 'em', 'i' ), true) ) { + if ( 'italic' === strtolower((string) ($declarations['font-style'] ?? '')) ) { + unset($declarations['font-style']); + } + if ( 'inherit' === strtolower((string) ($declarations['font-weight'] ?? '')) ) { + unset($declarations['font-weight']); + } + foreach ( array( 'margin', 'margin-bottom', 'margin-left', 'margin-right', 'margin-top', 'padding', 'padding-bottom', 'padding-left', 'padding-right', 'padding-top' ) as $property ) { + if ( isset($declarations[$property]) && ! $this->cssValueIsNonZero($declarations[$property]) ) { + unset($declarations[$property]); + } + } + } + + return $declarations; } private function replaceRichTextStylingHookWithMark(DOMElement $element): bool @@ -3961,6 +4009,25 @@ private function paragraphBlockFromInlineContentWrapper(DOMElement $element): ?a return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $content )), array(), $element); } + private function inlineNavigationGroupBlockFromElement(DOMElement $element): ?array + { + if ( ! $this->hasOnlyPhrasingChildren($element) ) { + return null; + } + + $content = $this->richTextContentWithMaterializedInlineStyles($element); + $inlineSvgContent = $this->richTextContentWithMaterializedSvgImages($element, $content); + if ( null !== $inlineSvgContent ) { + $content = $inlineSvgContent; + } + if ( '' === trim($this->runtime->stripAllTags($content)) || $this->richTextRequiresHtmlFallbackWithoutNativeSvgImageObjects($content) ) { + return null; + } + + $paragraph = $this->createBlock('core/paragraph', array( 'content' => $content )); + return $this->createBlock('core/group', $this->presentationAttributes($element), array( $paragraph ), $element); + } + private function hasAuthorSemanticMarkedChild(DOMElement $element): bool { foreach ( $element->childNodes as $child ) { @@ -6663,6 +6730,106 @@ private function formControls(DOMElement $form): array return $controls; } + /** + * Preserve only control-bearing wrapper ancestry. The node table is bounded, + * source ordered, and references the compatibility controls by flat index. + * + * @return array + */ + private function formControlTopology(DOMElement $form): array + { + $controlIndexes = array(); + $relevantElements = array(); + foreach ( $this->formControlElements($form) as $index => $control ) { + $controlIndexes[$control->getNodePath()] = $index; + for ( $ancestor = $control->parentNode; $ancestor instanceof DOMElement && $ancestor !== $form; $ancestor = $ancestor->parentNode ) { + $relevantElements[$ancestor->getNodePath()] = true; + } + } + + $nodes = array(); + $wrapperIndex = 0; + $truncated = false; + $order = 0; + foreach ( $form->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) continue; + if ( $this->appendFormTopologyNode($child, null, $order, 0, $controlIndexes, $relevantElements, $nodes, $wrapperIndex, $truncated) ) ++$order; + } + + return array( + 'schema' => 'generic/form-control-topology/v1', + 'max_depth' => self::MAX_FORM_TOPOLOGY_DEPTH, + 'max_nodes' => self::MAX_FORM_TOPOLOGY_NODES, + 'nodes' => $nodes, + 'truncated' => $truncated, + ); + } + + /** + * @param array $controlIndexes + * @param array $relevantElements + * @param array> $nodes + */ + private function appendFormTopologyNode(DOMElement $element, ?string $parent, int $order, int $depth, array $controlIndexes, array $relevantElements, array &$nodes, int &$wrapperIndex, bool &$truncated): bool + { + $nodePath = $element->getNodePath(); + if ( ! isset($controlIndexes[$nodePath]) && ! isset($relevantElements[$nodePath]) ) return false; + if ( $depth > self::MAX_FORM_TOPOLOGY_DEPTH || count($nodes) >= self::MAX_FORM_TOPOLOGY_NODES ) { + $truncated = true; + return false; + } + + if ( isset($controlIndexes[$nodePath]) ) { + $controlIndex = $controlIndexes[$nodePath]; + $nodes[] = array_filter(array( + 'id' => 'control-' . $controlIndex, + 'kind' => 'control', + 'parent' => $parent, + 'order' => $order, + 'depth' => $depth, + 'control' => $controlIndex, + ), static fn (mixed $value): bool => null !== $value); + return true; + } + + $id = 'wrapper-' . $wrapperIndex++; + $nodes[] = array_filter(array_merge(array( + 'id' => $id, + 'kind' => 'wrapper', + 'parent' => $parent, + 'order' => $order, + 'depth' => $depth, + ), $this->formTopologyPresentation($element)), static fn (mixed $value): bool => null !== $value && '' !== $value); + + $childOrder = 0; + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) continue; + if ( $this->appendFormTopologyNode($child, $id, $childOrder, $depth + 1, $controlIndexes, $relevantElements, $nodes, $wrapperIndex, $truncated) ) ++$childOrder; + } + + return true; + } + + /** @return array */ + private function formTopologyPresentation(DOMElement $element): array + { + $tag = strtolower($element->tagName); + $presentation = array(); + if ( in_array($tag, self::FORM_TOPOLOGY_WRAPPER_TAGS, true) ) $presentation['tag'] = $tag; + + $id = trim($this->attr($element, 'id')); + if ( 1 === preg_match('/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/D', $id) ) $presentation['source_id'] = $id; + + $classes = array(); + foreach ( preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array() as $class ) { + if ( count($classes) >= self::MAX_FORM_TOPOLOGY_CLASSES ) break; + if ( 1 === preg_match('/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/D', $class) ) $classes[] = $class; + } + if ( array() !== $classes ) $presentation['class'] = implode(' ', $classes); + + return $presentation; + } + /** * @return array */ @@ -7155,6 +7322,8 @@ private function hasRuntimeClassSignal(DOMElement $element): bool private function formFallbackFinding(DOMElement $element, ?array $readableFormBlock, ?array $bindingBlock = null): array { $controls = $this->formControls($element); + $controlTopology = $this->formControlTopology($element); + $layoutGraph = (new FormLayoutGraphBuilder())->build($element, $this->authorStylesheetAssets, $this->formLayoutCss); $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); $replacesRuntimeIsland = null !== $bindingBlock; $bindingBlock ??= $readableFormBlock; @@ -7179,6 +7348,8 @@ private function formFallbackFinding(DOMElement $element, ?array $readableFormBl 'readable_blocks' => null !== $readableFormBlock ? array( $readableFormBlock ) : array(), 'binding' => $this->blockBinding($bindingMarkup, 'form', $supersededRuntimeSelectors), 'controls' => $controls, + 'control_topology' => $controlTopology, + 'layout_graph' => $layoutGraph, 'control_count' => count($controls), 'text_length' => strlen(trim($element->textContent ?? '')), 'child_count' => $this->childElementCount($element), diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php index f8ecca89..6c0f3ac7 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php @@ -71,6 +71,15 @@ public function match( return null; } + // Explicit grid placement depends on the source parent's track model. + // core/columns replaces that model with flex and cannot preserve it. + foreach ( $elementChildren as $child ) { + $childStyle = strtolower(trim($resolvedStyle($child) . ';' . $this->attr($child, 'style'))); + if ( preg_match('/(?:^|;)\s*grid-(?:column|row|area)\s*:/', $childStyle) ) { + return null; + } + } + $columns = array(); $columnFallbacks = array(); foreach ( $elementChildren as $child ) { diff --git a/php-transformer/src/HtmlToBlocks/Style/CssStylesheetTransformer.php b/php-transformer/src/HtmlToBlocks/Style/CssStylesheetTransformer.php index 6bf50288..8c1a9c02 100644 --- a/php-transformer/src/HtmlToBlocks/Style/CssStylesheetTransformer.php +++ b/php-transformer/src/HtmlToBlocks/Style/CssStylesheetTransformer.php @@ -38,6 +38,32 @@ public function transformStyleRules(string $stylesheet, callable $transformStyle return $this->transformRules($stylesheet, static fn (string $prelude): string => $prelude, $transformStyleRule); } + /** + * @return array{preamble: string, stylesheet: string} + */ + public function splitLeadingAtRulePreamble(string $stylesheet): array + { + $offset = 0; + $length = strlen($stylesheet); + while ( $offset < $length ) { + $boundary = $this->nextRuleBoundary($stylesheet, $offset); + if ( null === $boundary || ';' !== $stylesheet[ $boundary ] ) { + break; + } + + $statement = substr($stylesheet, $offset, $boundary - $offset + 1); + if ( ! in_array(self::atRuleName($statement), array( 'charset', 'import', 'namespace' ), true) ) { + break; + } + $offset = $boundary + 1; + } + + return array( + 'preamble' => substr($stylesheet, 0, $offset), + 'stylesheet' => substr($stylesheet, $offset), + ); + } + /** * Split a selector list only at top-level commas. Null indicates malformed CSS. * diff --git a/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php b/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php new file mode 100644 index 00000000..6bc155f7 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Style/FormLayoutGraphBuilder.php @@ -0,0 +1,83 @@ +> $stylesheets @return array */ + public function build(DOMElement $form, array $stylesheets, string $inlineCss = ''): array + { + $this->diagnostics = array(); $this->truncated = false; $this->selectorCount = 0; + $rules = $this->rules($stylesheets, $inlineCss); + $controls = array(); $relevant = array(); + foreach ($this->controls($form) as $index => $control) { $controls[$control->getNodePath()] = $index; for ($node = $control->parentNode; $node instanceof DOMElement && $node !== $form; $node = $node->parentNode) $relevant[$node->getNodePath()] = true; } + $entries = array(); $wrapper = 0; $this->collect($form, null, 0, 0, $controls, $relevant, $entries, $wrapper); + $nodes = array(); $variants = array(); + foreach ($entries as $entry) { + $matched = $this->matched($entry['element'], $rules); + $layout = $this->layout($matched['base']); + $conditional = $this->effectiveConditional($matched['conditional'], $matched['base']); + if (array() === $layout && array() === $conditional) continue; + $nodes[$entry['id']] = array_filter(array('id' => $entry['id'], 'kind' => $entry['kind'], 'parent' => $entry['parent'], 'order' => $entry['order'], 'source' => $this->source($entry['element']), 'layout' => $layout, 'provenance' => $this->provenance($matched['base'], null)), static fn(mixed $value): bool => null !== $value); + foreach ($conditional as $encoded => $facts) { if (count($variants) >= self::MAX_VARIANTS) { $this->truncated = true; $this->diagnostics[] = 'variant_limit'; break; } $condition = json_decode($encoded, true); $patch = $this->layout($facts); if (array() !== $patch) $variants[] = array('node' => $entry['id'], 'condition' => $condition, 'layout_patch' => $patch, 'precedence' => $this->precedence($facts), 'provenance' => $this->provenance($facts, $condition)); } + } + // Layout-bearing descendants retain their structural ancestry even when the ancestor has no layout declaration. + foreach ($entries as $entry) if (!isset($nodes[$entry['id']])) foreach (array_keys($nodes) as $nodeId) if ($this->hasAncestor($entries, $nodeId, $entry['id'])) $nodes[$entry['id']] = array_filter(array('id' => $entry['id'], 'kind' => $entry['kind'], 'parent' => $entry['parent'], 'order' => $entry['order'], 'source' => $this->source($entry['element']), 'layout' => array(), 'provenance' => array()), static fn(mixed $value): bool => null !== $value); + $graph = array('schema' => 'generic/computed-layout-graph/v1', 'basis' => 'source_css_cascade', 'truncated' => $this->truncated, 'limits' => array('nodes' => self::MAX_NODES, 'depth' => self::MAX_DEPTH, 'rules_per_node' => self::MAX_RULES_PER_NODE), 'nodes' => array_values(array_filter($entries, static fn(array $entry): bool => isset($nodes[$entry['id']]))), 'variants' => $variants, 'diagnostics' => array_values(array_unique($this->diagnostics))); + $graph['nodes'] = array_map(static fn(array $entry): array => $nodes[$entry['id']], $graph['nodes']); + self::assertValid($graph); + return $graph; + } + + /** @param array $graph */ + public static function assertValid(array $graph): void + { + if ('generic/computed-layout-graph/v1' !== ($graph['schema'] ?? null) || 'source_css_cascade' !== ($graph['basis'] ?? null) || !is_bool($graph['truncated'] ?? null) || !is_array($graph['limits'] ?? null) || self::MAX_NODES !== ($graph['limits']['nodes'] ?? null) || self::MAX_DEPTH !== ($graph['limits']['depth'] ?? null) || self::MAX_RULES_PER_NODE !== ($graph['limits']['rules_per_node'] ?? null) || !is_array($graph['nodes'] ?? null) || !is_array($graph['variants'] ?? null) || !is_array($graph['diagnostics'] ?? null)) throw new InvalidArgumentException('Form layout graph envelope is invalid.'); + if (count($graph['nodes']) > self::MAX_NODES) throw new InvalidArgumentException('Form layout graph exceeds its node limit.'); + $ids = array(); foreach ($graph['nodes'] as $node) { if (!is_array($node) || !is_string($node['id'] ?? null) || isset($ids[$node['id']]) || !in_array($node['kind'] ?? null, array('container', 'control'), true) || !is_int($node['order'] ?? null) || !is_array($node['source'] ?? null) || !is_string($node['source']['tag'] ?? null) || !is_array($node['source']['classes'] ?? null) || !is_array($node['layout'] ?? null) || !is_array($node['provenance'] ?? null)) throw new InvalidArgumentException('Form layout graph node is invalid.'); $ids[$node['id']] = true; } + foreach ($graph['nodes'] as $node) { if (null !== ($node['parent'] ?? null) && !isset($ids[$node['parent']])) throw new InvalidArgumentException('Form layout graph has a dangling parent.'); for ($parent = $node['parent'] ?? null, $seen = array($node['id'] => true); null !== $parent; ) { if (isset($seen[$parent])) throw new InvalidArgumentException('Form layout graph has a parent cycle.'); $seen[$parent] = true; foreach ($graph['nodes'] as $candidate) if ($candidate['id'] === $parent) { $parent = $candidate['parent'] ?? null; continue 2; } break; } self::assertFacts($node['layout'], $node['provenance'], null); } + if (count($graph['variants']) > self::MAX_VARIANTS) throw new InvalidArgumentException('Form layout graph exceeds its variant limit.'); foreach ($graph['variants'] as $variant) { if (!is_array($variant) || !isset($ids[$variant['node'] ?? '']) || !is_array($variant['condition'] ?? null) || !self::validCondition($variant['condition']) || !is_array($variant['layout_patch'] ?? null) || !is_array($variant['precedence'] ?? null) || !is_array($variant['provenance'] ?? null)) throw new InvalidArgumentException('Form layout graph variant is invalid.'); foreach ($variant['precedence'] as $property => $precedence) if (!is_string($property) || !in_array($property, self::PROPERTIES, true) || !isset($variant['layout_patch'][self::layoutKey($property)]) || !is_array($precedence) || !is_int($precedence['source_order'] ?? null) || !is_int($precedence['specificity'] ?? null) || !is_bool($precedence['important'] ?? null)) throw new InvalidArgumentException('Form layout graph variant precedence is invalid.'); self::assertFacts($variant['layout_patch'], $variant['provenance'], $variant['condition']); } + } + private static function assertFacts(array $layout, array $provenance, ?array $condition): void { foreach ($layout as $key => $value) if (!is_string($key) || !in_array($key, self::LAYOUT_KEYS, true) || !is_string($value) || '' === trim($value)) throw new InvalidArgumentException('Form layout graph value is invalid.'); if (count($provenance) > self::MAX_PROVENANCE) throw new InvalidArgumentException('Form layout graph provenance exceeds its limit.'); foreach ($provenance as $fact) if (!is_array($fact) || !is_string($fact['source_path'] ?? null) || !preg_match('~^(?!.*(?:^|/)\.\.(?:/|$))[A-Za-z0-9._/-]+$~', $fact['source_path']) || !preg_match('/^[a-f0-9]{64}$/', $fact['source_sha256'] ?? '') || !is_string($fact['selector'] ?? null) || '' === trim($fact['selector']) || strlen($fact['selector']) > 1024 || !is_array($fact['properties'] ?? null) || array() === $fact['properties'] || count($fact['properties']) > count(self::PROPERTIES) || array_filter($fact['properties'], static fn(mixed $property): bool => !is_string($property) || !in_array($property, self::PROPERTIES, true) || !isset($layout[self::layoutKey($property)])) || ($condition !== null && $fact['condition'] !== $condition) || ($condition === null && ($fact['condition'] ?? null) !== null)) throw new InvalidArgumentException('Form layout graph provenance is invalid.'); } + private static function validCondition(array $condition, int $depth = 0): bool { if ($depth > self::MAX_CONDITION_DEPTH) return false; if ('all' === ($condition['kind'] ?? null)) return is_array($condition['conditions'] ?? null) && array() !== $condition['conditions'] && count($condition['conditions']) <= self::MAX_CONDITION_DEPTH && array_reduce($condition['conditions'], static fn(bool $ok, mixed $item): bool => $ok && is_array($item) && self::validCondition($item, $depth + 1), true); return in_array($condition['kind'] ?? null, array('media', 'container', 'supports'), true) && is_string($condition['query'] ?? null) && '' !== trim($condition['query']) && strlen($condition['query']) <= 1024; } + + /** @param array $controls @param array $relevant @param list> $entries */ + private function collect(DOMElement $element, ?string $parent, int $order, int $depth, array $controls, array $relevant, array &$entries, int &$wrapper): void { $path = $element->getNodePath(); $root = 'form' === strtolower($element->tagName) && null === $parent; if (!$root && !isset($controls[$path]) && !isset($relevant[$path])) return; if ($depth > self::MAX_DEPTH || count($entries) >= self::MAX_NODES) { $this->truncated = true; $this->diagnostics[] = 'node_or_depth_limit'; return; } $id = $root ? 'form' : (isset($controls[$path]) ? 'control-' . $controls[$path] : 'wrapper-' . $wrapper++); $entries[] = array('id' => $id, 'kind' => isset($controls[$path]) ? 'control' : 'container', 'parent' => $parent, 'order' => $order, 'element' => $element); $childOrder = 0; foreach ($element->childNodes as $child) if ($child instanceof DOMElement) { $before = count($entries); $this->collect($child, $id, $childOrder, $depth + 1, $controls, $relevant, $entries, $wrapper); if (count($entries) > $before) ++$childOrder; } } + private function hasAncestor(array $entries, string $node, string $ancestor): bool { foreach ($entries as $entry) if ($entry['id'] === $node) { for ($parent = $entry['parent']; null !== $parent; ) { if ($parent === $ancestor) return true; foreach ($entries as $candidate) if ($candidate['id'] === $parent) { $parent = $candidate['parent']; continue 2; } return false; } } return false; } + /** @return list */ private function controls(DOMElement $form): array { $result = array(); foreach ($form->getElementsByTagName('*') as $element) if (in_array(strtolower($element->tagName), array('input', 'select', 'textarea', 'button'), true)) $result[] = $element; return $result; } + /** @param list> $sheets @return list> */ private function rules(array $sheets, string $inlineCss): array { $rules = array(); $order = 0; foreach ($sheets as $sheet) $this->parse((string) ($sheet['content'] ?? ''), (string) ($sheet['source_path'] ?? $sheet['path'] ?? ''), (string) ($sheet['source_hash'] ?? ''), $this->linkCondition($sheet), $rules, $order); if (array() === $sheets && '' !== trim($inlineCss)) $this->parse($inlineCss, 'inline-style', hash('sha256', $inlineCss), null, $rules, $order); return $rules; } + private function linkCondition(array $sheet): ?array { $media = trim((string) ($sheet['media'] ?? '')); return '' === $media ? null : array('kind' => 'media', 'query' => $media); } + /** @param list> $rules */ private function parse(string $css, string $path, string $hash, ?array $condition, array &$rules, int &$order, int $conditionDepth = 0): void { if (strlen($css) > self::MAX_CSS_BYTES) { $css = substr($css, 0, self::MAX_CSS_BYTES); $this->truncated = true; $this->diagnostics[] = 'css_bytes_truncated:' . $path; } $css = preg_replace('~/\*.*?\*/~s', '', $css) ?? $css; for ($offset = 0, $length = strlen($css); $offset < $length;) { while ($offset < $length && ctype_space($css[$offset])) ++$offset; $start = $offset; $quote = ''; $paren = 0; while ($offset < $length) { $c = $css[$offset]; if ('' !== $quote) { if ($c === $quote && '\\' !== ($css[$offset - 1] ?? '')) $quote = ''; } elseif ('"' === $c || "'" === $c) $quote = $c; elseif ('(' === $c) ++$paren; elseif (')' === $c) --$paren; elseif ('{' === $c && 0 === $paren) break; ++$offset; } if ($offset >= $length) break; $prelude = trim(substr($css, $start, $offset++ - $start)); $bodyStart = $offset; for ($depth = 1, $quote = ''; $offset < $length && $depth > 0; ++$offset) { $c = $css[$offset]; if ('' !== $quote) { if ($c === $quote && '\\' !== ($css[$offset - 1] ?? '')) $quote = ''; } elseif ('"' === $c || "'" === $c) $quote = $c; elseif ('{' === $c) ++$depth; elseif ('}' === $c) --$depth; } if (0 !== $depth) { $this->diagnostics[] = 'malformed_stylesheet:' . $path; return; } $body = substr($css, $bodyStart, $offset - $bodyStart - 1); if (preg_match('/^@(media|container|supports)\s+(.+)$/i', $prelude, $match)) { if ($conditionDepth >= self::MAX_CONDITION_DEPTH) { $this->truncated = true; $this->diagnostics[] = 'condition_depth_limit'; return; } $next = $this->combineCondition($condition, array('kind' => strtolower($match[1]), 'query' => trim($match[2]))); if (!self::validCondition($next)) { $this->truncated = true; $this->diagnostics[] = 'invalid_condition'; return; } $this->parse($body, $path, $hash, $next, $rules, $order, $conditionDepth + 1); continue; } if (str_starts_with($prelude, '@')) continue; foreach ($this->selectors($prelude) as $selector) { if (count($rules) >= self::MAX_RULES || $this->selectorCount >= self::MAX_SELECTORS) { $this->truncated = true; $this->diagnostics[] = 'css_rule_or_selector_limit'; return; } $rules[] = array('selector' => $selector, 'declarations' => $this->declarations($body), 'condition' => $condition, 'path' => $path, 'hash' => $hash, 'order' => $order++, 'specificity' => $this->specificity($selector)); ++$this->selectorCount; } } } + private function combineCondition(?array $left, array $right): array { if (null === $left) return $right; return array('kind' => 'all', 'conditions' => ('all' === ($left['kind'] ?? null) ? array_merge($left['conditions'], array($right)) : array($left, $right))); } + /** @return list */ private function selectors(string $prelude): array { $result = array(); $start = 0; $depth = 0; for ($i = 0, $length = strlen($prelude); $i < $length; ++$i) { if (in_array($prelude[$i], array('(', '['), true)) ++$depth; elseif (in_array($prelude[$i], array(')', ']'), true)) --$depth; elseif (',' === $prelude[$i] && 0 === $depth) { $result[] = trim(substr($prelude, $start, $i - $start)); $start = $i + 1; } } $result[] = trim(substr($prelude, $start)); return array_values(array_filter($result)); } + /** @return list */ private function declarations(string $body): array { $result = array(); foreach (explode(';', $body) as $declaration) if (str_contains($declaration, ':')) { [$name, $value] = array_map('trim', explode(':', $declaration, 2)); if (in_array(strtolower($name), self::PROPERTIES, true) && '' !== $value) $result[] = array('name' => strtolower($name), 'value' => preg_replace('/\s+/', ' ', $value) ?? $value); } return $result; } + private function specificity(string $selector): int { return 100 * preg_match_all('/#[A-Za-z0-9_-]+/', $selector) + 10 * preg_match_all('/\.[A-Za-z0-9_-]+|\[[^]]+\]|:[A-Za-z-]+/', $selector) + preg_match_all('/(?>,conditional:array>>} */ private function matched(DOMElement $element, array $rules): array { if ($element->hasAttribute('style')) $rules[] = array('inline' => true, 'selector' => '[style]', 'declarations' => $this->declarations($element->getAttribute('style')), 'condition' => null, 'path' => 'inline-style', 'hash' => hash('sha256', $element->getAttribute('style')), 'order' => PHP_INT_MAX, 'specificity' => 10000); $base = array(); $conditional = array(); $matched = 0; foreach ($rules as $rule) { $match = !empty($rule['inline']) ? array('supported' => true, 'matches' => true) : CssSelectorMatcher::matches($element, CssSelectorMatcher::parse($rule['selector'])); if (!$match['supported']) { $this->diagnostics[] = 'unsupported_selector:' . $rule['selector']; continue; } if (!$match['matches']) continue; if ($matched++ >= self::MAX_RULES_PER_NODE) { $this->truncated = true; $this->diagnostics[] = 'rules_per_node_limit'; break; } foreach ($rule['declarations'] as $declaration) { $important = 1 === preg_match('/\s*!important\s*$/i', $declaration['value']); $value = preg_replace('/\s*!important\s*$/i', '', $declaration['value']) ?? $declaration['value']; $key = null === $rule['condition'] ? null : json_encode($rule['condition']); if (null === $key) $target =& $base; else { $conditional[$key] ??= array(); $target =& $conditional[$key]; } $current = $target[$declaration['name']] ?? null; if (is_array($current) && $current['value'] !== $value) $this->diagnostics[] = 'cascade_conflict:' . $declaration['name']; if (!is_array($current) || (int) $important > (int) $current['important'] || ((bool) $important === $current['important'] && ($rule['specificity'] > $current['specificity'] || ($rule['specificity'] === $current['specificity'] && $rule['order'] >= $current['order'])))) $target[$declaration['name']] = array('value' => $value, 'path' => $rule['path'], 'hash' => $rule['hash'], 'selector' => $rule['selector'], 'order' => $rule['order'], 'specificity' => $rule['specificity'], 'important' => $important); unset($target); } } return array('base' => $base, 'conditional' => $conditional); } + /** @return array */ private function layout(array $facts): array { $map = array('grid-template-columns' => 'columns', 'grid-template-rows' => 'rows', 'row-gap' => 'row_gap', 'column-gap' => 'column_gap', 'grid-column' => 'column', 'grid-row' => 'row', 'grid-area' => 'area', 'flex-direction' => 'direction', 'flex-wrap' => 'wrap', 'align-items' => 'align_items', 'align-content' => 'align_content', 'justify-content' => 'justify_content', 'align-self' => 'align_self', 'justify-self' => 'justify_self', 'flex-grow' => 'flex_grow', 'flex-shrink' => 'flex_shrink', 'flex-basis' => 'flex_basis'); $result = array(); foreach ($facts as $property => $fact) $result[$map[$property] ?? $property] = $fact['value']; ksort($result); return $result; } + private static function layoutKey(string $property): string { return array('grid-template-columns' => 'columns', 'grid-template-rows' => 'rows', 'row-gap' => 'row_gap', 'column-gap' => 'column_gap', 'grid-column' => 'column', 'grid-row' => 'row', 'grid-area' => 'area', 'flex-direction' => 'direction', 'flex-wrap' => 'wrap', 'align-items' => 'align_items', 'align-content' => 'align_content', 'justify-content' => 'justify_content', 'align-self' => 'align_self', 'justify-self' => 'justify_self', 'flex-grow' => 'flex_grow', 'flex-shrink' => 'flex_shrink', 'flex-basis' => 'flex_basis')[$property] ?? $property; } + /** @return array>> */ private function effectiveConditional(array $conditional, array $base): array { foreach ($conditional as $condition => &$facts) foreach ($facts as $property => $fact) if (isset($base[$property]) && !$this->wins($fact, $base[$property])) unset($facts[$property]); unset($facts); foreach ($conditional as $condition => $facts) if (array() === $facts) unset($conditional[$condition]); return $conditional; } + private function wins(array $candidate, array $current): bool { return (int) $candidate['important'] > (int) $current['important'] || ((bool) $candidate['important'] === $current['important'] && ($candidate['specificity'] > $current['specificity'] || ($candidate['specificity'] === $current['specificity'] && $candidate['order'] >= $current['order']))); } + /** @return array> */ 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; } + /** @return list> */ 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); return array_values($grouped); } + /** @return array */ private function source(DOMElement $element): array { $classes = array_values(array_filter(preg_split('/\s+/', trim($element->getAttribute('class'))) ?: array(), static fn(string $class): bool => 1 === preg_match('/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/D', $class))); return array('tag' => strtolower($element->tagName), 'id' => 1 === preg_match('/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/D', $element->getAttribute('id')) ? $element->getAttribute('id') : null, 'classes' => array_slice($classes, 0, 8)); } +} diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index edbddb39..06acb5d3 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -536,6 +536,58 @@ public function match(DOMElement $element, PatternContext $context): ?array $formRuntimeIslands = array_values(array_filter($formFallback['source_reports']['runtime_islands'] ?? array(), static fn (array $island): bool => 'form' === ($island['kind'] ?? ''))); $assert(1 === count($formRuntimeIslands), 'data-entry form preservation reports a form runtime island'); $assert('server_or_client_form_handler' === ($formRuntimeIslands[0]['runtime_requirement'] ?? ''), 'form runtime island carries the server/client form-handler requirement'); +$boundedTopologyHtml = static function (int $extraControls): string { + $controls = ''; + for ( $index = 0; $index < 63; ++$index ) $controls .= '
'; + $controls .= ''; + for ( $index = 0; $index < $extraControls; ++$index ) $controls .= ''; + return '
' . $controls . '
'; +}; +$exactTopology = ( new HtmlTransformer() )->transform($boundedTopologyHtml(0))->toArray()['fallbacks'][0]['control_topology'] ?? array(); +$overflowTopology = ( new HtmlTransformer() )->transform($boundedTopologyHtml(1))->toArray()['fallbacks'][0]['control_topology'] ?? array(); +$assert(128 === count($exactTopology['nodes'] ?? array()) && false === ($exactTopology['truncated'] ?? null), 'form control topology retains exactly the configured node limit without reporting truncation'); +$assert(128 === count($overflowTopology['nodes'] ?? array()) && true === ($overflowTopology['truncated'] ?? null), 'form control topology truncates deterministically when one source-ordered control exceeds the node limit'); +$assert(64 === ($overflowTopology['nodes'][127]['control'] ?? null), 'node-limit truncation preserves the last in-bounds flat control reference'); +$presentationTopology = ( new HtmlTransformer() )->transform('
')->toArray()['fallbacks'][0]['control_topology']['nodes'][0] ?? array(); +$assert(! isset($presentationTopology['tag']) && ! isset($presentationTopology['source_id']), 'form topology omits unsupported wrapper tags and malformed source IDs'); +$assert('safe one two three four five six seven' === ($presentationTopology['class'] ?? ''), 'form topology retains only the first eight bounded safe class tokens'); +$layoutGraphHtml = '
'; +$layoutGraphCss = '.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}}@container form-shell (max-width:30rem){.field{gap:.5rem}}@supports (display:grid){.form{align-content:start}}'; +$layoutGraphFallback = (new HtmlTransformer())->transform($layoutGraphHtml, array('static_css' => $layoutGraphCss))->toArray()['fallbacks'][0] ?? array(); +$layoutGraph = $layoutGraphFallback['layout_graph'] ?? array(); +$layoutNodes = array_column($layoutGraph['nodes'] ?? array(), null, 'id'); +$assert('generic/computed-layout-graph/v1' === ($layoutGraph['schema'] ?? null) && 'source_css_cascade' === ($layoutGraph['basis'] ?? null), 'form fallback emits the versioned declared-CSS layout graph contract'); +$assert('grid' === ($layoutNodes['form']['layout']['display'] ?? null) && '1fr 1fr' === ($layoutNodes['wrapper-0']['layout']['columns'] ?? null) && 'flex' === ($layoutNodes['wrapper-1']['layout']['display'] ?? null), 'layout graph preserves form, row, and field layout facts in source order'); +$assert('form' === ($layoutNodes['wrapper-0']['parent'] ?? null) && 'wrapper-0' === ($layoutNodes['wrapper-1']['parent'] ?? null), 'layout graph preserves deterministic source parentage without inferring Columns'); +$layoutVariantKinds = array_values(array_unique(array_column(array_column($layoutGraph['variants'] ?? array(), 'condition'), 'kind'))); sort($layoutVariantKinds, SORT_STRING); +$assert(array('container', 'media', 'supports') === $layoutVariantKinds, 'layout graph preserves media, container, and supports declarations as conditional variants'); +$malformedLayoutGraph = (new HtmlTransformer())->transform($layoutGraphHtml, array('static_css' => '.form{display:grid'))->toArray()['fallbacks'][0]['layout_graph'] ?? array(); +$assert(in_array('malformed_stylesheet:inline-style', $malformedLayoutGraph['diagnostics'] ?? array(), true), 'layout graph reports malformed source stylesheets instead of guessing layout facts'); +$cascadeGraph = (new HtmlTransformer())->transform($layoutGraphHtml, array('static_css' => '.form{display:flex;display:grid!important;display:block}.field{display:flex}@media (max-width:50rem){@supports (display:grid){.field{gap:1rem}}}.form button[type="submit"]{order:2}'))->toArray()['fallbacks'][0]['layout_graph'] ?? array(); +$cascadeNodes = array_column($cascadeGraph['nodes'] ?? array(), null, 'id'); +$cascadeVariant = $cascadeGraph['variants'][0] ?? array(); +$assert('grid' === ($cascadeNodes['form']['layout']['display'] ?? null) && !isset($cascadeNodes['control-2']['layout']['order']) && in_array('unsupported_selector:.form button[type="submit"]', $cascadeGraph['diagnostics'] ?? array(), true), 'layout graph resolves duplicate same-rule declarations by declaration order and !important, and reports unsupported attribute selector semantics explicitly'); +$assert('all' === ($cascadeVariant['condition']['kind'] ?? null) && $cascadeVariant['condition'] === ($cascadeVariant['provenance'][0]['condition'] ?? null), 'layout graph retains nested condition chains and conditional provenance exactly.'); +$conditionalOnlyGraph = (new HtmlTransformer())->transform('
', array('static_css' => '@media (max-width:50rem){.field{display:flex;flex-direction:column;align-items:flex-start}}'))->toArray()['fallbacks'][0]['layout_graph'] ?? array(); +$conditionalOnlyNode = array_column($conditionalOnlyGraph['nodes'] ?? array(), null, 'id')['wrapper-0'] ?? array(); $conditionalOnlyVariant = $conditionalOnlyGraph['variants'][0] ?? array(); +$conditionalOnlyProperties = $conditionalOnlyVariant['provenance'][0]['properties'] ?? array(); +$assert(array() === ($conditionalOnlyNode['layout'] ?? null) && array() === ($conditionalOnlyNode['provenance'] ?? null) && 'column' === ($conditionalOnlyVariant['layout_patch']['direction'] ?? null) && 'flex-start' === ($conditionalOnlyVariant['layout_patch']['align_items'] ?? null) && isset($conditionalOnlyVariant['precedence']['flex-direction'], $conditionalOnlyVariant['precedence']['align-items']) && in_array('flex-direction', $conditionalOnlyProperties, true) && in_array('align-items', $conditionalOnlyProperties, true), 'conditional-only layout nodes retain explicit empty base facts and canonical normalized-key to CSS-property correspondence.'); +$inlineGraph = (new HtmlTransformer())->transform('
', array('static_css' => '.form{display:grid}'))->toArray()['fallbacks'][0]['layout_graph'] ?? array(); +$inlineNode = array_column($inlineGraph['nodes'] ?? array(), null, 'id')['form'] ?? array(); +$assert('flex' === ($inlineNode['layout']['display'] ?? null) && 'inline-style' === ($inlineNode['provenance'][0]['source_path'] ?? null) && '[style]' === ($inlineNode['provenance'][0]['selector'] ?? null), 'layout graph gives inline declarations highest normal author specificity and source order.'); +$assert(is_int($cascadeVariant['precedence']['gap']['source_order'] ?? null) && is_int($cascadeVariant['precedence']['gap']['specificity'] ?? null) && is_bool($cascadeVariant['precedence']['gap']['important'] ?? null), 'conditional variants carry deterministic cascade precedence rather than implying independent winners.'); +$crossConditionGraph = (new HtmlTransformer())->transform('
', array('static_css' => '.form{display:grid!important}@media (max-width:50rem){.form{display:flex}}@media (min-width:40rem){.form#active{display:flex!important}}'))->toArray()['fallbacks'][0]['layout_graph'] ?? array(); +$crossConditionVariants = $crossConditionGraph['variants'] ?? array(); +$assert(1 === count($crossConditionVariants) && 'flex' === ($crossConditionVariants[0]['layout_patch']['display'] ?? null) && true === ($crossConditionVariants[0]['precedence']['display']['important'] ?? null), 'conditional graph patches exclude weaker declarations that cannot override unconditional winners while retaining effective important variants.'); +$nestedConditions = str_repeat('@media (min-width:1px){', 9) . '.form{display:grid}' . str_repeat('}', 9); $nestedGraph = (new HtmlTransformer())->transform($layoutGraphHtml, array('static_css' => $nestedConditions))->toArray()['fallbacks'][0]['layout_graph'] ?? array(); +$assert(true === ($nestedGraph['truncated'] ?? null) && in_array('condition_depth_limit', $nestedGraph['diagnostics'] ?? array(), true), 'layout graph bounds nested conditional at-rule recursion before parsing deeper rules.'); +$ancestryGraph = (new HtmlTransformer())->transform('
', array('static_css' => '.field{display:flex}'))->toArray()['fallbacks'][0]['layout_graph'] ?? array(); +$ancestryIds = array_flip(array_column($ancestryGraph['nodes'] ?? array(), 'id')); foreach ($ancestryGraph['nodes'] ?? array() as $node) $assert(null === ($node['parent'] ?? null) || isset($ancestryIds[$node['parent']]), 'layout graph emits deterministic ancestry instead of dangling parents.'); +$boundedCss = str_repeat('.form{display:grid}', 600); $boundedGraph = (new HtmlTransformer())->transform($layoutGraphHtml, array('static_css' => $boundedCss))->toArray()['fallbacks'][0]['layout_graph'] ?? array(); +$assert(true === ($boundedGraph['truncated'] ?? null) && in_array('css_rule_or_selector_limit', $boundedGraph['diagnostics'] ?? array(), true), 'layout graph bounds CSS parsing and rule matching work with explicit diagnostics.'); +$invalidLayoutGraph = $layoutGraph; $invalidLayoutGraph['nodes'][0]['id'] = 'wrapper-0'; try { \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder::assertValid($invalidLayoutGraph); $assert(false, 'layout graph validation rejects duplicate node identities'); } catch (\InvalidArgumentException) { $assert(true, 'layout graph validation rejects duplicate node identities'); } +$unsafeGraph = $layoutGraph; $unsafeGraph['nodes'][0]['provenance'][0]['source_path'] = '../../untrusted.css'; try { \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder::assertValid($unsafeGraph); $assert(false, 'layout graph validation rejects unsafe provenance traversal paths'); } catch (\InvalidArgumentException) { $assert(true, 'layout graph validation rejects unsafe provenance traversal paths'); } +$semanticGraph = $layoutGraph; $semanticGraph['nodes'][0]['layout']['unknown_layout'] = 'value'; try { \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\FormLayoutGraphBuilder::assertValid($semanticGraph); $assert(false, 'layout graph validation rejects unknown semantic layout keys'); } catch (\InvalidArgumentException) { $assert(true, 'layout graph validation rejects unknown semantic layout keys'); } $newsletterFallback = ( new HtmlTransformer() )->transform( '

Newsletter

' @@ -672,11 +724,25 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(str_contains($classOwnedGridMarkup, 'hero-inner'), 'class-owned CSS grid keeps the source class'); $assert(! str_contains($classOwnedGridMarkup, 'is-layout-grid'), 'class-owned CSS grid avoids WP layout classes that override exact source tracks'); +$explicitGridPlacement = ( new HtmlTransformer() )->transform('
Body
')->toArray(); +$explicitGridPlacementMarkup = (string) ($explicitGridPlacement['serialized_blocks'] ?? ''); +$assert(str_contains($explicitGridPlacementMarkup, '
transform('
Text
')->toArray(); $classOwnedFlexMarkup = (string) ($classOwnedFlex['serialized_blocks'] ?? ''); $assert(str_contains($classOwnedFlexMarkup, 'hero'), 'class-owned CSS flex keeps the source class'); $assert(! str_contains($classOwnedFlexMarkup, 'is-layout-flex'), 'class-owned CSS flex avoids WP layout classes that override exact source layout'); +$inlineBreadcrumb = ( new HtmlTransformer() )->transform( + '
Exhibition
' +)->toArray(); +$inlineBreadcrumbMarkup = (string) ($inlineBreadcrumb['serialized_blocks'] ?? ''); +$inlineBreadcrumbNavMarkup = strstr($inlineBreadcrumbMarkup, '', true) ?: ''; +$assert(str_contains($inlineBreadcrumbMarkup, '