From d6c6e27d663fcc2b218d16490fd95d96554419fb Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Mar 2026 17:53:55 +0100 Subject: [PATCH 1/5] Add extensible content parser interface and document wiring Introduce a pluggable content parser system for the `content` union field in site.standard.document records. - Add Content_Parser interface for custom parser implementations. - Wire content parsing into Document transformer with two filters: atmosphere_content_parser (swap/disable parser) and atmosphere_document_content (modify parsed output). - Add ?atproto query param preview endpoint for inspecting the document record JSON (requires edit_posts capability). - Always set the required `site` field in document records, falling back to the site URL when no publication record exists. --- includes/class-atmosphere.php | 42 ++++++++++++++++ .../interface-content-parser.php | 42 ++++++++++++++++ includes/transformer/class-document.php | 49 ++++++++++++++++++- 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 includes/content-parser/interface-content-parser.php diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index 8110424..52501de 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -44,6 +44,9 @@ public function init(): void { // Plugin integrations. Load::init(); + // JSON preview for AT Protocol records. + \add_action( 'template_redirect', array( $this, 'preview' ) ); + // Post lifecycle hooks. \add_action( 'transition_post_status', array( $this, 'on_status_change' ), 10, 3 ); @@ -145,6 +148,45 @@ public function serve_wellknown_publication(): void { exit; } + /** + * Serve a JSON preview of the AT Protocol record for a post. + * + * Append ?atproto to a singular post URL to see the document + * record JSON. Optionally pass ?atproto={parser} to preview + * with a specific content parser (requires the parser to be + * registered via the atmosphere_content_parser filter). + */ + public function preview(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['atproto'] ) || ! \is_singular() ) { + return; + } + + if ( ! \current_user_can( 'edit_posts' ) ) { + return; + } + + $post = \get_queried_object(); + + if ( ! $post instanceof \WP_Post ) { + return; + } + + if ( ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) ) { + \status_header( 404 ); + exit; + } + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + \status_header( 200 ); + \header( 'Content-Type: application/json; charset=utf-8' ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + echo \wp_json_encode( $record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + exit; + } + /** * Handle post status transitions. * diff --git a/includes/content-parser/interface-content-parser.php b/includes/content-parser/interface-content-parser.php new file mode 100644 index 0000000..4f11827 --- /dev/null +++ b/includes/content-parser/interface-content-parser.php @@ -0,0 +1,42 @@ + $this->to_iso8601( $this->object->post_date_gmt ), ); - // Publication reference. + // Publication reference (required by spec). $pub_tid = \get_option( 'atmosphere_publication_tid' ); if ( $pub_tid ) { $record['site'] = build_at_uri( get_did(), 'site.standard.publication', $pub_tid ); + } else { + // Fall back to site URL for standalone documents. + $record['site'] = \untrailingslashit( \get_home_url() ); } // Relative path. @@ -89,6 +93,12 @@ public function transform(): array { $record['textContent'] = $text_content; } + // Parsed rich content (open union). + $content = $this->get_content(); + if ( ! empty( $content ) ) { + $record['content'] = $content; + } + // Tags. $tags = $this->collect_tags( $this->object ); if ( ! empty( $tags ) ) { @@ -140,6 +150,43 @@ public function get_rkey(): string { return $rkey; } + /** + * Get parsed content for the document's content union field. + * + * @return array|null Parsed content object or null. + */ + private function get_content(): ?array { + if ( empty( \trim( $this->object->post_content ) ) ) { + return null; + } + + /** + * Filters the content parser used for site.standard.document records. + * + * Return a Content_Parser instance to provide a parser. + * Return null to disable the content field entirely. + * + * @param Content_Parser|null $parser The content parser. Default: null. + * @param \WP_Post $post The WordPress post. + */ + $parser = \apply_filters( 'atmosphere_content_parser', null, $this->object ); + + if ( ! $parser instanceof Content_Parser ) { + return null; + } + + $content = $parser->parse( $this->object->post_content, $this->object ); + + /** + * Filters the parsed content object before adding to the document record. + * + * @param array $content The parsed content object. + * @param \WP_Post $post The WordPress post. + * @param Content_Parser $parser The parser that produced the content. + */ + return \apply_filters( 'atmosphere_document_content', $content, $this->object, $parser ); + } + /** * Render post content to plain text. * From 8bc731e9c281e85116991415008fa8ce511d2228 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Mar 2026 18:01:34 +0100 Subject: [PATCH 2/5] Fix preview endpoint docblock to match implementation --- includes/class-atmosphere.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index 52501de..8ab16b3 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -152,9 +152,7 @@ public function serve_wellknown_publication(): void { * Serve a JSON preview of the AT Protocol record for a post. * * Append ?atproto to a singular post URL to see the document - * record JSON. Optionally pass ?atproto={parser} to preview - * with a specific content parser (requires the parser to be - * registered via the atmosphere_content_parser filter). + * record JSON. Requires the edit_posts capability. */ public function preview(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended From 28d1735dadef620199d18499055406457d5572f4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Mar 2026 18:04:28 +0100 Subject: [PATCH 3/5] Add changelog entry --- .github/changelog/content-parser-interface | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/content-parser-interface diff --git a/.github/changelog/content-parser-interface b/.github/changelog/content-parser-interface new file mode 100644 index 0000000..4715d62 --- /dev/null +++ b/.github/changelog/content-parser-interface @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add extensible content parser support and a JSON preview endpoint for AT Protocol records. From 75baa028a69b81cc0e7f6fb33a4d63aa712da98a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Mar 2026 18:12:24 +0100 Subject: [PATCH 4/5] Add Document transformer tests for content parser and site fallback --- .../tests/transformer/class-test-document.php | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/phpunit/tests/transformer/class-test-document.php diff --git a/tests/phpunit/tests/transformer/class-test-document.php b/tests/phpunit/tests/transformer/class-test-document.php new file mode 100644 index 0000000..3f49e89 --- /dev/null +++ b/tests/phpunit/tests/transformer/class-test-document.php @@ -0,0 +1,217 @@ + 'test.stub.parser', + 'text' => $content, + ); + } +} + +/** + * Document transformer tests. + */ +class Test_Document extends WP_UnitTestCase { + + /** + * Test that content field is absent when no parser is registered. + */ + public function test_content_absent_without_parser() { + $post = self::factory()->post->create_and_get( + array( 'post_content' => 'Some content here.' ) + ); + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + $this->assertArrayNotHasKey( 'content', $record ); + } + + /** + * Test that content field is present when a parser is registered via filter. + */ + public function test_content_present_with_parser_filter() { + \add_filter( + 'atmosphere_content_parser', + static fn() => new Stub_Parser() + ); + + $post = self::factory()->post->create_and_get( + array( 'post_content' => 'Hello world.' ) + ); + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + $this->assertArrayHasKey( 'content', $record ); + $this->assertSame( 'test.stub.parser', $record['content']['$type'] ); + $this->assertSame( 'Hello world.', $record['content']['text'] ); + + \remove_all_filters( 'atmosphere_content_parser' ); + } + + /** + * Test that returning null from the parser filter disables content. + */ + public function test_content_disabled_with_null_filter() { + \add_filter( 'atmosphere_content_parser', '__return_null' ); + + $post = self::factory()->post->create_and_get( + array( 'post_content' => 'Some content.' ) + ); + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + $this->assertArrayNotHasKey( 'content', $record ); + + \remove_all_filters( 'atmosphere_content_parser' ); + } + + /** + * Test that a non-Content_Parser return from the filter is ignored. + */ + public function test_content_ignored_with_invalid_parser() { + \add_filter( + 'atmosphere_content_parser', + static fn() => 'not a parser' + ); + + $post = self::factory()->post->create_and_get( + array( 'post_content' => 'Some content.' ) + ); + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + $this->assertArrayNotHasKey( 'content', $record ); + + \remove_all_filters( 'atmosphere_content_parser' ); + } + + /** + * Test that content field is absent for empty post content. + */ + public function test_content_absent_for_empty_content() { + \add_filter( + 'atmosphere_content_parser', + static fn() => new Stub_Parser() + ); + + $post = self::factory()->post->create_and_get( + array( 'post_content' => '' ) + ); + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + $this->assertArrayNotHasKey( 'content', $record ); + + \remove_all_filters( 'atmosphere_content_parser' ); + } + + /** + * Test the atmosphere_document_content filter can modify parsed content. + */ + public function test_document_content_filter() { + \add_filter( + 'atmosphere_content_parser', + static fn() => new Stub_Parser() + ); + + \add_filter( + 'atmosphere_document_content', + static function ( array $content ) { + $content['modified'] = true; + return $content; + } + ); + + $post = self::factory()->post->create_and_get( + array( 'post_content' => 'Hello.' ) + ); + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + $this->assertArrayHasKey( 'content', $record ); + $this->assertTrue( $record['content']['modified'] ); + + \remove_all_filters( 'atmosphere_content_parser' ); + \remove_all_filters( 'atmosphere_document_content' ); + } + + /** + * Test that site field falls back to home URL without publication TID. + */ + public function test_site_fallback_to_home_url() { + \delete_option( 'atmosphere_publication_tid' ); + + $post = self::factory()->post->create_and_get(); + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + $this->assertArrayHasKey( 'site', $record ); + $this->assertSame( \untrailingslashit( \get_home_url() ), $record['site'] ); + } + + /** + * Test that site field uses AT-URI when publication TID exists. + */ + public function test_site_uses_at_uri_with_publication_tid() { + \update_option( 'atmosphere_publication_tid', 'test-tid-123' ); + \update_option( 'atmosphere_did', 'did:plc:test' ); + + $post = self::factory()->post->create_and_get(); + + $transformer = new Document( $post ); + $record = $transformer->transform(); + + $this->assertArrayHasKey( 'site', $record ); + $this->assertStringStartsWith( 'at://', $record['site'] ); + $this->assertStringContainsString( 'site.standard.publication', $record['site'] ); + $this->assertStringContainsString( 'test-tid-123', $record['site'] ); + + \delete_option( 'atmosphere_publication_tid' ); + \delete_option( 'atmosphere_did' ); + } + + /** + * Test the collection NSID. + */ + public function test_collection() { + $post = self::factory()->post->create_and_get(); + $transformer = new Document( $post ); + + $this->assertSame( 'site.standard.document', $transformer->get_collection() ); + } +} From a5614403cbb833cfef07e15fc91dd713db93148a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Mar 2026 18:15:46 +0100 Subject: [PATCH 5/5] Move stub parser to its own file to satisfy PHPCS one-class-per-file rule --- .../tests/transformer/class-stub-parser.php | 36 +++++++++++++++++++ .../tests/transformer/class-test-document.php | 26 ++------------ 2 files changed, 38 insertions(+), 24 deletions(-) create mode 100644 tests/phpunit/tests/transformer/class-stub-parser.php diff --git a/tests/phpunit/tests/transformer/class-stub-parser.php b/tests/phpunit/tests/transformer/class-stub-parser.php new file mode 100644 index 0000000..b869820 --- /dev/null +++ b/tests/phpunit/tests/transformer/class-stub-parser.php @@ -0,0 +1,36 @@ + 'test.stub.parser', + 'text' => $content, + ); + } +} diff --git a/tests/phpunit/tests/transformer/class-test-document.php b/tests/phpunit/tests/transformer/class-test-document.php index 3f49e89..3915ac3 100644 --- a/tests/phpunit/tests/transformer/class-test-document.php +++ b/tests/phpunit/tests/transformer/class-test-document.php @@ -9,33 +9,11 @@ namespace Atmosphere\Tests\Transformer; +require_once __DIR__ . '/class-stub-parser.php'; + use WP_UnitTestCase; -use Atmosphere\Content_Parser\Content_Parser; use Atmosphere\Transformer\Document; -/** - * Stub content parser for testing. - */ -class Stub_Parser implements Content_Parser { - - /** - * {@inheritDoc} - */ - public function get_type(): string { - return 'test.stub.parser'; - } - - /** - * {@inheritDoc} - */ - public function parse( string $content, \WP_Post $post ): array { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - return array( - '$type' => 'test.stub.parser', - 'text' => $content, - ); - } -} - /** * Document transformer tests. */