Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bin/envs/cli-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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='<!-- wp:search /-->'
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
}
Expand Down
110 changes: 110 additions & 0 deletions e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>keeps</strong> this <a href="${LINK_HREF}">excerpt link</a> 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, {
Expand Down Expand Up @@ -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,
});
Comment thread
girishpanchal30 marked this conversation as resolved.

// 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`
);
Comment thread
Copilot marked this conversation as resolved.
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('<a ');
});

test('Trimming inside a link closes the link', async ({ page }) => {
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('<a ');
});
});
143 changes: 142 additions & 1 deletion inc/views/partials/excerpt.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ private function get_excerpt( $length = 25, $post_id = null ) {

if ( has_excerpt( $post_id ) ) {
$excerpt_more = apply_filters( 'excerpt_more', ' [&hellip;]' );
$content = wp_trim_words( get_the_excerpt( $post_id ), $length, $excerpt_more );
$content = $this->trim_words_keep_html( get_the_excerpt( $post_id ), $length, $excerpt_more );

return apply_filters( 'the_excerpt', $content );
}
Expand All @@ -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
*/
Comment thread
girishpanchal30 marked this conversation as resolved.
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' )
Comment thread
girishpanchal30 marked this conversation as resolved.
&& 1 === preg_match( '/^utf\-?8$/i', get_option( 'blog_charset' ) );

$tokens = preg_split(
'/(<[^>]*>)/',
$text,
-1,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
);
Comment thread
girishpanchal30 marked this conversation as resolved.

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`.
*
Expand Down
Loading
Loading