diff --git a/modules/shortcodes/recipe.php b/modules/shortcodes/recipe.php index 7d3c844098b32..983f01ee52de2 100644 --- a/modules/shortcodes/recipe.php +++ b/modules/shortcodes/recipe.php @@ -31,67 +31,36 @@ class Jetpack_Recipes { */ public function __construct() { add_action( 'init', array( $this, 'action_init' ) ); - - add_filter( 'wp_kses_allowed_html', array( $this, 'add_recipes_kses_rules' ), 10, 2 ); } /** - * Add Schema-specific attributes to our allowed tags in wp_kses, - * so we can have better Schema.org compliance. + * Returns KSES tags with Schema-specific attributes. * - * @param array $allowedtags Array of allowed HTML tags in recipes. - * @param array $context Context to judge allowed tags by. - */ - public function add_recipes_kses_rules( $allowedtags, $context ) { - if ( in_array( $context, array( '', 'post', 'data' ) ) ) : // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict - // Create an array of all the tags we'd like to add the itemprop attribute to. - $tags = array( 'li', 'ol', 'ul', 'img', 'p', 'h3', 'time' ); - foreach ( $tags as $tag ) { - $allowedtags = $this->add_kses_rule( - $allowedtags, - $tag, - array( - 'class' => array(), - 'itemprop' => array(), - 'datetime' => array(), - ) - ); - } - - // Allow itemscope and itemtype for divs. - $allowedtags = $this->add_kses_rule( - $allowedtags, - 'div', - array( - 'class' => array(), - 'itemscope' => array(), - 'itemtype' => array(), - ) - ); - endif; - - return $allowedtags; - } - - /** - * Function to add a new property rule to our kses array. - * Used by add_recipe_kses_rules() above. + * @since 8.0.0 * - * @param array $all_tags Array of allowed HTML tags in recipes. - * @param string $tag New HTML tag to add to the array of allowed HTML. - * @param array $rules Array of allowed attributes for that HTML tag. + * @return array Array to be used by KSES. */ - private function add_kses_rule( $all_tags, $tag, $rules ) { - - // If the tag doesn't already exist, add it. - if ( ! isset( $all_tags[ $tag ] ) ) { - $all_tags[ $tag ] = array(); + private static function kses_tags() { + $allowedtags = wp_kses_allowed_html( 'post' ); + // Create an array of all the tags we'd like to add the itemprop attribute to. + $tags = array( 'li', 'ol', 'ul', 'img', 'p', 'h3', 'time', 'span' ); + foreach ( $tags as $tag ) { + if ( ! isset( $allowedtags[ $tag ] ) ) { + $allowedtags[ $tag ] = array(); + } + $allowedtags[ $tag ]['class'] = array(); + $allowedtags[ $tag ]['itemprop'] = array(); + $allowedtags[ $tag ]['datetime'] = array(); } - // Merge the new tags with existing tags. - $all_tags[ $tag ] = array_merge( $all_tags[ $tag ], $rules ); - - return $all_tags; + // Allow itemscope and itemtype for divs. + if ( ! isset( $allowedtags['div'] ) ) { + $allowedtags['div'] = array(); + } + $allowedtags['div']['class'] = array(); + $allowedtags['div']['itemscope'] = array(); + $allowedtags['div']['itemtype'] = array(); + return $allowedtags; } /** @@ -106,6 +75,8 @@ public function action_init() { add_shortcode( 'recipe-notes', array( $this, 'recipe_notes_shortcode' ) ); add_shortcode( 'recipe-ingredients', array( $this, 'recipe_ingredients_shortcode' ) ); add_shortcode( 'recipe-directions', array( $this, 'recipe_directions_shortcode' ) ); + add_shortcode( 'recipe-nutrition', array( $this, 'recipe_nutrition_shortcode' ) ); + add_shortcode( 'recipe-image', array( $this, 'recipe_image_shortcode' ) ); } /** @@ -176,13 +147,16 @@ public static function recipe_shortcode( $atts, $content = '' ) { array( 'title' => '', // string. 'servings' => '', // intval. - 'time' => '', // string. + 'time' => '', // strtotime-compatible time description. 'difficulty' => '', // string. - 'print' => '', // string. + 'print' => '', // URL for external print version. 'source' => '', // string. - 'sourceurl' => '', // string. - 'image' => '', // string. + 'sourceurl' => '', // URL string. Only used if source set. + 'image' => '', // URL or attachment ID. 'description' => '', // string. + 'cooktime' => '', // strtotime-compatible time description. + 'preptime' => '', // strtotime-compatible time description. + 'rating' => '', // string. ), $atts, 'recipe' @@ -201,11 +175,11 @@ public static function recipe_shortcode( $atts, $content = '' ) { */ private static function recipe_shortcode_html( $atts, $content = '' ) { - $html = '
'; + $html = '
'; // Print the recipe title if exists. if ( '' !== $atts['title'] ) { - $html .= '

' . esc_html( $atts['title'] ) . '

'; + $html .= '

' . esc_html( $atts['title'] ) . '

'; } // Print the recipe meta if exists. @@ -214,33 +188,26 @@ private static function recipe_shortcode_html( $atts, $content = '' ) { || '' !== $atts['time'] || '' !== $atts['difficulty'] || '' !== $atts['print'] + || '' !== $atts['preptime'] + || '' !== $atts['cooktime'] + || '' !== $atts['rating'] ) { $html .= ''; } - // Output the image, if we have one. + // Output the image if we have one and it's not shown elsewhere. if ( '' !== $atts['image'] ) { - $html .= sprintf( - '', - esc_url( $atts['image'] ) - ); + if ( ! has_shortcode( $content, 'recipe-image' ) ) { + $html .= self::output_image_html( $atts['image'] ); + } } // Output the description, if we have one. @@ -313,12 +290,36 @@ private static function recipe_shortcode_html( $atts, $content = '' ) { } // Sanitize html. - $html = wp_kses_post( $html ); + $html = wp_kses( $html, self::kses_tags() ); // Return the HTML block. return $html; } + /** + * Our [recipe-image] shortcode. + * Controls placement of image in recipe. + * + * @param array $atts Array of shortcode attributes. + * + * @return string HTML for recipe notes shortcode. + */ + public static function recipe_image_shortcode( $atts ) { + $atts = shortcode_atts( + array( + 'image' => '', // string. + 0 => '', // string. + ), + $atts, + 'recipe-image' + ); + $src = $atts['image']; + if ( ! empty( $atts[0] ) ) { + $src = $atts[0]; + } + return self::output_image_html( $src ); + } + /** * Our [recipe-notes] shortcode. * Outputs ingredients, styled in a div. @@ -352,7 +353,7 @@ public static function recipe_notes_shortcode( $atts, $content = '' ) { $html .= '
'; // Sanitize html. - $html = wp_kses_post( $html ); + $html = wp_kses( $html, self::kses_tags() ); // Return the HTML block. return $html; @@ -389,7 +390,44 @@ public static function recipe_ingredients_shortcode( $atts, $content = '' ) { $html .= '
'; // Sanitize html. - $html = wp_kses_post( $html ); + $html = wp_kses( $html, self::kses_tags() ); + + // Return the HTML block. + return $html; + } + + /** + * Our [recipe-nutrition] shortcode. + * Outputs notes, styled in a div. + * + * @param array $atts Array of shortcode attributes. + * @param string $content Post content. + * + * @return string HTML for recipe nutrition shortcode. + */ + public static function recipe_nutrition_shortcode( $atts, $content = '' ) { + $atts = shortcode_atts( + array( + 'title' => esc_html_x( 'Nutrition', 'recipe', 'jetpack' ), // string. + ), + $atts, + 'recipe-nutrition' + ); + + $html = '
'; + + // Print a title unless the user has opted to exclude it. + if ( 'false' !== $atts['title'] ) { + $html .= '

' . esc_html( $atts['title'] ) . '

'; + } + + // Format content using list functionality. + $html .= self::output_list_content( $content, 'nutrition' ); + + $html .= '
'; + + // Sanitize html. + $html = wp_kses( $html, self::kses_tags() ); // Return the HTML block. return $html; @@ -419,10 +457,20 @@ private static function output_list_content( $content, $type ) { $listtype = 'ol'; break; case 'ingredients': - $list_item_replacement = '
  • ${1}
  • '; + $list_item_replacement = '
  • ${1}
  • '; $itemprop = ''; $listtype = 'ul'; break; + case 'nutrition': + $list_item_replacement = '
  • ${1}
  • '; + $itemprop = ' itemprop="nutrition"'; + $listtype = 'ul'; + break; + case 'nutrition': + $list_item_replacement = '
  • ${1}
  • '; + $itemprop = ' itemprop="nutrition"'; + $listtype = 'ul'; + break; default: $list_item_replacement = '
  • ${1}
  • '; $itemprop = ''; @@ -495,7 +543,7 @@ public static function recipe_directions_shortcode( $atts, $content = '' ) { 'recipe-directions' ); - $html = '
    '; + $html = '
    '; // Print a title unless the user has specified to exclude it. if ( 'false' !== $atts['title'] ) { @@ -508,12 +556,95 @@ public static function recipe_directions_shortcode( $atts, $content = '' ) { $html .= '
    '; // Sanitize html. - $html = wp_kses_post( $html ); + $html = wp_kses( $html, self::kses_tags() ); // Return the HTML block. return $html; } + /** + * Outputs time meta tag. + * + * @param string $time_str Raw time to output. + * @param string $time_type Type of time to show. + * + * @return string HTML for recipe time meta. + */ + private static function output_time( $time_str, $time_type ) { + // Get a time that's supported by Schema.org. + $duration = WPCOM_JSON_API_Date::format_duration( $time_str ); + // If no duration can be calculated, let's output what the user provided. + if ( ! $duration ) { + $duration = $time_str; + } + + switch ( $time_type ) { + case 'cooktime': + $title = _x( 'Cook Time', 'recipe', 'jetpack' ); + $itemprop = 'cookTime'; + break; + case 'preptime': + $title = _x( 'Prep Time', 'recipe', 'jetpack' ); + $itemprop = 'prepTime'; + break; + default: + $title = _x( 'Time', 'recipe', 'jetpack' ); + $itemprop = 'totalTime'; + break; + } + + return sprintf( + '
  • + +
  • ', + esc_html( $title ), + esc_html( $time_str ), + esc_attr( $time_type ), + esc_attr( $itemprop ), + esc_attr( $duration ) + ); + } + + /** + * Outputs image tag for recipe. + * + * @param string $src The image source. + * + * @return string + */ + private static function output_image_html( $src ) { + // Exit if there is no provided source. + if ( ! $src ) { + return ''; + } + + // If it's numeric, this may be an attachment. + if ( is_numeric( $src ) ) { + return wp_get_attachment_image( + $src, + 'full', + false, + array( + 'class' => 'jetpack-recipe-image u-photo photo', + 'itemprop' => 'image', + ) + ); + } + + // Check if it's an absolute or relative URL, and return if not. + if ( + 0 !== strpos( $src, '/' ) + && false === filter_var( $src, FILTER_VALIDATE_URL ) + ) { + return ''; + } + + return sprintf( + '', + esc_url( $src ) + ); + } + /** * Use $themecolors array to style the Recipes shortcode * diff --git a/tests/php/modules/shortcodes/test-class.recipe.php b/tests/php/modules/shortcodes/test-class.recipe.php new file mode 100644 index 0000000000000..af71d9e080a13 --- /dev/null +++ b/tests/php/modules/shortcodes/test-class.recipe.php @@ -0,0 +1,351 @@ +assertEquals( shortcode_exists( 'recipe' ), true ); + $this->assertEquals( shortcode_exists( 'recipe-notes' ), true ); + $this->assertEquals( shortcode_exists( 'recipe-ingredients' ), true ); + $this->assertEquals( shortcode_exists( 'recipe-directions' ), true ); + $this->assertEquals( shortcode_exists( 'recipe-nutrition' ), true ); + $this->assertEquals( shortcode_exists( 'recipe-image' ), true ); + } + + /** + * Verify that the recipe shortcode outputs prep time in HTML. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_preptime() { + $content = '[recipe preptime="30 min"]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertContains( '', $shortcode_content ); + } + + /** + * Verify that the recipe shortcode outputs cook time in HTML. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_cooktime() { + $content = '[recipe cooktime="2 hours 30 min"]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertContains( '', $shortcode_content ); + } + + /** + * Verify that the recipe shortcode outputs rating in HTML. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_rating() { + $content = '[recipe rating="2 stars"]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertContains( '2 stars', $shortcode_content ); + } + + /** + * Verify that the recipe shortcode does not output an image with an empty source. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_image_empty_src() { + $content = '[recipe image=""]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertNotContains( 'assertNotContains( 'assertNotContains( '_make_attachment( + array( + 'file' => 'example.jpg', + 'url' => 'http://example.com/wp-content/uploads/example.jpg', + 'type' => 'image/jpeg', + 'error' => false, + ) + ); + + // Get shortcode with new attachment. + $content = '[recipe image="' . $attachment_id . '"]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertContains( '', $shortcode_content ); + } + + /** + * Verify that the recipe shortcode outputs an image with a src string. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_image_src() { + $content = '[recipe image="https://example.com"]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertContains( '', $shortcode_content ); + } + + /** + * Verify that the recipe shortcode does not output an image if an empty recipe-image shortcode exists. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_image_location_move() { + $content = '[recipe image="https://example.com"][recipe-image][/recipe]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertNotContains( 'assertNotContains( 'assertNotContains( 'assertContains( '', $shortcode_content ); + } + + /** + * Verify that the recipe-image shortcode outputs an image with a string parameter. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_image_shortcode_src_attr() { + + $content = '[recipe-image image="https://example.com"]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertContains( '', $shortcode_content ); + } + + /** + * Verify that the recipe-image shortcode does not output an image with an invalid attachment. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_image_shortcode_invalid_attachment() { + $content = '[recipe-image -100]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertNotContains( 'assertNotContains( '_make_attachment( + array( + 'file' => 'example.jpg', + 'url' => 'http://example.com/wp-content/uploads/example.jpg', + 'type' => 'image/jpeg', + 'error' => false, + ) + ); + + // Get shortcode with new attachment. + $content = '[recipe-image ' . $attachment_id . ']'; + + $shortcode_content = do_shortcode( $content ); + $this->assertContains( '', $shortcode_content ); + } + + /** + * Verify that the recipe-image shortcode outputs an image with a valid attachment ID attribute. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_image_shortcode_attachment_attr() { + // Create a mock attachment. + $attachment_id = $this->_make_attachment( + array( + 'file' => 'example.jpg', + 'url' => 'http://example.com/wp-content/uploads/example.jpg', + 'type' => 'image/jpeg', + 'error' => false, + ) + ); + + // Get shortcode with new attachment. + $content = '[recipe-image image="' . $attachment_id . '"]'; + + $shortcode_content = do_shortcode( $content ); + $this->assertContains( '', $shortcode_content ); + } + + /** + * Verify that the recipe-nutrition shortcode formats a list of nutrition info. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_nutrition() { + $content = <<assertContains( 'itemprop="nutrition"', $shortcode_content ); + $this->assertContains( '
  • food 100%
  • ', $shortcode_content ); + $this->assertContains( '
  • taste 500mg
  • ', $shortcode_content ); + } + + /** + * Verify that the recipe shortcode allows needed content via KSES. + * + * @covers ::recipe_shortcode + * + * @since 8.0.0 + */ + public function test_shortcodes_recipe_kses_content() { + $tags = << +
      +
    • +
    + +

    +

    + + +
    +EOT; + + $shortcode_content = do_shortcode( "[recipe]\n$tags\n[/recipe]" ); + $this->assertContains( $tags, $shortcode_content ); + } +}