Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions php-transformer/src/Contract/ConversionReportProjection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -256,6 +257,20 @@ private static function presentationGaps(array $sourceReports): array
return $gaps;
}

/**
* @param array<string, mixed> $sourceReports
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $blocks
*/
Expand Down
2 changes: 1 addition & 1 deletion php-transformer/src/Contract/TransformerResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
177 changes: 177 additions & 0 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

final class HtmlTransformer
{
private const MAX_INTERACTION_CANDIDATES = 100;

private readonly BlockFactory $blockFactory;

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -946,6 +950,179 @@ private function childElementCount(DOMElement $element): int
return $count;
}

/**
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, string> $evidence
* @return array<string, mixed>
*/
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<int, array<string, string>> $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<int, array<string, string>> $events
* @param array<int, string> $actionDataAttributes
* @return array<int, string>
*/
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;
Expand Down
14 changes: 14 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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(
'<main><button aria-controls="panel" aria-expanded="false" data-action="toggle">Toggle</button><section id="panel">Panel</section><div role="tablist"><button role="tab" aria-controls="tab-one">One</button></div><div id="tab-one">Tab one</div><dialog id="signup">Join</dialog><div class="hero-carousel"><button class="carousel-next">Next</button></div></main>'
)->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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
@@ -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": "<main><details id=\"faq\"><summary>FAQ</summary><p>Answer.</p></details><form action=\"/contact\" method=\"post\"><input name=\"email\"><button type=\"submit\">Send</button></form><button class=\"accordion-toggle\" aria-controls=\"panel\" aria-expanded=\"false\" data-action=\"toggle\">Toggle panel</button><section id=\"panel\">Panel</section><div role=\"tablist\"><button role=\"tab\" aria-controls=\"tab-one\">One</button></div><div id=\"tab-one\">Tab one</div><dialog id=\"signup-modal\">Join</dialog><div class=\"hero-carousel\" data-event=\"slide\"><button class=\"carousel-next\">Next</button></div><a href=\"#signup-modal\" onclick=\"openSignup()\">Open signup</a></main>"
},
"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" }
]
}
Loading