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='
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 listy
y
y
y
', false ), + 'h1 in style' => array( 'y
', false ), + 'h1 in title' => array( 'y
', false ), + 'h1 after self-closing svg title' => array( 'y
', false ), + 'closed svg restores raw text' => array( '', false ), + 'self-closing svg opens nothing' => array( '', false ), + 'svg title is an integration point' => array( 'y
', false ), + 'svg desc is an integration point' => array( 'y
', false ), + 'math mtext is an integration point' => array( '', false ), + 'html title under foreignObject is raw text' => array( 'y
', false ), + 'closed foreignObject returns to foreign rules' => array( '', true ), + 'closed svg title returns to foreign rules' => array( '', true ), + 'closed svg desc returns to foreign rules' => array( '', true ), + 'closed math mi returns to foreign rules' => array( '', true ), + 'script end tag with attributes' => array( 'Body only
' ); + $markup = $this->render_page( $id ); + + $this->assert_single_foreign_h1( $markup ); + } + + /** + * 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(); + + $this->visit_page( 'Body only
' ); + ob_start(); + $view->fallback_h1( 'Fixture page' ); + $this->assertSame( '