During our 2022 redesign, Tyler noticed that our OG (Open Graph) tags weren’t working quite right. We had been using Jetpack to add these, but for reasons that weren’t clear to us, that stopped working in late 2020. In the interim, we tried a few alternative plugins, but none of them worked quite the way we wanted.

Here’s the great thing about the Open Graph protocol: It uses native HTML elements! There’s nothing particularly complex about the tags themselves, there are just a lot of them, and it was convenient to have a plugin generate them for us. But that meant we were giving up a degree of control, and accepting what the plugin thought was the correct output. Since our site has several custom content types and some special logic for handling the featured image, we decided to roll our own.

If you’re interested in doing the same, I hope this is helpful. I’ll be broadly summarizing what we did in this article, but if you use WordPress, you may be interested in viewing our OG helper code directly.

Adding an Open Graph helper functionStep one was adding a new PHP file containing a script that will generate the OG tags. We already have a collection of these, which we store in a /helpers directory. I created add_open_graph_tags.php there, and stubbed out an empty add_open_graph_tags() function.

```

'og:site_name', 'content' => get_bloginfo('name')], ['property' => 'og:locale', 'content' => get_locale()],];// Echo the OG tags to the pageforeach ($open_graph_tags as $tag) { echo sprintf( "\n", $tag['property'], $tag['content'] );} ``` And with that, suddenly two OG tags are being rendered on every page of the site! Homepage OG tagsFrom here, the bulk of the function is broken up by WordPress content type, using the handy `is_type()` helpers. For example, here’s what the code to add homepage-specific OG tags looks like: ``` // Homepage OG tagsif (is_front_page()) { $open_graph_tags = array_merge($open_graph_tags, [ ['property' => 'og:type', 'content' => 'website'], ['property' => 'og:url', 'content' => get_bloginfo('url')], ['property' => 'og:title', 'content' => get_bloginfo('name')], [ 'property' => 'og:description', 'content' => get_bloginfo('description'), ], [ 'name' => 'description', 'content' => get_bloginfo('description'), ], ]);} ``` The whole thing is nested inside an `is_front_page()` check, so we know this code will only run on the homepage. You may have noticed that all the items have a `property` key except the second `description` item. That’s because we want to generate both an OG description tag and a traditional `` tag. In theory, you can skip `og:title` and `og:description` and sites that consume your OG tags *should* fall back to the `` and `<meta property="description">` elements. In practice, we’ve seen some unpredictable results in tools like Slack or Apple News. It costs us nothing to use both, so we’re playing it safe. This did require a small update to our output code: ``` // Echo the OG tags to the pageforeach ($open_graph_tags as $tag) { // handle non-OG tags like meta description if (array_key_exists('name', $tag)) { echo sprintf( "<meta name='%s' content='%s' />\n", $tag['name'], $tag['content'] ); continue; } echo sprintf( "<meta property='%s' content='%s' />\n", $tag['property'], $tag['content'] );} ``` And now our homepage has a proper set of OG tags! I won’t waste your time by walking through every other content type since they all follow the same basic pattern. If you’re curious, feel free to view the full source, where we cover pages, single posts, author pages, taxonomy pages, and a few of our custom content types like talks. However, I would like to talk about how we handled the `og:image` tag, which is a little special. Image OG tagsThere are only a few required OG tags: title, type, URL, and image. Title and URL are easy. Type is limited to a few options like website (for standard pages), article (for blog posts), and profile (for author pages). But image is a bit special. In addition to setting `og:image` to the URL of the featured image for any given page, you’ll want to set a series of optional structured tags, such as `og:image:width` and `og:image:alt`. Since every page needs OG image tags, even if it doesn’t have a featured image, we handled it a bit differently. At the top of the file, we defined the default image: ``` // Define fallback image, for use in Image OG Tags below$image = new Image( $site->patterns->assets_directory_uri . '/favicons/icon-512.png'); ``` (If you don’t recognize the `new Image()` part, we’re using Timber, which offers this helper to return an image object that contains the image’s width, height, etc.) With the default image defined, now each content type gets the opportunity to override the `$image` variable with a better image. For example, the author section sets it to the user’s avatar: ``` // Author OG tagselseif (is_author()) { ... // Set the image to the user's avatar, for use in Image OG Tags below $image = $timber_user->avatar();} ``` For blog posts, we have some special code to use a generated default featured image based on the post’s category: ``` // Single Post OG tagselseif (is_single()) { ... // Set the image, for use in Image OG Tags below if ($timber_post->thumbnail()) { $image = $timber_post->thumbnail(); } else { $image = get_default_feature_image($timber_post, 'png'); }} ``` Then, at the end of the file, just before we render the OG tags, we have the section that adds the image-related OG items to the array: ``` // Image OG tags$open_graph_tags = array_merge($open_graph_tags, [ ['property' => 'og:image', 'content' => $image->src()], ['property' => 'og:image:secure_url', 'content' => $image->src()], ['property' => 'og:image:alt', 'content' => $image->alt()], ['property' => 'og:image:width', 'content' => $image->width()], ['property' => 'og:image:height', 'content' => $image->height()], [ 'property' => 'og:image:type', 'content' => get_post_mime_type($image->id), ],]); ``` Testing & ValidationNow, once you’ve gone to all the trouble of adding OG tags, you’ll want to validate that they’re working properly. Thankfully, there are a few tools out there to help ensure your tags are set properly: * Facebook’s Sharing Debugger * Twitter’s Card Validator * LinkedIn’s Post Inspector * Pinterest’s URL Debugger * OpenGraph.xyz Unfortunately, these tools rely on your site being publicly reachable, so you may not be able to test from your local computer. Something you could try in that case is creating a minimal HTML document at a public URL and pasting in the OG tags from your site. ConclusionThe result of all this, I’m happy to say, has been working very well. At the end of the day, the Open Graph protocol is just a recommendation for using standard HTML `meta` elements to express information about your site in an agreed format. I hope that after seeing this, even if you don’t decide to handle your own OG tags, at least the process has been demystified a bit. ?> </div> <div class="listen"> <p><a href="/podcast/3560555/cloud-four/episodes/">See All 95 Episodes of "Cloud Four"</a></p> </div> </div> </div> </div> <div class="episodes"> </div> </div> <footer class="footer"> <ul class="nav-menu"> <li class="nav-item"><a href="mailto:jasonlustig@gmail.com">Contact</a></li> <li class="nav-item"><a href="podcasters/">For Podcasters</a></li> <li class="nav-item"><a href="api/">API</a></li> </ul> </footer> <script type="text/javascript" src='/static/jquery/dist/jquery.min.js'></script> <script type="text/javascript" src='/static/typeahead.js/dist/bloodhound.js'></script> <script type="text/javascript" src='/static/typeahead.js/dist/typeahead.bundle.js'></script> <script type="text/javascript" src="https://unpkg.com/swiper@8/swiper-bundle.js"></script> <script type="text/javascript" src="https://unpkg.com/jsrender@1.0.11/jsrender.min.js"></script> <script type="text/javascript"> function twitterUsernameKeyUp(e) { if (e.keyCode == 13) { submitTwitterUsername(); return false; } return true; } function submitTwitterUsername() { search = document.forms["twitter_search"]; var username = search.twitter_handle.value; if (username == '') { return; } var url = 'twitter/'+username+"/"; window.location.href = url; } function searchQueryKeyUp(e) { if (e.keyCode == 13) { document.forms["search"].submit(); } }; function runSearchQuery() { search = document.forms["search"]; if (search.search_query == '') { return; } var url = 'search/'; /* if (search.search_for.value == "episodes") { url += 'episodes/'; } */ url += '?search_query='+escape(search.search_query.value) url += '&language='+escape(search.language.value); url += '&safe_search='+escape(search.safe_search.value); if (search.number_episodes) { url += '&number_episodes='+escape(search.number_episodes.value); } if (search.date) { url += '&date='+escape(search.date.value); } window.location.href = url; /* if (search.location) { url += '&location='+escape(search.location.value); if (search.location.value != 'any' && navigator.geolocation) { navigator.geolocation.getCurrentPosition(position => { const lat = position.coords.latitude; const long = position.coords.longitude; url += '&latitude='+escape(lat); url += '&longitude='+escape(long); window.location.href = url; }, error => { search.location.value = 'any'; }); } else { window.location.href = url; } } else { window.location.href = url; } */ } var lastSearchPodcasts; var lastSearchEpisodes; var searchPodcasts = new Bloodhound({ datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), queryTokenizer: Bloodhound.tokenizers.whitespace, cache: false, remote: { url: 'autocomplete/?query=', wildcard: '%QUERY', prepare: function (query, settings) { settings.url = settings.url + encodeURIComponent(query); settings.beforeSend = function(e) { if (lastSearchPodcasts) { lastSearchPodcasts.abort(); } lastSearchPodcasts = e; }; return settings; }, rateLimitBy: 'throttle', rateLimitWait: 800 } }); $('.typeahead').typeahead({ minLength: 5, highlight: true }, { name: 'podcast-search', display: 'title', limit: 'Infinity', source: searchPodcasts, templates: { empty: function() { if ($('.typeahead')[1].value.length >= 3) { return '<div class="empty-message"><p class="search-result"><b><a href="#" onclick="document.forms[\'search\'].submit()">No podcasts match this <i>exact</i> term, see all results</a></b></p></div>'; } }, suggestion: function(data) { return "<p class='search-result' style='text-align: left !important;'><table width='100%'><tr><td><img src='static/podcast/"+data.image+".jpg' width=50 height=50 /></td><td width='100%' valign='middle' align='left'>" + data.title + "<br /><small><i>"+data.author+"</i></small></td></tr></table></p>"; }, header: function() { return '<p class="search-result" style="width: 100%; height: 2.5rem; vertical-align:middle; text-align: center !important;"><a href="#" onclick="document.forms[\'search\'].submit()">Search All Results</a></p>' } } }) .on('typeahead:selected', function(event, data) { console.log(data); slug = data.title.replace(/[^a-z0-9]/gmi, "-").replace(/\s+/g, "-").toLowerCase(); if (data.podcast_id) { slug_podcast_title = data.podcast_title.replace(/[^a-z0-9]/gmi, "-").replace(/\s+/g, "-").toLowerCase() window.location.href = 'podcast/'+data.podcast_id+'/'+slug_podcast_title+'/episode/'+data.id+'/'+slug+'/'; } else { window.location.href = 'podcast/'+data.id+'/'+slug+'/'; } } ); </script> <script type="text/javascript"> const hamburger = document.querySelector(".hamburger"); const navMenu = document.querySelector(".nav-menu"); hamburger.addEventListener("click", mobileMenu); function mobileMenu() { hamburger.classList.toggle("active"); navMenu.classList.toggle("active"); } </script> <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js" crossorigin></script> <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js" crossorigin></script> <script> const sectionsWithCarousel = document.querySelectorAll( ".section-with-carousel" ); for (const section of sectionsWithCarousel) { let slidesPerView = [2.5, 3.5, 4.5]; if (section.classList.contains("section-with-left-offset")) { slidesPerView = [1.5, 1.5, 2.5]; } const swiper = section.querySelector(".swiper"); new Swiper(swiper, { slidesPerView: slidesPerView[0], spaceBetween: 0, loop: true, lazyLoading: false, preventClicks: false, preventClicksPropagation: false, keyboard: { enabled: true }, navigation: { prevEl: section.querySelector(".carousel-control-left"), nextEl: section.querySelector(".carousel-control-right") }, pagination: { el: section.querySelector(".swiper-pagination"), clickable: true }, breakpoints: { 768: { slidesPerView: slidesPerView[1] }, 1200: { slidesPerView: slidesPerView[2] } } }); } </script> </body> </html>