Skip to content

Commit 674c398

Browse files
pfefferlekraftbj
andauthored
Treat overflowing titleless posts as long-form (#145)
Co-authored-by: Brandon Kraft <public@brandonkraft.com>
1 parent 409a7b1 commit 674c398

6 files changed

Lines changed: 318 additions & 48 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: fixed
3+
4+
Long posts without a title are now shared to Bluesky as a summary with a link back to the original, instead of being cut off mid-sentence with no way to reach the full post.

includes/transformer/class-base.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@
2222
*/
2323
abstract class Base {
2424

25+
/**
26+
* Maximum length of an `app.bsky.feed.post` `text` field.
27+
*
28+
* The `app.bsky.feed.post` lexicon caps `text` at 300 graphemes. The
29+
* transformers approximate that with `mb_strlen` (code points) — every
30+
* grapheme is at least one code point, so a code-point cap never exceeds
31+
* the grapheme limit. Shared by the post and comment transformers, which
32+
* both emit Bluesky records bounded by this cap.
33+
*
34+
* @var int
35+
*/
36+
public const BLUESKY_MAX_GRAPHEMES = 300;
37+
2538
/**
2639
* The WordPress object being transformed.
2740
*

includes/transformer/class-comment.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ public function transform(): array {
8787
);
8888
}
8989

90-
$text = truncate_text( sanitize_text( (string) $comment->comment_content ), 300 );
90+
$text = truncate_text( sanitize_text( (string) $comment->comment_content ), self::BLUESKY_MAX_GRAPHEMES );
9191

9292
$record = array(
9393
'$type' => 'app.bsky.feed.post',

includes/transformer/class-post.php

Lines changed: 96 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,21 @@ class Post extends Base {
154154
*/
155155
private ?array $document_strong_ref = null;
156156

157+
/**
158+
* Memoized short-form verdict for this post.
159+
*
160+
* {@see self::is_short_form_post()} is evaluated more than once per
161+
* publish (Publisher's document-strongRef precompute and the short/long
162+
* routing, plus {@see self::transform()}), and the
163+
* `atmosphere_is_short_form_post` filter fires on every call. Caching the
164+
* verdict on the instance keeps every caller in agreement even when a
165+
* subscriber's filter is stateful, so the embed-strategy label, the
166+
* document-strongRef precompute, and the published record cannot disagree.
167+
*
168+
* @var bool|null
169+
*/
170+
private ?bool $short_form_verdict = null;
171+
157172
/**
158173
* Whether the transformer is running in projection mode.
159174
*
@@ -304,30 +319,13 @@ public function set_document_strong_ref( array $ref ): void {
304319
public function transform(): array {
305320
$redacted = $this->is_redacted();
306321

307-
/**
308-
* Filters whether the post should be treated as short-form for Bluesky.
309-
*
310-
* Short-form posts publish natively (post body as text, no external
311-
* embed card). Long-form posts use the teaser composition (title +
312-
* excerpt + permalink) with an external card linking back to
313-
* WordPress. The default discriminator mirrors the ActivityPub
314-
* plugin's Post::get_type() logic: short-form when the post type
315-
* does not support titles, the post has an empty title, or the
316-
* post has any non-empty post_format.
317-
*
318-
* @param bool $is_short Whether the post should be treated as short-form.
319-
* @param \WP_Post $post The post being transformed.
322+
/*
323+
* Redacted posts return short-form (empty text, no embed) without
324+
* exposing the post to the `atmosphere_is_short_form_post` filter.
325+
* For everyone else the public discriminator decides — including the
326+
* length gate that routes an overflowing titleless post to long-form.
320327
*/
321-
$is_short = true;
322-
if ( ! $redacted ) {
323-
$is_short = \wp_validate_boolean(
324-
\apply_filters(
325-
'atmosphere_is_short_form_post',
326-
$this->is_short_form( $this->object ),
327-
$this->object
328-
)
329-
);
330-
}
328+
$is_short = $redacted ? true : $this->is_short_form_post();
331329

332330
$text = $redacted ? '' : ( $is_short ? $this->build_short_form_text() : '' );
333331
$embed = null;
@@ -336,8 +334,17 @@ public function transform(): array {
336334
if ( $is_short ) {
337335
$embed = $this->build_images_embed();
338336
if ( '' === $text && null === $embed ) {
339-
$text = $this->build_text();
340-
$embed = $this->build_embed();
337+
/*
338+
* Empty body and no images: there is nothing to publish
339+
* natively, so fall back to the link-card composition. This
340+
* is a link-card record, so flip $is_short to false — the
341+
* embed-filter strategy label and the
342+
* atmosphere_transform_bsky_post context below must report
343+
* `link-card`, not `short-form`.
344+
*/
345+
$is_short = false;
346+
$text = $this->build_text();
347+
$embed = $this->build_embed();
341348
}
342349
} else {
343350
$text = $this->build_text();
@@ -486,18 +493,18 @@ private function build_text(): string {
486493
$parts = \array_filter( array( $title, $excerpt, $permalink ) );
487494
$text = \implode( "\n\n", $parts );
488495

489-
if ( \mb_strlen( $text ) <= 300 ) {
496+
if ( \mb_strlen( $text ) <= self::BLUESKY_MAX_GRAPHEMES ) {
490497
return $text;
491498
}
492499

493500
// Reserve space for permalink + separators.
494501
$reserved = \mb_strlen( $permalink ) + 4;
495-
$available = 300 - $reserved;
502+
$available = self::BLUESKY_MAX_GRAPHEMES - $reserved;
496503

497504
if ( $available <= 0 ) {
498505
$prose = \trim( $title . ( ! empty( $excerpt ) ? "\n\n" . $excerpt : '' ) );
499506

500-
return '' !== $prose ? truncate_text( $prose, 300 ) : truncate_text( $permalink, 300 );
507+
return '' !== $prose ? truncate_text( $prose, self::BLUESKY_MAX_GRAPHEMES ) : truncate_text( $permalink, self::BLUESKY_MAX_GRAPHEMES );
501508
}
502509

503510
$prose = $title;
@@ -1316,22 +1323,44 @@ private function apply_post_embed_filter( ?array $embed, string $strategy ): ?ar
13161323
/**
13171324
* Whether the post should be treated as short-form for Bluesky.
13181325
*
1319-
* Mirrors the ActivityPub plugin's Post::get_type() discriminator so
1320-
* a post federated as a Mastodon Note also goes to Bluesky as a
1321-
* native post instead of a link-card teaser. Short-form when:
1326+
* Starts from the ActivityPub plugin's Post::get_type() discriminator so
1327+
* a post federated as a Mastodon Note also goes to Bluesky as a native
1328+
* post instead of a link-card teaser. Categorically short-form when:
13221329
* - the post type does not support titles, OR
13231330
* - the post has an empty title, OR
13241331
* - the post has any non-empty post_format.
13251332
*
1333+
* A categorically short-form post is still treated as long-form when its
1334+
* body overflows Bluesky's 300-character native cap *and* it has no
1335+
* in-body images: short-form ships the body verbatim, so a body that
1336+
* cannot fit is not really "short", and routing it to the long-form
1337+
* composition (excerpt + permalink + external card) gives the reader a
1338+
* teaser plus a route back to the original instead of a sentence fragment
1339+
* with no link home. The overflow length is measured with `mb_strlen` to
1340+
* match `build_short_form_text()`'s own `truncate_text()` cap, so the gate
1341+
* and the truncation it avoids agree.
1342+
*
1343+
* An overflowing post that *does* carry in-body images stays short-form:
1344+
* the long-form link card can only show the featured thumbnail, so
1345+
* converting would silently drop the post's native `app.bsky.embed.images`
1346+
* gallery. A photo post with a long caption keeps its images and accepts
1347+
* the caption truncation; only the text-only link-blog case converts.
1348+
*
13261349
* @param \WP_Post $post Post being transformed.
13271350
* @return bool
13281351
*/
13291352
private function is_short_form( \WP_Post $post ): bool {
1330-
if ( ! \post_type_supports( $post->post_type, 'title' ) || empty( $post->post_title ) ) {
1353+
if ( \post_type_supports( $post->post_type, 'title' ) && ! empty( $post->post_title ) && ! \get_post_format( $post ) ) {
1354+
return false;
1355+
}
1356+
1357+
if ( \mb_strlen( $this->render_post_content_plain( $post ) ) <= self::BLUESKY_MAX_GRAPHEMES ) {
13311358
return true;
13321359
}
13331360

1334-
return (bool) \get_post_format( $post );
1361+
// Overflowing: only convert to long-form when there are no in-body
1362+
// images to preserve as a native gallery.
1363+
return ! empty( $this->collect_image_attachment_ids() );
13351364
}
13361365

13371366
/**
@@ -1349,16 +1378,16 @@ private function build_short_form_text(): string {
13491378
return '';
13501379
}
13511380

1352-
return truncate_text( $this->render_post_content_plain( $this->object ), 300 );
1381+
return truncate_text( $this->render_post_content_plain( $this->object ), self::BLUESKY_MAX_GRAPHEMES );
13531382
}
13541383

13551384
/**
13561385
* Whether this post should be treated as short-form for Bluesky.
13571386
*
1358-
* Thin public wrapper around the private discriminator plus the
1359-
* `atmosphere_is_short_form_post` filter. Callers such as
1360-
* Publisher branch on short vs. long without reaching into the
1361-
* transformer's private state.
1387+
* Exposes the private type/title/format-plus-length discriminator (see
1388+
* {@see self::is_short_form()}) through the `atmosphere_is_short_form_post`
1389+
* filter. Callers such as Publisher branch on short vs. long without
1390+
* reaching into the transformer's private state.
13621391
*
13631392
* Redacted posts return true without invoking the filter so direct
13641393
* transformer callers do not expose protected post objects to
@@ -1371,13 +1400,36 @@ public function is_short_form_post(): bool {
13711400
return true;
13721401
}
13731402

1374-
return \wp_validate_boolean(
1403+
if ( null !== $this->short_form_verdict ) {
1404+
return $this->short_form_verdict;
1405+
}
1406+
1407+
/**
1408+
* Filters whether the post should be treated as short-form for Bluesky.
1409+
*
1410+
* Short-form posts publish natively (post body as text, no external
1411+
* embed card). Long-form posts use the teaser composition (title +
1412+
* excerpt + permalink) with an external card linking back to
1413+
* WordPress. The default discriminator mirrors the ActivityPub
1414+
* plugin's Post::get_type() logic — short-form when the post type
1415+
* does not support titles, the post has an empty title, or the post
1416+
* has any non-empty post_format — but additionally treats a post
1417+
* whose body overflows the 300-character native cap as long-form, so
1418+
* a long, titleless link-blog post links back to the original
1419+
* instead of being truncated.
1420+
*
1421+
* @param bool $is_short Whether the post should be treated as short-form.
1422+
* @param \WP_Post $post The post being transformed.
1423+
*/
1424+
$this->short_form_verdict = \wp_validate_boolean(
13751425
\apply_filters(
13761426
'atmosphere_is_short_form_post',
13771427
$this->is_short_form( $this->object ),
13781428
$this->object
13791429
)
13801430
);
1431+
1432+
return $this->short_form_verdict;
13811433
}
13821434

13831435
/**
@@ -1646,7 +1698,7 @@ private function truncate_to_budget( string $text, int $max, bool $prefer_senten
16461698
* @return bool
16471699
*/
16481700
private function requires_link_card_for_long_permalink(): bool {
1649-
return \mb_strlen( \get_permalink( $this->object ) ) >= 300;
1701+
return \mb_strlen( \get_permalink( $this->object ) ) >= self::BLUESKY_MAX_GRAPHEMES;
16501702
}
16511703

16521704
/**
@@ -1662,7 +1714,7 @@ private function requires_link_card_for_long_permalink(): bool {
16621714
* @return bool
16631715
*/
16641716
private function requires_link_card_for_teaser_thread(): bool {
1665-
return \mb_strlen( $this->teaser_thread_cta_text() ) > 300;
1717+
return \mb_strlen( $this->teaser_thread_cta_text() ) > self::BLUESKY_MAX_GRAPHEMES;
16661718
}
16671719

16681720
/**
@@ -1693,7 +1745,7 @@ private function teaser_thread_cta_text(): string {
16931745
* @return string
16941746
*/
16951747
private function build_truncate_link_text(): string {
1696-
$max_length = 300;
1748+
$max_length = self::BLUESKY_MAX_GRAPHEMES;
16971749
$separator = "\n\n";
16981750
$permalink = \get_permalink( $this->object );
16991751
$plain = $this->render_post_content_plain( $this->object );
@@ -1804,7 +1856,7 @@ private function build_teaser_thread( ?array $precomputed_default = null ): arra
18041856
if ( \is_string( $entry ) ) {
18051857
$entry = sanitize_text( $entry );
18061858
if ( '' !== $entry ) {
1807-
$texts[] = $this->truncate_to_budget( $entry, 300, false );
1859+
$texts[] = $this->truncate_to_budget( $entry, self::BLUESKY_MAX_GRAPHEMES, false );
18081860
}
18091861
}
18101862
}
@@ -1888,7 +1940,7 @@ private function compute_default_teaser_thread(): array {
18881940
$plain = $this->render_post_content_plain( $this->object );
18891941

18901942
if ( \mb_strlen( $excerpt ) >= 10 ) {
1891-
$hook = $this->truncate_to_budget( $excerpt, 300, false );
1943+
$hook = $this->truncate_to_budget( $excerpt, self::BLUESKY_MAX_GRAPHEMES, false );
18921944
$chunk_source = $plain;
18931945
} else {
18941946
$hook = $this->truncate_to_budget( $plain, 280, true );

tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ public function test_preview_projects_draft_as_publishable() {
149149
* @covers ::get_preview
150150
*/
151151
public function test_preview_uses_unsaved_content() {
152+
// Keep the over-limit body short-form so the panel still reports the
153+
// over-limit warning; by default an overflowing titleless body is now
154+
// reclassified to long-form.
155+
\add_filter( 'atmosphere_is_short_form_post', '__return_true' );
156+
152157
$post = self::factory()->post->create_and_get(
153158
array(
154159
'post_title' => '',
@@ -160,6 +165,8 @@ public function test_preview_uses_unsaved_content() {
160165
$this->make_request( $post->ID, array( 'content' => \str_repeat( 'word ', 100 ) ) )
161166
)->get_data();
162167

168+
\remove_filter( 'atmosphere_is_short_form_post', '__return_true' );
169+
163170
$this->assertGreaterThan( 300, $data['records'][0]['characters'] );
164171
$this->assertTrue( $data['records'][0]['over_limit'] );
165172
}

0 commit comments

Comments
 (0)