Comments
You can use your Mastodon account to reply to this post.
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!).
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.
This quick tutorial is divided in three steps:
However, I’ll start by showing you how easy it will be to add comments to an existing blog entry!
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:
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?
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 ?
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.
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.
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.
When adding Carl’s comment system to your blog, you need to:
When writing an article:
My name is Tony Cheneau and I’m currently a devops (catchy title) at ANSSI.
I was previously occupying a postdoc position at the National Institute of Standards and Technology (also known as NIST), in the Advanced Network Technologies Division. This was a really entertaining job where my main research interests are focused on wireless applications over the Smart Grid and defining new security solution for these applications.
If you are interested in my education (or in hiring me), you can check out my very formal (and not so up to date) resume.pdf.
During my PhD, I studied several aspects of the Link-Layer security. through the extended use of the Secure Neighbor Discovery protocol (RFC 3971 and RFC 3972).
Other of my previous research interests included MANEMO. MANEMO is the combination of multiple research areas:
Back in time, I made some propositions inside the CGA and SEND maIntenance working (CSI) group:
During my PhD, I happened to give some lecture: