filter_valid_products( $products ); $xml = new \XMLWriter(); $xml->openMemory(); $xml->startDocument( '1.0', 'UTF-8' ); $xml->setIndent( true ); $xml->startElement( 'rss' ); $xml->writeAttribute( 'version', '2.0' ); $xml->writeAttribute( 'xmlns:g', 'http://base.google.com/ns/1.0' ); $xml->startElement( 'channel' ); $xml->writeElement( 'title', get_bloginfo( 'name' ) . ' — Product Feed' ); $xml->writeElement( 'link', home_url() ); $xml->writeElement( 'description', 'Product feed for ' . get_bloginfo( 'name' ) ); foreach ( $valid_products as $product ) { $this->write_item( $xml, $product ); } $xml->endElement(); // channel. $xml->endElement(); // rss. return $xml->outputMemory(); } /** * Validate generated feed content. * * Checks XML well-formedness, required namespace, required item fields, * and ensures no empty g:gtin or g:mpn elements. * * @param string $content The XML feed content. * * @return array{valid: bool, errors: string[], warnings: string[], product_count: int} */ public function validate( string $content ): array { $errors = array(); $warnings = array(); $count = 0; // Check XML well-formedness. libxml_use_internal_errors( true ); $xml = simplexml_load_string( $content ); if ( false === $xml ) { $xml_errors = libxml_get_errors(); foreach ( $xml_errors as $error ) { $errors[] = sprintf( 'XML parse error (line %d): %s', $error->line, trim( $error->message ) ); } libxml_clear_errors(); libxml_use_internal_errors( false ); return array( 'valid' => false, 'errors' => $errors, 'warnings' => $warnings, 'product_count' => 0, ); } libxml_clear_errors(); libxml_use_internal_errors( false ); // Check rss element and namespace. if ( 'rss' !== $xml->getName() ) { $errors[] = 'Root element must be .'; } $namespaces = $xml->getNamespaces( true ); if ( ! isset( $namespaces['g'] ) || 'http://base.google.com/ns/1.0' !== $namespaces['g'] ) { $errors[] = 'Missing xmlns:g="http://base.google.com/ns/1.0" namespace.'; } // Validate items. if ( isset( $xml->channel ) ) { $items = $xml->channel->item; $count = count( $items ); $required_elems = array( 'g:id', 'g:title', 'g:link', 'g:image_link', 'g:price' ); $item_num = 0; foreach ( $items as $item ) { ++$item_num; $g = $item->children( 'http://base.google.com/ns/1.0' ); foreach ( $required_elems as $elem_name ) { $local = str_replace( 'g:', '', $elem_name ); if ( ! isset( $g->{$local} ) || '' === (string) $g->{$local} ) { $errors[] = sprintf( 'Item %d: missing required element %s.', $item_num, $elem_name ); } } // No empty g:gtin or g:mpn. if ( isset( $g->gtin ) && '' === (string) $g->gtin ) { $errors[] = sprintf( 'Item %d: empty element (would fail GMC validation).', $item_num ); } if ( isset( $g->mpn ) && '' === (string) $g->mpn ) { $errors[] = sprintf( 'Item %d: empty element (would fail GMC validation).', $item_num ); } } } return array( 'valid' => empty( $errors ), 'errors' => $errors, 'warnings' => $warnings, 'product_count' => $count, ); } /** * Format price as "19.99 USD" — never "19,99". * * @param string $price Raw price value. * @param string $currency Currency code. * * @return string Formatted price string. */ private function format_price( string $price, string $currency ): string { return number_format( (float) $price, 2, '.', '' ) . ' ' . $currency; } /** * Write a single product item to the XML. * * @param \XMLWriter $xml The XML writer. * @param array $product Normalized product data. * * @return void */ private function write_item( \XMLWriter $xml, array $product ): void { $currency = $product['currency'] ?? 'USD'; /** * Filters the Google feed item data before XML generation. * * Allows developers to add custom_label_0 through custom_label_4 * and modify other product data. * * @param array $product The normalized product data. * @param array $product The raw product data (same in this context). */ $product = apply_filters( 'aicommerce/feed/google/product_data', $product, $product ); $xml->startElement( 'item' ); $xml->writeElement( 'g:id', (string) ( $product['sku'] ?: $product['id'] ) ); $xml->writeElement( 'g:title', $product['title'] ); // Description as CDATA. $xml->startElement( 'g:description' ); $xml->writeCdata( mb_substr( $product['description'], 0, 5000 ) ); $xml->endElement(); $xml->writeElement( 'g:link', $product['url'] ); // Images — handle both array-of-strings and array-of-arrays (with url/alt). $images = $this->normalize_images( $product['images'] ?? array() ); if ( ! empty( $images[0] ) ) { $xml->writeElement( 'g:image_link', $images[0] ); $max_additional = min( count( $images ), 11 ); // primary + 10 additional. for ( $i = 1; $i < $max_additional; $i++ ) { $xml->writeElement( 'g:additional_image_link', $images[ $i ] ); } } // Availability. $availability_map = array( 'in_stock' => 'in stock', 'out_of_stock' => 'out of stock', 'backorder' => 'preorder', ); $xml->writeElement( 'g:availability', $availability_map[ $product['availability'] ] ?? 'out of stock' ); // Price. $xml->writeElement( 'g:price', $this->format_price( $product['price'], $currency ) ); if ( ! empty( $product['sale_price'] ) ) { $xml->writeElement( 'g:sale_price', $this->format_price( $product['sale_price'], $currency ) ); } // Identifiers — NEVER output empty elements. if ( ! empty( $product['brand'] ) ) { $xml->writeElement( 'g:brand', $product['brand'] ); } if ( ! empty( $product['gtin'] ) ) { $xml->writeElement( 'g:gtin', $product['gtin'] ); } if ( ! empty( $product['mpn'] ) ) { $xml->writeElement( 'g:mpn', $product['mpn'] ); } $xml->writeElement( 'g:condition', $product['condition'] ?? 'new' ); // Categories as product_type with hierarchy. if ( ! empty( $product['categories'] ) ) { $category_path = $this->build_category_path( $product['categories'] ); $xml->startElement( 'g:product_type' ); $xml->writeCdata( $category_path ); $xml->endElement(); } // Item group ID for variants. if ( ! empty( $product['parent_id'] ) && $product['parent_id'] > 0 ) { $xml->writeElement( 'g:item_group_id', (string) $product['parent_id'] ); } elseif ( ! empty( $product['is_variable'] ) && ! empty( $product['variants'] ) ) { $xml->writeElement( 'g:item_group_id', (string) $product['id'] ); } // Variant attributes. if ( ! empty( $product['attributes'] ) ) { $this->write_variant_attributes( $xml, $product['attributes'] ); } // Shipping weight. if ( ! empty( $product['weight'] ) ) { $xml->writeElement( 'g:shipping_weight', $product['weight'] . ' ' . ( $product['weight_unit'] ?? 'kg' ) ); } // Custom labels (0–4) via filter. for ( $i = 0; $i <= 4; $i++ ) { $key = 'custom_label_' . $i; if ( ! empty( $product[ $key ] ) ) { $xml->writeElement( 'g:' . $key, $product[ $key ] ); } } $xml->endElement(); // item. } /** * Normalize images to array of URL strings. * * Handles both flat URL arrays and arrays with url/alt keys. * * @param array $images Raw image data. * * @return array Array of absolute image URLs. */ private function normalize_images( array $images ): array { $urls = array(); foreach ( $images as $image ) { if ( is_array( $image ) && isset( $image['url'] ) ) { $urls[] = $image['url']; } elseif ( is_string( $image ) ) { $urls[] = $image; } } return $urls; } /** * Build category path string with hierarchy. * * @param array $categories Array of category data. * * @return string Category path (e.g., "Electronics > Phones > Android"). */ private function build_category_path( array $categories ): string { $names = array_column( $categories, 'name' ); return implode( ' > ', $names ); } /** * Write variant attributes (color, size, material) to the XML. * * @param \XMLWriter $xml The XML writer. * @param array $attributes Product attributes. * * @return void */ private function write_variant_attributes( \XMLWriter $xml, array $attributes ): void { $variant_attrs = array( 'color' => 'g:color', 'size' => 'g:size', 'material' => 'g:material', 'pa_color' => 'g:color', 'pa_size' => 'g:size', 'pa_material' => 'g:material', ); foreach ( $variant_attrs as $attr_key => $g_element ) { if ( ! isset( $attributes[ $attr_key ] ) ) { continue; } $attr = $attributes[ $attr_key ]; $value = ''; if ( is_array( $attr ) && isset( $attr['options'] ) ) { $value = implode( '/', (array) $attr['options'] ); } elseif ( is_string( $attr ) ) { $value = $attr; } if ( ! empty( $value ) ) { $xml->writeElement( $g_element, $value ); } } } }