init_hooks(); } /** * Initialize hooks. */ private function init_hooks() { // REST API endpoints. add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); // Output schema in footer (same as WooCommerce core WC_Structured_Data). add_action( 'wp_footer', array( $this, 'output_organization_schema' ), 10 ); add_action( 'wp_footer', array( $this, 'output_product_schema' ), 10 ); } /** * Register REST API routes. */ public function register_rest_routes() { register_rest_route( 'agentshop/v1', '/aeo/organization-schema', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'store_schema' ), 'permission_callback' => array( $this, 'check_permission' ), 'args' => $this->get_store_args(), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_schema' ), 'permission_callback' => array( $this, 'check_permission' ), ), array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_schema' ), 'permission_callback' => array( $this, 'check_permission' ), ), ) ); } /** * Get arguments for store endpoint. * * @return array */ private function get_store_args() { return array( 'schema' => array( 'required' => true, 'type' => array( 'object', 'string' ), 'description' => __( 'Organization schema JSON-LD object.', 'agentshop' ), 'validate_callback' => array( $this, 'validate_schema' ), 'sanitize_callback' => array( $this, 'sanitize_schema' ), ), 'updated_at' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ); } /** * Check if current user has permission. * * @return bool|\WP_Error */ public function check_permission() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to manage organization schema.', 'agentshop' ), array( 'status' => 403 ) ); } return true; } /** * Validate schema has required fields. * * @param mixed $schema Schema to validate. * @return bool|\WP_Error */ public function validate_schema( $schema ) { // Parse if string. if ( is_string( $schema ) ) { $schema = json_decode( $schema, true ); if ( json_last_error() !== JSON_ERROR_NONE ) { return new \WP_Error( 'invalid_json', __( 'Schema must be valid JSON.', 'agentshop' ) ); } } if ( ! is_array( $schema ) || empty( $schema['@type'] ) ) { return new \WP_Error( 'invalid_schema', __( 'Schema must have @type field.', 'agentshop' ) ); } return true; } /** * Sanitize schema input. * * @param mixed $schema Schema input. * @return array */ public function sanitize_schema( $schema ) { if ( is_string( $schema ) ) { $schema = json_decode( $schema, true ); } if ( ! is_array( $schema ) ) { return array(); } return $this->sanitize_schema_recursive( $schema ); } /** * Recursively sanitize schema values. * * @param array $data Data to sanitize. * @return array */ private function sanitize_schema_recursive( $data ) { $sanitized = array(); foreach ( $data as $key => $value ) { $key = sanitize_text_field( $key ); if ( is_array( $value ) ) { $sanitized[ $key ] = $this->sanitize_schema_recursive( $value ); } elseif ( is_string( $value ) ) { // Allow URLs and basic text. if ( filter_var( $value, FILTER_VALIDATE_URL ) ) { $sanitized[ $key ] = esc_url_raw( $value ); } else { $sanitized[ $key ] = sanitize_text_field( $value ); } } else { $sanitized[ $key ] = $value; } } return $sanitized; } /** * Store organization schema. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function store_schema( $request ) { $schema = $request->get_param( 'schema' ); $updated_at = $request->get_param( 'updated_at' ) ?: current_time( 'c' ); $data = array( 'schema' => $schema, 'updated_at' => $updated_at, ); // Store with autoload false - only loaded when needed. $result = update_option( self::OPTION_NAME, $data, false ); // Log if AgentShop is available. if ( function_exists( 'AgentShop' ) ) { \agentshop()->log( 'Organization schema stored via REST API', 'info', array( 'schema_type' => $schema['@type'] ?? 'unknown' ) ); } return new \WP_REST_Response( array( 'success' => true, 'message' => __( 'Organization schema stored successfully.', 'agentshop' ), 'updated_at' => $updated_at, ), 200 ); } /** * Get stored organization schema. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function get_schema( $request ) { $data = get_option( self::OPTION_NAME, null ); if ( empty( $data ) || empty( $data['schema'] ) ) { return new \WP_REST_Response( array( 'success' => true, 'schema' => null, 'message' => __( 'No organization schema configured.', 'agentshop' ), ), 200 ); } return new \WP_REST_Response( array( 'success' => true, 'schema' => $data['schema'], 'updated_at' => $data['updated_at'] ?? null, ), 200 ); } /** * Delete organization schema. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function delete_schema( $request ) { $deleted = delete_option( self::OPTION_NAME ); if ( function_exists( 'AgentShop' ) ) { \agentshop()->log( 'Organization schema deleted via REST API', 'info' ); } return new \WP_REST_Response( array( 'success' => true, 'deleted' => $deleted, 'message' => __( 'Organization schema removed.', 'agentshop' ), ), 200 ); } /** * Output organization schema as JSON-LD in footer. * * Follows WooCommerce pattern: wp_footer hook, wc_esc_json encoding. */ public function output_organization_schema() { $data = get_option( self::OPTION_NAME, null ); if ( empty( $data['schema'] ) ) { return; } /** * Filter organization schema before output. * * Follows WooCommerce pattern (e.g., woocommerce_structured_data_product). * * @param array $schema Organization schema array. */ $schema = apply_filters( 'agentshop_structured_data_organization', $data['schema'] ); if ( empty( $schema ) ) { return; } // Encode with same flags as WooCommerce core. $encoded = wp_json_encode( $schema, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ); if ( ! $encoded ) { return; } // Use wp_print_inline_script_tag for WordPress-compliant script output. // See: https://developer.wordpress.org/reference/functions/wp_print_inline_script_tag/ wp_print_inline_script_tag( $encoded, array( 'type' => 'application/ld+json', 'id' => 'agentshop-organization-schema', ) ); } /** * Output product schema as JSON-LD in footer on single product pages. * * Follows WooCommerce WC_Structured_Data pattern: * - Only outputs on single product pages (is_product()). * - Uses wc_esc_json() for secure JSON output. * - Provides filter for customization. * * @since 1.0.0 */ public function output_product_schema() { // Only output on single product pages. if ( ! is_product() ) { return; } global $product; // Use a local prefixed variable to avoid modifying the global. $agentshop_product = $product; // Ensure we have a valid product object. if ( ! $agentshop_product instanceof \WC_Product ) { $agentshop_product = wc_get_product( get_the_ID() ); } if ( ! $agentshop_product ) { return; } // Get stored schema from product meta. $schema_data = get_post_meta( $agentshop_product->get_id(), self::PRODUCT_SCHEMA_META_KEY, true ); if ( empty( $schema_data ) ) { return; } // Handle both JSON string and array formats. if ( is_string( $schema_data ) ) { $schema_data = json_decode( $schema_data, true ); if ( json_last_error() !== JSON_ERROR_NONE ) { return; } } if ( ! is_array( $schema_data ) ) { return; } // Schema data may be stored in different formats: // 1. Array of schemas: [{ "@type": "Product", ... }, { "@type": "BreadcrumbList", ... }] // 2. Keyed object: { product: {...}, faq: {...}, breadcrumbList: {...} } // 3. Single schema: { "@type": "Product", ... } $schemas_to_output = array(); // Check if it's a sequential array (list of schemas). if ( isset( $schema_data[0] ) && is_array( $schema_data[0] ) ) { // Array format: [{ "@type": "Product" }, { "@type": "BreadcrumbList" }] foreach ( $schema_data as $schema ) { if ( ! empty( $schema['@type'] ) ) { $type = $schema['@type']; // Normalize type to lowercase key. $key = strtolower( str_replace( array( 'Page', 'List' ), '', $type ) ); $schemas_to_output[ $key ] = $schema; } } } elseif ( isset( $schema_data['product'] ) || isset( $schema_data['faq'] ) || isset( $schema_data['breadcrumbList'] ) || isset( $schema_data['howTo'] ) ) { // Keyed object format: { product: {...}, faq: {...} } foreach ( array( 'product', 'faq', 'breadcrumbList', 'howTo' ) as $schema_type ) { if ( ! empty( $schema_data[ $schema_type ] ) ) { $schemas_to_output[ $schema_type ] = $schema_data[ $schema_type ]; } } } elseif ( ! empty( $schema_data['@type'] ) ) { // Single schema object. $schemas_to_output['product'] = $schema_data; } if ( empty( $schemas_to_output ) ) { return; } // Output each schema separately for better debugging and control. foreach ( $schemas_to_output as $schema_type => $schema ) { $this->output_single_product_schema( $schema, $schema_type, $agentshop_product ); } } /** * Output a single product-related schema. * * @param array $schema Schema data array. * @param string $schema_type Schema type identifier (product, faq, breadcrumbList, howTo). * @param \WC_Product $product Product object. */ private function output_single_product_schema( $schema, $schema_type, $product ) { /** * Filter product schema before output. * * Follows WooCommerce pattern (e.g., woocommerce_structured_data_product). * * @since 1.0.0 * * @param array $schema Schema array. * @param string $schema_type Schema type (product, faq, breadcrumbList, howTo). * @param \WC_Product $product Product object. */ $schema = apply_filters( 'agentshop_structured_data_product', $schema, $schema_type, $product ); /** * Filter specific schema type before output. * * @since 1.0.0 * * @param array $schema Schema array. * @param \WC_Product $product Product object. */ $schema = apply_filters( "agentshop_structured_data_{$schema_type}", $schema, $product ); if ( empty( $schema ) ) { return; } // Encode with same flags as WooCommerce core. $encoded = wp_json_encode( $schema, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ); if ( ! $encoded ) { return; } // Sanitize schema type for use in ID attribute. $safe_type = sanitize_html_class( $schema_type ); // Use wp_print_inline_script_tag for WordPress-compliant script output. // See: https://developer.wordpress.org/reference/functions/wp_print_inline_script_tag/ wp_print_inline_script_tag( $encoded, array( 'type' => 'application/ld+json', 'id' => 'agentshop-' . $safe_type . '-schema', ) ); } /** * Check if organization schema is configured. * * @return bool */ public static function has_schema() { $data = get_option( self::OPTION_NAME, null ); return ! empty( $data['schema'] ); } /** * Get the stored schema data. * * @return array|null */ public static function get_stored_schema() { $data = get_option( self::OPTION_NAME, null ); return $data['schema'] ?? null; } /** * Check if a product has AEO schema configured. * * @param int $product_id Product ID. * @return bool */ public static function product_has_schema( $product_id ) { $schema = get_post_meta( $product_id, self::PRODUCT_SCHEMA_META_KEY, true ); return ! empty( $schema ); } /** * Get the stored product schema. * * @param int $product_id Product ID. * @return array|null */ public static function get_product_schema( $product_id ) { $schema = get_post_meta( $product_id, self::PRODUCT_SCHEMA_META_KEY, true ); if ( empty( $schema ) ) { return null; } if ( is_string( $schema ) ) { $schema = json_decode( $schema, true ); } return is_array( $schema ) ? $schema : null; } /** * Get the product schema update timestamp. * * @param int $product_id Product ID. * @return string|null ISO 8601 timestamp or null. */ public static function get_product_schema_updated( $product_id ) { return get_post_meta( $product_id, self::PRODUCT_SCHEMA_UPDATED_META_KEY, true ) ?: null; } }