false, 'title_template' => '{title} | {sitename}', 'meta_template' => 'Shop {title} for {price}. Fast shipping, easy returns. Order online today.', ] ); } public static function save_settings( $data ) { $clean = [ 'out_of_stock_noindex' => ! empty( $data['out_of_stock_noindex'] ), 'title_template' => sanitize_text_field( $data['title_template'] ?? '{title} | {sitename}' ), 'meta_template' => sanitize_textarea_field( $data['meta_template'] ?? '' ), ]; update_option( self::OPTION_SETTINGS, $clean ); return $clean; } private static function render_template( $template, $product ) { if ( ! $product ) return $template; $replacements = [ '{title}' => $product->get_name(), '{price}' => wp_strip_all_tags( wc_price( $product->get_price() ) ), '{sku}' => $product->get_sku(), '{sitename}' => get_bloginfo( 'name' ), ]; return str_replace( array_keys( $replacements ), array_values( $replacements ), $template ); } // ── Auto title/description from template ──────────────────────────────── /** * Fills the AWBSEO title/description meta from the template - but only * when the user hasn't already set a custom one, so this never clobbers * manual edits. */ public static function maybe_apply_templates( $post_id ) { if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) return; if ( ! current_user_can( 'edit_post', $post_id ) ) return; $product = function_exists( 'wc_get_product' ) ? wc_get_product( $post_id ) : null; if ( ! $product ) return; $settings = self::get_settings(); $prefix = AWBSEO_Meta_Manager::META_PREFIX; if ( ! get_post_meta( $post_id, $prefix . 'title', true ) && $settings['title_template'] ) { update_post_meta( $post_id, $prefix . 'title', sanitize_text_field( self::render_template( $settings['title_template'], $product ) ) ); } if ( ! get_post_meta( $post_id, $prefix . 'description', true ) && $settings['meta_template'] ) { update_post_meta( $post_id, $prefix . 'description', sanitize_textarea_field( self::render_template( $settings['meta_template'], $product ) ) ); } } // ── Out-of-stock noindex automation ───────────────────────────────────── public static function maybe_noindex_out_of_stock( $product_id, $status, $product ) { $settings = self::get_settings(); if ( empty( $settings['out_of_stock_noindex'] ) ) return; $prefix = AWBSEO_Meta_Manager::META_PREFIX; if ( $status === 'outofstock' ) { update_post_meta( $product_id, $prefix . 'robots_index', 'noindex' ); } else { // Back in stock - only clear it if we were the ones who set it // (don't override an intentional manual noindex). $current = get_post_meta( $product_id, $prefix . 'robots_index', true ); if ( $current === 'noindex' ) { delete_post_meta( $product_id, $prefix . 'robots_index' ); } } } // ── Schema enrichment ──────────────────────────────────────────────────── /** * Hooked to the `awbseo_post_schema` filter - replaces the manually-entered * Product schema fields with live WooCommerce data whenever the post is a * WC product, so schema stays accurate without the user maintaining it. */ public static function enrich_product_schema( $schema, $post_id ) { if ( get_post_type( $post_id ) !== 'product' || ! function_exists( 'wc_get_product' ) ) { return $schema; } $product = wc_get_product( $post_id ); if ( ! $product ) return $schema; $schema['@type'] = 'Product'; $schema['sku'] = $product->get_sku() ?: ( $schema['sku'] ?? '' ); $schema['name'] = $product->get_name(); $image_id = $product->get_image_id(); if ( $image_id ) { $schema['image'] = wp_get_attachment_url( $image_id ); } $brand_terms = get_the_terms( $post_id, 'product_brand' ); $schema['brand'] = [ '@type' => 'Brand', 'name' => ( $brand_terms && ! is_wp_error( $brand_terms ) ) ? $brand_terms[0]->name : get_bloginfo( 'name' ), ]; $schema['offers'] = [ '@type' => 'Offer', 'url' => get_permalink( $post_id ), 'priceCurrency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'USD', 'price' => $product->get_price() ?: '0', 'availability' => 'https://schema.org/' . ( $product->is_in_stock() ? 'InStock' : 'OutOfStock' ), ]; $review_count = (int) $product->get_review_count(); if ( $review_count > 0 ) { $schema['aggregateRating'] = [ '@type' => 'AggregateRating', 'ratingValue' => $product->get_average_rating(), 'reviewCount' => $review_count, ]; } return $schema; } // ── Catalog scan & bulk tools ──────────────────────────────────────────── /** * Scans published products for common SEO gaps: missing SEO title/desc, * missing featured image alt text, missing SKU. */ public static function scan_missing_data( $limit = 200 ) { if ( ! function_exists( 'wc_get_products' ) ) return []; $products = wc_get_products( [ 'status' => 'publish', 'limit' => $limit ] ); $prefix = AWBSEO_Meta_Manager::META_PREFIX; $issues = []; foreach ( $products as $product ) { $id = $product->get_id(); $gaps = []; if ( ! get_post_meta( $id, $prefix . 'title', true ) ) $gaps[] = 'Missing custom SEO title'; if ( ! get_post_meta( $id, $prefix . 'description', true ) ) $gaps[] = 'Missing meta description'; if ( ! $product->get_sku() ) $gaps[] = 'Missing SKU'; $image_id = $product->get_image_id(); if ( $image_id && ! get_post_meta( $image_id, '_wp_attachment_image_alt', true ) ) { $gaps[] = 'Missing image alt text'; } if ( $gaps ) { $issues[] = [ 'id' => $id, 'title' => $product->get_name(), 'edit' => get_edit_post_link( $id, '' ), 'gaps' => $gaps, ]; } } return $issues; } /** * Sets alt text = product name (falling back to "{name} product photo" * if the featured image is shared/generic) for every published product * missing alt text. Returns the number of images fixed. */ public static function bulk_fix_alt_text() { if ( ! function_exists( 'wc_get_products' ) ) return 0; $products = wc_get_products( [ 'status' => 'publish', 'limit' => -1 ] ); $fixed = 0; foreach ( $products as $product ) { $image_id = $product->get_image_id(); if ( ! $image_id ) continue; if ( get_post_meta( $image_id, '_wp_attachment_image_alt', true ) ) continue; update_post_meta( $image_id, '_wp_attachment_image_alt', sanitize_text_field( $product->get_name() ) ); $fixed++; } return $fixed; } public static function get_stats() { if ( ! function_exists( 'wc_get_products' ) ) { return [ 'total' => 0, 'missing_data' => 0 ]; } $total = (int) wp_count_posts( 'product' )->publish; $issues = self::scan_missing_data( 500 ); return [ 'total' => $total, 'missing_data' => count( $issues ) ]; } }