Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1f278cc
fix: avoid a duplicate H1 when a hidden title's page provides one els…
HardeepAsrani Oct 15, 2026
937dfe5
fix: buffer the whole body for the hidden-title H1 decision
HardeepAsrani Oct 16, 2026
121d8f8
fix: span the hidden-title H1 buffer across the theme body hooks
HardeepAsrani Oct 16, 2026
0830d44
test: make the hidden-title H1 tests order-independent and tighter
HardeepAsrani Oct 16, 2026
6e633c2
fix: scan the body for an H1 without a regex that can exhaust the bac…
HardeepAsrani Oct 19, 2026
6e22b17
fix: drop the ctype dependency from the closing-tag search
HardeepAsrani Oct 20, 2026
b01881a
fix: treat template contents as nested markup in the H1 scan
HardeepAsrani Oct 20, 2026
469eeae
test: drop two template scan rows that pass without the fix
HardeepAsrani Oct 20, 2026
ff60927
fix: skip attribute values in the H1 scan
HardeepAsrani Oct 22, 2026
28d2125
fix: recognise custom element tags in the H1 scan
HardeepAsrani Oct 22, 2026
9ee144f
fix: ignore an H1 tag cut off by the end of input
HardeepAsrani Oct 22, 2026
55d313c
fix: skip every raw-text element in the H1 scan
HardeepAsrani Oct 22, 2026
26fded7
fix: apply foreign-content rules inside svg and math in the H1 scan
HardeepAsrani Oct 23, 2026
0504889
test: cover each branch of the foreign-content tracking in the H1 scan
HardeepAsrani Oct 23, 2026
14cd6e7
fix: honour end-tag attributes and all integration points in the H1 scan
HardeepAsrani Oct 23, 2026
0fe4373
fix: close integration points in the H1 scan's context stack
HardeepAsrani Oct 23, 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
21 changes: 21 additions & 0 deletions bin/envs/a11y-ready/fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,25 @@ HT_NO_H1_ID=$($WP_CMD post create \
--porcelain)
$WP_CMD post meta update "$HT_NO_H1_ID" neve_meta_disable_title on

# A synced pattern's H1 is not in the page's post_content, only in the
# rendered output; the fallback decision must see it (neve#4652).
OLD_HT_PATTERN=$($WP_CMD post list --post_type=page --name=a11y-hidden-title-pattern-h1 --field=ID | head -1)
if [ -n "$OLD_HT_PATTERN" ]; then
$WP_CMD post delete "$OLD_HT_PATTERN" --force
fi
HT_PATTERN_BLOCK_ID=$($WP_CMD post create \
--post_title="A11y Pattern With H1" \
--post_type=wp_block \
--post_status=publish \
--post_content='<!-- wp:heading {"level":1} --><h1 class="wp-block-heading">Synced pattern heading</h1><!-- /wp:heading -->' \
--porcelain)
HT_PATTERN_ID=$($WP_CMD post create \
--post_title="A11y Hidden Title With Pattern H1" \
--post_name="a11y-hidden-title-pattern-h1" \
--post_type=page \
--post_status=publish \
--post_content="<!-- wp:block {\"ref\":$HT_PATTERN_BLOCK_ID} /--><!-- wp:paragraph --><p>Fixture page: the title is hidden and a synced pattern supplies the H1.</p><!-- /wp:paragraph -->" \
--porcelain)
$WP_CMD post meta update "$HT_PATTERN_ID" neve_meta_disable_title on

echo "== a11y-ready fixtures done =="
19 changes: 19 additions & 0 deletions e2e-tests/specs/a11y-ready/structure.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ test('hidden title keeps the content H1 and adds no second one', async ({
).toBe(0);
});

test('hidden title sees an H1 that only exists in rendered output', async ({
page,
request,
}) => {
// The H1 comes from a synced pattern, so it is absent from post_content
// and only appears once the page renders (neve#4652).
await page.goto(
await getPermalink(request, 'pages', 'a11y-hidden-title-pattern-h1')
);
const h1s = await page.$$eval('h1', (els) =>
els.map((el) => (el.textContent || '').trim().substring(0, 60))
);
expect(
h1s,
`the pattern provides the H1, so no fallback is expected, got: ${JSON.stringify(h1s)}`
).toHaveLength(1);
expect(await page.locator('h1.screen-reader-text').count()).toBe(0);
});

test('hidden title falls back to a screen-reader H1 when the content has none', async ({
page,
request,
Expand Down
255 changes: 255 additions & 0 deletions inc/views/page_header.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

namespace Neve\Views;

use Neve\Core\Migration_Flags;

/**
* Class Page_Header
*
Expand All @@ -25,6 +27,36 @@ class Page_Header extends Base_View {
'wrap-class' => '',
);

const H1_PLACEHOLDER = '<!-- neve-fallback-h1 -->';

/**
* Elements whose contents browsers never parse as tags.
*
* @var list<string>
*/
const RAW_TEXT_TAGS = array( 'script', 'style', 'noscript', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes' );
Comment thread
pirate-bot marked this conversation as resolved.

/**
* SVG and MathML elements whose contents are parsed with the HTML rules again.
*
* @var list<string>
*/
const INTEGRATION_POINT_TAGS = array( 'foreignobject', 'desc', 'title', 'mi', 'mo', 'mn', 'ms', 'mtext' );

/**
* Fallback H1 markup awaiting the buffer decision.
*
* @var string
*/
private $deferred_h1 = '';

/**
* Output-buffer level of the page-body buffer, 0 when not buffering.
*
* @var int
*/
private $buffer_level = 0;

/**
* Function that is run after instantiation.
*
Expand All @@ -43,6 +75,11 @@ public function run() {
$header_hook = get_theme_mod( 'neve_enable_featured_post', false ) && is_home() ? 'neve_do_featured_post' : 'neve_page_header';
add_action( $header_hook, array( $this, 'render_page_header' ), 9 );
add_filter( 'get_the_archive_title', array( $this, 'filter_archive_title' ) );
if ( $this->is_title_hidden() ) {
// The theme's first body hook fires before wp_body_open; the latter covers other templates.
add_action( 'neve_body_start_after', array( $this, 'start_h1_buffer' ), PHP_INT_MIN );
add_action( 'wp_body_open', array( $this, 'start_h1_buffer' ), 0 );
}
Comment thread
pirate-bot marked this conversation as resolved.
}

/**
Expand All @@ -66,6 +103,224 @@ public function render_page_header( $context ) {
$this->get_view( 'page-header', $title_args );
}

/**
* Whether the page title is hidden for the current request.
*
* @return bool
*/
public function is_title_hidden() {
$hide_title = get_theme_mod( 'neve_page_hide_title', false );

$current_page = get_queried_object();
$pid = $current_page instanceof \WP_Post ? $current_page->ID : get_the_ID();

if ( (int) get_option( 'page_for_posts' ) === $pid || is_archive() ) {
$hide_title = get_theme_mod( 'neve_archive_hide_title', Migration_Flags::is_new_user_after_v4() );
}

$specific_hide_title = get_post_meta( $pid, 'neve_meta_disable_title', true );
if ( ! empty( $specific_hide_title ) ) {
$hide_title = $specific_hide_title === 'on';
}

return (bool) $hide_title;
}

/**
* Buffer the page body so the screen-reader H1 fallback can be decided after
* every H1 source (content, patterns, builders, header/footer/custom layouts) rendered.
*
* @return void
*/
public function start_h1_buffer() {
if ( $this->buffer_level > 0 ) {
return;
}
ob_start( array( $this, 'filter_h1_buffer' ) );
$this->buffer_level = ob_get_level();
// Last theme hook in the body; if it never fires, PHP runs the callback at shutdown.
add_action( 'neve_body_end_before', array( $this, 'resolve_fallback_h1' ), PHP_INT_MAX );
}
Comment thread
pirate-bot marked this conversation as resolved.

/**
* Output the fallback H1, or its placeholder while buffering.
*
* @param string $title Hidden title text.
*
* @return void
*/
public function fallback_h1( $title ) {
$h1 = '<h1 class="screen-reader-text">' . $title . '</h1>';
if ( $this->buffer_level === 0 ) {
// Not buffering (template without the body hooks): fall back to the content scan.
$content_has_h1 = is_singular() && self::markup_has_h1( (string) get_post_field( 'post_content', get_queried_object_id() ) );
if ( ! $content_has_h1 ) {
echo wp_kses_post( $h1 );
}
return;
}
$this->deferred_h1 = wp_kses_post( $h1 );
echo wp_kses_post( self::H1_PLACEHOLDER );
}

/**
* Flush the buffer; filter_h1_buffer() resolves the placeholder on the way out.
*
* @return void
*/
public function resolve_fallback_h1() {
if ( $this->buffer_level === 0 ) {
return;
}
// A later buffer is still open; closing now would eat its output.
if ( ob_get_level() !== $this->buffer_level ) {
return;
Comment thread
pirate-bot marked this conversation as resolved.
}
$this->buffer_level = 0;
ob_end_flush();
}

/**
* Output-buffer callback: keep the fallback H1 only when no other H1 rendered.
*
* @param string $markup Buffered page-body markup.
*
* @return string
*/
public function filter_h1_buffer( $markup ) {
$replacement = self::markup_has_h1( $markup ) ? '' : $this->deferred_h1;
$this->deferred_h1 = '';
$this->buffer_level = 0;

return str_replace( self::H1_PLACEHOLDER, $replacement, $markup );
}

/**
* Whether markup contains an H1, ignoring comments, raw-text elements, templates and attribute text.
*
* @param string $markup Rendered HTML.
*
* @return bool
*/
public static function markup_has_h1( $markup ) {
// A single left-to-right pass: a regex over the whole body hits pcre.backtrack_limit
// on a large inline script (around 1 MB), and a failed scan must not miscount an H1.
$markup = (string) $markup;
$offset = strpos( $markup, '<' );
// Open <template> elements: their contents are parsed as markup but never rendered.
$inert = 0;
// Open <svg>/<math> elements and their integration points, innermost last. In foreign
// content no raw-text or template rules apply, tags can self-close, and an <h1> breaks
// out as real HTML; an integration point restores the HTML rules.
$context = array();
while ( false !== $offset ) {
if ( '<!--' === substr( $markup, $offset, 4 ) ) {
$close = strpos( $markup, '-->', $offset + 4 );
if ( false === $close ) {
return false;
}
$offset = $close + 3;
} elseif ( preg_match( '#\G<(/?)([a-z][^\s/>]*)#i', $markup, $match, 0, $offset ) ) {
Comment thread
pirate-bot marked this conversation as resolved.
$closing = '' !== $match[1];
$tag = strtolower( $match[2] );
// Skip the attributes: quoted values may hold literal tag text. A tag cut off
// by the end of input is dropped by browsers, so it counts for nothing.
$offset = self::find_tag_end( $markup, $offset + strlen( $match[0] ) );
if ( false === $offset ) {
return false;
}
if ( 'h1' === $tag && ! $closing && 0 === $inert ) {
return true;
}
$self_closing = '/' === $markup[ $offset - 2 ];
$innermost = end( $context );
$html_rules = false === $innermost || in_array( $innermost, self::INTEGRATION_POINT_TAGS, true );
$is_foreign = 'svg' === $tag || 'math' === $tag;
$is_context = $is_foreign || in_array( $tag, self::INTEGRATION_POINT_TAGS, true );
if ( $is_context && $closing ) {
$open = array_search( $tag, array_reverse( $context, true ), true );
if ( false !== $open ) {
$context = array_slice( $context, 0, $open );
}
} elseif ( $is_context && ! $self_closing && ( $is_foreign || ! $html_rules ) ) {
// An integration point only counts inside foreign content; in HTML, <title> is raw text.
$context[] = $tag;
} elseif ( 'template' === $tag && $html_rules ) {
$inert = $closing ? max( 0, $inert - 1 ) : $inert + 1;
} elseif ( ! $closing && $html_rules && in_array( $tag, self::RAW_TEXT_TAGS, true ) ) {
$offset = self::find_closing_tag( $markup, $tag, $offset );
if ( false === $offset ) {
return false;
}
}
} else {
$offset ++;
}
$offset = strpos( $markup, '<', $offset );
}

return false;
}

/**
* Offset just past a tag's ">", or false when the tag never ends.
*
* @param string $markup Rendered HTML.
* @param int $offset Position right after the tag name.
*
* @return int|false
*/
private static function find_tag_end( $markup, $offset ) {
$length = strlen( $markup );
while ( $offset < $length ) {
$offset += strcspn( $markup, '>"\'', $offset );
if ( $offset >= $length ) {
return false;
}
if ( '>' === $markup[ $offset ] ) {
return $offset + 1;
}
// A quote opens a value only after "="; elsewhere it is part of an unquoted name or value.
$prev = $offset;
do {
$prev --;
} while ( $prev >= 0 && false !== strpos( " \t\n\f\r", $markup[ $prev ] ) );
if ( $prev >= 0 && '=' === $markup[ $prev ] ) {
$offset = strpos( $markup, $markup[ $offset ], $offset + 1 );
if ( false === $offset ) {
return false;
}
}
$offset ++;
}

return false;
}

/**
* Offset just past the closing tag of a raw-text element, or false when it never closes.
*
* @param string $markup Rendered HTML.
* @param string $tag Lower-case tag name.
* @param int $offset Where to start looking.
*
* @return int|false
*/
private static function find_closing_tag( $markup, $tag, $offset ) {
$needle = '</' . $tag;
$pos = stripos( $markup, $needle, $offset );
while ( false !== $pos ) {
$end = $pos + strlen( $needle );
// Browsers close the element even when the end tag carries attributes or a stray slash.
if ( isset( $markup[ $end ] ) && false !== strpos( " \t\n\f\r/>", $markup[ $end ] ) ) {
return self::find_tag_end( $markup, $end );
}
$pos = stripos( $markup, $needle, $end );
}

return false;
}
Comment thread
pirate-bot marked this conversation as resolved.

/**
* The page title
*
Expand Down
Loading
Loading