Skip to content

Commit

Permalink
Merge pull request #344 from alleyinteractive/feature/GH-305/yoast-in…
Browse files Browse the repository at this point in the history
…tegration

Improve default integration with Yoast SEO
  • Loading branch information
dlh01 authored and Alley Operations committed May 29, 2024
1 parent 0831d6b commit 4d0e275
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 7 deletions.
7 changes: 5 additions & 2 deletions byline-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
11 changes: 9 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -37,5 +39,10 @@
"@phpstan",
"@phpunit"
]
},
"extra": {
"installer-paths": {
"vendor/{$vendor}/{$name}/": ["type:wordpress-plugin"]
}
}
}
7 changes: 4 additions & 3 deletions inc/models/class-profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
150 changes: 150 additions & 0 deletions inc/yoast.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php
/**
* Integrate with Yoast SEO
*
* @package Byline_Manager
*/

namespace Byline_Manager;

use Byline_Manager\Models\Profile;
use Byline_Manager\Models\TextProfile;
use Byline_Manager\Yoast\Profile_Schema;
use Byline_Manager\Yoast\TextProfile_Schema;
use Yoast\WP\SEO\Config\Schema_Types;
use Yoast\WP\SEO\Context\Meta_Tags_Context;
use Yoast\WP\SEO\Generators\Schema\Abstract_Schema_Piece;
use Yoast\WP\SEO\Generators\Schema\Person;
use Yoast\WP\SEO\Presentations\Indexable_Presentation;

// These filters run early to try to give preference to existing integrations on sites using both plugins.
add_filter( 'wpseo_meta_author', __NAMESPACE__ . '\filter_wpseo_meta_author', 1, 2 );
add_filter( 'wpseo_enhanced_slack_data', __NAMESPACE__ . '\filter_wpseo_enhanced_slack_data', 1, 2 );
add_filter( 'wpseo_schema_graph_pieces', __NAMESPACE__ . '\filter_wpseo_schema_graph_pieces', 1, 2 );
add_filter( 'wpseo_schema_graph', __NAMESPACE__ . '\filter_wpseo_schema_graph', 1, 2 );

/**
* Filter the Yoast SEO author meta tag.
*
* @param string $author_name The article author's display name. Return empty to disable the tag.
* @param Indexable_Presentation $presentation The presentation of an indexable.
* @return string Filtered tag.
*/
function filter_wpseo_meta_author( $author_name, $presentation ): string {
$id = $presentation->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;
}
72 changes: 72 additions & 0 deletions inc/yoast/class-profile-schema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php
/**
* Profile_Schema class file
*
* @package Byline_Manager
*/

namespace Byline_Manager\Yoast;

use Byline_Manager\Models\Profile;
use Yoast\WP\SEO\Config\Schema_IDs;
use Yoast\WP\SEO\Context\Meta_Tags_Context;
use Yoast\WP\SEO\Generators\Schema\Author;
use Yoast\WP\SEO\Surfaces\Helpers_Surface;

/**
* Adds a Byline Manager profile to the Yoast SEO schema graph.
*
* Extend the Author class for its `is_needed()` logic.
*/
final class Profile_Schema extends Author {
/**
* Constructor.
*
* @param Profile $profile Profile instance.
* @param Helpers_Surface $helpers Helpers surface.
* @param Meta_Tags_Context $context Meta tags context.
*/
public function __construct(
private readonly Profile $profile,
public $helpers,
public $context,
) {}

/**
* Generates the schema piece.
*
* @return mixed
*/
public function generate() {
$post = $this->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 );
}
}
47 changes: 47 additions & 0 deletions inc/yoast/class-textprofile-schema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
/**
* TextProfile_Schema class file
*
* @package Byline_Manager
*/

namespace Byline_Manager\Yoast;

use Byline_Manager\Models\TextProfile;
use Yoast\WP\SEO\Config\Schema_IDs;
use Yoast\WP\SEO\Context\Meta_Tags_Context;
use Yoast\WP\SEO\Generators\Schema\Author;
use Yoast\WP\SEO\Surfaces\Helpers_Surface;

/**
* Adds a Byline Manager text profile to the Yoast SEO schema graph.
*
* Extend Author for its `is_needed()` logic.
*/
final class TextProfile_Schema extends Author {
/**
* Constructor.
*
* @param TextProfile $profile TextProfile instance.
* @param Helpers_Surface $helpers Helpers surface.
* @param Meta_Tags_Context $context Meta tags context.
*/
public function __construct(
private readonly TextProfile $profile,
public $helpers,
public $context,
) {}

/**
* Generates the schema piece.
*
* @return mixed
*/
public function generate() {
return [
'@type' => '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 ),
];
}
}

0 comments on commit 4d0e275

Please sign in to comment.