Creating a Comment System For a Static Hugo Blog: Part II
In the first part, we built a serverless function that handles a comment submission and stores the comment to a GitHub repository.
Today we will build a comment form and a template to list comments belonging to a post.
Add a configuration variable
We start by adding a configuration variable containing the URL of the serverless function responsible for uploading the comment to GitHub repository.
You can hardcode the URL directly in the template, but I prefer to use the configuration files and would recommend you to do so as well. Why? It’s simple-just to make it easier to change the URL in the future. You may say that editing the template is simple enough, and I would agree with that. However, I also believe that if you decided for some reason to update the URL after 6 months or more, it would take some effort to remember where to do it.
# hugo.yaml
params:
comments:
backend: https://commentsdemo.aquarius.workers.dev
Create a template file
Next, we need to create a template file that will contain a list of comments that belong to the blog post, an HTML form, stylesheet, and JavaScript code to handle form submission.
Create an empty file called comments.html
inside the layouts/partials
folder.
Create the form layout
<!-- layouts/partials/comments.html -->
<h2>Leave a Comment</h2>
<form>
<input type="hidden" name="slug" value="{{ .File.BaseFileName }}" />
<div class="alert"></div>
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" />
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" />
</div>
<div>
<label for="comment">Your comment</label>
<textarea id="comment" rows="6" name="comment"></textarea>
</div>
<button class="button" type="button">Submit Comment</button>
</form>
The code is pretty self-explanatory. It contains a form with a hidden field that stores the file name of the current blog post. It will be used to separate comments from different blog posts. Then comes the rest of the fields-name, email, and comment.
After a visitor submits a comment, we will need to display a message indicating whether the action was successful or not.
For this purpose, we will use the div with a class name alert
. Initially, the div stays hidden unless an additional class alert-success
or alert-danger
is added.
<!-- layouts/partials/comments.html -->
<style>
.alert {
display: none;
}
.alert.alert-success {
display: block;
color: green;
}
.alert.alert-danger {
display: block;
color: red;
}
</style>
Write code to handle the submission of the comment form
There are two ways how to handle the submission of the form.
We can make the form to post directly to the serverless function endpoint, and then the function would redirect the user back to the blog post with a parameter in the URL indicating a successful submission. Then we could use JavaScript to check for the parameter and display a success message.
Alternatively, we can use JavaScript to do both—handle the form submission and display the message. I choose this method for this article only because I prefer not having to reload the page.
Place the following code inside a <script>
tag, which should also be placed inside the comment form.
const form = document.currentScript.closest("form")
const button = form.querySelector("button")
const alert = form.querySelector(".alert")
button.addEventListener("click", async () => {
const url = "{{ .Site.Params.comments.backend }}"
const data = new FormData(form)
const result = await fetch(url, {
method: "POST",
body: data,
})
if (!result.ok) {
alert.classList.remove("alert-success")
alert.classList.add("alert-danger")
alert.querySelector("div").innerHTML = await result.text()
return
}
alert.classList.remove("alert-danger")
alert.classList.add("alert-success")
alert.querySelector("div").innerHTML = await result.text()
form.reset()
})
Let’s go step by step through the code.
First, we need to locate all the needed elements inside the form.
We’re going to place the script inside the form,
so we can use the Document.currentScript
property to reference the script element,
and then using the closest()
method on the said property to find the form itself, and then all the rest of the
required elements.
Then we attach a function to the button’s click event.
The function starts with a declaration of the endpoint URL where the form data should be posted. You should have the URL from the first part of the series.
The next line takes the form element and creates a FormData object using values from the input elements found inside the form. It is followed by a call to the fetch function that makes the HTTP request and returns the response. Then the response is checked to see whether it is successful or not, and the appropriate message is displayed.
After the visitor successfully submits the comment, the form is restored to its default state.
List comments
Now that we have a working comment creation form, it’s time to move on to creating the code that lists all the comments that belong to a given blog post.
We store comments as JSON data files in the Hugo’s data files folder.
Hugo reads all the files during the build process
and stores them in a global object called .Data
.
This global object is a collection of key-value pairs that can be accessed using the dot notation.
Let’s say you have a JSON file called nba.json
that contains a list of NBA teams and a current champion.
{
"current_champion": {
"team": "3",
"year": 2022
},
"teams": {
"1": {
"name": "Los Angeles Lakers",
"city": "Los Angeles",
"state": "California",
"championships": 17
},
"2": {
"name": "Chicago Bulls",
"city": "Chicago",
"state": "Illinois",
"championships": 6
},
"3": {
"id": 3,
"name": "Golden State Warriors",
"city": "San Francisco",
"state": "California",
"championships": 6
},
"4": {
"id": 4,
"name": "Boston Celtics",
"city": "Boston",
"state": "Massachusetts",
"championships": 17
},
"5": {
"id": 5,
"name": "Miami Heat",
"city": "Miami",
"state": "Florida",
"championships": 3
}
}
}
You can iterate through the team list using the range
function like this:
<ul>
{{ range .Site.Data.nba.teams }}
<li>{{ .name }}</li>
{{ end }}
</ul>
You can access the current champion with the following code:
<p>Current NBA champion: {{ .Site.Data.nba.current_champion.team }}</p>
Unfortunately, it would return only the identifier of the team, and not the actual team.
To access the actual team, we need
to use the index
function which look-ups the collection by a provided key
and returns the value or null if the key is not found:
{{ $team := index .Site.Data.nba.teams .Site.Data.nba.current_champion.team }}
<p>Current NBA champion: {{ $team.name }}</p>
We will use the same approach to output the blog comments.
Because the comments are placed in separate folders,
named after the file name of the blog post,
we can use the index
function to access current post comments with the value of .File.BaseFileName
property.
{{ $comments := index .Site.Data.comments .File.BaseFileName }}
{{ if $comments }}
<h2>Comments</h2>
<ul>
{{ range sort $comments "date" "desc" }}
<li>
<div>
<div>{{ .name }}</div>
{{ $dateMachine := .date | time.Format "2006-01-02T15:04:05-07:00" }}
{{ $dateHuman := .date | time.Format ":date_long" }}
<time datetime="{{ $dateMachine }}">{{ $dateHuman }}</time>
</div>
<p>{{ .comment }}</p>
</li>
{{ end }}
</ul>
{{ end }}
Conclusions
That’s it. In this article, we finished building a simple comment system for a static Hugo blog. It uses a serverless function to handle the form submission, and then stores the comment to a GitHub repository. After a new commit is made to the repository, a rebuild is triggered, making the comment to appear on the website.
What improvements can be made?
The most obvious improvement I can think of is to make it more resistant to spam. I think that it would be great to set up a Google reCAPTCHA before accepting a comment. Also, upon submitting a comment, we could check its content using a spam filter like Akismet.