From 98a0fc4bff836a305b7ffa2b6d46bf05b3ea94a8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 28 Apr 2026 18:25:17 +0200 Subject: [PATCH 1/2] Add a setting for the long-form composition strategy Exposes the `atmosphere_long_form_composition` filter as a radio-group setting in the Publishing section: link-card (default), truncate-link, or teaser-thread. The setting seeds the filter at priority 1 so any downstream filter at the default priority can still override it per post. Sanitize callback rejects unknown values, falling back to the default. --- .../add-long-form-composition-setting | 4 + includes/class-atmosphere.php | 17 ++++ includes/wp-admin/class-admin.php | 84 +++++++++++++++++ ...ass-test-long-form-composition-setting.php | 91 +++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 .github/changelog/add-long-form-composition-setting create mode 100644 tests/phpunit/tests/class-test-long-form-composition-setting.php diff --git a/.github/changelog/add-long-form-composition-setting b/.github/changelog/add-long-form-composition-setting new file mode 100644 index 0000000..68cc108 --- /dev/null +++ b/.github/changelog/add-long-form-composition-setting @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Choose how long-form posts publish to Bluesky from the ATmosphere settings page — link card (default), a single post combining body text with the permalink, or a two-post teaser thread. diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index c4c5b1e..0308c9b 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -34,6 +34,23 @@ public function init(): void { \add_action( 'init', array( Admin::class, 'register' ), 5 ); \add_action( 'init', array( Backfill::class, 'register' ), 5 ); + /* + * Seed the long-form composition strategy from the user's + * setting. Priority 1 so any downstream filter at the default + * priority can still override it per post. + */ + \add_filter( + 'atmosphere_long_form_composition', + static function ( string $strategy ): string { + $option = (string) \get_option( 'atmosphere_long_form_composition', 'link-card' ); + + return \in_array( $option, array( 'link-card', 'truncate-link', 'teaser-thread' ), true ) + ? $option + : $strategy; + }, + 1 + ); + // REST route (always active for client-metadata). \add_action( 'rest_api_init', array( Admin::class, 'register_rest_routes' ) ); diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 69915e8..240302e 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -68,6 +68,22 @@ public static function register_settings(): void { ) ); + \register_setting( + 'atmosphere', + 'atmosphere_long_form_composition', + array( + 'type' => 'string', + 'description' => 'Composition strategy for long-form Bluesky posts.', + 'default' => 'link-card', + 'sanitize_callback' => array( self::class, 'sanitize_long_form_composition' ), + 'show_in_rest' => array( + 'schema' => array( + 'enum' => array( 'link-card', 'truncate-link', 'teaser-thread' ), + ), + ), + ) + ); + \register_setting( 'atmosphere', 'atmosphere_handle', @@ -116,6 +132,14 @@ public static function register_settings(): void { 'atmosphere_publishing' ); + \add_settings_field( + 'atmosphere_long_form_composition', + \__( 'Long-form posts', 'atmosphere' ), + array( self::class, 'render_long_form_composition_field' ), + 'atmosphere', + 'atmosphere_publishing' + ); + \add_settings_field( 'atmosphere_backfill', \__( 'Backfill', 'atmosphere' ), @@ -247,6 +271,66 @@ public static function render_auto_publish_field(): void { array( + 'label' => \__( 'Link card', 'atmosphere' ), + 'help' => \__( 'A single Bluesky post with the title, an excerpt, and a permalink card. (Default — unchanged behavior.)', 'atmosphere' ), + ), + 'truncate-link' => array( + 'label' => \__( 'Truncated post with link', 'atmosphere' ), + 'help' => \__( 'A single Bluesky post containing the body text followed by an inline permalink. No card.', 'atmosphere' ), + ), + 'teaser-thread' => array( + 'label' => \__( 'Teaser thread', 'atmosphere' ), + 'help' => \__( 'A two-post Bluesky thread: a hook followed by a "continue reading" reply with the permalink.', 'atmosphere' ), + ), + ); + + ?> +
+ + + + $choice ) : ?> +

+ +
+ +

+ +
+

+ +

+ assertSame( 'link-card', Admin::sanitize_long_form_composition( 'link-card' ) ); + $this->assertSame( 'truncate-link', Admin::sanitize_long_form_composition( 'truncate-link' ) ); + $this->assertSame( 'teaser-thread', Admin::sanitize_long_form_composition( 'teaser-thread' ) ); + } + + /** + * Sanitize callback falls back to the default for unknown / non-string input. + */ + public function test_sanitize_rejects_unknown_values() { + $this->assertSame( 'link-card', Admin::sanitize_long_form_composition( 'something-else' ) ); + $this->assertSame( 'link-card', Admin::sanitize_long_form_composition( '' ) ); + $this->assertSame( 'link-card', Admin::sanitize_long_form_composition( null ) ); + $this->assertSame( 'link-card', Admin::sanitize_long_form_composition( array( 'teaser-thread' ) ) ); + } + + /** + * The option seeds the `atmosphere_long_form_composition` filter. + */ + public function test_option_seeds_filter() { + \update_option( 'atmosphere_long_form_composition', 'teaser-thread' ); + + $result = \apply_filters( 'atmosphere_long_form_composition', 'link-card', null ); + + $this->assertSame( 'teaser-thread', $result ); + } + + /** + * Downstream filters at the default priority override the option. + */ + public function test_downstream_filter_overrides_option() { + \update_option( 'atmosphere_long_form_composition', 'teaser-thread' ); + + \add_filter( + 'atmosphere_long_form_composition', + static function (): string { + return 'truncate-link'; + } + ); + + $result = \apply_filters( 'atmosphere_long_form_composition', 'link-card', null ); + + $this->assertSame( 'truncate-link', $result ); + + \remove_all_filters( 'atmosphere_long_form_composition' ); + } + + /** + * Unknown stored values are ignored; the default flows through. + */ + public function test_corrupt_option_falls_through_to_default() { + \update_option( 'atmosphere_long_form_composition', 'bogus-strategy' ); + + $result = \apply_filters( 'atmosphere_long_form_composition', 'link-card', null ); + + $this->assertSame( 'link-card', $result ); + } +} From ae52f25c23df641412b55dee489b91d33d784c5f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 29 Apr 2026 08:53:16 +0200 Subject: [PATCH 2/2] Address review feedback on long-form composition setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the strategy slug list to `Atmosphere::LONG_FORM_STRATEGIES` and reference it from the REST enum, sanitize allowlist, and the render iteration. Translatable labels live in a small private helper keyed off the same constant. - Move the seed callback to a named static method, `Atmosphere::seed_long_form_composition()`, so production wiring and tests can register the same callable. - Log invalid stored values via `error_log()` (rate-limited to once per hour with a transient) so operators can spot config drift — matches the `error_log` convention in `Transformer\Post` for silent strategy downgrades. - Test class now re-registers the seed in `set_up()` and clears it in `tear_down()` so isolation no longer depends on alphabetical ordering of test classes that call `remove_all_filters()`. --- includes/class-atmosphere.php | 50 ++++++++++++---- includes/wp-admin/class-admin.php | 58 ++++++++++++------- ...ass-test-long-form-composition-setting.php | 21 ++++++- 3 files changed, 95 insertions(+), 34 deletions(-) diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index 0308c9b..f1b9005 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -21,6 +21,12 @@ */ class Atmosphere { + /** + * Allowed values for the long-form composition strategy filter and + * the matching `atmosphere_long_form_composition` option. + */ + public const LONG_FORM_STRATEGIES = array( 'link-card', 'truncate-link', 'teaser-thread' ); + /** * Wire up all hooks. */ @@ -39,17 +45,7 @@ public function init(): void { * setting. Priority 1 so any downstream filter at the default * priority can still override it per post. */ - \add_filter( - 'atmosphere_long_form_composition', - static function ( string $strategy ): string { - $option = (string) \get_option( 'atmosphere_long_form_composition', 'link-card' ); - - return \in_array( $option, array( 'link-card', 'truncate-link', 'teaser-thread' ), true ) - ? $option - : $strategy; - }, - 1 - ); + \add_filter( 'atmosphere_long_form_composition', array( self::class, 'seed_long_form_composition' ), 1 ); // REST route (always active for client-metadata). \add_action( 'rest_api_init', array( Admin::class, 'register_rest_routes' ) ); @@ -371,6 +367,38 @@ public function cron_refresh_token(): void { Client::refresh(); } + /** + * Seed the `atmosphere_long_form_composition` filter from the option. + * + * Returns the configured strategy when valid; otherwise returns the + * incoming `$strategy` (so downstream filters and the `link-card` + * default still apply). An invalid stored value is logged at most + * once per hour so operators can spot config drift. + * + * @param string $strategy Strategy passed in by `apply_filters()`. + * @return string + */ + public static function seed_long_form_composition( string $strategy ): string { + $option = (string) \get_option( 'atmosphere_long_form_composition', 'link-card' ); + + if ( \in_array( $option, self::LONG_FORM_STRATEGIES, true ) ) { + return $option; + } + + if ( ! \get_transient( 'atmosphere_invalid_long_form_composition_logged' ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + \error_log( + \sprintf( + '[atmosphere] invalid `atmosphere_long_form_composition` option value %s; falling through to default', + \wp_json_encode( $option ) + ) + ); + \set_transient( 'atmosphere_invalid_long_form_composition_logged', 1, \HOUR_IN_SECONDS ); + } + + return $strategy; + } + /** * Register async action hooks (called by WP-Cron). */ diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 240302e..8137438 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -9,6 +9,7 @@ \defined( 'ABSPATH' ) || exit; +use Atmosphere\Atmosphere; use Atmosphere\Backfill; use Atmosphere\OAuth\Client; use Atmosphere\Publisher; @@ -78,7 +79,7 @@ public static function register_settings(): void { 'sanitize_callback' => array( self::class, 'sanitize_long_form_composition' ), 'show_in_rest' => array( 'schema' => array( - 'enum' => array( 'link-card', 'truncate-link', 'teaser-thread' ), + 'enum' => Atmosphere::LONG_FORM_STRATEGIES, ), ), ) @@ -276,34 +277,23 @@ public static function render_auto_publish_field(): void { */ public static function render_long_form_composition_field(): void { $current = \get_option( 'atmosphere_long_form_composition', 'link-card' ); - $choices = array( - 'link-card' => array( - 'label' => \__( 'Link card', 'atmosphere' ), - 'help' => \__( 'A single Bluesky post with the title, an excerpt, and a permalink card. (Default — unchanged behavior.)', 'atmosphere' ), - ), - 'truncate-link' => array( - 'label' => \__( 'Truncated post with link', 'atmosphere' ), - 'help' => \__( 'A single Bluesky post containing the body text followed by an inline permalink. No card.', 'atmosphere' ), - ), - 'teaser-thread' => array( - 'label' => \__( 'Teaser thread', 'atmosphere' ), - 'help' => \__( 'A two-post Bluesky thread: a hook followed by a "continue reading" reply with the permalink.', 'atmosphere' ), - ), - ); ?>
- $choice ) : ?> +

@@ -318,6 +308,33 @@ public static function render_long_form_composition_field(): void { \__( 'Truncated post with link', 'atmosphere' ), + 'help' => \__( 'A single Bluesky post containing the body text followed by an inline permalink. No card.', 'atmosphere' ), + ); + case 'teaser-thread': + return array( + 'label' => \__( 'Teaser thread', 'atmosphere' ), + 'help' => \__( 'A two-post Bluesky thread: a hook followed by a "continue reading" reply with the permalink.', 'atmosphere' ), + ); + case 'link-card': + default: + return array( + 'label' => \__( 'Link card', 'atmosphere' ), + 'help' => \__( 'A single Bluesky post with the title, an excerpt, and a permalink card. (Default — unchanged behavior.)', 'atmosphere' ), + ); + } + } + /** * Sanitize the long-form composition setting. * @@ -325,10 +342,9 @@ public static function render_long_form_composition_field(): void { * @return string */ public static function sanitize_long_form_composition( $value ): string { - $allowed = array( 'link-card', 'truncate-link', 'teaser-thread' ); - $value = \is_string( $value ) ? \sanitize_text_field( $value ) : ''; + $value = \is_string( $value ) ? \sanitize_text_field( $value ) : ''; - return \in_array( $value, $allowed, true ) ? $value : 'link-card'; + return \in_array( $value, Atmosphere::LONG_FORM_STRATEGIES, true ) ? $value : 'link-card'; } /** diff --git a/tests/phpunit/tests/class-test-long-form-composition-setting.php b/tests/phpunit/tests/class-test-long-form-composition-setting.php index 491a330..901e2fd 100644 --- a/tests/phpunit/tests/class-test-long-form-composition-setting.php +++ b/tests/phpunit/tests/class-test-long-form-composition-setting.php @@ -12,6 +12,7 @@ namespace Atmosphere\Tests; use WP_UnitTestCase; +use Atmosphere\Atmosphere; use Atmosphere\WP_Admin\Admin; /** @@ -19,11 +20,29 @@ */ class Test_Long_Form_Composition_Setting extends WP_UnitTestCase { + /** + * Re-register the seed filter and clear option/transient state. + * + * Other test classes call `remove_all_filters( 'atmosphere_long_form_composition' )` + * in their `tear_down`, which would strip the production seed registered once on + * `plugins_loaded`. Re-add it here so each test in this class starts with the + * expected filter wiring regardless of execution order. + */ + public function set_up(): void { + parent::set_up(); + + \remove_all_filters( 'atmosphere_long_form_composition' ); + \add_filter( 'atmosphere_long_form_composition', array( Atmosphere::class, 'seed_long_form_composition' ), 1 ); + \delete_transient( 'atmosphere_invalid_long_form_composition_logged' ); + } + /** * Reset state between tests. */ public function tear_down(): void { \delete_option( 'atmosphere_long_form_composition' ); + \delete_transient( 'atmosphere_invalid_long_form_composition_logged' ); + \remove_all_filters( 'atmosphere_long_form_composition' ); parent::tear_down(); } @@ -74,8 +93,6 @@ static function (): string { $result = \apply_filters( 'atmosphere_long_form_composition', 'link-card', null ); $this->assertSame( 'truncate-link', $result ); - - \remove_all_filters( 'atmosphere_long_form_composition' ); } /**