Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a556ae1
fix: ensure figcaptions have unique IDs
SteelWagstaff May 22, 2026
be000e7
chore: fix linting
SteelWagstaff May 22, 2026
fb96413
fix: generate unique ID for figure elements
SteelWagstaff May 22, 2026
6fb659a
fix: update breaking tests
SteelWagstaff May 22, 2026
b5ebd2b
refactor: use wp_unique_id as requested
SteelWagstaff Jun 3, 2026
51e0a39
Merge branch 'trunk' into fix/avoid-duplicate-ids-media-caption
SteelWagstaff Jun 3, 2026
6cd23cb
Merge branch 'trunk' into fix/avoid-duplicate-ids-media-caption
westonruter Jun 3, 2026
895f84c
Use void return type hint
westonruter Jun 3, 2026
2181865
Use ticket reference instead of version reference
westonruter Jun 3, 2026
184e429
Fix caption shortcode test assertions to match wp_unique_id() output
westonruter Jun 3, 2026
1738223
Use WP_HTML_Tag_Processor instead of regex to extract caption IDs in …
westonruter Jun 3, 2026
17a9976
Add type hints to helper method
westonruter Jun 3, 2026
270c11b
Update src/wp-includes/media.php
SteelWagstaff Jun 3, 2026
4fe064b
fix: apply suggestions from code review
SteelWagstaff Jun 3, 2026
67f82c8
Merge branch 'trunk' into fix/avoid-duplicate-ids-media-caption
SteelWagstaff Jun 8, 2026
75e2121
fix: Ensure unique IDs for captions and avoid duplicates
SteelWagstaff Jun 8, 2026
1db8cca
Merge branch 'trunk' into fix/avoid-duplicate-ids-media-caption
joedolson Jun 30, 2026
cb0ac08
fix: improve caption handling
SteelWagstaff Jul 2, 2026
d6615f4
fix: ensure figure and figcaption elements have unique IDs without -1…
SteelWagstaff Jul 2, 2026
3d502ad
Merge branch 'trunk' into fix/avoid-duplicate-ids-media-caption
westonruter Jul 8, 2026
16708f9
Fix phpcs and phpstan issues
westonruter Jul 8, 2026
1d992f0
Fix omission of numeric suffix on IDs
westonruter Jul 8, 2026
e83167f
Add missing hyphen to regex in `test_img_caption_shortcode_has_aria_d…
westonruter Jul 9, 2026
2ec8377
Tweak code style
westonruter Jul 9, 2026
948f6d2
Merge branch 'fix/avoid-duplicate-ids-media-caption' of https://githu…
joedolson Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -2599,19 +2599,24 @@ function img_caption_shortcode( $attr, $content = '' ) {
$describedby = '';

if ( $atts['id'] ) {
Comment thread
westonruter marked this conversation as resolved.
$atts['id'] = sanitize_html_class( $atts['id'] );
$id = 'id="' . esc_attr( $atts['id'] ) . '" ';
$unique_id_value = wp_unique_id( sanitize_html_class( $atts['id'] ) );

@westonruter westonruter Jun 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the prefix should have - added to the end, so that it doesn't result in the attachment ID having an extra digit added to the end of it. Then to address the issue of back-compat for existing selectors, it could simply replace -1 from the end of the string. This would allow true duplicates to then start getting numbered with -2, -3,-4, and so on. This would match how attachment filenames are incremented to avoid duplicates.

Suggested change
$unique_id_value = wp_unique_id( sanitize_html_class( $atts['id'] ) );
$unique_id_value = preg_replace( '/-1$/', '', wp_unique_id( sanitize_html_class( $atts['id'] . '-' ) ) );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this won't work. The counter is global.

So we'd need to basically need to implement our own wp_unique_id(), but keeping track of which prefixes have been used.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, and that's a thing! wp_unique_prefixed_id().

Suggested change
$unique_id_value = wp_unique_id( sanitize_html_class( $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'] ) {
// User explicitly provided a caption_id - make it unique.
$atts['caption_id'] = sanitize_html_class( $atts['caption_id'] );
$caption_id_value = wp_unique_id( $atts['caption_id'] );
Comment thread
SteelWagstaff marked this conversation as resolved.
Outdated
} elseif ( $atts['id'] ) {
$atts['caption_id'] = 'caption-' . str_replace( '_', '-', $atts['id'] );
// Derive from the already-unique figure ID - guaranteed unique, no need for second call.
$caption_id_value = 'caption-' . str_replace( '_', '-', $unique_id_value );
} else {
$caption_id_value = '';
}

if ( $atts['caption_id'] ) {
$caption_id = 'id="' . esc_attr( $atts['caption_id'] ) . '" ';
$describedby = 'aria-describedby="' . esc_attr( $atts['caption_id'] ) . '" ';
if ( $caption_id_value ) {
$caption_id = 'id="' . esc_attr( $caption_id_value ) . '" ';
$describedby = 'aria-describedby="' . esc_attr( $caption_id_value ) . '" ';
}

$class = trim( 'wp-caption ' . $atts['align'] . ' ' . $atts['class'] );
Expand Down
92 changes: 90 additions & 2 deletions tests/phpunit/tests/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) );
}

Expand Down Expand Up @@ -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"
Comment thread
westonruter marked this conversation as resolved.
Outdated
$result_1 = img_caption_shortcode(
array(
'width' => 20,
'id' => 'attachment_123',
'caption' => 'My caption',
),
self::IMG_CONTENT . 'My caption'
);

// Second instance - identical to first
Comment thread
westonruter marked this conversation as resolved.
Outdated
$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
Comment thread
westonruter marked this conversation as resolved.
Outdated
$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
Comment thread
westonruter marked this conversation as resolved.
Outdated
$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)
Comment thread
westonruter marked this conversation as resolved.
Outdated
$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
Comment thread
westonruter marked this conversation as resolved.
Outdated
$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)
Comment thread
westonruter marked this conversation as resolved.
Outdated
$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;
Comment thread
joedolson marked this conversation as resolved.
}
Comment thread
joedolson marked this conversation as resolved.

public function test_add_remove_oembed_provider() {
Expand Down
Loading