Maurice Renck - Entwicklung (team) https://maurice-renck.de/de de Sat, 09 May 2026 18:00:00 +0200 Plugin Wizards https://maurice-renck.de/de/blog/2026/plugin-wizards https://maurice-renck.de/de/@/page/zuvuhxo0kzinpejg Sat, 09 May 2026 18:00:00 +0200 Maurice Renck kirby-cms Ich habe heute die erste Version eines neuen Tools veröffentlicht, was die Konfiguration meiner Kirby-Plugins vereinfachen soll.

Machen wir uns nichts vor: Ich versuche, meine Plugins so zu gestalten, dass sie möglichst einfach einzurichten sind, aber umfangreiche Plugins, wie beispielsweise der IndieConnector, haben so viele Anpassungsmöglichkeiten, dass man schon mal den Überblick verlieren kann.

Deshalb habe ich ein kleines Tool gebaut, welches alle Optionen des Plugins als Tabelle ausgeben kann (so wie sie bisher bei meinen Plugins zu finden war). Das Tool kann aber aus diesen Daten auch einen Wizard machen.

Ich habe beim IndieConnector damit begonnen. Dort kannst Du nun den Konfigurationsassistenten aufrufen und wirst Schritt für Schritt durch die wichtigsten Konfigurationen geleitet. Wähle aus, welches Feature du nutzen willst, und konfiguriere dieses dann direkt hier auf der Seite.

Am Ende des Assistenten bekommst du eine fertige Konfiguration angezeigt, die Du genauso in Deine config.php kopieren kannst.

Ich beschränke mich hier auf die wichtigsten Funktionen und Optionen. Du kannst bei Bedarf also noch in die Doku gucken, um Dein Setup noch weiter zu individualisieren. Aber mit dem Assistenten hast Du dann bereits ein solides Setup.

Das Ganze ist mit einer ersten Version gestartet und ich bin gespannt, wie es genutzt wird und was ich daran noch verbessern kann. Hier findest Du den Assistenten für den IndieConnector (weitere Plugins werden folgen):

Assistent - Maurice Renck

Dieser Konfigurationsassistent leitet Dich Schritt für Schritt durch die wichtigsten IndieConnector-Features.

]]>
delphitools - indie toolkit https://tools.rmv.fyi/ https://maurice-renck.de/de/@/page/zbbol6qpy3ygepzn Tue, 14 Apr 2026 11:54:00 +0200 Maurice Renck Das ist eine ziemlich coole Sammlung kleiner, hilfreicher Tools.

]]>
Kirby Blog Kurs https://maurice-renck.de/de/learn/kirby-blog-course https://maurice-renck.de/de/@/page/iilvqgyazlgnn9cz Tue, 31 Mar 2026 11:30:00 +0200 Maurice Renck kirby-cms

In diesem Kurs lernst Du nicht nur, wie Kirby funktioniert, nach Abschluss hast du zudem ein funktionierendes Blog mit Anbindung an Mastodon und Bluesky.

Kirby Kurs Newsletter

Du hast Interesse am Kurs? Hier kannst Du Dich unverbindlich vorregistrieren

Kursumfang

In diesem Kurs bauen wir gemeinsam ein Blog mit Kirby. Dabei lernen wir alle Grundlagen und zahlreiche erweiterte Funktionen, um nach Abschluss des Kurses live gehen zu können. Mit dem erlernten Wissen wirst Du in der Lage sein, Dein Blog weiter anzupassen und eigene, neue Features zu entwickeln.

Unser Blog kommt mit allen Standards daher: einem gruppierten Bloglisting, Filter für Tags, einem Listing für Kategorien, Kommentaren, einem RSS-Feed, Open-Graph-Daten und automatisch erzeugten Vorschaubildern zum Teilen bei Mastodon und Bluesky.

Wir installieren das IndieConnector-Plugin, um automatisch bei Mastodon und Bluesky zu posten und Antworten zurück ins Blog zu holen.

Wir erstellen eine Blogroll, eine Sitemap und eine robots.txt. Wir werden Beiträge planen und automatisch veröffentlichen können. Alle geplanten Beiträge können wir als Kalender abonnieren.

Wir entwickeln das Blog lokal und lernen, wie wir Kirby installieren und aktualisieren, wie Plugins installiert werden und wie wir schließlich unser Blog online bekommen.

Du lernst

  • Installation via composer
  • Konfiguration des Systems
  • Templates und Blueprints
  • Snippets
  • Controller und deren Vererbung
  • Panel-Anpassungen
  • Panel-Felder
  • Panel Blueprint-Querys
  • Kirby Helper-Methoden
  • Filter und Gruppierungen
  • Paginierung und Breadcrumbs
  • Schreiben mit Markdown
  • Such-Funktionen
  • Entwicklung einfacher Plugins
  • page- und site-methods
  • Virtuelle Seiten
  • uvm.

Gut zu wissen

Auch wenn wir HTML und CSS schreiben werden, ist dieser Kurs kein Frontend-Entwicklungs-Kurs, der Fokus wird auf Kirby liegen. Wir schaffen aber eine solide Grundlage, die gut aussieht und die Du weiter verfeinern kannst.

Dieser Kurs wird kostenpflichtig zu einem fairen Preis angeboten werden. Du bekommst Zugriff auf einen geschlossenen Bereich und wirst die Möglichkeit haben, Fragen zu stellen, falls Du mal nicht weiterkommen solltest. Die Umsetzung erfolgt in Text- und Videoform in englischer Sprache (mit charmant deutschem Akzent).

Wenn das für Dich interessant klingt, trage Dich jetzt unverbindlich in den Newsletter ein und Du bekommst gelegentlich Updates zum Stand der Dinge und natürlich zum offiziellen Launch des Kurses.

Wann geht es los?
Ein wenig Zeit benötige ich noch, um Kapitel abzurunden und aufzunehmen. Ein genaues Datum werde ich bald bekanntgeben.

]]>
Ein eigener Mastodon-Reply-Button https://maurice-renck.de/de/learn/webdev/a-custom-mastodon-reply-button https://maurice-renck.de/de/@/page/xehjjqdl28d6qjyg Tue, 03 Mar 2026 16:00:00 +0100 Maurice Renck kirby-cms Mastodon ist unter anderem deshalb so toll, weil es dezentral ist. Allerdings kann genau das auch zu Frust führen; schon mal versucht, auf einen Mastodon-Post zu verlinken, damit jemand darauf reagieren kann?

Die Person wird mit hoher Wahrscheinlichkeit auf einer fremden Instanz landen und muss von dort erst einmal zur eigenen Instanz finden, um dort dann auf den Post reagieren zu können. Auch ein einfacher "Folgen"-Button wird so schnell zur Hürde.

Das haben auch andere erkannt und arbeiten an Lösungen, wie dem "Universal Follow Button", der vielleicht einmal kommen mag. Und während ich diesen Artikel fast fertig habe, erscheint der neue Share-Button von Mastodon, dazu später mehr.

Auf meiner Seite habe ich deshalb versucht, Dine zu vereinfachen. Leserinnen geben ihre Mastodon-Instanz ein und werden dann direkt zum passenden Post geleitet.

Zu (fast) jedem meiner Beiträge auf meiner Seite gibt es auch einen Mastodon-Post. Reaktionen auf diesen Post werden synchronisiert und unter dem Beitrag auf meiner Seite angezeigt, darum kümmert sich der [[/kirby/indieconnector|IndieConnector]].

Ich kann also automatisch auf diesen Post verweisen, würde dann aber auf das oben beschriebene Problem stoßen. User würden auf meine Instanz gelangen, nicht auf ihre, und könnten nicht direkt reagieren – suboptimal.

Statt den Post direkt zu verlinken, müssen meine Leserinnen erst einmal ihre Instanz eintragen und können sich entscheiden, ob diese Instanz (im Browser) gespeichert werden soll:

Das Formular ist schlicht aufgebaut:

<form action="/reply/mastodon" method="post">
    <input type="url" value="" name="instance" id="instance" placeholder="https://mastodon.instance" required>
    <input type="hidden" name="target" value="<?= $mastodonUrl ?>">
    <button type="submit" class="button-primary">Ok</button>

    <input type="checkbox" name="rememberInstance" id="rememberInstance" value="true">
    <label for="rememberInstance" class="remember">
        Remember
    </label>
</form>

Im Wesentlichen enthält das Formular zwei Daten:

  1. Die Instanz des Users
  2. Meine jeweilige Mastodon-Post-URL

Ein Klick auf den Button leitet dann zur Instanz weiter und zeigt dort meinen Post an, auf den dann direkt reagiert werden kann.

Ich löse das über eine [[/kirby|Kirby]]-Route, die Umsetzung funktioniert aber genauso gut mit einer einfachen PHP-Datei, die irgendwo aufrufbar ist.

So sieht die Route aus:

[
    'pattern' => 'reply/mastodon',
    'method' => 'POST',
    'action' => function () {
        $request = kirby()->request();
        $data = $request->data();

        if (!isset($data) || empty($data)) {
            return new Response('No POST data found', 'text/plain', 400); // Not Acceptable
        }

        $instanceUrl = Url::stripPath($data['mastodonInstance']);
        $url = $instanceUrl . '/authorize_interaction?uri=' . $data['target'];

        if ($data['rememberInstance'] == true) {
            // set cookie
            Cookie::set('mastodon_instance', $instanceUrl, [
                'lifetime' => time() + 60 * 60 * 24 * 365,
                'path' => '/',
                'httpOnly' => false,
            ]);
        }

        header::redirect($url);
    },
],

Der Code sähe ohne Kirby ähnlich aus (gekürzt):

$data = $_POST;

$parsedUrl = parse_url($data['mastodonInstance']);
$instanceUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'];

$url = $instanceUrl . '/authorize_interaction?uri=' . $data['target'];

if (!empty($data['rememberInstance'])) {
    setcookie(
        'mastodon_instance',
        $instanceUrl,
        [
            'expires'  => time() + 60 * 60 * 24 * 365,
            'path'     => '/',
            'httponly' => false,
        ]
    );
}

header('Location: ' . $url);

Hier ist es zudem sinnvoll, zu prüfen, ob die Daten leer sind und ob es sich um einen POST-Request handelt. Da viele Leserinnen hier vermutlich ein CMS nutzen werden, habe ich mich entschlossen, diese Details wegzulassen, weil sie wahrscheinlich sowieso vom System gehandhabt werden.

Die eigentliche Magie ist in einer Zeile versteckt und ziemlich simpel:

$url = $instanceUrl . '/authorize_interaction?uri=' . $data['target'];

Wir bauen aus den gegebenen Informationen eine neue URL zusammen, beginnend mit der eingegebenen Instanz (ohne eventuellen Pfad hinten dran) dem Pfad /authorize_interaction und dem uri-Parameter, der die volle URL zu unserem Mastodon-Post enthält.

Der Endpunkt /authorize_interaction macht genau das, was man vermuten könnte: Er erlaubt die Interaktion mit, in diesem Fall, einem Post. Durchlaufen wir den Flow, sind wir also bereits auf der eigenen Instanz und haben dort dann über den Endpunkt die Möglichkeit, auf den Post zu reagieren, egal auf welcher Instanz sich dieser befindet.

Das geht übrigens auch mit User-Profilen und ist praktisch für einen "Follow-Button", wie ihn der Dienst oben anbieten will.

Unsere Leserinnen müssen also im besten Fall nur einmal ihre Instanz hinterlegen und können dann ohne weitere Umstände mit unseren Mastodon-Inhalten interagieren, denn sie gelangen immer auf ihre eigene Instanz und sehen dort den jeweiligen Post oder das Profil. Wir haben ihnen das umständliche Suchen und Copy & Paste abgenommen.

Nun gibt es seit dem 2. März einen offiziellen Share-Button von Mastodon, den man sich einfach zusammenklicken kann:

https://blog.joinmastodon.org/2026/03/a-new-share-button/

Und obwohl ich die Idee gut finde, habe ich zwei Probleme damit:

  1. Er erzeugt einen neuen Beitrag
  2. Er streicht die Dezentralität

In meinem speziellen Use-Case ist es so, dass der IndieConnector nur Reaktionen von Posts abholt, die im Plugin hinterlegt sind. Es besteht eine 1:1-Beziehung zwischen Blogbeitrag und Mastodon-Post. Der Share-Button erzeugt aber immer neue Beiträge. Das ist zumindest nicht das, was ich will.
Er ist aber sicherlich für all diejenigen passend, die gerne möchten, dass Leserinnen schnell und einfach Zitate oder URLs als eigene Posts teilen können. Aber!

Alles läuft über share.joinmastodon.org. Dort müssen die User dann ihre Instanz eingeben und werden dann auf diese Instanz umgeleitet, um dort unter dem share-Endpunkt einen neuen Post zu erstellen.

Wir können uns denken, was im Hintergrund passiert: vermutlich genau das, was wir im oben beschriebenen PHP-Code machen.

Das ist an sich vollkommen okay, wäre da nicht eine Sache: Es gibt eine zentrale Schnittstelle. Jeder Share-Button oder -Link läuft nun über share.joinmastodon.org. Man möge mich pingelig nennen, aber genau das wollten wir ja mit Mastodon gerne vermeiden.

Nun ist nicht jeder Entwickler oder möchte sich irgendeinen, irgendwas tuenden PHP-Code vibe coden, dafür ist das zumindest ein guter Anfang. Wer aber kann, sollte vielleicht eher den oben beschriebenen Weg gehen. Dieser hat den Charme, dass unsere eigene Webseite die "zentrale" Schnittstelle ist, und auf der befindet sich die Leserin sowieso schon. Außerdem ist der /authorize_interaction Endpunkt kein reiner Mastodon-Endpunkt, sondern Teil von ActivityPub und kann also auch außerhalb von Mastodon benutzt werden.

Ich bin gespannt, wie der Follow-Button umgesetzt werden wird. Ihr habt jetzt jedenfalls ein paar einfache Zeilen PHP-Code und einen hilfreichen Endpunkt zur Hand, mit denen ihr all das schon jetzt ohne externe Services einbauen könnt. Viel Spaß!

]]>
Web&shy;entwicklung https://maurice-renck.de/de/learn/webdev https://maurice-renck.de/de/@/page/cnvayxeninntmr5e Tue, 03 Mar 2026 15:49:00 +0100 Maurice Renck Länder blockieren https://all-inkl.com/wichtig/anleitungen/skripte/sonstiges/.htaccess/laendersperre-mit-mod_geoip_636.html https://maurice-renck.de/de/@/page/9om2urnmty15ekyx Tue, 27 Jan 2026 13:55:00 +0100 Maurice Renck engine-room Da in den vergangenen Wochen AI-Crawler meine Seite überrennen und es nicht ausreicht, einzelne UserAgents zu blockieren, musste ich nun leider so weit gehen, Länder zu blockieren. Mein Hoster hat dafür eine einfache Beschreibung parat gehabt.

Ich wollte das eigentlich vermeiden, aber zu sehr aktiven Zeiten wird meine Seite dank Cache zwar zügig ausgespielt, das Kirby-Panel ist aber kaum mehr zu bedienen und endlos langsam. Maßgeblich betrifft das Anfragen aus Singapur, die ich nun leider abweisen muss.

]]>
Darum mag ich mein Kirby-Setup so https://maurice-renck.de/de/notes/2025/that-s-why-i-like-me-kirby-setup https://maurice-renck.de/de/@/page/wchltpf7dei7bcyw Thu, 21 Aug 2025 17:02:00 +0200 Maurice Renck kirby-cms Deshalb gefällt mir mein Kirby-Setup so gut: Ich wollte die letzten fünf Folgen unseres Podcasts und die letzten fünf Beiträge unseres Sociabli-Blogs zu meiner Homepage hinzufügen. Und alles, was ich tun musste, war, das hier zum Controller hinzuzufügen:

$serverSideStories = $site->rssPages('https://konzentrik.de/server-side-stories/feed')->sortBy('date', 'desc')->limit(5);
$sociabli = $site->rssPages('https://sociab.li/blog/feed.xml')->sortBy('date', 'desc')->limit(5);

Und dann auf der Startseite mein Textlisten-Snippet benutzen:

<?php snippet('list/textlist', ['articleList' => $sociabli, 'useLink' => true]); ?>

Das war's!

]]>
Notizen erstellen mit Signal https://maurice-renck.de/de/learn/built-with-kirby/notizen-mit-signal https://maurice-renck.de/de/@/page/psssna9zltgcwepz Fri, 01 Aug 2025 10:45:00 +0200 Maurice Renck kirby-cms In diesem Beitrag schreiben wir uns ein Skript, welches es uns ermöglicht, mit dem Signal-Messenger eine Notiz im Kirby-CMS zu erstellen.

Das hier ist ein Experiment. Alles, was ich in diesem Artikel beschreibe, ist mit Vorsicht zu genießen und als Proof of Concept zu verstehen.

In diesem Beitrag habe ich bereits erklärt, wie wir mit Kirby und Raycast eine neue Notiz anlegen und veröffentlichen können. Diesen Code nehmen wir nun als Grundlage und treiben das Ganze auf die Spitze.

Das Ziel unseres Experiments: Wir möchten uns in Signal eine Nachricht schicken und diese Nachricht soll als Notiz im Kirby-Blog online gehen. Dazu nutzen wir den bereits beschriebenen POSSE-Endpunkt.

Signal CLI

Damit wir Nachrichten abgreifen und etwas mit ihnen machen können, benötigen wir irgendwie Zugriff. Hier hilft uns das Signal-CLI-Projekt, für das es dankenswerterweise einen Wrapper gibt, der uns eine REST-API bereitstellt.

Einmal gestartet, können wir dieses Tool als neues Gerät in unserem Account registrieren. Deshalb hiermit noch einmal die Warnung: Das hier ist ein Experiment! Falsch konfiguriert, kann dieses Setup im schlimmsten Fall möglichen Angreifern Zugriff auf alle eure Nachrichten verschaffen!

Wir lassen Signal-CLI in einem Docker-Container laufen. Dazu legen wir ein docker-compose.yaml an:

services:
  signal:
    image: bbernhard/signal-cli-rest-api:latest
    container_name: signal-api
    environment:
      - MODE=native #supported modes: json-rpc, native, normal
    ports:
      - "8080:8080" #map docker port 8080 to host port 8080.
    volumes:
      - "./signal-cli-config:/home/.local/share/signal-cli"

Als Basis dient uns das Signal-CLI-Rest-Api Docker-Image. Viel müssen wir gar nicht tun, wir setzen ein paar Grundeinstellungen. Wichtig ist das Volume, hier werden die Metadaten abgelegt, wie beispielsweise der Hinterlegte Account. Das Verzeichnis sollte also unbedingt erhalten bleiben, damit wir nicht ständig ein neues Gerät hinzufügen müssen.

Mit folgendem Befehl können wir das Setup testen:

docker-compose up

Danach können wir im Browser folgende URL aufrufen:

http://localhost:8080/v1/qrcodelink?device_name=signal-api

Wir müssen unsere laufende CLI-Instanz nun zu den vertrauten Geräten hinzufügen. In der Signal App gehen wir dazu auf den eigenen Avatar und dann "Gekoppelte Geräte" → "Neues Gerät hinzufügen". Sobald wir den QR-Code im Browser gescannt haben, sind wir startklar.

Signal-CLI kann nun auf alle Nachrichten zugreifen. Damit können wir aber noch nichts anfangen. Wirbenötigenn noch eine Verknüpfung zwischen Signal-CLI und unserem POSSE-Endpunkt. Dazu schreiben wir uns ein kleines Typescript.

Das Skript

Dazu legen wir das Verzeichnis bot an und erstellen dort eine package.json, damit wir alle Abhängigkeiten parat haben:

{
  "name": "signal-bot",
  "version": "1.0.0",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "axios": "^1.6.0",
    "dotenv": "^16.3.1"
  },
  "devDependencies": {
    "typescript": "^5.4.0",
    "@types/node": "^22.0.0"
  }
}

Wie man sieht, hält sich der Umfang in Grenzen. Im nächsten Schritt legen wir eine tsconfig.json Datei an.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "types": ["node"]
  },
  "include": ["index.ts"]
}

Zuerst fangen wir damit an, die wichtigesten Eckdaten auszulesen. Wir verwenden Environment-Variablen.

import axios from "axios";

const SIGNAL_API = process.env.SIGNAL_API!;
const SIGNAL_NUMBER = process.env.SIGNAL_NUMBER!;
const TARGET_API = process.env.TARGET_API!;
const KIRBY_SECRET = process.env.KIRBY_SECRET!;

Wir benötigen den API-Endpunkt, den uns unser Docker-Container bereitstellt, unsere eigene Telefonnummer, den Kirby-POSSE-Endpunkt und das Kirby-Secret.

async function poll() {
  try {
    const res = await axios.get(
      `${SIGNAL_API}/v1/receive/${encodeURIComponent(SIGNAL_NUMBER)}`,
    );
    const messages = res.data;

    for (const msg of messages) {
      const sender = msg.envelope?.source;
      const destination =
        msg.envelope?.syncMessage?.sentMessage?.destinationNumber;
      const text =
        msg.envelope?.dataMessage?.message ||
        msg.envelope?.syncMessage?.sentMessage?.message;

      if (sender === SIGNAL_NUMBER && destination === SIGNAL_NUMBER && text) {
        console.log(`Forwarding message: ${text}`);
      }
    }
  } catch (error) {
    if (error instanceof Error) {
      console.error("Error polling messages:", error.message);
    } else {
      console.error("Unknown error:", error);
    }
  }
}

Wir beginnen damit, den Signal-Endpunkt aufzurufen. Er liefert uns eine Liste ungelesener Nachrichten zurück, die wir durchlaufen. Wir brauchen ein paar Daten, die wir uns holen:

  • Die Absenderin der Nachricht
  • Die Empfängerin der Nachricht
  • Den Text der Nachricht

Beim Text gibt es eine kleine Besonderheit. Da wir den Text an uns selbst schicken werden (Notiz an mich), wird dieser Text sofort als gelesen markiert werden und wir finden den Text dann nicht mehr unter envelope?.dataMessage?.message wie es normalerweise der Fall wäre, sondern unter envelope?.syncMessage?.sentMessage?.message. Um ganz sicher zu sein, fragen wir beide Werte ab, ist ersterer leer, fallen wir auf die verschickten Daten zurück.

Da wir auf jede Nachricht reagieren und damit jede Nachricht in jedem Chat eine Notiz in Kirby erzeugen würde, müssen wir zunächst die passenden Nachrichten filtern:

if (sender === SIGNAL_NUMBER && destination === SIGNAL_NUMBER && text)

Diese Abfrage sorgt dafür, dass wir nur auf Nachrichten reagieren, die von unserer eigenen Nummer an unsere eigene Nummer gehen und die einen Text haben. Damit sind wir einigermaßen abgesichert.

Jetzt füllen wir den if-Block. Darin wollen wir den API-Request in Richtung Kirby absetzen:

let skipUrl = false;
let ext = false;
let autopublish = true;

// Find and remove hashtags, set flags
let parsedText = text
  .replace(/#skipUrl\b/gi, (_match: any) => {
    skipUrl = true;
    return "";
  })
  .replace(/#ext\b/gi, (_match: any) => {
    ext = true;
    return "";
  })
  .replace(/#draft\b/gi, (_match: any) => {
    autopublish = false;
    return "";
  })
  .trim();

await axios.post(
  TARGET_API,
  {
    posttext: parsedText,
    externalPost: ext,
    skipUrl,
    autopublish,
  },
  {
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${KIRBY_SECRET}`,
    },
  },
);

Zunächst setzen wir ein paar Standardwerte. Die haben wir im bereits erwähnten POSSE-Beitrag festgelegt. Sie ermöglichen es uns, Kirby zu steuern.

  • skipUrl legt fest, ob beim Posten zu Mastodon ein Link zur Notiz angehangen werden soll.
  • ext legt fest, ob überhaupt zu Mastodon oder Bluesky gepostet werden soll.
  • autopublish legt fest, ob die Notiz sofort veröffentlicht oder als Entwurf abgelegt werden soll.

Da wir im Signal-Chat keine passenden Buttons haben, verwenden wir Hashtags: #skipUrl, #ext und #draft.

Wir suchen nach diesen Hashtags im Text, ignorieren dabei Groß- und Kleinschreibung und setzen die entsprechenden Variablen. Schließlich entfernen wir die Hashtags aus dem Text, so dass sie in der Notiz nicht mehr vorkommen.

Ich habe autopublishing aktiviert und deaktivieren es gezielt mit dem Hashtag #draft. Das lässt sich bei Bedarf natürlich auch umkehren.

Schließlich setzen wir den API-Request in Richtung Kirby ab. Wie man sieht, enthält er alle notwendigen Felder und unser Secrect im Header.

Damit sind wir fast fertig. Wir müssen unsere Methode nur noch regelmäßig aufrufen, um nach neuen Nachrichten zu schauen:

setInterval(poll, 10_000);

Docker Compose

Bevor wir fertig sind, müssen wir unser docker-compose.yml noch anpassen. Wir wollen unseren Bot zusammen mit Signal-CLI hochfahren. Dazu braucht unser Bot zunächst ein Dockerfile:

FROM node:22-alpine

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

CMD ["node", "dist/index.js"]

Wir benutzen das node-alpine Image als Grundlage, installieren alle notwendigen Pakete, bauen unser Skript und starten es.

Hier lässt sich sicherlich noch optimieren und Platz sparen, aber für unser Experiment passt das.

Nun müssen wir noch unser docker-compose.yml erweitern. Unter dem bereits vorhanden Signal-Code ergänzen wir:

services:
    signal:
        # see code above
    bot:
        build: ./bot
        container_name: signal-bot
        environment:
          - SIGNAL_API=http://signal:8080
          - SIGNAL_NUMBER=+491234567890
          - TARGET_API=https://my-blog.tld/posse
          - KIRBY_SECRET=abc-def-ghi
        depends_on:
          - signal
        restart: unless-stopped

Hier setzen wir unsere Environment-Variablen. Für den Signal-Endpunkt benutzen wir den Containernamen. Wir müssen noch unsere Telefonnummer mit Ländervorwahl angeben und natürlich unseren Kirby-POSSE-Endpunkt und -Secret.

Unser Bot wartet darauf, dass die Signal-CLI vorhanden ist und fährt erst dann hoch.

Damit unser Bot gebaut wird, müssen wir bei Code-Änderungen diesen Befehl aufrufen:

docker compose up --build

Danach langt es, docker-compose aufzurufen:

docker-compose up

Sollen die Container im Hintergrund laufen, können wir einfach -d ergänzen:

docker-compose up -d

Fertig!

Jetzt können wir eine Nachricht an uns selbst schicken:

Unser Skript greift diese Nachricht auf:

Sendet sie an Kirby und publiziert die Notiz:

Und das IndieConnector-Plugin schickt sie weiter an Mastodon, weil wir externes Posten aktiviert haben:

Jetzt können wir in alter Twitter-Manier vom Telefon aus in die eigenen Notizen posten und nach Belieben weiter verteilen.

Abschließend sei noch angemerkt, dass alle Nachrichten, die wir an uns selbst schicken, veröffentlicht werden. Wer die Notiz an mich Funktion von Signal anderweitig nutzt, sollte die Logik etwas erweitern und einen weiteren Hashtag fürs Posten einfügen. So könnte #posse den Ablauf auslösen und alle anderen Nachrichten ignoriert werden. Vielleicht eine gute Idee, um versehentliche Notizen zu vermeiden.

Es sei noch einmal erwähnt, dass es sich hier um ein Experiment handelt, das zeigen soll, was wir alles mit unseren Blogs anstellen können. Wer das Skript in der Form betreiben will, sollte sich im Klaren darüber sein, welche Sicherheitsrisiken durch den Betrieb von Signal-CLI entstehen, besonders wenn der API-Endpunkt öffentlich verfügbar gemacht wird.

Dennoch: Viel Spaß beim Ausprobieren!

]]>
Spieleentwicklung https://konzentrik.de/de/server-side-stories/se01/spieleentwicklung-mit-max-knoblich https://maurice-renck.de/de/@/page/a3rdhtbpgjwsvjgv Thu, 24 Jul 2025 11:17:00 +0200 Maurice Renck gamedev Auf dieses Gespräch habe ich mich besonders gefreut, weil ich gerade selbst mitten im Thema stecke (dazu später mal mehr). Max war bei uns im Podcast zu Gast und hat mit uns über Spieleentwicklung gesprochen. Kürzlich ist sein Spiel "Savanna Sam" erschienen und er hatte Einiges über dessen Entwicklung zu erzählen.

]]>
Mit Raycast bei Kirby und Mastodon posten https://maurice-renck.de/de/learn/built-with-kirby/raycast-kirby-posse https://maurice-renck.de/de/@/page/Qa2bgqLrSVXBNZks Tue, 10 Jun 2025 09:00:00 +0200 Maurice Renck kirby-cms, indieweb In dieser Anleitung entwickeln wir ein Raycast-Plugin, mit dem wir vom Desktop aus eine Notiz erstellen können, die nicht nur auf der eigenen Seite, sondern auch bei Mastodon und/oder Bluesky veröffentlicht wird.

Mit nur einem Tastenkürzel werden wir jederzeit Zugriff auf das Formular in Raycast haben. Es wird ausreichen, einen Texte einzugeben und das Formular abzuschicken, um im Kirby-CMS eine Notiz oder ein Lesezeichen zu erstellen und diese von Kirby aus auf andere Plattformen zu verteilen (POSSE).

POSSE

POSSE ist in diesem Fall keine komische Darbietung, sondern steht für: Publish (on your) Own Site, Syndicate Elsewhere. Es geht also darum, immer zuerst auf der eigenen Seite zu veröffentlichen und von dort zu verteilen.

Auf dem Mac werden wir dafür Raycast benutzen. Raycast erlaubt es uns, schnell Befehle auszuführen, diesen Befehlen Tastenkürzel und einen Alias zuzuweisen.

Ich möchte auf meinem Mac die Tastenkombination SUPER+N drücken, um dieses Fenster zu öffnen und eine neue Notiz zu erstellen:

Hier kann ich einen kurzen Text eingeben, wenn ich ein Lesezeichen erstellen möchte, eine URL, einen optionalen Seitentitel und schließlich kann ich noch festlegen, ob der Beitrag sofort veröffentlicht werden soll, ob er ebenfalls bei Mastodon gepostet werden soll und ob eine URL an den Post bei Mastodon gehangen werden soll.

Was benötigen wir?

  • Ein Kirby-Plugin, das eine Route bereitstellt, die eine neue Notiz anlegt
  • Ein Raycast-Plugin, welches eine schnelle Eingabe erlaubt und die Kirby-Route aufruft
  • Das IndieConnector-Plugin, welches den POSSE-Teil übernimmt

Grundlagen

Auf meiner Webseite unterscheide ich zwischen zwei Arten von Notizen:

  1. Die einfache Textnotiz
  2. Ein Lesezeichen

Beide benutzen unterschiedliche Templates und verhalten sich auf der Seite unterschiedlich.

Um Beiträge automatisch bei Mastodon zu posten, verwende ich das IndieConnector-Plugin. Die Installation erfolgt in unserem Beispiel über Composer. Im Hauptverzeichnis von Kirby rufen wir auf:

composer require mauricerenck/indieconnector

Damit ist der IndieConnector installiert und kann konfiguriert werden. Dazu wechseln wir in die Datei sites/config/config.php und nehmen folgende Einstellungen vor:

'mauricerenck.indieConnector' => [
    'secret' => 'my-secret',
    'sqlitePath' => './content/.db/',
    'stats' => [
        'enabled' => true,
    ],

    'post' => [
        'prefereLanguage' => 'en',
        'allowedTemplates' => ['bookmark', 'note'],
        'textfields' => ['text'],
    ],

    'mastodon' => [
        'enabled' => true,
        'instance-url' => 'https://example.com',
        'bearer' => 'my_bearer',
    ],
],

Als Erstes setzen wir ein Secret, das an verschiedenen Stellen im Plugin gebraucht wird, um Routen und Webhooks abzusichern.

Wir wollen die Panel-Statistiken und später ggf. ein paar andere Funktionen nutzen, die eine Datenbank benötigen, deshalb müssen wir einen Pfad konfigurieren, in dem die Datenbank abgelegt werden kann.

Als Nächstes konfigurieren wir die allgemeinen Einstellungen, um zu Mastodon und anderen Services posten zu können. Ich setze die bevorzugte Sprache noch auf Englisch, weil ich in Deutsch und Englisch schreibe und obwohl Deutsch die Standardsprache in Kirby ist, poste ich auf Englisch.
Wie oben beschrieben, habe ich zwei Templates für meine Notizen, nur diese beiden Templates sollen ein Posting bei Mastodon auslösen. Also setze ich allowedTemplates auf diese beiden Templatenamen. Abschließend sage ich dem Plugin noch, in welchem Feld mein Text gespeichert ist. In unserem Beispiel text.

Jetzt noch schnell Mastodon konfigurieren. Hierzu setzen wir die Instanz und einen API-Token, den man unter https://example.com/settings/applications erzeugen kann. Die neue App benötigt mindestens die folgenden Scopes:

  • read
  • write:media
  • write:statuses

Den erzeugten Token sollten wir irgendwo ablegen, wo er sicher ist.

Ab sofort wird das IndieConnector-Plugin einen Mastodonbeitrag erstellen, sobald wir eine neue Notiz in Kirby veröffentlichen.

Raycast Plugin

Raycast-Erweiterungen werden in TypeScript geschrieben, die UI-Komponenten in React. Die Erweiterung, die wir schreiben werden, besteht aus einer Mischung. Wir benötigen einerseits das Formular, in dem wir den Beitrag schreiben können und wir müssen diesen Beitrag natürlich in Richtung Kirby schicken.

Ich werde hier nicht erklären, wie man eine neue Erweiterung erstellt, das lässt sich hier nachlesen und ist auch relativ leicht. Ich gehe an dieser Stelle davon aus, dass es bereits ein Dummy-Plugin gibt. Ich veröffentliche außerdem meinen Code bei GitHub.

Sobald wir die Entwicklung mit npm run dev starten, taucht das neue Plugin bei Raycast auf und kann getestet werden.

Wir beginnen mit dem simplen Teil, dem Formular. Wir legen ein neues Command an, wenn das bisher nicht geschehen ist, und geben das Formular aus:

export default function Command() {
return (
    <Form
      actions={
        <ActionPanel>
          <Action.SubmitForm onSubmit={handleSubmit} />
        </ActionPanel>
      }
    >
      <Form.TextArea id="posttext" title="Post Text" placeholder="Enter your text" enableMarkdown={true} />
      <Form.TextField id="url" title="Bookmark" placeholder="https://" value={url} onChange={setUrl} />
      <Form.TextField id="title" title="Titel" placeholder="Titel" />
      <Form.Separator />
      <Form.Checkbox id="autopublish" title="Autopublish" label="Autopublish" defaultValue={preferences.autopublish} />
      <Form.Checkbox id="skipUrl" title="Skip URL" label="Skip URL" defaultValue={preferences.skipurl} />
      <Form.Checkbox id="externalPost" title="External Post" label="External Post" defaultValue={preferences.posse} />
    </Form>
  );
}

Das sorgt für eine Darstellung wie oben im Screenshot. Woher die defaultValues kommen, erkläre ich gleich. In der Form kannst du sehen, dass beim Abschicken handleSubmit() aufgerufen wird. Hier findet der eigentliche API-Call statt:

  async function handleSubmit(values: Values) {
    try {
      const baseUrlNoTrailingSlash = preferences.baseurl.replace(/\/$/, "");
      const response = await fetch(`${baseUrlNoTrailingSlash}/posse`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${preferences.secret}`,
        },
        body: JSON.stringify(values),
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const responseData = (await response.json()) as ApiResponse;
      await Clipboard.copy(responseData.url);

      const status = responseData.status == "draft" ? "Posted as draft" : "Published your post";

      showToast({ title: status, message: "The URL has been copied to your clipboard" });
      await delay(1000);
      await closeMainWindow();
    } catch (error) {
      console.error(error);
      showToast({ style: Toast.Style.Failure, title: "Error", message: "Failed to submit form" });
    }
  }

Wir rufen die API mit fetch auf und greifen dabei auf zwei Werte zu, die später in den Einstellungen der Erweiterung festgelegt werden können, der Basis-URL und einem Secret. Auf diese Weise vermeiden wir das Hardcodieren privater Daten im Quellcode.

Wir setzen einen POST-Call ab und reichen diesem 1:1 die Werte rein, die wir aus dem Formular bekommen.

Läuft etwas schief, erzeugen wir einen Fehler, der unten im catch Block für eine Fehlermeldung unter dem Formular sorgt.

Läuft alles gut, lesen wir die Antwort der API aus, diese wird eine URL zum neuen Beitrag enthalten, welche wir prompt in die Zwischenablage kopieren, dem User das mitteilen und dann nach einer Sekunde das Fenster schließen.

Ich habe noch eine kleine Feinheit ergänzt, die beim Aufruf der Erweiterung in Form eines Hooks stattfindet:

  const [url, setUrl] = useState("");

  // Read clipboard when the form is opened
  useEffect(() => {
    async function fetchClipboard() {
      const text = await Clipboard.readText();

      if (text) {
        if (text.startsWith("http")) {
          setUrl(text);
        }
      }
    }

    fetchClipboard();
  }, []);

Sobald das Formular angezeigt wird, lese ich die Zwischenablage aus und prüfe, ob sie einen Link enthält. Wenn dem so ist, setze ich den lokalen State url. Ein Blick in den Code des Formulars zeigt, dass diese URL dann als Value für das Bookmark-Feld gesetzt wird. Denn oftmals kopiere ich eine URL im Browser, um sie dann direkt zu teilen. Mit dieser kleinen Feinheit spare ich mir dann ein paar Tastenkürzel.

React Hooks

React Hook werden in bestimmten Situationen aufgerufen. In diesem Fall wird der Hook beim Rendern des Formular aufgerufen und der enthaltene Code ausgeführt.

React States

Ein React State wird immer mit einem Variablennamen und einem Setter definiert. Die Variable wird künftig nur über den Setter aktualisiert. Überall dort, wo die Variable verwendet wird, wird automatisch ein Update/Render ausgelöst, wenn sich deren Wert ändert.

Damit könnten wir jetzt eigentlich arbeiten, noch fehlen uns aber alle Werte aus den Einstellungen, also alles, was unter preferences gespeichert ist. Hier der passende Typ zu besseren Übersicht:

interface Preferences {
  secret: string;
  baseurl: string;
  autopublish: boolean;
  posse: boolean;
  skipurl: boolean;
}

Bevor wir etwas anderes im Command tun, holen wir uns die Einstellungen:

  const preferences = getPreferenceValues<Preferences>();
  const markdown = "You need to provide a secret in the extension preferences.";
  if (!preferences.secret) {
    return (
      <Detail
        markdown={markdown}
        actions={
          <ActionPanel>
            <Action title="Open Extension Preferences" onAction={openExtensionPreferences} />
          </ActionPanel>
        }
      />
    );
  }

Einstellungen

Einstellungen können auf zwei Ebenen abgelegt werden, die wir beide nutzen:

  1. Global für alle Commands
  2. Für ein spezielles Command

Die Preferences werden in der package.json definiert.

baseUrl und secret werden wir global ablegen. Sollten wir einmal weitere API-Calls hinzufügen, benötigen wir sie dort auch. Alle anderen Einstellungen brauchen wir nur im Formular und werden sie deshalb nur für das Command ablegen.

Auf oberster Ebene der package.json legen wir einen neuen Eintrag an und definieren die Werte:

  "preferences": [
    {
      "name": "secret",
      "title": "Secret key",
      "description": "Enter your API token",
      "type": "password",
      "required": true
    },
    {
      "name": "baseurl",
      "title": "Base URL",
      "description": "Enter your API endpoint",
      "type": "textfield",
      "required": true
    }
  ],

Rufen wir die Erweiterung das erste Mal auf, müssen wir zunächst Werte für diese globalen Einstellungen eingeben. Damit sind diese auf jeden Fall gesetzt.

Nun definieren wir Einstellungen, die für unser Command gelten und für mögliche weitere Kommandos nicht von Nutzen sind:

"commands": [
    {
      "name": "new-note",
      "title": "New Note",
      "description": "Creates a new note",
      "mode": "view",
      "preferences": [
        {
          "name": "autopublish",
          "title": "Autopublish",
          "description": "Automatically publish the note",
          "type": "checkbox",
          "default": false
        },
        {
          "name": "skipurl",
          "title": "Skip URL",
          "description": "Skip URL",
          "type": "checkbox",
          "default": true
        },
        {
          "name": "posse",
          "title": "POSSE",
          "description": "Post to other platforms",
          "type": "checkbox",
          "default": true
        }
      ]
    }
],

Dieses Mal sind wir nicht ganz so streng. Alle Einstellungen habe einen Standardwert, sie müssen also nicht zwingen konfiguriert werden. Wir benutzen diese Einstellungen für die drei Checkboxen im Formular. So muss ich beispielsweise nicht jedes Mal anklicken, dass mein Beitrag automatisch veröffentlicht werden soll.

Im Formular greifen wir bereits mit defaultValue={preferences.posse} auf die Werte zu.

Sobald du npm run dev beendest, steht das Plugin dir normal in Raycast zur Verfügung und du kannst es wie andere Plugins auch nutzen.

Damit sind wir startklar. Uns fehlt allerdings noch die Empfangsseite in Form einer Kirby-Route.

Die Kirby-Route

Wir haben mehrere Möglichkeiten, die Kirby-Route anzulegen:

  1. Direkt in der config.php
  2. Als Plugin

Da der Code identisch ist, überlasse ich diese Entscheidung dir. Ich habe ein Plugin, in dem solche speziellen Routen sammle, damit meine config.php übersichtlich(er) bleibt. Der Code ist aber in beiden Fällen derselbe.

'routes' => [
    [
        'pattern' => 'posse',
        'method' => 'POST',
        'action' => function () {
            // FOLLOWING CODE
        }
    ]
]

Die Route soll unter /posse aufgerufen werden, und zwar als POST-Request. Bevor wir etwas machen, prüfen wir, ob der Token korrekt ist. Wir holen uns die Daten aus der Anfrage, die im POST-Request übermittelt wurden. Das machen wir mit den Kirby Request-Methoden1. Dort schauen wir, ob ein Authorization Header mit unserem Token vorhanden ist:

$request = kirby()->request();
$requestData = $request->data();

$authHeader = $request->header('Authorization');
$token = $authHeader ? explode(' ', $authHeader)[1] : null;

if ($token !== option('mauricerenck.posse.token', '')) {
    return new Response('Unauthorized', 'text/plain', 401);
}

In der config.php müssen wir nun unser Token festlegen. Stimmt das Token aus der URL nicht überein, brechen wir sofort ab und geben eine entsprechende Rückmeldung. Um ganz sicherzugehen, setzen wir den Standardwert des Tokens auf einen leeren Text. Stimmt das Token, läuft das Skript weiter.

Damit wir eine neue Notiz anlegen können, benötigen wir erst einmal eine Seite, unter der wir dies machen können. In meinem Fall gibt es die Seite notes, die wiederum in Jahre unterteilt ist. Wir versuchen uns, diese Seite zu holen, und sollte sie nicht gefunden werden, brechen wir sofort ab:

$year = date('Y');
$parent = kirby()->page('notes/' . $year);

if (is_null($parent)) {
    return new Response('Not Found', 'text/plain', 404);
}

Im folgenden Verlauf können wir sicher sein, dass alle Voraussetzungen erfüllt sind.

Im nächsten Schritt schnappen wir uns die übermittelten Werte und setzen nötige Variablen und fangen dabei gleich möglicherweise fehlende Werte ab:

$template = 'note';
$autoPublish = isset($requestData['autopublish']) && filter_var($requestData['autopublish'], FILTER_VALIDATE_BOOLEAN);

$newData = [
    'title' => empty($requestData['title']) ? 'Bookmark ' . date('Y-m-d') : trim($requestData['title']),
    'text' => !empty($requestData['posttext']) ? trim($requestData['posttext']) : '',
    'icSkipUrl' => isset($requestData['skipUrl']) && filter_var($requestData['skipUrl'], FILTER_VALIDATE_BOOLEAN),
    'enableExternalPosting' => isset($requestData['externalPost']) && filter_var($requestData['externalPost'], FILTER_VALIDATE_BOOLEAN)
];

In meinem Beispiel unterscheiden wir zwischen zwei Templates, einer einfachen Textnotiz note und einem Lesezeichen bookmark. Zunächst setzen wir das Standard-Template note.

Wir empfangen alle Daten als String, das trifft auch auf Boolesche Werte zu. Mit $autoPublish = isset($requestData['autopublish']) && filter_var($requestData['autopublish'], FILTER_VALIDATE_BOOLEAN); können wir den Wert sicher in eine echte Boolesche Variable umwandeln. Das machen wir für alle passenden Werte.

Unsere Kirby-Seite benötigt einen Titel. Wurde in Raycast kein Titel eingeben, setzen wir einen generierten Titel.

Der eigentliche Text wird im Normalfall vorhanden sein, aber wir stellen trotzdem sicher, dass zumindest ein leerer Text hinterlegt wird.

Wir setzen außerdem zwei Werte des IndieConnectors, nämlich ob die URL zur Notiz an den Mastodon-Post gehängt werden soll und ob wir überhaupt bei Mastodon (oder Bluesky) posten wollen.

Danach prüfen wir, ob eine gültige URL übermittelt wurde. Wenn ja, unterscheide ich an dieser Stelle zwischen einer reinen Textnotiz und einem Lesezeichen. Das spiegelt sich im Titel, dem Link und dem Template wider:

if (!empty($url) && V::url($url)) {
    $newData['title'] = 'Link: ' . $newData['title'];
    $newData['link'] = $url;
    $template = 'bookmark';
}

Bevor wir gleich die Seite anlegen, müssen wir noch eine Sache sicherstellen: Wir müssen Duplikate vermeiden. Erstellen wir eine Notiz mit dem Titel test und tun dies ein zweites Mal, wirft uns Kirby ein Duplikat um die Ohren und legt die zweite Seite nicht an, weil sie die erste überschreiben würde. Das müssen wir also abfangen:

$slug = Str::slug($newData['title']);
$unusedSlug = false;
while ($unusedSlug === false) {
    $unusedSlug = is_null($parent->childrenAndDrafts()->find($slug));
    if (!$unusedSlug) {
        $slug = $slug . '-' . uniqid();
    }
}

Wir erzeugen den Slug für die neue Seite, dieser entspricht später dem Ordnernamen. Dann durchlaufen wir eine Schleife und prüfen darin, ob es bereits eine Seite mit diesem Slug gibt. Wenn ja, ergänzen wir eine ID. Beim zweiten Durchlauf sollte dann keine Seite mehr gefunden werden und wir sind auf der sicheren Seite. Sollte wider Erwarten doch eine Seite existieren, läuft die Schleife so lange, bis das nicht mehr der Fall ist.

Jetzt können wir endlich die neue Seite erstellen. Dazu benötigen wir die entsprechenden Berechtigungen, die wir uns mit impersonate() holen:

kirby()->impersonate('kirby');
$newPage = Page::create([
    'parent'  => $parent,
    'slug'     => $slug,
    'template' => $template,
    'content' => $newData
]);

Neue Seiten sind zunächst immer ein Entwurf. Wenn wir bei Raycast angegeben haben, dass die Seite sofort publiziert werden soll, müssen wir das jetzt noch tun:

if ($autoPublish === true) {
    $newPage->changeStatus('listed');
}

Wir sind fast fertig. Ganz zum Schluss wollen wir Raycast noch das Ergebnis mitteilen:

$response = [
    'url' => $newPage->url(),
    'status' => $setToPublished ? 'published' : 'draft',
];

return new Response(json_encode($response), 'application/json', 201);

Anhand dieser Antwort zeigen wir in Raycast eine Erfolgs- oder Fehlermeldung an. Und kopieren die URL der Notiz in die Zwischenablage.

Verfeinerungen

Mit unserem können wir jetzt in Windeseile neue Notizen erzeugen und diese auch direkt als Mastodon- oder Bluesky-Beitrag veröffentlichen und das mit einer überschaubaren Code-Basis.

Wer mag, kann jetzt noch in die Verfeinerung gehen. Wir könnten uns etwa Antworten bei Bluesky zurück in die Notizen holen. Dazu werde ich demnächst einen Beitrag veröffentlichen, denn der IndieConnector kann das bereits.

Den Quellcode für die komplette Kirby-Route findest du hier:

Kirby POSSE Route

Kirby POSSE Route. GitHub Gist: instantly share code, notes, and snippets.

Den Quellcode für das Raycast-Plugin findest du hier:

GitHub - mauricerenck/raycast-posse: Create a new Kirby page directly from Raycast

Create a new Kirby page directly from Raycast. Contribute to mauricerenck/raycast-posse development by creating an account on GitHub.

Viel Spaß beim Posten!

Ich habe mich zunächst dagegen entschieden, beide Plugins offiziell zu veröffentlichen, weil gerade aufseiten von Kirby die Anpassungen sehr individuell sein können. Sollte aber Bedarf bestehen, schreib mir einen Kommentar!

]]>
Zwei Kirby Plugins https://maurice-renck.de/de/notes/2025/two-kirby-plugins https://maurice-renck.de/de/@/page/IYqmd441ZmTrfDoB Tue, 13 May 2025 18:09:00 +0200 Maurice Renck kirby-cms Eigentlich bin ich gerade mitten in den Arbeiten an einem großen Feature für den IndieConnector, dennoch habe ich in den vergangenen Tagen zwei weitere Kirby-Plugins veröffentlicht.

Da hätten wir einerseits das Bloggerrolle-Plugin, welches den Dienst anpingt und zum anderen ein Kirby-Plugin zum automatischen Veröffentlichen von Seiten, was wir bei konzentrik für uns entwickelt haben. Das war im Grunde schon fertig und brauche nur noch ein paar Vorbereitungen, damit die Releases auch funktionieren.

(Und irgendwie bin ich noch sehr unzufrieden darüber, wie die Detailseiten meiner Projekte aussehen)

]]>
Fetching Responses https://maurice-renck.de/de/blog/2025/fetching-responses https://maurice-renck.de/de/@/page/MmSzUF82MKkuwlZ3 Sun, 27 Apr 2025 18:51:00 +0200 Maurice Renck kirby-cms, indieweb Vor einigen Tagen schrieb ich bei Mastodon von Problemen mit dem IndieConnector und brid.gy

Maurice (@mauricerenck@mastodon.online)

There seems to be something up with the #indieConnector in combination with brid.gy. All webmentions fall back to the type „mention-of" instead of, for example, „like-of". I am pretty sure the webmention source (generated by brid.gy) previo…

Ich erwähnte dort, dass ich schon lange vorhabe, externe Antworten, Likes und Reposts selbst zu holen. Das ist allerdings nicht ganz so trivial, wie es auf den ersten Blick scheint.

Leider bin ich bisher nicht dahintergekommen, warum das Einsammeln von Antworten über brid.gy nicht mehr so funktioniert. Aktuelle Logs kann ich mir nicht ansehen, weil sie mit Fehlermeldungen abbrechen, bevor eine Webmention geschickt wird.

Es ist also doch Zeit, dieses Feature in Angriff zu nehmen und es bei GitHub von der Upcoming-Feature-Liste zu streichen. Und genau das habe ich getan.

Postet man automatisch zu Mastodon oder Bluesky, speichert das IndieConnector-Plugin (IC) schon die URLs zu den Posts. Im Falle von Mastodon kann man zudem selbst eine URL hinterlegen (das wird es dann auch für Bluesky geben).

Das sind schon einmal gute Voraussetzungen für das Feature, denn IC weiß somit, wo es nachschauen kann, um Reaktionen einzusammeln. Mastodon stellt bereitwillig drei Endpunkte bereit, um an Likes, Reposts und Replies zu kommen – wunderbar.

Es gibt schon ein paar Lösungen und Beschreibungen, wie man das mit JavaScript lösen kann. Die funktionieren gut, für die Plugins muss (und will) ich jedoch den PHP-Weg gehen.

Der IC geht alle URLs durch, holt sich die Antworten und schickt dann eine Webmention an sich selbst. Jede Response hat eine virtuelle Seite mit entsprechenden Microformats. Von hier an läuft der gewohnte Webmention-Ablauf ab. Ein Grund, warum ich diesen Weg gewählt habe. Auf diese Weise muss ich wirklich nur das Abholen bauen und nicht noch einen weiteren Weg öffnen, um Daten abzulegen usw.

Anders als bei der JavaScript-Lösung muss ich mir merken, welche Reaktionen ich schon verarbeitet habe, damit ich nicht ständig dieselben Webmentions verschicke und unnötige Last auf den Systemen erzeuge. Ich merke mir also für jeden Mastodon-Endpunkt die letzte bekannte Response-ID und beende dann den Ablauf, sobald die erreicht ist.

Das klappt bisher super.

Seit ein paar Stunden sitze ich nun aber hier und grübele, warum diese Webmentions nicht ankommen wollen. Verschickt werden sie. Und so cool ich diese ganze Technik finde, asynchrone Prozesse zu debuggen ist und bleibt fummelig. Ich versuche einzelne Schritte mit Unit-Tests abzudecken, aber manchmal – wie gerade – sitze ich dann trotzdem da und schaue fragend auf den Bildschirm.

Ich hoffe, dass ich das Problem schnell gelöst bekomme. Dann hätte ich den kompletten Ablauf für Mastodon schon fertig und müsste für Bluesky "nur" noch das Abholen bauen (denn danach läuft es genauso weiter wie bei Mastodon).

Update
Ich habe eine falsche (alte) URL für meinen Webmention-Endpunkt benutzt und alle Webmentions ins Nirvana geschickt, deshalb kamen sie nicht an 🤦🏼‍♂️

]]>
Mein kaputter RSS-Feed https://maurice-renck.de/de/blog/2025/my-broken-rss-feed https://maurice-renck.de/de/@/page/16ENhGdvX3s5TFQo Wed, 02 Apr 2025 15:15:00 +0200 Maurice Renck engine-room Feedbin war wegen einer Kleinigkeit nicht in der Lage meinen RSS-Feed zu lesen. Ein spezielles Blog-Feature half mir das Problem zu lösen.

Anfang des Jahres habe ich hier davon erzählt, dass ich von Feedly zu Feedbin gewechselt bin. Ich bin weiterhin sehr zufrieden mit Feedbin, habe dort zahlreiche Blogs und andere Seiten abonniert – meine eigene auch.

Vor einigen Tagen fiel mir dann etwas auf: Ich hatte einen neuen Beitrag veröffentlicht, ihn aber gar nicht bei Feedbin gesehen. Seltsam. Vielleicht habe ich einfach übersehen? Das lässt sich leicht nachprüfen. In der Liste aller Abos suchte ich nach meinem Namen und ließ mir die Details zu meinem Feed anzeigen:

Anders als hier im Screenshot zu sehen, stand dort allerdings, dass mein letztes Update im Feed zwei Jahre zurückliegt. Entweder ist die Zeit wie im Fluge vergangen, oder etwas stimmte mit meinem Feed nicht.

Im W3C-Validator sah alles gut aus, keine Fehler, nur ein paar Warnungen wegen relativer Anker-Links. Ich verwende außerdem einen freien Dienst, der mich per Mail informiert, wenn mein Feed defekt ist (ich komme gerade einfach nicht auf den Namen …).

Ich entschied mich, ein kleines Feature zu nutzen, über das ich gar nicht reden darf. Ich veröffentliche einen Blogpost, zeigte ihn aber ausschließlich im RSS-Feed an. Dort berichtete ich von meinen Problemen und bat um Kommentare.

Ich kam mir sehr clever vor, denn nur Abonnent:innen meines RSS-Feeds können diesen Beitrag sehen und kommentieren. Wer kommentiert, empfängt also eindeutig meinen Feed.

Es wurde fleißig kommentiert! Mein Feed war also lesbar und viele der Kommentare bestätigten mir dies. Das half mir sehr, vielen Dank an alle, die sich gemeldet haben!

Irgendwas musste Feedbin also an meinem Feed stören. Ich prüfte erneut im Detail das XML. Alles sah okay aus, Tags mit Datum waren gesetzt, das Datum im richtigen Format usw. Alles sah gut aus. Aber etwas musste meinen Feed von anderen unterscheiden!

Nach kurzem Grübeln vermutete ich, dass eine ganze bestimmte Zeile schuldig sein könnte: mein Stylesheet.

Ruft man einen RSS-Feed im Browser auf, wird er entweder direkt heruntergeladen oder man sieht den Quelltext, mit dem man wenig anfangen kann. Es gibt aber die Möglichkeit (wie bei HTML) Styles einzubetten, sodass der Feed dann im Browser auch für Menschen lesbar ist. Das habe ich gemacht.

Ich nahm das Styling also raus, schob die Änderungen auf den Server und abonnierte meinen Feed neu. Seitdem trudeln bei mir wieder Beiträge ein. Es lag tatsächlich am Styling.

Das ist ein wenig ärgerlich, aber nicht weltbewegend. Ich hatte auch bisher nicht die Muße herauszufinden, ob es an meinem speziellen Code lag, oder ob Feedbin damit generell nicht umgehen kann. Vielleicht teste ich das später noch einmal, gerade gibt es aber wichtigere Aufgaben.

Dank meiner tollen Feed-Leser:innen habe ich nun aber wieder einen RSS-Feed, der überall funktioniert – vielen Dank. Und vielleicht wollt ihr ja eure Feeds auch mal prüfen, das wäre doch ärgerlich, wenn es an so einer Kleinigkeit scheitert.

Welche Feeds ich abonniert habe, kann man übrigens jetzt auch in meiner Blogroll sehen!

]]>
Tipp: Eigenes Panel CSS https://maurice-renck.de/de/learn/built-with-kirby/quicktip-custom-panel-css https://maurice-renck.de/de/@/page/l0klxANMizi613wg Wed, 19 Feb 2025 13:30:00 +0100 Maurice Renck kirby-cms

Mit einem kleinen Trick blende ich nicht zu übersetzende Felder im Kirby Panel aus.

Im Kirby Panel verwende ich zahlreiche Felder, die nicht übersetzt werden müssen oder sollen. Das sind Felder wie Kategorien oder Tags, aber auch bestimmte Settings, die ich setzen kann und die für alle Sprachen gleichermaßen gelten.

Meine Hauptsache ist Deutsch, dort setze ich die Werte, wechsle ich dann auf Englisch, kann ich die Felder nicht bearbeiten und die Werte kommen aus der Deutschen Seite:

Da mich diese Felder in der Übersetzung nicht wirklich interessieren, fragte ich mich häufig, warum sie denn überhaupt sichtbar sein müssen.

Das Ausblenden von nicht übersetzbaren Feldern ist im Kirby Panel leider nicht möglich, also musste eine andere Lösung her.

Eigenes Panel-CSS

Wenn ich die Felder nicht via config ausblenden kann, dann kann ich das zumindest über CSS tun. Erfreulicherweise ist es möglich, eine eigene CSS-Datei in das Panel zu laden und somit Styles zu überschreiben.

Das ist nützlich, wenn man das Panel beispielsweise für einen Kunden branden will – oder eben Felder ausblenden.

Zunächst gilt es, die CSS-Datei zu laden. Das passiert in der config.php

'panel' => [
    'css' => '/assets/css/panel.css'
]

Schon wird die Datei im Panel geladen und wir können unser Unwesen treiben.

Ein Blick in den Inspektor zeigt auf, dass jedes Feld von einigen div umgeben ist. Leider wird die Information, dass ein Feld deaktiviert ist, erst auf zweiter Ebene in den Tree geschrieben:

Ausblenden müssen wir .k-column, damit nicht nur das Feld unsichtbar ist, sondern auch der Platz freigegeben wird. Blenden wir nur das Feld aus, bleibt Whitespace in Höhe des Feldes erhalten.

Wir wissen allerdings erst eine Ebene tiefer, dass das betroffene Feld deaktiviert wurde. Die Information steht im data-Attribute data-disabled="true".

Dank neuer CSS-Features und dem glorreichen has() Selektor können wir indirekt auf das Parent-Element von Data-Disabled-Div zugreifen:

div:has(> [data-disabled='true']) {
    display: none;
}

Hurra! So einfach geht das inzwischen. Wir schauen also nach divs, deren Kinder das Attribut data-disabled='true' haben und blenden diese dann aus.

Im Ergebnis sieht das dann so aus:

Zu sehen sind nur noch die relevanten Felder, alles andere ist ausgeblendet. Ganz optimal funktioniert das nicht, weil die Felder sich im Nachhinein nicht den Platz nehmen, den sie jetzt haben könnten, aber das ist eine Kleinigkeit. Viel wichtiger ist, dass nun nur noch relevante Felder gezeigt werden.

Auf diese Weise könnte ich jetzt weitere Anpassungen vornehmen, sollte mir mal wieder etwas auffallen oder ich das Panel-Design anpassen wollen.

Viel Spaß beim selbst ausprobieren.

]]>
Obsidian Kirby Sync https://maurice-renck.de/de/learn/built-with-kirby/obsidian-kirby-sync https://maurice-renck.de/de/@/page/A9d1cEyaKP9XaIqV Thu, 30 Jan 2025 10:00:00 +0100 Maurice Renck kirby-cms

Nach über zehn Jahren mit Kirby als CMS fürs Bloggen habe ich jetzt endlich einen Arbeitsablauf, der mir wirklich gefällt.

Ich bin kurz davor ein verbessertes Setup als Plugins zu veröffentlichen, folge mir gerne, wenn du über Updates informiert werden möchtest:

Kirby ist ein tolles CMS, um ein Blog zu betreiben. Daten werden in Textdateien gespeichert, Texte können in Markdown geschrieben werden. Das Panel – Kirbys Verwaltungsoberfläche – lässt sich bis ins kleinste Detail individualisieren und jede Seite kann ihr individuelles Set an Feldern haben.

Das macht es möglich, das eigene Blog auf die eigenen Bedürfnisse anzupassen und Dinge so zu gestalten, dass sie das Bloggen besonders einfach machen.

Aber so sehr ich die Oberfläche mag und mir coole Features und Plug-ins dafür gebaut habe, einen großen Haken hatte das ganze Set-up all die ganzen Jahre: Ich schreibe meine Texte nicht direkt im CMS, sondern in einem Markdown-Editor.

Das war mal VS Code, mal iA Writer, dann Ulysses, wieder iA Writer und zwischendurch immer mal wieder Obsidian. Alle hatten ein Problem gemeinsam: Sobald ein Text in Kirby war, liefen die Kirby-Version und die Version im Editor auseinander.

Wer kennt das nicht: Ein Text ist online und beim direkten Blick auf die Seite fällt dann doch noch ein Fehler auf. Ein Link ist nicht korrekt gesetzt oder irgendwo ein Vertippper. Also korrigiere ich diesen Fehler schnell im Panel, damit die Änderung sofort online ist.
Anfangs, als die Motivation noch groß war, korrigierte ich diese Fehler auch noch in meinem Dokument im Markdown-Editor. Aber um ehrlich zu sein, irgendwann hatte ich darauf keinen Bock mehr, schob es nach hinten und ließ es schließlich ganz sein.
Die Texte auf der Webseite sind dann also in einer anderen Form, als sie im Editor sind.

Das wäre alles kein Problem, wenn ich die Texte nicht gerne auch eben im Editor hätte, der für mich auch eine Art Datenbank ist. Ich bin großer Freund des Zettelkastens. Ich halte es auch für sinnvoll, Texte immer irgendwie als Datei lokal liegen zu haben, anstatt nur online.

Das erste Problem ist also die Synchronität zwischen beiden Welten.

Das zweite Problem ist der Zustand beider Dokumente. Neben dem Text gibt es noch zahlreiche Metadaten, wie Tags, ein Datum und den eigentlichen Zustand, also ob ein Dokument ein Entwurf, nicht gelistet oder publiziert ist. Einige Editoren haben dafür Lösungen parat.

Die Markdown-Editoren

Ich habe zwar anfangs VS Code zum Schreiben von Markdown verwendet, aber bin irgendwann davon ab. VS Code ist nun mal ein Code-Editor und viele Funktionen, die ich von einem Texteditor erwarte, sind nicht enthalten, ließen sich vielleicht nachrüsten, haben dann aber umgekehrt eigentlich auch nichts in einem Code-Editor zu suchen. Ich bin also schnell wieder zu anderen Apps gewechselt.

iA Writer

iA Writer ist einer der besten Editoren da draußen. Er ist minimal gestaltet und bietet dennoch alle Funktionen, die man sich beim Schreiben wünscht. Neben dem hübschen (und kaum vorhandenen) Interface haben mir besonders die Grammatikhelfer gefallen. Ich benutze iA Writer seit der ersten Version und dieses Feature war eines meiner meistgenutzten. Ich kann mir verschiedene Wortarten anzeigen lassen und auf diese Weise schnell Füllwörter und unnötige Adjektive ausfindig machen.

Hier sieht man iA Writers Stilprüfung:

Und so bunt kann es aussehen, wenn man alle Wortarten hervorheben lässt:

Alles lässt sich im Detail ein- und ausschalten:

Ganz besonders toll ist aber die Möglichkeit, Texte über verschiedene Dienste/APIs direkt aus iA Writer heraus zu veröffentlichen:

Wie man sieht, sind hier die populärsten Schnittstellen vertreten. Besonders interessant ist hier sicherlich Micropub, weil der Standard nicht auf ein bestimmtes CMS zugeschnitten ist. Für meinen Fall war das allerdings nur eingeschränkt nützlich.

Sebastian hat ein ganz großartiges Plug-in dafür geschrieben, welches es mir auch möglich machte, über mein Smartphone kurze Posts als Notizen zu veröffentlichen – und eben meine Texte aus iA Writer heraus zu veröffentlichen.

Das Plug-in wird leider nicht weiterentwickelt. Es lief zwar immer noch mit den letzten Kirby 4 Versionen, wurde mir dann aber doch etwas zu riskant; eine nicht gewartete Schnittstelle zum eigenen CMS ist langfristig keine gute Idee.

Außerdem gibt es auch hier ein wesentliches Problem: Die Kommunikation führt nur in eine Richtung, vom Editor ins Blog, aber nicht wieder zurück. Es ist eben nur zum Veröffentlichen gedacht.

Wenn es um das Verwalten meiner Texte geht, bin ich mit iA Writer bedauerlicherweise auch nie so richtig warm geworden. Die Datei/Ordner-Ansicht empfand ich nie als übersichtlich genug, gerade wenn es darum geht, dass Dokumente auch in Verbindung miteinander stehen und in verschiedene "Rubriken" einsortiert werden sollen.

Ulysses

Ulysses ist hier etwas besser aufgestellt. Neben Ordnern lassen sich auch noch Projekte anlegen, sodass sich verschiedene Inhalte gut gruppieren lassen. Das hilft mir ganz gut, weil ich nicht nur für mein eigenes Blog Texte schreibe und für jedes Blog ein eigenes Projekt anlegen kann.

Auch Ulysses kommt mit einer eigenen Stil- und Grammatikprüfung, die ganz hervorragend funktioniert; wenn auch nicht so plakativ wie bei iA Writer. Dafür sind die Empfehlungen hier häufig etwas ausgefeilter und umfangreicher, meist werden mehrere Vorschläge gemacht, wie man etwas besser eleganter formulieren könnte.

Offensichtlich ist die Oberfläche ebenfalls recht minimalistisch, wenn auch lange nicht so minimal wie bei iA Writer. Generell empfinde ich iA Writer immer als etwas "runder", mag vor allem den iA Writer Font besonders gerne.

Auch bei Ulysses lassen sich Texte direkt veröffentlichen. Wie zu sehen ist, sind auch hier alle gängigen Kanäle vertreten. Jedoch leider kein Micropub, stattdessen aber eine direkte Schnittstelle zu Micro.blog. Ein Feature-Request vor ein paar Jahren konnte hier bisher leider nichts bewegen.

Auch hier bleibt allerdings das Problem des fehlenden Rückkanals. Texte können zwar veröffentlicht, aber nicht wieder zurück in den Editor geholt werden.

Obsidian

Die Eierlegende Wollmilchsau.

Obsidian ist Open Source und kostenlos. Entsprechend weit verbreitet ist es. Es gibt zahlreiche Themes und mindestens ebenso viele Plug-ins, um den Editor zu erweitern.

Der Lieblingssport einiger User besteht darin, Obsidian mit Plug-ins und selbstgebauten Konstrukten so weit zu tunen, dass es wie ein Projektmanagementsystem funktioniert; daraus dann endlose Videoreihen bei YouTube zu machen und sich schließlich darüber zu beschweren, wie kompliziert Obsidian ist und zu erklären, dass man jetzt zu Notion wechselt.

Keine Panik. Obsidian kann so sein, dazu muss man sich aber schon anstrengen. Nach der Installation ist Obsidian erst einmal ein Texteditor, mit ein paar grundlegenden, hilfreichen Funktionen.

Der Griff zu einem Theme hat meinerseits dennoch nicht lange gedauert, weil ich unbedingt etwas Minimales haben wollte. Ich verwende Obsidian Boom mit einigen zusätzlichen Einstellungen, damit alles schön clean aussieht:

Weil ich den Font von iA Writer so toll finde, habe ich ihn mir heruntergeladen und installiert. Er ist frei verfügbar und eingeschränkt nutzbar.

Von Haus aus hat Obsidian keine besonderen Stil- oder Grammatik-Features. Das lässt sich über ein Plug-in nachrüsten. Das funktioniert dann ähnlich gut wie bei Ulysses:

Hier verwende ich das LanguageTool Integration Plug-in. Leider wird es nicht mehr aktiv weiterentwickelt, ich hoffe, es bleibt uns noch eine Weile erhalten, oder dass sich jemand findet, um die Entwicklung wieder aufzunehmen.

Ich habe außerdem eines der Focus-Plug-ins im Einsatz, welches sämtliche UI-Elemente ausblendet und damit eine ähnlich minimale Darstellung herstellt, wie iA Writer und Ulysses.

Plug-ins zum Synchronisieren von Texten und Einstellungen gibt es einige. Die meisten davon sind allerdings eher Backup-Syncs oder zum Synchronisieren zwischen verschiedenen Rechnern und Mobilgeräten gedacht. Hier habe ich mich vor einiger Zeit dazu entschieden, Obsidian Sync zu nutzen. Das scheint mir am stabilsten zu laufen und ich kann die Entwickler auf diese Weise etwas unterstützen. Die Versions-Historie hat mir bereits einmal den Hintern gerettet, als ich mit einem eigenen Plug-in herumprobiert habe; war den Kauf also schon wert.

Damit kommen wir zum wesentlichen Thema: dem Veröffentlichen von Texten. Auch hier gibt es einige Plug-ins, aber nichts, was mit Kirby funktionieren würde. Allerdings durfte ich freudig feststellen, dass Obsidian-Plug-ins in TypeScript geschrieben werden, etwas, womit ich mein Geld verdiene, also gut beherrsche.

Umso weniger konnte ich mich dann beherrschen und habe losgelegt, ein eigenes Plug-in zu schreiben.

Obsidian und Kirby verbinden

Mein Problem habe ich ja nun häufig genug benannt, mir fehlt die Möglichkeit, Texte zwischen Kirby und meinem Editor synchron zu halten. Das betrifft den Text selbst, aber auch dessen Metadaten.

Um das herzustellen, brauche ich jeweils ein Plug-in für Kirby und eines für Obsidian. Das Kirby-Plug-in muss eine Schnittstelle bereitstellen, die mir das Auslesen und Senden von Daten an Kirby ermöglicht. Das Obsidian-Plug-in muss diese Anfragen ausführen und mit den Ergebnissen arbeiten, also etwa meine lokalen Dateien aktualisieren.

Ich bin das Ganze experimentell in einem extra dafür erstellen Obsidian Vault angegangen. Ich wollte ein wenig herumspielen und dabei keine Angst vor Datenverlust haben müssen.

Dabei hat sich herausgestellt, dass ich vier Endpunkte brauche, die folgende Aufgaben erfüllen:

  • Daten in Kirby aktualisieren
  • Daten in Obsidian aktualisieren
  • Eine neue Seite in Kirby anlegen
  • Eine neue Seite in Obsidian anlegen

Im Grunde werden damit drei Fälle abgedeckt:

  1. Ich habe einen neuen Text in Obsidian erstellt und will diesen nun in Kirby veröffentlichen
  2. Ich habe bereits einen Text in Kirby, den ich bisher nicht lokal habe und möchten diesen Text in Obsidian "herunterladen"
  3. Ich habe den Text sowohl in Obsidian als auch in Kirby und will eines von beidem aktualisieren

Das Obsidian-Plug-in

Wie schon erwähnt, werden Obsidian-Plug-ins mit TypeScript geschrieben, was mir sehr liegt, weil ich damit mein Geld verdiene. Ich habe mich also ran gesetzt und erst einmal geschaut, wie das in Obsidian alles so funktioniert. Glücklicherweise ist das alles recht naheliegend und mithilfe von Ollama in meiner IDE kam ich ohne viel Grundwissen über die Obsidian API schnell voran.

Zunächst musste ich mir überlegen, was ich synchronisieren möchte. Natürlich den Text selbst, aber auch eine Handvoll, Metadaten. Das Problem hier sind die unterschiedlichen Formate, in denen Metadaten abgelegt werden. Wären diese identisch, bräuchte ich gar keine Plug-ins, sondern könnte vermutlich einfach direkt mit den Dateien arbeiten.

Obsidian legt Metadaten im Frontmatter Format ab. So sieht das gerade beim Schreiben für diesen Artikel aus:

---
type: text
aliases: 
date: 2024-12-05
channel: blog
status: draft
sync: false
slug: 
title: 
intro: 
tags:
---

Die Darstellung in Obsidian sieht dann wie folgt aus:

Kirby hingegen legt die Metadaten in einem leicht abweichenden Format ab:

Title: 

----

Intro: 

----

Text:

----

Date: 

----

Tags: 

Wie man sieht, unterscheiden sich beide leicht in ihrem Format und den Inhalten. Während Metadaten wie das Datum und Tags bei beiden direkt in die Textdatei geschrieben werden, identifiziert Kirby den Status der Seite (draft, unlisted, listed) über den Ordner, legt diese Information also nicht direkt in der Datei ab.

Die meiste Übersetzungsarbeit übernimmt Kirby für mich. Da ich in der API alle Kirby-Klassen und -Methoden nutzen kann, muss ich mir um das Format keine Gedanken machen. Ich schicke lediglich alle Metadaten und den Text als separate Datensätze mit.

Ich habe bereits ein Kirby-Plug-in namens "Internal API" in dem ich diverse Endpunkte bereitstelle, um Kleinkram zu machen oder auf dem Mac Informationen anzuzeigen. Das Plug-in konnte ich also einfach erweitern. Letztendlich handelt es sich um ein Plug-in, welches ein paar Routen bereitstellt.

Dank des relativ simplen Anwendungsfalls, konnte ich hier den klassischen CRUD1 Weg gehen. Wobei eigentlich nur CRU, weil ich das Löschen bewusst weggelassen habe. So sehen die Endpunkte aus:

[
    'pattern' => 'ENDPOINT/(:any)/(:any)/(:any)',
    'method' => 'VERB',
    'action' => function ($channel, $folder) {
        $request = kirby()->request();
        $requestData = $request->data();
        $requestHeaders = $request->headers();

        if ($requestHeaders['Authorization'] !== 'Bearer ' . option('mauricerenck.obsidian.token')) {
            return new Response('Unauthorized', 'text/plain', 401);
        }

        // DO STUFF

        return new Response(json_encode($data), 'application/json');
    },
],

Diese Route gibt es dreimal, dabei unterscheidet sich jeweils das Verb:

  • Create -> 'method' => 'POST'
  • Read -> 'method' => 'GET'
  • Update -> 'method' => 'PUT'

Der ENDPOINT heißt in Wirklichkeit natürlich auch anders.

Ich hätte hier eine Route wählen können, die alle drei Fälle abfängt, habe mich aber dagegen entschieden und mich auf etwas Code-Duplizierung eingelassen, um alles etwas klarer getrennt und letztendlich übersichtlicher zu gestalten.

Wie man sieht, wird jeder Request durch einen Token abgesichert. Jede Anfrage muss den richtigen Token im Header mitschicken, sonst wird sie abgelehnt. Damit sichere ich die Endpunkte ab, sonst könnte ja jeder einfach Daten schicken und lesen.

Die Struktur

Bevor wir weiter machen, ein paar Worte zur zugrunde liegenden Struktur. Auf meiner Webseite habe verschiedene Bereiche, die ich nutze, um Inhalte ein wenig voneinander trennen zu können. Im Wesentlichen sind das Blog, Hub, Notes und demnächst noch ein vierter Bereich. Hier spreche ich von Kanälen.

Damit ich nicht endlose lange Verzeichnislisten habe, sind die jeweiligen Kanäle noch einmal aufgeteilt, im Blog und in den Notizen nach dem Jahr und im Hub nach Themen.

Die Struktur ist also:

/blog/2025/article-slug/template.en.md

oder

/hub/built-with-kirby/article-slug/template.en.md

Mit einem Blick auf die Metadaten in Obsidian dürftest du davon schon etwas gesehen haben:

channel: blog
slug: article-slug

Eine Ebene fehlt, das Jahr bzw. das Thema. Hier habe ich mich dazu entschlossen, die Struktur in Obsidian nachzubilden. Schaut man sich also den Obsidian-Verzeichnisbaum an, ist dieser identisch mit der Kirby-Struktur:

Das Verzeichnis ATTACHMENTS kann ignoriert werden, hier legt Obsidian verknüpfte Dateien ab, Bilder z.B.
Wie man aber sieht, gibt es hier die Jahres-Verzeichnisse, wie es sie auch bei Kirby gibt.

Auf diese Weise baue ich mir dann im Obsidian-Plug-in die API-Endpunkt-URL zusammen:

const options = {
    url: `${this.settings.apiBaseUrl}/${channel}/${folder}/${slug}`,
    method: 'POST|PUT|GET',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.settings.apiToken}`
    },
    body: JSON.stringify(data)
};

Wir haben eine Base-URL, das ist die URL meiner Webseite plus den oben erwähnten ENDPOINT. Gefolgt vom Kanal, z.B. blog, dem folder, für diesen Beitrag also ´2025´. Schließlich der slug. Der Slug ist letztendlich der Dateiname der Markdown-Datei in Obsidian, bei Kirby ist es der Verzeichnisname des Posts.

Fall 1: Eine neue Seite erstellen

Einer der Anwendungsfälle ist das Erstellen einer neuen Seite in Kirby. Ich habe also in Obsidian bereits einen Text geschrieben. Dieser soll jetzt – wir bleiben beim Beispiel – im Blog veröffentlicht werden.

Damit nicht einfach wild synchronisiert wird, was gar nicht synchronisiert werden soll, habe ich einen Sync-Haken eingebaut. Nur, wenn dieser aktiviert ist, kann eine Datei überhaupt synchronisiert werden. Das sieht man weiter oben im Screenshot.

Überhaupt: Die Frage, was wann wie synchronisiert wird und ob es bereits synchronisiert wurde, stellte sich ziemlich früh.

Hier wird eine Mischung aus verschiedenen Status und dem Sync-Haken tätig.

how-to-burn-money ist nicht synchronisiert und als Idee abgelegt. Das bedeutet, dass es meist nur ein paar Stichpunkte gibt und ich noch gar nicht weiß, ob daraus mal ein Text wird.

obsidian-kirby-sync ist bereits in Arbeit, aber wurde noch nicht synchronisiert. Er liegt als neue Datei vor, hat aber seinen Weg noch nicht in Richtung Kirby gefunden.

example 2 wird synchronisiert und liegt als draft bei Kirby.

example wird synchronisiert und wurde veröffentlicht, allerdings nicht gelistet.

sociabli wird synchronisiert, wurde veröffentlicht und ist im Blog sichtbar.

In unserem Beispiel haben wir also einen Artikel in Arbeit, dieser wurde aber bisher nicht synchronisiert. Im Plug-in müssen wir daher zunächst lokal ein paar Daten sammeln, damit wir diese an Kirby schicken können:

const data = await this.readFileData(file);
const channel = data.frontmatter.channel;
const currentYear = new Date().getFullYear();
const folder = file.parent?.name || currentYear.toString();

try {
    // REQUEST CODE FROM ABOVE WITH THIS CHANGES
    // url: `${this.settings.apiBaseUrl}/${channel}/${folder}`,
    // method: 'POST',

    const response = await requestUrl(options);

    if (response.status !== 200) {
        console.error(`Failed to fetch API data from ${channel}`);
        return \{\};
    }

    this.syncFile(file, response.json);
}

Zunächst einmal lesen wir die aktuelle Markdown-Datei aus, holen uns aus den Frontmatter-Daten den Kanal und holen uns das aktuelle Jahr. Schließlich legen wir fest, wie der Folder lauten wird. Hat die Datei ein übergeordnetes Verzeichnis? Dann nehmen wir dessen Namen, wenn nicht, das Jahr. Der letzte Fall dürfte eigentlich nie eintreten, ich will hier aber auf Nummer sicher gehen.

Wie der eigentliche API-Call dann funktioniert, habe ich oben schon gezeigt. Weil wir einen neuen Artikel erstellen, ist es ein POST Request.

Schlägt der Aufruf aus irgendeinem Grund fehl, wird der Fehler geloggt und abgebrochen. Ist alles gut gelaufen, bekommen wir von der API die gespeicherten und durch Kirby angereicherten Daten zurück.

Läuft etwas komplett schief, greift der catch Block und macht Alarm:

catch (error) {
    console.error(`Error: Could not create page`);
    new Notice(`Error: Could not create page`);
    this.updateStatus('Error: Could not create page');
    return \{\};
}

Erst wird der Fehler geloggt, ich zeige außerdem noch eine Notification an und unten in der Statuszeile von Obsidian auch noch eine Meldung. Es sollte also nicht zu übersehen sein.

Kirby-Plug-in

Wie oben in der Route schon gezeigt, empfängt Kirby die Daten und prüft den Token. Kanal, Folder und Slug werden über die URL mitgegeben und direkt in die Route hereingereicht, der Text steht im POST-Body und muss ausgelesen werden.

Als Nächstes prüfe ich dann, ob es den Folder überhaupt gibt. Sollte das nicht der Fall sein, breche ich sofort ab:

$parent = kirby()->page($channel . '/' . $folder);

if (is_null($parent)) {
    return new Response('Not Found', 'text/plain', 404);
}

Nun geht es daran, die Daten ins Kirby-Format zu bringen und zu speichern:

$newData = [
    'title' => $requestData['frontmatter']['title'],
    'intro' => $requestData['frontmatter']['intro'],
    'text' => $requestData['content'],
    'tags' => $requestData['frontmatter']['tags'],
];

kirby()->impersonate('kirby');
$page = Page::create([
    'parent'  => $parent,
    'slug'     => Str::slug($requestData['frontmatter']['title']),
    'template' => 'post',
    'content' => $newData
]);

Ich baue mir hier also ein Array zusammen, das ich dem Page::create Aufruf hineingeben kann. Zusätzlich erzeuge ich hier noch einen slug aus dem Titel. Als Template dient mein Standard post Template.

Damit erzeugt Kirby eine neue Seite und liefert mir diese auch direkt zurück, sodass ich gleich über $page darauf zugreifen kann.

Erst einmal schaue ich aber, welchen Status die Seite haben soll. Ich könnte sie nämlich in Obsidian auch gleich so einstellen, dass sie direkt veröffentlicht wird:

if (in_array($requestData['frontmatter']['status'], ['listed', 'unlisted'])) {
    $page->changeStatus($requestData['frontmatter']['status']);
}

Der initiale Zustand ist immer draft weshalb ich hier nur listed und unlisted abfragen muss. Ist eines davon gesetzt, wird die Seite entsprechend veröffentlicht.

Jetzt reichern wir die Daten noch etwas an und schicken sie zurück an Obsidian:

$data = [
    'frontmatter' => [
        'tags' => $page->tags()->split(','),
        'date' => $page->date()->toDate('c'),
        'status' => $page->status(),
        'title' => $page->title()->value(),
        'intro' => $page->intro()->value(),
        'sync' => true,
        'slug' => $page->slug(),
        'channel' => $channel,
    ],
    'modified' => $page->modified('c'),
    'content' => $page->text()->value(),
    'folder' => $page->parent()->uid(),
    'headers' => $requestHeaders,
];

return new Response(json_encode($data), 'application/json');

Hier ist wesentlich, dass ich das Datum und den Slug zurückliefere, denn diese Information hatte Obsidian bisher nicht. Im Obsidian Plug-in gibt es eine Methode zum Aktualisieren der lokalen Datei, die diese Daten in Empfang nimmt.

Daten in Obsidian empfangen

Um unnötige Aktualisierungen und Endlosschleifen zu unterbinden, prüfe ich zunächst, ob es überhaupt Änderungen gibt:

const originalContent = await this.app.vault.read(file);
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;

let updatedContent = originalContent;

Ich lese mir also den aktuellen, lokalen Stand aus. Diesen schreibe ich in updatedcontent, das wirkt etwas schräg, erklärt sich aber gleich.

Zuerst geht es an die Metadaten:

if (frontmatter) {
    const frontmatterEndIndex = originalContent.indexOf("---", 3) + 3;
    const existingFrontmatter = parseYaml(originalContent.slice(3, frontmatterEndIndex - 3));
    const updatedFrontmatter = { ...existingFrontmatter, ...apiData.frontmatter };
    const newFrontmatterBlock = `---\n${stringifyYaml(updatedFrontmatter)}---`;

    updatedContent = newFrontmatterBlock + originalContent.slice(frontmatterEndIndex);
} else {
    apiData.frontmatter.sync = true;
    updatedContent = `---\n${stringifyYaml(apiData.frontmatter)}---\n${originalContent}`;
}

Ich mache aus dem Frontmatter-Text-Block erst einmal ein Objekt. Dieses überschreibe ich dann einfach mit den Daten aus der API. Ich kümmere mich hier gar nicht um einzelne Felder oder so, da bin ich ziemlich risikobereit. Am Ende schreibe ich die Daten in updatedContent und hänge den Text unten dran.

Hat die Datei noch gar kein Frontmatter, übernehme ich 1:1 die Daten aus der API. Das kommt später zum Tragen, wenn wir in Obsidian eine neue Datei mit den Daten aus Kirby anlegen.

Jetzt aktualisiere ich noch den eigentlichen Inhalt:

if (apiData.content) {
    const frontmatterEndIndex = updatedContent.indexOf("---", 3) + 3;
    updatedContent = updatedContent.slice(0, frontmatterEndIndex) + "\n" + apiData.content;
}

Auch hier nehme ich stumpf das, was die API liefert.

Jetzt wird es tricky, es geht um den Slug. Der lokale Dateiname soll immer dem remote Slug entsprechen, also müssen wir da ran:

const newSlug = apiData.frontmatter.slug;
const currentTitle = file.name.replace(/\.md$/, ""); 
console.log(`Checking slug ${file.path} vs ${newSlug}.md`);

if(apiData.folder !== file.parent?.name) {
    console.log(`Moving file ${file.path} to ${apiData.folder}`);
    await this.moveFile(file, apiData.folder, newSlug);
} else if (newSlug && newSlug !== currentTitle) {
    console.log(`Renaming file ${file.path} to ${newSlug}.md`);
    await this.renameFile(file, newSlug);
}

Ich schnappe mir den Slug von der API. Ich schnappe mir den aktuellen Dateinamen und entferne die Dateierweiterung. Hat sich der Folder geändert, bewege ich die Datei und benenne sie nach dem neuen Slug um. Hat sich nur der Slug geändert, nenne ich die Datei um.

Auch das greift schon ein wenig dem Voraus, was wir für andere Syncs brauchen.

Final schreiben wir nun noch die Datei, sollte das nötig sein:

if (originalContent !== updatedContent) {
    this.pluginStatus == "pulled"
    await this.app.vault.modify(file, updatedContent);
}

this.updateStatus('Sync Complete');

Fall 2: Eine Seite herunterladen

Der zweite Fall betrifft die Gegenrichtung. Es gibt bereits eine Seite in Kirby und ich möchte sie nun auch in Obsidian haben. Das betrifft meist alte Artikel, die ich geschrieben habe, bevor ich die Plug-ins am Start hatte.

Auf Seiten von Kirby ist das der simpelste Fall. Die Route horcht auf den Kanal, den Folder und Slug. Es handelt sich um einen GET Request. Wie immer wird erst der Token geprüft und dann hole ich mir die Seite:

$page = kirby()->page($channel . '/' . $folder . '/' . $slug);

if (is_null($page)) {
    return new Response('Not Found', 'text/plain', 404);
}

Gibt es sie nicht, wird das entsprechend quittiert und der Prozess beendet. Gibt es die Seite, wird das gleiche Array wie schon beim Create erzeugt und an Obsidian zurückgeliefert.

Damit das funktioniert, brauche ich die Möglichkeit, Folder und Slug etc. anzugeben, dafür gibt es einen Dialog:

Sehr spartanisch, ich weiß, es soll erst einmal nur funktionieren. Hier muss ich den Slug von Kirby wissen. Ich muss also ins Panel schauen. Das ist nicht ganz optimal, da der Fall aber sehr selten vorkommt, ist das okay für mich.

Klicke ich submit wird der GET-Request abgeschickt:

const options = {
    url: `${this.settings.apiBaseUrl}/${channel}/${folder}/${slug}`,
    method: 'GET',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.settings.apiToken}`
    },
};

Da ich hier wie beim Create dieselbe Datenstruktur zurückbekomme, kann ich im Erfolgsfall einfach dieselbe Methode aufrufen, die ich am Ende von Fall 1 aufgerufen habe. Die Logik bleibt dieselbe. Kirby schickt mir alle Daten zurück, insbesondere hier den Slug, der nun genutzt wird, um die Datei entsprechend zu benennen und ggf. ins richtige Verzeichnis zu schieben.

Fall 3: Kirby mit lokalen Daten aktualisieren

In diesem Fall synchronisieren sich beide Enden bereits. Ich möchte jetzt gezielt lokale Änderungen zu Kirby schicken. Ein PUT Request. Auf Seite von Kirby funktioniert dieser fast genauso, wie der POST Request.

Ich hole mir wieder die betroffene Seite, erstelle mir ein Array mit den neuen Daten und rufe dann ein update statt eines creates auf:

$page->update($newData);

Danach gebe ich die Daten der Seite wie schon beim Create zurück und Obsidian kann die lokale Datei wieder aktualisieren, sofern nötig.

Fall 4: Die lokale Datei aktualisieren

Der letzte Fall. Auch hier synchronisieren sich beide Enden bereits und wir wollen jetzt Änderungen, die ich im Panel vorgenommen habe, auch lokal aktualisieren.

Lokal haben wir bereits alle Daten, die wir brauchen, um den GET Request abzusetzen. Für Kirby ein leichtes Spiel. Ich hole mir die Seite, lese die Daten aus und liefere sie wieder im bekannten Format zurück an Obsidian.

Tja, und Obsidian kann dann einfach wieder die Methode zum Aktualisieren der Datei nutzen.

Der Feinschliff

Wagemutig hatte ich mich zunächst dazu entschieden, beim lokalen Öffnen einer Datei immer einen Sync auszuführen und mir Daten aus Kirby zu holen (sofern der Haken gesetzt ist, natürlich).

Ich meinte, das Risiko eingehen zu können, weil ich in Obsidian auf Änderungen gelauscht habe und immer dann in Richtung Kirby synchronisiert habe, wenn Obsidian die Datei gespeichert hat.

Das war etwas zu mutig und führte im Produktivbetrieb dann zu Datenverlust. Ich hatte lokal schon viel geschrieben, remote stand noch ein "Test" im Text und irgendwie schaffte ich es, die Datei zu öffnen, ohne dass sie vorher in Richtung Kirby geschickt wurde. Resultat: Mein Text war weg, es lachte mir ein freudiges "Test" an. Dank Obsidian Sync konnte ich dann aber den letzten Stand wiederherstellen.

Ich habe mich dann gegen automatisches Synchronisieren entschieden …

Ich hatte von Beginn an schon die Möglichkeit, einen Sync per Befehl zu starten. Dazu rufe ich die Befehlszeile mit cmd+p auf und tippe kirby. Dann kann ich auswählen, was ich machen möchte:

Wie man sieht, sind hier alle vier Fälle aufgelistet. Bei "Create page from remote" geht der oben gezeigte Dialog zur Eingabe des Slugs auf. Ansonsten werden die beschriebenen Prozesse ausgelöst.

Ein solches Command kann man in Obsidian wie folgt anlegen:

this.addCommand({
    id: 'kirby-pull-remote',
    name: 'Update local file',
    checkCallback: (checking: boolean) => {
        const activeFile = this.app.workspace.getActiveFile();

        if (activeFile && activeFile.extension === "md") {
            if (!checking) {
                this.pluginStatus = "pulling";
                this.handleFileModify(activeFile, true);
            }

            return true;
        }

        return false;
    }
});

Ein paar Abfragen, um sicherzugehen, dass auch alle Voraussetzungen erfüllt sind, dann wird der Sync aufgerufen. Die anderen Befehle funktionieren ähnlich.

Schließlich habe ich noch ein Plug-in im Einsatz, mit dem ich den Dateien Icons geben kann. Dieses Plug-in zapfe ich an, um entsprechend dem Datei-Status ein passendes Icon zu setzen, das hat man oben im Screenshot schon gesehen.

Fazit

Mit diesem Set-up bin ich bisher sehr zufrieden. Ich habe immer alles auf dem gleichen Stand. Dank Obsidian Sync läuft mein Plug-in auf allen Geräten.

Natürlich passiert noch ein wenig mehr, oben sieht man schon den pluginStatus, der sicherstellt, dass es keine Loops gibt und sich nichts überschneidet. In Kirby wandle ich noch die Obsidian-Syntax für Bilder in einen Kirbytag um. Aus ! [ [obsidian-8.png ] ] wird dann ( image: obsidian-8.png ) wenn die Page in Kirby gerendert wird (die Leerzeichen habe ich ergänzt, damit die Tags nicht ersetzt werden).

Apropos Bilder! Die synchronisiere ich bislang nicht, die müssen also extra hochgeladen werden. Das wäre noch ein nettes Feature für die Zukunft.

Ich kann also munter synchronisieren, mein Obsidian sieht dank Theme und Font toll aus und ich kann dort sehr angenehm schreiben. Dank Obsidian Sync habe ich keine Angst vor Datenverlust und kann auf allen Geräten auf meinen Vault zugreifen. Ich habe gute Stil- und Grammatikprüfung über das Plug-in und ich kann den Status meiner Dateien auf den ersten Blick im Verzeichnisbaum sehen.

Es gibt noch massives Potenzial für Verbesserungen. Das alles ist wild zusammen gehackt, weil ich viel ausprobiert und neu gelernt habe. Deshalb sind beide Plug-ins noch etwas davon entfernt, veröffentlicht werden zu können. Und ich bin auch gar nicht sicher, wie weit ihr dafür überhaupt Bedarf besteht. Ihr könnt mir ja mal einen Kommentar schreiben.

Ich werde hier jetzt noch einmal die Rechtschreibprüfung drüber schicken und dann via Obsidian eine neue Seite bei Kirby anlegen. Wie jedes Mal freue ich mich aufs Neue darüber, dass das jetzt möglich ist.


  1. CRUD steht für Create, Read, Update, Delete 

]]>
Ein Open Graph Image generieren https://maurice-renck.de/de/learn/built-with-kirby/open-graph-image-plugin https://maurice-renck.de/de/@/page/i7dCu6VODO2yxXWK Mon, 30 Dec 2024 16:00:00 +0100 Maurice Renck kirby-cms

Wenn ein Link zu unserer Webseite geteilt wird, soll in der Vorschau ein automatisch generiertes Bild angezeigt werden. Dazu benutzen wir mein Kirby-Plugin.

OpenGraph Image - Maurice Renck

Das OG-Image-Plugin erzeugt für jede deiner Seiten automatisch ein Vorschaubild, welches dann beim Teilen oder einbetten angezeigt werden kann.

Wenn wir unsere Blogposts, Notizen oder andere Seiten bei Mastodon, Bluesky oder wo auch immer teilen, sollen diese Posts gut aussehen. Deshalb wäre es schön, in der jeweiligen Vorschau auch ein Bild anzuzeigen. Das ist über spezielle Meta-Tags möglich. Allerdings haben wir nicht unbedingt für jede Seite, die wir teilen wollen auch ein Bild parat. Für diesen Fall können wir automatisch ein Bild erzeugen.

Das Plugin ermöglicht es uns, verschiedene Szenarien umzusetzen. Im Wesentlichen geht es um drei verschiedene Fälle, die wir abdecken wollen:

  1. Wir haben bereits eine Grafik erstellt, die beim Teilen verwendet werden soll
  2. Wir haben keine Grafik, eine neue soll erstellt werden
  3. Wir haben eine Grafik, diese soll in eine neu zu erstellende Grafik eingebunden werden

Der erste Fall dürfte klar sein: Wir haben bereits ein Bild und wollen das verwenden, ohne dass es verändert wird.

Die beiden anderen Fälle sind etwas komplexer. Haben wir gar keine Grafik zur Verfügung, soll eine neue Grafik erstellt und 1:1 benutzt werden. Im dritte Fall haben wir ein Beitragsbild, das soll aber nicht 1:1 als Bild zum Teilen verwendet, sondern mit Informationen angereichert werden.

Alle diese Fälle werden wir mit dem OG-Image-Plugin umsetzen.

Das Plugin installieren

Das OG-Image-Plugin kann wie alle Kirby-Plugins installiert werden. Ich halte eine Installation über Composer für die beste Methode, weil hier auch unkompliziert Updates installiert werden können, ohne dass wir selber nach Updates gucken müssen. Um das Plugin zu installieren, wechseln wir in der Kommandozeile zunächst in das Verzeichnis, in dem unsere Webseite liegt und geben den folgenden Befehl ein:

composer require mauricerenck/ogimage

Wer die Installation nicht über Composer vornehmen will, kann sich hier eine Zip-Datei herunterladen und diese dann im Verzeichnis der Webseite unter sites/plugins1 entpacken, sodass ein ogimage Verzeichnis erstellt wird.

Das Plugin ist nun bereit zum Einsatz und kann von uns jetzt konfiguriert werden. Dazu brauchen wir allerdings ein paar Dinge.

Vorbereitungen

Das Plugin braucht zwei Dateien, ohne die es nicht arbeiten kann:

  1. Ein Grafik-Template im PNG-Format
  2. Eine TrueType Schrift

Das Template kann theoretisch beliebig groß sein, empfohlen wird aber ein Bild der Größe 1600x930 Pixel. Das ist auch die Standardeinstellung für das Plugin. Du musst dir also zunächst ein solches Bild erstellen. Meines sieht so aus:

Mein Template

Wie du siehst, habe ich mich für eine simple Farbfläche entschieden. Unten zeige ich die URL der Webseite an und auf der rechten Seite habe ich einen Bereich ausgeschnitten, sodass ich einen transparenten Kreis habe. Warum ich das gemacht habe, erkläre ich später.

Ich habe mir außerdem die Schrift geschnappt, die ich auch auf meiner Webseite benutze.

Beide Dateien lege ich nun im Verzeichnis assets/og-image ab. Wir werden gleich in der Konfiguration darauf zugreifen.

Das Template verwenden

Jetzt geht es an die Konfiguration des Plugins, noch weiß es nichts von unserem Template oder der Schrift.

Wir öffnen die Konfiguration von Kirby unter site/config/config.php2

Wir legen eine neue Konfiguration für das Plugin an und geben auch direkt den Pfad zu unserer Grafikvorlage an:

"mauricerenck.ogimage" => [
    "image.template" => "assets/og-image/template.png",
],

Wie du siehst, handelt es sich um einen relativen Pfad, immer ausgehend vom Document-Root deiner Kirby-Seite.

Alle künftigen Optionen finden nun innerhalb von "mauricerenck.ogimage" => [ … ] statt.

Den Titel setzen

Als Nächstes konfigurieren wir unseren Text. Das OG-Image soll am Ende den Titel der Seite in der gewählten Schriftart anzeigen. Dazu legen wir ein paar Optionen fest. Wir beginnen mit dem Pfad zur Schriftdatei, genauso, wie wir es beim Template getan haben:

"font.path" => "assets/my-webfont.ttf",

Jetzt können wir das Bild bereits aufrufen, mehr müssten wir nicht tun. Rufen wir also einen Beitrag im Browser auf und hängen /og-image an die URL, erscheint unser neues Bild:

Erstes Resultat

Wie du sieht, hat das Plugin unser Template verwendet und den Titel mit der passenden Schriftart eingefügt. Der Transparente Bereich ist auf einmal farbig, das ändern wir später noch.

Kümmern wir uns erst einmal um unseren Titel. Zum einen soll der nicht so oben links in die Ecke gequetscht werden, außerdem ist er in der Farbe kaum lesbar. Wir werden nun also:

  1. Die Farbe ändern
  2. Die Position anpassen
"font.color" => [255, 255, 255],
"title.position" => [70, 215],

Die Schriftfarbe setzen wir mit font.color auf einen RGB-Wert, in diesem Beispiel weiß. Die Position des Titels setzen wir mit title.position. Das Array enthält zwei Werte, den X und Y Wert in Pixel.

Rufen wir das Bild im Browser nun erneut auf … ändert sich nichts. Das Plugin zeigt das Bild nicht nur einfach an, sondern legt eine Bilddatei an. Das tut es, damit nicht bei jedem Aufruf ein neues Bild berechnet werden muss. Teilen wir einen Link, könnte es ja sein, dass in kurzer Zeit sehr viele User das Bild sehen, würde es dann jedes Mal neu erzeugt werden, würde der Server vermutlich schnell in die Knie gehen.

Um die Änderungen zu sehen, müssen wir das vorhandene Bild erst einmal löschen.

Der Dateiname ist abhängig von deiner Kirby-Konfiguration, insbesondere davon, ob deine Webseite mehrsprachig ist. Das Muster lautet generated-og-image.LANGUAGE.png. Gibt es keine Sprache, wird LANGUAGE durch default ersetzt, ansonsten greift der Sprachcode

Nach dem Löschen und Neuladen im Browser sehen wir das neue Bild:

zweites Resultat

Das sieht schon besser aus. Der Text ist lesbar und klebt nicht in der Ecke. Er ragt aber in den farbigen Bereich und der Zeilenabstand ist mir persönlich noch zu hoch. Das passen wir hiermit an:

"font.lineheight" => 1.5,
"title.charactersPerLine" => 16,

Die Zeilenhöhe legen wir mit font.lineheight fest. Mit title.charactersPerLine legen wir fest, nach wie vielen Zeichen der Text umbrechen soll, sodass er nicht in die farbige Fläche ragt.

Die hier angegebenen Werte hängen stark vom Template und der Schriftart ab, hier lohnt es sich, mit einem langen Titel etwas zu experimentieren

Mit dieser Konfiguration sieht das Bild jetzt so aus:

drittes Resultat

Das gefällt mir schon ganz gut. Jetzt geht es aber ans Eingemachte. Wir werden uns im Folgenden um die oben beschriebenen, drei Szenarien kümmern.

Drei Variationen

Eine selbsterstellte Grafik

Haben wir bereits eine Grafik, die auch beim Teilen angezeigt werden soll, muss kein neues Bild erzeugt werden. Für den Fall bietet das Plugin die Möglichkeit, ein OG-Image-Feld zu konfigurieren. Standardmäßig prüft das Plugin, ob ein Feld mit dem Namen ogImage vorhanden ist. Dieser Feldname kann mit der Option field in der config.php angepasst werden. In unserem Fall passt er so und wir erstellen das Feld im Panel:

ogImage:
    type: files
    label: OG Image
    layout: cards
    multiple: false
    image:
      cover: true

Wir erstellen das Feld als Datei-Feld, in dem wir immer nur ein Bild selektieren können.

Das Plugin macht den Rest. Sobald das OG-Image abgerufen wird, schaut es in das konfigurierte Feld, ist ein Bild vorhanden, wird es ausgespielt, wenn nicht, ein neues erzeugt.

Eine Grafik einbetten

Wenn ein Beitragsbild vorhanden ist, soll dieses in unser OG-Image eingebettet werden, dafür haben wir den transparenten Bereich auf der rechten Seite angelegt.

Zuerst müssen wir dem Plugin sagen, wo es unser Beitragsbild findet. Damit es an der richtigen Position zu sehen ist, schneiden wir es zu und schieben es nach rechts:

"heroImage.field" => 'hero',
"heroImage.cropsize" => [610, 610],
"heroImage.position" => [900, 163],

Unser Kreis ist etwa 600 Pixel groß, ich habe etwas Toleranz hinzugenommen. Unser Beitragsbild liegt im Feld hero und wird auf 610x610 zugeschnitten. Wir schieben es 900 Pixel nach rechts und 163 nach unten.

Jetzt können wir ein Beitragsbild setzen und das OG-Image abrufen:

viertes Resultat

Das Plugin nutzt die Crop-Funktion von Kirby. Wenn du das Bild im Panel öffnest, kannst du einen Fokuspunkt setzen und so sicherstellen, dass der relevante Teil des Bildes beim Zuschneiden erhalten bleibt.

Den Kreis füllen

Wenn kein Beitragsbild vorhanden ist, wird der Kreis derzeit mit einer Farbe gefüllt. Selbstverständlich können wir diese Farbe ändern. In dieser Variante wollen wir den Kreis einfach mit der Hintergrundfarbe des Templates auffüllen, sodass er unsichtbar wird:

"heroImage.fallbackColor" => [40, 46, 59],

Wie schon bei der Schriftfarbe setzen wir auch hier einen RGB-Wert. Dieser entspricht nun der Hintergrundfarbe der Grafik. Unser OG-Image sieht nun so aus:

fünftes Resultat

Sehr schlicht, aber funktional.

Ein Logo anzeigen

Für mich eine Spur zu schlicht. Ich würde stattdessen lieber ein Logo anzeigen. Dafür gibt es eine weitere Option, parallel zur Fallback-Farbe, kann ich auch ein Fallback-Bild konfigurieren.

Zuerst habe ich mir ein Bild erzeugt, welches das Logo an der passenden Position enthält:

Das Fallbackbild

Jetzt kann ich dieses Bild wieder unter assets/og-image ablegen. Ich nenne es fallback.png. In der Konfiguration setze ich nun einfach den Bildpfad:

"heroImage.fallbackImage" => "assets/og-image/fallback.png",

Rufe ich das OG-Image nun erneut auf, sieht es so aus:

Og-Image mit Fallback

Das sieht doch schon ganz gut aus. Jetzt können wir natürlich weiter verfeinern, mit Schriftgrößen und Position experimentieren, aber damit lasse ich dich jetzt alleine.

Lass uns zum Abschluss noch einmal auf die gesamte Konfiguration schauen:

"mauricerenck.ogimage" => [
    "image.template" => "assets/og-image/template.png",

    "font.path" => "assets/spectral-regular-webfont.ttf",
    "font.color" => [255, 255, 255],
    "font.lineheight" => 1.5,

    "title.charactersPerLine" => 16,
    "title.position" => [70, 215],

    "heroImage.field" => 'hero',
    "heroImage.cropsize" => [610, 610],
    "heroImage.position" => [900, 163],
    "heroImage.fallbackColor" => [40, 46, 59],
    "heroImage.fallbackImage" => "assets/og-image/fallback.png",
],

Wir setzen unser Template, den Pfad zur Schrift und ihre Farbe und Zeilenhöhe. Schließlich geben wir dem Titel noch eine Position und sagen, wann er umbrechen soll. Wir konfigurieren unser Beitragsbild, setzen dafür den Feldnamen fest, sagen, dass es zugeschnitten und verschoben werden soll. Schließlich legen wir noch eine Fallback-Farbe und unser Fallback-Logo fest.

Damit ist unsere Konfiguration abgeschlossen und künftig hat jede unserer Seiten ein OG-Image, welches wir im Panel entweder selber festlegen können, oder welches sich entsprechend unser Beitragsbild oder ein Fallback schnappt.

Viel Spaß beim selbst ausprobieren!


  1. Wenn bisher noch kein Plugin installiert wurde, kann es sein, dass es das Plugin-Verzeichnis noch nicht gibt. Lege einfach einen leeren Ordner an. 

  2. Es kann sein, dass noch keine Datei existiert, dann musst du sie anlegen. Wie du das machst, kannst du in der Kirby Dokumentation nachlesen 

]]>
Mit Kirby gebaut https://maurice-renck.de/de/learn/built-with-kirby https://maurice-renck.de/de/@/page/Goz2YX6Fh7laGZBX Mon, 18 Nov 2024 00:00:00 +0100 Maurice Renck

In dieser Reihe werde ich nach und nach erklären, wie bestimmte Teile meiner Webseite funktionieren.

Um diese Webseite zu betreiben, verwende ich Kirby als Content-Management-System. Kirby ist sehr modular und entsprechend flexibel. Auf dieser Seite kommt eine Mischung aus zahlreichen Kirby-Plugins zum Einsatz, viele davon habe ich selber geschrieben, die meisten sind allerdings nicht öffentlich verfügbar, weil sie doch sehr speziell auf meinen Use-Case zugeschnitten sind.

Hier möchte ich erklären, wie meine Webseite funktioniert. Diese Seite wird also stetig weiter wachsen. Auf der rechten Seite findest du ein Inhaltsverzeichnis, damit kannst du jederzeit zwischen den Beiträgen hin und her springen.


Inhaltsangabe

]]>
Eine Kirby-Seite in zwei Wochen https://maurice-renck.de/de/blog/2024/a-kirby-site-in-two-weeks https://maurice-renck.de/de/@/page/LZm4i4cpkHMPZpVX Wed, 02 Oct 2024 09:30:00 +0200 Maurice Renck kirby-cms

Webseiten bauen ist viel zu kompliziert geworden? Wir haben eine Kirby-Seite in nur zwei Wochen gebaut, während mein Kollege in China saß und mit einer wackeligen Internetverbindung über VPN kämpfte.

Immer wieder lese ich davon, wie kompliziert es geworden ist, eine einfache Webseite zu bauen und online zu bringen. Etliche Frameworks, aufgeblähte CMS, komplizierte Deployments. Mein Kollege und ich haben uns vorgenommen, seine neue Webseite so schnell es geht online zu bringen.

Home - YADL

Mark ist seit 17 Jahren Full-Stack-Entwickler mit einer Leidenschaft für Event-Driven Architekturen. Er gründete ein Startup und hat eine eigene kleine Agentur. Er spielt Tischtennis und Rollenspiele.

Wir bei konzentrik bauen gerne kleine und große, hilfreiche Tools und Dienste, die uns und vielen anderen das Leben leichter machen sollen. Aber was bringt das ganze Entwickeln, wenn niemand davon mitbekommt?

Auf meiner Webseite habe ich für mich einen thematischen Schwerpunkt definiert, aus dem ich nur ungern ausbreche. Viele unserer Tools passten bisher nur sporadisch hier rein (unser Crossposter z.B.). Ich grübelte eine ganze Weile, wie sich das ändern lässt.

Vor etwas mehr als zwei Wochen sprach ich Mark dann darauf an. Mark saß zu dem Zeitpunkt gerade in China, hatte nur eine mäßig gute Internetverbindung und der Einsatz eines VPNs machte das nicht besser.

"Mark, ich werde dich jetzt zu etwas zwingen, was dir nicht so sehr gefallen wird."

Mark schaute mich leicht überrascht an und erfuhr dann, dass ich ihn zwingen würde, ein eigenes Blog zu führen. Für mich immer noch der beste Weg, um online präsent zu sein. Mark hat sich selbst nie als jemand gesehen, der gerne Artikel schreibt, deshalb wusste ich, dass er nicht in Begeisterung ausbrechen würde.

Ich schilderte ihm, was mir vorschwebte und wie ich das angehen würde, und er ließ sich darauf ein, hatte den Gedanken eines eigenen Blogs auch schon länger mit sich herumgetragen. Ich will nicht zu sehr spoilern, aber nach etwas mehr als einer Woche erreichte mich diese Nachricht:

Unser Ziel

Bis Mark wieder in zurück in Deutschland ist, wollten wir seine Webseite online haben, das waren etwas mehr als zwei Wochen. Die Webseite sollte gut aussehen, ein CMS als Grundlage haben, ans Fediverse angeschlossen sein und möglichst viel automatisch tun.

Für einen kurzen Moment stand noch im Raum, ob wir medium fürs Blog nutzen sollten, um es schneller online zu bekommen. Das haben wir aber schnell wieder verworfen, wollte uns auf keinen Fall von einer Plattform abhängig machen. Also fiel die Wahl rasch auf Kirby. Mein eigenes Blog sollte als Grundlage für einzelne Komponenten dienen – wir wollten das Rad nicht neu erfinden.

Die Basics

In der folgenden Woche legten wir dann auch direkt los. Über das Wochenende gab ich Mark die Aufgabe, darüber nachzudenken, wie er sich nach außen präsentieren, wofür er stehen möchte.

Mark hatte nicht nur eine Idee dafür, sondern auch schon einen möglichen Namen im Gepäck. Wir setzen uns also daran, grundlegende Aufgaben zu erledigen:

  • Eine Domain sichern
  • Ein Design-Template kaufen
  • Hosting einrichten
  • Kirby Lizenz kaufen

Eine gute Domain zu finden, ist ja nicht mehr allzu einfach, aber wir sind recht schnell fündig geworden und konnten sie über unseren Hoster registrieren. Dort richtete ich auch gleich ein minimales Deployment ein, welches über Git läuft.

Mark fand ein schönes Template und kaufte es prompt. Damit hatten wir alle notwendigen Grundlagen. In China wurde es bereits spät, richtig loslegen würden wir also erst am kommenden Tag.

Wir legten mit der Installation von Kirby los. Hier entschieden wir uns für das Plainkit, welches wir schnell über composer installierten. Dann schlug ich zu und setzte mein Public-Folder-Setup auf, eine Verzeichnisstruktur, bei der nur ein einzelnes Verzeichnis nach außen hin verfügbar gemacht wird, der Rest der Installation liegt außerhalb und ist somit nicht direkt abrufbar.

Plugins

Damit alles so läuft, wie wir uns das vorstellen, kommen ein paar Plugins zum Einsatz:

  • IndieConnector für Webmentions und Mastodon-Posts
  • Komments (beta) für Kommentare und dem Anzeigen von Webmentions
  • PexelsField um direkt im Panel Stockphotos zu suchen
  • Darkvisitors um Sitemaps verfügbar zu machen und bots steuern zu können
  • Staticache um statisches HTML zu erzeugen, damit die Seite schön schnell bleibt
  • Mein RSS-Feed-Plugin für RSS- und Atom-Feeds
  • Mein Page-Method-Plugin für eine Auswahl an praktischen Page-Methoden

Das Template / Design

Wir wollten auf keinen Fall selbst anfangen, ein Design zu bauen, das hätte viel zu lange gedauert und wir sind auch einfach keine Designer. Mark hat sich also für ein Tailwindcss Template entschieden. Wir haben uns die HTML-Templates geschnappt und sie zerstückelt. Nun haben wir verschiedene Snippets zur Hand, die wir auf allen Seiten verwenden können. Essenziell natürlich der Header und Footer.

Den Header haben wir noch um zahlreiche Meta-Tags erweitert. Hauptsächlich OG-Tags zum Teilen von Beiträgen, die sorgen z.B. dafür, dass bei Mastodon und Co. ein Vorschaubild zu sehen ist.

Außerdem haben wir bereits die notwendigen Tags eingebaut, um später Webmentions empfangen zu können.

Das Blog

Angefangen haben wir mit dem Bloglisting. Das Listing basiert aus verschiedenen Komponenten, die wir so angelegt haben, dass wir sie an anderen Stellen wieder verwenden können:

  • Bloglisting Template - Das eigentliche Template für die Darstellung des Blogs. Das Template enthält möglichst wenig Logik und Daten.
  • Bloglisting Controller - Hier werden Daten zusammengesammelt, sortiert, paginiert und ans Template weitergereicht
  • Bloglisting Collection - Als Quelle für alle Blogbeiträge. Die Collection enthält alle Beiträge und kann auch auf anderen Seiten verwendet werden
  • Bloglisting Eintrag - Ein einzelner Beitrag im Listing. Den haben wir als Snippet abgelegt und konnten ihn so auf der Startseite und den Fehlerseiten mit automatischer Suche wieder verwenden
  • Paginierung - Wenn es bald mehr Beiträge gibt, soll man durch das Blog blättern können. Dazu haben wir ein Snippet für die Paginierung angelegt, das wir auch auf anderen Seiten benutzen werden

Schließlich haben wir ein weiteres Template zum Anzeigen eines einzelnen Blogposts erstellt. Hier folgten wir wieder derselben Logik. Hier haben wir noch die Besonderheit, dass wir auf Microformats geachtet haben. Damit können wir später steuern, welche Daten erfasst werden, wenn wir Webmentions verschicken.

Natürlich benötigten wir auch noch die passenden Blueprints für das Bloglisting, denn Mark will zwar in Markdown schreiben, aber komfortabel im Kirby-Panel. Wir haben also zunächst das Blueprint für das Listing und dann auch direkt für Blogposts angelegt:

Anschluss ans IndieWeb

Damit wir Webmentions empfangen und senden können, haben wir das IndieConnector-Plugin installiert. In Kombination mit dem Komments-Plugin können wir so unter jedem Post Feedback von Leser:innen sammeln, und zwar direkt im Blog, aber auch in Netzwerken wie Mastodon.

Zusätzlich kann der IndieConnector auch automatisch Posts bei Mastodon veröffentlichen, sobald ein Beitrag veröffentlicht wird. Wir haben uns entschieden, Bluesky-Posts nicht auf diesem Weg zu erstellen, denn Mark nutzt unseren Crossposter, um alles von Mastodon zu Bluesky zu synchronisieren und beides zusammen, würde zu Dopplungen führen.

Mark kann nun also im Blog einen Beitrag veröffentlichen und dieser wird zugleich bei Mastodon gepostet und von dort aus zu Bluesky synchronisiert. Dafür muss er nichts weiter tun.

Ein Blog ohne RSS-Feed ist kein richtiges Blog, weshalb wir mein RSS-Feed-Plugin zu Mark kopiert und es etwas angepasst haben. Man kann Marks Blog nun also auch per RSS-Reader abonnieren.

Ebenso sind wir mit der Sitemap verfahren, die wir direkt bei Google eingereicht haben, damit die Google-Bots möglichst zeitig mit dem Indizieren loslegen können.

Notizen und Seiten

Ähnlich wie das Blog gibt es auch ein Listing für Notizen. Das sind kurze Beiträge, die zu kurz sind, um als Blogpost durchzugehen. Der Aufbau erfolgte analog zum Blog.

Für Seiten wie /now und /uses haben wir einfache Seitentemplates angelegt. Komplexere Seiten, wie about und die Startseite bestehen derzeit noch hauptsächlich aus statischem Content, den Mark jetzt Schritt für Schritt überführen wird.

Finetuning

Zwei Wochen waren indessen fast herum, die ersten Beiträge sind online und wir haben noch ein paar Verbesserungen eingebaut.

  • Die Fehlerseite führt – wie auch bei mir – eine Suche anhand der URL aus und zeigt eine List möglicher Beiträge an.
  • Die Seite läuft zweisprachig, was dank Kirby überhaupt kein Problem darstellt. Fehlende Übersetzungen quittieren wir allerdings mit einer 404 Seite, anstatt sie unübersetzt anzuzeigen
  • Wir haben das Staticache-Plugin aktiviert, damit die Seite möglichst schnell lädt. Nur bei Änderungen im Content wird der Cache neu erstellt.
  • Tags und Kategorien helfen beim Strukturieren der Daten, beim Auffinden ähnlicher Artikel und beim Filtern im Bloglisting

Fazit

Wir haben es wirklich geschafft, eine ansehnliche Webseite in nicht ganz zwei Wochen online zu bringen. Mit einem Deployment-Ablauf, Kirby als CMS, Mehrsprachigkeit, Anschluss ans IndieWeb und ersten Beiträgen im Blog und den Notizen.

Wir haben uns da – zurecht, wie ich finde – gegenseitig auf die Schultern geklopft und freuen ins tierisch, dass das so gut funktioniert hat und dabei auch noch so viel Spaß gemacht hat.

Wir konnten viele Hürden natürlich deshalb umgehen, weil ich schon auf meiner eigenen Seite darüber gestolpert bin. Das hat mich weiter darin bestärkt, an meinem Kurs zu arbeiten, der sich genau damit beschäftigt: von der Idee zum fertigen Full-Feature-Blog. Dort werde ich auf alle Details eingehen. Unser Unterfangen mit Marks Blog war ein hervorragender Pilotversuch für diesen Kurs.

Noch schreibe ich daran, aber wenn du Interesse hast, kannst du dich hier in den Verteiler eintragen. Ich verschicke hier keinen Newsletter oder so, sondern gebe wirklich nur Bescheid, wenn sich etwas bezüglich des Kurses tut:

(buttondown: https://buttondown.com/mauricerenck?as_embed=true)

]]>
Related Pages https://maurice-renck.de/de/learn/built-with-kirby/related-pages https://maurice-renck.de/de/@/page/ygeLYsF5TybOtlfP Fri, 16 Feb 2024 09:15:00 +0100 Maurice Renck kirby-cms

Auf diese Weise zeige ich ähnliche Seiten unter jedem Beitrag meiner Webseite an.

Unter den Beiträgen auf meiner Webseite, möchte ich weitere Artikel empfehlen, die zum Thema passen. In den ersten Versionen meiner Webseite habe ich diese Auswahl noch selbst vorgenommen. Das ist natürlich mit der Zeit etwas umständlich, denn es kommen schließlich ständig neue Beiträge hinzu und die Relevanz untereinander ändert sich laufend. Ich müsste also immer wieder alte Artikel bearbeiten, damit die Zuordnungen wieder stimmen.

Natürlich war und bin ich nicht der Einzige, der so etwas auf seiner Webseiten hat. Weshalb es schnell entsprechende Kirby-Plugins und diesen Cookbook-Artikel gab.

Nach einiger Zeit stelle sich für mich allerdings heraus, dass die angebotenen Plugins und Beispiele nicht die Ergebnisse lieferten, die ich mich vorgestellt habe. Deshalb habe mich dazu entschlossen, mich genauer mit dem Thema auseinanderzusetzen und eine eigene Lösung zu bauen.

Auch abseits von "Related Posts" finde ich das Thema "Auffindbarkeit" extrem spannend. Wir alle haven vermutlich irgendwo Inhalte im Netz, von denen wir wollen, dass sie nicht sang- und klanglos in der Masse untergehen. Im großen Maßstab möchte man in Suchmaschinen gefunden werden, im kleineren Maßstab sind es vielleicht Empfehlungen zu anderen Beiträgen auf der eigenen Webseite.

Für Kirby gibt es aktuell ein Plugin, was sich dem Problem annimmt: Similar.

Similar hatte ich eine ganze Weile im Einsatz, an einigem Stellen, war es mir aber nicht genau genug und das habe ich auch durchs Verfeinern von Einstellungen nicht in den Griff bekommen. Ich habe mir dann angesehen, wie Sonja die Sache angeht und mir ein paar Ideen gestohlen und auf meine Seite übertragen.

Um ähnliche Beiträge zu finden, werde ich auf drei Content-Felder zurückgreifen:

  1. Tags
  2. Text
  3. Titel

Tags

Ich verpasse jedem Beitrag, den ich veröffentliche, mehr oder weniger viele Tags. Dazu verwende ich Kirbys Tag-Field. Tags sind bei mir global, das heißt, dass ich mir von allen Seiten alle Tags hole und mir diese dann als Autocomplete anzeigen lasse.

Im Panel sieht das so aus:

Das passende Blueprint dazu wie folgt:

label: Tags
type: tags
options: query
query: site.index.pluck("tags", ",", true)
translate: false

Via query hole ich mir den Inhalt aller Tag-Felder, um diese als Autocomplete zu haben. Eine Übersetzung soll nicht möglich sein, die Tags gelten für jede Sprache.

Diese Tags als Quelle für relevante Seiten zu verwenden, ist einfach. Zunächst hole ich mir die Tags der aktuellen Seite. Dann schaue ich in allen anderen veröffentlichten Seiten nach gleichen Tags:

$tags = $this->tags()->split(',');
$pagesWithTags = site()->index()->published()->filterBy('tags', 'in', $tags, ',')->not($this);

Da ich die Suche nach Related Pages in einem Page-Method-Plugin aufrufe, verweist in all diesen Beispielen $this auf die aktuelle Seite. Nachdem ich alle Tags der aktuellen Seite habe, filtere ich alle anderen Seiten nach diesen Tags. Als Resultat bekomme ich eine Liste an Seiten, die alle mindestens einen Tag mit meiner aktuellen Seite gemeinsam haben.

Da die aktuelle Seite auf jeden Fall immer im Resultat vorkommen würde, schließe ich sie noch aus, ich will natürlich nicht auf dieselbe Seite verweisen.

Jetzt könnte ich schon alle Seiten mit denselben Tags auflisten, das ist mir aber nicht genau genug. Viel mehr möchte ich noch in der Lage sein, den drei oben genannten Feldern eine Gewichtung zu geben. Das habe ich von Sonja geklaut.

Dazu gehe ich alle gefunden Seite noch einmal durch, hole mir die einzelnen Tags jeder Seite und lasse mir nur die Tags zurückgeben, die auch in der aktuellen Seite hinterlegt sind.

Hat meine aktuelle Seite die Tags kirby, cms und plugin, die ähnliche Seite kirby, cms und theme bekomme ich auf diese Weise die beiden identischen Tags kirby und cms als Ergebnis zurück.

Um mir alle ähnlichen Seiten zu merken, fülle ich ein Array. Ich will an dieser Stelle nicht die gesamte Seite speichern, sondern lediglich ihre ID, über die ich dann später wieder auf sie zugreifen kann.

Außerdem möchte ich wissen, wie relevant jede einzelne Seite wirklich ist, dazu zähle ich die identischen Tags und errechne daraus eine Gewichtung. Wie stark jedes Feld gewichtet werden soll, kann ich später in meiner config.php ändern:

foreach($pagesWithTags as $page) {
    $pageTags = $page->tags()->split(',');
    $similarTags = array_intersect($pageTags, $tags);
    $uuid = $page->uuid()->toString();

    $similarPages[] = [
        'page' => $uuid,
        'weight' => count($similarTags) * option('mauricerenck.similar-related.tags.weight', 1),
    ];
}

Als Ergebnis habe ich nun eine Liste von Seiten und ihre jeweilige Gewichtung.

Titel

Im nächsten Schritt schnappe ich mir den Titel, um auch hier Ähnlichkeiten zu erkennen. Das ist nicht ganz so naheliegend, wie es bei den Tags war, aber auch recht unkompliziert umzusetzen.

Um Ähnlichkeiten zu finden, vergleiche ich alle Titel Wort für Wort. Dazu hole ich mir den Titel der aktuellen Seite und teile ihn in seine Bestandteile. Dann geht es wieder an das Filtern aller veröffentlichten Seiten. Dieses Mal mit einem eigenen Filter.

Für jede dieser Seiten hole ich mir den Titel und teile ihn in einzelne Worte. Ich habe nun ein Array mit Worten von beiden Titeln und kann diese vergleichen. Wie bei den Tags schaue ich jetzt, ob es mindestens eine Übereinstimmung gibt. Gibt es die, bekomme ich die Seite als Ergebnis zurück. Und natürlich möchte ich auch hier die aktuelle Seite ausschließen:

$wordsFromTitle = $this->title()->split(' ');
$pagesWithTitle = site()->index()->published()->filter(function($child) use($wordsFromTitle) {
    $wordsFromChildTitle = $child->title()->split(' ');
    return count(array_intersect($wordsFromTitle, $wordsFromChildTitle)) > 0;
})->not($this);

Im nächsten Schritt geht es wieder daran, das Ergebnis-Array zu füllen. Wieder gehe ich durch alle gefundenen Seiten und gewichte sie. Das funktioniert genauso wie bei den Tags:

foreach($pagesWithTitle as $page) {
    $wordsFromPageTitle = $page->title()->split(' ');
    $similarWords = array_intersect($wordsFromTitle, $wordsFromPageTitle);

    $uuid = $page->uuid()->toString();

    $similarPages[] = [
        'page' => $uuid,
        'weight' => count($similarWords) * option('mauricerenck.similar-related.title.weight', 0.5),
    ];
}

Text

Schließlich kommen wir zum aufwändigsten Feldtypen. Ich möchte so weit gehen, dass ich sogar jeden einzelnen Text Wort für Wort miteinander vergleiche. Hier wird es etwas knifflig, denn ich muss auch hier den gesamten Text in seine einzelnen Worte aufteilen. Das kann bei einem langen Text natürlich eine ziemlich lange Wortliste werden und trägt sicherlich nicht dazu bei, dass die Seite schneller lädt. Dazu aber später mehr.

Ich hole mir auch hier das Feld als Quelle und teile es in einzelne Worte. In diesem Fall möchte ich nur Worte haben, die länger als ein Zeichen sind. Das schließt im Englischen schon mal ein paar Füllwörter wie I oder a aus:

$wordsFromText = $this->text()->split(' ');
$wordsFromText = array_filter($wordsFromText, function($word) {
    return strlen($word) > 1;
});

Nun geht es ans Aufräumen, ich möchte nur "echte" Wörter haben:

$wordsFromText = array_map(function($word) {
    return preg_replace('/[^A-Za-z0-9\-]/', '', $word);
}, $wordsFromText);

Jetzt wird es interessant. Um ein möglichst genaues Ergebnis zu bekommen, werde ich bestimmte Worte ausschließen. Das sind Worte, die im eigentlichen Sinne nichts mit dem Inhalt zu tun haben. Schwer zu beschreiben. Hier ein Beispiel:

Meine Seite besteht aus diesem Text:

Ich möchte eine Webseite mit dem CMS Kirby erstellen, dazu schreibe ich mir ein Blueprint und ein Template

Für meinen Vergleich interessieren mich bestimmte Worte gar nicht, etwa Ich, eine, mit, dem, ein, … Diese Worte kommen in so gut wie jedem Text vor und würden das Ergebnis verwässern. Wirklich interessant sind hier nur Worte wie Webseite, CMS, Kirby, Blueprint und Template.

Würde ich ausschließlich auf Deutsch schreiben, könnte ich mir das Leben recht einfach machen und einfach alle großgeschriebenen Worte holen. Im Englischen funktioniert das allerdings nicht.

Wie also vorgehen? Ich habe eine sehr lange Liste von sogenannten Stopwords. Das sind solche Füllwörter, wie oben beschrieben. Zum Glück bin ich nicht der Einzige, der vor so einem Problem steht und es gibt ein paar gut gepflegte Listen da draußen im Netz. Ich habe mich für die ISO-Stopwords entschieden, die gleich in mehreren Sprachen daher kommen.

Die Daten liegen mir als JSON-Datei vor. Ich muss die Datei laden. Vorher hole ich mir die Sprache der aktuellen Seite. In meinem Fall ist das entweder Deutsch oder Englisch:

$pageLanguage = kirby()->language()->code() ?? 'en';
$stopWordsForLanguage = [];

$languagesJson = file_get_contents(__DIR__ . '/stopwords-iso.json');

if($languagesJson !== false) {
    $stopWords = json_decode($languagesJson);
    $stopWordsForLanguage = (isset($stopWords->$pageLanguage)) ? $stopWords->$pageLanguage : $stopWords->en;
}

Sicherheitshalber prüfe ich noch, ob die Datei geladen werden konnte. Wenn nicht, gibt es einfach eine leere Liste. Ansonsten schaue ich nach, ob die aktuelle Sprache in den Daten vorhanden ist. Sollte das nicht der Fall sein, greift mein Fallback und ich nutze Englisch.

Nun geht es wieder ans Filtern. Ich entferne alle Stopwords aus der Wortliste der aktuellen Seite:

$wordsFromText = array_filter($wordsFromText, function($word) use($stopWordsForLanguage) {
    return !in_array(strtolower($word), $stopWordsForLanguage);
});

Und jetzt gehe ich den üblichen Weg mit einem eigenen Filter über alle Seiten. Dieses Mal vergleiche die Worte des Textes. Da ich in meinem Quelldaten jetzt schon keine Stopwords mehr habe, muss ich sie nicht auch noch bei jeder einzelnen Seite herausfiltern:

$pagesWithText = site()->index()->published()->filter(function($child) use($wordsFromText) {

    if($child->text()->isEmpty()) return false;
    $wordsFromChildText = $child->text()->split(' ');

    return count(array_intersect($wordsFromText, $wordsFromChildText)) > 0;

})->not($this);

Ich prüfe ich noch, ob die Seite auch wirklich einen Text hat. Es könnte Seiten geben, die nur ein Listing sind oder Blöcke oder Layouts nutzen. Die will ich nicht in meinem Ergebnis haben und schließe sie direkt aus. Den Rest prüfe ich wieder auf mindestens eine Übereinstimmung und nehme sie entsprechend in die Liste auf.

Jetzt geht es wieder in die Schleife durch alle Resultate und ans Füllen des Ergebnis-Arrays:

foreach($pagesWithText as $page) {

    $wordsFromPageText = $child->text()->split(' ');
    $similarWords = array_intersect($wordsFromText, $wordsFromPageText);
    $uuid = $page->uuid()->toString();

    $similarPages[] = [
        'page' => $uuid,
        'weight' => count($similarWords) * option('mauricerenck.similar-related.text.weight', 0.95),
    ];

}

Nun habe ich im besten Fall eine sehr lange Liste an ähnlichen Seiten. Manche dieser Seiten haben dieselben Tags, manche einen ähnlichen Titel oder Text. Es kann sein, dass Seiten mehrfach vorkommen, das ist sogar sehr wahrscheinlich, denn wenn die Tags sich schon gleichen, dann werden sich vermutlich auch Textfragmente gleichen. Hat eine Seite den Tag kirby, ist die Wahrscheinlichkeit ziemlich hoch, dass auch im Fließtext das Wort Kirby vorkommt und die Seite demnach zweimal in der Liste zu finden ist.

Es gibt zwei Möglichkeiten, damit umzugehen:

Ich könnte von Anfang an, doppelte Seiten ausschließen. Habe ich schon eine Liste an Seiten, die ähnliche Tags haben, könnte ich diese beim Abfragen ähnlicher Titel bereits ausschließen. Beim Text könnte ich dann bereits Seiten ausschließen, die sowohl ähnliche Tags als auch Titel haben.

Ich möchte hier aber etwas schlauer damit umgehen. Ich gehe davon aus, dass eine Seite, die ähnliche Tags und einen ähnlichen Titel und einen ähnlichen Text hat, viel relevanter ist, als eine Seite, die sich nur einen Tag teilt oder in der sich ein paar gleiche Worte wiederfinden.

Deshalb ist der nächste Schritt das Zusammenführen der Daten und die Gewichtung der jeweiligen Seite unter Rücksichtnahme verschiedener Vorkommen:

$result = [];
foreach($similarPages as $page) {
    $uuid = $page['page'];

    if(!isset($result[$uuid])) {
        $result[$uuid] = [
            'page' => $uuid,
            'weight' => $page['weight'],
        ];
    } else {
        $result[$uuid]['weight'] += $page['weight'];
    }
}

Zuerst erstelle ich mir ein leeres Array für mein Ergebnis, dann gehe ich durch alle ähnlichen Seiten und hole mir ihre UUID. Taucht die Seite bisher nicht in meiner Ergebnisliste auf, füge ich sie hinzu. Als Array-Schlüssel nutze ich ihre UUID, wieder merke ich mir die UUID und die Gewichtung.

Taucht die Seite bereits in der Liste auf, füge ich sie nicht erneut hinzu, sondern addiere die Gewichtung. Steht eine Seite also dreimal in der Liste, nämlich mit Tags, Titel und Text, so werden alle drei Gewichtungen addiert.

Schließlich habe ich eine Liste aller Seiten, ohne Duplikate, mit der Summe Ihrer jeweiligen Gewichtungen. Jetzt möchte ich sie noch nach Gewicht sortieren:

usort($result, fn($a, $b) => $a['weight'] <=> $b['weight']);

Da ich in meinem Template wenig mit einem Array von UUIDs anfangen kann, wird mein Ergebnis im letzten Schritt noch in eine Page Collection umgewandelt:

$pages = array_map(function($page) {
    return page($page['page']);
}, array_reverse($result)) ?? [];

Als Ergebnis habe ich nun eine Collection aller passenden Seite, mit der ich nun im Template arbeiten kann. Ich kann mir also von jeder Seite den Titel mit $pageFromCollection->title(); ausgeben lassen, oder meine Collection noch einmal filtern.

Ich muss meine Collection noch ans Template zurückgeben:

return new Collection($pages);

Das ganze Konstrukt habe ich in ein Plugin verpackt und als pageMethod zur Verfügung gestellt:

kirby::plugin('mauricerenck/related-pages', [
    'pageMethods' => [
    'relatedPages' => function () {
        // CODE
    }
];

In meinem Template kann ich indessen einfach diese Methode aufrufen, bekomme eine Collection von Seiten zurück und kann damit etwas anstellen. Ich limitiere sie noch auf drei Einträge und zeige diese dann in einer Schleife an:

$related = $page->relatedPages()->limit(3);

// Render related pages

Ein Wort der Warnung

Ich bin mit dem Ergebnis sehr zufrieden. Die Related-Pages, die auf den Seiten angezeigt werden, passen meist ziemlich gut. Ich überlege noch, ob den ich den zeitlichen Aspekt einbringe, also neuere Seiten höher gewichte, als ältere Seiten.

Was jedoch gesagt werden muss: Bei einer Webseite mit ziemlich vielen Seiten und/oder langen Texten könnte der Ansatz zu Problemen führen. Da passiert ziemlich viel, gerade beim Textvergleich und das kann unter Umständen lange dauern, im schlimmsten Fall zu Timeouts oder Speicherüberläufen führen.

Vermutlich wäre es daher schlauer, den ganzen Prozess nicht bei jedem Seitenaufruf durchzuführen, sondern via Cronjob oder Hook. Das Ergebnis könnte man dann in der jeweiligen Seite speichern. Beim Aufruf der Seite muss dann nichts mehr berechnet werden.

Für mich wäre das der nächste Schritt meines kleinen Plugins. Derzeit läuft es theoretisch noch bei jedem Seitenaufruf. Das sehe ich bei mir als nicht so kritisch an, weil ich alle Seiten cache. Der Cache wird nur geleert, wenn ich den Code der Seite aktualisiere, oder der Inhalt sich ändert. Dann wird beim Seitenaufruf das obige Prozedere durchlaufen, danach aber nur noch statisches HTML ausgeliefert. Geschwindigkeitseinbußen kann ich auf meiner Seite bisher nicht feststellen.

Ich überlege, meinen Code noch etwas zu sortieren, vielleicht obige Anmerkungen noch einzubauen und dann zu veröffentlichen. Allerdings nur, wenn Interesse besteht – das Plugin von Sonja funktioniert ja hervorragend.

Lass mich doch wissen, ob du das Plugin nutzen würdest!

]]>
Blogroll https://maurice-renck.de/de/learn/built-with-kirby/blogroll https://maurice-renck.de/de/@/page/8aAmuSGf0ZCLYQRr Tue, 30 Jan 2024 20:00:00 +0100 Maurice Renck kirby-cms

So habe ich die Blogroll auf meiner Webseite umgesetzt

In meinem Blog gibt es seit geraumer Zeit eine Blogroll. Was genau es damit auf sich hat, habe ich hier beschrieben. An dieser Stelle möchte ich erklären, wie ich meine Blogroll mit Kirby umgesetzt habe.

Meine Blogroll ist ziemlich simpel aufgebaut: In meinem Blog-Blueprint habe ich ein Structure-Feld, welches lediglich aus zwei Feldern besteht:

  1. Der URL des Blogs
  2. Dem Titel des Blogs

Bisher wurde unter meinem Bloglisting unter anderem die Blogroll in Icon-Form angezeigt. Das möchte ich erweitern, sodass die Blogroll auch unter /blog/blogroll erreichbar ist und ich somit einen Deeplink habe.

Da ich die Blogroll nicht als eigenständige Seite im CMS liegen haben möchte, werde ich eine Route für die Blogroll anlegen und dort eine virtuelle Seite ausspielen.

Die Route

In der Datei site/config/config.php lege ich eine neue Route. Ich möchte, dass die Blogroll unter allen Sprachen unter blog/blogroll erreichbar ist:

[
    'pattern' => 'blog/blogroll',
    'language' => '*',
    'action' => function ($language) {
      // […]
    }
]

Damit kann die Seite unter der entsprechenden URL ausgeliefert werden. Würden wir diese URL jetzt aufrufen, bekämen wir allerdings einen 404-Fehler an den Kopf geworfen. Eine Route benötigt immer entweder eine Ausgabe oder muss auf eine andere Route weiterleiten.

Deshalb lege ich im nächsten Schritt eine virtuelle Seite an, die wir dann zurückgeben können:

    $data = [
        'slug' => 'blogroll',
        'parent' => page('blog'),
        'template' => 'listing-blogroll',
        'translations' => []
    ];

    $page = Page::factory($data);
    site()->visit($page, $language);

    return $page;

Zunächst lege ich den Slug fest, also blogroll, dann gebe ich die übergeordnete Seite an, in meinem Fall das Blog. Schließlich benötigt die Seite noch ein Template, damit sie gerendert werden kann. Ich verwende hier ein spezielles Blogroll-Template. Auf die Übersetzungen werde ich später noch zu sprechen kommen.

Template

Ich werde hier nicht das komplette Template beschreiben, sondern nur die relevanten Teile. Das sind im Wesentlichen der Seitenkopf mit einer kurzen Beschreibung und das eigentliche Listing.

Der Seitenkopf besteht aus dem Titel und einer kurzen Beschreibung:

<div class="page-intro">
    <h1><?= $page->title(); ?></h1>
    <?= $page->intro()->kt(); ?>
</div>

Das Listing verwendet ein Snippet, welches ich in den Notizen schon im Gebrauch habe:

<ul class="note-list">
    <?php foreach ($blogroll as $blog) : ?>
        <?php $site->organism('list-entry-note', ['note' => $blog]); ?>
    <?php endforeach; ?>
</ul>

Hier sei kurz angemerkt, dass ich für Snippets zwei Site-Methods nutzen, die ich mir selbst geschrieben haben. In diesem Fall organism(). Im Grunde funktionieren sie genauso wie Kirbys snippet() Funktion. Ich nutze meine Methoden, um etwas mehr Ordnung in meine Snippets zu bekommen. Darauf komme ich bestimmt in einem anderen Beitrag noch zu sprechen.

Mein Snippet list-entry-note bekommt also ein Blog aus meiner Blogroll hereingereicht und stellt es dann dar. Es braucht drei Informationen:

  1. Einen Titel
  2. Eine Url
  3. Ein Icon

Die Daten kommen aus dem entsprechenden Controller

Der Controller

Controller dienen in Kirby dazu, die Templates von Datenlogik zu befreien. Ich mag den Ansatz und versuche deshalb meine Templates möglichst dumm zu halten. Alles, was mit Daten zu tun hat, sollte möglichst im Controller passieren.

So sieht der Controller für die Blogroll aus:

return function ($page) {
    $blogroll = page('blog')->blogroll()->toStructure();
    $blogEntries = [];

  foreach ($blogroll as $blog) {

    $url = str_replace(['https://', 'http://', '/'], ['', '', ''], Url::stripPath($blog->url()->value()));
    $icon = $page->getSiteIcon($url);

    $blogEntries[] = new StructureObject([
        'content' => [
            'title' => $blog->title(),
            'url' => $blog->url(),
            'icon' => $icon,
            'intendedTemplate' => 'blogroll'
        ]
    ]);
    }

    $blogrollStructure = new Structure($blogEntries);

  return [
    'blogroll' => $blogrollStructure,
  ];
};

Zunächst hole ich mir das Blog, denn dort sind die Daten hinterlegt. Ich greife direkt auf das Blogroll-Feld zu und lasse es mir als Struktur zurückgeben. Dann fülle ich das $blogEntries Array mit Daten. Es bekommt jeweils den Titel, die Url und ein Icon.

Für das Icon verwende ich eine eigene Methode, die versucht, das Favicon einer Seite zu holen und ggf. einen Fallback liefert. Das Ergebnis ist ein data:image/png;base64String. Also keine Bild-URL. Das ermöglicht mir, die Favicons eine Weile zu cachen. So muss ich nicht bei jedem Seitenaufruf etliche andere Seiten anfragen (dazu in einem anderen Post mal mehr).

Schließlich erzeuge ich mir aus dem Array eine neue Struktur, denn mein Note-Entry-Snippet erwartet das so (es bekommt ja normalerweise eine Notiz rein und das ist eine Kirby-Page).

Abrunden der virtuellen Seite

Jetzt bin ich fast so weit, es fehlen noch ein paar Informationen in der virtuellen Seite, denn diese benötigt u. a. einen Titel und eine Beschreibung. Diese verstecken sich in den Übersetzungen. Ich habe mich dazu entschiede diese Daten zunächst nicht im Panel zu pflegen, weil ich sie vermutlich nicht besonders häufig anpassen werde:

'translations' => [
    'en' => [
        'code' => 'en',
        'content' => [
            'title' => 'Blogroll',
            'date' => '2024-01-30',
            'intro' => 'Blog I read regularly and can recommend.',
            ]
        ],
    'de' => [
        'code' => 'de',
        'content' => [
            'title' => 'Blogroll',
            'date' => '2024-01-30',
            'intro' => 'Blogs, die ich regelmäßig lese und die ich empfehlen kann.',
            'uuid' => Uuid::generate(),
        ]
    ]
],

Wie man sieht, stehen die nötigen Daten nun in der virtuellen Seite. Die deutsche Sprachvariante bekommt zusätzlich noch eine uuid, sie ist bei mir die Hauptsprache.

Die fertige Blogroll

Damit ist die Seite nun unter blog/blogroll abrufbar. Das Template bekommt die Daten vom Controller und rendert sie mithilfe des Snippets. Das Resultat könnt ihr hier sehen.

Neue Einträge kann ich somit weiterhin einfach im Blueprint vom Blog vornehmen und die virtuelle Seite kümmert sich um den Rest, ohne, dass ich im Panel noch extra eine Seite dafür anlegen muss.

]]>
Crosspost von Mastodon zu Bluesky https://maurice-renck.de/de/learn/tooling/crosspost-from-mastodon-to-bluesky https://maurice-renck.de/de/@/page/MrYmdXwbzDM0ajBX Thu, 11 Jan 2024 16:45:00 +0100 Maurice Renck indieweb

Mastodon Posts automatisch auch bei Bluesky veröffentlichen - so geht's

Da das Interesse an dem Tool so groß ist, haben wir uns dazu entschlossen, einen Service daraus zu machen. Du kannst dich gerne für die kostenlose Betaphase registrieren, wenn du das Script nicht selbst hosten möchtest.

Sociabli

Sociabli helps you to share your content online. Post once and let Sociabli sync to other services and platforms.

Neben Mastodon, tummeln sich inzwischen zwei andere neue Netzwerke, Threads.net und Bluesky. Während Threads gerade erst zögerlich an einer Integration von ActivityPub arbeitet und bisher keinerlei API bereitstellt, stehen bei BlueSky bereits ein paar Schnittstellen zur Verfügung.

Dank API ist es dort möglich, Beiträge zu posten, ohne die App oder Webseite benutzen zu müssen. Das können wir nutzen, um ein Script zu schreiben, welches uns beim Crossposten hilft.

Crossposting

Das Ziel: Einen Post bei Mastodon schreiben und diesen dann automatisiert, kurz darauf automatisch auch bei Bluesky zu posten. Realisieren werden wir das mit einem kleinen NodeJS Script, das dann dauerhaft im Hintergrund laufen kann und uns die Arbeit abnimmt.

Vorbereitungen

Damit wir bim Bluesky posten können, benötigen wir einen API-Zugang. Es empfiehlt sich, nicht die normalen Bluesky Login Daten zu verwenden, sondern ein AppPasswort anzulegen. Dazu gehen wir in die Settings und können dort ein neues AppPasswort anlegen. Zunächst müssen wir einen Namen festlegen, der sollte möglichst vielsagend sein, beispielsweise MastodonToBluesky.

Es wird ein neues Passwort generiert, welches wir sicher ablegen sollten, am besten im Passwortmanager unserer Wahl. Wir können das Passwort danach nämlich bei Bluesky nicht mehr einsehen.

Um Beiträge bei Bluesky zu veröffentlichen, werden wir das offizielle atproto Paket verwenden. Das macht uns das Leben recht einfach, kümmert sich um die Anmeldung mit unseren Login-Daten und unterstützt uns später beim Erstellen des Posts.

Auf der anderen Seite müssen wir Mastodon anzapfen, um an unsere neusten Posts zu kommen. Dazu könnten wir die Mastodon-API nutzen. Mastodon stellt allerdings auch bereitwillig eine Outbox für jeden Account bereit – ganz ohne API. Diese Outbox antwortet uns mit einem JSON-Response, das alle Daten enthält, die wir benötigen. Wir brauchen uns also nicht um einen API-Zugang bemühen, sondern können einfach die folgende URL aufrufen:

https://INSTANCE/users/USERNAME/outbox?page=true

Natürlich müssen INSTANCE und USERNAME gegen die korrekten Daten ausgetauscht werden, in meinem Fall wäre das mastodon.online und mauricerenck. Für einen schnellen Check rufen wir die URL einfach im Browser auf und sollten eine JSON-Text-Antwort sehen. Der page Parameter ermöglicht es uns in den Datensätzen vor und zurückspringen.

Der Ablauf

Jetzt, wo wir auf beiden Seiten Zugriff auf die Daten und Schnittstellen haben, können wir uns kurz anschauen, wie das fertige Script dann arbeiten wird:

  • Alle paar Minuten rufen wir die Mastodon-Outbox auf und fragen die letzten Posts ab
  • Wir gehen alle Posts durch und filtern Replies raus, die bringen uns bei Bluesky nichts
  • Jeden übrig gebliebenen Post schicken wir über die API als neuen Post an Bluesky.

Den Status speichern

Wir haben hier allerdings noch ein Problem: Wenn wir alle paar Minuten durch die Liste der Posts bei Mastodon gehen, werden wir dort nicht immer ausschließlich neue Posts finden. Wenn wir jedes Mal die Posts durchgehen und 1:1 bei Bluesky posten, wird das zu massenhaften Duplikaten führen. Wir müssen uns also irgendwie merken, welche Posts wir bereits an Bluesky weitergereicht haben. Dazu speichern wir uns die entsprechende ID in eine Datei. Falls unser Script mal abstürzt oder anderweitig beendet oder neu gestartet wird, können wir beim erneuten Starten, einfach diese Datei wieder auslesen und wissen, an welcher Position wir beim letzten Durchlauf aufgehört haben.

Das Script

Wir beginnen damit, zwei Dateien anzulegen:

  1. main.js
  2. lastProcessedPostId.txt

Die Datei main.js enthält unseren Quellcode, die zweite Datei dient zum Speichern der letzten Post-ID, die verarbeitet wurde. Hier tragen wir initial eine 0 ein. Das ist wichtig!

Beim Starten des Scripts werden wir als Erstes diese letzte ID auslesen. Dazu legen wir zunächst den Pfad und Dateinamen für die Datei fest, aus der wir diese Information auslesen können. Dann lesen wir die Datei aus und merken uns die ID in einer Variable:

// File to store the last processed Mastodon post ID
const lastProcessedPostIdFile = path.join(__dirname, 'lastProcessedPostId.txt');

// Variable to store the last processed Mastodon post ID
let lastProcessedPostId = loadLastProcessedPostId();

// Function to load the last processed post ID from the file
function loadLastProcessedPostId() {
  try {
    return fs.readFileSync(lastProcessedPostIdFile, 'utf8').trim();
  } catch (error) {
    console.error('Error loading last processed post ID:', error);
    return null;
  }
}

Kann die Datei lastProcessedPostId.txt aus irgendeinem Grund nicht gelesen werden, loggen wir eine Fehlermeldung und das Script wird beendet. Geht alles gut, haben wir die letzte ID in der Variable lastProcessedPostIdstehen und können damit arbeiten.

Posts bei Mastodon abholen

Jetzt können wir neue Beiträge bei Mastodon abholen. Dazu benötigen wir zwei Informationen:

  • Die Mastodon Instanz
  • Den Mastodon Username

Da diese Informationen nicht sicherheitsrelevant sind, könnten wir sie direkt in den Code schreiben, aber davon halte ich nicht sonderlich viel und rate dazu, sie in einer Konfigurationsdatei abzulegen. Später werden wir dort auch noch unsere Bluesky-Zugangsdaten hinterlegen.

Wir nutzen dazu eine Environment-Datei .env. Das hat den Vorteil, dass wir dort sensible Informationen ablegen können, ohne dass diese beispielsweise aus Versehen im Git-Repository landen, dazu nutzen wir das dotenv Paket.

Wir legen nun also eine Datei namens .env an und speichern darin in meinem Fall:

MASTODON_INSTANCE="https://mastodon.online"
MASTODON_USER="mauricerenck"

In unserer JavaScript-Datei können wir nach dem import vom dotenv Paket nun darauf zugreifen:

require('dotenv').config()
const mastodonInstance = process.env.MASTODON_INSTANCE;
const mastodonUser = process.env.MASTODON_USER;

Jetzt sind wir bereit und können uns die Outbox mit einem einfachen GET-Call holen. Die Antwort sollte eine Liste der letzten Posts enthalten. Wie wir bereits wissen, müssen wir diese Liste aber noch etwas filtern. Wir wollen keine Replies posten, weil es die User bei Bluesky nicht geben wird und wir wollen keine Duplikate posten, müssen also auf unsere gespeicherte ID zurückgreifen:

async function fetchNewPosts() {
  const response = await axios.get(`https://${mastodonInstance}/users/${mastodonUser}/outbox?page=true`);

  const reversed = response.data.orderedItems.filter(item => item.object.type === 'Note')
    .filter(item => item.object.inReplyTo === null)
    .reverse();

  let newTimestampId = 0;

  reversed.forEach(item => {
    const currentTimestampId = Date.parse(item.published);

    if(currentTimestampId > newTimestampId) {
      newTimestampId = currentTimestampId;
    }

   if(currentTimestampId > lastProcessedPostId && lastProcessedPostId != 0) {
      const text = removeHtmlTags(item.object.content);
      postToBluesky(text);
    }
  })

  if(newTimestampId > 0) {
    lastProcessedPostId = newTimestampId;
    saveLastProcessedPostId();
  }
}

Wir starten mit unserem GET-Request, um das JSON von Mastodon zu holen. Dann filtern wir die Posts so, dass wir nur von uns erstellte Posts bekommen und keine Replies. Wir speichern das Ergebnis als Array ab, welches wir noch einmal umdrehen, damit der älteste Post ganz oben in der Liste steht, der neueste ganz unten. So können wir dann einfach die Liste von oben nach unten durchgehen und bleiben in der richtigen zeitlichen Abfolge.

Jeder Post hat den Zeitpunkt der Veröffentlichung als Datum hinterlegt. Diese Information nehmen wir uns und machen ein JavaScript-Datum daraus. Das benutzen wir als ID. Wir könnten auch die von Mastodon erzeugte ID verwenden, da wir aber chronologisch bleiben wollen, können wir auf diese Weise davon ausgehen, dass wir nicht weiterschauen müssen, sobald ein Datum älter oder gleich alt wie der zuletzt verarbeitete Post ist.

Ist der Zeitstempel neuer als das zuletzt gemerkte Datum, schicken wir den Post weiter an Bluesky. Vorher entfernen wir noch mögliche HTML-Tags, wir wollen reinen Text haben.

Ganz zum Schluss speichern wir dann noch die neuste ID ab, damit wir diese beim nächsten Start des Scripts wieder im Zugriff haben.

Posten bei Bluesky

Um bei Bluesky posten zu können, verwenden wir das offizielle @atproto/api Paket. Dazu benötigen wir ein paar Informationen, um uns bei der API anzumelden:

  • Die Instanz
  • Den Handle (Username)
  • Das Passwort

Diese Daten legen wir ebenfalls in unserer .env Datei ab:

BLUESKY_ENDPOINT="https://bsky.social"
BLUESKY_HANDLE="USERNAME.bsky.social"
BLUESKY_PASSWORD="PASSWORD"

Derzeit gibt es nur eine Bluesky-Instanz, der Endpunkt sollte also überall gleich aussehen. Beim Passwort fügen wir das vorhin erstellte AppPassword ein.

Nun ist es an der Zeit, die Funktion zum Posten zu schreiben. Dazu erstellen wir uns noch den Bluesky Agent:

const agent = new BskyAgent({ service: process.env.BLUESKY_ENDPOINT });

Jetzt können wir posten:

async function postToBluesky(text) {
  await agent.login({
    identifier: process.env.BLUESKY_HANDLE,
    password: process.env.BLUESKY_PASSWORD,
  });

  const richText = new RichText({ text });
  await richText.detectFacets(agent);
  await agent.post({
    text: richText.text,
    facets: richText.facets,
  });
}

Über den Agent melden wir uns zunächst an. Dann geben wir den Text unseres Mastodon-Posts an die RichText-Klasse weiter. Die benutzen wir, damit wir nicht nur reinen Text, sondern zum Beispiel auch Links posten können, die dann auch als solche erkannt werden.

Jetzt müssen wir den Ablauf eigentlich nur noch regelmäßig aufrufen:

setInterval(fetchNewPosts, 2 * 60 * 1000);

Damit rufen wir alle zwei Minuten neue Posts ab und leiten sie ggf. an Bluesky weiter.

Ich habe noch ein paar andere Funktionen in den obigen Beispielen verwendet, die ich an dieser Stelle nicht im Detail ausführen möchte, weil es sich nur um kleine Helfer handelt. Du kannst dir das gesamte Script, inklusive dieser Funktionen aber hier ansehen:

GitHub - mauricerenck/mastodon-to-bluesky: A Node.js script for crossposting from mastodon to bluesky

A Node.js script for crossposting from mastodon to bluesky - mauricerenck/mastodon-to-bluesky

Das Script können wir jetzt einfach dauerhaft laufen lassen. Dazu bietet sich etwa ein Raspberry Pi an. Alternativ setze ich für solche Dinge gerne auf Koyeb.

Das Script holt sich alle zwei Minuten die neuen Posts, verarbeitet sie und reicht sie ggf. an Bluesky weiter. Da wir beim ersten Aufruf noch keinen einzigen Post rübergeschickt haben und mit einem Schlag zahlreiche neue Posts erzeugen würden, enthält das Script einen Mechanismus, der nur die Posts weiterleitet, die nach dem ersten Aufruf des Scripts veröffentlicht wurden.

Hiermit haben wir eine gut funktionierende, aber doch recht rudimentäre Lösung für das Crossposten an der Hand. Wir können das Script einfach so laufen lassen, oder noch weiter ausbauen und verbessern. Lass mich gerne wissen, was du noch für Verbesserungen vorgenommen hast – gerne hier als Kommentar.

Viel Erfolg bei der Umsetzung.

]]>
Linkvorschau und Embeds https://maurice-renck.de/de/learn/built-with-kirby/previews-and-embeds https://maurice-renck.de/de/@/page/xtOaVQfFe7dEUuV9 Sat, 08 Jan 2022 00:00:00 +0100 Maurice Renck kirby-cms Um kleine Linkvorschauen und eingebettete Inhalte anzeigen zu können, habe ich ein kleines Kirby-Plugin geschrieben.

Wenn Du Dir Artikel von mir ansiehst, wirst du feststellen, dass beim Hovern über Links, eine kleine Vorschau angezeigt wird. Im besten Fall mit Bild und Text. Um das zu bewerkstelligen, benutze ich zwei Tools:

  1. Ein Kirby-Plug-in zum Holen der Daten
  2. Tippy.js für die Tooltips.

Kirby Linkpreview-Plugin

Auch wenn ich den Tooltip mit JavaScript anzeige, wollte ich unbedingt vermeiden, dafür einen Request zu senden und erst im Falle eines Hovers, die Daten zu holen. Stattdessen hole ich mir die Daten beim Speichern der Seite und habe sie so jederzeit zur Verfügung.

Dazu habe ich mir ein kleines Plug-in geschrieben. Es lauscht auf den Kirby-Hook page.update:after 1, beginnt die Arbeit also, nachdem die Seite gespeichert wurde.

Es sucht nun im Text nach URLs, in welchen Feldern es schauen soll, kann ich meiner config festlegen. Jede gefundene URL wird zunächst in einem Array abgelegt.

Im nächsten Schritt schnappt sich das Plug-in die Liste der URLs und geht sie einzel durch. Zunächst schaut es, ob es sich auch wirklich um eine valide URL handelt, dazu bietet Kirby einen Validator: V::url($url).

Handelt es sich um eine valide URL kommt mein MetaParser ins Spiel, ein kleines Helferlein im Plug-in. Es kümmert sich darum, die Daten von der jeweiligen Webseite zu holen.

Es öffnet die URL und holt sich den Quellcode der Seite. Es schaut nach einem Seitentitel im <title></title> der Seite, dann schaut es nach OpenGraph Tags. Dabei schaut es nach den gängigen Tags:

twitter:title
twitter:description
twitter:image
og:title
og:description
og:image

Zusätzlich merke ich mir noch die aufgerufene URL und erzeuge eine Id. Alles zusammen wird zurückgegeben und schließlich habe ich am Ende des Hooks zu jeder URL mehr oder weniger detaillierte Daten.

Diese Daten speichere ich parallel zur Markdowndatei in einer JSON-Datei ab.

Auf der Seite

Zunächst habe ich die JSON-Daten dann mittels JavaScript GET-Call geholt und sie dann passend angezeigt, diesen Aufruf spare ich mir inzwischen auch. Ich schreibe die JSON-Daten einfach direkt in die Seite.

Schaust Du Dir also hier den Quellcode an und scrollst ganz nach unten, wirst Du etwas sehen wie: previewJson = …

Für die Hovers ist dann nur noch ein bisschen JavaScript-Magie angesagt: JSON durchgehen und URLs vergleichen, entsprechende Informationen anzeigen. Habe ich keine oder nicht genügend Informationen, zeige ich als Fallback die vollständige URL an.

Ich denke noch darüber nach, ob ich die Daten nicht direkt an den Link schreibe, dazu könnte ich data-Attribute benutzen. Da ich aber vermutlich nur selten so viele Links in einem Dokument haben werde, dass das Durchwandern des JSONs zeitliche Probleme verursachen würde, hat das keine Priorität.

Embeds

Ich benutze diese Daten auch, um Embedded Content anzuzeigen. Das sieht man im Blog und in den Notizen bei den Bookmark-Posts. Dort wird oben immer eine kleine Preview-Box des Bookmarks angezeigt:

Die Daten in der Box kommen ebenfalls aus den JSON-Daten.

Auch Tweets kann ich neuerdings so einbetten. Dazu habe ich mir einen Kirbytag geschrieben:

( embed: https://twitter.com/USER/status/TWEET )

Hier wollte ich nicht mit JavaScript arbeiten. Beim Rendern der Seite wird dieser Tag gegen ein HTML-Snippet ausgetauscht und die Daten entsprechend eingefügt.

So kann ich Tweets einbinden, ohne dass Daten an Twitter geschickt werden. Mit einer Ausnahme: Den Avatar speichere ich nicht mit ab, der kommt also weiterhin von Twitters Servern.

Fazit

Im Großen und Ganzen bin ich zufrieden mit der Lösung. Hier und da hakt es manchmal noch, aber in 98 % der Fälle klappt alles und die meisten Webseiten liefern inzwischen ausreichend gute Daten.

Vermutlich werde ich das Plug-in nach und nach erweitern, um andere Inhalte einbetten zu können. Ich halte diese Lösung für charmant, weil ich auf diese Weise Inhalte anzeigen kann, ohne dass die Daten meiner Seitenbesucher weitergereicht werden.

]]>
Automatische Beitragsbilder https://maurice-renck.de/de/learn/built-with-kirby/automatische-beitragsbilder https://maurice-renck.de/de/@/page/BMUWMci2L197IVG4 Fri, 10 Dec 2021 15:50:00 +0100 Maurice Renck kirby-cms

So können wir automatisch Beitragsbilder erzeugen, um sie dann beim Teilen auf Twitter und anderen Plattformen anzuzeigen.

An english translation can be found here.

Update: Ich habe auf Anfrage meinen aktuellen Stand mal hier online gepackt: https://github.com/mauricerenck/og-image das kann so als Plugin benutzt werden allerdings müssen das Bild und die Fonts entsprechend geändert werden. Vielleicht wird das irgendwann mal ein offizielles Plugin, gerade fehlt mir dazu die Zeit.

Wenn wir Blogposts bei Twitter und auf anderen Plattformen teilen, dann fallen diese Links besser auf, wenn sie ein Bild enthalten. Das lässt sich über eine Twitter-Card oder Open-Graph-Tags lösen. Das sind HTML Meta-Tags, die wir mit Informationen anreichern können (Titel, Beschreibung, Bild) und die dann dafür sorgen, dass Links zu den Seiten auf den Plattformen entsprechend gut aussehen.

Ein Teil dieser Daten ist ein Bild. In meinem CMS habe ich dazu zwei Felder, mit denen ich das Bild hochladen und festlegen kann. Doch nicht immer habe ich Bilder für meine Beiträge und möchte auch nicht immer krampfhaft danach suchen. Daher habe ich mir eine Idee von GitHub (und anderen Seiten) geklaut: Ich erstelle mir einfach selbst Bilder!

GitHub macht das besonders gut, finde ich. So sieht das z. B. für eins meiner Repositories aus:

GitHubs og:image

Diese Bilder werden automatisch generiert und genau das möchte ich auch erreichen.

Dazu habe ich mir in Affinity Designer eine Vorlage erstellt. Das muss nicht sein, aber ich sah so ein wenig mehr Spielraum für mich. Welches Programm man dazu benutzt, ist eigentlich egal, Hauptsache am Ende kommt ein PNG bei raus. So sieht meines derzeit aus:

Meine Vorlage fürs og:image

Ähnlichkeiten zu dem von GitHub sind eventuell vorhanden.

Bilder mit PHP erzeugen

Nun geht es ans Eingemachte. Der Weißraum links im Bild will nun gefüllt werden. Dargestellt werden soll sowohl der Titel der jeweiligen Seite als auch eine kurze Zusammenfassung, falls es so eine gibt. Ich benutze Kirby als CMS und habe mir dafür ein Plug-in geschrieben. Das macht alles etwas übersichtlicher, weil ich alles auf einem Haufen habe und ich einfach Routen definieren und auf Felder zugreifen kann.

Im PHP Code gilt es zunächst, ein Bild anzulegen.

$canvas = imagecreatetruecolor(1200, 600);

Unser Vorlagenbild dient hier als Grundlage, also laden wir es und kopieren es in unser neu erzeugtes Bild:

$background = imagecreatefrompng(__DIR__ . '/assets/background.png');

imagecopyresampled(
    $canvas,
    $background,
    0,
    0,
    0,
    0,
    imagesx($background),
    imagesy($background),
    imagesx($background),
    imagesy($background)
);

Wir könnten uns den Schritt mit dem Canvas auch sparen und direkt das vom PNG erzeugte Bild weiterverwenden. Aber ich habe diesen Schritt belassen, falls jemand einfach nur ein leeres Bild erzeugen und füllen möchte, ohne auf ein anderes Bild zuzugreifen. In dem Fall kann das Laden des PNGs einfach weggelassen werden.

Jetzt wollen wir unseren Seitentitel aufs Bild bringen, dazu nutzen wir die imagettftext() Funktion. Diese benötigt eine Schriftart, die wir zunächst laden müssen:

$fontRegular = __DIR__ . '/assets/GangsterGrotesk-Regular.ttf';
$fontBold = __DIR__ . '/assets/GangsterGrotesk-Bold.ttf';

Je nachdem, wo ihr den Font bei euch ablegt, wird dieser Pfad natürlich unterschiedlich sein. Ich lade direkt zwei Fonts, einen normalen für die Zusammenfassung und einen fetten Font für die Überschrift. Letztere können wir dann jetzt auch aufs Bild bannen:

[$titleX, $titleY] = imagettftext(
  $canvas,
  58,
  0,
  $margin,
  120,
  $black,
  $fontBold,
  $text
);

Wir erzeugen hier einen Text, und packen diesen auf unser Bild ($canvas). Die Schriftgröße ist 58, wir haben einen Abstand zum Rand, dazu benutze ich die Variable $margin, die steht bei mir auf 60. Der Abstand vom oberen Rand beträgt 120, den setze ich nur einmal und benötige deshalb keine Variable. Die Überschrift soll schwarz sein, dazu habe ich eine Farbe definiert:

$black = imagecolorallocate($canvas, 0, 0, 0);

Schließlich benutzen wir den fetten Font und übergeben unseren eigentlichen Text, der bei mir in der Variable $text steht. So sieht das dann aus:

Sieht schon mal relativ gut aus, allerdings haben wir ein noch nicht sichtbares Problem, und zwar folgendes:

Wenn der Titel zu lang ist, dann fließt er einfach aus dem Bild raus, wir müssen ihm also Grenzen setzen. Das geht leider nicht ganz so einfach wie in einer Bildbearbeitung. Was wir tun können, ist alle paar Zeichen einen Zeilenumbruch zu setzen. Nach wie vielen Zeilen dies der Fall sein muss, hängt von der Schriftart und der Schriftgröße ab, hier gibt es also keine feste Formel, da müssen wir probieren. Folgende Werte stimmen für mich:

$text = wordwrap($text, 30, "\n");

Alle 30 Zeichen füge ich also einen Zeilenumbruch ein. Das sieht dann so aus:

Schon besser, damit sind wir also in der Breite in Sicherheit. Natürlich könnte der Titel jetzt noch unten aus dem Bild herauslaufen, aber das ignorieren wir mal, denn so lang sollten Titel in der Regel auch nicht sein.

Weiter im Text (haha). Den wollen wir als Nächstes anzeigen. Der Beschreibungstext unter dem Titel soll etwas kleiner sein und ich hätte ihn gerne in Lila, dazu definiere ich wieder eine Farbe und gebe dann den Text aus:

$purple = imagecolorallocate($canvas, 139, 126, 164);
$text = wordwrap($text, 48, "\n");

imagettftext(
    $canvas,
    24,
    0,
    $margin,
    $titleY + 70,
    $purple,
    $fontRegular,
    $text
);

Weil der Text kleiner ist, können wir den Zeilenumbruch etwas später machen, bei 48 Zeichen. Wir benutzen den normalen Font und geben ihm die Größe 24. Zu beachten ist noch der Wert $titleY + 70. Wir können Texte immer nur absolut positionieren, würden wir ihm eine feste Position, vom oberen Rand aus gerechnet geben, könnte er sich mit dem Titel überschneiden, wenn dieser besonders lang ist. Also haben wir uns beim Erstellen des Titels u. a. den Wert $titleY zurückgeben lassen. Zu dieser Position auf der y-Achse rechnen wir noch einen Abstand von 70 Pixeln und setzen dieser Wert als Startposition für den neuen Text. So überschneiden sie sich nie.



Auch hier können wir wieder auf Probleme mit langen Titeln treffen. Diese können dafür sorgen, dass unser Beschreibungstext unten aus dem Bild rausläuft (weil wir ihn ja immer entsprechend weit unter den Titel schieben). Hier zum Beispiel:



In diesem Fall habe ich mich dazu entschieden, den Beschreibungstext einfach wegzulassen. Ein so langer Titel sollte dann ausreichend sein. Wir geben den Titel also nur dann aus, wenn die zurückgegebene Y-Position geringer als ein bestimmter Wert ist. Auch hier gibt es keine Formel und das hängt sehr von der Schrift und ihrer Größe ab:

if ($titleY <= 315) {
    $text = wordwrap($text, 48, "\n");

    imagettftext(
        $canvas,
        24,
        0,
        $margin,
        $titleY + 70,
        $purple,
        $fontRegular,
        $text
    );
}


Okay, das klappt also …

Als letztes Kleinigkeit hätte ich noch gerne die URL im lila Streifen unten stehen. Die Beitrags-URLs sind in der Regel zu lang, also muss die URL der Webseite langen. Wieder setzen wir einen Text, diesmal in Weiß:

imagettftext(
    $canvas,
    24,
    0,
    120,
    570,
    $white,
    $fontRegular,
    $url
);

Ich bin sehr zufrieden! Ein paar Anmerkungen habe ich aber noch!

Da mein CMS mir einige Arbeit abnimmt, sind in diesem Beitrag ein paar Punkte unter den Tisch gefallen. Da hätten wir etwa den Beschreibungstext. Der könnte natürlich auch beliebig lang sein und unten aus dem Bild herauslaufen. Deshalb lasse ich mir von Kirby einen gekürzten Text zurückgeben. Kirby macht hier schon die ganze Arbeit für mich:

$description = $page->intro()->excerpt(200);

Kirby kürzt den Text nach 200 Zeichen und fügt dann noch ein an. Das können wir natürlich auch direkt mit PHP tun, z. B. mit substr:

$description = substr($description, 0, 200) . '…';

Hier hängen die Nummern wieder vom Font ab.

Außerdem müssen wir das Bild natürlich noch irgendwie einbinden, dazu dienen entsprechende Meta-Tags:

<meta property="og:image" content="https://maurice-renck.de/de/your-website/automatische-beitragsbilder-im-blog/og-image">

<meta name="twitter:image" content="https://maurice-renck.de/de/your-website/automatische-beitragsbilder-im-blog/og-image">

Ich habe zwei Varianten in Verwendung, einmal die OpenGraph-Variante und die Twitter-Variante. Die müssen zwischen <head></head>.

Kirby ermöglicht mir auch das Routing. Das bedeutet, dass mein Plug-in immer dann automatisch das Bild für eine Seite erzeugt, wenn man an die Seiten-URL /og-image dran hängt. So muss ich mich wirklich um nichts kümmern. Ich habe bei mir noch eine kleine Weiche eingebaut. Im CMS habe ich einen Tab für die Sharing-Optionen:

Hier kann ich einzelne Werte überschreiben, ich kann individuelle Titel und Beschreibungen setzen und auch gezielt ein Bild hochladen. Sollte ich also bei einer Seite mal nicht das automatisch generierte Bild verwenden wollen, kann ich hier ein eigenes hochladen und das wird dann verwendet. Vielleicht ja eine nette Idee für die ein oder andere Leserin hier.

Mit dieser Lösung bin ich sehr zufrieden, sie erledigt die Bildgenerierung für mich, und wenn ich will, kann ich gezielt eingreifen. Vielleicht hilft das euch ja auch weiter.

Habt ihr noch Idee oder Fragen? Immer her damit! Die Kommentare sind offen!

]]>