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

Add resource hints transformers #179

Merged
merged 37 commits into from
Jun 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6fa218e
Add initial scaffolding for link management
schlessera Nov 25, 2020
82eeaf4
Ignore HTML special char entity encoding mismatches in assertSimilarM…
schlessera Nov 29, 2020
b734f1e
Add tests for Google Fonts preconnect
schlessera Nov 29, 2020
b835bad
Add logic to detect usage of Google Fonts
schlessera Nov 29, 2020
4ef17b8
Change LinkManager link placement
schlessera Nov 29, 2020
3bfa743
Improve preconnect generation according to best practices
schlessera Nov 29, 2020
e4e611d
Use resource hint terminology
schlessera Nov 29, 2020
2152d8c
Add ResourceHintManager::addPreload() helper
schlessera Nov 30, 2020
6a4c48b
Add scaffolding for adding AMP runtime script and style preloads
schlessera Nov 30, 2020
51611f0
Rename ResourceHints into GooglePreconnects to split up transformers
schlessera Nov 30, 2020
5772a5c
Harden resource hint manager reference node logic
schlessera Nov 30, 2020
5280f04
Add AmpRuntimePreloads transformer
schlessera Nov 30, 2020
dda1127
Fix line length CS issue in Dom\Document
schlessera Nov 30, 2020
c6191d1
Rename GoogleFontsPreconnectTest
schlessera Dec 3, 2020
40a50c4
Add TODO for AMP runtime is needed condition
schlessera Dec 14, 2020
e3fc16c
Add tests for AMP runtime CSS preload
schlessera Dec 14, 2020
b0f6d93
Add GoogleFontsPreconnect spec tests
schlessera Dec 15, 2020
dfcbd14
Fix test failures
schlessera May 3, 2021
e1d4687
Rename property $linkManager -> $resourceHints
schlessera May 3, 2021
2c1588c
Add tests for resource hint manager
schlessera May 3, 2021
08285b0
Include AmpRuntimePreloads in list of default transformers
schlessera May 3, 2021
26cd737
Add covers annotations for AmpRuntimePreloadsTest
schlessera May 3, 2021
34b9189
Make URL check for Google API domain stricter
schlessera May 3, 2021
1cb6796
Rename ResourceHintManager back to LinkManager
schlessera May 11, 2021
5a5dd19
Rephrase crossorigin argument
schlessera May 11, 2021
6c9aae1
Improve docblocks
schlessera May 11, 2021
ddf7ee4
Add missing resource hints
schlessera May 11, 2021
50956fe
Adapt tests
schlessera May 11, 2021
657f2ab
Rename Google Fonts constants
schlessera May 11, 2021
0890944
Use renamed constants
schlessera May 11, 2021
300a078
Add modulepreload
schlessera May 11, 2021
f8170a3
Add replacement and removal to link manager
schlessera May 25, 2021
4cd2c0d
Make RewriteAmpUrls use link manager
schlessera May 25, 2021
3b6d5a9
Ensure that the host is only replaced once
schlessera May 25, 2021
52c903d
Fix PHPStan issue
schlessera May 25, 2021
5831c16
Fix missing import
schlessera Jun 7, 2021
c3a2159
Remove unused variables
schlessera Jun 7, 2021
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
3 changes: 2 additions & 1 deletion src/Attribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -1117,5 +1117,6 @@ interface Attribute
const DATA_WIDGET_ID = 'data-widget-id';
const DATA_WIDGET_TYPE = 'data-widget-type';

const CROSSORIGIN_ANONYMOUS = 'anonymous';
const CROSSORIGIN_ANONYMOUS = 'anonymous';
const CROSSORIGIN_USE_CREDENTIALS = 'use-credentials';
}
19 changes: 17 additions & 2 deletions src/Dom/Document.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
* @property Element|null $viewport The document's viewport meta element.
* @property DOMNodeList $ampElements The document's <amp-*> elements.
* @property Element $ampCustomStyle The document's <style amp-custom> element.
* @property int $ampCustomStyleByteCount Count of bytes of the CSS in the <style amp-custom> tag.
* @property int $inlineStyleByteCount Count of bytes of the CSS in all of the inline style attributes.
* @property int $ampCustomStyleByteCount Count of bytes of CSS in the <style amp-custom> tag.
* @property int $inlineStyleByteCount Count of bytes of CSS in all of the inline style attributes.
* @property LinkManager $links Link manager to manage <link> tags in the <head>.
*
* @package ampproject/amp-toolbox
*/
Expand Down Expand Up @@ -333,6 +334,13 @@ final class Document extends DOMDocument
*/
private $convertedAmpBindAttributes = [];

/**
* Resource hint manager to manage resource hint <link> tags in the <head>.
*
* @var LinkManager|null
*/
private $links;

/**
* Creates a new AmpProject\Dom\Document object
*
Expand Down Expand Up @@ -2028,6 +2036,13 @@ public function __get($name)
}

return $this->inlineStyleByteCount;

case 'links':
if (! isset($this->links)) {
$this->links = new LinkManager($this);
}

return $this->links;
}

// Mimic regular PHP behavior for missing notices.
Expand Down
311 changes: 311 additions & 0 deletions src/Dom/LinkManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
<?php

namespace AmpProject\Dom;

use AmpProject\Attribute;
use AmpProject\Exception\FailedToCreateLink;
use AmpProject\RequestDestination;
use AmpProject\Tag;
use DOMNode;

/**
* Link manager class that is used to manage the <link> tags within a document's <head>.
*
* These can be used for example to give the browser hints about how to prioritize resources.
*
* @package ampproject/amp-toolbox
*/
final class LinkManager
{

/**
* List of relations currently managed by the link manager.
*
* @var array<string>
*/
const MANAGED_RELATIONS = [
Attribute::REL_DNS_PREFETCH,
Attribute::REL_MODULEPRELOAD,
Attribute::REL_PRECONNECT,
Attribute::REL_PREFETCH,
Attribute::REL_PRELOAD,
Attribute::REL_PRERENDER,
];

/**
* Document to manage the links for.
*
* @var Document
*/
private $document;

/**
* Reference node to attach the resource hint to.
*
* @var DOMNode|null
*/
private $referenceNode;

/**
* Collection of links already attached to the document.
*
* The key of the array is a concatenation of the HREF and the REL attributes.
*
* @var Element[]
*/
private $links = [];

/**
* LinkManager constructor.
*
* @param Document $document
*/
public function __construct(Document $document)
{
$this->document = $document;
$this->detectExistingLinks();
}

private function detectExistingLinks()
{
$node = $this->document->head->firstChild;
while ($node) {
$nextSibling = $node->nextSibling;
if (
! $node instanceof Element
||
$node->tagName !== Tag::LINK
) {
$node = $nextSibling;
continue;
}

$key = $this->getKey($node);

if ($key !== '') {
$this->links[$this->getKey($node)] = $node;
}

$node = $nextSibling;
}
}

/**
* Get the key to use for storing the element in the links cache.
*
* @param Element $element Element to get the key for.
* @return string Key to use. Returns an empty string for invalid elements.
*/
private function getKey(Element $element)
{
$href = $element->getAttribute(Attribute::HREF);
$rel = $element->getAttribute(Attribute::REL);

if (empty($href) || ! in_array($rel, self::MANAGED_RELATIONS, true)) {
return '';
}

return "{$href}{$rel}";
}

/**
* Add a dns-prefetch resource hint.
*
* @see https://www.w3.org/TR/resource-hints/#dns-prefetch
*
* @param string $href Origin to prefetch the DNS for.
*/
public function addDnsPrefetch($href)
{
$this->add(Attribute::REL_DNS_PREFETCH, $href);
}

/**
* Add a modulepreload declarative fetch primitive.
*
* @see https://html.spec.whatwg.org/multipage/links.html#link-type-modulepreload
*
* @param string $href Modular resource to preload.
* @param string|null $type Optional. Type of the resource. Defaults to not specified, which equals 'script'.
* @param bool|string $crossorigin Optional. Whether and how to configure CORS. Accepts a boolean for adding a
* boolean crossorigin flag, or a string to set a specific crossorigin strategy.
* Allowed values are 'anonymous' and 'use-credentials'. Defaults to true.
*/
public function addModulePreload($href, $type = null, $crossorigin = true)
{
$attributes = [];

if ($type !== null) {
$attributes = [Attribute::AS_ => $type];
}

if ($crossorigin !== false) {
$attributes[Attribute::CROSSORIGIN] = is_string($crossorigin) ? $crossorigin : null;
}

$this->add(Attribute::REL_MODULEPRELOAD, $href, $attributes);
}

/**
* Add a preconnect resource hint.
*
* @see https://www.w3.org/TR/resource-hints/#dfn-preconnect
*
* @param string $href Origin to preconnect to.
* @param bool|string $crossorigin Optional. Whether and how to configure CORS. Accepts a boolean for adding a
* boolean crossorigin flag, or a string to set a specific crossorigin strategy.
* Allowed values are 'anonymous' and 'use-credentials'. Defaults to true.
*/
public function addPreconnect($href, $crossorigin = true)
{
$this->add(
Attribute::REL_PRECONNECT,
$href,
$crossorigin !== false ? [Attribute::CROSSORIGIN => (is_string($crossorigin) ? $crossorigin : null)] : []
);

// Use dns-prefetch as fallback for browser that don't support preconnect.
// See https://web.dev/preconnect-and-dns-prefetch/#resolve-domain-name-early-with-reldns-prefetch
$this->addDnsPrefetch($href);
}

/**
* Add a prefetch resource hint.
*
* @see https://www.w3.org/TR/resource-hints/#prefetch
*
* @param string $href URL to the resource to prefetch.
* @param string $type Optional. Type of the resource. Defaults to type 'image'.
* @param bool|string $crossorigin Optional. Whether and how to configure CORS. Accepts a boolean for adding a
* boolean crossorigin flag, or a string to set a specific crossorigin strategy.
* Allowed values are 'anonymous' and 'use-credentials'. Defaults to true.
*/
public function addPrefetch($href, $type = RequestDestination::IMAGE, $crossorigin = true)
{
// TODO: Should we enforce a valid $type here?

$attributes = [Attribute::AS_ => $type];

if ($crossorigin !== false) {
$attributes[Attribute::CROSSORIGIN] = is_string($crossorigin) ? $crossorigin : null;
}

$this->add(Attribute::REL_PREFETCH, $href, $attributes);
}

/**
* Add a preload declarative fetch primitive.
*
* @see https://www.w3.org/TR/preload/
*
* @param string $href Resource to preload.
* @param string $type Optional. Type of the resource. Defaults to type 'image'.
* @param string|null $media Optional. Media query to add to the preload. Defaults to none.
* @param bool|string $crossorigin Optional. Whether and how to configure CORS. Accepts a boolean for adding a
* boolean crossorigin flag, or a string to set a specific crossorigin strategy.
* Allowed values are 'anonymous' and 'use-credentials'. Defaults to true.
*/
public function addPreload($href, $type = RequestDestination::IMAGE, $media = null, $crossorigin = true)
{
// TODO: Should we enforce a valid $type here?

$attributes = [Attribute::AS_ => $type];

if (!empty($media)) {
$attributes[Attribute::MEDIA] = $media;
}

if ($crossorigin !== false) {
$attributes[Attribute::CROSSORIGIN] = is_string($crossorigin) ? $crossorigin : null;
}

$this->add(Attribute::REL_PRELOAD, $href, $attributes);
}

/**
* Add a prerender resource hint.
*
* @see https://www.w3.org/TR/resource-hints/#prerender
*
* @param string $href URL of the page to prerender.
*/
public function addPrerender($href)
{
$this->add(Attribute::REL_PRERENDER, $href);
}

/**
* Add a link to the document.
*
* @param string $rel A 'rel' string.
* @param string $href URL to link to.
* @param string[] $attributes Associative array of attributes and their values.
*/
public function add($rel, $href, $attributes = [])
{
$link = $this->document->createElement(Tag::LINK);
$link->setAttribute(Attribute::REL, $rel);
$link->setAttribute(Attribute::HREF, $href);
foreach ($attributes as $attribute => $value) {
$link->setAttribute($attribute, $value);
}

$this->remove($rel, $href);

if (!isset($this->referenceNode)) {
$this->referenceNode = $this->document->viewport;
}

if ($this->referenceNode) {
$link = $this->document->head->insertBefore($link, $this->referenceNode->nextSibling);
} else {
$link = $this->document->head->appendChild($link);
}

if (! $link instanceof Element) {
throw FailedToCreateLink::forLink($link);
}
Comment on lines +265 to +267
Copy link
Member

Choose a reason for hiding this comment

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

How would this condition ever happen?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If you do something fishy with the document, you might end up with a DOMElement that is not an Element.

Also, PhpStan is happier with this restriction.


$this->links[$this->getKey($link)] = $link;

$this->referenceNode = $link;
}

/**
* Get a specific link from the link manager.
*
* @param string $rel Relation to fetch.
* @param string $href Reference to fetch.
* @return Element|null Requested link as a Dom\Element, or null if not found.
*/
public function get($rel, $href)
{
$key = "{$href}{$rel}";

if (! array_key_exists($key, $this->links)) {
return null;
}

return $this->links[$key];
}

/**
* Remove a specific link from the document.
*
* @param string $rel Relation of the link to remove.
* @param string $href Reference of the link to remove.
*/
public function remove($rel, $href)
{
$key = "{$href}{$rel}";

if (! array_key_exists($key, $this->links)) {
return;
}

$link = $this->links[$key];
$link->parentNode->removeChild($link);

unset($this->links[$key]);
}
}
29 changes: 29 additions & 0 deletions src/Exception/FailedToCreateLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace AmpProject\Exception;

use RuntimeException;

/**
* Exception thrown when a link could not be created.
*
* @package ampproject/amp-toolbox
*/
final class FailedToCreateLink extends RuntimeException implements AmpException
{

/**
* Instantiate a FailedToCreateLink exception for a link that could not be created.
*
* @param mixed $link Link that was not as expected.
* @return self
*/
public static function forLink($link)
{
$type = is_object($link) ? get_class($link) : gettype($link);
$message = "Failed to create a link via the link manager. "
. "Expected to produce an 'AmpProject\\Dom\\Element', got '$type' instead.";

return new self($message);
}
}
Loading