array( 'bottom', 'top' ), 'modal' => array( 'center', 'top-left', 'top-center', 'top-right', 'middle-left', 'middle-right', 'bottom-left', 'bottom-center', 'bottom-right' ), 'panel' => array( 'left', 'right', 'top-left', 'middle-left', 'bottom-left', 'top-right', 'middle-right', 'bottom-right' ), ); /** * Width in pixels of each layout when none is set (a bar: the width of its content). */ private const WIDTHS = array( 'bar' => 960, 'modal' => 560, 'panel' => 420, ); /** * Settings repository. * * @var Repository */ private Repository $settings; /** * Service and blocking rule registry. * * @var ServiceRegistry */ private ServiceRegistry $registry; /** * Plugin version; a new release rebuilds the config. * * @var string */ private string $version; /** * Service details source. * * @var ServiceDetails */ private ServiceDetails $details; /** * Google Consent Mode. * * @var GoogleConsentMode */ private GoogleConsentMode $consent_mode; /** * WP Consent API bridge. * * @var ConsentApiBridge */ private ConsentApiBridge $consent_api; /** * Constructor. * * @param Repository $settings Settings repository. * @param ServiceRegistry $registry Service and blocking rule registry. * @param ServiceDetails $details Service details source. * @param GoogleConsentMode $consent_mode Google Consent Mode. * @param ConsentApiBridge $consent_api WP Consent API bridge. * @param string $version Plugin version. */ public function __construct( Repository $settings, ServiceRegistry $registry, ServiceDetails $details, GoogleConsentMode $consent_mode, ConsentApiBridge $consent_api, string $version ) { $this->settings = $settings; $this->registry = $registry; $this->details = $details; $this->consent_mode = $consent_mode; $this->consent_api = $consent_api; $this->version = $version; } /** * Drop the cached config whenever the settings are saved or the active plugins or theme change. */ public function register(): void { foreach ( array( 'consentfold_settings_saved', 'activated_plugin', 'deactivated_plugin', 'switch_theme' ) as $hook ) { add_action( $hook, array( $this, 'invalidate' ) ); } } /** * Delete the cached config and the cached service details. */ public function invalidate(): void { delete_transient( self::TRANSIENT ); $this->details->invalidate(); } /** * The config for the active locale, from the cache when it is warm. * * @return array */ public function build(): array { $inputs = array_intersect_key( $this->settings->get(), array_flip( self::INPUT_KEYS ) ); $hash = md5( (string) wp_json_encode( array( $inputs, $this->version, self::FORMAT ) ) ); $locale = self::text_locale( (array) ( $inputs['texts'] ?? array() ), Locale::current() ); $cached = get_transient( self::TRANSIENT ); $fresh = is_array( $cached ) && ( $cached['hash'] ?? null ) === $hash; $configs = $fresh && is_array( $cached['configs'] ?? null ) ? $cached['configs'] : array(); $shared = $fresh && is_array( $cached['shared'] ?? null ) ? $cached['shared'] : null; $stale = false; if ( null === $shared ) { $shared = $this->compile_shared( $inputs ); $stale = true; } if ( ! is_array( $configs[ $locale ] ?? null ) ) { $configs[ $locale ] = self::compile( $inputs, $locale ); $stale = true; } if ( $stale ) { set_transient( self::TRANSIENT, array( 'hash' => $hash, 'configs' => $configs, 'shared' => $shared, ) ); } $config = $configs[ $locale ] + $shared; // A decision taken before categories moved does not cover what moved: another version makes visitors decide again. $config['banner_version'] = self::banner_version( (int) $config['banner_version'], (array) ( $config['blocking']['remap'] ?? array() ) ); unset( $config['blocking']['remap'] ); // Not cached with the rest: it names the details by their version, which the details source keeps up to date. $config['blocking']['details'] = $this->details->url(); // A path, not cached with the rest because it follows the permalink structure. Without it the banner logs nothing. if ( true === ( $inputs['consent']['log_enabled'] ?? true ) ) { $config['log'] = PublicConsentController::url(); // The record view is a script of its own, fetched when the visitor asks for their record. $config['record'] = array( 'api' => PublicConsentController::record_url(), 'script' => add_query_arg( 'ver', rawurlencode( $this->version ), CONSENTFOLD_URL . BuiltAsset::path( CONSENTFOLD_DIR, 'assets/build/banner/consentfold-record.js' ) ), ); } // Not cached with the rest: whether it shows can change without a settings save. if ( true === ( $inputs['appearance']['show_credit'] ?? false ) && true === apply_filters( 'consentfold_banner_credit', true ) ) { $config['credit'] = array( 'text' => TextDomain::in_locale( $locale, static fn (): string => __( 'Powered by Consentfold', 'consentfold' ) ), 'url' => self::CREDIT_URL, ); } /** * Filters the banner config printed on every page. Runs per request, after the cache: keep the result * identical for every visitor, or cached pages will show one visitor's variant to everybody. * * @param array $config Banner config. */ $filtered = apply_filters( 'consentfold_banner_config', $config ); return is_array( $filtered ) ? $filtered : $config; } /** * The banner version as the browser compares it with a stored decision: the admin's version, and when * services or rules were moved to another category, a number that changes with the move (cookie range 1-65535). * * @param int $version Banner version from the settings. * @param array $remap Moved categories, see {@see ServiceRegistry::remap()}. */ private static function banner_version( int $version, array $remap ): int { return array() === $remap ? $version : ( ( $version + crc32( (string) wp_json_encode( $remap ) ) ) % 65535 ) + 1; } /** * The locale whose texts the banner uses: the active one ({@see Locale::current()}) when the settings have texts for it * or the plugin ships its default texts, else en_US. * * @param array $texts Texts keyed by locale. * @param string $locale Active locale. */ private static function text_locale( array $texts, string $locale ): string { return isset( $texts[ $locale ] ) || in_array( $locale, TextDomain::SHIPPED, true ) ? $locale : self::FALLBACK_LOCALE; } /** * The banner texts of a locale, per string: what the admin stored for the locale, else the English text when the admin * changed it, else the shipped translation of the default, else the English text. * * @param array $stored Stored texts keyed by locale. * @param string $locale Locale of the texts. * @return array */ public static function texts( array $stored, string $locale ): array { $english = (array) ( $stored[ self::FALLBACK_LOCALE ] ?? array() ); $texts = $english; if ( self::FALLBACK_LOCALE !== $locale ) { $source = Defaults::all()['texts'][ self::FALLBACK_LOCALE ]; foreach ( BannerDefaults::for_locale( $locale )['texts'] as $id => $translation ) { if ( ( $english[ $id ] ?? null ) === ( $source[ $id ] ?? null ) ) { $texts[ $id ] = $translation; } } } return array_merge( $texts, (array) ( $stored[ $locale ] ?? array() ) ); } /** * Compile the part of the config that does not depend on the locale: what the browser needs to block and to tell * Google about the decision. * * - `blocking.observer`, `.placeholders`, `.reload`: the matching settings. * - `blocking.rules`: category => `src` | `inline` => list of `[ service slug, pattern, ... ]` (`''` for the * admin's rules), see {@see ServiceRegistry::public_rules()}. Only categories in effect: rules of a category * that is gone moved to another one, see {@see ServiceRegistry::effective_category()}. * - `blocking.allow`: substrings of URLs and code no rule may block, only when some are allowed, see * {@see ServiceRegistry::public_allow()}. * - `blocking.remap`: moved categories, folded into `banner_version` by {@see self::build()} and not sent. * - `blocking.details`: URL of the service details, added by {@see self::build()}. * - `consent_mode`: `mode` and the category => signals `map`, see {@see GoogleConsentMode::public_config()}; left out * when Consent Mode is off. * - `consent_api`: the `authority` and the category => WP Consent API categories `map`, see * {@see ConsentApiBridge::public_config()}. * * @param array $inputs Settings restricted to {@see self::INPUT_KEYS}. * @return array */ private function compile_shared( array $inputs ): array { $blocking = (array) ( $inputs['blocking'] ?? array() ); $shared = array( 'blocking' => array( 'observer' => $blocking['mutation_observer'] ?? true, 'placeholders' => $blocking['iframe_placeholders'] ?? true, 'reload' => $blocking['reload_on_withdraw'] ?? true, 'rules' => $this->registry->public_rules(), 'remap' => $this->registry->remap(), ), ); $allow = $this->registry->public_allow(); if ( array() !== $allow ) { $shared['blocking']['allow'] = $allow; } $shared['consent_api'] = $this->consent_api->public_config(); $consent_mode = $this->consent_mode->public_config(); if ( array() !== $consent_mode ) { $shared['consent_mode'] = $consent_mode; } return $shared; } /** * The banner's font. Settings saved before `font` existed only have `font_inherit`: when it is off and `font` * still has its default, the banner keeps the system font those settings asked for. A hosted font without a * family (nothing was chosen yet) is the theme's font. The one place that rule lives: the banner design route * shows the editor this value too. * * @param array $appearance The `appearance` settings. */ public static function font( array $appearance ): string { $font = (string) ( $appearance['font'] ?? 'theme' ); if ( 'hosted' === $font && '' === self::hosted_family( $appearance ) ) { $font = 'theme'; } return 'theme' === $font && false === ( $appearance['font_inherit'] ?? true ) ? 'system' : $font; } /** * The Google Fonts family chosen as the hosted font, whether or not it is the font in use. * * @param array $appearance The `appearance` settings. */ public static function hosted_family( array $appearance ): string { $hosted = $appearance['hosted_font'] ?? null; return is_array( $hosted ) && is_string( $hosted['family'] ?? null ) ? $hosted['family'] : ''; } /** * The entrance and exit animation of the banner and whether its interactions (resizing, hover, reopen) are animated too. A value that is not one of the choices (a setting saved by hand or * by another version) falls back to no animation at normal speed, so the banner never gets an unknown style. * * @param array $appearance The `appearance` settings. * @return array{animation: string, animation_speed: string, animate_interactions: bool} */ public static function animation( array $appearance ): array { $style = $appearance['animation'] ?? null; $speed = $appearance['animation_speed'] ?? null; return array( 'animation' => in_array( $style, array( 'none', 'fade', 'slide', 'zoom' ), true ) ? $style : 'none', 'animation_speed' => in_array( $speed, array( 'fast', 'normal', 'slow' ), true ) ? $speed : 'normal', 'animate_interactions' => true === ( $appearance['animate_interactions'] ?? false ), ); } /** * The layout of the banner with every value in range, so the banner never gets a spot or size it cannot draw. A * position the layout does not offer (a setting saved before the position grid, by hand, or by another version) * falls back to where that layout always sat; an offset or width out of range is clamped, and a width of 0 means the * layout's own. A bar and a full-height side panel sit flush with the edge, so their offset is 0. * * @param array $layout The `layout` settings. * @return array{type: string, position: string, overlay: bool, reserve_space: bool, offset: int, width: int} */ public static function layout( array $layout ): array { $type = $layout['type'] ?? null; $type = is_string( $type ) && isset( self::SPOTS[ $type ] ) ? $type : 'bar'; $position = $layout['position'] ?? null; $position = in_array( $position, self::SPOTS[ $type ], true ) ? $position : self::SPOTS[ $type ][0]; $flush = 'bar' === $type || in_array( $position, array( 'left', 'right' ), true ); $offset = $layout['offset'] ?? 16; $width = $layout['width'] ?? 0; return array( 'type' => $type, 'position' => $position, 'overlay' => (bool) ( $layout['overlay'] ?? true ), 'reserve_space' => (bool) ( $layout['reserve_space'] ?? false ), 'offset' => $flush ? 0 : max( 0, min( 200, is_int( $offset ) ? $offset : 16 ) ), 'width' => is_int( $width ) && $width > 0 ? max( 280, min( 960, $width ) ) : self::WIDTHS[ $type ], ); } /** * Compile the config from the settings that shape it. * * @param array $inputs Settings restricted to {@see self::INPUT_KEYS}. * @param string $locale Locale of the texts, see {@see self::text_locale()}. * @return array */ private static function compile( array $inputs, string $locale ): array { // A locale with only some strings translated shows English for the rest. $texts = self::texts( (array) ( $inputs['texts'] ?? array() ), $locale ); /** * Filters the banner texts of one locale, e.g. to feed them from a translation plugin. * * @param array $texts Banner texts keyed by string id. */ $filtered = apply_filters( "consentfold_texts_{$locale}", $texts ); $names = BannerDefaults::for_locale( $locale )['categories']; $categories = array(); foreach ( Categories::resolve( $inputs ) as $slug => $category ) { $categories[] = array( 'slug' => $slug, 'label' => $names[ $category['label'] ] ?? $category['label'], 'description' => $names[ $category['description'] ] ?? $category['description'], 'required' => Categories::NECESSARY === $slug, ); } $appearance = (array) ( $inputs['appearance'] ?? array() ); $consent = (array) ( $inputs['consent'] ?? array() ); $font = self::font( $appearance ); return array( 'v' => self::FORMAT, 'locale' => $locale, 'regime_mode' => $inputs['regime_mode'] ?? 'auto', 'banner_version' => $inputs['banner_version'] ?? 1, 'policy_version' => $inputs['policy_version'] ?? 1, 'layout' => self::layout( (array) ( $inputs['layout'] ?? array() ) ), 'buttons' => $inputs['buttons'] ?? array(), 'appearance' => array( 'tokens' => $appearance['tokens'] ?? array(), 'dark_mode' => $appearance['dark_mode'] ?? 'auto', 'radius' => $appearance['radius'] ?? 8, 'font' => $font, 'text_size' => $appearance['text_size'] ?? 'medium', 'spacing' => $appearance['spacing'] ?? 'normal', 'button_style' => $appearance['button_style'] ?? 'filled', 'toggle_style' => $appearance['toggle_style'] ?? 'switch', ) + self::animation( $appearance ) + ( 'hosted' === $font ? array( 'font_family' => self::hosted_family( $appearance ) ) : array() ), 'texts' => is_array( $filtered ) ? $filtered : $texts, 'categories' => $categories, 'consent' => array( 'duration_days' => $consent['duration_days'] ?? 180, 'respect_gpc' => $consent['respect_gpc'] ?? true, ), 'cookie' => array( 'name' => CookieCodec::NAME, 'version' => CookieCodec::VERSION, ), ); } }