I finally managed to fix a few annoying issues with my first approach, and this one finally works: Baking static, Markdown-ready comments right into your Hugo pages/posts without a subscription or even a GitHub account. All you need is PHP with YAML support on the server that serves your Hugo page.
It works like this:
- A random internet user comments on
/2024/10/foo
. - The script writes the comment into
/content/2024/10/foo/comments/
. - A page rebuild is triggered.
- Hugo will automatically add the new comment’s contents to the output page.
(I recommend some kind of moderation here, but you’re free to establish your own pipeline.)
The script:
<?php
$_POST = filter_input_array(INPUT_POST, FILTER_SANITIZE_STRING);
$timestamp_file = (new \DateTime())->format('Y-m-d\TH.i.s.u');
$timestamp_object = (new \DateTime())->format('Y-m-d H:i:s.uZ');
$comment = array(
"post_id" => $_POST["postid"],
"timestamp" => $timestamp_object,
"author" => $_POST["author"],
"email" => $_POST["email"],
"link" => $_POST["url"],
"comment" => $_POST["comment"]
);
$hash = substr(hash('md5', $_POST["comment"]), 0, 6);
$commentdir = dirname(__FILE__) . "/content/" . str_replace("/blog", "", $_POST["storage"]) . "comments/";
$filepath = $commentdir . $timestamp_file . "-" . $hash . ".yml";
if (!file_exists($commentdir)) {
mkdir($commentdir, 0777, true);
}
$success = yaml_emit_file($filepath, $comment);
if ($success) {
exec("cd " . dirname(__FILE__) . " && hugo");
header("Location: " . $_POST["postid"]);
}
else {
echo "Error. :-("; // << Here's a good place to log it...
}
?>
themes/quux/layouts/partials/comment-form.html
:
<form action="comments.php" method="POST" id="commentform">
<input type="hidden" name="postid" value="{{ .RelPermalink }}" />
<input type="hidden" name="storage" value="{{ .File.Dir }}" />
<p class="comment-form-author"><label for="author">Name (optional):</label> <input id="author" name="author" type="text" placeholder="Name" size="30" maxlength="245" /></p>
<p class="comment-form-email"><label for="email">E-mail (optional):</label><input id="email" name="email" type="text" placeholder="E-mail address" size="30" maxlength="100" /></p>
<p class="comment-form-url"><label for="url">Website (optional):</label><input id="url" name="url" type="text" placeholder="Website (URL)" size="30" maxlength="200" /></p>
<p class="comment-form-comment"><textarea name="comment" id="comment" cols="45" rows="8" required="required" placeholder="Your comment (Markdown is enabled)"></textarea></p>
<p class="form-submit"><input type="submit" value="Post" /></p>
</form>
themes/quux/layouts/partials/comment-list.html
:
{{ $scratch := newScratch }}
{{ $scratch.Set "comments" (.Resources.Match "comments/*.yml") }}
{{ if eq 0 (len ($scratch.Get "comments")) }}
<h2>No comments left here yet.</h2>
{{ else }}
{{ $.Scratch.Set "commentcounter" 0 }}
<ol class="commentlist">
{{ range ($scratch.Get "comments") }}
{{ $.Scratch.Set "commentcounter" (add ($.Scratch.Get "commentcounter") 1) }}
<li class="comment">
{{ $comment := (.Content | transform.Unmarshal) }}
<article id="comment-{{$.Scratch.Get "commentcounter"}}" class="comment">
<footer>
<div class="comment-author">
<cite class="fn">{{ if $comment.link }}
<a href="{{ $comment.link }}">{{ $comment.author }}</a>
{{ else }}
{{ $comment.author }}
{{ end }}
</cite>
</div>
<div class="comment-meta">
{{ $comment.timestamp | dateFormat ":date_long" -}}
at
{{ $comment.timestamp | dateFormat ":time_short" -}}
</div>
</footer>
<div class="comment-content">{{ $comment.comment | markdownify }}</div>
</article>
</li>
{{ end }}
</ol>
{{ end }}
themes/quux/_default/single.html
:
<article>
...
</article>
<div id="comments" class="comments-area">
<h1>Comments:</h1>
{{- partial "comment-list.html" . -}}
<br />
<h3>Add a new comment</h3>
{{- partial "comment-form.html" . -}}
</div>
Works for me.
A reply_to field for those who would like to have nested comments is left as an exercise to the reader.