diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php
index 22a1d3e307d3e..f4754488d4de5 100644
--- a/src/wp-includes/general-template.php
+++ b/src/wp-includes/general-template.php
@@ -473,7 +473,7 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
$defaults = array(
'id' => wp_unique_id( 'wp-tooltip-' ),
- 'button' => '',
+ 'button' => '',
'label' => __( 'Help' ),
'close_label' => __( 'Close' ),
'icon' => 'dashicons-editor-help',
@@ -481,6 +481,9 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
'type' => 'tooltip',
);
+ // Captured before `wp_parse_args()` merges in the default button markup below.
+ $is_default_button = empty( $args['button'] );
+
$args = wp_parse_args( $args, $defaults );
$classes = ( 'tooltip' === $args['type'] ) ? 'wp-tooltip wp-is-tooltip' : 'wp-tooltip wp-is-toggletip';
@@ -492,11 +495,22 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
$id = ( $args['id'] ) ? $args['id'] : $defaults['id'];
$button = ( $args['button'] ) ? $args['button'] : $defaults['button'];
$processed = false;
+
+ /*
+ * Placeholder tokens, rather than `sprintf()` conversion specifications,
+ * are used so the popover target, label, and icon can be filled in with
+ * `str_replace()` below. The button markup is later passed to `sprintf()`
+ * as an argument, not concatenated into the format string, so any literal
+ * `%` characters in caller-supplied markup (e.g. a percent-encoded URL)
+ * are never parsed as a conversion specification. See #65914.
+ */
+ $popovertarget_token = '{{wp-tooltip-popovertarget}}';
+
$processor = new WP_HTML_Tag_Processor( $button );
if ( true === $processor->next_tag( 'button' ) ) {
$processor->add_class( 'wp-tooltip__toggle' );
if ( 'tooltip' !== $args['type'] ) {
- $processor->set_attribute( 'popovertarget', '%2$s' );
+ $processor->set_attribute( 'popovertarget', $popovertarget_token );
$processor->set_attribute( 'aria-haspopup', 'dialog' );
}
$button = $processor->get_updated_html();
@@ -512,15 +526,34 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
}
if ( ! $processed ) {
// Button HTML passed was not valid.
- $processor = new WP_HTML_Tag_Processor( $defaults['button'] );
+ $is_default_button = true;
+ $processor = new WP_HTML_Tag_Processor( $defaults['button'] );
$processor->add_class( 'wp-tooltip__toggle' );
if ( 'tooltip' !== $args['type'] ) {
- $processor->set_attribute( 'popovertarget', '%2$s' );
+ $processor->set_attribute( 'popovertarget', $popovertarget_token );
$processor->set_attribute( 'aria-haspopup', 'dialog' );
}
$button = $processor->get_updated_html();
}
+ if ( false !== strpos( $button, $popovertarget_token ) ) {
+ $button = str_replace( $popovertarget_token, esc_attr( $id ), $button );
+ }
+
+ /*
+ * Only the default, core-generated button template contains the label
+ * and icon tokens. Caller-supplied button markup is never scanned for
+ * these tokens, so literal matching text in custom markup is left as-is.
+ */
+ if ( $is_default_button ) {
+ $button_label = ( 'tooltip' === $args['type'] ) ? wp_strip_all_tags( $content, true ) : $args['label'];
+ $button = str_replace(
+ array( '{{wp-tooltip-label}}', '{{wp-tooltip-icon}}' ),
+ array( esc_attr( $button_label ), esc_attr( $icon ) ),
+ $button
+ );
+ }
+
/*
* The markup only uses phrasing content so it is valid when nested
* in a phrasing context. Sectioning content (e.g. `div`, `dialog`) will
@@ -529,19 +562,17 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
*/
if ( 'tooltip' === $args['type'] ) {
// Tooltips are only used to visually display labels.
- $label = wp_strip_all_tags( $content, true );
$markup = sprintf(
'
- ' . $button . '
+ %4$s
' .
- '%5$s' .
+ '%3$s' .
'' .
'',
esc_attr( $classes ),
esc_attr( $id ),
- esc_attr( $label ),
- esc_attr( $icon ),
esc_html( $content ),
+ $button,
);
} else {
/*
@@ -551,10 +582,10 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
*/
$markup = sprintf(
'
- ' . $button . '
+ %6$s
' .
- '%5$s' .
- '' .
@@ -562,9 +593,9 @@ function wp_get_tooltip_helper( $content, $args = array() ) {
esc_attr( $classes ),
esc_attr( $id ),
esc_attr( $args['label'] ),
- esc_attr( $icon ),
esc_html( $content ),
esc_attr( $args['close_label'] ),
+ $button,
);
}
diff --git a/tests/phpunit/tests/general/wpGetTooltip.php b/tests/phpunit/tests/general/wpGetTooltip.php
index e545902e6ed6d..7f51e054b209a 100644
--- a/tests/phpunit/tests/general/wpGetTooltip.php
+++ b/tests/phpunit/tests/general/wpGetTooltip.php
@@ -206,6 +206,85 @@ public function test_wp_get_toggletip_bubble_uses_dialog_role_and_autofocus() {
$this->assertStringContainsString( 'tabindex="-1" autofocus>', $html );
}
+ /**
+ * Tests that a percent sign in custom button markup is not parsed as a
+ * `sprintf()` conversion specification.
+ *
+ * @ticket 65914
+ */
+ public function test_wp_get_tooltip_does_not_treat_percent_encoded_url_as_format_string() {
+ $html = wp_get_tooltip(
+ 'Helpful text.',
+ array( 'button' => 'Search' )
+ );
+
+ $this->assertStringContainsString( 'href="/wp-admin/edit.php?s=hello%20world"', $html );
+ }
+
+ /**
+ * Tests that literal text containing a percent sign is preserved verbatim.
+ *
+ * @ticket 65914
+ */
+ public function test_wp_get_tooltip_preserves_percent_sign_in_button_text() {
+ $html = wp_get_tooltip(
+ 'Helpful text.',
+ array( 'button' => '100% done' )
+ );
+
+ $this->assertStringContainsString( '100% done', $html );
+ }
+
+ /**
+ * Tests that a percent-encoded path is not silently rewritten.
+ *
+ * @ticket 65914
+ */
+ public function test_wp_get_tooltip_does_not_rewrite_percent_encoded_path() {
+ $html = wp_get_tooltip(
+ 'Helpful text.',
+ array( 'button' => 'Link' )
+ );
+
+ $this->assertStringContainsString( 'href="/x?p=a%2Fb"', $html );
+ }
+
+ /**
+ * Tests that a meta box style ID containing a percent sign, as used by
+ * `do_meta_boxes()`, does not cause a fatal error.
+ *
+ * @ticket 65914
+ */
+ public function test_wp_get_tooltip_does_not_fatal_on_percent_in_id_attribute() {
+ $html = wp_get_tooltip(
+ 'Helpful text.',
+ array( 'button' => 'Toggle' )
+ );
+
+ $this->assertStringContainsString( 'id="sale_100%_off"', $html );
+ }
+
+ /**
+ * Tests that the default, core-generated button still receives its label,
+ * icon, and popovertarget correctly after the button markup is no longer
+ * concatenated into the `sprintf()` format string.
+ *
+ * @ticket 65914
+ */
+ public function test_wp_get_toggletip_default_button_still_receives_label_and_icon() {
+ $html = wp_get_toggletip(
+ 'Some help content here.',
+ array(
+ 'label' => 'Info',
+ 'icon' => 'dashicons-info',
+ )
+ );
+
+ $this->assertStringContainsString( 'aria-label="Info"', $html );
+ $this->assertStringContainsString( 'class="dashicons dashicons-info"', $html );
+ $this->assertStringNotContainsString( '{{', $html, 'Placeholder tokens must not leak into output.' );
+ }
+
/**
* Data provider.
*