Source: php/ui/class-component.php

<?php
/**
 * Abstract UI Component.
 *
 * @package Cloudinary
 */

namespace Cloudinary\UI;

use Cloudinary\Settings\Setting;
use Cloudinary\UI\State;
use function Cloudinary\get_plugin_instance;

/**
 * Abstract Component.
 *
 * @package Cloudinary\UI
 */
abstract class Component {

	/**
	 * Holds the components type.
	 *
	 * @var string
	 */
	protected $type;

	/**
	 * Holds the parent setting for this component.
	 *
	 * @var Setting
	 */
	protected $setting;

	/**
	 * Holds the components build parts.
	 *
	 * @var array
	 */
	protected $build_parts;

	/**
	 * Holds the components build blueprint.
	 *
	 * @var string
	 */
	protected $blueprint = 'wrap|header|icon/|title/|collapse/|/header|body|clear/|/body|settings/|/wrap';

	/**
	 * Holds a list of the Components used parts.
	 *
	 * @var array
	 */
	protected $used_parts;
	/**
	 * Holds the components built HTML parts.
	 *
	 * @var array
	 */
	protected $html = array();

	/**
	 * Flag if component is a capture type.
	 *
	 * @var bool
	 */
	protected static $capture = false;

	/**
	 * Holds the conditional logic sequence.
	 *
	 * @var array
	 */
	protected static $condition = array();

	/**
	 * Holds the UI state.
	 *
	 * @var State
	 */
	protected $state;

	/**
	 * Render component for a setting.
	 * Component constructor.
	 *
	 * @param Setting $setting The parent Setting.
	 */
	public function __construct( $setting ) {
		$this->setting = $setting;
		$this->state   = get_plugin_instance()->get_component( 'state' );
		$class         = strtolower( get_class( $this ) );
		$class_name    = substr( strrchr( $class, '\\' ), 1 );
		$this->type    = str_replace( '_', '-', $class_name );

		// Setup blueprint.
		$this->blueprint = $this->setting->get_param( 'blueprint', $this->blueprint );

		// Setup the components parts for render.
		$this->setup_component_parts();

		// Add scripts.
		$this->enqueue_scripts();
	}

	/**
	 * Setup the component.
	 */
	public function setup() {
		$this->setup_conditions();
	}

	/**
	 * Setup the conditions.
	 */
	public function setup_conditions() {
		// Setup conditional logic.
		if ( $this->setting->has_param( 'condition' ) ) {
			$condition = $this->setting->get_param( 'condition' );
			foreach ( $condition as $slug => $value ) {
				$bound = $this->setting->get_root_setting()->get_setting( $slug, false );
				if ( ! is_null( $bound ) ) {
					$path = array(
						'attributes',
						'input',
						'data-bind-trigger',
					);
					$bound->set_param( implode( $this->setting->separator, $path ), $bound->get_slug() );
				} else {
					$this->setting->set_param( 'condition', null );
				}
			}
		}
	}

	/**
	 * Enqueue scripts this component may use.
	 */
	public function enqueue_scripts() {
	}

	/**
	 * Magic caller to filter component parts dynamically.
	 *
	 * @param string $name The part name.
	 * @param array  $args array of args to pass to the filter.
	 *
	 * @return mixed
	 */
	public function __call( $name, $args ) {
		if ( empty( $args ) ) {
			return null;
		}
		$struct = $args[0];
		if ( $this->setting->has_param( $name ) ) {
			$struct['content'] = $this->setting->get_param( $name );
		}

		// Apply type to each structs.
		$struct['attributes']['class'][] = 'cld-' . $this->type;

		return $struct;
	}

	/**
	 * Setup the components build parts.
	 */
	protected function setup_component_parts() {

		$default_input_atts = array(
			'type'  => $this->type,
			'class' => array(),
		);
		$input_atts         = $this->setting->get_param(
			'attributes',
			array()
		);

		$build_parts = array(
			'wrap'        => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(),
				),
			),
			'header'      => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(),
				),
			),
			'icon'        => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(),
				),
			),
			'title'       => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(),
				),
			),
			'collapse'    => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(),
				),
			),
			'tooltip'     => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(),
				),
			),
			'body'        => array(
				'element'    => 'p',
				'attributes' => array(
					'class' => array(),
				),
			),
			'clear'       => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(
						'clear',
					),
				),
			),
			'input'       => array(
				'element'    => 'input',
				'render'     => 'true',
				'attributes' => wp_parse_args( $input_atts, $default_input_atts ),
			),
			'settings'    => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(),
				),
			),
			'prefix'      => array(
				'element'    => 'span',
				'attributes' => array(
					'class' => array(),
				),
			),
			'suffix'      => array(
				'element'    => 'span',
				'attributes' => array(
					'class' => array(),
				),
			),
			'description' => array(
				'element'    => 'div',
				'attributes' => array(
					'class' => array(),
				),
			),
			'conditional' => array(
				'element'    => 'div',
				'attributes' => array(
					'data-condition' => wp_json_encode( $this->setting->get_param( 'condition', array() ) ),
				),
			),
		);

		/**
		 * Filter the components build parts.
		 *
		 * @hook cloudinary_setup_component_parts
		 *
		 * @param $build_parts {array} The build parts.
		 * @param $this        {self}  The component object.
		 *
		 * @return {array}
		 */
		$structs = apply_filters( 'cloudinary_setup_component_parts', $build_parts, $this );
		foreach ( $structs as $name => $struct ) {
			$struct['attributes']['class'][] = 'cld-ui-' . $name;
			$this->register_component_part( $name, $struct );
		}
	}

	/**
	 * Registers a new component part type.
	 *
	 * @param string $name   Name for the part type.
	 * @param array  $struct The array structure for the part type.
	 */
	public function register_component_part( $name, $struct ) {
		$base                       = array(
			'element'    => 'div',
			'attributes' => array(),
			'children'   => array(),
			'content'    => null,
		);
		$this->build_parts[ $name ] = wp_parse_args( $struct, $base );
	}

	/**
	 * Sanitize the value.
	 *
	 * @param string $value The value to sanitize.
	 *
	 * @return string
	 */
	public function sanitize_value( $value ) {
		return wp_kses_post( $value );
	}

	/**
	 * Check if component is enabled.
	 *
	 * @return bool
	 */
	protected function is_enabled() {
		$enabled = $this->setting->get_param( 'enabled', true );
		if ( is_callable( $enabled ) ) {
			$enabled = call_user_func( $enabled, $this );
		}

		return $enabled;
	}

	/**
	 * Renders the component.
	 *
	 * @param bool $echo Flag to echo output or return it.
	 *
	 * @return string
	 */
	public function render( $echo = false ) {
		// Setup the component.
		$this->pre_render();

		// Check if component is enabled.
		$enabled = $this->is_enabled();
		if ( false === $enabled ) {
			return null;
		}
		// Build the blueprint parts list.
		$blueprint = $this->setting->get_param( 'blueprint', $this->blueprint );
		if ( empty( $blueprint ) ) {
			return null;
		}
		$build_parts = explode( '|', $blueprint );

		// Build the multi-dimensional array.
		$struct = $this->build_struct( $build_parts );
		$this->compile_structures( $struct );

		// Output html.
		$return = self::compile_html( $this->html );
		if ( false === $echo ) {
			return $return;
		}
		echo $return; // phpcs:ignore WordPress.Security.EscapeOutput
	}

	/**
	 * Build the structures from the build parts.
	 *
	 * @param array $parts Array of build parts to build.
	 *
	 * @return array
	 */
	protected function build_struct( &$parts ) {

		$struct = array();
		while ( ! empty( $parts ) ) {
			$part  = array_shift( $parts );
			$state = $this->get_state( $part );
			if ( 'close' === $state ) {
				return $struct;
			}
			$name                 = trim( $part, '/' );
			$part_struct          = $this->get_part( $name );
			$part_struct['state'] = $state;
			$part_struct['name']  = $name;
			$struct[ $name ]      = $this->{$name}( $part_struct );
			// Prepare struct array.
			$this->prepare_struct_array( $struct, $parts, $name );
		}

		return $struct;
	}

	/**
	 * Prepared struct for children and multiple element building.
	 *
	 * @param array  $struct The structure array.
	 * @param array  $parts  The parts of the component.
	 * @param string $name   The component part name.
	 */
	protected function prepare_struct_array( &$struct, &$parts, $name ) {
		if ( ! isset( $struct[ $name ] ) ) {
			return; // Bail if struct is missing.
		}
		if ( $this->is_struct_array( $struct[ $name ] ) ) {
			$base_struct = $struct[ $name ];
			unset( $struct[ $name ] );
			foreach ( $base_struct as $index => $struct_instance ) {
				$struct_name            = $struct_instance['name'] . '_inst_' . $index;
				$struct[ $struct_name ] = $struct_instance;
				$this->prepare_struct_array( $struct, $parts, $struct_name );
			}

			return;
		}
		// Build children.
		if ( 'open' === $struct[ $name ]['state'] ) {
			$struct[ $name ]['children'] += $this->build_struct( $parts );
		}
	}

	/**
	 * Check if the structure is an array of structures.
	 *
	 * @param array $struct The structure to check.
	 *
	 * @return bool
	 */
	protected function is_struct_array( $struct ) {
		return is_array( $struct ) && ! isset( $struct['state'] ) && isset( $struct[0] );
	}

	/**
	 * Go through the structures and compile.
	 *
	 * @param array $structure The components structures.
	 */
	protected function compile_structures( $structure ) {
		foreach ( $structure as $struct ) {
			$this->handle_structure( $struct['name'], $struct );
		}
	}

	/**
	 * Get a blueprint parts state.
	 *
	 * @param string $part The part name.
	 *
	 * @return string
	 */
	public function get_state( $part ) {
		$state = 'open';
		$pos   = strpos( $part, '/' );
		if ( is_int( $pos ) ) {
			switch ( $pos ) {
				case 0:
					$state = 'close';
					break;
				default:
					$state = 'void';
			}
		}

		return $state;
	}

	/**
	 * Get the components value.
	 *
	 * @return mixed
	 */
	public function get_value() {
		return $this->setting->get_value();
	}

	/**
	 * Handles a structure part before rendering.
	 *
	 * @param string $name   The name of the part.
	 * @param array  $struct The parts structure.
	 */
	public function handle_structure( $name, $struct ) {
		if ( $this->has_content( $name, $struct ) ) {
			if ( ! empty( $struct['element'] ) && $this->setting->has_param( 'condition' ) ) {
				$struct = $this->conditional( $struct );
				$this->setting->set_param( 'condition', null );
			}
			$this->compile_part( $struct );
		}
	}

	/**
	 * Recursively check if the current structure has content.
	 *
	 * @param string | null $name   The name of the part.
	 * @param array         $struct The part structure.
	 *
	 * @return bool
	 */
	public function has_content( $name, $struct = array() ) {
		$return = isset( $struct['content'] ) || ! empty( $this->setting->get_param( $name ) ) || ! empty( $struct['render'] );
		if ( false === $return && ! empty( $struct['children'] ) ) {
			foreach ( $struct['children'] as $child => $child_struct ) {
				if ( true === $this->has_content( $child, $child_struct ) ) {
					$return = true;
					break;
				}
			}
		}

		return $return;
	}

	/**
	 * Build a component part.
	 *
	 * @param array $struct The component part structure array.
	 */
	public function compile_part( $struct ) {
		$this->open_tag( $struct );
		if ( ! self::is_void_element( $struct['element'] ) ) {
			$this->add_content( $struct['content'] );
			if ( ! empty( $struct['children'] ) ) {
				foreach ( $struct['children'] as $child ) {
					if ( ! is_null( $child ) ) {
						$this->handle_structure( $child['name'], $child );
					}
				}
			}
			$this->close_tag( $struct );
		}
	}

	/**
	 * Opens a new tag.
	 *
	 * @param array $struct The tag structure.
	 */
	protected function open_tag( $struct ) {
		if ( ! empty( $struct['element'] ) ) {
			$this->html[] = self::build_tag( $struct['element'], $struct['attributes'] );
		}
	}

	/**
	 * Closes an open tag.
	 *
	 * @param array $struct The tag structure.
	 */
	protected function close_tag( $struct ) {
		if ( ! empty( $struct['element'] ) ) {
			$this->html[] = self::build_tag( $struct['element'], $struct['attributes'], 'close' );
		}
	}

	/**
	 * Adds the content to the html.
	 *
	 * @param string $content The content to add.
	 */
	protected function add_content( $content ) {

		if ( ! is_string( $content ) && is_callable( $content ) ) {
			$this->html[] = call_user_func( $content );
		} else {
			$this->html[] = $content;
		}
	}

	/**
	 * Check if an element type is a void elements.
	 *
	 * @param string $element The element to check.
	 *
	 * @return bool
	 */
	public static function is_void_element( $element ) {
		$void_elements = array(
			'area',
			'base',
			'br',
			'col',
			'embed',
			'hr',
			'img',
			'input',
			'link',
			'meta',
			'param',
			'source',
			'track',
			'wbr',
		);

		return ! empty( $element ) && in_array( strtolower( $element ), $void_elements, true );
	}

	/**
	 * Build an HTML tag.
	 *
	 * @param string $element    The element to build.
	 * @param array  $attributes The attributes for the tags.
	 * @param string $state      The element state.
	 *
	 * @return string
	 */
	public static function build_tag( $element, $attributes = array(), $state = 'open' ) {

		$prefix_element = 'close' === $state ? '/' : '';
		$tag            = array();
		$tag[]          = $prefix_element . $element;
		if ( 'close' !== $state ) {
			$tag[] = self::build_attributes( $attributes );
		}
		$tag[] = self::is_void_element( $element ) ? '/' : null;

		return self::compile_tag( $tag );
	}

	/**
	 * Get a build part to construct.
	 *
	 * @param string $part The part name.
	 *
	 * @return array
	 */
	public function get_part( $part ) {
		$struct = array(
			'element'    => $part,
			'attributes' => array(),
			'children'   => array(),
			'state'      => null,
			'content'    => null,
			'name'       => $part,
		);
		if ( isset( $this->build_parts[ $part ] ) ) {
			$struct = wp_parse_args( $this->build_parts[ $part ], $struct );
		}
		if ( $this->setting->has_param( 'attributes' . $this->setting->separator . $part ) ) {
			$struct['attributes'] = wp_parse_args( $this->setting->get_param( 'attributes' . $this->setting->separator . $part ), $struct['attributes'] );
		}

		return $struct;
	}

	/**
	 * Filter the title parts structure.
	 *
	 * @param array $struct The array structure.
	 *
	 * @return array
	 */
	protected function title( $struct ) {
		$struct['content'] = $this->setting->get_param( 'title', $this->setting->get_param( 'page_title' ) );

		return $struct;
	}

	/**
	 * Filter the tooltip parts structure.
	 *
	 * @param array $struct The array structure.
	 *
	 * @return array
	 */
	protected function tooltip( $struct ) {
		$struct['content'] = null;
		if ( $this->setting->has_param( 'tooltip_text' ) ) {
			$struct['render']              = true;
			$struct['attributes']['class'] = array(
				'cld-tooltip',
			);
			$struct['content']             = $this->setting->get_param( 'tooltip_text' );
		}

		return $struct;
	}

	/**
	 * Filter the icon parts structure.
	 *
	 * @param array $struct The array structure.
	 *
	 * @return array
	 */
	protected function icon( $struct ) {

		$icon   = $this->setting->get_param( 'icon' );
		$method = 'dashicon';
		if ( ! empty( $icon ) && false === strpos( $icon, 'dashicons' ) ) {
			$method = 'image_icon';
		}

		return $this->$method( $struct );
	}

	/**
	 * Filter the dashicon parts structure.
	 *
	 * @param array  $struct The array structure.
	 * @param string $icon   The dashicon slug.
	 *
	 * @return array
	 */
	protected function dashicon( $struct, $icon = 'dashicons-yes-alt' ) {
		$struct['element']               = 'span';
		$struct['attributes']['class'][] = 'dashicons';
		$struct['attributes']['class'][] = $icon;

		return $struct;
	}

	/**
	 * Filter the image icons parts structure.
	 *
	 * @param array $struct The array structure.
	 *
	 * @return array
	 */
	protected function image_icon( $struct ) {
		$struct['element']           = 'img';
		$struct['attributes']['src'] = $this->setting->get_param( 'icon' );

		return $struct;
	}

	/**
	 * Filter the settings parts structure.
	 *
	 * @param array $struct The array structure.
	 *
	 * @return array
	 */
	protected function settings( $struct ) {
		$struct['element'] = null;
		if ( $this->setting->has_settings() ) {
			$html = array();
			foreach ( $this->setting->get_settings() as $setting ) {
				$html[] = $setting->get_component()->render();
			}
			$struct['content'] = self::compile_html( $html );
		}

		return $struct;
	}

	/**
	 * Builds and sanitizes attributes for an HTML tag.
	 *
	 * @param array $attributes Array of key value attributes to build.
	 *
	 * @return string
	 */
	public static function build_attributes( $attributes ) {
		$return = array();
		foreach ( $attributes as $attribute => $value ) {
			if ( is_numeric( $attribute ) ) {
				$return[] = esc_attr( $value );
				continue;
			}
			if ( is_array( $value ) ) {
				if ( count( $value ) !== count( $value, COUNT_RECURSIVE ) ) {
					$value = wp_json_encode( $value );
				} else {
					$value = implode( ' ', $value );
				}
			}
			if ( ! is_string( $value ) && is_callable( $value ) ) {
				$value = call_user_func( $value );
			}
			$return[] = esc_attr( $attribute ) . '="' . esc_attr( $value ) . '"';
		}

		return implode( ' ', $return );
	}

	/**
	 * Compiles HTML parts array into a string.
	 *
	 * @param array $html HTML parts array.
	 *
	 * @return string
	 */
	public static function compile_html( $html ) {
		$html = array_filter(
			$html,
			function ( $item ) {
				return ! is_null( $item );
			}
		);

		return implode( '', $html );
	}

	/**
	 * Compiles a tag from a parts array into a string.
	 *
	 * @param array $tag Tag parts array.
	 *
	 * @return string
	 */
	public static function compile_tag( $tag ) {
		$tag = array_filter( $tag );

		return '<' . implode( ' ', $tag ) . '>';
	}

	/**
	 * Init the component.
	 *
	 * @param Setting $setting The setting object.
	 *
	 * @return self
	 */
	final public static function init( $setting ) {

		$caller = get_called_class();
		$type   = $setting->get_param( 'type', 'tag' );
		// Final check if type is callable component.
		if ( ! is_string( $type ) || ! self::is_component_type( $type ) ) {
			// Check what type this component needs to be.
			if ( is_callable( $type ) ) {
				$setting->set_param( 'callback', $type );
				$setting->set_param( 'type', 'custom' );
				$type = 'custom';
			} else {
				// Set to a default HTML component if not found.
				$type = 'html';
			}
			$component = "{$caller}\\{$type}";
		} else {
			// Set Caller.
			$component = "{$caller}\\{$type}";
		}
		$component = new $component( $setting );
		$component->setup();

		return $component;
	}

	/**
	 * Check if the type is a component.
	 *
	 * @param string $type The type to check.
	 *
	 * @return bool
	 */
	public static function is_component_type( $type ) {
		$caller = get_called_class();

		// Check that this type of component exists.
		return is_callable( array( $caller . '\\' . $type, 'init' ) );
	}

	/**
	 * Filter the conditional struct.
	 *
	 * @param array $struct The struct array.
	 *
	 * @return array
	 */
	protected function conditional( $struct ) {

		if ( $this->setting->has_param( 'condition' ) ) {
			$conditions     = $this->setting->get_param( 'condition' );
			$results        = array();
			$class          = 'open';
			$condition_data = array();
			foreach ( $conditions as $slug => $value ) {
				$setting                                = $this->setting->find_setting( $slug );
				$compare_value                          = $setting->get_value();
				$results[]                              = $value === $compare_value;
				$condition_data[ $setting->get_slug() ] = $value;
			}
			$struct['attributes']['class'][] = 'cld-ui-conditional';

			if ( in_array( false, $results, true ) ) {
				$class = 'closed';
			}
			$struct['attributes']['class'][]        = $class;
			$struct['attributes']['data-condition'] = wp_json_encode( $condition_data );
		}

		return $struct;
	}

	/**
	 * Filter the body struct.
	 *
	 * @param array $struct The struct array.
	 *
	 * @return array
	 */
	protected function body( $struct ) {
		$struct['content'] = $this->setting->get_param( 'content' );

		return $struct;
	}

	/**
	 * Setup action before rendering.
	 */
	protected function pre_render() {
	}

	/**
	 * Check if this is a capture component.
	 *
	 * @return bool
	 */
	final public static function is_capture() {
		$caller = get_called_class();

		// Check that this type of component exists.
		return $caller::$capture;
	}
}