Skip to content
Open
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
1 change: 1 addition & 0 deletions homeboy-test-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"tests/smoke-companion-plugin.php": { "environment": "standalone-php" },
"tests/smoke-contact-layout-transformer.php": { "environment": "standalone-php" },
"tests/smoke-diagnostic-loss-classes.php": { "environment": "standalone-php" },
"tests/smoke-empty-js-populated-container.php": { "environment": "standalone-php" },
"tests/smoke-entity-materializer-registry.php": { "environment": "standalone-php" },
"tests/smoke-external-report-destinations.php": { "environment": "standalone-php" },
"tests/smoke-export-theme-ability.php": { "environment": "standalone-php" },
Expand Down
143 changes: 143 additions & 0 deletions includes/class-static-site-importer-page-materializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,10 @@ private static function source_page_content_blocks( Static_Site_Importer_Source_
return '';
}

foreach ( self::find_empty_js_populated_containers( $body ) as $container ) {
$diagnostics[] = self::empty_js_populated_container_diagnostic( $container, $source_path );
}

$blocks = self::html_to_blocks( $body, $source_path, $diagnostics );
if ( '' !== trim( $blocks ) ) {
return $blocks;
Expand Down Expand Up @@ -1143,6 +1147,145 @@ private static function source_page_content_blocks( Static_Site_Importer_Source_
return '';
}

/**
* Find empty container elements that appear to be client-side rendered.
*
* An element is only flagged when it is empty AND shows a strong signal that
* JavaScript is expected to populate it: a non-internal data-* attribute, or
* an app-shell id paired with a content-naming id/class. Deliberately
* conservative so bare layout grids and spacer divs are never reported.
*
* @param string $html Raw HTML body markup.
* @return array<int,array<string,mixed>> Detected containers.
*/
private static function find_empty_js_populated_containers( string $html ): array {
$previous = libxml_use_internal_errors( true );
$dom = new DOMDocument( '1.0', 'UTF-8' );
$loaded = $dom->loadHTML( '<!doctype html><html><body>' . $html . '</body></html>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
libxml_clear_errors();
libxml_use_internal_errors( $previous );
if ( ! $loaded ) {
return array();
}

$xpath = new DOMXPath( $dom );
$nodes = $xpath->query( '//*[self::div or self::section or self::main or self::article or self::ul or self::ol]' );
if ( false === $nodes ) {
return array();
}

$found = array();
foreach ( $nodes as $node ) {
if ( ! $node instanceof DOMElement || ! self::element_is_empty( $node ) ) {
continue;
}

$id = strtolower( trim( (string) $node->getAttribute( 'id' ) ) );
$classes = strtolower( trim( (string) $node->getAttribute( 'class' ) ) );
$data = self::non_internal_data_attributes( $node );
$app_shell = (bool) preg_match( '#\b(?:root|app|__next|gatsby|mount)\b#', $id );
$content = (bool) preg_match( '#(?:grid|products?|product-list|listing|featured|catalog|items?|shelf|collection)#', $id . ' ' . $classes );

if ( empty( $data ) && ! ( $app_shell && $content ) ) {
continue;
}

$found[] = array(
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM exposes tagName as a native property.
'tag_name' => strtolower( (string) $node->tagName ),
'id' => trim( (string) $node->getAttribute( 'id' ) ),
'class' => trim( (string) $node->getAttribute( 'class' ) ),
'selector' => self::container_selector( $node, $id ),
);
}

return $found;
}

/**
* Whether a DOM element has no meaningful element or text children.
*
* Comments and whitespace-only text nodes are ignored.
*
* @param DOMElement $node Element node.
* @return bool True when the element is empty.
*/
private static function element_is_empty( DOMElement $node ): bool {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM exposes childNodes as a native property.
foreach ( iterator_to_array( $node->childNodes ) as $child ) {
if ( $child instanceof DOMText ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM exposes textContent as a native property.
if ( '' !== trim( (string) $child->textContent ) ) {
return false;
}
continue;
}
if ( ! $child instanceof DOMComment && ! $child instanceof DOMProcessingInstruction ) {
return false;
}
}

return true;
}

/**
* Names of non-internal data-* attributes on an element.
*
* @param DOMElement $node Element node.
* @return array<int,string> Data attribute names, SSI-internal ones excluded.
*/
private static function non_internal_data_attributes( DOMElement $node ): array {
$names = array();
foreach ( $node->attributes as $attribute ) {
$name = strtolower( (string) $attribute->name );
if ( str_starts_with( $name, 'data-' ) && ! str_starts_with( $name, 'data-ssi-' ) ) {
$names[] = $name;
}
}

return $names;
}

/**
* Build a short selector for a detected container.
*
* @param DOMElement $node Element node.
* @param string $id Lowercased id attribute.
* @return string Selector string.
*/
private static function container_selector( DOMElement $node, string $id ): string {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM exposes tagName as a native property.
$tag = strtolower( (string) $node->tagName );
return '' !== $id ? $tag . '#' . $id : $tag;
}

/**
* Build a diagnostic row for an empty client-side rendered container.
*
* @param array<string,mixed> $container Detected container row.
* @param string $source_path Source path.
* @return array<string,mixed> Diagnostic row.
*/
private static function empty_js_populated_container_diagnostic( array $container, string $source_path ): array {
$tag = $container['tag_name'];

return array(
'type' => 'empty_js_populated_container',
'source' => 'source-html/static-scan',
'source_path' => $source_path,
'format' => 'html',
'selector' => $container['selector'],
'tag_name' => $tag,
'id' => $container['id'],
'class' => $container['class'],
'element' => $tag,
'severity' => 'info',
'category' => 'unsupported_source',
'loss_class' => 'unsupported_loss',
'message' => 'Empty container appears to be populated by client-side JavaScript, so its intended content is absent from the server-rendered HTML.',
);
}

/**
* Convert raw HTML document content to serialized block markup.
*
Expand Down
1 change: 1 addition & 0 deletions test-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
{ "path": "tests/smoke-companion-plugin.php", "environment": "standalone-php" },
{ "path": "tests/smoke-contact-layout-transformer.php", "environment": "standalone-php" },
{ "path": "tests/smoke-diagnostic-loss-classes.php", "environment": "standalone-php" },
{ "path": "tests/smoke-empty-js-populated-container.php", "environment": "standalone-php" },
{ "path": "tests/smoke-entity-materializer-registry.php", "environment": "standalone-php" },
{ "path": "tests/smoke-external-report-destinations.php", "environment": "standalone-php" },
{ "path": "tests/smoke-export-theme-ability.php", "environment": "standalone-php" },
Expand Down
95 changes: 95 additions & 0 deletions tests/smoke-empty-js-populated-container.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php
/**
* Smoke coverage for detecting empty client-side rendered containers.
*
* Run from the repository root:
* php tests/smoke-empty-js-populated-container.php
*
* @package StaticSiteImporter
*/

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
}

if ( ! function_exists( 'sanitize_key' ) ) {
function sanitize_key( $key ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.keyFound
$key = strtolower( (string) $key );

return preg_replace( '/[^a-z0-9_\-]/', '', $key );
}
}

require_once dirname( __DIR__ ) . '/includes/class-static-site-importer-page-materializer.php';
require_once dirname( __DIR__ ) . '/includes/class-static-site-importer-diagnostic-loss-classes.php';

$failures = array();
$assertions = 0;
$assert = static function ( bool $condition, string $label, string $detail = '' ) use ( &$assertions, &$failures ): void {
++$assertions;
if ( ! $condition ) {
$failures[] = 'FAIL [' . $label . ']' . ( '' !== $detail ? ': ' . $detail : '' );
}
};

$detect = static function ( string $html ): array {
$reflection = new ReflectionClass( Static_Site_Importer_Page_Materializer::class );
$method = $reflection->getMethod( 'find_empty_js_populated_containers' );

return $method->invoke( null, $html );
};

$page_selector_of = static function ( array $containers ): array {
return array_map(
static fn ( array $c ): string => (string) $c['selector'],
$containers
);
};

// Data-* carriers are the strongest signal and are always flagged.
$found = $detect( '<div id="featuredGrid" data-slider-container></div>' );
$assert( 1 === count( $found ), 'flag-data-attr-carrier', print_r( $page_selector_of( $found ), true ) );

// App-shell id paired with a content-naming class is a flagged mount.
$found = $detect( '<div id="root" class="product-list"></div>' );
$assert( 1 === count( $found ), 'flag-app-shell-plus-content-name', print_r( $page_selector_of( $found ), true ) );

// An app-shell id alone is not enough to claim JS population.
$assert( array() === $detect( '<div id="__next"></div>' ), 'ignore-app-shell-id-alone' );

// A content-naming id/class without a data-* hook is not enough either.
$assert( array() === $detect( '<div id="featuredGrid" class="wp-block-group grid"></div>' ), 'ignore-content-name-without-data-hook' );

// Legitimate empty spacer and layout divs must never be reported.
$assert( array() === $detect( '<div class="spacer"></div>' ), 'ignore-spacer' );
$assert( array() === $detect( '<div class="grid"></div>' ), 'ignore-layout-grid' );

// A non-empty container with a signal is not an absent-content gap.
$assert( array() === $detect( '<div id="featuredGrid" data-grid><p>Static item</p></div>' ), 'ignore-non-empty' );

// SSI-internal data attributes are excluded.
$assert( array() === $detect( '<div data-ssi-fragment-root="1"></div>' ), 'ignore-internal-data-attr' );

// Section and list containers are scanned too, not just divs.
$found = $detect( '<section id="products" data-products></section>' );
$assert( 1 === count( $found ), 'flag-section-data-attr', print_r( $page_selector_of( $found ), true ) );

// Empty sheet full of comments is still empty; a data hook still flags it.
$found = $detect( '<ul id="listings" data-tiles><!-- populated at runtime --></ul>' );
$assert( 1 === count( $found ), 'flag-comment-only-data-attr', print_r( $page_selector_of( $found ), true ) );

// Loss-class classification surfaces as unsupported loss.
$diagnostic = Static_Site_Importer_Page_Materializer::class;
$reflection = new ReflectionClass( $diagnostic );
$method = $reflection->getMethod( 'empty_js_populated_container_diagnostic' );
$row = $method->invoke( null, array( 'tag_name' => 'div', 'selector' => 'div#grid', 'id' => 'grid', 'class' => '', 'data_attributes_observed' => array( 'data-list' ) ), 'website/index.html' );
$assert( 'empty_js_populated_container' === $row['type'], 'diagnostic-type' );
$assert( 'info' === $row['severity'], 'diagnostic-severity' );
$assert( 'unsupported_loss' === Static_Site_Importer_Diagnostic_Loss_Classes::classify( $row ), 'diagnostic-loss-class' );

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

echo 'OK: empty client-side rendered container smoke passed (' . $assertions . " assertions)\n";
Loading