Hugo Relearn Mastodon Comments

Um Kommentare unter der den Artikeln zu ermöglichen, habe ich die Vorlage von Client-side comments with Mastodon on a static Hugo website (Vielen Dank dafür!) für das Hugo Relearn Theme angepasst und leicht modernisiert.

Wie im Original-Artikel beschrieben, ist in jedem Artikel im Frontmatter die ID des Mastodon Posts zu ergänzen:

comments:
  id: [Mastodon toot id]

Dateien

Konfiguration und Archetype

config.yaml

Erweitere mich…
params:
  comments:
    host: "social.tchncs.de"
    username: "wrdlbrmpft"

archetypes/default.md

Erweitere mich…
---
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

Erweitere mich…
{{ partial "comments" . }}

layouts/_partials/comments.html

Erweitere mich…
{{- $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

Erweitere mich…
(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

Erweitere mich…
#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;
        }
    }
}