| 1 |
<?php |
| 2 |
namespace ABlocks\Performance; |
| 3 |
|
| 4 |
if ( ! defined( 'ABSPATH' ) ) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
/** |
| 9 |
* The shared mechanism for stopping a <script> tag from executing, and letting |
| 10 |
* something on the client release it later. |
| 11 |
* |
| 12 |
* Two features need exactly this. `DelayJs` holds aBlocks' own scripts back |
| 13 |
* until the first user interaction; the cookie-consent addon holds third-party |
| 14 |
* tags back until consent for their category is granted. Both do the same three |
| 15 |
* things — decide whether a tag is in scope, rewrite its type so the browser |
| 16 |
* parses but does not run it, and release it later — so the rewriting lives |
| 17 |
* here once rather than as two independent regexes over every script on the |
| 18 |
* page. |
| 19 |
* |
| 20 |
* The rewrite is deliberately textual. `script_loader_tag` hands over a string, |
| 21 |
* and the consent addon's second layer works on already-rendered markup, so a |
| 22 |
* DOM-based rewrite is not available in either caller. |
| 23 |
*/ |
| 24 |
class ScriptGate { |
| 25 |
|
| 26 |
/** |
| 27 |
* Marker type written into the tag, e.g. `ablocks/delayed` or `text/plain`. |
| 28 |
* |
| 29 |
* @var string |
| 30 |
*/ |
| 31 |
private $type; |
| 32 |
|
| 33 |
/** |
| 34 |
* `( $handle, $src, $tag ) => bool` — whether this tag is in scope. |
| 35 |
* |
| 36 |
* @var callable |
| 37 |
*/ |
| 38 |
private $in_scope; |
| 39 |
|
| 40 |
/** |
| 41 |
* `( $handle, $src ) => array` — extra attributes to write onto the tag. |
| 42 |
* |
| 43 |
* @var callable|null |
| 44 |
*/ |
| 45 |
private $attributes; |
| 46 |
|
| 47 |
/** |
| 48 |
* Whether to move `src` out of the way as well as changing the type. |
| 49 |
* |
| 50 |
* @var bool |
| 51 |
*/ |
| 52 |
private $move_src; |
| 53 |
|
| 54 |
public function __construct( $type, callable $in_scope, $attributes = null, $move_src = false ) { |
| 55 |
$this->type = $type; |
| 56 |
$this->in_scope = $in_scope; |
| 57 |
$this->attributes = $attributes; |
| 58 |
$this->move_src = (bool) $move_src; |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* Start filtering enqueued script tags. |
| 63 |
* |
| 64 |
* @param int $priority Filter priority. |
| 65 |
*/ |
| 66 |
public function hook( $priority = 20 ) { |
| 67 |
add_filter( 'script_loader_tag', [ $this, 'filter_tag' ], $priority, 3 ); |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* @param string $tag Rendered script tag. |
| 72 |
* @param string $handle Script handle. |
| 73 |
* @param string $src Script source. |
| 74 |
* @return string |
| 75 |
*/ |
| 76 |
public function filter_tag( $tag, $handle, $src ) { |
| 77 |
if ( ! call_user_func( $this->in_scope, $handle, $src, $tag ) ) { |
| 78 |
return $tag; |
| 79 |
} |
| 80 |
$attrs = $this->attributes ? (array) call_user_func( $this->attributes, $handle, $src ) : []; |
| 81 |
return self::rewrite_tag( $tag, $this->type, $attrs, $this->move_src ); |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* Rewrite one script tag so the browser will not execute it. |
| 86 |
* |
| 87 |
* A `<script>` whose type is not a JavaScript MIME type is parsed as a data |
| 88 |
* block: it does not run, and — this is the part that matters for consent — |
| 89 |
* a `src` on it is never fetched either, so no request reaches the third |
| 90 |
* party before the visitor has decided. |
| 91 |
* |
| 92 |
* @param string $tag The full `<script …>` opening tag, or a whole tag pair. |
| 93 |
* @param string $type Marker type to write. |
| 94 |
* @param array $attrs Extra attributes, name => value. |
| 95 |
* @param bool $move_src Whether to rename `src` to `data-ablocks-src`. |
| 96 |
* @return string |
| 97 |
*/ |
| 98 |
public static function rewrite_tag( $tag, $type, array $attrs = [], $move_src = false ) { |
| 99 |
if ( $move_src ) { |
| 100 |
$tag = preg_replace( '/\ssrc=/', ' data-ablocks-src=', $tag, 1 ); |
| 101 |
} |
| 102 |
|
| 103 |
$extra = ''; |
| 104 |
foreach ( $attrs as $name => $value ) { |
| 105 |
$extra .= sprintf( '%s="%s" ', esc_attr( $name ), esc_attr( $value ) ); |
| 106 |
} |
| 107 |
|
| 108 |
// An existing type is preserved so the release step can restore it — |
| 109 |
// `type="module"` is not interchangeable with a classic script. |
| 110 |
if ( preg_match( '/\stype=["\']([^"\']*)["\']/i', $tag, $found ) ) { |
| 111 |
$extra .= sprintf( 'data-ablocks-type="%s" ', esc_attr( $found[1] ) ); |
| 112 |
$tag = preg_replace( '/\stype=["\'][^"\']*["\']/i', '', $tag, 1 ); |
| 113 |
} |
| 114 |
|
| 115 |
return preg_replace( |
| 116 |
'/^<script\s/', |
| 117 |
sprintf( '<script type="%s" %s', esc_attr( $type ), $extra ), |
| 118 |
$tag, |
| 119 |
1 |
| 120 |
); |
| 121 |
} |
| 122 |
|
| 123 |
/** |
| 124 |
* Whether a tag has already been gated by either feature. |
| 125 |
* |
| 126 |
* @param string $tag Script tag. |
| 127 |
* @return bool |
| 128 |
*/ |
| 129 |
public static function is_gated( $tag ) { |
| 130 |
return (bool) preg_match( '/\stype=["\'](?:text\/plain|ablocks\/[a-z-]+)["\']/i', $tag ); |
| 131 |
} |
| 132 |
} |
| 133 |
|