entity_chain = $entity_chain; $this->conflict_detector = $conflict_detector; $this->site_type = $site_type; } /** * Inject site-level JSON-LD into wp_head. * * @return void */ public function inject(): void { if ( ! $this->should_inject() ) { return; } $schemas = $this->build_schemas(); if ( empty( $schemas ) ) { return; } foreach ( $schemas as $schema ) { $json = wp_json_encode( $schema, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE ); if ( false === $json ) { continue; } printf( '' . "\n", $json // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Safe JSON-LD: JSON_HEX_TAG|JSON_HEX_AMP escape <, >, & so it cannot break out of the script element. ); } } /** * Determine whether site schema should output on the current page. * * @return bool */ private function should_inject(): bool { if ( ! get_option( 'aicommerce_site_schema_enabled', true ) ) { return false; } $is_target = is_front_page() || is_home(); if ( ! $is_target && function_exists( 'is_shop' ) && is_shop() ) { $is_target = true; } /** * Filters whether site-level schema should be injected on this request. * * @param bool $is_target Whether the current page is a site-schema target. */ return (bool) apply_filters( 'aicommerce_should_inject_site_schema', $is_target ); } /** * Build the site-level schema objects. * * @return array> */ public function build_schemas(): array { $schemas = array(); $seo_provides = $this->seo_plugin_provides_entity(); // WebSite (with SearchAction) applies to every site type. if ( ! $seo_provides ) { $schemas[] = $this->build_website(); } // The main entity depends on the detected website type. $main = $this->build_main_entity(); if ( ! empty( $main ) ) { // Avoid emitting a second plain Organization when an SEO plugin // already provides one; specialized nodes (OnlineStore, // LocalBusiness) still add commerce/local data worth keeping. $is_generic_org = 'Organization' === ( $main['@type'] ?? '' ); if ( ! ( $seo_provides && $is_generic_org ) ) { $schemas[] = $main; } } /** * Filters the site-level schema objects before output. * * @param array> $schemas The schema objects. */ return (array) apply_filters( 'aicommerce_site_schemas', array_values( array_filter( $schemas ) ) ); } /** * Build the main entity node for the site, based on its detected type. * * @return array */ private function build_main_entity(): array { switch ( $this->site_type->get_type() ) { case SiteTypeDetector::TYPE_ECOMMERCE: return $this->build_online_store(); case SiteTypeDetector::TYPE_LOCAL_BUSINESS: return $this->build_local_business(); default: return $this->entity_chain->build_organization(); } } /** * Build LocalBusiness schema from the configured business details. * * @return array */ private function build_local_business(): array { $business = array( '@context' => 'https://schema.org', '@type' => $this->site_type->get_local_business_type(), 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ), ); $description = get_bloginfo( 'description' ); if ( ! empty( $description ) ) { $business['description'] = $description; } $logo_id = get_theme_mod( 'custom_logo' ); if ( $logo_id ) { $logo_url = wp_get_attachment_image_url( $logo_id, 'full' ); if ( $logo_url ) { $business['logo'] = $logo_url; $business['image'] = $logo_url; } } $phone = trim( (string) get_option( 'aicommerce_business_phone', '' ) ); if ( '' !== $phone ) { $business['telephone'] = $phone; } $price_range = trim( (string) get_option( 'aicommerce_business_price_range', '' ) ); if ( '' !== $price_range ) { $business['priceRange'] = $price_range; } $address = $this->build_postal_address(); if ( ! empty( $address ) ) { $business['address'] = $address; } $geo = $this->build_geo(); if ( ! empty( $geo ) ) { $business['geo'] = $geo; } $hours = $this->build_opening_hours(); if ( ! empty( $hours ) ) { $business['openingHours'] = $hours; } $same_as = $this->same_as_links(); if ( ! empty( $same_as ) ) { $business['sameAs'] = $same_as; } /** * Filters the LocalBusiness schema. * * @param array $business The LocalBusiness schema data. */ return (array) apply_filters( 'aicommerce_local_business_schema', $business ); } /** * Build a PostalAddress node from configured options. * * @return array */ private function build_postal_address(): array { $fields = array( 'streetAddress' => get_option( 'aicommerce_business_address', '' ), 'addressLocality' => get_option( 'aicommerce_business_city', '' ), 'addressRegion' => get_option( 'aicommerce_business_region', '' ), 'postalCode' => get_option( 'aicommerce_business_postal', '' ), 'addressCountry' => get_option( 'aicommerce_business_country', '' ), ); $address = array( '@type' => 'PostalAddress' ); $has_any = false; foreach ( $fields as $key => $value ) { $value = trim( (string) $value ); if ( '' !== $value ) { $address[ $key ] = $value; $has_any = true; } } return $has_any ? $address : array(); } /** * Build a GeoCoordinates node from configured latitude/longitude. * * @return array */ private function build_geo(): array { $lat = trim( (string) get_option( 'aicommerce_business_lat', '' ) ); $lng = trim( (string) get_option( 'aicommerce_business_lng', '' ) ); if ( '' === $lat || '' === $lng || ! is_numeric( $lat ) || ! is_numeric( $lng ) ) { return array(); } return array( '@type' => 'GeoCoordinates', 'latitude' => (float) $lat, 'longitude' => (float) $lng, ); } /** * Build opening-hours strings from a configured value. * * Accepts newline- or comma-separated schema.org openingHours strings such * as "Mo-Fr 09:00-17:00". * * @return array */ private function build_opening_hours(): array { $raw = (string) get_option( 'aicommerce_business_hours', '' ); if ( '' === trim( $raw ) ) { return array(); } $parts = preg_split( '/[\r\n,]+/', $raw ); $parts = is_array( $parts ) ? $parts : array(); $hours = array(); foreach ( $parts as $part ) { $part = trim( $part ); if ( '' !== $part ) { $hours[] = $part; } } return $hours; } /** * Get validated SameAs links from settings. * * @return array */ private function same_as_links(): array { $same_as = get_option( 'aicommerce_same_as_links', array() ); if ( ! is_array( $same_as ) ) { return array(); } return array_values( array_filter( $same_as, static function ( $url ): bool { return ! empty( $url ) && false !== filter_var( $url, FILTER_VALIDATE_URL ); } ) ); } /** * Whether an active SEO plugin already outputs Organization/WebSite markup. * * @return bool */ private function seo_plugin_provides_entity(): bool { if ( ! get_option( 'aicommerce_conflict_suppress', true ) ) { return false; } return ! empty( $this->conflict_detector->detect() ); } /** * Build WebSite schema with a SearchAction. * * @return array */ private function build_website(): array { $website = array( '@context' => 'https://schema.org', '@type' => 'WebSite', 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ), 'potentialAction' => array( '@type' => 'SearchAction', 'target' => array( '@type' => 'EntryPoint', 'urlTemplate' => home_url( '/?s={search_term_string}&post_type=product' ), ), 'query-input' => 'required name=search_term_string', ), ); $description = get_bloginfo( 'description' ); if ( ! empty( $description ) ) { $website['description'] = $description; } /** * Filters the WebSite schema. * * @param array $website The WebSite schema data. */ return apply_filters( 'aicommerce_website_schema', $website ); } /** * Build OnlineStore schema for the commerce entity. * * @return array */ private function build_online_store(): array { $store = array( '@context' => 'https://schema.org', '@type' => 'OnlineStore', 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ), ); $description = get_bloginfo( 'description' ); if ( ! empty( $description ) ) { $store['description'] = $description; } $logo_id = get_theme_mod( 'custom_logo' ); if ( $logo_id ) { $logo_url = wp_get_attachment_url( $logo_id ); if ( $logo_url ) { $store['logo'] = $logo_url; $store['image'] = $logo_url; } } if ( function_exists( 'get_woocommerce_currency' ) ) { $store['currenciesAccepted'] = get_woocommerce_currency(); } // Payment methods from enabled WooCommerce gateways. $payment_methods = $this->get_payment_methods(); if ( ! empty( $payment_methods ) ) { $store['paymentAccepted'] = implode( ', ', $payment_methods ); } $same_as = get_option( 'aicommerce_same_as_links', array() ); if ( is_array( $same_as ) ) { $same_as = array_values( array_filter( $same_as, static function ( $url ): bool { return ! empty( $url ) && false !== filter_var( $url, FILTER_VALIDATE_URL ); } ) ); if ( ! empty( $same_as ) ) { $store['sameAs'] = $same_as; } } /** * Filters the OnlineStore schema. * * @param array $store The OnlineStore schema data. */ return apply_filters( 'aicommerce_online_store_schema', $store ); } /** * Get a human-readable list of enabled payment gateway titles. * * @return array */ private function get_payment_methods(): array { if ( ! function_exists( 'WC' ) || null === WC()->payment_gateways ) { return array(); } $methods = array(); foreach ( WC()->payment_gateways->get_available_payment_gateways() as $gateway ) { if ( isset( $gateway->title ) && '' !== $gateway->title ) { $methods[] = wp_strip_all_tags( $gateway->title ); } } return array_values( array_unique( $methods ) ); } }