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( + '
after
after