Integrate your Mastodon feed on a Hugo-powered website
While I was working on a friend’s website made with Hugo, he asked me if I could integrate his Mastodon profile on the homepage.
After a few research, I was surprised to see, or possibly because I couldn’t find it, that it wasn’t possible to create an integration of the user’s feed like you can have with other social media. So far, I could just find a post integration, but not the entire timeline. So let’s do it ourselves !
The functional spec was the following one :
- Displaying the public posts feed
- Having the profile’s URL and picture
- Showing the post date and time
- Having a link to the post
- Having a “Follow” button
- Set a limit to how many posts are displayed
First, I was thinking about using the API but eventually, I took the opportunity to use the RSS feed of a profile. In any case, we will need some Javascript because we will have to parse the RSS feed content. It would have been the same if the API was used, but with JSON.
On Mastodon, if you can add .rss
to a profile URL, you’ll be able to see its timeline formatted in RSS.
For example with mine :
https://fosstodon.org/@Wivik
=> https://fosstodon.org/@Wivik.rss
:
So we basically have the data available without anything more to do. Let’s go.
Some params for Hugo
My friend’s website has a little complexity : it’s a dual language site in French and English. And in order to avoid hard coding the Mastodon profile URL in the script, I’ve put in Hugo’s params
section of hugo.yaml
. I’ve also defined a mastodonMaxItems
setting to limit how many posts to return.
params:
mastodon: https://fosstodon.org/@Wivik
mastodonMaxItems: 5
Then, to manage the multilingual part, I’ve defined variables in i18n/fr.yaml
and i18n/en.yaml
.
en.yaml
:
home:
viewOnMastodon: "View on Mastodon"
followOnMastodon: "Follow"
publishedDateOnMastodon: "Published on"
fr.yaml
:
home:
viewOnMastodon: "Lire sur Mastodon"
followOnMastodon: "Suivre"
publishedDateOnMastodon: "PubliΓ© le"
Hugo’s setup : done.
Create the Javascript
Since I’m not a JS developer, I’ve made profitable the free trial I’ve activated for GitHub Copilot Chat. Some prompts later that gave me the basis, I could enhance by myself the rest of it and here is the script. This script has been saved into the template’s assets
folder : assets/js/mastodon.js
// fetch RSS of a mastodon profile and return the latest feed
fetch(mastodonProfile + '.rss')
// get the response content
.then(response => response.text())
.then(str => new window.DOMParser().parseFromString(str, "text/xml"))
.then(data => {
// put in variables the title, link and picture profile
const channelTitle = data.querySelector("channel > title").textContent;
const channelLink = data.querySelector("channel > link").textContent;
const channelImage = data.querySelector("channel > image > url").textContent;
// assign the script to a div using mastodon-feed id
const feed = document.getElementById('mastodon-feed');
// create the div container
const headerElement = document.createElement('div');
headerElement.className = 'header';
// add the profile picture
const imageElement = document.createElement('img');
imageElement.src = channelImage;
headerElement.appendChild(imageElement);
// add a lin to the profile name
const titleElement = document.createElement('a');
titleElement.href = channelLink;
titleElement.textContent = channelTitle;
headerElement.appendChild(titleElement);
// create a follow button redirecting to the profile using hugo i18n values
const followElement = document.createElement('a');
followElement.className = 'follow-button';
followElement.href = channelLink;
followElement.textContent = i18n.followOnMastodon;
headerElement.appendChild(followElement);
// close the header element
feed.appendChild(headerElement);
// loop over the feed content with a content limit
const items = data.querySelectorAll("item");
const itemsArray = Array.from(items).slice(0, maxItems);
itemsArray.forEach(item => {
// create a div container for the posts
const statusElement = document.createElement('div');
statusElement.className = 'status';
statusElement.innerHTML = item.querySelector("description").textContent;
// create a div footer for the post url and date
const footerElement = document.createElement('div');
footerElement.className = 'post-footer';
const dateElement = document.createElement('p');
const pubDate = new Date(item.querySelector("pubDate").textContent);
dateElement.textContent = `${i18n.publishedDateOnMastodon} ${pubDate.toLocaleDateString()} - ${pubDate.toLocaleTimeString()} | `;
footerElement.appendChild(dateElement);
const linkElement = document.createElement('a');
linkElement.href = item.querySelector("link").textContent;
linkElement.textContent = i18n.viewOnMastodon;
footerElement.appendChild(linkElement);
statusElement.appendChild(footerElement);
// close the post element
feed.appendChild(statusElement);
});
});
Integrate with the template
In the homepage template, I’ve added the following parts to integrate the Mastodon RSS feed.
The Hugo template with resources.Get
has been taken from the file partials/head/js.html
and adapted to import my script. It seems to check the script integrity for the live version.
The div
element with the id mastodon-feed
is the placeholder in which the Javascript code will integrate the dynamic part.
<p>
<div id="mastodon-feed" class="mastodon-feed"></div>
<script>
var i18n = {
followOnMastodon: "{{ i18n "home.followOnMastodon" }}",
viewOnMastodon: "{{ i18n "home.viewOnMastodon" }}",
publishedDateOnMastodon: "{{ i18n "home.publishedDateOnMastodon" }}",
};
var mastodonProfile = "{{ .Site.Params.mastodon }}";
var maxItems = "{{ default 5 .Site.Params.mastodonMaxItems }}"
</script>
{{- with resources.Get "js/mastodon.js" }}
{{- if eq hugo.Environment "development" }}
{{- with . | js.Build }}
<script src="{{ .RelPermalink }}"></script>
{{- end }}
{{- else }}
{{- $opts := dict "minify" true }}
{{- with . | js.Build $opts | fingerprint }}
<script src="{{ .RelPermalink }}" integrity="{{- .Data.Integrity }}" crossorigin="anonymous"></script>
{{- end }}
{{- end }}
{{- end }}
</p>
The important parts I’ve added are the variables :
<script>
var i18n = {
followOnMastodon: "{{ i18n "home.followOnMastodon" }}",
viewOnMastodon: "{{ i18n "home.viewOnMastodon" }}",
publishedDateOnMastodon: "{{ i18n "home.publishedDateOnMastodon" }}",
};
var mastodonProfile = "{{ .Site.Params.mastodon }}";
var maxItems = "{{ default 5 .Site.Params.mastodonMaxItems }}"
</script>
This is how you can pass the i18n translated values of the labels and Hugo’s settings. Same for the profile’s URL. The Javascript will append it with .rss
.
The CSS part
Since I wanted it to be properly integrated with the rest of the design, the CSS was made in this way.
/* mastodon feed */
.mastodon-feed {
margin-left: auto;
margin-right: auto;
}
.mastodon-feed div.header {
background-color: var(--header-bg-color);
max-height: 4.9rem;
padding: 0.1rem;
display: flex;
flex-direction: row;
align-items: center;
color: var(--body-font-color);
}
.mastodon-feed div.header img {
max-width: 45px;
border-radius: 50%;
margin: 0.5rem;
flex: 1;
}
.mastodon-feed div.header a:link, .mastodon-feed div.header a:visited {
color: var(--a-menu-link-color);
font-size: large;
flex: 1;
}
.mastodon-feed div.header a.follow-button {
flex: 1;
text-align: center;
padding: 0.5rem 1rem;
margin-right: 0.5rem;
max-width: 5rem;
color: var(--body-font-color);
background-color: var(--a-menu-bg-color-hover);
text-decoration: none;
border-radius: 5px;
}
.mastodon-feed div.header a.follow-button:hover {
background-color: var(--a-mastodon-follow-bg-color-hover);
}
.mastodon-feed div.header a:hover {
text-decoration: none;
}
.mastodon-feed div.status {
border: 1px solid var(--div-details-border-color);
padding: 1rem;
padding-bottom:0;
margin-bottom: 1rem;
}
.mastodon-feed div.status div.post-footer {
display: flex;
flex-direction: row;
align-items: center;
border-top: 1px solid var(--div-details-border-color);
font-size: smaller;
}
Test drive
Let’s boot up a test template with the expected settings (and some random CSS colors) ! I’ve set the value of params.mastodonMaxItems
to 2 to ensure the limit applies.
Et voilΓ !