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. diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index 8110424..8ab16b3 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,43 @@ 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. Requires the edit_posts capability. + */ + 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. * 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 new file mode 100644 index 0000000..3915ac3 --- /dev/null +++ b/tests/phpunit/tests/transformer/class-test-document.php @@ -0,0 +1,195 @@ +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() ); + } +}