Requires Nette/Utils
composer require nette/utils
<?php | |
declare(strict_types = 1); | |
namespace App\Twig\Extensions; | |
use Nette\Utils\Html; | |
use Nette\Utils\Image; | |
use Nette\Utils\Validators; | |
use Symfony\Component\DependencyInjection\ContainerInterface; | |
use Twig\Extension\AbstractExtension; | |
use Twig\TwigFilter; | |
final class AmpFiltersExtension extends AbstractExtension | |
{ | |
private const EVENT_ATTRS_REGULAR_EXPRESSION = | |
'/(<[^!<]+)\s(on[a-zA-Z]+\s*=\s*(?:([\'"])(?!\3).+?\3|(?:\S+?\(.*?\)(?=[\s>]))))(.*?>)/'; | |
private const IFRAME_REGULAR_EXPRESSION = '/<iframe (?<attributes>.*)>.*<\/iframe>/'; | |
private const IMAGE_REGULAR_EXPRESSION = '/<img (?<attributes>[^>]+)>/'; | |
private const ATTRIBUTES_TO_REMOVE_REGULAR_EXPRESSION = | |
'/(?:style|align|target|frame|scope|border|xml:lang|lang|aria-level|role|type)=' | |
. '("([^"]*)"|\'([^\']*)\')/'; | |
private const TAGS_TO_REMOVE_REGULAR_EXPRESSION = | |
'/<\/?(?object|video|font|meta|ins|style)(?: [^>]*)?>/'; | |
private const NORMAL_IFRAME_TYPE = 'normalIframe'; | |
private const YOUTUBE_IFRAME_TYPE = 'youtubeIframe'; | |
private const HEADERS_200_STATE_CODE = '200 OK'; | |
private const YOUTUBE_IFRAME_TYPE_KEYWORD = 'youtube.com'; | |
/** | |
* @var ContainerInterface | |
*/ | |
private $container; | |
public function __construct(ContainerInterface $container) | |
{ | |
$this->container = $container; | |
} | |
public function getFilters(): array | |
{ | |
return [ | |
new TwigFilter('convertToAmpValidContent', function (string $content, array $parameters = []): string { | |
return $this->toAmpValidContent($content, $parameters); | |
}), | |
new TwigFilter('convertImagesToAmpImages', function ( | |
string $content, | |
bool $enableLightbox = false | |
): string { | |
return $this->convertImagesToAmpImages($content, $enableLightbox); | |
}), | |
new TwigFilter('convertToAmpImage', function ( | |
string $tag, | |
bool $enableLightbox = false, | |
int $width = NULL, | |
int $height = NULL | |
): Html { | |
return $this->convertToAmpImage($tag, $enableLightbox, $width, $height); | |
}), | |
new TwigFilter('removeDisallowedAttributesAndTags', function (string $content): string { | |
return $this->removeDisallowedAttributesAndTags($content); | |
}), | |
new TwigFilter('removeEventAttributes', function (string $content): string { | |
return $this->removeEventAttributes($content); | |
}) | |
]; | |
} | |
private function toAmpValidContent(string $content, array $parameters = []): string | |
{ | |
$content = $this->convertImagesToAmpImages($content, $parameters['imagesLightboxEnabled'] ?? false); | |
$content = $this->convertIframesToAmpIframes($content); | |
return $content; | |
} | |
private function convertIframesToAmpIframes(string $content): string | |
{ | |
$iframeMatches = []; | |
self::matchIframeTags($content, $iframeMatches); | |
foreach ($iframeMatches as $iframe) { | |
$ampIframe = $this->convertToAmpIframe($iframe[0]); | |
$content = preg_replace('/' . preg_quote($iframe[0], '/') . '/', $ampIframe, $content, 1); | |
} | |
return $content; | |
} | |
private function convertImagesToAmpImages(string $content, bool $enableLightbox = false): string | |
{ | |
$imagesMatches = []; | |
preg_match_all(self::IMAGE_REGULAR_EXPRESSION, $content, $imagesMatches, PREG_SET_ORDER); | |
foreach ($imagesMatches as $image) { | |
$ampImage = $this->convertToAmpImage($image[0], $enableLightbox); | |
$content = preg_replace('/' . preg_quote($image[0], '/') . '/', $ampImage, $content, 1); | |
} | |
return $content; | |
} | |
private function convertToAmpIframe(string $tag): Html | |
{ | |
$ampIframeTag = $this->createHtmlElement(self::IFRAME_REGULAR_EXPRESSION, 'amp-iframe', $tag); | |
$ampIframeSrcAttribute = $ampIframeTag->getAttribute('src'); | |
if (self::isYoutubeIframeType($ampIframeSrcAttribute)) { | |
$ampIframeSrcAttributeToAray = explode('/', $ampIframeSrcAttribute); | |
$ampIframeTag->removeAttribute('src'); | |
$ampIframeTag->removeAttribute('frameborder'); | |
$ampIframeTag->removeAttribute('frame'); | |
$ampIframeTag->removeAttribute('allow'); | |
$ampIframeTag->removeAttribute('allowfullscreen'); | |
$ampIframeTag->removeAttribute('scrolling'); | |
$ampIframeTag->setName('amp-youtube'); | |
$videoId = end($ampIframeSrcAttributeToAray); | |
if (is_string($videoId)) { | |
$videoId = explode('?', $videoId); | |
$videoId = $videoId[0]; | |
$ampIframeTag->setAttribute('data-videoid', $videoId); | |
} | |
} else { | |
$ampIframeTag->appendAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups'); | |
} | |
return $ampIframeTag; | |
} | |
private function convertToAmpImage( | |
string $tag, | |
bool $enableLightbox = false, | |
?int $width = null, | |
?int $height = null | |
): Html { | |
$ampImageTag = $this->createHtmlElement(self::IMAGE_REGULAR_EXPRESSION, 'amp-img', $tag); | |
$ampImagePath = $ampImageTag->getAttribute('src'); | |
$ampImageDirectoryPath = $this->container->getParameter('kernel.public_dir') . $ampImagePath; | |
$ampImageTagFallbackWrapper = Html::el('noscript'); | |
$ampImageTagFallback = Html::el('img')->setAttribute('src', $ampImagePath); | |
$ampImageTagStyleAttribute = $ampImageTag->getAttribute('style'); | |
$ampImageWidth = null; | |
$ampImageHeight = null; | |
$ampImageTag->removeAttributes(['style, align, alt, title']); | |
$ampImageTag->setAttribute('alt', ''); | |
if ( | |
(bool) $ampImageTagStyleAttribute | |
&& (bool) preg_match('/width: (?<size>[\d]+px|%);?/', $ampImageTagStyleAttribute, $widthMatches) | |
&& (bool) preg_match('/height: (?<size>[\d]+px|%);?/', $ampImageTagStyleAttribute, $heightMatches) | |
) { | |
$ampImageWidth = $widthMatches['size']; | |
$ampImageHeight = $heightMatches['size']; | |
} elseif (file_exists($ampImageDirectoryPath)) { | |
$ampImage = Image::fromFile($ampImageDirectoryPath); | |
$ampImageWidth = $ampImage->width; | |
$ampImageHeight = $ampImage->height; | |
} elseif ($this->imageOnUrlExists($ampImagePath) && (bool) getimagesize($ampImagePath)) { | |
$size = getimagesize($ampImagePath); | |
$ampImageWidth = $size[0]; | |
$ampImageHeight = $size[1]; | |
} | |
if ($width && $height) { | |
$ampImageWidth = $width; | |
$ampImageHeight = $height; | |
} | |
if ((bool) $ampImageWidth && (bool) $ampImageHeight) { | |
$sizeAttributes = [ | |
'width' => $ampImageWidth, | |
'height' => $ampImageHeight | |
]; | |
$ampImageTagFallback->addAttributes($sizeAttributes); | |
$ampImageTag->addAttributes($sizeAttributes); | |
} else { | |
$ampImageTag->setAttribute('layout', 'nodisplay'); | |
} | |
if ($enableLightbox) { | |
$ampImageTag->addAttributes([ | |
'on' => 'tap:lightbox', | |
'role' => 'button', | |
'tabindex' => 0 | |
]); | |
} | |
return $ampImageTag->setHtml($ampImageTagFallbackWrapper->setHtml($ampImageTagFallback)); | |
} | |
public static function matchIframeTags(?string $content, ?array &$matches = null, ?array &$types = null): bool | |
{ | |
if ( ! is_array($matches)) { | |
$matches = []; | |
} | |
if (!is_array($types)) { | |
$types = []; | |
} | |
if (!$content) { | |
return false; | |
} | |
preg_match_all(self::IFRAME_REGULAR_EXPRESSION, $content, $matches, PREG_SET_ORDER); | |
foreach ($matches as $match) { | |
$isYoutubeIframeType = self::isYoutubeIframeType($match[0]); | |
if ($isYoutubeIframeType) { | |
if (self::youtubeIframeTypeFounded($types)) { | |
continue; | |
} | |
$types[] = self::YOUTUBE_IFRAME_TYPE; | |
} elseif (!self::normalIframeTypeFounded($types)) { | |
$types[] = self::NORMAL_IFRAME_TYPE; | |
} | |
} | |
return (bool) $matches; | |
} | |
public static function normalIframeTypeFounded(array $types): bool | |
{ | |
return in_array(self::NORMAL_IFRAME_TYPE, $types, true); | |
} | |
public static function youtubeIframeTypeFounded(array $types): bool | |
{ | |
return in_array(self::YOUTUBE_IFRAME_TYPE, $types, true); | |
} | |
private function createHtmlElement(string $regularExpression, string $newTagName, string $tag): Html | |
{ | |
$tag = preg_replace($regularExpression, $newTagName . ' $1', $tag, 1); | |
$tag = Html::el($tag)->appendAttribute('layout', 'responsive'); | |
return $tag; | |
} | |
private function imageOnUrlExists(string $url): bool | |
{ | |
if ( ! Validators::isUrl($url)) { | |
return false; | |
} | |
$headers = get_headers($url); | |
return is_array($headers) | |
&& (bool) count($headers) | |
&& stripos($headers[0], self::HEADERS_200_STATE_CODE) !== false; | |
} | |
private static function isYoutubeIframeType(string $content): bool | |
{ | |
if ( ! (bool) $content) { | |
return false; | |
} | |
return strpos($content, self::YOUTUBE_IFRAME_TYPE_KEYWORD) !== false; | |
} | |
private function removeDisallowedAttributesAndTags(string $content): string | |
{ | |
if ( ! $content) { | |
return ''; | |
} | |
$content = preg_replace(self::TAGS_TO_REMOVE_REGULAR_EXPRESSION, '', $content); | |
$content = preg_replace(self::ATTRIBUTES_TO_REMOVE_REGULAR_EXPRESSION, '', $content); | |
return $content; | |
} | |
private function removeEventAttributes(string $content): string | |
{ | |
$eventMatches = []; | |
preg_match_all(self::EVENT_ATTRS_REGULAR_EXPRESSION, $content, $eventMatches, PREG_SET_ORDER); | |
foreach ($eventMatches as $eventMatch) { | |
$elWithEvent = reset($eventMatch); | |
$el = preg_replace( | |
'/\s(on[a-zA-Z]+\s*=\s*(?:([\'"])(?!\2).+?\2|(?:\S+?\(.*?\)(?=[\s>]))))/', | |
'', | |
$elWithEvent | |
); | |
$content = str_replace($elWithEvent, $el, $content); | |
} | |
return $content; | |
} | |
} |