[ '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 )
? ''
: '' . $tag . '>';
}
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();