Multilingual Ghost CMS Blog: 8. Automating hreflang for #multi Posts

Multilingual Ghost CMS Blog: 8. Automating hreflang for #multi Posts
💡
On Managed Ghost hosting, you cannot modify Ghost Core or register custom Handlebars helpers. This is the approach I chose to eliminate repetitive Code Injection within that constraint.

For each post, I could manually insert hreflang into Ghost Admin's Code Injection. But every time I publish a translated post, I would have to enter both the English URL and the Korean URL repeatedly on both posts. There is also a chance of copying a URL incorrectly or adding it to only one side.

In 0.0.8, I changed this so the theme handles the job automatically. I also removed the tag-order dependency issue that surfaced along with it.

The slug and internal tags of the Korean translation post as shown in Ghost Admin

This is a screen showing a real published post imported into local Ghost. The slug ends with orca-codex-threads-chatgpt-ko, and the tags include both #ko and #multi.

0.0.8 · Automated bilingual hreflang and tag detection

Rules for multilingual posts

I use the following rules for automation.

  • The English post slug ends with -en.
  • The Korean post slug ends with -ko.
  • Add the #multi internal tag to posts whose translations are all ready.
  • Each post must have either the #en or #ko internal tag to indicate its own language.

For example, the two posts below are one translation pair.

English slug: multilingual-ghost-cms-en
Korean slug:  multilingual-ghost-cms-ko

The actual URLs are generated like this.

https://sanghunkang.com/multilingual-ghost-cms-en/
https://sanghunkang.com/ko/multilingual-ghost-cms-ko/

partials/hreflang-multi.hbs

I separated the code that generates multilingual URLs for posts and pages into a separate partial.

First, set the supported languages and the default language.

var LANGS = ["en", "ko"];
var DEFAULT_LANG = "en";

Next, turn all of the post's tag names into a JavaScript array.

var tagNames = [
    {{#foreach tags visibility="all"}}
        "{{name}}"{{#unless @last}},{{/unless}}
    {{/foreach}}
];

The reason for specifying visibility="all" is that #multi, #en, and #ko are all internal tags. Without this option, Ghost excludes internal tags from the loop result.

If there is no #multi, do nothing.

if (tagNames.indexOf("#multi") === -1) return;

The current post's language is also found from the tag array.

var lang = null;
LANGS.forEach(function(l) {
    if (tagNames.indexOf("#" + l) !== -1) lang = l;
});

if (!lang) return;

If you remove the trailing -en or -ko from the slug, you get the stem shared by the translated posts.

var suffixPattern = new RegExp("-(" + LANGS.join("|") + ")$");
var stem = slug.replace(suffixPattern, "");

Append each language suffix and URL prefix back onto this stem to build the URL for each language.

function buildUrl(l) {
    var prefix = (l === DEFAULT_LANG) ? "" : "/" + l;
    return siteUrl + prefix + "/" + stem + "-" + l + "/";
}

Finally, add hreflang links to <head>.

var html = "";

LANGS.forEach(function(l) {
    html += '<link rel="alternate" hreflang="' + l + '" href="' + buildUrl(l) + '"/>';
});

html += '<link rel="alternate" hreflang="x-default" href="' + buildUrl(DEFAULT_LANG) + '"/>';

document.head.insertAdjacentHTML("beforeend", html);

Calling the partial in post and page contexts

In the <head> of default.hbs, call the partial when the current view is a post or a page.

{{#is "post"}}
    {{#post}}
        {{> "hreflang-multi"}}
    {{/post}}
{{/is}}

{{#is "page"}}
    {{#page}}
        {{> "hreflang-multi"}}
    {{/page}}
{{/is}}

It must be called inside the {{#post}} and {{#page}} contexts so the partial can use the current content's {{slug}} and {{tags}}.

The problem that depended on tag order

The existing Tag page used the rule that the second tag was the language tag.

data-language="{{tags.[1].name}}"

With this approach, if the tag order changes, language filtering breaks.

[Ghost, #ko, #multi]  -> works
[#multi, Ghost, #ko]  -> fails

In 0.0.8, all tags of each post are placed into data-tags.

<div class="post-card" data-tags='[{{#foreach tags visibility="all"}}"{{name}}"{{#unless @last}},{{/unless}}{{/foreach}}]'>

JavaScript searches the entire array for the language tag.

let cardLang = null;

LANGS.forEach(l => {
    if (tagNames.indexOf('#' + l) !== -1) {
        cardLang = l;
    }
});

Now it does not matter which position #en or #ko is in.

I also did not use Ghost's {{#has tag="..."}} helper to detect internal tags. The display name of the internal tag is #multi, but its actual slug is hash-multi, so if you confuse the name and slug, the condition can fail silently. Passing all internal tag names into an array and comparing them explicitly in JavaScript was more predictable.

Checking the result

Open Developer Tools on a #multi post and run the code below.

[...document.querySelectorAll('link[rel="alternate"][hreflang]')]
  .map(link => [link.hreflang, link.href]);

On both the English post and the Korean post, three links, en, ko, and x-default, should appear, and each URL should point to the correct translated post.

hreflang inspection result for the Korean #multi post in 0.0.8

This is the result of inspecting the DOM on the Korean Orca post. en points to the English translated post, ko points to the current Korean post, and x-default points to the English translated post. The black panel is an overlay showing the actual DOM query result on the screen.

On the Tag page, even if you change the order of a post's tags, only posts in the selected language should still display correctly.

Things to watch out for

#multi should be added only when all language versions actually exist. If there is only a Korean post but you add #multi, the theme will also generate an English URL according to the rule, and that URL will return a 404.

Also, the post's hreflang is added to <head> by browser JavaScript. Google renders JavaScript, so it can process this approach, but some crawlers with weak JavaScript rendering may miss the links.


Starting from 0.0.8, if you set the language tag and #multi correctly on translated posts, hreflang is generated automatically.