', $deferred ? 'data-src' : 'src', esc_url( $src ), esc_attr( $title ), $size['fit'] ? self::HEIGHT : (int) $size['height'], $deferred ? '' : ' loading="lazy"', esc_attr( $style ) ); } /** * The size of a widget in a post: the site's Size settings, with a block's or shortcode's own * choices on top. * * @param array $overrides From block_overrides() or shortcode_overrides(). * @return array{fit: bool, height: int, max_width: int, align: string} */ public static function size( array $overrides = array() ) { $settings = Asklet_Settings::get(); return array_merge( array( 'fit' => 'fixed' !== $settings['height_mode'], 'height' => $settings['height'], 'max_width' => $settings['max_width'], 'align' => $settings['align'], ), array_intersect_key( $overrides, array_flip( array( 'fit', 'height', 'max_width', 'align' ) ) ) ); } /** * The `[asklet]` attributes: `height` ("fit" or px), `width` ("full" or px) and `align` * ("left" or "center"). Unknown or invalid values are ignored, so the site setting applies. * * @param array|string $atts Shortcode attributes. * @return array Size overrides. */ public static function shortcode_overrides( $atts ) { $atts = shortcode_atts( array( 'height' => '', 'width' => '', 'align' => '', ), is_array( $atts ) ? $atts : array(), 'asklet' ); $overrides = array(); $height = strtolower( trim( preg_replace( '/px$/i', '', trim( (string) $atts['height'] ) ) ) ); if ( 'fit' === $height ) { $overrides['fit'] = true; } elseif ( is_numeric( $height ) ) { $overrides['fit'] = false; $overrides['height'] = Asklet_Settings::clamp_height( $height ); } $width = strtolower( trim( preg_replace( '/px$/i', '', trim( (string) $atts['width'] ) ) ) ); if ( 'full' === $width ) { $overrides['max_width'] = 0; } elseif ( is_numeric( $width ) ) { $overrides['max_width'] = Asklet_Settings::clamp_width( $width ); } $align = strtolower( trim( (string) $atts['align'] ) ); if ( 'left' === $align || 'center' === $align ) { $overrides['align'] = $align; } return $overrides; } /** * The block's Size settings (`heightMode`, `height`, `widthMode`, `maxWidth`, `alignment`). * Empty modes follow the site setting. * * @param array $attributes Block attributes. * @return array Size overrides. */ public static function block_overrides( $attributes ) { $attributes = is_array( $attributes ) ? $attributes : array(); $overrides = array(); $height_mode = isset( $attributes['heightMode'] ) ? $attributes['heightMode'] : ''; if ( 'fit' === $height_mode ) { $overrides['fit'] = true; } elseif ( 'fixed' === $height_mode ) { $overrides['fit'] = false; $overrides['height'] = Asklet_Settings::clamp_height( isset( $attributes['height'] ) ? $attributes['height'] : null ); } $width_mode = isset( $attributes['widthMode'] ) ? $attributes['widthMode'] : ''; if ( 'full' === $width_mode ) { $overrides['max_width'] = 0; } elseif ( 'max' === $width_mode ) { $overrides['max_width'] = Asklet_Settings::clamp_width( isset( $attributes['maxWidth'] ) ? $attributes['maxWidth'] : null ); $overrides['align'] = isset( $attributes['alignment'] ) && 'left' === $attributes['alignment'] ? 'left' : 'center'; } return $overrides; } /** * The markup the widget may contain, for wp_kses() at output: the iframe, plus a wrapper that * the `asklet_widget_html` filter may add. * * @return array> */ public static function allowed_html() { $wrapper = array( 'class' => true, 'id' => true, 'style' => true, ); return array( 'iframe' => array( 'class' => true, 'src' => true, 'data-src' => true, 'title' => true, 'width' => true, 'height' => true, 'loading' => true, 'referrerpolicy' => true, 'style' => true, ), 'div' => $wrapper, 'span' => $wrapper, ); } /** * Escapes widget markup at output: wp_kses() with allowed_html(). It also lets `display` * through inline styles, which WordPress 6.4's safe CSS list lacks: without it the iframe * falls back to inline display there, which leaves a gap under it and breaks centring. * * @param string $html Markup. * @return string */ public static function kses( $html ) { $allow_display = static function ( $properties ) { $properties[] = 'display'; return $properties; }; add_filter( 'safe_style_css', $allow_display ); $html = wp_kses( $html, self::allowed_html() ); remove_filter( 'safe_style_css', $allow_display ); return $html; } /** * The inline widget, or '' when there is no site key. Already passed through kses(). In * fit mode it loads widget.js, which sizes the iframe to the survey; a fixed height needs no * script. * * @param array $overrides Size overrides from a block or shortcode. * @return string */ public static function render( array $overrides = array() ) { $size = self::size( $overrides ); $html = self::iframe( false, $size ); if ( '' === $html ) { return ''; } if ( $size['fit'] ) { self::enqueue_script(); } /** * Filters the inline widget markup before it is output. Only the tags and attributes in * Asklet_Embed::allowed_html() survive. * * @param string $html The iframe. * @param array $settings The plugin settings. * @param array $size This widget's size: fit, height, max_width, align. */ $html = (string) apply_filters( 'asklet_widget_html', $html, Asklet_Settings::get(), $size ); return self::kses( $html ); } /** * `[asklet]` shortcode, with optional `height`, `width` and `align` (shortcode_overrides()). * * @param array|string $atts Shortcode attributes. * @return string */ public static function shortcode( $atts = array() ) { return self::can_place() ? self::render( self::shortcode_overrides( $atts ) ) : ''; } /** * Whether a block or shortcode placed by hand should output the widget. Not in feeds, and not * inside a post shown in a list (home, archives, search): there the widget would read the * list's address, not the post's, and file the answers under the wrong path. Templates and * widget areas outside the post loop are fine. * * @return bool */ public static function can_place() { if ( is_feed() || self::is_rest() ) { return false; } return ! ( in_the_loop() && ! is_singular() ); } /** * Whether this is a REST API request (or another JSON one), where post content is rendered * for some other page or app. wp_is_json_request() alone misses REST calls that don't send a * JSON Accept header. * * @return bool */ private static function is_rest() { // wp_is_rest_endpoint() is WordPress 6.5+; 6.4 only has the constant. if ( function_exists( 'wp_is_rest_endpoint' ) ) { if ( wp_is_rest_endpoint() ) { return true; } } elseif ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return true; } return wp_is_json_request(); } /** * Registers the block from block.json (server-rendered; the editor shows a placeholder). */ public static function register_block() { register_block_type( ASKLET_PLUGIN_DIR . 'blocks/feedback' ); } /** * How this page shows the widget automatically: `after`, `before`, `panel`, `slidein` or * `none`. Only single views get one. The post's own choice wins over the site default; the * site default applies only to the enabled post types. A post that places the block or * shortcode gets nothing automatic, so a page never shows two widgets. * * @return string */ public static function page_display() { if ( null === self::$page_display ) { self::$page_display = self::resolve_page_display(); } return self::$page_display; } /** * Works out page_display(). * * @return string */ private static function resolve_page_display() { if ( is_admin() || is_feed() || is_embed() || self::is_rest() || ! is_singular() ) { return 'none'; } $settings = Asklet_Settings::get(); $post = get_queried_object(); if ( '' === $settings['site_key'] || ! $post instanceof WP_Post ) { return 'none'; } if ( has_shortcode( $post->post_content, 'asklet' ) || has_block( 'asklet/feedback', $post ) ) { return 'none'; } $own = Asklet_Post_Display::get( $post->ID ); if ( 'hidden' === $own ) { return 'none'; } if ( '' !== $own ) { return $own; } if ( 'manual' === $settings['display'] || ! in_array( $post->post_type, $settings['post_types'], true ) ) { return 'none'; } return $settings['display']; } /** * Adds the inline widget before or after the main post's content, or the end-of-article marker * that triggers the slide-in. * * @param string $content Post content. * @return string */ public static function filter_content( $content ) { if ( ! self::is_main_post_content() ) { return $content; } switch ( self::page_display() ) { case 'after': return $content . self::render(); case 'before': return self::render() . $content; case 'slidein': return $content . ''; default: return $content; } } /** * Whether this `the_content` call is the main post of the page, not an excerpt, a related-posts * list or an SEO plugin reading content in . * * @return bool */ private static function is_main_post_content() { if ( doing_filter( 'get_the_excerpt' ) || ! in_the_loop() || ! is_main_query() ) { return false; } $post_id = get_the_ID(); return $post_id && get_queried_object_id() === $post_id; } /** * Loads widget.js and the stylesheet on pages with the side panel or slide-in. Inline, the * block and the shortcode load the script themselves when they render in fit mode (render()). */ public static function enqueue_assets() { if ( ! in_array( self::page_display(), array( 'panel', 'slidein' ), true ) ) { return; } self::enqueue_script(); wp_enqueue_style( 'asklet-widget', plugin_dir_url( ASKLET_PLUGIN_FILE ) . 'assets/widget.css', array(), ASKLET_VERSION ); } /** * Loads widget.js (deferred, in the footer): it sizes every widget to its survey, and runs * the panel and the slide-in. Safe to call from content rendering, before the footer prints. */ private static function enqueue_script() { wp_enqueue_script( 'asklet-widget', plugin_dir_url( ASKLET_PLUGIN_FILE ) . 'assets/widget.js', array(), ASKLET_VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); } /** * Outputs the side panel or the slide-in card at the end of the page. */ public static function render_floating() { $display = self::page_display(); if ( 'panel' !== $display && 'slidein' !== $display ) { return; } // The panel and the slide-in always fit the survey, at their own width. $iframe = self::iframe( true, array( 'fit' => true, 'height' => self::HEIGHT, 'max_width' => 0, 'align' => 'center', ) ); if ( '' === $iframe ) { return; } $settings = Asklet_Settings::get(); $label = '' !== $settings['tab_label'] ? $settings['tab_label'] : __( 'Feedback', 'asklet' ); $style = '--asklet-accent:' . $settings['tab_color']; if ( 'panel' === $display ) { printf( '' . '', esc_attr( $style ), esc_html( $label ), esc_attr__( 'Close feedback', 'asklet' ), self::kses( $iframe ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_kses() in kses(). ); return; } printf( '', esc_attr( $label ), esc_attr( $style ), esc_attr__( 'Dismiss feedback', 'asklet' ), self::kses( $iframe ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_kses() in kses(). ); } }