WordPress: Fix thumbnail on Mastodon’s preview cards

I’ve noticed that my articles did not have their carefully chosen thumbnail images displayed when shared on Mastodon and investigated a bit on that. I found two ways of making it work:

Mastodon first tries to build a preview using the OpenGraph meta tags, and some of them were missing. Required og tags are:

  • og:type
  • og:title
  • og:description
  • og:image
  • og:url
  • og:locale

Otherwise, Mastodon fallbacks to fetch the relevant data from the post using the link found in the post’s header:

<link rel="alternate" type="application/json+oembed" href=...>

This returns JSON data generated by WordPress in the following form:

{
  "version": "1.0",
  "provider_name": "colin@colino.net",
  "provider_url": "https://www.colino.net/wordpress",
  "author_name": "Colin",
  "author_url": "/wordpress/archives/author/colin/",
  "title": "Au revoir Twitter, et merci !",
  "type": "rich",
  "width": 600,
  "height": 338,
  "html": "<blockquote class=\"wp-embedded-content\" data-secret=\"YsQM2MlHiO\"><a href=\"/wordpress/archives/2022/11/27/au-revoir-twitter-et-merci/\">Au revoir Twitter, et merci !</a></blockquote><iframe sandbox=\"allow-scripts\" security=\"restricted\" src=\"/wordpress/archives/2022/11/27/au-revoir-twitter-et-merci/embed/#?secret=YsQM2MlHiO\" width=\"600\" height=\"338\" title=\"« Au revoir Twitter, et merci ! » &#8212; colin@colino.net\" data-secret=\"YsQM2MlHiO\" frameborder=\"0\" marginwidth=\"0\" marginheight=\"0\" scrolling=\"no\" class=\"wp-embedded-content\"></iframe><script type=\"text/javascript\">\n/*! This file is auto-generated */\n!function(c,l){\"use strict\";var e=!1,o=!1;if(l.querySelector)if(c.addEventListener)e=!0;if(c.wp=c.wp||{},c.wp.receiveEmbedMessage);else if(c.wp.receiveEmbedMessage=function(e){var t=e.data;if(!t);else if(!(t.secret||t.message||t.value));else if(/[^a-zA-Z0-9]/.test(t.secret));else{for(var r,s,a,i=l.querySelectorAll('iframe[data-secret=\"'+t.secret+'\"]'),n=l.querySelectorAll('blockquote[data-secret=\"'+t.secret+'\"]'),o=0;o<n.length;o++)n[o].style.display=\"none\";for(o=0;o<i.length;o++)if(r=i[o],e.source!==r.contentWindow);else{if(r.removeAttribute(\"style\"),\"height\"===t.message){if(1e3<(s=parseInt(t.value,10)))s=1e3;else if(~~s<200)s=200;r.height=s}if(\"link\"===t.message)if(s=l.createElement(\"a\"),a=l.createElement(\"a\"),s.href=r.getAttribute(\"src\"),a.href=t.value,a.host===s.host)if(l.activeElement===r)c.top.location.href=t.value}}},e)c.addEventListener(\"message\",c.wp.receiveEmbedMessage,!1),l.addEventListener(\"DOMContentLoaded\",t,!1),c.addEventListener(\"load\",t,!1);function t(){if(o);else{o=!0;for(var e,t,r,s=-1!==navigator.appVersion.indexOf(\"MSIE 10\"),a=!!navigator.userAgent.match(/Trident.*rv:11\\./),i=l.querySelectorAll(\"iframe.wp-embedded-content\"),n=0;n<i.length;n++){if(!(r=(t=i[n]).getAttribute(\"data-secret\")))r=Math.random().toString(36).substr(2,10),t.src+=\"#?secret=\"+r,t.setAttribute(\"data-secret\",r);if(s||a)(e=t.cloneNode(!0)).removeAttribute(\"security\"),t.parentNode.replaceChild(e,t);t.contentWindow.postMessage({message:\"ready\",secret:r},\"*\")}}}}(window,document);\n</script>\n",
  "thumbnail_url": "/wordpress/wp-content/uploads/image-59.png",
  "thumbnail_width": 600,
  "thumbnail_height": 305
}

Mastodon wants nothing to do with type: rich oembed contents, explaining why in its sourcecode:

    case @card.type
    when 'link'
      @card.image_remote_url = (url + embed[:thumbnail_url]).to_s if embed[:thumbnail_url].present?
    when 'photo'
      return false if embed[:url].blank?

      @card.embed_url        = (url + embed[:url]).to_s
      @card.image_remote_url = (url + embed[:url]).to_s
      @card.width            = embed[:width].presence  || 0
      @card.height           = embed[:height].presence || 0
    when 'video'
      @card.width            = embed[:width].presence  || 0
      @card.height           = embed[:height].presence || 0
      @card.html             = Sanitize.fragment(embed[:html], Sanitize::Config::MASTODON_OEMBED)
      @card.image_remote_url = (url + embed[:thumbnail_url]).to_s if embed[:thumbnail_url].present?
    when 'rich'
      # Most providers rely on <script> tags, which is a no-no
      return false
    end

This is very much understandable, nobody sane wants to inject javascript as returned by WordPress’s oembed generator. Fortunately, we can fix it by adding a filter in our child theme’s functions.php file, removing the html field and setting type to link :

/* Return link instead of rich oembed data */
function filter_oembed_response_data($data) {
  if ($data['type'] === 'rich') {
    $data['type'] = 'link';
    unset($data['html']);
  }
  /* While we're at it, remove single quotes html entities */
  $data["title"] = str_replace("&rsquo;","'", $data["title"]);

  return $data;
}
add_filter('oembed_response_data', 'filter_oembed_response_data', 11, 4);

Before/after:

A toot with a link to a WordPress post, with no preview image
A toot with a link to a WordPress post with its preview image