'default', 'text' => 'Stay connected via Google News', 'subtitle' => 'Follow us for the latest travel updates and guides.', 'link_prefix' => 'Enjoyed this article? You can', 'link_suffix' => 'to see more from us.', 'bg_color' => '#ffffff', 'text_color' => '#1c1e21', 'align' => 'center', ); /** * The ID of this plugin. * * @since 1.0.0 * @access private * @var string $plugin_name The ID of this plugin. */ private $plugin_name; /** * The version of this plugin. * * @since 1.0.0 * @access private * @var string $version The current version of this plugin. */ private $version; /** * The options of this plugin. * * @since 1.0.0 * @access private * @var array $options The options of this plugin. */ private $options; /** * Whether the late (footer) asset fallback has already run for this * request, so a page with several shortcodes only prints it once. * * @since 1.3.0 * @access private * @var bool */ private $late_assets_printed = false; /** * Cached result of shortcode_should_load() for this request. * * @since 1.3.0 * @access private * @var bool|null */ private $has_manual_placement = null; /** * Initialize the class and set its properties. * * @since 1.0.0 * @param string $plugin_name The name of the plugin. * @param string $version The version of this plugin. * @param array $options The options of this plugin. */ public function __construct($plugin_name, $version, $options) { $this->plugin_name = $plugin_name; $this->version = $version; $this->options = $options; } /** * Maps a WordPress locale string to a badge language code. * Returns null if no matching badge is available (caller falls back to EN). */ private function locale_to_badge_code( $locale ) { $map = array( 'da' => 'da', 'da_DK' => 'da', 'de' => 'de', 'de_DE' => 'de', 'de_AT' => 'de', 'de_CH' => 'de', 'de_DE_formal' => 'de', 'es' => 'es', 'es_ES' => 'es', 'es_MX' => 'es', 'es_AR' => 'es', 'es_CL' => 'es', 'et' => 'et', 'et_EE' => 'et', 'fi' => 'fi', 'fi_FI' => 'fi', 'fr' => 'fr', 'fr_FR' => 'fr', 'fr_BE' => 'fr', 'fr_CA' => 'fr', 'fr_CH' => 'fr', 'hi' => 'hi', 'hi_IN' => 'hi', 'he' => 'iw', 'he_IL' => 'iw', 'ja' => 'ja', 'ja_JP' => 'ja', 'ko' => 'ko', 'ko_KR' => 'ko', 'nb' => 'no', 'nb_NO' => 'no', 'nn_NO' => 'no', 'pt_BR' => 'pt-br', 'ru' => 'ru', 'ru_RU' => 'ru', 'sv' => 'sv', 'sv_SE' => 'sv', 'tr' => 'tr', 'tr_TR' => 'tr', 'uk' => 'uk', 'uk_UA' => 'uk', ); return isset( $map[ $locale ] ) ? $map[ $locale ] : null; } /** * Returns the URL of the badge image to display, based on the saved language setting. */ public function get_badge_image_url() { $setting = isset( $this->options['badge_language'] ) ? $this->options['badge_language'] : 'auto'; if ( $setting === 'custom' ) { return ''; } if ( $setting === 'upload' ) { $attachment_id = isset( $this->options['badge_custom_image_id'] ) ? absint( $this->options['badge_custom_image_id'] ) : 0; $url = $attachment_id ? wp_get_attachment_url( $attachment_id ) : ''; if ( $url ) { return $url; } // No image uploaded yet — fall back to the default English badge. $setting = 'en'; } if ( $setting === 'auto' ) { $code = $this->locale_to_badge_code( get_locale() ); } else { $code = ( $setting === 'en' ) ? null : $setting; } // Default to the dark badges so installs updated from older versions // (which have no badge_theme option) keep their current look. $theme = ( isset( $this->options['badge_theme'] ) && 'light' === $this->options['badge_theme'] ) ? 'light' : 'dark'; $dir = ( 'light' === $theme ) ? 'public/images/light/' : 'public/images/'; if ( $code && in_array( $code, self::BADGE_CODES, true ) ) { return ADASPRSO_PLUGIN_URL . $dir . 'google-preferred-source-button-' . $code . '.png'; } return ADASPRSO_PLUGIN_URL . $dir . 'google-preferred-source-button.png'; } public function get_custom_badge_text() { return isset( $this->options['badge_custom_text'] ) ? $this->options['badge_custom_text'] : ''; } public function is_custom_badge() { $setting = isset( $this->options['badge_language'] ) ? $this->options['badge_language'] : 'auto'; return $setting === 'custom'; } /** * Return the domain to use in Google Preferred Source links. * Uses the saved custom_domain option when set, otherwise falls back to home_url(). */ private function get_google_domain() { $custom = isset( $this->options['custom_domain'] ) ? trim( $this->options['custom_domain'] ) : ''; if ( ! empty( $custom ) ) { return $custom; } return wp_parse_url( home_url(), PHP_URL_HOST ); } /** * Returns the saved implementation method. Installs that predate this * setting have no 'implementation_method' saved, so they fall back to * 'legacy' — their behaviour is unchanged after updating. * * @return string 'legacy'|'advanced'|'standard' */ public function get_implementation_method() { $method = isset( $this->options['implementation_method'] ) ? $this->options['implementation_method'] : 'legacy'; return in_array( $method, array( 'legacy', 'advanced', 'standard' ), true ) ? $method : 'legacy'; } /** * The languages Google's preferred-source script itself supports (its * `data-lang` / `lang` values) — a larger list than the 17 languages we * have bundled badge images for, since "Your Own Design" and "Google's * Own Button" don't render our badge image at all. */ private function get_google_publisher_languages() { return array( 'ar', 'bg', 'bn', 'cs', 'da', 'de', 'el', 'en', 'en-GB', 'es', 'es-419', 'et', 'fil', 'fr', 'fr-CA', 'gu', 'hi', 'hr', 'hu', 'id', 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'lv', 'ml', 'mr', 'ms', 'nl', 'no', 'pa', 'pl', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'te', 'th', 'tr', 'uk', 'ur', 'vi', 'zh-CN', 'zh-HK', 'zh-TW', ); } /** * Best-effort match from a WordPress locale to one of Google's * publisher language codes, for "auto" language selections. Falls back * to the bare language prefix, then to '' (Google uses the visitor's * browser language) if nothing matches. */ private function locale_to_google_lang_code( $locale ) { $map = array( 'en_GB' => 'en-GB', 'es_MX' => 'es-419', 'fr_CA' => 'fr-CA', 'he_IL' => 'iw', 'nb_NO' => 'no', 'nn_NO' => 'no', 'pt_BR' => 'pt-BR', 'pt_PT' => 'pt-PT', 'zh_CN' => 'zh-CN', 'zh_HK' => 'zh-HK', 'zh_TW' => 'zh-TW', ); if ( isset( $map[ $locale ] ) ) { return $map[ $locale ]; } $prefix = substr( $locale, 0, 2 ); return in_array( $prefix, $this->get_google_publisher_languages(), true ) ? $prefix : ''; } /** * Resolves the `data-lang` value for the "Google's own button" method. * Returns '' for "auto" so the attribute is omitted and Google falls * back to the visitor's browser language. */ private function get_standard_lang_code() { $setting = isset( $this->options['standard_lang'] ) ? $this->options['standard_lang'] : 'auto'; return ( 'auto' === $setting ) ? '' : $setting; } /** * Resolves the `lang` value passed to preferredSource.init() for the * "Your own design" method. */ private function get_advanced_lang_code() { $setting = isset( $this->options['advanced_lang'] ) ? $this->options['advanced_lang'] : 'auto'; if ( 'auto' !== $setting ) { return $setting; } return $this->locale_to_google_lang_code( get_locale() ); } /** * Markup for Google's own auto-rendered button, used by both the * "Google's own button" banner and inline placements. */ private function google_button_markup() { $theme = ( isset( $this->options['standard_theme'] ) && 'dark' === $this->options['standard_theme'] ) ? 'dark' : 'light'; $lang = $this->get_standard_lang_code(); $attrs = ' data-theme="' . esc_attr( $theme ) . '"'; if ( $lang ) { $attrs .= ' data-lang="' . esc_attr( $lang ) . '"'; } return '
'; } /** * Injects HTML above/below/around the post content per the saved * inline position setting. Shared by both the custom inline button * and the "Google's own button" inline placement. */ private function inject_by_position( $content, $html ) { $position = isset( $this->options['inline_position'] ) ? $this->options['inline_position'] : 'both'; if ( 'above' === $position ) { return $html . $content; } if ( 'below' === $position ) { return $content . $html; } return $html . $content . $html; } /** * Prints Google's preferred-source script in wp_head, when needed by the * saved implementation method. */ public function render_google_publisher_script() { if ( ! $this->banner_should_load() && ! $this->inline_should_load() && ! $this->shortcode_should_load() ) { return; } if ( 'advanced' === $this->get_implementation_method() ) { $this->print_advanced_module(); } } /** * Prints the ES module that boots Google's preferred-source API for the * "Your own design" (advanced) method. Shared by the wp_head hook and the * late fallback used by manual placements, so both emit the same script. * * @since 1.3.0 */ private function print_advanced_module() { $theme = ( isset( $this->options['badge_theme'] ) && 'light' === $this->options['badge_theme'] ) ? 'light' : 'dark'; $lang = $this->get_advanced_lang_code(); ?> $this->get_google_domain(), 'method' => $this->get_implementation_method(), 'bannerTrigger' => isset($this->options['banner_trigger']) ? $this->options['banner_trigger'] : 'immediate', 'bannerScrollPercent' => isset($this->options['banner_scroll_percent']) ? (int) $this->options['banner_scroll_percent'] : 50, 'bannerDelaySeconds' => isset($this->options['banner_delay_seconds']) ? (int) $this->options['banner_delay_seconds'] : 5, 'bannerFrequency' => ( isset( $this->options['banner_frequency'] ) && 'always' === $this->options['banner_frequency'] ) ? 'always' : 'remember', 'bannerDismissDays' => isset($this->options['banner_dismiss_days']) ? (int) $this->options['banner_dismiss_days'] : 7, 'ajaxUrl' => admin_url('admin-ajax.php'), ); } /** * Whether the banner will be rendered on the current request * (enabled + device settings + display rules). * * @return bool */ private function banner_should_load() { if (!isset($this->options['enabled']) || !$this->options['enabled']) { return false; } // Check device-specific settings $is_mobile = wp_is_mobile(); $show_on_mobile = isset($this->options['show_on_mobile']) ? $this->options['show_on_mobile'] : 1; $show_on_desktop = isset($this->options['show_on_desktop']) ? $this->options['show_on_desktop'] : 1; if (($is_mobile && !$show_on_mobile) || (!$is_mobile && !$show_on_desktop)) { return false; } return $this->should_display(); } /** * Whether the inline button will be rendered on the current request * (enabled + singular view of a selected post type). * * @return bool */ private function inline_should_load() { if (!isset($this->options['inline_enabled']) || !$this->options['inline_enabled']) { return false; } if (!is_singular()) { return false; } $post_types = isset($this->options['inline_post_types']) ? (array) $this->options['inline_post_types'] : array('post'); return in_array(get_post_type(), $post_types, true); } /** * Whether the post being viewed contains the shortcode or the block, so * the assets can be enqueued the normal way (in the head) for the common * case of a shortcode typed into the editor. * * Placements this cannot see — a theme template tag, a widget, a page * builder — are covered by the late fallback in ensure_assets(). * * @since 1.3.0 * @return bool */ private function shortcode_should_load() { if ( null !== $this->has_manual_placement ) { return $this->has_manual_placement; } $this->has_manual_placement = false; if ( is_singular() ) { $post = get_post(); if ( $post instanceof WP_Post ) { $this->has_manual_placement = has_shortcode( $post->post_content, self::SHORTCODE_TAG ) || has_shortcode( $post->post_content, self::SHORTCODE_ALIAS ) || has_block( self::BLOCK_NAME, $post ); } } return $this->has_manual_placement; } /** * Makes sure a manually placed button has its assets, even when it is * rendered too late for the normal enqueue pass (theme template tag, * widget, page builder, or a shortcode inside an archive loop). * * Styles enqueued after wp_head are printed by WordPress in the footer. * The advanced-method module is printed inline, once per request, only if * wp_head has already run without it. * * @since 1.3.0 */ private function ensure_assets() { if ( ! wp_style_is( $this->plugin_name, 'enqueued' ) ) { wp_enqueue_style( $this->plugin_name ); } if ( 'advanced' !== $this->get_implementation_method() ) { return; } if ( ! wp_script_is( $this->plugin_name, 'enqueued' ) ) { wp_enqueue_script( $this->plugin_name ); wp_localize_script( $this->plugin_name, 'adasprsoOptions', $this->get_script_data() ); } // wp_head has already run and did not print the module (no banner, // no auto inline button, and no shortcode in the post content). if ( ! $this->late_assets_printed && did_action( 'wp_head' ) && ! $this->banner_should_load() && ! $this->inline_should_load() && ! $this->shortcode_should_load() ) { $this->late_assets_printed = true; $this->print_advanced_module(); } } /** * Returns '.min' unless SCRIPT_DEBUG is enabled, so the readable source * files are served while debugging. * * @return string */ private function asset_suffix() { return ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; } /** * Register the stylesheets for the public-facing side of the site. */ public function enqueue_styles() { // Always registered so a late placement (template tag, widget, page // builder) can enqueue it by handle from ensure_assets(). $this->register_public_style(); if ($this->banner_should_load() || $this->inline_should_load() || $this->shortcode_should_load()) { wp_enqueue_style($this->plugin_name); } } /** * Registers the front-end stylesheet. Called both on wp_enqueue_scripts and * on init (before the block is registered), because block.json references * this handle in its "style" field - which is what gets the stylesheet into * the editor canvas, iframe and all. * * @since 1.3.0 */ public function register_public_style() { if ( wp_style_is( $this->plugin_name, 'registered' ) ) { return; } wp_register_style( $this->plugin_name, ADASPRSO_PLUGIN_URL . 'public/css/add-as-preferred-source-public' . $this->asset_suffix() . '.css', array(), $this->version, 'all' ); } /** * Loads the front-end stylesheet inside the block editor, so the block's * server-rendered preview looks like the published page. * * enqueue_block_assets fires for the editor canvas (unlike * enqueue_block_editor_assets, which only reaches the editor chrome and so * never styled the preview). Front-end requests keep the conditional * loading in enqueue_styles(), so this only runs in the admin. * * @since 1.3.0 */ public function enqueue_block_editor_canvas_styles() { if ( ! is_admin() ) { return; } $this->register_public_style(); wp_enqueue_style( $this->plugin_name ); } /** * Register the JavaScript for the public-facing side of the site. */ public function enqueue_scripts() { $method = $this->get_implementation_method(); // Google's publisher library, used by the "standard" implementation. // The "advanced" method loads its own ES module in wp_head instead. if ( 'standard' === $method && ( $this->banner_should_load() || $this->inline_should_load() || $this->shortcode_should_load() ) ) { wp_enqueue_script( $this->plugin_name . '-google-publisher', 'https://news.google.com/swg/js/v1/publisher.js', array(), null, false ); // Honoured on WP 6.3+; a harmless no-op on older versions. wp_script_add_data( $this->plugin_name . '-google-publisher', 'strategy', 'async' ); } // Always registered so a late placement can enqueue it by handle. // 'strategy' => 'defer' is applied on WP 6.3+; older versions treat // the (truthy) array like the previous `true` and load in the footer. wp_register_script($this->plugin_name, ADASPRSO_PLUGIN_URL . 'public/js/add-as-preferred-source-public' . $this->asset_suffix() . '.js', array(), $this->version, array( 'in_footer' => true, 'strategy' => 'defer', )); // The banner always needs the script (show/hide/trigger/dismiss). // The inline button and manual placements only need it in "advanced" // mode, to intercept clicks and call Google's API instead of // navigating directly. $needs_public_js = $this->banner_should_load() || ( 'advanced' === $method && ( $this->inline_should_load() || $this->shortcode_should_load() ) ); if ($needs_public_js) { wp_enqueue_script($this->plugin_name); // Localize script with options. No nonce on purpose: the tracking // endpoint is a public counter, and a nonce baked into cached pages // goes stale and breaks tracking once the cache outlives it. wp_localize_script($this->plugin_name, 'adasprsoOptions', $this->get_script_data()); } } /** * Display the preferred source banner in the footer. * * @since 1.0.0 */ public function display_preferred_source_banner() { if (!$this->banner_should_load()) { return; } if ( 'standard' === $this->get_implementation_method() ) { $this->render_standard_banner(); return; } // Load the banner template include_once ADASPRSO_PLUGIN_PATH . 'public/partials/add-as-preferred-source-public-display.php'; } /** * Renders Google's own button docked where the banner would normally sit. */ private function render_standard_banner() { $position = isset( $this->options['banner_position'] ) ? $this->options['banner_position'] : 'bottom'; ?> options['display_on']) ? $this->options['display_on'] : 'everywhere'; // Broad context filter if ($display_on === 'posts_only' && !is_singular('post')) { return false; } if ($display_on === 'pages_only' && !is_page()) { return false; } if ($display_on === 'front_page_only' && !is_front_page()) { return false; } // Exclude specific post / page IDs $exclude_ids = $this->parse_ids(isset($this->options['exclude_post_ids']) ? $this->options['exclude_post_ids'] : ''); if (!empty($exclude_ids) && is_singular() && in_array(get_the_ID(), $exclude_ids, true)) { return false; } // Exclude categories $exclude_cats = isset($this->options['exclude_categories']) ? (array) $this->options['exclude_categories'] : array(); $exclude_cats = array_filter(array_map('intval', $exclude_cats)); if (!empty($exclude_cats) && is_singular('post') && has_category($exclude_cats)) { return false; } return true; } /** * Parse a comma-separated string of IDs into an array of integers. * * @since 1.1 * @param string $str * @return int[] */ private function parse_ids($str) { if (empty($str)) { return array(); } return array_values(array_filter(array_map('intval', explode(',', $str)))); } /** * Get processed banner text with website name replacement * * @since 1.0.0 * @return string The processed banner text. */ public function get_processed_banner_text() { $banner_text = isset($this->options['banner_text']) ? $this->options['banner_text'] : 'Add as a preferred source on Google'; $website_name = isset($this->options['website_name']) ? $this->options['website_name'] : get_bloginfo('name'); // Replace {website_name} placeholder with actual website name $processed_text = str_replace('{website_name}', $website_name, $banner_text); return $processed_text; } /** * Add the inline button to the content. * * @since 1.0.0 * @param string $content The content. * @return string The content with the inline button appended/prepended. */ public function add_inline_button_to_content($content) { // Skip excerpt/meta-description generation (wp_trim_excerpt(), SEO plugins) so the banner text isn't baked in if (doing_filter('get_the_excerpt')) { return $content; } // Enabled + singular view of a selected post type, and main query only if (!$this->inline_should_load() || !is_main_query()) { return $content; } // The post already places the button by hand — don't add a second one. if ( ! empty( $this->options['shortcode_skip_auto_inline'] ) && $this->shortcode_should_load() ) { return $content; } return $this->inject_by_position( $content, $this->render_button( array( 'context' => 'inline' ) ) ); } /** * Resolves one placement value: an explicit argument wins, then the saved * setting for this context, then (for manual placements) the inline * setting, then the built-in fallback. * * isset() rather than a truthiness check on purpose — once the settings * page has been saved the key always exists, so an intentionally emptied * subtitle stays empty instead of springing back to the default. * * @since 1.3.0 * @param string $context 'inline' for the automatic placement, anything else for a manual one. * @param string $key Key without its prefix, e.g. 'text'. * @param mixed $value Explicit value, or null when not supplied. * @return mixed */ private function placement_value( $context, $key, $value = null ) { // An explicitly empty attribute (subtitle="") is intentional — only // null means "not supplied". if ( null !== $value ) { return $value; } $keys = ( 'inline' === $context ) ? array( 'inline_' . $key ) : array( 'shortcode_' . $key, 'inline_' . $key ); foreach ( $keys as $option_key ) { if ( isset( $this->options[ $option_key ] ) ) { return $this->options[ $option_key ]; } } $fallbacks = self::FALLBACKS; return isset( $fallbacks[ $key ] ) ? $fallbacks[ $key ] : ''; } /** * Builds the class attribute for the button container. * * @since 1.3.0 * @param string $context 'inline', 'shortcode', 'block' or 'template'. * @param string $align Resolved alignment, or '' to add no alignment class. * @param string $class Extra classes supplied by the caller. * @return string */ private function container_classes( $context, $align, $class = '' ) { $classes = array( 'adasprso-inline-container' ); if ( 'inline' !== $context ) { $classes[] = 'adasprso-manual-container'; } if ( in_array( $align, self::ALIGNMENTS, true ) ) { $classes[] = 'adasprso-align-' . $align; } foreach ( preg_split( '/\s+/', (string) $class, -1, PREG_SPLIT_NO_EMPTY ) as $extra ) { $sanitized = sanitize_html_class( $extra ); if ( $sanitized ) { $classes[] = $sanitized; } } return implode( ' ', array_unique( $classes ) ); } /** * Renders the button. The single source of markup for every placement — * the automatic inline button, the shortcode, the block, and the template * tag — so they all behave identically. * * @since 1.3.0 * @param array $args { * Optional. Any value left null falls back to the saved settings. * * @type string $context 'inline', 'shortcode', 'block' or 'template'. * @type string $style default|simple|strip|pill|link|boxed. * @type string $text Title / button text. * @type string $subtitle Subtitle (default and boxed styles). * @type string $link_prefix Text before the link (link style). * @type string $link_suffix Text after the link (link style). * @type string $bg_color Background color hex. * @type string $text_color Text color hex. * @type string $align left|center|right. * @type string $class Extra CSS classes for the container. * } * @return string The button HTML. */ public function render_button( $args = array() ) { $args = array_merge( array( 'context' => 'shortcode', 'style' => null, 'text' => null, 'subtitle' => null, 'link_prefix' => null, 'link_suffix' => null, 'bg_color' => null, 'text_color' => null, 'align' => null, 'class' => '', ), (array) $args ); $context = $args['context']; // The automatic inline placement keeps its historic full-width markup; // only manual placements get an alignment class. $align = ''; if ( 'inline' !== $context ) { $align = $this->placement_value( $context, 'align', $args['align'] ); $align = in_array( $align, self::ALIGNMENTS, true ) ? $align : self::FALLBACKS['align']; } $adasprso_container_class = $this->container_classes( $context, $align, $args['class'] ); $this->ensure_assets(); if ( 'standard' === $this->get_implementation_method() ) { return '