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
122 changes: 110 additions & 12 deletions includes/class-static-site-importer-page-materializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,19 +163,25 @@ private static function dedupe_template_part_shell_blocks( string $content, arra
$footer_parts = self::template_part_write_sequences( $template_part_writes, 'footer' );
$remove_start = 0;
$remove_end = count( $top_level );
$matched_header_part = '';
$matched_footer_part = '';

foreach ( $header_parts as $part_blocks ) {
foreach ( $header_parts as $part ) {
$part_blocks = $part['blocks'];
$count = count( $part_blocks );
if ( $count > 0 && self::block_sequence_matches( array_slice( $top_level, 0, $count ), $part_blocks ) ) {
$remove_start = max( $remove_start, $count );
$matched_header_part = $part['path'];
break;
}
}

foreach ( $footer_parts as $part_blocks ) {
foreach ( $footer_parts as $part ) {
$part_blocks = $part['blocks'];
$count = count( $part_blocks );
if ( $count > 0 && $remove_start <= count( $top_level ) - $count && self::block_sequence_matches( array_slice( $top_level, -$count ), $part_blocks ) ) {
$remove_end = min( $remove_end, count( $top_level ) - $count );
$matched_footer_part = $part['path'];
break;
}
}
Expand All @@ -189,15 +195,21 @@ private static function dedupe_template_part_shell_blocks( string $content, arra
$deduped = trim( substr( $content, $start_offset, $end_offset - $start_offset ) );

$diagnostics[] = array(
'type' => 'template_part_shell_deduped',
'source' => 'static-site-importer/page-materializer',
'source_path' => $source_path,
'reason' => 'matching_header_footer_template_parts',
'removed' => array(
'type' => 'template_part_shell_deduped',
'source' => 'static-site-importer/page-materializer',
'source_path' => $source_path,
'reason' => 'matching_header_footer_template_parts',
'removed_header_blocks' => $remove_start,
'removed_footer_blocks' => count( $top_level ) - $remove_end,
'matched_header_template_part' => $matched_header_part,
'matched_footer_template_part' => $matched_footer_part,
'preserved_local_header_count' => self::html_tag_count( $deduped, 'header' ),
'preserved_local_footer_count' => self::html_tag_count( $deduped, 'footer' ),
'removed' => array(
'leading_blocks' => $remove_start,
'trailing_blocks' => count( $top_level ) - $remove_end,
),
'message' => 'Page body blocks matching generated header/footer template parts were removed to avoid duplicate global shell rendering.',
'message' => 'Page body blocks matching generated header/footer template parts were removed to avoid duplicate global shell rendering.',
);

return $deduped;
Expand Down Expand Up @@ -262,7 +274,7 @@ private static function top_level_serialized_blocks( string $markup ): array {
*
* @param array<string,string> $template_part_writes Generated template part writes.
* @param string $area Template part area.
* @return array<int,array<int,array{normalized:string}>>
* @return array<int,array{path:string,blocks:array<int,array{normalized:string}>}>
*/
private static function template_part_write_sequences( array $template_part_writes, string $area ): array {
$sequences = array();
Expand All @@ -274,13 +286,43 @@ private static function template_part_write_sequences( array $template_part_writ

$blocks = self::top_level_serialized_blocks( (string) $markup );
if ( ! empty( $blocks ) ) {
$sequences[] = $blocks;
$sequences[] = array(
'path' => self::template_part_report_path( (string) $path ),
'blocks' => $blocks,
);
}
}

return $sequences;
}

/**
* Convert a generated template-part write path into a report-stable relative path.
*
* @param string $path Template part write path.
* @return string
*/
private static function template_part_report_path( string $path ): string {
$normalized = str_replace( '\\', '/', $path );
$parts_pos = strrpos( $normalized, '/parts/' );
if ( false !== $parts_pos ) {
return ltrim( substr( $normalized, $parts_pos + 1 ), '/' );
}

return basename( $normalized );
}

/**
* Count preserved HTML tags in the post-dedupe page body.
*
* @param string $markup Serialized block markup.
* @param string $tag Tag name.
* @return int
*/
private static function html_tag_count( string $markup, string $tag ): int {
return preg_match_all( '/<\s*' . preg_quote( $tag, '/' ) . '\b/i', $markup ) ?: 0;
}

/**
* Check whether two top-level block sequences are the same rendered shell.
*
Expand Down Expand Up @@ -1126,7 +1168,7 @@ private static function fallback_html_to_native_blocks( string $html, string $fa
* @return string|null Serialized block markup, or null when unsupported.
*/
private static function safe_html_fragment_to_blocks( string $html ): ?string {
if ( preg_match( '/<\s*(?:script|style|iframe|canvas|svg|select|textarea)\b/i', $html ) ) {
if ( preg_match( '/<\s*(?:script|style|iframe|canvas)\b/i', $html ) ) {
return null;
}

Expand Down Expand Up @@ -1196,6 +1238,9 @@ private static function safe_dom_node_to_block_markup( DOMNode $node ): ?string
}

if ( in_array( $tag, array( 'a', 'button' ), true ) ) {
if ( 'button' === $tag && self::is_runtime_control_element( $node ) ) {
return null;
}
$content = self::safe_inline_html( $node );
if ( null === $content || '' === trim( wp_strip_all_tags( $content ) ) ) {
return null;
Expand Down Expand Up @@ -1285,7 +1330,7 @@ private static function safe_dom_node_to_block_markup( DOMNode $node ): ?string
foreach ( iterator_to_array( $node->childNodes ) as $child ) {
$child_markup = self::safe_dom_node_to_block_markup( $child );
if ( null === $child_markup ) {
return null;
$child_markup = self::fallback_markup_for_dom_node( $child );
}
$children .= $child_markup;
}
Expand All @@ -1303,6 +1348,56 @@ private static function safe_dom_node_to_block_markup( DOMNode $node ): ?string
return null;
}

/**
* Preserve an unsupported child as a bounded fallback inside an otherwise native container.
*
* @param DOMNode $node DOM node.
* @return string
*/
private static function fallback_markup_for_dom_node( DOMNode $node ): string {
$html = self::dom_node_outer_html( $node );
return '' === trim( $html ) ? '' : self::serialized_block_markup( 'core/html', array( 'content' => $html ), $html );
}

/**
* Serialize a DOM node back to its fragment HTML.
*
* @param DOMNode $node DOM node.
* @return string
*/
private static function dom_node_outer_html( DOMNode $node ): string {
if ( $node instanceof DOMText ) {
return (string) $node->textContent;
}

if ( $node->ownerDocument instanceof DOMDocument ) {
return (string) $node->ownerDocument->saveHTML( $node );
}

return '';
}

/**
* Detect source controls whose behavior depends on commerce/runtime JavaScript.
*
* @param DOMElement $element Element.
* @return bool
*/
private static function is_runtime_control_element( DOMElement $element ): bool {
$class = strtolower( trim( preg_replace( '/\s+/', ' ', $element->getAttribute( 'class' ) ) ?? '' ) );
if ( '' !== $class && preg_match( '/(^|[-_\s])(?:add-to-cart|qty|quantity|cart)([-_\s]|$)/', $class ) ) {
return true;
}

foreach ( iterator_to_array( $element->attributes ) as $attribute ) {
if ( $attribute instanceof DOMAttr && preg_match( '/^data-(?:dir|cart|quantity|qty|product|product-id)$/', strtolower( $attribute->name ) ) ) {
return true;
}
}

return false;
}

/**
* Extract className block attrs from an element.
*
Expand Down Expand Up @@ -1367,6 +1462,9 @@ private static function safe_inline_html( DOMElement $element ): ?string {
}

$tag = strtolower( $child->tagName );
if ( 'svg' === $tag && 'true' === strtolower( trim( $child->getAttribute( 'aria-hidden' ) ) ) ) {
continue;
}
if ( ! in_array( $tag, array( 'a', 'br', 'strong', 'b', 'em', 'i', 'span', 'small', 'mark', 'sub', 'sup' ), true ) || $child->hasAttribute( 'style' ) ) {
return null;
}
Expand Down
6 changes: 6 additions & 0 deletions includes/class-static-site-importer-report-diagnostics.php
Original file line number Diff line number Diff line change
Expand Up @@ -4144,6 +4144,12 @@ private static function compact_import_report_diagnostics( array $diagnostics ):
'stage',
'reason',
'message',
'removed_header_blocks',
'removed_footer_blocks',
'matched_header_template_part',
'matched_footer_template_part',
'preserved_local_header_count',
'preserved_local_footer_count',
'tag_name',
'element',
'html_excerpt',
Expand Down
37 changes: 37 additions & 0 deletions lib/fixture-matrix/collectors/visual-parity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ function normalizeVisualParityComparison(value, options = {}) {
return null;
}
const compareOptions = objectValue(obj.options || summary.options || visualCompare.options || comparison.options);
const viewport = normalizeViewport(firstObject([
obj.viewport,
summary.viewport,
visualCompare.viewport,
comparison.viewport,
compareOptions.viewport,
]));
const overlapPixels = firstNumber([summary.overlap_pixels, summary.overlapPixels, visualCompare.overlapPixels, visualCompare.overlap_pixels, comparison.overlapPixels, comparison.overlap_pixels, obj.overlap_pixels, obj.overlapPixels]);
const dimensionDeltaPixels = firstNumber([summary.dimension_delta_pixels, summary.dimensionDeltaPixels, visualCompare.dimensionDeltaPixels, visualCompare.dimension_delta_pixels, comparison.dimensionDeltaPixels, comparison.dimension_delta_pixels, obj.dimension_delta_pixels, obj.dimensionDeltaPixels]);
const safeMismatch = Number.isFinite(mismatchPixels) ? mismatchPixels : 0;
Expand Down Expand Up @@ -343,6 +350,8 @@ function normalizeVisualParityComparison(value, options = {}) {
has_overlap_signal: hasOverlapSignal,
dimension_delta_pixels: safeDimensionDelta,
dimension_mismatch: dimensionMismatch,
viewport,
full_page: firstDefined([obj.full_page, obj.fullPage, summary.full_page, summary.fullPage, visualCompare.full_page, visualCompare.fullPage, comparison.full_page, comparison.fullPage, compareOptions.full_page, compareOptions.fullPage]),
pixelmatch_threshold: firstNumber([compareOptions.threshold, compareOptions.pixelmatchThreshold, compareOptions.pixelmatch_threshold]),
source_path: firstString([sourceObject.path, sourceObject.url, obj.source_path, obj.sourcePath]),
source_screenshot: firstRef([artifacts.source_screenshot, artifactSlots.source_screenshot, files.sourceScreenshot, obj.source_screenshot, obj.sourceScreenshot]),
Expand All @@ -359,6 +368,32 @@ function normalizeVisualParityComparison(value, options = {}) {
return classification ? { ...withAlignment, ...classification } : withAlignment;
}

function firstObject(values) {
for (const value of values) {
const obj = objectValue(value);
if (Object.keys(obj).length > 0) {
return obj;
}
}
return {};
}

function firstDefined(values) {
for (const value of values) {
if (value !== undefined && value !== null && value !== '') {
return value;
}
}
return undefined;
}

function normalizeViewport(value) {
const obj = objectValue(value);
const width = finiteNumber(obj.width, 0);
const height = finiteNumber(obj.height, 0);
return width || height ? compactObject({ width, height }) : undefined;
}

function normalizeMismatchRegions(values) {
return values.map((value) => {
const row = objectValue(value);
Expand Down Expand Up @@ -604,6 +639,8 @@ export function collectVisualParityArtifacts(payload, options = {}) {
overlap_pixels: comparison.overlap_pixels,
dimension_delta_pixels: comparison.dimension_delta_pixels,
dimension_mismatch: comparison.dimension_mismatch,
viewport: comparison.viewport,
full_page: comparison.full_page,
}),
...(comparison.visual_diff_regions?.length ? { visual_diff_regions: comparison.visual_diff_regions } : {}),
...(comparison.visual_diff_cause_summary ? { visual_diff_cause_summary: comparison.visual_diff_cause_summary } : {}),
Expand Down
5 changes: 4 additions & 1 deletion lib/fixture-matrix/gutenberg-incompatibility-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const DEFAULT_CUSTOM_BLOCK_CANDIDATE_FIXTURE_THRESHOLD = 2;
const PATTERNS = [
pattern('static-form', 'Static newsletter/contact/search-style form markup that requires fields, submission semantics, validation, and response behavior.', 'core_html', 'WordPress core has no generic form block with field schema, submit handling, validation state, and submission response behavior.', 'custom-block-candidate', true, /<form\b|\bnewsletter\b|\bcontact\s+form\b|\bsubscribe\b/i),
pattern('js-commerce-controls', 'Commerce controls such as quantity steppers, add-to-cart controls, cart counters, and product option interactions.', 'runtime_island', 'Core blocks do not provide commerce purchase controls, quantity-stepper behavior, product option state, cart mutation, or add-to-cart runtime semantics.', 'custom-block-candidate', true, /quantity|\bqty\b|add[-_\s]?to[-_\s]?cart|\bcart\b|checkout|product[-_\s]?grid|product[-_\s]?option|woocommerce|\bprice\b/i),
pattern('contact-layout', 'Static contact/social layout wrappers and affordance links that can decompose into native layout and text/link blocks.', 'core_html', 'Static contact layout wrappers are transformer-convertible; embedded forms are tracked separately as provider/runtime materialization findings.', 'convertible', false, /\bcontact(?:[-_\s]?(?:content|layout|card|section|widget|info|details?))?\b|mailto:|tel:|\bsocial[-_\s]?(?:links?|icons?)\b/i),
pattern('contact-layout', 'Static contact/social layout wrappers that can decompose into native layout and text/link blocks.', 'core_html', 'Static contact layout wrappers are transformer-convertible; embedded forms are tracked separately as provider/runtime materialization findings and contact/social anchors are native links.', 'convertible', false, /\bcontact[-_\s](?:content|layout|card|section|widget|info|details?)\b/i),
pattern('inline-svg-filter-gradient', 'Inline SVG artwork that depends on defs such as filters, masks, clip paths, gradients, symbols, or data-URI SVG preservation.', 'core_html', 'Core image/media blocks cannot preserve arbitrary inline SVG DOM, defs/filter graphs, gradient IDs, masks, clip paths, or scriptable SVG structure as editable block attributes.', 'custom-block-candidate', true, /<svg\b|<filter\b|<lineargradient\b|<radialgradient\b|<clippath\b|<mask\b|<defs\b|data:image\/svg\+xml|svg[-_\s]?(?:filter|gradient|mask|clip|defs)/i),
pattern('css-grid-masonry', 'CSS grid/masonry layouts that rely on dense auto-placement, masonry-like columns, or source-order-independent packing.', 'fidelity_loss', 'Core layout blocks expose grid/flex controls but do not model masonry packing, dense auto-placement, or arbitrary CSS grid placement semantics as editable attributes.', 'custom-block-candidate', true, /masonry|grid-auto-flow\s*:\s*dense|column-count|columns\s*:|css[-_\s]?grid|grid-template|grid-area/i),
pattern('position-sticky-nav', 'Sticky/fixed navigation or header behavior coupled to source scroll state, offsets, or JavaScript classes.', 'fidelity_loss', 'Core navigation/group blocks can approximate simple sticky positioning, but source-specific scroll state, offset choreography, and JS class toggles require transformer/runtime work before a custom block decision.', 'convertible', false, /position\s*:\s*(?:sticky|fixed)|sticky[-_\s]?nav|fixed[-_\s]?(?:nav|header)|scroll[-_\s]?(?:state|class)/i),
Expand Down Expand Up @@ -280,9 +280,12 @@ function addEvidence(rows, definition, evidence) {

function finalizeRow(row, threshold) {
const fixtureCount = row.fixtures.length;
const providerMaterializable = isProviderMaterializable(row);
let classification = row.base_classification;
if (row.base_classification === 'runtime-island') {
classification = 'runtime-island';
} else if (providerMaterializable) {
classification = 'convertible';
} else if (row.no_core_block_path && fixtureCount >= threshold) {
classification = 'custom-block-candidate';
} else if (row.base_classification === 'custom-block-candidate') {
Expand Down
6 changes: 6 additions & 0 deletions lib/fixture-matrix/visual-evidence-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,12 @@ function viewportEvidenceRows({ artifacts, artifactRefs }) {
rows.push(compactObject({ phase: entry.phase, width, height, message: entry.message }));
}
};
const metrics = objectValue(artifacts.metrics || artifacts.comparison);
pushViewport({
phase: 'visual-compare',
viewport: metrics.viewport,
message: metrics.viewport ? `visual compare viewport${metrics.full_page === true ? ' (full-page capture)' : ''}` : '',
});
for (const diagnostic of normalizeArray(artifacts.capture_diagnostics || artifacts.captureDiagnostics || artifacts.visual_explanation?.capture_diagnostics || artifacts.visual_explanation?.captureDiagnostics)) {
pushViewport(diagnostic);
}
Expand Down
21 changes: 21 additions & 0 deletions tests/smoke-safe-html-fallback-reducer.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,27 @@ function wp_strip_all_tags( string $value ): string {
$assert( str_contains( $output, 'Search posts' ), 'search-placeholder-preserved' );
$assert( str_contains( $output, '<form class="lead-form"><input name="email"></form>' ), 'unsupported-form-fallback-preserved' );

$mixed_contact_html = '<div class="contact-content"><aside class="contact-sidebar"><div class="contact-block"><div class="label">Booking</div><h3>Book a Show</h3><p>Email <a href="mailto:booking@example.com" class="contact-email"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></svg> booking@example.com</a></p></div></aside><div class="contact-form-wrap"><h2>Send a Message</h2><form class="contact-form"><label>Name<input name="name"></label><select name="topic"><option>Booking</option></select><textarea name="message"></textarea><button type="submit">Send</button></form></div></div>';
$mixed_contact_input = '<!-- wp:html ' . wp_json_encode( array( 'content' => $mixed_contact_html ) ) . ' -->' . $mixed_contact_html . '<!-- /wp:html -->';
$mixed_contact_output = $method->invoke( null, $mixed_contact_input );
$mixed_contact_after = $count_blocks( $mixed_contact_output );

$assert( 1 === ( $count_blocks( $mixed_contact_input )['core/html'] ?? 0 ), 'mixed-contact-before-single-large-html' );
$assert( 1 === ( $mixed_contact_after['core/html'] ?? 0 ), 'mixed-contact-after-single-bounded-form-html', print_r( $mixed_contact_after, true ) );
$assert( 3 <= ( $mixed_contact_after['core/group'] ?? 0 ), 'mixed-contact-static-layout-groups-converted', print_r( $mixed_contact_after, true ) );
$assert( in_array( 'core/heading', array_keys( $mixed_contact_after ), true ), 'mixed-contact-heading-converted' );
$assert( str_contains( $mixed_contact_output, 'booking@example.com' ), 'mixed-contact-link-text-preserved' );
$assert( str_contains( $mixed_contact_output, '<form class="contact-form">' ), 'mixed-contact-runtime-form-preserved' );

$cart_control_html = '<article class="merch-card"><h3>EP Tee</h3><p>Washed black tee.</p><button class="qty-btn" data-dir="down" aria-label="Decrease quantity">-</button><span class="qty-display" aria-live="polite">1</span><button class="add-to-cart">Add</button></article>';
$cart_control_input = '<!-- wp:html ' . wp_json_encode( array( 'content' => $cart_control_html ) ) . ' -->' . $cart_control_html . '<!-- /wp:html -->';
$cart_control_output = $method->invoke( null, $cart_control_input );
$cart_control_after = $count_blocks( $cart_control_output );

$assert( 3 === ( $cart_control_after['core/html'] ?? 0 ), 'cart-controls-remain-bounded-html-islands', print_r( $cart_control_after, true ) );
$assert( 0 === ( $cart_control_after['core/button'] ?? 0 ), 'cart-controls-not-faked-as-core-buttons', print_r( $cart_control_after, true ) );
$assert( str_contains( $cart_control_output, 'class="add-to-cart"' ), 'cart-add-control-preserved' );

if ( $failures ) {
fwrite( STDERR, implode( "\n", $failures ) . "\n" );
exit( 1 );
Expand Down
Loading
Loading