From b5455a1a90d631a22cb5febd7995ff59a204c5ed Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 28 Apr 2026 18:20:35 +0200 Subject: [PATCH 1/3] Choose which post types publish to AT Protocol Adds an `atmosphere_support_post_types` setting with a checkbox UI in the Publishing section, and exposes WordPress' canonical `add_post_type_support($post_type, 'atmosphere')` opt-in for plugins and themes. Replaces the misnamed `Backfill::syncable_post_types()` with a small `Atmosphere\Post_Types` class plus `get_supported_post_types()` / `is_supported_post_type()` bridge functions. The `atmosphere_syncable_post_types` filter is preserved and now runs after the option/native sources are merged so callers can add or remove from either. --- .github/changelog/add-post-type-support | 4 + includes/class-atmosphere.php | 28 +++-- includes/class-backfill.php | 16 +-- includes/class-post-types.php | 71 ++++++++++++ includes/functions.php | 19 ++++ includes/wp-admin/class-admin.php | 62 +++++++++- tests/phpunit/tests/class-test-post-types.php | 106 ++++++++++++++++++ 7 files changed, 274 insertions(+), 32 deletions(-) create mode 100644 .github/changelog/add-post-type-support create mode 100644 includes/class-post-types.php create mode 100644 tests/phpunit/tests/class-test-post-types.php diff --git a/.github/changelog/add-post-type-support b/.github/changelog/add-post-type-support new file mode 100644 index 0000000..5363d0f --- /dev/null +++ b/.github/changelog/add-post-type-support @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Choose which post types are published to AT Protocol from the ATmosphere settings page. Plugins and themes can also opt their custom post types in directly with `add_post_type_support( 'your_type', 'atmosphere' )`. diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index acbba7f..434b03b 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -98,7 +98,7 @@ public function output_document_link(): void { return; } - if ( ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) ) { + if ( ! is_supported_post_type( $post->post_type ) ) { return; } @@ -220,7 +220,7 @@ public function preview(): void { return; } - if ( ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) ) { + if ( ! is_supported_post_type( $post->post_type ) ) { \status_header( 404 ); exit; } @@ -261,16 +261,14 @@ public function on_status_change( string $new_status, string $old_status, \WP_Po } /* - * Publish-time decisions respect the allowlist so sites only sync - * the post types they've opted into. Unpublish is a cleanup path - * for records that were already synced, so narrowing the allowlist - * later must not orphan those remote records: unpublish defers to - * publication metadata (TIDs on the post) instead of the current - * allowlist. + * Publish-time decisions respect the supported list so sites + * only sync the post types they've opted into. Unpublish is a + * cleanup path for records that were already synced, so + * narrowing support later must not orphan those remote records: + * unpublish defers to publication metadata (TIDs on the post) + * instead of the current support list. */ - if ( ( $is_new_publish || $is_update ) - && ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) - ) { + if ( ( $is_new_publish || $is_update ) && ! is_supported_post_type( $post->post_type ) ) { return; } @@ -325,12 +323,12 @@ public function on_before_delete( int $post_id ): void { } /* - * No allowlist check here. Permanent delete is a cleanup path: if + * No support check here. Permanent delete is a cleanup path: if * the post has Atmosphere publication metadata it was synced at * some point, and the remote records must be removed even if the - * post type has since been dropped from the syncable allowlist. - * Gating this on the current allowlist would orphan already- - * published records whenever a site narrows its configuration. + * post type has since been removed from the supported list. + * Gating this on current support would orphan already-published + * records whenever a site narrows its configuration. */ $bsky_tid = \get_post_meta( $post_id, Transformer\Post::META_TID, true ); $doc_tid = \get_post_meta( $post_id, Transformer\Document::META_TID, true ); diff --git a/includes/class-backfill.php b/includes/class-backfill.php index 02b8046..ecf2696 100644 --- a/includes/class-backfill.php +++ b/includes/class-backfill.php @@ -37,7 +37,7 @@ public static function handle_count(): void { \check_ajax_referer( 'atmosphere_backfill', 'nonce' ); - $post_types = self::syncable_post_types(); + $post_types = get_supported_post_types(); /** * Filters the maximum number of posts to backfill. @@ -133,18 +133,4 @@ public static function handle_batch(): void { \wp_send_json_success( array( 'results' => $results ) ); } - - /** - * Get the post types eligible for syncing. - * - * @return string[] - */ - public static function syncable_post_types(): array { - /** - * Filters the post types that can be synced to AT Protocol. - * - * @param string[] $post_types Post type slugs. - */ - return \apply_filters( 'atmosphere_syncable_post_types', array( 'post' ) ); - } } diff --git a/includes/class-post-types.php b/includes/class-post-types.php new file mode 100644 index 0000000..4cd9e59 --- /dev/null +++ b/includes/class-post-types.php @@ -0,0 +1,71 @@ + true ) ); + $value = \array_map( 'sanitize_text_field', (array) $value ); + + return \array_values( \array_intersect( $value, $allowed ) ); + } +} diff --git a/includes/functions.php b/includes/functions.php index 1b6f416..60524e7 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -128,3 +128,22 @@ function get_did(): string { function get_pds_endpoint(): string { return get_connection()['pds_endpoint'] ?? ''; } + +/** + * Get post types that publish to AT Protocol. + * + * @return string[] Post type slugs. + */ +function get_supported_post_types(): array { + return Post_Types::get_supported(); +} + +/** + * Whether a post type publishes to AT Protocol. + * + * @param string $post_type Post type slug. + * @return bool + */ +function is_supported_post_type( string $post_type ): bool { + return Post_Types::supports( $post_type ); +} diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 69915e8..44a72ef 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -9,10 +9,11 @@ \defined( 'ABSPATH' ) || exit; -use Atmosphere\Backfill; use Atmosphere\OAuth\Client; +use Atmosphere\Post_Types; use Atmosphere\Publisher; use function Atmosphere\get_connection; +use function Atmosphere\get_supported_post_types; use function Atmosphere\is_connected; /** @@ -68,6 +69,23 @@ public static function register_settings(): void { ) ); + \register_setting( + 'atmosphere', + 'atmosphere_support_post_types', + array( + 'type' => 'array', + 'description' => 'Post types to publish to AT Protocol.', + 'default' => array( 'post' ), + 'sanitize_callback' => array( Post_Types::class, 'sanitize' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ) + ); + \register_setting( 'atmosphere', 'atmosphere_handle', @@ -116,6 +134,14 @@ public static function register_settings(): void { 'atmosphere_publishing' ); + \add_settings_field( + 'atmosphere_support_post_types', + \__( 'Post types', 'atmosphere' ), + array( self::class, 'render_support_post_types_field' ), + 'atmosphere', + 'atmosphere_publishing' + ); + \add_settings_field( 'atmosphere_backfill', \__( 'Backfill', 'atmosphere' ), @@ -247,6 +273,38 @@ public static function render_auto_publish_field(): void { true ), 'objects' ); + $enabled = get_supported_post_types(); + ?> +
+ + + + +

+ +

+ +
+

+ +

+ assertTrue( is_supported_post_type( 'post' ) ); + } + + /** + * Custom option drives the supported list. + */ + public function test_option_drives_supported_list() { + \update_option( 'atmosphere_support_post_types', array( 'post', 'page' ) ); + + $supported = get_supported_post_types(); + + $this->assertContains( 'post', $supported ); + $this->assertContains( 'page', $supported ); + $this->assertTrue( is_supported_post_type( 'page' ) ); + } + + /** + * Empty option means nothing is supported (unless opted in via WP API). + */ + public function test_empty_option_supports_nothing() { + \update_option( 'atmosphere_support_post_types', array() ); + + $this->assertFalse( is_supported_post_type( 'post' ) ); + $this->assertSame( array(), get_supported_post_types() ); + } + + /** + * Third parties can opt their post types in via WP's native API. + */ + public function test_third_party_add_post_type_support() { + \update_option( 'atmosphere_support_post_types', array() ); + \add_post_type_support( 'page', 'atmosphere' ); + + $this->assertTrue( is_supported_post_type( 'page' ) ); + $this->assertContains( 'page', get_supported_post_types() ); + } + + /** + * The `atmosphere_syncable_post_types` filter still adjusts the list. + */ + public function test_filter_can_add_post_type() { + \add_filter( + 'atmosphere_syncable_post_types', + static function ( array $types ): array { + $types[] = 'page'; + return $types; + } + ); + + $this->assertTrue( is_supported_post_type( 'page' ) ); + } + + /** + * `Post_Types::sanitize()` drops unknown / non-public post types. + */ + public function test_sanitize_filters_unknown_post_types() { + $result = Post_Types::sanitize( array( 'post', 'bogus_type', 'page' ) ); + + $this->assertSame( array( 'post', 'page' ), $result ); + } + + /** + * `Post_Types::sanitize()` coerces empty input to an empty array. + */ + public function test_sanitize_handles_empty_input() { + $this->assertSame( array(), Post_Types::sanitize( null ) ); + $this->assertSame( array(), Post_Types::sanitize( '' ) ); + $this->assertSame( array(), Post_Types::sanitize( array() ) ); + } +} From 1132250b2d548fc9cccc3d0481eaea7872f11c18 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 28 Apr 2026 18:42:10 +0200 Subject: [PATCH 2/3] Address review feedback on post-type support - Empty supported list short-circuits backfill count so an unconfigured site isn't queried with the default `post` fallback. Eligibility is also re-checked per post in the batch handler. - `get_supported()` normalises the filter result: drops empties / non-strings, dedupes after the filter so callbacks can't surface duplicates. - Settings UI now derives checkbox state from the saved option only; post types enabled via `add_post_type_support()` or the filter are noted under the label as "Enabled by another plugin or theme." --- includes/class-backfill.php | 16 +++++++++++- includes/class-post-types.php | 11 ++++++-- includes/wp-admin/class-admin.php | 25 ++++++++++++++++--- tests/phpunit/tests/class-test-post-types.php | 17 +++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/includes/class-backfill.php b/includes/class-backfill.php index ecf2696..cbb4672 100644 --- a/includes/class-backfill.php +++ b/includes/class-backfill.php @@ -39,6 +39,20 @@ public static function handle_count(): void { $post_types = get_supported_post_types(); + /* + * Short-circuit when no post types are enabled. Passing an empty + * array to get_posts() falls back to the default `post` query, + * which would surface posts that nothing is configured to sync. + */ + if ( empty( $post_types ) ) { + \wp_send_json_success( + array( + 'total' => 0, + 'post_ids' => array(), + ) + ); + } + /** * Filters the maximum number of posts to backfill. * @@ -104,7 +118,7 @@ public static function handle_batch(): void { foreach ( $post_ids as $post_id ) { $post = \get_post( $post_id ); - if ( ! $post || 'publish' !== $post->post_status ) { + if ( ! $post || 'publish' !== $post->post_status || ! is_supported_post_type( $post->post_type ) ) { $results[] = array( 'id' => $post_id, 'success' => false, diff --git a/includes/class-post-types.php b/includes/class-post-types.php index 4cd9e59..90824f9 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -26,7 +26,7 @@ public static function get_supported(): array { $configured = (array) \get_option( 'atmosphere_support_post_types', array( 'post' ) ); $native = \get_post_types_by_support( 'atmosphere' ); - $post_types = \array_values( \array_unique( \array_merge( $configured, $native ) ) ); + $post_types = \array_merge( $configured, $native ); /** * Filters the post types that support ATmosphere publishing. @@ -36,7 +36,14 @@ public static function get_supported(): array { * * @param string[] $post_types Post type slugs. */ - return \apply_filters( 'atmosphere_syncable_post_types', $post_types ); + $post_types = (array) \apply_filters( 'atmosphere_syncable_post_types', $post_types ); + + // Normalise: drop empties / non-strings, dedupe, re-index. + $post_types = \array_filter( $post_types, '\is_string' ); + $post_types = \array_map( 'sanitize_key', $post_types ); + $post_types = \array_filter( $post_types ); + + return \array_values( \array_unique( $post_types ) ); } /** diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 44a72ef..db33e0b 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -278,24 +278,43 @@ public static function render_auto_publish_field(): void { */ public static function render_support_post_types_field(): void { $post_types = \get_post_types( array( 'public' => true ), 'objects' ); - $enabled = get_supported_post_types(); + + /* + * The checkbox state reflects the saved option only. Native + * `add_post_type_support()` opt-ins and the syncable filter are + * surfaced as a note below the label so the user can see when a + * post type is enabled outside this UI. + */ + $saved = (array) \get_option( 'atmosphere_support_post_types', array( 'post' ) ); + $saved = \array_filter( \array_map( 'sanitize_key', $saved ) ); + $effective = get_supported_post_types(); ?>
- + name, $saved, true ); + $is_external = ! $is_saved && \in_array( $post_type->name, $effective, true ); + ?>

+ +
+ + + +

diff --git a/tests/phpunit/tests/class-test-post-types.php b/tests/phpunit/tests/class-test-post-types.php index 3e3c767..9c47e23 100644 --- a/tests/phpunit/tests/class-test-post-types.php +++ b/tests/phpunit/tests/class-test-post-types.php @@ -103,4 +103,21 @@ public function test_sanitize_handles_empty_input() { $this->assertSame( array(), Post_Types::sanitize( '' ) ); $this->assertSame( array(), Post_Types::sanitize( array() ) ); } + + /** + * Filter callbacks returning duplicates / non-strings / empties are + * normalised away so callers always get a clean string[] of unique slugs. + */ + public function test_get_supported_normalises_filter_output() { + \add_filter( + 'atmosphere_syncable_post_types', + static function (): array { + return array( 'post', 'post', '', null, 42, 'page' ); + } + ); + + $result = get_supported_post_types(); + + $this->assertSame( array( 'post', 'page' ), \array_values( $result ) ); + } } From 0685c4768f86605ae5ec094018a2e87ffd9bb0ed Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 29 Apr 2026 18:07:03 +0200 Subject: [PATCH 3/3] Re-check support in cron, normalize sanitize, surface filter overrides - Publish/update cron callbacks re-check is_supported_post_type so a user disabling a post type after queueing prevents the cron firing. Delete callback intentionally unchanged so cleanup still runs. - Post_Types::sanitize uses sanitize_key, drops non-strings/empties, and dedupes so the saved option matches get_supported's canonical format. - Backfill::handle_batch resolves get_supported_post_types once per batch instead of recomputing per ID. - Settings UI now also notes saved post types whose effective state was removed by a filter, so the page doesn't claim a type publishes when runtime would skip it. --- includes/class-atmosphere.php | 12 +++++++++-- includes/class-backfill.php | 6 ++++-- includes/class-post-types.php | 12 +++++++---- includes/wp-admin/class-admin.php | 11 ++++++++-- tests/phpunit/tests/class-test-post-types.php | 20 +++++++++++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index 434b03b..3605a6d 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -366,11 +366,19 @@ public function cron_refresh_token(): void { * Register async action hooks (called by WP-Cron). */ public static function register_async_hooks(): void { + /* + * Publish/update cron callbacks re-check post-type support. + * A user (or downstream filter) can disable a post type after a + * cron event was queued, and we must not still publish it. + * + * The delete callback intentionally skips this check so cleanup + * still runs after support is removed. + */ \add_action( 'atmosphere_publish_post', static function ( int $post_id ): void { $post = \get_post( $post_id ); - if ( $post && 'publish' === $post->post_status ) { + if ( $post && 'publish' === $post->post_status && is_supported_post_type( $post->post_type ) ) { Publisher::publish( $post ); } } @@ -380,7 +388,7 @@ static function ( int $post_id ): void { 'atmosphere_update_post', static function ( int $post_id ): void { $post = \get_post( $post_id ); - if ( $post && 'publish' === $post->post_status ) { + if ( $post && 'publish' === $post->post_status && is_supported_post_type( $post->post_type ) ) { Publisher::update( $post ); } } diff --git a/includes/class-backfill.php b/includes/class-backfill.php index cbb4672..339484f 100644 --- a/includes/class-backfill.php +++ b/includes/class-backfill.php @@ -113,12 +113,14 @@ public static function handle_batch(): void { \wp_send_json_error( 'No post IDs provided.' ); } - $results = array(); + // Resolve supported post types once for the whole batch. + $supported = get_supported_post_types(); + $results = array(); foreach ( $post_ids as $post_id ) { $post = \get_post( $post_id ); - if ( ! $post || 'publish' !== $post->post_status || ! is_supported_post_type( $post->post_type ) ) { + if ( ! $post || 'publish' !== $post->post_status || ! \in_array( $post->post_type, $supported, true ) ) { $results[] = array( 'id' => $post_id, 'success' => false, diff --git a/includes/class-post-types.php b/includes/class-post-types.php index 90824f9..39a34db 100644 --- a/includes/class-post-types.php +++ b/includes/class-post-types.php @@ -59,8 +59,9 @@ public static function supports( string $post_type ): bool { /** * Sanitize the option on save. * - * Drops unknown or non-public post types and coerces empty input - * (e.g. all checkboxes unchecked) to an empty array. + * Normalises input to a clean string[] of unique public post type + * slugs. Coerces empty input (e.g. all checkboxes unchecked) to an + * empty array. * * @param mixed $value Submitted value. * @return string[] @@ -71,8 +72,11 @@ public static function sanitize( $value ): array { } $allowed = \get_post_types( array( 'public' => true ) ); - $value = \array_map( 'sanitize_text_field', (array) $value ); - return \array_values( \array_intersect( $value, $allowed ) ); + $value = \array_filter( (array) $value, '\is_string' ); + $value = \array_map( 'sanitize_key', $value ); + $value = \array_filter( $value ); + + return \array_values( \array_unique( \array_intersect( $value, $allowed ) ) ); } } diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index db33e0b..d7f5ca3 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -295,8 +295,10 @@ public static function render_support_post_types_field(): void { name, $saved, true ); - $is_external = ! $is_saved && \in_array( $post_type->name, $effective, true ); + $is_saved = \in_array( $post_type->name, $saved, true ); + $is_effective = \in_array( $post_type->name, $effective, true ); + $is_external = ! $is_saved && $is_effective; + $is_filtered_out = $is_saved && ! $is_effective; ?>

diff --git a/tests/phpunit/tests/class-test-post-types.php b/tests/phpunit/tests/class-test-post-types.php index 9c47e23..3726e1f 100644 --- a/tests/phpunit/tests/class-test-post-types.php +++ b/tests/phpunit/tests/class-test-post-types.php @@ -104,6 +104,26 @@ public function test_sanitize_handles_empty_input() { $this->assertSame( array(), Post_Types::sanitize( array() ) ); } + /** + * `Post_Types::sanitize()` dedupes the result so the saved option is + * a canonical list. + */ + public function test_sanitize_dedupes() { + $result = Post_Types::sanitize( array( 'post', 'page', 'post', 'page' ) ); + + $this->assertSame( array( 'post', 'page' ), $result ); + } + + /** + * `Post_Types::sanitize()` drops non-strings and empties so callers + * never store junk. + */ + public function test_sanitize_drops_non_strings_and_empties() { + $result = Post_Types::sanitize( array( 'post', '', null, 42, true, 'page' ) ); + + $this->assertSame( array( 'post', 'page' ), $result ); + } + /** * Filter callbacks returning duplicates / non-strings / empties are * normalised away so callers always get a clean string[] of unique slugs.