Well, that was a bit of a journey.
If you’ve been following along my site for the last few years, you know that I’ve been working to turn it into a personal hub. After finally getting around to re-building the site on a non-headless way, I’ve come to realize that there’s officially nothing stopping me from federating my blog, and nudging myself a little closer to that goal.
So now if you search for @alex you’ll find me (It might come out as @alex, but it’s the same thing either way).
I feel obligated to mention the code you’re about to see is not “good”. it’s very much copypasta as I experiment with everything and learn. if you’re coming to this post to learn to write PHP, I beg you, look at literally any other tutorial of mine.
Auto-Boosting (For Now)
The original version of this site relied on cross-posting content, both on my X and Fosstodon accounts. I had a few different solutions to make this seamless, but eventually gave up on maintaining them in-favor of just manually posting in all 3 places, my blog, X, and Mastodon.
The problem with this approach is that I’m building those followers, and having those discussions off of my site. Instead of actually keeping some, or all of that centralized on my site. Not to mention if I make an edit to the post I have to go back and modify it in all places, post lengths vary, etc.
Unsurprisingly enough – cross-posting is a pain in the ass. Which is why so few people do this, but I still insist it’s worth it. You get in a rhythm and it only adds about a minute or so to the time it takes to publish something. Unless you’re sharing a video, and in that case it’s hit-or-miss.
Anyway, the real problem with this is actually that I’m building my followers on these other platforms. This was fine until I decided I wanted to actually start getting people to follow my blog directly on the fediverse instead of simply following my social accounts. I want my website to become my social account, and the canonical source of everything.
The problem? Nobody’s followin’ me, because I just connected to the fediverse.
In a recent interview on WPBuilds, @pfefferle@mastodon.social mentioned that he got around this limitation by setting up something to automatically boost all posts from his site on his original Mastodon account. This essentially turns your original account into a “proxy” of sorts, shares the canonical post, and invites people to follow the direct account instead of the proxy.
It’s not perfect, but this entire process is going to be a time-intensive process, and require slow movements over a long period of time. Someday, maybe we’ll have a nice migration solution to migrate users from a Mastodon account to a personal WordPress account, but for now, the next best thing is auto-boosting.
Replies Use “Chat” Post Format
One nifty feature of the ActivityPub plugin is that you can actually reply to people directly inside of the post editor. There’s a handy little bookmarklet that you can use so you can reply as your personal site. This then creates the post through your site, and allows people to reply to it, etc. This allows you to stop using your Mastodon account and instead just use WordPress for everything.
I, however, do not want all of these replies to clog up my post archive, so I am making a point to set their post format to “chat”, and then filtering out that post format from all queries on my site. Deep-linking will still work, though.
This is pretty exciting, because more of the conversation I have on social will be added to my site, including replies and other conversations. This better-centralizes a lot of the conversations I’m having with people on the internet, and gives me a canonical source to link without putting someone on a platform in the process. Just one step closer!
// Hook into pre_get_posts to modify WP_Query
function exclude_chat_post_format( $query ) {
// Check if it's the main query and not in the admin area
if ( ! is_admin()) {
// Set the tax_query to exclude posts with post format "chat"
$tax_query = array(
array(
'taxonomy' => 'post_format',
'field' => 'slug',
'terms' => array( 'post-format-chat' ), // The slug for the chat post format
'operator' => 'NOT IN',
),
);
// If there's an existing tax query, merge it
if ( isset( $query->tax_query->queries ) && is_array( $query->tax_query->queries ) ) {
$query->set( 'tax_query', array_merge( $query->tax_query->queries, $tax_query ) );
} else {
$query->set( 'tax_query', $tax_query );
}
}
}
add_action( 'pre_get_posts', 'exclude_chat_post_format' );
Better Editing (Maybe)
As I’m writing this, ActivityPub for WordPress just added support for the Mastodon app, so you can actually post directly from the Mastodon app.
This literally just happened so I’m going to have to tinker with it to see how it works. Looks like it auto-generates tags. Hopefully I can filter into this and detect when it’s being published via Mastodon so I can properly format the hashtags, and also set the post format automatically. 🤞
For now I’m going to keep using the native WordPress app, but I’ll definitely be keeping an eye on this. I think if you’re just getting started with messing with all of this, it might actually be a better choice. Hard to say.
Blog Posts Use Excerpts
I don’t want my blog posts to use excerpts, so I’m hoping that this filter will actually force my activitypub feed to show the excerpt for longform content, instead. I might end up adding a custom meta field that has the social content since sometimes that needs to be different than the excerpt. Someday, maybe.
What would be really cool about that is I could then use all of that formatting for my Open Graph enhancements (more on that in a sec!)
add_filter( 'activitypub_the_content', function( $content, $post_id ) {
$post_format = get_post_format( $post_id );
// Only apply to "standard" post format
if ( $post_format === false || $post_format === 'standard' ) {
// Get the post excerpt
$excerpt = get_the_excerpt( $post_id );
// Get the post tags and format them as CamelCase hashtags
$tags = get_the_tags( $post_id );
$hashtags = '';
if ( $tags ) {
foreach ( $tags as $tag ) {
$camel_case_tag = ucwords( str_replace( ' ', '', strtolower( $tag->name ) ) ); // Convert tag to CamelCase
$hashtags .= ' #' . $camel_case_tag;
}
}
// Get the post permalink
$link = get_permalink( $post_id );
// Combine the excerpt, hashtags, and link into the new content
$content = $excerpt . $hashtags . ' ' . $link;
}
return $content;
}, 10, 2 );
Quality Of Life Stuff
It’s safe to say I have the process of publishing through WordPress pretty much down at this point, but now that I can reply to posts with the WordPress editor, I wanted to make it a lot easier to do a few things. Namely, Replying to posts in the app, and removing the need to remember to set the post format.
I’ve been meaning to do the latter for a long time. It wasn’t too difficult of a thing to do – just requires that you filter the content before it publishes, and if it doesn’t have a title, assume it’s a micropost and use the aside
format. If it’s a reply to a message, use chat
(that way I can isolate them in my queries and filter them in most cases.)
I was hesitant to do this because I wasn’t sure how to detect it, but it became quite clear pretty fast that the key difference between a micropost and a regular post is simple – microposts don’t have a title! So we can leverage that, as well as the custom activity pub “reply to” block to detect the post format when we save. This saves a step when publishing, which is really important when using the mobile app.
add_action( 'save_post', 'detect_post_format_based_on_content', 10, 3 );
function detect_post_format_based_on_content( $post_ID, $post, $update ) {
// Avoid auto-drafts or revisions
if ( $post->post_status !== 'auto-draft' && !wp_is_post_revision( $post_ID ) ) {
// Check if the post has the activitypub/reply block
if ( has_block( 'activitypub/reply', $post->post_content ) ) {
// Set post format to 'chat' if it contains the reply block
set_post_format( $post_ID, 'chat' );
}
// If no title and no reply block, set post format to 'aside'
elseif ( empty( $post->post_title ) && !has_block( 'activitypub/reply', $post->post_content ) ) {
set_post_format( $post_ID, 'aside' );
}
}
}
As for replying to posts, this was a little tricker. The ActivityPub plugin handles replies by putting a special block at the top of the post that links to the content that the post is replying to. The app then detects this block is in the content, and does the magical ActivityPub things.
Which is all fine and dancy on the desktop experience, but guess what? I’m not replying to social posts on the desktop most of the time. I want to do it on mobile. And you know what the mobile app can’t do? Custom blocks.
But fear not, for I have become somewhat of an expert at dealing with the WordPress app’s limitations, and have found a workaround – simply detecting if the first block contains a link. If it does, the system transforms that link into the activitypub block just before saving.
Theoretically, you’d be able to use the Mastodon plugin I mentioned above, but I’m still experimenting with that. I don’t love how it formats the content, and the way it handles tags is troublesome. Could probably be filtered out, but I decided it would be best if I didn’t change too much of my process too fast.
Here it is in action:
And here’s the snippet that makes it work:
//Automatically set activitypub replies.
add_filter( 'wp_insert_post_data', 'transform_url_to_activitypub_reply_on_first_publish', 10, 2 );
function transform_url_to_activitypub_reply_on_first_publish( $data, $postarr ) {
// Only run if the post status is transitioning to 'publish'
if ( isset( $data['post_status'] ) && $data['post_status'] === 'publish' ) {
// Check the current post status, we only want to run this on first-time publish
if(isset($postarr['ID'])) {
$current_post_status = get_post_status($postarr['ID']);
} else{
$current_post_status = 'draft';
}
if ( $current_post_status !== 'publish' ) {
// Parse the blocks in the post content
$blocks = parse_blocks( $data['post_content'] );
// Check if the first block is a paragraph
if ( isset( $blocks[0] ) && $blocks[0]['blockName'] === 'core/paragraph' ) {
// Load the block's inner HTML into a DOMDocument
$dom = new DOMDocument();
// Suppress warnings when loading HTML with special characters
@$dom->loadHTML( $blocks[0]['innerHTML'], LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
// Find the <a> tag and extract its href attribute
$anchor = $dom->getElementsByTagName('a')->item(0);
if ( $anchor && !empty($anchor->getAttribute('href')) ) {
$url = esc_url( $anchor->getAttribute('href') );
// Create the ActivityPub reply block with the extracted URL
$activitypub_reply_block = '<!-- wp:activitypub/reply {"url":"' . $url . '"} /-->';
// Rebuild the full block's raw representation
$original_block = serialize_block( $blocks[0] );
// Replace the entire block with the new ActivityPub reply block
$data['post_content'] = str_replace( $original_block, $activitypub_reply_block, $data['post_content'] );
}
}
}
}
return $data;
}
Improved OpenGraph
This has me so damn excited I can’t even stand it. Since I am able to detect the type of post, I can actually provide much better opengraph data, and if the platform supports it, it will embed the content a lot nicer.
For example, if a post includes a video, it changes the OpenGraph data so that the video can be embedded, both on Twitter and inside iMessage. Conversely, if you share a link to one of my microposts, it will actually embed the micropost content directly in the message.
This is great because an unexpected side-effect of posting…basically everything through your site is that, well, you find yourself sharing links to your own content a lot. I never realized how often I would share a Tweet, or a Facebook post that I wrote to a family member directly, or something along those lines. Well, I was doing that with my own site now, and really wasn’t happy with the quality of the content embedding. So I went down a bit of a rabbit hole, and boy oh boy is it awesome. Seriously, try it. Here’s a video of me flying a kite. It’s a good use for it.
Here’s the code that I’m using to customize. This is all kinda custom to my needs, but it can give you an idea on some of the changes I’ve made.
<?php
/**
* Plugin Name: Video Block Open Graph & Twitter Card Tweaks
* Description: Overwrite Yoast's Open Graph and Twitter Card meta tags for posts with video blocks or 'video' post format.
*/
/**
* Detects video block data in post content, including URL, width, and height.
* If width and height are not available in the block, it tries to fetch them from the video metadata.
*
* @param string $content The post content.
* @return array|false The video URL and dimensions if found, false otherwise.
*/
function detect_video_block_data( $content ) {
if(!$content){
return false;
}
// Scrape video block HTML
$doc = new DOMDocument();
@$doc->loadHTML($content);
$videoElements = $doc->getElementsByTagName('video');
if ($videoElements->length > 0) {
$video = $videoElements->item(0);
$url = $video->getAttribute('src');
// Check if width and height are set in the HTML attributes
$width = $video->hasAttribute('width') ? $video->getAttribute('width') : null;
$height = $video->hasAttribute('height') ? $video->getAttribute('height') : null;
// If width or height are not available, attempt to fetch metadata from the video file
if (empty($width) || empty($height)) {
$video_metadata = get_video_metadata( $url );
if ( $video_metadata ) {
$width = $video_metadata['width'];
$height = $video_metadata['height'];
}
}
return [
'url' => $url,
'width' => $width ?: '640', // Default width if not found
'height' => $height ?: '360' // Default height if not found
];
}
return false;
}
/**
* Attempts to fetch video metadata such as width and height from a video URL.
* This is a basic implementation, more advanced methods (FFmpeg, external services, etc.) can be used if needed.
*
* @param string $video_url The URL of the video.
* @return array|false Array with width and height, or false if it cannot be determined.
*/
function get_video_metadata( $video_url ) {
// This is a simplified way to get metadata. It would be more complex in a real-world scenario.
// You might need to use a video processing library like FFmpeg for more detailed information.
// For this example, let's assume we can do a quick HTTP HEAD request to check metadata.
// However, WordPress itself doesn't have a built-in way to extract video metadata, so we'll use defaults.
$headers = wp_remote_head( $video_url );
if ( is_wp_error( $headers ) ) {
return false;
}
// Example of how we might pull information from headers (many video hosts won't provide this)
// You might need to implement an external API or FFmpeg to fully extract metadata.
// As a fallback, return default values (in reality, more advanced parsing would happen here)
return [
'width' => '640', // Example fallback width
'height' => '360' // Example fallback height
];
}
// Filter to replace Yoast's Open Graph type if a video is detected or the post format is 'video'.
add_filter( 'wpseo_opengraph_type', function( $type ) {
if ( is_singular() ) {
global $post;
$post_format = get_post_format( $post );
$has_video_block = detect_video_block_data( $post->post_content );
if ( 'video' === $post_format || $has_video_block ) {
return 'video.other'; // Set Open Graph type to video
}
}
return $type;
});
// Filter to overwrite Yoast's Open Graph title based on post type or format.
add_filter( 'wpseo_opengraph_title', function( $title ) {
global $post;
// Check if the post contains a video block
$has_video_block = detect_video_block_data( $post->post_content );
// If the post is a video, use the post content as the title with the prefix.
if ( $has_video_block ) {
$post_content = wp_strip_all_tags( $post->post_content ); // Remove any HTML tags
$custom_title = 'Alex Standiford: ' . $post_content;
return $custom_title;
}
// If the post format is 'aside', set a custom title.
if ( get_post_format( $post ) === 'aside' ) {
return 'Alex Standiford';
}
// Return the default title for other cases.
return $title;
});
// Filter to overwrite Yoast's Open Graph image with video meta tags when a video is detected.
add_filter( 'wpseo_opengraph_image', function( $image ) {
global $post;
$has_video_block = detect_video_block_data( $post->post_content );
if ( $has_video_block ) {
// Return false to remove the image as we're replacing it with video.
return false;
}
return $image;
});
// Filter to add Open Graph video properties.
add_action( 'wpseo_opengraph', function() {
global $post;
$post_format = get_post_format( $post );
$has_video_block = detect_video_block_data( $post->post_content );
if ( 'video' === $post_format || $has_video_block ) {
// Get video data from content
$video_data = detect_video_block_data( $post->post_content );
if ( $video_data ) {
echo '<meta property="og:video" content="' . esc_url( $video_data['url'] ) . '" />';
if ( !empty( $video_data['width'] ) && !empty( $video_data['height'] ) ) {
echo '<meta property="og:video:width" content="' . esc_attr( $video_data['width'] ) . '" />';
echo '<meta property="og:video:height" content="' . esc_attr( $video_data['height'] ) . '" />';
}
echo '<meta property="og:video:type" content="video/mp4" />'; // Adjust type as needed
}
}
}, 20 );
// Filter to overwrite Twitter card type with 'player' if video is detected.
add_filter( 'wpseo_twitter_card_type', function( $type ) {
global $post;
$post_format = get_post_format( $post );
$has_video_block = detect_video_block_data( $post->post_content );
if ( 'video' === $post_format || $has_video_block ) {
return 'player'; // Set Twitter Card to player
}
return $type;
});
// Add Twitter Player card meta tags when a video is detected.
add_action( 'wpseo_twitter', function() {
global $post;
$post_format = get_post_format( $post );
$has_video_block = detect_video_block_data( $post->post_content );
if ( 'video' === $post_format || $has_video_block ) {
// Get video data from content
$video_data = detect_video_block_data( $post->post_content );
if ( $video_data ) {
echo '<meta name="twitter:player" content="' . esc_url( $video_data['url'] ) . '" />';
echo '<meta name="twitter:text:player_width" content="' . esc_attr( $video_data['width'] ) . '" />';
echo '<meta name="twitter:text:player_height" content="' . esc_attr( $video_data['height'] ) . '" />';
echo '<meta name="twitter:player:width" content="' . esc_attr( $video_data['width'] ) . '" />';
echo '<meta name="twitter:player:height" content="' . esc_attr( $video_data['height'] ) . '" />';
echo '<meta name="twitter:player:stream" content="' . esc_url( $video_data['url'] ) . '" />';
echo '<meta name="twitter:player:stream:content_type" content="video/mp4" />'; // Adjust content type if needed
}
}
});
Leave a Reply