diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index d318a275a9607..5ef5bd638a9a9 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -2599,8 +2599,8 @@ function img_caption_shortcode( $attr, $content = '' ) { $describedby = ''; if ( $atts['id'] ) { - $atts['id'] = sanitize_html_class( $atts['id'] ); - $id = 'id="' . esc_attr( $atts['id'] ) . '" '; + $unique_id_value = preg_replace( '/-1$/', '', wp_unique_prefixed_id( sanitize_html_class( $atts['id'] . '-' ) ) ); + $id = 'id="' . esc_attr( $unique_id_value ) . '" '; } if ( $atts['caption_id'] ) { @@ -2610,8 +2610,9 @@ function img_caption_shortcode( $attr, $content = '' ) { } if ( $atts['caption_id'] ) { - $caption_id = 'id="' . esc_attr( $atts['caption_id'] ) . '" '; - $describedby = 'aria-describedby="' . esc_attr( $atts['caption_id'] ) . '" '; + $caption_id_value = preg_replace( '/-1$/', '', wp_unique_id( $atts['caption_id'] . '-' ) ); + $caption_id = 'id="' . esc_attr( $caption_id_value ) . '" '; + $describedby = 'aria-describedby="' . esc_attr( $caption_id_value ) . '" '; } $class = trim( 'wp-caption ' . $atts['align'] . ' ' . $atts['class'] ); diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 060a7295f6bb4..09343efae4ba2 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -206,7 +206,7 @@ public function test_img_caption_shortcode_with_old_format_id_and_align() { ) ); $this->assertSame( 1, substr_count( $result, 'wp-caption &myAlignment' ) ); - $this->assertSame( 1, substr_count( $result, 'id="myId"' ) ); + $this->assertSame( 1, preg_match( '/id="myId[0-9]+"/', $result ) ); $this->assertSame( 1, substr_count( $result, self::CAPTION ) ); } @@ -302,7 +302,95 @@ public function test_img_caption_shortcode_has_aria_describedby() { self::IMG_CONTENT . self::HTML_CONTENT ); - $this->assertSame( 1, substr_count( $result, 'aria-describedby="caption-myId"' ) ); + $this->assertSame( 1, preg_match( '/aria-describedby="caption-myId[0-9]+"/', $result ) ); + } + + /** + * Tests that both figure and figcaption IDs are unique for multiple caption instances. + * + * When the same image with the same or different captions appears multiple + * times on a page, each figure and figcaption should receive a unique ID to + * maintain HTML validity and accessibility. + * + * @ticket 65315 + */ + public function test_img_caption_shortcode_unique_ids_per_instance(): void { + // First instance with caption "My caption" + $result_1 = img_caption_shortcode( + array( + 'width' => 20, + 'id' => 'attachment_123', + 'caption' => 'My caption', + ), + self::IMG_CONTENT . 'My caption' + ); + + // Second instance - identical to first + $result_2 = img_caption_shortcode( + array( + 'width' => 20, + 'id' => 'attachment_123', + 'caption' => 'My caption', + ), + self::IMG_CONTENT . 'My caption' + ); + + // Third instance - same image, different caption + $result_3 = img_caption_shortcode( + array( + 'width' => 20, + 'id' => 'attachment_123', + 'caption' => 'Different caption', + ), + self::IMG_CONTENT . 'Different caption' + ); + + // Extract the figure (caption wrapper) and caption text IDs from each instance. + $figure_id_1 = $this->get_id_of_first_tag_with_class( $result_1, 'wp-caption' ); + $figure_id_2 = $this->get_id_of_first_tag_with_class( $result_2, 'wp-caption' ); + $figure_id_3 = $this->get_id_of_first_tag_with_class( $result_3, 'wp-caption' ); + $caption_id_1 = $this->get_id_of_first_tag_with_class( $result_1, 'wp-caption-text' ); + $caption_id_2 = $this->get_id_of_first_tag_with_class( $result_2, 'wp-caption-text' ); + $caption_id_3 = $this->get_id_of_first_tag_with_class( $result_3, 'wp-caption-text' ); + + // Figure IDs should all exist + $this->assertNotEmpty( $figure_id_1, 'First figure should have an ID' ); + $this->assertNotEmpty( $figure_id_2, 'Second figure should have an ID' ); + $this->assertNotEmpty( $figure_id_3, 'Third figure should have an ID' ); + + // Figure IDs should all be different (each instance gets unique ID) + $this->assertNotSame( $figure_id_1, $figure_id_2, 'First and second figures should have different IDs even with identical content' ); + $this->assertNotSame( $figure_id_2, $figure_id_3, 'Second and third figures should have different IDs' ); + $this->assertNotSame( $figure_id_1, $figure_id_3, 'First and third figures should have different IDs' ); + + // Caption IDs should all exist + $this->assertNotEmpty( $caption_id_1, 'First caption should have an ID' ); + $this->assertNotEmpty( $caption_id_2, 'Second caption should have an ID' ); + $this->assertNotEmpty( $caption_id_3, 'Third caption should have an ID' ); + + // Caption IDs should all be different (each instance gets unique ID) + $this->assertNotSame( $caption_id_1, $caption_id_2, 'First and second captions should have different IDs even with identical content' ); + $this->assertNotSame( $caption_id_2, $caption_id_3, 'Second and third captions should have different IDs' ); + $this->assertNotSame( $caption_id_1, $caption_id_3, 'First and third captions should have different IDs' ); + } + + /** + * Returns the `id` attribute of the first tag bearing the given class name. + * + * @param string $html Markup to search. + * @param string $class_name Class name to locate the tag by. + * @return string|null The tag's `id` value, or null if no matching tag or `id` is found. + */ + private function get_id_of_first_tag_with_class( string $html, string $class_name ): ?string { + $processor = new WP_HTML_Tag_Processor( $html ); + + if ( ! $processor->next_tag( array( 'class_name' => $class_name ) ) ) { + return null; + } + + $id = $processor->get_attribute( 'id' ); + + return is_string( $id ) ? $id : null; } public function test_add_remove_oembed_provider() {