Hugo: Mastodon als Kommentarsystem für den statischen Blog

Datum: Dienstag, 7. Februar 2023

Tag(s): Hugo Mastodon Kommentarsystem Statischen Blog Tutorial

Seitdem ich von WordPress auf Hugo gewechselt bin, konnte man auf diesem Blog keine Kommentare mehr hinterlassen.

Deshalb habe ich in den letzten Jahren immer wieder E-Mails oder Nachrichten auf LinkedIn zu Blog-Beiträgen von mir bekommen. Rückmeldung von meinen Lesern ist zwar ganz nett, allerdings sollte die Kommunikation offen auf meinem Blog bleiben, damit andere Leser z.B. Informationen oder Antworten zu Themen auch nachlesen können.

Hugo selbst ist nur ein Generator von statischen Seiten, was bedeutet, dass eine Kommentarfunktion nicht Teil von Hugo ist und man sich selbst etwas ausdenken muss.

Ich habe mir verschiedene Lösungen angeschaut und bin zur Entscheidung gekommen, dass eine Einbindung von Mastodon Kommentaren für meinen Anwendungsfall am besten ist. Bevor ich zu den technischen Details komme, möchte ich aber erstmal ein paar Alternativen erwähnen:

Remark42

Remark42 ist eine Software-Lösung, die man auf seinem eigenen Server selbst hosten kann. Nutzer können sich per Email und Password oder mit bekannten OAuth-Anbietern wie Google, Facebook usw. anmelden.

Zur Umsetzung muss man die Software auf einem Server installieren, konfigurieren und anschließend eine JavaScript-Datei auf dem Blog einbinden.

Ich habe mich gegen diese Lösung entscheiden, da ich nicht noch einen extra Service auf meinem Server laufen lassen will, ich zudem kein extra Login-System betreuen möchte (ich betreibe bereits einen OpenID Connect Server, der nach Möglichkeit zentral für alle Logins zuständig sein soll) und ich auch nicht einen extra Speicher für Kommentare haben möchte.

utterances & giscus

utterances und giscus sind zwei sehr interessante Projekte, die GitHub Issues / GitHub Discussions als Speicher für die Kommentare verwenden.

Der klare Vorteil der Lösung ist, dass die Kommentare nicht auf dem eigenen Server, sondern bei GitHub gespeichert werden und die Besucher ihren bestehenden GitHub Account verwenden können.

Da nicht alle meine Besucher GitHub kennen und ich mich auch nicht komplett von einem Dienst abhängig machen will, habe ich mich dagegen entschieden.

Mastodon

Mastodon ist ein dezentrales soziales Netzwerk ähnlich zu Twitter. Ich veröffentliche meine Blog-Posts sowieso schon seit Jahren auf Twitter und Mastodon, d.h. zu jedem Beitrag von mir gibt es automatisch einen Toot auf Mastodon, den andere Nutzer im Mastodon-Netzwerk auch kommentieren können.

Im Moment verwende ich zwar noch Mastodon-Server, die von anderen kontrolliert werden, allerdings könnte ich theoretisch Mastodon auf meinem eigenen Server hosten. Da Mastodon seit kurzem auch OpenID Connect versteht, kann ich ebenfalls meinen bestehenden Authentifizierungsserver nutzen.

Die Nachteile, die ich bei den anderen Lösungen hatte, habe ich also bei Mastodon nicht. Es gibt nur ein neues Problem: der Toot zu meinem Blog-Beitrag wird erst erstellt, nachdem ich den Blog-Beitrag schon gepostet habe. D.h. ich muss meine Blog-Posts immer im Nachhinein um den Toot ergänzen.

Die Umsetzung

Ich habe den Code von Daniel Pecos Martínez genommen (Daniel hat selbst den Code von Carl Schwan verwendet) und ihn an meine Bedürfnisse angepasst (u.a. deutsche Sprache ergänzt und CSS angepasst).

Die config.toml Datei muss um den Host und den Nutzer ergänzt werden, z.B.:

[params]
    [params.comment.fediverse]
        host = "mastodon.social"
        user = "cmiksche"

Im jeweiligen Beitrag muss nun ebenfalls die Toot-ID gesetzt werden (diese kann man aus der URL auslesen - z.B.: https://mastodon.social/@cmiksche/109495175684081408):

---
title: 'Hugo: Shortcode für CCC Videos'
date: "2022-06-09"
fediverse: 108453074700235160
---

Anschließend eine neue Datei comments.html im Verzeichnis layouts/partials des Theme mit dem folgenden Inhalt erstellen:

{{ if isset .Params "fediverse" }}
<h2>
    {{ if eq .Site.Language.Lang "en" }}
        Comments
    {{ else }}
        Kommentare
    {{ end }}
</h2>

<noscript>
  <div id="error">
    {{ if eq .Site.Language.Lang "en" }}
        Please enable JavaScript to view the comments powered by the Fediverse.
    {{ else }}
        Bitte aktivieren Sie JavaScript, um die Kommentare vom Fediverse anzuzeigen.
    {{ end }}
  </div>
</noscript>

<p>
    {{ if eq .Site.Language.Lang "en" }}
        You can use your Fediverse (i.e. Mastodon, among many others) account to reply to this <a class="link" href="https://{{ .Site.Params.comment.fediverse.host }}/@{{ .Site.Params.comment.fediverse.user }}/{{ .Params.fediverse }}">post</a>.
    {{ else }}
    Sie können Ihr Fediverse-Konto (z. B. Mastodon und viele andere) verwenden, um auf diesen <a class="link" href="https://{{ .Site.Params.comment.fediverse.host }}/@{{ .Site.Params.comment.fediverse.user }}/{{ .Params.fediverse }}">Beitrag</a> zu antworten.
    {{ end }}
</p>
<p id="mastodon-comments-list"></p>

<script src="/js/purify.min.js"></script>
<script type="text/javascript">
  var host = '{{ .Site.Params.comment.fediverse.host }}';
  var user = '{{ .Site.Params.comment.fediverse.user }}';
  var id = '{{ .Params.fediverse }}'

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

  var commentsLoaded = false;

  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);
    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" />`);
    });
    mastodonComment =
      `<div class="mastodon-comment" style="margin-left: calc(10px * ${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="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 = "{{ if eq .Site.Language.Lang "en" }}Loading comments from the Fediverse...{{ else }}Kommentare aus dem Fediverse werden geladen...{{ end }}";

    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><i>{{ if eq .Site.Language.Lang "en" }}Not comments found{{ else }}Keine Kommentare gefunden{{ end }}</i></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);
  }

  var comments = document.getElementById("mastodon-comments-list");
  respondToVisibility(comments, loadComments);
</script>
{{ end }}

Um die Kommentare anzuzeigen noch die Datei _default/single.html im Theme um den Shortcode ergänzen:

{{- partial "comments.html" . -}}

Dann sollte man noch die CSS-Datei um ein paar Style-Informationen ergänzen:

.mastodon-comment {
  color: #000;
  margin: 0.4rem;
  padding: 0.4rem;
  opacity: 0.8;
  display: flex;
  flex-direction: column;
}

.mastodon-comment .author {
  display: flex;
}

.mastodon-comment .author .details {
  margin: 10px;
  padding: 10px;
}

.mastodon-comment .author .date {
  margin-left: auto;
  font-size: small;
}

.mastodon-comment .author a:link, .mastodon-comment .author a:visited, .mastodon-comment .status a:link {
  text-decoration: none;
  border-bottom: 0px;
}

.mastodon-comment .content {
  padding: 5px;
}

.mastodon-comment .status {
  display: block;
}

.mastodon-comment .status > div {
  display: inline-block;
  margin-right: 15px;
}

Das war es, nun die Änderung noch vor dem Veröffentlichen lokal ausprobieren und anschließend auf Kommentare freuen.

Kommentare

Sie können Ihr Fediverse-Konto (z. B. Mastodon und viele andere) verwenden, um auf diesen Beitrag zu antworten.