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; | |
| } | |
| } |