`, with a `-top` suffix for a bar at the top; the banner stylesheet * turns it into padding and the banner script removes it once there is no banner. */ public const RESERVE_CLASS = 'consentfold-reserve'; /** * Handle (and file name) of the early blocking script, printed inline in the head instead of enqueued. */ public const GUARD = 'consentfold-guard'; /** * Handle of the hosted font's stylesheet, enqueued only while the banner uses a hosted font. */ public const FONT_HANDLE = 'consentfold-font'; private const BUILD_DIR = 'assets/build/banner/'; /** * Config builder. * * @var ConfigBuilder */ private ConfigBuilder $config; /** * Absolute path of the plugin directory, with trailing slash. * * @var string */ private string $dir; /** * URL of the plugin directory, with trailing slash. * * @var string */ private string $url; /** * Plugin version, used to bust browser caches. * * @var string */ private string $version; /** * Hosted fonts. * * @var HostedFonts */ private HostedFonts $fonts; /** * Constructor. * * @param ConfigBuilder $config Config builder. * @param string $dir Absolute path of the plugin directory, with trailing slash. * @param string $url URL of the plugin directory, with trailing slash. * @param string $version Plugin version. * @param HostedFonts $fonts Hosted fonts. */ public function __construct( ConfigBuilder $config, string $dir, string $url, string $version, HostedFonts $fonts ) { $this->config = $config; $this->dir = $dir; $this->url = $url; $this->version = $version; $this->fonts = $fonts; } /** * Hook the enqueue and the head output. None of the hooks fires in the admin. */ public function register(): void { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ) ); // Before the head scripts print at priority 9, so the config tag precedes the banner script tag. // Priority 0: the config and the blocking script come before every other script a plugin prints in the head. add_action( 'wp_head', array( $this, 'print_config' ), 0 ); add_action( 'wp_head', array( $this, 'print_guard' ), 0 ); add_action( 'wp_head', array( $this, 'print_reserve_space' ), 8 ); } /** * Enqueue the banner script (deferred, in the head) and its stylesheet, and the stylesheet of the hosted font when * the banner uses one. Does nothing until the banner is built. */ public function enqueue(): void { if ( ! $this->is_built() ) { return; } wp_enqueue_style( self::HANDLE, $this->url . $this->built( self::HANDLE, 'css' ), array(), $this->version ); wp_enqueue_script( self::HANDLE, $this->url . $this->built( self::HANDLE, 'js' ), array(), $this->version, array( 'strategy' => 'defer', 'in_footer' => false, ) ); $this->enqueue_font(); } /** * Link the hosted font's local stylesheet and name the family for the banner stylesheet, when the banner uses a * hosted font whose files are on this site. The font comes from this site's uploads: the page never contacts Google. */ private function enqueue_font(): void { $appearance = (array) ( $this->config->build()['appearance'] ?? array() ); $family = (string) ( $appearance['font_family'] ?? '' ); $url = 'hosted' === ( $appearance['font'] ?? null ) && '' !== $family ? $this->fonts->stylesheet_url( $family ) : null; if ( null === $url ) { return; } wp_enqueue_style( self::FONT_HANDLE, $url, array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- The URL carries the version of the download. wp_add_inline_style( self::HANDLE, $this->fonts->variable_css( $family ) ); } /** * Print the config tag, unless the banner is not built. It runs before core enqueues the scripts (in `wp_head`), * so it cannot ask whether the banner script is enqueued: a script dequeued later still has its config. */ public function print_config(): void { if ( ! $this->is_built() ) { return; } // Hex-escaping `<`, `>` and `&` keeps a text value from closing the tag or opening a comment. $json = wp_json_encode( $this->config->build(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( false === $json ) { return; } /** * Filters the visitor's region hint, which the banner takes as the visitor's region without asking. The tag is * part of the page, so only a page that no cache serves to anyone else may answer with a visitor's region; a * wrong hint is worse than none. * * @param string $region Region such as `DE` or `US-CA`, empty for none. */ $region = apply_filters( 'consentfold_region_hint', '' ); $hint = is_string( $region ) && 1 === preg_match( ConsentPayload::REGION, $region ) ? array( 'data-consentfold-region' => $region ) : array(); wp_print_inline_script_tag( $json, array( 'type' => 'application/json', 'id' => self::CONFIG_ID, ) + $hint ); } /** * Print the early blocking script inline, right after the config: it neutralises the scripts and iframes that * need consent from the first tag the parser adds, which a deferred script could not. The tag carries * `data-consentfold`, so none of the rewriters touches it. Same for every visitor, like the config. */ public function print_guard(): void { $file = $this->dir . $this->built( self::GUARD, 'js' ); if ( ! $this->is_built() || ! is_readable( $file ) ) { return; } $code = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- Local build artefact. if ( false !== $code && '' !== $code ) { wp_print_inline_script_tag( $code, array( 'data-consentfold' => 'guard' ) ); } } /** * Print the inline script of the optional reserve-space mode (a bar banner that pushes the page up instead * of covering it). It runs before first paint and adds the class when there is no consent cookie yet, so the * output is the same for every visitor and cached pages stay correct. */ public function print_reserve_space(): void { if ( ! wp_script_is( self::HANDLE, 'enqueued' ) ) { return; } $layout = (array) ( $this->config->build()['layout'] ?? array() ); if ( true !== ( $layout['reserve_space'] ?? false ) || 'bar' !== ( $layout['type'] ?? null ) ) { return; } $class = self::RESERVE_CLASS . ( 'top' === ( $layout['position'] ?? null ) ? '-top' : '' ); wp_print_inline_script_tag( sprintf( '(function(d){if(d.cookie.indexOf("%s=")<0)d.documentElement.classList.add("%s")})(document)', esc_js( CookieCodec::NAME ), esc_js( $class ) ) ); } /** * Whether the banner script is built. */ private function is_built(): bool { return is_readable( $this->dir . $this->built( self::HANDLE, 'js' ) ); } /** * Plugin-relative path of a built banner file, minified unless the readable one is wanted. * * @param string $name File name without extension. * @param string $extension File extension. */ private function built( string $name, string $extension ): string { return BuiltAsset::path( $this->dir, self::BUILD_DIR . $name . '.' . $extension ); } }