···11+import { describe, expect, it } from 'vitest';
22+import { deriveExcerpt } from './excerpt';
33+44+describe( 'deriveExcerpt', () => {
55+ it( 'returns empty string for empty or whitespace-only input', () => {
66+ expect( deriveExcerpt( '' ) ).toBe( '' );
77+ expect( deriveExcerpt( ' \n\t ' ) ).toBe( '' );
88+ } );
99+1010+ it( 'returns short text unchanged, with no ellipsis', () => {
1111+ expect( deriveExcerpt( 'A short lede.' ) ).toBe( 'A short lede.' );
1212+ } );
1313+1414+ it( 'collapses internal whitespace and newlines to single spaces', () => {
1515+ expect( deriveExcerpt( 'one\n\ntwo three' ) ).toBe( 'one two three' );
1616+ } );
1717+1818+ it( 'truncates long text on a word boundary with a trailing ellipsis', () => {
1919+ const long = 'word '.repeat( 100 ).trim(); // 499 chars, all word boundaries
2020+ const result = deriveExcerpt( long );
2121+ expect( result.endsWith( '…' ) ).toBe( true );
2222+ // Body (sans ellipsis) stays within the limit and never splits a word.
2323+ const body = result.slice( 0, -1 );
2424+ expect( body.length ).toBeLessThanOrEqual( 200 );
2525+ expect( body.endsWith( 'word' ) ).toBe( true );
2626+ expect( body.endsWith( ' ' ) ).toBe( false );
2727+ } );
2828+2929+ it( 'honours a custom maxChars and cuts at the last space within it', () => {
3030+ expect( deriveExcerpt( 'alpha beta gamma', 10 ) ).toBe( 'alpha…' );
3131+ } );
3232+} );
+17
src/lib/publish/excerpt.ts
···11+/**
22+ * A brief plain-text excerpt for a document/card description (the og:description fallback).
33+ * Collapses runs of whitespace to single spaces, cuts on a word boundary at or before
44+ * `maxChars`, and appends an ellipsis when it had to truncate. Returns '' for empty or
55+ * whitespace-only input. Pure + dependency-free (no `@wordpress/*`, no network) so it is
66+ * safe in BOTH the server reader and the browser publisher (AGENTS.md §3).
77+ */
88+export function deriveExcerpt( text: string, maxChars = 200 ): string {
99+ const normalized = text.replace( /\s+/g, ' ' ).trim();
1010+ if ( normalized.length <= maxChars ) {
1111+ return normalized;
1212+ }
1313+ const slice = normalized.slice( 0, maxChars );
1414+ const lastSpace = slice.lastIndexOf( ' ' );
1515+ const cut = lastSpace > 0 ? slice.slice( 0, lastSpace ) : slice;
1616+ return `${ cut.trimEnd() }…`;
1717+}