diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index a04d62e54924c..546002170f8a5 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1170,6 +1170,11 @@ private function data_wp_style_processor( WP_Interactivity_API_Directives_Proces * merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;' * merge_style_property( 'color:green;', 'color', null ) => '' * + * Declarations are separated on semicolons without regard for quoted strings or + * `url()` values, so the property being replaced is not recognized when its own + * value contains a semicolon. Resolving that requires tokenizing the declaration + * list rather than splitting it. See #65738. + * * @param string $style_attribute_value The current style attribute value. * @param string $style_property_name The style property name to set. * @param string|false|null $style_property_value The value to set for the style property. With false, null or an @@ -1187,7 +1192,25 @@ private function merge_style_property( string $style_attribute_value, string $st if ( empty( trim( $style_assignment ) ) ) { continue; } - list( $name, $value ) = explode( ':', $style_assignment ); + + /* + * Only the first colon separates a property name from its value; a value may + * legitimately contain more, as in `background-image:url(https://…)`. + */ + $name_and_value = explode( ':', $style_assignment, 2 ); + + /* + * A segment with no colon is not a declaration. It is most often the remainder + * of a value that was split above by a semicolon inside a quoted string or a + * `url()`, as in `font-family:"Foo;Bar"`. Preserving it verbatim means + * re-joining the segments restores the original value. + */ + if ( ! isset( $name_and_value[1] ) ) { + $result[] = $style_assignment . ';'; + continue; + } + + list( $name, $value ) = $name_and_value; if ( trim( $name ) !== $style_property_name ) { $result[] = trim( $name ) . ':' . trim( $value ) . ';'; } diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php index 1a5884f34b8e6..eb1198805f80c 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php @@ -106,6 +106,66 @@ public function test_merge_style_property_sets_properties() { $this->assertSame( 'border:3px solid red;border-style:inset;', $result ); } + /** + * Tests that `merge_style_property` preserves values containing a colon. + * + * Only the first colon in a declaration separates the property name from its + * value, so a value may legitimately contain more of them. + * + * @ticket 63899 + * + * @covers ::merge_style_property + */ + public function test_merge_style_property_preserves_values_containing_colons() { + // Keeps an untouched property whose value contains a URL. + $result = $this->merge_style_property( 'background-image:url(https://example.com/a.png)', 'color', 'green' ); + $this->assertSame( 'background-image:url(https://example.com/a.png);color:green;', $result ); + + // Replaces a property whose previous value contained a URL. + $result = $this->merge_style_property( 'background-image:url(https://example.com/a.png)', 'background-image', 'none' ); + $this->assertSame( 'background-image:none;', $result ); + + // Keeps every colon after the first, not only the second. + $result = $this->merge_style_property( '--fallback:url(https://a.test/x.png), url(https://b.test/y.png);', 'color', 'green' ); + $this->assertSame( '--fallback:url(https://a.test/x.png), url(https://b.test/y.png);color:green;', $result ); + } + + /** + * Tests that `merge_style_property` preserves values containing a semicolon. + * + * Declarations are separated on semicolons, so a semicolon inside a quoted + * string or a `url()` splits a value across two segments. The segments must + * re-join to the original value. + * + * @ticket 63899 + * + * @covers ::merge_style_property + */ + public function test_merge_style_property_preserves_values_containing_semicolons() { + // Keeps a quoted value containing a semicolon. + $result = $this->merge_style_property( 'font-family:"Foo;Bar",serif', 'color', 'green' ); + $this->assertSame( 'font-family:"Foo;Bar",serif;color:green;', $result ); + + // Keeps a data URI, which contains both a colon and a semicolon. + $result = $this->merge_style_property( 'background-image:url(data:image/gif;base64,R0lGODlhAQ)', 'color', 'green' ); + $this->assertSame( 'background-image:url(data:image/gif;base64,R0lGODlhAQ);color:green;', $result ); + } + + /** + * Tests that `merge_style_property` handles segments that contain no colon. + * + * Malformed input previously raised "Undefined array key 1" and a `trim()` + * deprecation notice, and emitted a stray colon into the result. + * + * @ticket 63899 + * + * @covers ::merge_style_property + */ + public function test_merge_style_property_handles_segments_without_a_colon() { + $result = $this->merge_style_property( 'translate:none;translate3d(0px, 0px, 0px);', 'color', 'green' ); + $this->assertSame( 'translate:none;translate3d(0px, 0px, 0px);color:green;', $result ); + } + /** * Tests that `merge_style_property` works correctly with falsy values, * removing or ignoring them as appropriate. @@ -234,6 +294,20 @@ public function test_wp_style_sets_style_property_when_style_attribute_exists() $this->assertSame( 'padding:10px;color:green;', $p->get_attribute( 'style' ) ); } + /** + * Tests that the `data-wp-style` directive leaves an existing style attribute + * intact when one of its values contains a colon. + * + * @ticket 63899 + * + * @covers ::process_directives + */ + public function test_wp_style_preserves_existing_style_attribute_containing_a_url() { + $html = '