[ 'id', 'class', 'style', 'title' ], 'i' => [ 'id', 'class', 'style', 'title' ], 'del' => [ 'id', 'class', 'style', 'title' ], 'strong' => [ 'id', 'class', 'style', 'title' ], 'em' => [ 'id', 'class', 'style', 'title' ], 'u' => [ 'id', 'class', 'style', 'title' ], 's' => [ 'id', 'class', 'style', 'title' ], 'sup' => [ 'id', 'class', 'style', 'title' ], 'sub' => [ 'id', 'class', 'style', 'title' ], 'a' => [ 'id', 'class', 'style', 'title', 'href', 'target', 'rel' ], 'span' => [ 'id', 'class', 'style', 'title' ], 'img' => [ 'id', 'class', 'style', 'src', 'alt', 'title', 'width', 'height', 'loading', 'decoding' ], 'br' => [], ]; public static function init(): void { add_action( 'elementor/init', [ __CLASS__, 'register_schema_adapter' ] ); // Frontend rendering. add_action( 'elementor/frontend/widget/before_render', [ __CLASS__, 'start_buffer' ] ); add_action( 'elementor/frontend/widget/after_render', [ __CLASS__, 'finish_buffer' ] ); // Elementor editor preview. add_action( 'elementor/editor/footer', [ __CLASS__, 'editor_preview_script' ], 30 ); add_action( 'wp_footer', [ __CLASS__, 'editor_preview_script' ], 30 ); } public static function register_schema_adapter(): void { if ( ! class_exists( '\\Elementor\\Modules\\AtomicWidgets\\PropTypes\\Escaped_Html_Prop_Type' ) ) { return; } require_once __DIR__ . '/class-elementor-inline-html-prop-type.php'; // Adapt the static type before Elementor adds dynamic and component unions. add_filter( 'elementor/atomic-widgets/props-schema', [ __CLASS__, 'adapt_schema' ], 5 ); } public static function adapt_schema( array $schema ): array { $tag = $schema['tag'] ?? null; if ( ! is_object( $tag ) || ! method_exists( $tag, 'get_enum' ) ) { return $schema; } $tags = $tag->get_enum(); $key = null; if ( [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ] === $tags ) { $key = 'title'; } elseif ( [ 'p', 'span' ] === $tags ) { $key = 'paragraph'; } if ( $key && ( $schema[ $key ] ?? null ) instanceof \Elementor\Modules\AtomicWidgets\PropTypes\Escaped_Html_Prop_Type ) { $schema[ $key ] = WPAddons_Inline_HTML_Prop_Type::from_prop( $schema[ $key ], 'paragraph' === $key ); } return $schema; } /** Sanitize canonical markup without interpreting entity-encoded literal tags. */ public static function sanitize_html( string $html, bool $paragraph = false ): string { $allowed = []; foreach ( self::INLINE_ATTRIBUTES as $tag => $attributes ) { $allowed[ $tag ] = array_fill_keys( $attributes, true ); } if ( $paragraph ) { foreach ( [ 'ul', 'ol', 'li', 'blockquote' ] as $tag ) { $allowed[ $tag ] = array_fill_keys( [ 'id', 'class', 'style', 'title' ], true ); } } $allow_background_clip = static function ( array $properties ): array { return array_unique( array_merge( $properties, [ 'background-clip' ] ) ); }; add_filter( 'safe_style_css', $allow_background_clip ); try { return wp_kses( $html, $allowed ); } finally { remove_filter( 'safe_style_css', $allow_background_clip ); } } /** * Start frontend output buffer. */ public static function start_buffer( $element ): void { if ( ! self::is_supported_widget( $element ) ) { return; } $GLOBALS['wpaddons_atomic_html_buffers'][ spl_object_id( $element ) ] = [ 'level' => ob_get_level() + 1, 'native_text' => self::uses_html_string_prop( $element ), ]; ob_start(); } /** * Process frontend output. */ public static function finish_buffer( $element ): void { if ( ! is_object( $element ) ) { return; } $object_id = spl_object_id( $element ); if ( empty( $GLOBALS['wpaddons_atomic_html_buffers'][ $object_id ] ) ) { return; } $buffer = $GLOBALS['wpaddons_atomic_html_buffers'][ $object_id ]; unset( $GLOBALS['wpaddons_atomic_html_buffers'][ $object_id ] ); if ( ob_get_level() !== $buffer['level'] ) { return; } $native_text = $buffer['native_text']; $html = ob_get_clean(); if ( false === $html ) { return; } if ( $native_text ) { $html = self::restore_native_content( $html, $element ); // On the frontend, encoded tags in canonical HTML remain literal text. $html = preg_replace( '/<(h[1-6]|p|span)\b/i', '<$1 data-wpaddons-native-text="1"', $html, 1 ) ?? $html; echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped return; } echo self::restore_inline_html( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } /** Replace only the text contents, retaining Elementor's root and link wrapper. */ private static function restore_native_content( string $html, $element ): string { $data = $element->get_data(); $key = 'e-paragraph' === $element->get_name() ? 'paragraph' : 'title'; $prop = $data['settings'][ $key ] ?? null; if ( ! is_array( $prop ) || ! empty( $prop['disabled'] ) || ! in_array( $prop['$$type'] ?? '', [ 'escaped-html', 'html', 'string' ], true ) || ! is_string( $prop['value'] ?? null ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) { return $html; } $content = 'string' === $prop['$$type'] ? esc_html( $prop['value'] ) : self::sanitize_html( $prop['value'], 'paragraph' === $key ); // Token offsets preserve the original wrapper bytes. A DOM parser would // reparent lists inside p roots and duplicate their already-rendered content. $processor = new class( $html ) extends WP_HTML_Tag_Processor { public function content_range(): ?array { $tag = $this->get_tag(); $this->set_bookmark( 'content-start' ); $opening = $this->bookmarks['content-start']; $start = $opening->start + $opening->length; $depth = 1; while ( $this->next_tag( [ 'tag_closers' => 'visit' ] ) ) { if ( $this->get_tag() !== $tag ) { continue; } $depth += $this->is_tag_closer() ? -1 : 1; if ( 0 === $depth ) { $this->set_bookmark( 'content-end' ); return [ $start, $this->bookmarks['content-end']->start - $start ]; } } return null; } }; $id = method_exists( $element, 'get_interaction_id' ) ? (string) $element->get_interaction_id() : null; $root_tags = 'paragraph' === $key ? [ 'P', 'SPAN' ] : [ 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ]; while ( $processor->next_tag() ) { if ( ! in_array( $processor->get_tag(), $root_tags, true ) || ( null !== $id && $processor->get_attribute( 'data-interaction-id' ) !== $id ) ) { continue; } $processor->set_bookmark( 'root' ); $link = method_exists( $element, 'get_atomic_setting' ) ? $element->get_atomic_setting( 'link' ) : null; if ( empty( $link['href'] ) || ! $processor->next_tag( [ 'tag_closers' => 'visit' ] ) || $processor->is_tag_closer() || ! in_array( $processor->get_tag(), [ 'A', 'BUTTON' ], true ) ) { $processor->seek( 'root' ); } $range = $processor->content_range(); return $range ? substr_replace( $html, $content, $range[0], $range[1] ) : $html; } return $html; } /** * Supported Elementor Atomic widgets. */ private static function is_supported_widget( $element ): bool { return is_object( $element ) && method_exists( $element, 'get_name' ) && in_array( $element->get_name(), self::WIDGETS, true ); } /** New HTML strings are rendered natively; encoded tags are literal text. */ private static function uses_html_string_prop( $element ): bool { if ( ! method_exists( $element, 'get_data' ) ) { return false; } $data = $element->get_data(); $key = 'e-paragraph' === $element->get_name() ? 'paragraph' : 'title'; $type = $data['settings'][ $key ]['$$type'] ?? ''; return in_array( $type, [ 'escaped-html', 'html', 'string', 'dynamic', 'overridable' ], true ); } /** * Restore escaped inline HTML. */ private static function restore_inline_html( string $html ): string { $inline_tags = implode( '|', array_map( static function ( string $tag ): string { return preg_quote( $tag, '/' ); }, self::INLINE_TAGS ) ); $html = preg_replace_callback( '/<(\/)?\s*(' . $inline_tags . ')\b(.*?)>/is', static function ( array $matches ): string { $tag = strtolower( $matches[2] ); if ( ! empty( $matches[1] ) ) { return in_array( $tag, [ 'br', 'img' ], true ) ? '' : ''; } if ( 'br' === $tag ) { return '
'; } return '<' . $tag . self::sanitize_attributes( $matches[3], $tag ) . '>'; }, $html ) ?? $html; return $html; } /** * Sanitize allowed HTML attributes. */ private static function sanitize_attributes( string $raw_attrs, string $tag ): string { $raw_attrs = html_entity_decode( $raw_attrs, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); if ( ! preg_match_all( '/([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(["\'])(.*?)\2/', $raw_attrs, $matches, PREG_SET_ORDER ) ) { return ''; } $output = ''; foreach ( $matches as $match ) { $name = strtolower( $match[1] ); $value = html_entity_decode( $match[3], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); if ( ! in_array( $name, self::INLINE_ATTRIBUTES[ $tag ] ?? [], true ) ) { continue; } switch ( $name ) { case 'id': $value = preg_match( '/^[A-Za-z0-9_-]+$/', $value ) ? $value : ''; break; case 'href': case 'src': $value = esc_url( $value ); break; case 'class': $classes = preg_split( '/\s+/', trim( $value ) ) ?: []; $classes = array_filter( array_map( 'sanitize_html_class', $classes ) ); $value = implode( ' ', $classes ); break; case 'style': $value = self::sanitize_inline_style( $value ); break; case 'target': if ( ! in_array( $value, [ '_blank', '_self', '_parent', '_top', ], true ) ) { $value = ''; } break; default: $value = sanitize_text_field( $value ); break; } if ( '' === $value ) { continue; } $output .= sprintf( ' %s="%s"', esc_attr( $name ), esc_attr( $value ) ); } return $output; } /** * Sanitize an inline style while allowing text background clipping. */ private static function sanitize_inline_style( string $value ): string { $allow_background_clip = static function ( array $properties ): array { $properties[] = 'background-clip'; return array_unique( $properties ); }; add_filter( 'safe_style_css', $allow_background_clip ); try { return safecss_filter_attr( $value ); } finally { remove_filter( 'safe_style_css', $allow_background_clip ); } } /** * Make inline HTML render inside Elementor's live editor canvas. */ public static function editor_preview_script(): void { if ( ! is_user_logged_in() || ! defined( 'ELEMENTOR_VERSION' ) ) { return; } // Use WordPress's live CSS allowlist in both preview and inline editing. $allowed_styles = []; $capture_styles = static function ( array $properties ) use ( &$allowed_styles ): array { $allowed_styles = array_unique( array_merge( $properties, [ 'background-clip' ] ) ); return $properties; }; add_filter( 'safe_style_css', $capture_styles, PHP_INT_MAX ); try { safecss_filter_attr( 'color:red' ); } finally { remove_filter( 'safe_style_css', $capture_styles, PHP_INT_MAX ); } $options = [ 'allowedStyles' => array_values( $allowed_styles ), 'protocols' => wp_allowed_protocols() ]; echo ''; } } WPAddons_Atomic_Inline_HTML::init();