Static comments script for Hugo (in PHP)

I finally managed to fix a few annoying issues :wink: 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. :slight_smile:

It works like this:

  1. A random internet user comments on /2024/10/foo.
  2. The script writes the comment into /content/2024/10/foo/comments/.
  3. A page rebuild is triggered.
  4. 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" -}}
                  &nbsp;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.

I wrote a small(ish) moderation script for the shell. Requires ksh (probably works with bash too) and yq. You’ll need to store moderated comments as .yml2 (just change the $filepath line in the PHP script!).

#!/usr/bin/env ksh
done_something=0    # 1, if Hugo needs to be run

echo Comments moderation script for Hugo.
read content_path?"Path to /content: "

# Remove the trailing /, if any:
if [[ "$content_path" == */ ]]; then
    content_path=${content_path%?}
fi

if [ -d $content_path ]; then
    # Path exists.
    # Search for .yml2 files:
    file_list=`find $content_path -name "*.yml2"`
    if [ -z $file_list ]; then
        echo No comments are currently awaiting moderation.
        exit
    fi

    # Comments were found.
    comment_counter=1
    for comment_file in $file_list; do
        echo ""
        echo ---------------------
        echo Comment no. $comment_counter:
        echo ""

        # Read:
        echo "    Post:     `yq -e '.post_id' $comment_file`"
        echo "    Author:   `yq -e '.author' $comment_file` (`yq -e '.email' $comment_file`)"
        echo "    Text:     `yq -e '.comment' $comment_file`"
        echo ""

        # What to do?
        read action?"[P]ublish, [D]elete, [E]dit first? "
        case $action in
            P|p)
                # Rename .yml2 to .yml:
                newName=${comment_file%?}
                mv $comment_file $newName
                done_something=1
                echo The comment will be published.
                ;;
            D|d)
                # Delete $comment_file:
                rm $comment_file
                echo Comment deleted.
                ;;
            E|e)
                # Edit $comment_file, then publish it:
                vi $comment_file  # you could also use ed here, or emacs...
                newName=${comment_file%?}
                mv $comment_file $newName
                done_something=1
                echo The comment has been edited and will be published.
                ;;
            *)
                echo This comment will be skipped.
                ;;
        esac
        
        ((comment_counter++))
    done

    # Run Hugo if a new comment has been published:
    if [[ $done_something -eq 1 ]]; then
        cd $content_path/..
        hugo
    fi
else
    echo Wrong path.
fi

No offense, I’m just curious that why it called static?

Static websites mean there is no real-time interactions between server and clients, likes github pages base on my understanding. There will be some misunderstanding after a brief glance

Still Thanks for your product.

Unlike dynamic commenting systems like Disqus, the comments are baked right into the website, not loaded “on demand”. Depending on your deployment pipeline, you can even keep both comments.php and the commenting script on an entirely different server, so your Hugo server remains static.

The scripts itself aren’t static, the comments will be.

I’m open for ideas.

1 Like

Thanks for you friendly reply.
I think I got it right now, sorry for my reckless views.

1 Like