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 ) );
}
}