Skip to content

Commit

Permalink
Merge pull request #179 from ampproject/add/31-browser-hints-transformer
Browse files Browse the repository at this point in the history
  • Loading branch information
schlessera authored Jun 10, 2021
2 parents 8678f0d + c3a2159 commit b31e538
Show file tree
Hide file tree
Showing 13 changed files with 1,245 additions and 48 deletions.
3 changes: 2 additions & 1 deletion src/Attribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -1118,5 +1118,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);
}

$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

0 comments on commit b31e538

Please sign in to comment.