In this short blog entry, I’ll explain how to add a comment system to Hugo’s static blog engine. I’ll expand on Carl Schwan’s excellent blog article and attempt to make it more accessible to Hugo’s newcomers. For the sake of example, I expand from the Dream theme (kudos to g1eny0ung for his great theme!).
Foreword
All the hard work has been pulled of by Carl. This is merely the steps I had to reproduce to integrate Carl’s suggestion. I hope it will help others like me, willing to bring some life into their blogs. Carl provides the source of his blog on Github , this is a valuable source of information.
Breakdown of the tutorial
This quick tutorial is divided in three steps:
- modify your existing template to make it possible to add comments
- adding Carl’s comment system
- copying some Javascripp dependencies
However, I’ll start by showing you how easy it will be to add comments to an existing blog entry!
Finally: adding comments on an article!
Perhaps the best thing to start with is the thing you’ll do at the end: add comments with your new comment system. So it is important to take a moment and check that it indeed works as you want it to.
Hugo articles usually start with a YAML block. Here, you can just add the following block to add comments in your article (provided that you do everything else from this article):
---
# add this somewhere along with you parameters
comments:
host: linuxrocks.online
username: tcheneau
id: 105483088210721661
---
Here the parameters are:
- host: the FQDN of the mastodon instance you want to use
- username: your username on this instance
- id: the id of the toot (mastodon language for tweet)
Naturally, you might not have the id when you write you blog post: the trick is to publish your article first, without the comment system enabled, and then write a toot about it. Then, edit the YAML block at the beginning of the post and add your snippet (with the proper id). Obviously, you then re-publish the blog.
So, ready to dive in and see how it all fit in?
Overloading default theme pages
Your config.toml indicates the theme you will use. The theme will then be located in the themes/{{theme-name}} directory. The theme will control how your article are rendered. One obvious way to change how it is generated is to edit the files in this directory. But what happens when the theme upgrades? Do you overwrite your changes?
I would say: it seems better to overload the few files that are need.
Here, I created a file at the root of my Hugo repository named layouts/_default/single.html. This file is a copy of the themes/dream/layouts/_default/single.html that I edited (remember that I use the dream theme).
{{ define "title" }}
{{- .Title -}}
{{ end }}
{{ define "css" }}
{{ if .Site.Params.highlightjs }}
{{ if .Site.Params.highlightjsTheme }}
<link rel="stylesheet" data-highlight href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/{{ .Site.Params.highlightjsTheme }}.min.css" />
{{ else }}
<link rel="stylesheet" data-highlight href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/default.min.css" />
{{ end }}
{{ end }}
{{ if .Site.Params.valine }}
<script src='//unpkg.com/valine/dist/Valine.min.js'></script>
{{ end }}
{{ end }}
{{ define "main" }}
<div class="ui centered relaxed grid dream-grid">
<div class="sixteen wide mobile sixteen wide tablet twelve wide computer column markdown-body dream-single" id="dream-save-post-as-img">
{{ if and .Params.Cover .Site.Params.showSummaryCoverInPost }}
<section class="ui top attached segment cover">
<div class="cover-img" style="background-image: url({{ .Params.Cover }});"></div>
</section>
{{ end }}
<section class="ui {{ if not .Site.Params.showSummaryCoverInPost }}top {{ end }}attached segment">
<header>
<h1 class="ui large header">
{{ .Title }}
<div class="sub header">
@
{{ if isset .Params "author" }}
{{ if isset .Params "authorlink" }}
<a href="{{ .Params.authorlink }}" target="_blank">{{ .Params.author }}</a>
{{ else }}
{{ .Params.author }}
{{ end }}
{{ else }}
{{ .Site.Params.author }}
{{ end }}
| {{ if eq .Site.Language.Lang "zh" }}
{{ index .Site.Data.zh.Weekday (printf "%d" .Date.Weekday) }},{{ index .Site.Data.zh.Month (printf "%d" .Date.Month) }} {{ .Date.Day }} 日,{{ .Date.Year }} 年
{{ else if eq .Site.Language.Lang "es" }}
{{ index .Site.Data.es.Weekday (printf "%d" .Date.Weekday) }}, {{ .Date.Day }} de {{ index .Site.Data.es.Month (printf "%d" .Date.Month) }} de {{ .Date.Year }}
{{ else }}
{{ .Date.Format "Monday, Jan 2, 2006" }}
{{ end }}
| {{ .ReadingTime }}{{ i18n "minuteRead" }}
| {{ i18n "updateAt" }}
{{ if eq .Site.Language.Lang "zh" }}
{{ index .Site.Data.zh.Weekday (printf "%d" .Lastmod.Weekday) }},{{ index .Site.Data.zh.Month (printf "%d" .Lastmod.Month) }} {{ .Lastmod.Day }} 日,{{ .Lastmod.Year }} 年
{{ else if eq .Site.Language.Lang "es" }}
{{ index .Site.Data.es.Weekday (printf "%d" .Lastmod.Weekday) }}, {{ .Lastmod.Day }} de {{ index .Site.Data.es.Month (printf "%d" .Lastmod.Month) }} de {{ .Lastmod.Year }}
{{ else }}
{{ .Lastmod.Format "Monday, Jan 2, 2006" }}
{{ end }}
</div>
</h1>
</header>
<article class="main">{{ .Content | emojify }}</article>
</section>
<footer class="ui attached segment dream-tags" data-html2canvas-ignore>
{{ if isset .Params "tags" }}
{{ range $tag := .Params.tags }}
<a class="ui label" href="{{ "tags/" | relLangURL }}{{ $tag | urlize }}" title="{{ $tag }}">{{ $tag }}</a>
{{ end }}
{{ else }}
<a class="ui label">{{ i18n "noTag" }}</a>
{{ end }}
<div
class="ui label"
style="float: right; cursor: pointer;"
onclick="savePostAsImg()">
<i class="save icon"></i>{{ i18n "saveAsImage" }}
</div>
</footer>
{{ if .Site.Copyright }}
<footer class="ui attached segment" data-html2canvas-ignore>
{{ .Site.Copyright | safeHTML }}
</footer>
{{ end }}
{{ if .Site.DisqusShortname }}
<footer class="ui bottom attached stacked segment post-disqus-area" data-html2canvas-ignore>
<div id="disqus_thread"></div>
<script>
/**
* RECOMMENDED CONFIGURATION VARIABLES: EDIT AND UNCOMMENT THE SECTION BELOW TO INSERT DYNAMIC VALUES FROM YOUR PLATFORM OR CMS.
* LEARN WHY DEFINING THESE VARIABLES IS IMPORTANT: https://disqus.com/admin/universalcode/#configuration-variables
*/
var disqus_config = function () {
this.page.url = '{{ .Permalink }}'; // Replace PAGE_URL with your page's canonical URL variable
this.page.identifier = '{{ .RelPermalink }}'; // Replace PAGE_IDENTIFIER with your page's unique identifier variable
};
(function() { // DON'T EDIT BELOW THIS LINE
var d = document, s = d.createElement('script');
s.src = 'https://' + '{{ .Site.DisqusShortname }}' + '.disqus.com/embed.js';
s.setAttribute('data-timestamp', +new Date());
(d.head || d.body).appendChild(s);
})();
</script>
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
</footer>
{{ end }}
{{ if .Site.Params.utterancesRepo }}
<div class="ui segment utterances-comments" data-html2canvas-ignore>
<script src="https://utteranc.es/client.js"
repo="{{ .Site.Params.utterancesRepo }}"
issue-term="og:title"
theme="github-light"
crossorigin="anonymous"
async>
</script>
</div>
{{ end }}
{{ if .Site.Params.valine }}
<div class="ui segment">
<div id="vcomments"></div>
</div>
<script>
new Valine({
el: '#vcomments',
appId: {{ .Site.Params.LEANCLOUD_APP_ID }},
appKey: {{ .Site.Params.LEANCLOUD_APP_KEY }}
})
</script>
{{ end }}
</div>
<aside class="sixteen wide mobile sixteen wide tablet four wide computer column dream-single-aside">
<!-- len <nav id="TableOfContents"></nav> == 32 -->
{{ if ge (len .TableOfContents) 33 }}
<div class="ui segment toc">
{{ .TableOfContents }}
</div>
{{ end }}
{{ partial "header.html" . }}
</aside>
</div>
{{ end }}
{{ define "js" }}
<script src="{{ "/js/html2canvas.min.js" | relURL }}"></script>
<script src="{{ "/js/post.js" | relURL }}"></script>
{{ if .Site.Params.highlightjs }}
<script src="{{ if .Site.Params.highlightjsCDN }}{{ .Site.Params.highlightjsCDN }}{{ else }}{{ "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js" }}{{ end}}"></script>
{{ if .Site.Params.highlightjsExtraLanguages }}
{{ range .Site.Params.highlightjsExtraLanguages }}
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/languages/{{ . }}.min.js"></script>
{{ end }}
{{ end }}
{{ partial "highlight.html" . }}
{{ end }}
{{ end }}
Here is my modified file in layouts/_default/single.html:
{{ define "title" }}
{{- .Title -}}
{{ end }}
{{ define "css" }}
<link rel="stylesheet" type="text/css" href="{{.Site.BaseURL}}css/article.css" />
{{ if .Site.Params.highlightjs }}
{{ if .Site.Params.highlightjsTheme }}
<link rel="stylesheet" data-highlight href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/{{ .Site.Params.highlightjsTheme }}.min.css" />
{{ else }}
<link rel="stylesheet" data-highlight href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/default.min.css" />
{{ end }}
{{ end }}
{{ if .Site.Params.valine }}
<script src='//unpkg.com/valine/dist/Valine.min.js'></script>
{{ end }}
{{ end }}
{{ define "main" }}
<div class="ui centered relaxed grid dream-grid">
<div class="sixteen wide mobile sixteen wide tablet twelve wide computer column markdown-body dream-single" id="dream-save-post-as-img">
{{ if and .Params.Cover .Site.Params.showSummaryCoverInPost }}
<section class="ui top attached segment cover">
<div class="cover-img" style="background-image: url({{ .Params.Cover }});"></div>
</section>
{{ end }}
<section class="ui {{ if not .Site.Params.showSummaryCoverInPost }}top {{ end }}attached segment">
<header>
<h1 class="ui large header">
{{ .Title }}
<div class="sub header">
@
{{ if isset .Params "author" }}
{{ if isset .Params "authorlink" }}
<a href="{{ .Params.authorlink }}" target="_blank">{{ .Params.author }}</a>
{{ else }}
{{ .Params.author }}
{{ end }}
{{ else }}
{{ .Site.Params.author }}
{{ end }}
| {{ if eq .Site.Language.Lang "zh" }}
{{ index .Site.Data.zh.Weekday (printf "%d" .Date.Weekday) }},{{ index .Site.Data.zh.Month (printf "%d" .Date.Month) }} {{ .Date.Day }} 日,{{ .Date.Year }} 年
{{ else if eq .Site.Language.Lang "es" }}
{{ index .Site.Data.es.Weekday (printf "%d" .Date.Weekday) }}, {{ .Date.Day }} de {{ index .Site.Data.es.Month (printf "%d" .Date.Month) }} de {{ .Date.Year }}
{{ else }}
{{ .Date.Format "Monday, Jan 2, 2006" }}
{{ end }}
| {{ .ReadingTime }}{{ i18n "minuteRead" }}
| {{ i18n "updateAt" }}
{{ if eq .Site.Language.Lang "zh" }}
{{ index .Site.Data.zh.Weekday (printf "%d" .Lastmod.Weekday) }},{{ index .Site.Data.zh.Month (printf "%d" .Lastmod.Month) }} {{ .Lastmod.Day }} 日,{{ .Lastmod.Year }} 年
{{ else if eq .Site.Language.Lang "es" }}
{{ index .Site.Data.es.Weekday (printf "%d" .Lastmod.Weekday) }}, {{ .Lastmod.Day }} de {{ index .Site.Data.es.Month (printf "%d" .Lastmod.Month) }} de {{ .Lastmod.Year }}
{{ else }}
{{ .Lastmod.Format "Monday, Jan 2, 2006" }}
{{ end }}
</div>
</h1>
</header>
<article class="main">{{ .Content | emojify }}</article>
{{ partial "article/article.html" .}}
</section>
<footer class="ui attached segment dream-tags" data-html2canvas-ignore>
{{ if isset .Params "tags" }}
{{ range $tag := .Params.tags }}
<a class="ui label" href="{{ "tags/" | relLangURL }}{{ $tag | urlize }}" title="{{ $tag }}">{{ $tag }}</a>
{{ end }}
{{ else }}
<a class="ui label">{{ i18n "noTag" }}</a>
{{ end }}
<div
class="ui label"
style="float: right; cursor: pointer;"
onclick="savePostAsImg()">
<i class="save icon"></i>{{ i18n "saveAsImage" }}
</div>
</footer>
{{ if .Site.Copyright }}
<footer class="ui attached segment" data-html2canvas-ignore>
{{ .Site.Copyright | safeHTML }}
</footer>
{{ end }}
{{ if .Site.DisqusShortname }}
<footer class="ui bottom attached stacked segment post-disqus-area" data-html2canvas-ignore>
<div id="disqus_thread"></div>
<script>
/**
* RECOMMENDED CONFIGURATION VARIABLES: EDIT AND UNCOMMENT THE SECTION BELOW TO INSERT DYNAMIC VALUES FROM YOUR PLATFORM OR CMS.
* LEARN WHY DEFINING THESE VARIABLES IS IMPORTANT: https://disqus.com/admin/universalcode/#configuration-variables
*/
var disqus_config = function () {
this.page.url = '{{ .Permalink }}'; // Replace PAGE_URL with your page's canonical URL variable
this.page.identifier = '{{ .RelPermalink }}'; // Replace PAGE_IDENTIFIER with your page's unique identifier variable
};
(function() { // DON'T EDIT BELOW THIS LINE
var d = document, s = d.createElement('script');
s.src = 'https://' + '{{ .Site.DisqusShortname }}' + '.disqus.com/embed.js';
s.setAttribute('data-timestamp', +new Date());
(d.head || d.body).appendChild(s);
})();
</script>
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
</footer>
{{ end }}
{{ if .Site.Params.utterancesRepo }}
<div class="ui segment utterances-comments" data-html2canvas-ignore>
<script src="https://utteranc.es/client.js"
repo="{{ .Site.Params.utterancesRepo }}"
issue-term="og:title"
theme="github-light"
crossorigin="anonymous"
async>
</script>
</div>
{{ end }}
{{ if .Site.Params.valine }}
<div class="ui segment">
<div id="vcomments"></div>
</div>
<script>
new Valine({
el: '#vcomments',
appId: {{ .Site.Params.LEANCLOUD_APP_ID }},
appKey: {{ .Site.Params.LEANCLOUD_APP_KEY }}
})
</script>
{{ end }}
</div>
<aside class="sixteen wide mobile sixteen wide tablet four wide computer column dream-single-aside">
<!-- len <nav id="TableOfContents"></nav> == 32 -->
{{ if ge (len .TableOfContents) 33 }}
<div class="ui segment toc">
{{ .TableOfContents }}
</div>
{{ end }}
{{ partial "header.html" . }}
</aside>
</div>
{{ end }}
{{ define "js" }}
<script src="{{ "/js/html2canvas.min.js" | relURL }}"></script>
<script src="{{ "/js/post.js" | relURL }}"></script>
{{ if .Site.Params.highlightjs }}
<script src="{{ if .Site.Params.highlightjsCDN }}{{ .Site.Params.highlightjsCDN }}{{ else }}{{ "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js" }}{{ end}}"></script>
{{ if .Site.Params.highlightjsExtraLanguages }}
{{ range .Site.Params.highlightjsExtraLanguages }}
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/languages/{{ . }}.min.js"></script>
{{ end }}
{{ end }}
{{ partial "highlight.html" . }}
{{ end }}
{{ end }}
If you just look at the diff, to make it more obvious:
--- themes/dream/layouts/_default/single.html 2021-01-01 18:05:35.588805563 +0100
+++ layouts/_default/single.html 2021-01-02 01:24:47.302647331 +0100
@@ -3,6 +3,7 @@
{{ end }}
{{ define "css" }}
+<link rel="stylesheet" type="text/css" href="{{.Site.BaseURL}}css/article.css" />
{{ if .Site.Params.highlightjs }}
{{ if .Site.Params.highlightjsTheme }}
@@ -65,6 +66,7 @@
</header>
<article class="main">{{ .Content | emojify }}</article>
+ {{ partial "article/article.html" .}}
</section>
First change you see is some code to load an extra CSS file for a mysterious “article”.
Here is Carl’s static/css/article.css file:
.mastodon-comment {
background-color: var(--body-background);
border-radius: var(--card-border-radius);
padding: var(--card-padding);
margin-bottom: 1rem;
display: flex;
.content {
flex-grow: 2;
}
.avatar img {
margin-right: 1rem;
min-width: 60px;
}
.author {
padding-top: 0;
display: flex;
.date {
margin-left: auto;
}
}
.disabled {
color: var(--accent-color)
}
}
.mastodon-comment-content p:first-child {
margin-top: 0;
}
The second thing you see from this diff is a partial, but what are partials ?
Partial templates
In Hugo parlance, partial template is a simple mecanism that let you extend your templating. Here, it will help us load the comments “block” when it is present among the article’s comment (the begining of the document).
Here is a partial derived from Carl’s comment system. It rests in layouts/partials/article/article.html:
{{ with .Params.comments }}
<div class="article-content">
<h2>Comments</h2>
<p>You can use your Mastodon account to reply to this <a class="link" href="https://{{ .host }}/@{{ .username }}/{{ .id }}">post</a>.</p>
<p><a class="button" href="https://{{ .host }}/interact/{{ .id }}?type=reply">Reply</a></p>
<p id="mastodon-comments-list"><button id="load-comment">Load comments</button></p>
<noscript><p>You need JavaScript to view the comments.</p></noscript>
<script src="/assets/js/purify.min.js"></script>
<script type="text/javascript">
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
document.getElementById("load-comment").addEventListener("click", function() {
document.getElementById("load-comment").innerHTML = "Loading";
fetch('https://{{ .host }}/api/v1/statuses/{{ .id }}/context')
.then(function(response) {
return response.json();
})
.then(function(data) {
if(data['descendants'] &&
Array.isArray(data['descendants']) &&
data['descendants'].length > 0) {
document.getElementById('mastodon-comments-list').innerHTML = "";
data['descendants'].forEach(function(reply) {
reply.account.display_name = escapeHtml(reply.account.display_name);
reply.account.emojis.forEach(emoji => {
reply.account.display_name = reply.account.display_name.replace(`:${emoji.shortcode}:`,
`<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" height="20" width="20" />`);
});
mastodonComment =
`<div class="mastodon-comment">
<div class="avatar">
<img src="${escapeHtml(reply.account.avatar_static)}" height=60 width=60 alt="">
</div>
<div class="content">
<div class="author">
<a href="${reply.account.url}" rel="nofollow">
<span>${reply.account.display_name}</span>
<span class="disabled">${escapeHtml(reply.account.acct)}</span>
</a>
<a class="date" href="${reply.uri}" rel="nofollow">
${reply.created_at.substr(0, 10)}
</a>
</div>
<div class="mastodon-comment-content">${reply.content}</div>
</div>
</div>`;
document.getElementById('mastodon-comments-list').appendChild(DOMPurify.sanitize(mastodonComment, {'RETURN_DOM_FRAGMENT': true}));
});
} else {
document.getElementById('mastodon-comments-list').innerHTML = "<p>Not comments found</p>";
}
});
});
</script>
</div>
{{ end }}
See the first line: it only load comments when you defined the comments YAML parameter at the begining of the page.
Adding some style to your comments (with CSS)
There is a custom CSS that is needed for the comment (see Carl’s code):
.mastodon-comment {
background-color: var(--body-background);
border-radius: var(--card-border-radius);
padding: var(--card-padding);
margin-bottom: 1rem;
display: flex;
.content {
flex-grow: 2;
}
.avatar img {
margin-right: 1rem;
min-width: 60px;
}
.author {
padding-top: 0;
display: flex;
.date {
margin-left: auto;
}
}
.disabled {
color: var(--accent-color)
}
}
kb
.mastodon-comment-content p:first-child {
margin-top: 0;
}
Save it as static/css/article.css.
Adding a missing dependency
Purify is a dependancy that is required by Carl’s code. It is used for sanitizing Mastodon’s comments as they are loaded.
You can download its latest version from GitHub:
wget https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.min.js
Copy this purify.min.js file to static/assets/js/purify.min.js.
Wrapping things up
When adding Carl’s comment system to your blog, you need to:
- add a partial for the comment system itself
- add the corresponding CSS
- change the default article template to load the partial at the appropriate location
- add the DOMPurify dependency
When writing an article:
- link it on mastodon
- retrieve the toot’s ID
- add the corresponding blog to your article