, and elements for * blocks and shortcodes. * * Every element starts with native `hidden`, so nothing flashes before the * widget upgrades it, and nothing shows when no one can answer. The card's * action, visitor-message and dismiss controls are always direct children of * , as the widget requires. */ final class Markup { const SHOW_WHEN = array( 'either', 'rep', 'standin' ); const CARD_THEMES = array( 'outlined', 'elevated', 'horizontal', 'cover-media', 'stretch-media', 'divided-footer' ); const CARD_IDENTITY = array( 'avatar', 'name', 'title' ); /** * . * * @param array $args label, showWhen, avatar, greeting, prompt, analyticsId, buttonBackground, buttonText, buttonRadius. * @param array $wrapper Extra attributes for the element (class, style, id). */ public static function button( array $args, array $wrapper = array() ): string { $label = trim( (string) ( $args['label'] ?? '' ) ); if ( '' === $label ) { $label = __( 'Chat with us', 'stand-chat' ); } $style = array(); $classes = array(); $bg = sanitize_hex_color( (string) ( $args['buttonBackground'] ?? '' ) ); $text = sanitize_hex_color( (string) ( $args['buttonText'] ?? '' ) ); if ( $bg ) { $style[] = '--stand-chat-button-background:' . $bg; $classes[] = 'stand-chat-button--background'; } if ( $text ) { $style[] = '--stand-chat-button-color:' . $text; $classes[] = 'stand-chat-button--color'; } if ( isset( $args['buttonRadius'] ) && is_numeric( $args['buttonRadius'] ) ) { $style[] = '--stand-chat-button-radius:' . min( 999, max( 0, (int) $args['buttonRadius'] ) ) . 'px'; $classes[] = 'stand-chat-button--radius'; } $attributes = self::merge_wrapper( $wrapper, $classes, $style, array( 'show-when' => self::show_when( $args['showWhen'] ?? 'either' ), 'avatar' => ! empty( $args['avatar'] ), 'message' => self::text( $args['greeting'] ?? '', 500 ), 'prompt' => self::text( $args['prompt'] ?? '', 2000 ), 'analytics-id' => self::text( $args['analyticsId'] ?? '', 120 ), ) ); return '' . esc_html( $label ) . ''; } /** * . * * @param array $args showWhen, greeting, prompt, analyticsId, identity[], theme[], spotlight, * actionType (button|card|none), actionLabel, messageMode (none|fixed|field), * visitorMessage, fieldLabel, fieldPlaceholder, footer, mediaUrl, mediaAlt, dismissLabel. * @param string $body Card body HTML (already rendered and escaped by WordPress). * @param array $wrapper Extra attributes for the element. */ public static function card( array $args, string $body, array $wrapper = array() ): string { $action_type = (string) ( $args['actionType'] ?? 'button' ); $action_type = in_array( $action_type, array( 'button', 'card', 'none' ), true ) ? $action_type : 'button'; $spotlight = ! empty( $args['spotlight'] ); $attributes = self::merge_wrapper( $wrapper, array(), array(), array( 'show-when' => self::show_when( $args['showWhen'] ?? 'either' ), 'greeting' => self::text( $args['greeting'] ?? '', 500 ), 'prompt' => self::text( $args['prompt'] ?? '', 2000 ), 'analytics-id' => self::text( $args['analyticsId'] ?? '', 120 ), 'identity' => self::tokens( $args['identity'] ?? array(), self::CARD_IDENTITY ), 'theme' => self::tokens( $args['theme'] ?? array(), self::CARD_THEMES ), 'spotlight' => $spotlight, 'dismiss-label' => $spotlight ? self::text( $args['dismissLabel'] ?? __( 'Cancel', 'stand-chat' ), 80 ) : '', 'data-stand-action' => 'card' === $action_type, ) ); $children = array(); $media = esc_url( (string) ( $args['mediaUrl'] ?? '' ) ); if ( '' !== $media ) { $children[] = '' . esc_attr( (string) ( $args['mediaAlt'] ?? '' ) ) . ''; } $children[] = $body; $message_mode = (string) ( $args['messageMode'] ?? 'none' ); $message_mode = in_array( $message_mode, array( 'none', 'fixed', 'field' ), true ) ? $message_mode : 'none'; if ( 'field' === $message_mode ) { $label = self::text( $args['fieldLabel'] ?? '', 120 ); $children[] = ''; } elseif ( 'fixed' === $message_mode && '' !== self::text( $args['visitorMessage'] ?? '', 500 ) ) { // Sent as the visitor's first message when they choose the action. $children[] = ''; } if ( 'button' === $action_type ) { $label = self::text( $args['actionLabel'] ?? '', 80 ); if ( '' === $label ) { $label = __( 'Start chat', 'stand-chat' ); } $children[] = ''; } if ( $spotlight && '' !== self::text( $args['dismissText'] ?? '', 80 ) ) { $children[] = ''; } $footer = self::text( $args['footer'] ?? '', 300 ); if ( '' !== $footer ) { $children[] = '' . esc_html( $footer ) . ''; } return '' . implode( "\n", array_filter( $children, 'strlen' ) ) . ''; } /** * . * * @param array $args greeting, placeholder, sendLabel, floating, showHeader, heightMode (capped|grow), * maxHeight, showWhen, sharedSession, prompt, analyticsId, sessionKey, label, * chatAccent, chatBackground, chatText, chatBorder, chatRadius. * @param string $intro Intro HTML for slot="intro" (already rendered by WordPress). * @param array $wrapper Extra attributes for the element. */ public static function chatbox( array $args, string $intro, array $wrapper = array() ): string { $style = array(); $colors = array( 'chatAccent' => '--stand-chatbox-accent', 'chatBackground' => '--stand-chatbox-background', 'chatText' => '--stand-chatbox-color', 'chatBorder' => '--stand-chatbox-border-color', ); foreach ( $colors as $key => $variable ) { $color = sanitize_hex_color( (string) ( $args[ $key ] ?? '' ) ); if ( $color ) { $style[] = $variable . ':' . $color; } } if ( isset( $args['chatRadius'] ) && is_numeric( $args['chatRadius'] ) ) { $style[] = '--stand-chatbox-radius:' . min( 48, max( 0, (int) $args['chatRadius'] ) ) . 'px'; } $grow = 'grow' === ( $args['heightMode'] ?? '' ); $max_height = isset( $args['maxHeight'] ) && is_numeric( $args['maxHeight'] ) && (int) $args['maxHeight'] > 0 ? min( 2000, max( 200, (int) $args['maxHeight'] ) ) . 'px' : ''; $label = self::text( $args['label'] ?? '', 120 ); $attributes = self::merge_wrapper( $wrapper, array(), $style, array( 'session-key' => self::session_key( (string) ( $args['sessionKey'] ?? '' ) ), 'greeting' => self::text( $args['greeting'] ?? '', 500 ), 'placeholder' => self::text( $args['placeholder'] ?? '', 120 ), 'send-label' => self::text( $args['sendLabel'] ?? '', 40 ), 'floating' => ! empty( $args['floating'] ), 'show-header' => ! empty( $args['showHeader'] ), 'height-mode' => $grow ? 'grow' : '', 'max-height' => $grow ? '' : $max_height, 'show-when' => self::show_when( $args['showWhen'] ?? 'either' ), 'shared-session' => ! empty( $args['sharedSession'] ), 'prompt' => self::text( $args['prompt'] ?? '', 2000 ), 'analytics-id' => self::text( $args['analyticsId'] ?? '', 120 ), 'aria-label' => '' !== $label ? $label : __( 'Chat', 'stand-chat' ), ) ); $slot = '' !== trim( $intro ) ? '
' . $intro . '
' : ''; return '' . $slot . ''; } /** * The show-when value; "either" is the widget's default and isn't printed. * * @param mixed $value Raw value. */ private static function show_when( $value ): string { $value = is_string( $value ) ? $value : 'either'; return in_array( $value, self::SHOW_WHEN, true ) && 'either' !== $value ? $value : ''; } /** * Space-separated allowed tokens, in the allowed order. * * @param mixed $values Selected tokens (array or space-separated string). * @param string[] $allowed Allowed tokens. */ private static function tokens( $values, array $allowed ): string { if ( is_string( $values ) ) { $values = preg_split( '/[\s,]+/', $values ); } $values = is_array( $values ) ? array_map( 'strval', $values ) : array(); return implode( ' ', array_values( array_intersect( $allowed, $values ) ) ); } /** * Plain text, trimmed and capped. * * @param mixed $value Raw value. * @param int $max Maximum characters. */ private static function text( $value, int $max ): string { $value = is_scalar( $value ) ? trim( wp_strip_all_tags( (string) $value ) ) : ''; return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $max ) : substr( $value, 0, $max ); } /** * A stable conversation key for an independent chatbox. * * @param string $value Raw key from the block or shortcode. */ private static function session_key( string $value ): string { $value = preg_replace( '/[^A-Za-z0-9_:-]/', '', $value ); return is_string( $value ) ? substr( $value, 0, 80 ) : ''; } /** * Combines wrapper attributes (class, style, id from blocks) with ours. * * @param array $wrapper Wrapper attributes. * @param string[] $classes Extra classes. * @param string[] $style Extra style declarations. * @param array $attributes Element attributes. * @return array */ private static function merge_wrapper( array $wrapper, array $classes, array $style, array $attributes ): array { $out = array(); if ( ! empty( $wrapper['id'] ) ) { $out['id'] = (string) $wrapper['id']; } $class = trim( ( $wrapper['class'] ?? '' ) . ' ' . implode( ' ', $classes ) ); if ( '' !== $class ) { $out['class'] = $class; } $css = trim( rtrim( (string) ( $wrapper['style'] ?? '' ), ';' ) . ( empty( $style ) ? '' : ';' . implode( ';', $style ) ), ';' ); if ( '' !== $css ) { $out['style'] = $css; } return array_merge( $out, $attributes ); } /** * Renders attributes; false and '' are omitted, true is a boolean attribute. * * @param array $attributes Attributes. */ private static function attributes( array $attributes ): string { $html = ''; foreach ( $attributes as $name => $value ) { if ( false === $value || '' === $value || null === $value ) { continue; } $html .= true === $value ? ' ' . $name : ' ' . $name . '="' . esc_attr( (string) $value ) . '"'; } return $html; } /** * Parses the attribute string WordPress returns for a block wrapper. * * @param string $html Output of get_block_wrapper_attributes(). * @return array */ public static function parse_wrapper( string $html ): array { $out = array(); if ( preg_match_all( '/([a-zA-Z_:][-a-zA-Z0-9_:.]*)="([^"]*)"/', $html, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { $out[ $match[1] ] = html_entity_decode( $match[2], ENT_QUOTES ); } } return $out; } }