Monday, March 21, 2022

Self Hosted Hugo Comments: data rendered with partials

In an earlier post, I added self-hosted static comments via shortcodes in Hugo. This approach had some benefits, but I didn’t like how it required modifying every blog page to support comments, even if no comments were present.

Hugo has a system of partials and templates to allow for similar pages to have the same layout. We can take advantage of these to handle comments on every blog page. This will pull the comments out of the main flow of the blog posts; we could move them into the front matter of the pages, but insted I’m going to knock out another con of the previous approach and consolidate all comments into one data file.

The method

We have a few steps to go through here:

  • Consoolidate comments into a data file
  • Build comments.html and comment.html as partials
  • Build a new blogpost template to use the comments partial
  • Use cascading front-matter to shift all the blog posts to the new template

Consolidate comments into a data file

To make it easy to work with comments as a separate construct from posts, we’ll shift all of them into a new file at data/comments.yaml. Hugo automatically parses files in the data directory and makes their content available for the site builder as site.data.<name of file>.

I’m using yaml because it splits the difference a bit: easy to use, but allows for multi-line strings without a lot of hassle (and it place nicely with my emacs config). here’s a snippet of the resulting yaml file.

"/posts/2021/this-is-year-of-linux-on-desktop/":
  - id: 1
    username: "Anon 1"
    date: 2021-10-21T16:54:40.122Z
    comment: You have working audio on your GNU/Linux laptop?  Must be nice.
    replies:
      - id: 2
        username: Mark T. Tomczak
        date: 2021-10-21T17:56:00.084Z
        comment: I used to, but I changed my window manager and now I'm not so sure. :-p
"/posts/2021/marks-gallery-of-facebook-infractions-3/":
  - id: 1
    username: Anon-2
    date: 2021-06-14T15:34:10.877Z
    comment: |
      My vote is for "kill the filibuster." This is a failure of the algorithm to differentiate actual calls for violence from figurative language.  I wonder if you could post a comment about "Killing the Lights" when discussing what you might do before a movie or bedtime.

      Reminds me of when I tried to sell a dart board on FB Marketplace, and I included a photo of the darts themselves. I had my post removed for trying to sell weapons.

      There is an ENTIRE CATEGORY devoted to "Darts Equipment." Oh, Zuck...      

Worth noting:

  • The top-level object is a dictionary mapping post paths to a list of the top-level comments in the posts
  • comment IDs are unique within the post (they’re used to build URLs to email replies in)
  • We preserved the tree structure from the previous short-code solution, but since the replies are now a separate field from the comment text body, we’ll be able ot use Markdown on the comment without mangling replies.

Build the comments.html partial

The partial at layouts/partials/comments.html finds the comments for the current page. If they exist, it stitches them in.


{{ with site.Data.comments }}
{{ $comments := index . $.Page.RelPermalink }}
<div class="comments">
  <h1 class="comments">Comments</h1>
  <div class="comments-menu">
    <ul>
      <li>
        <a href="mailto:blog+personal-comment@fixermark.com?body=Your Name:%0d%0aIcon:%0d%0aComment:&subject=Comment on {{ $.Page.Permalink }}">
          Add comment
        </a>
      </li>
      <li>
        <a href="/how-to-comment">How to comment</a>
      </li>
    </ul>
  </div>
  <div class="comments">
  {{ with $comments }}
    {{ $sorted := sort $comments "date" "desc"}}
    {{ range $sorted }}
      {{ partial "comment.html" (dict "comment" . "permalink" $.Page.Permalink) }}
    {{ end }}
  {{ else }}
      <div class="no-comments"><i>This article has no comments</i></div>
  {{ end }}
  </div>
</div>
{{ end }}

Once we fetch the list of comments, we check for any comment list with a key matching this page. If we find any, we sort them by date and render them ({{ range $sorted}}). This partial also renders a header for the comments section and a link to add a comment to the post.

Partials receive only the state given to them by their invoking template. When we render individual comments with the comment.html partial, we only give it the two pieces of information it needs (in the form of a new dictionary): the comment data as comment and the link to this page as permalink. The link is used to build replies to comments.

Build the comment.html partial

Rendering individual comments is delegated to a second partial.

<div class="comment">
  <div class="user-info">
    {{ if .comment.usericon }}
      <img src="{{.usericon}}">
    {{ end }}
    <span class="username">{{ .comment.username }}</span> <span class="post-time">{{ time.Format "2006-01-02 15:04 Z" .comment.date }}</span>
  </div>
  <div class="comments-menu"><a href="mailto:blog+personal-comment@fixermark.com?body=Your Name:%0d%0aIcon:%0d%0aComment:&subject=Comment on {{ .permalink }}?comment-id={{ .comment.id }}">Reply</a></div>
  <div class="comment-body">
    {{ .comment.comment | markdownify }}
  </div>
    {{ with .comment.replies }}
    <div class="replies">
      {{ $sorted := sort . "date" "desc" }}
      {{ range $sorted }}
        {{ partial "comment.html" (dict "comment" . "permalink" $.permalink) }}
      {{ end }}
    </div>
    {{ end }}
</div>

We pretty up the time representation of the comment using time.Format and run the body of the comment through markdownify to convert any special characters. We also add a reply link taking advantage of the comment ID.

Note that to render replies to this comment, this partial re-invokes itself passing the reply as the comment. This sort of recursion is fine in Hugo as long as it’s not infinite (the nature of the tree data structure this function is running on makes such infinite recursion impossible).

Build a new blogpost template to use the comments partial

Hugo allows pages to specify their type, which determines which of several templates Hugo will use to render the content of the page. Now that we have comments, I copied the single.html template from the theme I’m using into layouts/blogpost/single.md and replaced its invocation of a comment partial with my own:

{{ partial "comments.html" . }}

At this top level, I give the partial everything the template has for convenience.

Use cascading front-matter to shift all the blog posts to the new template

Hugo uses a speciall-named _index file to allow for application of front-matter to every page in a subdirectory of the site. Using that, it’s straightforward to shift all my blog posts to the new tepmlate. I add the file content/posts/index.md:

---
cascade:
  type: blogpost
---

Now, every page under posts/ has its type set by default.

Putting that all together (and adding a bit of CSS to clean the formatting), we now have comments on every page without changes to every page. Very happy with the result!

A couple of comments underneath an article


Pros and cons

Pros

Thread flow still clear
Replies are nested under their comments. I’m glad I didn’t have to lose this from the inline solution
Comment text is just markdown
I’m much happier with markdown as the comment body text than markup; easier to read, and modestly harder for end-users to find a way to accidentally break the whole sight flow

Cons

Comments no longer live on their articles
I’m considering this a pro in the overall assessment. It’d be nice if comments lived right next to their articles, but with comments consolidated in one file it’s much easier to manage them as an entity (including scrubbing one if a user asks to have it removed; I only have to purge one file through all archives).

Final thoughts

I’m really finding Hugo very straightforward to use. It’s nice to have my toolchain more tightly integrated and this level of control over both content and presentation.

No comments:

Post a Comment