diff --git a/php-transformer/src/Contract/ConversionFindingContract.php b/php-transformer/src/Contract/ConversionFindingContract.php index 8220196c..fb2830bc 100644 --- a/php-transformer/src/Contract/ConversionFindingContract.php +++ b/php-transformer/src/Contract/ConversionFindingContract.php @@ -92,7 +92,7 @@ final class ConversionFindingContract */ private const ARRAY_FIELDS = array( 'source_selector_specificity', 'context', 'events', 'classification', - 'controls', 'control', 'form', 'readable_blocks', 'signals', + 'controls', 'control', 'form', 'readable_blocks', 'signals', 'materialization_target', 'products', 'source_items', 'block_items', 'source_item', 'block_item', ); diff --git a/php-transformer/src/Contract/ConversionReportProjection.php b/php-transformer/src/Contract/ConversionReportProjection.php index 24c46729..a48de9eb 100644 --- a/php-transformer/src/Contract/ConversionReportProjection.php +++ b/php-transformer/src/Contract/ConversionReportProjection.php @@ -145,21 +145,25 @@ private static function fallbackDiagnostics(array $fallbacks): array 'pattern_family' => $fallback['pattern_family'] ?? '', 'pattern_family_detail' => $fallback['pattern_family_detail'] ?? '', 'materialization_hint' => $fallback['materialization_hint'] ?? '', + 'materialization_target' => $fallback['materialization_target'] ?? array(), 'source_format' => $fallback['source_format'] ?? '', 'source' => $fallback['source'] ?? '', 'scope' => $fallback['scope'] ?? '', 'source_path' => $fallback['source_path'] ?? '', 'tag' => $fallback['tag'] ?? '', 'selector' => $fallback['selector'] ?? '', + 'container_selector' => $fallback['container_selector'] ?? '', 'source_selector' => $fallback['source_selector'] ?? '', 'source_selector_specificity' => $fallback['source_selector_specificity'] ?? array(), 'parent_reason' => $fallback['parent_reason'] ?? '', 'ancestor_reason' => $fallback['ancestor_reason'] ?? '', 'child_count' => $fallback['child_count'] ?? null, 'control_count' => $fallback['control_count'] ?? null, + 'product_count' => $fallback['product_count'] ?? null, 'form' => $fallback['form'] ?? array(), 'control' => $fallback['control'] ?? array(), 'controls' => $fallback['controls'] ?? array(), + 'products' => $fallback['products'] ?? array(), 'context' => $fallback['context'] ?? array(), 'events' => $fallback['events'] ?? array(), 'script_dependency_hint' => $fallback['script_dependency_hint'] ?? '', diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php b/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php index 6ca3870a..7394bcb3 100644 --- a/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php +++ b/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php @@ -78,6 +78,7 @@ public function collect( 'suggested_repair_class' => $fallback['suggested_repair_class'] ?? null, 'suggested_primitive' => $fallback['suggested_primitive'] ?? null, 'materialization_hint' => $fallback['materialization_hint'] ?? null, + 'materialization_target' => $fallback['materialization_target'] ?? null, 'tag' => $fallback['tag'] ?? null, 'selector' => $fallback['selector'] ?? null, 'pattern_family' => $fallback['pattern_family'] ?? null, diff --git a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php index 1cf7b333..7001af75 100644 --- a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php +++ b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php @@ -51,6 +51,7 @@ public static function withGenericFindingMetadata(array $fields): array private static function defaults(array $fields): array { $code = (string) ($fields['diagnostic_code'] ?? ''); + $formHasControls = 'html_form_fallback' === $code && ! empty($fields['controls']) && is_array($fields['controls']); return match ( $code ) { 'html_form_fallback' => array( @@ -60,11 +61,17 @@ private static function defaults(array $fields): array 'diagnostic_class' => 'runtime_island_preserved', 'preservation_strategy' => 'fallback_metadata_with_readable_blocks', 'runtime_requirement' => 'server_or_client_form_handler', - 'recoverability' => 'recoverable_with_runtime_mapping', - 'actionability' => 'map_form_action_controls_and_submission_handler', - 'suggested_repair_class' => 'preserve_runtime_island', + 'recoverability' => $formHasControls ? 'recoverable_with_form_provider_materialization' : 'recoverable_with_runtime_mapping', + 'actionability' => $formHasControls ? 'materialize_detected_form_with_form_provider' : 'map_form_runtime_or_preserve_handler', + 'suggested_repair_class' => $formHasControls ? 'materialize_form_provider' : 'preserve_runtime_island', 'suggested_primitive' => 'form', - 'materialization_hint' => 'preserve_form_markup_or_replace_with_form_block_integration', + 'materialization_hint' => $formHasControls ? 'map_form_action_controls_labels_options_and_submit_text_to_a_form_provider' : 'preserve_form_runtime_source_until_controls_can_be_mapped', + 'materialization_target' => $formHasControls ? array( + 'capability' => 'form', + 'entity' => 'form', + 'provider_role' => 'form_provider', + 'requires' => array( 'controls', 'form' ), + ) : array(), ), 'html_product_grid_fallback' => array( 'severity' => 'info', @@ -77,7 +84,32 @@ private static function defaults(array $fields): array 'actionability' => 'materialize_detected_products_in_a_commerce_provider', 'suggested_repair_class' => 'materialize_commerce_products', 'suggested_primitive' => 'product_grid', - 'materialization_hint' => 'layout_blocks_are_emitted_as_is; map_each_detected_product_name_price_and_cart_control_onto_a_commerce_provider', + 'materialization_hint' => 'layout_blocks_are_emitted_as_is; map_detected_product_names_prices_images_and_descriptions_to_a_shop_provider', + 'materialization_target' => array( + 'capability' => 'shop', + 'entity' => 'product', + 'provider_role' => 'commerce_product_provider', + 'requires' => array( 'products' ), + ), + ), + 'html_commerce_controls_fallback' => array( + 'severity' => 'warning', + 'conversion_classification' => 'runtime_island_preserved', + 'loss_class' => 'runtime_island_preserved', + 'diagnostic_class' => 'commerce_runtime_controls_detected', + 'preservation_strategy' => 'layout_blocks_with_commerce_control_metadata', + 'runtime_requirement' => 'commerce_cart_runtime', + 'recoverability' => 'recoverable_with_commerce_cart_runtime_binding', + 'actionability' => 'bind_quantity_and_add_to_cart_controls_to_cart_runtime_after_product_materialization', + 'suggested_repair_class' => 'materialize_commerce_runtime', + 'suggested_primitive' => 'commerce_controls', + 'materialization_hint' => 'product_data_can_be_seeded_by_a_shop_provider; core_blocks_cannot_provide_cart_state_or_quantity_mutation_without_runtime_binding', + 'materialization_target' => array( + 'capability' => 'shop', + 'entity' => 'commerce_controls', + 'provider_role' => 'commerce_cart_runtime', + 'requires' => array( 'controls', 'seeded_products' ), + ), ), 'html_script_fallback' => array( 'severity' => 'warning', @@ -225,6 +257,7 @@ private static function patternFamily(array $fields): string return match ( $code ) { 'html_form_fallback' => 'interactive_form', 'html_product_grid_fallback' => 'commerce_product_grid', + 'html_commerce_controls_fallback' => 'commerce_controls', 'html_script_fallback' => 'runtime_script', 'interactive_control_behavior_lost' => 'interactive_control', 'html_iframe_embed_fallback' => 'external_embed', diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 7317edb8..703c0f27 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -454,6 +454,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->recordRuntimeIslandsForPreservedHtmlBlocks($blocks); $this->appendInteractiveControlBehaviorLossFallbacks($body, $fallbacks); $this->appendProductGridFallbacks($body, $fallbacks); + $this->appendCommerceControlsFallbacks($body, $fallbacks); $sourceProvenance = $this->sourceProvenanceForBlocks($blocks); $serializedBlocks = $this->runtime->serializeBlocks($blocks); $blockValidityReport = $this->runtime->validateBlockSerialization($blocks); @@ -3473,7 +3474,7 @@ private function appendProductGridFallbacks(DOMElement $body, array &$fallbacks) 'reason' => 'commerce_product_grid_detected', 'diagnostic_code' => 'html_product_grid_fallback', 'kind' => 'html_product_grid_fallback', - 'message' => 'A product grid was detected; per-card commerce structure was extracted so a consumer can materialize the products.', + 'message' => 'A product grid was detected; per-card commerce structure was extracted so a shop provider can materialize the products.', 'source_format' => 'html', 'tag' => strtolower($element->tagName), 'selector' => $this->elementSelector($element), @@ -3486,6 +3487,52 @@ private function appendProductGridFallbacks(DOMElement $body, array &$fallbacks) } } + /** + * Surface commerce-specific runtime controls separately from the surrounding + * product-grid structure. The transformer can emit editable layout/product + * metadata, but quantity and add-to-cart controls require a commerce runtime. + * + * @param array> $fallbacks + */ + private function appendCommerceControlsFallbacks(DOMElement $body, array &$fallbacks): void + { + $emitted = 0; + foreach ( $body->getElementsByTagName('*') as $element ) { + if ( ! $element instanceof DOMElement ) { + continue; + } + + if ( $emitted >= self::MAX_INTERACTION_CANDIDATES ) { + return; + } + + if ( ! $this->isProductGridContainer($element) ) { + continue; + } + + $controlGroups = $this->commerceControlGroupsForContainer($element); + if ( array() === $controlGroups ) { + continue; + } + + $fallbacks[] = FallbackDiagnostic::build(array_filter(array( + 'type' => 'html', + 'reason' => 'commerce_controls_require_runtime', + 'diagnostic_code' => 'html_commerce_controls_fallback', + 'kind' => 'html_commerce_controls_fallback', + 'message' => 'Commerce quantity and add-to-cart controls were detected; product data can be seeded by a shop provider, but these controls need cart runtime binding rather than a static core block approximation.', + 'source_format' => 'html', + 'tag' => strtolower($element->tagName), + 'selector' => $this->elementSelector($element), + 'container_selector' => $this->elementSelector($element), + 'context' => $this->sourceContext($element), + 'controls' => $controlGroups, + 'control_count' => count($controlGroups), + ), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value), $this->fallbackProvenance); + ++$emitted; + } + } + /** * Whether an element is a plausible product-grid container: a list (ul/ol) or * an element the structure classifier already flags as grid_like. @@ -3525,6 +3572,35 @@ private function productCardsForContainer(DOMElement $container): array return $products; } + /** + * @return array> + */ + private function commerceControlGroupsForContainer(DOMElement $container): array + { + $groups = array(); + foreach ( $container->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + + $product = $this->productCardData($child); + if ( null === $product || empty($product['has_cart_control']) ) { + continue; + } + + $hasQuantity = $this->hasQuantityControl($child); + $groups[] = array_filter(array( + 'product_name' => $product['name'] ?? '', + 'source_selector' => $this->elementSelector($child), + 'has_quantity_control' => $hasQuantity, + 'has_cart_control' => true, + 'runtime_requirement' => 'commerce_cart_runtime', + ), static fn (mixed $value): bool => null !== $value && '' !== $value); + } + + return $groups; + } + /** * Build the per-card product payload when a card qualifies, else null. * @@ -3765,6 +3841,46 @@ private function hasCartControl(DOMElement $card): bool return false; } + /** + * Whether the card contains quantity UI: number input, spinbutton, +/- controls, + * or explicit quantity labels/classes/ARIA. This is diagnostic only. + */ + private function hasQuantityControl(DOMElement $card): bool + { + foreach ( $card->getElementsByTagName('*') as $descendant ) { + if ( ! $descendant instanceof DOMElement ) { + continue; + } + + $tagName = strtolower($descendant->tagName); + $role = strtolower($this->attr($descendant, 'role')); + if ( 'input' === $tagName && 'number' === strtolower($this->attr($descendant, 'type')) ) { + return true; + } + if ( 'spinbutton' === $role ) { + return true; + } + + $haystack = strtolower(implode(' ', array( + $this->attr($descendant, 'class'), + $this->attr($descendant, 'id'), + $this->attr($descendant, 'name'), + $this->attr($descendant, 'aria-label'), + implode(' ', $this->safeDataAttributes($descendant)), + $this->collapsedText($descendant), + ))); + + if ( preg_match('/\b(?:qty|quantity|decrease|increase)\b/', $haystack) ) { + return true; + } + if ( in_array($tagName, array( 'button', 'a' ), true) && preg_match('/^[+\x{2212}-]$/u', trim($this->collapsedText($descendant))) ) { + return true; + } + } + + return false; + } + /** * An optional short product description: the first paragraph in the card whose * text is neither the name nor a price. Returns null when none is present. @@ -5546,7 +5662,7 @@ private function formFallbackFinding(DOMElement $element, ?array $readableFormBl 'type' => 'html', 'reason' => 'form_requires_runtime', 'diagnostic_code' => 'html_form_fallback', - 'message' => 'Form HTML requires runtime behavior and was preserved as safe fallback metadata.', + 'message' => 'Form intent and controls were extracted as provider-materializable metadata; the source form markup is preserved until a form provider materializes it.', 'source_format' => 'html', 'tag' => strtolower($element->tagName), 'selector' => $this->elementSelector($element), diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 0e7f939d..9dedc1de 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -484,7 +484,10 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert('html_form_fallback' === ($formFallbackDiagnostic['diagnostic_code'] ?? ''), 'data-entry runtime form fallback carries the form diagnostic code'); $assert('email' === ($formFallbackDiagnostic['controls'][0]['name'] ?? ''), 'data-entry runtime form fallback carries generic control metadata'); $assert('/contact' === ($formFallbackDiagnostic['form']['action'] ?? ''), 'data-entry runtime form fallback carries form action metadata'); +$assert('form' === ($formFallbackDiagnostic['materialization_target']['capability'] ?? ''), 'data-entry runtime form targets a form materializer capability'); +$assert('form_provider' === ($formFallbackDiagnostic['materialization_target']['provider_role'] ?? ''), 'data-entry runtime form targets a form provider role'); $assertNormalizedFallbackDiagnostic($formFallback['source_reports']['conversion_report']['fallback_diagnostics'][0] ?? array(), 'html_form_fallback', 'warning', 'server_or_client_form_handler', 'form'); +$assert('form_provider' === ($formFallback['source_reports']['conversion_report']['fallback_diagnostics'][0]['materialization_target']['provider_role'] ?? ''), 'conversion report preserves form provider materialization target'); $assert('core/html' === ($formFallback['blocks'][0]['blockName'] ?? ''), 'data-entry form materializes as preserved form HTML'); $assert(str_contains((string) ($formFallback['serialized_blocks'] ?? ''), '
transform( + '

Newsletter

' +)->toArray(); +$newsletterFallbackDiagnostic = $newsletterFallback['fallbacks'][0] ?? array(); +$assert('html_form_fallback' === ($newsletterFallbackDiagnostic['diagnostic_code'] ?? ''), 'static newsletter form stays classified as a provider-materializable form target'); +$assert('interactive_form' === ($newsletterFallbackDiagnostic['pattern_family'] ?? ''), 'static newsletter form uses the interactive_form family'); +$assert('form' === ($newsletterFallbackDiagnostic['suggested_primitive'] ?? ''), 'static newsletter form suggests a form primitive, not a fake native layout'); +$assert('form_provider' === ($newsletterFallbackDiagnostic['materialization_target']['provider_role'] ?? ''), 'static newsletter form declares the form provider materialization role'); +$assert(0 === substr_count((string) ($newsletterFallback['serialized_blocks'] ?? ''), '