Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve handling of YouTube embeds #3358

Merged
merged 21 commits into from
Nov 10, 2019
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
35260fd
Preserve original YouTube iframe title as fallback content
westonruter Sep 27, 2019
2f43691
Add title attribute to amp-youtube in addition to fallback content
westonruter Sep 27, 2019
26faef0
Rename class-amp-youtube-embed.php to class-amp-youtube-embed-handler…
pierlon Nov 7, 2019
ceec460
Merge test-amp-youtube-embed.php and test-class-amp-youtube-embed-han…
pierlon Nov 8, 2019
5225f4e
Mock oEmbed responses
pierlon Nov 8, 2019
4e58e82
Make tests compatible with WP 5.1 and below
pierlon Nov 8, 2019
6602800
Bump `oembed` function deprecation version to 1.5.0
pierlon Nov 8, 2019
899603e
HTML entity encoding will be handled by the DOM instead
pierlon Nov 8, 2019
3ea23d7
Remove todo
pierlon Nov 9, 2019
e392dcc
Replace the fallback with an image placeholder for the iframe
pierlon Nov 9, 2019
7a97571
Refactor `get_video_id_from_url` function
pierlon Nov 9, 2019
4b0fbd1
Replace ambiguous `$fallback` with `$fallback_for_expected`
pierlon Nov 9, 2019
c65db4d
Merge separate regexes used to retrieve props into one
pierlon Nov 9, 2019
6224606
Update doc comment for `get_video_id_from_url`
pierlon Nov 9, 2019
514bfd9
Extract HTML attribute parsing logic into base class, and use for You…
westonruter Nov 10, 2019
97e8db5
Prevent copying empty title/alt in WP<5.2
westonruter Nov 10, 2019
fc0e9e0
Add object-fit=contain to amp-youtube placeholder image
westonruter Nov 10, 2019
933c050
Remove empty alt attributes
westonruter Nov 10, 2019
6b92d0a
Replace incorrect usage of esc_url() with esc_url_raw()
westonruter Nov 10, 2019
62e7ee1
Quote variables added to regex pattern
westonruter Nov 10, 2019
d45fd59
Re-factor get_html_attribute_pattern as match_element_attributes
westonruter Nov 10, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion includes/class-amp-autoloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class AMP_Autoloader {
'AMP_Vimeo_Embed_Handler' => 'includes/embeds/class-amp-vimeo-embed',
'AMP_Vine_Embed_Handler' => 'includes/embeds/class-amp-vine-embed',
'AMP_WordPress_TV_Embed_Handler' => 'includes/embeds/class-amp-wordpress-tv-embed-handler',
'AMP_YouTube_Embed_Handler' => 'includes/embeds/class-amp-youtube-embed',
'AMP_YouTube_Embed_Handler' => 'includes/embeds/class-amp-youtube-embed-handler',
'AMP_Scribd_Embed_Handler' => 'includes/embeds/class-amp-scribd-embed-handler',
'AMP_Analytics_Options_Submenu' => 'includes/options/class-amp-analytics-options-submenu',
'AMP_Options_Menu' => 'includes/options/class-amp-options-menu',
Expand Down
25 changes: 25 additions & 0 deletions includes/embeds/class-amp-base-embed-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,29 @@ public function __construct( $args = [] ) {
public function get_scripts() {
return [];
}

/**
* Get regex pattern for matching HTML attributes from a given tag name.
*
* @since 1.5.0
*
* @param string $tag_name Tag name.
* @param string[] $attribute_names Attribute names.
* @return string Regular expression pattern.
*/
protected function get_html_attribute_pattern( $tag_name, $attribute_names ) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you make this reusable, why not include the preg_match() call? Something like match_element_attributes( $tag_name, $attribute_names ).

This way, usage could be simplified:

$props = $this->match_element_attributes( 'iframe', [ 'src', 'title', 'width', 'height' ] );

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent point. I didn't refactor far enough. See d45fd59.

return sprintf(
'/<%s%s/',
$tag_name,
implode(
'',
array_map(
function ( $attr_name ) {
return sprintf( '(?=[^>]*?%1$s="(?P<%1$s>[^"]+)")?', $attr_name );
},
$attribute_names
)
)
);
}
}
2 changes: 1 addition & 1 deletion includes/embeds/class-amp-dailymotion-embed.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public function render( $args ) {
return AMP_HTML_Utils::build_tag(
'a',
[
'href' => esc_url( $args['url'] ),
'href' => esc_url_raw( $args['url'] ),
'class' => 'amp-wp-embed-fallback',
],
esc_html( $args['url'] )
Expand Down
2 changes: 1 addition & 1 deletion includes/embeds/class-amp-instagram-embed.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public function render( $args ) {
return AMP_HTML_Utils::build_tag(
'a',
[
'href' => esc_url( $args['url'] ),
'href' => esc_url_raw( $args['url'] ),
'class' => 'amp-wp-embed-fallback',
],
esc_html( $args['url'] )
Expand Down
67 changes: 33 additions & 34 deletions includes/embeds/class-amp-soundcloud-embed.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,39 +78,39 @@ public function filter_embed_oembed_html( $cache, $url ) {
* @param string|null $url Embed URL, for fallback purposes.
* @return string AMP component or empty if unable to determine SoundCloud ID.
*/
private function parse_amp_component_from_iframe( $html, $url ) {
$embed = '';

if ( preg_match( '#<iframe[^>]*?src="(?P<src>[^"]+)"#s', $html, $matches ) ) {
$src = html_entity_decode( $matches['src'], ENT_QUOTES );
$query = [];
parse_str( wp_parse_url( $src, PHP_URL_QUERY ), $query );
if ( ! empty( $query['url'] ) ) {
$props = $this->extract_params_from_iframe_src( $query['url'] );
if ( isset( $query['visual'] ) ) {
$props['visual'] = $query['visual'];
}

if ( $url && preg_match( '#<iframe[^>]*?title="(?P<title>[^"]+)"#s', $html, $matches ) ) {
$props['fallback'] = sprintf(
'<a fallback href="%s">%s</a>',
esc_url( $url ),
esc_html( $matches['title'] )
);
}

if ( preg_match( '#<iframe[^>]*?height="(?P<height>\d+)"#s', $html, $matches ) ) {
$props['height'] = (int) $matches['height'];
}

if ( preg_match( '#<iframe[^>]*?width="(?P<width>\d+)"#s', $html, $matches ) ) {
$props['width'] = (int) $matches['width'];
}

$embed = $this->render( $props, $url );
}
}
return $embed;
private function parse_amp_component_from_iframe( $html, $url = null ) {
$attr_names = [ 'src', 'title', 'width', 'height' ];
$props = [];
$props_pattern = $this->get_html_attribute_pattern( 'iframe', $attr_names );
if ( ! preg_match( $props_pattern, $html, $props ) || empty( $props['src'] ) ) {
return $html;
}

$src = html_entity_decode( $props['src'], ENT_QUOTES );
$query = [];
parse_str( wp_parse_url( $src, PHP_URL_QUERY ), $query );

if ( empty( $query['url'] ) ) {
return $html;
}

$props = array_merge(
$props,
$this->extract_params_from_iframe_src( $query['url'] )
);
if ( isset( $query['visual'] ) ) {
$props['visual'] = $query['visual'];
}

if ( $url && ! empty( $props['title'] ) ) {
$props['fallback'] = sprintf(
'<a fallback href="%s">%s</a>',
esc_url( $url ),
esc_html( $props['title'] )
);
}

return $this->render( $props, $url );
}

/**
Expand Down Expand Up @@ -157,7 +157,6 @@ public function shortcode( $attr, $content = null ) {
* @param array $args Args.
* @param string $url Embed URL for fallback purposes. Optional.
* @return string Rendered embed.
* @global WP_Embed $wp_embed
*/
public function render( $args, $url ) {
$args = wp_parse_args(
Expand Down
2 changes: 1 addition & 1 deletion includes/embeds/class-amp-vimeo-embed.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ public function render( $args ) {
return AMP_HTML_Utils::build_tag(
'a',
[
'href' => esc_url( $args['url'] ),
'href' => esc_url_raw( $args['url'] ),
'class' => 'amp-wp-embed-fallback',
],
esc_html( $args['url'] )
Expand Down
2 changes: 1 addition & 1 deletion includes/embeds/class-amp-vine-embed.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public function render( $args ) {
return AMP_HTML_Utils::build_tag(
'a',
[
'href' => esc_url( $args['url'] ),
'href' => esc_url_raw( $args['url'] ),
'class' => 'amp-wp-embed-fallback',
],
esc_html( $args['url'] )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* Much of this class is borrowed from Jetpack embeds.
*/
class AMP_YouTube_Embed_Handler extends AMP_Base_Embed_Handler {
const SHORT_URL_HOST = 'youtu.be';

// Only handling single videos. Playlists are handled elsewhere.
const URL_PATTERN = '#https?://(?:www\.)?(?:youtube.com/(?:v/|e/|embed/|watch[/\#?])|youtu\.be/).*#i';
const RATIO = 0.5625;
Expand Down Expand Up @@ -39,6 +39,7 @@ public function __construct( $args = [] ) {
parent::__construct( $args );

if ( isset( $this->args['content_max_width'] ) ) {
// Set default width/height; these will be overridden by whatever YouTube specifies.
$max_width = $this->args['content_max_width'];
$this->args['width'] = $max_width;
$this->args['height'] = round( $max_width * self::RATIO );
Expand All @@ -49,22 +50,90 @@ public function __construct( $args = [] ) {
* Register embed.
*/
public function register_embed() {
wp_embed_register_handler( 'amp-youtube', self::URL_PATTERN, [ $this, 'oembed' ], -1 );
add_shortcode( 'youtube', [ $this, 'shortcode' ] );
add_filter( 'embed_oembed_html', [ $this, 'filter_embed_oembed_html' ], 10, 2 );
add_shortcode( 'youtube', [ $this, 'shortcode' ] ); // @todo Deprecated. See <https://github.com/ampproject/amp-wp/issues/3309>.
add_filter( 'wp_video_shortcode_override', [ $this, 'video_override' ], 10, 2 );
}

/**
* Unregister embed.
*/
public function unregister_embed() {
wp_embed_unregister_handler( 'amp-youtube', -1 );
remove_shortcode( 'youtube' );
remove_filter( 'embed_oembed_html', [ $this, 'filter_embed_oembed_html' ], 10 );
remove_shortcode( 'youtube' ); // @todo Deprecated. See <https://github.com/ampproject/amp-wp/issues/3309>.
}

/**
* Filter oEmbed HTML for YouTube to convert to AMP.
*
* @param string $cache Cache for oEmbed.
* @param string $url Embed URL.
* @return string Embed.
*/
public function filter_embed_oembed_html( $cache, $url ) {
$host = wp_parse_url( $url, PHP_URL_HOST );
if ( ! in_array( $host, [ 'youtu.be', 'youtube.com', 'www.youtube.com' ], true ) ) {
return $cache;
}

$id = $this->get_video_id_from_url( $url );
if ( ! $id ) {
return $cache;
}

$props = $this->parse_props( $cache, $url, $id );
if ( empty( $props ) ) {
return $cache;
}

$props['video_id'] = $id;
return $this->render( $props, $url );
}

/**
* Parse AMP component from iframe.
*
* @param string $html HTML.
* @param string $url Embed URL, for fallback purposes.
* @param string $video_id YouTube video ID.
* @return array|null Props for rendering the component, or null if unable to parse.
*/
private function parse_props( $html, $url, $video_id ) {
$attr_names = [ 'title', 'height', 'width' ];
$props = [];
$props_pattern = $this->get_html_attribute_pattern( 'iframe', $attr_names );
if ( ! preg_match( $props_pattern, $html, $props ) ) {
return null;
}

$img_attributes = [
'src' => esc_url_raw( sprintf( 'https://i.ytimg.com/vi/%s/hqdefault.jpg', $video_id ) ),
'layout' => 'fill',
'object-fit' => 'cover',
];
if ( ! empty( $props['title'] ) ) {
$img_attributes['alt'] = $props['title'];
}
$img = AMP_HTML_Utils::build_tag( 'img', $img_attributes );

$props['placeholder'] = AMP_HTML_Utils::build_tag(
'a',
[
'placeholder' => '',
'href' => esc_url_raw( $url ),
],
$img
);

return $props;
}


/**
* Gets AMP-compliant markup for the YouTube shortcode.
*
* @deprecated This should be moved to Jetpack. See <https://github.com/ampproject/amp-wp/issues/3309>.
*
* @param array $attr The YouTube attributes.
* @return string YouTube shortcode markup.
*/
Expand All @@ -83,102 +152,80 @@ public function shortcode( $attr ) {

$video_id = $this->get_video_id_from_url( $url );

return $this->render(
[
'url' => $url,
'video_id' => $video_id,
]
);
return $this->render( compact( 'video_id' ), $url );
}

/**
* Render oEmbed.
*
* @see \WP_Embed::shortcode()
* @deprecated This is no longer being used.
*
* @param array $matches URL pattern matches.
* @param array $attr Shortcode attribues.
* @param string $url URL.
* @return string Rendered oEmbed.
*/
public function oembed( $matches, $attr, $url ) {
_deprecated_function( __METHOD__, '1.5.0' );
return $this->shortcode( [ $url ] );
}

/**
* Render.
* Render embed.
*
* @param array $args Args.
* @param array $args Args.
* @param string $url URL.
* @return string Rendered.
*/
public function render( $args ) {
public function render( $args, $url ) {
$args = wp_parse_args(
$args,
[
'video_id' => false,
'video_id' => false,
'layout' => 'responsive',
'width' => $this->args['width'],
'height' => $this->args['height'],
'placeholder' => '',
]
);

if ( empty( $args['video_id'] ) ) {
return AMP_HTML_Utils::build_tag(
'a',
[
'href' => esc_url( $args['url'] ),
'href' => esc_url_raw( $url ),
'class' => 'amp-wp-embed-fallback',
],
esc_html( $args['url'] )
esc_html( $url )
);
}

$this->did_convert_elements = true;

return AMP_HTML_Utils::build_tag(
'amp-youtube',
[
'data-videoid' => $args['video_id'],
'layout' => 'responsive',
'width' => $this->args['width'],
'height' => $this->args['height'],
]
$attributes = array_merge(
[ 'data-videoid' => $args['video_id'] ],
wp_array_slice_assoc( $args, [ 'layout', 'width', 'height' ] )
);
if ( ! empty( $args['title'] ) ) {
$attributes['title'] = $args['title'];
}

return AMP_HTML_Utils::build_tag( 'amp-youtube', $attributes, $args['placeholder'] );
}

/**
* Determine the video ID from the URL.
*
* @param string $url URL.
* @return integer Video ID.
* @return integer|false Video ID, or false if none could be retrieved.
*/
private function get_video_id_from_url( $url ) {
$video_id = false;
$parsed_url = wp_parse_url( $url );

if ( self::SHORT_URL_HOST === substr( $parsed_url['host'], -strlen( self::SHORT_URL_HOST ) ) ) {
/* youtu.be/{id} */
$parts = explode( '/', $parsed_url['path'] );
if ( ! empty( $parts ) ) {
$video_id = $parts[1];
}
} else {
/* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
The query looks like ?v={id} or ?list={id} */
parse_str( $parsed_url['query'], $query_args );

if ( isset( $query_args['v'] ) ) {
$video_id = $this->sanitize_v_arg( $query_args['v'] );
}
}

if ( empty( $video_id ) ) {
/* The path looks like /(v|e|embed)/{id} */
$parts = explode( '/', $parsed_url['path'] );

if ( in_array( $parts[1], [ 'v', 'e', 'embed' ], true ) ) {
$video_id = $parts[2];
}
if ( preg_match( '/(?:watch\?v=|embed\/|youtu.be\/)(?P<id>\w*)/', $url, $match ) ) {
return $match['id'];
}

return $video_id;
return false;
}

/**
Expand Down
Loading