@@ -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 );
0 commit comments