diff --git a/bin/envs/cli-setup.sh b/bin/envs/cli-setup.sh index e1a98de0c5..4d1c342cd2 100755 --- a/bin/envs/cli-setup.sh +++ b/bin/envs/cli-setup.sh @@ -21,6 +21,13 @@ init_environment(){ echo "Installing Neve theme from $NEVE_LOCATION" wp --allow-root theme install --activate $NEVE_LOCATION wp --allow-root option update fresh_site 0 + + # Activating Neve remaps widgets by sidebar id, and 'blog-sidebar' does not + # match the previous theme's, so the default widgets can be left unassigned. + if [ "$(wp --allow-root widget list blog-sidebar --format=count)" = "0" ]; then + echo "Populating blog-sidebar" + wp --allow-root widget add block blog-sidebar --content='' + fi echo "Installing Theme API Plugin" wp --allow-root plugin install https://github.com/codeinwp/wp-thememods-api/archive/refs/heads/main.zip --force --activate } diff --git a/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts b/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts index 17df82fc77..1cbcf2c52e 100755 --- a/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts +++ b/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts @@ -2,6 +2,22 @@ import { test, expect } from '@playwright/test'; import { setCustomizeSettings } from '../../../utils'; import data from '../../../fixtures/customizer/layout/blog-archive-setting-setup.json'; +const SEARCH_TERM = 'nvexcerpthtmlfixture'; +const POST_SLUG = 'nv-excerpt-html-fixture'; +const LINK_HREF = 'https://example.com/neve-excerpt-link'; +const EXCERPT = `Neve keeps this excerpt link visible while trimming the rest sentinelword away.`; + +const excerptHtmlSettings = { + neve_blog_archive_layout: 'default', + neve_post_excerpt_length: 8, + neve_post_content_ordering: '["title-meta","excerpt"]', +}; + +const excerptHtmlCutSettings = { + ...excerptHtmlSettings, + neve_post_excerpt_length: 4, +}; + test.describe('Blog/Archive 1 / Default Layout', () => { test.beforeAll(async ({ request, baseURL }) => { await setCustomizeSettings('defaultLayout', data.archive1, { @@ -283,3 +299,97 @@ test.describe('Blog/Archive 4 / Default Layout', () => { ).toEqual(0); }); }); + +test.describe('Blog/Archive / Excerpt markup', () => { + test.describe.configure({ mode: 'serial' }); + + let postId: number; + + test.beforeAll(async ({ request, baseURL }) => { + await setCustomizeSettings('excerptHtml', excerptHtmlSettings, { + request, + baseURL, + }); + await setCustomizeSettings('excerptHtmlCut', excerptHtmlCutSettings, { + request, + baseURL, + }); + + // A run killed before afterAll leaves the post behind; drop it by slug so + // the search page has exactly one result either way. + const stale = await request.get( + baseURL + `/wp-json/wp/v2/posts?slug=${POST_SLUG}&status=any` + ); + expect(stale.ok()).toBeTruthy(); + for (const post of await stale.json()) { + const staleDeleteResponse = await request.delete( + baseURL + `/wp-json/wp/v2/posts/${post.id}?force=true` + ); + expect(staleDeleteResponse.ok()).toBeTruthy(); + } + + const response = await request.post(baseURL + '/wp-json/wp/v2/posts', { + data: { + title: `Excerpt markup ${SEARCH_TERM}`, + slug: POST_SLUG, + content: `Body copy for ${SEARCH_TERM}.`, + excerpt: EXCERPT, + status: 'publish', + // Dated far back so the post lands on the last archive page and + // leaves the other archive specs alone. + date: '2001-01-01T00:00:00', + }, + }); + expect(response.ok()).toBeTruthy(); + postId = (await response.json()).id; + }); + + test.afterAll(async ({ request, baseURL }) => { + if (postId) { + const deleteResponse = await request.delete( + baseURL + `/wp-json/wp/v2/posts/${postId}?force=true` + ); + expect(deleteResponse.ok()).toBeTruthy(); + } + }); + + test('Trimming the excerpt keeps its HTML', async ({ page }) => { + await page.goto(`/?s=${SEARCH_TERM}&test_name=excerptHtml`); + + const excerpt = page.locator('article.post .excerpt-wrap'); + await expect(excerpt).toHaveCount(1); + + const link = excerpt.locator(`a[href="${LINK_HREF}"]`); + await expect(link).toBeVisible(); + await expect(link).toHaveText('excerpt link'); + await expect(excerpt.locator('strong')).toHaveText('keeps'); + + const text = await excerpt.innerText(); + // The trim still happens: the last kept word is in, the next one is out. + expect(text).toContain('trimming'); + expect(text).not.toContain('sentinelword'); + // Markup is rendered, not printed as escaped text. + expect(text).not.toContain(' { + await page.goto(`/?s=${SEARCH_TERM}&test_name=excerptHtmlCut`); + + const excerpt = page.locator('article.post .excerpt-wrap'); + await expect(excerpt).toHaveCount(1); + + // Word 4 is the first half of the link text and word 5 is past the cut, so + // only an anchor closed by the trim can render as a link at all. + const link = excerpt.locator(`a[href="${LINK_HREF}"]`); + await expect(link).toBeVisible(); + await expect(link).toHaveText('excerpt'); + // The read more marker follows the link instead of being swallowed by it. + await expect(link).not.toContainText('…'); + await expect(excerpt.locator('strong')).toHaveText('keeps'); + + const text = await excerpt.innerText(); + expect(text).toContain('…'); + expect(text).not.toContain('visible'); + expect(text).not.toContain('trim_words_keep_html( get_the_excerpt( $post_id ), $length, $excerpt_more ); return apply_filters( 'the_excerpt', $content ); } @@ -89,6 +89,147 @@ private function get_excerpt( $length = 25, $post_id = null ) { return apply_filters( 'the_excerpt', $content ); } + /** + * Trim words while preserving HTML markup. + * + * Similar to wp_trim_words(), but preserves HTML tags. + * + * @param string $text HTML content to trim. + * @param int $num_words Maximum number of words. + * @param string $more String to append when the content is trimmed. + * + * @return string + */ + private function trim_words_keep_html( $text, $num_words, $more = '...' ) { + $num_words = (int) $num_words; + $trimmed = $this->trim_markup( $text, $num_words, $more ); + + return apply_filters( 'wp_trim_words', $trimmed, $num_words, $more, $text ); + } + + /** + * Trim a text to a number of words, leaving the markup around them in place. + * + * @param string $text HTML content to trim. + * @param int $num_words Maximum number of words. + * @param string $more String to append when the content is trimmed. + * + * @return string + */ + private function trim_markup( $text, $num_words, $more ) { + if ( $num_words <= 0 || '' === $text ) { + return ''; + } + + // `wp_get_word_count_type()` is WP 6.2+; older installs count words. + $count_type = function_exists( 'wp_get_word_count_type' ) ? wp_get_word_count_type() : 'words'; + + // Some locales budget characters rather than words, as `wp_trim_words()` does. + $count_chars = 0 === strpos( $count_type, 'characters' ) + && 1 === preg_match( '/^utf\-?8$/i', get_option( 'blog_charset' ) ); + + $tokens = preg_split( + '/(<[^>]*>)/', + $text, + -1, + PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY + ); + + if ( ! is_array( $tokens ) ) { + return wp_trim_words( $text, $num_words, $more ); + } + + $output = ''; + $remaining = $num_words; + $cut = false; + + // Markup and whitespace are held back until a kept word follows them, so a + // tag opened right at the cut does not leave an empty element behind. + $pending = ''; + $pending_cost = 0; + + foreach ( $tokens as $token ) { + // Tokens are split on this same pattern, so a match is one of the tags. + if ( preg_match( '#^<[^>]*>$#', $token ) ) { + $pending .= $token; + + continue; + } + + $parts = preg_split( + '/(\s+)/u', + $token, + -1, + PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY + ); + + if ( ! is_array( $parts ) ) { + $output .= $pending . $token; + $pending = ''; + $pending_cost = 0; + + continue; + } + + foreach ( $parts as $part ) { + if ( '' === trim( $part ) ) { + // Core collapses each run of whitespace to a single space. + $pending .= $count_chars ? ' ' : $part; + $pending_cost += $count_chars ? 1 : 0; + + continue; + } + + $remaining -= $pending_cost; + + if ( $remaining <= 0 ) { + $cut = true; + break; + } + + $chars = $count_chars ? $this->split_characters( $part ) : array( $part ); + + // Character locales can cut part way through a run of text. + if ( count( $chars ) > $remaining ) { + $output .= $pending . implode( '', array_slice( $chars, 0, $remaining ) ); + $cut = true; + break; + } + + $output .= $pending . $part; + $pending = ''; + $pending_cost = 0; + $remaining -= count( $chars ); + } + + if ( $cut ) { + break; + } + } + + if ( ! $cut ) { + return $text; + } + + // Close any tags that are still open so the resulting HTML remains valid. + return force_balance_tags( $output ) . $more; + } + + /** + * Split a string into its characters. + * + * @param string $text Text to split. + * + * @return string[] + */ + private function split_characters( $text ) { + if ( ! preg_match_all( '/./u', $text, $matches ) ) { + return array(); + } + + return $matches[0]; + } + /** * Get the excerpt length option casted as `int`. * diff --git a/tests/test-neve-excerpt-html.php b/tests/test-neve-excerpt-html.php new file mode 100644 index 0000000000..4e9e6872bd --- /dev/null +++ b/tests/test-neve-excerpt-html.php @@ -0,0 +1,342 @@ +setAccessible( true ); + + return $method->invoke( $excerpt, $text, $num_words, $this->more ); + } + + /** + * Trim a text with the locale counting characters, as CJK locales do. + * + * @param string $text Text to trim. + * @param int $limit Number of characters to keep. + * + * @return string + */ + private function trim_by_characters( $text, $limit ) { + global $wp_locale; + + $previous = $wp_locale->word_count_type; + $wp_locale->word_count_type = 'characters_including_spaces'; + + $output = $this->trim( $text, $limit ); + + $wp_locale->word_count_type = $previous; + + return $output; + } + + /** + * A link before the cut keeps both its markup and its text. + */ + public function test_keeps_link_when_cut_falls_after_it() { + $text = 'Read the full announcement and then a long tail of words'; + + $this->assertSame( + 'Read the full announcement and then a long' . $this->more, + $this->trim( $text, 8 ) + ); + } + + /** + * A cut inside a link closes it and leaves the marker outside. + */ + public function test_closes_inline_tag_left_open_by_the_cut() { + $text = 'Read the full release announcement tail words'; + + $this->assertSame( + 'Read the full release' . $this->more, + $this->trim( $text, 4 ) + ); + } + + /** + * A text shorter than the limit is returned untouched, marker included. + */ + public function test_returns_short_text_unchanged() { + $text = 'Short bold excerpt'; + + $this->assertSame( $text, $this->trim( $text, 25 ) ); + $this->assertSame( $text, $this->trim( $text, 3 ) ); + } + + /** + * A tag opened right at the cut must not leave an empty element behind. + */ + public function test_drops_element_opened_at_the_cut() { + $this->assertSame( + '

one

two

' . $this->more, + $this->trim( '

one

two

three

', 2 ) + ); + } + + /** + * The same, for a list: an empty `li` would render as a stray bullet. + */ + public function test_drops_list_item_opened_at_the_cut() { + $this->assertSame( + '' . $this->more, + $this->trim( '', 2 ) + ); + } + + /** + * Nested tags are closed at the cut. + */ + public function test_closes_nested_tags() { + $this->assertSame( + 'One two three' . $this->more, + $this->trim( 'One two three four five six', 3 ) + ); + } + + /** + * A tag nested in itself is unwound rather than left doubled. + */ + public function test_unwinds_a_tag_nested_in_itself() { + $this->assertSame( + 'Nested one two' . $this->more, + $this->trim( 'Nested one two three four five', 3 ) + ); + } + + /** + * Markup the author never closed is closed at the cut anyway. + */ + public function test_closes_unbalanced_author_markup() { + $this->assertSame( + 'Unclosed link never closed' . $this->more, + $this->trim( 'Unclosed link never closed and more words', 4 ) + ); + } + + /** + * A closing tag with nothing to close is dropped. + */ + public function test_drops_orphan_closing_tag() { + $this->assertSame( + 'Stray closing tag here' . $this->more, + $this->trim( 'Stray closing tag here and more', 4 ) + ); + } + + /** + * Void elements are neither counted as words nor closed. + */ + public function test_leaves_void_elements_alone() { + $this->assertSame( + 'Image after
text words' . $this->more, + $this->trim( 'Image after
text words more', 4 ) + ); + } + + /** + * A hyphenated custom element is a tag, not three words of text. + */ + public function test_treats_custom_elements_as_markup() { + $this->assertSame( + 'Custom element text' . $this->more, + $this->trim( 'Custom element text here more words', 3 ) + ); + + $this->assertSame( + 'Cut inside' . $this->more, + $this->trim( 'Cut inside element tail', 2 ) + ); + } + + /** + * A tag name carrying a colon is markup too, and is closed at the cut. + */ + public function test_treats_namespaced_elements_as_markup() { + $this->assertSame( + 'Vector icon label' . $this->more, + $this->trim( 'Vector icon label here more words', 3 ) + ); + + $this->assertSame( + 'Cut inside' . $this->more, + $this->trim( 'Cut inside icon tail', 2 ) + ); + } + + /** + * Comments carry no words, so their contents must not spend the budget. + */ + public function test_does_not_count_comments_as_words() { + $this->assertSame( + 'bold text more' . $this->more, + $this->trim( 'bold text more words here', 3 ) + ); + } + + /** + * Whitespace between kept words is preserved rather than normalised. + */ + public function test_preserves_whitespace_between_kept_words() { + $this->assertSame( + "Line\nbreaks and\ttabs" . $this->more, + $this->trim( "Line\nbreaks and\ttabs here plus more", 4 ) + ); + } + + /** + * A zero length keeps nothing. + */ + public function test_zero_length_returns_empty_string() { + $this->assertSame( '', $this->trim( 'a b c', 0 ) ); + $this->assertSame( '', $this->trim( '', 8 ) ); + } + + /** + * Text without markup is trimmed exactly as `wp_trim_words()` would. + */ + public function test_matches_core_for_text_without_markup() { + $text = 'one two three four five six'; + + foreach ( array( 1, 3, 5, 6, 25 ) as $limit ) { + $this->assertSame( + wp_trim_words( $text, $limit, $this->more ), + $this->trim( $text, $limit ), + 'Diverged from wp_trim_words() at length ' . $limit + ); + } + } + + /** + * Character locales keep their markup rather than falling back to core. + */ + public function test_keeps_link_in_character_counting_locales() { + $text = '你好世界
链接文字更多内容'; + + $this->assertSame( + '你好世界链接' . $this->more, + $this->trim_by_characters( $text, 6 ) + ); + } + + /** + * Character locales spend the budget per character, whitespace included. + */ + public function test_matches_core_in_character_counting_locales() { + $cases = array( + array( 'ab cd efgh', 5 ), + array( 'aa bb cc dd', 4 ), + array( 'a b c d e f', 3 ), + array( '你好世界链接文字', 6 ), + ); + + foreach ( $cases as list( $text, $limit ) ) { + global $wp_locale; + + $previous = $wp_locale->word_count_type; + $wp_locale->word_count_type = 'characters_including_spaces'; + $core = wp_trim_words( $text, $limit, $this->more ); + $wp_locale->word_count_type = $previous; + + $this->assertSame( + $core, + $this->trim_by_characters( $text, $limit ), + 'Diverged from wp_trim_words() for "' . $text . '" at length ' . $limit + ); + } + } + + /** + * A text within the character budget is returned untouched. + */ + public function test_character_locales_return_short_text_unchanged() { + $text = '短字'; + + $this->assertSame( $text, $this->trim_by_characters( $text, 25 ) ); + } + + /** + * The `wp_trim_words` filter runs on the result, as it does in core. + */ + public function test_applies_the_wp_trim_words_filter() { + $calls = array(); + + $capture = function ( $trimmed, $num_words, $more, $original ) use ( &$calls ) { + $calls[] = compact( 'num_words', 'more', 'original' ); + + return strtoupper( $trimmed ); + }; + + add_filter( 'wp_trim_words', $capture, 10, 4 ); + + $text = 'one two three four five'; + + // Both the trimmed and the untrimmed return paths go through the filter. + $this->assertSame( 'ONE TWO THREE' . strtoupper( $this->more ), $this->trim( $text, 3 ) ); + $this->assertSame( strtoupper( $text ), $this->trim( $text, 25 ) ); + + remove_filter( 'wp_trim_words', $capture, 10 ); + + $this->assertCount( 2, $calls ); + $this->assertSame( 3, $calls[0]['num_words'] ); + $this->assertSame( $this->more, $calls[0]['more'] ); + $this->assertSame( $text, $calls[0]['original'] ); + } + + /** + * The rendered archive excerpt keeps the link of a hand-written excerpt. + */ + public function test_rendered_excerpt_keeps_the_link() { + $post_id = self::factory()->post->create( + array( + 'post_content' => 'Body content, not used by this assertion.', + 'post_excerpt' => 'Read the full announcement and then a long tail of words to drop', + ) + ); + + $GLOBALS['post'] = get_post( $post_id ); + setup_postdata( $GLOBALS['post'] ); + set_theme_mod( 'neve_post_excerpt_length', 8 ); + + $partial = new \Neve\Views\Partials\Excerpt(); + + ob_start(); + $partial->render_post_excerpt( 'index', $post_id ); + $output = (string) ob_get_clean(); + + remove_theme_mod( 'neve_post_excerpt_length' ); + wp_reset_postdata(); + + $this->assertStringContainsString( 'full announcement', $output ); + $this->assertStringNotContainsString( 'to drop', $output ); + } +}