Manchmal ist eine Gemini-Kapsel doch sehr unsichtbar und man wünscht sich den Text auch auf dem Pelican-Blog, ohne dass man viel ändern muss. Mit Pelican ist man es ja schon gewöhnt eine einfache Auszeichnungssprache zu benutzen (markdown oder rst, wobei ich wegen Sphinx lieber rst benutze), so dass der Gedanke gemini als Input zu benutzen nicht sehr fern liegt. Natürlich muss man wegen der Struktur von Gemini-Text mit Einschränkungen leben – Links können nicht inline sein. Die Link-Section unten sieht in HTML ungewohnt aus.

Wie in der Dokumentation beschrieben wird ist das für das Einlesen von Text ein Reader zuständig. Auch ein Beispiel findet dort. Ein Reader wird einem Dateityp zugeordnet (also einem oder mehreren Suffixen). Die Rückgabe eines Readers ist ein Tupel aus einem HTML-String und Metadaten. Da gemini-Text zeilenorientiert ist lässt sich die Umwandlung mit einem regulären Ausdruck pro Zeile und einem Mrker für vorformatierte Blöcke erledigen.

Bei den Metadaten war eine Entscheidung nötig: Sollen sie am Anfang der gmi-Datei stehen oder kommen sie in eine eigene Datei? Ich habe mich für letzteres entschieden, denn damit kann ich einen Beitrag nur für die Gemini-Kapsel schreiben ohne die Gemini-Datei mit überflüssigen Daten zu belasten. Im Verzeichnis des Pelican-Blogs kann ich einfach eine Datei mit den Metadaten und einen Link zu Gemini-Datei anlegen.

Etwas fehleranfällig war beim Erzeugen des HTML waren die reservierten Zeichen < und >, die ersetzt werden mussten, wenn sie nicht gerade Bedeutung im Gemini-Text haben (bei Link- und Blockquote-Zeilen).

Alles in allem hat der Reader Stand heute 98 Zeilen, ist also nicht wirklich komplex. Das sehe ich als Zeichen dass das Versprechen „Gemtext is carefully designed to be very, very easy to parse and render.“ eingehalten wird; und auch Pelican macht da einen guten Job.

Links

Pelican internals - Reader, Writer & Co. Mit Beispiel.

https://docs.getpelican.com/en/latest/internals.html

Als Inspiration genutzt, aber letztlich komplett anders gemacht.

https://github.com/khoulihan/pelican-gemini

Inspiration für das Konvertieren. Viel Nacharbeit nötig.

https://github.com/huntingb/gemtext-html-converter

Code

from pelican import signals
from pelican.readers import BaseReader
import logging
import re


class GeminiReader(BaseReader):
    enabled = True
    logger = logging.getLogger(__name__)
    file_extensions = ["gmi", "gemini"]

    def read(self, filename):
        metadata = {}
        content = ""
        # Metadaten sind in Dateiname.pelican_meta
        with open(filename + ".pelican_meta", mode="r") as f:
            while current := f.readline():
                current = current.strip()
                split = current.split(": ", 1)
                metadata[split[0].lower()] = split[1]

        with open(filename, mode="r") as f:
            # After the first blank line, there is the title
            current = f.readline()
            if match := re.match(r"^#\s*(.*)$", current):
                # use if no title, otherwise ignore
                if not metadata.get("title"):
                    self.logger.info(f"No title, using {current}")
                    metadata["title"] = match.groups()[0]
            else:
                content = current
            # The rest is content.
            content = content + f.read()
        parsed = {}
        for key, value in metadata.items():
            parsed[key] = self.process_metadata(key, value)
        return self._convert2html(content), parsed

    def _convert2html(self, content):
        result = ""
        preformat = False
        gemini_footer_delim = self.settings.get("GEMINI_FOOTER_DELIM")

        for line in content.splitlines():
            if line.startswith("```"):
                preformat = not preformat
                result += "<pre>\n" if preformat else "</pre>\n"
                continue
            # Meine Art von gemini-Footer – Alles danach ignorieren
            if (
                gemini_footer_delim
                and line.startswith(gemini_footer_delim)
                and not preformat
            ):
                return result
            result += self._parse_gmi_line(line, preformat)
        return result

    def _parse_gmi_line(self, gmi_line, preformat):
        tags_dict = {
            r"^#\s*([^#].*)": "h1",
            r"^##\s*([^#].*)": "h2",
            r"^###\s*([^#].*)": "h3",
            r"^\* (.*)": "li",
            r"^> (.*)": "blockquote",
            r"^=>\s+(\S+)(\s+.*)?": "a",
        }
        # HTML special chars
        gmi_line = gmi_line.replace("&", "&amp;")
        gmi_line = gmi_line.replace("<", "&lt;")
        # keep > for blockqoute or link
        if not (gmi_line.startswith(">") or gmi_line.startswith("=>")):
            gmi_line = gmi_line.replace(">", "&gt;")
        if preformat:
            return gmi_line + "\n"
        for pattern in tags_dict.keys():
            if match := re.match(pattern, gmi_line):
                tag = tags_dict[pattern]
                groups = match.groups()
                if tag == "a":
                    href = groups[0]
                    text = groups[1].strip() if len(groups) > 1 and groups[1] else href
                    # text might be empty after strip - not by spec, but in practice
                    text = href if not text else text
                    return f"<p><a href='{href}'>{text}</a></p>\n"
                else:
                    text = groups[0].strip()
                    return f"<{tag}>{text}</{tag}>\n"
        return f"<p>{gmi_line}</p>\n"


def add_reader(readers):
    for ext in GeminiReader.file_extensions:
        readers.reader_classes[ext] = GeminiReader


def register():
    signals.readers_init.connect(add_reader)