Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8e7719a
Insert block template skip link via HTML API, minify CSS, remove JS.
rutviksavsani Jan 1, 2026
7e29956
remove extra checks for block template.
rutviksavsani Jan 3, 2026
908f6a9
Add type and return type to the function.
rutviksavsani Jan 3, 2026
d6278d3
Update translators comment.
rutviksavsani Jan 3, 2026
9a73e67
Add extra checks for get_attribute link target.
rutviksavsani Jan 3, 2026
a62dffb
update function doc comment.
rutviksavsani Jan 3, 2026
43c4b01
Remove the duplication check and tag closers check.
rutviksavsani Jan 3, 2026
ce8885a
css concat but make it still readable.
rutviksavsani Jan 3, 2026
cd9e029
Remove single-use private method
westonruter Jan 5, 2026
a194ca8
Add since tag to wp_enqueue_block_template_skip_link()
westonruter Jan 5, 2026
6b7841c
address feedback.
rutviksavsani Jan 5, 2026
0bb3ec4
Add missing path data for stylesheet
westonruter Jan 5, 2026
c340b2c
Leverage assertEqualHTML
westonruter Jan 5, 2026
eff4489
Add test case for removal of the_block_template_skip_link from wp_footer
westonruter Jan 5, 2026
b00b244
Tweak comment
westonruter Jan 5, 2026
1ae2bb2
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Jan 5, 2026
d179f23
Fix swapped test names
westonruter Jan 5, 2026
540b526
Replace while loop with if statement
westonruter Jan 5, 2026
746e686
Use data provider
westonruter Jan 5, 2026
45c26b8
Add test cases for malformed IDs
westonruter Jan 5, 2026
c565ff9
Remove unnecessary variable
westonruter Jan 5, 2026
ce5b88e
Use esc_url() instead of esc_attr()
westonruter Jan 7, 2026
8b83d4e
Block template skip link handling
rutviksavsani Jan 8, 2026
d066dc4
Add tests for block template skip link
rutviksavsani Jan 8, 2026
64ff9d1
Remove the extra processor loop and use seek to insert link.
rutviksavsani Jan 9, 2026
64a8b4a
Merge branch 'WordPress:trunk' into refact/insert-skip-link-via-html
rutviksavsani Jan 9, 2026
350b57c
Fix URL for wp-block-template-skip-link
westonruter Jan 9, 2026
b1afb70
Debug: Reduce phpunit-tests for debugging
westonruter Jan 9, 2026
3ed9039
Revert "Debug: Reduce phpunit-tests for debugging"
westonruter Jan 9, 2026
61711e2
Add wp-block-template-skip-link-css to $ignored_styles
westonruter Jan 9, 2026
999acca
Update docs for _block_template_add_skip_link()
westonruter Jan 9, 2026
0db64ac
Fix comment
westonruter Jan 9, 2026
b219ac5
Merge branch 'trunk' into refact/insert-skip-link-via-html
westonruter Jan 10, 2026
9f62b62
Merge branch 'trunk' into refact/insert-skip-link-via-html
westonruter Jan 11, 2026
357ea8a
Add wp-block-template-skip-link to RTL styles
westonruter Jan 11, 2026
65fedcb
Remove unnecessary re-adding of action
westonruter Jan 11, 2026
3fceee0
Use HTML Tag Processor in tests
westonruter Jan 11, 2026
4d539a8
Account for omitting skip link when wp_enqueue_block_template_skip_li…
westonruter Jan 11, 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
81 changes: 80 additions & 1 deletion src/wp-includes/block-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,86 @@ function get_the_block_template_html() {

// Wrap block template in .wp-site-blocks to allow for specific descendant styles
// (e.g. `.wp-site-blocks > *`).
return '<div class="wp-site-blocks">' . $content . '</div>';
$template_html = '<div class="wp-site-blocks">' . $content . '</div>';

return _block_template_skip_link_markup( $template_html );
Comment thread
rutviksavsani marked this conversation as resolved.
Outdated
}

/**
* Inserts the block template skip link into the template HTML.
Comment thread
rutviksavsani marked this conversation as resolved.
Outdated
*
* Uses the HTML API to ensure that the main content element has an ID and to
* inject the skip-link anchor before the block template wrapper.
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.

as much as I love this, I’m not sure we need to describe in the docblock how the function operates. we can probably clarify the goal, which is well-stated here, and give an illustrative example.

/**
 * ...
 *
 * When a `MAIN` element exists in the template, this function will ensure
 * that the element contains a `id` attribute and will insert a link to
 * that main element at the top of the first `DIV.wp-site-blocks` match.
 *
 * Example:
 *
 *     // Input.
 *     <main>
 *         <nav>...</nav>
 *         <div class="wp-site-blocks">
 *             <h2>...
 *
 *     // Output.
 *     <main id="wp--skip-link--target">
 *         <nav>...</nav>
 *         <div class="wp-site-blocks">
 *             <a href="#wp--skip-link--target" id="wp-skip-link" class="...">
 *             <h2>...
 *
 * When the `MAIN` element already contains a non-empty `id` value it will be
 * used instead of the default skip-link id.

*
* @access private
* @since 7.0.0
*
* @param string $template_html Block template markup.
* @return string Modified markup with skip link when applicable.
*/
function _block_template_skip_link_markup( string $template_html ): string {
Comment thread
rutviksavsani marked this conversation as resolved.
Outdated

// Back-compat for plugins that disable functionality by unhooking this action.
if ( ! has_action( 'wp_footer', 'the_block_template_skip_link' ) ) {
return $template_html;
}
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.

It seems like we could move this check up into get_the_block_template_html() which would leave this function considerably more declarative and stable. with the check here it’s leading us to create the awkward Closure-passing for test setup because the function is stateful with the system.


// Ensure a skip-link target exists and has an ID.
$processor = new WP_HTML_Tag_Processor( $template_html );
$skip_link_target_id = null;

// Get the first <main> element.
while ( $processor->next_tag() ) {
if ( 'MAIN' !== $processor->get_tag() ) {
continue;
}

$skip_link_target_id = $processor->get_attribute( 'id' );
if ( ! is_string( $skip_link_target_id ) || '' === trim( $skip_link_target_id ) ) {
$skip_link_target_id = 'wp--skip-link--target';
$processor->set_attribute( 'id', $skip_link_target_id );
}

// Only consider the first <main> element.
break;
}
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.

if we’re going to default to a given value, we could set it first and only change if we find one already exists.

// Only add skip-links to templates with a MAIN element.
if ( ! $processor->next_tag( 'MAIN' ) ) {
	return $template_html;
}

$target_id = $processor->get_attribute( 'id' );
if ( ! is_string( $target_id ) && '' === $target_id ) {
	$target_id = 'wp--skip-link--target'
	$processor->set_attribute( 'id', $target_id );
}

...

Note too that I did not use trim() in my example. This is a secondary mention, but it’s not appropriate for us to trim() an id attribute’s value, as “Identifiers are opaque strings.” (HTML spec).

we can see differentiation between id values with different whitespace.

<p id="me">one</p>
<p id=" me">two</p>
<p id="me ">three</p>
<p id=" me ">four</p>
<style>
#me {
  border: 1px solid blue;
}

#\20me {
  border: 1px solid green;
}

#me\20 {
  border: 1px solid red;
}

#\20me\20 {
  border: 1px solid orange;
}
</style>

In other words, the trim() will cause WordPress to misidentify the id and break the skip-link any time it would ever make a difference in the code.


// Early exit if a skip-link target can't be located.
if ( ! $skip_link_target_id ) {
return $template_html;
}

// Apply any updates from setting the main ID.
$template_html = $processor->get_updated_html();

// Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans
// so that text can be inserted before the current token.
$inserter = new class( $template_html ) extends WP_HTML_Tag_Processor {
/**
* Inserts text before the current token.
*
* @param string $text Text to insert.
*/
public function insert_before( string $text ) {
$this->set_bookmark( 'here' );
$this->lexical_updates[] = new WP_HTML_Text_Replacement( $this->bookmarks['here']->start, 0, $text );
}
};
Comment thread
dmsnell marked this conversation as resolved.

while ( $inserter->next_tag() ) {
if ( 'DIV' === $inserter->get_tag() && $inserter->has_class( 'wp-site-blocks' ) ) {
$skip_link = sprintf(
'<a class="skip-link screen-reader-text" id="wp-skip-link" href="#%s">%s</a>',
esc_attr( $skip_link_target_id ),
Comment thread
rutviksavsani marked this conversation as resolved.
Outdated
Comment thread
westonruter marked this conversation as resolved.
Outdated
/* translators: Hidden accessibility text. */
esc_html__( 'Skip to content' )
);
$inserter->insert_before( $skip_link );
break;
}
}
Comment thread
dmsnell marked this conversation as resolved.

return $inserter->get_updated_html();
}

/**
Expand Down
111 changes: 29 additions & 82 deletions src/wp-includes/theme-templates.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,11 @@ function wp_filter_wp_template_unique_post_slug( $override_slug, $slug, $post_id
}

/**
* Enqueues the skip-link script & styles.
* Enqueues the skip-link styles.
*
* @access private
* @since 6.4.0
* @since 7.0.0 A script is no longer printed in favor of being added via {@see _block_template_skip_link_markup()}.
*
* @global string $_wp_current_template_content
*/
Expand All @@ -125,34 +126,33 @@ function wp_enqueue_block_template_skip_link() {
return;
}

$skip_link_styles = '
.skip-link.screen-reader-text {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute !important;
width: 1px;
word-wrap: normal !important;
}

.skip-link.screen-reader-text:focus {
background-color: #eee;
clip-path: none;
color: #444;
display: block;
font-size: 1em;
height: auto;
left: 5px;
line-height: normal;
padding: 15px 23px 14px;
text-decoration: none;
top: 5px;
width: auto;
z-index: 100000;
}';
$skip_link_styles = '.skip-link.screen-reader-text {'
. 'border:0;'
. 'clip-path:inset(50%);'
. 'height:1px;'
. 'margin:-1px;'
. 'overflow:hidden;'
. 'padding:0;'
. 'position:absolute!important;'
. 'width:1px;'
. 'word-wrap:normal!important;'
. '}';

$skip_link_styles .= '.skip-link.screen-reader-text:focus {'
. 'background-color:#eee;'
. 'clip-path:none;'
. 'color:#444;'
. 'display:block;'
. 'font-size:1em;'
. 'height:auto;'
. 'left:5px;'
. 'line-height:normal;'
. 'padding:15px 23px 14px;'
. 'text-decoration:none;'
. 'top:5px;'
. 'width:auto;'
. 'z-index:100000;'
. '}';
Comment thread
rutviksavsani marked this conversation as resolved.
Outdated

$handle = 'wp-block-template-skip-link';

Expand All @@ -162,59 +162,6 @@ function wp_enqueue_block_template_skip_link() {
wp_register_style( $handle, false );
wp_add_inline_style( $handle, $skip_link_styles );
wp_enqueue_style( $handle );

/**
* Enqueue the skip-link script.
*/
ob_start();
?>
<script>
( function() {
var skipLinkTarget = document.querySelector( 'main' ),
sibling,
skipLinkTargetID,
skipLink;

// Early exit if a skip-link target can't be located.
if ( ! skipLinkTarget ) {
return;
}

/*
* Get the site wrapper.
* The skip-link will be injected in the beginning of it.
*/
sibling = document.querySelector( '.wp-site-blocks' );

// Early exit if the root element was not found.
if ( ! sibling ) {
return;
}

// Get the skip-link target's ID, and generate one if it doesn't exist.
skipLinkTargetID = skipLinkTarget.id;
if ( ! skipLinkTargetID ) {
skipLinkTargetID = 'wp--skip-link--target';
skipLinkTarget.id = skipLinkTargetID;
}

// Create the skip link.
skipLink = document.createElement( 'a' );
skipLink.classList.add( 'skip-link', 'screen-reader-text' );
skipLink.id = 'wp-skip-link';
skipLink.href = '#' + skipLinkTargetID;
skipLink.innerText = '<?php /* translators: Hidden accessibility text. Do not use HTML entities (&nbsp;, etc.). */ esc_html_e( 'Skip to content' ); ?>';

// Inject the skip link.
sibling.parentElement.insertBefore( skipLink, sibling );
}() );
</script>
<?php
$skip_link_script = wp_remove_surrounding_empty_script_tags( ob_get_clean() );
$script_handle = 'wp-block-template-skip-link';
wp_register_script( $script_handle, false, array(), false, array( 'in_footer' => true ) );
wp_add_inline_script( $script_handle, $skip_link_script );
wp_enqueue_script( $script_handle );
}

/**
Expand Down
139 changes: 139 additions & 0 deletions tests/phpunit/tests/block-template-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,145 @@ public function data_remove_theme_attribute_in_block_template_content() {
);
}

/**
* Tests that the skip link is added and a missing main ID is created.
*
* @ticket 64361
*
* @covers ::_block_template_skip_link_markup
*/
public function test_block_template_skip_link_inserts_link_and_adds_main_id_when_missing() {
global $_wp_current_template_content;

$previous_template_content = null;
if ( isset( $_wp_current_template_content ) ) {
$previous_template_content = $_wp_current_template_content;
}

$_wp_current_template_content = 'Template content.';

Comment thread
rutviksavsani marked this conversation as resolved.
Outdated
$has_existing_hook = has_action( 'wp_footer', 'the_block_template_skip_link' );
$had_block_templates_support = current_theme_supports( 'block-templates' );
Comment thread
westonruter marked this conversation as resolved.
Outdated
if ( ! $has_existing_hook ) {
add_action( 'wp_footer', 'the_block_template_skip_link' );
}
if ( ! $had_block_templates_support ) {
add_theme_support( 'block-templates' );
}

$template_html = '<div class="wp-site-blocks"><main>Content</main></div>';
$result = _block_template_skip_link_markup( $template_html );

if ( ! $has_existing_hook ) {
remove_action( 'wp_footer', 'the_block_template_skip_link' );
}
if ( ! $had_block_templates_support ) {
remove_theme_support( 'block-templates' );
}

if ( null === $previous_template_content ) {
unset( $_wp_current_template_content );
} else {
$_wp_current_template_content = $previous_template_content;
}

Comment thread
rutviksavsani marked this conversation as resolved.
Outdated
$this->assertNotSame( $template_html, $result, 'Skip link markup was not added.' );
$this->assertStringContainsString( 'id="wp--skip-link--target"', $result, 'Main element ID was not added.' );
$this->assertStringContainsString( 'href="#wp--skip-link--target"', $result, 'Skip link does not point to the expected target.' );
}

/**
* Tests that an existing main ID is preserved and used by the skip link.
*
* @ticket 64361
*
* @covers ::_block_template_skip_link_markup
*/
public function test_block_template_skip_link_uses_existing_main_id() {
global $_wp_current_template_content;

$previous_template_content = null;
if ( isset( $_wp_current_template_content ) ) {
$previous_template_content = $_wp_current_template_content;
}

$_wp_current_template_content = 'Template content.';

Comment thread
rutviksavsani marked this conversation as resolved.
Outdated
$has_existing_hook = has_action( 'wp_footer', 'the_block_template_skip_link' );
$had_block_templates_support = current_theme_supports( 'block-templates' );
if ( ! $has_existing_hook ) {
add_action( 'wp_footer', 'the_block_template_skip_link' );
}
if ( ! $had_block_templates_support ) {
add_theme_support( 'block-templates' );
}

$template_html = '<div class="wp-site-blocks"><main id="custom-id">Content</main></div>';
$result = _block_template_skip_link_markup( $template_html );

if ( ! $has_existing_hook ) {
remove_action( 'wp_footer', 'the_block_template_skip_link' );
}
if ( ! $had_block_templates_support ) {
remove_theme_support( 'block-templates' );
}

if ( null === $previous_template_content ) {
unset( $_wp_current_template_content );
} else {
$_wp_current_template_content = $previous_template_content;
}
Comment thread
rutviksavsani marked this conversation as resolved.
Outdated

$this->assertStringContainsString( 'id="custom-id"', $result, 'Existing main element ID was not preserved.' );
$this->assertStringContainsString( 'href="#custom-id"', $result, 'Skip link does not point to the existing main element ID.' );
$this->assertStringNotContainsString( 'wp--skip-link--target', $result, 'Unexpected default skip link target ID was added.' );
}

/**
* Tests that no skip link is added when there is no main element.
*
* @ticket 64361
*
* @covers ::_block_template_skip_link_markup
*/
public function test_block_template_skip_link_not_inserted_when_main_missing() {
global $_wp_current_template_content;

$previous_template_content = null;
if ( isset( $_wp_current_template_content ) ) {
$previous_template_content = $_wp_current_template_content;
}

$_wp_current_template_content = 'Template content.';

Comment thread
rutviksavsani marked this conversation as resolved.
Outdated
$has_existing_hook = has_action( 'wp_footer', 'the_block_template_skip_link' );
$had_block_templates_support = current_theme_supports( 'block-templates' );
if ( ! $has_existing_hook ) {
add_action( 'wp_footer', 'the_block_template_skip_link' );
}
if ( ! $had_block_templates_support ) {
add_theme_support( 'block-templates' );
}

$template_html = '<div class="wp-site-blocks"><div>Content</div></div>';
$result = _block_template_skip_link_markup( $template_html );

if ( ! $has_existing_hook ) {
remove_action( 'wp_footer', 'the_block_template_skip_link' );
}
if ( ! $had_block_templates_support ) {
remove_theme_support( 'block-templates' );
}

if ( null === $previous_template_content ) {
unset( $_wp_current_template_content );
} else {
$_wp_current_template_content = $previous_template_content;
}
Comment thread
rutviksavsani marked this conversation as resolved.
Outdated

$this->assertSame( $template_html, $result, 'Skip link markup should not be added when there is no main element.' );
}
Comment thread
westonruter marked this conversation as resolved.

/**
* Should retrieve the template from the theme files.
*/
Expand Down
Loading