From 1f278ccd5fd7843be55deec664abf190f2ca45d9 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Thu, 15 Oct 2026 10:46:58 +0530 Subject: [PATCH 01/16] fix: avoid a duplicate H1 when a hidden title's page provides one elsewhere The screen-reader H1 fallback only scanned post_content, so an H1 from a synced pattern, a page builder or a Neve Pro custom layout produced a second H1. The main area is now buffered and the fallback is kept only when no other H1 rendered. Fixes #4652 Co-Authored-By: Claude Fable 5.1 --- bin/envs/a11y-ready/fixtures.sh | 21 ++ e2e-tests/specs/a11y-ready/structure.spec.ts | 19 ++ inc/views/page_header.php | 130 ++++++++++++ tests/test-neve-hidden-title-h1.php | 212 +++++++++++++++++++ views/page-header.php | 32 +-- 5 files changed, 391 insertions(+), 23 deletions(-) create mode 100644 tests/test-neve-hidden-title-h1.php diff --git a/bin/envs/a11y-ready/fixtures.sh b/bin/envs/a11y-ready/fixtures.sh index 3e96728ddf..eba3966af2 100644 --- a/bin/envs/a11y-ready/fixtures.sh +++ b/bin/envs/a11y-ready/fixtures.sh @@ -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='

Synced pattern 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="

Fixture page: the title is hidden and a synced pattern supplies the H1.

" \ + --porcelain) +$WP_CMD post meta update "$HT_PATTERN_ID" neve_meta_disable_title on + echo "== a11y-ready fixtures done ==" diff --git a/e2e-tests/specs/a11y-ready/structure.spec.ts b/e2e-tests/specs/a11y-ready/structure.spec.ts index 7fd94aee00..a3bbd90bf1 100644 --- a/e2e-tests/specs/a11y-ready/structure.spec.ts +++ b/e2e-tests/specs/a11y-ready/structure.spec.ts @@ -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, diff --git a/inc/views/page_header.php b/inc/views/page_header.php index 8c9424f6db..5b07fc8a01 100644 --- a/inc/views/page_header.php +++ b/inc/views/page_header.php @@ -8,6 +8,8 @@ namespace Neve\Views; +use Neve\Core\Migration_Flags; + /** * Class Page_Header * @@ -25,6 +27,22 @@ class Page_Header extends Base_View { 'wrap-class' => '', ); + const H1_PLACEHOLDER = ''; + + /** + * Fallback H1 markup awaiting the buffer decision. + * + * @var string + */ + private $deferred_h1 = ''; + + /** + * Output-buffer level of the main-area buffer, 0 when not buffering. + * + * @var int + */ + private $buffer_level = 0; + /** * Function that is run after instantiation. * @@ -43,6 +61,9 @@ 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() ) { + add_action( 'neve_after_primary_start', array( $this, 'start_h1_buffer' ), 0 ); + } } /** @@ -66,6 +87,115 @@ 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 main area so the screen-reader H1 fallback can be decided + * after every H1 source (content, patterns, builders, 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(); + add_action( 'neve_before_primary_end', array( $this, 'resolve_fallback_h1' ), 0 ); + add_action( 'wp_footer', array( $this, 'resolve_fallback_h1' ), 0 ); + } + + /** + * Output the fallback H1, or its placeholder while buffering. + * + * @param string $title Hidden title text. + * + * @return void + */ + public function fallback_h1( $title ) { + $h1 = '

' . $title . '

'; + if ( $this->buffer_level === 0 ) { + // Not buffering (template without the main-area 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; + } + $this->buffer_level = 0; + ob_end_flush(); + } + + /** + * Output-buffer callback: keep the fallback H1 only when no other H1 rendered. + * + * @param string $markup Buffered main-area 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 script/template/noscript blocks. + * + * @param string $markup Rendered HTML. + * + * @return bool + */ + public static function markup_has_h1( $markup ) { + $stripped = preg_replace( '#<(script|template|noscript)\b[^>]*>.*?#is', '', (string) $markup ); + // On a PCRE limit error keep the original markup rather than scanning nothing. + if ( null === $stripped ) { + $stripped = (string) $markup; + } + + return (bool) preg_match( '/]/i', $stripped ); + } + /** * The page title * diff --git a/tests/test-neve-hidden-title-h1.php b/tests/test-neve-hidden-title-h1.php new file mode 100644 index 0000000000..3ed061338a --- /dev/null +++ b/tests/test-neve-hidden-title-h1.php @@ -0,0 +1,212 @@ +hooked as $hook ) { + remove_action( $hook[0], $hook[1], $hook[2] ); + } + $this->hooked = array(); + parent::tear_down(); + } + + /** + * Hook an echo callback and remember it for cleanup. + * + * @param string $hook Action name. + * @param string $markup Markup to echo. + * @param int $priority Priority. + */ + private function echo_on( $hook, $markup, $priority = 10 ) { + $callback = function () use ( $markup ) { + echo $markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + }; + add_action( $hook, $callback, $priority ); + $this->hooked[] = array( $hook, $callback, $priority ); + } + + /** + * Create a page, optionally with a hidden title, and load it. + * + * @param string $content Post content. + * @param bool $hidden Hide the title. + * + * @return int + */ + private function visit_page( $content, $hidden = true ) { + $id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_content' => $content, + 'post_title' => 'Fixture page', + ) + ); + if ( $hidden ) { + update_post_meta( $id, 'neve_meta_disable_title', 'on' ); + } + $this->go_to( get_permalink( $id ) ); + + return $id; + } + + /** + * Render the main area the way page.php does and return the markup. + * + * @param int $id Page ID. + * + * @return string + */ + private function render_main( $id ) { + ob_start(); + do_action( 'neve_after_primary_start' ); + do_action( 'neve_page_header', 'single-page' ); + do_action( 'neve_before_content', 'single-page' ); + echo apply_filters( 'the_content', get_post_field( 'post_content', $id ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + do_action( 'neve_after_content', 'single-page' ); + do_action( 'neve_before_primary_end' ); + + return ob_get_clean(); + } + + /** + * Count H1 elements in markup. + * + * @param string $markup HTML. + * + * @return int + */ + private function count_h1( $markup ) { + return preg_match_all( '/]/i', $markup ); + } + + /** + * Hidden title with no other H1 gets the screen-reader fallback once. + */ + public function test_fallback_renders_when_nothing_else_provides_h1() { + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_main( $id ); + + $this->assertSame( 1, $this->count_h1( $markup ) ); + $this->assertStringContainsString( '

Fixture page

', $markup ); + $this->assertStringNotContainsString( \Neve\Views\Page_Header::H1_PLACEHOLDER, $markup ); + } + + /** + * An H1 in the content suppresses the fallback. + */ + public function test_content_h1_suppresses_fallback() { + $id = $this->visit_page( '

Content heading

Body

' ); + $markup = $this->render_main( $id ); + + $this->assertSame( 1, $this->count_h1( $markup ) ); + $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + } + + /** + * An H1 hooked after the page header (custom layouts) suppresses the fallback. + */ + public function test_hooked_h1_after_header_suppresses_fallback() { + $this->echo_on( 'neve_before_content', '

Layout heading

' ); + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_main( $id ); + + $this->assertSame( 1, $this->count_h1( $markup ) ); + $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + } + + /** + * An H1 hooked before the page header (blog-page layouts) suppresses the fallback. + */ + public function test_hooked_h1_before_header_suppresses_fallback() { + $this->echo_on( 'neve_after_primary_start', '

Early layout heading

', 5 ); + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_main( $id ); + + $this->assertSame( 1, $this->count_h1( $markup ) ); + $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + } + + /** + * An H1 inside a script template is not a heading, so the fallback stays. + */ + public function test_h1_inside_script_does_not_count() { + $this->echo_on( 'neve_before_content', '' ); + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_main( $id ); + + $this->assertStringContainsString( '

Fixture page

', $markup ); + } + + /** + * A visible title renders the normal H1 and never the fallback. + */ + public function test_visible_title_renders_normal_h1() { + $id = $this->visit_page( '

Body only

', false ); + $markup = $this->render_main( $id ); + + $this->assertSame( 1, $this->count_h1( $markup ) ); + $this->assertStringContainsString( '

Fixture page

', $markup ); + $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + } + + /** + * Without the main-area hooks the content scan still decides the fallback. + */ + public function test_unbuffered_path_keeps_content_scan() { + $view = new \Neve\Views\Page_Header(); + + $this->visit_page( '

Content heading

' ); + ob_start(); + $view->fallback_h1( 'Fixture page' ); + $this->assertSame( '', ob_get_clean() ); + + $this->visit_page( '

Body only

' ); + ob_start(); + $view->fallback_h1( 'Fixture page' ); + $this->assertSame( '

Fixture page

', ob_get_clean() ); + } + + /** + * Output buffering never drops or reorders the rendered main area. + */ + public function test_markup_is_passed_through_unchanged() { + $this->echo_on( 'neve_before_content', '

before

' ); + $this->echo_on( 'neve_after_content', '

after

' ); + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_main( $id ); + + $this->assertMatchesRegularExpression( '#screen-reader-text.*class="before".*Body only.*class="after"#s', $markup ); + } +} diff --git a/views/page-header.php b/views/page-header.php index 916b00d8a3..74342542e2 100644 --- a/views/page-header.php +++ b/views/page-header.php @@ -5,26 +5,18 @@ * @package Neve */ -use Neve\Core\Migration_Flags; +use Neve\Views\Page_Header; + +if ( ! isset( $this ) || ! $this instanceof Page_Header ) { + return; +} $title_style = apply_filters( 'neve_title_alignment_style', '', 'normal' ); if ( ! empty( $title_style ) ) { $title_style = 'style="' . $title_style . '"'; } -$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(); -$specific_hide_title = get_post_meta( $pid, 'neve_meta_disable_title', true ); - -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() ); -} - -if ( ! empty( $specific_hide_title ) ) { - $hide_title = $specific_hide_title === 'on'; -} +$hide_title = $this->is_title_hidden(); // The search title embeds get_search_query(), which is already escaped; // decoding it would re-arm user-supplied markup. @@ -44,13 +36,7 @@ ]/i', (string) get_post_field( 'post_content', $pid ) ); - - if ( ! $content_has_h1 ) { - echo '

' . wp_kses_post( $title_text ) . '

'; - } + // A hidden title still needs an H1 for screen readers (a11y-ready criterion 6), + // but only when nothing else on the page provides one; see Page_Header::fallback_h1(). + $this->fallback_h1( $title_text ); } From 937dfe56b00768f3688b3d04075d51f4eda8547b Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 16 Oct 2026 12:38:10 +0530 Subject: [PATCH 02/16] fix: buffer the whole body for the hidden-title H1 decision Start at wp_body_open and resolve at wp_footer so header and footer custom layouts count, ignore H1s inside HTML comments, and strip scripts before comments so a lone "#s' ), + '', + (string) $markup + ); // On a PCRE limit error keep the original markup rather than scanning nothing. if ( null === $stripped ) { $stripped = (string) $markup; diff --git a/tests/test-neve-hidden-title-h1.php b/tests/test-neve-hidden-title-h1.php index 3ed061338a..93435fdbdb 100644 --- a/tests/test-neve-hidden-title-h1.php +++ b/tests/test-neve-hidden-title-h1.php @@ -13,7 +13,7 @@ class TestNeveHiddenTitleH1 extends WP_UnitTestCase { /** * Callbacks added during a test, removed in tear_down. * - * @var array + * @var list */ private $hooked = array(); @@ -23,6 +23,8 @@ class TestNeveHiddenTitleH1 extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); + // unfiltered_html: fixtures with script tags and HTML comments must survive saving. + wp_set_current_user( 1 ); if ( class_exists( 'WooCommerce', false ) && ! function_exists( 'is_woocommerce' ) ) { function is_woocommerce() { return false; @@ -57,14 +59,13 @@ private function echo_on( $hook, $markup, $priority = 10 ) { } /** - * Create a page, optionally with a hidden title, and load it. + * Create a hidden-title page and load it. * * @param string $content Post content. - * @param bool $hidden Hide the title. * * @return int */ - private function visit_page( $content, $hidden = true ) { + private function visit_page( $content ) { $id = self::factory()->post->create( array( 'post_type' => 'page', @@ -73,29 +74,34 @@ private function visit_page( $content, $hidden = true ) { 'post_title' => 'Fixture page', ) ); - if ( $hidden ) { - update_post_meta( $id, 'neve_meta_disable_title', 'on' ); - } + update_post_meta( $id, 'neve_meta_disable_title', 'on' ); $this->go_to( get_permalink( $id ) ); return $id; } /** - * Render the main area the way page.php does and return the markup. + * Render the page body the way header.php, page.php and footer.php do. * * @param int $id Page ID. * * @return string */ - private function render_main( $id ) { + private function render_page( $id ) { ob_start(); + do_action( 'wp_body_open' ); + do_action( 'neve_after_header_hook' ); do_action( 'neve_after_primary_start' ); do_action( 'neve_page_header', 'single-page' ); do_action( 'neve_before_content', 'single-page' ); echo apply_filters( 'the_content', get_post_field( 'post_content', $id ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped do_action( 'neve_after_content', 'single-page' ); do_action( 'neve_before_primary_end' ); + do_action( 'neve_before_footer_hook' ); + // Core's own wp_footer callbacks print scripts enqueued by other tests and a deprecation notice. + remove_action( 'wp_footer', 'the_block_template_skip_link' ); + remove_action( 'wp_footer', 'wp_print_footer_scripts', 20 ); + do_action( 'wp_footer' ); return ob_get_clean(); } @@ -112,26 +118,45 @@ private function count_h1( $markup ) { } /** - * Hidden title with no other H1 gets the screen-reader fallback once. + * Assert the page has exactly one H1 and it is not the fallback. + * + * @param string $markup Rendered page. */ - public function test_fallback_renders_when_nothing_else_provides_h1() { + private function assert_single_foreign_h1( $markup ) { + $this->assertSame( 1, $this->count_h1( $markup ) ); + $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + $this->assertStringNotContainsString( \Neve\Views\Page_Header::H1_PLACEHOLDER, $markup ); + } + + /** + * Hidden title with no other H1 gets the screen-reader fallback once, in place. + */ + public function test_fallback_renders_once_in_title_position() { + $this->echo_on( 'neve_before_content', '

before

' ); + $this->echo_on( 'neve_after_content', '

after

' ); $id = $this->visit_page( '

Body only

' ); - $markup = $this->render_main( $id ); + $markup = $this->render_page( $id ); $this->assertSame( 1, $this->count_h1( $markup ) ); - $this->assertStringContainsString( '

Fixture page

', $markup ); $this->assertStringNotContainsString( \Neve\Views\Page_Header::H1_PLACEHOLDER, $markup ); + $this->assertMatchesRegularExpression( '#

Fixture page

.*class="before".*Body only.*class="after"#s', $markup ); } /** - * An H1 in the content suppresses the fallback. + * An H1 that exists only in rendered output (synced pattern) suppresses the fallback. */ - public function test_content_h1_suppresses_fallback() { - $id = $this->visit_page( '

Content heading

Body

' ); - $markup = $this->render_main( $id ); + public function test_pattern_h1_suppresses_fallback() { + $block_id = self::factory()->post->create( + array( + 'post_type' => 'wp_block', + 'post_status' => 'publish', + 'post_content' => '

Pattern heading

', + ) + ); + $id = $this->visit_page( '

Body

' ); + $markup = $this->render_page( $id ); - $this->assertSame( 1, $this->count_h1( $markup ) ); - $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + $this->assert_single_foreign_h1( $markup ); } /** @@ -140,10 +165,9 @@ public function test_content_h1_suppresses_fallback() { public function test_hooked_h1_after_header_suppresses_fallback() { $this->echo_on( 'neve_before_content', '

Layout heading

' ); $id = $this->visit_page( '

Body only

' ); - $markup = $this->render_main( $id ); + $markup = $this->render_page( $id ); - $this->assertSame( 1, $this->count_h1( $markup ) ); - $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + $this->assert_single_foreign_h1( $markup ); } /** @@ -152,37 +176,68 @@ public function test_hooked_h1_after_header_suppresses_fallback() { public function test_hooked_h1_before_header_suppresses_fallback() { $this->echo_on( 'neve_after_primary_start', '

Early layout heading

', 5 ); $id = $this->visit_page( '

Body only

' ); - $markup = $this->render_main( $id ); + $markup = $this->render_page( $id ); - $this->assertSame( 1, $this->count_h1( $markup ) ); - $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + $this->assert_single_foreign_h1( $markup ); + } + + /** + * An H1 in a header custom layout suppresses the fallback. + */ + public function test_header_hook_h1_suppresses_fallback() { + $this->echo_on( 'neve_after_header_hook', '

Header layout heading

' ); + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_page( $id ); + + $this->assert_single_foreign_h1( $markup ); + } + + /** + * An H1 in a footer custom layout suppresses the fallback. + */ + public function test_footer_hook_h1_suppresses_fallback() { + $this->echo_on( 'neve_before_footer_hook', '

Footer layout heading

' ); + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_page( $id ); + + $this->assert_single_foreign_h1( $markup ); } /** * An H1 inside a script template is not a heading, so the fallback stays. */ public function test_h1_inside_script_does_not_count() { - $this->echo_on( 'neve_before_content', '' ); - $id = $this->visit_page( '

Body only

' ); - $markup = $this->render_main( $id ); + $id = $this->visit_page( '' ); + $markup = $this->render_page( $id ); + $this->assertSame( 1, $this->count_h1( preg_replace( '##s', '', $markup ) ) ); $this->assertStringContainsString( '

Fixture page

', $markup ); } /** - * A visible title renders the normal H1 and never the fallback. + * An H1 inside an HTML comment is not a heading, so the fallback stays. */ - public function test_visible_title_renders_normal_h1() { - $id = $this->visit_page( '

Body only

', false ); - $markup = $this->render_main( $id ); + public function test_h1_inside_comment_does_not_count() { + $id = $this->visit_page( '' ); + $markup = $this->render_page( $id ); - $this->assertSame( 1, $this->count_h1( $markup ) ); - $this->assertStringContainsString( '

Fixture page

', $markup ); - $this->assertStringNotContainsString( 'screen-reader-text', $markup ); + $this->assertStringContainsString( '

Fixture page

', $markup ); + } + + /** + * A lone "' ); + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_page( $id ); + + $this->assert_single_foreign_h1( $markup ); } /** - * Without the main-area hooks the content scan still decides the fallback. + * Without wp_body_open the content scan still decides the fallback. */ public function test_unbuffered_path_keeps_content_scan() { $view = new \Neve\Views\Page_Header(); @@ -197,16 +252,4 @@ public function test_unbuffered_path_keeps_content_scan() { $view->fallback_h1( 'Fixture page' ); $this->assertSame( '

Fixture page

', ob_get_clean() ); } - - /** - * Output buffering never drops or reorders the rendered main area. - */ - public function test_markup_is_passed_through_unchanged() { - $this->echo_on( 'neve_before_content', '

before

' ); - $this->echo_on( 'neve_after_content', '

after

' ); - $id = $this->visit_page( '

Body only

' ); - $markup = $this->render_main( $id ); - - $this->assertMatchesRegularExpression( '#screen-reader-text.*class="before".*Body only.*class="after"#s', $markup ); - } } From 121d8f84cf5deb731ed9960eb54b116f4667c3cb Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 16 Oct 2026 13:35:52 +0530 Subject: [PATCH 03/16] fix: span the hidden-title H1 buffer across the theme body hooks Open on neve_body_start_after (wp_body_open as fallback) and resolve on neve_body_end_before so custom layouts on the body start and end hooks count. Tests: one data-provider case per body hook, a stray open buffer, the wp_body_open fallback, and a placement test that fails on the base tree. Co-Authored-By: Claude Fable 5.1 --- inc/views/page_header.php | 11 ++- tests/test-neve-hidden-title-h1.php | 125 ++++++++++++++++++---------- 2 files changed, 90 insertions(+), 46 deletions(-) diff --git a/inc/views/page_header.php b/inc/views/page_header.php index ec5cbfeea7..4c4a35928a 100644 --- a/inc/views/page_header.php +++ b/inc/views/page_header.php @@ -37,7 +37,7 @@ class Page_Header extends Base_View { private $deferred_h1 = ''; /** - * Output-buffer level of the main-area buffer, 0 when not buffering. + * Output-buffer level of the page-body buffer, 0 when not buffering. * * @var int */ @@ -62,6 +62,8 @@ public function run() { 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 ); } } @@ -122,7 +124,8 @@ public function start_h1_buffer() { } ob_start( array( $this, 'filter_h1_buffer' ) ); $this->buffer_level = ob_get_level(); - add_action( 'wp_footer', array( $this, 'resolve_fallback_h1' ), PHP_INT_MAX ); + // 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 ); } /** @@ -135,7 +138,7 @@ public function start_h1_buffer() { public function fallback_h1( $title ) { $h1 = '

' . $title . '

'; if ( $this->buffer_level === 0 ) { - // Not buffering (template without wp_body_open): fall back to the content scan. + // 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 ); @@ -166,7 +169,7 @@ public function resolve_fallback_h1() { /** * Output-buffer callback: keep the fallback H1 only when no other H1 rendered. * - * @param string $markup Buffered main-area markup. + * @param string $markup Buffered page-body markup. * * @return string */ diff --git a/tests/test-neve-hidden-title-h1.php b/tests/test-neve-hidden-title-h1.php index 93435fdbdb..931c5c4b4b 100644 --- a/tests/test-neve-hidden-title-h1.php +++ b/tests/test-neve-hidden-title-h1.php @@ -19,7 +19,7 @@ class TestNeveHiddenTitleH1 extends WP_UnitTestCase { /** * The cart-guard stubs declare a WooCommerce class but not is_woocommerce(), - * which the compat layer calls on the main-area hooks used here. + * which the compat layer calls on the body hooks used here. */ public function set_up() { parent::set_up(); @@ -81,14 +81,15 @@ private function visit_page( $content ) { } /** - * Render the page body the way header.php, page.php and footer.php do. + * Fire the body hooks in the order header.php, page.php and footer.php do. * - * @param int $id Page ID. - * - * @return string + * @param int $id Page ID. + * @param bool $theme_body Fire the theme's own body-start hook; false mimics a template with only wp_body_open. */ - private function render_page( $id ) { - ob_start(); + private function fire_page_hooks( $id, $theme_body = true ) { + if ( $theme_body ) { + do_action( 'neve_body_start_after' ); + } do_action( 'wp_body_open' ); do_action( 'neve_after_header_hook' ); do_action( 'neve_after_primary_start' ); @@ -102,6 +103,19 @@ private function render_page( $id ) { remove_action( 'wp_footer', 'the_block_template_skip_link' ); remove_action( 'wp_footer', 'wp_print_footer_scripts', 20 ); do_action( 'wp_footer' ); + do_action( 'neve_body_end_before' ); + } + + /** + * Render the page body and return the markup. + * + * @param int $id Page ID. + * + * @return string + */ + private function render_page( $id ) { + ob_start(); + $this->fire_page_hooks( $id ); return ob_get_clean(); } @@ -134,69 +148,96 @@ private function assert_single_foreign_h1( $markup ) { public function test_fallback_renders_once_in_title_position() { $this->echo_on( 'neve_before_content', '

before

' ); $this->echo_on( 'neve_after_content', '

after

' ); - $id = $this->visit_page( '

Body only

' ); + // The noscript H1 is not a heading; the base content scan would wrongly count it. + $id = $this->visit_page( '

Body only

' ); $markup = $this->render_page( $id ); - $this->assertSame( 1, $this->count_h1( $markup ) ); + $this->assertSame( 1, substr_count( $markup, '

' ) ); $this->assertStringNotContainsString( \Neve\Views\Page_Header::H1_PLACEHOLDER, $markup ); $this->assertMatchesRegularExpression( '#

Fixture page

.*class="before".*Body only.*class="after"#s', $markup ); } /** - * An H1 that exists only in rendered output (synced pattern) suppresses the fallback. + * A template without the theme's body-start hook still buffers from wp_body_open. */ - public function test_pattern_h1_suppresses_fallback() { - $block_id = self::factory()->post->create( - array( - 'post_type' => 'wp_block', - 'post_status' => 'publish', - 'post_content' => '

Pattern heading

', - ) - ); - $id = $this->visit_page( '

Body

' ); - $markup = $this->render_page( $id ); + public function test_buffer_starts_on_wp_body_open_without_theme_hook() { + $this->echo_on( 'neve_before_content', '

Layout heading

' ); + $id = $this->visit_page( '

Body only

' ); + ob_start(); + $this->fire_page_hooks( $id, false ); + $markup = ob_get_clean(); $this->assert_single_foreign_h1( $markup ); } /** - * An H1 hooked after the page header (custom layouts) suppresses the fallback. + * A buffer opened after ours and left open must not be closed by us; the + * placeholder is still resolved when the outer buffers flush. */ - public function test_hooked_h1_after_header_suppresses_fallback() { - $this->echo_on( 'neve_before_content', '

Layout heading

' ); - $id = $this->visit_page( '

Body only

' ); - $markup = $this->render_page( $id ); + public function test_stray_open_buffer_is_left_alone() { + $stray = function () { + ob_start(); + }; + add_action( 'wp_body_open', $stray, 5 ); + $this->hooked[] = array( 'wp_body_open', $stray, 5 ); - $this->assert_single_foreign_h1( $markup ); + $id = $this->visit_page( '

Body only

' ); + ob_start(); + $level = ob_get_level(); + $this->fire_page_hooks( $id ); + $this->assertGreaterThan( $level, ob_get_level(), 'the stray and page-body buffers are still open after the hooks' ); + while ( ob_get_level() > $level ) { + ob_end_flush(); + } + $markup = ob_get_clean(); + + $this->assertSame( 1, substr_count( $markup, '

' ) ); + $this->assertStringNotContainsString( \Neve\Views\Page_Header::H1_PLACEHOLDER, $markup ); } /** - * An H1 hooked before the page header (blog-page layouts) suppresses the fallback. + * An H1 that exists only in rendered output (synced pattern) suppresses the fallback. */ - public function test_hooked_h1_before_header_suppresses_fallback() { - $this->echo_on( 'neve_after_primary_start', '

Early layout heading

', 5 ); - $id = $this->visit_page( '

Body only

' ); - $markup = $this->render_page( $id ); + public function test_pattern_h1_suppresses_fallback() { + $block_id = self::factory()->post->create( + array( + 'post_type' => 'wp_block', + 'post_status' => 'publish', + 'post_content' => '

Pattern heading

', + ) + ); + $id = $this->visit_page( '

Body

' ); + $markup = $this->render_page( $id ); $this->assert_single_foreign_h1( $markup ); } /** - * An H1 in a header custom layout suppresses the fallback. + * Hooks a custom layout can render an H1 on, with the priority used in the test. + * + * @return array */ - public function test_header_hook_h1_suppresses_fallback() { - $this->echo_on( 'neve_after_header_hook', '

Header layout heading

' ); - $id = $this->visit_page( '

Body only

' ); - $markup = $this->render_page( $id ); - - $this->assert_single_foreign_h1( $markup ); + public function hook_provider() { + return array( + 'body start' => array( 'neve_body_start_after', 10 ), + 'after header' => array( 'neve_after_header_hook', 10 ), + 'before the header' => array( 'neve_after_primary_start', 5 ), + 'before content' => array( 'neve_before_content', 10 ), + 'before footer' => array( 'neve_before_footer_hook', 10 ), + 'body end' => array( 'neve_body_end_before', 10 ), + ); } /** - * An H1 in a footer custom layout suppresses the fallback. + * An H1 rendered on any body hook suppresses the fallback. + * + * @dataProvider hook_provider + * + * @param string $hook Action the H1 is echoed on. + * @param int $priority Priority of the echo. */ - public function test_footer_hook_h1_suppresses_fallback() { - $this->echo_on( 'neve_before_footer_hook', '

Footer layout heading

' ); + public function test_hooked_h1_suppresses_fallback( $hook, $priority ) { + $this->echo_on( $hook, '

Layout heading

', $priority ); $id = $this->visit_page( '

Body only

' ); $markup = $this->render_page( $id ); @@ -237,7 +278,7 @@ public function test_unbalanced_comment_in_script_does_not_hide_h1() { } /** - * Without wp_body_open the content scan still decides the fallback. + * Without the body hooks the content scan still decides the fallback. */ public function test_unbuffered_path_keeps_content_scan() { $view = new \Neve\Views\Page_Header(); From 0830d445714bf79600ed5ce596fe91b196f3e8bc Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 16 Oct 2026 17:35:32 +0530 Subject: [PATCH 04/16] test: make the hidden-title H1 tests order-independent and tighter The bootstrap assigns user 1 directly, so wp_set_current_user() left kses on and stripped the noscript fixture when the class ran alone. Assert the exact buffer level in the stray-buffer case and reuse render_page() for the wp_body_open-only render. Co-Authored-By: Claude Fable 5.1 --- tests/test-neve-hidden-title-h1.php | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/test-neve-hidden-title-h1.php b/tests/test-neve-hidden-title-h1.php index 931c5c4b4b..073fd403e9 100644 --- a/tests/test-neve-hidden-title-h1.php +++ b/tests/test-neve-hidden-title-h1.php @@ -23,8 +23,10 @@ class TestNeveHiddenTitleH1 extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); - // unfiltered_html: fixtures with script tags and HTML comments must survive saving. + // Fixtures with script tags and HTML comments must survive saving; the bootstrap + // assigns user 1 directly, so wp_set_current_user() alone leaves kses on. wp_set_current_user( 1 ); + kses_remove_filters(); if ( class_exists( 'WooCommerce', false ) && ! function_exists( 'is_woocommerce' ) ) { function is_woocommerce() { return false; @@ -40,6 +42,7 @@ public function tear_down() { remove_action( $hook[0], $hook[1], $hook[2] ); } $this->hooked = array(); + kses_init_filters(); parent::tear_down(); } @@ -109,13 +112,14 @@ private function fire_page_hooks( $id, $theme_body = true ) { /** * Render the page body and return the markup. * - * @param int $id Page ID. + * @param int $id Page ID. + * @param bool $theme_body See fire_page_hooks(). * * @return string */ - private function render_page( $id ) { + private function render_page( $id, $theme_body = true ) { ob_start(); - $this->fire_page_hooks( $id ); + $this->fire_page_hooks( $id, $theme_body ); return ob_get_clean(); } @@ -162,10 +166,8 @@ public function test_fallback_renders_once_in_title_position() { */ public function test_buffer_starts_on_wp_body_open_without_theme_hook() { $this->echo_on( 'neve_before_content', '

Layout heading

' ); - $id = $this->visit_page( '

Body only

' ); - ob_start(); - $this->fire_page_hooks( $id, false ); - $markup = ob_get_clean(); + $id = $this->visit_page( '

Body only

' ); + $markup = $this->render_page( $id, false ); $this->assert_single_foreign_h1( $markup ); } @@ -185,7 +187,7 @@ public function test_stray_open_buffer_is_left_alone() { ob_start(); $level = ob_get_level(); $this->fire_page_hooks( $id ); - $this->assertGreaterThan( $level, ob_get_level(), 'the stray and page-body buffers are still open after the hooks' ); + $this->assertSame( $level + 2, ob_get_level(), 'the stray and page-body buffers are both still open after the hooks' ); while ( ob_get_level() > $level ) { ob_end_flush(); } From 6e633c29a3c68cf37ab9f7f52cbd15f7d231749e Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Mon, 19 Oct 2026 11:07:10 +0530 Subject: [PATCH 05/16] fix: scan the body for an H1 without a regex that can exhaust the backtrack limit The lazy regex that stripped script, template, noscript and comment blocks gave up on an inline script larger than pcre.backtrack_limit (1 MB by default). preg_replace returned null, the unstripped body was scanned instead, and a literal "

" inside the script counted as a real heading, so a hidden-title page lost its only H1. Found by QA on issue #4652; the same scan miscounted on the base tree. Replace it with a single left-to-right pass over the markup that skips comments and the raw-text elements by string search, which is linear and has no limit. Tests: the 1.2 MB script fixture plus a data provider for the comment and raw-text edge cases. Co-Authored-By: Claude Fable 5.1 --- inc/views/page_header.php | 64 ++++++++++++++++++++++++----- tests/test-neve-hidden-title-h1.php | 50 ++++++++++++++++++++++ 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/inc/views/page_header.php b/inc/views/page_header.php index 4c4a35928a..5ce1de333a 100644 --- a/inc/views/page_header.php +++ b/inc/views/page_header.php @@ -189,18 +189,62 @@ public function filter_h1_buffer( $markup ) { * @return bool */ public static function markup_has_h1( $markup ) { - // Scripts first: a lone "#s' ), - '', - (string) $markup - ); - // On a PCRE limit error keep the original markup rather than scanning nothing. - if ( null === $stripped ) { - $stripped = (string) $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, '<' ); + while ( false !== $offset ) { + if ( '', $offset + 4 ); + if ( false === $close ) { + return false; + } + $offset = $close + 3; + } elseif ( preg_match( '#\G<([a-z][a-z0-9]*)(?=[\s/>])#i', $markup, $match, 0, $offset ) ) { + $tag = strtolower( $match[1] ); + if ( 'h1' === $tag ) { + return true; + } + $offset += strlen( $match[0] ); + if ( in_array( $tag, array( 'script', 'template', 'noscript' ), 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 the closing tag of an 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 = '' === $markup[ $end ] ) { + return $end + 1; + } + $pos = stripos( $markup, $needle, $end ); } - return (bool) preg_match( '/]/i', $stripped ); + return false; } /** diff --git a/tests/test-neve-hidden-title-h1.php b/tests/test-neve-hidden-title-h1.php index 073fd403e9..0d9e8a5673 100644 --- a/tests/test-neve-hidden-title-h1.php +++ b/tests/test-neve-hidden-title-h1.php @@ -257,6 +257,56 @@ public function test_h1_inside_script_does_not_count() { $this->assertStringContainsString( '

Fixture page

', $markup ); } + /** + * A script larger than pcre.backtrack_limit (1 MB by default) must still be + * skipped: the regex scan gave up there and counted the literal H1 inside it. + */ + public function test_large_inert_script_keeps_fallback() { + $json = '{"html":"

Not a real heading

","pad":"' . str_repeat( 'x', 1200000 ) . '"}'; + $script = ''; + + $this->assertFalse( \Neve\Views\Page_Header::markup_has_h1( $script ) ); + $this->assertTrue( \Neve\Views\Page_Header::markup_has_h1( $script . '

Real heading

' ) ); + + $id = $this->visit_page( '' . $script . '' ); + $markup = $this->render_page( $id ); + + $this->assertStringContainsString( '

Fixture page

', $markup ); + } + + /** + * Raw-text and comment edge cases the scanner has to get right. + * + * @dataProvider markup_provider + * + * @param string $markup HTML. + * @param bool $expected Whether it holds a real H1. + */ + public function test_markup_has_h1( $markup, $expected ) { + $this->assertSame( $expected, \Neve\Views\Page_Header::markup_has_h1( $markup ) ); + } + + /** + * @return array + */ + public function markup_provider() { + return array( + 'plain h1' => array( '

Title

', true ), + 'h1 with attributes' => array( '

Title

', true ), + 'h10 is not h1' => array( 'Nope', false ), + 'h1 after a comment' => array( '

Title

', true ), + 'h1 in unterminated comment' => array( '

Title

', true ), + 'stray angle bracket' => array( '<

Title

', true ), + ); + } + /** * An H1 inside an HTML comment is not a heading, so the fallback stays. */ From 6e22b17aca52b850dcfac7e4366eadfd882d5cb8 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Tue, 20 Oct 2026 11:48:23 +0530 Subject: [PATCH 06/16] fix: drop the ctype dependency from the closing-tag search ctype_space() needs ext-ctype, which WordPress only recommends. Use strspn() over the HTML whitespace set instead. Co-Authored-By: Claude Fable 5.1 --- inc/views/page_header.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/inc/views/page_header.php b/inc/views/page_header.php index 5ce1de333a..78133d7bb0 100644 --- a/inc/views/page_header.php +++ b/inc/views/page_header.php @@ -234,10 +234,8 @@ private static function find_closing_tag( $markup, $tag, $offset ) { $needle = '' === $markup[ $end ] ) { return $end + 1; } From b01881a8c5606d03e37807274e823ce9818d16b4 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Tue, 20 Oct 2026 14:27:12 +0530 Subject: [PATCH 07/16] fix: treat template contents as nested markup in the H1 scan Browsers parse ", must not close the outer one. The scan now tracks template depth in its main loop and ignores H1s inside it; find_closing_tag() stays a raw-text search for script and noscript. Found by QA on issue #4652. Co-Authored-By: Claude Fable 5.1 --- inc/views/page_header.php | 18 +++++++++++------- tests/test-neve-hidden-title-h1.php | 7 +++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/inc/views/page_header.php b/inc/views/page_header.php index 78133d7bb0..28d1f0038c 100644 --- a/inc/views/page_header.php +++ b/inc/views/page_header.php @@ -193,6 +193,8 @@ public static function markup_has_h1( $markup ) { // on a large inline script (around 1 MB), and a failed scan must not miscount an H1. $markup = (string) $markup; $offset = strpos( $markup, '<' ); + // Open ', false ), + 'template closer in script inside template' => array( '', false ), + 'h1 after template holding a commented closer' => array( '

Title

', true ), 'h1 in noscript' => array( '', false ), 'h1 in unterminated script' => array( '

Title

', true ), From 469eeaea2861c2b32b28859779746b29a38693fa Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Tue, 20 Oct 2026 15:54:34 +0530 Subject: [PATCH 08/16] test: drop two template scan rows that pass without the fix Co-Authored-By: Claude Fable 5.1 --- tests/test-neve-hidden-title-h1.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test-neve-hidden-title-h1.php b/tests/test-neve-hidden-title-h1.php index bcfd71fa35..c53e088ab3 100644 --- a/tests/test-neve-hidden-title-h1.php +++ b/tests/test-neve-hidden-title-h1.php @@ -300,11 +300,9 @@ public function markup_provider() { 'h1 in template' => array( '', false ), 'h1 in nested template' => array( '', false ), 'h1 after nested template' => array( '

Title

', true ), - 'template name prefix' => array( '', false ), 'h1 after unterminated nested template' => array( '