diff --git a/byline-manager.php b/byline-manager.php index c33e7818..50164bf9 100755 --- a/byline-manager.php +++ b/byline-manager.php @@ -50,11 +50,14 @@ function validate_path( string $path ) : bool { // Admin interfaces. require_once BYLINE_MANAGER_PATH . 'inc/admin-ui.php'; -// REST API interfaces. +// REST API integration. require_once BYLINE_MANAGER_PATH . 'inc/rest-api.php'; -// GraphQL interfaces. +// WPGraphQL integration. require_once BYLINE_MANAGER_PATH . 'inc/graphql.php'; +// Yoast SEO integration. +require_once BYLINE_MANAGER_PATH . 'inc/yoast.php'; + // Hook into core filters to output byline. require_once BYLINE_MANAGER_PATH . 'inc/core-filters.php'; diff --git a/composer.json b/composer.json index 5812302e..d056f8d1 100644 --- a/composer.json +++ b/composer.json @@ -16,13 +16,15 @@ "phpstan/phpstan": "^1.10", "szepeviktor/phpstan-wordpress": "^1.1.6", "yoast/phpunit-polyfills": "^2.0", - "axepress/wp-graphql-stubs": "^1.14" + "axepress/wp-graphql-stubs": "^1.14", + "yoast/wordpress-seo": "^22.7" }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, "alleyinteractive/composer-wordpress-autoloader": true, - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "composer/installers": false } }, "scripts": { @@ -37,5 +39,10 @@ "@phpstan", "@phpunit" ] + }, + "extra": { + "installer-paths": { + "vendor/{$vendor}/{$name}/": ["type:wordpress-plugin"] + } } } diff --git a/inc/models/class-profile.php b/inc/models/class-profile.php index 59cf044e..eb26a941 100755 --- a/inc/models/class-profile.php +++ b/inc/models/class-profile.php @@ -19,10 +19,11 @@ * * Dynamic properties. * - * @property int $post_id Post ID for the profile. - * @property int $term_id Term ID for the profile. + * @property int $post_id Post ID for the profile. + * @property int $term_id Term ID for the profile. * @property string $display_name Display name for the profile. - * @property string $user_url User url. + * @property string $user_url User url. + * @property string $link Profile permalink. */ class Profile { /** diff --git a/inc/yoast.php b/inc/yoast.php new file mode 100644 index 00000000..fa90dd60 --- /dev/null +++ b/inc/yoast.php @@ -0,0 +1,150 @@ +context->post->ID; + $type = $presentation->model->object_sub_type; + + if ( $id && $type && Utils::is_post_type_supported( $type ) ) { + $author_name = get_the_byline( $id ); + } + + return $author_name; +} + +/** + * Filter the Yoast SEO enhanced data for sharing on Slack. + * + * @param array $data The enhanced Slack sharing data. + * @param Indexable_Presentation $presentation The presentation of an indexable. + * @return array Filtered data. + */ +function filter_wpseo_enhanced_slack_data( $data, $presentation ): array { + $id = $presentation->context->post->ID; + $type = $presentation->model->object_sub_type; + + if ( $id && $type && Utils::is_post_type_supported( $type ) ) { + $byline = get_the_byline( $id ); + + if ( $byline ) { + $data['Written by'] = $byline; + } else { + unset( $data['Written by'] ); + } + } + + return $data; +} + +/** + * Remove all 'Person' nodes from the Yoast SEO schema graph before adding ours. + * + * @param Abstract_Schema_Piece[] $schema_pieces The existing graph pieces. + * @param Meta_Tags_Context $context An object with context variables. + * @return Abstract_Schema_Piece[] + */ +function filter_wpseo_schema_graph_pieces( $schema_pieces, $context ) { + if ( + 'post' === $context->indexable->object_type + && Utils::is_post_type_supported( $context->indexable->object_sub_type ) + ) { + $schema_pieces = array_filter( $schema_pieces, fn ( $piece ) => ! $piece instanceof Person ); + } + + return $schema_pieces; +} + +/** + * Filters the Yoast SEO schema graph output. + * + * @param array $graph The graph to filter. + * @param Meta_Tags_Context $context An object with context variables. + * @return array + */ +function filter_wpseo_schema_graph( $graph, $context ) { + if ( ! class_exists( Schema_Types::class ) || ! function_exists( 'YoastSEO' ) ) { + return $graph; + } + + if ( ! $graph ) { + return $graph; + } + + $schema_pieces = []; + $schema_types = new Schema_Types(); + $helpers = YoastSeo()->helpers; + + /* + * It's easier to create the schema pieces and generate their output here, rather than filtering the schema + * pieces into 'filter_schema_graph_pieces', because Yoast allows only one node of each '@type' in the graph. + */ + if ( 'post' === $context->indexable->object_type && Utils::is_post_type_supported( $context->indexable->object_sub_type ) ) { + foreach ( Utils::get_byline_entries_for_post( $context->indexable->object_id ) as $entry ) { + if ( $entry instanceof Profile ) { + $schema_pieces[] = new Profile_Schema( $entry, $helpers, $context ); + } + + if ( $entry instanceof TextProfile ) { + $schema_pieces[] = new TextProfile_Schema( $entry, $helpers, $context ); + } + } + } + + $new_schema_nodes = []; + $new_schema_ids = []; + + foreach ( $schema_pieces as $schema_piece ) { + if ( $schema_piece->is_needed() ) { + $generated = $schema_piece->generate(); + $new_schema_nodes[] = $generated; + + if ( isset( $generated['@id'] ) ) { + $new_schema_ids[] = [ '@id' => $generated['@id'] ]; + } + } + } + + array_push( $graph, ...$new_schema_nodes ); + + // Update 'author' property in article nodes to reference the IDs of our new schema objects. + foreach ( $graph as $i => $node ) { + if ( + isset( $node['@type'] ) + && is_string( $node['@type'] ) + && isset( $schema_types::ARTICLE_TYPES[ $node['@type'] ] ) + ) { + $graph[ $i ]['author'] = $new_schema_ids; + } + } + + return $graph; +} diff --git a/inc/yoast/class-profile-schema.php b/inc/yoast/class-profile-schema.php new file mode 100644 index 00000000..6994cb17 --- /dev/null +++ b/inc/yoast/class-profile-schema.php @@ -0,0 +1,72 @@ +profile->get_post(); + + $data = [ + '@type' => 'Person', + '@id' => $post->guid, + 'name' => $this->helpers->schema->html->smart_strip_tags( $this->profile->display_name ), + 'url' => $this->profile->link, + ]; + + $excerpt = $this->helpers->schema->html->smart_strip_tags( get_the_excerpt( $post ) ); + + if ( $excerpt ) { + $data['description'] = $excerpt; + } + + if ( has_post_thumbnail( $post ) ) { + $data['image'] = $this->helpers->schema->image->generate_from_attachment_id( + $this->context->site_url . Schema_IDs::PERSON_LOGO_HASH, + get_post_thumbnail_id( $post ), + '' + ); + } + + /** + * Filters the Yoast SEO schema graph data for the Byline Manager profile. + * + * @param array $data The schema data for the profile. + * @param Profile $profile The Byline Manager profile. + */ + return apply_filters( 'byline_manager_yoast_profile_schema', $data, $this->profile ); + } +} diff --git a/inc/yoast/class-textprofile-schema.php b/inc/yoast/class-textprofile-schema.php new file mode 100644 index 00000000..8ac1d90e --- /dev/null +++ b/inc/yoast/class-textprofile-schema.php @@ -0,0 +1,47 @@ + 'Person', + '@id' => $this->context->site_url . Schema_IDs::PERSON_HASH . wp_hash( $this->profile->display_name ), + 'name' => $this->helpers->schema->html->smart_strip_tags( $this->profile->display_name ), + ]; + } +}