diff --git a/includes/classes/legacy/class-schema.php b/includes/classes/legacy/class-schema.php index e912577d..77fe8ed2 100644 --- a/includes/classes/legacy/class-schema.php +++ b/includes/classes/legacy/class-schema.php @@ -3,6 +3,13 @@ /** * Schema Class * + * Entry point for Tour Operator structured data. Loads the shared helper class + * and the three P1 graph pieces (Trip, Accommodation, TouristDestination). + * + * When Yoast SEO is active the pieces are registered via the Yoast graph API. + * When Yoast is inactive a standalone JSON-LD block is printed in
using + * the same piece classes via `output_standalone_schema()`. + * * @package Tour Operator * @author LightSpeed * @license GPL3 @@ -13,7 +20,7 @@ namespace lsx\legacy; /** - * Main plugin class. + * Main Schema orchestrator class. * * @package Schema * @author LightSpeed @@ -30,16 +37,25 @@ class Schema /** * Constructor + * + * Loads new graph-piece classes and registers them either with Yoast SEO + * (when the WPSEO_Graph_Piece interface is present) or as a standalone + * wp_head output (when Yoast is inactive). */ public function __construct() { + // Always load shared helpers and piece classes. + require_once LSX_TO_PATH . 'includes/classes/schema/class-lsx-to-schema-helpers.php'; + require_once LSX_TO_PATH . 'includes/classes/schema/pieces/class-lsx-to-schema-trip.php'; + require_once LSX_TO_PATH . 'includes/classes/schema/pieces/class-lsx-to-schema-accommodation.php'; + require_once LSX_TO_PATH . 'includes/classes/schema/pieces/class-lsx-to-schema-destination.php'; + if (interface_exists('WPSEO_Graph_Piece')) { - require_once LSX_TO_PATH . 'includes/classes/legacy/schema/class-schema-utils.php'; - require_once LSX_TO_PATH . 'includes/classes/legacy/schema/class-lsx-to-schema-graph-piece.php'; - require_once LSX_TO_PATH . 'includes/classes/legacy/schema/class-lsx-to-tour-schema.php'; - require_once LSX_TO_PATH . 'includes/classes/legacy/schema/class-lsx-to-accommodation-schema.php'; - require_once LSX_TO_PATH . 'includes/classes/legacy/schema/class-lsx-to-destination-schema.php'; + // Yoast SEO is active: register new pieces via its graph API. add_filter('wpseo_schema_graph_pieces', array($this, 'add_graph_pieces'), 11, 2); + } else { + // No Yoast: output a standalone JSON-LD graph in . + add_action('wp_head', array($this, 'output_standalone_schema'), 5); } } @@ -59,199 +75,110 @@ public static function get_instance() } /** - * Adds Schema pieces to our output. + * Adds new graph pieces to the Yoast SEO schema graph. * - * @param array $pieces Graph pieces to output. - * @param \WPSEO_Schema_Context $context Object with context variables. + * Each piece is wrapped in a LSX_TO_Schema_Piece_Adapter so that it + * satisfies the WPSEO_Graph_Piece interface without the piece classes + * themselves depending on Yoast. * - * @return array $pieces Graph pieces to output. + * @param array $pieces Existing graph pieces. + * @param \WPSEO_Schema_Context $context Yoast context object. + * @return array Updated graph pieces. */ public function add_graph_pieces($pieces, $context) { - $pieces[] = new \LSX_TO_Tour_Schema($context); - $pieces[] = new \LSX_TO_Accommodation_Schema($context); - $pieces[] = new \LSX_TO_Destination_Schema($context); + $pieces[] = new LSX_TO_Schema_Piece_Adapter(new \lsx\schema\pieces\Trip($context)); + $pieces[] = new LSX_TO_Schema_Piece_Adapter(new \lsx\schema\pieces\Accommodation($context)); + $pieces[] = new LSX_TO_Schema_Piece_Adapter(new \lsx\schema\pieces\Destination($context)); return $pieces; } /** - * Creates the schema for the tour post type + * Prints a standalone JSON-LD schema graph when Yoast SEO is not active. * - * @since 1.0.0 - */ - public function tour_single_schema() - { - if (is_singular('tour')) { - $start_val = get_post_meta(get_the_ID(), 'booking_validity_start', false); - $end_val = get_post_meta(get_the_ID(), 'booking_validity_end', false); - - if (! empty($des_list)) { - foreach ($des_list as $single_destination) { - ++$i; - $url_option = get_the_permalink() . '#destination-' . $i; - $destination_name = get_the_title($single_destination); - $schema_day = array( - '@type' => 'PostalAddress', - 'addressLocality' => $destination_name, - ); - $des_schema[] = $schema_day; - } - } - $meta = array( - array( - 'address' => $des_schema, - 'telephone' => '0216713090', - ), - ); - $output = wp_json_encode($meta, JSON_UNESCAPED_SLASHES); -?> - - name; - } - } - global $post; - - $args = array( - 'post_parent' => $post->ID, - 'posts_per_page' => -1, - 'post_type' => 'destination', - ); - - $the_query = new \WP_Query($args); - $the_regions = array(); - - if ($the_query->have_posts()) { - while ($the_query->have_posts()) { - $the_query->the_post(); - $region_title = get_the_title(); - $region_description = wp_strip_all_tags(get_the_content()); - - $region_list = array( - '@type' => 'TouristAttraction', - 'name' => $region_title, - 'description' => $region_description, - ); - $the_regions[] = $region_list; + $pieces = array( + new \lsx\schema\pieces\Trip(), + new \lsx\schema\pieces\Accommodation(), + new \lsx\schema\pieces\Destination(), + ); + + foreach ($pieces as $piece) { + if ($piece->is_needed()) { + $graph = array( + '@context' => 'https://schema.org', + '@graph' => array($piece->generate()), + ); + // JSON_HEX_TAG prevents injection; JSON_HEX_AMP avoids + // HTML entity issues. JSON_UNESCAPED_UNICODE keeps readability. + $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP; + $json = wp_json_encode($graph, $flags); + if ($json) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '\n"; } + // Only one piece should match per page. + break; } - - wp_reset_postdata(); - - $meta = array( - '@context' => 'http://schema.org', - '@type' => 'TouristDestination', - 'name' => $destination_name, - 'address' => $street_address, - 'description' => $destination_description, - 'touristType' => $destination_travel, - 'url' => $destination_url, - 'geo' => array( - '@type' => 'GeoCoordinates', - 'latitude' => $lat_address, - 'longitude' => $long_address, - ), - 'containsPlace' => $the_regions, - ); - $output = wp_json_encode($meta, JSON_UNESCAPED_SLASHES); - ?> - - base_currency; + /** + * The wrapped schema piece instance. + * + * @var object + */ + private $piece; + + /** + * Constructor. + * + * @param object $piece Schema piece with is_needed() and generate() methods. + */ + public function __construct($piece) + { + $this->piece = $piece; + } - foreach ($spoken_languages as $language) { - foreach ($language as $morelanguage) { - ++$i; - $url_option = get_the_permalink() . '#language-' . $i; - $language_list = array( - '@type' => 'language', - '@id' => $url_option, - 'name' => $morelanguage, - ); - $final_lang_list[] = $language_list; - } - } + /** + * Determines whether the piece should be added to the graph. + * + * @return bool + */ + public function is_needed() + { + return $this->piece->is_needed(); + } - $meta = array( - 'availableLanguage' => $final_lang_list, - 'address' => array( - 'addressCountry' => $country, - 'addressRegion' => $region_destinations, - 'streetAddress' => $street_address, - ), - 'checkinTime' => $checkin_accommodation, - 'checkoutTime' => $checkout_accommodation, - 'employee' => $accommodation_expert, - 'image' => $image_accommodation, - 'name' => $title_accommodation, - 'numberOfRooms' => $rooms_accommodation, - 'priceRange' => $price_val . $price_accommodation, - 'url' => $url_accommodation, - 'telephone' => '+18666434336', - ); - $output = wp_json_encode($meta, JSON_UNESCAPED_SLASHES); - ?> - -piece->generate(); } } } diff --git a/includes/classes/schema/class-lsx-to-schema-helpers.php b/includes/classes/schema/class-lsx-to-schema-helpers.php new file mode 100644 index 00000000..0d6fa76d --- /dev/null +++ b/includes/classes/schema/class-lsx-to-schema-helpers.php @@ -0,0 +1,265 @@ +options['currency'] ) ) { + return strtoupper( sanitize_text_field( $tour_operator->options['currency'] ) ); + } + if ( ! empty( $tour_operator->options['general']['currency'] ) ) { + return strtoupper( sanitize_text_field( $tour_operator->options['general']['currency'] ) ); + } + } + return 'USD'; + } + + /** + * Format a day count as an ISO 8601 duration string. + * + * Returns `'P7D'` for 7 days, or an empty string when the value is not a + * positive integer. + * + * @param string $value Raw duration value (expected to be a day count). + * @return string ISO 8601 duration string or empty string. + */ + public static function format_iso_duration( $value ) { + $int = (int) $value; + if ( $int < 1 ) { + return ''; + } + return 'P' . $int . 'D'; + } + + /** + * Format a Unix timestamp or date string as an ISO 8601 date (YYYY-MM-DD). + * + * Returns an empty string when the input cannot be parsed as a valid date. + * + * @param string $value Raw timestamp or date string. + * @return string ISO 8601 date string or empty string. + */ + public static function format_iso_date( $value ) { + $value = (string) $value; + if ( '' === $value ) { + return ''; + } + // Numeric UNIX timestamp. + if ( is_numeric( $value ) && (int) $value > 0 ) { + return gmdate( 'Y-m-d', (int) $value ); + } + // Date string – attempt strtotime. + $ts = strtotime( $value ); + if ( false !== $ts && $ts > 0 ) { + return gmdate( 'Y-m-d', $ts ); + } + return ''; + } + + /** + * Format a time value into HH:MM 24-hour format. + * + * Returns an empty string when the input cannot be parsed. + * + * @param string $value Raw time value (e.g. '14:00', '2:00 PM'). + * @return string Formatted time string or empty string. + */ + public static function format_time( $value ) { + $value = (string) $value; + if ( '' === $value ) { + return ''; + } + $ts = strtotime( $value ); + if ( false === $ts ) { + return ''; + } + return gmdate( 'H:i', $ts ); + } + + /** + * Convert an array of month slugs to a human-readable comma-separated string. + * + * Accepts lowercase month slugs (e.g. 'january') and returns their capitalised + * label equivalents (e.g. 'January'). Unknown slugs are silently ignored. + * + * @param array $slugs Array of month slug strings. + * @return string Comma-separated month labels, or empty string. + */ + public static function month_slugs_to_labels( array $slugs ) { + static $map = null; + if ( null === $map ) { + $map = array( + 'january' => 'January', + 'february' => 'February', + 'march' => 'March', + 'april' => 'April', + 'may' => 'May', + 'june' => 'June', + 'july' => 'July', + 'august' => 'August', + 'september' => 'September', + 'october' => 'October', + 'november' => 'November', + 'december' => 'December', + ); + // Allow translations or overrides via WordPress filter. + if ( function_exists( 'apply_filters' ) ) { + $map = (array) apply_filters( 'lsx_to_schema_month_labels', $map ); + } + } + + $labels = array(); + foreach ( $slugs as $slug ) { + $slug = strtolower( trim( (string) $slug ) ); + if ( isset( $map[ $slug ] ) ) { + $labels[] = $map[ $slug ]; + } + } + return implode( ', ', $labels ); + } + + /** + * Build a schema.org PropertyValue node. + * + * @param string $name Property name (human-readable label). + * @param string $value Property value. + * @return array PropertyValue array. + */ + public static function make_property_value( $name, $value ) { + return array( + '@type' => 'PropertyValue', + 'name' => (string) $name, + 'value' => (string) $value, + ); + } + + /** + * Strip all HTML tags from a string and decode HTML entities. + * + * Falls back to `strip_tags()` when `wp_strip_all_tags()` is not available + * (e.g. in unit tests without a WordPress environment). + * + * @param string $value Raw HTML string. + * @return string Plain text. + */ + public static function strip_to_text( $value ) { + $value = (string) $value; + if ( function_exists( 'wp_strip_all_tags' ) ) { + $clean = wp_strip_all_tags( $value ); + } else { + $clean = strip_tags( $value ); + } + return html_entity_decode( $clean, ENT_QUOTES, 'UTF-8' ); + } +} diff --git a/includes/classes/schema/pieces/class-lsx-to-schema-accommodation.php b/includes/classes/schema/pieces/class-lsx-to-schema-accommodation.php new file mode 100644 index 00000000..a5644705 --- /dev/null +++ b/includes/classes/schema/pieces/class-lsx-to-schema-accommodation.php @@ -0,0 +1,436 @@ +context = $context; + $this->post_id = ( null !== $context ) ? (int) $context->id : (int) get_the_ID(); + $this->post = get_post( $this->post_id ); + $this->canonical = (string) get_permalink( $this->post_id ); + } + + /** + * Determines whether this piece should be added to the graph. + * + * @return bool + */ + public function is_needed() { + return is_singular( 'accommodation' ); + } + + /** + * Generates and returns the Accommodation schema data array. + * + * @return array Schema.org Accommodation data. + */ + public function generate() { + $data = array( + '@type' => 'LodgingBusiness', + '@id' => $this->canonical . '#/schema/accommodation/' . $this->post_id, + 'name' => get_the_title( $this->post_id ), + 'url' => $this->canonical, + 'mainEntityOfPage' => array( '@id' => $this->canonical ), + ); + + // Description: prefer excerpt over stripped post content. + $description = $this->get_description(); + if ( '' !== $description ) { + $data['description'] = $description; + } + + // Image. + $data = $this->add_image( $data ); + + // numberOfRooms. + $rooms = (int) Helpers::get_meta( $this->post_id, 'number_of_rooms' ); + if ( $rooms > 0 ) { + $data['numberOfRooms'] = $rooms; + } + + // Check-in / check-out times. + $checkin = Helpers::format_time( Helpers::get_meta( $this->post_id, 'checkin_time' ) ); + $checkout = Helpers::format_time( Helpers::get_meta( $this->post_id, 'checkout_time' ) ); + if ( '' !== $checkin ) { + $data['checkinTime'] = $checkin; + } + if ( '' !== $checkout ) { + $data['checkoutTime'] = $checkout; + } + + // Available languages. + $data = $this->add_languages( $data ); + + // Star rating (official ratings only). + $data = $this->add_star_rating( $data ); + + // Address and geo coordinates. + $data = $this->add_location( $data ); + + // Contained in (related destinations). + $data = $this->add_contained_in( $data ); + + // Offers. + $data = $this->add_offers( $data ); + + // Additional properties. + $data = $this->add_additional_properties( $data ); + + /** + * Filter the complete Accommodation schema data array. + * + * @param array $data Accommodation schema data. + * @param int $post_id Current accommodation post ID. + */ + return (array) apply_filters( 'lsx_to_schema_accommodation_data', $data, $this->post_id ); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Get post description. + * + * @return string Plain-text description. + */ + protected function get_description() { + if ( ! is_object( $this->post ) ) { + return ''; + } + if ( '' !== $this->post->post_excerpt ) { + return Helpers::strip_to_text( $this->post->post_excerpt ); + } + $content = apply_filters( 'the_content', $this->post->post_content ); + return Helpers::strip_to_text( $content ); + } + + /** + * Add image from Yoast context or featured image fallback. + * + * @param array $data Schema data. + * @return array + */ + protected function add_image( array $data ) { + if ( null !== $this->context && $this->context->has_image && defined( 'WPSEO_Schema_IDs::PRIMARY_IMAGE_HASH' ) ) { + $data['image'] = array( '@id' => $this->canonical . \WPSEO_Schema_IDs::PRIMARY_IMAGE_HASH ); + } else { + $thumbnail_url = get_the_post_thumbnail_url( $this->post_id, 'large' ); + if ( $thumbnail_url ) { + $data['image'] = esc_url( $thumbnail_url ); + } + } + return $data; + } + + /** + * Add availableLanguage from the spoken_languages multiselect field. + * + * @param array $data Schema data. + * @return array + */ + protected function add_languages( array $data ) { + $languages = Helpers::get_meta_array( $this->post_id, 'spoken_languages' ); + if ( empty( $languages ) ) { + return $data; + } + + $nodes = array(); + foreach ( $languages as $lang ) { + $label = sanitize_text_field( (string) $lang ); + if ( '' !== $label ) { + $nodes[] = array( + '@type' => 'Language', + 'name' => ucfirst( $label ), + ); + } + } + + if ( ! empty( $nodes ) ) { + $data['availableLanguage'] = $nodes; + } + + return $data; + } + + /** + * Add starRating for official accommodation ratings only. + * + * Only outputs `starRating` when `rating_type` is one of the recognised + * official classification systems (TGCSA, Hotelstars Union). An unspecified + * or user-review rating is NOT mapped to `starRating` to avoid misleading + * structured data. + * + * @param array $data Schema data. + * @return array + */ + protected function add_star_rating( array $data ) { + $rating = Helpers::get_meta( $this->post_id, 'rating' ); + $rating_type = Helpers::get_meta( $this->post_id, 'rating_type' ); + + /** + * Filter the list of official rating type slugs that map to starRating. + * + * @param string[] $types Official rating type slugs. + */ + $official_types = (array) apply_filters( 'lsx_to_official_rating_types', self::OFFICIAL_RATING_TYPES ); + + if ( '' === $rating || '0' === $rating || ! in_array( $rating_type, $official_types, true ) ) { + return $data; + } + + $data['starRating'] = array( + '@type' => 'Rating', + 'ratingValue' => (int) $rating, + 'bestRating' => 5, + 'worstRating' => 1, + ); + + return $data; + } + + /** + * Add address and geo coordinates from the `location` pw_map field. + * + * @param array $data Schema data. + * @return array + */ + protected function add_location( array $data ) { + $location = get_post_meta( $this->post_id, 'location', true ); + if ( ! is_array( $location ) || empty( $location ) ) { + return $data; + } + + if ( ! empty( $location['address'] ) ) { + $data['address'] = sanitize_text_field( $location['address'] ); + } + + if ( ! empty( $location['latitude'] ) && ! empty( $location['longitude'] ) ) { + $data['geo'] = array( + '@type' => 'GeoCoordinates', + 'latitude' => (float) $location['latitude'], + 'longitude' => (float) $location['longitude'], + ); + } + + return $data; + } + + /** + * Add containedInPlace from destination_to_accommodation relationship field. + * + * @param array $data Schema data. + * @return array + */ + protected function add_contained_in( array $data ) { + $dest_ids = Helpers::get_meta_array( $this->post_id, 'destination_to_accommodation' ); + if ( empty( $dest_ids ) ) { + return $data; + } + + $places = array(); + foreach ( $dest_ids as $dest_id ) { + $dest_id = (int) $dest_id; + if ( $dest_id > 0 && get_post( $dest_id ) ) { + $places[] = array( + '@type' => 'TouristDestination', + 'name' => get_the_title( $dest_id ), + 'url' => get_permalink( $dest_id ), + ); + } + } + + if ( ! empty( $places ) ) { + $data['containedInPlace'] = ( 1 === count( $places ) ) ? $places[0] : $places; + } + + return $data; + } + + /** + * Build Offer node for price, sale_price, and price_type. + * + * The price_type label is mapped to a PriceSpecification description so + * humans and search engines understand the pricing model (e.g. "Per Person + * Sharing Per Night"). + * + * @param array $data Schema data. + * @return array + */ + protected function add_offers( array $data ) { + $price = Helpers::get_meta( $this->post_id, 'price' ); + $sale_price = Helpers::get_meta( $this->post_id, 'sale_price' ); + $price_type = Helpers::get_meta( $this->post_id, 'price_type' ); + $currency = Helpers::get_currency(); + + $active_price = Helpers::normalise_price( '' !== $sale_price ? $sale_price : $price ); + if ( '' === $active_price ) { + return $data; + } + + $offer = array( + '@type' => 'Offer', + 'price' => $active_price, + 'priceCurrency' => $currency, + ); + + // Human-readable price type as PriceSpecification. + if ( '' !== $price_type && 'none' !== $price_type && function_exists( 'lsx_to_get_price_type_label' ) ) { + $label = lsx_to_get_price_type_label( $price_type ); + if ( '' !== $label ) { + $offer['priceSpecification'] = array( + '@type' => 'PriceSpecification', + 'description' => $label, + ); + } + } + + $data['offers'] = $offer; + return $data; + } + + /** + * Append additionalProperty nodes for tourism-specific fields. + * + * Fields: single_supplement, best_time_to_visit, minimum_child_age, + * suggested_visitor_types, special_interests. + * + * @param array $data Schema data. + * @return array + */ + protected function add_additional_properties( array $data ) { + $properties = array(); + + // Tagline (slogan is not a valid Accommodation/Place property). + $tagline = sanitize_text_field( Helpers::get_meta( $this->post_id, 'tagline' ) ); + if ( '' !== $tagline ) { + $properties[] = Helpers::make_property_value( 'Tagline', $tagline ); + } + + // Single supplement. + $supplement = Helpers::normalise_price( Helpers::get_meta( $this->post_id, 'single_supplement' ) ); + if ( '' !== $supplement ) { + $currency = Helpers::get_currency(); + $properties[] = Helpers::make_property_value( 'Single supplement', $currency . ' ' . $supplement ); + } + + // Best time to visit. + $best_time_slugs = Helpers::get_meta_array( $this->post_id, 'best_time_to_visit' ); + if ( ! empty( $best_time_slugs ) ) { + $labels = Helpers::month_slugs_to_labels( $best_time_slugs ); + if ( '' !== $labels ) { + $properties[] = Helpers::make_property_value( 'Best time to visit', $labels ); + } + } + + // Minimum child age. + $min_child_age = Helpers::get_meta( $this->post_id, 'minimum_child_age' ); + if ( '' !== $min_child_age ) { + $properties[] = Helpers::make_property_value( 'Minimum child age', sanitize_text_field( $min_child_age ) ); + } + + // Suggested visitor types (multiselect – sanitise each value). + $visitor_types = Helpers::get_meta_array( $this->post_id, 'suggested_visitor_types' ); + $visitor_types = array_filter( + $visitor_types, + static function ( $v ) { + return '' !== $v; + } + ); + if ( ! empty( $visitor_types ) ) { + $properties[] = Helpers::make_property_value( + 'Suitable for', + implode( ', ', array_map( 'sanitize_text_field', $visitor_types ) ) + ); + } + + // Special interests (multiselect). + $interests = Helpers::get_meta_array( $this->post_id, 'special_interests' ); + $interests = array_filter( + $interests, + static function ( $v ) { + return '' !== $v; + } + ); + if ( ! empty( $interests ) ) { + $properties[] = Helpers::make_property_value( + 'Special interests', + implode( ', ', array_map( 'sanitize_text_field', $interests ) ) + ); + } + + if ( ! empty( $properties ) ) { + $data['additionalProperty'] = $properties; + } + + return $data; + } +} diff --git a/includes/classes/schema/pieces/class-lsx-to-schema-destination.php b/includes/classes/schema/pieces/class-lsx-to-schema-destination.php new file mode 100644 index 00000000..ddd0ada3 --- /dev/null +++ b/includes/classes/schema/pieces/class-lsx-to-schema-destination.php @@ -0,0 +1,365 @@ +context = $context; + $this->post_id = ( null !== $context ) ? (int) $context->id : (int) get_the_ID(); + $this->post = get_post( $this->post_id ); + $this->canonical = (string) get_permalink( $this->post_id ); + } + + /** + * Determines whether this piece should be added to the graph. + * + * @return bool + */ + public function is_needed() { + return is_singular( 'destination' ); + } + + /** + * Generates and returns the TouristDestination schema data array. + * + * @return array Schema.org TouristDestination data. + */ + public function generate() { + $data = array( + '@type' => 'TouristDestination', + '@id' => $this->canonical . '#/schema/destination/' . $this->post_id, + 'name' => get_the_title( $this->post_id ), + 'url' => $this->canonical, + 'mainEntityOfPage' => array( '@id' => $this->canonical ), + ); + + // Description. + $description = $this->get_description(); + if ( '' !== $description ) { + $data['description'] = $description; + } + + // Image. + $data = $this->add_image( $data ); + + // touristType from travel-style taxonomy. + $data = $this->add_tourist_type( $data ); + + // containedInPlace (parent destination). + $data = $this->add_contained_in( $data ); + + // containsPlace (direct child destinations). + $data = $this->add_contains_places( $data ); + + // Address and geo coordinates. + $data = $this->add_location( $data ); + + // Additional properties (travel information fields). + $data = $this->add_additional_properties( $data ); + + /** + * Filter the complete TouristDestination schema data array. + * + * @param array $data TouristDestination schema data. + * @param int $post_id Current destination post ID. + */ + return (array) apply_filters( 'lsx_to_schema_destination_data', $data, $this->post_id ); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Get post description. + * + * @return string Plain-text description. + */ + protected function get_description() { + if ( ! is_object( $this->post ) ) { + return ''; + } + if ( '' !== $this->post->post_excerpt ) { + return Helpers::strip_to_text( $this->post->post_excerpt ); + } + $content = apply_filters( 'the_content', $this->post->post_content ); + return Helpers::strip_to_text( $content ); + } + + /** + * Add image from Yoast context or featured image fallback. + * + * @param array $data Schema data. + * @return array + */ + protected function add_image( array $data ) { + if ( null !== $this->context && $this->context->has_image && defined( 'WPSEO_Schema_IDs::PRIMARY_IMAGE_HASH' ) ) { + $data['image'] = array( '@id' => $this->canonical . \WPSEO_Schema_IDs::PRIMARY_IMAGE_HASH ); + } else { + $thumbnail_url = get_the_post_thumbnail_url( $this->post_id, 'large' ); + if ( $thumbnail_url ) { + $data['image'] = esc_url( $thumbnail_url ); + } + } + return $data; + } + + /** + * Add touristType from the `travel-style` taxonomy. + * + * Each term becomes an Audience node with the term name as audienceType. + * + * @param array $data Schema data. + * @return array + */ + protected function add_tourist_type( array $data ) { + $terms = get_the_terms( $this->post_id, 'travel-style' ); + if ( ! is_array( $terms ) || empty( $terms ) ) { + return $data; + } + + $audiences = array(); + foreach ( $terms as $term ) { + if ( ! is_object( $term ) || '' === $term->name ) { + continue; + } + $audiences[] = array( + '@type' => 'Audience', + 'audienceType' => sanitize_text_field( $term->name ), + ); + } + + if ( ! empty( $audiences ) ) { + $data['touristType'] = $audiences; + } + + return $data; + } + + /** + * Add containedInPlace from the destination post parent. + * + * @param array $data Schema data. + * @return array + */ + protected function add_contained_in( array $data ) { + if ( ! is_object( $this->post ) ) { + return $data; + } + + $parent_id = (int) $this->post->post_parent; + if ( $parent_id <= 0 ) { + return $data; + } + + $parent = get_post( $parent_id ); + if ( ! is_object( $parent ) || 'destination' !== $parent->post_type ) { + return $data; + } + + $data['containedInPlace'] = array( + '@type' => 'TouristDestination', + 'name' => get_the_title( $parent_id ), + 'url' => get_permalink( $parent_id ), + ); + + return $data; + } + + /** + * Add containsPlace from direct child destinations. + * + * Fetches published direct children of the current destination post and + * lists each as a TouristDestination. This is most useful for country pages + * that contain region children. + * + * @param array $data Schema data. + * @return array + */ + protected function add_contains_places( array $data ) { + $children = get_posts( + array( + 'post_type' => 'destination', + 'post_parent' => $this->post_id, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + 'fields' => 'ids', + ) + ); + + if ( empty( $children ) ) { + return $data; + } + + $places = array(); + foreach ( $children as $child_id ) { + $child_id = (int) $child_id; + $places[] = array( + '@type' => 'TouristDestination', + 'name' => get_the_title( $child_id ), + 'url' => get_permalink( $child_id ), + ); + } + + if ( ! empty( $places ) ) { + $data['containsPlace'] = $places; + } + + return $data; + } + + /** + * Add address and geo coordinates from the `location` pw_map field. + * + * @param array $data Schema data. + * @return array + */ + protected function add_location( array $data ) { + $location = get_post_meta( $this->post_id, 'location', true ); + if ( ! is_array( $location ) || empty( $location ) ) { + return $data; + } + + if ( ! empty( $location['address'] ) ) { + $data['address'] = sanitize_text_field( $location['address'] ); + } + + if ( ! empty( $location['latitude'] ) && ! empty( $location['longitude'] ) ) { + $data['geo'] = array( + '@type' => 'GeoCoordinates', + 'latitude' => (float) $location['latitude'], + 'longitude' => (float) $location['longitude'], + ); + } + + return $data; + } + + /** + * Append additionalProperty nodes for all travel information fields. + * + * Tourism-specific travel information (electricity, banking, cuisine, etc.) + * is mapped to PropertyValue nodes because Schema.org does not provide clean + * first-class properties for these facts. HTML is stripped to plain text. + * + * Fields: best_time_to_visit, electricity, banking, cuisine, climate, + * transport, dress, health, safety, visa, additional_info. + * + * @param array $data Schema data. + * @return array + */ + protected function add_additional_properties( array $data ) { + $properties = array(); + + // Tagline (slogan is not a valid TouristDestination/Place property). + $tagline = sanitize_text_field( Helpers::get_meta( $this->post_id, 'tagline' ) ); + if ( '' !== $tagline ) { + $properties[] = Helpers::make_property_value( 'Tagline', $tagline ); + } + + // Best time to visit. + $best_time_slugs = Helpers::get_meta_array( $this->post_id, 'best_time_to_visit' ); + if ( ! empty( $best_time_slugs ) ) { + $labels = Helpers::month_slugs_to_labels( $best_time_slugs ); + if ( '' !== $labels ) { + $properties[] = Helpers::make_property_value( 'Best time to visit', $labels ); + } + } + + // Travel information fields from config-destination.php. + // Note: 'safety' is handled separately below to keep field ordering consistent. + $travel_info_fields = array( + 'electricity' => 'Electricity', + 'banking' => 'Banking', + 'cuisine' => 'Cuisine', + 'climate' => 'Climate', + 'transport' => 'Transport', + 'dress' => 'Dress', + 'health' => 'Health', + 'visa' => 'Visa', + 'additional_info' => 'General information', + ); + + foreach ( $travel_info_fields as $meta_key => $label ) { + $raw = Helpers::strip_to_text( Helpers::get_meta( $this->post_id, $meta_key ) ); + if ( '' !== $raw ) { + $properties[] = Helpers::make_property_value( $label, $raw ); + } + } + + // Safety: additionalProperty only (safetyConsideration is not a valid Place property). + $safety = Helpers::strip_to_text( Helpers::get_meta( $this->post_id, 'safety' ) ); + if ( '' !== $safety ) { + $properties[] = Helpers::make_property_value( 'Safety', $safety ); + } + + if ( ! empty( $properties ) ) { + $data['additionalProperty'] = $properties; + } + + return $data; + } +} diff --git a/includes/classes/schema/pieces/class-lsx-to-schema-trip.php b/includes/classes/schema/pieces/class-lsx-to-schema-trip.php new file mode 100644 index 00000000..b1eca098 --- /dev/null +++ b/includes/classes/schema/pieces/class-lsx-to-schema-trip.php @@ -0,0 +1,408 @@ +context = $context; + $this->post_id = ( null !== $context ) ? (int) $context->id : (int) get_the_ID(); + $this->post = get_post( $this->post_id ); + $this->canonical = (string) get_permalink( $this->post_id ); + } + + /** + * Determines whether this piece should be added to the graph. + * + * @return bool + */ + public function is_needed() { + return is_singular( 'tour' ); + } + + /** + * Generates and returns the Trip schema data array. + * + * @return array Schema.org Trip data. + */ + public function generate() { + $data = array( + '@type' => 'TouristTrip', + '@id' => $this->canonical . '#/schema/trip/' . $this->post_id, + 'name' => get_the_title( $this->post_id ), + 'url' => $this->canonical, + 'mainEntityOfPage' => array( '@id' => $this->canonical ), + ); + + // Description: prefer excerpt, fall back to stripped post content. + $description = $this->get_description(); + if ( '' !== $description ) { + $data['description'] = $description; + } + + // Duration → always additionalProperty (duration is not a valid Trip/TouristTrip property). + $duration_raw = Helpers::get_meta( $this->post_id, 'duration' ); + if ( '' !== $duration_raw ) { + $iso = Helpers::format_iso_duration( $duration_raw ); + $display = '' !== $iso ? $iso : sanitize_text_field( $duration_raw ); + $data = $this->append_property_value( $data, 'Duration', $display ); + } + + // Image: reference Yoast primary image when available, else featured image. + $data = $this->add_image( $data ); + + // tripOrigin / ends_in (departs_from / ends_in destination posts). + $data = $this->add_locations( $data ); + + // Offers node (price, sale_price, booking window). + $data = $this->add_offers( $data ); + + // Provider: reference Yoast site_represents_reference when present. + if ( null !== $this->context && ! empty( $this->context->site_represents_reference ) ) { + $data['provider'] = $this->context->site_represents_reference; + } + + // Itinerary: ordered subTrip list from repeatable CMB2 group. + $data = $this->add_itinerary( $data ); + + // Additional properties. + $data = $this->add_additional_properties( $data ); + + /** + * Filter the complete Trip schema data array. + * + * @param array $data Trip schema data. + * @param int $post_id Current tour post ID. + */ + return (array) apply_filters( 'lsx_to_schema_trip_data', $data, $this->post_id ); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Get post description, preferring excerpt over stripped post content. + * + * @return string Plain-text description. + */ + protected function get_description() { + if ( ! is_object( $this->post ) ) { + return ''; + } + if ( '' !== $this->post->post_excerpt ) { + return Helpers::strip_to_text( $this->post->post_excerpt ); + } + $content = apply_filters( 'the_content', $this->post->post_content ); + return Helpers::strip_to_text( $content ); + } + + /** + * Add image data from Yoast context or featured image fallback. + * + * @param array $data Schema data. + * @return array + */ + protected function add_image( array $data ) { + if ( null !== $this->context && $this->context->has_image && defined( 'WPSEO_Schema_IDs::PRIMARY_IMAGE_HASH' ) ) { + $data['image'] = array( '@id' => $this->canonical . \WPSEO_Schema_IDs::PRIMARY_IMAGE_HASH ); + } else { + $thumbnail_url = get_the_post_thumbnail_url( $this->post_id, 'large' ); + if ( $thumbnail_url ) { + $data['image'] = esc_url( $thumbnail_url ); + } + } + return $data; + } + + /** + * Add tripOrigin from departs_from and ends_in as additionalProperty. + * + * tripOrigin (schema.org) replaces the non-existent fromLocation property. + * toLocation is not a schema.org Trip property; ends_in is recorded as an + * additionalProperty instead. + * + * @param array $data Schema data. + * @return array + */ + protected function add_locations( array $data ) { + $departs_from_id = (int) Helpers::get_meta( $this->post_id, 'departs_from' ); + if ( $departs_from_id > 0 && get_post( $departs_from_id ) ) { + $data['tripOrigin'] = array( + '@type' => 'Place', + 'name' => get_the_title( $departs_from_id ), + 'url' => get_permalink( $departs_from_id ), + ); + } + + $ends_in_id = (int) Helpers::get_meta( $this->post_id, 'ends_in' ); + if ( $ends_in_id > 0 && get_post( $ends_in_id ) ) { + $data = $this->append_property_value( + $data, + 'Ends in', + get_the_title( $ends_in_id ) + ); + } + + return $data; + } + + /** + * Build the Offer node for pricing and booking windows. + * + * Uses sale_price as the active offer price when populated; otherwise falls + * back to the regular price. Booking validity timestamps are converted to + * ISO 8601 date strings. + * + * @param array $data Schema data. + * @return array + */ + protected function add_offers( array $data ) { + $price = Helpers::get_meta( $this->post_id, 'price' ); + $sale_price = Helpers::get_meta( $this->post_id, 'sale_price' ); + $currency = Helpers::get_currency(); + + $active_price = Helpers::normalise_price( '' !== $sale_price ? $sale_price : $price ); + + if ( '' === $active_price ) { + return $data; + } + + $offer = array( + '@type' => 'Offer', + 'price' => $active_price, + 'priceCurrency' => $currency, + ); + + $start_date = Helpers::format_iso_date( Helpers::get_meta( $this->post_id, 'booking_validity_start' ) ); + $end_date = Helpers::format_iso_date( Helpers::get_meta( $this->post_id, 'booking_validity_end' ) ); + + if ( '' !== $start_date ) { + $offer['availabilityStarts'] = $start_date; + } + if ( '' !== $end_date ) { + $offer['availabilityEnds'] = $end_date; + } + + $data['offers'] = $offer; + return $data; + } + + /** + * Build itinerary subTrip nodes from the repeatable CMB2 itinerary group. + * + * Each entry in the group becomes a `Trip` subtype with optional stop + * references to `Accommodation` and `TouristDestination` nodes. + * + * @param array $data Schema data. + * @return array + */ + protected function add_itinerary( array $data ) { + $itinerary = get_post_meta( $this->post_id, 'itinerary', false ); + if ( empty( $itinerary ) || ! is_array( $itinerary ) ) { + return $data; + } + + $sub_trips = array(); + + foreach ( $itinerary as $index => $day ) { + if ( ! is_array( $day ) ) { + continue; + } + + $title = isset( $day['title'] ) ? sanitize_text_field( $day['title'] ) : ''; + if ( '' === $title ) { + continue; + } + + $sub_trip = array( + '@type' => 'TouristTrip', + '@id' => $this->canonical . '#/schema/trip/' . $this->post_id . '/day/' . ( (int) $index + 1 ), + 'name' => $title, + ); + + $desc = isset( $day['description'] ) ? Helpers::strip_to_text( $day['description'] ) : ''; + if ( '' !== $desc ) { + $sub_trip['description'] = $desc; + } + + // Build itinerary stop list for this day. + $stops = array(); + + $accom_id = isset( $day['accommodation_to_tour'] ) ? (int) $day['accommodation_to_tour'] : 0; + if ( $accom_id > 0 && get_post( $accom_id ) ) { + $stops[] = array( + '@type' => 'LodgingBusiness', + 'name' => get_the_title( $accom_id ), + 'url' => get_permalink( $accom_id ), + ); + } + + $dest_id = isset( $day['destination_to_tour'] ) ? (int) $day['destination_to_tour'] : 0; + if ( $dest_id > 0 && get_post( $dest_id ) ) { + $stops[] = array( + '@type' => 'TouristDestination', + 'name' => get_the_title( $dest_id ), + 'url' => get_permalink( $dest_id ), + ); + } + + if ( ! empty( $stops ) ) { + $sub_trip['itinerary'] = $stops; + } + + $sub_trips[] = $sub_trip; + } + + if ( ! empty( $sub_trips ) ) { + $data['subTrip'] = $sub_trips; + } + + return $data; + } + + /** + * Append schema additionalProperty nodes for tourism-specific fields. + * + * Fields: single_supplement, best_time_to_visit, group_size, highlights, + * included, not_included. + * + * @param array $data Schema data. + * @return array + */ + protected function add_additional_properties( array $data ) { + $properties = array(); + + // Tagline (slogan is not a valid Trip/Thing property). + $tagline = sanitize_text_field( Helpers::get_meta( $this->post_id, 'tagline' ) ); + if ( '' !== $tagline ) { + $properties[] = Helpers::make_property_value( 'Tagline', $tagline ); + } + + // Single supplement (price value). + $supplement = Helpers::normalise_price( Helpers::get_meta( $this->post_id, 'single_supplement' ) ); + if ( '' !== $supplement ) { + $currency = Helpers::get_currency(); + $properties[] = Helpers::make_property_value( 'Single supplement', $currency . ' ' . $supplement ); + } + + // Best time to visit (multiselect → month labels). + $best_time_slugs = Helpers::get_meta_array( $this->post_id, 'best_time_to_visit' ); + if ( ! empty( $best_time_slugs ) ) { + $labels = Helpers::month_slugs_to_labels( $best_time_slugs ); + if ( '' !== $labels ) { + $properties[] = Helpers::make_property_value( 'Best time to visit', $labels ); + } + } + + // Group size (wysiwyg field – strip HTML). + $group_size = Helpers::strip_to_text( Helpers::get_meta( $this->post_id, 'group_size' ) ); + if ( '' !== $group_size ) { + $properties[] = Helpers::make_property_value( 'Group size', $group_size ); + } + + // Highlights. + $highlights = Helpers::strip_to_text( Helpers::get_meta( $this->post_id, 'highlights' ) ); + if ( '' !== $highlights ) { + $properties[] = Helpers::make_property_value( 'Highlights', $highlights ); + } + + // Included. + $included = Helpers::strip_to_text( Helpers::get_meta( $this->post_id, 'included' ) ); + if ( '' !== $included ) { + $properties[] = Helpers::make_property_value( 'Included', $included ); + } + + // Not included. + $not_included = Helpers::strip_to_text( Helpers::get_meta( $this->post_id, 'not_included' ) ); + if ( '' !== $not_included ) { + $properties[] = Helpers::make_property_value( 'Not included', $not_included ); + } + + if ( ! empty( $properties ) ) { + $data['additionalProperty'] = $properties; + } + + return $data; + } + + /** + * Append a single PropertyValue to the additionalProperty array. + * + * Used when a field (e.g. non-numeric duration) must be added before the + * main additionalProperty batch. + * + * @param array $data Schema data. + * @param string $name Property name. + * @param string $value Property value. + * @return array + */ + protected function append_property_value( array $data, $name, $value ) { + if ( ! isset( $data['additionalProperty'] ) ) { + $data['additionalProperty'] = array(); + } + $data['additionalProperty'][] = Helpers::make_property_value( $name, $value ); + return $data; + } +} diff --git a/tests/php/TestSchemaHelpers.php b/tests/php/TestSchemaHelpers.php new file mode 100644 index 00000000..f86dbb6f --- /dev/null +++ b/tests/php/TestSchemaHelpers.php @@ -0,0 +1,292 @@ +assertSame('12500', Helpers::normalise_price('12500')); + } + + /** + * A price with a currency symbol and spaces is stripped to a numeric string. + */ + public function test_normalise_price_strips_currency_symbol() + { + $this->assertSame('3500', Helpers::normalise_price('R 3,500')); + $this->assertSame('3500', Helpers::normalise_price('$ 3500')); + } + + /** + * A float price is preserved. + */ + public function test_normalise_price_float() + { + $this->assertSame('1250.5', Helpers::normalise_price('1250.50')); + } + + /** + * Zero is not a valid price – returns empty string. + */ + public function test_normalise_price_zero_returns_empty() + { + $this->assertSame('', Helpers::normalise_price('0')); + $this->assertSame('', Helpers::normalise_price('0.00')); + } + + /** + * Non-numeric input returns empty string. + */ + public function test_normalise_price_non_numeric_returns_empty() + { + $this->assertSame('', Helpers::normalise_price('POA')); + $this->assertSame('', Helpers::normalise_price('')); + $this->assertSame('', Helpers::normalise_price('contact us')); + } + + // ------------------------------------------------------------------------- + // format_iso_duration + // ------------------------------------------------------------------------- + + /** + * A positive integer produces an ISO 8601 day duration. + */ + public function test_format_iso_duration_positive_int() + { + $this->assertSame('P7D', Helpers::format_iso_duration('7')); + $this->assertSame('P14D', Helpers::format_iso_duration('14')); + $this->assertSame('P1D', Helpers::format_iso_duration('1')); + } + + /** + * Zero or negative values return empty string. + */ + public function test_format_iso_duration_zero_or_negative() + { + $this->assertSame('', Helpers::format_iso_duration('0')); + $this->assertSame('', Helpers::format_iso_duration('-3')); + } + + /** + * Non-numeric strings return empty string. + */ + public function test_format_iso_duration_non_numeric() + { + $this->assertSame('', Helpers::format_iso_duration('')); + $this->assertSame('', Helpers::format_iso_duration('seven days')); + } + + // ------------------------------------------------------------------------- + // format_iso_date + // ------------------------------------------------------------------------- + + /** + * A Unix timestamp is formatted as YYYY-MM-DD. + */ + public function test_format_iso_date_unix_timestamp() + { + // 2026-06-01 UTC + $ts = mktime(0, 0, 0, 6, 1, 2026); + $this->assertSame('2026-06-01', Helpers::format_iso_date((string) $ts)); + } + + /** + * An ISO date string round-trips correctly. + */ + public function test_format_iso_date_date_string() + { + $this->assertSame('2026-09-30', Helpers::format_iso_date('2026-09-30')); + } + + /** + * Empty input returns empty string. + */ + public function test_format_iso_date_empty_returns_empty() + { + $this->assertSame('', Helpers::format_iso_date('')); + } + + /** + * Invalid date strings return empty string. + */ + public function test_format_iso_date_invalid_returns_empty() + { + $this->assertSame('', Helpers::format_iso_date('not-a-date')); + } + + // ------------------------------------------------------------------------- + // format_time + // ------------------------------------------------------------------------- + + /** + * A 24-hour time string is returned as HH:MM. + */ + public function test_format_time_24h() + { + $this->assertSame('14:00', Helpers::format_time('14:00')); + } + + /** + * A 12-hour AM/PM time string is converted to 24-hour HH:MM. + */ + public function test_format_time_12h_ampm() + { + $this->assertSame('14:00', Helpers::format_time('2:00 PM')); + $this->assertSame('09:00', Helpers::format_time('9:00 AM')); + } + + /** + * Empty input returns empty string. + */ + public function test_format_time_empty_returns_empty() + { + $this->assertSame('', Helpers::format_time('')); + } + + /** + * Unparseable input returns empty string. + */ + public function test_format_time_invalid_returns_empty() + { + $this->assertSame('', Helpers::format_time('not-a-time')); + } + + // ------------------------------------------------------------------------- + // month_slugs_to_labels + // ------------------------------------------------------------------------- + + /** + * Valid month slugs are converted to capitalised labels. + */ + public function test_month_slugs_to_labels_basic() + { + $result = Helpers::month_slugs_to_labels(array('january', 'march', 'december')); + $this->assertSame('January, March, December', $result); + } + + /** + * A single slug returns a single label with no trailing comma. + */ + public function test_month_slugs_to_labels_single() + { + $this->assertSame('July', Helpers::month_slugs_to_labels(array('july'))); + } + + /** + * Unknown slugs are silently ignored. + */ + public function test_month_slugs_to_labels_unknown_ignored() + { + $result = Helpers::month_slugs_to_labels(array('january', 'unknown-month')); + $this->assertSame('January', $result); + } + + /** + * An empty array returns an empty string. + */ + public function test_month_slugs_to_labels_empty_array() + { + $this->assertSame('', Helpers::month_slugs_to_labels(array())); + } + + // ------------------------------------------------------------------------- + // make_property_value + // ------------------------------------------------------------------------- + + /** + * make_property_value returns a valid PropertyValue array. + */ + public function test_make_property_value_structure() + { + $pv = Helpers::make_property_value('Single supplement', 'ZAR 1500'); + $this->assertIsArray($pv); + $this->assertSame('PropertyValue', $pv['@type']); + $this->assertSame('Single supplement', $pv['name']); + $this->assertSame('ZAR 1500', $pv['value']); + } + + // ------------------------------------------------------------------------- + // strip_to_text + // ------------------------------------------------------------------------- + + /** + * HTML tags are stripped and entities decoded. + */ + public function test_strip_to_text_html() + { + $this->assertSame('Hello World', Helpers::strip_to_text('Hello World
')); + } + + /** + * HTML entities are decoded. + */ + public function test_strip_to_text_entities() + { + $this->assertSame("Safari & Wildlife Tour", Helpers::strip_to_text('Safari & Wildlife Tour')); + } + + /** + * Plain text passes through unchanged. + */ + public function test_strip_to_text_plain() + { + $this->assertSame('Plain text', Helpers::strip_to_text('Plain text')); + } +}