Hugo Relearn Mastodon Comments

To enable comments under the articles, I adapted and slightly modernised the template from Client-side comments with Mastodon on a static Hugo website (Thank you very much!) for the Hugo Relearn Theme and modernised it slightly.

As described in the original article, the ID of the Mastodon post must be added to the front matter of each article:

comments:
  id: [Mastodon toot id]

Files

Configuration and Archetype

config.yaml

Expand me…
params:
  comments:
    host: "social.tchncs.de"
    username: "wrdlbrmpft"

archetypes/default.md

Expand me…
---
title: "{{ replace .Name "-" " " | title }}"
description: ""
date: {{ .Date }}
draft: true
{{- if and (isset .Site.Params.comments "host") (isset .Site.Params.comments "username") }}
{{- if and (ne .Site.Params.comments.host "") (ne .Site.Params.comments.username "") }}
comments:
  host: {{ .Site.Params.comments.host }}
  username: {{ .Site.Params.comments.username }}
  id:
  blockedcomments:
{{ end -}}
{{ end -}}
---

Templates

layouts/_partials/content-footer.html

Expand me…
{{ partial "comments" . }}

layouts/_partials/comments.html

Expand me…
{{- $comments := dict
    "host" .Site.Params.comments.host
    "user" (string .Site.Params.comments.username)
    "id" (string .Params.comments.id)
    "blockedcomments" .Params.comments.blockedcomments
}}
{{- if and ($comments.host) ($comments.user) ($comments.id) }}
<hr>
<h2>{{ i18n "Comments" }}</h2>
<p>
    {{ i18n "With an account on the Fediverse or Mastodon, you can respond to this" }}
    <a href="https://{{ $comments.host }}/@{{ $comments.user }}/{{ $comments.id }}">{{ i18n "post" }}</a>.
    {{ i18n "Known replies are displayed below" }}:
</p>
<p id="mastodon-comments-list" data-params="{{ $comments | jsonify }}">
    <button type="button" class="_js_loadcomments" type="button">
        <i class="fa-fw fas fa-download"></i>
        {{ i18n "Load Comments from " }}
        {{ $comments.host }}
    </button>
</p>

<script src="{{ "DOMPurify/purify.min.js" | relURL }}" defer></script>
<script src="{{ "comments.js" | relURL }}" defer></script>

{{- with (resources.Get "css/comments.scss") }}
{{- $opts := dict
    "enableSourceMap" hugo.IsDevelopment
    "outputStyle" (cond hugo.IsDevelopment "expanded" "compressed")
    "transpiler" "dartsass"
    "vars" site.Params.styles
}}
{{- $style := css.Sass . | resources.Fingerprint }}
<link rel="stylesheet" href="{{ $style.Permalink }}" integrity="{{ $style.Data.Integrity }}">
{{- end }}
{{- end }}

Javascript

  • DOMPurify in
    • static/DOMPurify/purify.min.js
    • static/DOMPurify/purify.min.js.map
    • static/DOMPurify/purify.js
    • static/DOMPurify/purify.js.map

static/comments.js

Expand me…
(function () {
  function escapeHtml(unsafe) {
    return unsafe
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }

  function toot_active(toot, what) {
    var count = toot[what + "_count"];
    return count > 0 ? "active" : "";
  }

  function toot_count(toot, what) {
    var count = toot[what + "_count"];
    return count > 0 ? count : "";
  }

  function user_account(account) {
    var result = `@${account.acct}`;
    if (account.acct.indexOf("@") === -1) {
      var domain = new URL(account.url);
      result += `@${domain.hostname}`;
    }
    return result;
  }

  function render_toots(toots, in_reply_to, depth) {
    var tootsToRender = toots
      .filter((toot) => toot.in_reply_to_id === in_reply_to)
      .sort((a, b) => a.created_at.localeCompare(b.created_at));
    tootsToRender.forEach((toot) => render_toot(toots, toot, depth));
  }

  function render_toot(toots, toot, depth) {
    toot.account.display_name = escapeHtml(toot.account.display_name);
    toot.account.emojis.forEach((emoji) => {
      toot.account.display_name = toot.account.display_name.replace(
        `:${emoji.shortcode}:`,
        `<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" height="20" width="20" />`,
      );
    });
    if (!blockedToots.includes(toot.url)) {
      mastodonComment = `<div class="mastodon-comment" style="margin-left: calc(var(--mastodon-comment-indent) * ${depth})">
          <div class="author">
            <div class="avatar">
              <img src="${escapeHtml(toot.account.avatar_static)}" height=60 width=60 alt="">
            </div>
            <div class="details">
              <a class="name" href="${toot.account.url}" rel="nofollow">${toot.account.display_name}</a>
              <a class="user" href="${toot.account.url}" rel="nofollow">${user_account(toot.account)}</a>
            </div>
            <a class="date" href="${toot.url}" rel="nofollow">${toot.created_at.substr(0, 10)} ${toot.created_at.substr(11, 8)}</a>
          </div>
          <div class="content">${toot.content}</div>
          <div class="attachments">
            ${toot.media_attachments
              .map((attachment) => {
                if (attachment.type === "image") {
                  return `<a href="${attachment.url}" rel="nofollow"><img src="${attachment.preview_url}" alt="${attachment.description}" /></a>`;
                } else if (attachment.type === "video") {
                  return `<video controls><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
                } else if (attachment.type === "gifv") {
                  return `<video autoplay loop muted playsinline><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
                } else if (attachment.type === "audio") {
                  return `<audio controls><source src="${attachment.url}" type="${attachment.mime_type}"></audio>`;
                } else {
                  return `<a href="${attachment.url}" rel="nofollow">${attachment.type}</a>`;
                }
              })
              .join("")}
          </div>
          <div class="status">
            <div class="replies ${toot_active(toot, "replies")}">
              <a href="${toot.url}" rel="nofollow"><i class="fa fa-reply fa-fw"></i>${toot_count(toot, "replies")}</a>
            </div>
            <div class="reblogs ${toot_active(toot, "reblogs")}">
              <a href="${toot.url}" rel="nofollow"><i class="fa fa-retweet fa-fw"></i>${toot_count(toot, "reblogs")}</a>
            </div>
            <div class="favourites ${toot_active(toot, "favourites")}">
              <a href="${toot.url}" rel="nofollow"><i class="fa fa-star fa-fw"></i>${toot_count(toot, "favourites")}</a>
            </div>
          </div>
        </div>`;
      document
        .getElementById("mastodon-comments-list")
        .appendChild(
          DOMPurify.sanitize(mastodonComment, { RETURN_DOM_FRAGMENT: true }),
        );
    }

    render_toots(toots, toot.id, depth + 1);
  }

  function loadComments() {
    if (commentsLoaded) return;

    document.getElementById("mastodon-comments-list").innerHTML =
      "Loading comments from the Fediverse...";

    console.debug(
      "Comment URL: https://" + host + "/api/v1/statuses/" + id + "/context",
    );
    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 = "";
          render_toots(data["descendants"], id, 0);
        } else {
          document.getElementById("mastodon-comments-list").innerHTML =
            "<p>No comments found</p>";
        }

        commentsLoaded = true;
      });
  }

  function respondToVisibility(element, callback) {
    var options = {
      root: null,
    };

    var observer = new IntersectionObserver((entries, observer) => {
      entries.forEach((entry) => {
        if (entry.intersectionRatio > 0) {
          callback();
        }
      });
    }, options);

    observer.observe(element);
  }

  const comments = document.getElementById("mastodon-comments-list");
  const params = JSON.parse(comments.dataset.params);
  const host = params.host;
  const id = params.id;
  const blockedToots = params.blockedcomments || [];
  var commentsLoaded = false;
  if (comments) {
    comments.querySelector('button._js_loadcomments').addEventListener('click', (ev) => {
      respondToVisibility(comments, loadComments);
      ev.preventDefault();
    });
    respondToVisibility(comments, loadComments);
  }
})();

CSS

assets/css/comments.scss

Expand me…
#mastodon-comments-list {
    button {
        padding: 0.3em;
        border-radius: 0.3em;
    }
}
.mastodon-comment {
    background-color: var(--block-background-color);
    border-radius: var(--block-border-radius);
    border: 1px var(--block-border-color) solid;
    padding: 20px;
    margin-bottom: 1.5rem;
    display: flex;
    flex-direction: column;
    color: var(--font-color);
    font-size: var(--font-size);
    p {
        margin-bottom: 0px;
    }
    .author {
        padding-top: 0;
        display: flex;
        a {
            text-decoration: none;
        }
        .avatar {
            img {
                margin-right: 1rem;
                min-width: 60px;
                border-radius: 5px;
            }
        }
        .details {
            display: flex;
            flex-direction: column;
            .name {
                font-weight: bold;
            }
            .user {
                color: #5d686f;
                font-size: medium;
            }
        }
        .date {
            margin-left: auto;
            font-size: small;
        }
    }
    .content {
        margin: 15px 20px;
        p:first-child {
            margin-top: 0;
            margin-bottom: 0;
        }
    }
    .attachments {
        margin: 0px 10px;
        > * {
            margin: 0px 10px;
        }
    }
    .status {
        > div {
            display: inline-block;
            margin-right: 15px;
        }
        a {
            color: #5d686f;
            text-decoration: none;
        }
        .replies.active a {
            color: #003eaa;
        }
        .reblogs.active a {
            color: #8c8dff;
        }
        .favourites.active a {
            color: #ca8f04;
        }
    }
}