You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

795 lines
22 KiB
PHP

<?php
/**
* Output the Schema.org markup in JSON-LD format.
*
* @since 0.9.0
* @package RankMath
* @subpackage RankMath\Schema
* @author Rank Math <support@rankmath.com>
*/
namespace RankMath\Schema;
use RankMath\Helper;
use RankMath\Helpers\Url;
use RankMath\Helpers\Str;
use RankMath\Helpers\Arr;
use RankMath\Paper\Paper;
use RankMath\Traits\Hooker;
defined( 'ABSPATH' ) || exit;
/**
* JsonLD class.
*/
class JsonLD {
use Hooker;
/**
* Hold post object.
*
* @var WP_Post
*/
public $post = null;
/**
* Hold post ID.
*
* @var ID
*/
public $post_id = 0;
/**
* Hold post parts.
*
* @var array
*/
public $parts = [];
/**
* The Constructor.
*/
public function setup() {
$this->action( 'rank_math/head', 'json_ld', 90 );
$this->action( 'rank_math/json_ld', 'add_context_data' );
$this->action( 'rank_math/json_ld/preview', 'generate_preview' );
new Block_Parser();
new Frontend();
}
/**
* Get Schema Preview. Used in the Code Validation in Pro.
*/
public function generate_preview() {
global $post;
if ( is_singular() ) {
$this->post = $post;
$this->post_id = $post->ID;
$this->get_parts();
}
$data = $this->do_filter( 'json_ld', [], $this );
unset( $data['BreadcrumbList'] );
// Preview schema.
$schema = \json_decode( file_get_contents( 'php://input' ), true );
$schema_id = $schema['schemaID'];
if ( isset( $data[ $schema_id ] ) ) {
$current_data = $data[ $schema_id ];
unset( $data[ $schema_id ] );
} else {
$current_data = array_pop( $data );
}
unset( $schema['schemaID'] );
$schema = $this->replace_variables( $schema );
$schema = $this->filter( $schema, $this, $data );
$schema = wp_parse_args( $schema['schema'], $current_data );
if ( ! empty( $schema['@type'] ) && in_array( $schema['@type'], [ 'WooCommerceProduct', 'EDDProduct' ], true ) ) {
$schema['@type'] = 'Product';
}
// Merge.
$data = array_merge( $data, [ $schema_id => $schema ] );
/**
* Filter to change the Code validation data..
*
* @param array $unsigned An array of data to output in JSON-LD.
*/
$data = $this->do_filter( 'schema/preview/validate', $this->do_filter( 'schema/validated_data', $this->validate_schema( $data ) ) );
echo wp_json_encode( array_values( $data ) );
}
/**
* Function to get Old schema data. This code is used in the Schema_Converter to convert old schema data.
*
* @param int $post_id Post id for conversion.
* @param mixed $class Class instance of snippet.
* @return array
*/
public function get_old_schema( $post_id, $class ) {
global $post;
$post = get_post( $post_id ); // phpcs:ignore
$this->post = $post;
$this->post_id = $post_id;
setup_postdata( $post );
$this->get_parts();
/**
* Collect data to output in JSON-LD.
*
* @param array $unsigned An array of data to output in json-ld.
* @param JsonLD $unsigned JsonLD instance.
*/
return $class->process( [], $this );
}
/**
* Output the ld+json tag with the generated schema data.
*/
public function json_ld() {
global $post;
if ( is_singular() ) {
$this->post = $post;
$this->post_id = $post->ID;
$this->get_parts();
}
/**
* Filter to collect data to output in JSON-LD.
*
* @param array $unsigned An array of data to output in JSON-LD.
* @param JsonLD $unsigned JsonLD instance.
*/
$data = $this->do_filter( 'json_ld', [], $this );
$data = $this->do_filter( 'schema/validated_data', $this->validate_schema( $data ) );
if ( is_array( $data ) && ! empty( $data ) ) {
$class = defined( 'RANK_MATH_PRO_FILE' ) ? 'schema-pro' : 'schema';
$json = [
'@context' => 'https://schema.org',
'@graph' => array_values( $data ),
];
$options = defined( 'RANKMATH_DEBUG' ) && RANKMATH_DEBUG ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : JSON_UNESCAPED_SLASHES;
echo '<script type="application/ld+json" class="rank-math-' . esc_attr( $class ) . '">' . wp_json_encode( wp_kses_post_deep( $json ), $options ) . '</script>' . "\n";
}
}
/**
* Validate schema data. Removes invalid and empty values from the schema data.
*
* @param array $data Array of JSON-LD data.
*
* @return array
*/
private function validate_schema( $data ) {
if ( ! is_array( $data ) || empty( $data ) ) {
return $data;
}
foreach ( $data as $id => $value ) {
if ( is_array( $value ) ) {
// Remove aline @type.
if ( isset( $value['@type'] ) && 1 === count( $value ) ) {
unset( $data[ $id ] );
continue;
}
// Remove empty review.
if ( 'review' === $id && isset( $value['@type'] ) ) {
if ( ! isset( $value['reviewRating'] ) || ! isset( $value['reviewRating']['ratingValue'] ) ) {
unset( $data[ $id ] );
continue;
}
}
// Recursive.
$data[ $id ] = $this->validate_schema( $value );
}
// Remove empty values.
// Remove need of array_filter as this will go recursive.
if ( '' === $value ) {
unset( $data[ $id ] );
continue;
}
}
return $data;
}
/**
* Get Default Schema Data.
*
* @param array $data Array of JSON-LD data.
*
* @return array
*/
public function add_schema( $data ) {
global $post;
$schema = DB::get_schemas( $post->ID );
return array_merge( $data, $schema );
}
/**
* Get Default Schema Data.
*
* @param array $data Array of json-ld data.
*
* @return array
*/
public function add_context_data( $data ) {
$is_product_archive = $this->is_product_archive_page();
$can_add_global = $this->can_add_global_entities( $data, $is_product_archive );
$snippets = [
'\\RankMath\\Schema\\Publisher' => ! isset( $data['publisher'] ) && $can_add_global,
'\\RankMath\\Schema\\Website' => $can_add_global,
'\\RankMath\\Schema\\PrimaryImage' => is_singular() && ! post_password_required() && $can_add_global,
'\\RankMath\\Schema\\Breadcrumbs' => $this->can_add_breadcrumb(),
'\\RankMath\\Schema\\Webpage' => $can_add_global,
'\\RankMath\\Schema\\Author' => is_author() || ( is_singular() && $can_add_global ),
'\\RankMath\\Schema\\Products_Page' => $is_product_archive,
'\\RankMath\\Schema\\Singular' => ! post_password_required() && is_singular(),
];
foreach ( $snippets as $class => $can_run ) {
if ( $can_run ) {
$class = new $class();
$data = $class->process( $data, $this );
}
}
return $data;
}
/**
* Function to replace variables used in Schema fields.
*
* @param array $schemas Schema to replace.
* @param object $object Current Object.
* @param array $data Array of json-ld data.
*
* @return array
*/
public function replace_variables( $schemas, $object = [], $data = [] ) {
$new_schemas = [];
$object = empty( $object ) ? get_queried_object() : $object;
foreach ( $schemas as $key => $schema ) {
if ( 'metadata' === $key ) {
$new_schemas['isPrimary'] = ! empty( $schema['isPrimary'] );
if ( ! empty( $schema['type'] ) && 'custom' === $schema['type'] ) {
$new_schemas['isCustom'] = true;
}
continue;
}
$this->replace_author( $schema, $data );
if ( is_array( $schema ) ) {
$new_schemas[ $key ] = $this->replace_variables( $schema, $object, $data );
continue;
}
// Need this conditions to convert date to valid ISO 8601 format.
if ( in_array( $key, [ 'datePublished', 'uploadDate' ], true ) && '%date(Y-m-dTH:i:sP)%' === $schema ) {
$schema = '%date(Y-m-d\TH:i:sP)%';
}
if ( 'dateModified' === $key && '%modified(Y-m-dTH:i:sP)%' === $schema ) {
$schema = '%modified(Y-m-d\TH:i:sP)%';
}
$new_schemas[ $key ] = is_string( $schema ) && Str::contains( '%', $schema ) && ! filter_var( $schema, FILTER_VALIDATE_URL )
? Helper::replace_vars( $schema, $object ) : $schema;
if ( '' === $new_schemas[ $key ] ) {
unset( $new_schemas[ $key ] );
}
}
return $new_schemas;
}
/**
* Function to replace %author% with Author entity @id.
*
* @param array $schema Schema to replace.
* @param array $data Array of json-ld data.
*
* @return array
*/
private function replace_author( &$schema, $data ) {
if ( empty( $data['ProfilePage'] ) ) {
return;
}
if ( empty( $schema['author'] ) || ! isset( $schema['author']['name'] ) || ! in_array( $schema['author']['name'], [ '%name%', '%post_author%' ], true ) ) {
return;
}
$schema['author'] = [
'@id' => $data['ProfilePage']['@id'],
'name' => get_the_author(),
];
}
/**
* Filter schema data before adding it to ld+json.
*
* @param array $schemas Schema to replace.
* @param JsonLD $jsonld Instance of jsonld.
* @param array $data Array of json-ld data.
*
* @return array
*/
public function filter( $schemas, $jsonld, $data ) {
$new_schemas = [];
foreach ( $schemas as $key => $schema ) {
$type = is_array( $schema['@type'] ) ? $schema['@type'][0] : $schema['@type'];
$type = strtolower( $type );
$type = in_array( $type, [ 'musicgroup', 'musicalbum' ], true )
? 'music'
: ( in_array( $type, [ 'blogposting', 'newsarticle' ], true ) ? 'article' : $type );
$type = Str::contains( 'event', $type ) ? 'event' : $type;
$hook = 'snippet/rich_snippet_' . $type;
/**
* Short-circuit if 3rd party is interested generating his own data.
*/
$pre = $this->do_filter( $hook, false, $jsonld->parts, $data );
if ( false !== $pre ) {
$new_schemas[ $key ] = $this->do_filter( $hook . '_entity', $pre );
$new_schemas[ $key ] = $this->do_filter( 'snippet/rich_snippet_entity', $new_schemas[ $key ] );
continue;
}
$new_schemas[ $key ] = $this->do_filter( $hook . '_entity', $schema );
$new_schemas[ $key ] = $this->do_filter( 'snippet/rich_snippet_entity', $new_schemas[ $key ] );
}
return $new_schemas;
}
/**
* Whether to add global schema entities.
*
* @param array $data Array of json-ld data.
* @param bool $is_product_archive Whether the current page is a Product archive.
* @return bool
*/
public function can_add_global_entities( $data = [], $is_product_archive = false ) {
if ( ! $is_product_archive && ( is_category() || is_tag() || is_tax() ) ) {
$queried_object = get_queried_object();
return ! Helper::get_settings( 'titles.remove_' . $queried_object->taxonomy . '_snippet_data' ) && ! $this->do_filter( 'snippet/remove_taxonomy_data', false, $queried_object->taxonomy );
}
if ( is_front_page() || ! is_singular() || ! Helper::can_use_default_schema( $this->post_id ) || ! empty( $data ) ) {
return true;
}
$schemas = DB::get_schemas( $this->post_id );
if ( ! empty( $schemas ) ) {
return true;
}
/**
* Allow developer to remove global schema entities.
*
* @param bool $can_add
* @param JsonLD $unsigned JsonLD instance.
*/
return $this->do_filter( 'schema/add_global_entities', Helper::get_default_schema_type( $this->post_id, true ), $this );
}
/**
* Can add breadcrumb schema.
*
* @return bool
*/
private function can_add_breadcrumb() {
/**
* Allow developer to disable the breadcrumb JSON-LD output.
*
* @param bool $unsigned Default: true
*/
return ! is_front_page() && Helper::is_breadcrumbs_enabled() && $this->do_filter( 'json_ld/breadcrumbs_enabled', true );
}
/**
* Check if current page is a WooCommerce archive page.
*
* @return bool
*/
private function is_product_archive_page() {
return Helper::is_woocommerce_active() && ( ( is_tax() && in_array( get_query_var( 'taxonomy' ), get_object_taxonomies( 'product' ), true ) ) || is_shop() );
}
/**
* Add property to entity.
*
* @param string $prop Name of the property to add into entity.
* @param array $entity Array of json-ld entity.
* @param string $key Property key to add into entity.
* @param array $data Array of json-ld data.
*/
public function add_prop( $prop, &$entity, $key = '', $data = [] ) {
if ( empty( $prop ) ) {
return;
}
$hash = [
'email' => [ 'titles.email', 'email' ],
'phone' => [ 'titles.phone', 'telephone' ],
];
if ( isset( $hash[ $prop ] ) && $value = Helper::get_settings( $hash[ $prop ][0] ) ) { // phpcs:ignore
$entity[ $hash[ $prop ][1] ] = $value;
return;
}
$perform = "add_prop_{$prop}";
if ( method_exists( $this, $perform ) ) {
$this->$perform( $entity, $key, $data );
}
}
/**
* Add logo property to the entity.
*
* @param array $entity Array of JSON-LD entity.
*/
private function add_prop_image( &$entity ) {
$logo = Helper::get_settings( 'titles.knowledgegraph_logo' );
if ( ! $logo ) {
$logo_id = \get_option( 'site_logo' );
$logo = $logo_id ? wp_get_attachment_image_url( $logo_id ) : '';
}
if ( ! $logo ) {
return;
}
$entity['logo'] = [
'@type' => 'ImageObject',
'@id' => home_url( '/#logo' ),
'url' => $logo,
'contentUrl' => $logo,
'caption' => $this->get_website_name(),
];
$this->add_prop_language( $entity['logo'] );
$attachment = wp_get_attachment_metadata( Helper::get_settings( 'titles.knowledgegraph_logo_id' ), true );
if ( ! $attachment ) {
return;
}
$entity['logo']['width'] = $attachment['width'];
$entity['logo']['height'] = $attachment['height'];
}
/**
* Add Language property to the entity.
*
* @param array $entity Array of JSON-LD entity.
*/
private function add_prop_language( &$entity ) {
$entity['inLanguage'] = $this->do_filter( 'schema/language', get_bloginfo( 'language' ) );
}
/**
* Add Image property to entity.
*
* @param array $entity Array of json-ld entity.
* @param string $key Entity Key.
* @param array $data Schema Data.
*/
private function add_prop_thumbnail( &$entity, $key, $data ) {
if ( ! empty( $data['primaryImage'] ) ) {
$entity[ $key ] = [ '@id' => $data['primaryImage']['@id'] ];
}
}
/**
* Add isPartOf property to entity.
*
* @param array $entity Array of json-ld entity.
* @param string $key Entity Key.
*/
private function add_prop_is_part_of( &$entity, $key ) {
$hash = [
'website' => home_url( '/#website' ),
'webpage' => Paper::get()->get_canonical() . '#webpage',
];
if ( ! empty( $hash[ $key ] ) ) {
$entity['isPartOf'] = [ '@id' => $hash[ $key ] ];
}
}
/**
* Add publisher property to entity
*
* @param array $entity Entity.
* @param string $key Entity Key.
* @param array $data Schema Data.
*/
public function add_prop_publisher( &$entity, $key, $data ) {
if ( empty( $data['publisher'] ) ) {
return;
}
$entity[ $key ] = [ '@id' => $data['publisher']['@id'] ];
}
/**
* Add url property to entity.
*
* @param array $entity Array of JSON-LD entity.
*/
private function add_prop_url( &$entity ) {
if ( $url = Helper::get_settings( 'titles.url' ) ) { // phpcs:ignore
$entity['url'] = ! Url::is_relative( $url ) ? $url : 'http://' . $url;
}
}
/**
* Add address property to entity.
*
* @param array $entity Array of JSON-LD entity.
*/
private function add_prop_address( &$entity ) {
if ( $address = Helper::get_settings( 'titles.local_address' ) ) { // phpcs:ignore
$entity['address'] = [ '@type' => 'PostalAddress' ] + $address;
}
}
/**
* Add aggregateratings to entity.
*
* @param string $schema Schema to get data for.
* @param array $entity Array of JSON-LD entity to attach data to.
*/
public function add_ratings( $schema, &$entity ) {
$rating = Helper::get_post_meta( "snippet_{$schema}_rating" );
// Early Bail!
if ( ! $rating ) {
return;
}
$entity['review'] = [
'author' => [
'@type' => 'Person',
'name' => get_the_author_meta( 'display_name' ),
],
'datePublished' => get_post_time( 'Y-m-d\TH:i:sP', false ),
'dateModified' => get_post_modified_time( 'Y-m-d\TH:i:sP', false ),
'reviewRating' => [
'@type' => 'Rating',
'ratingValue' => $rating,
'bestRating' => Helper::get_post_meta( "snippet_{$schema}_rating_max" ) ? Helper::get_post_meta( "snippet_{$schema}_rating_max" ) : 5,
'worstRating' => Helper::get_post_meta( "snippet_{$schema}_rating_min" ) ? Helper::get_post_meta( "snippet_{$schema}_rating_min" ) : 1,
],
];
}
/**
* Get website name with a fallback to bloginfo( 'name' ).
*
* @return string
*/
public function get_website_name() {
return Helper::get_settings( 'titles.website_name', $this->get_organization_name() );
}
/**
* Get website name with a fallback to bloginfo( 'name' ).
*
* @return string
*/
public function get_organization_name() {
$name = Helper::get_settings( 'titles.knowledgegraph_name' );
return $name ? $name : get_bloginfo( 'name' );
}
/**
* Set publisher/provider data for JSON-LD.
*
* @param array $entity Array of JSON-LD entity.
* @param array $organization Organization data.
* @param string $type Type data set to. Default: 'publisher'.
*/
public function set_publisher( &$entity, $organization, $type = 'publisher' ) {
$keys = [ '@context', '@type', 'url', 'name', 'logo', 'image', 'contactPoint', 'sameAs' ];
foreach ( $keys as $key ) {
if ( ! isset( $organization[ $key ] ) ) {
continue;
}
$entity[ $type ][ $key ] = 'logo' !== $key ? $organization[ $key ] : [
'@type' => 'ImageObject',
'url' => $organization[ $key ],
];
}
}
/**
* Set address for JSON-LD.
*
* @param string $schema Schema to get data for.
* @param array $entity Array of JSON-LD entity to attach data to.
*/
public function set_address( $schema, &$entity ) {
$address = Helper::get_post_meta( "snippet_{$schema}_address" );
// Early Bail!
if ( ! is_array( $address ) || empty( $address ) ) {
return;
}
$entity['address'] = [ '@type' => 'PostalAddress' ];
foreach ( $address as $key => $value ) {
$entity['address'][ $key ] = $value;
}
}
/**
* Set data to entity.
*
* Loop through post meta value grab data and attache it to the entity.
*
* @param array $hash Key to get data and Value to save as.
* @param array $entity Array of JSON-LD entity to attach data to.
*/
public function set_data( $hash, &$entity ) {
foreach ( $hash as $metakey => $dest ) {
$entity[ $dest ] = Helper::get_post_meta( $metakey, $this->post_id );
}
}
/**
* Get post title.
*
* Retrieves the title in this order.
* 1. Custom post meta set in rich snippet
* 2. Headline template set in Titles & Meta
*
* @param int $post_id Post ID to get title for.
*
* @return string
*/
public function get_post_title( $post_id = 0 ) {
$title = Helper::get_post_meta( 'snippet_name', $post_id );
if ( ! $title && ! empty( $this->post ) ) {
$title = Helper::replace_vars( Helper::get_settings( "titles.pt_{$this->post->post_type}_default_snippet_name", '%seo_title%' ), $this->post );
}
$title = $title ? $title : Paper::get()->get_title();
return Str::truncate( $title );
}
/**
* Get post url.
*
* @param int $post_id Post ID to get URL for.
* @return string
*/
public function get_post_url( $post_id = 0 ) {
$url = Helper::get_post_meta( 'snippet_url', $post_id );
return $url ? $url : ( 0 === $post_id ? Paper::get()->get_canonical() : get_the_permalink( $post_id ) );
}
/**
* Get product description.
*
* @param object $product Product Object.
* @return string
*/
public function get_product_desc( $product = [] ) {
if ( empty( $product ) ) {
return;
}
if ( $description = Helper::get_post_meta( 'description', $product->get_id() ) ) { //phpcs:ignore
return $description;
}
$product_object = get_post( $product->get_id() );
$description = Paper::get_from_options( 'pt_product_description', $product_object, '%excerpt%' );
if ( ! $description ) {
$description = $product->get_short_description() ? $product->get_short_description() : $product->get_description();
}
$description = $this->do_filter( 'product_description/apply_shortcode', false ) ? do_shortcode( $description ) : Helper::strip_shortcodes( $description );
return wp_strip_all_tags( $description, true );
}
/**
* Get product title.
*
* @param object $product Product Object.
* @return string
*/
public function get_product_title( $product = [] ) {
if ( empty( $product ) ) {
return '';
}
if ( $title = Helper::get_post_meta( 'title', $product->get_id() ) ) { //phpcs:ignore
return $title;
}
$product_object = get_post( $product->get_id() );
$title = Paper::get_from_options( 'pt_product_title', $product_object, '%title% %sep% %sitename%' );
return $title ? $title : $product->get_name();
}
/**
* Get post parts.
*/
private function get_parts() {
$parts = [
'title' => $this->get_post_title(),
'url' => $this->get_post_url(),
'canonical' => Paper::get()->get_canonical(),
'modified' => mysql2date( DATE_W3C, $this->post->post_modified, false ),
'published' => mysql2date( DATE_W3C, $this->post->post_date, false ),
'excerpt' => Helper::replace_vars( '%excerpt%', $this->post ),
];
// Description.
$desc = Helper::get_post_meta( 'snippet_desc' );
if ( ! $desc ) {
$desc = Helper::replace_vars( Helper::get_settings( "titles.pt_{$this->post->post_type}_default_snippet_desc" ), $this->post );
}
$parts['desc'] = $desc ? $desc : ( Helper::get_post_meta( 'description' ) ? Helper::get_post_meta( 'description' ) : $parts['excerpt'] );
// Author.
$author = Helper::get_post_meta( 'snippet_author' );
$parts['author'] = $author ? $author : get_the_author_meta( 'display_name', $this->post->post_author );
// Modified date cannot be before publish date.
if ( strtotime( $this->post->post_modified ) < strtotime( $this->post->post_date ) ) {
$parts['modified'] = $parts['published'];
}
$this->parts = $parts;
}
/**
* Get global social profile URLs, to use in the `sameAs` property.
*
* @link https://developers.google.com/webmasters/structured-data/customize/social-profiles
*/
public function get_social_profiles() {
$profiles = [ Helper::get_settings( 'titles.social_url_facebook' ) ];
$twitter = Helper::get_settings( 'titles.twitter_author_names' );
if ( $twitter ) {
$profiles[] = "https://twitter.com/$twitter";
}
$addional_profiles = Helper::get_settings( 'titles.social_additional_profiles' );
if ( ! empty( $addional_profiles ) ) {
$profiles = array_merge( $profiles, Arr::from_string( $addional_profiles, "\n" ) );
}
return array_values( array_filter( $profiles ) );
}
}