Life without {{ghost_head}}

If you want to customize more fully, you might want to replace {{ghost_head}} with your own code. Here we break it down.

Life without {{ghost_head}}
Don't worry, little Ghost! I didn't mean like that!
☠️
Heads up! Today we go weird, technical, and speculative. If you just want to write posts with Ghost and maybe make them look a little nicer, this is not the post for you. But if you'd like to dive deep into what's possible in a Ghost theme if you stretch the boundaries, well then, read on!

TL;DR: You can load a custom version of the Portal, search, comments, etc, even on Ghost Pro, but at the expense of needing to replace a bunch of really convenient default behavior.

Today, let's talk about {{ghost_head}}. If you've done any editing of your theme, you've probably noticed it hanging out in default.hbs. So what's it doing?

Well, here's what mine is doing. (Ghost 5.69.0, local development.)

Why do we care about {{ghost_head}}? Because I'm thinking it's the key to being able to load customized versions of search, portal, and comments on Ghost Pro.

Let's walk through it a bit at a time.

Meta data

    <meta name="description" content="Thoughts, stories and ideas.">
    <link rel="canonical" href="http://localhost:2368/">
    <meta name="referrer" content="no-referrer-when-downgrade">
    <link rel="next" href="http://localhost:2368/page/2/">
    
    <meta property="og:site_name" content="Demo Content">
    <meta property="og:type" content="website">
    <meta property="og:title" content="Demo Content">
    <meta property="og:description" content="Thoughts, stories and ideas.">
    <meta property="og:url" content="http://localhost:2368/">
    <meta property="og:image" content="https://images.unsplash.com/<truncated>">
    <meta property="article:publisher" content="https://www.facebook.com/ghost">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="Demo Content">
    <meta name="twitter:description" content="Thoughts, stories and ideas.">
    <meta name="twitter:url" content="http://localhost:2368/">
    <meta name="twitter:image" content="https://images.unsplash.com/<truncated>">
    <meta name="twitter:site" content="@ghost">
    <meta property="og:image:width" content="2000">
    <meta property="og:image:height" content="1179">
    

Interestingly, there's really nothing here that couldn't be output manually, except the Facebook image height and width. (og:image:width and og_image:height.)

Moving on to the next part...

JSON schema

    <script type="application/ld+json">
{
    "@context": "https://schema.org",
    "@type": "WebSite",
    "publisher": {
        "@type": "Organization",
        "name": "Demo Content",
        "url": "http://localhost:2368/",
        "logo": {
            "@type": "ImageObject",
            "url": "http://localhost:2368/favicon.ico",
            "width": 48,
            "height": 48
        }
    },
    "url": "http://localhost:2368/",
    "image": {
        "@type": "ImageObject",
        "url": "https://images.unsplash.com/photo-<truncated>",
        "width": 2000,
        "height": 1179
    },
    "mainEntityOfPage": "http://localhost:2368/",
    "description": "Thoughts, stories and ideas."
}
    </script>

This part is different on every page, but again, it's all stuff that could be written out using handlebars (except image dimensions). The part above is what my index page looks like. If I navigate to a specific blog post, it looks like this:

{
    "@context": "https://schema.org",
    "@type": "Article",
    "publisher": {
        "@type": "Organization",
        "name": "Demo Content",
        "url": "http://localhost:2368/",
        "logo": {
            "@type": "ImageObject",
            "url": "http://localhost:2368/favicon.ico",
            "width": 48,
            "height": 48
        }
    },
    "author": {
        "@type": "Person",
        "name": "Spectral Web Services",
        "image": {
            "@type": "ImageObject",
            "url": "https://www.gravatar.com/avatar/<truncated>",
            "width": 250,
            "height": 250
        },
        "url": "http://localhost:2368/author/spectral/",
        "sameAs": []
    },
    "headline": "Customer Support",
    "url": "http://localhost:2368/customer-support/",
    "datePublished": "2022-02-17T17:02:23.000Z",
    "dateModified": "2023-10-15T13:32:55.000Z",
    "image": {
        "@type": "ImageObject",
        "url": "https://images.unsplash.com/photo-<truncated>",
        "width": 2000,
        "height": 1333
    },
    "keywords": "News, Getting Started",
    "description": "Wasn&#x27;t through to brief <truncated>",
    "mainEntityOfPage": "http://localhost:2368/customer-support/"
}

No surprises here - it's basically the contents of the post object, plus some image dimensions. Moving on...

Ghost version and RSS feed:

    <meta name="generator" content="Ghost 5.69">
    <link rel="alternate" type="application/rss+xml" title="Demo Content" href="http://localhost:2368/rss/">

Not much to see here, but if you wanted to pretend to not be a Ghost site, you'd want to replace that first line.

Portal

<script defer 
   src="https://cdn.jsdelivr.net/ghost/portal@~2.36/umd/portal.min.js" 
   data-i18n="false" data-ghost="http://localhost:2368/" 
   data-key="cba0df74185e29eaeb70800693" 
   data-api="http://localhost:2368/ghost/api/content/" 
   crossorigin="anonymous">
</script>

If you've ever wanted to load a custom version of Portal on Ghost Pro, here's where you do it. Note that the the Portal script needs to be passed the API url and content API key. There's currently no way to get the Content API key from handlebars, but if you wanted to get rid of Ghost head, you could get it out of the integration tab in settings and pass it in.

It'd be awesome if Portal took a css file as an argument. It doesn't currently, so if you want to change its styling, you'll have to build your own.

Self-hosters take note: You can load a custom version of Portal by editing your config.production.json.

Styles

<style id="gh-members-styles">
.gh-post-upgrade-cta-content,
.gh-post-upgrade-cta {
    display: flex;
    flex-direction: column;
    align-items: center;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
    text-align: center;
    width: 100%;
    color: #ffffff;
    font-size: 16px;
}

.gh-post-upgrade-cta-content {
    border-radius: 8px;
    padding: 40px 4vw;
}

.gh-post-upgrade-cta h2 {
    color: #ffffff;
    font-size: 28px;
    letter-spacing: -0.2px;
    margin: 0;
    padding: 0;
}

.gh-post-upgrade-cta p {
    margin: 20px 0 0;
    padding: 0;
}

.gh-post-upgrade-cta small {
    font-size: 16px;
    letter-spacing: -0.2px;
}

.gh-post-upgrade-cta a {
    color: #ffffff;
    cursor: pointer;
    font-weight: 500;
    box-shadow: none;
    text-decoration: underline;
}

.gh-post-upgrade-cta a:hover {
    color: #ffffff;
    opacity: 0.8;
    box-shadow: none;
    text-decoration: underline;
}

.gh-post-upgrade-cta a.gh-btn {
    display: block;
    background: #ffffff;
    text-decoration: none;
    margin: 28px 0 0;
    padding: 8px 18px;
    border-radius: 4px;
    font-size: 16px;
    font-weight: 600;
}

.gh-post-upgrade-cta a.gh-btn:hover {
    opacity: 0.92;
}</style>

I'm not totally sure why these styles are here. Inlining them means they load early, but if I were going to pick a few dozen lines of styles to load early, I'm not sure these would be the ones I'd pick?

Stripe (if linked)

<script async src="https://js.stripe.com/v3/" type="<removed>-text/javascript"></script>

<removed> might be account-specific, so I'm not showing it here.

This code kicks off a loading of a couple more Stripe items. Stripe says it should be loaded on every page, which Ghost does. Sometimes I'm tempted to get a little bit more selective about which pages Stripe gets to load on, or to have it not load until the user starts scrolling or interacting. I want fraud detection, yes, but it's also a big load with a weirdly short cache time.

Search

<script defer 
  src="https://cdn.jsdelivr.net/ghost/sodo-search@~1.1/umd/sodo-search.min.js" 
  data-key="cba0df74185e29eaeb70800693" 
  data-styles="https://cdn.jsdelivr.net/ghost/sodo-search@~1.1/umd/main.css" 
  data-sodo-search="http://localhost:2368/" crossorigin="anonymous"
></script>

Next we load sodo-search (Ghost's built in search). Note that it takes a css file, so if you wanted to restyle it, that's a place to do it! It also takes the Content API URL and key.

Self-hosters take note: You can load a custom version of Search (including custom styles) by editing your config.production.json.

    <link href="http://localhost:2368/webmentions/receive/" rel="webmention">
    
    <script defer src="/public/cards.min.js?v=bde6fc7a4b"></script>
    
    <link rel="stylesheet" type="text/css" href="/public/cards.min.css?v=bde6fc7a4b">
    
    <script defer src="/public/member-attribution.min.js?v=bde6fc7a4b"></script>
    
    <style>:root {--ghost-accent-color: #46008c;}</style>

    

Next up: Webmentions. I'm not sure what this is for. It might be a work in progress?

Then the JavaScript related to cards loads. If you'd like your accordions to open and shut and your galleries to pack nicely, you want cards.min.js. The css for the cards also loads at this time. If you were trying to minimize load times, you could turn off the card loads on non-post pages. (I don't think they do anything on an index page...)

Finally, the member-attribution JavaScript loads. This code tracks the member's path through the site and stores it in LocalStorage. It's how your Ghost site knows what path your new member took for signing up. You could remove this code, if you don't want those analytics.

The accent color also gets set here. It's available as {{@site.accent_color}}, should you wish to replace {{ghost_head}}.

If you've enabled comments, you'll also see the comment counts loading:

<script defer src="/public/comment-counts.min.js?v=bde6fc7a4b" 
    data-ghost-comments-counts-api="http://localhost:2368/members/api/comments/counts/">
</script>

This happens even on index pages. If your theme isn't using comment counts, you can probably turn this off.

Self-hosters take note: You can load a custom version of Comments (including custom styles) by editing your config.production.json.

Code injection header

💡
10/23/23 update: Thanks to Ravn on the Ghost forum for correcting my errors in this section. I got tripped up believing the documentation (or rather, lack of documentation that @site includes it). The paragraph below has been updated.

Anything you've added to code injection (header) gets added at the end of {{ghost_head}}, so if you decided to replace {{ghost_head}}, you're going to want {{{@site.codeinjection_head}}} instead.

Conclusions?

There are a couple places where some strategic loading could be implemented. On a slow network, Portal and sodo-search are competing for bandwidth with image downloads. I'd definitely rather not load sodo-search until all the images have loaded. I'd say the same about Portal, except that it also handles the inbound magic-link clicks. Would it matter to cellphone users if Portal didn't load for ten seconds, and then it welcomed the user to the fully loaded page? I'm honestly not sure... some experimentation may be required! 🥽⚗️🥼

I'd love to see {{ghost_head}} split into some smaller helpers, to allow better customization. Perhaps it could work more like the {{navigation}} helper, which has a default behavior but can be overridden by adding a navigation.hbs file into the partials/ folder. For this to happen, we'd need the following available in handlebars:

  • A content API key helper
  • A helper with access to image width and height parameters, to be used to including width and height parameters in the schema. (Replicating the {{ghost_head}} behavior requires dimensions for og:image, the site logo, and featured images, but it would be more broadly useful than just these.)
  • A helper that writes out the Stripe integration script.

Phew! That concludes this week's entirely speculative speculation. The next post will be more normal.... probably! Have a great one!

Bye!