diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index 8be2085d..8406a661 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -1551,6 +1551,10 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR 'invalid_numeric_token_count' => 0, 'invalid_numeric_tokens' => array(), ); + $textCoverage = array( + 'empty_decoded_text_nodes' => 0, + 'missing_emitted_text_nodes' => 0, + ); $htmlArtifact = array( 'schema' => 'blocks-engine/figma-transformer/html-artifact-diagnostics/v1', 'media_query_count' => 0, @@ -1629,6 +1633,8 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR $pageCss = is_array($diagnostics['css'] ?? null) ? $diagnostics['css'] : array(); DiagnosticAggregation::addIntegerCounts($css, $pageCss, array('invalid_numeric_token_count')); DiagnosticAggregation::appendContextSamples($css, 'invalid_numeric_tokens', $pageCss, 'invalid_numeric_tokens', $pageContext); + $pageQualitySummary = is_array($diagnostics['artifact_quality']['summary'] ?? null) ? $diagnostics['artifact_quality']['summary'] : array(); + DiagnosticAggregation::addIntegerCounts($textCoverage, $pageQualitySummary, array('empty_decoded_text_nodes', 'missing_emitted_text_nodes')); $pageHtmlArtifact = is_array($diagnostics['html_artifact'] ?? null) ? $diagnostics['html_artifact'] : array(); DiagnosticAggregation::addIntegerCounts($htmlArtifact, $pageHtmlArtifact, array('media_query_count', 'fixed_width_over_desktop_count', 'fixed_width_declaration_count', 'fixed_width_with_responsive_override_count', 'fixed_width_without_responsive_override_count', 'giant_fixed_section_count', 'large_overflow_risk_count', 'fallback_prone_form_island_count', 'fallback_prone_svg_island_count', 'fallback_prone_input_island_count', 'invalid_list_child_count', 'missing_semantic_role_count')); DiagnosticAggregation::appendContextSamples($htmlArtifact, 'fixed_width_samples', $pageHtmlArtifact, 'fixed_width_samples', $pageContext); @@ -1777,6 +1783,12 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR ); $generatedSvgAssets = $this->generatedSvgAssetsFromReport($assetReport); + $artifactQuality = $this->artifactQualityDiagnostics($images, $vectors, $fonts, $assets, $generatedSvgAssets, $layout, $links, $css, $htmlArtifact); + $artifactQuality['summary'] = array_merge( + is_array($artifactQuality['summary'] ?? null) ? $artifactQuality['summary'] : array(), + $textCoverage + ); + return array( 'schema' => 'blocks-engine/figma-transformer/transform-diagnostics/v1', 'scope' => 'multi_page', @@ -1793,7 +1805,7 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR 'links' => $links, 'css' => $css, 'html_artifact' => $htmlArtifact, - 'artifact_quality' => $this->artifactQualityDiagnostics($images, $vectors, $fonts, $assets, $generatedSvgAssets, $layout, $links, $css, $htmlArtifact), + 'artifact_quality' => $artifactQuality, 'diagnostic_codes' => $diagnosticCodes, ); } diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index f3fbca97..cf91103d 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -4716,6 +4716,9 @@ private function componentCloneOmissionReason(array $node, ?array $parentNode, ? if ( null !== $parentNode && $this->isComposedVectorChild($node, $parentNode) ) { return 'composed-into-parent'; } + if ( null !== $parentNode && $this->formControlComposesChild($parentNode, $node) ) { + return 'form-control-composed-into-parent'; + } if ( $this->isComponentSourceDuplicateNode($node) ) { return 'component_source_duplicate'; } @@ -4735,7 +4738,13 @@ private function componentCloneOmissionReason(array $node, ?array $parentNode, ? private function isIntentionalComponentCloneSuppression(string $reason): bool { - return in_array($reason, array('hidden', 'zero-area', 'mask-source', 'composed-into-parent', 'non-rendering-vector-layer', 'invisible-zero-area-scaffold', 'component_source_duplicate'), true); + return in_array($reason, array('hidden', 'zero-area', 'mask-source', 'composed-into-parent', 'form-control-composed-into-parent', 'non-rendering-vector-layer', 'invisible-zero-area-scaffold', 'component_source_duplicate'), true); + } + + private function formControlComposesChild(array $parent, array $child): bool + { + $parentIsControl = ($this->isInputLike($parent) || $this->isTextareaLike($parent)) && $this->hasFormControlAccessoryChildren($parent); + return $parentIsControl && ($this->isFormControlPlaceholderChild($child) || $this->isInputLike($child, $parent) || $this->isTextareaLike($child, $parent)); } /** diff --git a/figma-transformer/tests/contract/DiagnosticsEvidenceContract.php b/figma-transformer/tests/contract/DiagnosticsEvidenceContract.php index 61240a3f..326b1158 100644 --- a/figma-transformer/tests/contract/DiagnosticsEvidenceContract.php +++ b/figma-transformer/tests/contract/DiagnosticsEvidenceContract.php @@ -944,6 +944,8 @@ function blocks_engine_figma_transformer_run_diagnostics_evidence_contract(calla $assert('diag:aggregation-home-vector' === ($placeholderNodes[0]['node_id'] ?? null), 'diagnostics-evidence-multi-page-placeholder-home-node'); $assert('aggregation-about.html' === ($placeholderNodes[1]['page_path'] ?? null), 'diagnostics-evidence-multi-page-placeholder-about-context'); $assert(2 === ($multiPageDiagnostics['diagnostic_codes']['unsupported_vector_node_placeholder'] ?? null), 'diagnostics-evidence-multi-page-diagnostic-code-count'); + $assert(0 === ($multiPageDiagnostics['artifact_quality']['summary']['empty_decoded_text_nodes'] ?? null), 'diagnostics-evidence-multi-page-empty-text-count-aggregated'); + $assert(0 === ($multiPageDiagnostics['artifact_quality']['summary']['missing_emitted_text_nodes'] ?? null), 'diagnostics-evidence-multi-page-missing-text-count-aggregated'); blocks_engine_figma_transformer_contract_assert_diagnostic_value($assert, $multiPageDiagnostics, array('decision_traces', 'schema'), 'blocks-engine/figma-transformer/decision-traces/v1', 'diagnostics-evidence-multi-page-decision-traces-schema'); $assert(2 <= ($multiPageDiagnostics['decision_traces']['domain_counts']['positioning_context'] ?? 0), 'diagnostics-evidence-multi-page-decision-traces-positioning-aggregated'); blocks_engine_figma_transformer_contract_assert_diagnostic_value($assert, $multiPageDiagnostics, array('layout', 'positional_parity', 'schema'), 'blocks-engine/figma-transformer/positional-parity/v1', 'diagnostics-evidence-multi-page-positional-parity-schema'); diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 4b9fc989..9817f4ee 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -284,6 +284,9 @@ private function runtimeDeclarationsFromFallbacks(array $declarations, array $fa } } ksort($products, SORT_STRING); ksort($forms, SORT_STRING); + $bindingOccurrences = array(); + $products = $this->normalizeEntityBindingOccurrences($products, $bindingOccurrences); + $forms = $this->normalizeEntityBindingOccurrences($forms, $bindingOccurrences); $collections = array( 'shop' => array('type' => 'products', 'aliases' => array('product', 'products'), 'entities' => array_values($products), 'schema' => 'generic/products/v1'), @@ -305,6 +308,22 @@ private function runtimeDeclarationsFromFallbacks(array $declarations, array $fa return RuntimeDeclarations::normalizeList($declarations); } + /** @param array> $entities @param array $occurrences @return array> */ + private function normalizeEntityBindingOccurrences(array $entities, array &$occurrences): array + { + foreach ($entities as &$entity) { + if (!is_array($entity['bindings'] ?? null)) continue; + foreach ($entity['bindings'] as &$binding) { + $key = ($binding['source_path'] ?? '') . "\n" . ($binding['role'] ?? '') . "\n" . ($binding['search_block_markup'] ?? ''); + $occurrences[$key] = ($occurrences[$key] ?? 0) + 1; + $binding['occurrence'] = $occurrences[$key]; + } + unset($binding); + } + unset($entity); + return $entities; + } + /** @param array $fallback @param array> $files @return array> */ private function supersededFormScripts(array $fallback, array $files, string $sourcePath): array { diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index b3f96e48..0d57dd23 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -422,8 +422,14 @@ private function imageHtml(array $attrs): string { $figureAttrs = $attrs; $baseClass = 'wp-block-image'; - if ( ! empty($attrs['style']['border']) ) { - $baseClass .= ' has-custom-border'; + $border = is_array($attrs['style']['border'] ?? null) ? $attrs['style']['border'] : array(); + $borderSupport = $this->styleSupport(array( 'border' => $border )); + if ( array() !== $border ) { + $baseClass = $this->mergeClassNames('wp-block-image has-custom-border', $borderSupport['classes']); + unset($figureAttrs['style']['border']); + if ( empty($figureAttrs['style']) ) { + unset($figureAttrs['style']); + } } if ( ! empty($attrs['sizeSlug']) ) { $figureAttrs['className'] = $this->mergeClassNames((string) ($figureAttrs['className'] ?? ''), 'size-' . (string) $attrs['sizeSlug']); @@ -438,8 +444,8 @@ private function imageHtml(array $attrs): string 'title' => $attrs['title'] ?? '', 'srcset' => $attrs['srcset'] ?? '', 'sizes' => $attrs['sizes'] ?? '', - 'class' => ! empty($attrs['id']) ? 'wp-image-' . (string) $attrs['id'] : '', - 'style' => $this->imageDimensionStyle($attrs), + 'class' => $this->mergeClassNames(! empty($attrs['id']) ? 'wp-image-' . (string) $attrs['id'] : '', $borderSupport['classes']), + 'style' => trim($this->imageDimensionStyle($attrs) . ';' . $borderSupport['style'], ';'), ); $img = 'htmlAttrs($imageAttrs, array( 'alt' )) . '/>'; @@ -481,11 +487,15 @@ private function galleryClasses(array $attrs): string */ private function imageDimensionStyle(array $attrs): string { - if ( ! array_key_exists('width', $attrs) && ! array_key_exists('height', $attrs) ) { + if ( ! array_key_exists('width', $attrs) && ! array_key_exists('height', $attrs) && ! array_key_exists('scale', $attrs) ) { return ''; } $style = array(); + if ( array_key_exists('scale', $attrs) && null !== $attrs['scale'] ) { + $style[] = 'object-fit:' . (string) $attrs['scale']; + } + if ( array_key_exists('width', $attrs) && null !== $attrs['width'] ) { $style[] = 'width:' . (string) $attrs['width']; } diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index d5ab581c..c2cbefdd 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -225,6 +225,9 @@ final class HtmlTransformer /** @var array */ private array $blockBindingOccurrences = array(); + /** @var array */ + private array $formControlSlotPaths = array(); + /** * @var array> */ @@ -431,6 +434,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->droppedLinkWrapperFindings = array(); $this->sourceProvenance = array(); $this->blockBindingOccurrences = array(); + $this->formControlSlotPaths = array(); $this->structureProvenance = array(); $this->scriptMetadata = array(); $this->runtimeIslands = array(); @@ -1215,7 +1219,10 @@ private function imageProjectionBridgeDeclarations(array $declarations): string { $bridge = array( 'display:block' ); $position = strtolower(trim((string) ($declarations['position'] ?? ''))); - if ( in_array($position, array( 'absolute', 'fixed' ), true) ) { + $width = strtolower(trim((string) ($declarations['width'] ?? ''))); + $height = strtolower(trim((string) ($declarations['height'] ?? ''))); + $ownsBox = ! in_array($width, array( '', 'auto' ), true) && ! in_array($height, array( '', 'auto' ), true); + if ( $ownsBox || in_array($position, array( 'absolute', 'fixed' ), true) ) { $bridge[] = 'width:100%'; $bridge[] = 'height:100%'; } @@ -1653,6 +1660,10 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca { $tagName = strtolower($element->tagName); + if ( isset($this->formControlSlotPaths[$element->getNodePath()]) ) { + return $this->htmlPreservationBlock($element); + } + if ( $this->isRedundantMenuToggleControl($element) ) { return null; } @@ -6469,6 +6480,58 @@ private function readableFormBlockFromForm(DOMElement $form, bool $allowFormEven return $this->createBlock('core/group', $this->presentationAttributes($form), $contentBlocks, $form); } + /** + * Preserve one unambiguous controls-only subtree as the provider binding + * slot while converting the form's surrounding visual content normally. + * + * @param array> $fallbacks + * @return array{block:array,slot:array}|null + */ + private function compositionalFormBlock(DOMElement $form, array &$fallbacks): ?array + { + $slot = $this->formControlSlotElement($form); + if ( null === $slot ) return null; + + $path = $slot->getNodePath(); + $this->formControlSlotPaths[$path] = true; + try { + $children = $this->convertChildren($form, $fallbacks, true); + } finally { + unset($this->formControlSlotPaths[$path]); + } + if ( array() === $children ) return null; + + return array( + 'block' => $this->createBlock('core/group', $this->presentationAttributes($form), $children, $form), + 'slot' => $this->htmlPreservationBlock($slot), + ); + } + + private function formControlSlotElement(DOMElement $form): ?DOMElement + { + $controls = $this->formControlElements($form); + if ( array() === $controls ) return null; + + $formPath = $form->getNodePath(); + for ( $candidate = $controls[0]->parentNode; $candidate instanceof DOMElement && $candidate->getNodePath() !== $formPath; $candidate = $candidate->parentNode ) { + if ( array_filter($controls, fn(DOMElement $control): bool => !$this->elementContains($candidate, $control)) ) continue; + foreach ( $candidate->childNodes as $child ) { + if ( XML_TEXT_NODE === $child->nodeType && '' !== trim($child->textContent ?? '') ) continue 2; + if ( !$child instanceof DOMElement ) continue; + if ( !array_filter($controls, fn(DOMElement $control): bool => $this->elementContains($child, $control)) ) continue 2; + } + return $candidate; + } + return null; + } + + private function elementContains(DOMElement $ancestor, DOMElement $element): bool + { + $ancestorPath = $ancestor->getNodePath(); + for ( $node = $element; $node instanceof DOMElement; $node = $node->parentNode ) if ( $node->getNodePath() === $ancestorPath ) return true; + return false; + } + /** * @return array|null */ @@ -6732,8 +6795,11 @@ private function formFallbackFinding(DOMElement $element, ?array $readableFormBl { $controls = $this->formControls($element); $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); + $replacesRuntimeIsland = null !== $bindingBlock; $bindingBlock ??= $readableFormBlock; $bindingMarkup = null !== $bindingBlock ? $this->runtime->serializeBlocks(array($bindingBlock)) : ''; + $supersededRuntimeSelectors = $this->runtimeDomSelectorsForElement($element); + if ( $replacesRuntimeIsland ) $supersededRuntimeSelectors[] = $this->runtimeIslandSelector($element); return FallbackDiagnostic::build(array( 'type' => 'html', @@ -6750,7 +6816,7 @@ private function formFallbackFinding(DOMElement $element, ?array $readableFormBl 'classification' => $this->fallbackEmitter->classifyFallbackSubtree($element), 'events' => $this->eventMetadata($element), 'readable_blocks' => null !== $readableFormBlock ? array( $readableFormBlock ) : array(), - 'binding' => $this->blockBinding($bindingMarkup, 'form', $this->runtimeDomSelectorsForElement($element)), + 'binding' => $this->blockBinding($bindingMarkup, 'form', $supersededRuntimeSelectors), 'controls' => $controls, 'control_count' => count($controls), 'text_length' => strlen(trim($element->textContent ?? '')), @@ -7369,15 +7435,23 @@ private function selectOptions(DOMElement $select): array */ private function backgroundImageBlockFromElement(DOMElement $element): ?array { + $declarations = $this->presentationDeclarations($element); $url = $this->backgroundImageExtractor->urlFromStyle($this->mergedPresentationStyle($element)); if ( '' === $url ) { return null; } + $width = trim((string) ($declarations['width'] ?? '')); + $height = trim((string) ($declarations['height'] ?? '')); + $scale = strtolower(trim((string) ($declarations['background-size'] ?? ''))); + return $this->createBlock('core/image', array_filter(array( 'url' => $this->resolvedAssetImageUrl($url), 'alt' => $this->backgroundImageExtractor->altFromAttributes($this->htmlAttributes($element)), 'className' => 'blocks-engine-background-image', + 'width' => ! in_array(strtolower($width), array( '', 'auto' ), true) ? $width : '', + 'height' => ! in_array(strtolower($height), array( '', 'auto' ), true) ? $height : '', + 'scale' => in_array($scale, array( 'cover', 'contain' ), true) ? $scale : '', ), static fn (string $value): bool => '' !== $value), array(), $element); } diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleAttributeMapper.php b/php-transformer/src/HtmlToBlocks/Style/StyleAttributeMapper.php index 2ba05ecf..66d7b8bb 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleAttributeMapper.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleAttributeMapper.php @@ -453,6 +453,9 @@ private function border(array $declarations, array &$consumed): array } $width = trim((string) ($declarations['border-width'] ?? $shorthand['width'] ?? '')); + if ( '' === $width ) { + $width = $this->uniformBorderSideValue('width', $declarations, $consumed); + } $style = strtolower(trim((string) ($declarations['border-style'] ?? $shorthand['style'] ?? ''))); $colorValue = $this->cssColor((string) ($declarations['border-color'] ?? $shorthand['color'] ?? '')); foreach ( array( 'border-width', 'border-style', 'border-color' ) as $name ) { @@ -483,6 +486,27 @@ private function border(array $declarations, array &$consumed): array return $border; } + /** + * Collapse equal physical side values into the canonical border support. + * Unequal sides remain under author stylesheet ownership. + * + * @param array $declarations + * @param array $consumed + */ + private function uniformBorderSideValue(string $property, array $declarations, array &$consumed): string + { + $names = array_map(static fn (string $side): string => 'border-' . $side . '-' . $property, array( 'top', 'right', 'bottom', 'left' )); + $values = array_map(static fn (string $name): string => trim((string) ($declarations[ $name ] ?? '')), $names); + if ( in_array('', $values, true) || 1 !== count(array_unique($values)) ) { + return ''; + } + + foreach ( $names as $name ) { + $consumed[ $name ] = true; + } + return $values[0]; + } + /** * @return array{width?: string, style?: string, color?: string} */ diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index fa5e90f4..c245bccc 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -47,8 +47,8 @@ trait StyleResolutionTrait private array $mergedPresentationStyleCache = array(); /** - * Inline sizing declarations which core block supports cannot serialize are - * carried by deterministic classes in a generated stylesheet. + * Inline presentation declarations which core block supports cannot serialize + * are carried by deterministic classes in a generated stylesheet. * * @var array */ @@ -71,6 +71,8 @@ private function inlineGeometryProperties(): array 'aspect-ratio', 'box-sizing', 'flex-basis', + 'object-fit', + 'object-position', ); } @@ -781,6 +783,10 @@ private function safeVisualDeclarations(array $declarations): array 'border-color', 'border-radius', 'border-style', + 'border-bottom-width', + 'border-left-width', + 'border-right-width', + 'border-top-width', 'border-width', 'box-shadow', 'color', diff --git a/php-transformer/src/HtmlToBlocks/Support/FormDispatchTrait.php b/php-transformer/src/HtmlToBlocks/Support/FormDispatchTrait.php index c21562ed..e0853779 100644 --- a/php-transformer/src/HtmlToBlocks/Support/FormDispatchTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/FormDispatchTrait.php @@ -28,6 +28,12 @@ private function convertFormDispatchElement(DOMElement $element, array &$fallbac } if ( $this->formHasDataEntryControls($element) ) { + $composition = $this->compositionalFormBlock($element, $fallbacks); + if ( null !== $composition ) { + $fallbacks[] = $this->formFallbackFinding($element, $composition['block'], $composition['slot']); + $this->recordFormRuntimeIsland($element, $composition['block']); + return $composition['block']; + } $preservationBlock = $this->htmlPreservationBlock($element); $fallbacks[] = $this->formFallbackFinding($element, $readableFormBlock, $preservationBlock); $this->recordFormRuntimeIsland($element, $readableFormBlock); diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index e43b7a9c..81b76787 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -31,12 +31,10 @@ public function fromResult(TransformerResult|array $result): array $tokens = $this->tokens($assets); $references = new AssetReferenceCanonicalizer($tokens); $routeMap = $this->canonicalRoutes($compiled['pages'] ?? null, is_array($materialization['routes'] ?? null) ? $materialization['routes'] : array()); - // A binding anchors on the source page's block markup, which the plan - // rewrites for canonical routes (e.g. a form action="../about.html" - // becomes action="/about"). Rewrite the binding markup through the same - // route map so its anchor still matches the canonical page markup and - // downstream materialization replaces the route-canonical block. - $runtimeDeclarations = $this->routeLinkedEntityBindings($runtimeDeclarations, $routeMap); + // Bindings anchor on source-page block markup. Canonicalize their asset + // references and routes through the same pipeline as page documents so + // the anchors still match the destination-independent page markup. + $runtimeDeclarations = $this->canonicalEntityBindings($runtimeDeclarations, $references, $routeMap); $pages = $this->documents($compiled['pages'] ?? null, false, $tokens, $references, $routeMap); $pages = $this->pageHierarchy($pages, $routeMap); $routes = $this->routesForPages($pages); @@ -44,7 +42,7 @@ public function fromResult(TransformerResult|array $result): array // canonical plan rebuilds them from full page shell candidates. $compiledParts = is_array($compiled['template_parts'] ?? null) ? array_values(array_filter($compiled['template_parts'], static fn(mixed $part): bool => !is_array($part) || 'entry_shell' !== ($part['placement']['kind'] ?? null))) : null; $existingParts = $this->documents($compiledParts, true, $tokens, $references, $routeMap); - $shells = $this->sharedShells($pages, array_fill_keys(array_column($existingParts, 'slug'), true)); + $shells = $this->sharedShells($pages, array_fill_keys(array_column($existingParts, 'slug'), true), $runtimeDeclarations); $pages = $shells['pages']; $parts = array_merge($existingParts, $shells['parts']); self::assertEntityBindingsRemainPageOwned($runtimeDeclarations, $pages, $assets); @@ -254,10 +252,18 @@ private function shellCandidates(array $document, AssetReferenceCanonicalizer $r return $candidates; } - /** @param array> $pages @param array $reservedSlugs @return array{pages:array>,parts:array>,diagnostics:array>} */ - private function sharedShells(array $pages, array $reservedSlugs = array()): array + /** @param array> $pages @param array $reservedSlugs @param array> $runtimeDeclarations @return array{pages:array>,parts:array>,diagnostics:array>} */ + private function sharedShells(array $pages, array $reservedSlugs = array(), array $runtimeDeclarations = array()): array { $parts = array(); $diagnostics = array(); + foreach ($pages as &$page) { + foreach ($page['shell_candidates'] ?? array() as $candidate) { + $restored = $this->replaceTopLevelShell($page['canonical_block_markup'], (string) ($candidate['area'] ?? ''), (string) ($candidate['markup'] ?? '')); + if (null !== $restored) $page['canonical_block_markup'] = $restored; + } + $page['content_hash'] = self::contentHash($page['canonical_block_markup']); + } + unset($page); foreach (array('header', 'footer') as $area) { if (isset($reservedSlugs[$area])) { $diagnostics[] = array('code' => 'wordpress_site_plan_shell_retained_ambiguous', 'severity' => 'info', 'message' => "{$area} shell conflicts with an existing template part.", 'area' => $area); @@ -277,17 +283,36 @@ private function sharedShells(array $pages, array $reservedSlugs = array()): arr $diagnostics[] = array('code' => 'wordpress_site_plan_shell_retained_ambiguous', 'severity' => 'info', 'message' => "{$area} shell candidates are not semantically equivalent across every page.", 'area' => $area); continue 2; } - foreach ($pages as $index => &$page) { + $withShells = array(); $withoutShells = array(); $boundSources = array(); $boundCount = 0; + foreach ($pages as $index => $page) { if (!empty($page['synthetic'])) continue; - $withoutShell = $this->withoutTopLevelShell($page['canonical_block_markup'], $area); - if (null === $withoutShell) { + $withShell = $this->replaceTopLevelShell($page['canonical_block_markup'], $area, $candidates[$index][0]['markup']); + $withoutShell = null === $withShell ? null : $this->withoutTopLevelShell($withShell, $area); + if (null === $withShell || null === $withoutShell) { $diagnostics[] = array('code' => 'wordpress_site_plan_shell_retained_ambiguous', 'severity' => 'warning', 'message' => "{$area} shell candidate cannot be removed unambiguously from {$page['source_path']}.", 'area' => $area, 'source_path' => $page['source_path']); - unset($page); continue 2; + continue 2; + } + $withShells[$index] = $withShell; + $withoutShells[$index] = $withoutShell; + foreach ($runtimeDeclarations as $declaration) foreach ($declaration['payload']['entities'] ?? array() as $entity) foreach (is_array($entity) && is_array($entity['bindings'] ?? null) ? $entity['bindings'] : array() as $binding) { + $search = $binding['search_block_markup'] ?? null; $occurrence = $binding['occurrence'] ?? null; + if (($binding['source_path'] ?? null) === ($page['source_path'] ?? null) && is_string($search) && is_int($occurrence) && $occurrence > substr_count($withoutShell, $search)) { + ++$boundCount; $boundSources[$page['source_path']] = true; + } + } + } + if ($boundCount > 0) { + foreach ($withShells as $index => $withShell) { + $pages[$index]['canonical_block_markup'] = $withShell; + $pages[$index]['content_hash'] = self::contentHash($withShell); } - $page['canonical_block_markup'] = $withoutShell; - $page['content_hash'] = self::contentHash($page['canonical_block_markup']); + $diagnostics[] = array('code' => 'wordpress_site_plan_shell_retained_runtime_binding', 'severity' => 'info', 'message' => "{$area} shell remains page-owned because extracting it would remove runtime entity binding anchors.", 'area' => $area, 'binding_count' => $boundCount, 'source_paths' => array_keys($boundSources)); + continue; + } + foreach ($withoutShells as $index => $withoutShell) { + $pages[$index]['canonical_block_markup'] = $withoutShell; + $pages[$index]['content_hash'] = self::contentHash($withoutShell); } - unset($page); $singlePage = 1 === count($applicable); $sourcePath = $singlePage ? $pages[array_key_first($applicable)]['source_path'] : 'wordpress-site-plan/shared/' . $area; $placement = $singlePage ? 'entry_shell' : 'shared_shell'; @@ -301,6 +326,11 @@ private function sharedShells(array $pages, array $reservedSlugs = array()): arr } private function withoutTopLevelShell(string $markup, string $area): ?string + { + return $this->replaceTopLevelShell($markup, $area, ''); + } + + private function replaceTopLevelShell(string $markup, string $area, string $replacement): ?string { if (!preg_match_all('//s', $markup, $matches, PREG_OFFSET_CAPTURE)) return null; $depth = 0; $candidate = null; @@ -319,7 +349,7 @@ private function withoutTopLevelShell(string $markup, string $area): ?string if (!$selfClosing) ++$depth; } if (!is_array($candidate) || !is_int($candidate['end'])) return null; - return substr($markup, 0, $candidate['start']) . substr($markup, $candidate['end']); + return substr($markup, 0, $candidate['start']) . $replacement . substr($markup, $candidate['end']); } /** @param mixed $assets @return array> */ @@ -616,16 +646,14 @@ private static function relativePath(string $origin, string $target): string return str_repeat('../', count($from)) . implode('/', $to); } /** - * Rewrite route links inside every entity binding's search markup so the - * binding anchors on the same canonical block markup the plan emits for its - * source page. Only single-directory route links change; other markup is - * byte-preserved so binding hashes and occurrence counts stay stable. + * Canonicalize every entity binding's search markup through the same asset + * and route projections used for its source page. * * @param array> $declarations * @param array> $routes * @return array> */ - private function routeLinkedEntityBindings(array $declarations, array $routes): array + private function canonicalEntityBindings(array $declarations, AssetReferenceCanonicalizer $references, array $routes): array { foreach ( $declarations as &$declaration ) { if ( ! is_array($declaration) || ! isset($declaration['payload']['entities']) || ! is_array($declaration['payload']['entities']) ) { @@ -637,7 +665,8 @@ private function routeLinkedEntityBindings(array $declarations, array $routes): } foreach ( $entity['bindings'] as &$binding ) { if ( is_array($binding) && is_string($binding['search_block_markup'] ?? null) && is_string($binding['source_path'] ?? null) ) { - $binding['search_block_markup'] = $this->routeLinks($binding['search_block_markup'], $binding['source_path'], $routes); + $markup = $references->content($binding['search_block_markup'], $binding['source_path']); + $binding['search_block_markup'] = $this->routeLinks($markup, $binding['source_path'], $routes); } } unset($binding); diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index d674a498..cf39a899 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -3203,8 +3203,8 @@ public function match(DOMElement $element, PatternContext $context): ?array 'className' => 'source-image', 'style' => array('border' => array('color' => '#ffffff', 'style' => 'solid')), )); -$assert(str_contains($borderedImage['innerHTML'], 'class="wp-block-image has-custom-border has-border-color source-image"'), 'core/image emits the custom-border class required by core save'); -$assert(str_contains($borderedImage['innerHTML'], 'style="border-color:#ffffff;border-style:solid"'), 'core/image retains canonical border support styles'); +$assert(str_contains($borderedImage['innerHTML'], '
create( 'core/table', diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index ea7bf0b4..05c5d241 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -99,9 +99,26 @@ $duplicateForms = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
')))->toArray()['source_reports']['wordpress_site_plan']; $duplicateFormDeclaration = current(array_filter($duplicateForms['runtime_declarations'], static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); $assert(array(1, 2) === array_map(static fn(array $entity): int => (int) ($entity['bindings'][0]['occurrence'] ?? 0), $duplicateFormDeclaration['payload']['entities'] ?? array()), 'Identical page bindings use deterministic occurrence indexes instead of ambiguous cardinality claims.'); -$sharedFormArtifact = array('entrypoint' => 'index.html', 'files' => array('index.html' => '

Home

', 'about.html' => '

About

')); +$assetFormPlan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'assets/form.svg' => '')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); +$assetFormDeclaration = current(array_filter($assetFormPlan['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); +$assetFormBinding = $assetFormDeclaration['payload']['entities'][0]['bindings'][0] ?? array(); +$assert(str_contains((string) ($assetFormBinding['search_block_markup'] ?? ''), WordPressSitePlan::TOKEN_PREFIX) && 1 === substr_count((string) ($assetFormPlan['pages'][0]['canonical_block_markup'] ?? ''), (string) ($assetFormBinding['search_block_markup'] ?? '')), 'Entity binding anchors use the same canonical asset references and routes as page markup.'); +$assert(in_array('.provider-form', $assetFormBinding['superseded_runtime_selectors'] ?? array(), true), 'A provider binding that replaces a preserved form claims its matching runtime-island selector.'); +$composedFormPlan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'assets/form.svg' => '')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); +$composedFormDeclaration = current(array_filter($composedFormPlan['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); +$composedFormBinding = $composedFormDeclaration['payload']['entities'][0]['bindings'][0] ?? array(); +$composedFormMarkup = (string) ($composedFormPlan['pages'][0]['canonical_block_markup'] ?? ''); +$assert(str_contains($composedFormMarkup, 'Updates') && str_contains($composedFormMarkup, 'Join the list.') && str_contains($composedFormMarkup, WordPressSitePlan::TOKEN_PREFIX), 'A form with one controls-only subtree preserves its surrounding visual content as native blocks.'); +$assert(str_contains((string) ($composedFormBinding['search_block_markup'] ?? ''), 'class="controls"') && !str_contains((string) ($composedFormBinding['search_block_markup'] ?? ''), 'Updates') && 1 === substr_count($composedFormMarkup, (string) ($composedFormBinding['search_block_markup'] ?? '')), 'A compositional form binding replaces only its exact controls slot.'); +$sharedFormArtifact = array('entrypoint' => 'index.html', 'files' => array('index.html' => '

Home

', 'about.html' => '

About

')); $sharedFormResult = (new ArtifactCompiler())->compile($sharedFormArtifact)->toArray(); -$assert(isset($sharedFormResult['source_reports']['wordpress_site_plan_diagnostics']) && !isset($sharedFormResult['source_reports']['wordpress_site_plan']), 'Bindings extracted into shared shells fail closed instead of pointing at missing page anchors.'); +$sharedFormPlan = $sharedFormResult['source_reports']['wordpress_site_plan'] ?? array(); +$sharedFormParts = array_column($sharedFormPlan['template_parts'] ?? array(), 'area'); +$sharedFormPages = array_column($sharedFormPlan['pages'] ?? array(), 'canonical_block_markup', 'source_path'); +$sharedFormDeclaration = current(array_filter($sharedFormPlan['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); +$assert(array() !== $sharedFormPlan && in_array('header', $sharedFormParts, true) && !in_array('footer', $sharedFormParts, true), 'An unbound shared header extracts while a shared footer containing runtime binding anchors remains page-owned.'); +$assert(in_array('wordpress_site_plan_shell_retained_runtime_binding', array_column($sharedFormPlan['diagnostics'], 'code'), true), 'Binding-aware shell retention emits a bounded reason-coded diagnostic.'); +foreach ($sharedFormDeclaration['payload']['entities'] ?? array() as $entity) foreach ($entity['bindings'] ?? array() as $binding) $assert(($binding['occurrence'] ?? 0) <= substr_count($sharedFormPages[$binding['source_path']] ?? '', $binding['search_block_markup'] ?? ''), 'Every retained shared-shell binding keeps its declared source-page anchor.'); $unsafeFormScript = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '

Home

', 'contact.html' => '
')))->toArray()['source_reports']['wordpress_site_plan']; $unsafeFormDeclarations = array(); foreach ($unsafeFormScript['runtime_declarations'] as $declaration) $unsafeFormDeclarations[$declaration['kind'] . ':' . ($declaration['type'] ?? $declaration['capability'])] = $declaration; $unsafeSupersededScripts = array_merge(...array_map(static fn(array $entity): array => $entity['superseded_scripts'] ?? array(), $unsafeFormDeclarations['entity_collection:forms']['payload']['entities'])); diff --git a/php-transformer/tests/fixtures/parity/html-layered-background-top-image.json b/php-transformer/tests/fixtures/parity/html-layered-background-top-image.json index cc661644..bbe01d28 100644 --- a/php-transformer/tests/fixtures/parity/html-layered-background-top-image.json +++ b/php-transformer/tests/fixtures/parity/html-layered-background-top-image.json @@ -17,7 +17,7 @@ }, "expected_blocks": [ { "path": "blocks.0", "name": "core/group" }, - { "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "url": "assets/top.png", "alt": "Featured story", "className": "blocks-engine-background-image" } }, + { "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "url": "assets/top.png", "alt": "Featured story", "className": "blocks-engine-background-image", "width": "691px", "height": "345.5px", "scale": "cover" } }, { "path": "blocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "Featured story", "level": 1 } } ], "expected_fallbacks": [], @@ -25,6 +25,7 @@ { "path": "status", "assert": "equals", "value": "success" }, { "path": "serialized_blocks", "assert": "contains", "value": "hero layered-image" }, { "path": "serialized_blocks", "assert": "contains", "value": "src=\"assets/top.png\"" }, + { "path": "serialized_blocks", "assert": "contains", "value": "style=\"object-fit:cover;width:691px;height:345.5px\"" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "src=\"assets/bottom.png\"" }, { "path": "fallbacks", "assert": "count", "count": 0 } ] diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index 31f1399a..d527f253 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -105,6 +105,18 @@ $assert('text' === ($image['assets'][0]['content_encoding'] ?? '') && ! isset($image['assets'][0]['content_base64']) && '' !== $imageCss, 'stylesheet projection drops the stale base64 twin and keeps the rewritten text as the sole payload representation'); $assert(1 === preg_match('/