Skip to content

Instantly share code, notes, and snippets.

@westonruter
Last active October 11, 2020 06:16
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save westonruter/e0545fcd2dcc18c9263dff6ec0ac055d to your computer and use it in GitHub Desktop.
Save westonruter/e0545fcd2dcc18c9263dff6ec0ac055d to your computer and use it in GitHub Desktop.
=== AMP SVG Inliner ===
Contributors: westonruter
Tags: amp, svg, img, performance
Requires at least: 4.9
Tested up to: 5.5
Stable tag: 0.1
Requires PHP: 5.6
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Automatically inline SVG images that are referenced by IMG tags on AMP pages. Depends on official AMP plugin.
== Description ==
This plugin is an extension of the [official AMP plugin](https://wordpress.org/plugins/amp/); it automatically inlines SVG images when referenced by `img` tags on AMP pages. This reduces [Largest Contentful Paint](https://web.dev/lcp/) (LCP) by avoiding the need to wait for the browser to fetch the remote SVG file to display. Also in an AMP context there is no need to wait for the AMP library JavaScript to load to render the `amp-img` element. (This won't be a concern in the future since `amp-img` will be deprecated in favor of `img` per [amphtml#29786](https://github.com/ampproject/amphtml/issues/29786), since lazy-loading is now part of the web platform.)
When SVGs are loaded via `img` tags, any scripts in the SVG are not executed. When SVGs are inlined however, any such scripts would then be execited. This security concern is mitigated by the AMP plugin since it has sanitizers that only permit valid AMP markup to be rendered on the page: since custom JS is not valid AMP then it is removed if present in the inlined SVGs.
There are also [security concerns](https://core.trac.wordpress.org/ticket/24251#comment:8) about allowing WordPress users to upload SVGs into the media library. To that end, this plugin does not enable that ability. However, you may want to consider allowing any users (e.g. administrators) who have the `unfiltered_html` capability to also be able to upload SVGs via an `upload_mimes` filter like the following:
`add_filter(
'upload_mimes',
function ( $mimes ) {
if ( current_user_can( 'unfiltered_html' ) ) {
$mimes['svg'] = 'image/svg+xml';
}
return $mimes;
}
);`
== Changelog ==
...
<?php
/**
* AMP SVG Inliner plugin bootstrap.
*
* @package Google\AMP_SVG_Inliner
* @author Weston Ruter, Google
* @license GPL-2.0-or-later
* @copyright 2020 Google Inc.
*
* @wordpress-plugin
* Plugin Name: AMP SVG Inliner
* Plugin URI: https://gist.github.com/westonruter/e0545fcd2dcc18c9263dff6ec0ac055d
* Description: Automatically inline SVG images when referenced by IMG tags. This reduces LCP by avoiding the need to wait for the image to load.
* Version: 0.3.0
* Author: Weston Ruter, Google
* Author URI: https://weston.ruter.net/
* License: GNU General Public License v2 (or later)
* License URI: http://www.gnu.org/licenses/gpl-2.0.html
* Gist Plugin URI: https://gist.github.com/westonruter/e0545fcd2dcc18c9263dff6ec0ac055d
*/
namespace Google\AMP_SVG_Inliner;
add_filter(
'amp_content_sanitizers',
function ( $sanitizers ) {
require_once __DIR__ . '/Sanitizer.php';
// Make sure SVG <img> elements get replaced with <svg> before the AMP_Img_Sanitizer runs.
return array_merge(
[
__NAMESPACE__ . '\Sanitizer' => [],
],
$sanitizers
);
}
);
<?php
/**
* Sanitizer file.
*
* @package Google\AMP_SVG_Inliner
*/
namespace Google\AMP_SVG_Inliner;
use AMP_Base_Sanitizer;
use AmpProject\Dom\Document;
use DOMAttr;
use DOMComment;
use DOMDocument;
use DOMElement;
use WP_Error;
/**
* Class Sanitizer
*/
class Sanitizer extends AMP_Base_Sanitizer {
/**
* Base URL for styles.
*
* Full URL with trailing slash.
*
* @var string
*/
private $base_url;
/**
* URL of the content directory.
*
* @var string
*/
private $content_url;
/**
* AMP_Base_Sanitizer constructor.
*
* @since 0.7
*
* @param Document $dom Represents the HTML document to sanitize.
* @param array $args Args.
*/
public function __construct( $dom, array $args = [] ) {
parent::__construct( $dom, $args );
$guessurl = site_url();
if ( ! $guessurl ) {
$guessurl = wp_guess_url();
}
$this->base_url = untrailingslashit( $guessurl );
$this->content_url = $this->remove_url_scheme( content_url( '/' ) );
}
/**
* Sanitize.
*/
public function sanitize() {
// Prevent mutating anything in the admin bar.
$admin_bar_placeholder = null;
$admin_bar = $this->dom->getElementById( 'wpadminbar' );
if ( $admin_bar instanceof DOMElement ) {
$admin_bar_placeholder = $this->dom->createComment( 'wpadminbar' );
$admin_bar->parentNode->replaceChild( $admin_bar_placeholder, $admin_bar );
}
$this->replace_emoji_with_inline_svgs();
$this->replace_img_with_svgs();
// Restore admin bar after mutation.
if ( $admin_bar instanceof DOMElement && $admin_bar_placeholder instanceof DOMComment ) {
$admin_bar_placeholder->parentNode->replaceChild( $admin_bar, $admin_bar_placeholder );
}
}
/**
* Replace emoji with inline SVGs.
*
* @todo This currently depends on wp_staticize_emoji() being run on the content et al. Ideally the AMP plugin would filter emoji_url and emoji_ext to supply SVG from the start.
*/
private function replace_emoji_with_inline_svgs() {
$emojis_replaced = 0;
foreach ( $this->dom->xpath->query( '//img[ @class = "wp-smiley" ]' ) as $img_element ) {
/** @var DOMElement $img_element */
if ( ! preg_match(
'#^https?://s\.w\.org/images/core/emoji/(?P<version>\d+(?:\.\d+)*)/\d+x\d+/(?P<codepoint>[0-9a-f-]+)\.png$#',
$img_element->getAttribute( 'src' ),
$matches
) ) {
continue;
}
$svg_url = sprintf( 'https://s.w.org/images/core/emoji/%s/svg/%s.svg', $matches['version'], $matches['codepoint'] );
$transient_key = "emoji-{$matches['version']}-{$matches['codepoint']}";
$svg_doc = get_transient( $transient_key );
if ( false === $svg_doc ) {
$response = wp_remote_get( $svg_url );
$svg_doc = null;
if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
$svg_doc = wp_remote_retrieve_body( $response );
}
if ( $svg_doc ) {
set_transient( $transient_key, $svg_doc, MONTH_IN_SECONDS );
}
}
if ( empty( $svg_doc ) ) {
continue;
}
if ( $this->replace_image_with_svg_doc( $img_element, $svg_doc ) ) {
$emojis_replaced++;
}
}
if ( $emojis_replaced > 0 ) {
$style = $this->dom->createElement( 'style' );
$style->setAttribute( 'class', 'amp-svg-inline-emoji' );
$style->appendChild( $this->dom->createTextNode( 'svg.wp-smiley { display: inline-block; }' ) );
$this->dom->head->appendChild( $style );
}
}
/**
* Replace images pointing to SVG with inline SVGs.
*/
private function replace_img_with_svgs() {
foreach ( $this->dom->xpath->query( '//img[ contains( @src, ".svg" ) ]' ) as $img_element ) {
/** @var DOMElement $img_element */
$src = $img_element->getAttribute( 'src' );
$file_path = $this->get_validated_url_file_path( $src );
if ( is_wp_error( $file_path ) ) {
$this->set_error_attribute( $img_element, $file_path->get_error_code() );
continue;
}
$svg_doc = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( empty( $svg_doc ) ) {
$this->set_error_attribute( $img_element, 'file_not_readable' );
continue;
}
$this->replace_image_with_svg_doc( $img_element, $svg_doc );
}
}
/**
* Replace image with SVG.
*
* @param DOMElement $img_element Image element.
* @param string $svg_doc SVG document markup.
* @return DOMElement|null SVG element.
*/
private function replace_image_with_svg_doc( DOMElement $img_element, $svg_doc ) {
$svg_dom = new DOMDocument();
if ( ! @$svg_dom->loadHTML( $svg_doc ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
$this->set_error_attribute( $img_element, 'xml_parse_error' );
return null;
}
$svg_element = $svg_dom->getElementsByTagName( 'svg' )->item( 0 );
if ( ! $svg_element instanceof DOMElement ) {
$this->set_error_attribute( $img_element, 'svg_element_not_found' );
return null;
}
/** @var DOMElement $svg_element */
$svg_element = $this->dom->importNode( $svg_element, true );
/** @var DOMAttr $attribute */
foreach ( $img_element->attributes as $attribute ) {
switch ( $attribute->nodeName ) {
case 'alt':
$alt = trim( $attribute->nodeValue );
if ( $alt ) {
$title = $svg_element->getElementsByTagName( 'title' )->item( 0 );
if ( ! $title || $title->parentNode !== $svg_element ) {
$title = $this->dom->createElement( 'title' );
$svg_element->insertBefore( $title, $svg_element->firstChild );
}
$title->textContent = $attribute->nodeValue;
}
break;
case 'crossorigin':
case 'decoding':
case 'importance':
case 'intrinsicsize':
case 'loading':
case 'referrerpolicy':
case 'sizes':
case 'src':
case 'srcset':
// Ignored since irrelevant due to inlining.
break;
case 'style':
// Merge with existing style.
$svg_element->setAttribute(
$attribute->nodeName,
implode( ';', array_filter( [ $svg_element->getAttribute( $attribute->nodeName ), $attribute->nodeValue ] ) )
);
break;
default:
$svg_element->setAttribute( $attribute->nodeName, $attribute->nodeValue );
}
}
if ( ! $svg_element->hasAttribute( 'role' ) ) {
$svg_element->setAttribute( 'role', 'img' );
}
$svg_element->setAttribute( 'data-amp-svg-src', $img_element->getAttribute( 'src' ) );
$img_element->parentNode->replaceChild( $svg_element, $img_element );
return $svg_element;
}
/**
* Set error attribute.
*
* @param DOMElement $element Element.
* @param string $error Error code.
*/
private function set_error_attribute( DOMElement $element, $error ) {
if ( defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY ) {
$element->setAttribute( 'data-img-svg-inliner-error', $error );
}
}
/**
* Remove URL scheme.
*
* @param string $schemed_url Schemed URL.
* @return string Schemeless URL.
*/
private function remove_url_scheme( $schemed_url ) {
return preg_replace( '#^\w+:(?=//)#', '', $schemed_url );
}
/**
* Generate a URL's fully-qualified file path.
*
* @see WP_Styles::_css_href()
* @see \AMP_Style_Sanitizer::get_validated_url_file_path()
*
* @param string $url The file URL.
* @return string|WP_Error Style's absolute validated filesystem path, or WP_Error when error.
*/
private function get_validated_url_file_path( $url ) {
if ( ! is_string( $url ) ) {
return new WP_Error( 'url_not_string' );
}
$url = $this->remove_url_scheme( $url );
$needs_base_url = (
! preg_match( '|^(https?:)?//|', $url )
&&
! ( $this->content_url && 0 === strpos( $url, $this->content_url ) )
);
if ( $needs_base_url ) {
$url = $this->base_url . '/' . ltrim( $url, '/' );
}
$parsed_url = wp_parse_url( $url );
if ( empty( $parsed_url['host'] ) || empty( $parsed_url['path'] ) ) {
return new WP_Error( 'url_syntax_error' );
}
$path = $this->unrelativize_path( $parsed_url['path'] );
if ( is_wp_error( $path ) ) {
return $path;
}
$parsed_url['path'] = $path;
unset( $parsed_url['scheme'], $parsed_url['query'], $parsed_url['fragment'] );
$url = $this->remove_url_scheme( $this->reconstruct_url( $parsed_url ) );
$includes_url = $this->remove_url_scheme( includes_url( '/' ) );
$admin_url = $this->remove_url_scheme( get_admin_url( null, '/' ) );
$site_url = $this->remove_url_scheme( site_url( '/' ) );
$allowed_hosts = [
wp_parse_url( $includes_url, PHP_URL_HOST ),
wp_parse_url( $this->content_url, PHP_URL_HOST ),
wp_parse_url( $admin_url, PHP_URL_HOST ),
];
// Validate file extension.
if ( ! preg_match( '/\.svg$/i', $url ) ) {
return new WP_Error( 'bad_file_extension' );
}
if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) {
return new WP_Error( 'disallowed_host' );
}
$base_path = null;
$file_path = null;
$wp_content = 'wp-content';
if ( 0 === strpos( $url, $this->content_url ) ) {
$base_path = WP_CONTENT_DIR;
$file_path = substr( $url, strlen( $this->content_url ) - 1 );
} elseif ( 0 === strpos( $url, $includes_url ) ) {
$base_path = ABSPATH . WPINC;
$file_path = substr( $url, strlen( $includes_url ) - 1 );
} elseif ( 0 === strpos( $url, $admin_url ) ) {
$base_path = ABSPATH . 'wp-admin';
$file_path = substr( $url, strlen( $admin_url ) - 1 );
} elseif ( 0 === strpos( $url, $site_url . trailingslashit( $wp_content ) ) ) {
// Account for loading content from original wp-content directory not WP_CONTENT_DIR which can happen via register_theme_directory().
$base_path = ABSPATH . $wp_content;
$file_path = substr( $url, strlen( $site_url ) + strlen( $wp_content ) );
}
if ( ! $file_path || false !== strpos( $file_path, '../' ) || false !== strpos( $file_path, '..\\' ) ) {
return new WP_Error( 'file_path_not_allowed' );
}
if ( ! file_exists( $base_path . $file_path ) ) {
return new WP_Error( 'file_path_not_found' );
}
return $base_path . $file_path;
}
/**
* Construct a URL from a parsed one.
*
* @see \AMP_Style_Sanitizer::reconstruct_url()
*
* @param array $parsed_url Parsed URL.
* @return string Reconstructed URL.
*/
private function reconstruct_url( $parsed_url ) {
$url = '';
if ( ! empty( $parsed_url['host'] ) ) {
if ( ! empty( $parsed_url['scheme'] ) ) {
$url .= $parsed_url['scheme'] . ':';
}
$url .= '//';
$url .= $parsed_url['host'];
if ( ! empty( $parsed_url['port'] ) ) {
$url .= ':' . $parsed_url['port'];
}
}
if ( ! empty( $parsed_url['path'] ) ) {
$url .= $parsed_url['path'];
}
if ( ! empty( $parsed_url['query'] ) ) {
$url .= '?' . $parsed_url['query'];
}
if ( ! empty( $parsed_url['fragment'] ) ) {
$url .= '#' . $parsed_url['fragment'];
}
return $url;
}
/**
* Eliminate relative segments (../ and ./) from a path.
*
* @see \AMP_Style_Sanitizer::unrelativize_path()
*
* @param string $path Path with relative segments. This is not a URL, so no host and no query string.
* @return string|WP_Error Unrelativized path or WP_Error if there is too much relativity.
*/
private function unrelativize_path( $path ) {
// Eliminate current directory relative paths, like <foo/./bar/./baz.css> => <foo/bar/baz.css>.
do {
$path = preg_replace(
'#/\./#',
'/',
$path,
-1,
$count
);
} while ( 0 !== $count );
// Collapse relative paths, like <foo/bar/../../baz.css> => <baz.css>.
do {
$path = preg_replace(
'#(?<=/)(?!\.\./)[^/]+/\.\./#',
'',
$path,
1,
$count
);
} while ( 0 !== $count );
if ( preg_match( '#(^|/)\.+/#', $path ) ) {
return new WP_Error( 'invalid_relative_path' );
}
return $path;
}
}
@westonruter
Copy link
Author

@pradeep910
Copy link

Awesome 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment