$raw_html, 'json' => $tree, ); } /** * Recursively scans the JSON for 'data-store' blocks with a 'query', * fetches the WordPress data, and saves it for HTML injection. * * @param array $node The current node in the JSON tree being processed. Passed by reference. * @param array $queries_data Reference to the array holding fetched WordPress data. * @return void */ private static function hydrate_tree_recursive( &$node, &$queries_data ) { if ( isset( $node['mode'] ) && 'data-store' === $node['mode'] && ! empty( $node['query'] ) ) { $args = array( 'post_type' => isset( $node['query']['post_type'] ) ? sanitize_text_field( $node['query']['post_type'] ) : 'post', 'posts_per_page' => isset( $node['query']['posts_per_page'] ) ? intval( $node['query']['posts_per_page'] ) : 10, 'orderby' => isset( $node['query']['orderby'] ) ? sanitize_text_field( $node['query']['orderby'] ) : 'date', 'order' => isset( $node['query']['order'] ) ? sanitize_text_field( $node['query']['order'] ) : 'DESC', 'post_status' => 'publish', 'no_found_rows' => true, ); $posts = get_posts( $args ); $records = array(); foreach ( $posts as $index => $post ) { $categories = get_the_category( $post->ID ); $category_name = ! empty( $categories ) ? $categories[0]->name : 'Uncategorized'; // Standardize the ternary to pass PHPCS. $thumbnail_url = get_the_post_thumbnail_url( $post->ID, 'large' ); $records[] = array( '_index' => $index, 'id' => (string) $post->ID, 'title' => get_the_title( $post ), 'excerpt' => get_the_excerpt( $post ), 'permalink' => get_permalink( $post ), 'date' => get_the_date( '', $post ), 'thumbnail' => $thumbnail_url ? $thumbnail_url : '', 'category' => $category_name, ); } // Hydrate the JSON node for React. $node['records'] = $records; // Save to our array for the HTML DOMDocument parser. $queries_data[ $node['id'] ] = $records; } // Recurse down the tree. foreach ( $node as $key => &$value ) { if ( is_array( $value ) ) { self::hydrate_tree_recursive( $value, $queries_data ); } } } /** * Uses DOMDocument to find the React templates, process attribute bindings, * replace text tokens, and inject the WordPress data directly into the HTML string. * * @param string $html The raw HTML template string. * @param array $queries_data The array of fetched WordPress data mapped by query IDs. * @return string The final, hydrated HTML string. */ private static function render_html_templates( $html, $queries_data ) { libxml_use_internal_errors( true ); $dom = new DOMDocument(); $dom->loadHTML( '' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); $xpath = new DOMXPath( $dom ); // Find all elements marked with data-ssr-query. $wrappers = $xpath->query( '//*[@data-ssr-query]' ); foreach ( $wrappers as $wrapper ) { $query_id = $wrapper->getAttribute( 'data-ssr-query' ); if ( isset( $queries_data[ $query_id ] ) ) { // 1. Extract the raw React HTML template $template_html = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase foreach ( $wrapper->childNodes as $child ) { $template_html .= $dom->saveHTML( $child ); } $rendered_html = ''; // 2. Loop through the actual WP Posts foreach ( $queries_data[ $query_id ] as $item_data ) { // Create a temporary DOM for this specific iteration to safely manipulate attributes $temp_dom = new DOMDocument(); $temp_dom->loadHTML( '
' . $template_html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); $temp_xpath = new DOMXPath( $temp_dom ); // --- NEW LOGIC: Process Attribute Bindings --- $bound_elements = $temp_xpath->query( '//*[@data-bind-attr]' ); foreach ( $bound_elements as $el ) { // Support multiple bound attributes (e.g., "src,alt,href") $attrs_to_bind = explode( ',', $el->getAttribute( 'data-bind-attr' ) ); foreach ( $attrs_to_bind as $attr ) { $attr = trim( $attr ); if ( empty( $attr ) ) continue; // Grab the binding path (e.g., "item.thumbnail") $bind_path = $el->getAttribute( $attr ); if ( strpos( $bind_path, 'item.' ) === 0 ) { $data_key = str_replace( 'item.', '', $bind_path ); // Apply the actual WP data securely using setAttribute if ( isset( $item_data[ $data_key ] ) ) { $el->setAttribute( $attr, esc_attr( $item_data[ $data_key ] ) ); } else { $el->setAttribute( $attr, '' ); // Clear it if no data exists } } } // Clean up the helper attribute so it doesn't render on the live site $el->removeAttribute( 'data-bind-attr' ); } // --- Extract the processed HTML to run text replacements --- $current_item_html = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase foreach ( $temp_dom->documentElement->childNodes as $child ) { $current_item_html .= $temp_dom->saveHTML( $child ); } // --- Standard Text Token Replacement (e.g., {{item.title}}) --- foreach ( $item_data as $key => $value ) { if ( is_scalar( $value ) ) { $current_item_html = str_replace( '{{item.' . $key . '}}', esc_html( $value ), $current_item_html ); } } $rendered_html .= $current_item_html; } // 3. Clear out the original template node // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase while ( $wrapper->firstChild ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $wrapper->removeChild( $wrapper->firstChild ); } // 4. Inject the compiled HTML back into the DOM safely if ( ! empty( $rendered_html ) ) { $final_temp_dom = new DOMDocument(); $final_temp_dom->loadHTML( '
' . $rendered_html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); $final_temp_wrapper = $final_temp_dom->documentElement; if ( $final_temp_wrapper ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase foreach ( $final_temp_wrapper->childNodes as $node ) { $imported_node = $dom->importNode( $node, true ); $wrapper->appendChild( $imported_node ); } } } $wrapper->removeAttribute( 'data-ssr-query' ); } } $final_html = $dom->saveHTML(); return str_replace( '', '', $final_html ); } }