From 702eb083ee04f39c73df0186275ee5902c97aee6 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 23:01:42 -0400 Subject: [PATCH 1/2] Report static interaction candidates --- .../Contract/ConversionReportProjection.php | 15 ++ .../src/Contract/TransformerResult.php | 2 +- .../src/HtmlToBlocks/HtmlTransformer.php | 177 ++++++++++++++++++ php-transformer/tests/contract/run.php | 14 ++ .../html-interaction-candidates-report.json | 40 ++++ 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 php-transformer/tests/fixtures/parity/html-interaction-candidates-report.json diff --git a/php-transformer/src/Contract/ConversionReportProjection.php b/php-transformer/src/Contract/ConversionReportProjection.php index 1f1f00e..ff8f26a 100644 --- a/php-transformer/src/Contract/ConversionReportProjection.php +++ b/php-transformer/src/Contract/ConversionReportProjection.php @@ -28,6 +28,7 @@ public static function fromResultParts(string $sourceFormat, array $blocks, arra 'fallback_diagnostics' => self::fallbackDiagnostics($fallbacks), 'asset_refs' => self::assetReferences($blocks, $sourceReports), 'navigation_candidates' => self::navigationCandidates($blocks, $sourceReports), + 'interaction_candidates' => self::interactionCandidates($sourceReports), 'presentation_gaps' => self::presentationGaps($sourceReports), 'metrics' => $metrics, ); @@ -256,6 +257,20 @@ private static function presentationGaps(array $sourceReports): array return $gaps; } + /** + * @param array $sourceReports + * @return array> + */ + private static function interactionCandidates(array $sourceReports): array + { + $candidates = $sourceReports['interaction_candidates'] ?? array(); + if ( ! is_array($candidates) ) { + return array(); + } + + return self::dedupeRows(array_values(array_filter($candidates, static fn (mixed $candidate): bool => is_array($candidate)))); + } + /** * @param array> $blocks */ diff --git a/php-transformer/src/Contract/TransformerResult.php b/php-transformer/src/Contract/TransformerResult.php index 12dd087..efe1973 100644 --- a/php-transformer/src/Contract/TransformerResult.php +++ b/php-transformer/src/Contract/TransformerResult.php @@ -128,7 +128,7 @@ public static function assertCanonicalEnvelope(array $result, bool $requireMater throw new InvalidArgumentException('Canonical transformer result conversion report is missing source_format.'); } - foreach ( array( 'source_summary', 'selector_summary', 'fallback_diagnostics', 'asset_refs', 'navigation_candidates', 'presentation_gaps', 'metrics' ) as $key ) { + foreach ( array( 'source_summary', 'selector_summary', 'fallback_diagnostics', 'asset_refs', 'navigation_candidates', 'interaction_candidates', 'presentation_gaps', 'metrics' ) as $key ) { if ( array_key_exists($key, $conversionReport) && ! is_array($conversionReport[$key]) ) { throw new InvalidArgumentException(sprintf('Canonical transformer result conversion report %s must be an array.', $key)); } diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 76ec83e..21fa6be 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -13,6 +13,8 @@ final class HtmlTransformer { + private const MAX_INTERACTION_CANDIDATES = 100; + private readonly BlockFactory $blockFactory; /** @@ -124,6 +126,7 @@ public function transform(string $html, array $options = array()): TransformerRe } $fallbacks = array(); + $interactionCandidates = $this->interactionCandidates($body); $blocks = $this->convertChildren($body, $fallbacks, true); $sourceProvenance = $this->sourceProvenanceForBlocks($blocks); $serializedBlocks = $this->runtime->serializeBlocks($blocks); @@ -150,6 +153,7 @@ public function transform(string $html, array $options = array()): TransformerRe $metrics = $this->metrics($html, $blocks, $serializedBlocks, $fallbacks, $diagnostics, $startedAt); $sourceReports = array( + 'interaction_candidates' => $interactionCandidates, 'html' => array( 'presentation_signals' => $this->presentationProvenance, 'source_provenance' => $sourceProvenance, @@ -946,6 +950,179 @@ private function childElementCount(DOMElement $element): int return $count; } + /** + * @return array> + */ + private function interactionCandidates(DOMElement $root): array + { + $candidates = array(); + $seen = array(); + foreach ( $root->getElementsByTagName('*') as $element ) { + if ( ! $element instanceof DOMElement ) { + continue; + } + + foreach ( $this->interactionCandidatesForElement($element) as $candidate ) { + $key = json_encode($candidate, JSON_UNESCAPED_SLASHES); + if ( ! is_string($key) || isset($seen[$key]) ) { + continue; + } + $seen[$key] = true; + $candidates[] = $candidate; + if ( count($candidates) >= self::MAX_INTERACTION_CANDIDATES ) { + return $candidates; + } + } + } + + return $candidates; + } + + /** + * @return array> + */ + private function interactionCandidatesForElement(DOMElement $element): array + { + $tagName = strtolower($element->tagName); + $role = strtolower($this->attr($element, 'role')); + $classes = strtolower($this->attr($element, 'class')); + $id = strtolower($this->attr($element, 'id')); + $data = $this->safeDataAttributes($element); + $dataText = strtolower(implode(' ', array_merge(array_keys($data), array_values($data)))); + $nameText = trim($classes . ' ' . $id . ' ' . $dataText); + $events = $this->eventMetadata($element); + $actionDataAttributes = array_keys(array_filter($data, static fn (string $value, string $name): bool => preg_match('/^data-(?:action|on|event)$/i', $name) && '' !== trim($value), ARRAY_FILTER_USE_BOTH)); + $hasAriaControl = '' !== trim($this->attr($element, 'aria-controls')) || '' !== trim($this->attr($element, 'aria-expanded')); + $candidates = array(); + + if ( 'details' === $tagName ) { + $candidates[] = $this->interactionCandidate($element, 'details', 'summary', $this->targetForElement($element), array('details_element'), 'high', 'native_toggle'); + } + + if ( 'form' === $tagName ) { + $metadata = $this->formMetadata($element); + $candidates[] = $this->interactionCandidate($element, 'form', 'submit', (string) ($metadata['action'] ?? ''), array_filter(array('form_element', (string) ($metadata['method'] ?? ''))), 'high', 'form_submission'); + } + + if ( in_array($tagName, array('button', 'a'), true) && ( array() !== $events || array() !== $actionDataAttributes || $hasAriaControl ) ) { + $candidates[] = $this->interactionCandidate($element, 'control', $this->controlTrigger($element, $events), $this->controlledTarget($element), $this->controlEvidence($element, $events, $actionDataAttributes), $hasAriaControl ? 'high' : 'medium', 'client_runtime'); + } + + if ( 'dialog' === $tagName || in_array($role, array('dialog', 'alertdialog'), true) || preg_match('/(?:^|[\s_-])(?:modal|dialog|popup|lightbox)(?:$|[\s_-])/', $nameText) ) { + $candidates[] = $this->interactionCandidate($element, 'modal', $this->modalTriggerHint($element), $this->targetForElement($element), array_filter(array('modal_like', 'dialog' === $tagName ? 'dialog_element' : '', '' !== $role ? 'role:' . $role : '')), 'medium', 'modal_runtime'); + } + + if ( 'tablist' === $role || 'tab' === $role ) { + $candidates[] = $this->interactionCandidate($element, 'tabs', 'tab' === $role ? 'tab_select' : 'tablist', $this->controlledTarget($element), array_filter(array('role:' . $role, '' !== $this->attr($element, 'aria-controls') ? 'aria-controls' : '')), 'high', 'tab_state'); + } + + if ( ( in_array($tagName, array('button', 'a'), true) || '' !== $role ) && preg_match('/(?:^|[\s_-])accordion(?:$|[\s_-])/', $nameText) ) { + $candidates[] = $this->interactionCandidate($element, 'accordion', $this->controlTrigger($element, $events), $this->controlledTarget($element), array_filter(array('accordion_like', '' !== $this->attr($element, 'aria-expanded') ? 'aria-expanded' : '')), 'medium', 'accordion_state'); + } + + if ( preg_match('/(?:^|[\s_-])(?:carousel|slider|slideshow|swiper)(?:$|[\s_-])/', $nameText) ) { + $candidates[] = $this->interactionCandidate($element, 'carousel', $this->carouselTriggerHint($element), $this->targetForElement($element), array('carousel_like'), 'medium', 'carousel_runtime'); + } + + return $candidates; + } + + /** + * @param array $evidence + * @return array + */ + private function interactionCandidate(DOMElement $element, string $kind, string $trigger, string $target, array $evidence, string $confidence, string $runtimeRequirement): array + { + return array_filter( + array( + 'selector' => $this->elementSelector($element), + 'kind' => $kind, + 'trigger' => $trigger, + 'target' => $target, + 'evidence' => array_values(array_unique(array_filter($evidence, static fn (string $value): bool => '' !== $value))), + 'confidence' => $confidence, + 'runtime_requirement' => $runtimeRequirement, + 'materialization_hint' => $this->materializationHintForInteractionKind($kind), + ), + static fn (mixed $value): bool => '' !== $value && array() !== $value + ); + } + + private function targetForElement(DOMElement $element): string + { + $id = trim($this->attr($element, 'id')); + return '' !== $id ? '#' . $id : $this->elementSelector($element); + } + + private function controlledTarget(DOMElement $element): string + { + $target = trim($this->attr($element, 'aria-controls')); + return '' !== $target ? '#' . ltrim($target, '#') : $this->targetForElement($element); + } + + /** + * @param array> $events + */ + private function controlTrigger(DOMElement $element, array $events): string + { + if ( array() !== $events ) { + return (string) ($events[0]['type'] ?? 'event'); + } + + $type = strtolower($this->attr($element, 'type')); + return 'submit' === $type ? 'submit' : 'click'; + } + + /** + * @param array> $events + * @param array $actionDataAttributes + * @return array + */ + private function controlEvidence(DOMElement $element, array $events, array $actionDataAttributes): array + { + $evidence = array(); + foreach ( $events as $event ) { + $attribute = (string) ($event['attribute'] ?? ''); + if ( '' !== $attribute ) { + $evidence[] = $attribute; + } + } + foreach ( $actionDataAttributes as $attribute ) { + $evidence[] = $attribute; + } + if ( '' !== trim($this->attr($element, 'aria-controls')) ) { + $evidence[] = 'aria-controls'; + } + if ( '' !== trim($this->attr($element, 'aria-expanded')) ) { + $evidence[] = 'aria-expanded'; + } + + return $evidence; + } + + private function modalTriggerHint(DOMElement $element): string + { + return '' !== trim($this->attr($element, 'open')) ? 'open' : 'show'; + } + + private function carouselTriggerHint(DOMElement $element): string + { + return preg_match('/(?:^|[\s_-])(?:next|prev|previous)(?:$|[\s_-])/', strtolower($this->attr($element, 'class'))) ? 'advance' : 'slide'; + } + + private function materializationHintForInteractionKind(string $kind): string + { + return match ( $kind ) { + 'details' => 'preserve_native_details', + 'form' => 'preserve_or_replace_form_runtime', + 'tabs' => 'materialize_tab_panels_or_runtime', + 'accordion' => 'materialize_expanded_state_or_runtime', + 'carousel' => 'preserve_static_slides_or_runtime', + 'modal' => 'preserve_dialog_markup_or_runtime', + default => 'preserve_static_markup_with_runtime_note', + }; + } + private function closestTagName(DOMElement $element): ?string { return $element->parentNode instanceof DOMElement ? strtolower($element->parentNode->tagName) : null; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index a75f65d..bf94fff 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -127,6 +127,10 @@ function serialize_blocks(array $blocks): string $formDiagnostic = $formFallback['source_reports']['conversion_report']['fallback_diagnostics'][0] ?? array(); $assert(array() === ($formFallback['blocks'] ?? array()), 'form fallback does not synthesize canonical blocks'); $assertNormalizedFallbackDiagnostic($formDiagnostic, 'html_form_fallback', 'warning', 'server_or_client_form_handler', 'form'); +$assert('form' === ($formFallback['source_reports']['interaction_candidates'][0]['kind'] ?? ''), 'HTML source report exposes form interaction candidate'); +$assert('form' === ($formFallback['source_reports']['conversion_report']['interaction_candidates'][0]['kind'] ?? ''), 'conversion report projects interaction candidates'); +$assert('/contact' === ($formFallback['source_reports']['interaction_candidates'][0]['target'] ?? ''), 'form interaction candidate exposes action target'); +$assert('html_form_fallback' === ($formDiagnostic['diagnostic_code'] ?? ''), 'conversion report exposes form fallback diagnostic code'); $assert('/contact' === ($formDiagnostic['form']['action'] ?? ''), 'conversion report exposes form action metadata'); $assert('post' === ($formDiagnostic['form']['method'] ?? ''), 'conversion report exposes normalized form method metadata'); $assert(3 === ($formDiagnostic['control_count'] ?? null), 'conversion report exposes form control count'); @@ -150,6 +154,16 @@ function serialize_blocks(array $blocks): string $assertNormalizedFallbackDiagnostic($diagnosticsByCode['html_unsupported_element'] ?? array(), 'html_unsupported_element', 'info', 'unknown', 'core/html'); $assertNormalizedFallbackDiagnostic($diagnosticsByCode['html_iframe_embed_fallback'] ?? array(), 'html_iframe_embed_fallback', 'warning', 'third_party_embed_runtime', 'embed'); +$interactions = ( new HtmlTransformer() )->transform( + '
Panel
Tab one
Join
' +)->toArray(); +$interactionKinds = array_map(static fn (array $candidate): string => (string) ($candidate['kind'] ?? ''), $interactions['source_reports']['interaction_candidates'] ?? array()); +$assert(in_array('control', $interactionKinds, true), 'HTML source report detects declarative control interactions'); +$assert(in_array('tabs', $interactionKinds, true), 'HTML source report detects tab interactions'); +$assert(in_array('modal', $interactionKinds, true), 'HTML source report detects modal-ish interactions'); +$assert(in_array('carousel', $interactionKinds, true), 'HTML source report detects carousel-ish interactions'); +$assert('#panel' === ($interactions['source_reports']['interaction_candidates'][0]['target'] ?? ''), 'control interaction candidate exposes aria-controls target'); + $assetMetadataOptions = array( 'context' => array( 'asset_metadata' => array( diff --git a/php-transformer/tests/fixtures/parity/html-interaction-candidates-report.json b/php-transformer/tests/fixtures/parity/html-interaction-candidates-report.json new file mode 100644 index 0000000..8e9b76e --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-interaction-candidates-report.json @@ -0,0 +1,40 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "html-interaction-candidates-report", + "description": "Reports bounded static interaction candidates from declarative HTML signals without requiring browser runtime execution.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/html-interaction-candidates-report.json", + "notes": "Product-neutral first-pass static interaction report fixture." + }, + "legacy_comparison": { + "skip": true, + "reason": "This upstream report fixture has no downstream legacy comparison." + }, + "operation": "html_transformer.transform", + "input": { + "content": "
FAQ

Answer.

Panel
Tab one
Join
Open signup
" + }, + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "source_reports.interaction_candidates", "assert": "count", "count": 11 }, + { "path": "source_reports.interaction_candidates.0.kind", "assert": "equals", "value": "details" }, + { "path": "source_reports.interaction_candidates.0.target", "assert": "equals", "value": "#faq" }, + { "path": "source_reports.interaction_candidates.1.kind", "assert": "equals", "value": "form" }, + { "path": "source_reports.interaction_candidates.1.trigger", "assert": "equals", "value": "submit" }, + { "path": "source_reports.interaction_candidates.1.target", "assert": "equals", "value": "/contact" }, + { "path": "source_reports.interaction_candidates.2.kind", "assert": "equals", "value": "control" }, + { "path": "source_reports.interaction_candidates.2.target", "assert": "equals", "value": "#panel" }, + { "path": "source_reports.interaction_candidates.3.kind", "assert": "equals", "value": "accordion" }, + { "path": "source_reports.interaction_candidates.4.kind", "assert": "equals", "value": "tabs" }, + { "path": "source_reports.interaction_candidates.5.kind", "assert": "equals", "value": "control" }, + { "path": "source_reports.interaction_candidates.5.target", "assert": "equals", "value": "#tab-one" }, + { "path": "source_reports.interaction_candidates.6.kind", "assert": "equals", "value": "tabs" }, + { "path": "source_reports.interaction_candidates.7.kind", "assert": "equals", "value": "modal" }, + { "path": "source_reports.interaction_candidates.8.kind", "assert": "equals", "value": "carousel" }, + { "path": "source_reports.interaction_candidates.9.kind", "assert": "equals", "value": "carousel" }, + { "path": "source_reports.interaction_candidates.10.kind", "assert": "equals", "value": "control" }, + { "path": "source_reports.conversion_report.interaction_candidates", "assert": "count", "count": 11 }, + { "path": "source_reports.conversion_report.interaction_candidates.10.evidence.0", "assert": "equals", "value": "onclick" } + ] +} From 7a764e68d9e811334ce64805a9b3a32b8893794b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 23:07:55 -0400 Subject: [PATCH 2/2] Align CSS asset fixture status --- .../fixtures/parity/artifact-css-import-font-references.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php-transformer/tests/fixtures/parity/artifact-css-import-font-references.json b/php-transformer/tests/fixtures/parity/artifact-css-import-font-references.json index 9aad590..38c1483 100644 --- a/php-transformer/tests/fixtures/parity/artifact-css-import-font-references.json +++ b/php-transformer/tests/fixtures/parity/artifact-css-import-font-references.json @@ -46,7 +46,7 @@ } }, "expect": [ - { "path": "status", "assert": "equals", "value": "success" }, + { "path": "status", "assert": "equals", "value": "success_with_warnings" }, { "path": "source_reports.artifact.asset_references", "assert": "count", "count": 4 }, { "path": "source_reports.artifact.asset_references.1.selector", "assert": "equals", "value": "css:@import(1)" }, { "path": "source_reports.artifact.asset_references.1.context", "assert": "equals", "value": "css-import" },