}> */ public static function definitions() { $availability = array( 'InStock' => __( 'In stock', 'orbitstem-seo' ), 'OutOfStock' => __( 'Out of stock', 'orbitstem-seo' ), 'PreOrder' => __( 'Pre-order', 'orbitstem-seo' ), 'BackOrder' => __( 'Back order', 'orbitstem-seo' ), 'Discontinued' => __( 'Discontinued', 'orbitstem-seo' ), ); $defs = array( 'FAQPage' => array( 'label' => __( 'FAQ', 'orbitstem-seo' ), 'help' => __( 'Questions and answers that also appear on the page.', 'orbitstem-seo' ), 'fields' => array( 'items' => array( 'kind' => 'pairs', 'label' => __( 'Questions', 'orbitstem-seo' ), 'a' => __( 'Question', 'orbitstem-seo' ), 'b' => __( 'Answer', 'orbitstem-seo' ), 'required' => true, ), ), ), 'HowTo' => array( 'label' => __( 'How-to', 'orbitstem-seo' ), 'help' => __( 'Step-by-step instructions.', 'orbitstem-seo' ), 'fields' => array( 'steps' => array( 'kind' => 'pairs', 'label' => __( 'Steps', 'orbitstem-seo' ), 'a' => __( 'Step name', 'orbitstem-seo' ), 'b' => __( 'What to do', 'orbitstem-seo' ), 'required' => true, ), 'totalTime' => array( 'kind' => 'number', 'label' => __( 'Total time (minutes)', 'orbitstem-seo' ), ), 'supply' => array( 'kind' => 'lines', 'label' => __( 'Materials (one per line)', 'orbitstem-seo' ), ), 'tool' => array( 'kind' => 'lines', 'label' => __( 'Tools (one per line)', 'orbitstem-seo' ), ), ), ), 'Product' => array( 'label' => __( 'Product', 'orbitstem-seo' ), 'help' => __( 'A product you sell or review. WooCommerce products get this automatically.', 'orbitstem-seo' ), 'fields' => array( 'brand' => array( 'kind' => 'text', 'label' => __( 'Brand', 'orbitstem-seo' ), ), 'sku' => array( 'kind' => 'text', 'label' => __( 'SKU', 'orbitstem-seo' ), ), 'price' => array( 'kind' => 'number', 'label' => __( 'Price', 'orbitstem-seo' ), 'required' => true, ), 'currency' => array( 'kind' => 'currency', 'label' => __( 'Currency (e.g. USD, EUR, INR)', 'orbitstem-seo' ), 'required' => true, ), 'availability' => array( 'kind' => 'select', 'label' => __( 'Availability', 'orbitstem-seo' ), 'options' => $availability, ), 'rating' => array( 'kind' => 'number', 'label' => __( 'Average rating (1 to 5)', 'orbitstem-seo' ), 'min' => 1, 'max' => 5, ), 'reviewCount' => array( 'kind' => 'number', 'label' => __( 'Number of ratings', 'orbitstem-seo' ), ), ), ), 'Recipe' => array( 'label' => __( 'Recipe', 'orbitstem-seo' ), 'help' => __( 'Ingredients, steps and times. Uses the featured image.', 'orbitstem-seo' ), 'fields' => array( 'ingredients' => array( 'kind' => 'lines', 'label' => __( 'Ingredients (one per line)', 'orbitstem-seo' ), 'required' => true, ), 'instructions' => array( 'kind' => 'lines', 'label' => __( 'Steps (one per line)', 'orbitstem-seo' ), 'required' => true, ), 'prepTime' => array( 'kind' => 'number', 'label' => __( 'Preparation time (minutes)', 'orbitstem-seo' ), ), 'cookTime' => array( 'kind' => 'number', 'label' => __( 'Cooking time (minutes)', 'orbitstem-seo' ), ), 'yield' => array( 'kind' => 'text', 'label' => __( 'Servings (e.g. 4 servings)', 'orbitstem-seo' ), ), 'cuisine' => array( 'kind' => 'text', 'label' => __( 'Cuisine', 'orbitstem-seo' ), ), 'category' => array( 'kind' => 'text', 'label' => __( 'Course (e.g. Dessert)', 'orbitstem-seo' ), ), 'calories' => array( 'kind' => 'number', 'label' => __( 'Calories per serving', 'orbitstem-seo' ), ), ), ), 'Event' => array( 'label' => __( 'Event', 'orbitstem-seo' ), 'help' => __( 'A concert, class, webinar or other event with a date.', 'orbitstem-seo' ), 'fields' => array( 'startDate' => array( 'kind' => 'datetime', 'label' => __( 'Starts', 'orbitstem-seo' ), 'required' => true, ), 'endDate' => array( 'kind' => 'datetime', 'label' => __( 'Ends', 'orbitstem-seo' ), ), 'mode' => array( 'kind' => 'select', 'label' => __( 'Where', 'orbitstem-seo' ), 'options' => array( 'offline' => __( 'At a venue', 'orbitstem-seo' ), 'online' => __( 'Online', 'orbitstem-seo' ), ), ), 'venue' => array( 'kind' => 'text', 'label' => __( 'Venue name', 'orbitstem-seo' ), ), 'address' => array( 'kind' => 'text', 'label' => __( 'Venue address (needed for events at a venue)', 'orbitstem-seo' ), ), 'onlineUrl' => array( 'kind' => 'url', 'label' => __( 'Online event address', 'orbitstem-seo' ), ), 'price' => array( 'kind' => 'number', 'label' => __( 'Ticket price (0 for free)', 'orbitstem-seo' ), ), 'currency' => array( 'kind' => 'currency', 'label' => __( 'Currency (e.g. USD, EUR, INR)', 'orbitstem-seo' ), ), 'ticketUrl' => array( 'kind' => 'url', 'label' => __( 'Ticket address', 'orbitstem-seo' ), ), ), ), 'Course' => array( 'label' => __( 'Course', 'orbitstem-seo' ), 'help' => __( 'A course or class. Uses the post title and description.', 'orbitstem-seo' ), 'fields' => array( 'provider' => array( 'kind' => 'text', 'label' => __( 'Provider (empty: this site)', 'orbitstem-seo' ), ), 'code' => array( 'kind' => 'text', 'label' => __( 'Course code', 'orbitstem-seo' ), ), ), ), 'JobPosting' => array( 'label' => __( 'Job posting', 'orbitstem-seo' ), 'help' => __( 'An open position. Uses the post content as the job description.', 'orbitstem-seo' ), 'fields' => array( 'validThrough' => array( 'kind' => 'date', 'label' => __( 'Apply before', 'orbitstem-seo' ), ), 'employmentType' => array( 'kind' => 'select', 'label' => __( 'Type', 'orbitstem-seo' ), 'options' => array( 'FULL_TIME' => __( 'Full time', 'orbitstem-seo' ), 'PART_TIME' => __( 'Part time', 'orbitstem-seo' ), 'CONTRACTOR' => __( 'Contractor', 'orbitstem-seo' ), 'TEMPORARY' => __( 'Temporary', 'orbitstem-seo' ), 'INTERN' => __( 'Internship', 'orbitstem-seo' ), ), ), 'remote' => array( 'kind' => 'select', 'label' => __( 'Remote', 'orbitstem-seo' ), 'options' => array( 'no' => __( 'No', 'orbitstem-seo' ), 'yes' => __( 'Yes, fully remote', 'orbitstem-seo' ), ), ), 'locality' => array( 'kind' => 'text', 'label' => __( 'City', 'orbitstem-seo' ), 'required' => true, ), 'region' => array( 'kind' => 'text', 'label' => __( 'State or region', 'orbitstem-seo' ), ), 'country' => array( 'kind' => 'text', 'label' => __( 'Country code (e.g. US, IN)', 'orbitstem-seo' ), 'required' => true, ), 'salaryMin' => array( 'kind' => 'number', 'label' => __( 'Salary from', 'orbitstem-seo' ), ), 'salaryMax' => array( 'kind' => 'number', 'label' => __( 'Salary to', 'orbitstem-seo' ), ), 'currency' => array( 'kind' => 'currency', 'label' => __( 'Currency (e.g. USD, EUR, INR)', 'orbitstem-seo' ), ), 'unit' => array( 'kind' => 'select', 'label' => __( 'Salary per', 'orbitstem-seo' ), 'options' => array( 'YEAR' => __( 'Year', 'orbitstem-seo' ), 'MONTH' => __( 'Month', 'orbitstem-seo' ), 'HOUR' => __( 'Hour', 'orbitstem-seo' ), ), ), ), ), 'Review' => array( 'label' => __( 'Review', 'orbitstem-seo' ), 'help' => __( 'Your review of a product, book, film, place or service.', 'orbitstem-seo' ), 'fields' => array( 'itemName' => array( 'kind' => 'text', 'label' => __( 'What is reviewed', 'orbitstem-seo' ), 'required' => true, ), 'itemType' => array( 'kind' => 'select', 'label' => __( 'It is a', 'orbitstem-seo' ), 'options' => array( 'Product' => __( 'Product', 'orbitstem-seo' ), 'Book' => __( 'Book', 'orbitstem-seo' ), 'Movie' => __( 'Film', 'orbitstem-seo' ), 'LocalBusiness' => __( 'Local business', 'orbitstem-seo' ), 'SoftwareApplication' => __( 'Software', 'orbitstem-seo' ), 'Course' => __( 'Course', 'orbitstem-seo' ), ), ), 'rating' => array( 'kind' => 'number', 'label' => __( 'Your rating (1 to 5)', 'orbitstem-seo' ), 'required' => true, 'min' => 1, 'max' => 5, ), ), ), 'VideoObject' => array( 'label' => __( 'Video', 'orbitstem-seo' ), 'help' => __( 'Videos from YouTube are detected automatically; use this for others.', 'orbitstem-seo' ), 'fields' => array( 'contentUrl' => array( 'kind' => 'url', 'label' => __( 'Video file or page address', 'orbitstem-seo' ), 'required' => true, ), 'thumbnailUrl' => array( 'kind' => 'url', 'label' => __( 'Thumbnail image (empty: featured image)', 'orbitstem-seo' ), ), 'duration' => array( 'kind' => 'number', 'label' => __( 'Length (seconds)', 'orbitstem-seo' ), ), ), ), ); // Prices, times, counts and salaries are never negative. foreach ( $defs as $type => $def ) { foreach ( $def['fields'] as $name => $field ) { if ( 'number' === $field['kind'] && ! isset( $field['min'] ) ) { $defs[ $type ]['fields'][ $name ]['min'] = 0; } } } return $defs; } /** * Cleans a stored value (JSON string or array) to {type, data}, or ''. * * @param mixed $value Raw value. * @return string JSON, or '' when there is nothing valid. */ public static function sanitize( $value ) { $value = is_string( $value ) ? json_decode( $value, true ) : $value; if ( ! is_array( $value ) || empty( $value['type'] ) || ! is_string( $value['type'] ) ) { return ''; } $defs = self::definitions(); if ( ! isset( $defs[ $value['type'] ] ) ) { return ''; } $data = isset( $value['data'] ) && is_array( $value['data'] ) ? $value['data'] : array(); $clean = array(); foreach ( $defs[ $value['type'] ]['fields'] as $name => $field ) { $raw = isset( $data[ $name ] ) ? $data[ $name ] : ''; $val = self::clean_field( $field, $raw ); if ( '' !== $val && array() !== $val ) { $clean[ $name ] = $val; } } return (string) wp_json_encode( array( 'type' => $value['type'], 'data' => (object) $clean, ) ); } /** * Cleans one field. * * @param array $field Definition. * @param mixed $raw Raw value. * @return mixed */ private static function clean_field( array $field, $raw ) { switch ( $field['kind'] ) { case 'pairs': $out = array(); foreach ( is_array( $raw ) ? $raw : array() as $pair ) { $a = is_array( $pair ) && isset( $pair[0] ) ? Settings::sanitize_text( $pair[0] ) : ''; $b = is_array( $pair ) && isset( $pair[1] ) ? wp_kses_post( mb_substr( (string) $pair[1], 0, 5000 ) ) : ''; if ( '' !== $a || '' !== trim( $b ) ) { $out[] = array( $a, $b ); } } return array_slice( $out, 0, 50 ); case 'lines': $lines = array(); foreach ( preg_split( '/[\r\n]+/', is_scalar( $raw ) ? (string) $raw : '' ) as $line ) { $line = Settings::sanitize_text( $line ); if ( '' !== $line ) { $lines[] = $line; } } return implode( "\n", array_slice( $lines, 0, 100 ) ); case 'number': return is_numeric( $raw ) ? (string) ( 0 + $raw ) : ''; case 'url': return Settings::sanitize_absolute_url( $raw ); case 'date': return is_string( $raw ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ? $raw : ''; case 'datetime': return is_string( $raw ) && preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $raw ) ? $raw : ''; case 'select': return is_string( $raw ) && isset( $field['options'][ $raw ] ) ? $raw : ''; case 'currency': return strtoupper( Settings::sanitize_text( $raw ) ); case 'textarea': return mb_substr( sanitize_textarea_field( is_scalar( $raw ) ? (string) $raw : '' ), 0, 5000 ); default: return Settings::sanitize_text( $raw ); } } /** * Decoded stored value. * * @param string $json Stored JSON. * @return array{type:string,data:array}|null */ public static function decode( $json ) { $value = json_decode( (string) $json, true ); if ( ! is_array( $value ) || empty( $value['type'] ) || ! isset( self::definitions()[ $value['type'] ] ) ) { return null; } return array( 'type' => $value['type'], 'data' => isset( $value['data'] ) && is_array( $value['data'] ) ? $value['data'] : array(), ); } /** * What Google needs that is not filled in yet, in plain words. * * @param string $type Type. * @param array $data Values. * @param WP_Post|null $post Post, for rules about its content. * @param string|null $image The page's image (social, featured or default image), or null to look it up from the post. * @param bool $with_items Whether to include notes about single FAQ questions or HowTo steps that are * left out (they do not stop the rest from being printed). * @return string[] */ public static function missing( $type, array $data, $post = null, $image = null, $with_items = true ) { $defs = self::definitions(); if ( ! isset( $defs[ $type ] ) ) { return array(); } $out = array(); foreach ( $defs[ $type ]['fields'] as $name => $field ) { $value = isset( $data[ $name ] ) ? $data[ $name ] : ''; if ( 'pairs' === $field['kind'] && is_array( $value ) ) { // Blank items are dropped when saved, so they do not count as filled in. $value = array_values( array_filter( $value, function ( $pair ) { return is_array( $pair ) && ( '' !== self::pair_part( $pair, 0 ) || '' !== self::pair_part( $pair, 1 ) ); } ) ); } if ( ! empty( $field['required'] ) && ( '' === $value || array() === $value ) ) { /* translators: %s: field label */ $out[] = sprintf( __( 'Fill in "%s": search engines need it for this rich result.', 'orbitstem-seo' ), $field['label'] ); continue; } if ( '' === $value || ! is_scalar( $value ) ) { continue; } if ( 'number' === $field['kind'] && ( ! is_numeric( $value ) || ( isset( $field['min'] ) && $value < $field['min'] ) || ( isset( $field['max'] ) && $value > $field['max'] ) ) ) { $out[] = isset( $field['max'] ) ? /* translators: 1: field label, 2: lowest value, 3: highest value */ sprintf( __( '"%1$s" must be between %2$s and %3$s.', 'orbitstem-seo' ), $field['label'], $field['min'], $field['max'] ) : /* translators: 1: field label, 2: lowest value */ sprintf( __( '"%1$s" cannot be lower than %2$s.', 'orbitstem-seo' ), $field['label'], isset( $field['min'] ) ? $field['min'] : 0 ); } if ( 'currency' === $field['kind'] && ! in_array( strtoupper( trim( (string) $value ) ), self::currencies(), true ) ) { /* translators: 1: field label, 2: value */ $out[] = sprintf( __( '"%1$s": "%2$s" is not a currency code. Use the three-letter code, such as USD, EUR, GBP or INR.', 'orbitstem-seo' ), $field['label'], $value ); } if ( in_array( $field['kind'], array( 'date', 'datetime' ), true ) && ! self::valid_date( (string) $value ) ) { /* translators: %s: field label */ $out[] = sprintf( __( '"%s" is not a real date.', 'orbitstem-seo' ), $field['label'] ); } } if ( 'Event' === $type && ! empty( $data['startDate'] ) && ! empty( $data['endDate'] ) && $data['endDate'] < $data['startDate'] ) { $out[] = __( 'The event ends before it starts.', 'orbitstem-seo' ); } if ( 'Event' === $type && ( empty( $data['mode'] ) || 'offline' === $data['mode'] ) && empty( $data['address'] ) ) { $out[] = __( 'Fill in "Venue address": search engines need to know where an event at a venue takes place.', 'orbitstem-seo' ); } if ( 'JobPosting' === $type && $post instanceof WP_Post && '' === trim( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) ) ) { $out[] = __( 'Write the job description in the post content: search engines need it for job listings.', 'orbitstem-seo' ); } // Incomplete questions and steps are only left out themselves; the rest is still printed. if ( $with_items && 'FAQPage' === $type && ! empty( $data['items'] ) && is_array( $data['items'] ) ) { $number = 0; foreach ( $data['items'] as $pair ) { $question = is_array( $pair ) ? self::pair_part( $pair, 0 ) : ''; $answer = is_array( $pair ) ? self::pair_part( $pair, 1 ) : ''; if ( '' === $question && '' === $answer ) { continue; // Blank items are dropped when saved. } ++$number; if ( '' === $answer ) { /* translators: %d: question number */ $out[] = sprintf( __( 'Question %d has no answer: search engines only show questions with an answer, so it is left out.', 'orbitstem-seo' ), $number ); } elseif ( '' === $question ) { /* translators: %d: question number */ $out[] = sprintf( __( 'Answer %d has no question: write the question, or it is left out.', 'orbitstem-seo' ), $number ); } } } if ( $with_items && 'HowTo' === $type && ! empty( $data['steps'] ) && is_array( $data['steps'] ) ) { $number = 0; foreach ( $data['steps'] as $pair ) { $step = is_array( $pair ) ? self::pair_part( $pair, 0 ) : ''; $text = is_array( $pair ) ? self::pair_part( $pair, 1 ) : ''; if ( '' === $step && '' === $text ) { continue; } ++$number; if ( '' === $text ) { /* translators: %d: step number */ $out[] = sprintf( __( 'Step %d says nothing to do: search engines need the text of each step, so it is left out.', 'orbitstem-seo' ), $number ); } } } // Every item incomplete: nothing would be left to print. foreach ( array( 'FAQPage' => 'items', 'HowTo' => 'steps', ) as $list_type => $key ) { if ( $list_type === $type && ! empty( $data[ $key ] ) && is_array( $data[ $key ] ) && ! self::complete_items( $type, $data[ $key ] ) ) { $out[] = 'FAQPage' === $type ? __( 'No question has both a question and an answer yet, so the FAQ is left out.', 'orbitstem-seo' ) : __( 'No step has its text yet, so the how-to is left out.', 'orbitstem-seo' ); } } if ( 'VideoObject' === $type && empty( $data['thumbnailUrl'] ) ) { if ( null === $image && $post instanceof WP_Post ) { $image = self::page_image( $post ); } if ( null !== $image && '' === (string) $image ) { $out[] = __( 'Add a thumbnail image (or a featured image): search engines need one to show a video.', 'orbitstem-seo' ); } } return $out; } /** * One part of a two-part item (0: question or step name, 1: answer or * text) as plain text, so an answer that is only empty markup counts as empty. * * @param array $pair Item. * @param int $part 0 or 1. * @return string */ private static function pair_part( array $pair, $part ) { return isset( $pair[ $part ] ) && is_scalar( $pair[ $part ] ) ? trim( wp_strip_all_tags( (string) $pair[ $part ] ) ) : ''; } /** * The FAQ questions with an answer, or the HowTo steps with text, as printed. * * @param string $type FAQPage or HowTo. * @param array $items Items. * @return array */ private static function complete_items( $type, array $items ) { $out = array(); foreach ( $items as $pair ) { if ( ! is_array( $pair ) || '' === self::pair_part( $pair, 1 ) || ( 'FAQPage' === $type && '' === self::pair_part( $pair, 0 ) ) ) { continue; } $out[] = array( isset( $pair[0] ) ? (string) $pair[0] : '', (string) $pair[1] ); } return $out; } /** * The image a post's page uses (social image, featured image, then the * site's default image), the same order as the page's own share image. * * @param WP_Post $post Post. * @return string */ private static function page_image( WP_Post $post ) { $social = (string) get_post_meta( $post->ID, \Orbitstem\SEO\Content\MetaRepository::PREFIX . 'social_image', true ); if ( '' !== trim( $social ) ) { return $social; } $featured = get_the_post_thumbnail_url( $post, 'large' ); if ( $featured ) { return (string) $featured; } $settings = (array) get_option( Settings::OPTION, array() ); return isset( $settings['og_default_image'] ) ? (string) $settings['og_default_image'] : ''; } /** * ISO 4217 currency codes search engines accept for prices. * * @return string[] */ public static function currencies() { return explode( ' ', 'AED AFN ALL AMD ANG AOA ARS AUD AWG AZN BAM BBD BDT BGN BHD BIF BMD BND BOB BRL BSD BTN BWP BYN BZD CAD CDF CHF CLP CNY COP CRC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HTG HUF IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SLL SOS SRD SSP STN SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS UAH UGX USD UYU UZS VES VND VUV WST XAF XCD XCG XOF XPF YER ZAR ZMW ZWG' ); } /** * Whether "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM" is a real calendar date and time. * * @param string $value Value. * @return bool */ private static function valid_date( $value ) { if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/', $value, $m ) ) { return false; } if ( ! checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) ) { return false; } return ! isset( $m[4] ) || ( (int) $m[4] < 24 && (int) $m[5] < 60 ); } /** * JSON-LD node for a post, or null when required values are missing * (an incomplete node can get a site a manual action, so none is printed). * * @param string $type Type. * @param array $data Values. * @param WP_Post $post Post. * @param array $resolved Resolver output (title, description, image, url). * @param array $ids @ids: page, publisher. * @return array|null */ public static function node( $type, array $data, WP_Post $post, array $resolved, array $ids ) { // Only problems with the whole result stop it; an incomplete question or step is just skipped below. if ( self::missing( $type, $data, $post, (string) $resolved['image'], false ) ) { return null; } $url = $resolved['url']; $name = TemplateEngine::plain( $post->post_title ); $desc = (string) $resolved['description']; $image = (string) $resolved['image']; $get = function ( $key ) use ( $data ) { return isset( $data[ $key ] ) ? $data[ $key ] : ''; }; $node = array( '@type' => $type, '@id' => $url . '#' . strtolower( $type ), ); switch ( $type ) { case 'FAQPage': $node['isPartOf'] = array( '@id' => $ids['page'] ); $node['mainEntity'] = array(); foreach ( self::complete_items( 'FAQPage', (array) $get( 'items' ) ) as $pair ) { $node['mainEntity'][] = array( '@type' => 'Question', 'name' => $pair[0], 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $pair[1], ), ); } return $node['mainEntity'] ? $node : null; case 'HowTo': $node['name'] = $name; if ( '' !== $desc ) { $node['description'] = $desc; } if ( '' !== $image ) { $node['image'] = $image; } if ( '' !== $get( 'totalTime' ) ) { $node['totalTime'] = 'PT' . (int) $get( 'totalTime' ) . 'M'; } foreach ( array( 'supply' => 'HowToSupply', 'tool' => 'HowToTool', ) as $key => $kind ) { foreach ( self::lines( $get( $key ) ) as $line ) { $node[ $key ][] = array( '@type' => $kind, 'name' => $line, ); } } $node['step'] = array(); foreach ( self::complete_items( 'HowTo', (array) $get( 'steps' ) ) as $i => $pair ) { $node['step'][] = array_filter( array( '@type' => 'HowToStep', 'position' => $i + 1, 'name' => $pair[0], 'text' => wp_strip_all_tags( $pair[1] ), 'url' => $url . '#step-' . ( $i + 1 ), ) ); } return $node['step'] ? $node : null; case 'Product': $node['name'] = $name; if ( '' !== $desc ) { $node['description'] = $desc; } if ( '' !== $image ) { $node['image'] = $image; } if ( '' !== $get( 'brand' ) ) { $node['brand'] = array( '@type' => 'Brand', 'name' => $get( 'brand' ), ); } if ( '' !== $get( 'sku' ) ) { $node['sku'] = $get( 'sku' ); } $node['offers'] = array( '@type' => 'Offer', 'url' => $url, 'price' => $get( 'price' ), 'priceCurrency' => strtoupper( $get( 'currency' ) ), 'availability' => 'https://schema.org/' . ( '' !== $get( 'availability' ) ? $get( 'availability' ) : 'InStock' ), ); if ( '' !== $get( 'rating' ) && (int) $get( 'reviewCount' ) > 0 ) { $node['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => $get( 'rating' ), 'reviewCount' => (int) $get( 'reviewCount' ), ); } return $node; case 'Recipe': $node['name'] = $name; $node['author'] = self::author( $post ); $node['datePublished'] = get_post_time( 'c', true, $post ); if ( '' !== $desc ) { $node['description'] = $desc; } if ( '' !== $image ) { $node['image'] = $image; } foreach ( array( 'prepTime', 'cookTime' ) as $key ) { if ( '' !== $get( $key ) ) { $node[ $key ] = 'PT' . (int) $get( $key ) . 'M'; } } if ( '' !== $get( 'prepTime' ) && '' !== $get( 'cookTime' ) ) { $node['totalTime'] = 'PT' . ( (int) $get( 'prepTime' ) + (int) $get( 'cookTime' ) ) . 'M'; } foreach ( array( 'yield' => 'recipeYield', 'cuisine' => 'recipeCuisine', 'category' => 'recipeCategory', ) as $key => $prop ) { if ( '' !== $get( $key ) ) { $node[ $prop ] = $get( $key ); } } if ( '' !== $get( 'calories' ) ) { $node['nutrition'] = array( '@type' => 'NutritionInformation', 'calories' => (int) $get( 'calories' ) . ' calories', ); } $node['recipeIngredient'] = self::lines( $get( 'ingredients' ) ); $node['recipeInstructions'] = array(); foreach ( self::lines( $get( 'instructions' ) ) as $line ) { $node['recipeInstructions'][] = array( '@type' => 'HowToStep', 'text' => $line, ); } return $node; case 'Event': $tz = wp_timezone(); $node['name'] = $name; $node['startDate'] = self::iso( $get( 'startDate' ), $tz ); $node['eventStatus'] = 'https://schema.org/EventScheduled'; if ( '' !== $get( 'endDate' ) ) { $node['endDate'] = self::iso( $get( 'endDate' ), $tz ); } if ( '' !== $desc ) { $node['description'] = $desc; } if ( '' !== $image ) { $node['image'] = $image; } if ( 'online' === $get( 'mode' ) ) { $node['eventAttendanceMode'] = 'https://schema.org/OnlineEventAttendanceMode'; $node['location'] = array( '@type' => 'VirtualLocation', 'url' => '' !== $get( 'onlineUrl' ) ? $get( 'onlineUrl' ) : $url, ); } else { $node['eventAttendanceMode'] = 'https://schema.org/OfflineEventAttendanceMode'; $node['location'] = array( '@type' => 'Place', 'name' => '' !== $get( 'venue' ) ? $get( 'venue' ) : $name, 'address' => $get( 'address' ), ); } if ( '' !== $get( 'price' ) ) { $node['offers'] = array( '@type' => 'Offer', 'price' => $get( 'price' ), 'priceCurrency' => strtoupper( '' !== $get( 'currency' ) ? $get( 'currency' ) : 'USD' ), 'url' => '' !== $get( 'ticketUrl' ) ? $get( 'ticketUrl' ) : $url, 'availability' => 'https://schema.org/InStock', ); } $node['organizer'] = array( '@id' => $ids['publisher'] ); return $node; case 'Course': $node['name'] = $name; $node['description'] = '' !== $desc ? $desc : $name; $node['provider'] = '' !== $get( 'provider' ) ? array( '@type' => 'Organization', 'name' => $get( 'provider' ), ) : array( '@id' => $ids['publisher'] ); if ( '' !== $get( 'code' ) ) { $node['courseCode'] = $get( 'code' ); } return $node; case 'JobPosting': $node['title'] = $name; $node['description'] = wpautop( wp_kses_post( strip_shortcodes( $post->post_content ) ) ); $node['datePosted'] = get_post_time( 'Y-m-d', true, $post ); $node['hiringOrganization'] = array( '@id' => $ids['publisher'] ); $node['jobLocation'] = array( '@type' => 'Place', 'address' => array_filter( array( '@type' => 'PostalAddress', 'addressLocality' => $get( 'locality' ), 'addressRegion' => $get( 'region' ), 'addressCountry' => strtoupper( $get( 'country' ) ), ) ), ); if ( 'yes' === $get( 'remote' ) ) { $node['jobLocationType'] = 'TELECOMMUTE'; $node['applicantLocationRequirements'] = array( '@type' => 'Country', 'name' => strtoupper( $get( 'country' ) ), ); } if ( '' !== $get( 'validThrough' ) ) { $node['validThrough'] = $get( 'validThrough' ) . 'T23:59:59'; } if ( '' !== $get( 'employmentType' ) ) { $node['employmentType'] = $get( 'employmentType' ); } if ( '' !== $get( 'salaryMin' ) && '' !== $get( 'currency' ) ) { $node['baseSalary'] = array( '@type' => 'MonetaryAmount', 'currency' => strtoupper( $get( 'currency' ) ), 'value' => array_filter( array( '@type' => 'QuantitativeValue', 'minValue' => $get( 'salaryMin' ), 'maxValue' => $get( 'salaryMax' ), 'unitText' => '' !== $get( 'unit' ) ? $get( 'unit' ) : 'YEAR', ) ), ); } return $node; case 'Review': $node['itemReviewed'] = array( '@type' => '' !== $get( 'itemType' ) ? $get( 'itemType' ) : 'Product', 'name' => $get( 'itemName' ), ); $node['reviewRating'] = array( '@type' => 'Rating', 'ratingValue' => $get( 'rating' ), 'bestRating' => 5, 'worstRating' => 1, ); $node['author'] = self::author( $post ); $node['datePublished'] = get_post_time( 'c', true, $post ); $node['name'] = $name; if ( '' !== $desc ) { $node['reviewBody'] = $desc; } return $node; case 'VideoObject': $thumb = '' !== $get( 'thumbnailUrl' ) ? $get( 'thumbnailUrl' ) : $image; if ( '' === $thumb ) { return null; // Google requires a thumbnail. } $node['name'] = $name; $node['description'] = '' !== $desc ? $desc : $name; $node['thumbnailUrl'] = $thumb; $node['uploadDate'] = get_post_time( 'c', true, $post ); $node['contentUrl'] = $get( 'contentUrl' ); if ( '' !== $get( 'duration' ) ) { $node['duration'] = 'PT' . (int) $get( 'duration' ) . 'S'; } return $node; } return null; } /** * VideoObject nodes for YouTube videos embedded in a post. The thumbnail is * YouTube's own image address for the video; nothing is fetched from it. * * @param WP_Post $post Post. * @param array $resolved Resolver output. * @return array */ public static function detected_videos( WP_Post $post, array $resolved ) { $nodes = array(); foreach ( self::youtube_ids( (string) $post->post_content ) as $i => $id ) { $nodes[] = array( '@type' => 'VideoObject', '@id' => $resolved['url'] . '#video-' . ( $i + 1 ), 'name' => TemplateEngine::plain( $post->post_title ) . ( $i ? ' (' . ( $i + 1 ) . ')' : '' ), 'description' => '' !== $resolved['description'] ? $resolved['description'] : TemplateEngine::plain( $post->post_title ), 'thumbnailUrl' => 'https://i.ytimg.com/vi/' . $id . '/hqdefault.jpg', 'uploadDate' => get_post_time( 'c', true, $post ), 'embedUrl' => 'https://www.youtube.com/embed/' . $id, ); } return $nodes; } /** * IDs of YouTube videos embedded in content (at most five): embed blocks, * iframes (including youtube-nocookie.com), [embed] shortcodes and * addresses alone on a line, which WordPress turns into players. A plain * link to a video is not an embedded video and is ignored. * * @param string $content Post content. * @return string[] */ public static function youtube_ids( $content ) { $id_in = '(?:youtube(?:-nocookie)?\.com/(?:watch\?(?:[^"\s<]*&(?:amp;)?)?v=|embed/|shorts/|live/)|youtu\.be/)([A-Za-z0-9_-]{11})'; $found = array(); $sets = array( // Players: