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..b008f9a4ff 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,36 @@ class Page_Header extends Base_View { 'wrap-class' => '', ); + const H1_PLACEHOLDER = ''; + + /** + * Elements whose contents browsers never parse as tags. + * + * @var list + */ + const RAW_TEXT_TAGS = array( 'script', 'style', 'noscript', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes' ); + + /** + * SVG and MathML elements whose contents are parsed with the HTML rules again. + * + * @var list + */ + 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. * @@ -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 ); + } } /** @@ -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 ); + } + + /** + * 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 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; + } + $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